PageSourceSearch

https://www.guidemapp.io/nadir/nadir-prod-ui.js

js guidemapp.io collected 2026-10-02 01:59:10 UTC 118,859 bytes, 2,464 lines download raw bytes

1/* NADIR — interfaz de PRODUCCIÓN: la que verá el usuario final.
2   Depende de: nadir-orbit.js, nadir-route.js, nadir-anim.js, nadir-labels.js,
3   nadir-card.js, nadir-search.js, nadir-sun.js.
4   Expone NADIR.ui.
5
6   Es la hermana de nadir-ui.js, no su sucesora: las dos publican el mismo
7   contrato —NADIR.ui = {telemetry, updateSun}, que es lo que consumen
8   nadir-main.js y nadir-export.js— y cada página carga UNA de las dos. Este
9   añade una tercera, `paused`, que el contrato declara OPCIONAL: dice si el
10   bucle debe saltarse el fotograma, y solo la necesita esta página, mientras
11   enseña el vídeo terminado.
12   nadir.html es el banco de pruebas, con sus cuarenta controles; nadir-prod.html
13   es el producto, y de momento solo sabe hacer una cosa.
14
15   Aquí NO se duplica nada de nadir-ui.js. Lo único que las dos necesitaban de
16   verdad —el reflejo del sol anclado a la cámara— vive desde el 8 septiembre
17   2026 en nadir-sun.js, y las dos lo llaman. telemetry() no tiene lecturas que
18   escribir —en producción no hay números en pantalla—, pero tampoco es un hueco:
19   es el ÚNICO sitio por el que este archivo pasa en cada fotograma, y de ahí
20   cuelga el vigía que mantiene el botón de la vista previa al día con el
21   director. Ver sync(). */
22(function(){
23"use strict";
24const {cam, goTo, flyTo, setAimY, distForKmPerPx, kmPerPx} = NADIR.orbit;
25/* EL GLOBO NO SE TOCA. En producción es el escenario: lo mueven los viajes a
26   la ciudad elegida y la vista previa, nunca el dedo, la rueda ni las flechas.
27   Sin esto, un usuario que intenta desplazar la lista de resultados o que roza
28   la pantalla acaba girando el planeta y perdiendo de vista el itinerario. */
29NADIR.orbit.setInteractive(false);
30const {DEG} = NADIR.geo;
31const route  = NADIR.route;
32const labels = NADIR.labels;
33const card   = NADIR.card;
34const anim   = NADIR.anim;
35const sun    = NADIR.sun;
36
37const el = id => document.getElementById(id);
38/* Todas las frases de este archivo salen de aquí. Las constantes de abajo
39   conservan su nombre —es el mismo que la clave en nadir-i18n.js— y solo
40   cambia de dónde viene el texto: del diccionario del idioma de la URL. */
41const t = NADIR.i18n.t;
42
43/* ---------- analítica ----------
44   Los eventos del embudo de NADIR (ver nadir-analytics.js): entró (lo manda
45   ese archivo solo, al cargar) → añadió ciudad → vista previa → exportó, y el
46   clic en el botón premium. Cada uno se dispara en el sitio donde la acción
47   SE CONSUMA y no donde se pulsa: «añadió ciudad» va detrás de applyRoute(),
48   que es quien puede rechazarla; «exportó» va en showDone(), que es el único
49   sitio con bytes en la mano. Con la analítica sin cargar —un index.html viejo,
50   una copia a medias— no pasa nada: se pregunta cada vez y no hay a quién
51   apuntarse. `trip()` es lo que se sabe del viaje en ese momento y va en
52   todos, para leer cualquier paso por tamaño del itinerario. */
53function track(event, props){
54  const a = NADIR.analytics;
55  if(a) a.track(event, props);
56}
57function trip(){
58  return {stop_count: picked.length, photos: picked.filter(c => c.img).length};
59}
60
61/* ---------- arranque: el globo desnudo ----------
62   nadir-route.js exige tener SIEMPRE un itinerario dibujable y viene con Madrid
63   → Roma de fábrica; sin estas líneas, la primera pantalla del producto enseñaría
64   dos puntos blancos y dos nombres de una ruta que nadie ha pedido. La cinta ya
65   está oculta por su cuenta (arcGeometry arranca con drawRange 0) y el director
66   no la revela hasta que se reproduce.
67
68   El globo SÍ se toca: se arrastra, se pellizca y se gira igual que en el banco
69   de pruebas. Un arrastre a mitad de viaje lo cancela — ver flyTo() en
70   nadir-orbit.js—, o sea que la mano siempre gana. */
71route.setVisible(false);
72labels.setEnabled(false);
73labels.setPlaces(null);
74
75/* Altura de la primera pantalla, en radios terrestres. nadir-orbit.js arranca a
76   3,0, que es el plano del banco de pruebas; el producto abre más abierto, con
77   el planeta entero holgado detrás del panel de cristal. Se cambia solo la
78   ALTURA y no el punto observado: dónde mira la cámara al abrir sigue siendo
79   cosa de nadir-orbit.js, y repetir aquí su latitud y su longitud sería tener el
80   mismo número escrito en dos sitios. */
81const START_DIST = 3.5;
82goTo(cam.lat, cam.lon, START_DIST);
83
84/* CON LA QUE ABRE TODO: la vista de carga, congelada aquí mismo. Es el punto de
85   partida de la secuencia —vista previa y vídeo por igual, ver wideFrom()—, así
86   que todos los vídeos abren con el mismo fotograma: el planeta entero, de
87   frente, a la altura holgada de la primera pantalla.
88   SE COPIA DE `cam`, no se escribe a mano, y por la misma razón que START_DIST
89   no repite el punto observado: la latitud y la longitud de fábrica son cosa de
90   nadir-orbit.js y aquí solo se leen. La línea de arriba acaba de dejarlas en su
91   sitio y todavía no ha corrido nada que las mueva, así que este es el único
92   instante en que `cam` ES la vista de carga. */
93const OPEN_POSE = {lat:cam.lat, lon:cam.lon, dist:cam.dist, tilt:cam.tilt};
94
95/* ---------- las ciudades elegidas ----------
96   Esta lista ES el itinerario: se le pasa tal cual a route.setStops() en cuanto
97   tiene dos paradas —ver applyRoute()—, con su `mode` por parada incluido.
98   Con una sola ciudad no va a route, que rechaza por contrato cualquier lista
99   más corta que un trayecto; va a labels.setPlaces(), que dibuja pastillas sin
100   necesidad de itinerario y sigue haciéndolo después con las mismas paradas. */
101const picked = [];
102
103/* Dónde se planta la ciudad DENTRO de la pantalla, de arriba abajo. No en el
104   centro: ahí está el panel de cristal, y la pastilla quedaría detrás. A 0,20 se
105   queda en el quinto superior, con la pastilla justo encima de su punta y sitio
106   de sobra hasta el panel. Es el único número que hay que tocar para subirla o
107   bajarla; ver setAimY() en nadir-orbit.js. */
108const CITY_Y = 0.20;
109
110/* A qué ESCALA se queda la vista al posarse sobre una ciudad: kilómetros de
111   superficie por píxel, el mismo número que enseña la telemetría del panel de
112   dev. Se pide una escala y no una altura porque es lo que se ve: la altura
113   que hace falta para conseguirla la calcula nadir-orbit.js con el fov y el
114   alto del lienzo de ESTE momento.
115   Consecuencia, y hay que saberla: la misma escala es una altura distinta en
116   cada pantalla. En un lienzo de 1920 px de alto salen 3,4 radios; en la
117   ventana de un móvil, que es más baja y además abre el fov a 42°, la cámara
118   se acerca bastante más para enseñar los mismos kilómetros por píxel. Lo que
119   se conserva es el detalle del terreno, no cuánto planeta cabe.
120
121   VA AL REVÉS QUE EL ZOOM, y es la trampa de este número: son kilómetros POR
122   píxel, así que subirlo aleja la cámara y bajarlo la acerca. El suelo lo pone
123   distForKmPerPx(), que recorta a 1,09 radios. */
124const CITY_SCALE = 3.5;
125
126/* Inclinación de la cámara al llegar, en grados. Positiva es hacia el norte:
127   la cámara se retira hacia el sur y mira al planeta de frente-arriba en vez de
128   a plomo, que es lo que le da relieve a la ciudad. Ver placeCamera() en
129   nadir-orbit.js, que es quien la aplica, y tiltMax(), que la recorta a 80°. */
130const CITY_TILT = 55;
131
132/* Cuánto dura el viaje hasta la ciudad. Es una decisión de ritmo, no técnica:
133   por debajo de un segundo el giro del planeta se lee como un corte, y por
134   encima de dos se hace esperar cada vez que se añade una parada.
135   El desplazamiento del encuadre usa exactamente esta misma duración para que
136   los dos movimientos sean uno solo; ver setAimY() en nadir-orbit.js. */
137const FLY_SECS = 1.4;
138
139/* ---------- los dos estados de la card ----------
140   Sin ninguna parada la card pide una ciudad; con la PRIMERA ya se convierte en
141   la línea del viaje y el campo se esconde detrás del +. El viaje empieza a
142   existir en cuanto hay un punto, no cuando hay un trayecto.
143   Las frases son literales del encargo. No hay ninguna más: cuando haga falta
144   decir algo, se pregunta antes de escribirlo. DEL_LABEL es la cuarta y llegó
145   igual, pedida y con su sitio dicho —debajo del tacho—; ver delBtn(). */
146const HEAD_START = t("HEAD_START");
147const HEAD_TRIP  = t("HEAD_TRIP");
148const ADD_MORE   = t("ADD_MORE");
149const DEL_LABEL  = t("DEL_LABEL");
150
151/* Los tres modos, en el orden en que los enumera nadir-ui.js para que las dos
152   versiones se lean igual. Los valores NO son cadenas sueltas: son las mismas
153   constantes que consume route.setLegMode(), así que el día que este itinerario
154   se escriba de verdad en la escena no hay nada que traducir.
155   El emoji lo dibuja la fuente del sistema. Aquí no pasa nada si falta —es DOM,
156   no el canvas del vídeo—, al revés que la bandera de las pastillas del globo. */
157const MODES = [
158  {mode: route.AIR,    label: t("MODE_AIR"),    em: "\u2708\uFE0F"},
159  {mode: route.GROUND, label: t("MODE_GROUND"), em: "\uD83D\uDE97"},
160  {mode: route.RAIL,   label: t("MODE_RAIL"),   em: "\uD83D\uDE86"}
161];
162const modeInfo = m => MODES.find(x => x.mode === m) || MODES[0];
163
164/* Qué tramo tiene el desplegable abierto, o -1. Vive aquí y no en el DOM porque
165   paintLine() rehace las filas enteras en cada cambio y se perdería. */
166let hopOpen = -1;
167
168/* ---------- la foto de cada parada ----------
169   Un único <input type=file> para las doce, y esta variable dice a cuál va la
170   que se elija. Se pone justo antes de abrirlo y se limpia al terminar.
171
172   `URL.createObjectURL` y NO un data: URL de FileReader. Dos motivos: no hay que
173   codificar en base64 una foto de varios MB para volver a decodificarla, y el
174   blob resultante es del MISMO ORIGEN que la página. Eso último es lo que
175   importa de verdad: la trampa número uno de este proyecto es que una imagen de
176   otro origen contamina el canvas y `new VideoFrame(canvas)` muere con
177   SecurityError, o sea que se cae la exportación entera y no solo la imagen. Un
178   blob: propio no contamina, así que estas fotos podrán entrar en el vídeo tal
179   cual. Si algún día llegan de un servidor, harán falta CORS y crossOrigin. */
180let shotFor = -1;
181const fileInput = el("p-file");
182
183fileInput.addEventListener("change", () => {
184  const f = fileInput.files && fileInput.files[0];
185  const i = shotFor;
186  shotFor = -1;
187  /* El valor se limpia SIEMPRE: sin esto, elegir dos veces seguidas el mismo
188     archivo no dispara `change` y la segunda vez no pasaría nada. */
189  fileInput.value = "";
190  if(!f || !picked[i]) return;
191  /* Reemplazar libera la anterior: un objectURL vive hasta que se revoca, y sin
192     esto cada cambio de foto se dejaría la vieja en memoria. */
193  if(picked[i].img) URL.revokeObjectURL(picked[i].img);
194  picked[i].img = URL.createObjectURL(f);
195  /* Y a la escena: la tarjeta la recorta, la sube y la enseña posada sobre su
196     ciudad en el mismo sitio donde estaba la pastilla del nombre, que se retira
197     sola al ver el `img` de esta parada (nadir-labels.js). Poner la foto en la
198     card y ponerla en el globo son la misma acción, así que van juntas. */
199  card.setPhoto(i, picked[i].img);
200  paintLine();
201});
202
203function pickShot(i){
204  shotFor = i;
205  fileInput.click();
206}
207
208const head = el("p-head"), line = el("p-line"), box = el("p-box"), bar = el("p-bar");
209/* La pieza de cristal entera, que es lo que se aparta al reproducir. Es la card
210   (#p-panel); la pila que la envuelve y que fit() mueve es otra cosa y se llama
211   `stack`. */
212const glass = el("p-panel");
213
214/* Si el campo está a la vista teniendo ya un viaje. Solo significa algo en el
215   segundo estado: en el primero el campo se ve siempre. */
216let boxOpen = false;
217/* Eco de si el director está reproduciendo. No es la verdad —la verdad es
218   anim.state()—, solo la última lectura conocida; ver sync(). */
219let previewing = false;
220
221/* El marcador de una parada, de la SEGUNDA en adelante: el hueco de su foto
222   vacío, o la miniatura si ya la tiene. Es el mismo botón en los dos casos
223   —pulsarlo abre el selector— y solo cambian la clase y el contenido, así que
224   reemplazar una foto no es un caso aparte: es volver a pulsar.
225   La primera no pasa por aquí: es el origen, lleva casa fija y no admite foto;
226   ver homeMark(). */
227/* El icono del hueco vacío: un marco de foto con una insignia de «+» pegada a
228   su esquina, al estilo de los símbolos del sistema de Apple. Dibujado a mano
229   en una rejilla de 20 y no traído como PNG, por tres motivos: hereda el color
230   —.shot pasa de velado a blanco al pasar por encima, y un PNG negro no puede
231   seguir ese cambio sin filtros—, sale nítido en cualquier densidad de pantalla
232   sin cargar ningún archivo, y no arrastra la atribución de un banco de iconos.
233
234   Las cuatro piezas, en orden: el marco, que es una C —le falta la esquina de
235   abajo a la derecha, mordida para dejar sitio a la insignia con su holgura—;
236   el sol; las montañas, cuyo faldón derecho muere sobre esa misma mordida; y la
237   insignia, un círculo con el «+» CALADO, no dibujado encima: por eso lleva
238   fill-rule evenodd, que es lo que convierte el segundo trazado en un agujero. */
239const SHOT_ICON = '<path d="M17.4 12V5.8Q17.4 3.9 15.5 3.9H4.3Q2.4 3.9 2.4 5.8'
240                + 'V14.4Q2.4 16.3 4.3 16.3H11.8L11.9 14.8H4.6Q3.9 14.8 3.9 14.1'
241                + 'V6.1Q3.9 5.4 4.6 5.4H15.2Q15.9 5.4 15.9 6.1V11.7Z"/>'
242                + '<circle cx="7" cy="7.8" r="1.6"/>'
243                + '<path d="M3.9 14.8 7.3 10.4 9.9 12.8 12.5 9.5 14.2 11.6'
244                + 'Q12.2 12.8 11.9 14.8Z"/>'
245                + '<path fill-rule="evenodd" d="M15.9 12.6a3.2 3.2 0 1 0 0 6.4'
246                + ' 3.2 3.2 0 1 0 0-6.4zM15.45 13.6h.9v1.75h1.75v.9h-1.75v1.75'
247                + 'h-.9v-1.75H13.7v-.9h1.75z"/>';
248
249/* La casa del origen. Misma rejilla de 20 que el icono de la foto, para que los
250   dos marcadores se lean del mismo tamaño dentro de su recuadro. */
251const HOME_ICON = '<path d="M10 2.3 2.2 8.6v8.2c0 .5.4.9.9.9h4V12.1h5.8v5.6h4'
252                + 'c.5 0 .9-.4.9-.9V8.6z"/>';
253
254/* El tacho de basura de quitar una parada. Misma rejilla de 20 que los otros dos
255   iconos, para que los tres se lean del mismo tamaño dentro de su fila, y de
256   RELLENO como ellos: heredan el color por currentColor, que es lo que deja que
257   el tacho pase de velado a blanco sin más que cambiar el color del botón.
258
259   Tres trazados, de arriba abajo: el asa, que es un arco con las dos patas
260   bajando hasta el canto de la tapa; la tapa, una barra con las puntas
261   redondeadas y más ancha que el cubo, como en un tacho de verdad; y el cubo,
262   ligeramente estrechado hacia abajo. Las dos ranuras del cubo van CALADAS y no
263   dibujadas encima —fill-rule evenodd, igual que la insignia del hueco de la
264   foto—, porque el fondo del botón es cristal translúcido: pintadas del color
265   del panel se verían como dos barras claras en vez de como dos huecos. */
266/* La corona del premium. La lleva la fila de añadir cuando el viaje llega al
267   tope del plan gratuito, y otra vez, en grande, arriba de #p-pay: es la misma
268   forma en los dos sitios a propósito — quien pulsa la fila tiene que reconocer
269   dónde ha aterrizado sin leer nada. */
270const CROWN_ICON = '<path d="M2.5 7.2 6.6 10.4 12 4l5.4 6.4 4.1-3.2-1.7 9.4H4.2z"/>'
271                 + '<path d="M4.4 18.1h15.2v2.3H4.4z"/>';
272
273const DEL_ICON = '<path d="M8.05 2.9h3.9a1.05 1.05 0 0 1 1.05 1.05V5.3h-1.4V4.3'
274               + 'H8.4v1H7V3.95a1.05 1.05 0 0 1 1.05-1.05z"/>'
275               + '<path d="M4.4 5.3h11.2a.8.8 0 0 1 0 1.6H4.4a.8.8 0 0 1 0-1.6z"/>'
276               + '<path fill-rule="evenodd" d="M5.7 7.7h8.6l-.72 8.62c-.06.72-.66'
277               + ' 1.28-1.38 1.28H7.8c-.72 0-1.32-.56-1.38-1.28z'
278               + 'M8.4 9.6a.55 .55 0 0 1 1.1 0v5.1a.55 .55 0 0 1-1.1 0z'
279               + 'M10.5 9.6a.55 .55 0 0 1 1.1 0v5.1a.55 .55 0 0 1-1.1 0z"/>';
280
281/* El marcador de la PRIMERA parada. No es un botón: es de donde se sale y no
282   admite foto, así que no hay nada que pulsar. aria-hidden porque el nombre de
283   la ciudad va justo al lado y ya lo dice todo; anunciar «casa» sería un texto
284   inventado que nadie ha pedido. */
285function homeMark(){
286  const sp = document.createElement("span");
287  sp.className = "home";
288  const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
289  svg.setAttribute("viewBox", "0 0 20 20");
290  svg.setAttribute("aria-hidden", "true");
291  svg.innerHTML = HOME_ICON;
292  sp.appendChild(svg);
293  return sp;
294}
295
296function shotBtn(i, c){
297  const b = document.createElement("button");
298  b.type = "button";
299  b.className = c.img ? "shot has" : "shot";
300  /* Botón de solo icono: sin esta etiqueta un lector de pantalla anunciaría doce
301     botones idénticos. Es el único texto de esta fila que no se ve. */
302  b.setAttribute("aria-label", t(c.img ? "IMG_CHANGE" : "IMG_ADD", {city: c.name}));
303  if(c.img){
304    const im = document.createElement("img");
305    im.src = c.img; im.alt = "";
306    b.appendChild(im);
307  }else{
308    /* innerHTML solo aquí y solo con una constante escrita a mano: es un SVG
309       fijo, no hay nada que venga de fuera. */
310    const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
311    svg.setAttribute("viewBox", "0 0 20 20");
312    svg.setAttribute("aria-hidden", "true");
313    svg.innerHTML = SHOT_ICON;
314    b.appendChild(svg);
315  }
316  b.addEventListener("click", () => pickShot(i));
317  return b;
318}
319
320/* El botón del final de la fila: el tacho y, debajo, la palabra.
321
322   LLEVA aria-label AUNQUE TENGA TEXTO A LA VISTA, y no es una contradicción: la
323   palabra es la misma en las once filas, así que un lector de pantalla leería
324   once veces «Eliminar» sin decir el qué. La etiqueta añade la ciudad y empieza
325   por la palabra que se ve, que es lo que pide el criterio de «rótulo en el
326   nombre»: lo que se lee en voz alta contiene lo que está escrito.
327   El svg va aria-hidden por lo mismo de siempre: el botón ya tiene nombre.
328   LAS ONCE FILAS LO LLEVAN, EL ORIGEN INCLUIDO: quitar la primera ciudad es lo
329   único que permite empezar el viaje en otro sitio sin recargar la página. Ver
330   removeStop(). */
331function delBtn(i, c){
332  const b = document.createElement("button");
333  b.type = "button";
334  b.className = "del";
335  b.setAttribute("aria-label", DEL_LABEL + " " + c.name);
336  const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
337  svg.setAttribute("viewBox", "0 0 20 20");
338  svg.setAttribute("aria-hidden", "true");
339  /* innerHTML solo con una constante escrita a mano, como en los otros iconos:
340     es un SVG fijo, aquí no entra nada de fuera. */
341  svg.innerHTML = DEL_ICON;
342  b.append(svg, Object.assign(document.createElement("span"),
343                              {className:"t", textContent:DEL_LABEL}));
344  b.addEventListener("click", () => removeStop(i));
345  return b;
346}
347
348/* ---------- del panel al globo ----------
349   La lista de la card ES el itinerario de la escena, y este es el único sitio
350   donde se le escribe. En cuanto hay DOS paradas hay algo que dibujar, así que
351   se enciende la cinta; con una sola no se toca nada, porque setStops() rechaza
352   por contrato cualquier lista más corta.
353
354   setProgress(1) en cada escritura y no una sola vez al encender: el trazado se
355   mide en tramos —un tramo por cada 1/n del recorrido— así que el mismo rango de
356   dibujo significa otra cosa en cuanto el itinerario gana una parada. Sin esto,
357   añadir la tercera ciudad dejaría la cinta a medias.
358
359   Devuelve si la escena aceptó. Puede decir que no: dos paradas pegadas son un
360   tramo degenerado y setStops() no lo compone. Quien llama deshace su cambio, y
361   así la línea de la card y lo que se ve sobre el globo no pueden discrepar.
362   Se pregunta al motor en vez de repetir aquí su umbral, que es un número suyo. */
363function applyRoute(){
364  if(picked.length < 2) return true;
365  if(!route.setStops(picked)) return false;
366  route.setProgress(1);
367  route.setVisible(true);
368  return true;
369}
370
371/* El modo del tramo que SALE de la parada i, igual que en nadir-route.js: un
372   itinerario de n paradas tiene n-1 tramos y el de la última no existe.
373
374   setLegMode() y NO volver a pasar por applyRoute(): setStops() tira las
375   carreteras ya descargadas —son de un itinerario que quizá ya no exista— y
376   cambiar de avión a coche y volver las pediría otra vez. setLegMode() las
377   conserva, redibuja el tramo y avisa él solo. Es también lo que hace que Auto y
378   Tren no cuesten dos peticiones: piden la misma polilínea. */
379function setMode(i, mode){
380  picked[i].mode = mode;
381  hopOpen = -1;
382  paintLine();
383  route.setLegMode(i, mode);
384}
385
386function toggleHop(i){
387  hopOpen = hopOpen === i ? -1 : i;
388  paintLine();
389}
390
391/* ---------- quitar una parada ----------
392   El tacho de CUALQUIER fila, el origen incluido. La lista puede quedarse vacía,
393   así que `picked[0]` NO existe siempre: quien lo lea tiene que contar antes.
394
395   El tramo no es un dato: es el hueco entre dos paradas, y su modo vive en la
396   parada de la que SALE. Por eso quitar una parada intermedia deja un solo tramo
397   donde había dos, con el modo del anterior — el de la que se va se va con ella,
398   que es lo único que no obliga a elegir por quien pulsa la X. Quitar el ORIGEN
399   es el mismo trato leído desde el otro lado: el tramo que salía de él se va con
400   él y la segunda ciudad pasa a ser de donde se sale, con su propio modo, que ya
401   era el del tramo siguiente.
402
403   EL NUEVO ORIGEN PIERDE SU FOTO, y no es un descuido: la primera parada es de
404   donde se sale y no lleva imagen —ni en la fila, que enseña la casa en vez del
405   hueco, ni en el vídeo, donde el director solo saca la foto de las ciudades a
406   las que se LLEGA—. Dejarla puesta sería guardar una imagen que ya no se puede
407   ver ni volver a quitar.
408
409   QUITAR LA ÚLTIMA devuelve la card a su primera pantalla, la de «¿Dónde empezó
410   tu viaje?»: es lo mismo que hay al abrir la página —globo desnudo, sin
411   pastillas y sin cinta— y por eso se deshace exactamente lo que el arranque
412   monta, cámara incluida. Sin eso quedaría un planeta en primerísimo plano
413   detrás de una card que pide una ciudad.
414
415   Puede fallar, igual que añadir: si las dos paradas que quedan a los lados
416   están en el mismo sitio, el itinerario resultante es degenerado y setStops() lo
417   rechaza. Entonces se deshace el corte y aquí no ha pasado nada. Mismo trato
418   que en pick() y por el mismo motivo: la línea de la card y lo que se ve sobre
419   el globo no pueden discrepar. */
420function removeStop(i){
421  if(i < 0 || i >= picked.length) return;
422  const gone = picked[i];
423  picked.splice(i, 1);
424  if(!applyRoute()){ picked.splice(i, 0, gone); return; }
425  /* El objectURL es de este panel: quien lo crea lo revoca. */
426  if(gone.img) URL.revokeObjectURL(gone.img);
427  /* La segunda ciudad acaba de ascender a origen y el origen no lleva foto. Se
428     suelta AQUÍ, con el corte ya aceptado por la escena: hacerlo antes la
429     perdería en el itinerario degenerado que se deshace tres líneas más arriba.
430     Solo se revoca lo que este panel creó —las fotos de la demo son rutas de
431     archivo, no blobs—, el mismo cuidado que en la demo. */
432  if(i === 0 && picked.length && picked[0].img){
433    if(picked[0].img.indexOf("blob:") === 0) URL.revokeObjectURL(picked[0].img);
434    picked[0].img = null;
435  }
436  /* Las fotos de la tarjeta van por NÚMERO DE RANURA, así que todas las paradas
437     por debajo de la vacante han cambiado de sitio y hay que reescribirlas; la
438     ranura que sobra al final se apaga con null. Solo desde la vacante: repasar
439     las doce volvería a recortar y a subir fotos que no se han movido. */
440  for(let k = i; k < picked.length; k++) card.setPhoto(k, picked[k].img);
441  card.setPhoto(picked.length, null);
442  /* Con una sola parada ya no hay trayecto, y applyRoute() ni siquiera ha
443     llegado a la escena: la cinta se apaga a mano y el globo vuelve a como
444     estaba antes de la segunda ciudad. Las pastillas se quedan, que no necesitan
445     itinerario. */
446  if(picked.length < 2) route.setVisible(false);
447  /* SIN NINGUNA PARADA no basta con pasar la lista vacía: setPlaces() la lee
448     como «no tengo nada que decir» y las pastillas caen de vuelta al itinerario
449     de fábrica de nadir-route.js —Madrid → Roma—, que es justo lo que el
450     arranque apaga con setEnabled(false). Se deshace igual que se monta. */
451  if(picked.length){
452    labels.setPlaces(picked);
453  }else{
454    labels.setPlaces(null);
455    labels.setEnabled(false);
456    toStart();
457  }
458  /* El desplegable abierto apuntaba a un tramo que ya es otro. */
459  hopOpen = -1;
460  render();
461}
462
463/* La vuelta a la primera pantalla: el encuadre con el que abre la página, ni más
464   ni menos. Es la pose de apertura (OPEN_POSE), que ya se copió de `cam` en el
465   arranque, y el punto de mira otra vez en el centro — CITY_Y lo había subido al
466   quinto superior para dejarle sitio a la card de la línea del viaje, y la card
467   que vuelve es la del campo, que es más baja.
468   CON VIAJE (FLY_SECS) y no de un corte: quien pulsa el tacho tiene que ver de
469   dónde sale el planeta, igual que lo ve llegar al elegir la primera ciudad. Los
470   dos movimientos duran lo mismo por lo mismo que en pick(): son uno solo. */
471function toStart(){
472  setAimY(0.5, FLY_SECS);
473  flyTo(OPEN_POSE.lat, OPEN_POSE.lon, OPEN_POSE.dist, FLY_SECS, OPEN_POSE.tilt);
474}
475
476/* La fila del modo de un tramo: la pastilla con el modo puesto y, si está
477   abierta, las tres opciones colgando de ella.
478   Botones de verdad y no divs con onclick: se tabulan, y los atributos aria
479   dicen a un lector de pantalla que esto se despliega y cuál está elegido. */
480function hopRow(i){
481  const li = document.createElement("li");
482  li.className = "hop";
483  /* Columna vacía del ancho de los marcadores: alinea la pastilla con los
484     nombres y deja pasar el raíl por detrás. */
485  li.appendChild(Object.assign(document.createElement("span"), {className:"dotcol"}));
486
487  const wrap = document.createElement("div");
488  wrap.className = "modewrap";
489  const open = hopOpen === i;
490  const now = modeInfo(picked[i].mode);
491
492  const b = document.createElement("button");
493  b.type = "button"; b.className = "modebtn";
494  b.setAttribute("aria-haspopup", "listbox");
495  b.setAttribute("aria-expanded", String(open));
496  b.setAttribute("aria-label", t("HOP_ARIA", {a: picked[i].name, b: picked[i+1].name}));
497  b.append(
498    Object.assign(document.createElement("span"), {className:"em", textContent:now.em}),
499    document.createTextNode(now.label),
500    Object.assign(document.createElement("span"), {className:"cv"})
501  );
502  b.addEventListener("click", () => toggleHop(i));
503  wrap.appendChild(b);
504
505  if(open){
506    const ul = document.createElement("ul");
507    ul.className = "modemenu";
508    ul.setAttribute("role", "listbox");
509    for(const m of MODES){
510      const opt = document.createElement("li");
511      const ob = document.createElement("button");
512      ob.type = "button";
513      ob.setAttribute("role", "option");
514      ob.setAttribute("aria-selected", String(m.mode === picked[i].mode));
515      ob.append(
516        Object.assign(document.createElement("span"), {className:"em", textContent:m.em}),
517        document.createTextNode(m.label)
518      );
519      ob.addEventListener("click", () => setMode(i, m.mode));
520      opt.appendChild(ob);
521      ul.appendChild(opt);
522    }
523    wrap.appendChild(ul);
524  }
525
526  li.appendChild(wrap);
527  return li;
528}
529
530/* Se repinta la lista entera en cada cambio, igual que la de resultados: son
531   doce paradas como mucho y así no hay que llevar la cuenta de cuál cambió. */
532function paintLine(){
533  /* Vaciar la línea pone su desplazamiento a cero, y esto se repinta en cada
534     cambio —abrir un modo, poner una foto—: sin guardarlo, cualquiera de esas
535     cosas devolvería al usuario al principio del viaje. */
536  const scroll = line.scrollTop;
537  line.textContent = "";
538  picked.forEach((c, i) => {
539    const li = document.createElement("li");
540    li.className = "pt";
541    const col = document.createElement("span"); col.className = "dotcol";
542    /* La primera parada es el origen: casa fija, sin foto. De la segunda en
543       adelante, el hueco de su imagen. */
544    col.appendChild(i === 0 ? homeMark() : shotBtn(i, c));
545    /* Ciudad y bandera en el MISMO texto y compuestos igual que en la pastilla
546       del globo (nadir-labels.js): la fila de la card y lo que se ve sobre el
547       planeta no pueden decir cosas distintas. */
548    const f = route.flag(c.cc);
549    const n = document.createElement("span"); n.className = "n";
550    n.textContent = f ? c.name + " " + f : c.name;
551    li.append(col, n);
552    /* TODAS las filas se quitan, el origen incluido: es la única forma de mover
553       el principio del viaje. Con la última se vuelve a la card de inicio; ver
554       removeStop(). */
555    li.appendChild(delBtn(i, c));
556    line.appendChild(li);
557    /* El modo va DETRÁS de cada parada menos la última: hay un tramo por cada
558       hueco entre paradas. */
559    if(i < picked.length - 1) line.appendChild(hopRow(i));
560  });
561
562  /* La fila de añadir se cae al llegar al tope de paradas que aguanta
563     nadir-route.js. Se quita sin más: poner un aviso sería inventarse un texto
564     que nadie ha pedido, y a doce paradas la línea ya dice que está llena. */
565  if(picked.length < route.MAX_STOPS){
566    /* CON EL PLAN GRATUITO LLENO, LA FILA NO SE VA: SE CONVIERTE. El signo más
567       pasa a corona y el botón abre el muro en vez del buscador. Se decidió así
568       y no escondiéndola ni desactivándola porque una fila que desaparece no
569       explica nada —el usuario cree que ha llegado al tope del producto— y una
570       desactivada no se puede pulsar, que es justo lo que hace falta que pase
571       para poder contarle qué hay al otro lado. */
572    const locked = payLocked();
573    const li = document.createElement("li");
574    li.className = "pt add";
575    const b = document.createElement("button");
576    b.type = "button"; b.className = locked ? "addrow locked" : "addrow";
577    const col = document.createElement("span"); col.className = "dotcol";
578    /* El círculo con el signo más NO cambia. Sigue siendo la misma invitación de
579       siempre: lo que cambia es el texto, que se pone de oro, y la corona que
580       viene detrás. */
581    const plus = document.createElement("span"); plus.className = "plus";
582    plus.textContent = "+";
583    col.appendChild(plus);
584    const n = document.createElement("span"); n.className = "add-n"; n.textContent = ADD_MORE;
585    b.append(col, n);
586    if(locked){
587      /* La corona va DESPUÉS del texto: es una etiqueta de lo que cuesta, no el
588         icono de la acción. Las dimensiones van en el propio SVG además de en el
589         CSS —a prueba de una hoja de estilos vieja en la caché de alguien: sin
590         ellas, un SVG sin tamaño se pinta a 300×150 y revienta la fila. */
591      const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
592      svg.setAttribute("viewBox", "0 0 24 24");
593      svg.setAttribute("width", "15");
594      svg.setAttribute("height", "15");
595      svg.setAttribute("aria-hidden", "true");
596      svg.setAttribute("class", "crown");
597      svg.innerHTML = CROWN_ICON;
598      b.appendChild(svg);
599    }
600    /* El nombre accesible sale del texto que se ve, y ese texto es el mismo en
601       los dos estados: la corona es un adorno para el lector de pantalla. De ahí
602       que la fila cerrada SÍ lleve aria-label — es lo único que puede contar que
603       esto ya no añade una ciudad, sino que abre una ventana. */
604    if(locked){
605      b.setAttribute("aria-label", ADD_MORE + " (premium)");
606      b.setAttribute("aria-haspopup", "dialog");
607    }
608    b.addEventListener("click", locked ? openPay : openBox);
609    li.appendChild(b);
610    line.appendChild(li);
611  }
612
613  /* ---------- hasta dónde crece la línea ----------
614     Dos paradas —con el tramo que va entre ellas— más la fila de añadir. Lo que
615     pase de ahí se desplaza por dentro y la card se queda del mismo alto.
616
617     SE MIDE, no se escribe: una parada con foto es casi el doble de alta que una
618     sin ella, así que el mismo número de píxeles sería el tope bueno en un viaje
619     y el malo en el siguiente. Y se mide con getBoundingClientRect, que da
620     decimales: sumando offsetHeight, que redondea, el tope se queda un par de
621     píxeles corto y aparece una barra de desplazamiento con solo dos paradas.
622
623     Ojo con las clases: la fila de añadir también es `pt`, así que se descuenta
624     primero y se suma aparte —siempre es la última—. */
625  let tope = 0, paradas = 0;
626  for(const li of line.children){
627    if(li.classList.contains("add")) continue;
628    /* En cuanto están las dos paradas se corta, y se corta ANTES de mirar la
629       fila: la que viene detrás de la segunda es el tramo que sale de ella, y
630       ese ya pertenece a la tercera. */
631    if(paradas === 2) break;
632    if(li.classList.contains("pt")) paradas++;
633    tope += li.getBoundingClientRect().height;
634  }
635  const add = line.querySelector("li.add");
636  if(add) tope += add.getBoundingClientRect().height;
637  line.style.maxHeight = Math.ceil(tope) + "px";
638
639  line.scrollTop = scroll;
640
641  /* El desplegable de los modos ya no puede asomar por fuera de la línea: un
642     contenedor con scroll recorta lo que se sale. Así que se trae a la vista, y
643     con `nearest` para que se mueva lo justo y no dé un salto. */
644  const menu = line.querySelector(".modemenu");
645  if(menu) menu.scrollIntoView({block:"nearest"});
646
647  /* La línea acaba de cambiar de alto —una foto, una parada menos—, y de ella
648     depende el alto de la card. */
649  fit();
650}
651
652/* Una parada nueva entra por el FINAL, y la línea ya puede ser más larga que su
653   hueco: lo que hay que enseñar es el final —la parada recién puesta y el + para
654   la siguiente—, no el principio del viaje. */
655function lineToEnd(){ line.scrollTop = line.scrollHeight; }
656
657function render(){
658  const trip = picked.length >= 1;
659  head.textContent = trip ? HEAD_TRIP : HEAD_START;
660  line.hidden = !trip;
661  /* El campo solo se esconde con el viaje ya montado y el + sin pulsar. */
662  box.hidden = trip && !boxOpen;
663  /* Mientras se reproduce, la card se aparta ENTERA: ocupa el centro de la
664     pantalla y la vista previa existe precisamente para ver lo que hay debajo. */
665  glass.hidden = previewing;
666  /* La lista de resultados ya no vive dentro del campo —flota fuera de la card,
667     ver .qlist en la hoja de estilos—, así que esconder el campo o apartar la
668     card no se la lleva por delante: se cierra aquí. Hoy no puede pasar (los dos
669     caminos que esconden el campo cierran antes), pero es una línea contra un
670     desplegable huérfano flotando sobre el globo. */
671  if(!list.hidden && (box.hidden || glass.hidden)) close();
672  /* Las acciones piden DOS paradas, no una: con un solo punto no hay trayecto
673     que previsualizar ni que grabar. Y se apartan mientras el campo está abierto,
674     porque la lista de resultados baja hasta donde están ellas; ver #p-bar en la
675     hoja de estilos.
676     La barra NO se va con la card al reproducir: ahí está el botón de detener.
677     Tampoco se toca al grabar: para eso está la capa, que la tapa entera. */
678  bar.hidden = picked.length < 2 || boxOpen;
679  if(trip) paintLine();
680  /* Todo lo de arriba cambia el alto de la card —el campo, la línea, las fotos—,
681     así que se recoloca aquí en vez de acordarse en cada sitio que llama. */
682  fit();
683}
684
685function openBox(){
686  boxOpen = true;
687  render();
688  input.focus();
689}
690
691/* ---------- la vista previa ----------
692   Reproduce en pantalla la MISMA secuencia que grabará el vídeo: el director de
693   nadir-anim.js recorriendo el itinerario que hay puesto, con sus paradas y sus
694   fotos. De la secuencia aquí no se decide nada —es cosa del director, y el
695   bucle de nadir-main.js ya le da su turno en cada fotograma—; lo único que se
696   resuelve es la diferencia entre MIRAR la escena y editarla.
697
698   EL ENCUADRE VUELVE AL CENTRO, y no es cuestión de gusto. En reposo el punto
699   observado vive en el quinto superior (CITY_Y) para que la ciudad no quede
700   detrás del cristal, pero el director y route.fitWide() calculan sus planos
701   dando por hecho que está centrado —ver aimY en nadir-orbit.js—, así que con el
702   encuadre descentrado los márgenes del plano general se reparten mal. Se pide
703   SIN transición, en el mismo corte que planta la cámara en la pose de apertura
704   —ver startPreview()—, para que la vista previa y el vídeo tengan el encuadre
705   centrado desde el primer fotograma; render() en nadir-export.js hace lo mismo
706   y por la misma razón. Tuvo la duración de un viaje a una ciudad mientras la
707   secuencia arrancaba desde donde estuviera la cámara: entonces el
708   desplazamiento corría por debajo del acercamiento y los dos se leían como un
709   solo movimiento. Al parar se devuelve a CITY_Y, que es dejarle su sitio al
710   panel que vuelve, y ese sí conserva su duración: ahí no hay director con el
711   que competir.
712
713   AQUÍ NO SE GUARDA SI ESTÁ REPRODUCIENDO. La verdad es anim.state(), porque la
714   secuencia se para de tres maneras —el botón, un arrastre sobre el globo (lo
715   registra el propio nadir-anim.js sobre el canvas) y llegar al final— y de dos
716   de ellas este archivo no se entera nunca. `previewing` es solo el último eco, y
717   quien lo pone al día es sync(). */
718const previewBtn  = el("p-preview");
719const previewIcon = el("p-preview-i");
720const previewText = el("p-preview-t");
721const PREVIEW_PLAY = t("PREVIEW_PLAY");
722const PREVIEW_STOP = t("PREVIEW_STOP");
723/* Los dos iconos, en la misma rejilla de 16 que el resto de la barra: el
724   triángulo de reproducir y el cuadrado de detener. El botón cambia de oficio,
725   así que cambia entero; un triángulo de play al lado de la palabra «Detener»
726   diría dos cosas contrarias en el mismo botón. */
727const ICON_PLAY = '<path d="M5 3.2v9.6l8-4.8z"/>';
728const ICON_STOP = '<path d="M4.4 4.4h7.2v7.2H4.4z"/>';
729
730/* Arrancar la secuencia. Está aparte del manejador del botón porque tiene otro
731   pulsador: el de la demo, que monta un itinerario y lo reproduce sin pasar por
732   la barra de acciones. Parar no lo necesita —es una sola llamada— y además
733   ocurre de tres maneras, solo una de las cuales pasa por aquí. */
734function startPreview(){
735  /* Un desplegable de modos abierto se quedaría colgado al irse la card. */
736  hopOpen = -1;
737  /* LA VISTA PREVIA ENSEÑA EL VÍDEO, así que sale del mismo sitio: la cámara se
738     planta en la pose de apertura ANTES de play(), que es quien fotografía el
739     punto de partida. Antes arrancaba desde donde estuviera —encima de la última
740     ciudad añadida—, y lo que se veía no era lo que salía en el archivo.
741     SIN VIAJE (secs 0) y con el encuadre también sin transición, por lo mismo
742     que en render() de nadir-export.js: el director empieza a mover la cámara en
743     el mismo fotograma, y un desplazamiento en curso sería otra mano empujando lo
744     mismo. Es un corte, no un movimiento: se cambia de plano. */
745  flyTo(OPEN_POSE.lat, OPEN_POSE.lon, OPEN_POSE.dist, 0, OPEN_POSE.tilt);
746  setAimY(0.5, 0);
747  /* SIN LA FIRMA DE MARCA. Es lo único que la vista previa no enseña del vídeo:
748     el cierre de cristal con el logo y la dirección va en el archivo, no aquí.
749     Quien está montando su itinerario ya está en el sitio, así que en pantalla
750     la firma solo tapa el plano final y alarga la espera. El pase termina en el
751     plano general y dura los segundos que la firma añadía menos. */
752  anim.play({outro:false});
753  /* Sin esperar al siguiente fotograma: quien pulsa tiene que ver el botón
754     cambiado en el mismo gesto. */
755  sync();
756  track("nadir_preview_started", trip());
757}
758
759previewBtn.addEventListener("click", () => {
760  if(anim.state().playing){
761    /* Solo parar: todo lo que hay que deshacer —el encuadre, la card— lo hace
762       sync(), que es también quien atiende las paradas que no pasan por aquí. */
763    anim.stop();
764    sync();
765  }else{
766    startPreview();
767  }
768});
769
770/* El eco del director, al día. Lo llama el bucle de render en cada fotograma a
771   través de telemetry(), así que lo primero que hace es no hacer nada: solo
772   toca el DOM cuando el estado CAMBIA. */
773function sync(){
774  const on = anim.state().playing;
775  if(on === previewing) return;
776  previewing = on;
777  /* Se ha parado, y da igual cuál de las tres formas ha sido. */
778  if(!on) setAimY(CITY_Y, FLY_SECS);
779  /* innerHTML con una constante escrita a mano, como en los iconos de la card:
780     es un SVG fijo, aquí no entra nada de fuera. */
781  previewIcon.innerHTML = on ? ICON_STOP : ICON_PLAY;
782  previewText.textContent = on ? PREVIEW_STOP : PREVIEW_PLAY;
783  render();
784}
785
786/* ---------- generar el vídeo ----------
787   El otro botón de la barra. Graba la misma secuencia que reproduce la vista
788   previa —más la firma de marca del final, que solo va en el archivo—, pero
789   fotograma a fotograma con el reloj congelado y pasándola por
790   WebCodecs: nadir-export.js. Aquí no se decide nada del vídeo salvo dos cosas
791   —de dónde sale la cámara y qué se ve mientras tanto—; el resto son los valores
792   de fábrica del exportador: 1080×1920, 30 fps, sin suavizado.
793
794   POR QUÉ NADIR.exporter SE BUSCA DENTRO DEL CLIC Y NO ARRIBA. nadir-export.js
795   consume NADIR.ui.updateSun(), así que en nadir-prod.html se carga DESPUÉS de
796   este archivo: mientras estas líneas se ejecutan, NADIR.exporter todavía no
797   existe. Guardarlo en una constante al montar dejaría undefined para siempre.
798   Es la misma resolución tardía que hace nadir-main.js, y por el mismo motivo.
799
800   SIN SUAVIZADO 2×, que es una decisión de producto y no un olvido: renderizar a
801   2160×3840 para reducir después cuesta unas diez veces más, y esta página está
802   pensada de móvil hacia fuera. Un vídeo de once segundos pasaría de segundos a
803   más de un minuto en un teléfono. Cuando haya medida real en un móvil se podrá
804   revisar; hasta entonces, terminar vale más que pulir. */
805const OUT_WORK = t("OUT_WORK");
806const OUT_WAIT = t("OUT_WAIT");
807const OUT_NOPE = t("OUT_NOPE");
808const OUT_STOP = t("OUT_STOP");
809
810const out     = el("p-out");
811const outText = el("p-out-t");
812const outPct  = el("p-out-n");
813const outStop = el("p-out-x");
814
815/* Cierto mientras se espera a que carguen texturas, fotos o el modelo, ANTES de
816   que el exportador haya arrancado. En ese tramo no hay `busy` al que preguntar
817   —el motor aún no sabe nada— y sin embargo la capa ya está puesta y su botón ya
818   tiene que poder cancelar. De ahí esta bandera y no una consulta. */
819let awaiting = false;
820
821/* Un solo camino hacia la capa, con el porcentaje opcional: null lo esconde. Los
822   tres estados son el mismo marcado con distinto texto, así que no hay nada que
823   mostrar ni ocultar aparte de la cifra. */
824function showOut(text, pct){
825  outText.textContent = text;
826  outPct.hidden = pct === null;
827  if(pct !== null) outPct.textContent = t("OUT_PCT", {n: pct});
828  out.hidden = false;
829}
830function hideOut(){ awaiting = false; out.hidden = true; }
831
832/* DE DÓNDE SALE LA CÁMARA, que es lo único del encuadre que se decide aquí.
833   La fase de acercamiento interpola desde donde esté la cámara al arrancar —ver
834   render() en nadir-export.js—, o sea que el punto de partida forma parte del
835   vídeo. Tomarlo de la vista sería lo mismo que hace el panel de dev, pero aquí
836   convierte el archivo en una función de dónde tuviera el usuario el globo:
837   pedir el mismo viaje dos veces daría dos vídeos distintos según si entre medias
838   giró el planeta o no.
839   ES LA VISTA DE CARGA, LA MISMA PARA TODOS LOS ITINERARIOS: el producto abre
840   siempre con el planeta entero y de ahí baja. No depende ni de las paradas, así
841   que dos rutas distintas comparten el primer fotograma.
842   ANTES ERA route.wide(), el plano general con el que el director cierra: el
843   vídeo abría y cerraba en el mismo encuadre, ajustado a la ruta. Se cambió el 9
844   septiembre 2026 por una apertura fija y más alejada. Consecuencia asumida: la
845   apertura ya no coincide con el cierre, y en un itinerario muy largo —donde
846   route.wide() se iba a 6,0— la apertura queda MÁS CERCA que el plano final.
847   Quien quiera lo de antes cambia esta función, no el director. */
848function wideFrom(){ return Object.assign({}, OPEN_POSE); }
849
850/* El eco del exportador. Llega por setOnChange() y no por telemetry(), porque
851   durante la exportación el bucle de nadir-main.js se aparta y telemetry() no
852   corre: sin este hilo nadie repintaría el porcentaje.
853   Que `busy` pase a false es el final, y da igual cuál de los tres haya sido —el
854   vídeo terminado, el botón de cancelar o un fallo—: en el primer caso la
855   descarga ya la ha lanzado el propio exportador, y en los otros dos no hay nada
856   que entregar. La capa se va en los tres. */
857function onExport(s){
858  if(s.busy){ showOut(OUT_WORK, Math.round(s.progress*100)); return; }
859  /* Se acabó, y ahora hay que mirar CUÁL de los tres finales fue. Antes daba
860     igual —la descarga la lanzaba el exportador y aquí solo se quitaba la capa—;
861     desde que el producto pasa autoSave:false, el vídeo terminado no va a
862     ninguna parte si esta rama no lo recoge. Cancelado y error no dejan bytes y
863     se van por donde se iban. */
864  hideOut();
865  if(s.done){ showDone(); return; }
866  /* Los otros dos finales, que son los que §1.2 de NADIR_TODO.md quiere ver:
867     si la gente que pulsa «generar» llega al MP4 o se queda por el camino. */
868  track("nadir_export_failed", Object.assign(trip(), {
869    reason: s.error ? "error" : "cancelled",
870    error: s.error || null,
871    render_s: Math.round((performance.now() - exportT0) / 1000)
872  }));
873}
874
875/* Cuándo arrancó la exportación en curso, para medir cuánto tardó en acabar,
876   fallar o cancelarse. */
877let exportT0 = 0;
878
879function fire(exporter){
880  showOut(OUT_WORK, 0);
881  exportT0 = performance.now();
882  track("nadir_export_started", trip());
883  /* Se apunta aquí y no al montar por lo dicho arriba: al montar no hay a quién
884     apuntarse. Repetirlo en cada grabación es inofensivo —es una asignación
885     sobre la única ranura que tiene el exportador— y ahorra un `if` de estado. */
886  exporter.setOnChange(onExport);
887  /* autoSave:false: el vídeo se queda en el exportador y lo recoge showDone().
888     Descargarlo es una de las cuatro cosas que el usuario puede querer hacer con
889     él, no lo que le pasa por defecto sin que nadie le pregunte. */
890  exporter.start({from: wideFrom(), autoSave: false});
891}
892
893/* La antesala de cualquier grabación —la del botón y la del vídeo de pago—:
894   comprueba que se puede, espera lo que falte y llama a go(exporter). Devuelve
895   si se ha puesto en marcha (o en espera); false si no se puede grabar ahora. */
896function whenCanStart(go){
897  const exporter = NADIR.exporter;
898  if(!exporter || exporter.busy() || awaiting || proBusy) return false;
899  /* Una vista previa en marcha se para: el director recorrería el itinerario dos
900     veces a la vez, con el bucle en vivo y el del exportador peleándose por la
901     cámara. sync() para que el botón de al lado deje de decir «Detener». */
902  if(anim.state().playing){ anim.stop(); sync(); }
903
904  outStop.textContent = OUT_STOP;
905  const why = exporter.canStart();
906  /* Sin códec no se arregla esperando: se dice y se acabó. La capa se queda
907     puesta con su botón, que aquí solo sirve para cerrarla. */
908  if(why === "codec"){
909    showOut(OUT_NOPE, null);
910    track("nadir_export_failed", Object.assign(trip(), {reason:"codec", error:null, render_s:0}));
911    return false;
912  }
913  if(!why){ go(exporter); return true; }
914
915  /* Falta algo que SÍ va a llegar —una textura, una foto recién elegida, el
916     modelo del vehículo—. Se espera y se arranca solo, en vez de rebotar al
917     usuario para que vuelva a pulsar: son estados de menos de un segundo y él no
918     tiene forma de saber cuál se ha ido a buscar. Mismo compás que
919     whenPhotosReady(), un sondeo por fotograma, y por lo mismo: no hay evento al
920     que suscribirse.
921     SIN TOPE, al revés que allí. Allí pasado el plazo la demo arranca igual con
922     las fotos que hayan llegado, que es un vídeo peor pero un vídeo; aquí
923     arrancar sin recursos sería grabar un globo negro, y el exportador lo
924     rechazaría de todas formas: la capa se quedaría puesta sin que nada avance.
925     Si algo no llega nunca, el que decide es el usuario, con el botón. */
926  awaiting = true;
927  showOut(OUT_WAIT, null);
928  (function poll(){
929    if(!awaiting) return;                       /* lo canceló mientras esperaba */
930    if(NADIR.exporter.canStart()){ requestAnimationFrame(poll); return; }
931    awaiting = false;
932    go(NADIR.exporter);
933  })();
934  return true;
935}
936
937function startRender(){ whenCanStart(fire); }
938
939el("p-render").addEventListener("click", startRender);
940
941/* El botón de la capa hace dos oficios con el mismo rótulo, y el estado dice
942   cuál: con el exportador en marcha, cancela —y la capa NO se va aquí, sino
943   cuando restore() avise por onExport, que es cuando la escena ha vuelto de
944   verdad—; en cualquier otro caso (esperando recursos, o navegador incapaz) no
945   hay nada que cancelar y solo cierra. */
946outStop.addEventListener("click", () => {
947  const exporter = NADIR.exporter;
948  if(exporter && exporter.busy()){ exporter.cancel(); return; }
949  hideOut();
950});
951
952/* ---------- la pantalla de resultado ----------
953   Se abre cuando el exportador termina con bytes y es lo último que ve el
954   usuario: su vídeo puesto, y cuatro salidas debajo. Marcado en nadir-prod.html,
955   bajo <div id="p-done">.
956
957   MIENTRAS ESTÁ PUESTA, EL GLOBO SE CONGELA. Es la bandera de aquí abajo, que
958   nadir-main.js consulta cada fotograma: detrás de esta pantalla hay un canvas
959   que no ve nadie, y dibujarlo sesenta veces por segundo le quita GPU al
960   decodificador del <video> justo cuando hace falta. */
961const done     = el("p-done");
962const doneVid  = el("p-done-v");
963const doneNote = el("p-done-note");
964
965/* Lo que lee nadir-main.js. Módulo-privada y expuesta como función al final del
966   archivo, no como propiedad suelta: quien la lee no tiene por qué poder
967   escribirla. */
968let frozen = false;
969
970/* El blob: del <video>. Se guarda porque hay que revocarlo A MANO: mientras esa
971   URL viva, el navegador retiene los ~15 MB del MP4 aunque la pantalla esté
972   escondida y el usuario se haya ido a componer otro viaje. */
973let vidUrl = null;
974
975/* Safari de iOS no descarga un blob: y no hay forma de preguntárselo — `download`
976   en un <a> dice que sí y luego no ocurre nada, sin error y sin aviso. Es la
977   ÚNICA detección de plataforma del proyecto y está aquí por eso: para mandar a
978   esos aparatos por la hoja de compartir, que es el único camino por el que un
979   iPhone puede quedarse el vídeo («Guardar en Fotos» sale en esa hoja).
980   El día que Safari lo arregle se borra esta constante y downloadVideo() vuelve
981   a ser una línea. El segundo término es un iPad moderno, que se declara Mac y
982   solo se delata por el táctil. */
983const IOS = /iP(hone|od|ad)/.test(navigator.platform) ||
984            (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
985
986/* A dónde se manda al usuario cuando NO hay hoja de compartir —o sea, en el
987   escritorio—. No suben nada: son la página desde la que él sube el archivo que
988   se acaba de descargar. */
989const UPLOAD = {
990  tiktok:    "https://www.tiktok.com/upload",
991  instagram: "https://www.instagram.com/"
992};
993const NOTE_DESK = t("NOTE_DESK");
994const NOTE_FAIL = t("NOTE_FAIL");
995
996/* EL VÍDEO QUE SE ENSEÑA, que desde el cobro ya no es siempre el del exportador:
997   el de pago vuelve de IndexedDB después de pasar por Stripe, cuando el
998   exportador no tiene nada. Los cuatro botones leen de aquí y no de
999   NADIR.exporter.blob(), y así tampoco importa que durante el render limpio el
1000   exportador tenga unos segundos los bytes sin marca: lo que se descarga y se
1001   comparte es esto. `premium` decide si el botón de quitar la marca tiene
1002   sentido. */
1003let shown = null;
1004
1005function openDone(b, name, premium){
1006  if(vidUrl) URL.revokeObjectURL(vidUrl);
1007  vidUrl = URL.createObjectURL(b);
1008  shown = {blob:b, name, premium:!!premium};
1009  doneVid.src = vidUrl;
1010  doneNote.hidden = true;
1011  fillDonePro();
1012  done.hidden = false;
1013  frozen = true;
1014  const go = doneVid.play();
1015  if(go && go.catch) go.catch(() => {});
1016}
1017
1018function showDone(){
1019  const b = NADIR.exporter.blob();
1020  /* Sin bytes no hay pantalla que enseñar. No debería ocurrir —onExport solo
1021     llama aquí con s.done— pero abrir un <video> con src null deja un rectángulo
1022     negro sin explicación, y eso es peor que no abrir nada. */
1023  if(!b) return;
1024  openDone(b, NADIR.exporter.filename(), false);
1025  /* Para el reporte de problemas: cuántos vídeos ha llegado a terminar esta
1026     sesión. Ver repExports. */
1027  repExports++;
1028  /* «Exportó», con lo que pide NADIR_TODO.md §2.5: duración y resolución. Las
1029     dos salen del exportador, que es quien las decidió. */
1030  const st = NADIR.exporter.state();
1031  track("nadir_video_exported", Object.assign(trip(), {
1032    duration_s: st.fps ? Math.round(st.frames / st.fps) : null,
1033    resolution: st.preset,
1034    fps: st.fps,
1035    size_mb: Math.round(b.size / 1048576 * 10) / 10,
1036    render_s: Math.round((performance.now() - exportT0) / 1000),
1037    exports_this_session: repExports
1038  }));
1039}
1040
1041/* Cerrar es tirar el vídeo, y la ÚNICA puerta es la flecha de arriba: el botón
1042   «Descartar vídeo» que había debajo se fue cuando su sitio lo ocupó el botón
1043   premium (ver donePro más abajo). Hace exactamente esto. Se sueltan las tres cosas que pesan, en este orden: el
1044   decodificador del <video>, la URL del blob y los bytes del exportador. Sin el
1045   load() de en medio, Chrome mantiene vivo el búfer del vídeo aunque se le quite
1046   el src. */
1047function closeDone(){
1048  doneVid.pause();
1049  doneVid.removeAttribute("src");
1050  doneVid.load();
1051  if(vidUrl){ URL.revokeObjectURL(vidUrl); vidUrl = null; }
1052  shown = null;
1053  NADIR.exporter.discard();
1054  done.hidden = true;
1055  frozen = false;
1056}
1057
1058/* Descargar `shown` con su nombre. Es save() de nadir-export.js sobre otro
1059   blob: el exportador solo sabe guardar el suyo, y el vídeo de pago no está
1060   ahí. */
1061function saveShown(){
1062  const a = document.createElement("a");
1063  const url = URL.createObjectURL(shown.blob);
1064  a.href = url;
1065  a.download = shown.name;
1066  document.body.appendChild(a);
1067  a.click();
1068  a.remove();
1069  setTimeout(() => URL.revokeObjectURL(url), 60000);
1070}
1071
1072/* ---------- compartir ----------
1073   NI TIKTOK NI INSTAGRAM PUEDEN RECIBIR UN VÍDEO DESDE UNA PÁGINA WEB, y conviene
1074   tenerlo escrito para que nadie lo intente otra vez: Instagram no expone
1075   absolutamente nada —ni intent, ni URL que acepte un archivo— y la API de
1076   publicación de TikTok exige OAuth, subida desde un servidor propio y pasar su
1077   revisión de app; su SDK de compartir es solo para apps nativas.
1078
1079   Lo único que existe en el navegador es la hoja de compartir del sistema
1080   —navigator.share() con el archivo—, donde las dos aparecen como destino si
1081   están instaladas. Chrome de Android y Safari de iOS 16.4+ la traen; Firefox no,
1082   y el escritorio a medias.
1083
1084   POR ESO LOS DOS BOTONES HACEN LO MISMO EN EL MÓVIL: la hoja es el camino real,
1085   y el usuario toca ahí la app que quiera. Solo se separan donde no hay hoja, que
1086   es donde el destino sí cambia algo: se descarga y se abre su web de subida. */
1087/* QUE EL NAVEGADOR SEPA COMPARTIR NO BASTA, y esto costó un fallo: Safari y
1088   Chrome de ESCRITORIO en macOS también aceptan navigator.share() con archivos,
1089   y abren la hoja de compartir del sistema. Pero en esa hoja no está TikTok ni
1090   Instagram —no son apps de escritorio—, así que pulsar «TikTok» en un Mac
1091   abriría un menú con AirDrop y Mensajes y no llegaría nunca al camino que sí
1092   sirve allí: descargar y abrir su web de subida.
1093
1094   `pointer: coarse` es el filtro: cierto en teléfonos y tabletas, que es donde
1095   las dos apps existen de verdad, y falso en un portátil aunque tenga pantalla
1096   táctil, mientras se maneje con ratón. No es una detección de sistema
1097   operativo, que es justo lo que no se quiere aquí: la pregunta no es «¿qué
1098   aparato es?», es «¿tiene sentido enseñarle una hoja de compartir?». */
1099function hasShareSheet(){
1100  return !!(navigator.canShare && navigator.share) &&
1101         window.matchMedia && window.matchMedia("(pointer: coarse)").matches;
1102}
1103
1104/* SON DOS PREGUNTAS DISTINTAS Y HAY QUE NO MEZCLARLAS.
1105   `shareFile` es la capacidad pelada: ¿puede este navegador entregar este
1106   archivo a la hoja? La usa el botón de descargar en iOS, donde la hoja es el
1107   único camino que existe HAYA EL PUNTERO QUE HAYA — un iPad con teclado y
1108   trackpad declara puntero fino y sigue sin poder descargar un blob:.
1109   `hasShareSheet` es la de arriba y además la de conveniencia, y es la que
1110   gobierna los botones de marca. */
1111function shareFile(b){
1112  /* El File se construye para preguntar porque canShare() necesita verlo para
1113     responder. Es una vista sobre los mismos bytes, no una copia. */
1114  if(!navigator.canShare || !navigator.share) return null;
1115  const f = new File([b], shown ? shown.name : NADIR.exporter.filename(), {type:"video/mp4"});
1116  return navigator.canShare({files:[f]}) ? f : null;
1117}
1118
1119function shareable(b){ return hasShareSheet() ? shareFile(b) : null; }
1120
1121/* La misma reacción para los dos sitios que abren la hoja: si la cierra, es que
1122   ha cambiado de idea y no ha pasado nada; cualquier otro fallo sí hay que
1123   contarlo, porque el usuario se queda sin vídeo y sin saber por qué. */
1124function shareFailed(e){
1125  if(e && e.name === "AbortError") return;
1126  doneNote.textContent = NOTE_FAIL;
1127  doneNote.hidden = false;
1128}
1129
1130function share(where){
1131  const b = shown && shown.blob;
1132  if(!b) return;
1133  const f = shareable(b);
1134  if(f){
1135    /* SIN await ANTES de esta línea, y no es casualidad: navigator.share() exige
1136       estar dentro del gesto del usuario y cualquier espera de por medio lo
1137       rompe. Por eso la decisión de qué camino tomar es síncrona. */
1138    navigator.share({files:[f]}).catch(shareFailed);
1139    return;
1140  }
1141  /* Sin hoja de compartir. Las dos cosas en el mismo gesto y sin esperas de por
1142     medio, o el navegador tomaría la pestaña por un emergente y la bloquearía. */
1143  saveShown();
1144  window.open(UPLOAD[where], "_blank", "noopener");
1145  doneNote.textContent = NOTE_DESK;
1146  doneNote.hidden = false;
1147}
1148
1149/* Descargar. En iOS no se puede —ver IOS arriba—, así que allí este botón abre la
1150   hoja, que es de donde sale «Guardar en Fotos». No es un botón distinto para
1151   iPhone: es el mismo botón haciendo lo único que en iPhone significa
1152   «quédate el vídeo». */
1153function downloadVideo(){
1154  const b = shown && shown.blob;
1155  if(!b) return;
1156  if(IOS){
1157    /* shareFile y NO shareable: aquí no se pregunta si la hoja es CONVENIENTE
1158       —en iOS es la única salida— sino si se puede abrir. Ver arriba. */
1159    const f = shareFile(b);
1160    if(f){ navigator.share({files:[f]}).catch(shareFailed); return; }
1161  }
1162  saveShown();
1163}
1164
1165el("p-done-tt").addEventListener("click", () => share("tiktok"));
1166el("p-done-ig").addEventListener("click", () => share("instagram"));
1167el("p-done-dl").addEventListener("click", downloadVideo);
1168el("p-done-back").addEventListener("click", closeDone);
1169
1170/* ---------- el vídeo de pago: quitar la marca de agua ----------
1171   El botón rojo de la pantalla de resultado. Lo que vende está en la tabla del
1172   muro: el mismo vídeo sin la marca de la esquina (nadir-watermark.js) y sin la
1173   firma final (nadir-outro.js), por payAmount(). Un pago = un vídeo, sin cuenta:
1174   es un producto propio de Stripe, no la membresía del sitio.
1175
1176   EL ORDEN, que es decisión de producto (14 septiembre 2026): PRIMERO SE PAGA.
1177
1178     1. se guarda el viaje                → nadir-vault.js, IndexedDB, fotos incluidas
1179     2. se abre Stripe                    → la página se va
1180     3. se vuelve con ?nadir_session_id=  → /api/nadir/unlock confirma el pago
1181     4. RECIÉN AHÍ se renderiza sin marca → render({brand:false}) sobre el viaje guardado
1182     5. se entrega                        → la pantalla de resultado, sin botón rojo
1183
1184   El vídeo limpio no existe hasta que el pago está confirmado. Lo que protege
1185   al que pagó es el viaje guardado y la URL de retorno, que es el recibo: si el
1186   render falla al volver —memoria, un cierre de pestaña—, abrir esa misma URL
1187   lo vuelve a intentar, y como el render es determinista (NADIR.md §7) sale el
1188   mismo archivo. El MP4 terminado también se guarda en la caja fuerte, para
1189   que la siguiente visita a esa URL lo entregue sin renderizar otra vez.
1190
1191   Sin caja fuerte no se abre el pago: sin el viaje guardado no habría nada que
1192   renderizar al volver.
1193
1194   MIENTRAS SE ABRE EL PAGO, LA PANTALLA DE RESULTADO SE ESCONDE: está por
1195   encima de la capa de progreso (#p-done 30, #p-out 20). Si abrir el pago
1196   falla, se vuelve a poner tal cual estaba: el vídeo con marca sigue en `shown`.
1197
1198   SIEMPRE EN EUROS, para todo el mundo: el price del producto solo tiene EUR
1199   (decisión del 14 septiembre 2026) y createNadirCheckoutFn no manda moneda.
1200   Por eso este botón no pasa por payEur(), que es del muro de las 4 paradas.
1201   El importe y el producto tienen que ser el mismo: 2,99 € aquí y el price del
1202   producto NADIR_PRODUCT_ID en Stripe (functions/src/payments/stripe.ts). */
1203const PRO_AMOUNT = "2,99 €";
1204const DONE_PRO_TAG = t("DONE_PRO_TAG");
1205const DONE_PRO_T   = t("DONE_PRO_T");
1206const DONE_PRO_OK  = t("DONE_PRO_OK");
1207const PRO_WORK     = t("PRO_WORK");
1208const PRO_PAY      = t("PRO_PAY");
1209const PRO_CHECK    = t("PRO_CHECK");
1210const PRO_PAY_FAIL_H   = t("PRO_PAY_FAIL_H");
1211const PRO_PAY_FAIL_P   = t("PRO_PAY_FAIL_P");
1212const PRO_FAIL_H   = t("PRO_FAIL_H");
1213const PRO_FAIL_P   = t("PRO_FAIL_P");
1214const PRO_CHECK_FAIL_H = t("PRO_CHECK_FAIL_H");
1215const PRO_CHECK_FAIL_P = t("PRO_CHECK_FAIL_P");
1216const PRO_LOST_H   = t("PRO_LOST_H");
1217const PRO_LOST_P   = t("PRO_LOST_P");
1218const MSG_OK       = t("MSG_OK");
1219
1220const CHECKOUT_URL = "/api/nadir/checkout";
1221const UNLOCK_URL   = "/api/nadir/unlock";
1222/* Dónde se apunta el id del viaje que se fue a pagar, para que al volver con
1223   ?nadir=cancel se recupere. sessionStorage: es de esta pestaña y de este
1224   viaje de ida y vuelta, nada más. */
1225const PENDING_KEY = "nadir_pending";
1226
1227const donePro = el("p-done-pro");
1228/* Cierto desde que se pulsa el botón hasta que la página se va a Stripe o el
1229   intento se abandona; y al volver, mientras se confirma y se renderiza. Es lo
1230   que hace que el botón no se pueda pulsar dos veces y que startRender() no se
1231   cuele en medio. */
1232let proBusy = false;
1233
1234/* El interior del botón se escribe al abrir la pantalla y no al montar:
1235   payAmount() lee constantes declaradas más abajo en este archivo, y aquí
1236   arriba todavía no existen. Mismo marcado que #p-pay-go —etiqueta pequeña
1237   arriba, título grande debajo e importe a la derecha— para que compartan hoja
1238   de estilos. Con un vídeo de pago puesto el botón no tiene nada que vender y
1239   se quita; en su lugar, la nota dice lo que es. */
1240function fillDonePro(){
1241  if(!donePro) return;
1242  if(shown && shown.premium){
1243    donePro.hidden = true;
1244    doneNote.textContent = DONE_PRO_OK;
1245    doneNote.hidden = false;
1246    return;
1247  }
1248  donePro.hidden = false;
1249  donePro.disabled = false;
1250  donePro.textContent = "";
1251  const lbl = document.createElement("span"); lbl.className = "lbl";
1252  const tag = document.createElement("span"); tag.className = "tag"; tag.textContent = DONE_PRO_TAG;
1253  const t   = document.createElement("span"); t.className = "t";     t.textContent = DONE_PRO_T;
1254  lbl.append(tag, t);
1255  /* El importe con el formato de PriceTag del sitio: «€2» grande y «99» pequeño
1256     al lado, sin coma. */
1257  const amt = document.createElement("span"); amt.className = "amt";
1258  const whole = document.createElement("span"); whole.textContent = "€2";
1259  const cents = document.createElement("span"); cents.className = "cents"; cents.textContent = "99";
1260  amt.append(whole, cents);
1261  donePro.append(lbl, amt);
1262  donePro.setAttribute("aria-label", DONE_PRO_T + ": " + DONE_PRO_TAG + ", " + PRO_AMOUNT);
1263}
1264
1265/* ---------- el aviso ----------
1266   Una frase y una salida, para lo que sale mal en el camino del pago. Es el
1267   marcado que fue de «todavía no está disponible» (#p-soon), con los textos
1268   por parámetro. Se cierra con el botón, con Escape o tocando el fondo —mismos
1269   tres caminos que el muro—, y devuelve el foco al botón premium si está. */
1270const msg   = el("p-soon");
1271const msgOk = el("p-soon-ok");
1272function msgOpen(h, p){
1273  if(!msg) return;
1274  el("p-soon-h").textContent = h;
1275  el("p-soon-p").textContent = p;
1276  msgOk.textContent = MSG_OK;
1277  msg.hidden = false;
1278  try{ msgOk.focus({preventScroll:true}); }catch(_){ msgOk.focus(); }
1279}
1280function msgClose(){
1281  if(!msg || msg.hidden) return;
1282  msg.hidden = true;
1283  if(donePro && !donePro.hidden && !done.hidden){
1284    try{ donePro.focus({preventScroll:true}); }catch(_){ donePro.focus(); }
1285  }
1286}
1287if(msg){
1288  msgOk.addEventListener("click", msgClose);
1289  msg.addEventListener("click", e => { if(e.target === msg) msgClose(); });
1290  document.addEventListener("keydown", e => {
1291    if(e.key === "Escape" && !msg.hidden) msgClose();
1292  });
1293}
1294
1295/* Esconder y volver a poner la pantalla de resultado mientras se abre el pago.
1296   No es closeDone(): el vídeo con marca no se tira. */
1297function parkDone(){ doneVid.pause(); done.hidden = true; frozen = false; }
1298function unparkDone(){
1299  if(!shown) return;
1300  done.hidden = false;
1301  frozen = true;
1302  const go = doneVid.play();
1303  if(go && go.catch) go.catch(() => {});
1304}
1305
1306/* El nombre del archivo de pago, con la misma forma que le da el exportador. */
1307function premiumName(){
1308  return NADIR.exporter.filename().replace(/(-premium)?\.mp4$/, "-premium.mp4");
1309}
1310
1311/* Lo que hay que guardar del viaje para poder renderizarlo al volver: las
1312   paradas con su modo y su foto. Las fotos del usuario son objectURLs, que no
1313   sobreviven a la recarga, así que se leen a Blob; las de la demo son rutas y
1314   van tal cual. */
1315async function tripSnapshot(){
1316  const stops = [];
1317  for(const c of picked){
1318    let photo = null;
1319    if(c.img) photo = c.img.indexOf("blob:") === 0 ? await (await fetch(c.img)).blob() : c.img;
1320    stops.push({name:c.name, country:c.country, cc:c.cc, lat:c.lat, lon:c.lon, mode:c.mode, photo});
1321  }
1322  return {stops};
1323}
1324
1325/* El camino inverso: el viaje guardado vuelve al globo y a la card. Es lo que
1326   hace loadDemo() con su lista, y por eso comparten setTrip(). */
1327function restoreTrip(trip){
1328  if(!trip || !Array.isArray(trip.stops) || !trip.stops.length) return false;
1329  const list = trip.stops.map(s => ({
1330    name:s.name, country:s.country, cc:s.cc, lat:s.lat, lon:s.lon, mode:s.mode,
1331    img: s.photo instanceof Blob ? URL.createObjectURL(s.photo) : (s.photo || null)
1332  }));
1333  return setTrip(list);
1334}
1335
1336async function checkoutUrl(videoId){
1337  const r = await fetch(CHECKOUT_URL, {
1338    method: "POST",
1339    headers: {"Content-Type": "application/json"},
1340    body: JSON.stringify({videoId, locale: NADIR.i18n.lang})
1341  });
1342  const j = await r.json().catch(() => ({}));
1343  if(!r.ok || !j.url) throw new Error(j.error || ("checkout " + r.status));
1344  return j.url;
1345}
1346
1347/* El clic: guardar el viaje y a pagar. Si algo falla antes de irse, la pantalla
1348   de resultado vuelve como estaba y no se ha cobrado nada. */
1349function premiumStart(){
1350  track("nadir_premium_clicked", Object.assign(trip(), {surface:"result", currency:"eur"}));
1351  if(proBusy || !shown || shown.premium) return;
1352  if(!NADIR.vault || picked.length < 2){ msgOpen(PRO_PAY_FAIL_H, PRO_PAY_FAIL_P); return; }
1353  proBusy = true;
1354  donePro.disabled = true;
1355  parkDone();
1356  outStop.hidden = true;
1357  showOut(PRO_PAY, null);
1358
1359  const videoId = NADIR.vault.newId();
1360  tripSnapshot()
1361    .then(snap => NADIR.vault.put(videoId, {trip: snap}))
1362    .then(() => checkoutUrl(videoId))
1363    .then(url => {
1364      try{ sessionStorage.setItem(PENDING_KEY, videoId); }catch(_){}
1365      track("nadir_checkout_opened", Object.assign(trip(), {video_id: videoId}));
1366      location.href = url;
1367    })
1368    .catch(e => {
1369      hideOut();
1370      outStop.hidden = false;
1371      proBusy = false;
1372      unparkDone();
1373      fillDonePro();
1374      track("nadir_premium_failed", Object.assign(trip(), {step: "checkout", error: String(e && e.message || e)}));
1375      msgOpen(PRO_PAY_FAIL_H, PRO_PAY_FAIL_P);
1376    });
1377}
1378
1379if(donePro) donePro.addEventListener("click", premiumStart);
1380
1381/* ---------- la vuelta de Stripe ----------
1382   Con ?nadir_session_id= en la URL se pregunta al servidor si ese pago existe
1383   —lo escribe el webhook, así que puede tardar un par de segundos en aparecer;
1384   de ahí los reintentos— y con el videoId que devuelve se busca el viaje en la
1385   caja fuerte, se pone en el globo y se renderiza sin marca. Si esa URL ya se
1386   había abierto y el vídeo quedó guardado, se entrega sin renderizar.
1387   La URL se deja como está: es el recibo. */
1388const UNLOCK_TRIES = 4;
1389const UNLOCK_WAIT_MS = 2000;
1390/* Cuánto se espera a las rutas de carretera antes de grabar. En el camino
1391   normal el usuario las esperó sin saberlo mientras miraba el globo; aquí se
1392   acaba de poner el viaje y las peticiones están en vuelo. */
1393const ROADS_WAIT_MS = 2500;
1394
1395async function unlock(sid){
1396  for(let i = 0; i < UNLOCK_TRIES; i++){
1397    const r = await fetch(UNLOCK_URL + "?session_id=" + encodeURIComponent(sid));
1398    if(r.ok) return r.json();
1399    if(r.status !== 404) throw new Error("unlock " + r.status);
1400    await new Promise(res => setTimeout(res, UNLOCK_WAIT_MS));
1401  }
1402  throw new Error("unlock pending");
1403}
1404
1405/* Arranca el exportador sin marca. `after` recibe el estado final del
1406   exportador (done, error) cuando acaba, se cancele o falle. */
1407function renderClean(after){
1408  const go = exporter => {
1409    showOut(PRO_WORK, 0);
1410    exporter.setOnChange(s => {
1411      if(s.busy){ showOut(PRO_WORK, Math.round(s.progress*100)); return; }
1412      after(s);
1413    });
1414    exporter.start({from: wideFrom(), autoSave: false, brand: false});
1415  };
1416  return whenCanStart(go);
1417}
1418
1419function deliver(b, videoId, rendered){
1420  hideOut();
1421  outStop.hidden = false;
1422  proBusy = false;
1423  openDone(b, premiumName(), true);
1424  repExports++;
1425  track("nadir_premium_delivered", Object.assign(trip(), {video_id: videoId, rendered: !!rendered,
1426                                                          size_mb: Math.round(b.size / 1048576 * 10) / 10}));
1427}
1428
1429function premiumReturn(sid){
1430  proBusy = true;
1431  outStop.hidden = true;
1432  showOut(PRO_CHECK, null);
1433  const fail = (h, p, step, error) => {
1434    hideOut();
1435    outStop.hidden = false;
1436    proBusy = false;
1437    track("nadir_premium_return_failed", {step, error: error || null});
1438    msgOpen(h, p);
1439  };
1440  unlock(sid).then(async r => {
1441    const rec = NADIR.vault ? await NADIR.vault.get(r.videoId).catch(() => null) : null;
1442    try{ sessionStorage.removeItem(PENDING_KEY); }catch(_){}
1443    const restored = rec && restoreTrip(rec.trip);
1444    /* Ya se renderizó en una visita anterior a esta URL: se entrega y punto. */
1445    if(rec && rec.mp4 instanceof Blob){ deliver(rec.mp4, r.videoId, false); return; }
1446    if(!restored || picked.length < 2){ fail(PRO_LOST_H, PRO_LOST_P, "lost"); return; }
1447    showOut(PRO_WORK, null);
1448    setTimeout(() => whenPhotosReady(() => {
1449      proBusy = false;                    /* whenCanStart lo exige libre */
1450      const started = renderClean(s => {
1451        if(!s.done){ fail(PRO_FAIL_H, PRO_FAIL_P, "render", s.error); return; }
1452        const b = NADIR.exporter.blob();
1453        NADIR.exporter.discard();
1454        NADIR.vault.put(r.videoId, {trip: rec.trip, mp4: b}).catch(() => {});
1455        deliver(b, r.videoId, true);
1456      });
1457      if(started) proBusy = true;
1458      else fail(PRO_FAIL_H, PRO_FAIL_P, "render-start");
1459    }), ROADS_WAIT_MS);
1460  }).catch(e => fail(PRO_CHECK_FAIL_H, PRO_CHECK_FAIL_P, "unlock", String(e && e.message || e)));
1461}
1462
1463/* Se arrepintió en Stripe: el viaje vuelve al globo para que no lo tenga que
1464   escribir otra vez, y la URL se limpia. Generar el vídeo con marca otra vez
1465   son unos segundos, así que la pantalla de resultado no se reabre. */
1466function premiumCancelled(){
1467  let id = null;
1468  try{ id = sessionStorage.getItem(PENDING_KEY); sessionStorage.removeItem(PENDING_KEY); }catch(_){}
1469  track("nadir_checkout_cancelled", {video_id: id});
1470  history.replaceState(null, "", location.pathname);
1471  if(!id || !NADIR.vault) return;
1472  NADIR.vault.get(id).then(rec => { if(rec) restoreTrip(rec.trip); }).catch(() => {});
1473}
1474
1475/* ---------- buscador ----------
1476   Misma mecánica que la caja de nadir-ui.js —teclear, flechas, Intro, tocar un
1477   resultado—, sin los pies de ayuda: en el panel de dev explican el teclado, y
1478   en un móvil no hay teclado que explicar.
1479   La lista se pinta entera en cada pulsación: son ocho filas. */
1480const input = el("p-input"), list = el("p-list");
1481let hits = [], at = -1;
1482
1483function paint(){
1484  list.textContent = "";
1485  hits.forEach((c, i) => {
1486    const li = document.createElement("li");
1487    li.setAttribute("role", "option");
1488    li.setAttribute("aria-selected", String(i === at));
1489    const n = document.createElement("span"); n.className = "n"; n.textContent = c.name;
1490    const k = document.createElement("span"); k.className = "c"; k.textContent = c.country;
1491    li.append(n, k);
1492    /* Se elige al SOLTAR, no al apoyar: en un móvil, apoyar el dedo es también
1493       el principio de un desplazamiento de la lista, y elegir en pointerdown
1494       hacía que intentar hacer scroll volara a la primera ciudad que se tocaba.
1495       Así que pointerdown solo cancela su acción por defecto —eso mantiene el
1496       foco en el campo: en el escritorio el click llegaría después del blur y
1497       la lista ya se habría cerrado, y en el móvil el teclado no baja— y
1498       anota dónde empezó el toque; pointerup elige si el dedo apenas se movió.
1499       Si el navegador se queda el gesto para desplazar, manda pointercancel y
1500       no hay pointerup: no se elige nada, que es lo que se quiere. */
1501    li.addEventListener("pointerdown", e => {
1502      e.preventDefault();
1503      tap = {id: e.pointerId, x: e.clientX, y: e.clientY};
1504    });
1505    li.addEventListener("pointerup", e => {
1506      if(!tap || tap.id !== e.pointerId) return;
1507      const moved = Math.hypot(e.clientX - tap.x, e.clientY - tap.y);
1508      tap = null;
1509      if(moved <= TAP_SLOP) pick(i);
1510    });
1511    li.addEventListener("pointercancel", () => { tap = null; });
1512    list.appendChild(li);
1513  });
1514  list.hidden = !hits.length;
1515  input.setAttribute("aria-expanded", String(!list.hidden));
1516  /* El campo y la lista son una sola barra: mientras hay resultados, el campo
1517     pierde sus esquinas de abajo. Ver #p-box.res en la hoja de estilos. */
1518  box.classList.toggle("res", !list.hidden);
1519  /* La card no cambia de alto —la lista está fuera de su flujo—, pero la lista sí
1520     acaba de aparecer o de crecer: fit() le pone su tope en píxeles y, si ni con
1521     eso cabe, aparta la card. Antes de que se pinte el fotograma. */
1522  fit();
1523}
1524
1525function close(){ hits = []; at = -1; paint(); }
1526/* El toque en curso sobre una fila de la lista (ver paint()) y cuánto puede
1527   moverse el dedo para que siga contando como toque y no como desplazamiento. */
1528let tap = null;
1529const TAP_SLOP = 10;
1530
1531/* Tres letras antes de ofrecer nada. Con dos —el suelo que trae el propio
1532   buscador— cualquier arranque frecuente suelta ocho ciudades de golpe y el
1533   desplegable se abre y se cierra a cada pulsación mientras el usuario todavía
1534   está escribiendo; con tres, la lista aparece cuando ya significa algo.
1535
1536   EL LÍMITE ES DE ESTA INTERFAZ, no del buscador: NADIR.search sigue
1537   respondiendo desde dos letras y el panel de pruebas de nadir.html no se entera
1538   de este cambio. */
1539const MIN_LETRAS = 3;
1540
1541function search(){
1542  hits = input.value.trim().length >= MIN_LETRAS ? NADIR.search.query(input.value) : [];
1543  at = hits.length ? 0 : -1;
1544  paint();
1545}
1546
1547/* Elegir una ciudad hace tres cosas: apuntarla, ponerle su pastilla y viajar
1548   hasta ella. El panel no cambia —sigue pidiendo una ciudad— y el campo se vacía
1549   porque ya ha cumplido.
1550
1551   Los tres movimientos se piden en el mismo fotograma y con la misma duración, y
1552   por eso se leen como uno: la cámara gira, baja hasta la escala pedida y se
1553   inclina 55° hacia el norte mientras el punto de mira sube del centro al
1554   quinto superior.
1555   La pastilla se enciende YA, al principio del viaje y no al final: nadir-labels
1556   la desvanece sola según lo cerca que esté la ciudad del borde del disco, así
1557   que asoma por su cuenta según el planeta gira hasta ponerla de cara. */
1558function pick(i){
1559  const c = hits[i];
1560  if(!c) return;
1561  /* La red de seguridad del muro. Con premium elegido el campo se abre solo (ver
1562     payChoose), y basta con que alguien lo deje abierto y siga pulsando para
1563     colarse una parada de más. Sin esto, entraría y el tope se quedaría en un
1564     número que la interfaz ya no defiende. */
1565  if(payLocked()){
1566    close();
1567    input.value = "";
1568    boxOpen = false;
1569    render();
1570    openPay();
1571    return;
1572  }
1573  /* `mode` es el del tramo que SALE de esta parada, y arranca en avión por lo
1574     mismo que en nadir-route.js: es el único que no necesita ni red ni terreno.
1575     En la última parada no significa nada y no se dibuja. */
1576  picked.push({name:c.name, country:c.country, cc:c.cc, lat:c.lat, lon:c.lon,
1577               mode:route.AIR, img:null});
1578  /* Se apunta y se le enseña a la escena ANTES de tocar nada de la interfaz: si
1579     la rechaza —dos paradas en el mismo sitio— se deshace y aquí no ha pasado
1580     nada, con el buscador tal y como estaba y sin ningún texto que inventar. */
1581  if(!applyRoute()){ picked.pop(); return; }
1582  track("nadir_city_added", Object.assign(trip(), {city: c.name, country: c.cc, position: picked.length - 1}));
1583  close();
1584  input.value = "";
1585  labels.setPlaces(picked);
1586  labels.setEnabled(true);
1587  /* La parada ya está puesta, así que el campo vuelve a esconderse detrás del +:
1588     lo que toca ver ahora es la línea del viaje, no el buscador. */
1589  boxOpen = false;
1590  render();
1591  lineToEnd();
1592  setAimY(CITY_Y, FLY_SECS);
1593  /* La altura se resuelve AQUÍ y no al arrancar: depende del tamaño del lienzo,
1594     que cambia al girar el móvil o al redimensionar la ventana, así que se
1595     pregunta en cada viaje.
1596     El tilt viaja con el resto del gesto —girar, bajar e inclinarse son un solo
1597     movimiento de 1,4 s— y por eso va como argumento de flyTo() y no como una
1598     escritura suelta en cam.tilt, que sería un salto en el primer fotograma. */
1599  flyTo(c.lat, c.lon, distForKmPerPx(CITY_SCALE), FLY_SECS, CITY_TILT*DEG);
1600}
1601
1602input.addEventListener("input", search);
1603input.addEventListener("keydown", e => {
1604  if(e.key === "ArrowDown" || e.key === "ArrowUp"){
1605    if(!hits.length) return;
1606    at = (at + (e.key === "ArrowDown" ? 1 : hits.length - 1)) % hits.length;
1607    paint();
1608    list.children[at].scrollIntoView({block:"nearest"});
1609  }else if(e.key === "Enter"){ pick(at); }
1610  else if(e.key === "Escape"){ close(); input.blur(); }
1611  else return;
1612  e.preventDefault();
1613});
1614
1615/* Un desplegable abierto se cierra al tocar cualquier otra cosa o con Escape,
1616   que es lo que se espera de un menú. Va en el documento y no en cada pastilla:
1617   paintLine() rehace las filas en cada cambio y los oyentes se irían con ellas.
1618   `closest(".hop")` deja pasar el clic que cae dentro de la propia fila —abrir,
1619   elegir— y cierra todos los demás. */
1620document.addEventListener("pointerdown", e => {
1621  if(hopOpen < 0 || e.target.closest(".hop")) return;
1622  hopOpen = -1;
1623  paintLine();
1624});
1625document.addEventListener("keydown", e => {
1626  if(e.key !== "Escape" || hopOpen < 0) return;
1627  hopOpen = -1;
1628  paintLine();
1629});
1630
1631/* ---------- el viaje de ejemplo ----------
1632   Un itinerario escrito de antemano, con sus fotos, para poder enseñar el
1633   producto sin teclear cinco ciudades. Deja EXACTAMENTE el mismo estado que
1634   dejaría alguien añadiéndolas una a una desde el panel —las mismas paradas, los
1635   mismos modos, las mismas fotos en la tarjeta— y a continuación reproduce.
1636
1637   LAS COORDENADAS NO ESTÁN AQUÍ. Cada ciudad se le pide al mismo buscador que
1638   usa el campo, por su clave; repetir una latitud sería tener el mismo número
1639   escrito en dos sitios y con el listado siendo generado, el de aquí sería el
1640   que se quedara viejo. El `cc` es lo que desempata a los homónimos: "londres"
1641   encuentra también la de Canadá, y va por delante o por detrás según la
1642   población del día que se regenere el listado.
1643
1644   El `mode` de una parada es el del tramo que SALE de ella, igual que en pick():
1645   la última no tiene tramo y el suyo no significa nada.
1646   La PRIMERA no lleva foto y no es un olvido: es el origen, y en esta interfaz
1647   el origen lleva casa fija y no admite ninguna (ver homeMark()).
1648
1649   Las fotos son archivos del propio sitio, así que no contaminan el canvas y
1650   pueden entrar en el vídeo igual que las que sube el usuario. Sirviendo por
1651   HTTP los nombres distinguen mayúsculas: son los del disco, tal cual. */
1652const DEMO = [
1653  {q:"buenos aires", cc:"AR", mode:route.AIR,    img:null},
1654  {q:"roma",         cc:"IT", mode:route.GROUND, img:"imagenes/roma.jpeg"},
1655  {q:"milan",        cc:"IT", mode:route.AIR,    img:"imagenes/Milan.jpeg"},
1656  {q:"paris",        cc:"FR", mode:route.RAIL,   img:"imagenes/paris.jpeg"},
1657  {q:"londres",      cc:"GB", mode:route.AIR,    img:"imagenes/Londres.jpeg"}
1658];
1659
1660/* La primera coincidencia del país que se espera. El límite es holgado a
1661   propósito: el buscador ordena por población y la ciudad buscada puede no ser
1662   la primera de su nombre. */
1663function demoCity(d){
1664  const hits = NADIR.search.query(d.q, 20);
1665  for(const c of hits) if(c.cc === d.cc) return c;
1666  return null;
1667}
1668
1669/* Cuánto se espera a que las fotos estén recortadas antes de arrancar.
1670   HAY QUE ESPERARLAS, y no es por que se vean: el director PREGUNTA por ellas en
1671   cada fotograma —una parada sin foto se queda en un respiro corto en vez de la
1672   parada entera, ver holdAt() en nadir-anim.js— así que una foto que llegue con
1673   la secuencia ya en marcha le mueve las duraciones por debajo. El tope está por
1674   si un archivo no está en su sitio: entonces la demo arranca igual, con las
1675   paradas que hayan podido cargar, en vez de quedarse esperando en silencio. */
1676const PHOTOS_WAIT_MS = 4000;
1677function whenPhotosReady(done){
1678  const t0 = performance.now();
1679  (function poll(){
1680    if(card.ready() || performance.now() - t0 > PHOTOS_WAIT_MS) return done();
1681    requestAnimationFrame(poll);
1682  })();
1683}
1684
1685function loadDemo(){
1686  /* Se resuelven las cinco ANTES de tocar nada: si una no aparece, no se ha
1687     deshecho el viaje que hubiera puesto para dejar la demo a medias. */
1688  /* LA DEMO TAMBIÉN CUENTA. Son cinco ciudades y el plan gratuito llega a
1689     cuatro: sin este recorte, el botón «Demo» sería la forma más rápida de
1690     saltarse el muro, y encima la propia página estaría desmintiendo el número
1691     que el muro acaba de decir. Con premium entran las cinco. */
1692  const list = [];
1693  for(const d of DEMO.slice(0, payLimit())){
1694    const c = demoCity(d);
1695    if(!c) return;
1696    list.push({name:c.name, country:c.country, cc:c.cc, lat:c.lat, lon:c.lon,
1697               mode:d.mode, img:d.img});
1698  }
1699  if(!setTrip(list)) return;
1700  /* Un evento y no cuatro «añadió ciudad»: la demo no es el usuario eligiendo,
1701     y mezclarla inflaría ese paso del embudo. */
1702  track("nadir_demo_loaded", trip());
1703  whenPhotosReady(startPreview);
1704}
1705
1706/* Poner un viaje entero de golpe, en vez de parada a parada como hace pick().
1707   Lo usan la demo y la vuelta de Stripe (restoreTrip, en «el vídeo de pago»).
1708   Devuelve false si la escena rechaza el itinerario, y entonces no queda nada
1709   puesto. */
1710function setTrip(list){
1711  if(anim.state().playing) anim.stop();
1712  /* Las fotos de antes se van, y las que puso el usuario se llevan su memoria:
1713     un objectURL vive hasta que se revoca. Las de la demo son rutas de archivo y
1714     no hay nada que revocar, de ahí la comprobación del esquema. */
1715  for(const c of picked) if(c.img && c.img.indexOf("blob:") === 0) URL.revokeObjectURL(c.img);
1716  picked.length = 0;
1717  /* Todas las ranuras de la tarjeta y no solo las que va a usar este viaje:
1718     un itinerario anterior más largo se dejaría las suyas puestas de la sexta en
1719     adelante, y volverían a aparecer al alargar este. */
1720  for(let i=0;i<route.MAX_STOPS;i++) card.setPhoto(i, null);
1721
1722  for(const c of list) picked.push(c);
1723  /* Mismo trato que en pick(): si la escena rechaza el itinerario se deshace y
1724     no queda una línea en la card que el globo no esté dibujando. */
1725  if(!applyRoute()){ picked.length = 0; labels.setEnabled(false); render(); return false; }
1726  picked.forEach((c, i) => { if(c.img) card.setPhoto(i, c.img); });
1727  labels.setPlaces(picked);
1728  labels.setEnabled(true);
1729
1730  close();
1731  input.value = "";
1732  boxOpen = false;
1733  render();
1734  lineToEnd();
1735
1736  /* La cámara se planta sobre la última parada SIN viajar (secs 0), que es donde
1737     la habría dejado añadirla a mano. No se viaja porque el director empieza a
1738     mover la cámara en cuanto arranca —toma su punto de partida en play()— y un
1739     viaje de 1,4 s en curso sería otra mano empujando lo mismo. */
1740  const last = picked[picked.length-1];
1741  flyTo(last.lat, last.lon, distForKmPerPx(CITY_SCALE), 0, CITY_TILT*DEG);
1742  return true;
1743}
1744
1745/* ---------- dónde va la card ----------
1746   Dos cosas la sacan de su sitio de reposo, y las dos son la misma cuenta: caber
1747   en el trozo de pantalla que se ve de verdad.
1748
1749   EL TECLADO DEL MÓVIL. Al abrirse, la mitad de abajo de la pantalla deja de
1750   existir: en iOS la card se queda DEBAJO y el usuario escribe a ciegas. La
1751   ventana de layout no se entera de nada —sigue midiendo lo mismo—, así que hay
1752   que preguntárselo a visualViewport, la única que sabe qué se ve.
1753   Se resuelve como en fitRep() y fitPay(), que es la técnica que funciona en
1754   esta página: el CONTENEDOR (#p-wrap) se hace del tamaño exacto de la franja
1755   visible —top y height en píxeles— y el centrado flex de la hoja de estilos
1756   coloca la card dentro de ella solo. Antes se dejaba el contenedor a pantalla
1757   entera y se compensaba la pila con un translateY calculado a partir de
1758   getBoundingClientRect(); en el móvil real la card seguía debajo del teclado.
1759   Medir el contenedor no depende de dónde estuviera la card ni de en qué orden
1760   lleguen los eventos de resize y scroll: cada llamada parte de cero.
1761
1762   LOS RESULTADOS. Cuelgan por fuera de la card (ver .qlist en nadir-prod.html),
1763   así que ya no la estiran: lo que hacen es bajar por debajo de ella y pueden
1764   acabar fuera de la pantalla. Se resuelve en dos pasos y por este orden: primero
1765   se le da a la lista SOLO el hueco que queda debajo del campo con la card en su
1766   sitio, así que mientras quepan tres filas la que se recorta es la lista y la
1767   card no se mueve; y solo cuando ni eso —el teclado— se aparta la card.
1768
1769   SOLO CORRIGE CUANDO HACE FALTA. Si la card cabe donde la deja la hoja de
1770   estilos, no se escribe ningún transform y manda el CSS (#p-wrap y su --lift).
1771   Y sin visualViewport —navegadores viejos— no se hace nada en absoluto: la card
1772   se queda donde el CSS la ponga, que es como estaba.
1773
1774   CUANDO NO CABE NI ASÍ —doce paradas con foto y el teclado abierto— la card se
1775   sale por ARRIBA: la línea del viaje se pierde de vista y los resultados se
1776   quedan enteros justo encima del teclado. Mientras se escribe, lo que hay que
1777   ver es lo que se está eligiendo; la línea vuelve al cerrar el campo. */
1778const vv = window.visualViewport;
1779const wrap  = el("p-wrap");
1780const stack = el("p-stack");
1781/* Aire entre la card y los dos cantos de la franja visible. */
1782const AIRE = 12;
1783/* Lo que se le reserva a la lista cuando no cabe todo. Se mide una fila de
1784   verdad en vez de escribir una altura a mano, que se quedaría vieja en cuanto
1785   cambie el tamaño de letra o el relleno de las filas. */
1786const MIN_FILAS = 3;
1787/* Y su techo: sin él, buscar un país —hasta 60 ciudades— convertiría la card en
1788   una columna de pantalla y media en un monitor. Es el mismo número que traía la
1789   hoja de estilos. */
1790const MAX_LISTA = 300;
1791/* El translateY que hay puesto ahora mismo. Hace falta para saber dónde estaría
1792   la card SIN él, que es la posición que manda mientras quepa. */
1793let shift = 0;
1794
1795function fit(){
1796  if(!vv) return;
1797  /* 0. El contenedor mide lo que se ve. Se escribe SIEMPRE, también con la
1798     card escondida: al cerrar el teclado el contenedor tiene que volver a la
1799     pantalla entera aunque en ese momento se esté reproduciendo. */
1800  wrap.style.top    = vv.offsetTop + "px";
1801  wrap.style.height = vv.height + "px";
1802  /* Sin altura no hay nada que colocar: la card está escondida porque se está
1803     reproduciendo. Se deshace el desplazamiento para no volver con él puesto. */
1804  if(!stack.offsetHeight){ shift = 0; stack.style.transform = ""; return; }
1805
1806  const arriba = vv.offsetTop + AIRE, abajo = vv.offsetTop + vv.height - AIRE;
1807  const franja = abajo - arriba;
1808  /* Dónde deja la card la hoja de estilos —centrada en el contenedor recién
1809     medido, --lift por encima—, o sea sin nuestro transform. Se mide ANTES que
1810     nada: el alto de la pila no depende de la lista, así que este número no se
1811     mueve por debajo y sirve para saber cuánto hueco hay bajo el campo. */
1812  const crudo = stack.getBoundingClientRect().top - shift;
1813
1814  /* 1. El tope de la lista, en píxeles: lo que quede libre DEBAJO del campo con
1815     la card quieta en su sitio, nunca más de MAX_LISTA y nunca menos de tres
1816     filas —aunque para conseguirlas haya que sacar la card por arriba, que es lo
1817     que resuelve el paso 2—. */
1818  if(!list.hidden){
1819    const fila = list.firstElementChild ? list.firstElementChild.offsetHeight : 0;
1820    /* Los 11 px son lo que la lista mide de más: 5 de relleno arriba, 5 abajo y
1821       el borde de abajo. Y no se piden tres filas si no hay tres. */
1822    const minimo = Math.min(list.scrollHeight + 1, fila * MIN_FILAS + 11);
1823    const hueco = abajo - (crudo + list.offsetTop);
1824    const tope = Math.max(Math.min(hueco, MAX_LISTA), minimo);
1825    list.style.maxHeight = tope.toFixed(1) + "px";
1826  }else{
1827    list.style.maxHeight = "";
1828  }
1829
1830  /* 2. Dónde ponerla. El alto que cuenta es hasta donde llega lo ÚLTIMO que se
1831     ve, que con resultados abiertos es el pie de la lista y no el de la card:
1832     offsetTop la sitúa dentro de la pila y offsetHeight ya trae el tope de
1833     arriba aplicado. */
1834  const alto = list.hidden ? stack.offsetHeight
1835                           : list.offsetTop + list.offsetHeight;
1836  let quiero = crudo;
1837  if(quiero + alto > abajo) quiero = abajo - alto;
1838  /* Si cabe entera, tampoco se sale por arriba. Si no cabe, el ajuste de la
1839     línea anterior manda y lo que sobra se va por el canto de arriba. */
1840  if(quiero < arriba && alto <= franja) quiero = arriba;
1841  shift = quiero - crudo;
1842  stack.style.transform = shift ? "translateY(" + shift.toFixed(1) + "px)" : "";
1843}
1844
1845if(vv){
1846  vv.addEventListener("resize", fit);
1847  vv.addEventListener("scroll", fit);
1848}
1849
1850/* ---------- informar de un problema ----------
1851   El botón de arriba a la derecha y su formulario (#p-rep en nadir-prod.html).
1852   Es la ÚNICA vía por la que un fallo de esta página llega a alguien: NADIR no
1853   manda telemetría, no escribe nada en el navegador y corre entero en el
1854   cliente, así que lo que se rompa en un aparato que no tenemos delante no deja
1855   ningún rastro. De ahí que el reporte lleve tanto contexto — ver collect().
1856
1857   NADA DE LO QUE SE RECOGE PIDE PERMISO. Todo sale de propiedades que el
1858   navegador contesta sin diálogo. `navigator.geolocation` NO se llama a
1859   propósito: abriría el aviso del sistema, y para saber desde dónde escribe
1860   alguien basta con lo que el servidor ya ve de su petición (lo resuelve Vercel
1861   por IP en app/api/nadir/problems/route.ts) más su zona horaria.
1862
1863   EL DOCUMENTO LO ESCRIBE EL SERVIDOR, no esta página: aquí no hay SDK de
1864   Firebase y no lo va a haber —son 200 KB en una página que ya arranca con 900
1865   de texturas—, así que esto POSTea JSON y el Admin SDK guarda en la colección
1866   `nadir_problems`. Que el cliente no pueda escribir en Firestore es además lo
1867   que impide llenarla de basura desde la consola del navegador. */
1868const rep     = el("p-rep");
1869const repForm = el("p-rep-card");
1870const repBtn  = el("p-report");
1871const repMail = el("p-rep-mail");
1872const repMsg  = el("p-rep-msg");
1873const repSend = el("p-rep-send");
1874const repNote = el("p-rep-note");
1875
1876const REP_URL  = "/api/nadir/problems";
1877/* Todas las frases juntas, como el resto del archivo. La del subtítulo se
1878   escribe desde aquí y no en el marcado por lo mismo: si un día cambia, cambia
1879   en el sitio donde están todas y no hay que buscarla en el HTML. */
1880const REP_SUB     = t("REP_SUB");
1881const REP_SEND    = t("REP_SEND");
1882const REP_SENDING = t("REP_SENDING");
1883const REP_OK      = t("REP_OK");
1884const REP_BADMAIL = t("REP_BADMAIL");
1885const REP_BADMSG  = t("REP_BADMSG");
1886const REP_FAIL    = t("REP_FAIL");
1887const REP_FLOOD   = t("REP_FLOOD");
1888
1889/* Mismo criterio que el servidor, y a propósito: validar distinto en los dos
1890   lados es prometer aquí algo que allí se rechaza. */
1891const REP_MAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
1892
1893/* Cuándo se abrió la página. Un «va lentísimo» a los diez segundos de entrar y
1894   otro a los veinte minutos no son el mismo problema. */
1895const repStart = Date.now();
1896
1897/* Cuántos vídeos ha terminado de exportar esta sesión. Lo incrementa showDone(),
1898   que es el único sitio por el que se pasa con bytes en la mano. */
1899let repExports = 0;
1900
1901/* EL ÚLTIMO ERROR DE JAVASCRIPT, si lo hubo. Es lo más valioso que puede llevar
1902   un reporte y no cuesta nada: sin esto, la traza vive en una consola que el
1903   usuario no va a abrir y que nadie nos va a leer por teléfono. Se queda solo
1904   el último —el primero suele arrastrar diez iguales detrás— y recortado, que
1905   una pila entera no cabe ni hace falta. */
1906let repError = null;
1907window.addEventListener("error", e => {
1908  repError = (e.message || "error") +
1909             (e.filename ? " @ " + e.filename.split("/").pop() + ":" + e.lineno : "");
1910});
1911window.addEventListener("unhandledrejection", e => {
1912  const r = e.reason;
1913  repError = "promesa: " + ((r && (r.message || r)) || "sin motivo");
1914});
1915
1916/* En qué pantalla estaba al pulsar. Se mira el DOM y no una bandera propia
1917   porque el DOM es el que manda: si mañana se abre una capa más, esto sigue
1918   contestando lo que el usuario tiene delante y no lo que creíamos tener.
1919   «export» no debería llegar nunca —el botón se apaga con #p-out puesto, ver la
1920   hoja de estilos— pero se contempla por si esa regla se cae. */
1921function repScreen(){
1922  if(!done.hidden) return "result";
1923  if(!out.hidden)  return "export";
1924  return picked.length ? "trip" : "globe";
1925}
1926
1927/* Todo lo que acompaña a los dos campos del formulario. Cada lectura va envuelta
1928   o con guardas: es información de apoyo, y un navegador que no conozca una de
1929   estas propiedades no puede tumbar el envío del reporte. */
1930function collect(){
1931  const nav = navigator;
1932  const ua  = nav.userAgentData || null;
1933
1934  /* La GPU y el tope de textura son las dos lecturas que de verdad explican los
1935     fallos de esta página: casi todo lo que se rompe aquí se rompe en el driver.
1936     Se le piden al contexto que ya existe, no a uno nuevo — crear un segundo
1937     contexto WebGL para preguntar es la clase de cosa que en un móvil justo de
1938     memoria tira el primero. */
1939  let gpu = null, maxTex = null;
1940  try{
1941    const gl  = NADIR.globe.renderer.getContext();
1942    const dbg = gl.getExtension("WEBGL_debug_renderer_info");
1943    const raw = gl.getParameter(dbg ? dbg.UNMASKED_RENDERER_WEBGL : gl.RENDERER);
1944    /* Sin la extensión —Firefox la capa por huella digital— getParameter puede
1945       contestar null, y un "null" de texto en el reporte es peor que un hueco. */
1946    gpu    = raw ? String(raw) : null;
1947    maxTex = gl.getParameter(gl.MAX_TEXTURE_SIZE);
1948  }catch(_){}
1949
1950  let tz = null;
1951  try{ tz = Intl.DateTimeFormat().resolvedOptions().timeZone; }catch(_){}
1952
1953  const conn = nav.connection || null;
1954  const orient = window.screen && window.screen.orientation;
1955
1956  return {
1957    /* aparato y navegador */
1958    userAgent: nav.userAgent,
1959    platform: ua ? ua.platform : (nav.platform || null),
1960    /* userAgentData.mobile es la única respuesta honesta a «¿es un móvil?», y
1961       solo la dan los Chromium. Donde no está, el servidor lo deduce del UA. */
1962    mobile: ua ? ua.mobile : undefined,
1963    touch: (nav.maxTouchPoints || 0) > 0,
1964    language: nav.language || null,
1965    languages: nav.languages ? Array.prototype.slice.call(nav.languages, 0, 8) : null,
1966    timezone: tz,
1967
1968    /* pantalla: la física y la que de verdad se está usando, que en un móvil con
1969       la barra del navegador puesta no son la misma */
1970    screenWidth: window.screen ? window.screen.width : null,
1971    screenHeight: window.screen ? window.screen.height : null,
1972    viewportWidth: window.innerWidth,
1973    viewportHeight: window.innerHeight,
1974    pixelRatio: window.devicePixelRatio || 1,
1975    orientation: orient ? orient.type : null,
1976
1977    /* de qué es capaz el aparato */
1978    cores: nav.hardwareConcurrency || null,
1979    memoryGb: nav.deviceMemory || null,
1980    connection: conn ? (conn.effectiveType || null) : null,
1981    gpu: gpu,
1982    maxTextureSize: maxTex,
1983    /* Sin WebCodecs no hay exportación posible, y es la primera pregunta que hay
1984       que hacerle a un «no me sale el vídeo». */
1985    webcodecs: typeof VideoEncoder !== "undefined",
1986    shareFiles: hasShareSheet(),
1987    reducedMotion: !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches),
1988
1989    /* en qué estaba cuando pulsó */
1990    screen: repScreen(),
1991    stops: picked.map(c => c.name),
1992    stopCount: picked.length,
1993    photos: picked.filter(c => c.img).length,
1994    exported: repExports > 0,
1995    lastError: repError,
1996    sessionSeconds: Math.round((Date.now() - repStart) / 1000),
1997    url: location.href,
1998    referrer: document.referrer || null
1999  };
2000}
2001
2002/* El aviso de debajo del formulario, que es el mismo hueco para las dos noticias.
2003   Sin texto se esconde: un párrafo vacío deja un salto de línea que descoloca la
2004   card cada vez que se abre. */
2005function repSay(text, kind){
2006  repNote.textContent = text || "";
2007  repNote.className = kind || "";
2008  repNote.hidden = !text;
2009}
2010
2011/* MIENTRAS EL FORMULARIO ESTÁ ABIERTO, EL RESTO DE LA PÁGINA NO EXISTE. El velo
2012   ya para los toques, pero no el tabulador ni los lectores de pantalla: sin esto
2013   se puede tabular hasta el campo del buscador que está detrás del cristal y
2014   escribir a ciegas en él. `inert` lo resuelve de una vez —quita foco, clics y
2015   árbol de accesibilidad—; donde no exista, la asignación no hace nada y queda
2016   como estaba. El propio botón de informar entra en la lista: está fuera del
2017   diálogo y es el primero al que se llegaría tabulando. */
2018const REP_BEHIND = ["view","p-wrap","p-bar","p-demo","p-home","p-report","p-out","p-done"];
2019function repInert(on){
2020  for(const id of REP_BEHIND){ const n = el(id); if(n) n.inert = on; }
2021}
2022
2023/* Abrir NO limpia los campos, y es deliberado: cerrar por error —un toque fuera,
2024   un Escape de más— con tres líneas escritas y volver a encontrarlas es la
2025   diferencia entre un reporte y un usuario que se rinde. Se vacían al enviar. */
2026function openRep(){
2027  repSay("", "");
2028  repSub();
2029  rep.hidden = false;
2030  repInert(true);
2031  repBtn.setAttribute("aria-expanded", "true");
2032  fitRep();
2033  /* Al correo primero si está vacío, y si ya lo escribió antes, directo al
2034     mensaje: nadie quiere volver a teclear su email para el segundo aviso. */
2035  const first = repMail.value.trim() ? repMsg : repMail;
2036  /* preventScroll: el foco en un campo dentro de un overlay hace que el
2037     navegador intente desplazar la página, que aquí no se desplaza, y en iOS
2038     eso deja el <body> corrido y el globo torcido al cerrar. */
2039  try{ first.focus({preventScroll:true}); }catch(_){ first.focus(); }
2040}
2041
2042function closeRep(){
2043  rep.hidden = true;
2044  rep.style.height = "";
2045  rep.style.top = "";
2046  /* Se devuelve la página ANTES de mover el foco: enfocar un botón que todavía
2047     está inerte no hace nada, y el foco se quedaría en el <body>. */
2048  repInert(false);
2049  repBtn.setAttribute("aria-expanded", "false");
2050  /* El foco vuelve de donde salió. Sin esto, al cerrar con Escape el foco se
2051     queda en el <body> y el siguiente tabulador empieza por el principio de la
2052     página. */
2053  try{ repBtn.focus({preventScroll:true}); }catch(_){ repBtn.focus(); }
2054}
2055
2056/* El subtítulo se escribe cada vez que se abre y no una sola vez al montar: es
2057   una sola línea y así la frase vive junto a las demás, en las constantes de
2058   arriba, y no en el marcado. */
2059function repSub(){ el("p-rep-sub").textContent = REP_SUB; }
2060
2061/* ---------- el teclado del móvil, otra vez ----------
2062   La misma cuenta que fit() hace para la card y por el mismo motivo: con el
2063   teclado abierto, la ventana de layout sigue midiendo la pantalla entera, así
2064   que un overlay a inset:0 se queda con la mitad de abajo —el botón de enviar
2065   incluido— debajo del teclado. visualViewport es la única que sabe qué se ve
2066   de verdad; sin ella (navegadores viejos) esto no hace nada y el overlay se
2067   queda como lo deje el CSS, que es como estaba. */
2068function fitRep(){
2069  if(!vv || rep.hidden) return;
2070  rep.style.height = vv.height + "px";
2071  rep.style.top    = vv.offsetTop + "px";
2072}
2073if(vv){
2074  vv.addEventListener("resize", fitRep);
2075  vv.addEventListener("scroll", fitRep);
2076}
2077
2078repBtn.addEventListener("click", openRep);
2079el("p-rep-x").addEventListener("click", closeRep);
2080
2081/* Tocar el velo cierra; tocar la card, no. La comprobación es contra el propio
2082   overlay y no un contains(): el velo ES el overlay, y cualquier clic que llegue
2083   a él sin haber sido atajado por la card viene de fuera. */
2084rep.addEventListener("mousedown", e => { if(e.target === rep) closeRep(); });
2085
2086document.addEventListener("keydown", e => {
2087  if(e.key === "Escape" && !rep.hidden) closeRep();
2088});
2089
2090let repBusy = false;
2091
2092repForm.addEventListener("submit", e => {
2093  e.preventDefault();
2094  if(repBusy) return;
2095
2096  const email = repMail.value.trim();
2097  const description = repMsg.value.trim();
2098  /* Se valida en este orden y se para en el primero: dos avisos a la vez en un
2099     mismo renglón no caben, y el de arriba es el campo que hay que arreglar
2100     primero de todas formas. */
2101  if(!REP_MAIL_RE.test(email)){ repSay(REP_BADMAIL, "bad"); repMail.focus(); return; }
2102  if(!description){ repSay(REP_BADMSG, "bad"); repMsg.focus(); return; }
2103
2104  repBusy = true;
2105  repSend.disabled = true;
2106  repSend.textContent = REP_SENDING;
2107  repSay("", "");
2108
2109  /* La cookie de sesión viaja sola por ser el mismo origen, y es lo que permite
2110     que el servidor ponga el uid en el reporte cuando quien escribe tiene sesión
2111     abierta en guidemapp.io. No hay nada que mandar desde aquí para eso. */
2112  fetch(REP_URL, {
2113    method: "POST",
2114    headers: {"Content-Type": "application/json"},
2115    body: JSON.stringify({email:email, description:description, client:collect()})
2116  }).then(r => {
2117    if(r.ok) return r.json();
2118    /* 429 es el freno del servidor y merece su propia frase: «no se ha podido
2119       enviar» invitaría a reintentar, que es justo lo contrario de lo que toca. */
2120    throw new Error(r.status === 429 ? "flood" : "http " + r.status);
2121  }).then(() => {
2122    /* Enviado: los campos se vacían salvo el correo, que se queda puesto por si
2123       hay un segundo aviso. Y la ventana se cierra sola después de que dé tiempo
2124       a leer el acuse — cerrarla en el mismo instante deja al usuario sin saber
2125       si llegó. */
2126    repMsg.value = "";
2127    repSay(REP_OK, "good");
2128    setTimeout(() => { if(!rep.hidden) closeRep(); }, 2200);
2129  }).catch(err => {
2130    repSay(err && err.message === "flood" ? REP_FLOOD : REP_FAIL, "bad");
2131  }).then(() => {
2132    repBusy = false;
2133    repSend.disabled = false;
2134    repSend.textContent = REP_SEND;
2135  });
2136});
2137
2138/* ---------- el muro de pago ----------
2139   Al llegar a FREE_STOPS paradas, la fila de añadir se señala con una corona
2140   (ver paintLine y `.addrow.locked` en la hoja de estilos) y abre #p-pay en vez
2141   del buscador.
2142
2143   NO ES UN COBRO, ES UNA ELECCIÓN. La ventana no pide dinero: enseña las dos
2144   columnas y pregunta cuál de los dos vídeos quiere. Quien elige premium sigue
2145   montando el viaje EN EL ACTO, con sus doce paradas, y paga al final —de ahí el
2146   subtítulo del botón—. Cobrar antes de que haya visto su vídeo es pedirle que
2147   compre a ciegas, y en un producto que se prueba en dos minutos eso no lo pasa
2148   casi nadie.
2149
2150   IGUAL PARA TODO EL MUNDO. No se pregunta quién está mirando y no hay ninguna
2151   petición de red antes de que la corona aparezca: el límite es una constante de
2152   este archivo. El muro sale siempre, en la cuarta parada, sin depender de un
2153   servidor ni de una sesión.
2154
2155   LO QUE FALTA, Y ES LO GORDO: «pagarás al final» todavía no es verdad. Elegir
2156   premium desbloquea las doce paradas y NADIE COBRA NADA al exportar. El cobro
2157   tiene que engancharse al botón de generar vídeo (ver payWanted() más abajo,
2158   que es la bandera que hay que mirar desde ahí).
2159
2160   Y ESTO NO ES UNA PUERTA, ES UN CARTEL: el motor corre entero en el navegador y
2161   cualquiera con la consola abierta llega a doce paradas. No tiene arreglo
2162   mientras el vídeo se calcule en el cliente, y no lo necesita. */
2163
2164/* ---------- EL INTERRUPTOR ----------
2165   EL MURO ESTÁ APAGADO. Con esto en `false`, NADIR se comporta exactamente como
2166   antes de que existiera: la fila de añadir es el «+» de siempre, se llega a las
2167   doce paradas del motor sin que nadie pregunte nada, la demo carga sus cinco
2168   ciudades y la ventana de #p-pay no se abre jamás.
2169
2170   TODO LO DEMÁS DE ESTA SECCIÓN SE QUEDA, y no es olvido: es código muerto a la
2171   espera, igual que el flujo de preview con `SKIP_PREVIEW_GENERATION`. La tabla,
2172   los dos botones, el desbloqueo, el marcado de #p-pay y su hoja de estilos
2173   siguen enteros y probados.
2174
2175   PARA VOLVER A ENCENDERLO: `true` acá. No hay nada más que tocar en esta página.
2176   Lo que sí falta fuera —y sin ello el muro promete cosas que no pasan— está en
2177   el §0.5 de .claude/NADIR_TODO.md: el cobro al exportar (la bandera es
2178   `payWanted()`), la marca de agua del vídeo gratuito, la atribución obligatoria
2179   y el producto de 2,99 en Stripe, que todavía no existe. */
2180const PAYWALL_ON = false;
2181
2182/* Las paradas del plan gratuito, contando el origen. */
2183const FREE_STOPS = 4;
2184
2185/* Las del premium: el tope del motor (`MAX_STOPS` de nadir-route.js). Por encima
2186   de eso la escena no dibuja, así que el muro no puede prometer más. */
2187const PREMIUM_STOPS = route.MAX_STOPS;
2188
2189/* ¿Ha elegido el vídeo premium? Vive solo en memoria, a propósito: no se guarda
2190   en el navegador (NADIR no escribe nada, ver §4 de NADIR_TODO.md) y se pierde
2191   al recargar, que es lo correcto mientras nadie haya pagado todavía. */
2192let payChosen = false;
2193
2194/* La bandera que tendrá que mirar el botón de generar vídeo el día que se cobre
2195   al final. Se expone con nombre propio para que ese día no haya que acordarse
2196   de cómo se llamaba la variable. */
2197function payWanted(){ return payChosen; }
2198
2199/* Cuántas paradas se pueden poner ahora mismo. Con el muro apagado son todas las
2200   que aguanta el motor, que es lo que había antes de todo esto. */
2201function payLimit(){
2202  if(!PAYWALL_ON) return PREMIUM_STOPS;
2203  return payChosen ? PREMIUM_STOPS : FREE_STOPS;
2204}
2205
2206/* ¿Toca corona en la fila de añadir? Apagado el muro, nunca. */
2207function payLocked(){ return PAYWALL_ON && picked.length >= payLimit(); }
2208
2209/* ---------- las frases ----------
2210   Poco texto y a propósito: la tabla explica sola, y cada frase que se le pone
2211   encima retrasa la comparación, que es lo único que el usuario ha venido a
2212   hacer. Los números salen de las constantes de arriba — el copy no puede decir
2213   «4» si el límite es otro. */
2214const PAY_H        = "¿Hasta dónde llega tu viaje?";
2215const PAY_COL_FREE = "Gratis";
2216const PAY_COL_PRO  = "Premium";
2217const PAY_ROWS = [
2218  ["Ciudades",               String(FREE_STOPS), String(PREMIUM_STOPS)],
2219  ["Marca de agua",          "Sí",               "No"],
2220  ["Atribución obligatoria", "Sí",               "No"],
2221];
2222const PAY_GO_TAG  = "Pagarás al final";
2223const PAY_GO_T    = "Vídeo premium";
2224const PAY_X_TAG   = "Hasta " + FREE_STOPS + " ciudades";
2225const PAY_X_T     = "Continuar gratis";
2226
2227/* ---------- el importe ----------
2228   Europa paga en euros y el resto del mundo en dólares, la misma regla que el
2229   resto del sitio (`lib/currency.ts`). Se decide por la zona horaria del aparato
2230   y NO preguntándole al servidor por la IP: son los mismos dos importes, no hay
2231   conversión de por medio, y una petición de red para elegir entre dos cadenas
2232   de texto es una forma tonta de que el muro salga sin precio.
2233
2234   Las de Atlantic/ son las europeas que no empiezan por "Europe/": Canarias,
2235   Madeira, Azores, Feroe e Islandia.
2236
2237   OJO: 2,99 es un precio NUEVO, y todavía no existe en Stripe. El checkout que
2238   hay escrito (`app/api/nadir/checkout/route.ts`) apunta al plan `lifetime`, que
2239   son 9,99. Antes de cobrar hay que crear el producto de NADIR y apuntar el
2240   `PLAN` de esa ruta ahí, o el botón dirá una cifra y la pasarela cobrará otra. */
2241const PAY_EUR_TZ = ["Atlantic/Canary","Atlantic/Madeira","Atlantic/Azores",
2242                    "Atlantic/Faroe","Atlantic/Reykjavik"];
2243function payEur(){
2244  let tz = "";
2245  try{ tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ""; }catch(_){}
2246  return tz.indexOf("Europe/") === 0 || PAY_EUR_TZ.indexOf(tz) >= 0;
2247}
2248function payAmount(){ return payEur() ? "2,99 €" : "$2.99"; }
2249
2250const pay     = el("p-pay");
2251const payGo   = el("p-pay-go");
2252const payX    = el("p-pay-x");
2253const payNote = el("p-pay-note");
2254
2255function paySay(text, kind){
2256  if(!payNote) return;
2257  payNote.textContent = text || "";
2258  payNote.className = kind || "";
2259  payNote.hidden = !text;
2260}
2261
2262/* ---------- la tabla ----------
2263   Se construye a mano en vez de escribirla en el marcado porque sus números son
2264   FREE_STOPS y PREMIUM_STOPS, y escritos allí se quedarían viejos en cuanto uno
2265   de los dos cambiara. */
2266function payCell(tag, text, pro){
2267  const c = document.createElement(tag);
2268  c.textContent = text;
2269  if(pro) c.className = "pro";
2270  if(tag === "th") c.setAttribute("scope", pro === null ? "row" : "col");
2271  return c;
2272}
2273
2274function payTable(){
2275  const t = el("p-pay-t");
2276  if(!t) return;
2277  t.textContent = "";
2278
2279  const thead = document.createElement("thead");
2280  const hr = document.createElement("tr");
2281  /* La celda vacía de la esquina: encabeza la columna de las etiquetas, que no
2282     tiene título porque no lo necesita. */
2283  hr.appendChild(document.createElement("th"));
2284  hr.append(payCell("th", PAY_COL_FREE, false), payCell("th", PAY_COL_PRO, true));
2285  thead.appendChild(hr);
2286
2287  const tbody = document.createElement("tbody");
2288  for(const [label, free, pro] of PAY_ROWS){
2289    const tr = document.createElement("tr");
2290    tr.append(payCell("th", label, null), payCell("td", free, false), payCell("td", pro, true));
2291    tbody.appendChild(tr);
2292  }
2293
2294  t.append(thead, tbody);
2295}
2296
2297/* La anatomía del botón de GuideMapp: rótulo en versales y título apilados a la
2298   izquierda, importe a la derecha. Son nodos separados y no una frase porque cada
2299   uno lleva su tamaño, su peso y su color — ver #p-pay-go y #p-pay-x en la hoja
2300   de estilos. El mismo molde para los dos botones: lo único que los distingue es
2301   el degradado, y eso lo pone el CSS. */
2302function payBtn(node, tag, title, amount){
2303  if(!node) return;
2304  node.textContent = "";
2305  const lbl = document.createElement("span"); lbl.className = "lbl";
2306  const g   = document.createElement("span"); g.className = "tag"; g.textContent = tag;
2307  const t   = document.createElement("span"); t.className = "t";   t.textContent = title;
2308  lbl.append(g, t);
2309  node.appendChild(lbl);
2310  /* Sin importe —el botón de gratis— no se pinta un hueco vacío: el rótulo se
2311     queda solo y el botón conserva su alto porque lo da el padding. */
2312  if(amount){
2313    const amt = document.createElement("span"); amt.className = "amt"; amt.textContent = amount;
2314    node.appendChild(amt);
2315  }
2316}
2317
2318function payFill(){
2319  const h = el("p-pay-h");
2320  if(h) h.textContent = PAY_H;
2321  payTable();
2322  payBtn(payGo, PAY_GO_TAG, PAY_GO_T, payAmount());
2323  payBtn(payX,  PAY_X_TAG,  PAY_X_T,  null);
2324  if(payGo) payGo.disabled = false;
2325  paySay("", "");
2326}
2327
2328/* Mismo criterio que repInert: con la ventana abierta, el resto de la página no
2329   existe ni para el tabulador ni para un lector de pantalla. Entra también
2330   #p-rep, que es la otra ventana. */
2331const PAY_BEHIND = ["view","p-wrap","p-bar","p-demo","p-home","p-report","p-out","p-done","p-rep"];
2332function payInert(on){
2333  for(const id of PAY_BEHIND){ const n = el(id); if(n) n.inert = on; }
2334}
2335
2336/* La misma cuenta que fitRep, y por lo mismo: en iOS la ventana de layout sigue
2337   midiendo la pantalla entera cuando algo la tapa. */
2338function fitPay(){
2339  if(!vv || !pay || pay.hidden) return;
2340  pay.style.height = vv.height + "px";
2341  pay.style.top    = vv.offsetTop + "px";
2342}
2343
2344function openPay(){
2345  /* Con el muro apagado nadie debería llegar acá —payLocked() es false y la fila
2346     abre el buscador— pero se cierra igual la puerta: es la única función por la
2347     que la ventana puede aparecer. */
2348  if(!PAYWALL_ON || !pay) return;
2349  payFill();
2350  pay.hidden = false;
2351  payInert(true);
2352  fitPay();
2353  if(payGo){ try{ payGo.focus({preventScroll:true}); }catch(_){ payGo.focus(); } }
2354}
2355
2356function closePay(){
2357  if(!pay) return;
2358  pay.hidden = true;
2359  pay.style.height = "";
2360  pay.style.top = "";
2361  payInert(false);
2362}
2363
2364/* Cerrar sin elegir: el foco vuelve a la fila de la corona, que es de donde
2365   salió. Se busca en vez de guardarse porque paintLine rehace la línea entera en
2366   cada cambio y el botón que abrió esto puede que ya no exista. */
2367function payDismiss(){
2368  closePay();
2369  const back = line.querySelector("li.add .addrow");
2370  if(back){ try{ back.focus({preventScroll:true}); }catch(_){ back.focus(); } }
2371}
2372
2373/* Elegir premium. El límite sube en el acto y se abre el buscador: quien pulsó
2374   la fila quería añadir una ciudad, y hacerle buscar otra vez el mismo botón
2375   después de haber dicho que sí es un paso de más. */
2376function payChoose(){
2377  /* Mismo evento que el botón de la pantalla de resultado, distinta superficie.
2378     Hoy no se dispara nunca (PAYWALL_ON=false); el día que el muro se
2379     encienda, ya cuenta. */
2380  track("nadir_premium_clicked", Object.assign(trip(), {surface:"paywall", currency: payEur() ? "eur" : "usd"}));
2381  payChosen = true;
2382  closePay();
2383  render();
2384  openBox();
2385}
2386
2387/* ---------- el cableado ----------
2388   TODO ESTO VA GUARDADO. Si un día el marcado de #p-pay no está —un index.html
2389   viejo en la caché de alguien, una copia a medias desde Map_videos— lo que NO
2390   puede pasar es que un addEventListener sobre null se lleve por delante el
2391   resto del archivo: debajo de esta sección están el botón de la demo y la
2392   primera pintada de la card. El muro puede faltar; la página, no. */
2393if(pay && payGo && payX){
2394  payX.addEventListener("click", payDismiss);
2395  payGo.addEventListener("click", payChoose);
2396  /* Tocar el velo cierra; tocar la card, no. El velo ES el overlay, así que
2397     cualquier clic que llegue hasta él viene de fuera de la ventana. */
2398  pay.addEventListener("mousedown", e => { if(e.target === pay) payDismiss(); });
2399  document.addEventListener("keydown", e => {
2400    if(e.key === "Escape" && !pay.hidden) payDismiss();
2401  });
2402  if(vv){
2403    vv.addEventListener("resize", fitPay);
2404    vv.addEventListener("scroll", fitPay);
2405  }
2406}
2407
2408/* El botón «Demo» está comentado en index.html desde el 11 de septiembre de
2409   2026 (ver allí). Guardado por si no está: un addEventListener sobre null se
2410   llevaría por delante la primera pintada de abajo. loadDemo() se queda. */
2411const demoBtn = el("p-demo");
2412if(demoBtn) demoBtn.addEventListener("click", loadDemo);
2413
2414/* Primera pintada: sin paradas, o sea el primer estado. */
2415render();
2416
2417/* ¿Se viene de Stripe? Ver «la vuelta de Stripe». Se mira después de la primera
2418   pintada y todo lo que hace es asíncrono, así que para cuando toque el
2419   exportador ya está cargado (nadir-export.js va detrás de este archivo). */
2420{
2421  const q = new URLSearchParams(location.search);
2422  const sid = q.get("nadir_session_id");
2423  if(sid) premiumReturn(sid);
2424  else if(q.get("nadir") === "cancel") premiumCancelled();
2425}
2426
2427/* ---------- contrato con el bucle de render ----------
2428   Ver la cabecera: telemetry() es el hueco que el bucle llama sesenta veces por
2429   segundo, y updateSun es el mismo de nadir-ui.js porque los dos son
2430   nadir-sun.js.
2431
2432   PROVISIONAL: el hueco está ocupado. Escribe en pantalla la escala y la
2433   inclinación de la cámara para poder ajustar CITY_SCALE y CITY_TILT mirándolos.
2434   NO ES PARTE DEL PRODUCTO: se vacía esta función, se quita el <div id="p-hud">
2435   de nadir-prod.html con su bloque de estilos, y todo vuelve a estar como estaba.
2436   Solo se toca el DOM cuando el texto CAMBIA: escribir el mismo textContent
2437   sesenta veces por segundo obliga al navegador a rehacer el trazado del rótulo
2438   para nada. */
2439/* Comentado el 9 septiembre 2026 a petición del usuario, junto con el
2440   <div id="p-hud"> y su bloque de estilos en nadir-prod.html. No se borra.
2441const hud = el("p-hud-v");
2442let hudLast = "";
2443*/
2444function telemetry(){
2445  /* Esto SÍ es del producto y se queda cuando el HUD de debajo se vaya: el botón
2446     de la vista previa tiene que enterarse de que la secuencia se ha parado sola
2447     o de un arrastre, y el director no avisa a nadie. */
2448  sync();
2449  /*
2450  const txt = "zoom " + kmPerPx().toFixed(2) + " km/px  ·  tilt "
2451            + (cam.tilt/DEG).toFixed(1) + "\u00B0";
2452  if(txt === hudLast) return;
2453  hudLast = txt;
2454  hud.textContent = txt;
2455  */
2456}
2457
2458window.NADIR = window.NADIR || {};
2459/* `paused` es NUEVA y es opcional en el contrato: nadir-main.js la resuelve a
2460   () => false si no está, que es lo que le pasa a nadir-ui.js —el banco de
2461   pruebas no tiene pantalla de resultado y por tanto nunca congela nada—. Dice
2462   si el bucle debe saltarse este fotograma; ver showDone() y closeDone(). */
2463window.NADIR.ui = {telemetry, updateSun: sun.update, paused: () => frozen};
2464})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.