@underpostnet/underpost
Version:
Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orc
948 lines (932 loc) • 40.7 kB
JavaScript
// https://underpost.net/cube.php
import { BtnIcon } from './BtnIcon.js';
import { getId, random } from './CommonJs.js';
import { dynamicCol } from './Css.js';
import { fullScreenIn, htmls, s } from './VanillaJs.js';
import { Translate } from './Translate.js';
// https://css-loaders.com/3d/
class Polyhedron {
static Tokens = {};
static async instance(options) {
const id = options?.id ? options.id : getId(Polyhedron.Tokens, 'polyhedron-');
if (!Polyhedron.Tokens[id])
Polyhedron.Tokens[id] = {
cr: [-25, -57, 90],
ct: [0, 0, 0],
dim: 150,
interval: null,
immersive: false,
immersiveParticles: null,
immersiveRAF: null,
immersiveStart: 0,
immersiveSeed: random(0, 1000000000),
immersiveEffect: 'waveLight', // 'waveLight' | 'darkElectronic' | 'prismBloom' | 'noirEmbers'
faceOpacity: 1,
chosenFace: 'front',
faces: {
front: null,
back: null,
left: null,
right: null,
top: null,
bottom: null,
},
};
const getFaceSelector = (faceName) => `.face_${faceName}-${id}`;
const applyFaceBackground = (faceName) => {
const el = s(getFaceSelector(faceName));
if (!el) return;
const url = Polyhedron.Tokens[id].faces?.[faceName];
if (url) {
el.style.backgroundImage = `url("${url}")`;
// Always cover the full face area.
// Using `cover` ensures full coverage even during 3D transforms / resizes.
el.style.backgroundSize = 'cover';
el.style.backgroundPosition = 'center center';
el.style.backgroundRepeat = 'no-repeat';
} else {
el.style.backgroundImage = '';
el.style.backgroundSize = '';
el.style.backgroundPosition = '';
el.style.backgroundRepeat = '';
}
};
const applyAllFaceBackgrounds = () => {
['front', 'back', 'left', 'right', 'top', 'bottom'].forEach(applyFaceBackground);
};
const applyFaceOpacity = () => {
const opacity = typeof Polyhedron.Tokens[id].faceOpacity === 'number' ? Polyhedron.Tokens[id].faceOpacity : 1;
const faces = document.querySelectorAll(`.face-${id}`);
faces.forEach((el) => {
el.style.opacity = `${opacity}`;
});
};
const startImmersiveEffects = () => {
const scene = s(`.scene-${id}`);
if (!scene) return;
const canvas = s(`.polyhedron-immersive-canvas-${id}`);
if (!canvas) return;
if (!s(`.main-body-btn-ui-close`).classList.contains('hide')) s(`.main-body-btn-ui`).click();
if (!s(`.main-body-btn-ui-menu-close`).classList.contains('hide')) s(`.main-body-btn-menu`).click();
fullScreenIn();
// Ensure canvas is visible during immersive mode.
canvas.style.display = 'block';
const ctx = canvas.getContext('2d');
if (!ctx) return;
const resize = () => {
const dpr = Math.max(1, window.devicePixelRatio || 1);
const w = scene.clientWidth || window.innerWidth;
const h = scene.clientHeight || window.innerHeight;
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
resize();
const mkParticlesWaveLight = () => {
const count = 110;
const w = scene.clientWidth || window.innerWidth;
const h = scene.clientHeight || window.innerHeight;
return Array.from({ length: count }).map((_, i) => {
const r = random(1, 4);
return {
x: random(0, w),
y: random(0, h),
vx: (random(-100, 100) / 100) * 0.25,
vy: (random(-100, 100) / 100) * 0.25,
r,
phase: random(0, 628) / 100,
i,
};
});
};
const mkParticlesDarkElectronic = () => {
const count = 170;
const w = scene.clientWidth || window.innerWidth;
const h = scene.clientHeight || window.innerHeight;
return Array.from({ length: count }).map((_, i) => {
const r = random(1, 3);
return {
x: random(0, w),
y: random(0, h),
// slower, more "system-like" motion so the pipe network reads clearly
vx: (random(-100, 100) / 100) * 0.18,
vy: (random(-100, 100) / 100) * 0.18,
r,
phase: random(0, 628) / 100,
i,
// stronger spark makes "nodes" pop
spark: random(0, 1000) / 1000,
};
});
};
// New (light): Prism Bloom — bright prismatic bursts with bloom + gently swirling motion
const mkParticlesPrismBloom = () => {
const count = 160;
const w = scene.clientWidth || window.innerWidth;
const h = scene.clientHeight || window.innerHeight;
return Array.from({ length: count }).map((_, i) => {
const r = random(1, 4);
return {
x: random(0, w),
y: random(0, h),
vx: (random(-100, 100) / 100) * 0.18,
vy: (random(-100, 100) / 100) * 0.18,
r,
phase: random(0, 628) / 100,
i,
// prismatic palette offsets + burst timing
hueOffset: random(0, 360),
jitter: random(0, 1000) / 1000,
};
});
};
// New (dark): Noir Embers — smoky dark with warm embers + occasional flicker
const mkParticlesNoirEmbers = () => {
// Repurposed into a much more eye-catching "full fire" effect:
// denser, brighter, more turbulent + extra per-particle state for tongues of flame.
const count = 240;
const w = scene.clientWidth || window.innerWidth;
const h = scene.clientHeight || window.innerHeight;
return Array.from({ length: count }).map((_, i) => {
const r = random(1, 4);
return {
x: random(0, w),
y: random(h * 0.25, h + 60),
vx: (random(-100, 100) / 100) * 0.14,
vy: (random(-100, 100) / 100) * 0.35,
r,
phase: random(0, 628) / 100,
i,
heat: random(0, 1000) / 1000,
flicker: random(0, 1000) / 1000,
// flame "tongue" shape & motion
curl: random(0, 1000) / 1000,
life: random(0, 1000) / 1000,
// small bias so some particles stretch more than others
stretch: 0.6 + (random(0, 1000) / 1000) * 1.6,
};
});
};
const resetParticlesForEffect = () => {
const eff = Polyhedron.Tokens[id].immersiveEffect || 'waveLight';
console.error(eff);
if (eff === 'darkElectronic') Polyhedron.Tokens[id].immersiveParticles = mkParticlesDarkElectronic();
else if (eff === 'prismBloom') Polyhedron.Tokens[id].immersiveParticles = mkParticlesPrismBloom();
else if (eff === 'noirEmbers') Polyhedron.Tokens[id].immersiveParticles = mkParticlesNoirEmbers();
else Polyhedron.Tokens[id].immersiveParticles = mkParticlesWaveLight();
};
resetParticlesForEffect();
Polyhedron.Tokens[id].immersiveStart = performance.now();
const tick = (t) => {
if (!Polyhedron.Tokens[id].immersive) return;
// Keep canvas sized to the scene.
if (canvas.clientWidth !== scene.clientWidth || canvas.clientHeight !== scene.clientHeight) resize();
const w2 = canvas.clientWidth || window.innerWidth;
const h2 = canvas.clientHeight || window.innerHeight;
const tt = (t - Polyhedron.Tokens[id].immersiveStart) / 1000;
const eff = Polyhedron.Tokens[id].immersiveEffect || 'waveLight';
if (eff === 'darkElectronic') {
// Dark electronic: GREEN PIPES — grid/pipe network with bright green flow + pulsing nodes
ctx.fillStyle = 'rgba(0,0,0,1)';
ctx.fillRect(0, 0, w2, h2);
// Pipe layout (more orthogonal so it reads like piping)
const base = 120; // green hue
const step = 52;
const driftX = Math.sin(tt * 0.35) * 10;
const driftY = Math.cos(tt * 0.28) * 10;
// faint background glow to lift the pipes off black
ctx.globalAlpha = 0.18;
const bgG = ctx.createRadialGradient(w2 * 0.5, h2 * 0.5, 0, w2 * 0.5, h2 * 0.5, Math.min(w2, h2) * 0.95);
bgG.addColorStop(0, `hsla(${base}, 90%, 18%, 0.9)`);
bgG.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = bgG;
ctx.fillRect(0, 0, w2, h2);
ctx.globalAlpha = 1;
// scanlines, very subtle
ctx.globalAlpha = 0.04;
ctx.fillStyle = 'rgba(255,255,255,1)';
for (let y = 0; y < h2; y += 3) ctx.fillRect(0, y, w2, 1);
ctx.globalAlpha = 1;
// Pipes: horizontal lines
ctx.lineWidth = 2;
for (let y = 0; y <= h2 + step; y += step) {
const yy = y + (Math.sin(tt * 0.6 + y * 0.02) * 2 + driftY);
// glow pass
ctx.globalAlpha = 0.15;
ctx.strokeStyle = `hsla(${base}, 100%, 70%, 1)`;
ctx.beginPath();
ctx.moveTo(0, yy);
ctx.lineTo(w2, yy);
ctx.stroke();
// core pass
ctx.globalAlpha = 0.35;
ctx.strokeStyle = `hsla(${base}, 100%, 55%, 1)`;
ctx.beginPath();
ctx.moveTo(0, yy);
ctx.lineTo(w2, yy);
ctx.stroke();
}
ctx.globalAlpha = 1;
// Pipes: vertical lines
for (let x = 0; x <= w2 + step; x += step) {
const xx = x + (Math.cos(tt * 0.55 + x * 0.02) * 2 + driftX);
// glow pass
ctx.globalAlpha = 0.15;
ctx.strokeStyle = `hsla(${base}, 100%, 70%, 1)`;
ctx.beginPath();
ctx.moveTo(xx, 0);
ctx.lineTo(xx, h2);
ctx.stroke();
// core pass
ctx.globalAlpha = 0.35;
ctx.strokeStyle = `hsla(${base}, 100%, 55%, 1)`;
ctx.beginPath();
ctx.moveTo(xx, 0);
ctx.lineTo(xx, h2);
ctx.stroke();
}
ctx.globalAlpha = 1;
// Particles as flowing "packets" and junction nodes
const ps = Polyhedron.Tokens[id].immersiveParticles || [];
for (const p of ps) {
// quantize positions toward pipe lanes so motion reads as "through pipes"
const laneX = Math.round(p.x / step) * step;
const laneY = Math.round(p.y / step) * step;
// drift toward nearest lane
p.x += (laneX - p.x) * 0.04;
p.y += (laneY - p.y) * 0.04;
// move slowly along lanes
p.x += p.vx * 60;
p.y += p.vy * 60;
if (p.x < -20) p.x = w2 + 20;
if (p.x > w2 + 20) p.x = -20;
if (p.y < -20) p.y = h2 + 20;
if (p.y > h2 + 20) p.y = -20;
const sparkle = (0.25 + 0.75 * Math.max(0, Math.sin(tt * 5.2 + p.phase))) * (0.55 + p.spark);
// packet glow
ctx.beginPath();
ctx.fillStyle = `hsla(${base}, 100%, 72%, ${0.08 + sparkle * 0.16})`;
ctx.arc(p.x, p.y, p.r * 2.6, 0, Math.PI * 2);
ctx.fill();
// packet core
ctx.beginPath();
ctx.fillStyle = `hsla(${base + 10}, 100%, 62%, ${0.12 + sparkle * 0.22})`;
ctx.arc(p.x, p.y, Math.max(1, p.r * 1.05), 0, Math.PI * 2);
ctx.fill();
}
// Vignette
const vg = ctx.createRadialGradient(
w2 / 2,
h2 / 2,
Math.min(w2, h2) * 0.12,
w2 / 2,
h2 / 2,
Math.min(w2, h2) * 0.82,
);
vg.addColorStop(0, 'rgba(0,0,0,0)');
vg.addColorStop(1, 'rgba(0,0,0,0.75)');
ctx.fillStyle = vg;
ctx.fillRect(0, 0, w2, h2);
// Polyhedron motion (calmer so the pipe aesthetic stays readable)
const drift = 9;
Polyhedron.Tokens[id].cr[1] += 0.44;
Polyhedron.Tokens[id].cr[0] += 0.14;
Polyhedron.Tokens[id].cr[2] += 0.08;
Polyhedron.Tokens[id].ct[0] = Math.sin(tt * 0.65) * drift;
Polyhedron.Tokens[id].ct[1] = Math.cos(tt * 0.45) * drift;
Polyhedron.Tokens[id].ct[2] = Math.sin(tt * 0.35) * (drift * 0.65);
} else if (eff === 'prismBloom') {
// Prism Bloom (light): COOL-CORES — soft light/white wave-lite style, slower particles
const tt2 = tt;
// cool white background with a gentle wave tint (very subtle blues)
const bg = ctx.createLinearGradient(0, 0, w2, h2);
bg.addColorStop(0, `hsla(205, 45%, 96%, 1)`);
bg.addColorStop(0.5, `hsla(215, 35%, 98%, 1)`);
bg.addColorStop(1, `hsla(195, 40%, 95%, 1)`);
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w2, h2);
// soft wave bands
ctx.globalAlpha = 0.12;
ctx.lineWidth = 2;
for (let y = 0; y < h2; y += 42) {
const hueW = 200 + Math.sin(tt2 * 0.35 + y * 0.02) * 10;
ctx.strokeStyle = `hsla(${hueW}, 55%, 72%, 1)`;
ctx.beginPath();
for (let x = 0; x <= w2; x += 28) {
const yy = y + Math.sin(tt2 * 0.9 + x * 0.02 + y * 0.03) * 10;
if (x === 0) ctx.moveTo(x, yy);
else ctx.lineTo(x, yy);
}
ctx.stroke();
}
ctx.globalAlpha = 1;
const ps = Polyhedron.Tokens[id].immersiveParticles || [];
for (const p of ps) {
// slow drifting, wave-like
p.x += (p.vx * 0.55 + Math.sin(tt2 * 0.55 + p.phase) * 0.06) * 60;
p.y += (p.vy * 0.55 + Math.cos(tt2 * 0.45 + p.phase) * 0.06) * 60;
if (p.x < -30) p.x = w2 + 30;
if (p.x > w2 + 30) p.x = -30;
if (p.y < -30) p.y = h2 + 30;
if (p.y > h2 + 30) p.y = -30;
const pulse = 0.35 + 0.65 * Math.max(0, Math.sin(tt2 * 1.5 + p.phase + p.jitter * 2));
// cool core colors (icy blue -> lavender)
const hueP = (205 + (p.hueOffset % 40) + Math.sin(tt2 * 0.25 + p.phase) * 8) % 360;
// outer soft glow
ctx.beginPath();
ctx.fillStyle = `hsla(${hueP}, 70%, 75%, ${0.06 + pulse * 0.08})`;
ctx.arc(p.x, p.y, p.r * 3.0, 0, Math.PI * 2);
ctx.fill();
// bright cool core
ctx.beginPath();
ctx.fillStyle = `hsla(${hueP}, 65%, 86%, ${0.11 + pulse * 0.14})`;
ctx.arc(p.x, p.y, Math.max(1.0, p.r * 1.1), 0, Math.PI * 2);
ctx.fill();
}
// very light vignette so white background still has depth
const vg = ctx.createRadialGradient(
w2 / 2,
h2 / 2,
Math.min(w2, h2) * 0.25,
w2 / 2,
h2 / 2,
Math.min(w2, h2) * 0.95,
);
vg.addColorStop(0, 'rgba(255,255,255,0)');
vg.addColorStop(1, 'rgba(30,40,60,0.16)');
ctx.fillStyle = vg;
ctx.fillRect(0, 0, w2, h2);
const drift = 8;
Polyhedron.Tokens[id].cr[1] += 0.34;
Polyhedron.Tokens[id].cr[0] += 0.11;
Polyhedron.Tokens[id].ct[0] = Math.sin(tt2 * 0.6) * drift;
Polyhedron.Tokens[id].ct[1] = Math.cos(tt2 * 0.45) * drift;
Polyhedron.Tokens[id].ct[2] = Math.sin(tt2 * 0.38) * (drift * 0.6);
} else if (eff === 'noirEmbers') {
// FULL FIRE (replaces Noir Embers): bright, high-intensity flame field + hot cores + tongues of fire.
// Goal: unmistakably "on fire" and eye-catching, not a subtle ember drift.
ctx.fillStyle = 'rgba(0,0,0,1)';
ctx.fillRect(0, 0, w2, h2);
// Base flame bed (glowing furnace at the bottom)
const bed = ctx.createRadialGradient(w2 * 0.5, h2 * 1.05, 0, w2 * 0.5, h2 * 1.05, Math.min(w2, h2) * 1.05);
bed.addColorStop(0, 'hsla(38, 100%, 55%, 0.85)');
bed.addColorStop(0.35, 'hsla(18, 100%, 42%, 0.65)');
bed.addColorStop(0.8, 'hsla(8, 100%, 18%, 0.25)');
bed.addColorStop(1, 'rgba(0,0,0,0)');
ctx.globalAlpha = 0.9;
ctx.fillStyle = bed;
ctx.fillRect(0, 0, w2, h2);
ctx.globalAlpha = 1;
// Heat haze / smoke-lace near the top to add depth (still subtle, fire stays dominant)
const haze = ctx.createLinearGradient(0, 0, 0, h2);
haze.addColorStop(0, 'rgba(0,0,0,0.55)');
haze.addColorStop(0.35, 'rgba(0,0,0,0.18)');
haze.addColorStop(1, 'rgba(0,0,0,0)');
ctx.globalAlpha = 0.55;
ctx.fillStyle = haze;
ctx.fillRect(0, 0, w2, h2);
ctx.globalAlpha = 1;
// Flame tongues + sparks
const ps = Polyhedron.Tokens[id].immersiveParticles || [];
for (const p of ps) {
// life cycles: respawn near bottom with new heat/curl to keep it lively
p.life += 0.012 + p.flicker * 0.01;
if (p.life >= 1 || p.y < -80) {
p.x = random(-20, w2 + 20);
p.y = random(h2 * 0.45, h2 + 80);
p.vx = (random(-100, 100) / 100) * 0.16;
p.vy = (random(-100, 100) / 100) * 0.42;
p.heat = random(0, 1000) / 1000;
p.flicker = random(0, 1000) / 1000;
p.curl = random(0, 1000) / 1000;
p.life = 0;
p.stretch = 0.6 + (random(0, 1000) / 1000) * 1.6;
}
// Rising motion + turbulence (tongues curl side to side)
const curl = Math.sin(tt * (1.8 + p.curl * 1.6) + p.phase) * (0.12 + p.curl * 0.18);
const wag = Math.sin(tt * 6.5 + p.phase + p.flicker * 10) * 0.06;
p.x += (p.vx + curl + wag) * 60;
p.y -= (0.25 + Math.abs(p.vy)) * 58;
if (p.x < -60) p.x = w2 + 60;
if (p.x > w2 + 60) p.x = -60;
// Intensity ramps up then tapers (flame tongue shape)
const ramp = Math.sin(Math.min(1, p.life) * Math.PI); // 0..1..0
const flick = 0.45 + 0.55 * Math.max(0, Math.sin(tt * (8 + p.flicker * 7) + p.phase));
const heat = 0.25 + 0.75 * p.heat;
// Color: deep red -> orange -> yellow-white hot core
const hueP = 10 + heat * 35; // 10..45
const lumCore = 70 + heat * 20; // 70..90
const lumGlow = 45 + heat * 20; // 45..65
// Tongue body (stretched vertical glow)
const tongueH = (14 + heat * 34) * p.stretch * (0.5 + ramp);
const tongueW = (3 + heat * 4) * (0.7 + ramp);
ctx.globalAlpha = 0.06 + ramp * 0.16;
ctx.fillStyle = `hsla(${hueP}, 100%, ${lumGlow}%, 1)`;
ctx.beginPath();
ctx.ellipse(p.x, p.y, tongueW * 2.2, tongueH * 1.05, 0, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
// Hot core
ctx.beginPath();
ctx.fillStyle = `hsla(${hueP + 8}, 100%, ${lumCore}%, ${0.1 + ramp * 0.22 * flick})`;
ctx.arc(p.x, p.y, Math.max(1.2, p.r * (1.2 + heat * 0.9)), 0, Math.PI * 2);
ctx.fill();
// White-hot sparkles (a few particles become bright sparks)
const sparkChance = p.i % 9 === 0 ? 1 : 0;
if (sparkChance) {
ctx.beginPath();
ctx.fillStyle = `hsla(55, 100%, 92%, ${0.08 + ramp * 0.22 * flick})`;
ctx.arc(p.x + curl * 80, p.y - ramp * 18, Math.max(0.8, p.r * 0.75), 0, Math.PI * 2);
ctx.fill();
// tiny upward streak
ctx.globalAlpha = 0.06 + ramp * 0.12;
ctx.strokeStyle = `hsla(48, 100%, 85%, 1)`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(p.x + curl * 50, p.y - 22 - heat * 18);
ctx.stroke();
ctx.globalAlpha = 1;
}
}
// Stronger vignette to keep fire contrast & "cinematic" intensity
const vg = ctx.createRadialGradient(
w2 / 2,
h2 / 2,
Math.min(w2, h2) * 0.12,
w2 / 2,
h2 / 2,
Math.min(w2, h2) * 0.92,
);
vg.addColorStop(0, 'rgba(0,0,0,0)');
vg.addColorStop(1, 'rgba(0,0,0,0.78)');
ctx.fillStyle = vg;
ctx.fillRect(0, 0, w2, h2);
// Slightly more energetic polyhedron motion to match the intensity
const drift = 9;
Polyhedron.Tokens[id].cr[1] += 0.46;
Polyhedron.Tokens[id].cr[0] += 0.15;
Polyhedron.Tokens[id].cr[2] += 0.08;
Polyhedron.Tokens[id].ct[0] = Math.sin(tt * 0.7) * drift;
Polyhedron.Tokens[id].ct[1] = Math.cos(tt * 0.5) * drift;
Polyhedron.Tokens[id].ct[2] = Math.sin(tt * 0.42) * (drift * 0.65);
} else {
// Wave light: animated gradient + soft floaty particles (brighter particles)
const hueA = (tt * 18 + Polyhedron.Tokens[id].immersiveSeed) % 360;
const hueB = (hueA + 90) % 360;
const g = ctx.createLinearGradient(0, 0, w2, h2);
g.addColorStop(0, `hsla(${hueA}, 78%, 12%, 1)`);
g.addColorStop(1, `hsla(${hueB}, 78%, 12%, 1)`);
ctx.fillStyle = g;
ctx.fillRect(0, 0, w2, h2);
const ps = Polyhedron.Tokens[id].immersiveParticles || [];
for (const p of ps) {
p.x += p.vx * 60;
p.y += p.vy * 60;
if (p.x < -10) p.x = w2 + 10;
if (p.x > w2 + 10) p.x = -10;
if (p.y < -10) p.y = h2 + 10;
if (p.y > h2 + 10) p.y = -10;
const alpha = 0.22 + 0.16 * Math.sin(tt * 1.7 + p.phase);
const hue = (hueA + p.i * 2) % 360;
// glow
ctx.beginPath();
ctx.fillStyle = `hsla(${hue}, 95%, 78%, ${alpha * 0.45})`;
ctx.arc(p.x, p.y, p.r * 2.4, 0, Math.PI * 2);
ctx.fill();
// core
ctx.beginPath();
ctx.fillStyle = `hsla(${hue}, 90%, 72%, ${alpha})`;
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fill();
}
const vg = ctx.createRadialGradient(
w2 / 2,
h2 / 2,
Math.min(w2, h2) * 0.15,
w2 / 2,
h2 / 2,
Math.min(w2, h2) * 0.75,
);
vg.addColorStop(0, 'rgba(0,0,0,0)');
vg.addColorStop(1, 'rgba(0,0,0,0.55)');
ctx.fillStyle = vg;
ctx.fillRect(0, 0, w2, h2);
const drift = 8;
Polyhedron.Tokens[id].cr[1] += 0.35;
Polyhedron.Tokens[id].cr[0] += 0.12;
Polyhedron.Tokens[id].ct[0] = Math.sin(tt * 0.6) * drift;
Polyhedron.Tokens[id].ct[1] = Math.cos(tt * 0.45) * drift;
Polyhedron.Tokens[id].ct[2] = Math.sin(tt * 0.35) * (drift * 0.6);
}
// Keep face backgrounds synced to current face pixel sizing
// (important when immersive resize happens).
applyAllFaceBackgrounds();
// Apply transformation now (outside the 200ms interval) for smooth motion.
if (s(`.polyhedron-${id}`)) renderTransform();
Polyhedron.Tokens[id].immersiveRAF = requestAnimationFrame(tick);
};
if (Polyhedron.Tokens[id].immersiveRAF) cancelAnimationFrame(Polyhedron.Tokens[id].immersiveRAF);
Polyhedron.Tokens[id].immersiveRAF = requestAnimationFrame(tick);
// Resize handler while immersive
const onResize = () => {
if (!Polyhedron.Tokens[id].immersive) return;
resize();
};
window.removeEventListener('resize', onResize);
window.addEventListener('resize', onResize);
Polyhedron.Tokens[id].immersiveOnResize = onResize;
// Expose reset so the effect toggle can rebuild particles quickly.
Polyhedron.Tokens[id]._resetImmersiveParticles = resetParticlesForEffect;
};
const stopImmersiveEffects = () => {
if (Polyhedron.Tokens[id].immersiveRAF) cancelAnimationFrame(Polyhedron.Tokens[id].immersiveRAF);
Polyhedron.Tokens[id].immersiveRAF = null;
Polyhedron.Tokens[id].immersiveParticles = null;
if (Polyhedron.Tokens[id].immersiveOnResize) {
window.removeEventListener('resize', Polyhedron.Tokens[id].immersiveOnResize);
Polyhedron.Tokens[id].immersiveOnResize = null;
}
// Clear + hide canvas so the last rendered frame is not left visible.
const canvas = s(`.polyhedron-immersive-canvas-${id}`);
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) ctx.clearRect(0, 0, canvas.width || 0, canvas.height || 0);
canvas.style.display = 'none';
}
// Reset drift so manual controls feel normal again
Polyhedron.Tokens[id].ct = [0, 0, 0];
};
const setImmersive = (isImmersive) => {
Polyhedron.Tokens[id].immersive = !!isImmersive;
const scene = s(`.scene-${id}`);
if (!scene) return;
if (Polyhedron.Tokens[id].immersive) {
scene.classList.add(`scene-immersive-${id}`);
startImmersiveEffects();
} else {
scene.classList.remove(`scene-immersive-${id}`);
stopImmersiveEffects();
}
};
const renderTransform = () => {
s(`.polyhedron-${id}`).style.transform =
`rotateX(${Polyhedron.Tokens[id].cr[0]}deg) rotateY(${Polyhedron.Tokens[id].cr[1]}deg) rotateZ(${Polyhedron.Tokens[id].cr[2]}deg)
translateX(${Polyhedron.Tokens[id].ct[0]}px) translateY(${Polyhedron.Tokens[id].ct[1]}px) translateZ(${Polyhedron.Tokens[id].ct[2]}px)`;
s(`.polyhedron-${id}`).style.left = `${s(`.scene-${id}`).offsetWidth / 2 - Polyhedron.Tokens[id].dim / 2}px`;
s(`.polyhedron-${id}`).style.top = `${s(`.scene-${id}`).offsetHeight / 2 - Polyhedron.Tokens[id].dim / 2}px`;
s(`.polyhedron-${id}`).style.width = `${Polyhedron.Tokens[id].dim}px`;
s(`.polyhedron-${id}`).style.height = `${Polyhedron.Tokens[id].dim}px`;
/* rotate Y */
s(`.face_front-${id}`).style.transform = `rotateY(0deg) translateZ(${Polyhedron.Tokens[id].dim / 2}px)`;
s(`.face_back-${id}`).style.transform = `rotateY(-180deg) translateZ(${Polyhedron.Tokens[id].dim / 2}px)`;
s(`.face_left-${id}`).style.transform = `rotateY(90deg) translateZ(${Polyhedron.Tokens[id].dim / 2}px)`;
s(`.face_right-${id}`).style.transform = `rotateY(-90deg) translateZ(${Polyhedron.Tokens[id].dim / 2}px)`;
/* rotate X */
s(`.face_top-${id}`).style.transform = `rotateX(-90deg) translateZ(${Polyhedron.Tokens[id].dim / 2}px)`;
s(`.face_bottom-${id}`).style.transform = `rotateX(90deg) translateZ(${Polyhedron.Tokens[id].dim / 2}px)`;
};
if (Polyhedron.Tokens[id].interval) clearInterval(Polyhedron.Tokens[id].interval);
Polyhedron.Tokens[id].interval = setInterval(() => {
if (s(`.polyhedron-${id}`)) renderTransform();
else return clearInterval(Polyhedron.Tokens[id].interval);
}, 200);
setTimeout(() => {
renderTransform();
s(`.polyhedron-${id}`).style.transition = `.4s`;
applyAllFaceBackgrounds();
applyFaceOpacity();
setImmersive(Polyhedron.Tokens[id].immersive);
s(`.btn-polyhedron-rotate-down-${id}`).onclick = () => {
Polyhedron.Tokens[id].cr[0] += 45;
};
s(`.btn-polyhedron-rotate-up-${id}`).onclick = () => {
Polyhedron.Tokens[id].cr[0] -= 45;
};
s(`.btn-polyhedron-rotate-left-${id}`).onclick = () => {
Polyhedron.Tokens[id].cr[1] += 45;
};
s(`.btn-polyhedron-rotate-right-${id}`).onclick = () => {
Polyhedron.Tokens[id].cr[1] -= 45;
};
s(`.btn-polyhedron-add-zoom-${id}`).onclick = () => {
Polyhedron.Tokens[id].dim += 25;
};
s(`.btn-polyhedron-remove-zoom-${id}`).onclick = () => {
Polyhedron.Tokens[id].dim -= 25;
};
const facePicker = s(`.polyhedron-face-picker-${id}`);
if (facePicker) {
// Keep UI and state in sync on re-render
facePicker.value = Polyhedron.Tokens[id].chosenFace || 'front';
facePicker.onchange = (e) => {
Polyhedron.Tokens[id].chosenFace = e?.target?.value || 'front';
};
}
const opacitySlider = s(`.polyhedron-face-opacity-${id}`);
if (opacitySlider) {
// Keep UI and state in sync on re-render
opacitySlider.value = `${Polyhedron.Tokens[id].faceOpacity}`;
opacitySlider.oninput = (e) => {
const v = parseFloat(e?.target?.value);
Polyhedron.Tokens[id].faceOpacity = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 1;
applyFaceOpacity();
};
}
const fileInput = s(`.polyhedron-face-image-file-${id}`);
if (fileInput) {
fileInput.onchange = (e) => {
const file = e?.target?.files?.[0];
if (!file) return;
const url = URL.createObjectURL(file);
const face = Polyhedron.Tokens[id].chosenFace || 'front';
if (face === 'all') {
['front', 'back', 'left', 'right', 'top', 'bottom'].forEach((f) => {
Polyhedron.Tokens[id].faces[f] = url;
});
applyAllFaceBackgrounds();
} else {
Polyhedron.Tokens[id].faces[face] = url;
applyFaceBackground(face);
}
fileInput.value = '';
};
}
const clearFaceBtn = s(`.btn-polyhedron-clear-face-image-${id}`);
if (clearFaceBtn) {
clearFaceBtn.onclick = () => {
const face = Polyhedron.Tokens[id].chosenFace || 'front';
if (face === 'all') {
['front', 'back', 'left', 'right', 'top', 'bottom'].forEach((f) => {
Polyhedron.Tokens[id].faces[f] = null;
});
applyAllFaceBackgrounds();
} else {
Polyhedron.Tokens[id].faces[face] = null;
applyFaceBackground(face);
}
};
}
const immersiveBtn = s(`.btn-polyhedron-immersive-${id}`);
if (immersiveBtn) {
immersiveBtn.onclick = () => setImmersive(!Polyhedron.Tokens[id].immersive);
}
const immersiveExitBtn = s(`.btn-polyhedron-immersive-exit-${id}`);
if (immersiveExitBtn) {
immersiveExitBtn.onclick = () => setImmersive(false);
}
const immersiveEffectBtn = s(`.btn-polyhedron-immersive-effect-${id}`);
if (immersiveEffectBtn) {
immersiveEffectBtn.onclick = () => {
const order = ['waveLight', 'prismBloom', 'darkElectronic', 'noirEmbers'];
const curr = Polyhedron.Tokens[id].immersiveEffect || 'waveLight';
const idx = order.indexOf(curr);
Polyhedron.Tokens[id].immersiveEffect = order[(idx + 1) % order.length] || 'waveLight';
if (Polyhedron.Tokens[id].immersive && Polyhedron.Tokens[id]._resetImmersiveParticles)
Polyhedron.Tokens[id]._resetImmersiveParticles();
};
}
const escHandler = (ev) => {
if (ev?.key === 'Escape' && Polyhedron.Tokens[id].immersive) setImmersive(false);
};
document.removeEventListener('keydown', escHandler);
document.addEventListener('keydown', escHandler);
});
return html`
<style>
.scene-${id} {
background: #c7c7c7;
overflow: hidden;
padding-bottom: 400px;
box-sizing: border-box;
/* perspective: 10000px; */
position: relative;
}
.polyhedron-${id} {
transform-style: preserve-3d;
cursor: pointer;
}
.face-${id} {
width: 100%;
height: 100%;
background-position: center center;
background-repeat: no-repeat;
/* default; JS may override with exact px sizing to match face dimensions */
background-size: cover;
/* avoid “quarter visible” artifacts in some browsers when faces rotate in 3D */
background-origin: border-box;
background-clip: border-box;
}
@keyframes polyhedronImmersiveIn-${id} {
0% {
opacity: 0;
transform: scale(0.98);
filter: blur(6px) saturate(1.1);
}
100% {
opacity: 1;
transform: scale(1);
filter: blur(0px) saturate(1);
}
}
.scene-immersive-${id} {
position: fixed ;
inset: 0 ;
width: 100vw ;
height: 100vh ;
z-index: 100 ;
background: #000 ;
animation: polyhedronImmersiveIn-${id} 500ms ease both;
}
/* Hide face labels while immersive */
.scene-immersive-${id} .center {
display: none ;
}
.polyhedron-immersive-canvas-${id} {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 0;
pointer-events: none;
}
.polyhedron-${id} {
position: relative;
z-index: 2;
will-change: transform;
}
/* Exit + effect buttons are only visible in immersive mode */
.scene-immersive-exit-${id} {
display: none;
position: absolute;
right: 12px;
bottom: 12px;
z-index: 5;
padding: 6px 10px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.35);
color: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(255, 255, 255, 0.18);
backdrop-filter: blur(6px);
font-size: 12px;
line-height: 1;
cursor: pointer;
transition: 180ms ease;
user-select: none;
}
.scene-immersive-${id} .scene-immersive-exit-${id} {
display: inline-flex;
align-items: center;
justify-content: center;
}
.scene-immersive-exit-${id}:hover {
background: rgba(0, 0, 0, 0.55);
border-color: rgba(255, 255, 255, 0.28);
color: rgba(255, 255, 255, 0.95);
transform: translateY(-1px);
}
.scene-immersive-effect-${id} {
display: none;
position: absolute;
right: 74px;
bottom: 12px;
z-index: 5;
width: 34px;
height: 26px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.30);
color: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(255, 255, 255, 0.16);
backdrop-filter: blur(6px);
font-size: 12px;
line-height: 1;
cursor: pointer;
transition: 180ms ease;
user-select: none;
}
.scene-immersive-${id} .scene-immersive-effect-${id} {
display: inline-flex;
align-items: center;
justify-content: center;
}
.scene-immersive-effect-${id}:hover {
background: rgba(0, 0, 0, 0.52);
border-color: rgba(255, 255, 255, 0.26);
color: rgba(255, 255, 255, 0.95);
transform: translateY(-1px);
}
${options?.style?.face
? css`
.face-${id} {
${Object.keys(options.style.face)
.map((styleKey) => `${styleKey} : ${options.style.face[styleKey]};`)
.join('')}
}
`
: css``}
${options?.style?.scene
? css`
.scene-${id} {
${Object.keys(options.style.scene)
.map((styleKey) => `${styleKey} : ${options.style.scene[styleKey]};`)
.join('')}
}
`
: css``}
</style>
<!--
<style class="polyhedron-animation-${id}"></style>
-->
${dynamicCol({ containerSelector: options.idModal, id: `polyhedron-${id}` })}
<div class="fl">
<div class="in fll polyhedron-${id}-col-a">
<div class="in section-mp">
<div class="in sub-title-modal"><i class="fa-solid fa-arrows-spin"></i> Rotate</div>
${await BtnIcon.instance({
class: `inl section-mp btn-custom btn-polyhedron-rotate-up-${id}`,
label: html`<i class="fa-solid fa-angle-up"></i>`,
})}
${await BtnIcon.instance({
class: `inl section-mp btn-custom btn-polyhedron-rotate-down-${id}`,
label: html`<i class="fa-solid fa-angle-down"></i>`,
})}
${await BtnIcon.instance({
class: `inl section-mp btn-custom btn-polyhedron-rotate-left-${id}`,
label: html`<i class="fa-solid fa-angle-left"></i>`,
})}
${await BtnIcon.instance({
class: `inl section-mp btn-custom btn-polyhedron-rotate-right-${id}`,
label: html`<i class="fa-solid fa-angle-right"></i>`,
})}
<div class="in sub-title-modal"><i class="fa-solid fa-magnifying-glass"></i> Zoom</div>
${await BtnIcon.instance({
class: `inl section-mp btn-custom btn-polyhedron-add-zoom-${id}`,
label: html`<i class="fa-solid fa-plus"></i>`,
})}
${await BtnIcon.instance({
class: `inl section-mp btn-custom btn-polyhedron-remove-zoom-${id}`,
label: html`<i class="fa-solid fa-minus"></i>`,
})}
<div class="in sub-title-modal"><i class="fa-solid fa-droplet"></i> Face opacity</div>
<input
class="in polyhedron-face-opacity-${id}"
type="range"
min="0"
max="1"
step="0.01"
value="${Polyhedron.Tokens[id].faceOpacity}"
title="Face opacity"
/>
<div class="in sub-title-modal"><i class="fa-solid fa-image"></i> Face image</div>
<select class="in polyhedron-face-picker-${id}">
<option value="all">all</option>
<option value="front">front</option>
<option value="back">back</option>
<option value="left">left</option>
<option value="right">right</option>
<option value="top">top</option>
<option value="bottom">bottom</option>
</select>
<input class="in polyhedron-face-image-file-${id}" type="file" accept="image/*" />
${await BtnIcon.instance({
class: `inl section-mp btn-custom btn-polyhedron-clear-face-image-${id}`,
label: html`<i class="fa-solid fa-eraser"></i> ${Translate.instance('clear')}`,
})}
${await BtnIcon.instance({
class: `inl section-mp btn-custom btn-polyhedron-immersive-${id}`,
label: html`<i class="fa-solid fa-expand"></i> Immersive`,
})}
</div>
</div>
<div class="in fll polyhedron-${id}-col-b">
<div class="in section-mp">
<div class="in sub-title-modal"><i class="fa-solid fa-vector-square"></i> instance</div>
<div class="in scene-${id}">
<canvas class="polyhedron-immersive-canvas-${id}"></canvas>
<div
class="scene-immersive-effect-${id} btn-polyhedron-immersive-effect-${id}"
title="Change background effect"
>
<i class="fa-solid fa-wand-magic-sparkles"></i>
</div>
<div class="scene-immersive-exit-${id} btn-polyhedron-immersive-exit-${id}" title="Exit immersive (Esc)">
Exit
</div>
<div class="abs polyhedron-${id}">
<div class="abs face-${id} face_front-${id} ${id}-0"><div class="abs center">1</div></div>
<div class="abs face-${id} face_bottom-${id} ${id}-1"><div class="abs center">2</div></div>
<div class="abs face-${id} face_back-${id} ${id}-2"><div class="abs center">3</div></div>
<div class="abs face-${id} face_top-${id} ${id}-3"><div class="abs center">4</div></div>
<div class="abs face-${id} face_right-${id} ${id}-4"><div class="abs center">5</div></div>
<div class="abs face-${id} face_left-${id} ${id}-5"><div class="abs center">6</div></div>
</div>
</div>
</div>
</div>
</div>
`;
}
}
export { Polyhedron };