radionicapp / js /cielo.js
Javier Villarroel
feat: piel de vidrio y paleta aurora en la app
113e479
Raw History Blame Contribute Delete
7.72 kB
/* ============================================================================
CIELO — estrellas generativas
----------------------------------------------------------------------------
No hay ni una estrella escrita a mano. Se generan al cargar, leyendo de
tokens.css cuántas, de qué brillo y con qué semilla. Cambiar la densidad o
el brillo del cielo es cambiar un número en tokens.css.
Se usa una semilla fija para que el cielo sea el mismo en cada visita (y
para que las capturas de los tests no cambien sin motivo). Cambia
--cielo-semilla y tendrás otro cielo.
========================================================================== */
/** Generador determinista: misma semilla, mismas estrellas. */
function azar(semilla) {
let a = semilla >>> 0;
return function () {
a = (a + 0x6d2b79f5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function leerNumero(estilos, nombre, porDefecto) {
const valor = parseFloat(estilos.getPropertyValue(nombre));
return Number.isFinite(valor) ? valor : porDefecto;
}
/* Cuánto se siembra más allá de la pantalla, por cada lado. El cielo gira, y
sin este margen el giro dejaría a la vista una esquina sin estrellas. */
const MARGEN = 0.6;
/** Las posiciones de una capa, antes de convertirlas en box-shadow. */
function generar(dado, cantidad, ancho, alto, alfaMin, alfaMax) {
const estrellas = [];
for (let i = 0; i < cantidad; i += 1) {
const x = Math.round((dado() * (1 + MARGEN * 2) - MARGEN) * ancho);
const y = Math.round((dado() * (1 + MARGEN * 2) - MARGEN) * alto);
const alfa = alfaMin + dado() * (alfaMax - alfaMin);
estrellas.push({ x, y, alfa });
}
return estrellas;
}
function sembrar(estrellas) {
// El color sale del token --c-estrella: aquí no se escribe ningún color.
return estrellas
.map(({ x, y, alfa }) => `${x}px ${y}px 0 rgb(var(--c-estrella) / ${alfa.toFixed(2)})`)
.join(", ");
}
/**
* Los destellos no son estrellas nuevas: son algunas de las estrellas
* CERCANAS ya sembradas, elegidas al azar (las lejanas son demasiadas y
* demasiado chicas para que un brillo grande tenga sentido ahí). Solo qué
* posiciones son "candidatas" sale de la semilla (mismo campo en cada
* visita); CUÁNDO y CUÁL destella en cada momento se decide aparte, en
* `encenderCielo`, con temporizadores al azar — un período fijo, por más
* que varíe, siempre se termina percibiendo como un ciclo.
*/
function elegirAlAzar(dado, lista, cantidad) {
const indices = lista.map((_, i) => i);
const tope = Math.min(cantidad, indices.length);
const elegidas = [];
for (let i = 0; i < tope; i += 1) {
const j = i + Math.floor(dado() * (indices.length - i));
[indices[i], indices[j]] = [indices[j], indices[i]];
elegidas.push(lista[indices[i]]);
}
return elegidas;
}
/**
* Dibuja el cielo y lo redibuja cuando cambia el tamaño de la ventana.
* Devuelve una función para dejar de escuchar (la usan los tests).
*/
export function encenderCielo(raiz = document.documentElement) {
const nodoDestellos = raiz.querySelector?.(".cielo__capa--destellos")
?? document.getElementById("estrellas-destello");
let nodosDestello = [];
let proximoDestello;
const pintar = () => {
const estilos = getComputedStyle(raiz);
const ancho = Math.max(window.innerWidth, 360);
const alto = Math.max(window.innerHeight, 640);
const semilla = leerNumero(estilos, "--cielo-semilla", 7);
const dado = azar(semilla);
const lejanas = generar(
dado, Math.round(leerNumero(estilos, "--cielo-lejanas", 150) * (1 + MARGEN * 2) ** 2 / 2),
ancho, alto,
leerNumero(estilos, "--cielo-alfa-lejana-min", 0.14),
leerNumero(estilos, "--cielo-alfa-lejana-max", 0.4)
);
raiz.style.setProperty("--estrellas-lejanas", sembrar(lejanas));
const cercanas = generar(
dado, Math.round(leerNumero(estilos, "--cielo-cercanas", 34) * (1 + MARGEN * 2) ** 2 / 2),
ancho, alto,
leerNumero(estilos, "--cielo-alfa-cercana-min", 0.5),
leerNumero(estilos, "--cielo-alfa-cercana-max", 0.9)
);
raiz.style.setProperty("--estrellas-cercanas", sembrar(cercanas));
if (nodoDestellos) {
const candidatas = elegirAlAzar(
dado, cercanas, Math.round(leerNumero(estilos, "--cielo-destellos", 14))
);
nodosDestello = candidatas.map(({ x, y }) => {
const nodo = document.createElement("i");
nodo.className = "destello";
nodo.style.left = `${x}px`;
nodo.style.top = `${y}px`;
return nodo;
});
nodoDestellos.replaceChildren(...nodosDestello);
}
};
pintar();
// Cada destello se dispara solo, en una estrella al azar, con una espera
// también al azar hasta el siguiente. Nada de período: por eso no se
// sincronizan ni se sienten mecánicos.
const encender = (nodo) => {
nodo.classList.remove("destello--activo");
// Un solo requestAnimationFrame no alcanza: ese callback corre ANTES de
// que el navegador recalcule estilos para el fotograma, así que a veces
// nunca llega a "ver" la clase quitada, y la animación no se reinicia
// (la estrella destellaría una vez y después no más). Con dos, entre
// medio ya hubo un recálculo de estilos real.
requestAnimationFrame(() => {
requestAnimationFrame(() => nodo.classList.add("destello--activo"));
});
};
const encenderUnDestello = () => {
if (nodosDestello.length > 0) {
encender(nodosDestello[Math.floor(Math.random() * nodosDestello.length)]);
// De tanto en tanto, que le toque a dos casi al mismo tiempo: en un
// cielo real no destellan de a una por turno.
if (nodosDestello.length > 1 && Math.random() < 0.25) {
const otro = nodosDestello[Math.floor(Math.random() * nodosDestello.length)];
setTimeout(() => encender(otro), Math.random() * 150);
}
}
const estilos = getComputedStyle(raiz);
const esperaMin = leerNumero(estilos, "--cielo-destello-espera-min", 0.6) * 1000;
const esperaMax = leerNumero(estilos, "--cielo-destello-espera-max", 3.5) * 1000;
proximoDestello = setTimeout(encenderUnDestello, esperaMin + Math.random() * (esperaMax - esperaMin));
};
encenderUnDestello();
let espera;
const alRedimensionar = () => {
clearTimeout(espera);
espera = setTimeout(pintar, 200);
};
window.addEventListener("resize", alRedimensionar);
return () => {
clearTimeout(espera);
clearTimeout(proximoDestello);
window.removeEventListener("resize", alRedimensionar);
};
}
/**
* El grano del vidrio: un lienzo de 128×128 con ruido al azar, convertido a
* imagen y puesto en --grano para que css/tokens.css lo use como textura
* (ver --vidrio-fondo). Se hace una sola vez, al arrancar. Si el dispositivo
* no puede crear el lienzo, no se pone nada: el vidrio se queda sin grano,
* no es imprescindible.
*/
export function aplicarGrano(raiz = document.documentElement) {
const lienzo = document.createElement("canvas");
lienzo.width = 128;
lienzo.height = 128;
const ctx = lienzo.getContext("2d");
if (!ctx) return;
const imagen = ctx.createImageData(lienzo.width, lienzo.height);
for (let i = 0; i < imagen.data.length; i += 4) {
const v = Math.floor(Math.random() * 256);
imagen.data[i] = v;
imagen.data[i + 1] = v;
imagen.data[i + 2] = v;
imagen.data[i + 3] = 255;
}
ctx.putImageData(imagen, 0, 0);
raiz.style.setProperty("--grano", `url(${lienzo.toDataURL()})`);
}
export const _paraPruebas = { azar, generar, sembrar, elegirAlAzar };