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