@5even7/dlc-ui 0.2.11 → 0.2.12

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