@5even7/dlc-ui 0.2.18 → 0.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/progress.mjs CHANGED
@@ -1,157 +1,157 @@
1
- /**
2
- * Tiny event emitter. Accepts any event name so the API stays open for
3
- * future events (hover, click, custom) without breaking changes.
4
- */
5
- function createEmitter() {
6
- // Plain object storage (no Map/Set) so the legacy build has no API
7
- // dependencies beyond what IE11 provides.
8
- const listeners = Object.create(null);
9
-
10
- return {
11
- on(event, fn) {
12
- if (!listeners[event]) listeners[event] = [];
13
- listeners[event].push(fn);
14
- return () => {
15
- const current = listeners[event];
16
- if (current) {
17
- const index = current.indexOf(fn);
18
- if (index !== -1) current.splice(index, 1);
19
- }
20
- };
21
- },
22
- off(event, fn) {
23
- const current = listeners[event];
24
- if (!current) return false;
25
- const index = current.indexOf(fn);
26
- if (index === -1) return false;
27
- current.splice(index, 1);
28
- return true;
29
- },
30
- emit(event, ...args) {
31
- const current = listeners[event];
32
- if (!current) return;
33
- for (const fn of current.slice()) fn(...args);
34
- }
35
- };
1
+ /**
2
+ * Tiny event emitter. Accepts any event name so the API stays open for
3
+ * future events (hover, click, custom) without breaking changes.
4
+ */
5
+ function createEmitter() {
6
+ // Plain object storage (no Map/Set) so the legacy build has no API
7
+ // dependencies beyond what IE11 provides.
8
+ const listeners = Object.create(null);
9
+
10
+ return {
11
+ on(event, fn) {
12
+ if (!listeners[event]) listeners[event] = [];
13
+ listeners[event].push(fn);
14
+ return () => {
15
+ const current = listeners[event];
16
+ if (current) {
17
+ const index = current.indexOf(fn);
18
+ if (index !== -1) current.splice(index, 1);
19
+ }
20
+ };
21
+ },
22
+ off(event, fn) {
23
+ const current = listeners[event];
24
+ if (!current) return false;
25
+ const index = current.indexOf(fn);
26
+ if (index === -1) return false;
27
+ current.splice(index, 1);
28
+ return true;
29
+ },
30
+ emit(event, ...args) {
31
+ const current = listeners[event];
32
+ if (!current) return;
33
+ for (const fn of current.slice()) fn(...args);
34
+ }
35
+ };
36
36
  }
37
37
 
38
- const 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;
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
57
  }
58
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);
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;
82
70
  }
83
71
 
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 };
72
+ function dprCapFor(quality) {
73
+ if (quality === 'auto') return autoDprCap();
74
+ const tier = QUALITY_TIERS[quality] || QUALITY_TIERS.medium;
75
+ return tier.dpr;
102
76
  }
103
77
 
