@5even7/dlc-ui 0.1.0

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.
@@ -0,0 +1,831 @@
1
+ /**
2
+ * Tiny event emitter. Accepts any event name so the API stays open for
3
+ * future events (hover, click, custom) without breaking changes.
4
+ */
5
+ function createEmitter() {
6
+ // Plain object storage (no Map/Set) so the legacy build has no API
7
+ // dependencies beyond what IE11 provides.
8
+ const listeners = Object.create(null);
9
+
10
+ return {
11
+ on(event, fn) {
12
+ if (!listeners[event]) listeners[event] = [];
13
+ listeners[event].push(fn);
14
+ return () => {
15
+ const current = listeners[event];
16
+ if (current) {
17
+ const index = current.indexOf(fn);
18
+ if (index !== -1) current.splice(index, 1);
19
+ }
20
+ };
21
+ },
22
+ off(event, fn) {
23
+ const current = listeners[event];
24
+ if (!current) return false;
25
+ const index = current.indexOf(fn);
26
+ if (index === -1) return false;
27
+ current.splice(index, 1);
28
+ return true;
29
+ },
30
+ emit(event, ...args) {
31
+ const current = listeners[event];
32
+ if (!current) return;
33
+ for (const fn of current.slice()) fn(...args);
34
+ }
35
+ };
36
+ }
37
+
38
+ const UNIT_PATTERN = /^[\d.]+(?:px|%|vw|vh|vmin|vmax|em|rem)$/;
39
+
40
+ /**
41
+ * Normalize a size option to a CSS length string.
42
+ * Accepts numbers (treated as px) or strings with px/%, vw/vh/vmin/vmax, em/rem.
43
+ */
44
+ function parseSize(value) {
45
+ if (typeof value === 'number') {
46
+ if (!Number.isFinite(value) || value < 0) throw new Error(`Invalid size: ${value}`);
47
+ return `${value}px`;
48
+ }
49
+ if (typeof value !== 'string') throw new Error(`Invalid size: ${String(value)}`);
50
+ const trimmed = value.trim();
51
+ if (!trimmed) throw new Error('Invalid size: empty string');
52
+ if (/^\d+(?:\.\d+)?$/.test(trimmed)) return `${trimmed}px`;
53
+ if (!UNIT_PATTERN.test(trimmed)) {
54
+ throw new Error(`Invalid size or unsupported unit: "${value}" (use px, %, vw, vh, vmin, vmax, em, rem)`);
55
+ }
56
+ return trimmed;
57
+ }
58
+
59
+ const QUALITY_TIERS = {
60
+ low: { dpr: 1 },
61
+ medium: { dpr: 1.5 },
62
+ high: { dpr: 2 }
63
+ };
64
+
65
+ function autoDprCap() {
66
+ const nav = typeof navigator !== 'undefined' ? navigator : null;
67
+ const memory = nav && typeof nav.deviceMemory === 'number' ? nav.deviceMemory : 8;
68
+ const cores = nav && typeof nav.hardwareConcurrency === 'number' ? nav.hardwareConcurrency : 8;
69
+ return memory <= 4 || cores <= 4 ? 1.5 : 2;
70
+ }
71
+
72
+ function dprCapFor(quality) {
73
+ if (quality === 'auto') return autoDprCap();
74
+ const tier = QUALITY_TIERS[quality] || QUALITY_TIERS.medium;
75
+ return tier.dpr;
76
+ }
77
+
78
+ /**
79
+ * Merge defaults < preset < user. Unknown user keys are preserved so the
80
+ * component API can grow (new props, cssVars, callbacks) without a breaking
81
+ * change.
82
+ */
83
+ function normalizeOptions(defaults, preset, user) {
84
+ // undefined means "not provided" (e.g. Vue $props with unset props):
85
+ // drop those keys before merging so defaults/preset values survive.
86
+ const omitUndefined = (source) => {
87
+ const result = {};
88
+ for (const key of Object.keys(source)) {
89
+ if (source[key] !== undefined) result[key] = source[key];
90
+ }
91
+ return result;
92
+ };
93
+ const presetOptions = omitUndefined(preset && typeof preset === 'object' ? preset : {});
94
+ const userOptions = omitUndefined(user && typeof user === 'object' ? user : {});
95
+ return { ...defaults, ...presetOptions, ...userOptions };
96
+ }
97
+
98
+ /**
99
+ * 公共色板:NC-01~NC-06 的四色组(底色 / 主色 / 辅色 / 高光色)。
100
+ * Capsule 预置与 dlc-color 等组件共用,新增色板只改这里。
101
+ */
102
+ const PALETTES = {
103
+ original: ['#FFF3EA', '#F5B27A', '#F67BC6', '#A978E8'],
104
+ ocean: ['#EAF6FF', '#8FD0FF', '#3B87F6', '#6B58E9'],
105
+ klein: ['#EDF2FF', '#2F58D5', '#1B2040', '#E07A43'],
106
+ ultraviolet: ['#F2EEFF', '#B99AF1', '#8F74DB', '#D7D85C'],
107
+ chrome: ['#F5F6F8', '#B9C0CC', '#7F8793', '#4A4F59'],
108
+ plus: ['#FFF0E6', '#F6C26B', '#F98A64', '#E86D74']
109
+ };
110
+
111
+ /**
112
+ * Capsule 预置:仅星云材质 NC-01~NC-06(NC-07~09 aurora 已移除)。
113
+ * 颜色引用公共色板,seed/speed 决定形态与流速。
114
+ */
115
+ const CAPSULE_PRESETS = [
116
+ { id: 'original', code: 'NC-01', name: 'ORIGINAL', group: 'warm', seed: 1.7, speed: 0.5, colors: [...PALETTES.original] },
117
+ { id: 'ocean', code: 'NC-02', name: 'OCEAN', group: 'cold', seed: 8.2, speed: 0.48, colors: [...PALETTES.ocean] },
118
+ { id: 'klein', code: 'NC-03', name: 'KLEIN', group: 'cold', seed: 14.1, speed: 0.49, colors: [...PALETTES.klein] },
119
+ { id: 'ultraviolet', code: 'NC-04', name: 'ULTRAVIOLET', group: 'cold', seed: 23.4, speed: 0.47, colors: [...PALETTES.ultraviolet] },
120
+ { id: 'chrome', code: 'NC-05', name: 'CHROME', group: 'cold', seed: 37.8, speed: 0.42, colors: [...PALETTES.chrome] },
121
+ { id: 'plus', code: 'NC-06', name: 'PLUS', group: 'warm', seed: 51.3, speed: 0.5, colors: [...PALETTES.plus] }
122
+ ];
123
+
124
+ function validatePreset(preset) {
125
+ return Boolean(
126
+ preset &&
127
+ typeof preset.id === 'string' &&
128
+ typeof preset.code === 'string' &&
129
+ typeof preset.name === 'string' &&
130
+ Number.isFinite(preset.seed) &&
131
+ Number.isFinite(preset.speed) &&
132
+ Array.isArray(preset.colors) &&
133
+ preset.colors.length === 4
134
+ );
135
+ }
136
+
137
+ function getPreset(kind, ref) {
138
+ const list = CAPSULE_PRESETS ;
139
+ if (!list) throw new Error(`Unknown preset kind: ${kind} (use "capsule" or "progress")`);
140
+ if (ref && typeof ref === 'object') {
141
+ const valid = validatePreset(ref) ;
142
+ if (!valid) throw new Error(`Invalid ${kind} preset object`);
143
+ return ref;
144
+ }
145
+ const key = String(ref).trim().toLowerCase();
146
+ const found = list.find(
147
+ (preset) =>
148
+ preset.id.toLowerCase() === key ||
149
+ preset.code.toLowerCase() === key ||
150
+ preset.name.toLowerCase() === key
151
+ );
152
+ if (!found) throw new Error(`Unknown ${kind} preset: ${ref}`);
153
+ return found;
154
+ }
155
+
156
+ const DEFAULTS = {
157
+ capsule: {
158
+ width: '100%',
159
+ height: 160,
160
+ quality: 'auto',
161
+ renderer: 'auto',
162
+ respectReducedMotion: true,
163
+ interactive: true,
164
+ mouseColor: true,
165
+ showCopy: true
166
+ }};
167
+
168
+ /**
169
+ * All user-facing copy lives here so wording/brand changes never require a
170
+ * global search. Templates use {brand} {code} {name} placeholders.
171
+ */
172
+ const COPY = {
173
+ brandName: '画境观屿',
174
+ dragLabel: 'DRAG',
175
+ valueSuffix: '%',
176
+ progressAria: '{brand} {code} 加载进度',
177
+ capsuleAria: '打开 {name} 沉浸预览'
178
+ };
179
+
180
+ function hexToRgb01(hex) {
181
+ const normalized = hex.replace('#', '');
182
+ const value = Number.parseInt(normalized, 16);
183
+ return [
184
+ ((value >> 16) & 255) / 255,
185
+ ((value >> 8) & 255) / 255,
186
+ (value & 255) / 255
187
+ ];
188
+ }
189
+
190
+ function hexToRgba(hex, alpha = 1) {
191
+ const normalized = hex.replace('#', '');
192
+ const value = Number.parseInt(normalized, 16);
193
+ const red = (value >> 16) & 255;
194
+ const green = (value >> 8) & 255;
195
+ const blue = value & 255;
196
+ return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
197
+ }
198
+
199
+ const VERTEX_SHADER = `#version 300 es
200
+ in vec2 a_position;
201
+ out vec2 v_uv;
202
+ void main() {
203
+ v_uv = a_position * 0.5 + 0.5;
204
+ gl_Position = vec4(a_position, 0.0, 1.0);
205
+ }`;
206
+
207
+ const FRAGMENT_SHADER = `#version 300 es
208
+ precision highp float;
209
+
210
+ in vec2 v_uv;
211
+ out vec4 outColor;
212
+
213
+ uniform vec2 u_resolution;
214
+ uniform float u_time;
215
+ uniform float u_seed;
216
+ uniform float u_motion;
217
+ uniform vec2 u_pointer;
218
+ uniform vec3 u_colorA;
219
+ uniform vec3 u_colorB;
220
+ uniform vec3 u_colorC;
221
+ uniform vec3 u_colorD;
222
+
223
+ float hash21(vec2 p) {
224
+ p = fract(p * vec2(123.34, 456.21));
225
+ p += dot(p, p + 45.32 + u_seed);
226
+ return fract(p.x * p.y);
227
+ }
228
+
229
+ float noise(vec2 p) {
230
+ vec2 i = floor(p);
231
+ vec2 f = fract(p);
232
+ f = f * f * (3.0 - 2.0 * f);
233
+ float a = hash21(i);
234
+ float b = hash21(i + vec2(1.0, 0.0));
235
+ float c = hash21(i + vec2(0.0, 1.0));
236
+ float d = hash21(i + vec2(1.0, 1.0));
237
+ return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
238
+ }
239
+
240
+ float fbm(vec2 p) {
241
+ float value = 0.0;
242
+ float amplitude = 0.52;
243
+ mat2 rotation = mat2(0.80, 0.60, -0.60, 0.80);
244
+ for (int i = 0; i < 6; i++) {
245
+ value += amplitude * noise(p);
246
+ p = rotation * p * 2.03 + 17.7;
247
+ amplitude *= 0.5;
248
+ }
249
+ return value;
250
+ }
251
+
252
+ float gaussian(float value, float center, float width) {
253
+ return exp(-pow(value - center, 2.0) / max(width, 0.0001));
254
+ }
255
+
256
+ vec3 palette(float t) {
257
+ t = clamp(t, 0.0, 1.0);
258
+ vec3 shadow = mix(u_colorA, u_colorB, smoothstep(0.06, 0.62, t));
259
+ vec3 body = mix(u_colorB, u_colorC, smoothstep(0.30, 0.82, t));
260
+ vec3 highlight = mix(u_colorC, u_colorD, smoothstep(0.74, 1.0, t));
261
+ vec3 restrained = mix(shadow, body, smoothstep(0.26, 0.72, t));
262
+ return mix(restrained, highlight, smoothstep(0.78, 0.97, t));
263
+ }
264
+
265
+ vec3 renderNebula(vec2 uv, vec2 p, vec2 pointer, float distanceToPointer, float t) {
266
+ vec2 delta = p - pointer;
267
+ float influence = exp(-distanceToPointer * 4.6) * u_motion;
268
+ float angle = influence * 1.7;
269
+ mat2 swirl = mat2(cos(angle), -sin(angle), sin(angle), cos(angle));
270
+ p = pointer + swirl * delta;
271
+ p += normalize(delta + 0.0001) * influence * 0.08;
272
+
273
+ vec2 drift = vec2(t * 0.22, -t * 0.13);
274
+ vec2 q = vec2(
275
+ fbm(p * 1.35 + drift + u_seed),
276
+ fbm(p * 1.35 + vec2(5.2, 1.3) - drift * 0.85)
277
+ );
278
+ vec2 r = vec2(
279
+ fbm(p * 2.0 + 3.6 * q + vec2(1.7, 9.2) + t * 0.10),
280
+ fbm(p * 2.0 + 3.0 * q + vec2(8.3, 2.8) - t * 0.085)
281
+ );
282
+
283
+ float cloud = fbm(p * 1.7 + 4.2 * r);
284
+ float veins = fbm(p * 4.0 - 2.0 * q + t * 0.065);
285
+ float nebula = smoothstep(0.18, 0.91, cloud * 0.9 + veins * 0.22);
286
+
287
+ vec3 color = palette(nebula);
288
+ color += u_colorD * pow(max(cloud - 0.63, 0.0), 2.0) * 1.05;
289
+ color *= 0.78 + 0.34 * smoothstep(0.15, 0.9, veins);
290
+
291
+ vec2 starGrid = floor((uv + vec2(u_seed * 0.013, 0.0)) * vec2(132.0, 58.0));
292
+ vec2 starCell = fract(uv * vec2(132.0, 58.0)) - 0.5;
293
+ float starRandom = hash21(starGrid);
294
+ float starShape = smoothstep(0.075, 0.0, length(starCell));
295
+ float starMask = step(0.989, starRandom) * starShape;
296
+ float twinkle = 0.35 + 0.65 * sin(t * (1.0 + starRandom * 2.4) + starRandom * 40.0) * 0.5 + 0.5;
297
+ color += starMask * twinkle * mix(u_colorC, u_colorD, starRandom) * 1.05;
298
+
299
+ float pointerGlow = exp(-distanceToPointer * 7.0) * u_motion;
300
+ color += u_colorD * pointerGlow * 0.28;
301
+ return color;
302
+ }
303
+
304
+ void main() {
305
+ vec2 uv = v_uv;
306
+ vec2 p = uv - 0.5;
307
+ p.x *= u_resolution.x / max(u_resolution.y, 1.0);
308
+
309
+ vec2 pointer = u_pointer - 0.5;
310
+ pointer.x *= u_resolution.x / max(u_resolution.y, 1.0);
311
+ float distanceToPointer = length(p - pointer);
312
+
313
+ vec3 color = renderNebula(uv, p, pointer, distanceToPointer, u_time);
314
+ float vignette = smoothstep(0.94, 0.18, length((uv - 0.5) * vec2(1.0, 1.35)));
315
+ color *= 0.70 + vignette * 0.42;
316
+ color = pow(max(color, vec3(0.0)), vec3(0.88));
317
+
318
+ outColor = vec4(color, 1.0);
319
+ }`;
320
+
321
+ function compileShader(gl, type, source) {
322
+ const shader = gl.createShader(type);
323
+ gl.shaderSource(shader, source);
324
+ gl.compileShader(shader);
325
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
326
+ const message = gl.getShaderInfoLog(shader) || 'Unknown shader compile error';
327
+ gl.deleteShader(shader);
328
+ throw new Error(message);
329
+ }
330
+ return shader;
331
+ }
332
+
333
+ function createProgram(gl) {
334
+ const vertex = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
335
+ const fragment = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
336
+ const program = gl.createProgram();
337
+ gl.attachShader(program, vertex);
338
+ gl.attachShader(program, fragment);
339
+ gl.linkProgram(program);
340
+ gl.deleteShader(vertex);
341
+ gl.deleteShader(fragment);
342
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
343
+ const message = gl.getProgramInfoLog(program) || 'Unknown program link error';
344
+ gl.deleteProgram(program);
345
+ throw new Error(message);
346
+ }
347
+ return program;
348
+ }
349
+
350
+ class CosmicRenderer {
351
+ constructor(canvas, preset, options = {}) {
352
+ this.canvas = canvas;
353
+ this.preset = { ...preset };
354
+ this.options = { dprCap: 1.75, mouseColor: true, ...options };
355
+ this.gl = canvas.getContext('webgl2', {
356
+ alpha: false,
357
+ antialias: false,
358
+ depth: false,
359
+ powerPreference: 'high-performance',
360
+ preserveDrawingBuffer: false
361
+ });
362
+ if (!this.gl) throw new Error('WebGL2 is not available');
363
+
364
+ this.program = createProgram(this.gl);
365
+ this.locations = this.#getLocations();
366
+ this.pointer = [0.72, 0.45];
367
+ this.pointerTarget = [...this.pointer];
368
+ this.motion = 0;
369
+ this.motionTarget = 0;
370
+ this.timeOffset = preset.seed * 0.73;
371
+ this.visible = true;
372
+ this.disposed = false;
373
+
374
+ this.#setupGeometry();
375
+ this.#bindEvents();
376
+ this.resize();
377
+ }
378
+
379
+ #getLocations() {
380
+ const gl = this.gl;
381
+ const uniform = (name) => gl.getUniformLocation(this.program, name);
382
+ return {
383
+ position: gl.getAttribLocation(this.program, 'a_position'),
384
+ resolution: uniform('u_resolution'),
385
+ time: uniform('u_time'),
386
+ seed: uniform('u_seed'),
387
+ motion: uniform('u_motion'),
388
+ pointer: uniform('u_pointer'),
389
+ colorA: uniform('u_colorA'),
390
+ colorB: uniform('u_colorB'),
391
+ colorC: uniform('u_colorC'),
392
+ colorD: uniform('u_colorD')
393
+ };
394
+ }
395
+
396
+ #setupGeometry() {
397
+ const gl = this.gl;
398
+ const vertices = new Float32Array([-1, -1, 3, -1, -1, 3]);
399
+ this.buffer = gl.createBuffer();
400
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
401
+ gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
402
+ }
403
+
404
+ #bindEvents() {
405
+ if (this.options.mouseColor === false) return;
406
+ this.eventTarget = this.options.eventTarget || this.canvas.parentElement || this.canvas;
407
+ this.onPointerMove = (event) => {
408
+ const rect = this.canvas.getBoundingClientRect();
409
+ this.pointerTarget[0] = (event.clientX - rect.left) / Math.max(rect.width, 1);
410
+ this.pointerTarget[1] = 1 - (event.clientY - rect.top) / Math.max(rect.height, 1);
411
+ this.motionTarget = 1;
412
+ };
413
+ this.onPointerLeave = () => { this.motionTarget = 0; };
414
+ this.eventTarget.addEventListener('pointermove', this.onPointerMove, { passive: true });
415
+ this.eventTarget.addEventListener('pointerdown', this.onPointerMove, { passive: true });
416
+ this.eventTarget.addEventListener('pointerleave', this.onPointerLeave, { passive: true });
417
+ }
418
+
419
+ setPreset(preset) {
420
+ this.preset = { ...preset };
421
+ this.timeOffset = preset.seed * 0.73;
422
+ }
423
+
424
+ setDprCap(cap) {
425
+ this.options.dprCap = cap;
426
+ this.resize();
427
+ }
428
+
429
+ randomize() {
430
+ this.preset.seed = Math.random() * 100;
431
+ this.timeOffset = Math.random() * 40;
432
+ }
433
+
434
+ resize() {
435
+ const dpr = Math.min(window.devicePixelRatio || 1, this.options.dprCap);
436
+ const rect = this.canvas.getBoundingClientRect();
437
+ const width = Math.max(2, Math.round(rect.width * dpr));
438
+ const height = Math.max(2, Math.round(rect.height * dpr));
439
+ if (this.canvas.width !== width || this.canvas.height !== height) {
440
+ this.canvas.width = width;
441
+ this.canvas.height = height;
442
+ this.gl.viewport(0, 0, width, height);
443
+ }
444
+ }
445
+
446
+ draw(elapsedSeconds, paused = false) {
447
+ if (this.disposed || !this.visible) return;
448
+ this.resize();
449
+ const gl = this.gl;
450
+ this.pointer[0] += (this.pointerTarget[0] - this.pointer[0]) * 0.08;
451
+ this.pointer[1] += (this.pointerTarget[1] - this.pointer[1]) * 0.08;
452
+ this.motion += (this.motionTarget - this.motion) * 0.07;
453
+
454
+ gl.useProgram(this.program);
455
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
456
+ gl.enableVertexAttribArray(this.locations.position);
457
+ gl.vertexAttribPointer(this.locations.position, 2, gl.FLOAT, false, 0, 0);
458
+
459
+ const colors = this.preset.colors.map(hexToRgb01);
460
+ gl.uniform2f(this.locations.resolution, this.canvas.width, this.canvas.height);
461
+ gl.uniform1f(this.locations.time, this.timeOffset + (paused ? 0 : elapsedSeconds * this.preset.speed));
462
+ gl.uniform1f(this.locations.seed, this.preset.seed);
463
+ gl.uniform1f(this.locations.motion, this.motion);
464
+ gl.uniform2f(this.locations.pointer, this.pointer[0], this.pointer[1]);
465
+ gl.uniform3fv(this.locations.colorA, colors[0]);
466
+ gl.uniform3fv(this.locations.colorB, colors[1]);
467
+ gl.uniform3fv(this.locations.colorC, colors[2]);
468
+ gl.uniform3fv(this.locations.colorD, colors[3]);
469
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
470
+ }
471
+
472
+ dispose() {
473
+ this.disposed = true;
474
+ const target = this.eventTarget || this.canvas;
475
+ target.removeEventListener('pointermove', this.onPointerMove);
476
+ target.removeEventListener('pointerdown', this.onPointerMove);
477
+ target.removeEventListener('pointerleave', this.onPointerLeave);
478
+ this.gl.deleteBuffer(this.buffer);
479
+ this.gl.deleteProgram(this.program);
480
+ const lose = this.gl.getExtension('WEBGL_lose_context');
481
+ if (lose) lose.loseContext();
482
+ }
483
+ }
484
+
485
+ function rgb(color, alpha = 1) {
486
+ const [r, g, b] = hexToRgb01(color).map((value) => Math.round(value * 255));
487
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
488
+ }
489
+
490
+ class FallbackRenderer {
491
+ constructor(canvas, preset) {
492
+ this.canvas = canvas;
493
+ this.preset = preset;
494
+ this.context = canvas.getContext('2d');
495
+ this.visible = true;
496
+ }
497
+
498
+ resize() {
499
+ const rect = this.canvas.getBoundingClientRect();
500
+ const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
501
+ const width = Math.max(2, Math.round(rect.width * dpr));
502
+ const height = Math.max(2, Math.round(rect.height * dpr));
503
+ if (this.canvas.width !== width || this.canvas.height !== height) {
504
+ this.canvas.width = width;
505
+ this.canvas.height = height;
506
+ }
507
+ }
508
+
509
+ drawNebula(time) {
510
+ const ctx = this.context;
511
+ const { width, height } = this.canvas;
512
+ const gradient = ctx.createLinearGradient(0, 0, width, height);
513
+ gradient.addColorStop(0, this.preset.colors[0]);
514
+ gradient.addColorStop(0.38, this.preset.colors[1]);
515
+ gradient.addColorStop(0.72, this.preset.colors[2]);
516
+ gradient.addColorStop(1, this.preset.colors[3]);
517
+ ctx.fillStyle = gradient;
518
+ ctx.fillRect(0, 0, width, height);
519
+
520
+ ctx.globalCompositeOperation = 'screen';
521
+ for (let index = 0; index < 6; index += 1) {
522
+ const x = (0.5 + 0.45 * Math.sin(time * 0.32 + index * 1.7)) * width;
523
+ const y = (0.5 + 0.4 * Math.cos(time * 0.25 + index)) * height;
524
+ const radius = Math.max(width, height) * (0.08 + (index % 3) * 0.04);
525
+ const glow = ctx.createRadialGradient(x, y, 0, x, y, radius);
526
+ glow.addColorStop(0, rgb(this.preset.colors[(index + 1) % 4], 0.24));
527
+ glow.addColorStop(1, rgb(this.preset.colors[index % 4], 0));
528
+ ctx.fillStyle = glow;
529
+ ctx.fillRect(x - radius, y - radius, radius * 2, radius * 2);
530
+ }
531
+ ctx.globalCompositeOperation = 'source-over';
532
+ }
533
+
534
+ draw(time) {
535
+ if (!this.visible) return;
536
+ this.resize();
537
+ this.drawNebula(time);
538
+ }
539
+
540
+ setPreset(preset) {
541
+ this.preset = preset;
542
+ }
543
+
544
+ randomize() {}
545
+ dispose() {}
546
+ }
547
+
548
+ /**
549
+ * Document-level shared rAF scheduler. Every component instance subscribes
550
+ * its own frame callback; the whole page runs ONE animation loop (like the
551
+ * original demo), which avoids jank from many competing rAF loops.
552
+ */
553
+ const subscribers = [];
554
+ let running = false;
555
+ let rafId = 0;
556
+ let last = 0;
557
+
558
+ function tick(now) {
559
+ if (!running) return;
560
+ const delta = last ? Math.min((now - last) / 1000, 0.05) : 0;
561
+ last = now;
562
+ // Schedule the next frame BEFORE running callbacks so one throwing
563
+ // subscriber can never kill the whole animation loop.
564
+ rafId = requestAnimationFrame(tick);
565
+ {
566
+ for (const item of subscribers.slice()) {
567
+ try {
568
+ if (!item.isPaused()) item.onFrame(delta, now);
569
+ } catch (error) {
570
+ console.warn('[dlc-ui] frame error:', error);
571
+ }
572
+ }
573
+ }
574
+ if (subscribers.length === 0) {
575
+ cancelAnimationFrame(rafId);
576
+ running = false;
577
+ rafId = 0;
578
+ }
579
+ }
580
+
581
+ function start() {
582
+ if (running) return;
583
+ running = true;
584
+ last = 0;
585
+ rafId = requestAnimationFrame(tick);
586
+ }
587
+
588
+ function subscribeScheduler(onFrame, isPaused) {
589
+ const item = { onFrame, isPaused };
590
+ subscribers.push(item);
591
+ start();
592
+ return () => {
593
+ const index = subscribers.indexOf(item);
594
+ if (index !== -1) subscribers.splice(index, 1);
595
+ if (subscribers.length === 0 && rafId) {
596
+ cancelAnimationFrame(rafId);
597
+ running = false;
598
+ rafId = 0;
599
+ }
600
+ };
601
+ }
602
+
603
+ /**
604
+ * Gates drawing on "element intersects viewport AND the page tab is visible".
605
+ * Falls back to always-visible when IntersectionObserver is unavailable.
606
+ */
607
+ function createVisibilityGuard(element) {
608
+ let intersecting = true;
609
+ let pageVisible = typeof document === 'undefined' || !document.hidden;
610
+ let disposed = false;
611
+ let observer = null;
612
+
613
+ if (typeof IntersectionObserver !== 'undefined') {
614
+ observer = new IntersectionObserver(
615
+ (entries) => {
616
+ intersecting = entries.some((entry) => entry.isIntersecting);
617
+ },
618
+ { rootMargin: '180px' }
619
+ );
620
+ observer.observe(element);
621
+ }
622
+
623
+ const onVisibilityChange = () => {
624
+ pageVisible = typeof document !== 'undefined' && !document.hidden;
625
+ };
626
+ if (typeof document !== 'undefined') {
627
+ document.addEventListener('visibilitychange', onVisibilityChange);
628
+ }
629
+
630
+ return {
631
+ isVisible() {
632
+ return intersecting && pageVisible;
633
+ },
634
+ dispose() {
635
+ if (disposed) return;
636
+ disposed = true;
637
+ if (observer) observer.disconnect();
638
+ if (typeof document !== 'undefined') {
639
+ document.removeEventListener('visibilitychange', onVisibilityChange);
640
+ }
641
+ }
642
+ };
643
+ }
644
+
645
+ function prefersReducedMotion() {
646
+ return typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
647
+ }
648
+
649
+ /**
650
+ * Mount a cosmic (nebula) capsule into `container`.
651
+ *
652
+ * Options: preset (id/code/name or object), width, height (number=px or
653
+ * string with px/%/vw/vh/em/rem), colors, seed, speed, quality, interactive,
654
+ * respectReducedMotion, copy, cssVars.
655
+ */
656
+ function createCapsule(container, options = {}) {
657
+ if (!container || typeof container.appendChild !== 'function') {
658
+ throw new Error('createCapsule: container element is required');
659
+ }
660
+
661
+ const preset = { ...getPreset('capsule', options.preset ?? 'NC-01') };
662
+ const merged = normalizeOptions(DEFAULTS.capsule, preset, options);
663
+ const copy = { ...COPY, ...(merged.copy || {}) };
664
+
665
+ const root = document.createElement('div');
666
+ root.className = 'hj-capsule-root hj-capsule-cosmic';
667
+ root.dataset.group = preset.group;
668
+ root.dataset.mode = 'nebula';
669
+ root.dataset.theme = preset.theme || 'light';
670
+ root.setAttribute('aria-label', copy.capsuleAria.replace('{name}', preset.name));
671
+
672
+ const copyEnabled = copy.enabled !== false && merged.showCopy !== false;
673
+
674
+ const copyText = (key, fallback) => {
675
+ const value = copy[key];
676
+ return value === undefined ? fallback : value;
677
+ };
678
+
679
+ function buildCopyHtml() {
680
+ const codeText = copyText('code', preset.code);
681
+ const nameText = copyText('name', preset.name);
682
+ const subtitleText = copyText('subtitle', preset.subtitle);
683
+ const stateText = copyText('state', 'LIVE COSMIC STUDY');
684
+ let html = '';
685
+ if (codeText) html += `<span class="hj-capsule-code">${codeText}</span>`;
686
+ if (nameText) html += `<span class="hj-capsule-name">${nameText}</span>`;
687
+ if (subtitleText) html += `<span class="hj-capsule-brand">${subtitleText}</span>`;
688
+ if (stateText) html += `<span class="hj-capsule-state">${stateText}</span>`;
689
+ return html;
690
+ }
691
+
692
+ const copyLayer = document.createElement('div');
693
+ copyLayer.className = 'hj-capsule-copy';
694
+ const renderCopy = () => {
695
+ if (copyEnabled) copyLayer.innerHTML = buildCopyHtml();
696
+ };
697
+ renderCopy();
698
+
699
+ const isHexColor = (value) => typeof value === 'string' && /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(value);
700
+ if (copy.background) {
701
+ root.style.setProperty('--hj-copy-bg', isHexColor(copy.background)
702
+ ? `linear-gradient(90deg, ${copy.background} 0%, ${copy.background} 74%, ${hexToRgba(copy.background, 0.97)} 84%, ${hexToRgba(copy.background, 0)} 100%)`
703
+ : copy.background);
704
+ }
705
+ if (copy.color) root.style.setProperty('--hj-copy-color', copy.color);
706
+
707
+ const canvas = document.createElement('canvas');
708
+ canvas.className = 'hj-capsule-canvas';
709
+ canvas.setAttribute('aria-hidden', 'true');
710
+
711
+ if (copyEnabled) root.appendChild(copyLayer);
712
+ root.appendChild(canvas);
713
+ container.appendChild(root);
714
+
715
+ const emitter = createEmitter();
716
+ let paused = merged.respectReducedMotion && prefersReducedMotion();
717
+
718
+ let renderer;
719
+ const useWebgl = merged.renderer !== 'canvas2d';
720
+ if (useWebgl) {
721
+ try {
722
+ renderer = new CosmicRenderer(canvas, merged, { dprCap: dprCapFor(merged.quality) });
723
+ } catch (error) {
724
+ renderer = new FallbackRenderer(canvas, merged);
725
+ emitter.emit('error', { message: String(error && error.message ? error.message : error) });
726
+ }
727
+ } else {
728
+ renderer = new FallbackRenderer(canvas, merged);
729
+ }
730
+
731
+ const applySize = () => {
732
+ root.style.width = parseSize(merged.width);
733
+ root.style.height = parseSize(merged.height);
734
+ const vars = merged.cssVars || {};
735
+ for (const name of Object.keys(vars)) root.style.setProperty(name, vars[name]);
736
+ renderer.resize();
737
+ };
738
+ applySize();
739
+
740
+ const resizeObserver = typeof ResizeObserver !== 'undefined'
741
+ ? new ResizeObserver(() => renderer.resize())
742
+ : null;
743
+ if (resizeObserver) resizeObserver.observe(root);
744
+ else window.addEventListener('resize', renderer.resize);
745
+
746
+ const visibility = createVisibilityGuard(root);
747
+
748
+ if (merged.interactive !== false) {
749
+ root.addEventListener('pointerenter', () => emitter.emit('pointerenter', { preset: { ...preset } }));
750
+ root.addEventListener('pointerleave', () => emitter.emit('pointerleave', { preset: { ...preset } }));
751
+ }
752
+ root.addEventListener('click', (event) => {
753
+ emitter.emit('click', { event, preset: { ...preset } });
754
+ });
755
+ root.addEventListener('pointerdown', (event) => emitter.emit('pointerdown', { event, preset: { ...preset } }));
756
+ root.addEventListener('pointerup', (event) => emitter.emit('pointerup', { event, preset: { ...preset } }));
757
+ root.addEventListener('dblclick', (event) => emitter.emit('dblclick', { event, preset: { ...preset } }));
758
+
759
+ let animationTime = 0;
760
+ const unsubscribe = subscribeScheduler(
761
+ (delta) => {
762
+ animationTime += delta;
763
+ if (visibility.isVisible()) renderer.draw(animationTime);
764
+ },
765
+ () => paused
766
+ );
767
+
768
+ emitter.emit('ready', { preset: { ...preset } });
769
+
770
+ const syncCopy = () => {
771
+ root.dataset.mode = 'nebula';
772
+ root.dataset.theme = preset.theme || 'light';
773
+ renderCopy();
774
+ };
775
+
776
+ return {
777
+ element: root,
778
+ canvas,
779
+ preset,
780
+ on: emitter.on,
781
+ off: emitter.off,
782
+ setPreset(ref) {
783
+ const next = getPreset('capsule', ref);
784
+ Object.assign(preset, next);
785
+ renderer.setPreset(next);
786
+ syncCopy();
787
+ renderer.resize();
788
+ emitter.emit('presetchange', { preset: { ...next } });
789
+ return this;
790
+ },
791
+ setColors(colors) {
792
+ if (!Array.isArray(colors) || colors.length !== 4) return this;
793
+ preset.colors = colors;
794
+ renderer.setPreset({ ...preset, colors });
795
+ return this;
796
+ },
797
+ setSize(width, height) {
798
+ if (width !== undefined) merged.width = width;
799
+ if (height !== undefined) merged.height = height;
800
+ applySize();
801
+ return this;
802
+ },
803
+ randomize() {
804
+ renderer.randomize();
805
+ return this;
806
+ },
807
+ pause() {
808
+ paused = true;
809
+ return this;
810
+ },
811
+ resume() {
812
+ paused = false;
813
+ return this;
814
+ },
815
+ setQuality(quality) {
816
+ merged.quality = quality;
817
+ if (typeof renderer.setDprCap === 'function') renderer.setDprCap(dprCapFor(quality));
818
+ return this;
819
+ },
820
+ dispose() {
821
+ unsubscribe();
822
+ visibility.dispose();
823
+ if (resizeObserver) resizeObserver.disconnect();
824
+ else window.removeEventListener('resize', renderer.resize);
825
+ renderer.dispose();
826
+ root.remove();
827
+ }
828
+ };
829
+ }
830
+
831
+ export { createCapsule };