File size: 7,723 Bytes
4255d2e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d01b9eb
 
 
4255d2e
 
 
d01b9eb
 
4255d2e
d01b9eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4255d2e
 
 
 
 
 
 
d01b9eb
 
 
 
 
 
4255d2e
 
 
 
 
 
 
 
d01b9eb
 
 
 
 
4255d2e
d01b9eb
4255d2e
d01b9eb
 
 
 
 
4255d2e
d01b9eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4255d2e
 
 
 
d01b9eb
 
 
 
 
fbbe335
 
 
 
 
 
 
 
d01b9eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4255d2e
 
 
 
 
 
 
 
 
d01b9eb
4255d2e
 
 
 
113e479
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d01b9eb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
/* ============================================================================
   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 };