104
- const PROGRESS_PRESETS = [
105
- {
106
- id: 'model-training',
107
- code: 'NC-10',
108
- name: '星火',
109
- subtitle: 'SHA 4.5 + 100 2026 TKN',
110
- group: 'progress',
111
- initialProgress: 43,
112
- edgeStyle: 'flow',
113
- colors: ['#2B1025', '#FF3F94', '#FF8A3D', '#FFF06A']
114
- },
115
- {
116
- id: 'agent-migration',
117
- code: 'NC-11',
118
- name: '迁流',
119
- subtitle: 'TRANSFERRING PROTOCOL',
120
- group: 'progress',
121
- initialProgress: 33,
122
- edgeStyle: 'flow',
123
- colors: ['#101C37', '#245BFF', '#00CFFF', '#5DFFE6']
124
- },
125
- {
126
- id: 'visual-training',
127
- code: 'NC-12',
128
- name: '幻境',
129
- subtitle: 'GENERATING POWER ++',
130
- group: 'progress',
131
- initialProgress: 58,
132
- edgeStyle: 'flow',
133
- colors: ['#21142D', '#7042FF', '#42F58D', '#C4FF8A']
134
- },
135
- {
136
- id: 'tide',
137
- code: 'NC-13',
138
- name: '汐潮',
139
- subtitle: 'MOON PULL / COAST',
140
- group: 'progress',
141
- initialProgress: 30,
142
- edgeStyle: 'tide',
143
- colors: ['#0A2239', '#2E9BFF', '#7FE3FF', '#EAF9FF']
144
- },
145
- {
146
- id: 'botanic-lab',
147
- code: 'NC-14',
148
- name: '森息',
149
- subtitle: 'MOSS CULTURE / ROOTING',
150
- group: 'progress',
151
- initialProgress: 46,
152
- edgeStyle: 'flow',
153
- colors: ['#102A23', '#287A58', '#9BD65B', '#F2FFC4']
154
- }
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
+ const PROGRESS_PRESETS = [
105
+ {
106
+ id: 'model-training',
107
+ code: 'NC-10',
108
+ name: '星火',
109
+ subtitle: 'SHA 4.5 + 100 2026 TKN',
110
+ group: 'progress',
111
+ initialProgress: 43,
112
+ edgeStyle: 'flow',
113
+ colors: ['#2B1025', '#FF3F94', '#FF8A3D', '#FFF06A']
114
+ },
115
+ {
116
+ id: 'agent-migration',
117
+ code: 'NC-11',
118
+ name: '迁流',
119
+ subtitle: 'TRANSFERRING PROTOCOL',
120
+ group: 'progress',
121
+ initialProgress: 33,
122
+ edgeStyle: 'flow',
123
+ colors: ['#101C37', '#245BFF', '#00CFFF', '#5DFFE6']
124
+ },
125
+ {
126
+ id: 'visual-training',
127
+ code: 'NC-12',
128
+ name: '幻境',
129
+ subtitle: 'GENERATING POWER ++',
130
+ group: 'progress',
131
+ initialProgress: 58,
132
+ edgeStyle: 'flow',
133
+ colors: ['#21142D', '#7042FF', '#42F58D', '#C4FF8A']
134
+ },
135
+ {
136
+ id: 'tide',
137
+ code: 'NC-13',
138
+ name: '汐潮',
139
+ subtitle: 'MOON PULL / COAST',
140
+ group: 'progress',
141
+ initialProgress: 30,
142
+ edgeStyle: 'tide',
143
+ colors: ['#0A2239', '#2E9BFF', '#7FE3FF', '#EAF9FF']
144
+ },
145
+ {
146
+ id: 'botanic-lab',
147
+ code: 'NC-14',
148
+ name: '森息',
149
+ subtitle: 'MOSS CULTURE / ROOTING',
150
+ group: 'progress',
151
+ initialProgress: 46,
152
+ edgeStyle: 'flow',
153
+ colors: ['#102A23', '#287A58', '#9BD65B', '#F2FFC4']
154
+ }
155
155
  ];
156
156
 
157
157
  /**
@@ -174,31 +174,31 @@ function getPreset(kind, ref) {
174
174
  return found;
175
175
  }
176
176
 
177
- const DEFAULTS = {
178
- progress: {
179
- width: 454,
180
- height: 104,
181
- min: 0,
182
- max: 100,
183
- step: 'any',
184
- draggable: true,
185
- keyboard: true,
186
- disabled: false,
187
- readonly: false,
188
- direction: 'ltr',
189
- precision: 0,
190
- valueSuffix: '%',
191
- edgeStyle: 'flow',
192
- quality: 'auto',
193
- renderer: 'auto',
194
- renderScale: 1,
195
- powerPreference: 'high-performance',
196
- fps: 60,
197
- paused: false,
198
- static: false,
199
- textRatio: 54,
200
- showValue: true,
201
- respectReducedMotion: true
177
+ const DEFAULTS = {
178
+ progress: {
179
+ width: 454,
180
+ height: 104,
181
+ min: 0,
182
+ max: 100,
183
+ step: 'any',
184
+ draggable: true,
185
+ keyboard: true,
186
+ disabled: false,
187
+ readonly: false,
188
+ direction: 'ltr',
189
+ precision: 0,
190
+ valueSuffix: '%',
191
+ edgeStyle: 'flow',
192
+ quality: 'auto',
193
+ renderer: 'auto',
194
+ renderScale: 1,
195
+ powerPreference: 'high-performance',
196
+ fps: 60,
197
+ paused: false,
198
+ static: false,
199
+ textRatio: 54,
200
+ showValue: true,
201
+ respectReducedMotion: true
202
202
  }};
203
203
 
204
204
  /**
@@ -209,16 +209,22 @@ const DEFAULTS = {
209
209
  const COPY = {
210
210
  brandName: '画境观屿',
211
211
  valueSuffix: '%',
212
- progressAria: '{brand} {code} 加载进度',
213
- capsuleAria: '打开 {name} 沉浸预览'
212
+ progressAria: '{brand} {code} 加载进度',
213
+ capsuleAria: '打开 {name} 沉浸预览'
214
214
  };
215
215
 
216
216
  /**
217
217
  * Color helpers. `normalizeColor` converts any supported CSS color
218
- * (hex / named / rgb() / rgba()) into a 6-digit hex string, so renderers can
219
- * keep working with #rrggbb only. rgba() alpha is intentionally dropped:
220
- * the WebGL shader has no per-color alpha channel, and the component is
221
- * opaque by design — use component-level `opacity` for transparency.
218
+ * (hex / named / rgb() / rgba() / hsl() / hsla()) into a 6-digit hex string,
219
+ * so renderers can keep working with #rrggbb only. rgba()/hsla() alpha is
220
+ * intentionally dropped: the WebGL shader has no per-color alpha channel, and
221
+ * the component is opaque by design — use component-level `opacity` for
222
+ * transparency.
223
+ *
224
+ * `normalizeColorWithAlpha` keeps the alpha channel: it returns a 6-digit hex
225
+ * when alpha is 1, an 8-digit #rrggbbaa hex when alpha < 1, and the literal
226
+ * string 'transparent' for `transparent`. Use it for renderers (e.g. SVG)
227
+ * that can represent per-color transparency.
222
228
  */
223
229
 
224
230
  const NAMED_COLORS = {
@@ -376,46 +382,145 @@ function clampChannel(value) {
376
382
  return Math.min(255, Math.max(0, Math.round(value)));
377
383
  }
378
384
 
379
- function normalizeRgbArgs(args) {
380
- const parts = args.split(/[,\s/]+/).filter(Boolean);
385
+ function clampUnit(value) {
386
+ return Math.min(1, Math.max(0, value));
387
+ }
388
+
389
+ function hexPair(value) {
390
+ const text = clampChannel(value).toString(16);
391
+ return text.length === 1 ? `0${text}` : text;
392
+ }
393
+
394
+ /**
395
+ * Parse the argument list of rgb()/rgba()/hsl()/hsla() (comma syntax or
396
+ * modern space + slash syntax). Returns { parts, alpha } where alpha is 1 when
397
+ * omitted. Returns null when the argument list cannot be parsed.
398
+ */
399
+ function parseFunctionArgs(args) {
400
+ const parts = args
401
+ .split(/[,\s]+/)
402
+ .map((part) => part.trim())
403
+ .filter((part) => part !== '' && part !== '/');
381
404
  if (parts.length < 3) return null;
382
- const to255 = (value) => {
383
- if (typeof value !== 'string' || !value) return null;
384
- if (value.charAt(value.length - 1) === '%') return clampChannel((parseFloat(value) / 100) * 255);
385
- const number = Number(value);
386
- return Number.isFinite(number) ? clampChannel(number) : null;
387
- };
388
- const red = to255(parts[0]);
389
- const green = to255(parts[1]);
390
- const blue = to255(parts[2]);
391
- if (red === null || green === null || blue === null) return null;
392
- const hex = (n) => {
393
- const text = n.toString(16);
394
- return text.length === 1 ? `0${text}` : text;
395
- };
396
- return `#${hex(red)}${hex(green)}${hex(blue)}`;
405
+ let alpha = 1;
406
+ if (parts.length >= 4) {
407
+ const alphaText = parts.pop().trim();
408
+ if (alphaText.endsWith('%')) {
409
+ const percent = parseFloat(alphaText);
410
+ if (!Number.isFinite(percent)) return null;
411
+ alpha = clampUnit(percent / 100);
412
+ } else {
413
+ const number = Number(alphaText);
414
+ if (!Number.isFinite(number)) return null;
415
+ alpha = clampUnit(number);
416
+ }
417
+ }
418
+ return { parts, alpha };
419
+ }
420
+
421
+ function parseChannel(value) {
422
+ if (typeof value !== 'string' || value === '') return null;
423
+ const text = value.trim();
424
+ if (text.endsWith('%')) return clampChannel((parseFloat(text) / 100) * 255);
425
+ const number = Number(text);
426
+ return Number.isFinite(number) ? clampChannel(number) : null;
427
+ }
428
+
429
+ function parsePercent(value) {
430
+ if (typeof value !== 'string' || value === '') return null;
431
+ const text = value.trim();
432
+ if (!text.endsWith('%')) return null;
433
+ const number = parseFloat(text);
434
+ return Number.isFinite(number) ? Math.min(100, Math.max(0, number)) : null;
435
+ }
436
+
437
+ function parseHue(value) {
438
+ if (typeof value !== 'string' || value === '') return null;
439
+ const number = parseFloat(value);
440
+ return Number.isFinite(number) ? number : null;
441
+ }
442
+
443
+ /** Standard HSL -> RGB. h in degrees, s/l in 0..100, returns r/g/b in 0..255. */
444
+ function hslToRgb(h, s, l) {
445
+ const hue = ((h % 360) + 360) % 360 / 360;
446
+ const saturation = s / 100;
447
+ const lightness = l / 100;
448
+ const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
449
+ const section = hue * 6;
450
+ const x = chroma * (1 - Math.abs((section % 2) - 1));
451
+ let red = 0;
452
+ let green = 0;
453
+ let blue = 0;
454
+ if (section < 1) { red = chroma; green = x; }
455
+ else if (section < 2) { red = x; green = chroma; }
456
+ else if (section < 3) { green = chroma; blue = x; }
457
+ else if (section < 4) { green = x; blue = chroma; }
458
+ else if (section < 5) { red = x; blue = chroma; }
459
+ else { red = chroma; blue = x; }
460
+ const match = lightness - chroma / 2;
461
+ return [
462
+ Math.round((red + match) * 255),
463
+ Math.round((green + match) * 255),
464
+ Math.round((blue + match) * 255)
465
+ ];
397
466
  }
398
467
 
399
468
  /**
400
- * Convert any supported CSS color into `#rrggbb`. Returns null when the
401
- * value cannot be parsed. Supports: #rgb / #rrggbb (case-insensitive),
402
- * CSS named colors, rgb() / rgba() with comma or modern space syntax,
403
- * including percentages. Alpha in rgba() is ignored.
469
+ * Parse any supported CSS color. When keepAlpha is true the alpha channel is
470
+ * preserved (8-digit hex for alpha < 1); when false it is dropped. 'transparent'
471
+ * is only preserved when keepAlpha is true.
404
472
  */
405
- function normalizeColor(value) {
473
+ function parseColor(value, keepAlpha) {
406
474
  if (typeof value !== 'string') return null;
407
475
  const input = value.trim();
408
476
  if (!input) return null;
409
477
  const lower = input.toLowerCase();
410
478
  if (NAMED_COLORS[lower]) return NAMED_COLORS[lower];
411
- if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(input)) {
412
- const hex = input.slice(1).toLowerCase();
413
- if (hex.length === 3) return `#${hex.split('').map((c) => c + c).join('')}`;
414
- return `#${hex}`;
415
- }
416
- const match = input.match(/^rgba?\((.*)\)$/i);
417
- if (match) return normalizeRgbArgs(match[1]);
418
- return null;
479
+ // #rgb / #rgba / #rrggbb / #rrggbbaa,以及无 # 前缀的 6/8 位 hex(dicebear 习惯)
480
+ const hexMatch = input.match(/^#?([0-9a-f]{3,8})$/i);
481
+ if (hexMatch) {
482
+ let hex = hexMatch[1].toLowerCase();
483
+ if (hex.length === 3 || hex.length === 4) {
484
+ hex = hex.split('').map((c) => c + c).join('');
485
+ }
486
+ return `#${hex.slice(0, 6)}`;
487
+ }
488
+ const funcMatch = input.match(/^(rgba?|hsla?)\(([^)]*)\)$/i);
489
+ if (!funcMatch) return null;
490
+ const kind = funcMatch[1].toLowerCase();
491
+ const parsed = parseFunctionArgs(funcMatch[2]);
492
+ if (!parsed) return null;
493
+ const { parts, alpha } = parsed;
494
+ let red;
495
+ let green;
496
+ let blue;
497
+ if (kind === 'rgb' || kind === 'rgba') {
498
+ if (parts.length !== 3) return null;
499
+ red = parseChannel(parts[0]);
500
+ green = parseChannel(parts[1]);
501
+ blue = parseChannel(parts[2]);
502
+ if (red === null || green === null || blue === null) return null;
503
+ } else {
504
+ if (parts.length !== 3) return null;
505
+ const hue = parseHue(parts[0]);
506
+ const saturation = parsePercent(parts[1]);
507
+ const lightness = parsePercent(parts[2]);
508
+ if (hue === null || saturation === null || lightness === null) return null;
509
+ [red, green, blue] = hslToRgb(hue, saturation, lightness);
510
+ }
511
+ const hex = `#${hexPair(red)}${hexPair(green)}${hexPair(blue)}`;
512
+ return hex;
513
+ }
514
+
515
+ /**
516
+ * Convert any supported CSS color into `#rrggbb`. Returns null when the
517
+ * value cannot be parsed. Supports: #rgb / #rgba / #rrggbb / #rrggbbaa
518
+ * (case-insensitive), bare 6/8-digit hex, CSS named colors, rgb() / rgba() /
519
+ * hsl() / hsla() with comma or modern space syntax, including percentages.
520
+ * Alpha is ignored.
521
+ */
522
+ function normalizeColor(value) {
523
+ return parseColor(value);
419
524
  }
420
525
 
421
526
  function hexToRgba(color, alpha = 1) {
@@ -428,781 +533,781 @@ function hexToRgba(color, alpha = 1) {
428
533
  return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
429
534
  }
430
535
 
431
- /**
432
- * Document-level shared rAF scheduler. Every component instance subscribes
433
- * its own frame callback; the whole page runs ONE animation loop (like the
434
- * original demo), which avoids jank from many competing rAF loops.
435
- */
436
- const subscribers = [];
437
- let running = false;
438
- let rafId = 0;
439
- let last = 0;
440
-
441
- function tick(now) {
442
- if (!running) return;
443
- const activeItems = [];
444
- {
445
- for (const item of subscribers.slice()) {
446
- try {
447
- if (!item.isPaused()) activeItems.push(item);
448
- } catch {}
449
- }
450
- }
451
- if (activeItems.length === 0) {
452
- running = false;
453
- rafId = 0;
454
- last = 0;
455
- return;
456
- }
457
- const delta = last ? Math.min((now - last) / 1000, 0.05) : 0;
458
- last = now;
459
- // Schedule the next frame BEFORE running callbacks so one throwing
460
- // subscriber can never kill the whole animation loop.
461
- rafId = requestAnimationFrame(tick);
462
- for (const item of activeItems) {
463
- try {
464
- item.onFrame(delta, now);
465
- } catch (error) {
466
- console.warn('[dlc-ui] frame error:', error);
467
- }
468
- }
469
- if (subscribers.length === 0) {
470
- cancelAnimationFrame(rafId);
471
- running = false;
472
- rafId = 0;
473
- }
474
- }
475
-
476
- function start() {
477
- if (running) return;
478
- running = true;
479
- last = 0;
480
- rafId = requestAnimationFrame(tick);
481
- }
482
-
483
- function wakeScheduler() {
484
- start();
485
- }
486
-
487
- function subscribeScheduler(onFrame, isPaused) {
488
- const item = { onFrame, isPaused };
489
- subscribers.push(item);
490
- start();
491
- return () => {
492
- const index = subscribers.indexOf(item);
493
- if (index !== -1) subscribers.splice(index, 1);
494
- if (subscribers.length === 0 && rafId) {
495
- cancelAnimationFrame(rafId);
496
- running = false;
497
- rafId = 0;
498
- }
499
- };
536
+ /**
537
+ * Document-level shared rAF scheduler. Every component instance subscribes
538
+ * its own frame callback; the whole page runs ONE animation loop (like the
539
+ * original demo), which avoids jank from many competing rAF loops.
540
+ */
541
+ const subscribers = [];
542
+ let running = false;
543
+ let rafId = 0;
544
+ let last = 0;
545
+
546
+ function tick(now) {
547
+ if (!running) return;
548
+ const activeItems = [];
549
+ {
550
+ for (const item of subscribers.slice()) {
551
+ try {
552
+ if (!item.isPaused()) activeItems.push(item);
553
+ } catch {}
554
+ }
555
+ }
556
+ if (activeItems.length === 0) {
557
+ running = false;
558
+ rafId = 0;
559
+ last = 0;
560
+ return;
561
+ }
562
+ const delta = last ? Math.min((now - last) / 1000, 0.05) : 0;
563
+ last = now;
564
+ // Schedule the next frame BEFORE running callbacks so one throwing
565
+ // subscriber can never kill the whole animation loop.
566
+ rafId = requestAnimationFrame(tick);
567
+ for (const item of activeItems) {
568
+ try {
569
+ item.onFrame(delta, now);
570
+ } catch (error) {
571
+ console.warn('[dlc-ui] frame error:', error);
572
+ }
573
+ }
574
+ if (subscribers.length === 0) {
575
+ cancelAnimationFrame(rafId);
576
+ running = false;
577
+ rafId = 0;
578
+ }
579
+ }
580
+
581
+ function start() {
582
+ if (running) return;
583
+ running = true;
584
+ last = 0;
585
+ rafId = requestAnimationFrame(tick);
586
+ }
587
+
588
+ function wakeScheduler() {
589
+ start();
590
+ }
591
+
592
+ function subscribeScheduler(onFrame, isPaused) {
593
+ const item = { onFrame, isPaused };
594
+ subscribers.push(item);
595
+ start();
596
+ return () => {
597
+ const index = subscribers.indexOf(item);
598
+ if (index !== -1) subscribers.splice(index, 1);
599
+ if (subscribers.length === 0 && rafId) {
600
+ cancelAnimationFrame(rafId);
601
+ running = false;
602
+ rafId = 0;
603
+ }
604
+ };
605
+ }
606
+
607
+ /**
608
+ * Gates drawing on "element intersects viewport AND the page tab is visible".
609
+ * Falls back to always-visible when IntersectionObserver is unavailable.
610
+ */
611
+ function createVisibilityGuard(element, onChange = null) {
612
+ let intersecting = true;
613
+ let pageVisible = typeof document === 'undefined' || !document.hidden;
614
+ let disposed = false;
615
+ let observer = null;
616
+
617
+ if (typeof IntersectionObserver !== 'undefined') {
618
+ observer = new IntersectionObserver(
619
+ (entries) => {
620
+ intersecting = entries.some((entry) => entry.isIntersecting);
621
+ if (typeof onChange === 'function') onChange();
622
+ },
623
+ { rootMargin: '180px' }
624
+ );
625
+ observer.observe(element);
626
+ }
627
+
628
+ const onVisibilityChange = () => {
629
+ pageVisible = typeof document !== 'undefined' && !document.hidden;
630
+ if (typeof onChange === 'function') onChange();
631
+ };
632
+ if (typeof document !== 'undefined') {
633
+ document.addEventListener('visibilitychange', onVisibilityChange);
634
+ }
635
+
636
+ return {
637
+ isVisible() {
638
+ return intersecting && pageVisible;
639
+ },
640
+ dispose() {
641
+ if (disposed) return;
642
+ disposed = true;
643
+ if (observer) observer.disconnect();
644
+ if (typeof document !== 'undefined') {
645
+ document.removeEventListener('visibilitychange', onVisibilityChange);
646
+ }
647
+ }
648
+ };
500
649
  }
501
650
 
502
- /**
503
- * Gates drawing on "element intersects viewport AND the page tab is visible".
504
- * Falls back to always-visible when IntersectionObserver is unavailable.
505
- */
506
- function createVisibilityGuard(element, onChange = null) {
507
- let intersecting = true;
508
- let pageVisible = typeof document === 'undefined' || !document.hidden;
509
- let disposed = false;
510
- let observer = null;
511
-
512
- if (typeof IntersectionObserver !== 'undefined') {
513
- observer = new IntersectionObserver(
514
- (entries) => {
515
- intersecting = entries.some((entry) => entry.isIntersecting);
516
- if (typeof onChange === 'function') onChange();
517
- },
518
- { rootMargin: '180px' }
519
- );
520
- observer.observe(element);
521
- }
522
-
523
- const onVisibilityChange = () => {
524
- pageVisible = typeof document !== 'undefined' && !document.hidden;
525
- if (typeof onChange === 'function') onChange();
526
- };
527
- if (typeof document !== 'undefined') {
528
- document.addEventListener('visibilitychange', onVisibilityChange);
529
- }
530
-
531
- return {
532
- isVisible() {
533
- return intersecting && pageVisible;
534
- },
535
- dispose() {
536
- if (disposed) return;
537
- disposed = true;
538
- if (observer) observer.disconnect();
539
- if (typeof document !== 'undefined') {
540
- document.removeEventListener('visibilitychange', onVisibilityChange);
541
- }
542
- }
543
- };
651
+ const PROGRESS_MOTION_WIDTH = 240;
652
+ const PROGRESS_MOTION_HEIGHT = 80;
653
+ const PROGRESS_MOTION_DURATION = 12.0;
654
+ const PROGRESS_MOTION_MAX_PX = 40.0;
655
+
656
+ const PROFILE_CONFIG = {
657
+ 'model-training': { seed: 0.37, broad: 0.58, middle: 0.25, detail: 0.13, lobe: 0.24 },
658
+ 'agent-migration': { seed: 1.71, broad: 0.72, middle: 0.10, detail: 0.03, lobe: 0.18 },
659
+ 'visual-training': { seed: 2.83, broad: 0.66, middle: 0.16, detail: 0.06, lobe: 0.23 },
660
+ 'botanic-lab': { seed: 3.46, broad: 0.54, middle: 0.18, detail: 0.05, lobe: 0.28 },
661
+ // ponytail: first-pass tide = asymmetric time warp on the same motion pipeline.
662
+ // Refine (foam line / wash streaks) in the shader after visual QA.
663
+ 'tide': { seed: 4.12, broad: 0.82, middle: 0.07, detail: 0.02, lobe: 0.30, warp: 0.5 }
664
+ };
665
+
666
+ const CACHE$1 = Object.create(null);
667
+
668
+ function gaussian(value, center, width) {
669
+ const delta = (value - center) / Math.max(width, 0.001);
670
+ return Math.exp(-delta * delta);
544
671
  }
545
672
 
546
- const PROGRESS_MOTION_WIDTH = 240;
547
- const PROGRESS_MOTION_HEIGHT = 80;
548
- const PROGRESS_MOTION_DURATION = 12.0;
549
- const PROGRESS_MOTION_MAX_PX = 40.0;
550
-
551
- const PROFILE_CONFIG = {
552
- 'model-training': { seed: 0.37, broad: 0.58, middle: 0.25, detail: 0.13, lobe: 0.24 },
553
- 'agent-migration': { seed: 1.71, broad: 0.72, middle: 0.10, detail: 0.03, lobe: 0.18 },
554
- 'visual-training': { seed: 2.83, broad: 0.66, middle: 0.16, detail: 0.06, lobe: 0.23 },
555
- 'botanic-lab': { seed: 3.46, broad: 0.54, middle: 0.18, detail: 0.05, lobe: 0.28 },
556
- // ponytail: first-pass tide = asymmetric time warp on the same motion pipeline.
557
- // Refine (foam line / wash streaks) in the shader after visual QA.
558
- 'tide': { seed: 4.12, broad: 0.82, middle: 0.07, detail: 0.02, lobe: 0.30, warp: 0.5 }
559
- };
560
-
561
- const CACHE$1 = Object.create(null);
562
-
563
- function gaussian(value, center, width) {
564
- const delta = (value - center) / Math.max(width, 0.001);
565
- return Math.exp(-delta * delta);
566
- }
567
-
568
- function createMotionData(id, edgeStyle = 'flow') {
569
- const profile = edgeStyle === 'tide'
570
- ? PROFILE_CONFIG.tide
571
- : (PROFILE_CONFIG[id] || PROFILE_CONFIG['visual-training']);
572
- const data = new Uint8Array(PROGRESS_MOTION_WIDTH * PROGRESS_MOTION_HEIGHT);
573
-
574
- for (let x = 0; x < PROGRESS_MOTION_WIDTH; x += 1) {
575
- let time = (x / PROGRESS_MOTION_WIDTH) * Math.PI * 2;
576
- if (profile.warp) time += profile.warp * Math.sin(time * 2);
577
- const centerA = 0.28 + Math.sin(time * 0.53 + profile.seed) * 0.13;
578
- const centerB = 0.70 + Math.cos(time * 0.47 + profile.seed * 1.7) * 0.12;
579
-
580
- for (let y = 0; y < PROGRESS_MOTION_HEIGHT; y += 1) {
581
- const ratio = y / Math.max(PROGRESS_MOTION_HEIGHT - 1, 1);
582
- const envelope = Math.pow(Math.max(Math.sin(Math.PI * ratio), 0), 0.48);
583
- const broad = Math.sin(ratio * Math.PI * 2 * 1.35 + time * 0.58 + profile.seed) * profile.broad;
584
- const middle = Math.sin(ratio * Math.PI * 2 * 3.2 - time * 0.91 + profile.seed * 2.1) * profile.middle;
585
- const detail = Math.sin(ratio * Math.PI * 2 * 6.1 + time * 1.31 + profile.seed * 3.2) * profile.detail;
586
- const lobes = (
587
- gaussian(ratio, centerA, 0.09) * Math.sin(time * 1.11 + profile.seed * 4.0) -
588
- gaussian(ratio, centerB, 0.10) * Math.cos(time * 0.97 + profile.seed * 3.3)
589
- ) * profile.lobe;
590
- const normalized = Math.max(-1, Math.min(1, (broad + middle + detail + lobes) * envelope));
591
- data[y * PROGRESS_MOTION_WIDTH + x] = Math.round((normalized * 0.5 + 0.5) * 255);
592
- }
593
- }
594
-
595
- return data;
596
- }
597
-
598
- function getProgressMotionData(id, edgeStyle = 'flow') {
599
- const key = `${id}:${edgeStyle}`;
600
- if (!CACHE$1[key]) CACHE$1[key] = createMotionData(id, edgeStyle);
601
- return CACHE$1[key];
673
+ function createMotionData(id, edgeStyle = 'flow') {
674
+ const profile = edgeStyle === 'tide'
675
+ ? PROFILE_CONFIG.tide
676
+ : (PROFILE_CONFIG[id] || PROFILE_CONFIG['visual-training']);
677
+ const data = new Uint8Array(PROGRESS_MOTION_WIDTH * PROGRESS_MOTION_HEIGHT);
678
+
679
+ for (let x = 0; x < PROGRESS_MOTION_WIDTH; x += 1) {
680
+ let time = (x / PROGRESS_MOTION_WIDTH) * Math.PI * 2;
681
+ if (profile.warp) time += profile.warp * Math.sin(time * 2);
682
+ const centerA = 0.28 + Math.sin(time * 0.53 + profile.seed) * 0.13;
683
+ const centerB = 0.70 + Math.cos(time * 0.47 + profile.seed * 1.7) * 0.12;
684
+
685
+ for (let y = 0; y < PROGRESS_MOTION_HEIGHT; y += 1) {
686
+ const ratio = y / Math.max(PROGRESS_MOTION_HEIGHT - 1, 1);
687
+ const envelope = Math.pow(Math.max(Math.sin(Math.PI * ratio), 0), 0.48);
688
+ const broad = Math.sin(ratio * Math.PI * 2 * 1.35 + time * 0.58 + profile.seed) * profile.broad;
689
+ const middle = Math.sin(ratio * Math.PI * 2 * 3.2 - time * 0.91 + profile.seed * 2.1) * profile.middle;
690
+ const detail = Math.sin(ratio * Math.PI * 2 * 6.1 + time * 1.31 + profile.seed * 3.2) * profile.detail;
691
+ const lobes = (
692
+ gaussian(ratio, centerA, 0.09) * Math.sin(time * 1.11 + profile.seed * 4.0) -
693
+ gaussian(ratio, centerB, 0.10) * Math.cos(time * 0.97 + profile.seed * 3.3)
694
+ ) * profile.lobe;
695
+ const normalized = Math.max(-1, Math.min(1, (broad + middle + detail + lobes) * envelope));
696
+ data[y * PROGRESS_MOTION_WIDTH + x] = Math.round((normalized * 0.5 + 0.5) * 255);
697
+ }
698
+ }
699
+
700
+ return data;
602
701
  }
603
702
 
604
- function hexToRgb01(hex) {
605
- const value = Number.parseInt(hex.replace('#', ''), 16);
606
- return [
607
- ((value >> 16) & 255) / 255,
608
- ((value >> 8) & 255) / 255,
609
- (value & 255) / 255
610
- ];
611
- }
612
-
613
- function stringSeed$1(value) {
614
- let hash = 2166136261;
615
- for (const character of value) {
616
- hash ^= character.charCodeAt(0);
617
- hash = Math.imul(hash, 16777619);
618
- }
619
- return (hash >>> 0) / 4294967295;
620
- }
621
-
622
- const PROFILE_INDEX = {
623
- 'model-training': 0,
624
- 'agent-migration': 1,
625
- 'visual-training': 2,
626
- 'tide': 3
627
- };
628
-
629
- const MOTION_SCALE_FACTORS = {
630
- 'model-training': 1.05,
631
- 'agent-migration': 1.04,
632
- 'visual-training': 1.04,
633
- 'tide': 1.18
634
- };
635
-
636
- const VERTEX_SHADER = `#version 300 es
637
- in vec2 a_position;
638
- out vec2 v_uv;
639
- void main() {
640
- v_uv = a_position * 0.5 + 0.5;
641
- gl_Position = vec4(a_position, 0.0, 1.0);
642
- }`;
643
-
644
- const FRAGMENT_SHADER = `#version 300 es
645
- precision highp float;
646
-
647
- in vec2 v_uv;
648
- out vec4 outColor;
649
-
650
- uniform vec2 u_resolution;
651
- uniform float u_time;
652
- uniform float u_progress;
653
- uniform float u_seed;
654
- uniform float u_profile;
655
- uniform sampler2D u_motion;
656
- uniform sampler2D u_effect;
657
- uniform float u_hasEffect;
658
- uniform float u_effectFrames;
659
- uniform float u_motionDuration;
660
- uniform float u_motionScale;
661
- uniform vec3 u_dark;
662
- uniform vec3 u_accentA;
663
- uniform vec3 u_accentB;
664
- uniform vec3 u_glow;
665
-
666
- float hash21(vec2 p) {
667
- p = fract(p * vec2(123.34, 456.21));
668
- p += dot(p, p + 45.32 + u_seed * 11.7);
669
- return fract(p.x * p.y);
670
- }
671
-
672
- float noise(vec2 p) {
673
- vec2 i = floor(p);
674
- vec2 f = fract(p);
675
- f = f * f * (3.0 - 2.0 * f);
676
- float a = hash21(i);
677
- float b = hash21(i + vec2(1.0, 0.0));
678
- float c = hash21(i + vec2(0.0, 1.0));
679
- float d = hash21(i + vec2(1.0, 1.0));
680
- return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
681
- }
682
-
683
- float fbm(vec2 p) {
684
- float value = 0.0;
685
- float amplitude = 0.55;
686
- mat2 rotation = mat2(0.82, 0.57, -0.57, 0.82);
687
- for (int i = 0; i < 6; i++) {
688
- value += noise(p) * amplitude;
689
- p = rotation * p * 2.02 + 13.7;
690
- amplitude *= 0.48;
691
- }
692
- return value;
693
- }
694
-
695
- float gaussian(float value, float center, float width) {
696
- float delta = (value - center) / max(width, 0.0001);
697
- return exp(-delta * delta);
698
- }
699
-
700
- float profileMix(float model, float agent, float visual) {
701
- if (u_profile < 0.5) return model;
702
- if (u_profile < 1.5) return agent;
703
- return visual;
704
- }
705
-
706
- float motionSample(float y, float t) {
707
- float phase = fract(t / max(u_motionDuration, 0.001));
708
- float captured = texture(u_motion, vec2(phase, 1.0 - clamp(y, 0.0, 1.0))).r;
709
- return (captured * 2.0 - 1.0) * u_motionScale;
710
- }
711
-
712
- float edgeDisplacement(float y, float t) {
713
- return motionSample(y, t);
714
- }
715
-
716
- float flowDisplacement(float y, float t) {
717
- return (
718
- motionSample(y - 0.024, t) * 0.08 +
719
- motionSample(y - 0.012, t) * 0.18 +
720
- motionSample(y, t) * 0.48 +
721
- motionSample(y + 0.012, t) * 0.18 +
722
- motionSample(y + 0.024, t) * 0.08
723
- );
724
- }
725
-
726
- float ellipseRing(vec2 p, float radius, float width) {
727
- return gaussian(length(p), radius, width);
728
- }
729
-
730
- void main() {
731
- vec2 uv = v_uv;
732
- float t = u_time;
733
- float edge = u_progress + edgeDisplacement(uv.y, t);
734
- float flowEdge = u_progress + flowDisplacement(uv.y, t);
735
- float d = uv.x - edge;
736
- float fd = uv.x - flowEdge;
737
-
738
- vec3 rightBase = vec3(0.125, 0.129, 0.145);
739
- vec3 color = rightBase;
740
-
741
- float leftMask = 1.0 - smoothstep(-0.001, 0.002, d);
742
- color = mix(color, u_dark, leftMask * profileMix(0.96, 0.92, 0.96));
743
-
744
- vec2 flowP = vec2((fd + 0.10) * 6.2, uv.y * 1.95);
745
- float flowA = fbm(flowP + vec2(-t * 0.22, t * 0.27) + u_seed * 1.7);
746
- float flowB = fbm(flowP * 1.52 + vec2(t * 0.28, -t * 0.36) + 8.2 + u_seed);
747
- float flowC = fbm(flowP * 2.25 + vec2(-t * 0.41, t * 0.46) + 19.0);
748
-
749
- float farCenter = profileMix(-0.060, -0.079, -0.045) + (flowA - 0.5) * profileMix(0.018, 0.022, 0.014);
750
- float midCenter = profileMix(-0.039, -0.052, -0.030) + (flowB - 0.5) * profileMix(0.013, 0.016, 0.010);
751
- float hotCenter = profileMix(-0.026, -0.029, -0.023) + (flowC - 0.5) * 0.010;
752
-
753
- float farBand = gaussian(fd, farCenter, profileMix(0.035, 0.049, 0.030));
754
- float midBand = gaussian(fd, midCenter, profileMix(0.026, 0.034, 0.026));
755
- float hotBand = gaussian(fd, hotCenter, profileMix(0.023, 0.027, 0.026));
756
- float darkTrough = gaussian(fd, profileMix(-0.050, -0.058, -0.044) + (flowB - 0.5) * 0.010, profileMix(0.020, 0.025, 0.021));
757
-
758
- float ringY = 0.47 + sin(t * 0.58 + u_seed * 2.4) * 0.12;
759
- vec2 ringP = vec2((fd + 0.086) / 0.078, (uv.y - ringY) / 0.25);
760
- ringP += vec2((flowB - 0.5) * 0.08, (flowA - 0.5) * 0.06);
761
- float ringTexture = fbm(ringP * 2.15 + vec2(t * 0.18, -t * 0.14) + u_seed * 1.9);
762
- float ring = ellipseRing(ringP, 0.66, 0.32) * (0.30 + 0.64 * ringTexture);
763
- float ringCore = gaussian(length(ringP), 0.25, 0.25);
764
- float ringPulse = smoothstep(0.58, 0.90, 0.5 + 0.5 * sin(t * 0.82 + u_seed * 4.1));
765
- float modelRing = ring * ringPulse * (1.0 - step(0.5, u_profile));
766
- float visualRing = ring * 0.16 * step(1.5, u_profile) * ringPulse;
767
-
768
- float cloudGate = leftMask * smoothstep(-0.30, -0.008, fd);
769
- float textureA = smoothstep(0.24, 0.92, flowA * 0.72 + flowB * 0.42);
770
- float textureB = smoothstep(0.28, 0.94, flowB * 0.68 + flowC * 0.38);
771
-
772
- vec3 hotColor = u_accentB;
773
- color += u_accentA * farBand * cloudGate * (0.07 + textureA * profileMix(0.42, 0.24, 0.40));
774
- color += u_accentB * midBand * cloudGate * (0.15 + textureB * profileMix(0.70, 0.46, 0.68));
775
- color += hotColor * hotBand * cloudGate * profileMix(0.88, 0.62, 0.84);
776
- float modelMask = 1.0 - step(0.5, u_profile);
777
- color += u_accentA * (modelRing + visualRing) * cloudGate * profileMix(0.54, 0.0, 0.34);
778
- color *= 1.0 - darkTrough * profileMix(0.44, 0.24, 0.24) * cloudGate;
779
- color *= 1.0 - ringCore * modelMask * ringPulse * 0.44 * cloudGate;
780
-
781
- float broadHalo = exp(-abs(d) * 96.0);
782
- float innerHalo = exp(-abs(d) * 176.0);
783
- float colorCore = exp(-abs(d) * 360.0);
784
- float sharpCore = exp(-abs(d) * 760.0);
785
- float leftGate = 1.0 - smoothstep(-0.003, 0.005, d);
786
-
787
- color += u_accentA * broadHalo * leftGate * profileMix(0.09, 0.04, 0.06);
788
- color += hotColor * innerHalo * leftGate * profileMix(0.76, 0.72, 0.78);
789
- color += u_glow * colorCore * profileMix(0.72, 0.34, 0.24);
790
-
791
- float whiteStrength = profileMix(0.10, 0.0, 0.0);
792
- color += vec3(1.0, 0.99, 0.91) * sharpCore * whiteStrength;
793
-
794
- float rightCut = smoothstep(0.001, 0.006, d);
795
- color = mix(color, rightBase, rightCut);
796
-
797
- float effectX = (d * 1257.0 + 260.0) / 320.0;
798
- float atlasPhase = fract(t / 12.0) * u_effectFrames;
799
- float atlasFrameA = floor(atlasPhase);
800
- float atlasFrameB = mod(atlasFrameA + 1.0, u_effectFrames);
801
- float atlasMix = smoothstep(0.0, 1.0, fract(atlasPhase));
802
- float atlasXA = (atlasFrameA + clamp(effectX, 0.0, 1.0)) / u_effectFrames;
803
- float atlasXB = (atlasFrameB + clamp(effectX, 0.0, 1.0)) / u_effectFrames;
804
- vec3 referenceA = texture(u_effect, vec2(atlasXA, uv.y)).rgb;
805
- vec3 referenceB = texture(u_effect, vec2(atlasXB, uv.y)).rgb;
806
- vec3 referenceColor = mix(referenceA, referenceB, atlasMix);
807
- float stripMask = smoothstep(0.0, 0.018, effectX) * (1.0 - smoothstep(0.982, 1.0, effectX));
808
- float referenceLeft = 1.0 - smoothstep(-0.026, -0.012, d);
809
- // 参考图集只提供亮度结构,颜色始终由用户 colors(u_dark / u_accentA /
810
- // u_accentB / u_glow)决定:这样 setColors / colors 属性在 WebGL 路径下
811
- // 真实生效,改色有可见反馈,而不是被图集整体覆盖。
812
- float referenceLuma = dot(referenceColor, vec3(0.299, 0.587, 0.114));
813
- vec3 tintedColor = color * (0.42 + 0.86 * referenceLuma);
814
- color = mix(color, tintedColor, stripMask * referenceLeft * u_hasEffect);
815
-
816
- outColor = vec4(clamp(color, 0.0, 1.0), 1.0);
817
- }`;
818
-
819
- function compileShader(gl, type, source) {
820
- const shader = gl.createShader(type);
821
- gl.shaderSource(shader, source);
822
- gl.compileShader(shader);
823
- if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
824
- const message = gl.getShaderInfoLog(shader) || 'Unknown shader compile error';
825
- gl.deleteShader(shader);
826
- throw new Error(message);
827
- }
828
- return shader;
829
- }
830
-
831
- function createProgram(gl) {
832
- const vertex = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
833
- const fragment = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
834
- const program = gl.createProgram();
835
- gl.attachShader(program, vertex);
836
- gl.attachShader(program, fragment);
837
- gl.linkProgram(program);
838
- gl.deleteShader(vertex);
839
- gl.deleteShader(fragment);
840
- if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
841
- const message = gl.getProgramInfoLog(program) || 'Unknown shader link error';
842
- gl.deleteProgram(program);
843
- throw new Error(message);
844
- }
845
- return program;
846
- }
847
-
848
- class ProgressFlowRenderer {
849
- constructor(canvas, preset, options = {}) {
850
- const gl = canvas.getContext('webgl2', {
851
- alpha: false,
852
- antialias: true,
853
- premultipliedAlpha: false,
854
- powerPreference: options.powerPreference || 'high-performance'
855
- });
856
- if (!gl) throw new Error('WebGL2 unavailable');
857
-
858
- this.canvas = canvas;
859
- this.options = options;
860
- this.gl = gl;
861
- this.profile = preset.edgeStyle === 'tide' ? 3 : (PROFILE_INDEX[preset.id] ?? 2);
862
- this.seed = stringSeed$1(`${preset.id}-shader`) * 13.7 + 1.0;
863
- this.colors = preset.colors.map(hexToRgb01);
864
- this.motionData = getProgressMotionData(preset.id, preset.edgeStyle);
865
- const motionFactor = preset.edgeStyle === 'tide'
866
- ? MOTION_SCALE_FACTORS.tide
867
- : (MOTION_SCALE_FACTORS[preset.id] || 1.04);
868
- this.motionScale = (PROGRESS_MOTION_MAX_PX * motionFactor) / 1257;
869
- this.disposed = false;
870
- this.contextLost = false;
871
- this.setupResources();
872
- this.bindContextEvents();
873
- }
874
-
875
- setupResources() {
876
- const gl = this.gl;
877
- this.program = createProgram(gl);
878
-
879
- this.position = gl.getAttribLocation(this.program, 'a_position');
880
- this.uniforms = {
881
- resolution: gl.getUniformLocation(this.program, 'u_resolution'),
882
- time: gl.getUniformLocation(this.program, 'u_time'),
883
- progress: gl.getUniformLocation(this.program, 'u_progress'),
884
- seed: gl.getUniformLocation(this.program, 'u_seed'),
885
- profile: gl.getUniformLocation(this.program, 'u_profile'),
886
- motion: gl.getUniformLocation(this.program, 'u_motion'),
887
- effect: gl.getUniformLocation(this.program, 'u_effect'),
888
- hasEffect: gl.getUniformLocation(this.program, 'u_hasEffect'),
889
- effectFrames: gl.getUniformLocation(this.program, 'u_effectFrames'),
890
- motionDuration: gl.getUniformLocation(this.program, 'u_motionDuration'),
891
- motionScale: gl.getUniformLocation(this.program, 'u_motionScale'),
892
- dark: gl.getUniformLocation(this.program, 'u_dark'),
893
- accentA: gl.getUniformLocation(this.program, 'u_accentA'),
894
- accentB: gl.getUniformLocation(this.program, 'u_accentB'),
895
- glow: gl.getUniformLocation(this.program, 'u_glow')
896
- };
897
-
898
- this.buffer = gl.createBuffer();
899
- gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
900
- gl.bufferData(
901
- gl.ARRAY_BUFFER,
902
- new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
903
- gl.STATIC_DRAW
904
- );
905
-
906
- this.motionTexture = gl.createTexture();
907
- gl.activeTexture(gl.TEXTURE0);
908
- gl.bindTexture(gl.TEXTURE_2D, this.motionTexture);
909
- gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
910
- gl.texImage2D(
911
- gl.TEXTURE_2D,
912
- 0,
913
- gl.R8,
914
- PROGRESS_MOTION_WIDTH,
915
- PROGRESS_MOTION_HEIGHT,
916
- 0,
917
- gl.RED,
918
- gl.UNSIGNED_BYTE,
919
- this.motionData
920
- );
921
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
922
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
923
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
924
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
925
-
926
- this.effectTexture = gl.createTexture();
927
- gl.activeTexture(gl.TEXTURE1);
928
- gl.bindTexture(gl.TEXTURE_2D, this.effectTexture);
929
- gl.texImage2D(
930
- gl.TEXTURE_2D,
931
- 0,
932
- gl.RGB,
933
- 1,
934
- 1,
935
- 0,
936
- gl.RGB,
937
- gl.UNSIGNED_BYTE,
938
- new Uint8Array([32, 33, 38])
939
- );
940
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
941
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
942
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
943
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
944
- this.effectUploaded = false;
945
- }
946
-
947
- bindContextEvents() {
948
- this.onContextLost = (event) => {
949
- event.preventDefault();
950
- if (this.disposed) return;
951
- this.contextLost = true;
952
- if (typeof this.options.onContextLost === 'function') this.options.onContextLost(event);
953
- };
954
- this.onContextRestored = () => {
955
- if (this.disposed) return;
956
- this.setupResources();
957
- this.contextLost = false;
958
- this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
959
- if (typeof this.options.onContextRestored === 'function') this.options.onContextRestored();
960
- };
961
- this.canvas.addEventListener('webglcontextlost', this.onContextLost, false);
962
- this.canvas.addEventListener('webglcontextrestored', this.onContextRestored, false);
963
- }
964
-
965
- resize(width, height, dpr) {
966
- const pixelWidth = Math.max(1, Math.round(width * dpr));
967
- const pixelHeight = Math.max(1, Math.round(height * dpr));
968
- if (this.canvas.width !== pixelWidth || this.canvas.height !== pixelHeight) {
969
- this.canvas.width = pixelWidth;
970
- this.canvas.height = pixelHeight;
971
- }
972
- this.gl.viewport(0, 0, pixelWidth, pixelHeight);
973
- }
974
-
975
- draw(time, progress, effectImage = null) {
976
- if (this.disposed || this.contextLost) return;
977
- const gl = this.gl;
978
- gl.useProgram(this.program);
979
- gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
980
- gl.enableVertexAttribArray(this.position);
981
- gl.vertexAttribPointer(this.position, 2, gl.FLOAT, false, 0, 0);
982
-
983
- gl.uniform2f(this.uniforms.resolution, this.canvas.width, this.canvas.height);
984
- gl.uniform1f(this.uniforms.time, time);
985
- gl.uniform1f(this.uniforms.progress, progress / 100);
986
- gl.uniform1f(this.uniforms.seed, this.seed);
987
- gl.uniform1f(this.uniforms.profile, this.profile);
988
- gl.activeTexture(gl.TEXTURE0);
989
- gl.bindTexture(gl.TEXTURE_2D, this.motionTexture);
990
- gl.uniform1i(this.uniforms.motion, 0);
991
- gl.uniform1f(this.uniforms.motionDuration, PROGRESS_MOTION_DURATION);
992
- gl.uniform1f(this.uniforms.motionScale, this.motionScale);
993
-
994
- let hasEffect = this.effectUploaded ? 1 : 0;
995
- const effectWidth = effectImage && (effectImage.naturalWidth || effectImage.width || 0);
996
- if (!this.effectUploaded && effectImage && effectWidth > 0) {
997
- gl.activeTexture(gl.TEXTURE1);
998
- gl.bindTexture(gl.TEXTURE_2D, this.effectTexture);
999
- gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
1000
- try {
1001
- gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, effectImage);
1002
- this.effectUploaded = true;
1003
- hasEffect = 1;
1004
- } catch (error) {
1005
- console.warn('[画境观屿] 参考纹理图集上传失败,继续使用程序化降级。', error);
1006
- }
1007
- gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
1008
- }
1009
- gl.activeTexture(gl.TEXTURE1);
1010
- gl.bindTexture(gl.TEXTURE_2D, this.effectTexture);
1011
- gl.uniform1i(this.uniforms.effect, 1);
1012
- gl.uniform1f(this.uniforms.hasEffect, hasEffect);
1013
- gl.uniform1f(this.uniforms.effectFrames, 24);
1014
-
1015
- gl.uniform3fv(this.uniforms.dark, this.colors[0]);
1016
- gl.uniform3fv(this.uniforms.accentA, this.colors[1]);
1017
- gl.uniform3fv(this.uniforms.accentB, this.colors[2]);
1018
- gl.uniform3fv(this.uniforms.glow, this.colors[3]);
1019
-
1020
- gl.drawArrays(gl.TRIANGLES, 0, 6);
1021
- }
1022
-
1023
- setColors(colors) {
1024
- this.colors = colors.map(hexToRgb01);
1025
- }
1026
-
1027
- dispose() {
1028
- this.disposed = true;
1029
- const gl = this.gl;
1030
- this.canvas.removeEventListener('webglcontextlost', this.onContextLost, false);
1031
- this.canvas.removeEventListener('webglcontextrestored', this.onContextRestored, false);
1032
- gl.deleteBuffer(this.buffer);
1033
- gl.deleteTexture(this.motionTexture);
1034
- gl.deleteTexture(this.effectTexture);
1035
- gl.deleteProgram(this.program);
1036
- const lose = gl.getExtension('WEBGL_lose_context');
1037
- if (lose) lose.loseContext();
1038
- }
1039
- }
1040
-
1041
- function createProgressFlowRenderer(canvas, preset, options = {}) {
1042
- try {
1043
- return new ProgressFlowRenderer(canvas, preset, options);
1044
- } catch (error) {
1045
- console.warn('[画境观屿] 进度流体 WebGL2 不可用,使用 Canvas 2D 降级。', error);
1046
- return null;
1047
- }
703
+ function getProgressMotionData(id, edgeStyle = 'flow') {
704
+ const key = `${id}:${edgeStyle}`;
705
+ if (!CACHE$1[key]) CACHE$1[key] = createMotionData(id, edgeStyle);
706
+ return CACHE$1[key];
1048
707
  }
1049
708
 
1050
- const PROGRESS_REFERENCE_DURATION = 12;
1051
- const PROGRESS_REFERENCE_FRAME_COUNT = 24;
1052
-
1053
- const FRAME_WIDTH = 64;
1054
- const FRAME_HEIGHT = 32;
1055
- const CACHE = Object.create(null);
1056
-
1057
- const PALETTES = {
1058
- 'model-training': ['#20131f', '#ff3f94', '#ff8a3d', '#fff06a'],
1059
- 'agent-migration': ['#111a31', '#245bff', '#00cfff', '#5dffe6'],
1060
- 'visual-training': ['#1f172b', '#7042ff', '#42f58d', '#c4ff8a'],
1061
- 'tide': ['#0a2239', '#2e9bff', '#7fe3ff', '#eaf9ff'],
1062
- 'botanic-lab': ['#102a23', '#287a58', '#9bd65b', '#f2ffc4']
1063
- };
1064
-
1065
- function drawCloud(context, x, y, radiusX, radiusY, color, alpha) {
1066
- context.save();
1067
- context.translate(x, y);
1068
- context.scale(1, radiusY / radiusX);
1069
- const gradient = context.createRadialGradient(0, 0, 0, 0, 0, radiusX);
1070
- const alphaHex = (value) => {
1071
- const text = Math.round(value).toString(16);
1072
- return text.length === 1 ? `0${text}` : text;
1073
- };
1074
- gradient.addColorStop(0, `${color}${alphaHex(alpha * 255)}`);
1075
- gradient.addColorStop(0.46, `${color}${alphaHex(alpha * 0.42 * 255)}`);
1076
- gradient.addColorStop(1, `${color}00`);
1077
- context.fillStyle = gradient;
1078
- context.fillRect(-radiusX, -radiusX, radiusX * 2, radiusX * 2);
1079
- context.restore();
1080
- }
1081
-
1082
- function createAtlas(id) {
1083
- const palette = PALETTES[id] || PALETTES['visual-training'];
1084
- const canvas = document.createElement('canvas');
1085
- canvas.width = FRAME_WIDTH * PROGRESS_REFERENCE_FRAME_COUNT;
1086
- canvas.height = FRAME_HEIGHT;
1087
- const context = canvas.getContext('2d');
1088
-
1089
- for (let frame = 0; frame < PROGRESS_REFERENCE_FRAME_COUNT; frame += 1) {
1090
- const phase = (frame / PROGRESS_REFERENCE_FRAME_COUNT) * Math.PI * 2;
1091
- const left = frame * FRAME_WIDTH;
1092
- context.save();
1093
- context.translate(left, 0);
1094
- context.fillStyle = palette[0];
1095
- context.fillRect(0, 0, FRAME_WIDTH, FRAME_HEIGHT);
1096
- context.globalCompositeOperation = 'screen';
1097
-
1098
- 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);
1099
- drawCloud(context, 45 + Math.cos(phase * 0.72) * 4, 23 + Math.sin(phase * 0.54) * 4, 22, 15, palette[2], 0.44);
1100
- 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);
1101
-
1102
- context.globalCompositeOperation = 'source-over';
1103
- const trough = context.createRadialGradient(41, 16, 1, 41, 16, 17);
1104
- trough.addColorStop(0, 'rgba(5,6,11,0.64)');
1105
- trough.addColorStop(0.58, 'rgba(6,7,12,0.26)');
1106
- trough.addColorStop(1, 'rgba(6,7,12,0)');
1107
- context.fillStyle = trough;
1108
- context.fillRect(20, 0, 44, FRAME_HEIGHT);
1109
- context.restore();
1110
- }
1111
-
1112
- // A canvas is a valid TexImageSource. Keeping it directly avoids a PNG
1113
- // encode -> base64 allocation -> Image decode round trip at startup.
1114
- return { image: canvas, ready: true };
1115
- }
1116
-
1117
- function getProgressReferenceAtlas(id) {
1118
- if (!CACHE[id]) CACHE[id] = createAtlas(id);
1119
- return CACHE[id];
709
+ function hexToRgb01(hex) {
710
+ const value = Number.parseInt(hex.replace('#', ''), 16);
711
+ return [
712
+ ((value >> 16) & 255) / 255,
713
+ ((value >> 8) & 255) / 255,
714
+ (value & 255) / 255
715
+ ];
1120
716
  }
1121
717
 
1122
- /**
1123
- * Attach a WebGL2 fluid overlay to a progress capsule root.
1124
- *
1125
- * @param {object} params
1126
- * @param {HTMLElement} params.root progress capsule root element
1127
- * @param {HTMLCanvasElement} params.canvas 2D fallback canvas (kept beneath)
1128
- * @param {object} params.preset progress preset
1129
- * @param {() => number} params.getProgress reads the current progress value
1130
- * @returns {{ update(flowTime: number): void, setDprCap(cap: number): void, dispose(): void } | null}
1131
- */
1132
- function attachProgressFlowOverlay({
1133
- root,
1134
- canvas,
1135
- preset,
1136
- getProgress,
1137
- dprCap = 2,
1138
- powerPreference = 'high-performance',
1139
- onContextLost,
1140
- onContextRestored
1141
- }) {
1142
- const overlay = document.createElement('canvas');
1143
- overlay.className = 'hj-progress-canvas hj-progress-overlay';
1144
- overlay.setAttribute('aria-hidden', 'true');
1145
- canvas.insertAdjacentElement('afterend', overlay);
1146
-
1147
- const renderer = createProgressFlowRenderer(overlay, preset, {
1148
- powerPreference,
1149
- onContextLost,
1150
- onContextRestored
1151
- });
1152
- if (!renderer) {
1153
- overlay.remove();
1154
- return null;
1155
- }
1156
-
1157
- const atlas = getProgressReferenceAtlas(preset.id);
1158
- root.classList.add('has-webgl-progress');
1159
-
1160
- const resize = () => {
1161
- const bounds = root.getBoundingClientRect();
1162
- const width = Math.max(1, bounds.width);
1163
- const height = Math.max(1, bounds.height);
1164
- const dpr = Math.min(window.devicePixelRatio || 1, dprCap);
1165
- overlay.style.width = `${width}px`;
1166
- overlay.style.height = `${height}px`;
1167
- renderer.resize(width, height, dpr);
1168
- };
1169
-
1170
- let resizeObserver = null;
1171
- let onWindowResize = null;
1172
- if (typeof ResizeObserver !== 'undefined') {
1173
- resizeObserver = new ResizeObserver(resize);
1174
- resizeObserver.observe(root);
1175
- } else {
1176
- onWindowResize = resize;
1177
- window.addEventListener('resize', onWindowResize);
1178
- }
1179
- resize();
1180
-
1181
- return {
1182
- update(flowTime) {
1183
- const effectTime = flowTime % PROGRESS_REFERENCE_DURATION;
1184
- const progress = getProgress();
1185
- renderer.draw(
1186
- effectTime,
1187
- Number.isFinite(progress) ? progress : preset.initialProgress,
1188
- atlas.ready ? atlas.image : null
1189
- );
1190
- },
1191
- setColors(colors) {
1192
- renderer.setColors(colors);
1193
- },
1194
- setDprCap(cap) {
1195
- dprCap = cap;
1196
- resize();
1197
- },
1198
- dispose() {
1199
- if (resizeObserver) resizeObserver.disconnect();
1200
- if (onWindowResize) window.removeEventListener('resize', onWindowResize);
1201
- renderer.dispose();
1202
- overlay.remove();
1203
- root.classList.remove('has-webgl-progress');
1204
- }
1205
- };
718
+ function stringSeed$1(value) {
719
+ let hash = 2166136261;
720
+ for (const character of value) {
721
+ hash ^= character.charCodeAt(0);
722
+ hash = Math.imul(hash, 16777619);
723
+ }
724
+ return (hash >>> 0) / 4294967295;
725
+ }
726
+
727
+ const PROFILE_INDEX = {
728
+ 'model-training': 0,
729
+ 'agent-migration': 1,
730
+ 'visual-training': 2,
731
+ 'tide': 3
732
+ };
733
+
734
+ const MOTION_SCALE_FACTORS = {
735
+ 'model-training': 1.05,
736
+ 'agent-migration': 1.04,
737
+ 'visual-training': 1.04,
738
+ 'tide': 1.18
739
+ };
740
+
741
+ const VERTEX_SHADER = `#version 300 es
742
+ in vec2 a_position;
743
+ out vec2 v_uv;
744
+ void main() {
745
+ v_uv = a_position * 0.5 + 0.5;
746
+ gl_Position = vec4(a_position, 0.0, 1.0);
747
+ }`;
748
+
749
+ const FRAGMENT_SHADER = `#version 300 es
750
+ precision highp float;
751
+
752
+ in vec2 v_uv;
753
+ out vec4 outColor;
754
+
755
+ uniform vec2 u_resolution;
756
+ uniform float u_time;
757
+ uniform float u_progress;
758
+ uniform float u_seed;
759
+ uniform float u_profile;
760
+ uniform sampler2D u_motion;
761
+ uniform sampler2D u_effect;
762
+ uniform float u_hasEffect;
763
+ uniform float u_effectFrames;
764
+ uniform float u_motionDuration;
765
+ uniform float u_motionScale;
766
+ uniform vec3 u_dark;
767
+ uniform vec3 u_accentA;
768
+ uniform vec3 u_accentB;
769
+ uniform vec3 u_glow;
770
+
771
+ float hash21(vec2 p) {
772
+ p = fract(p * vec2(123.34, 456.21));
773
+ p += dot(p, p + 45.32 + u_seed * 11.7);
774
+ return fract(p.x * p.y);
775
+ }
776
+
777
+ float noise(vec2 p) {
778
+ vec2 i = floor(p);
779
+ vec2 f = fract(p);
780
+ f = f * f * (3.0 - 2.0 * f);
781
+ float a = hash21(i);
782
+ float b = hash21(i + vec2(1.0, 0.0));
783
+ float c = hash21(i + vec2(0.0, 1.0));
784
+ float d = hash21(i + vec2(1.0, 1.0));
785
+ return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
786
+ }
787
+
788
+ float fbm(vec2 p) {
789
+ float value = 0.0;
790
+ float amplitude = 0.55;
791
+ mat2 rotation = mat2(0.82, 0.57, -0.57, 0.82);
792
+ for (int i = 0; i < 6; i++) {
793
+ value += noise(p) * amplitude;
794
+ p = rotation * p * 2.02 + 13.7;
795
+ amplitude *= 0.48;
796
+ }
797
+ return value;
798
+ }
799
+
800
+ float gaussian(float value, float center, float width) {
801
+ float delta = (value - center) / max(width, 0.0001);
802
+ return exp(-delta * delta);
803
+ }
804
+
805
+ float profileMix(float model, float agent, float visual) {
806
+ if (u_profile < 0.5) return model;
807
+ if (u_profile < 1.5) return agent;
808
+ return visual;
809
+ }
810
+
811
+ float motionSample(float y, float t) {
812
+ float phase = fract(t / max(u_motionDuration, 0.001));
813
+ float captured = texture(u_motion, vec2(phase, 1.0 - clamp(y, 0.0, 1.0))).r;
814
+ return (captured * 2.0 - 1.0) * u_motionScale;
815
+ }
816
+
817
+ float edgeDisplacement(float y, float t) {
818
+ return motionSample(y, t);
819
+ }
820
+
821
+ float flowDisplacement(float y, float t) {
822
+ return (
823
+ motionSample(y - 0.024, t) * 0.08 +
824
+ motionSample(y - 0.012, t) * 0.18 +
825
+ motionSample(y, t) * 0.48 +
826
+ motionSample(y + 0.012, t) * 0.18 +
827
+ motionSample(y + 0.024, t) * 0.08
828
+ );
829
+ }
830
+
831
+ float ellipseRing(vec2 p, float radius, float width) {
832
+ return gaussian(length(p), radius, width);
833
+ }
834
+
835
+ void main() {
836
+ vec2 uv = v_uv;
837
+ float t = u_time;
838
+ float edge = u_progress + edgeDisplacement(uv.y, t);
839
+ float flowEdge = u_progress + flowDisplacement(uv.y, t);
840
+ float d = uv.x - edge;
841
+ float fd = uv.x - flowEdge;
842
+
843
+ vec3 rightBase = vec3(0.125, 0.129, 0.145);
844
+ vec3 color = rightBase;
845
+
846
+ float leftMask = 1.0 - smoothstep(-0.001, 0.002, d);
847
+ color = mix(color, u_dark, leftMask * profileMix(0.96, 0.92, 0.96));
848
+
849
+ vec2 flowP = vec2((fd + 0.10) * 6.2, uv.y * 1.95);
850
+ float flowA = fbm(flowP + vec2(-t * 0.22, t * 0.27) + u_seed * 1.7);
851
+ float flowB = fbm(flowP * 1.52 + vec2(t * 0.28, -t * 0.36) + 8.2 + u_seed);
852
+ float flowC = fbm(flowP * 2.25 + vec2(-t * 0.41, t * 0.46) + 19.0);
853
+
854
+ float farCenter = profileMix(-0.060, -0.079, -0.045) + (flowA - 0.5) * profileMix(0.018, 0.022, 0.014);
855
+ float midCenter = profileMix(-0.039, -0.052, -0.030) + (flowB - 0.5) * profileMix(0.013, 0.016, 0.010);
856
+ float hotCenter = profileMix(-0.026, -0.029, -0.023) + (flowC - 0.5) * 0.010;
857
+
858
+ float farBand = gaussian(fd, farCenter, profileMix(0.035, 0.049, 0.030));
859
+ float midBand = gaussian(fd, midCenter, profileMix(0.026, 0.034, 0.026));
860
+ float hotBand = gaussian(fd, hotCenter, profileMix(0.023, 0.027, 0.026));
861
+ float darkTrough = gaussian(fd, profileMix(-0.050, -0.058, -0.044) + (flowB - 0.5) * 0.010, profileMix(0.020, 0.025, 0.021));
862
+
863
+ float ringY = 0.47 + sin(t * 0.58 + u_seed * 2.4) * 0.12;
864
+ vec2 ringP = vec2((fd + 0.086) / 0.078, (uv.y - ringY) / 0.25);
865
+ ringP += vec2((flowB - 0.5) * 0.08, (flowA - 0.5) * 0.06);
866
+ float ringTexture = fbm(ringP * 2.15 + vec2(t * 0.18, -t * 0.14) + u_seed * 1.9);
867
+ float ring = ellipseRing(ringP, 0.66, 0.32) * (0.30 + 0.64 * ringTexture);
868
+ float ringCore = gaussian(length(ringP), 0.25, 0.25);
869
+ float ringPulse = smoothstep(0.58, 0.90, 0.5 + 0.5 * sin(t * 0.82 + u_seed * 4.1));
870
+ float modelRing = ring * ringPulse * (1.0 - step(0.5, u_profile));
871
+ float visualRing = ring * 0.16 * step(1.5, u_profile) * ringPulse;
872
+
873
+ float cloudGate = leftMask * smoothstep(-0.30, -0.008, fd);
874
+ float textureA = smoothstep(0.24, 0.92, flowA * 0.72 + flowB * 0.42);
875
+ float textureB = smoothstep(0.28, 0.94, flowB * 0.68 + flowC * 0.38);
876
+
877
+ vec3 hotColor = u_accentB;
878
+ color += u_accentA * farBand * cloudGate * (0.07 + textureA * profileMix(0.42, 0.24, 0.40));
879
+ color += u_accentB * midBand * cloudGate * (0.15 + textureB * profileMix(0.70, 0.46, 0.68));
880
+ color += hotColor * hotBand * cloudGate * profileMix(0.88, 0.62, 0.84);
881
+ float modelMask = 1.0 - step(0.5, u_profile);
882
+ color += u_accentA * (modelRing + visualRing) * cloudGate * profileMix(0.54, 0.0, 0.34);
883
+ color *= 1.0 - darkTrough * profileMix(0.44, 0.24, 0.24) * cloudGate;
884
+ color *= 1.0 - ringCore * modelMask * ringPulse * 0.44 * cloudGate;
885
+
886
+ float broadHalo = exp(-abs(d) * 96.0);
887
+ float innerHalo = exp(-abs(d) * 176.0);
888
+ float colorCore = exp(-abs(d) * 360.0);
889
+ float sharpCore = exp(-abs(d) * 760.0);
890
+ float leftGate = 1.0 - smoothstep(-0.003, 0.005, d);
891
+
892
+ color += u_accentA * broadHalo * leftGate * profileMix(0.09, 0.04, 0.06);
893
+ color += hotColor * innerHalo * leftGate * profileMix(0.76, 0.72, 0.78);
894
+ color += u_glow * colorCore * profileMix(0.72, 0.34, 0.24);
895
+
896
+ float whiteStrength = profileMix(0.10, 0.0, 0.0);
897
+ color += vec3(1.0, 0.99, 0.91) * sharpCore * whiteStrength;
898
+
899
+ float rightCut = smoothstep(0.001, 0.006, d);
900
+ color = mix(color, rightBase, rightCut);
901
+
902
+ float effectX = (d * 1257.0 + 260.0) / 320.0;
903
+ float atlasPhase = fract(t / 12.0) * u_effectFrames;
904
+ float atlasFrameA = floor(atlasPhase);
905
+ float atlasFrameB = mod(atlasFrameA + 1.0, u_effectFrames);
906
+ float atlasMix = smoothstep(0.0, 1.0, fract(atlasPhase));
907
+ float atlasXA = (atlasFrameA + clamp(effectX, 0.0, 1.0)) / u_effectFrames;
908
+ float atlasXB = (atlasFrameB + clamp(effectX, 0.0, 1.0)) / u_effectFrames;
909
+ vec3 referenceA = texture(u_effect, vec2(atlasXA, uv.y)).rgb;
910
+ vec3 referenceB = texture(u_effect, vec2(atlasXB, uv.y)).rgb;
911
+ vec3 referenceColor = mix(referenceA, referenceB, atlasMix);
912
+ float stripMask = smoothstep(0.0, 0.018, effectX) * (1.0 - smoothstep(0.982, 1.0, effectX));
913
+ float referenceLeft = 1.0 - smoothstep(-0.026, -0.012, d);
914
+ // 参考图集只提供亮度结构,颜色始终由用户 colors(u_dark / u_accentA /
915
+ // u_accentB / u_glow)决定:这样 setColors / colors 属性在 WebGL 路径下
916
+ // 真实生效,改色有可见反馈,而不是被图集整体覆盖。
917
+ float referenceLuma = dot(referenceColor, vec3(0.299, 0.587, 0.114));
918
+ vec3 tintedColor = color * (0.42 + 0.86 * referenceLuma);
919
+ color = mix(color, tintedColor, stripMask * referenceLeft * u_hasEffect);
920
+
921
+ outColor = vec4(clamp(color, 0.0, 1.0), 1.0);
922
+ }`;
923
+
924
+ function compileShader(gl, type, source) {
925
+ const shader = gl.createShader(type);
926
+ gl.shaderSource(shader, source);
927
+ gl.compileShader(shader);
928
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
929
+ const message = gl.getShaderInfoLog(shader) || 'Unknown shader compile error';
930
+ gl.deleteShader(shader);
931
+ throw new Error(message);
932
+ }
933
+ return shader;
934
+ }
935
+
936
+ function createProgram(gl) {
937
+ const vertex = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
938
+ const fragment = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
939
+ const program = gl.createProgram();
940
+ gl.attachShader(program, vertex);
941
+ gl.attachShader(program, fragment);
942
+ gl.linkProgram(program);
943
+ gl.deleteShader(vertex);
944
+ gl.deleteShader(fragment);
945
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
946
+ const message = gl.getProgramInfoLog(program) || 'Unknown shader link error';
947
+ gl.deleteProgram(program);
948
+ throw new Error(message);
949
+ }
950
+ return program;
951
+ }
952
+
953
+ class ProgressFlowRenderer {
954
+ constructor(canvas, preset, options = {}) {
955
+ const gl = canvas.getContext('webgl2', {
956
+ alpha: false,
957
+ antialias: true,
958
+ premultipliedAlpha: false,
959
+ powerPreference: options.powerPreference || 'high-performance'
960
+ });
961
+ if (!gl) throw new Error('WebGL2 unavailable');
962
+
963
+ this.canvas = canvas;
964
+ this.options = options;
965
+ this.gl = gl;
966
+ this.profile = preset.edgeStyle === 'tide' ? 3 : (PROFILE_INDEX[preset.id] ?? 2);
967
+ this.seed = stringSeed$1(`${preset.id}-shader`) * 13.7 + 1.0;
968
+ this.colors = preset.colors.map(hexToRgb01);
969
+ this.motionData = getProgressMotionData(preset.id, preset.edgeStyle);
970
+ const motionFactor = preset.edgeStyle === 'tide'
971
+ ? MOTION_SCALE_FACTORS.tide
972
+ : (MOTION_SCALE_FACTORS[preset.id] || 1.04);
973
+ this.motionScale = (PROGRESS_MOTION_MAX_PX * motionFactor) / 1257;
974
+ this.disposed = false;
975
+ this.contextLost = false;
976
+ this.setupResources();
977
+ this.bindContextEvents();
978
+ }
979
+
980
+ setupResources() {
981
+ const gl = this.gl;
982
+ this.program = createProgram(gl);
983
+
984
+ this.position = gl.getAttribLocation(this.program, 'a_position');
985
+ this.uniforms = {
986
+ resolution: gl.getUniformLocation(this.program, 'u_resolution'),
987
+ time: gl.getUniformLocation(this.program, 'u_time'),
988
+ progress: gl.getUniformLocation(this.program, 'u_progress'),
989
+ seed: gl.getUniformLocation(this.program, 'u_seed'),
990
+ profile: gl.getUniformLocation(this.program, 'u_profile'),
991
+ motion: gl.getUniformLocation(this.program, 'u_motion'),
992
+ effect: gl.getUniformLocation(this.program, 'u_effect'),
993
+ hasEffect: gl.getUniformLocation(this.program, 'u_hasEffect'),
994
+ effectFrames: gl.getUniformLocation(this.program, 'u_effectFrames'),
995
+ motionDuration: gl.getUniformLocation(this.program, 'u_motionDuration'),
996
+ motionScale: gl.getUniformLocation(this.program, 'u_motionScale'),
997
+ dark: gl.getUniformLocation(this.program, 'u_dark'),
998
+ accentA: gl.getUniformLocation(this.program, 'u_accentA'),
999
+ accentB: gl.getUniformLocation(this.program, 'u_accentB'),
1000
+ glow: gl.getUniformLocation(this.program, 'u_glow')
1001
+ };
1002
+
1003
+ this.buffer = gl.createBuffer();
1004
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
1005
+ gl.bufferData(
1006
+ gl.ARRAY_BUFFER,
1007
+ new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
1008
+ gl.STATIC_DRAW
1009
+ );
1010
+
1011
+ this.motionTexture = gl.createTexture();
1012
+ gl.activeTexture(gl.TEXTURE0);
1013
+ gl.bindTexture(gl.TEXTURE_2D, this.motionTexture);
1014
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
1015
+ gl.texImage2D(
1016
+ gl.TEXTURE_2D,
1017
+ 0,
1018
+ gl.R8,
1019
+ PROGRESS_MOTION_WIDTH,
1020
+ PROGRESS_MOTION_HEIGHT,
1021
+ 0,
1022
+ gl.RED,
1023
+ gl.UNSIGNED_BYTE,
1024
+ this.motionData
1025
+ );
1026
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
1027
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
1028
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
1029
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1030
+
1031
+ this.effectTexture = gl.createTexture();
1032
+ gl.activeTexture(gl.TEXTURE1);
1033
+ gl.bindTexture(gl.TEXTURE_2D, this.effectTexture);
1034
+ gl.texImage2D(
1035
+ gl.TEXTURE_2D,
1036
+ 0,
1037
+ gl.RGB,
1038
+ 1,
1039
+ 1,
1040
+ 0,
1041
+ gl.RGB,
1042
+ gl.UNSIGNED_BYTE,
1043
+ new Uint8Array([32, 33, 38])
1044
+ );
1045
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
1046
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
1047
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1048
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1049
+ this.effectUploaded = false;
1050
+ }
1051
+
1052
+ bindContextEvents() {
1053
+ this.onContextLost = (event) => {
1054
+ event.preventDefault();
1055
+ if (this.disposed) return;
1056
+ this.contextLost = true;
1057
+ if (typeof this.options.onContextLost === 'function') this.options.onContextLost(event);
1058
+ };
1059
+ this.onContextRestored = () => {
1060
+ if (this.disposed) return;
1061
+ this.setupResources();
1062
+ this.contextLost = false;
1063
+ this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
1064
+ if (typeof this.options.onContextRestored === 'function') this.options.onContextRestored();
1065
+ };
1066
+ this.canvas.addEventListener('webglcontextlost', this.onContextLost, false);
1067
+ this.canvas.addEventListener('webglcontextrestored', this.onContextRestored, false);
1068
+ }
1069
+
1070
+ resize(width, height, dpr) {
1071
+ const pixelWidth = Math.max(1, Math.round(width * dpr));
1072
+ const pixelHeight = Math.max(1, Math.round(height * dpr));
1073
+ if (this.canvas.width !== pixelWidth || this.canvas.height !== pixelHeight) {
1074
+ this.canvas.width = pixelWidth;
1075
+ this.canvas.height = pixelHeight;
1076
+ }
1077
+ this.gl.viewport(0, 0, pixelWidth, pixelHeight);
1078
+ }
1079
+
1080
+ draw(time, progress, effectImage = null) {
1081
+ if (this.disposed || this.contextLost) return;
1082
+ const gl = this.gl;
1083
+ gl.useProgram(this.program);
1084
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
1085
+ gl.enableVertexAttribArray(this.position);
1086
+ gl.vertexAttribPointer(this.position, 2, gl.FLOAT, false, 0, 0);
1087
+
1088
+ gl.uniform2f(this.uniforms.resolution, this.canvas.width, this.canvas.height);
1089
+ gl.uniform1f(this.uniforms.time, time);
1090
+ gl.uniform1f(this.uniforms.progress, progress / 100);
1091
+ gl.uniform1f(this.uniforms.seed, this.seed);
1092
+ gl.uniform1f(this.uniforms.profile, this.profile);
1093
+ gl.activeTexture(gl.TEXTURE0);
1094
+ gl.bindTexture(gl.TEXTURE_2D, this.motionTexture);
1095
+ gl.uniform1i(this.uniforms.motion, 0);
1096
+ gl.uniform1f(this.uniforms.motionDuration, PROGRESS_MOTION_DURATION);
1097
+ gl.uniform1f(this.uniforms.motionScale, this.motionScale);
1098
+
1099
+ let hasEffect = this.effectUploaded ? 1 : 0;
1100
+ const effectWidth = effectImage && (effectImage.naturalWidth || effectImage.width || 0);
1101
+ if (!this.effectUploaded && effectImage && effectWidth > 0) {
1102
+ gl.activeTexture(gl.TEXTURE1);
1103
+ gl.bindTexture(gl.TEXTURE_2D, this.effectTexture);
1104
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
1105
+ try {
1106
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, effectImage);
1107
+ this.effectUploaded = true;
1108
+ hasEffect = 1;
1109
+ } catch (error) {
1110
+ console.warn('[画境观屿] 参考纹理图集上传失败,继续使用程序化降级。', error);
1111
+ }
1112
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
1113
+ }
1114
+ gl.activeTexture(gl.TEXTURE1);
1115
+ gl.bindTexture(gl.TEXTURE_2D, this.effectTexture);
1116
+ gl.uniform1i(this.uniforms.effect, 1);
1117
+ gl.uniform1f(this.uniforms.hasEffect, hasEffect);
1118
+ gl.uniform1f(this.uniforms.effectFrames, 24);
1119
+
1120
+ gl.uniform3fv(this.uniforms.dark, this.colors[0]);
1121
+ gl.uniform3fv(this.uniforms.accentA, this.colors[1]);
1122
+ gl.uniform3fv(this.uniforms.accentB, this.colors[2]);
1123
+ gl.uniform3fv(this.uniforms.glow, this.colors[3]);
1124
+
1125
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
1126
+ }
1127
+
1128
+ setColors(colors) {
1129
+ this.colors = colors.map(hexToRgb01);
1130
+ }
1131
+
1132
+ dispose() {
1133
+ this.disposed = true;
1134
+ const gl = this.gl;
1135
+ this.canvas.removeEventListener('webglcontextlost', this.onContextLost, false);
1136
+ this.canvas.removeEventListener('webglcontextrestored', this.onContextRestored, false);
1137
+ gl.deleteBuffer(this.buffer);
1138
+ gl.deleteTexture(this.motionTexture);
1139
+ gl.deleteTexture(this.effectTexture);
1140
+ gl.deleteProgram(this.program);
1141
+ const lose = gl.getExtension('WEBGL_lose_context');
1142
+ if (lose) lose.loseContext();
1143
+ }
1144
+ }
1145
+
1146
+ function createProgressFlowRenderer(canvas, preset, options = {}) {
1147
+ try {
1148
+ return new ProgressFlowRenderer(canvas, preset, options);
1149
+ } catch (error) {
1150
+ console.warn('[画境观屿] 进度流体 WebGL2 不可用,使用 Canvas 2D 降级。', error);
1151
+ return null;
1152
+ }
1153
+ }
1154
+
1155
+ const PROGRESS_REFERENCE_DURATION = 12;
1156
+ const PROGRESS_REFERENCE_FRAME_COUNT = 24;
1157
+
1158
+ const FRAME_WIDTH = 64;
1159
+ const FRAME_HEIGHT = 32;
1160
+ const CACHE = Object.create(null);
1161
+
1162
+ const PALETTES = {
1163
+ 'model-training': ['#20131f', '#ff3f94', '#ff8a3d', '#fff06a'],
1164
+ 'agent-migration': ['#111a31', '#245bff', '#00cfff', '#5dffe6'],
1165
+ 'visual-training': ['#1f172b', '#7042ff', '#42f58d', '#c4ff8a'],
1166
+ 'tide': ['#0a2239', '#2e9bff', '#7fe3ff', '#eaf9ff'],
1167
+ 'botanic-lab': ['#102a23', '#287a58', '#9bd65b', '#f2ffc4']
1168
+ };
1169
+
1170
+ function drawCloud(context, x, y, radiusX, radiusY, color, alpha) {
1171
+ context.save();
1172
+ context.translate(x, y);
1173
+ context.scale(1, radiusY / radiusX);
1174
+ const gradient = context.createRadialGradient(0, 0, 0, 0, 0, radiusX);
1175
+ const alphaHex = (value) => {
1176
+ const text = Math.round(value).toString(16);
1177
+ return text.length === 1 ? `0${text}` : text;
1178
+ };
1179
+ gradient.addColorStop(0, `${color}${alphaHex(alpha * 255)}`);
1180
+ gradient.addColorStop(0.46, `${color}${alphaHex(alpha * 0.42 * 255)}`);
1181
+ gradient.addColorStop(1, `${color}00`);
1182
+ context.fillStyle = gradient;
1183
+ context.fillRect(-radiusX, -radiusX, radiusX * 2, radiusX * 2);
1184
+ context.restore();
1185
+ }
1186
+
1187
+ function createAtlas(id) {
1188
+ const palette = PALETTES[id] || PALETTES['visual-training'];
1189
+ const canvas = document.createElement('canvas');
1190
+ canvas.width = FRAME_WIDTH * PROGRESS_REFERENCE_FRAME_COUNT;
1191
+ canvas.height = FRAME_HEIGHT;
1192
+ const context = canvas.getContext('2d');
1193
+
1194
+ for (let frame = 0; frame < PROGRESS_REFERENCE_FRAME_COUNT; frame += 1) {
1195
+ const phase = (frame / PROGRESS_REFERENCE_FRAME_COUNT) * Math.PI * 2;
1196
+ const left = frame * FRAME_WIDTH;
1197
+ context.save();
1198
+ context.translate(left, 0);
1199
+ context.fillStyle = palette[0];
1200
+ context.fillRect(0, 0, FRAME_WIDTH, FRAME_HEIGHT);
1201
+ context.globalCompositeOperation = 'screen';
1202
+
1203
+ 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);
1204
+ drawCloud(context, 45 + Math.cos(phase * 0.72) * 4, 23 + Math.sin(phase * 0.54) * 4, 22, 15, palette[2], 0.44);
1205
+ 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);
1206
+
1207
+ context.globalCompositeOperation = 'source-over';
1208
+ const trough = context.createRadialGradient(41, 16, 1, 41, 16, 17);
1209
+ trough.addColorStop(0, 'rgba(5,6,11,0.64)');
1210
+ trough.addColorStop(0.58, 'rgba(6,7,12,0.26)');
1211
+ trough.addColorStop(1, 'rgba(6,7,12,0)');
1212
+ context.fillStyle = trough;
1213
+ context.fillRect(20, 0, 44, FRAME_HEIGHT);
1214
+ context.restore();
1215
+ }
1216
+
1217
+ // A canvas is a valid TexImageSource. Keeping it directly avoids a PNG
1218
+ // encode -> base64 allocation -> Image decode round trip at startup.
1219
+ return { image: canvas, ready: true };
1220
+ }
1221
+
1222
+ function getProgressReferenceAtlas(id) {
1223
+ if (!CACHE[id]) CACHE[id] = createAtlas(id);
1224
+ return CACHE[id];
1225
+ }
1226
+
1227
+ /**
1228
+ * Attach a WebGL2 fluid overlay to a progress capsule root.
1229
+ *
1230
+ * @param {object} params
1231
+ * @param {HTMLElement} params.root progress capsule root element
1232
+ * @param {HTMLCanvasElement} params.canvas 2D fallback canvas (kept beneath)
1233
+ * @param {object} params.preset progress preset
1234
+ * @param {() => number} params.getProgress reads the current progress value
1235
+ * @returns {{ update(flowTime: number): void, setDprCap(cap: number): void, dispose(): void } | null}
1236
+ */
1237
+ function attachProgressFlowOverlay({
1238
+ root,
1239
+ canvas,
1240
+ preset,
1241
+ getProgress,
1242
+ dprCap = 2,
1243
+ powerPreference = 'high-performance',
1244
+ onContextLost,
1245
+ onContextRestored
1246
+ }) {
1247
+ const overlay = document.createElement('canvas');
1248
+ overlay.className = 'hj-progress-canvas hj-progress-overlay';
1249
+ overlay.setAttribute('aria-hidden', 'true');
1250
+ canvas.insertAdjacentElement('afterend', overlay);
1251
+
1252
+ const renderer = createProgressFlowRenderer(overlay, preset, {
1253
+ powerPreference,
1254
+ onContextLost,
1255
+ onContextRestored
1256
+ });
1257
+ if (!renderer) {
1258
+ overlay.remove();
1259
+ return null;
1260
+ }
1261
+
1262
+ const atlas = getProgressReferenceAtlas(preset.id);
1263
+ root.classList.add('has-webgl-progress');
1264
+
1265
+ const resize = () => {
1266
+ const bounds = root.getBoundingClientRect();
1267
+ const width = Math.max(1, bounds.width);
1268
+ const height = Math.max(1, bounds.height);
1269
+ const dpr = Math.min(window.devicePixelRatio || 1, dprCap);
1270
+ overlay.style.width = `${width}px`;
1271
+ overlay.style.height = `${height}px`;
1272
+ renderer.resize(width, height, dpr);
1273
+ };
1274
+
1275
+ let resizeObserver = null;
1276
+ let onWindowResize = null;
1277
+ if (typeof ResizeObserver !== 'undefined') {
1278
+ resizeObserver = new ResizeObserver(resize);
1279
+ resizeObserver.observe(root);
1280
+ } else {
1281
+ onWindowResize = resize;
1282
+ window.addEventListener('resize', onWindowResize);
1283
+ }
1284
+ resize();
1285
+
1286
+ return {
1287
+ update(flowTime) {
1288
+ const effectTime = flowTime % PROGRESS_REFERENCE_DURATION;
1289
+ const progress = getProgress();
1290
+ renderer.draw(
1291
+ effectTime,
1292
+ Number.isFinite(progress) ? progress : preset.initialProgress,
1293
+ atlas.ready ? atlas.image : null
1294
+ );
1295
+ },
1296
+ setColors(colors) {
1297
+ renderer.setColors(colors);
1298
+ },
1299
+ setDprCap(cap) {
1300
+ dprCap = cap;
1301
+ resize();
1302
+ },
1303
+ dispose() {
1304
+ if (resizeObserver) resizeObserver.disconnect();
1305
+ if (onWindowResize) window.removeEventListener('resize', onWindowResize);
1306
+ renderer.dispose();
1307
+ overlay.remove();
1308
+ root.classList.remove('has-webgl-progress');
1309
+ }
1310
+ };
1206
1311
  }
