@underpostnet/underpost 2.99.0 → 2.99.4

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.
@@ -3,8 +3,8 @@
3
3
  import { BtnIcon } from './BtnIcon.js';
4
4
  import { getId, random } from './CommonJs.js';
5
5
  import { dynamicCol } from './Css.js';
6
- import { htmls, s } from './VanillaJs.js';
7
-
6
+ import { fullScreenIn, htmls, s } from './VanillaJs.js';
7
+ import { Translate } from './Translate.js';
8
8
  // https://css-loaders.com/3d/
9
9
 
10
10
  const Polyhedron = {
@@ -17,11 +17,647 @@ const Polyhedron = {
17
17
  ct: [0, 0, 0],
18
18
  dim: 150,
19
19
  interval: null,
20
+ immersive: false,
21
+ immersiveParticles: null,
22
+ immersiveRAF: null,
23
+ immersiveStart: 0,
24
+ immersiveSeed: random(0, 1000000000),
25
+ immersiveEffect: 'waveLight', // 'waveLight' | 'darkElectronic' | 'prismBloom' | 'noirEmbers'
26
+ faceOpacity: 1,
27
+ chosenFace: 'front',
28
+ faces: {
29
+ front: null,
30
+ back: null,
31
+ left: null,
32
+ right: null,
33
+ top: null,
34
+ bottom: null,
35
+ },
36
+ };
37
+
38
+ const getFaceSelector = (faceName) => `.face_${faceName}-${id}`;
39
+ const applyFaceBackground = (faceName) => {
40
+ const el = s(getFaceSelector(faceName));
41
+ if (!el) return;
42
+ const url = this.Tokens[id].faces?.[faceName];
43
+
44
+ if (url) {
45
+ el.style.backgroundImage = `url("${url}")`;
46
+
47
+ // Always cover the full face area.
48
+ // Using `cover` ensures full coverage even during 3D transforms / resizes.
49
+ el.style.backgroundSize = 'cover';
50
+ el.style.backgroundPosition = 'center center';
51
+ el.style.backgroundRepeat = 'no-repeat';
52
+ } else {
53
+ el.style.backgroundImage = '';
54
+ el.style.backgroundSize = '';
55
+ el.style.backgroundPosition = '';
56
+ el.style.backgroundRepeat = '';
57
+ }
58
+ };
59
+ const applyAllFaceBackgrounds = () => {
60
+ ['front', 'back', 'left', 'right', 'top', 'bottom'].forEach(applyFaceBackground);
61
+ };
62
+
63
+ const applyFaceOpacity = () => {
64
+ const opacity = typeof this.Tokens[id].faceOpacity === 'number' ? this.Tokens[id].faceOpacity : 1;
65
+ const faces = document.querySelectorAll(`.face-${id}`);
66
+ faces.forEach((el) => {
67
+ el.style.opacity = `${opacity}`;
68
+ });
69
+ };
70
+
71
+ const startImmersiveEffects = () => {
72
+ const scene = s(`.scene-${id}`);
73
+ if (!scene) return;
74
+
75
+ const canvas = s(`.polyhedron-immersive-canvas-${id}`);
76
+ if (!canvas) return;
77
+
78
+ if (!s(`.main-body-btn-ui-close`).classList.contains('hide')) s(`.main-body-btn-ui`).click();
79
+ if (!s(`.main-body-btn-ui-menu-close`).classList.contains('hide')) s(`.main-body-btn-menu`).click();
80
+ fullScreenIn();
81
+ // Ensure canvas is visible during immersive mode.
82
+ canvas.style.display = 'block';
83
+
84
+ const ctx = canvas.getContext('2d');
85
+ if (!ctx) return;
86
+
87
+ const resize = () => {
88
+ const dpr = Math.max(1, window.devicePixelRatio || 1);
89
+ const w = scene.clientWidth || window.innerWidth;
90
+ const h = scene.clientHeight || window.innerHeight;
91
+ canvas.width = Math.floor(w * dpr);
92
+ canvas.height = Math.floor(h * dpr);
93
+ canvas.style.width = `${w}px`;
94
+ canvas.style.height = `${h}px`;
95
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
96
+ };
97
+
98
+ resize();
99
+
100
+ const mkParticlesWaveLight = () => {
101
+ const count = 110;
102
+ const w = scene.clientWidth || window.innerWidth;
103
+ const h = scene.clientHeight || window.innerHeight;
104
+ return Array.from({ length: count }).map((_, i) => {
105
+ const r = random(1, 4);
106
+ return {
107
+ x: random(0, w),
108
+ y: random(0, h),
109
+ vx: (random(-100, 100) / 100) * 0.25,
110
+ vy: (random(-100, 100) / 100) * 0.25,
111
+ r,
112
+ phase: random(0, 628) / 100,
113
+ i,
114
+ };
115
+ });
116
+ };
117
+
118
+ const mkParticlesDarkElectronic = () => {
119
+ const count = 170;
120
+ const w = scene.clientWidth || window.innerWidth;
121
+ const h = scene.clientHeight || window.innerHeight;
122
+ return Array.from({ length: count }).map((_, i) => {
123
+ const r = random(1, 3);
124
+ return {
125
+ x: random(0, w),
126
+ y: random(0, h),
127
+ // slower, more "system-like" motion so the pipe network reads clearly
128
+ vx: (random(-100, 100) / 100) * 0.18,
129
+ vy: (random(-100, 100) / 100) * 0.18,
130
+ r,
131
+ phase: random(0, 628) / 100,
132
+ i,
133
+ // stronger spark makes "nodes" pop
134
+ spark: random(0, 1000) / 1000,
135
+ };
136
+ });
20
137
  };
138
+
139
+ // New (light): Prism Bloom — bright prismatic bursts with bloom + gently swirling motion
140
+ const mkParticlesPrismBloom = () => {
141
+ const count = 160;
142
+ const w = scene.clientWidth || window.innerWidth;
143
+ const h = scene.clientHeight || window.innerHeight;
144
+ return Array.from({ length: count }).map((_, i) => {
145
+ const r = random(1, 4);
146
+ return {
147
+ x: random(0, w),
148
+ y: random(0, h),
149
+ vx: (random(-100, 100) / 100) * 0.18,
150
+ vy: (random(-100, 100) / 100) * 0.18,
151
+ r,
152
+ phase: random(0, 628) / 100,
153
+ i,
154
+ // prismatic palette offsets + burst timing
155
+ hueOffset: random(0, 360),
156
+ jitter: random(0, 1000) / 1000,
157
+ };
158
+ });
159
+ };
160
+
161
+ // New (dark): Noir Embers — smoky dark with warm embers + occasional flicker
162
+ const mkParticlesNoirEmbers = () => {
163
+ // Repurposed into a much more eye-catching "full fire" effect:
164
+ // denser, brighter, more turbulent + extra per-particle state for tongues of flame.
165
+ const count = 240;
166
+ const w = scene.clientWidth || window.innerWidth;
167
+ const h = scene.clientHeight || window.innerHeight;
168
+ return Array.from({ length: count }).map((_, i) => {
169
+ const r = random(1, 4);
170
+ return {
171
+ x: random(0, w),
172
+ y: random(h * 0.25, h + 60),
173
+ vx: (random(-100, 100) / 100) * 0.14,
174
+ vy: (random(-100, 100) / 100) * 0.35,
175
+ r,
176
+ phase: random(0, 628) / 100,
177
+ i,
178
+ heat: random(0, 1000) / 1000,
179
+ flicker: random(0, 1000) / 1000,
180
+ // flame "tongue" shape & motion
181
+ curl: random(0, 1000) / 1000,
182
+ life: random(0, 1000) / 1000,
183
+ // small bias so some particles stretch more than others
184
+ stretch: 0.6 + (random(0, 1000) / 1000) * 1.6,
185
+ };
186
+ });
187
+ };
188
+
189
+ const resetParticlesForEffect = () => {
190
+ const eff = this.Tokens[id].immersiveEffect || 'waveLight';
191
+ console.error(eff);
192
+ if (eff === 'darkElectronic') this.Tokens[id].immersiveParticles = mkParticlesDarkElectronic();
193
+ else if (eff === 'prismBloom') this.Tokens[id].immersiveParticles = mkParticlesPrismBloom();
194
+ else if (eff === 'noirEmbers') this.Tokens[id].immersiveParticles = mkParticlesNoirEmbers();
195
+ else this.Tokens[id].immersiveParticles = mkParticlesWaveLight();
196
+ };
197
+
198
+ resetParticlesForEffect();
199
+ this.Tokens[id].immersiveStart = performance.now();
200
+
201
+ const tick = (t) => {
202
+ if (!this.Tokens[id].immersive) return;
203
+
204
+ // Keep canvas sized to the scene.
205
+ if (canvas.clientWidth !== scene.clientWidth || canvas.clientHeight !== scene.clientHeight) resize();
206
+
207
+ const w2 = canvas.clientWidth || window.innerWidth;
208
+ const h2 = canvas.clientHeight || window.innerHeight;
209
+
210
+ const tt = (t - this.Tokens[id].immersiveStart) / 1000;
211
+ const eff = this.Tokens[id].immersiveEffect || 'waveLight';
212
+
213
+ if (eff === 'darkElectronic') {
214
+ // Dark electronic: GREEN PIPES — grid/pipe network with bright green flow + pulsing nodes
215
+ ctx.fillStyle = 'rgba(0,0,0,1)';
216
+ ctx.fillRect(0, 0, w2, h2);
217
+
218
+ // Pipe layout (more orthogonal so it reads like piping)
219
+ const base = 120; // green hue
220
+ const step = 52;
221
+ const driftX = Math.sin(tt * 0.35) * 10;
222
+ const driftY = Math.cos(tt * 0.28) * 10;
223
+
224
+ // faint background glow to lift the pipes off black
225
+ ctx.globalAlpha = 0.18;
226
+ const bgG = ctx.createRadialGradient(w2 * 0.5, h2 * 0.5, 0, w2 * 0.5, h2 * 0.5, Math.min(w2, h2) * 0.95);
227
+ bgG.addColorStop(0, `hsla(${base}, 90%, 18%, 0.9)`);
228
+ bgG.addColorStop(1, 'rgba(0,0,0,0)');
229
+ ctx.fillStyle = bgG;
230
+ ctx.fillRect(0, 0, w2, h2);
231
+ ctx.globalAlpha = 1;
232
+
233
+ // scanlines, very subtle
234
+ ctx.globalAlpha = 0.04;
235
+ ctx.fillStyle = 'rgba(255,255,255,1)';
236
+ for (let y = 0; y < h2; y += 3) ctx.fillRect(0, y, w2, 1);
237
+ ctx.globalAlpha = 1;
238
+
239
+ // Pipes: horizontal lines
240
+ ctx.lineWidth = 2;
241
+ for (let y = 0; y <= h2 + step; y += step) {
242
+ const yy = y + (Math.sin(tt * 0.6 + y * 0.02) * 2 + driftY);
243
+ // glow pass
244
+ ctx.globalAlpha = 0.15;
245
+ ctx.strokeStyle = `hsla(${base}, 100%, 70%, 1)`;
246
+ ctx.beginPath();
247
+ ctx.moveTo(0, yy);
248
+ ctx.lineTo(w2, yy);
249
+ ctx.stroke();
250
+
251
+ // core pass
252
+ ctx.globalAlpha = 0.35;
253
+ ctx.strokeStyle = `hsla(${base}, 100%, 55%, 1)`;
254
+ ctx.beginPath();
255
+ ctx.moveTo(0, yy);
256
+ ctx.lineTo(w2, yy);
257
+ ctx.stroke();
258
+ }
259
+ ctx.globalAlpha = 1;
260
+
261
+ // Pipes: vertical lines
262
+ for (let x = 0; x <= w2 + step; x += step) {
263
+ const xx = x + (Math.cos(tt * 0.55 + x * 0.02) * 2 + driftX);
264
+ // glow pass
265
+ ctx.globalAlpha = 0.15;
266
+ ctx.strokeStyle = `hsla(${base}, 100%, 70%, 1)`;
267
+ ctx.beginPath();
268
+ ctx.moveTo(xx, 0);
269
+ ctx.lineTo(xx, h2);
270
+ ctx.stroke();
271
+
272
+ // core pass
273
+ ctx.globalAlpha = 0.35;
274
+ ctx.strokeStyle = `hsla(${base}, 100%, 55%, 1)`;
275
+ ctx.beginPath();
276
+ ctx.moveTo(xx, 0);
277
+ ctx.lineTo(xx, h2);
278
+ ctx.stroke();
279
+ }
280
+ ctx.globalAlpha = 1;
281
+
282
+ // Particles as flowing "packets" and junction nodes
283
+ const ps = this.Tokens[id].immersiveParticles || [];
284
+ for (const p of ps) {
285
+ // quantize positions toward pipe lanes so motion reads as "through pipes"
286
+ const laneX = Math.round(p.x / step) * step;
287
+ const laneY = Math.round(p.y / step) * step;
288
+
289
+ // drift toward nearest lane
290
+ p.x += (laneX - p.x) * 0.04;
291
+ p.y += (laneY - p.y) * 0.04;
292
+
293
+ // move slowly along lanes
294
+ p.x += p.vx * 60;
295
+ p.y += p.vy * 60;
296
+
297
+ if (p.x < -20) p.x = w2 + 20;
298
+ if (p.x > w2 + 20) p.x = -20;
299
+ if (p.y < -20) p.y = h2 + 20;
300
+ if (p.y > h2 + 20) p.y = -20;
301
+
302
+ const sparkle = (0.25 + 0.75 * Math.max(0, Math.sin(tt * 5.2 + p.phase))) * (0.55 + p.spark);
303
+
304
+ // packet glow
305
+ ctx.beginPath();
306
+ ctx.fillStyle = `hsla(${base}, 100%, 72%, ${0.08 + sparkle * 0.16})`;
307
+ ctx.arc(p.x, p.y, p.r * 2.6, 0, Math.PI * 2);
308
+ ctx.fill();
309
+
310
+ // packet core
311
+ ctx.beginPath();
312
+ ctx.fillStyle = `hsla(${base + 10}, 100%, 62%, ${0.12 + sparkle * 0.22})`;
313
+ ctx.arc(p.x, p.y, Math.max(1, p.r * 1.05), 0, Math.PI * 2);
314
+ ctx.fill();
315
+ }
316
+
317
+ // Vignette
318
+ const vg = ctx.createRadialGradient(
319
+ w2 / 2,
320
+ h2 / 2,
321
+ Math.min(w2, h2) * 0.12,
322
+ w2 / 2,
323
+ h2 / 2,
324
+ Math.min(w2, h2) * 0.82,
325
+ );
326
+ vg.addColorStop(0, 'rgba(0,0,0,0)');
327
+ vg.addColorStop(1, 'rgba(0,0,0,0.75)');
328
+ ctx.fillStyle = vg;
329
+ ctx.fillRect(0, 0, w2, h2);
330
+
331
+ // Polyhedron motion (calmer so the pipe aesthetic stays readable)
332
+ const drift = 9;
333
+ this.Tokens[id].cr[1] += 0.44;
334
+ this.Tokens[id].cr[0] += 0.14;
335
+ this.Tokens[id].cr[2] += 0.08;
336
+ this.Tokens[id].ct[0] = Math.sin(tt * 0.65) * drift;
337
+ this.Tokens[id].ct[1] = Math.cos(tt * 0.45) * drift;
338
+ this.Tokens[id].ct[2] = Math.sin(tt * 0.35) * (drift * 0.65);
339
+ } else if (eff === 'prismBloom') {
340
+ // Prism Bloom (light): COOL-CORES — soft light/white wave-lite style, slower particles
341
+ const tt2 = tt;
342
+
343
+ // cool white background with a gentle wave tint (very subtle blues)
344
+ const bg = ctx.createLinearGradient(0, 0, w2, h2);
345
+ bg.addColorStop(0, `hsla(205, 45%, 96%, 1)`);
346
+ bg.addColorStop(0.5, `hsla(215, 35%, 98%, 1)`);
347
+ bg.addColorStop(1, `hsla(195, 40%, 95%, 1)`);
348
+ ctx.fillStyle = bg;
349
+ ctx.fillRect(0, 0, w2, h2);
350
+
351
+ // soft wave bands
352
+ ctx.globalAlpha = 0.12;
353
+ ctx.lineWidth = 2;
354
+ for (let y = 0; y < h2; y += 42) {
355
+ const hueW = 200 + Math.sin(tt2 * 0.35 + y * 0.02) * 10;
356
+ ctx.strokeStyle = `hsla(${hueW}, 55%, 72%, 1)`;
357
+ ctx.beginPath();
358
+ for (let x = 0; x <= w2; x += 28) {
359
+ const yy = y + Math.sin(tt2 * 0.9 + x * 0.02 + y * 0.03) * 10;
360
+ if (x === 0) ctx.moveTo(x, yy);
361
+ else ctx.lineTo(x, yy);
362
+ }
363
+ ctx.stroke();
364
+ }
365
+ ctx.globalAlpha = 1;
366
+
367
+ const ps = this.Tokens[id].immersiveParticles || [];
368
+ for (const p of ps) {
369
+ // slow drifting, wave-like
370
+ p.x += (p.vx * 0.55 + Math.sin(tt2 * 0.55 + p.phase) * 0.06) * 60;
371
+ p.y += (p.vy * 0.55 + Math.cos(tt2 * 0.45 + p.phase) * 0.06) * 60;
372
+
373
+ if (p.x < -30) p.x = w2 + 30;
374
+ if (p.x > w2 + 30) p.x = -30;
375
+ if (p.y < -30) p.y = h2 + 30;
376
+ if (p.y > h2 + 30) p.y = -30;
377
+
378
+ const pulse = 0.35 + 0.65 * Math.max(0, Math.sin(tt2 * 1.5 + p.phase + p.jitter * 2));
379
+
380
+ // cool core colors (icy blue -> lavender)
381
+ const hueP = (205 + (p.hueOffset % 40) + Math.sin(tt2 * 0.25 + p.phase) * 8) % 360;
382
+
383
+ // outer soft glow
384
+ ctx.beginPath();
385
+ ctx.fillStyle = `hsla(${hueP}, 70%, 75%, ${0.06 + pulse * 0.08})`;
386
+ ctx.arc(p.x, p.y, p.r * 3.0, 0, Math.PI * 2);
387
+ ctx.fill();
388
+
389
+ // bright cool core
390
+ ctx.beginPath();
391
+ ctx.fillStyle = `hsla(${hueP}, 65%, 86%, ${0.11 + pulse * 0.14})`;
392
+ ctx.arc(p.x, p.y, Math.max(1.0, p.r * 1.1), 0, Math.PI * 2);
393
+ ctx.fill();
394
+ }
395
+
396
+ // very light vignette so white background still has depth
397
+ const vg = ctx.createRadialGradient(
398
+ w2 / 2,
399
+ h2 / 2,
400
+ Math.min(w2, h2) * 0.25,
401
+ w2 / 2,
402
+ h2 / 2,
403
+ Math.min(w2, h2) * 0.95,
404
+ );
405
+ vg.addColorStop(0, 'rgba(255,255,255,0)');
406
+ vg.addColorStop(1, 'rgba(30,40,60,0.16)');
407
+ ctx.fillStyle = vg;
408
+ ctx.fillRect(0, 0, w2, h2);
409
+
410
+ const drift = 8;
411
+ this.Tokens[id].cr[1] += 0.34;
412
+ this.Tokens[id].cr[0] += 0.11;
413
+ this.Tokens[id].ct[0] = Math.sin(tt2 * 0.6) * drift;
414
+ this.Tokens[id].ct[1] = Math.cos(tt2 * 0.45) * drift;
415
+ this.Tokens[id].ct[2] = Math.sin(tt2 * 0.38) * (drift * 0.6);
416
+ } else if (eff === 'noirEmbers') {
417
+ // FULL FIRE (replaces Noir Embers): bright, high-intensity flame field + hot cores + tongues of fire.
418
+ // Goal: unmistakably "on fire" and eye-catching, not a subtle ember drift.
419
+ ctx.fillStyle = 'rgba(0,0,0,1)';
420
+ ctx.fillRect(0, 0, w2, h2);
421
+
422
+ // Base flame bed (glowing furnace at the bottom)
423
+ const bed = ctx.createRadialGradient(w2 * 0.5, h2 * 1.05, 0, w2 * 0.5, h2 * 1.05, Math.min(w2, h2) * 1.05);
424
+ bed.addColorStop(0, 'hsla(38, 100%, 55%, 0.85)');
425
+ bed.addColorStop(0.35, 'hsla(18, 100%, 42%, 0.65)');
426
+ bed.addColorStop(0.8, 'hsla(8, 100%, 18%, 0.25)');
427
+ bed.addColorStop(1, 'rgba(0,0,0,0)');
428
+ ctx.globalAlpha = 0.9;
429
+ ctx.fillStyle = bed;
430
+ ctx.fillRect(0, 0, w2, h2);
431
+ ctx.globalAlpha = 1;
432
+
433
+ // Heat haze / smoke-lace near the top to add depth (still subtle, fire stays dominant)
434
+ const haze = ctx.createLinearGradient(0, 0, 0, h2);
435
+ haze.addColorStop(0, 'rgba(0,0,0,0.55)');
436
+ haze.addColorStop(0.35, 'rgba(0,0,0,0.18)');
437
+ haze.addColorStop(1, 'rgba(0,0,0,0)');
438
+ ctx.globalAlpha = 0.55;
439
+ ctx.fillStyle = haze;
440
+ ctx.fillRect(0, 0, w2, h2);
441
+ ctx.globalAlpha = 1;
442
+
443
+ // Flame tongues + sparks
444
+ const ps = this.Tokens[id].immersiveParticles || [];
445
+ for (const p of ps) {
446
+ // life cycles: respawn near bottom with new heat/curl to keep it lively
447
+ p.life += 0.012 + p.flicker * 0.01;
448
+ if (p.life >= 1 || p.y < -80) {
449
+ p.x = random(-20, w2 + 20);
450
+ p.y = random(h2 * 0.45, h2 + 80);
451
+ p.vx = (random(-100, 100) / 100) * 0.16;
452
+ p.vy = (random(-100, 100) / 100) * 0.42;
453
+ p.heat = random(0, 1000) / 1000;
454
+ p.flicker = random(0, 1000) / 1000;
455
+ p.curl = random(0, 1000) / 1000;
456
+ p.life = 0;
457
+ p.stretch = 0.6 + (random(0, 1000) / 1000) * 1.6;
458
+ }
459
+
460
+ // Rising motion + turbulence (tongues curl side to side)
461
+ const curl = Math.sin(tt * (1.8 + p.curl * 1.6) + p.phase) * (0.12 + p.curl * 0.18);
462
+ const wag = Math.sin(tt * 6.5 + p.phase + p.flicker * 10) * 0.06;
463
+ p.x += (p.vx + curl + wag) * 60;
464
+ p.y -= (0.25 + Math.abs(p.vy)) * 58;
465
+
466
+ if (p.x < -60) p.x = w2 + 60;
467
+ if (p.x > w2 + 60) p.x = -60;
468
+
469
+ // Intensity ramps up then tapers (flame tongue shape)
470
+ const ramp = Math.sin(Math.min(1, p.life) * Math.PI); // 0..1..0
471
+ const flick = 0.45 + 0.55 * Math.max(0, Math.sin(tt * (8 + p.flicker * 7) + p.phase));
472
+ const heat = 0.25 + 0.75 * p.heat;
473
+
474
+ // Color: deep red -> orange -> yellow-white hot core
475
+ const hueP = 10 + heat * 35; // 10..45
476
+ const lumCore = 70 + heat * 20; // 70..90
477
+ const lumGlow = 45 + heat * 20; // 45..65
478
+
479
+ // Tongue body (stretched vertical glow)
480
+ const tongueH = (14 + heat * 34) * p.stretch * (0.5 + ramp);
481
+ const tongueW = (3 + heat * 4) * (0.7 + ramp);
482
+
483
+ ctx.globalAlpha = 0.06 + ramp * 0.16;
484
+ ctx.fillStyle = `hsla(${hueP}, 100%, ${lumGlow}%, 1)`;
485
+ ctx.beginPath();
486
+ ctx.ellipse(p.x, p.y, tongueW * 2.2, tongueH * 1.05, 0, 0, Math.PI * 2);
487
+ ctx.fill();
488
+ ctx.globalAlpha = 1;
489
+
490
+ // Hot core
491
+ ctx.beginPath();
492
+ ctx.fillStyle = `hsla(${hueP + 8}, 100%, ${lumCore}%, ${0.1 + ramp * 0.22 * flick})`;
493
+ ctx.arc(p.x, p.y, Math.max(1.2, p.r * (1.2 + heat * 0.9)), 0, Math.PI * 2);
494
+ ctx.fill();
495
+
496
+ // White-hot sparkles (a few particles become bright sparks)
497
+ const sparkChance = p.i % 9 === 0 ? 1 : 0;
498
+ if (sparkChance) {
499
+ ctx.beginPath();
500
+ ctx.fillStyle = `hsla(55, 100%, 92%, ${0.08 + ramp * 0.22 * flick})`;
501
+ ctx.arc(p.x + curl * 80, p.y - ramp * 18, Math.max(0.8, p.r * 0.75), 0, Math.PI * 2);
502
+ ctx.fill();
503
+
504
+ // tiny upward streak
505
+ ctx.globalAlpha = 0.06 + ramp * 0.12;
506
+ ctx.strokeStyle = `hsla(48, 100%, 85%, 1)`;
507
+ ctx.lineWidth = 1;
508
+ ctx.beginPath();
509
+ ctx.moveTo(p.x, p.y);
510
+ ctx.lineTo(p.x + curl * 50, p.y - 22 - heat * 18);
511
+ ctx.stroke();
512
+ ctx.globalAlpha = 1;
513
+ }
514
+ }
515
+
516
+ // Stronger vignette to keep fire contrast & "cinematic" intensity
517
+ const vg = ctx.createRadialGradient(
518
+ w2 / 2,
519
+ h2 / 2,
520
+ Math.min(w2, h2) * 0.12,
521
+ w2 / 2,
522
+ h2 / 2,
523
+ Math.min(w2, h2) * 0.92,
524
+ );
525
+ vg.addColorStop(0, 'rgba(0,0,0,0)');
526
+ vg.addColorStop(1, 'rgba(0,0,0,0.78)');
527
+ ctx.fillStyle = vg;
528
+ ctx.fillRect(0, 0, w2, h2);
529
+
530
+ // Slightly more energetic polyhedron motion to match the intensity
531
+ const drift = 9;
532
+ this.Tokens[id].cr[1] += 0.46;
533
+ this.Tokens[id].cr[0] += 0.15;
534
+ this.Tokens[id].cr[2] += 0.08;
535
+ this.Tokens[id].ct[0] = Math.sin(tt * 0.7) * drift;
536
+ this.Tokens[id].ct[1] = Math.cos(tt * 0.5) * drift;
537
+ this.Tokens[id].ct[2] = Math.sin(tt * 0.42) * (drift * 0.65);
538
+ } else {
539
+ // Wave light: animated gradient + soft floaty particles (brighter particles)
540
+ const hueA = (tt * 18 + this.Tokens[id].immersiveSeed) % 360;
541
+ const hueB = (hueA + 90) % 360;
542
+
543
+ const g = ctx.createLinearGradient(0, 0, w2, h2);
544
+ g.addColorStop(0, `hsla(${hueA}, 78%, 12%, 1)`);
545
+ g.addColorStop(1, `hsla(${hueB}, 78%, 12%, 1)`);
546
+ ctx.fillStyle = g;
547
+ ctx.fillRect(0, 0, w2, h2);
548
+
549
+ const ps = this.Tokens[id].immersiveParticles || [];
550
+ for (const p of ps) {
551
+ p.x += p.vx * 60;
552
+ p.y += p.vy * 60;
553
+
554
+ if (p.x < -10) p.x = w2 + 10;
555
+ if (p.x > w2 + 10) p.x = -10;
556
+ if (p.y < -10) p.y = h2 + 10;
557
+ if (p.y > h2 + 10) p.y = -10;
558
+
559
+ const alpha = 0.22 + 0.16 * Math.sin(tt * 1.7 + p.phase);
560
+ const hue = (hueA + p.i * 2) % 360;
561
+
562
+ // glow
563
+ ctx.beginPath();
564
+ ctx.fillStyle = `hsla(${hue}, 95%, 78%, ${alpha * 0.45})`;
565
+ ctx.arc(p.x, p.y, p.r * 2.4, 0, Math.PI * 2);
566
+ ctx.fill();
567
+
568
+ // core
569
+ ctx.beginPath();
570
+ ctx.fillStyle = `hsla(${hue}, 90%, 72%, ${alpha})`;
571
+ ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
572
+ ctx.fill();
573
+ }
574
+
575
+ const vg = ctx.createRadialGradient(
576
+ w2 / 2,
577
+ h2 / 2,
578
+ Math.min(w2, h2) * 0.15,
579
+ w2 / 2,
580
+ h2 / 2,
581
+ Math.min(w2, h2) * 0.75,
582
+ );
583
+ vg.addColorStop(0, 'rgba(0,0,0,0)');
584
+ vg.addColorStop(1, 'rgba(0,0,0,0.55)');
585
+ ctx.fillStyle = vg;
586
+ ctx.fillRect(0, 0, w2, h2);
587
+
588
+ const drift = 8;
589
+ this.Tokens[id].cr[1] += 0.35;
590
+ this.Tokens[id].cr[0] += 0.12;
591
+ this.Tokens[id].ct[0] = Math.sin(tt * 0.6) * drift;
592
+ this.Tokens[id].ct[1] = Math.cos(tt * 0.45) * drift;
593
+ this.Tokens[id].ct[2] = Math.sin(tt * 0.35) * (drift * 0.6);
594
+ }
595
+
596
+ // Keep face backgrounds synced to current face pixel sizing
597
+ // (important when immersive resize happens).
598
+ applyAllFaceBackgrounds();
599
+
600
+ // Apply transformation now (outside the 200ms interval) for smooth motion.
601
+ if (s(`.polyhedron-${id}`)) renderTransform();
602
+
603
+ this.Tokens[id].immersiveRAF = requestAnimationFrame(tick);
604
+ };
605
+
606
+ if (this.Tokens[id].immersiveRAF) cancelAnimationFrame(this.Tokens[id].immersiveRAF);
607
+ this.Tokens[id].immersiveRAF = requestAnimationFrame(tick);
608
+
609
+ // Resize handler while immersive
610
+ const onResize = () => {
611
+ if (!this.Tokens[id].immersive) return;
612
+ resize();
613
+ };
614
+ window.removeEventListener('resize', onResize);
615
+ window.addEventListener('resize', onResize);
616
+ this.Tokens[id].immersiveOnResize = onResize;
617
+
618
+ // Expose reset so the effect toggle can rebuild particles quickly.
619
+ this.Tokens[id]._resetImmersiveParticles = resetParticlesForEffect;
620
+ };
621
+
622
+ const stopImmersiveEffects = () => {
623
+ if (this.Tokens[id].immersiveRAF) cancelAnimationFrame(this.Tokens[id].immersiveRAF);
624
+ this.Tokens[id].immersiveRAF = null;
625
+ this.Tokens[id].immersiveParticles = null;
626
+
627
+ if (this.Tokens[id].immersiveOnResize) {
628
+ window.removeEventListener('resize', this.Tokens[id].immersiveOnResize);
629
+ this.Tokens[id].immersiveOnResize = null;
630
+ }
631
+
632
+ // Clear + hide canvas so the last rendered frame is not left visible.
633
+ const canvas = s(`.polyhedron-immersive-canvas-${id}`);
634
+ if (canvas) {
635
+ const ctx = canvas.getContext('2d');
636
+ if (ctx) ctx.clearRect(0, 0, canvas.width || 0, canvas.height || 0);
637
+ canvas.style.display = 'none';
638
+ }
639
+
640
+ // Reset drift so manual controls feel normal again
641
+ this.Tokens[id].ct = [0, 0, 0];
642
+ };
643
+
644
+ const setImmersive = (isImmersive) => {
645
+ this.Tokens[id].immersive = !!isImmersive;
646
+ const scene = s(`.scene-${id}`);
647
+ if (!scene) return;
648
+
649
+ if (this.Tokens[id].immersive) {
650
+ scene.classList.add(`scene-immersive-${id}`);
651
+ startImmersiveEffects();
652
+ } else {
653
+ scene.classList.remove(`scene-immersive-${id}`);
654
+ stopImmersiveEffects();
655
+ }
656
+ };
657
+
21
658
  const renderTransform = () => {
22
- s(
23
- `.polyhedron-${id}`,
24
- ).style.transform = `rotateX(${this.Tokens[id].cr[0]}deg) rotateY(${this.Tokens[id].cr[1]}deg) rotateZ(${this.Tokens[id].cr[2]}deg)
659
+ s(`.polyhedron-${id}`).style.transform =
660
+ `rotateX(${this.Tokens[id].cr[0]}deg) rotateY(${this.Tokens[id].cr[1]}deg) rotateZ(${this.Tokens[id].cr[2]}deg)
25
661
  translateX(${this.Tokens[id].ct[0]}px) translateY(${this.Tokens[id].ct[1]}px) translateZ(${this.Tokens[id].ct[2]}px)`;
26
662
  s(`.polyhedron-${id}`).style.left = `${s(`.scene-${id}`).offsetWidth / 2 - this.Tokens[id].dim / 2}px`;
27
663
  s(`.polyhedron-${id}`).style.top = `${s(`.scene-${id}`).offsetHeight / 2 - this.Tokens[id].dim / 2}px`;
@@ -45,6 +681,9 @@ const Polyhedron = {
45
681
  setTimeout(() => {
46
682
  renderTransform();
47
683
  s(`.polyhedron-${id}`).style.transition = `.4s`;
684
+ applyAllFaceBackgrounds();
685
+ applyFaceOpacity();
686
+ setImmersive(this.Tokens[id].immersive);
48
687
 
49
688
  s(`.btn-polyhedron-rotate-down-${id}`).onclick = () => {
50
689
  this.Tokens[id].cr[0] += 45;
@@ -65,14 +704,108 @@ const Polyhedron = {
65
704
  s(`.btn-polyhedron-remove-zoom-${id}`).onclick = () => {
66
705
  this.Tokens[id].dim -= 25;
67
706
  };
707
+
708
+ const facePicker = s(`.polyhedron-face-picker-${id}`);
709
+ if (facePicker) {
710
+ // Keep UI and state in sync on re-render
711
+ facePicker.value = this.Tokens[id].chosenFace || 'front';
712
+
713
+ facePicker.onchange = (e) => {
714
+ this.Tokens[id].chosenFace = e?.target?.value || 'front';
715
+ };
716
+ }
717
+
718
+ const opacitySlider = s(`.polyhedron-face-opacity-${id}`);
719
+ if (opacitySlider) {
720
+ // Keep UI and state in sync on re-render
721
+ opacitySlider.value = `${this.Tokens[id].faceOpacity}`;
722
+
723
+ opacitySlider.oninput = (e) => {
724
+ const v = parseFloat(e?.target?.value);
725
+ this.Tokens[id].faceOpacity = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 1;
726
+ applyFaceOpacity();
727
+ };
728
+ }
729
+
730
+ const fileInput = s(`.polyhedron-face-image-file-${id}`);
731
+ if (fileInput) {
732
+ fileInput.onchange = (e) => {
733
+ const file = e?.target?.files?.[0];
734
+ if (!file) return;
735
+
736
+ const url = URL.createObjectURL(file);
737
+ const face = this.Tokens[id].chosenFace || 'front';
738
+
739
+ if (face === 'all') {
740
+ ['front', 'back', 'left', 'right', 'top', 'bottom'].forEach((f) => {
741
+ this.Tokens[id].faces[f] = url;
742
+ });
743
+ applyAllFaceBackgrounds();
744
+ } else {
745
+ this.Tokens[id].faces[face] = url;
746
+ applyFaceBackground(face);
747
+ }
748
+
749
+ fileInput.value = '';
750
+ };
751
+ }
752
+
753
+ const clearFaceBtn = s(`.btn-polyhedron-clear-face-image-${id}`);
754
+ if (clearFaceBtn) {
755
+ clearFaceBtn.onclick = () => {
756
+ const face = this.Tokens[id].chosenFace || 'front';
757
+
758
+ if (face === 'all') {
759
+ ['front', 'back', 'left', 'right', 'top', 'bottom'].forEach((f) => {
760
+ this.Tokens[id].faces[f] = null;
761
+ });
762
+ applyAllFaceBackgrounds();
763
+ } else {
764
+ this.Tokens[id].faces[face] = null;
765
+ applyFaceBackground(face);
766
+ }
767
+ };
768
+ }
769
+
770
+ const immersiveBtn = s(`.btn-polyhedron-immersive-${id}`);
771
+ if (immersiveBtn) {
772
+ immersiveBtn.onclick = () => setImmersive(!this.Tokens[id].immersive);
773
+ }
774
+
775
+ const immersiveExitBtn = s(`.btn-polyhedron-immersive-exit-${id}`);
776
+ if (immersiveExitBtn) {
777
+ immersiveExitBtn.onclick = () => setImmersive(false);
778
+ }
779
+
780
+ const immersiveEffectBtn = s(`.btn-polyhedron-immersive-effect-${id}`);
781
+ if (immersiveEffectBtn) {
782
+ immersiveEffectBtn.onclick = () => {
783
+ const order = ['waveLight', 'prismBloom', 'darkElectronic', 'noirEmbers'];
784
+ const curr = this.Tokens[id].immersiveEffect || 'waveLight';
785
+ const idx = order.indexOf(curr);
786
+ this.Tokens[id].immersiveEffect = order[(idx + 1) % order.length] || 'waveLight';
787
+
788
+ if (this.Tokens[id].immersive && this.Tokens[id]._resetImmersiveParticles)
789
+ this.Tokens[id]._resetImmersiveParticles();
790
+ };
791
+ }
792
+
793
+ const escHandler = (ev) => {
794
+ if (ev?.key === 'Escape' && this.Tokens[id].immersive) setImmersive(false);
795
+ };
796
+ document.removeEventListener('keydown', escHandler);
797
+ document.addEventListener('keydown', escHandler);
68
798
  });
799
+
69
800
  return html`
70
801
  <style>
71
802
  .scene-${id} {
72
- height: 500px;
73
803
  background: #c7c7c7;
74
804
  overflow: hidden;
805
+ padding-bottom: 400px;
806
+ box-sizing: border-box;
75
807
  /* perspective: 10000px; */
808
+ position: relative;
76
809
  }
77
810
  .polyhedron-${id} {
78
811
  transform-style: preserve-3d;
@@ -81,6 +814,118 @@ const Polyhedron = {
81
814
  .face-${id} {
82
815
  width: 100%;
83
816
  height: 100%;
817
+ background-position: center center;
818
+ background-repeat: no-repeat;
819
+ /* default; JS may override with exact px sizing to match face dimensions */
820
+ background-size: cover;
821
+ /* avoid “quarter visible” artifacts in some browsers when faces rotate in 3D */
822
+ background-origin: border-box;
823
+ background-clip: border-box;
824
+ }
825
+
826
+ @keyframes polyhedronImmersiveIn-${id} {
827
+ 0% {
828
+ opacity: 0;
829
+ transform: scale(0.98);
830
+ filter: blur(6px) saturate(1.1);
831
+ }
832
+ 100% {
833
+ opacity: 1;
834
+ transform: scale(1);
835
+ filter: blur(0px) saturate(1);
836
+ }
837
+ }
838
+
839
+ .scene-immersive-${id} {
840
+ position: fixed !important;
841
+ inset: 0 !important;
842
+ width: 100vw !important;
843
+ height: 100vh !important;
844
+ z-index: 100 !important;
845
+ background: #000 !important;
846
+ animation: polyhedronImmersiveIn-${id} 500ms ease both;
847
+ }
848
+
849
+ /* Hide face labels while immersive */
850
+ .scene-immersive-${id} .center {
851
+ display: none !important;
852
+ }
853
+
854
+ .polyhedron-immersive-canvas-${id} {
855
+ position: absolute;
856
+ inset: 0;
857
+ width: 100%;
858
+ height: 100%;
859
+ z-index: 0;
860
+ pointer-events: none;
861
+ }
862
+
863
+ .polyhedron-${id} {
864
+ position: relative;
865
+ z-index: 2;
866
+ will-change: transform;
867
+ }
868
+
869
+ /* Exit + effect buttons are only visible in immersive mode */
870
+ .scene-immersive-exit-${id} {
871
+ display: none;
872
+ position: absolute;
873
+ right: 12px;
874
+ bottom: 12px;
875
+ z-index: 5;
876
+ padding: 6px 10px;
877
+ border-radius: 999px;
878
+ background: rgba(0, 0, 0, 0.35);
879
+ color: rgba(255, 255, 255, 0.85);
880
+ border: 1px solid rgba(255, 255, 255, 0.18);
881
+ backdrop-filter: blur(6px);
882
+ font-size: 12px;
883
+ line-height: 1;
884
+ cursor: pointer;
885
+ transition: 180ms ease;
886
+ user-select: none;
887
+ }
888
+ .scene-immersive-${id} .scene-immersive-exit-${id} {
889
+ display: inline-flex;
890
+ align-items: center;
891
+ justify-content: center;
892
+ }
893
+ .scene-immersive-exit-${id}:hover {
894
+ background: rgba(0, 0, 0, 0.55);
895
+ border-color: rgba(255, 255, 255, 0.28);
896
+ color: rgba(255, 255, 255, 0.95);
897
+ transform: translateY(-1px);
898
+ }
899
+
900
+ .scene-immersive-effect-${id} {
901
+ display: none;
902
+ position: absolute;
903
+ right: 74px;
904
+ bottom: 12px;
905
+ z-index: 5;
906
+ width: 34px;
907
+ height: 26px;
908
+ border-radius: 999px;
909
+ background: rgba(0, 0, 0, 0.30);
910
+ color: rgba(255, 255, 255, 0.82);
911
+ border: 1px solid rgba(255, 255, 255, 0.16);
912
+ backdrop-filter: blur(6px);
913
+ font-size: 12px;
914
+ line-height: 1;
915
+ cursor: pointer;
916
+ transition: 180ms ease;
917
+ user-select: none;
918
+ }
919
+ .scene-immersive-${id} .scene-immersive-effect-${id} {
920
+ display: inline-flex;
921
+ align-items: center;
922
+ justify-content: center;
923
+ }
924
+ .scene-immersive-effect-${id}:hover {
925
+ background: rgba(0, 0, 0, 0.52);
926
+ border-color: rgba(255, 255, 255, 0.26);
927
+ color: rgba(255, 255, 255, 0.95);
928
+ transform: translateY(-1px);
84
929
  }
85
930
 
86
931
  ${options?.style?.face
@@ -92,7 +937,7 @@ const Polyhedron = {
92
937
  }
93
938
  `
94
939
  : css``}
95
- ${options?.style?.scene
940
+ ${options?.style?.scene
96
941
  ? css`
97
942
  .scene-${id} {
98
943
  ${Object.keys(options.style.scene)
@@ -137,12 +982,56 @@ const Polyhedron = {
137
982
  class: `inl section-mp btn-custom btn-polyhedron-remove-zoom-${id}`,
138
983
  label: html`<i class="fa-solid fa-minus"></i>`,
139
984
  })}
985
+
986
+ <div class="in sub-title-modal"><i class="fa-solid fa-droplet"></i> Face opacity</div>
987
+ <input
988
+ class="in polyhedron-face-opacity-${id}"
989
+ type="range"
990
+ min="0"
991
+ max="1"
992
+ step="0.01"
993
+ value="${this.Tokens[id].faceOpacity}"
994
+ title="Face opacity"
995
+ />
996
+
997
+ <div class="in sub-title-modal"><i class="fa-solid fa-image"></i> Face image</div>
998
+
999
+ <select class="in polyhedron-face-picker-${id}">
1000
+ <option value="all">all</option>
1001
+ <option value="front">front</option>
1002
+ <option value="back">back</option>
1003
+ <option value="left">left</option>
1004
+ <option value="right">right</option>
1005
+ <option value="top">top</option>
1006
+ <option value="bottom">bottom</option>
1007
+ </select>
1008
+ <input class="in polyhedron-face-image-file-${id}" type="file" accept="image/*" />
1009
+ ${await BtnIcon.Render({
1010
+ class: `inl section-mp btn-custom btn-polyhedron-clear-face-image-${id}`,
1011
+ label: html`<i class="fa-solid fa-eraser"></i> ${Translate.Render('clear')}`,
1012
+ })}
1013
+ ${await BtnIcon.Render({
1014
+ class: `inl section-mp btn-custom btn-polyhedron-immersive-${id}`,
1015
+ label: html`<i class="fa-solid fa-expand"></i> Immersive`,
1016
+ })}
140
1017
  </div>
141
1018
  </div>
142
1019
  <div class="in fll polyhedron-${id}-col-b">
143
1020
  <div class="in section-mp">
144
1021
  <div class="in sub-title-modal"><i class="fa-solid fa-vector-square"></i> Render</div>
145
1022
  <div class="in scene-${id}">
1023
+ <canvas class="polyhedron-immersive-canvas-${id}"></canvas>
1024
+
1025
+ <div
1026
+ class="scene-immersive-effect-${id} btn-polyhedron-immersive-effect-${id}"
1027
+ title="Change background effect"
1028
+ >
1029
+ <i class="fa-solid fa-wand-magic-sparkles"></i>
1030
+ </div>
1031
+ <div class="scene-immersive-exit-${id} btn-polyhedron-immersive-exit-${id}" title="Exit immersive (Esc)">
1032
+ Exit
1033
+ </div>
1034
+
146
1035
  <div class="abs polyhedron-${id}">
147
1036
  <div class="abs face-${id} face_front-${id} ${id}-0"><div class="abs center">1</div></div>
148
1037
  <div class="abs face-${id} face_bottom-${id} ${id}-1"><div class="abs center">2</div></div>