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.