1207
1312
 
1208
1313
  /**
@@ -1221,1228 +1326,1228 @@ function nextTick(fn) {
1221
1326
  }
1222
1327
  }
1223
1328
 
1224
- function toFiniteNumber(value) {
1225
- if (value == null || typeof value === 'boolean') return null;
1226
- if (typeof value === 'string' && value.trim() === '') return null;
1227
- const number = Number(value);
1228
- return Number.isFinite(number) ? number : null;
1329
+ function toFiniteNumber(value) {
1330
+ if (value == null || typeof value === 'boolean') return null;
1331
+ if (typeof value === 'string' && value.trim() === '') return null;
1332
+ const number = Number(value);
1333
+ return Number.isFinite(number) ? number : null;
1334
+ }
1335
+
1336
+ function normalizeRange(min, max, fallbackMin = 0, fallbackMax = 100) {
1337
+ let nextMin = toFiniteNumber(min);
1338
+ let nextMax = toFiniteNumber(max);
1339
+ if (nextMin === null) nextMin = fallbackMin;
1340
+ if (nextMax === null) nextMax = fallbackMax;
1341
+ if (nextMin > nextMax) {
1342
+ const swap = nextMin;
1343
+ nextMin = nextMax;
1344
+ nextMax = swap;
1345
+ }
1346
+ return [nextMin, nextMax];
1347
+ }
1348
+
1349
+ function clampProgressValue(value, min, max) {
1350
+ const number = toFiniteNumber(value);
1351
+ if (number === null) return null;
1352
+ return Math.min(Math.max(number, min), max);
1353
+ }
1354
+
1355
+ function normalizeProgressStep(step, fallback = 'any') {
1356
+ if (step === 'any' || step == null) return 'any';
1357
+ const increment = toFiniteNumber(step);
1358
+ return increment !== null && increment > 0 ? increment : fallback;
1359
+ }
1360
+
1361
+ function snapProgressValue(value, min, max, step = 'any') {
1362
+ const clamped = clampProgressValue(value, min, max);
1363
+ if (clamped === null) return null;
1364
+ if (step === 'any' || step == null) return clamped;
1365
+ const increment = normalizeProgressStep(step);
1366
+ if (increment === 'any') return clamped;
1367
+ const snapped = min + Math.round((clamped - min) / increment) * increment;
1368
+ return Math.min(Math.max(Number(snapped.toFixed(12)), min), max);
1369
+ }
1370
+
1371
+ function progressRatio(value, min, max) {
1372
+ const range = max - min;
1373
+ if (!Number.isFinite(range) || range <= 0) return 0;
1374
+ return Math.min(Math.max((value - min) / range, 0), 1);
1375
+ }
1376
+
1377
+ function normalizeFps(value, fallback = 60) {
1378
+ const fps = Number(value);
1379
+ if (!Number.isFinite(fps)) return fallback;
1380
+ return Math.min(60, Math.max(1, fps));
1381
+ }
1382
+
1383
+ function createFrameGate(initialFps = 60) {
1384
+ let fps = normalizeFps(initialFps);
1385
+ let elapsed = 0;
1386
+ return {
1387
+ shouldDraw(delta) {
1388
+ elapsed += delta;
1389
+ const interval = 1 / fps;
1390
+ if (elapsed + 0.0001 < interval) return false;
1391
+ elapsed %= interval;
1392
+ return true;
1393
+ },
1394
+ setFps(value) {
1395
+ fps = normalizeFps(value, fps);
1396
+ elapsed = 0;
1397
+ return fps;
1398
+ },
1399
+ getFps() {
1400
+ return fps;
1401
+ }
1402
+ };
1403
+ }
1404
+
1405
+ function createReducedMotionPreference(enabled, onChange) {
1406
+ const query = enabled && typeof matchMedia !== 'undefined'
1407
+ ? matchMedia('(prefers-reduced-motion: reduce)')
1408
+ : null;
1409
+ const notify = () => {
1410
+ if (typeof onChange === 'function') onChange(Boolean(query && query.matches));
1411
+ };
1412
+ if (query) {
1413
+ if (typeof query.addEventListener === 'function') query.addEventListener('change', notify);
1414
+ else if (typeof query.addListener === 'function') query.addListener(notify);
1415
+ }
1416
+ return {
1417
+ matches() {
1418
+ return Boolean(query && query.matches);
1419
+ },
1420
+ dispose() {
1421
+ if (!query) return;
1422
+ if (typeof query.removeEventListener === 'function') query.removeEventListener('change', notify);
1423
+ else if (typeof query.removeListener === 'function') query.removeListener(notify);
1424
+ }
1425
+ };
1426
+ }
1427
+
1428
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
1429
+ const SUPPORTS_CTX_FILTER = typeof CanvasRenderingContext2D !== 'undefined' && 'filter' in CanvasRenderingContext2D.prototype;
1430
+
1431
+ function stringSeed(value) {
1432
+ let hash = 2166136261;
1433
+ for (const character of value) {
1434
+ hash ^= character.charCodeAt(0);
1435
+ hash = Math.imul(hash, 16777619);
1436
+ }
1437
+ return (hash >>> 0) / 4294967295;
1229
1438
  }
1230
1439
 
1231
- function normalizeRange(min, max, fallbackMin = 0, fallbackMax = 100) {
1232
- let nextMin = toFiniteNumber(min);
1233
- let nextMax = toFiniteNumber(max);
1234
- if (nextMin === null) nextMin = fallbackMin;
1235
- if (nextMax === null) nextMax = fallbackMax;
1236
- if (nextMin > nextMax) {
1237
- const swap = nextMin;
1238
- nextMin = nextMax;
1239
- nextMax = swap;
1240
- }
1241
- return [nextMin, nextMax];
1242
- }
1243
-
1244
- function clampProgressValue(value, min, max) {
1245
- const number = toFiniteNumber(value);
1246
- if (number === null) return null;
1247
- return Math.min(Math.max(number, min), max);
1248
- }
1249
-
1250
- function normalizeProgressStep(step, fallback = 'any') {
1251
- if (step === 'any' || step == null) return 'any';
1252
- const increment = toFiniteNumber(step);
1253
- return increment !== null && increment > 0 ? increment : fallback;
1254
- }
1255
-
1256
- function snapProgressValue(value, min, max, step = 'any') {
1257
- const clamped = clampProgressValue(value, min, max);
1258
- if (clamped === null) return null;
1259
- if (step === 'any' || step == null) return clamped;
1260
- const increment = normalizeProgressStep(step);
1261
- if (increment === 'any') return clamped;
1262
- const snapped = min + Math.round((clamped - min) / increment) * increment;
1263
- return Math.min(Math.max(Number(snapped.toFixed(12)), min), max);
1264
- }
1265
-
1266
- function progressRatio(value, min, max) {
1267
- const range = max - min;
1268
- if (!Number.isFinite(range) || range <= 0) return 0;
1269
- return Math.min(Math.max((value - min) / range, 0), 1);
1440
+ function interpolate(template, values) {
1441
+ return template.replace(/\{(\w+)\}/g, (_, key) => (values[key] !== undefined ? values[key] : ''));
1270
1442
  }
1271
1443
 
1272
- function normalizeFps(value, fallback = 60) {
1273
- const fps = Number(value);
1274
- if (!Number.isFinite(fps)) return fallback;
1275
- return Math.min(60, Math.max(1, fps));
1276
- }
1277
-
1278
- function createFrameGate(initialFps = 60) {
1279
- let fps = normalizeFps(initialFps);
1280
- let elapsed = 0;
1281
- return {
1282
- shouldDraw(delta) {
1283
- elapsed += delta;
1284
- const interval = 1 / fps;
1285
- if (elapsed + 0.0001 < interval) return false;
1286
- elapsed %= interval;
1287
- return true;
1288
- },
1289
- setFps(value) {
1290
- fps = normalizeFps(value, fps);
1291
- elapsed = 0;
1292
- return fps;
1293
- },
1294
- getFps() {
1295
- return fps;
1296
- }
1297
- };
1298
- }
1299
-
1300
- function createReducedMotionPreference(enabled, onChange) {
1301
- const query = enabled && typeof matchMedia !== 'undefined'
1302
- ? matchMedia('(prefers-reduced-motion: reduce)')
1303
- : null;
1304
- const notify = () => {
1305
- if (typeof onChange === 'function') onChange(Boolean(query && query.matches));
1306
- };
1307
- if (query) {
1308
- if (typeof query.addEventListener === 'function') query.addEventListener('change', notify);
1309
- else if (typeof query.addListener === 'function') query.addListener(notify);
1310
- }
1311
- return {
1312
- matches() {
1313
- return Boolean(query && query.matches);
1314
- },
1315
- dispose() {
1316
- if (!query) return;
1317
- if (typeof query.removeEventListener === 'function') query.removeEventListener('change', notify);
1318
- else if (typeof query.removeListener === 'function') query.removeListener(notify);
1319
- }
1320
- };
1444
+ const FLOW_PROFILES = {
1445
+ 'model-training': {
1446
+ cycles: [0.98, 3.05, 5.45],
1447
+ amplitudes: [11.2, 11.0, 5.2],
1448
+ speeds: [0.31, -0.53, 0.78],
1449
+ bulgeAmplitude: 8.2,
1450
+ timeScale: 1.0,
1451
+ glowWidth: 5.8,
1452
+ haloWidth: 20,
1453
+ whiteAlpha: 0.72,
1454
+ whiteWidth: 1.15,
1455
+ cloudWidth: 0.17,
1456
+ autoRange: [25, 66]
1457
+ },
1458
+ 'agent-migration': {
1459
+ cycles: [0.62, 1.55, 2.95],
1460
+ amplitudes: [16.0, 7.8, 2.3],
1461
+ speeds: [0.25, -0.4, 0.60],
1462
+ bulgeAmplitude: 8.4,
1463
+ timeScale: 0.82,
1464
+ glowWidth: 6.3,
1465
+ haloWidth: 21,
1466
+ whiteAlpha: 0.18,
1467
+ whiteWidth: 0.45,
1468
+ cloudWidth: 0.18,
1469
+ autoRange: [24, 62]
1470
+ },
1471
+ 'visual-training': {
1472
+ cycles: [0.88, 2.45, 4.35],
1473
+ amplitudes: [12.8, 10.2, 4.3],
1474
+ speeds: [0.28, -0.47, 0.69],
1475
+ bulgeAmplitude: 7.3,
1476
+ timeScale: 0.91,
1477
+ glowWidth: 6.0,
1478
+ haloWidth: 21,
1479
+ whiteAlpha: 0.30,
1480
+ whiteWidth: 0.55,
1481
+ cloudWidth: 0.175,
1482
+ autoRange: [20, 75]
1483
+ },
1484
+ // ponytail: first-pass tide = asymmetric surge on the 2D fallback path.
1485
+ // Tune surge/amplitudes after visual QA against the WebGL overlay.
1486
+ 'tide': {
1487
+ cycles: [0.72, 1.9, 4.2],
1488
+ amplitudes: [19.0, 6.5, 1.5],
1489
+ speeds: [0.42, -0.5, 0.66],
1490
+ bulgeAmplitude: 9.5,
1491
+ timeScale: 0.85,
1492
+ glowWidth: 6.2,
1493
+ haloWidth: 21,
1494
+ whiteAlpha: 0.5,
1495
+ whiteWidth: 1.0,
1496
+ cloudWidth: 0.18,
1497
+ autoRange: [18, 70],
1498
+ surge: 0.55
1499
+ }
1500
+ };
1501
+
1502
+ class ProgressCapsuleController {
1503
+ constructor({ root, canvas, valueElement, preset, emitter, options, copy, dirty }) {
1504
+ this.root = root;
1505
+ this.canvas = canvas;
1506
+ this.valueElement = valueElement;
1507
+ this.preset = preset;
1508
+ this.emitter = emitter;
1509
+ this.options = options;
1510
+ this.copy = copy;
1511
+ this.dirty = dirty;
1512
+ this.valueSuffix = options.valueSuffix ?? copy.valueSuffix;
1513
+ this.profile = preset.edgeStyle === 'tide'
1514
+ ? FLOW_PROFILES.tide
1515
+ : (FLOW_PROFILES[preset.id] || FLOW_PROFILES['visual-training']);
1516
+ [this.min, this.max] = normalizeRange(options.min, options.max);
1517
+ this.step = normalizeProgressStep(options.step);
1518
+ this.precision = Number.isInteger(Number(options.precision))
1519
+ ? clamp(Number(options.precision), 0, 20)
1520
+ : 0;
1521
+ this.formatValue = typeof options.formatValue === 'function' ? options.formatValue : null;
1522
+ this.direction = options.direction === 'rtl' ? 'rtl' : 'ltr';
1523
+ this.dprCap = effectiveDprCap(options.quality, options.renderScale);
1524
+ this.ctx = canvas.getContext('2d');
1525
+ this.value = snapProgressValue(options.value ?? preset.initialProgress, this.min, this.max, this.step);
1526
+ if (this.value === null) this.value = clamp(preset.initialProgress, this.min, this.max);
1527
+ this.dragging = false;
1528
+ this.webglActive = false;
1529
+ this.flowTime = stringSeed(preset.id) * 31;
1530
+ this.seed = stringSeed(`${preset.id}-reference`) * Math.PI * 2;
1531
+ this.randomState = Math.floor(stringSeed(`${preset.id}-auto`) * 0x7fffffff) || 1;
1532
+ this.dpr = 1;
1533
+ this.width = 0;
1534
+ this.height = 0;
1535
+ this.handlers = {};
1536
+
1537
+ this.onResize = () => {
1538
+ this.resizeCanvas();
1539
+ if (typeof this.options.onResize === 'function') this.options.onResize();
1540
+ };
1541
+ this.resizeObserver = typeof ResizeObserver !== 'undefined'
1542
+ ? new ResizeObserver(this.onResize)
1543
+ : null;
1544
+ if (this.resizeObserver) this.resizeObserver.observe(this.root);
1545
+ else window.addEventListener('resize', this.onResize);
1546
+
1547
+ this.suppressEvents = true;
1548
+ this.setProgress(this.value, 'init');
1549
+ this.suppressEvents = false;
1550
+ this.bindEvents();
1551
+ this.resizeCanvas();
1552
+ }
1553
+
1554
+ random() {
1555
+ this.randomState = (Math.imul(this.randomState, 1664525) + 1013904223) >>> 0;
1556
+ return this.randomState / 4294967296;
1557
+ }
1558
+
1559
+ displayText() {
1560
+ if (this.formatValue) {
1561
+ return String(this.formatValue(this.value, {
1562
+ min: this.min,
1563
+ max: this.max,
1564
+ suffix: this.valueSuffix
1565
+ }));
1566
+ }
1567
+ const number = this.precision > 0 ? this.value.toFixed(this.precision) : String(Math.round(this.value));
1568
+ return `${number}${this.valueSuffix}`;
1569
+ }
1570
+
1571
+ syncValueDom() {
1572
+ const text = this.displayText();
1573
+ this.root.style.setProperty('--progress', this.value.toFixed(2));
1574
+ this.root.style.setProperty('--progress-ratio', progressRatio(this.value, this.min, this.max).toFixed(4));
1575
+ this.root.setAttribute('aria-valuenow', String(this.value));
1576
+ this.root.setAttribute('aria-valuetext', text);
1577
+ if (this.valueElement) this.valueElement.textContent = text;
1578
+ }
1579
+
1580
+ setProgress(nextValue, source = 'auto') {
1581
+ const next = snapProgressValue(nextValue, this.min, this.max, this.step);
1582
+ if (next === null) return false;
1583
+ this.value = next;
1584
+ this.syncValueDom();
1585
+ if (!this.suppressEvents) {
1586
+ if (this.dirty) this.dirty.value = true;
1587
+ this.emitter.emit('change', { value: this.value, source });
1588
+ }
1589
+ return true;
1590
+ }
1591
+
1592
+ setValueSuffix(suffix) {
1593
+ if (this.dirty) this.dirty.valueSuffix = true;
1594
+ this.valueSuffix = String(suffix == null ? '' : suffix);
1595
+ this.syncValueDom();
1596
+ return this;
1597
+ }
1598
+
1599
+ setStep(step) {
1600
+ const nextStep = normalizeProgressStep(step, this.step);
1601
+ if (nextStep === this.step) return this;
1602
+ if (this.dirty) this.dirty.step = true;
1603
+ this.step = nextStep;
1604
+ const next = snapProgressValue(this.value, this.min, this.max, this.step);
1605
+ if (next !== null && next !== this.value) this.setProgress(next, 'prop');
1606
+ return this;
1607
+ }
1608
+
1609
+ setPrecision(precision) {
1610
+ const value = Number(precision);
1611
+ if (!Number.isInteger(value) || value < 0 || value > 20) return this;
1612
+ if (this.dirty) this.dirty.precision = true;
1613
+ this.precision = value;
1614
+ this.syncValueDom();
1615
+ return this;
1616
+ }
1617
+
1618
+ setFormatValue(formatter) {
1619
+ if (formatter != null && typeof formatter !== 'function') return this;
1620
+ if (this.dirty) this.dirty.formatValue = true;
1621
+ this.formatValue = formatter || null;
1622
+ this.syncValueDom();
1623
+ return this;
1624
+ }
1625
+
1626
+ setDirection(direction) {
1627
+ if (this.dirty) this.dirty.direction = true;
1628
+ this.direction = direction === 'rtl' ? 'rtl' : 'ltr';
1629
+ this.root.dataset.direction = this.direction;
1630
+ return this;
1631
+ }
1632
+
1633
+ setShowValue(show) {
1634
+ if (this.dirty) this.dirty.showValue = true;
1635
+ if (this.valueElement) this.valueElement.hidden = show === false;
1636
+ return this;
1637
+ }
1638
+
1639
+ resizeCanvas() {
1640
+ const bounds = this.root.getBoundingClientRect();
1641
+ this.dpr = Math.min(window.devicePixelRatio || 1, this.dprCap);
1642
+ this.width = Math.max(1, bounds.width);
1643
+ this.height = Math.max(1, bounds.height);
1644
+ this.canvas.width = Math.round(this.width * this.dpr);
1645
+ this.canvas.height = Math.round(this.height * this.dpr);
1646
+ this.canvas.style.width = `${this.width}px`;
1647
+ this.canvas.style.height = `${this.height}px`;
1648
+ this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
1649
+ }
1650
+
1651
+ edgeEnvelope(yRatio) {
1652
+ const edge = Math.sin(Math.PI * clamp(yRatio, 0, 1));
1653
+ return Math.pow(Math.max(edge, 0), 0.48);
1654
+ }
1655
+
1656
+ localBulge(yRatio, time, index) {
1657
+ const direction = index === 0 ? 1 : -1;
1658
+ const center = 0.28 + index * 0.40 + Math.sin(time * (0.19 + index * 0.035) + this.seed * (1.1 + index)) * 0.13;
1659
+ const width = 0.075 + index * 0.016 + Math.sin(time * 0.13 + this.seed * 2.1) * 0.012;
1660
+ const distance = (yRatio - center) / Math.max(width, 0.035);
1661
+ const gaussian = Math.exp(-0.5 * distance * distance);
1662
+ return gaussian * Math.sin(time * (0.71 + index * 0.09) + this.seed * (2.7 + index)) * this.profile.bulgeAmplitude * direction;
1663
+ }
1664
+
1665
+ edgeOffset(y, time, phase = 0, amplitudeScale = 1) {
1666
+ const yRatio = this.height > 0 ? y / this.height : 0;
1667
+ const envelope = this.edgeEnvelope(yRatio);
1668
+ const scaledTime = time * this.profile.timeScale;
1669
+ const phaseTime = this.profile.surge
1670
+ ? scaledTime + this.profile.surge * Math.sin(scaledTime * 2)
1671
+ : scaledTime;
1672
+ let offset = 0;
1673
+
1674
+ for (let index = 0; index < this.profile.cycles.length; index += 1) {
1675
+ const cycle = this.profile.cycles[index];
1676
+ const amplitude = this.profile.amplitudes[index];
1677
+ const speed = this.profile.speeds[index];
1678
+ const amplitudeMotion = 0.74 + 0.26 * Math.sin(
1679
+ phaseTime * (0.17 + index * 0.045) + this.seed * (index + 2.4)
1680
+ );
1681
+ offset += Math.sin(
1682
+ yRatio * Math.PI * 2 * cycle + phaseTime * speed * Math.PI * 2 + this.seed * (index + 1) + phase
1683
+ ) * amplitude * amplitudeMotion;
1684
+ }
1685
+
1686
+ offset += this.localBulge(yRatio, phaseTime + phase, 0);
1687
+ offset += this.localBulge(yRatio, phaseTime - phase * 0.7, 1);
1688
+ return offset * envelope * amplitudeScale;
1689
+ }
1690
+
1691
+ createEdgePath(ctx, baseX, time, phase = 0, amplitudeScale = 1) {
1692
+ const step = Math.max(1.8, this.height / 92);
1693
+ ctx.beginPath();
1694
+ for (let y = 0; y <= this.height + step; y += step) {
1695
+ const x = baseX + this.edgeOffset(y, time, phase, amplitudeScale);
1696
+ if (y === 0) ctx.moveTo(x, y);
1697
+ else ctx.lineTo(x, y);
1698
+ }
1699
+ }
1700
+
1701
+ createFillPath(ctx, baseX, time, phase = 0, amplitudeScale = 1) {
1702
+ const step = Math.max(1.8, this.height / 92);
1703
+ ctx.beginPath();
1704
+ ctx.moveTo(0, 0);
1705
+ ctx.lineTo(baseX + this.edgeOffset(0, time, phase, amplitudeScale), 0);
1706
+ for (let y = step; y <= this.height + step; y += step) {
1707
+ ctx.lineTo(baseX + this.edgeOffset(y, time, phase, amplitudeScale), y);
1708
+ }
1709
+ ctx.lineTo(0, this.height);
1710
+ ctx.closePath();
1711
+ }
1712
+
1713
+ drawEllipticalGlow(x, y, radiusX, radiusY, color, alpha) {
1714
+ const ctx = this.ctx;
1715
+ ctx.save();
1716
+ ctx.translate(x, y);
1717
+ ctx.scale(1, radiusY / radiusX);
1718
+ const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, radiusX);
1719
+ gradient.addColorStop(0, hexToRgba(color, alpha));
1720
+ gradient.addColorStop(0.42, hexToRgba(color, alpha * 0.48));
1721
+ gradient.addColorStop(1, hexToRgba(color, 0));
1722
+ ctx.globalCompositeOperation = 'screen';
1723
+ ctx.fillStyle = gradient;
1724
+ ctx.fillRect(-radiusX, -radiusX, radiusX * 2, radiusX * 2);
1725
+ ctx.restore();
1726
+ }
1727
+
1728
+ drawDarkEllipticalShadow(x, y, radiusX, radiusY, alpha) {
1729
+ const ctx = this.ctx;
1730
+ ctx.save();
1731
+ ctx.translate(x, y);
1732
+ ctx.scale(1, radiusY / radiusX);
1733
+ const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, radiusX);
1734
+ gradient.addColorStop(0, `rgba(6, 6, 11, ${alpha})`);
1735
+ gradient.addColorStop(0.54, `rgba(8, 8, 14, ${alpha * 0.62})`);
1736
+ gradient.addColorStop(1, 'rgba(8, 8, 14, 0)');
1737
+ ctx.globalCompositeOperation = 'source-over';
1738
+ ctx.fillStyle = gradient;
1739
+ ctx.fillRect(-radiusX, -radiusX, radiusX * 2, radiusX * 2);
1740
+ ctx.restore();
1741
+ }
1742
+
1743
+ drawColorClouds(shoreline, time, accentA, accentB, glow) {
1744
+ this.ctx;
1745
+ const width = this.width;
1746
+ const height = this.height;
1747
+ const scale = this.profile.cloudWidth;
1748
+ const t = time * this.profile.timeScale;
1749
+
1750
+ const upperY = height * (0.28 + Math.sin(t * 0.24 + this.seed) * 0.13);
1751
+ const lowerY = height * (0.70 + Math.cos(t * 0.21 + this.seed * 1.7) * 0.12);
1752
+ const middleY = height * (0.49 + Math.sin(t * 0.31 + this.seed * 2.3) * 0.15);
1753
+
1754
+ const farX = shoreline - width * 0.095;
1755
+ const farRx = Math.max(52, width * scale);
1756
+ const farRy = height * 0.42;
1757
+ this.drawEllipticalGlow(farX, upperY, farRx, farRy, accentA, 0.38);
1758
+ this.drawDarkEllipticalShadow(
1759
+ farX + farRx * 0.16,
1760
+ upperY,
1761
+ farRx * 0.58,
1762
+ farRy * 0.66,
1763
+ 0.74
1764
+ );
1765
+
1766
+ const lowerX = shoreline - width * 0.072;
1767
+ const lowerRx = Math.max(44, width * scale * 0.82);
1768
+ const lowerRy = height * 0.36;
1769
+ this.drawEllipticalGlow(lowerX, lowerY, lowerRx, lowerRy, accentB, 0.31);
1770
+ this.drawDarkEllipticalShadow(
1771
+ lowerX + lowerRx * 0.14,
1772
+ lowerY,
1773
+ lowerRx * 0.54,
1774
+ lowerRy * 0.62,
1775
+ 0.64
1776
+ );
1777
+
1778
+ this.drawEllipticalGlow(
1779
+ shoreline - width * 0.034,
1780
+ middleY,
1781
+ Math.max(30, width * scale * 0.48),
1782
+ height * 0.27,
1783
+ glow,
1784
+ 0.20
1785
+ );
1786
+ }
1787
+
1788
+ drawPathBand({ baseX, time, phase, amplitudeScale, color, alpha, blur, width, composite = 'screen' }) {
1789
+ const ctx = this.ctx;
1790
+ this.createEdgePath(ctx, baseX, time, phase, amplitudeScale);
1791
+ ctx.save();
1792
+ ctx.globalCompositeOperation = composite;
1793
+ ctx.globalAlpha = alpha;
1794
+ // Safari < 18 and some old WebViews ignore ctx.filter; setting it is a
1795
+ // no-op there, so only assign when supported to keep intent explicit.
1796
+ if (SUPPORTS_CTX_FILTER) ctx.filter = `blur(${blur}px)`;
1797
+ ctx.strokeStyle = color;
1798
+ ctx.lineWidth = width;
1799
+ ctx.stroke();
1800
+ ctx.restore();
1801
+ }
1802
+
1803
+ setRange(min, max) {
1804
+ const nextRange = normalizeRange(min, max, this.min, this.max);
1805
+ this.min = nextRange[0];
1806
+ this.max = nextRange[1];
1807
+ this.root.setAttribute('aria-valuemin', String(this.min));
1808
+ this.root.setAttribute('aria-valuemax', String(this.max));
1809
+ const next = snapProgressValue(this.value, this.min, this.max, this.step);
1810
+ if (next !== this.value) this.setProgress(next, 'prop');
1811
+ else this.syncValueDom();
1812
+ return this;
1813
+ }
1814
+
1815
+ drawReferenceFlow() {
1816
+ const ctx = this.ctx;
1817
+ const width = this.width;
1818
+ const height = this.height;
1819
+ if (!ctx || width <= 0 || height <= 0) return;
1820
+
1821
+ const range = Math.max(this.max - this.min, 1);
1822
+ const shoreline = width * Math.min(Math.max((this.value - this.min) / range, 0), 1);
1823
+ const time = this.flowTime;
1824
+ const [dark, accentA, accentB, glow] = this.preset.colors;
1825
+
1826
+ ctx.clearRect(0, 0, width, height);
1827
+ ctx.fillStyle = '#202126';
1828
+ ctx.fillRect(0, 0, width, height);
1829
+
1830
+ this.createFillPath(ctx, shoreline, time, 0, 1);
1831
+ const bodyGradient = ctx.createLinearGradient(0, 0, Math.max(shoreline, 1), 0);
1832
+ bodyGradient.addColorStop(0, dark);
1833
+ bodyGradient.addColorStop(0.74, dark);
1834
+ bodyGradient.addColorStop(0.89, hexToRgba(dark, 0.99));
1835
+ bodyGradient.addColorStop(0.955, hexToRgba(accentA, 0.09));
1836
+ bodyGradient.addColorStop(0.992, hexToRgba(accentB, 0.54));
1837
+ bodyGradient.addColorStop(1, hexToRgba(glow, 0.78));
1838
+ ctx.fillStyle = bodyGradient;
1839
+ ctx.fill();
1840
+
1841
+ this.drawColorClouds(shoreline, time, accentA, accentB, glow);
1842
+
1843
+ this.drawPathBand({
1844
+ baseX: shoreline - width * 0.105,
1845
+ time,
1846
+ phase: 1.42,
1847
+ amplitudeScale: 1.18,
1848
+ color: accentA,
1849
+ alpha: 0.22,
1850
+ blur: 21,
1851
+ width: 54
1852
+ });
1853
+ this.drawPathBand({
1854
+ baseX: shoreline - width * 0.073,
1855
+ time,
1856
+ phase: -0.92,
1857
+ amplitudeScale: 1.06,
1858
+ color: accentB,
1859
+ alpha: 0.30,
1860
+ blur: 15,
1861
+ width: 42
1862
+ });
1863
+ this.drawPathBand({
1864
+ baseX: shoreline - width * 0.047,
1865
+ time,
1866
+ phase: 0.42,
1867
+ amplitudeScale: 0.94,
1868
+ color: 'rgba(5, 5, 10, 0.92)',
1869
+ alpha: 0.72,
1870
+ blur: 12,
1871
+ width: 34,
1872
+ composite: 'source-over'
1873
+ });
1874
+ this.drawPathBand({
1875
+ baseX: shoreline - width * 0.025,
1876
+ time,
1877
+ phase: -0.28,
1878
+ amplitudeScale: 0.96,
1879
+ color: accentB,
1880
+ alpha: 0.66,
1881
+ blur: 8,
1882
+ width: 28
1883
+ });
1884
+
1885
+ ctx.save();
1886
+ this.createFillPath(ctx, shoreline, time, 0, 1);
1887
+ ctx.clip();
1888
+ this.createEdgePath(ctx, shoreline, time, 0, 1);
1889
+
1890
+ ctx.save();
1891
+ ctx.globalCompositeOperation = 'screen';
1892
+ ctx.strokeStyle = hexToRgba(accentA, 0.20);
1893
+ ctx.lineWidth = this.profile.haloWidth;
1894
+ ctx.shadowColor = accentA;
1895
+ ctx.shadowBlur = this.profile.haloWidth * 0.72;
1896
+ ctx.stroke();
1897
+ ctx.restore();
1898
+
1899
+ ctx.save();
1900
+ ctx.globalCompositeOperation = 'screen';
1901
+ ctx.strokeStyle = hexToRgba(accentB, 0.78);
1902
+ ctx.lineWidth = this.profile.glowWidth + 4.2;
1903
+ ctx.shadowColor = accentB;
1904
+ ctx.shadowBlur = 8;
1905
+ ctx.stroke();
1906
+ ctx.restore();
1907
+
1908
+ ctx.save();
1909
+ ctx.globalCompositeOperation = 'screen';
1910
+ ctx.strokeStyle = hexToRgba(glow, 0.88);
1911
+ ctx.lineWidth = this.profile.glowWidth;
1912
+ ctx.shadowColor = glow;
1913
+ ctx.shadowBlur = 5;
1914
+ ctx.stroke();
1915
+ ctx.restore();
1916
+
1917
+ ctx.restore();
1918
+
1919
+ ctx.save();
1920
+ ctx.globalCompositeOperation = 'screen';
1921
+ ctx.strokeStyle = hexToRgba(glow, 0.84);
1922
+ ctx.lineWidth = 2.15;
1923
+ ctx.shadowColor = glow;
1924
+ ctx.shadowBlur = 2.5;
1925
+ this.createEdgePath(ctx, shoreline, time, 0, 1);
1926
+ ctx.stroke();
1927
+ ctx.restore();
1928
+
1929
+ if (this.profile.whiteAlpha > 0.05) {
1930
+ ctx.save();
1931
+ ctx.globalCompositeOperation = 'screen';
1932
+ ctx.strokeStyle = `rgba(255,255,245,${this.profile.whiteAlpha})`;
1933
+ ctx.lineWidth = this.profile.whiteWidth;
1934
+ this.createEdgePath(ctx, shoreline, time, 0, 1);
1935
+ ctx.stroke();
1936
+ ctx.restore();
1937
+ }
1938
+ }
1939
+
1940
+ updateFromPointer(event) {
1941
+ const bounds = this.root.getBoundingClientRect();
1942
+ let ratio = bounds.width > 0 ? (event.clientX - bounds.left) / bounds.width : 0;
1943
+ if (this.direction === 'rtl') ratio = 1 - ratio;
1944
+ this.setProgress(this.min + ratio * (this.max - this.min), 'drag');
1945
+ }
1946
+
1947
+ onKeyDown(event) {
1948
+ const range = this.max - this.min;
1949
+ const configuredStep = Number(this.step);
1950
+ const step = Number.isFinite(configuredStep) && configuredStep > 0
1951
+ ? configuredStep
1952
+ : Math.max(range / 100, 1e-7);
1953
+ const direction = this.direction === 'rtl' ? -1 : 1;
1954
+ const aliases = { Left: 'ArrowLeft', Right: 'ArrowRight', Up: 'ArrowUp', Down: 'ArrowDown' };
1955
+ const keyCodes = {
1956
+ 35: 'End', 36: 'Home', 33: 'PageUp', 34: 'PageDown',
1957
+ 37: 'ArrowLeft', 38: 'ArrowUp', 39: 'ArrowRight', 40: 'ArrowDown'
1958
+ };
1959
+ const key = aliases[event.key] || event.key || keyCodes[event.keyCode];
1960
+ let next = null;
1961
+ if (key === 'ArrowRight') next = this.value + step * direction;
1962
+ else if (key === 'ArrowLeft') next = this.value - step * direction;
1963
+ else if (key === 'ArrowUp') next = this.value + step;
1964
+ else if (key === 'ArrowDown') next = this.value - step;
1965
+ else if (key === 'PageUp') next = this.value + step * 10;
1966
+ else if (key === 'PageDown') next = this.value - step * 10;
1967
+ else if (key === 'Home') next = this.min;
1968
+ else if (key === 'End') next = this.max;
1969
+ if (next === null) return;
1970
+ event.preventDefault();
1971
+ this.setProgress(next, 'keyboard');
1972
+ }
1973
+
1974
+ beginDrag(event) {
1975
+ if (event.button !== undefined && event.button !== 0) return;
1976
+ event.preventDefault();
1977
+ // preventDefault 会吞掉点击聚焦,主动聚焦让根节点获得无障碍焦点。
1978
+ try { this.root.focus({ preventScroll: true }); } catch { this.root.focus(); }
1979
+ this.dragging = true;
1980
+ this.pendingPointer = null;
1981
+ this._dragRaf = 0;
1982
+ this.root.classList.add('is-dragging');
1983
+ try { this.root.setPointerCapture?.(event.pointerId); } catch {}
1984
+ this.emitter.emit('dragstart', { value: this.value });
1985
+ this.updateFromPointer(event);
1986
+ }
1987
+
1988
+ moveDrag(event) {
1989
+ if (!this.dragging) return;
1990
+ event.preventDefault();
1991
+ // rAF 合并:一帧最多一次 setProgress/change,视觉仍每帧更新。
1992
+ this.pendingPointer = event;
1993
+ if (this._dragRaf) return;
1994
+ this._dragRaf = requestAnimationFrame(() => {
1995
+ this._dragRaf = 0;
1996
+ const pending = this.pendingPointer;
1997
+ this.pendingPointer = null;
1998
+ if (pending) this.updateFromPointer(pending);
1999
+ });
2000
+ }
2001
+
2002
+ endDrag(event) {
2003
+ if (!this.dragging) return;
2004
+ this.dragging = false;
2005
+ if (this._dragRaf) {
2006
+ cancelAnimationFrame(this._dragRaf);
2007
+ this._dragRaf = 0;
2008
+ }
2009
+ const pending = this.pendingPointer;
2010
+ this.pendingPointer = null;
2011
+ if (pending) this.updateFromPointer(pending);
2012
+ this.root.classList.remove('is-dragging');
2013
+ try {
2014
+ if (event?.pointerId !== undefined && this.root.hasPointerCapture?.(event.pointerId)) {
2015
+ this.root.releasePointerCapture(event.pointerId);
2016
+ }
2017
+ } catch {}
2018
+ this.emitter.emit('dragend', { value: this.value });
2019
+ }
2020
+
2021
+ bindEvents() {
2022
+ if (this.options.draggable !== false) {
2023
+ this.handlers.pointerdown = (event) => this.beginDrag(event);
2024
+ this.handlers.pointermove = (event) => this.moveDrag(event);
2025
+ this.handlers.pointerup = (event) => this.endDrag(event);
2026
+ this.handlers.pointercancel = (event) => this.endDrag(event);
2027
+ this.root.addEventListener('pointerdown', this.handlers.pointerdown);
2028
+ this.root.addEventListener('pointermove', this.handlers.pointermove);
2029
+ this.root.addEventListener('pointerup', this.handlers.pointerup);
2030
+ this.root.addEventListener('pointercancel', this.handlers.pointercancel);
2031
+ }
2032
+
2033
+ if (this.options.keyboard !== false) {
2034
+ this.handlers.keydown = (event) => this.onKeyDown(event);
2035
+ this.root.addEventListener('keydown', this.handlers.keydown);
2036
+ }
2037
+
2038
+ }
2039
+
2040
+ update(delta, paused) {
2041
+ if (!paused) this.flowTime += delta;
2042
+ }
2043
+
2044
+ draw() {
2045
+ // Performance fix: when the WebGL overlay is active the 2D layer is
2046
+ // hidden behind it, so drawing it every frame would be wasted CPU.
2047
+ if (this.webglActive) return;
2048
+ this.drawReferenceFlow();
2049
+ }
2050
+
2051
+ randomize() {
2052
+ this.flowTime = this.random() * 40;
2053
+ return this.flowTime;
2054
+ }
2055
+
2056
+ setColors(colors) {
2057
+ if (!Array.isArray(colors) || colors.length !== 4) return;
2058
+ this.preset.colors = [...colors];
2059
+ }
2060
+
2061
+ dispose() {
2062
+ if (this._dragRaf) cancelAnimationFrame(this._dragRaf);
2063
+ if (this.resizeObserver) this.resizeObserver.disconnect();
2064
+ else window.removeEventListener('resize', this.onResize);
2065
+ for (const name of Object.keys(this.handlers)) {
2066
+ const handler = this.handlers[name];
2067
+ this.root.removeEventListener(name, handler);
2068
+ }
2069
+ this.handlers = {};
2070
+ }
1321
2071
  }
1322
2072
 
1323
- const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
1324
- const SUPPORTS_CTX_FILTER = typeof CanvasRenderingContext2D !== 'undefined' && 'filter' in CanvasRenderingContext2D.prototype;
1325
-
1326
- function stringSeed(value) {
1327
- let hash = 2166136261;
1328
- for (const character of value) {
1329
- hash ^= character.charCodeAt(0);
1330
- hash = Math.imul(hash, 16777619);
1331
- }
1332
- return (hash >>> 0) / 4294967295;
1333
- }
1334
-
1335
- function interpolate(template, values) {
1336
- return template.replace(/\{(\w+)\}/g, (_, key) => (values[key] !== undefined ? values[key] : ''));
1337
- }
1338
-
1339
- const FLOW_PROFILES = {
1340
- 'model-training': {
1341
- cycles: [0.98, 3.05, 5.45],
1342
- amplitudes: [11.2, 11.0, 5.2],
1343
- speeds: [0.31, -0.53, 0.78],
1344
- bulgeAmplitude: 8.2,
1345
- timeScale: 1.0,
1346
- glowWidth: 5.8,
1347
- haloWidth: 20,
1348
- whiteAlpha: 0.72,
1349
- whiteWidth: 1.15,
1350
- cloudWidth: 0.17,
1351
- autoRange: [25, 66]
1352
- },
1353
- 'agent-migration': {
1354
- cycles: [0.62, 1.55, 2.95],
1355
- amplitudes: [16.0, 7.8, 2.3],
1356
- speeds: [0.25, -0.4, 0.60],
1357
- bulgeAmplitude: 8.4,
1358
- timeScale: 0.82,
1359
- glowWidth: 6.3,
1360
- haloWidth: 21,
1361
- whiteAlpha: 0.18,
1362
- whiteWidth: 0.45,
1363
- cloudWidth: 0.18,
1364
- autoRange: [24, 62]
1365
- },
1366
- 'visual-training': {
1367
- cycles: [0.88, 2.45, 4.35],
1368
- amplitudes: [12.8, 10.2, 4.3],
1369
- speeds: [0.28, -0.47, 0.69],
1370
- bulgeAmplitude: 7.3,
1371
- timeScale: 0.91,
1372
- glowWidth: 6.0,
1373
- haloWidth: 21,
1374
- whiteAlpha: 0.30,
1375
- whiteWidth: 0.55,
1376
- cloudWidth: 0.175,
1377
- autoRange: [20, 75]
1378
- },
1379
- // ponytail: first-pass tide = asymmetric surge on the 2D fallback path.
1380
- // Tune surge/amplitudes after visual QA against the WebGL overlay.
1381
- 'tide': {
1382
- cycles: [0.72, 1.9, 4.2],
1383
- amplitudes: [19.0, 6.5, 1.5],
1384
- speeds: [0.42, -0.5, 0.66],
1385
- bulgeAmplitude: 9.5,
1386
- timeScale: 0.85,
1387
- glowWidth: 6.2,
1388
- haloWidth: 21,
1389
- whiteAlpha: 0.5,
1390
- whiteWidth: 1.0,
1391
- cloudWidth: 0.18,
1392
- autoRange: [18, 70],
1393
- surge: 0.55
1394
- }
1395
- };
1396
-
1397
- class ProgressCapsuleController {
1398
- constructor({ root, canvas, valueElement, preset, emitter, options, copy, dirty }) {
1399
- this.root = root;
1400
- this.canvas = canvas;
1401
- this.valueElement = valueElement;
1402
- this.preset = preset;
1403
- this.emitter = emitter;
1404
- this.options = options;
1405
- this.copy = copy;
1406
- this.dirty = dirty;
1407
- this.valueSuffix = options.valueSuffix ?? copy.valueSuffix;
1408
- this.profile = preset.edgeStyle === 'tide'
1409
- ? FLOW_PROFILES.tide
1410
- : (FLOW_PROFILES[preset.id] || FLOW_PROFILES['visual-training']);
1411
- [this.min, this.max] = normalizeRange(options.min, options.max);
1412
- this.step = normalizeProgressStep(options.step);
1413
- this.precision = Number.isInteger(Number(options.precision))
1414
- ? clamp(Number(options.precision), 0, 20)
1415
- : 0;
1416
- this.formatValue = typeof options.formatValue === 'function' ? options.formatValue : null;
1417
- this.direction = options.direction === 'rtl' ? 'rtl' : 'ltr';
1418
- this.dprCap = effectiveDprCap(options.quality, options.renderScale);
1419
- this.ctx = canvas.getContext('2d');
1420
- this.value = snapProgressValue(options.value ?? preset.initialProgress, this.min, this.max, this.step);
1421
- if (this.value === null) this.value = clamp(preset.initialProgress, this.min, this.max);
1422
- this.dragging = false;
1423
- this.webglActive = false;
1424
- this.flowTime = stringSeed(preset.id) * 31;
1425
- this.seed = stringSeed(`${preset.id}-reference`) * Math.PI * 2;
1426
- this.randomState = Math.floor(stringSeed(`${preset.id}-auto`) * 0x7fffffff) || 1;
1427
- this.dpr = 1;
1428
- this.width = 0;
1429
- this.height = 0;
1430
- this.handlers = {};
1431
-
1432
- this.onResize = () => {
1433
- this.resizeCanvas();
1434
- if (typeof this.options.onResize === 'function') this.options.onResize();
1435
- };
1436
- this.resizeObserver = typeof ResizeObserver !== 'undefined'
1437
- ? new ResizeObserver(this.onResize)
1438
- : null;
1439
- if (this.resizeObserver) this.resizeObserver.observe(this.root);
1440
- else window.addEventListener('resize', this.onResize);
1441
-
1442
- this.suppressEvents = true;
1443
- this.setProgress(this.value, 'init');
1444
- this.suppressEvents = false;
1445
- this.bindEvents();
1446
- this.resizeCanvas();
1447
- }
1448
-
1449
- random() {
1450
- this.randomState = (Math.imul(this.randomState, 1664525) + 1013904223) >>> 0;
1451
- return this.randomState / 4294967296;
1452
- }
1453
-
1454
- displayText() {
1455
- if (this.formatValue) {
1456
- return String(this.formatValue(this.value, {
1457
- min: this.min,
1458
- max: this.max,
1459
- suffix: this.valueSuffix
1460
- }));
1461
- }
1462
- const number = this.precision > 0 ? this.value.toFixed(this.precision) : String(Math.round(this.value));
1463
- return `${number}${this.valueSuffix}`;
1464
- }
1465
-
1466
- syncValueDom() {
1467
- const text = this.displayText();
1468
- this.root.style.setProperty('--progress', this.value.toFixed(2));
1469
- this.root.style.setProperty('--progress-ratio', progressRatio(this.value, this.min, this.max).toFixed(4));
1470
- this.root.setAttribute('aria-valuenow', String(this.value));
1471
- this.root.setAttribute('aria-valuetext', text);
1472
- if (this.valueElement) this.valueElement.textContent = text;
1473
- }
1474
-
1475
- setProgress(nextValue, source = 'auto') {
1476
- const next = snapProgressValue(nextValue, this.min, this.max, this.step);
1477
- if (next === null) return false;
1478
- this.value = next;
1479
- this.syncValueDom();
1480
- if (!this.suppressEvents) {
1481
- if (this.dirty) this.dirty.value = true;
1482
- this.emitter.emit('change', { value: this.value, source });
1483
- }
1484
- return true;
1485
- }
1486
-
1487
- setValueSuffix(suffix) {
1488
- if (this.dirty) this.dirty.valueSuffix = true;
1489
- this.valueSuffix = String(suffix == null ? '' : suffix);
1490
- this.syncValueDom();
1491
- return this;
1492
- }
1493
-
1494
- setStep(step) {
1495
- const nextStep = normalizeProgressStep(step, this.step);
1496
- if (nextStep === this.step) return this;
1497
- if (this.dirty) this.dirty.step = true;
1498
- this.step = nextStep;
1499
- const next = snapProgressValue(this.value, this.min, this.max, this.step);
1500
- if (next !== null && next !== this.value) this.setProgress(next, 'prop');
1501
- return this;
1502
- }
1503
-
1504
- setPrecision(precision) {
1505
- const value = Number(precision);
1506
- if (!Number.isInteger(value) || value < 0 || value > 20) return this;
1507
- if (this.dirty) this.dirty.precision = true;
1508
- this.precision = value;
1509
- this.syncValueDom();
1510
- return this;
1511
- }
1512
-
1513
- setFormatValue(formatter) {
1514
- if (formatter != null && typeof formatter !== 'function') return this;
1515
- if (this.dirty) this.dirty.formatValue = true;
1516
- this.formatValue = formatter || null;
1517
- this.syncValueDom();
1518
- return this;
1519
- }
1520
-
1521
- setDirection(direction) {
1522
- if (this.dirty) this.dirty.direction = true;
1523
- this.direction = direction === 'rtl' ? 'rtl' : 'ltr';
1524
- this.root.dataset.direction = this.direction;
1525
- return this;
1526
- }
1527
-
1528
- setShowValue(show) {
1529
- if (this.dirty) this.dirty.showValue = true;
1530
- if (this.valueElement) this.valueElement.hidden = show === false;
1531
- return this;
1532
- }
1533
-
1534
- resizeCanvas() {
1535
- const bounds = this.root.getBoundingClientRect();
1536
- this.dpr = Math.min(window.devicePixelRatio || 1, this.dprCap);
1537
- this.width = Math.max(1, bounds.width);
1538
- this.height = Math.max(1, bounds.height);
1539
- this.canvas.width = Math.round(this.width * this.dpr);
1540
- this.canvas.height = Math.round(this.height * this.dpr);
1541
- this.canvas.style.width = `${this.width}px`;
1542
- this.canvas.style.height = `${this.height}px`;
1543
- this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
1544
- }
1545
-
1546
- edgeEnvelope(yRatio) {
1547
- const edge = Math.sin(Math.PI * clamp(yRatio, 0, 1));
1548
- return Math.pow(Math.max(edge, 0), 0.48);
1549
- }
1550
-
1551
- localBulge(yRatio, time, index) {
1552
- const direction = index === 0 ? 1 : -1;
1553
- const center = 0.28 + index * 0.40 + Math.sin(time * (0.19 + index * 0.035) + this.seed * (1.1 + index)) * 0.13;
1554
- const width = 0.075 + index * 0.016 + Math.sin(time * 0.13 + this.seed * 2.1) * 0.012;
1555
- const distance = (yRatio - center) / Math.max(width, 0.035);
1556
- const gaussian = Math.exp(-0.5 * distance * distance);
1557
- return gaussian * Math.sin(time * (0.71 + index * 0.09) + this.seed * (2.7 + index)) * this.profile.bulgeAmplitude * direction;
1558
- }
1559
-
1560
- edgeOffset(y, time, phase = 0, amplitudeScale = 1) {
1561
- const yRatio = this.height > 0 ? y / this.height : 0;
1562
- const envelope = this.edgeEnvelope(yRatio);
1563
- const scaledTime = time * this.profile.timeScale;
1564
- const phaseTime = this.profile.surge
1565
- ? scaledTime + this.profile.surge * Math.sin(scaledTime * 2)
1566
- : scaledTime;
1567
- let offset = 0;
1568
-
1569
- for (let index = 0; index < this.profile.cycles.length; index += 1) {
1570
- const cycle = this.profile.cycles[index];
1571
- const amplitude = this.profile.amplitudes[index];
1572
- const speed = this.profile.speeds[index];
1573
- const amplitudeMotion = 0.74 + 0.26 * Math.sin(
1574
- phaseTime * (0.17 + index * 0.045) + this.seed * (index + 2.4)
1575
- );
1576
- offset += Math.sin(
1577
- yRatio * Math.PI * 2 * cycle + phaseTime * speed * Math.PI * 2 + this.seed * (index + 1) + phase
1578
- ) * amplitude * amplitudeMotion;
1579
- }
1580
-
1581
- offset += this.localBulge(yRatio, phaseTime + phase, 0);
1582
- offset += this.localBulge(yRatio, phaseTime - phase * 0.7, 1);
1583
- return offset * envelope * amplitudeScale;
1584
- }
1585
-
1586
- createEdgePath(ctx, baseX, time, phase = 0, amplitudeScale = 1) {
1587
- const step = Math.max(1.8, this.height / 92);
1588
- ctx.beginPath();
1589
- for (let y = 0; y <= this.height + step; y += step) {
1590
- const x = baseX + this.edgeOffset(y, time, phase, amplitudeScale);
1591
- if (y === 0) ctx.moveTo(x, y);
1592
- else ctx.lineTo(x, y);
1593
- }
1594
- }
1595
-
1596
- createFillPath(ctx, baseX, time, phase = 0, amplitudeScale = 1) {
1597
- const step = Math.max(1.8, this.height / 92);
1598
- ctx.beginPath();
1599
- ctx.moveTo(0, 0);
1600
- ctx.lineTo(baseX + this.edgeOffset(0, time, phase, amplitudeScale), 0);
1601
- for (let y = step; y <= this.height + step; y += step) {
1602
- ctx.lineTo(baseX + this.edgeOffset(y, time, phase, amplitudeScale), y);
1603
- }
1604
- ctx.lineTo(0, this.height);
1605
- ctx.closePath();
1606
- }
1607
-
1608
- drawEllipticalGlow(x, y, radiusX, radiusY, color, alpha) {
1609
- const ctx = this.ctx;
1610
- ctx.save();
1611
- ctx.translate(x, y);
1612
- ctx.scale(1, radiusY / radiusX);
1613
- const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, radiusX);
1614
- gradient.addColorStop(0, hexToRgba(color, alpha));
1615
- gradient.addColorStop(0.42, hexToRgba(color, alpha * 0.48));
1616
- gradient.addColorStop(1, hexToRgba(color, 0));
1617
- ctx.globalCompositeOperation = 'screen';
1618
- ctx.fillStyle = gradient;
1619
- ctx.fillRect(-radiusX, -radiusX, radiusX * 2, radiusX * 2);
1620
- ctx.restore();
1621
- }
1622
-
1623
- drawDarkEllipticalShadow(x, y, radiusX, radiusY, alpha) {
1624
- const ctx = this.ctx;
1625
- ctx.save();
1626
- ctx.translate(x, y);
1627
- ctx.scale(1, radiusY / radiusX);
1628
- const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, radiusX);
1629
- gradient.addColorStop(0, `rgba(6, 6, 11, ${alpha})`);
1630
- gradient.addColorStop(0.54, `rgba(8, 8, 14, ${alpha * 0.62})`);
1631
- gradient.addColorStop(1, 'rgba(8, 8, 14, 0)');
1632
- ctx.globalCompositeOperation = 'source-over';
1633
- ctx.fillStyle = gradient;
1634
- ctx.fillRect(-radiusX, -radiusX, radiusX * 2, radiusX * 2);
1635
- ctx.restore();
1636
- }
1637
-
1638
- drawColorClouds(shoreline, time, accentA, accentB, glow) {
1639
- this.ctx;
1640
- const width = this.width;
1641
- const height = this.height;
1642
- const scale = this.profile.cloudWidth;
1643
- const t = time * this.profile.timeScale;
1644
-
1645
- const upperY = height * (0.28 + Math.sin(t * 0.24 + this.seed) * 0.13);
1646
- const lowerY = height * (0.70 + Math.cos(t * 0.21 + this.seed * 1.7) * 0.12);
1647
- const middleY = height * (0.49 + Math.sin(t * 0.31 + this.seed * 2.3) * 0.15);
1648
-
1649
- const farX = shoreline - width * 0.095;
1650
- const farRx = Math.max(52, width * scale);
1651
- const farRy = height * 0.42;
1652
- this.drawEllipticalGlow(farX, upperY, farRx, farRy, accentA, 0.38);
1653
- this.drawDarkEllipticalShadow(
1654
- farX + farRx * 0.16,
1655
- upperY,
1656
- farRx * 0.58,
1657
- farRy * 0.66,
1658
- 0.74
1659
- );
1660
-
1661
- const lowerX = shoreline - width * 0.072;
1662
- const lowerRx = Math.max(44, width * scale * 0.82);
1663
- const lowerRy = height * 0.36;
1664
- this.drawEllipticalGlow(lowerX, lowerY, lowerRx, lowerRy, accentB, 0.31);
1665
- this.drawDarkEllipticalShadow(
1666
- lowerX + lowerRx * 0.14,
1667
- lowerY,
1668
- lowerRx * 0.54,
1669
- lowerRy * 0.62,
1670
- 0.64
1671
- );
1672
-
1673
- this.drawEllipticalGlow(
1674
- shoreline - width * 0.034,
1675
- middleY,
1676
- Math.max(30, width * scale * 0.48),
1677
- height * 0.27,
1678
- glow,
1679
- 0.20
1680
- );
1681
- }
1682
-
1683
- drawPathBand({ baseX, time, phase, amplitudeScale, color, alpha, blur, width, composite = 'screen' }) {
1684
- const ctx = this.ctx;
1685
- this.createEdgePath(ctx, baseX, time, phase, amplitudeScale);
1686
- ctx.save();
1687
- ctx.globalCompositeOperation = composite;
1688
- ctx.globalAlpha = alpha;
1689
- // Safari < 18 and some old WebViews ignore ctx.filter; setting it is a
1690
- // no-op there, so only assign when supported to keep intent explicit.
1691
- if (SUPPORTS_CTX_FILTER) ctx.filter = `blur(${blur}px)`;
1692
- ctx.strokeStyle = color;
1693
- ctx.lineWidth = width;
1694
- ctx.stroke();
1695
- ctx.restore();
1696
- }
1697
-
1698
- setRange(min, max) {
1699
- const nextRange = normalizeRange(min, max, this.min, this.max);
1700
- this.min = nextRange[0];
1701
- this.max = nextRange[1];
1702
- this.root.setAttribute('aria-valuemin', String(this.min));
1703
- this.root.setAttribute('aria-valuemax', String(this.max));
1704
- const next = snapProgressValue(this.value, this.min, this.max, this.step);
1705
- if (next !== this.value) this.setProgress(next, 'prop');
1706
- else this.syncValueDom();
1707
- return this;
1708
- }
1709
-
1710
- drawReferenceFlow() {
1711
- const ctx = this.ctx;
1712
- const width = this.width;
1713
- const height = this.height;
1714
- if (!ctx || width <= 0 || height <= 0) return;
1715
-
1716
- const range = Math.max(this.max - this.min, 1);
1717
- const shoreline = width * Math.min(Math.max((this.value - this.min) / range, 0), 1);
1718
- const time = this.flowTime;
1719
- const [dark, accentA, accentB, glow] = this.preset.colors;
1720
-
1721
- ctx.clearRect(0, 0, width, height);
1722
- ctx.fillStyle = '#202126';
1723
- ctx.fillRect(0, 0, width, height);
1724
-
1725
- this.createFillPath(ctx, shoreline, time, 0, 1);
1726
- const bodyGradient = ctx.createLinearGradient(0, 0, Math.max(shoreline, 1), 0);
1727
- bodyGradient.addColorStop(0, dark);
1728
- bodyGradient.addColorStop(0.74, dark);
1729
- bodyGradient.addColorStop(0.89, hexToRgba(dark, 0.99));
1730
- bodyGradient.addColorStop(0.955, hexToRgba(accentA, 0.09));
1731
- bodyGradient.addColorStop(0.992, hexToRgba(accentB, 0.54));
1732
- bodyGradient.addColorStop(1, hexToRgba(glow, 0.78));
1733
- ctx.fillStyle = bodyGradient;
1734
- ctx.fill();
1735
-
1736
- this.drawColorClouds(shoreline, time, accentA, accentB, glow);
1737
-
1738
- this.drawPathBand({
1739
- baseX: shoreline - width * 0.105,
1740
- time,
1741
- phase: 1.42,
1742
- amplitudeScale: 1.18,
1743
- color: accentA,
1744
- alpha: 0.22,
1745
- blur: 21,
1746
- width: 54
1747
- });
1748
- this.drawPathBand({
1749
- baseX: shoreline - width * 0.073,
1750
- time,
1751
- phase: -0.92,
1752
- amplitudeScale: 1.06,
1753
- color: accentB,
1754
- alpha: 0.30,
1755
- blur: 15,
1756
- width: 42
1757
- });
1758
- this.drawPathBand({
1759
- baseX: shoreline - width * 0.047,
1760
- time,
1761
- phase: 0.42,
1762
- amplitudeScale: 0.94,
1763
- color: 'rgba(5, 5, 10, 0.92)',
1764
- alpha: 0.72,
1765
- blur: 12,
1766
- width: 34,
1767
- composite: 'source-over'
1768
- });
1769
- this.drawPathBand({
1770
- baseX: shoreline - width * 0.025,
1771
- time,
1772
- phase: -0.28,
1773
- amplitudeScale: 0.96,
1774
- color: accentB,
1775
- alpha: 0.66,
1776
- blur: 8,
1777
- width: 28
1778
- });
1779
-
1780
- ctx.save();
1781
- this.createFillPath(ctx, shoreline, time, 0, 1);
1782
- ctx.clip();
1783
- this.createEdgePath(ctx, shoreline, time, 0, 1);
1784
-
1785
- ctx.save();
1786
- ctx.globalCompositeOperation = 'screen';
1787
- ctx.strokeStyle = hexToRgba(accentA, 0.20);
1788
- ctx.lineWidth = this.profile.haloWidth;
1789
- ctx.shadowColor = accentA;
1790
- ctx.shadowBlur = this.profile.haloWidth * 0.72;
1791
- ctx.stroke();
1792
- ctx.restore();
1793
-
1794
- ctx.save();
1795
- ctx.globalCompositeOperation = 'screen';
1796
- ctx.strokeStyle = hexToRgba(accentB, 0.78);
1797
- ctx.lineWidth = this.profile.glowWidth + 4.2;
1798
- ctx.shadowColor = accentB;
1799
- ctx.shadowBlur = 8;
1800
- ctx.stroke();
1801
- ctx.restore();
1802
-
1803
- ctx.save();
1804
- ctx.globalCompositeOperation = 'screen';
1805
- ctx.strokeStyle = hexToRgba(glow, 0.88);
1806
- ctx.lineWidth = this.profile.glowWidth;
1807
- ctx.shadowColor = glow;
1808
- ctx.shadowBlur = 5;
1809
- ctx.stroke();
1810
- ctx.restore();
1811
-
1812
- ctx.restore();
1813
-
1814
- ctx.save();
1815
- ctx.globalCompositeOperation = 'screen';
1816
- ctx.strokeStyle = hexToRgba(glow, 0.84);
1817
- ctx.lineWidth = 2.15;
1818
- ctx.shadowColor = glow;
1819
- ctx.shadowBlur = 2.5;
1820
- this.createEdgePath(ctx, shoreline, time, 0, 1);
1821
- ctx.stroke();
1822
- ctx.restore();
1823
-
1824
- if (this.profile.whiteAlpha > 0.05) {
1825
- ctx.save();
1826
- ctx.globalCompositeOperation = 'screen';
1827
- ctx.strokeStyle = `rgba(255,255,245,${this.profile.whiteAlpha})`;
1828
- ctx.lineWidth = this.profile.whiteWidth;
1829
- this.createEdgePath(ctx, shoreline, time, 0, 1);
1830
- ctx.stroke();
1831
- ctx.restore();
1832
- }
1833
- }
1834
-
1835
- updateFromPointer(event) {
1836
- const bounds = this.root.getBoundingClientRect();
1837
- let ratio = bounds.width > 0 ? (event.clientX - bounds.left) / bounds.width : 0;
1838
- if (this.direction === 'rtl') ratio = 1 - ratio;
1839
- this.setProgress(this.min + ratio * (this.max - this.min), 'drag');
1840
- }
1841
-
1842
- onKeyDown(event) {
1843
- const range = this.max - this.min;
1844
- const configuredStep = Number(this.step);
1845
- const step = Number.isFinite(configuredStep) && configuredStep > 0
1846
- ? configuredStep
1847
- : Math.max(range / 100, 1e-7);
1848
- const direction = this.direction === 'rtl' ? -1 : 1;
1849
- const aliases = { Left: 'ArrowLeft', Right: 'ArrowRight', Up: 'ArrowUp', Down: 'ArrowDown' };
1850
- const keyCodes = {
1851
- 35: 'End', 36: 'Home', 33: 'PageUp', 34: 'PageDown',
1852
- 37: 'ArrowLeft', 38: 'ArrowUp', 39: 'ArrowRight', 40: 'ArrowDown'
1853
- };
1854
- const key = aliases[event.key] || event.key || keyCodes[event.keyCode];
1855
- let next = null;
1856
- if (key === 'ArrowRight') next = this.value + step * direction;
1857
- else if (key === 'ArrowLeft') next = this.value - step * direction;
1858
- else if (key === 'ArrowUp') next = this.value + step;
1859
- else if (key === 'ArrowDown') next = this.value - step;
1860
- else if (key === 'PageUp') next = this.value + step * 10;
1861
- else if (key === 'PageDown') next = this.value - step * 10;
1862
- else if (key === 'Home') next = this.min;
1863
- else if (key === 'End') next = this.max;
1864
- if (next === null) return;
1865
- event.preventDefault();
1866
- this.setProgress(next, 'keyboard');
1867
- }
1868
-
1869
- beginDrag(event) {
1870
- if (event.button !== undefined && event.button !== 0) return;
1871
- event.preventDefault();
1872
- // preventDefault 会吞掉点击聚焦,主动聚焦让根节点获得无障碍焦点。
1873
- try { this.root.focus({ preventScroll: true }); } catch { this.root.focus(); }
1874
- this.dragging = true;
1875
- this.pendingPointer = null;
1876
- this._dragRaf = 0;
1877
- this.root.classList.add('is-dragging');
1878
- try { this.root.setPointerCapture?.(event.pointerId); } catch {}
1879
- this.emitter.emit('dragstart', { value: this.value });
1880
- this.updateFromPointer(event);
1881
- }
1882
-
1883
- moveDrag(event) {
1884
- if (!this.dragging) return;
1885
- event.preventDefault();
1886
- // rAF 合并:一帧最多一次 setProgress/change,视觉仍每帧更新。
1887
- this.pendingPointer = event;
1888
- if (this._dragRaf) return;
1889
- this._dragRaf = requestAnimationFrame(() => {
1890
- this._dragRaf = 0;
1891
- const pending = this.pendingPointer;
1892
- this.pendingPointer = null;
1893
- if (pending) this.updateFromPointer(pending);
1894
- });
1895
- }
1896
-
1897
- endDrag(event) {
1898
- if (!this.dragging) return;
1899
- this.dragging = false;
1900
- if (this._dragRaf) {
1901
- cancelAnimationFrame(this._dragRaf);
1902
- this._dragRaf = 0;
1903
- }
1904
- const pending = this.pendingPointer;
1905
- this.pendingPointer = null;
1906
- if (pending) this.updateFromPointer(pending);
1907
- this.root.classList.remove('is-dragging');
1908
- try {
1909
- if (event?.pointerId !== undefined && this.root.hasPointerCapture?.(event.pointerId)) {
1910
- this.root.releasePointerCapture(event.pointerId);
1911
- }
1912
- } catch {}
1913
- this.emitter.emit('dragend', { value: this.value });
1914
- }
1915
-
1916
- bindEvents() {
1917
- if (this.options.draggable !== false) {
1918
- this.handlers.pointerdown = (event) => this.beginDrag(event);
1919
- this.handlers.pointermove = (event) => this.moveDrag(event);
1920
- this.handlers.pointerup = (event) => this.endDrag(event);
1921
- this.handlers.pointercancel = (event) => this.endDrag(event);
1922
- this.root.addEventListener('pointerdown', this.handlers.pointerdown);
1923
- this.root.addEventListener('pointermove', this.handlers.pointermove);
1924
- this.root.addEventListener('pointerup', this.handlers.pointerup);
1925
- this.root.addEventListener('pointercancel', this.handlers.pointercancel);
1926
- }
1927
-
1928
- if (this.options.keyboard !== false) {
1929
- this.handlers.keydown = (event) => this.onKeyDown(event);
1930
- this.root.addEventListener('keydown', this.handlers.keydown);
1931
- }
1932
-
1933
- }
1934
-
1935
- update(delta, paused) {
1936
- if (!paused) this.flowTime += delta;
1937
- }
1938
-
1939
- draw() {
1940
- // Performance fix: when the WebGL overlay is active the 2D layer is
1941
- // hidden behind it, so drawing it every frame would be wasted CPU.
1942
- if (this.webglActive) return;
1943
- this.drawReferenceFlow();
1944
- }
1945
-
1946
- randomize() {
1947
- this.flowTime = this.random() * 40;
1948
- return this.flowTime;
1949
- }
1950
-
1951
- setColors(colors) {
1952
- if (!Array.isArray(colors) || colors.length !== 4) return;
1953
- this.preset.colors = [...colors];
1954
- }
1955
-
1956
- dispose() {
1957
- if (this._dragRaf) cancelAnimationFrame(this._dragRaf);
1958
- if (this.resizeObserver) this.resizeObserver.disconnect();
1959
- else window.removeEventListener('resize', this.onResize);
1960
- for (const name of Object.keys(this.handlers)) {
1961
- const handler = this.handlers[name];
1962
- this.root.removeEventListener(name, handler);
1963
- }
1964
- this.handlers = {};
1965
- }
1966
- }
1967
-
1968
- /**
1969
- * Mount a fluid progress capsule into `container`.
1970
- *
1971
- * Options: preset (literary name), width, height, value/modelValue, min, max,
1972
- * step, draggable, keyboard, direction, precision/formatValue, colors,
1973
- * edgeStyle, textRatio (0-100, text region
1974
- * width in percent), text (HTML string or DOM nodes for the text slot),
1975
- * colorContent (HTML string or DOM nodes for the color slot),
1976
- * showValue (show/hide the right-side percentage), quality, renderScale,
1977
- * powerPreference, fps, paused/static, respectReducedMotion, cssVars.
1978
- */
1979
- function createProgressCapsule(container, options = {}) {
1980
- if (!container || typeof container.appendChild !== 'function') {
1981
- throw new Error('createProgressCapsule: container element is required');
1982
- }
1983
-
1984
- const preset = { ...getPreset('progress', options.preset ?? '星火') };
1985
- const merged = normalizeOptions(DEFAULTS.progress, preset, options);
1986
- [merged.min, merged.max] = normalizeRange(merged.min, merged.max);
1987
- if (merged.modelValue !== undefined) merged.value = merged.modelValue;
1988
- const normalizedColors = Array.isArray(merged.colors) ? merged.colors.map(normalizeColor) : [];
1989
- if (normalizedColors.length === 4 && normalizedColors.every(Boolean)) preset.colors = normalizedColors;
1990
- const initialEdgeStyle = merged.edgeStyle === 'tide' || merged.edgeStyle === 'flow'
1991
- ? merged.edgeStyle
1992
- : preset.edgeStyle;
1993
- preset.edgeStyle = initialEdgeStyle;
1994
- merged.edgeStyle = initialEdgeStyle;
1995
- merged.colors = [...preset.colors];
1996
- const optionColors = Array.isArray(options.colors) ? options.colors.map(normalizeColor) : [];
1997
- let colorOverride = optionColors.length === 4 && optionColors.every(Boolean)
1998
- ? [...optionColors]
1999
- : null;
2000
- let edgeStyleOverride = options.edgeStyle === 'flow' || options.edgeStyle === 'tide'
2001
- ? merged.edgeStyle
2002
- : null;
2003
- const copy = COPY;
2004
- let customLabel = typeof options.label === 'string' && options.label ? options.label : null;
2005
- // Vue 的裸布尔属性(<ProgressCapsule disabled />)会传成空字符串,
2006
- // 这里统一按 true 处理。
2007
- const disabled = merged.disabled === true || merged.disabled === '' || merged.disabled === 'true';
2008
- const readonly = merged.readonly === true || merged.readonly === '' || merged.readonly === 'true';
2009
- const locked = disabled || readonly;
2010
- const effectiveDraggable = locked ? false : merged.draggable !== false;
2011
- const effectiveKeyboard = locked ? false : merged.keyboard !== false;
2012
- const dirty = {
2013
- preset: false,
2014
- colors: false,
2015
- textRatio: false,
2016
- cssVars: false,
2017
- edgeStyle: false,
2018
- value: false,
2019
- step: false,
2020
- precision: false,
2021
- formatValue: false,
2022
- direction: false,
2023
- showValue: false,
2024
- valueSuffix: false,
2025
- quality: false,
2026
- renderScale: false,
2027
- paused: false,
2028
- static: false,
2029
- fps: false
2030
- };
2031
-
2032
- const root = document.createElement('div');
2033
- root.className = 'hj-capsule-root hj-progress-root';
2034
- root.setAttribute('role', 'slider');
2035
- root.setAttribute('data-draggable', String(effectiveDraggable));
2036
- root.dataset.direction = merged.direction === 'rtl' ? 'rtl' : 'ltr';
2037
- if (disabled) {
2038
- root.setAttribute('aria-disabled', 'true');
2039
- root.classList.add('is-disabled');
2040
- }
2041
- if (readonly) {
2042
- root.setAttribute('aria-readonly', 'true');
2043
- root.classList.add('is-readonly');
2044
- }
2045
- root.setAttribute('tabindex', locked ? '-1' : (effectiveKeyboard ? '0' : '-1'));
2046
- root.setAttribute('aria-orientation', 'horizontal');
2047
- root.setAttribute('aria-valuemin', String(merged.min ?? 0));
2048
- root.setAttribute('aria-valuemax', String(merged.max ?? 100));
2049
- root.setAttribute('aria-valuenow', String(preset.initialProgress));
2050
- const updateAria = () => {
2051
- root.setAttribute(
2052
- 'aria-label',
2053
- customLabel || interpolate(copy.progressAria, { brand: copy.brandName, code: preset.code, name: preset.name })
2054
- );
2055
- };
2056
- updateAria();
2057
- const canvas = document.createElement('canvas');
2058
- canvas.className = 'hj-progress-canvas';
2059
- canvas.setAttribute('aria-hidden', 'true');
2060
-
2061
- // Slot containers are always present but transparent by default; they only
2062
- // provide geometry, never typography/padding/background (docs: 插槽 CSS 约定).
2063
- const fillContent = (layer, content) => {
2064
- layer.innerHTML = '';
2065
- if (content == null) return;
2066
- if (typeof content === 'string') {
2067
- layer.innerHTML = content;
2068
- return;
2069
- }
2070
- const nodes = Array.isArray(content) ? content : [content];
2071
- for (const node of nodes) {
2072
- if (node && typeof node.nodeType === 'number') layer.appendChild(node);
2073
- }
2074
- };
2075
-
2076
- const textLayer = document.createElement('div');
2077
- textLayer.className = 'hj-progress-text hj-progress-copy';
2078
-
2079
- const visualLayer = document.createElement('div');
2080
- visualLayer.className = 'hj-progress-visual';
2081
-
2082
- fillContent(textLayer, options.text);
2083
- fillContent(visualLayer, options.colorContent);
2084
-
2085
- const valueElement = document.createElement('div');
2086
- valueElement.className = 'hj-progress-value';
2087
- valueElement.setAttribute('aria-hidden', 'true');
2088
- valueElement.hidden = merged.showValue === false;
2089
-
2090
- root.appendChild(canvas);
2091
- root.appendChild(textLayer);
2092
- root.appendChild(visualLayer);
2093
- root.appendChild(valueElement);
2094
- container.appendChild(root);
2095
-
2096
- const emitter = createEmitter();
2097
- let manuallyPaused = merged.paused === true;
2098
- let reducedPaused = false;
2099
- let staticMode = merged.static === true;
2100
- let contextLost = false;
2101
- let disposed = false;
2102
- let renderOnce = () => {};
2103
- const onContextLost = () => {
2104
- contextLost = true;
2105
- emitter.emit('contextlost', {});
2106
- };
2107
- const onContextRestored = () => {
2108
- contextLost = false;
2109
- emitter.emit('contextrestored', {});
2110
- renderOnce();
2111
- wakeScheduler();
2112
- };
2113
-
2114
- const applySize = () => {
2115
- root.style.width = parseSize(merged.width);
2116
- root.style.height = parseSize(merged.height);
2117
- const vars = merged.cssVars || {};
2118
- for (const name of Object.keys(vars)) root.style.setProperty(name, vars[name]);
2119
- if (merged.textRatio !== undefined) {
2120
- root.style.setProperty('--hj-text-width', `${merged.textRatio}%`);
2121
- }
2122
- };
2123
- applySize();
2124
-
2125
- const controller = new ProgressCapsuleController({
2126
- root,
2127
- canvas,
2128
- valueElement,
2129
- preset,
2130
- emitter,
2131
- options: {
2132
- ...merged,
2133
- draggable: effectiveDraggable,
2134
- keyboard: effectiveKeyboard,
2135
- onResize: () => {
2136
- if (manuallyPaused || reducedPaused || staticMode) renderOnce();
2137
- }
2138
- },
2139
- copy,
2140
- dirty
2141
- });
2142
-
2143
- let overlay = null;
2144
- if (merged.renderer !== 'canvas2d') {
2145
- overlay = attachProgressFlowOverlay({
2146
- root,
2147
- canvas,
2148
- preset,
2149
- getProgress: () => progressRatio(controller.value, controller.min, controller.max) * 100,
2150
- dprCap: effectiveDprCap(merged.quality, merged.renderScale),
2151
- powerPreference: merged.powerPreference,
2152
- onContextLost,
2153
- onContextRestored
2154
- });
2155
- }
2156
- if (overlay) controller.webglActive = true;
2157
- else if (merged.renderer !== 'canvas2d') {
2158
- nextTick(() => {
2159
- if (disposed) return;
2160
- emitter.emit('error', { message: 'WebGL2 unavailable, using Canvas2D fallback' });
2161
- });
2162
- }
2163
-
2164
- const visibility = createVisibilityGuard(root, wakeScheduler);
2165
- const motionPreference = createReducedMotionPreference(
2166
- merged.respectReducedMotion,
2167
- (matches) => {
2168
- reducedPaused = matches;
2169
- if (matches) renderOnce();
2170
- else wakeScheduler();
2171
- }
2172
- );
2173
- reducedPaused = motionPreference.matches();
2174
- const isMotionPaused = () => manuallyPaused || reducedPaused || staticMode || contextLost;
2175
- let flowTime = 0;
2176
- const frameGate = createFrameGate(merged.fps);
2177
- renderOnce = () => {
2178
- controller.draw();
2179
- if (overlay) overlay.update(flowTime);
2180
- };
2181
- renderOnce();
2182
- const offPausedChange = emitter.on('change', () => {
2183
- if (isMotionPaused()) renderOnce();
2184
- });
2185
- const unsubscribe = subscribeScheduler(
2186
- (delta, now) => {
2187
- flowTime += delta;
2188
- controller.update(delta, false);
2189
- if (frameGate.shouldDraw(delta)) {
2190
- controller.draw();
2191
- if (overlay) overlay.update(flowTime);
2192
- }
2193
- },
2194
- () => isMotionPaused() || !visibility.isVisible()
2195
- );
2196
-
2197
- nextTick(() => {
2198
- if (disposed) return;
2199
- emitter.emit('ready', { preset: { ...preset } });
2200
- });
2201
-
2202
- const syncDom = () => {
2203
- updateAria();
2204
- };
2205
-
2206
- return {
2207
- element: root,
2208
- canvas,
2209
- preset,
2210
- on: emitter.on,
2211
- off: emitter.off,
2212
- setValue(value, source = 'prop') {
2213
- controller.setProgress(value, source);
2214
- return this;
2215
- },
2216
- getValue() {
2217
- return controller.value;
2218
- },
2219
- setRange(min, max) {
2220
- controller.setRange(min, max);
2221
- if (isMotionPaused()) renderOnce();
2222
- return this;
2223
- },
2224
- setPreset(ref) {
2225
- const next = getPreset('progress', ref);
2226
- dirty.preset = true;
2227
- Object.assign(preset, next);
2228
- if (colorOverride) preset.colors = [...colorOverride];
2229
- if (edgeStyleOverride) preset.edgeStyle = edgeStyleOverride;
2230
- const nextColors = preset.colors.map(normalizeColor);
2231
- if (nextColors.every(Boolean)) preset.colors = nextColors;
2232
- controller.preset = preset;
2233
- controller.profile = preset.edgeStyle === 'tide'
2234
- ? FLOW_PROFILES.tide
2235
- : (FLOW_PROFILES[preset.id] || FLOW_PROFILES['visual-training']);
2236
- controller.flowTime = stringSeed(next.id) * 31;
2237
- controller.seed = stringSeed(`${next.id}-reference`) * Math.PI * 2;
2238
- syncDom();
2239
- if (overlay) {
2240
- overlay.dispose();
2241
- contextLost = false;
2242
- overlay = attachProgressFlowOverlay({
2243
- root,
2244
- canvas,
2245
- preset,
2246
- getProgress: () => progressRatio(controller.value, controller.min, controller.max) * 100,
2247
- dprCap: effectiveDprCap(merged.quality, merged.renderScale),
2248
- powerPreference: merged.powerPreference,
2249
- onContextLost,
2250
- onContextRestored
2251
- });
2252
- }
2253
- controller.webglActive = Boolean(overlay);
2254
- controller.resizeCanvas();
2255
- if (isMotionPaused()) renderOnce();
2256
- emitter.emit('presetchange', { preset: { ...preset } });
2257
- return this;
2258
- },
2259
- setEdgeStyle(edgeStyle) {
2260
- const value = String(edgeStyle || '').toLowerCase();
2261
- if (value !== 'flow' && value !== 'tide') return this;
2262
- dirty.edgeStyle = true;
2263
- edgeStyleOverride = value;
2264
- preset.edgeStyle = value;
2265
- controller.profile = value === 'tide'
2266
- ? FLOW_PROFILES.tide
2267
- : (FLOW_PROFILES[preset.id] || FLOW_PROFILES['visual-training']);
2268
- if (overlay) {
2269
- overlay.dispose();
2270
- contextLost = false;
2271
- overlay = attachProgressFlowOverlay({
2272
- root,
2273
- canvas,
2274
- preset,
2275
- getProgress: () => progressRatio(controller.value, controller.min, controller.max) * 100,
2276
- dprCap: effectiveDprCap(merged.quality, merged.renderScale),
2277
- powerPreference: merged.powerPreference,
2278
- onContextLost,
2279
- onContextRestored
2280
- });
2281
- }
2282
- controller.webglActive = Boolean(overlay);
2283
- controller.resizeCanvas();
2284
- if (isMotionPaused()) renderOnce();
2285
- return this;
2286
- },
2287
- setText(content) {
2288
- fillContent(textLayer, content);
2289
- return this;
2290
- },
2291
- setColorContent(content) {
2292
- fillContent(visualLayer, content);
2293
- return this;
2294
- },
2295
- setTextRatio(ratio) {
2296
- const value = Number(ratio);
2297
- if (!Number.isFinite(value) || value < 0 || value > 100) return this;
2298
- dirty.textRatio = true;
2299
- merged.textRatio = value;
2300
- root.style.setProperty('--hj-text-width', `${value}%`);
2301
- return this;
2302
- },
2303
- setCssVars(vars) {
2304
- if (!vars || typeof vars !== 'object') return this;
2305
- dirty.cssVars = true;
2306
- merged.cssVars = { ...(merged.cssVars || {}), ...vars };
2307
- for (const name of Object.keys(vars)) root.style.setProperty(name, vars[name]);
2308
- return this;
2309
- },
2310
- setLabel(label) {
2311
- customLabel = typeof label === 'string' && label ? label : null;
2312
- updateAria();
2313
- return this;
2314
- },
2315
- setValueSuffix(suffix) {
2316
- controller.setValueSuffix(suffix);
2317
- return this;
2318
- },
2319
- setStep(step) {
2320
- controller.setStep(step);
2321
- return this;
2322
- },
2323
- setPrecision(precision) {
2324
- controller.setPrecision(precision);
2325
- return this;
2326
- },
2327
- setFormatValue(formatter) {
2328
- controller.setFormatValue(formatter);
2329
- return this;
2330
- },
2331
- setDirection(direction) {
2332
- controller.setDirection(direction);
2333
- return this;
2334
- },
2335
- setShowValue(show) {
2336
- controller.setShowValue(show);
2337
- return this;
2338
- },
2339
- setColors(colors) {
2340
- const next = Array.isArray(colors) ? colors.map(normalizeColor) : [];
2341
- if (next.length !== 4 || next.some((color) => !color)) return this;
2342
- dirty.colors = true;
2343
- colorOverride = [...next];
2344
- preset.colors = [...next];
2345
- controller.setColors(next);
2346
- if (overlay) overlay.setColors(next);
2347
- controller.resizeCanvas();
2348
- if (isMotionPaused()) renderOnce();
2349
- return this;
2350
- },
2351
- setSize(width, height) {
2352
- if (width !== undefined) {
2353
- parseSize(width);
2354
- merged.width = width;
2355
- }
2356
- if (height !== undefined) {
2357
- parseSize(height);
2358
- merged.height = height;
2359
- }
2360
- applySize();
2361
- controller.resizeCanvas();
2362
- if (isMotionPaused()) renderOnce();
2363
- return this;
2364
- },
2365
- randomize() {
2366
- flowTime = controller.randomize();
2367
- if (isMotionPaused()) renderOnce();
2368
- return this;
2369
- },
2370
- pause() {
2371
- dirty.paused = true;
2372
- manuallyPaused = true;
2373
- renderOnce();
2374
- return this;
2375
- },
2376
- resume() {
2377
- dirty.paused = true;
2378
- manuallyPaused = false;
2379
- wakeScheduler();
2380
- return this;
2381
- },
2382
- setPaused(value) {
2383
- return value ? this.pause() : this.resume();
2384
- },
2385
- setStatic(value) {
2386
- dirty.static = true;
2387
- staticMode = value === true;
2388
- merged.static = staticMode;
2389
- if (staticMode) renderOnce();
2390
- else wakeScheduler();
2391
- return this;
2392
- },
2393
- setFps(fps) {
2394
- dirty.fps = true;
2395
- merged.fps = frameGate.setFps(fps);
2396
- wakeScheduler();
2397
- return this;
2398
- },
2399
- setQuality(quality) {
2400
- dirty.quality = true;
2401
- merged.quality = quality;
2402
- controller.dprCap = effectiveDprCap(quality, merged.renderScale);
2403
- controller.resizeCanvas();
2404
- if (overlay) overlay.setDprCap(controller.dprCap);
2405
- if (isMotionPaused()) renderOnce();
2406
- return this;
2407
- },
2408
- setRenderScale(renderScale) {
2409
- const value = Number(renderScale);
2410
- if (!Number.isFinite(value)) return this;
2411
- dirty.renderScale = true;
2412
- merged.renderScale = Math.min(1, Math.max(0.25, value));
2413
- controller.dprCap = effectiveDprCap(merged.quality, merged.renderScale);
2414
- controller.resizeCanvas();
2415
- if (overlay) overlay.setDprCap(controller.dprCap);
2416
- if (isMotionPaused()) renderOnce();
2417
- return this;
2418
- },
2419
- dispose() {
2420
- disposed = true;
2421
- unsubscribe();
2422
- offPausedChange();
2423
- visibility.dispose();
2424
- motionPreference.dispose();
2425
- if (overlay) overlay.dispose();
2426
- controller.dispose();
2427
- root.remove();
2428
- },
2429
- get textRatio() { return merged.textRatio; },
2430
- get cssVars() { return merged.cssVars; },
2431
- get min() { return controller.min; },
2432
- get max() { return controller.max; },
2433
- get step() { return controller.step; },
2434
- get precision() { return controller.precision; },
2435
- get formatValue() { return controller.formatValue; },
2436
- get direction() { return controller.direction; },
2437
- get showValue() { return !valueElement.hidden; },
2438
- get valueSuffix() { return controller.valueSuffix; },
2439
- get quality() { return merged.quality; },
2440
- get renderScale() { return merged.renderScale; },
2441
- get paused() { return manuallyPaused; },
2442
- get static() { return staticMode; },
2443
- get fps() { return frameGate.getFps(); },
2444
- dirty
2445
- };
2073
+ /**
2074
+ * Mount a fluid progress capsule into `container`.
2075
+ *
2076
+ * Options: preset (literary name), width, height, value/modelValue, min, max,
2077
+ * step, draggable, keyboard, direction, precision/formatValue, colors,
2078
+ * edgeStyle, textRatio (0-100, text region
2079
+ * width in percent), text (HTML string or DOM nodes for the text slot),
2080
+ * colorContent (HTML string or DOM nodes for the color slot),
2081
+ * showValue (show/hide the right-side percentage), quality, renderScale,
2082
+ * powerPreference, fps, paused/static, respectReducedMotion, cssVars.
2083
+ */
2084
+ function createProgressCapsule(container, options = {}) {
2085
+ if (!container || typeof container.appendChild !== 'function') {
2086
+ throw new Error('createProgressCapsule: container element is required');
2087
+ }
2088
+
2089
+ const preset = { ...getPreset('progress', options.preset ?? '星火') };
2090
+ const merged = normalizeOptions(DEFAULTS.progress, preset, options);
2091
+ [merged.min, merged.max] = normalizeRange(merged.min, merged.max);
2092
+ if (merged.modelValue !== undefined) merged.value = merged.modelValue;
2093
+ const normalizedColors = Array.isArray(merged.colors) ? merged.colors.map(normalizeColor) : [];
2094
+ if (normalizedColors.length === 4 && normalizedColors.every(Boolean)) preset.colors = normalizedColors;
2095
+ const initialEdgeStyle = merged.edgeStyle === 'tide' || merged.edgeStyle === 'flow'
2096
+ ? merged.edgeStyle
2097
+ : preset.edgeStyle;
2098
+ preset.edgeStyle = initialEdgeStyle;
2099
+ merged.edgeStyle = initialEdgeStyle;
2100
+ merged.colors = [...preset.colors];
2101
+ const optionColors = Array.isArray(options.colors) ? options.colors.map(normalizeColor) : [];
2102
+ let colorOverride = optionColors.length === 4 && optionColors.every(Boolean)
2103
+ ? [...optionColors]
2104
+ : null;
2105
+ let edgeStyleOverride = options.edgeStyle === 'flow' || options.edgeStyle === 'tide'
2106
+ ? merged.edgeStyle
2107
+ : null;
2108
+ const copy = COPY;
2109
+ let customLabel = typeof options.label === 'string' && options.label ? options.label : null;
2110
+ // Vue 的裸布尔属性(<ProgressCapsule disabled />)会传成空字符串,
2111
+ // 这里统一按 true 处理。
2112
+ const disabled = merged.disabled === true || merged.disabled === '' || merged.disabled === 'true';
2113
+ const readonly = merged.readonly === true || merged.readonly === '' || merged.readonly === 'true';
2114
+ const locked = disabled || readonly;
2115
+ const effectiveDraggable = locked ? false : merged.draggable !== false;
2116
+ const effectiveKeyboard = locked ? false : merged.keyboard !== false;
2117
+ const dirty = {
2118
+ preset: false,
2119
+ colors: false,
2120
+ textRatio: false,
2121
+ cssVars: false,
2122
+ edgeStyle: false,
2123
+ value: false,
2124
+ step: false,
2125
+ precision: false,
2126
+ formatValue: false,
2127
+ direction: false,
2128
+ showValue: false,
2129
+ valueSuffix: false,
2130
+ quality: false,
2131
+ renderScale: false,
2132
+ paused: false,
2133
+ static: false,
2134
+ fps: false
2135
+ };
2136
+
2137
+ const root = document.createElement('div');
2138
+ root.className = 'hj-capsule-root hj-progress-root';
2139
+ root.setAttribute('role', 'slider');
2140
+ root.setAttribute('data-draggable', String(effectiveDraggable));
2141
+ root.dataset.direction = merged.direction === 'rtl' ? 'rtl' : 'ltr';
2142
+ if (disabled) {
2143
+ root.setAttribute('aria-disabled', 'true');
2144
+ root.classList.add('is-disabled');
2145
+ }
2146
+ if (readonly) {
2147
+ root.setAttribute('aria-readonly', 'true');
2148
+ root.classList.add('is-readonly');
2149
+ }
2150
+ root.setAttribute('tabindex', locked ? '-1' : (effectiveKeyboard ? '0' : '-1'));
2151
+ root.setAttribute('aria-orientation', 'horizontal');
2152
+ root.setAttribute('aria-valuemin', String(merged.min ?? 0));
2153
+ root.setAttribute('aria-valuemax', String(merged.max ?? 100));
2154
+ root.setAttribute('aria-valuenow', String(preset.initialProgress));
2155
+ const updateAria = () => {
2156
+ root.setAttribute(
2157
+ 'aria-label',
2158
+ customLabel || interpolate(copy.progressAria, { brand: copy.brandName, code: preset.code, name: preset.name })
2159
+ );
2160
+ };
2161
+ updateAria();
2162
+ const canvas = document.createElement('canvas');
2163
+ canvas.className = 'hj-progress-canvas';
2164
+ canvas.setAttribute('aria-hidden', 'true');
2165
+
2166
+ // Slot containers are always present but transparent by default; they only
2167
+ // provide geometry, never typography/padding/background (docs: 插槽 CSS 约定).
2168
+ const fillContent = (layer, content) => {
2169
+ layer.innerHTML = '';
2170
+ if (content == null) return;
2171
+ if (typeof content === 'string') {
2172
+ layer.innerHTML = content;
2173
+ return;
2174
+ }
2175
+ const nodes = Array.isArray(content) ? content : [content];
2176
+ for (const node of nodes) {
2177
+ if (node && typeof node.nodeType === 'number') layer.appendChild(node);
2178
+ }
2179
+ };
2180
+
2181
+ const textLayer = document.createElement('div');
2182
+ textLayer.className = 'hj-progress-text hj-progress-copy';
2183
+
2184
+ const visualLayer = document.createElement('div');
2185
+ visualLayer.className = 'hj-progress-visual';
2186
+
2187
+ fillContent(textLayer, options.text);
2188
+ fillContent(visualLayer, options.colorContent);
2189
+
2190
+ const valueElement = document.createElement('div');
2191
+ valueElement.className = 'hj-progress-value';
2192
+ valueElement.setAttribute('aria-hidden', 'true');
2193
+ valueElement.hidden = merged.showValue === false;
2194
+
2195
+ root.appendChild(canvas);
2196
+ root.appendChild(textLayer);
2197
+ root.appendChild(visualLayer);
2198
+ root.appendChild(valueElement);
2199
+ container.appendChild(root);
2200
+
2201
+ const emitter = createEmitter();
2202
+ let manuallyPaused = merged.paused === true;
2203
+ let reducedPaused = false;
2204
+ let staticMode = merged.static === true;
2205
+ let contextLost = false;
2206
+ let disposed = false;
2207
+ let renderOnce = () => {};
2208
+ const onContextLost = () => {
2209
+ contextLost = true;
2210
+ emitter.emit('contextlost', {});
2211
+ };
2212
+ const onContextRestored = () => {
2213
+ contextLost = false;
2214
+ emitter.emit('contextrestored', {});
2215
+ renderOnce();
2216
+ wakeScheduler();
2217
+ };
2218
+
2219
+ const applySize = () => {
2220
+ root.style.width = parseSize(merged.width);
2221
+ root.style.height = parseSize(merged.height);
2222
+ const vars = merged.cssVars || {};
2223
+ for (const name of Object.keys(vars)) root.style.setProperty(name, vars[name]);
2224
+ if (merged.textRatio !== undefined) {
2225
+ root.style.setProperty('--hj-text-width', `${merged.textRatio}%`);
2226
+ }
2227
+ };
2228
+ applySize();
2229
+
2230
+ const controller = new ProgressCapsuleController({
2231
+ root,
2232
+ canvas,
2233
+ valueElement,
2234
+ preset,
2235
+ emitter,
2236
+ options: {
2237
+ ...merged,
2238
+ draggable: effectiveDraggable,
2239
+ keyboard: effectiveKeyboard,
2240
+ onResize: () => {
2241
+ if (manuallyPaused || reducedPaused || staticMode) renderOnce();
2242
+ }
2243
+ },
2244
+ copy,
2245
+ dirty
2246
+ });
2247
+
2248
+ let overlay = null;
2249
+ if (merged.renderer !== 'canvas2d') {
2250
+ overlay = attachProgressFlowOverlay({
2251
+ root,
2252
+ canvas,
2253
+ preset,
2254
+ getProgress: () => progressRatio(controller.value, controller.min, controller.max) * 100,
2255
+ dprCap: effectiveDprCap(merged.quality, merged.renderScale),
2256
+ powerPreference: merged.powerPreference,
2257
+ onContextLost,
2258
+ onContextRestored
2259
+ });
2260
+ }
2261
+ if (overlay) controller.webglActive = true;
2262
+ else if (merged.renderer !== 'canvas2d') {
2263
+ nextTick(() => {
2264
+ if (disposed) return;
2265
+ emitter.emit('error', { message: 'WebGL2 unavailable, using Canvas2D fallback' });
2266
+ });
2267
+ }
2268
+
2269
+ const visibility = createVisibilityGuard(root, wakeScheduler);
2270
+ const motionPreference = createReducedMotionPreference(
2271
+ merged.respectReducedMotion,
2272
+ (matches) => {
2273
+ reducedPaused = matches;
2274
+ if (matches) renderOnce();
2275
+ else wakeScheduler();
2276
+ }
2277
+ );
2278
+ reducedPaused = motionPreference.matches();
2279
+ const isMotionPaused = () => manuallyPaused || reducedPaused || staticMode || contextLost;
2280
+ let flowTime = 0;
2281
+ const frameGate = createFrameGate(merged.fps);
2282
+ renderOnce = () => {
2283
+ controller.draw();
2284
+ if (overlay) overlay.update(flowTime);
2285
+ };
2286
+ renderOnce();
2287
+ const offPausedChange = emitter.on('change', () => {
2288
+ if (isMotionPaused()) renderOnce();
2289
+ });
2290
+ const unsubscribe = subscribeScheduler(
2291
+ (delta, now) => {
2292
+ flowTime += delta;
2293
+ controller.update(delta, false);
2294
+ if (frameGate.shouldDraw(delta)) {
2295
+ controller.draw();
2296
+ if (overlay) overlay.update(flowTime);
2297
+ }
2298
+ },
2299
+ () => isMotionPaused() || !visibility.isVisible()
2300
+ );
2301
+
2302
+ nextTick(() => {
2303
+ if (disposed) return;
2304
+ emitter.emit('ready', { preset: { ...preset } });
2305
+ });
2306
+
2307
+ const syncDom = () => {
2308
+ updateAria();
2309
+ };
2310
+
2311
+ return {
2312
+ element: root,
2313
+ canvas,
2314
+ preset,
2315
+ on: emitter.on,
2316
+ off: emitter.off,
2317
+ setValue(value, source = 'prop') {
2318
+ controller.setProgress(value, source);
2319
+ return this;
2320
+ },
2321
+ getValue() {
2322
+ return controller.value;
2323
+ },
2324
+ setRange(min, max) {
2325
+ controller.setRange(min, max);
2326
+ if (isMotionPaused()) renderOnce();
2327
+ return this;
2328
+ },
2329
+ setPreset(ref) {
2330
+ const next = getPreset('progress', ref);
2331
+ dirty.preset = true;
2332
+ Object.assign(preset, next);
2333
+ if (colorOverride) preset.colors = [...colorOverride];
2334
+ if (edgeStyleOverride) preset.edgeStyle = edgeStyleOverride;
2335
+ const nextColors = preset.colors.map(normalizeColor);
2336
+ if (nextColors.every(Boolean)) preset.colors = nextColors;
2337
+ controller.preset = preset;
2338
+ controller.profile = preset.edgeStyle === 'tide'
2339
+ ? FLOW_PROFILES.tide
2340
+ : (FLOW_PROFILES[preset.id] || FLOW_PROFILES['visual-training']);
2341
+ controller.flowTime = stringSeed(next.id) * 31;
2342
+ controller.seed = stringSeed(`${next.id}-reference`) * Math.PI * 2;
2343
+ syncDom();
2344
+ if (overlay) {
2345
+ overlay.dispose();
2346
+ contextLost = false;
2347
+ overlay = attachProgressFlowOverlay({
2348
+ root,
2349
+ canvas,
2350
+ preset,
2351
+ getProgress: () => progressRatio(controller.value, controller.min, controller.max) * 100,
2352
+ dprCap: effectiveDprCap(merged.quality, merged.renderScale),
2353
+ powerPreference: merged.powerPreference,
2354
+ onContextLost,
2355
+ onContextRestored
2356
+ });
2357
+ }
2358
+ controller.webglActive = Boolean(overlay);
2359
+ controller.resizeCanvas();
2360
+ if (isMotionPaused()) renderOnce();
2361
+ emitter.emit('presetchange', { preset: { ...preset } });
2362
+ return this;
2363
+ },
2364
+ setEdgeStyle(edgeStyle) {
2365
+ const value = String(edgeStyle || '').toLowerCase();
2366
+ if (value !== 'flow' && value !== 'tide') return this;
2367
+ dirty.edgeStyle = true;
2368
+ edgeStyleOverride = value;
2369
+ preset.edgeStyle = value;
2370
+ controller.profile = value === 'tide'
2371
+ ? FLOW_PROFILES.tide
2372
+ : (FLOW_PROFILES[preset.id] || FLOW_PROFILES['visual-training']);
2373
+ if (overlay) {
2374
+ overlay.dispose();
2375
+ contextLost = false;
2376
+ overlay = attachProgressFlowOverlay({
2377
+ root,
2378
+ canvas,
2379
+ preset,
2380
+ getProgress: () => progressRatio(controller.value, controller.min, controller.max) * 100,
2381
+ dprCap: effectiveDprCap(merged.quality, merged.renderScale),
2382
+ powerPreference: merged.powerPreference,
2383
+ onContextLost,
2384
+ onContextRestored
2385
+ });
2386
+ }
2387
+ controller.webglActive = Boolean(overlay);
2388
+ controller.resizeCanvas();
2389
+ if (isMotionPaused()) renderOnce();
2390
+ return this;
2391
+ },
2392
+ setText(content) {
2393
+ fillContent(textLayer, content);
2394
+ return this;
2395
+ },
2396
+ setColorContent(content) {
2397
+ fillContent(visualLayer, content);
2398
+ return this;
2399
+ },
2400
+ setTextRatio(ratio) {
2401
+ const value = Number(ratio);
2402
+ if (!Number.isFinite(value) || value < 0 || value > 100) return this;
2403
+ dirty.textRatio = true;
2404
+ merged.textRatio = value;
2405
+ root.style.setProperty('--hj-text-width', `${value}%`);
2406
+ return this;
2407
+ },
2408
+ setCssVars(vars) {
2409
+ if (!vars || typeof vars !== 'object') return this;
2410
+ dirty.cssVars = true;
2411
+ merged.cssVars = { ...(merged.cssVars || {}), ...vars };
2412
+ for (const name of Object.keys(vars)) root.style.setProperty(name, vars[name]);
2413
+ return this;
2414
+ },
2415
+ setLabel(label) {
2416
+ customLabel = typeof label === 'string' && label ? label : null;
2417
+ updateAria();
2418
+ return this;
2419
+ },
2420
+ setValueSuffix(suffix) {
2421
+ controller.setValueSuffix(suffix);
2422
+ return this;
2423
+ },
2424
+ setStep(step) {
2425
+ controller.setStep(step);
2426
+ return this;
2427
+ },
2428
+ setPrecision(precision) {
2429
+ controller.setPrecision(precision);
2430
+ return this;
2431
+ },
2432
+ setFormatValue(formatter) {
2433
+ controller.setFormatValue(formatter);
2434
+ return this;
2435
+ },
2436
+ setDirection(direction) {
2437
+ controller.setDirection(direction);
2438
+ return this;
2439
+ },
2440
+ setShowValue(show) {
2441
+ controller.setShowValue(show);
2442
+ return this;
2443
+ },
2444
+ setColors(colors) {
2445
+ const next = Array.isArray(colors) ? colors.map(normalizeColor) : [];
2446
+ if (next.length !== 4 || next.some((color) => !color)) return this;
2447
+ dirty.colors = true;
2448
+ colorOverride = [...next];
2449
+ preset.colors = [...next];
2450
+ controller.setColors(next);
2451
+ if (overlay) overlay.setColors(next);
2452
+ controller.resizeCanvas();
2453
+ if (isMotionPaused()) renderOnce();
2454
+ return this;
2455
+ },
2456
+ setSize(width, height) {
2457
+ if (width !== undefined) {
2458
+ parseSize(width);
2459
+ merged.width = width;
2460
+ }
2461
+ if (height !== undefined) {
2462
+ parseSize(height);
2463
+ merged.height = height;
2464
+ }
2465
+ applySize();
2466
+ controller.resizeCanvas();
2467
+ if (isMotionPaused()) renderOnce();
2468
+ return this;
2469
+ },
2470
+ randomize() {
2471
+ flowTime = controller.randomize();
2472
+ if (isMotionPaused()) renderOnce();
2473
+ return this;
2474
+ },
2475
+ pause() {
2476
+ dirty.paused = true;
2477
+ manuallyPaused = true;
2478
+ renderOnce();
2479
+ return this;
2480
+ },
2481
+ resume() {
2482
+ dirty.paused = true;
2483
+ manuallyPaused = false;
2484
+ wakeScheduler();
2485
+ return this;
2486
+ },
2487
+ setPaused(value) {
2488
+ return value ? this.pause() : this.resume();
2489
+ },
2490
+ setStatic(value) {
2491
+ dirty.static = true;
2492
+ staticMode = value === true;
2493
+ merged.static = staticMode;
2494
+ if (staticMode) renderOnce();
2495
+ else wakeScheduler();
2496
+ return this;
2497
+ },
2498
+ setFps(fps) {
2499
+ dirty.fps = true;
2500
+ merged.fps = frameGate.setFps(fps);
2501
+ wakeScheduler();
2502
+ return this;
2503
+ },
2504
+ setQuality(quality) {
2505
+ dirty.quality = true;
2506
+ merged.quality = quality;
2507
+ controller.dprCap = effectiveDprCap(quality, merged.renderScale);
2508
+ controller.resizeCanvas();
2509
+ if (overlay) overlay.setDprCap(controller.dprCap);
2510
+ if (isMotionPaused()) renderOnce();
2511
+ return this;
2512
+ },
2513
+ setRenderScale(renderScale) {
2514
+ const value = Number(renderScale);
2515
+ if (!Number.isFinite(value)) return this;
2516
+ dirty.renderScale = true;
2517
+ merged.renderScale = Math.min(1, Math.max(0.25, value));
2518
+ controller.dprCap = effectiveDprCap(merged.quality, merged.renderScale);
2519
+ controller.resizeCanvas();
2520
+ if (overlay) overlay.setDprCap(controller.dprCap);
2521
+ if (isMotionPaused()) renderOnce();
2522
+ return this;
2523
+ },
2524
+ dispose() {
2525
+ disposed = true;
2526
+ unsubscribe();
2527
+ offPausedChange();
2528
+ visibility.dispose();
2529
+ motionPreference.dispose();
2530
+ if (overlay) overlay.dispose();
2531
+ controller.dispose();
2532
+ root.remove();
2533
+ },
2534
+ get textRatio() { return merged.textRatio; },
2535
+ get cssVars() { return merged.cssVars; },
2536
+ get min() { return controller.min; },
2537
+ get max() { return controller.max; },
2538
+ get step() { return controller.step; },
2539
+ get precision() { return controller.precision; },
2540
+ get formatValue() { return controller.formatValue; },
2541
+ get direction() { return controller.direction; },
2542
+ get showValue() { return !valueElement.hidden; },
2543
+ get valueSuffix() { return controller.valueSuffix; },
2544
+ get quality() { return merged.quality; },
2545
+ get renderScale() { return merged.renderScale; },
2546
+ get paused() { return manuallyPaused; },
2547
+ get static() { return staticMode; },
2548
+ get fps() { return frameGate.getFps(); },
2549
+ dirty
2550
+ };
2446
2551
  }
2447
2552
 
2448
2553
  export { ProgressCapsuleController, createProgressCapsule };