Saltar a contenido

Decisiones de diseño

Decisiones confirmadas de la reconciliación del plan original (LAMULA-WebViewer — Project Plan, que asumía FastAPI + PostgreSQL + GeoTIFF por FTP) con la realidad de ejecución: Cloudflare Pages con D1/R2 compartidos con nexrad-l3-pipeline. No re-litigar sin motivo.

Confirmadas

  1. Nuxt 3 sobre Cloudflare Pages, un solo deploy. D1 solo se lee vía binding de Worker/Pages Functions (misma cuenta Cloudflare), así que el backend pasa a las server routes de Nuxt (Nitro preset cloudflare-pages). Alternativas descartadas: SPA + Pages Functions sueltas (dos capas menos integradas), Worker Hono separado + SPA (dos deploys, CORS interno).

  2. SSR encendido, mapa client-only. OpenLayers no se renderiza en servidor; el SSR aporta shell, meta tags y resolución de deep links. En Nuxt el costo marginal es bajo (<ClientOnly> alrededor del mapa). No es un requisito que justifique complejidad extra — si estorba, se degrada a SPA sin cambiar arquitectura.

  3. DAL con dos adaptadores. Live (server routes → binding D1 + URLs R2) y fixture (respuestas grabadas + COGs golden en el repo, switch por env). Es lo que permite CI determinista, desarrollo offline y — estratégicamente — apuntar el mismo viewer al contrato de LAMULA-Ingest (PostgreSQL + HTTP) en el futuro escribiendo solo un adaptador.

  4. Renderizado desde datos, no desde píxeles. El COG trae niveles crudos uint8 + value_scale/value_offset; la paleta se aplica en el cliente como color ramp WebGL. La paleta es fuente única para raster y leyenda; el valor bajo el cursor es posible; cambiar paleta no regenera nada.

  5. Paletas versionadas en este repo, no en la base. No existe (a propósito) tabla palette en D1. Módulo TS por product_code con colores, stops, unidad y ticks. Coherente con "paleta en cliente": el pipeline no sabe de presentación. Igual para nombre display, categoría y rango de cada producto — catálogo estático del viewer keyed por code; D1 products solo aporta code/mnemonic/unit/kind.

  6. Radar-agnóstico, proyección dinámica. Ningún radar ni endpoint hardcodeado. radars.proj4 se registra tal cual (proj4.defs + register); el catálogo, el switch de radares y la frescura ("minutos desde último scan") salen de la tabla radars.

  7. Base Web Mercator + OSM. Los radares del demo son Florida/Puerto Rico; la base Lambert-conformal de Cuba (EPSG:2085) del legado no aplica. Cuando el viewer se apunte a radares cubanos, la base vuelve como configuración, no como código.

  8. Storm charts vía extensión del pipeline (acordada). El parser actual de NST solo extrae posición + vector movimiento. El tabular de NST trae POH/POSH/tamaño de granizo/VIL/dBZ máx/top por celda, y los packets 23/24 del symbology traen posiciones pasadas/pronóstico. El pipeline se extiende para volcar eso en phenomena.attrs (JSON — cero migración D1). Los charts de tendencia se construyen cliente-side como serie temporal por cell_id (estable entre volúmenes) consultando phenomena cross-volumen. Las claves de attrs por kind se documentan como parte del contrato.

  9. VWP sin componente vertical. D1 trae dir/speed/rms/height por nivel; u/v se derivan en cliente, w no existe en el contrato. Barbas y tabla completas; si el canvas legado usaba w, esa serie se omite.

  10. Oracle de regresión visual: goldens propios + QGIS. Los PNGs del legado (radares cubanos, productos retirados del feed) no sirven de oracle píxel-a-píxel para el demo. Verdad de referencia: screenshots golden propios (COG conocido + paleta → render esperado, Playwright) y validación cruzada en QGIS — el mismo mecanismo que la puerta F6 del pipeline. La fidelidad al legado se valida cualitativamente por el experto de dominio (misma semántica de paleta/leyenda), no por diff de píxeles.

  11. Timeline asume ventana de retención de 72 h. El pipeline barre datos a los 3 días; el date picker y la UX del timeline lo hacen explícito en vez de mostrar días vacíos.

  12. Mosaico en alcance. N capas WebGLTile (una por radar, cada una en su AEQD) reproyectadas en GPU sobre la vista común. Sin servidor de tiles: es la misma maquinaria de la vista single-radar, multiplicada.

  13. Demo público, sin auth. Los datos NEXRAD son públicos; solo se expone el viewer. Si más adelante hace falta gatear el acceso, Cloudflare Access delante de Pages es cero código.

  14. PrimeVue v4 (unstyled + Tailwind) como librería de componentes. La app es pesada en DataTable, DatePicker y Slider; batteries-included gana sobre shadcn-vue aquí. Tailwind sigue siendo la capa de estilos.

  15. i18n: @nuxtjs/i18n, es + en, locale en URL, catálogo extensible.

  16. Charts de tormenta y VWP: canvas/SVG propio portado del legado. La matemática validada (barbas, geometría az/range, trends) se porta de Svelte a composables Vue; no se introduce librería de charts.

  17. Solo lectura, por disciplina. D1 no tiene roles: el contrato es que este proyecto solo ejecuta SELECT y las migraciones viven en db/ del pipeline. Cualquier cambio de schema es una migración negociada allí.

  18. Todo el estado de la interfaz con XState (v5 + @xstate/vue), URL como fuente de verdad. Decisión tomada al arrancar F3, amplía el stack fijado y aplica retroactivamente a F2. Las máquinas viven en machines/ (puras, testeables con createActor sin DOM); los componentes las consumen vía useSelector. La URL manda sobre lo compartible (/{site}/{product}/{time} + query): los cambios de ruta —incluido back/forward— entran a la máquina como eventos, y las transiciones que cambian la selección navegan como efecto (push/replace). Cada máquina se documenta con un diagrama Mermaid en Máquinas de estado, actualizado en el mismo commit que toca la máquina.

  19. Datetime compacto (YYYYMMDDTHHMMSS) en el path de la ruta, no el ISO con :. El : del ISO es hostil a proxies/copy-paste; el formato compacto conserva el orden lexicográfico y su conversión con el ISO naive del contrato es 1:1 (shared/url/time-path.ts). Sin datetime en el path = vista live (closest a "ahora"), materializada con replace al vol_time resuelto en cuanto se conoce — la URL nunca queda apuntando a un instante que no sea exactamente el frame mostrado.

  20. Huecos de la timeline: umbral relativo a la cadencia real, no fijo. Un intervalo se marca como hueco cuando excede max(2×mediana de los intervalos, 10 min) (utils/timeline/gaps.ts). Un umbral fijo (p.ej. "todo intervalo > 10 min") marcaría huecos falsos en radares con cadencia lenta legítima; uno puramente relativo (solo 2×mediana) sería demasiado sensible en series muy densas. Con menos de 3 vol_time no hay señal suficiente para una mediana — no se computan huecos.

  21. Animación: pool de una WebGLTileLayer por frame, no una sola capa con setSource(). Verificado contra ol 10.9.0: setSource() dispone las texturas cacheadas de la capa (parpadeo en cada frame); N capas con el mismo className/zIndex contiguo comparten un solo contexto WebGL, así que el pool no choca con el límite de contextos del navegador. Prefetch en segundo plano se logra con visible:true, opacity:0 (sí carga tiles, no se ve) y swap instantáneo cuando ya está en GPU. "Frame listo" se lee de layer.getRenderer().renderComplete (propiedad semi-pública del renderer, no de la API documentada de ol) — protegido por un test canario (tests/unit/render-complete-canary.spec.ts) que debe fallar primero si un upgrade de ol la renombra o la quita; el plan B documentado es volver a rendercomplete secuencial del mapa (más lento, 100% API pública).

  22. F4 recortado: dBZ máx en lugar de VIL/top/granizo por celda. Acordado con el pipeline (jul-2026, db/README.md de aquel repo): los productos SS (62) y HI (59) no fluyen en el bucket de Unidata (sondeo con tormentas activas) y el NST no trae esos campos — la tabla "STORM CELL ATTRIBUTES" de los visores clásicos es un compuesto cliente de STI+SS+HI. Consecuencia: la tabla de celdas ordena por dbz_max (faltantes al final — el GAB de NST pagina de a 6 celdas), la tendencia grafica dbz_max + dbz_max_height_kft, y la señal TVS es la columna tvs del NMD (no hay markers de granizo). Si esas claves aterrizan algún día, shared/contract/attrs.ts suma claves opcionales sin migración ni cambio estructural. VIL y echo top sí existen como productos raster de grilla (DVL, EET).

  23. Estado de overlays en query params: ?layers=cells,meso, ?panel=cells|trend|vwp, ?cell=<ID>. Extiende la decisión 18 (URL manda): defaults "off" — las URLs de F3 y los goldens existentes no cambian de comportamiento; valores inválidos degradan al default sin anular la ruta. Los cambios entran por eventos raíz de viewerMachine (TOGGLE_LAYER/SELECT_PANEL/SELECT_CELL) con assign optimista + acción inyectable syncOverlayQuery (replace inmediato, sin el debounce de ?opacity — son acciones discretas, no un slider). Un cambio solo-query reentra por ROUTE_CHANGED y cae en el guard sameFrame: el raster no reparpadea.

  24. Join temporal de fenómenos/VWP: índice de times + join en cliente, no endpoint closest. Los fenómenos y el VWP tienen vol_times propios que solo coinciden con los productos volumétricos; los frames N0B/N0G intermedios no tienen fila exacta. Endpoints nuevos GET /api/{phenomena,vwp}/times?site&day (SELECT DISTINCT vol_time, cubierto por idx_*_lookup); el cliente casa el frame mostrado con nearestWithin(times, t, 600 s) (empate → anterior, la regla de pickClosest) y pega a los endpoints por vol_time exacto existentes — inmutables una vez escritos ⇒ cache cliente por vol_time sin invalidación. Fuera de tolerancia el overlay se limpia (estado visible), nunca celdas de otro momento como actuales. Descartados: batch /day (un día real de tormenta ≈ cientos de volúmenes × decenas de filas) y closest?t (un t arbitrario por frame = cache pobre + roundtrip por frame de animación).

  25. Matemática de barbas/u-v/tracks reimplementada desde cero, con tests (desviación parcial de la decisión 16). El legado VestaWeb2 no estaba disponible para portar; u/v desde dir/velocidad y la descomposición de barbas WMO son matemática estándar (utils/wind/, tests/unit/wind.spec.ts). Sigue vigente el resto de la 16: cero librería de charts — SVG propio (declarativo y testeable en happy-dom, a diferencia de canvas). La semántica de los tracks SCIT (past reciente→viejo, movement_deg convención "desde") se dedujo de las grabaciones reales y está protegida por un test canario de continuidad geométrica (tests/unit/tracks.spec.ts); su confirmación por el experto es parte de la puerta M4 — si la convención resultara otra, solo cambian utils/overlay/tracks.ts y su test.

  26. Panel derecho colapsable con rail de tabs (Celdas / Tendencia / VWP). Segundo aside a la derecha del mapa (~384 px), mapa dominante; el rail (36 px) queda siempre visible. Estado en la URL (decisión 23). Tabla HTML nativa + Tailwind — PrimeVue sigue instalado pero sin uso: no introducir un patrón nuevo de componentes en F4. Consecuencia aceptada: el rail estrecha el mapa en toda vista, lo que regeneró los baselines de los goldens (mismo render, 36 px menos de ancho).

  27. overlayMachine separada, no una tercera región de viewerMachine. Patrón animationMachine (orquestación por la página con watchers): (a) el vol_time efectivo durante la animación vive en la página (times[activeFrameIndex]), fuera de viewerMachine; (b) el ensombrecido por región de XState v5 encarece cada evento compartido en una máquina paralela que crece; (c) el overlay tiene ciclo de vida propio (caches por vol_time, gating por toggles) que no debe reiniciarse con el del raster. En overlayMachine ningún evento se maneja en la raíz — todo a nivel de región, con las dos sutilezas de la difusión de eventos documentadas en Máquinas de estado.

  28. Preferencias de usuario (alcance del radar / unidades / hora) en lamula:prefs v2, jamás en la URL. Son preferencias de display personales, no estado compartible — la línea de la decisión 18 (compartible → URL) las manda a localStorage; a diferencia del locale (decisión 15), no cambian qué se ve sino cómo se formatea. Detalles y descartes:

    • Contexto de viewerMachine con eventos raíz (PREFS_LOADED/SET_PREF), no máquina aparte: el criterio de la 27 no aplica — las prefs no tienen ciclo de vida (sin fetch/cache) y persistPrefs ya estaba inyectado. Los valores iniciales del contexto son placeholders SSR deterministas; los reales entran post-mount.
    • Default del reloj = hora local (zona del navegador vía Intl; los días/agrupación de la timeline siguen en UTC — el día UTC es clave de partición de datos, reagrupar por día local exigiría fetch cross-día). Consecuencia aceptada: flash UTC→local de un frame en cada carga — el server no conoce la zona del navegador.
    • Conversión de unidades solo-texto (utils/units.ts): kft→km, kt→km/h en tablas, charts, leyenda y cursor (mapa passthrough — dBZ/mm/kg/m² y unidades futuras pasan intactas). Las barbas WMO siguen siempre en kt (convención meteorológica; banderín = 50 kt) igual que la matemática interna. Consecuencia: ticks no redondos en la leyenda SI (20 kt → 37 km/h) — preferible a duplicar paletas; si el experto lo veta, el fallback es convertir solo el cursor.
    • Diálogo <dialog> nativo, no PrimeVue (la 26 sigue vigente): unstyled sin design tokens obliga a escribir el mismo CSS, y showModal() da top-layer/focus-trap/Esc gratis. Reversible si las preferencias crecen.
    • Migración v1→v2 de lamula:prefs en memoria al leer (rellena defaults, conserva lo guardado); shape inválido o versión desconocida degradan a null como siempre.
  29. Capa opcional de viento animado (partículas GFS 10 m), fuente externa vía el pipeline. No existe viento vectorial en grilla en el stack (N0G es velocidad radial escalar; VWP es un perfil puntual): el dato lo ingiere un job nuevo en nexrad-l3-pipeline desde NOMADS (spec entregada en pipeline-viento.md — tabla wind_grids, JSON u/v por sitio en R2, ciclos 00/06/12/18Z × f000–f012 → valid_times horarios en las 72 h). Decisiones y descartes:

    • GFS 0.25°, no HRRR: HRRR es CONUS-only y no cubre JUA — rompería el principio radar-agnóstico.
    • velocity-JSON, no binario: 49×49 puntos ≈ 30 KB (~10 KB gzip) — un decoder binario no se paga; fixtures legibles.
    • Render propio canvas 2D (utils/wind/{grid,particles}.ts + utils/map/wind-layer.ts, algoritmo earth.nullschool, RNG con seed): ol-wind lleva ~2 años sin release y sin evidencia de compat OL ≥9; línea de la decisión 25. Canvas 2D y no WebGL: no compite con el contexto del frame-pool y SwiftShader (CI) lo rasteriza. zIndex 15 (sobre raster y máscara — el viento cubre ±6°, más allá del alcance del radar —, bajo fenómenos).
    • Región wind en overlayMachine con índice propio (/api/wind/times, día ±2 h) + nearestWithin con tolerancia 1 h (3 h dejaría viento de otra masa de aire como actual) + cache por r2_key (el ciclo va en la key ⇒ inmutable). 'wind' entra en OVERLAY_LAYERS (?layers=wind — toda la plomería URL sale gratis) pero NO en PHENOMENA_LAYERS: activar viento no fetchea fenómenos.
    • ~~Oculta durante la reproducción de la animación (mismo contrato que el satélite)~~ — revertido, ver decisión 33: viento y satélite se mantienen activos durante la animación; el grid/tiempo de satélite se actualiza por frame en vez de congelarse.
    • Fixtures sintéticas (scripts/make-wind-fixture.mjs: campo analítico flujo+vórtice, data-driven sobre las grabaciones) y DDL en tests/contract/proposed/ — NO en tests/contract/schema/ (el drift check byte a byte rompería CI) — hasta que el pipeline mergee su migración. Goldens intactos: default off y la capa jamás entra en screenshot-diff (e2e funcional con readback del canvas + unit deterministas por seed).
  30. Catálogo de mapas base con nombres por encima de las capas. Los nombres de lugares de OSM van horneados en los tiles — quedan bajo raster/viento/cobertura. Solución: catálogo shared/basemaps.ts (osm default + variantes CARTO voyager/positron/dark) donde las variantes CARTO usan el par *_nolabels (base, zIndex 0) + *_only_labels (capa de nombres, zIndex 18: sobre raster 5, cobertura 10 y viento 15, bajo fenómenos 20 — las celdas de tormenta siempre ganan). Detalles y descartes:

    • Nunca labels duplicados: con base OSM no se superpone capa de nombres (fuentes/posiciones distintas a las horneadas); quien quiera nombres arriba elige una variante CARTO.
    • base reutiliza la plomería existente (query ?base + lamula:prefs + contexto de viewerMachine): solo se amplió la unión ('osm' | 'off' → catálogo) y se añadió SELECT_BASE (patrón SET_OPACITY: persistPrefs + syncQuery). 'off' se conserva para goldens/e2e y no aparece en el selector.
    • Tiles raster de CARTO (gratis con atribución OSM+CARTO), no vector tiles con estilo partido: fuera del stack (OL + tiles raster) por un solo requisito. {r} retina se resuelve con devicePixelRatio al crear la fuente (OL no expande ese token).
    • Sin migración de prefs: v2 sigue válida, el validador acepta el catálogo ampliado.
  31. Capa opcional de rayos animados (descargas GLM), fuente externa vía el pipeline. Las descargas del intervalo de observación de cada frame se reproducen en un bucle de 5 s proporcional al tiempo real, con desvanecimiento estilo Windy (blanco→amarillo→naranja→púrpura, radio creciente al morir). Spec entregada en pipeline-rayos.md — tabla lightning_buckets (cubos UTC fijos de 300 s por sitio, fila SIEMPRE al cerrar el cubo: strike_count 0 con r2_key NULL distingue "sin descargas" de "hueco de ingesta"), JSON [lon, lat, offset_s] inmutable en R2, Worker de Cloudflare (no contenedor) sobre GOES-19 GLM de AWS Open Data. Decisiones y descartes:

    • Cubos fijos de 300 s, no vol_times del radar: los rayos llegan en continuo y desacoplados del VCP; el cliente cruza los cubos con la ventana de observación del frame.
    • Join por VENTANA (prevVolTime, volTime], no nearestWithin (utils/overlay/lightning-join.ts): el dato de un frame es un intervalo, no un instante. Sin frame anterior — o con hueco del feed > 600 s — la ventana se recorta a 600 s: comprimir 1 h de rayos en el bucle los presentaría como una tormenta irreal (espíritu D24). SET_TIME gana prevVolTime opcional.
    • Región lightning en overlayMachine con índice propio (/api/lightning/times, día ±900 s) + cache por r2_key + fetch batch de cubos. 'lightning' entra en OVERLAY_LAYERS (?layers=lightning gratis) pero NO en PHENOMENA_LAYERS.
    • Canvas 2D custom, no VectorLayer + style function: 500+ features con estilo reevaluado por frame a 60 fps genera basura de objetos Style y pelea con el declutter de fenómenos; el patrón WindParticleLayer (rAF vía layer.changed(), position:absolute, SwiftShader-safe) ya está validado. Bucle con edad modular (wrap): el reinicio empalma sin costura. zIndex 19 (sobre labels CARTO 18, bajo fenómenos 20 — las celdas siempre ganan).
    • Oculta durante la reproducción (contrato animPlaying — ya no compartido con viento/satélite desde la decisión 33): el micro-bucle de 5 s no compite con frames avanzando; al pausar, setFrame() limpia y reinicia el reloj.
    • Fixtures sintéticas (scripts/make-lightning-fixture.mjs: clústeres deterministas sobre las celdas grabadas, cubos vacíos y vecino cross-día incluidos) hasta la próxima re-grabación completa. El pipeline mergeó su 0004_lightning_buckets.sql (2026-07-19, snapshot en tests/contract/schema/, drift check activo) e ingesta verificada contra producción. Goldens intactos (default off).
    • GLM asumido, caveats a validar con el experto: rayo total (IC+CG sin distinguir), eficiencia ~70–90 %, precisión ~8–14 km — sobra para evolución de tormenta, no para localizar impactos.
  32. Suavizado de la capa raster, opcional y solo estático — excepción explícita a la decisión 4 — vía interpolate de OL, no palette. La decisión 4 fija "renderizado desde datos, no desde píxeles" (LUT discreta, un color por nivel exacto); este suavizado da a las celdas del dato crudo bordes orgánicos en vez de rectángulos, sin reinterpretar el dato físico. Empezó como prueba de concepto (poc/raster-smoothing) con tres enfoques descartados antes del final:

    • interpolate:true directo sobre el nivel crudo del GeoTIFF, con el estilo existente (rasterStyle, operador palette) — descartado: OpenLayers fija filtrado NEAREST en la textura de paleta (ol/webgl/PaletteTexture.js, no configurable), así que el color de salida queda siempre snapeado a un valor discreto sin importar que el nivel de entrada llegue interpolado — sin ganancia visual, y en la práctica se veía tono falso en los bordes de celda.
    • Blur gaussiano nativo de Canvas2D sobre el color ya resuelto por la paleta (decodificar el COG aparte con geotiff.js, filter:blur(), servir como ol/source/DataTile premultiplicado) — funcionaba pero exigía un pipeline CPU/canvas propio por raster (segundo readRasters(), blur, premultiplicación) y un canal aparte para el readout de cursor porque la textura que ve la GPU dejaba de representar el nivel físico.
    • ol-ext SVGFilter+Laplacian sobre una capa Canvas2D (SVGFilter necesita un context 2D, no aplica a WebGLTileLayer — su WebGLRenderingContext no tiene .save()/.drawImage()) — descartado: Laplacian es un kernel de detección de bordes, no de suavizado; el resultado visual es casi negro con trazas de contorno.

    Enfoque adoptado: reemplazar el operador palette por interpolate (lerp de color nativo del lenguaje de expresiones de OL) en el estilo (interpolatedPaletteStyle, utils/map/raster-style.ts), junto con interpolate:true en la fuente GeoTIFF. Nivel de entrada y color de salida quedan continuos — contornos suaves sin canvas ni dependencias nuevas, mismo costo de render (un shader distinto, cero JS extra). El readout de cursor no necesita canal aparte: sigue usando getData() sobre la misma fuente, igual que sin suavizar. - 256 stops (uno por nivel) exceden el límite de complejidad del fragment shader ("Expression too complex") — se reduce a solo los bordes de cada tramo de color constante (2 por tramo, ~20-30 stops típico para una paleta NEXRAD). - Caveat conocido, no resuelto: nivel 1 (range folded) es categórico, no continuo en la escala física — en el borde con una celda de dato real el lerp puede mostrar un degradado hacia su color, semánticamente incorrecto (raro en la práctica). - Solo modo estático: el pool de animación (frame-pool.ts) no lo implementa — perf sin medir contra el presupuesto de prefetch existente (decisión 21). El toggle se deshabilita explícitamente durante animación en vez de ignorarse en silencio. - Toggle smooth en lamula:prefs v3, mismo mecanismo de la decisión 28 (contexto de viewerMachine con eventos raíz PREFS_LOADED/SET_PREF, jamás en la URL) — checkbox en el panel izquierdo, sin selector de método (ya no hay alternativas que valga la pena exponer). Migración v2→v3 en memoria al leer, igual que v1→v2. - Pendiente antes de graduar de POC: validación del experto de dominio — el lerp de color entre niveles físicos distintos puede suavizar un borde que en operación es real (hook echo, frente de racha), no solo artefacto de grilla; es una decisión de fidelidad vs. estética, no solo de render.

  33. Viento y satélite se mantienen activos durante la reproducción de la animación — revierte parte de la decisión 29 (PR externo del bot google-labs-jules, corregido antes de mezclar). Motivación del pedido: ocultarlos en cada play daba un mapa vacío de contexto en pantallas grandes durante buena parte del uso normal. RadarMap.vue ya no pasa !props.animPlaying a satelliteLayer.setVisible() ni pausa windLayer con props.animPlaying — el grid de viento y el tiempo de satélite se actualizan por frame igual que el raster. Los rayos (decisión 31) no se tocan: siguen ocultos durante playback, el contrato ya no es compartido entre las tres capas. Corrección aplicada al PR original: su primer intento también quitaba el guard animPlaying del watcher que emite SET_TIME a overlayMachine — eso no es viento/satélite, es fenómenos/VWP (decisión 27/D24), y los hubiera hecho refetchear en cada frame de animación, exactamente el fetch-storm que ese guard existe para evitar (ver "Congelado durante playback" en Máquinas de estado). Se restauró intacto: fenómenos/VWP siguen clavados durante el play y resincronizan una vez, OVERLAY_RESUME_DELAY_MS (3 s) después de pausar.

  34. Radio de suavizado ajustable (1/2/4/8), remuestreo del nivel crudo — extiende la decisión 32. Motivo: el lerp 1-texel de la decisión 32 no depende del zoom (confirmado: no hay lógica de resolución/overview atada a smooth) pero su radio es fijo y chico — a mayor zoom se siguen viendo detalles de grilla que no interesan (ruido de celda, no la forma de la región isovalor). Es una decisión puramente estética: no reinterpreta el dato, solo cómo se dibuja; quien quiera el detalle crudo desmarca smooth (default false/radio 1). Para un radio mayor hace falta un filtro espacial multi-muestra, no una interpolación punto-a-punto — igual al enfoque descartado en la decisión 32 (blur gaussiano Canvas2D), pero recortado a solo el remuestreo (sin el pase de blur+premultiplicación completo).

    Enfoque final: utils/map/downsample-source.ts decodifica el COG con geotiff.js (readRasters({width, height, resampleMethod:'bilinear'})) al tamaño nativo dividido por el factor, y re-empaqueta ese nivel reducido como un GeoTIFF sintético en memoria con un encoder TIFF mínimo propio (1 IFD, 1 strip sin comprimir, georef vía ModelPixelScale+ModelTiepoint) — el blob resultante entra a la misma ol/source/GeoTIFF de siempre (normalize:false + interpolate:true), sin tocar tileGrid ni reproyección a mano. Tres iteraciones antes de llegar acá, cada una con un bug real encontrado (dos por verificación visual del usuario, no por los tests):

    • Intento 1 — ol/source/DataTile con tileGrid manual (1 solo tile, 1 sola resolución, anclado al getBoundingBox() del GeoTIFF): reproyectaba con ReprojDataTile de OL pero sin la malla de resoluciones auxiliar que ol/source/GeoTIFF siempre agrega (su configure_ interno rellena a ≥3 niveles) — producía una retícula regular de artefactos (costuras de la triangulación de reproyección) visible en TODO el raster, tierra y mar por igual, no solo cerca de celdas. Encontrado por el usuario en producción, no por los tests (capturas de pantalla, no goldens).
    • Dentro de ese mismo intento, un segundo bug: pasar el Uint8Array crudo de readRasters() tal cual al loader de la DataTile la sube como textura UNSIGNED_BYTE, que WebGL normaliza a [0,1] al samplear — el nivel (0-255) le llegaba al estilo dividido por 255, cayendo siempre en el bucket "nodata" (raster invisible, sin error). Fix intermedio: Float32Array (mismo truco que normalize:false en ol/source/GeoTIFF) — pero quedó sin efecto al descartar el enfoque completo por el bug de la retícula.
    • Intento 2 — reempaquetar como GeoTIFF real con el writer de geotiff.js (writeArrayBuffer): resuelve la retícula (reusa el pipeline de reproyección ya probado en radio 1), pero demasiado lento para un COG de este tamaño (3680×3680): ~7 s para radio 2, extrapola a >40 s para radio 1 — inaceptable para un control interactivo. Causa: el writer genérico asigna un ArrayBuffer+DataView por píxel (herencia de un port de UTIF.js pensado para imágenes chicas). Además, un segundo defecto: si no se pasa ProjectedCSTypeGeoKey/GeographicTypeGeoKey, writeGeotiff() pisa el ModelTiepoint provisto con el default "cabe el globo completo" ([-180,90]) — origen del raster sintético quedaba mal sin ningún error (detectado por test, no en producción).
    • Intento 3 (final) — encoder TIFF propio: mismo tag set mínimo ya validado (ImageWidth/Length, BitsPerSample, Compression=none, PhotometricInterpretation, StripOffsets/ByteCounts, RowsPerStrip, PlanarConfiguration, SampleFormat, ModelPixelScale, ModelTiepoint — sin GeoKeys, ol/source/GeoTIFF recibe la projection explícita por opción del constructor y nunca los lee), escrito directo a DataView con un solo Uint8Array.set() para los píxeles (sin asignación por elemento). Mismo resultado byte-exacto verificado por round-trip con geotiff.js, <15 ms incluso al tamaño nativo (vs. los ~7-40 s del writer genérico).
    • Costo final: un readRasters() extra (decode, ~0.5-0.8 s en el COG golden de prueba) por raster mostrado cuando el radio es > 1 — no por frame; el encoder en sí es insignificante. El pool de animación sigue sin implementar ninguna variante de suavizado, igual que en D32.
    • Bug adicional encontrado por el usuario (dBZ negativo bajo el cursor): con smooth activo, el nivel que reporta layer.getData(pixel) puede llegar fraccional (lerp de GPU entre dos niveles enteros, o entre un nivel real y nodata/range-folded en el borde de una celda). sampleFromLevel (utils/map/cursor.ts) solo trataba level <= 0 como nodata y level === 1 como range folded — cualquier fraccional entre 0 y 2 (p.ej. 0.3, viniendo de nodata mezclándose con range folded) caía en la rama "físico" y calculaba un dBZ inventado (a menudo negativo, por el value_offset típico de N0B). Preexistía desde D32 (el lerp 1-texel ya produce fraccionales en el borde de celda) pero se volvió mucho más visible con radios grandes. Fix: redondear (Math.round) antes de clasificar — la categoría entera más cercana es la única interpretación válida de un nivel interpolado.
    • Segundo hallazgo del usuario, en producción, tras el fix de arriba: seguía viendo dBZ negativo — "un halo que rodea a todas las zonas con reflectividad mayor que 0". El redondeo solo resuelve la transición angosta 0↔1; el problema real es más amplio: CUALQUIER nivel entre nodata (0) y el nivel real de una celda es 100% interpolación (radio 1: lerp de GPU; radio > 1: ya viene promediado desde el remuestreo) — cruzar ese degradé de punta a punta pasa por TODOS los niveles bajos intermedios (2, 3, 4…), cada uno con un dBZ "válido" en apariencia (nivel·scale+offset) pero enteramente espurio, no medido. Para dBZ (value_offset de N0B ronda -33) ese degradé completo cae mayormente en negativo o cerca de 0 — de ahí el halo rodeando cada celda, sin importar el radio. Fix (sampleFromLevel, cuarto parámetro clampNonPositive): cuando el producto es dBZ y smooth está activo (RadarMap.vue decide, unit === 'dBZ'), cualquier valor físico ≤ 0 se trata como nodata — pedido explícito del usuario ("que solo existan valores mayores que 0"), aceptando el trade-off de que un eco real muy débil (0-2 dBZ) tampoco se muestre mientras smooth esté activo. Con smooth desactivado el flag nunca se activa — el ground truth (incluye dBZ negativo real, p.ej. nivel 2 = -32 dBZ, física válida) sigue intacto. Deliberadamente NO generalizado a otros productos: VIL (kg/m2) y precipitación (mm) ya flotan cerca de 0 legítimamente (ver capturas de verificación), y velocidad (kt) es negativa la mitad del tiempo por diseño (acercamiento/alejamiento radial) — ahí el halo se resuelve solo (cae en RF, no en un número, según el propio reporte del usuario). Verificado con barrido de cursor (600 puntos, N0B BYX 03:08:18, radio 8): cero valores ≤ 0 dBZ tras el fix.
    • Semántica del readout de cursor con smooth activo — el valor NO es el pixel crudo del COG en ese punto. layer.getData(pixel) (RadarMap.vue, map.on('pointermove', …)) lee el nivel de la superficie ya renderizada, no el dato fuente:
    • Radio 1: la fuente sigue siendo el COG nativo, pero el lerp de GPU interpola entre los texeles nativos vecinos al pixel de pantalla — en el centro de una celda coincide con el dato real; en el borde entre dos niveles distintos da un valor intermedio que no existe en el COG (de ahí el fix de redondeo arriba).
    • Radio > 1: el dato mismo ya llegó promediado (remuestreo bilineal a grilla más gruesa) antes de tocar la GPU — el valor bajo el cursor puede diferir bastante del pixel crudo original en ese punto exacto, sobre todo cerca de bordes de celda.
    • Único camino a un readout garantizado = pixel crudo del COG: smooth desactivado (radio queda sin efecto) — vuelve al lookup discreto nivel→color de rasterStyle/D32 sin interpolate.
    • Caveat de D32 se agrava (visual, no del cursor — ese ya está resuelto arriba): nivel 1 (range folded, categórico) promediado con dBZ real en el remuestreo da un degradado falso más ancho que con el radio 1-texel — más visible cuanto mayor el factor.
    • Pref smoothRadius en lamula:prefs v4 (1|2|4|8, default 1 = comportamiento idéntico a antes de esta decisión), mismo mecanismo que smooth (D32/D28) — selector en el panel izquierdo, visible solo con smooth activo, deshabilitado durante animación. Migración v3→v4 en memoria al leer.
    • Pendiente antes de graduar de POC: igual que D32, validación del experto — a mayor radio, más probable que se suavice una estructura real (hook echo, frente de racha) en vez de solo ruido de grilla; sin overviews reales en el COG, el remuestreo aproxima con bilineal 2-tap por eje, no con un box filter correcto (aliasing posible en factores altos).
    • Selector de nivel de altura para la capa de viento (fase 2 de la decisión 29), 0005_wind_levels.sql. El pipeline extiende wind_grids con niveles de presión (850hPa/700hPa/500hPa, terna "steering flow") además de la superficie 10m ya en producción — spec en pipeline-viento.md. Decisiones:
    • PK gana level: de (site_id, valid_time) a (site_id, valid_time, level). SQLite/D1 no soportan ALTER de PK — la migración reconstruye la tabla y backfillea level='10m' en las filas existentes; el snapshot de 0003_wind_grids.sql no se toca (drift check byte a byte), la migración nueva vive aparte en 0005_wind_levels.sql.
    • Un nivel a la vez, no los 4 juntos: el selector de UI muestra una sola altura (distinto de VWP, que muestra todas sus alturas a la vez en su propio panel) — /api/wind/times gana el query param level (ausente → 10m, no rompe URLs viejas) y la query SQL agrega AND level = ?, sin necesidad de traer ni fusionar payloads de varios niveles.
    • windLevel en overlayMachine (contexto de la región wind, default 10m): cambiar de nivel con la capa activa invalida windTimes/windCache/windGrid y recarga el índice — mismo costo que un SET_SCOPE, pero acotado a esa región (un solo guard en SET_ACTIVE cubre tanto "ya activa, cambia nivel" como "off→on con nivel distinto al último usado", porque la página siempre reemite SET_ACTIVE completo — layers+panel+windLevel — ante cualquiera de los tres cambios).
    • ?windLevel en la URL (shareable, nunca en lamula:prefs — mismo criterio que layers/panel/cell, decisión 23): omitido cuando es el default 10m, así las URLs de antes de esta fase siguen intactas.
    • Rollout desacoplado del viewer: el pipeline solo ingiere 10m por ahora; el selector ya expone los 4 niveles pero 850/700/500 hPa devuelven [] hasta que el pipeline habilite esas descargas — comportamiento esperado, documentado en pipeline-viento.md para no confundirlo con un bug.
    • Fixture sigue sintética, un solo nivel: mismo motivo que siempre (re-grabar destruiría el caso BYX 03:08:18) — scripts/make-wind-fixture.mjs ahora escribe level:'10m' en cada fila, sin generar los otros tres hasta que el pipeline los ingiera de verdad.
  35. VWP sale del rail derecho: botón en el menú izquierdo + modal genérico de tabs. El tab "VWP" de SidePanel (Celdas/Tendencia/VWP) se reemplaza por un botón (data-testid="vwp-open", fieldset propio en el menú izquierdo) que abre un modal con 2 tabs (Gráfico/Datos) — pedido explícito para reusar el mismo modal en la tendencia de una celda seleccionada (próximo cambio). Decisiones:

    • TabModal.vue genérico (título, lista de tabs, slot por tab.id, testidPrefix) extraído como la pieza reusable — mismo patrón <dialog> nativo sin librería que PrefsDialog/TimelineMenu (D26/D28), no PrimeVue.
    • VwpPanel.vue se parte en VwpChart.vue (grid de barbas) + VwpTable.vue (tabla numérica), compuestos por VwpModal.vue (maneja además los estados error/empty). El gráfico sube un poco de tamaño (340×430 → 480×460, barba 14→16 px) — "un poco más espacioso" pedido por el usuario; como el ancho ya escala con la cantidad de columnas, ganar espacio real en el modal (vs. los 384px del aside viejo) ya separa las columnas sin más cambios.
    • Sin tocar las máquinas: ctx.panel==='vwp' sigue gobernando la región vwp de overlayMachine (que solo carga datos con el panel abierto) — el modal se abre/cierra con un watch sobre ese mismo valor en la página, y su cierre (botón ✕ o Esc, evento nativo close del <dialog>) despacha SELECT_PANEL con panel:null. El deep-link ?panel=vwp sigue abriendo el modal igual que abría el tab antes.
    • SidePanel queda con 2 tabs (Celdas/Tendencia); su <aside> ahora solo se muestra si panel corresponde a uno de esos dos — evita un panel vacío si ctx.panel queda en 'vwp' mientras el modal está abierto.
  36. Mapa a pantalla completa: aside izquierdo + rail derecho se reemplazan por controles flotantes, referencia Windy.com. Motivo doble: en escritorio la agrupación de botones del timebar resultaba incómoda (mezclaba refrescar/menú/velocidad con prev/play/next); en móvil el layout de dos columnas fijas (320 px + rail) directamente no cabía — cero clases responsive en todo el repo hasta este cambio. Mockup aprobado por el usuario antes de tocar código (chip de radar/producto, menú de capas, timebar reordenado, leyenda + cuadro de puntero). Decisiones:

    • Cero cambios de máquina de estados. ctx.panel (null | 'cells' | 'trend' | 'vwp', ya unificado desde la decisión 35) y ctx.layers/send siguen siendo la única fuente de verdad; este cambio es puro reordenamiento de template/componentes sobre el mismo contrato de eventos.
    • RadarProductChip.vue (flotante, arriba-izquierda) reemplaza el <header> fijo + el primer bloque del aside: identidad (radar/producto/frescura) + estado del raster (raster-meta/-empty/-error, cog-error) siempre visibles, sin expandir — son información de un vistazo, no un control; solo los <select> de radar/producto viven detrás del toggle.
    • LayersMenu.vue (flotante, arriba-derecha, un solo botón "☰ Capas") agrupa el resto del aside viejo: mapa base, opacidad/suavizado (ocultos si el producto activo no tiene paleta), satélite, fenómenos, viento, rayos, día, y los 3 accesos a datos (Celdas/Tendencia/VWP). Un solo árbol DOM — el contenido no se duplica entre escritorio/móvil, solo cambia de posición vía clases md: de Tailwind (flyout anclado vs. overlay fixed inset-0); sin composable de media query, porque SSR + matchMedia meten flash de hidratación y CSS puro no.
    • DataModal.vue reemplaza SidePanel.vue Y VwpModal.vue (extiende la decisión 35: ya compartían el mismo PanelId). Un solo TabModal con 3 tabs top-level (Celdas/Tendencia/VWP) en vez de 2 superficies distintas — el rail ya mostraba un solo tab a la vez, así que no hay pérdida de UX real. TabModal.active pasa a ser un v-model opcional (retrocompatible si nadie lo bindea) para poder cambiar de tab con el modal ya abierto sin pasar por open() (que antes siempre reseteaba a la primera tab — flash corregido en el mismo cambio).
    • Timebar reordenado, sin tocar handlers. Reproducción (prev/play/next, play agrandado) pasa de la derecha a la izquierda; "en vivo" deja de ser un botón más del grupo y se vuelve una pastilla flotando sobre el track en la posición del handle; el popup flotante de velocidad se elimina (quedaba redundante con el fieldset "Velocidad" de TimelineMenu, único lugar que sobrevive). Las marcas de tiempo bajo el track no se tocan (al usuario le gustaban).
    • Hallazgo de la verificación, preexistente: el watcher que engancha el pool de animación al presionar play (RadarMap.vue) puede hacer que OpenLayers emita un moveend espurio (fin de su propio render interno, no un pan real), que dispara el MOVE_END global de animationMachine y pausa la animación recién arrancada — carrera de siempre entre ese watcher y el render interno de OL, no introducida por este cambio, pero con más DOM montando junto al mapa parece más fácil de perder. Fix de raíz aplicado después (ver decisión 41): filtro por origen del moveend, no por tiempo.
    • Goldens visuales sin regenerar (ya estaban desactivados por diseño, decisión previa — layout en flujo). Variante móvil compacta del timebar (leyenda inline como en la referencia) queda pendiente como iteración siguiente, con el mismo patrón CSS-only sentado por LayersMenu.
  37. DataModal deja de ser <dialog> modal: pasa al mismo dock derecho de LayersMenu, con el doble de su ancho. Pedido explícito: Celdas/Tendencia/VWP ya no bloquean el mapa como overlay centrado — ocupan el hueco donde vivía el menú de capas (md:w-80 → md:w-[40rem]), mutuamente excluyentes con él.

    • TabModal.vue se borra (quedó sin otro consumidor tras esta migración) — su lógica de tabs se inlinea directo en DataModal.vue, ya no hace falta el wrapper genérico <dialog> que exponía.
    • Mutua exclusión, un solo sentido necesario: LayersMenu.openPanel() ya cerraba su propio dock al abrir un tab de Datos (decisión 36). El camino inverso no hace falta un evento aparte: los pills flotantes de LayersMenu (botón "Menú" incluido) comparten el rincón superior derecho con el botón cerrar de DataModal — con el dock de Datos abierto, LayersMenu oculta sus pills vía prop panelOpen (si no, el pill "Menú" tapa el botón cerrar y bloquea el click, encontrado por e2e: "subtree intercepts pointer events"). Sin el pill visible no hay forma de reabrir Capas mientras Datos está abierto — hay que cerrarlo primero (✕), que es justamente la exclusión mutua.
    • Sin open()/close() imperativos: el dock es v-if="panel" directo sobre ctx.panel — más simple que el patrón <dialog>.showModal()/.close() que exigía sincronizar un watch + onMounted para el deep-link inicial.
    • e2e ajustados: los tests que cerraban el modal con Escape (aprovechando el <dialog> nativo) ahora cierran con el botón data-modal-close explícito — ya no hay evento close nativo que capturar, y el pill "Menú" que antes se podía clickear directo está oculto mientras el dock de Datos está abierto (ver punto de arriba), así que hace falta cerrar primero.
    • Goldens intactos: el dock de Datos nunca compite con el mapa en los goldens (siempre corren con panel sin abrir).
  38. D1 → Postgres self-hosted, y el viewer sale de Cloudflare Pages al mismo Swarm. Motivo: D1 agotaba la cuota del plan gratuito de Cloudflare (riesgo #4, abajo). Se evaluaron dos accesos para el lado Cloudflare-side (Hyperdrive vs. una API HTTP propia) y, en paralelo, se decidió que el viewer no necesita quedarse en Cloudflare en absoluto:

    • El viewer se mueve al Swarm como contenedor Node (nitro.preset: 'node-server', Dockerfile), hablando Postgres directo por red interna — ahorra construirle una API HTTP nueva (que sí hace falta para el otro consumidor Cloudflare-side, el Worker nexrad-l3-ops del pipeline, que debe seguir viviendo fuera del Swarm para alertar si el VPS muere). Cloudflare pasa a ser DNS/CDN (orange-cloud) delante del Swarm, no hosting.
    • No es re-litigar la decisión 1 (que fijó Cloudflare Pages): esa decisión estaba condicionada a que D1 solo fuera alcanzable vía binding de Workers/Pages — una vez el datastore es Postgres, la premisa cambia. Tampoco contradice la decisión 3 (DAL con adaptador live/fixture, pensado desde el inicio para poder "apuntar al contrato de LAMULA-Ingest (PostgreSQL + HTTP)"): ese HTTP intermedio resulta innecesario para este consumidor específico, ya que corre en el mismo Swarm que la base.
    • server/dal/live.ts cambia de D1Like (binding) a PgLike (postgres.js, server/dal/pg.ts) — mismo patrón de abstracción, ver CLAUDE.md para el detalle mecánico del port y de la suite de tests.
    • Tradeoff aceptado explícitamente: el viewer deja de estar distribuido en el edge de Cloudflare y pasa a depender de la disponibilidad del VPS/Swarm donde ya corre la ingesta — mismo riesgo que el proyecto ya acepta hoy (por eso existe nexrad-l3-ops, para alertar cuando ese VPS muere), ahora también afecta al viewer. Cloudflare en modo proxy sigue cacheando los assets estáticos en su edge aunque el origin sea el VPS.
  39. Feedback de usuarios: se levanta la regla de "solo lectura" de Postgres, acotada a un schema propio del viewer. Hasta ahora el proyecto (CLAUDE.md, regla desde el inicio) nunca escribía en Postgres ni aplicaba migraciones — el contrato era 100% del pipeline. El feature de feedback in-app (modal + nudge + panel admin) necesita persistir datos que el pipeline no modela y que no son su responsabilidad (identidad de usuario del viewer, mensajes, replies, estado de triage). Decisiones:

    • Namespace separado, no una tabla suelta: CREATE SCHEMA viewer + viewer.feedback/viewer.feedback_users/viewer.feedback_replies (db/viewer_migrations/0001_feedback.sql, paralelo a db/pg_migrations/ del pipeline pero en carpeta propia de este repo). La regla de solo-lectura sigue intacta para todo lo que no sea viewer.* — radares/rasters/fenómenos/VWP/viento/rayos no se tocan, ni su migración ni sus filas.
    • Migraciones de este schema las aplica este repo, no el pipeline: 0001_feedback.sql es idempotente (IF NOT EXISTS en tablas e índices) y se aplica on-boot vía server/plugins/migrate-feedback.ts — sin runner de migraciones dedicado (no hace falta uno nuevo para una sola tabla con 3 sentencias CREATE), el SQL vive embebido en el plugin (el contenedor de producción solo empaqueta .output/, no el árbol fuente, así que leer el .sql de disco en runtime no funciona sin empaquetarlo aparte — más simple inlinearlo, manteniéndolo en sync manual con el .sql versionado que sirve de referencia legible).
    • Credenciales de escritura separadas de las de lectura: pgWriteUser/pgWritePassword (vs. pgUser/pgPassword del resto del DAL), mismo Postgres pero rol distinto — si alguna vez se quiere, a nivel de base, revocar INSERT/UPDATE al rol de lectura usado por el resto del viewer, ya queda separado desde el día uno.
    • No es un commitment a escribir más: esta excepción es para viewer.* específicamente. Cualquier otra necesidad de escritura futura se evalúa caso a caso, no se asume abierta por este precedente.
  40. Exportar la vista como imagen: compositor multi-canvas propio, chrome en Canvas2D y crossOrigin global en las fuentes de teselas. La única forma de compartir una vista era el deep-link (M3), que exige abrir la app, tener red y que el dato siga dentro de la retención de 72 h. F7.1 añade un PNG autocontenido del viewport. Las decisiones que condicionan todo lo demás:

    • No existe "el canvas del mapa": OL 10 usa CompositeMapRenderer y deja en el DOM N canvas apilados dentro de .ol-layers, cuyo número NO es 1:1 con las capas (los renderers Canvas2D reutilizan el contenedor del anterior cuando los transforms coinciden; todas las WebGLTileLayer del frame-pool comparten un solo contexto y un solo canvas). Hay que componer, no leer un canvas.
    • La receta oficial de OL no sirve tal cual: su bucle (ol/renderer/Composite.js:161-193) cae a parseFloat(canvas.style.width)/canvas.width cuando no hay style.transform, y nuestras capas custom de viento y rayos no fijan ninguno de los dos (canvas creado a mano, DPR 1). Eso da NaN y drawImage no pinta nada: la receta canónica descarta en silencio exactamente esas dos capas. utils/export/transform.ts resuelve las tres formas de canvas (transform inline / style.width / búfer ya en px CSS) en una función pura con tabla de tests, y e2e/export.spec.ts lleva un canario del contrato de DOM al estilo de render-complete-canary.spec.ts.
    • La opacidad de capa NO se re-aplica: ya está horneada en los píxeles (Canvas2D vía globalAlpha, WebGL vía u_opacity en el post-proceso) y OL nunca escribe style.opacity en un contenedor. Se lee a la defensiva porque la receta oficial lo hace, pero re-aplicarla elevaría la opacidad del raster al cuadrado.
    • crossOrigin: 'anonymous' en utils/map/base-layers.ts (XYZ de CARTO) y utils/map/satellite-layer.ts (TileWMS de NOAA): sin eso el canvas queda tainted y toBlob lanza SecurityError. Verificado que los tres hosts (basemaps.cartocdn.com, nowcoast.noaa.gov, tile.openstreetmap.org) responden access-control-allow-origin: *. Coste asumido: es un cambio global, no solo de export — añade cabecera Origin, parte la caché HTTP (un refetch único) y, si algún CDN dejara de mandar ACAO: *, las teselas dejarían de verse para todos, que es peor que no poder exportar. Una fuente no puede cambiar crossOrigin tras construirse, así que la alternativa "solo al exportar" implicaba reconstruir la fuente y un parpadeo. Red de seguridad: isCanvasReadable() prueba cada fuente ANTES de dibujarla (un canvas destino contaminado no se descontamina) y onTaint: 'skip' degrada con aviso visible en vez de romper; un e2e con el mapa base encendido asserta que no se omite ninguna capa.
    • Chrome en Canvas2D, no SVG serializado: un SVG cargado como imagen es un documento aislado (sin el CSS ni las fuentes de la página, currentColor sin resolver) y habría que rasterizarlo con img.decode() por dibujo. Lo decisivo es que la marca de tiempo cambia en cada frame de un GIF (F7.3), lo que mata el atajo de rasterizar una vez y cachear. La geometría de la leyenda se extrajo de MapLegend.vue a utils/export/legend-geometry.ts para que pantalla y export compartan fuente (extensión natural de la decisión 4); a 320 px los números son byte-idénticos a los del SVG original y el chrome la pide más ancha, con lo que la tipografía crece — el destino principal es un chat a tamaño reducido.
    • RadarMap.vue expone una capacidad, no el ol/Map: defineExpose de un MapCaptureHandle (viewportEl/size/settle). utils/export/ no importa ol, así que el núcleo se testea en Vitest sin navegador y es invocable desde page.evaluate. settle() hace renderSync() y luego engancha rendercomplete y fuerza un render más: ese evento no se vuelve a disparar si el mapa ya estaba en reposo, y siempre lleva timeout.
    • WYSIWYG del viewport, sin lienzo arbitrario: la salida es el tamaño CSS del mapa × 1 o 2. Pedir "1280×720" no se consigue escalando — habría que redimensionar el contenedor y re-asentar, y OL elegiría otro nivel de zoom de teselas: sería otra foto, no la misma más grande. Nota conocida: CARTO lee devicePixelRatio una sola vez al construir la fuente, así que un export a 2× en pantalla 1× amplía teselas 1×.
    • Preferencias en clave propia lamula:export, no en lamula:prefs (que ya va por la v5 con cadena de migraciones y se lee en cada arranque; además en F7.2 esta clave guardará el avatar del usuario como data-URL).
    • Etiquetas de tick que colisionan se ocultan, la marca no (legendGeometry): varias paletas tienen ticks juntos en el extremo bajo de una escala lineal (DVL 5 y 10, DAA 2.5 y 6, DTA 5 y 12, DU3 4 y 10) y los números se pisaban. El ancho de la etiqueta se estima en el módulo puro (no hay measureText disponible ahí) y la etiqueta que no entra se omite. Afecta también a la leyenda de pantalla, que es justo el punto: es la misma geometría. Como todo escala proporcionalmente, el conjunto de etiquetas ocultas no depende del ancho — pantalla y export muestran lo mismo.
    • La atribución del modo superpuesto va DENTRO de la pastilla de la leyenda: dibujada suelta contra el borde inferior quedaba gris sobre el mapa y la propia pastilla la tapaba a medias (visto en el primer PNG real). Sin leyenda activa, se dibuja con pastilla propia.
    • Anotaciones (F7.2) fuera de la URL, excepción explícita a la decisión 23: lo compartible vive en la URL, pero un trazo a mano alzada son cientos de puntos y reventaría su longitud. Viven en annotationMachine + localStorage (clave propia lamula:annotations, colección por site_id), en coordenadas de mapa EPSG:3857 y no en píxeles — así el trazo queda clavado a la geografía y sobrevive a pan/zoom, a otro pixelRatio y a la secuencia de frames de F7.3. Se renderizan en una VectorLayer REAL de OL (zIndex 21, sobre fenómenos) con Draw/Modify, no en el chrome del export: así se ven mientras se dibujan, se pueden editar con hit-testing y al compositor le salen gratis por la rama 1 de layerDrawTransform, sin que utils/export/ sepa que existen. El Draw dibuja sobre una Collection suelta y NO sobre la fuente de la capa: si escribiera ahí, el trazo entraría dos veces (una por OL y otra al volver por props desde la máquina).
    • El "resaltado de geometrías" no es un mecanismo nuevo: la celda seleccionada ya se dibuja distinta en phenomena-layer.ts y el export es WYSIWYG, así que ya sale resaltada; para cualquier otra geometría está la herramienta de círculo. Un tercer camino solo habría duplicado estado.
    • Marca de agua y avatar (F7.2): el logo (public/logo.svg, mismo origen ⇒ no contamina) va con globalAlpha 0.10 en la esquina inferior derecha del área del mapa — fuera del chrome, por eso lo dibuja captureMap y no drawChrome, y existe también en modo 'sin datos'. El avatar es del usuario: se normaliza a 128×128 PNG antes de guardarse en lamula:export (una foto de cámara no cabe en la cuota de localStorage), se recorta en círculo con cover centrado y nunca sale del navegador. Las dos imágenes entran a dibujarse ya decodificadas (BrandImage): drawChrome es síncrono a propósito porque en F7.3 corre una vez por frame y no puede llevar un await dentro.
  41. moveend se filtra por ORIGEN, no por tiempo: MOVE_END solo se emite tras una interacción real con el viewport. OpenLayers emite moveend tanto al terminar un pan/zoom del usuario como al asentarse su propio render interno — no es un MapBrowserEvent, así que la librería no distingue los dos casos; reconstruir el pool de animación dispara uno sin que medie ningún gesto. animationMachine interpreta MOVE_END siempre como pan/zoom real y pausa, de modo que ese evento espurio abortaba el play recién arrancado (hallazgo de D36).

    • Lo que había antes y por qué no alcanzaba: armMoveEndSuppression() descartaba un moveend dentro de una ventana de 500 ms tras tocar el pool. Medido en el contenedor de CI (2 núcleos, WebGL por SwiftShader) un frame de render cuesta ~215 ms, así que esa ventana son ~2 frames: o se queda corta y el evento espurio pasa, o se alarga y acaba comiéndose un pan genuino. Cualquier constante temporal es frágil por construcción.
    • Lo que hay ahora: RadarMap.vue marca userGesture en pointerdown/wheel/keydown del viewport (los botones de zoom de OL viven dentro, así que el pointerdown los cubre), la consume en movestart — emparejando gesto con movimiento — y moveend solo reenvía moveEnd si ese movimiento venía de un gesto. Los movimientos programáticos de la vista dejan de pausar la animación, que es el comportamiento querido. invalidateInactive() del pool sigue corriendo en TODO moveend: es idempotente y no depende del origen.
    • El caso que casi devuelve el bug: un click que no mueve el mapa (seleccionar una celda) deja el gesto marcado sin que llegue ningún movestart, y el siguiente movimiento programático se lo habría apropiado — click en una celda, luego play, y el pool vuelve a pausar la animación. Limpiarlo en pointerup no sirve: los botones de zoom de OL arrancan su animación después del click, así que ahí el movestart llega más tarde que el pointerup y el zoom por botón dejaría de pausar. Se limpia en initOrUpdatePool(), el único punto donde la app provoca un moveend por su cuenta. Honestidad sobre su peso: emparejar el gesto con movestart ya basta para el caso observado (al reconstruir el pool, OL no emitió movestart), así que esta limpieza es defensa en profundidad para el caso en que sí lo emita — el e2e un click previo sobre el mapa no se lleva puesto el play pasa con y sin ella.
    • Coste de arranque medido, documentado para que nadie lo lea como bug: el raster estático tarda ~8.5 s desde el goto hasta data-raster-loaded="true" en ese contenedor, y el pool vuelve a decodificar el mismo COG de 1.4 MB (capa GL nueva por frame) en otros ~6.5 s. Por eso e2e/animation.spec.ts espera el raster estático antes de tocar play y usa timeouts holgados: verifica la transición de la máquina, no la velocidad de decodificación. La doble decodificación (estático + pool) es real y evitable, pero el ahorro pertenece a una optimización del pool, no a esta decisión.
  42. Export animado (F7.3): el reloj de las capas se INYECTA, y el GIF se encodea en un worker. map.renderSync() estampa frameState.time = Date.now() (ol/Map.js), así que mientras el reloj salga del frameState dos exports de la misma vista nunca pueden coincidir. El driver (utils/export/frame-driver.ts) pasa a mandar: por cada frame activa el raster del pool, inyecta fase de rayos y avance de viento, captura y cede el bucle de eventos.

    • Dos formas, no una: sequence recorre los frames del pool de animación (el radar avanza) y phase recorre el bucle de 5 s de los rayos sobre un solo volumen. Las dos reparten la fase de rayos en k/N: como la edad de una descarga es modular (decisión 31), eso cierra el bucle del GIF sin costura. sequence exige que el pool exista, es decir que el usuario haya dado a play al menos una vez — en modo estático no hay frames que recorrer, y el botón lo dice en vez de fallar.
    • Los overlays NO se re-tiempan por frame. Los strikes y el grid de viento son los del volumen en reposo y se mantienen durante toda la secuencia; re-joinearlos exigiría conducir overlayMachine con sus fetches por frame. Es la misma frontera que ya existe en reproducción (SET_TIME bloqueado por animPlaying), no una regresión nueva.
    • Rayos y viento, cambios mínimos y simétricos: setExportPhase(0–1) / setExportMode(dtS) + stepExport(), más setExportPixelRatio() en ambas. Tres detalles que no son adorno: (a) el viento resiembra al entrar y al salir del modo export, porque si no el segundo GIF parte de un estado de partículas distinto del primero y la puerta de determinismo no puede pasar; (b) un settle() provoca MÁS de un render, y repetir el fade de estelas cambiaría los píxeles — de ahí el contador de frame lógico que hace idempotente el render repetido (los rayos no lo necesitan: se limpian y repintan enteros, así que la misma fase da los mismos píxeles); (c) MAX_DT_S = 0.05 NO se sube para exportar — protege de hipos reales de rAF —, un dt grande se sub-pasa en ceil(dt / MAX_DT_S) tramos. El fade de estelas se escala con el dt de cada sub-paso (trailFadeAlpha, ver abajo): sin eso el sub-paso no bastaba.
    • El fade de estelas es exponencial en el TIEMPO, no por tick (trailFadeAlpha en utils/wind/particles.ts). El primer intento dejó TRAIL_FADE = 0.92 fijo por tick: en pantalla a 60 fps eso son 60 atenuaciones por segundo simulado, pero un sub-paso de export de 0.05 s solo da 20, y el GIF salía con estelas unas 3 veces más largas y más oscuras que la pantalla — visible a simple vista en las muestras de pnpm shot:export, invisible para la puerta byte-idéntica (los dos GIF comparten el defecto). Elevar el alpha a dtS / (1/60) deja la vida de la estela invariante al framerate y de paso estabiliza la pantalla ante hipos de rAF. Medido sobre el GIF de muestra: el salto del empalme bajó de 0.0514 a 0.0376 de RMSE y el fichero de 2.67 MB a 2.24 MB.
    • El driver calienta el viento antes del primer frame (warmupFrames): como el viento resiembra al entrar en modo export, el frame 0 salía con estelas a medio formar y el bucle daba un tirón al volver del último. El número de frames de calentamiento sale del propio fade — log(0.01) / log(trailFadeAlpha(windDtS)), acotado a 24 —, así que son 4 para el GIF de fases. Lo que el calentamiento NO arregla, y conviene no volver a perseguirlo: el empalme medido se queda en ~0.0380 de RMSE contra ~0.0303 del interior, y eso es irreducible. La curva de decorrelación del viento (RMSE del frame 0 contra el frame n) satura en 0.0380 a partir del frame 3: el campo no es periódico, así que el par 19→0 no es una costura sino simplemente dos frames de viento no correlacionados, igual que cualquier par separado por 3 frames o más. La costura deja de verse porque el viento es visualmente estocástico, no porque el bucle cierre.
    • setExportPixelRatio también mueve los canvas de viento y rayos de la rama 3 a la rama 2 de layerDrawTransform: ahora declaran su tamaño CSS siempre. Con ratio 1 las dos ramas dan la misma matriz, así que no cambia nada en pantalla; la rama 3 se queda como respaldo y el canario de DOM de e2e/export.spec.ts se actualizó para exigir tamaño CSS en vez de su ausencia.
    • Esperar a 'listo' habría colgado la captura: un frame cuyo COG devuelve 404 (fuera de la retención de 72 h, y en fixtures todos menos el más reciente) nunca llega a ready. El driver espera a isSettled() — listo o fallado — y exporta el mismo blanco que se ve en pantalla, en vez de gastar el timeout por frame.
    • gifenc en un worker propio, no gif.js: aquel es de 2015, no-ESM y sirve su worker como .js suelto cargado por URL, incómodo bajo Vite/Nitro. gifenc es ESM, sin dependencias y síncrono, así que el scheduling lo controlamos nosotros: new Worker(new URL('./gif.worker.ts', import.meta.url), { type: 'module' }) con el búfer de cada frame transferido (copia cero). Paleta local por frame y sin dithering: las paletas steps del radar cuantizan casi sin pérdida y el ruido del difuminado es lo que más engorda un GIF. Respaldo en el hilo principal si el worker no se puede construir.
    • El GIF se reduce a 960 px de ancho y 1×, el vídeo a 1280. No es pereza: a 2560×1440 un frame RGBA son 14.75 MB y cuantizar cuesta ~25× más; además el techo de área de canvas de iOS mata un búfer de frames a esa resolución. El vídeo va por canvas.captureStream(0) + requestFrame() para no correr carreras con el rAF, y la extensión sale del mime elegido (Safari graba mp4/avc1, no WebM). Dos límites conocidos que no se arreglan aquí: el ritmo del vídeo lo marca el reloj real entre frames —si componer tarda más que el retardo nominal, el vídeo sale lento— y el WebM de MediaRecorder no lleva duración en la cabecera EBML, así que algunos reproductores muestran la barra de búsqueda rota.
  43. Accesibilidad de F5: el dueño de una tecla es quien tiene el foco, y el foco se ve. Auditoría contra cinco criterios (operable por teclado, nombre accesible, modales correctos, cambios anunciados, contraste AA). Los modales ya estaban bien —los cinco <dialog> usan showModal(), que da trampa de foco, Escape y retorno de foco nativos— así que el trabajo quedó en los otros cuatro.

    • El mapa pasa a ser focusable, y eso obligó a repartir las flechas. defaults() de OL ya monta KeyboardPan y KeyboardZoom, pero OL escucha el keydown en el elemento target del mapa y ese <div> no tenía tabindex: las interacciones estaban montadas y muertas desde F2. Añadir tabindex="0" las revive y destapa una colisión: ambas páginas escuchan ArrowLeft/ArrowRight en window para saltar de volumen, así que una flecha paneaba y cambiaba de frame a la vez. La regla que se adopta es que la tecla pertenece a quien tiene el foco: el listener global se inhibe si el objetivo está dentro de un [role="application"], y los controles que prometen flechas (el slider del timeline, el tablist de DataModal) las declaran localmente con stopPropagation(). Se descartó la alternativa —un modificador para panear, tipo Alt+flecha— porque sería un atajo inventado y no descubrible.
    • El tabindex del mapa rompió el ratón, y eso obligó a declarar las interacciones. ol/Map.js (10.9.0) construye sus interacciones por defecto con onFocusOnly: true, y esa opción solo muerde cuando el elemento target tiene tabindex — justo lo que acabábamos de añadir. DragPan y MouseWheelZoom pasaron entonces a exigir que el mapa YA tuviera el foco, y el foco llega en mousedown, posterior al pointerdown donde OL evalúa la condición: el primer arrastre después de cargar la página no movía nada, el segundo sí. Un arreglo de teclado había roto el ratón. La salida es pasar las interacciones explícitas (defaultInteractions({ onFocusOnly: false }) en RadarMap.vue y MosaicMap.vue), que restituye exactamente el comportamiento previo —sin el atributo, focusWithTabindex se cortocircuitaba a true— sin tocar el teclado, porque KeyboardPan/KeyboardZoom no usan esa opción. Se descartó enfocar el mapa a mano en un pointerdown de fase de captura: funciona, pero es pelearse con el orden de listeners de OL por un valor que la librería deja configurar.
    • El test que lo cazó no existía al principio. La primera versión del spec afirmaba que una flecha con el foco en el mapa no cambia la URL — y eso pasaba igual con el mapa completamente inerte. Un aserto negativo no prueba que la interacción viva. e2e/a11y.spec.ts lleva ahora un caso que arrastra 120 px sin enfocar antes (el primer gesto de un usuario real llega con el foco en el <body>) y compara capturas del mapa.
    • role="application" en el mapa, a sabiendas de lo que cuesta. Es el único rol donde un lector de pantalla entrega las flechas a la aplicación en vez de mover su cursor virtual. Suprime el buffer virtual de todo lo que haya dentro, y es aceptable solo porque dentro del <div> del mapa únicamente hay canvas: todo dato legible (celdas, valor bajo el cursor, VWP, estado del raster) ya vive en paneles DOM fuera del mapa. Si algún día entra texto real dentro del viewport, esta decisión hay que revisarla.
    • El slider del timeline funcionaba por accidente. Tenía role="slider", tabindex="0" y los aria-value* correctos desde D36, pero ningún manejador propio: las flechas se las servía el listener global de la página. Ahora TimelineStrip.vue declara su propio keydown (flechas → step, Home/End → select del primero/último), que además es lo que role="slider" ya prometía y no cumplía.
    • Anillo de foco global, porque no había ninguno. El repo no definía ningún estilo de foco y seis inputs llevaban focus:outline-none, que borra incluso el del navegador. assets/css/tailwind.css (fichero nuevo; @nuxtjs/tailwindcss ya lo resolvía por defecto en esa ruta y hasta ahora caía al CSS por defecto del módulo) define :focus-visible con doble trazo: outline teal más un box-shadow oscuro casi opaco. El halo oscuro no es decorativo — los controles flotan sobre el mapa, así que el fondo bajo un botón es imagen arbitraria y un solo color no garantiza los 3:1 de 1.4.11. :focus-visible y no :focus para que el anillo no salte al hacer clic.
    • Contraste: tres arreglos medidos, ninguno al ojo. (a) text-slate-500 daba 3.75:1 sobre slate-900 y 3.07:1 sobre slate-800, por debajo de los 4.5 de 1.4.3 → slate-400 (6.96 / 5.71). (b) Los bordes slate-600 sobre slate-800 daban 1.93:1 y son el único rasgo que identifica un <select> o un <input> (el relleno slate-800 sobre slate-900 es 1.33:1) → slate-500 (3.07). (c) El handle del timeline, azul de marca #1565A8 sobre la pista #8B9095, daba 1.89:1 siendo el elemento que comunica el valor; se le añadió un anillo blanco de 2px (3.22:1 contra la pista, 17.85:1 contra el fondo) en vez de retocar el azul o aclarar la pista, para no mover la identidad visual.
    • aria-live solo donde el cambio es noticia. Los errores llevan role="alert" y los estados vacíos role="status". El <dl data-testid="raster-meta"> queda deliberadamente sin anunciar: contiene el valor bajo el cursor, que cambia en cada movimiento del ratón, y un aria-live ahí sería ruido continuo en un lector de pantalla.
    • Las pestañas de DataModal pasan al patrón ARIA tabs completo (tablist/tab/tabpanel, aria-selected, aria-controls, tabindex rodante y flechas + Home/End con activación automática). Antes eran seis <button> sin relación declarada con sus paneles — y sin type="button", es decir submit por defecto.
    • La regresión se vigila en e2e, no en unit. e2e/a11y.spec.ts comprueba lo que un refactor deshace en silencio: que el mapa sea focusable, que una flecha con el foco en el mapa no cambie la URL, que con el foco en el slider sí, que Tab deje un outline de ≥2px, y el patrón de tabs. Un test de componente no habría cubierto la colisión de flechas, que es el fallo que de verdad importaba.
  44. Pase móvil del timebar: el ancho se mide, no se adivina, y el corte de layout no es el de tamaño táctil. D36 dejó explícitamente "la variante móvil compacta del timebar para una iteración siguiente". La auditoría se hizo midiendo el DOM renderizado a 320, 360, 390, 414, 640×360 y 768 px, no mirando capturas. Lo encontrado a 360 px: las etiquetas de tick se pisaban en tres de cinco parejas (la peor, 48 px), la pastilla "EN VIVO" se salía 18 px del viewport, tapaba 34×26 px del botón ☰ y partía su texto en dos renglones, y prev/next medían 36 px. Criterios con los que se juzgó "mejor": nada se solapa ni sale del viewport entre 320 y 430 px; todo control táctil ≥ 44×44 px; el alto del timebar no crece; de 1024 px para arriba no cambia un píxel; y la información degrada en vez de desaparecer.

    • Las etiquetas se eligen por píxeles medidos, no por un conteo fijo. D36 dibujaba seis equiespaciadas en la lista de vol_times, y equiespaciar en la lista no equiespacia en el track: un volumen tarda entre ~2 y ~6 min según el VCP y un hueco de ingesta los separa más todavía. utils/timeline/labels.ts (puro, con tests) recorre los candidatos y descarta el que quede a menos de TICK_LABEL_PITCH_PX del último conservado, conservando siempre los dos extremos, que son los que dan el rango del día. El paso mínimo es 120 px y sale de una medición: '11:03 pm' en text-sm font-bold ocupa 74 px, y como la primera etiqueta va anclada a la izquierda y las intermedias centradas, el caso peor es la pareja borde→intermedia, que necesita 74 + 37 + aire. El ancho del track lo aporta un ResizeObserver (y no un listener de resize: el track también cambia de ancho sin que cambie el viewport). Con el ancho sin medir —SSR, jsdom— la función devuelve los seis de siempre, para que el HTML del servidor coincida con el del cliente y la hidratación no parpadee. Esto también arregla el escritorio: a 768 px las seis etiquetas ya se solapaban 11 px.
    • La pastilla "en vivo" se duplica en el DOM en vez de moverse por CSS. Flotando sobre el track en la posición del handle (D36) no hay forma de que quepa en un móvil: con el handle en el extremo vivo —que es el caso por defecto— medio cuerpo queda fuera de la pantalla y el resto encima del ☰. Ningún CSS mueve un nodo de padre, así que por debajo de sm: se renderiza una gemela inline en la fila de controles, donde ya hay 56 px de alto disponibles y el área táctil sale gratis. Es el mismo patrón de nodo duplicado con data-testid propio que el componente ya usaba para los dos botones ☰, y se prefirió a un v-if sobre ancho medido justamente porque un v-if así parpadea al hidratar. La flotante de escritorio ganó además whitespace-nowrap y un clamp() en su left, que es lo que le faltaba para no salirse del track por los extremos.
    • El tooltip del scrub se ancla al borde, no se recorta. Seguía al handle centrado, así que en los extremos se salía por el costado — medio tooltip fuera de pantalla justo mientras el dedo arrastra, que es cuando hace falta leerlo. Ahora usa las mismas tres posiciones que las etiquetas (start/mid/end) y la flecha se queda clavada bajo el handle con un desplazamiento en píxeles, lo que evita tener que conocer el ancho real del tooltip, que depende del texto y del idioma.
    • Dos cortes distintos, a propósito: el layout en sm: y el tamaño de los botones en md:. Un móvil girado mide 640×360, y con la pila vertical el timebar se comía 146 px de 360 (41 % de la pantalla) teniendo 608 px de ancho de sobra; bajar el corte de layout a sm: (640 px) lo deja en 88 px. Pero entre 640 y 768 px el usuario sigue tocando con el dedo, así que prev/next se quedan en 44 px hasta md: y el escritorio conserva sus 36 px de D36.
    • El sangrado de las etiquetas pasa a medirse. Alinear la fila de etiquetas con los extremos del track se hacía con un calc(2*2.25rem+3.5rem+2*0.25rem+0.75rem) que sumaba a mano los anchos de los botones. Ese truco se desincroniza en cuanto un botón cambia de tamaño, que es exactamente lo que pasa ahora, y es lo que permitía que los dos cortes anteriores tuvieran que coincidir. Se sustituye por el offset medido del track respecto de la raíz del componente; el calc() queda solo como valor previo a la primera medición para que el SSR no salte.
    • La regresión se vigila en e2e, no en unit. Todo esto es geometría de layout real: un mount en jsdom devuelve 0 en cada getBoundingClientRect y no vería nada — de hecho el componente trata ese 0 como "sin medir". e2e/mobile.spec.ts comprueba a 360×640 que ninguna etiqueta empiece antes de que termine la anterior, que nada del timebar salga del viewport ni genere scroll horizontal, que la pastilla y el ☰ no se solapen y que el ☰ siga respondiendo al clic, y que los cinco controles midan ≥ 44 px; a 640×360 que el timebar baje de 100 px de alto; y a 1280 que sigan siendo seis etiquetas y la pastilla flotante. La selección de etiquetas, que sí es pura, se prueba aparte en tests/unit/timeline-labels.spec.ts.
    • Lo que NO se hizo y por qué. El alto del timebar en vertical se queda en 122 px: el suelo lo marcan el botón de play de 56 px de D36, los 32 px de la fila de etiquetas de dos renglones (hora + fecha) y los 16 px del track. Bajar de ahí exige o encoger el play —que es la acción primaria y en móvil es cuando más se agradece grande— o quitarle la fecha a las etiquetas, que es información real. Se deja medido y dicho en vez de forzarlo. Tampoco se tocó RadarProductChip.vue, que a 360 px trunca su título y superpone la insignia de frescura con el enlace a mosaico y el botón de info: es el siguiente trozo del pase móvil, no este.
  45. Chip de identidad en móvil: la cabecera pasa a dos filas y el despliegue a uno solo. Segundo trozo del pase móvil, el que D55 dejó nombrado. Medido otra vez sobre el DOM renderizado a 320, 360, 390, 414, 640×360, 768 y 1280 px. Lo encontrado: el título se truncaba a todos los anchos —a 1280 px necesitaba 197 px y tenía 177, y a 360 px se quedaba con 73, así que se leía "AMX · " sin producto—; la insignia de frescura envolvía a tres renglones por debajo de 414 px y pisaba el enlace "mosaico" 29 px a 360 px y 44 px a 320 px; el botón ⓘ medía 28×28 px. Criterios con los que se juzgó "mejor": título legible entero entre 320 y 1280 px; cero solapes medidos; insignia en un renglón a cualquier ancho; nada se esconde para caber (el enlace a mosaico y el bloque de detalle siguen a un toque); y escritorio sin regresión de ancho ni de orden visual.

    • El chip no se puede ensanchar, así que el contenido envuelve. max-w-[calc(100vw-9rem)] reserva las 9rem del botón de capas de arriba a la derecha, que mide 107 px y a 320 px deja solo 5 px de aire. No hay ancho que ganar ahí: a 360 px quedan 190 px útiles y el título necesita 197 en un renglón. Por eso se quita el truncate y se deja envolver — cortar un título de dos partes deja la segunda entera fuera, que es justo la que dice qué producto se está mirando.
    • El enlace a mosaico baja a la fila de estado. En la fila del título le quitaba 62 px de los 190 y era la causa directa del truncado; en la fila de la hora y la insignia cabe, y en escritorio entra con ellas. Eso obliga a sacar la fila de estado de dentro del <button> —un <a> dentro de un <button> es HTML inválido—, con el efecto lateral de que el nombre accesible del botón pasa a ser solo el título, sin la hora ni la frescura que cambiaban en cada volumen.
    • Un solo control de despliegue. Había dos: ⌄ abría los selectores y un ⓘ aparte abría el bloque de estado, cada uno con su aria-expanded. En una tarjeta de 216 px, un ⓘ con área táctil de verdad cuesta 44 px de los 190 útiles (23 %) para exponer un panel que de todas formas se apila bajo el otro. Ahora expanded gobierna los dos y el ⓘ desaparece; en md: el bloque de estado sigue visible siempre, como antes, y un error lo sigue mostrando sin esperar a ningún toque.
    • whitespace-nowrap en FreshnessBadge.vue. Es una pastilla de 12 px de alto: partir "hace 3 meses" en tres renglones la convierte en un bloque de 52 px que se come la fila. La insignia envuelve o no según el idioma y según la antigüedad, así que el arreglo va en el componente de la insignia, no en quien la coloca.
    • Mismo cambio en MosaicDomainChip.vue, por D48. Los dos chips comparten el patrón desde la paridad de chrome; arreglar uno solo los volvía a separar. Ahí el título era aún más largo ("Golfo de México · Reflectividad base", 202 px) y se cortaba a 56 px a 360 px y a 160 px a 1280 px.
    • Lo que costó, dicho con números. A 360 px la tarjeta pasa de 90 a 126 px de alto y a 320 px de 90 a 166: con tres o cuatro piezas que antes se solapaban y ahora no, el alto es el precio. A 1280 px la del radar pasa de 72 a 90 px, porque la hora (101 px), la insignia (99) y el enlace (58) suman 274 con sus huecos y la fila mide 262 — el enlace cae a su propio renglón por 12 px. Se podía raspar ese margen apretando huecos y relleno, pero quedaba un presupuesto de un píxel que la primera traducción distinta rompe. La del mosaico, con su insignia corta, baja de 70 a 66. El criterio de "escritorio sin regresión" se cumple en ancho y en orden visual, no en alto: 18 px más en una tarjeta flotante sobre el mapa, a cambio de que el título deje de cortarse también ahí.
    • La regresión se vigila en e2e, por lo mismo que D55. e2e/mobile.spec.ts comprueba a 360×640 que el título no quede cortado (scrollWidth contra clientWidth), que no haya solape entre título, hora, insignia y enlace, que la tarjeta no salga del viewport, que la insignia mida un renglón, que el enlace llegue a 44 px de alto y que un solo toque en ⌄ abra selectores y detalle sin que exista ya un radar-details-toggle; a 1280 px, que el título tampoco se corte y que el detalle siga visible sin desplegar nada.

Qué murió del plan original (y por qué)

Ítem del plan original Destino Motivo
Backend FastAPI + Python 3.12 + Uvicorn Muerto D1 se lee por binding de Worker; el backend es TypeScript en el edge (server routes de Nuxt)
PostgreSQL como store de metadata Sustituido por D1, y luego vuelto a Postgres (decisión 38) D1 compartida con nexrad-l3-pipeline al inicio; migrada de vuelta a Postgres self-hosted cuando D1 agotó la cuota del plan gratuito — el schema ya estaba diseñado migrable
Gap FTP ↔ HTTP range (§6, "decision to confirm") Resuelto R2 sirve COGs con CORS + range requests; la opción (a) del plan es la realidad desde el día uno
Fallback titiler / rio-tiler server-side Innecesario Sin el gap FTP, el fallback pierde su razón; los COG están bajo el cap de textura WebGL (peor caso 3680×3680)
Tabla palette en la base Muerta Paletas en el repo del viewer (decisión 5)
product_def rico (nombre, categoría, rango, ref. paleta) Catálogo estático del viewer D1 products es mínimo a propósito
Base cartográfica Cuba EPSG:2085 Diferida a config Radares demo en Florida/PR (decisión 7)
Oracle visual = PNGs legados Sustituido Radares y productos distintos; goldens propios + QGIS (decisión 10)
Auth de sesión ligera Sin auth Demo público (decisión 13)
Restos de Svelte en el texto ("SvelteKit server routes", "runes", "svelte-i18n") Purgados Inconsistencias de redacción del plan original; la decisión Vue 3 ya estaba tomada
  1. Mosaico: el compuesto lo produce el pipeline; el viewer lee un COG por slot y pinta la COBERTURA encima. La decisión 12 original (N capas WebGLTile apiladas, una por radar) resuelve la vista pero no el dato: en el solape gana la capa de arriba, no la medida mejor, y la puerta de F5 pide tres radares animando dentro del presupuesto de frames con N fetch y N decodificaciones por frame. Se sustituye por composición en nexrad-l3-pipeline (promedio pesado en Z lineal por altura de haz, 0002_mosaic.sql) y un solo COG por slot de 300 s. Lo que eso impone aquí:

    • Tablas propias, no rasters. mosaic_domains/mosaic_domain_sites/mosaic_rasters. rasters.site_id tiene FK a radars y un mosaico no tiene sitio único: un pseudo-radar "GULF" aparecería en el selector de radares con lat/lon inventados y sin fenómenos ni VWP.
    • Sin código de producto nuevo. mosaic_rasters.product_code reusa el código NEXRAD, así que shared/products/<code> y su paleta valen sin tocar nada. La calibración del compuesto es canónica y fija entre slots — si fuera heredada de una fila de rasters, la rampa cambiaría de un frame al siguiente.
    • Rejilla temporal propia. slot_time, no vol_time: los radares no escanean sincronizados. pickClosest se generaliza a pickClosestBy para indexar por otra columna con la misma regla de desempate.
    • Geometría por fila, no solo por dominio. Añadir un radar a un dominio recalcula su malla; sin proj4/width/height/cell_m en la fila, el viewer pintaría los COG anteriores desplazados.
    • contributing es contrato, no depuración. Un slot se compone con los radares que haya dentro de tolerancia (un radar caído no apaga el mosaico), así que la cobertura cambia entre frames. El DAL sirve la procedencia ya parseada y /api/mosaic/domains da la lista completa de miembros: la resta es lo que permite pintar los AUSENTES. Sin eso, un hueco en el mapa es indistinguible de "no hay ecos".
    • Procedencia por píxel fuera de alcance. Con weighted no hay ganador único, y servirla exigiría un COG extra por slot o una segunda banda que toca el camino de render del raster (puerta M2, goldens). Costeado, no construido.
    • N0G no se compone nunca. La velocidad radial es relativa al radar.
  2. El mosaico tiene ruta, máquina y mapa propios — no se injertó en viewerMachine. La decisión obvia (añadir un domain opcional a ViewerRouteState y reusar la máquina existente) se descartó: viewerMachine ya tiene regiones de fenómenos/VWP/viento/rayos/anotaciones acopladas a un site_id único (overlayMachine indexado por sitio, annotationMachine persistido por sitio), y el mosaico no tiene uno — es N radares. Forzar esa forma habría significado o bien una máquina con ramas condicionales por todas partes (site a veces string, a veces lista) o un dominio tratado como un pseudo-sitio falso (lo que la decisión 43 ya rechazó para la tabla radars). Se optó por una hermana reducida: machines/mosaic-viewer.ts (regiones raster/timeline, mismo patrón URL-manda/sameFrame/sameDay/assign optimista que viewerMachine, ver docs/maquinas-estado.md), pages/mosaic/[domain]/[product]/[[time]].vue, components/MosaicMap.vue y components/MosaicDomainChip.vue. Costo: código paralelo con viewerMachine en vez de una sola fuente de verdad — aceptado porque las dos máquinas van a seguir divergiendo (P5 le añade celdas multi-radar con ?cell=SITE:ID, nunca fenómenos/VWP/viento/rayos) y la alternativa unificada crecía en complejidad condicional más rápido de lo que ahorraba en líneas.

    • Sin animación todavía. utils/map/frame-pool.ts se genericizó (FramePool<T extends CogFrame>) para poder aceptar MosaicRasterMeta además de RasterMeta — ese cambio es gratis y queda hecho — pero el pool asume una única proyección proj4 compartida por toda la ventana de frames, y la geometría del mosaico es por fila (decisión 43): una ventana que cruza un recálculo de dominio reproyectaría mal los frames viejos del pool. Vista estática + stepping manual primero; animar exige antes detectar el caso común (proj4 estable en la ventana) y degradar con aviso si no — queda para P4.1 si el caso de uso lo pide.
    • registerDomainProjection indexa por el contenido del proj4, no por domain_id (hash FNV-1a corto). La geometría por fila (decisión 43) significa que dos COGs del mismo dominio pueden traer un proj4 distinto si el dominio se recalculó entre medias; una clave por domain_id habría hecho que el chequeo idempotente de registro (if (!getProjection(code))) se saltara el re-registro y una fila vieja quedara leyendo la definición de otra — error silencioso, geometría desplazada sin ningún log.
    • El overlay de cobertura es la pieza que el usuario pidió explícitamente (no una ocurrencia de diseño): un anillo sólido/teal por radar que aportó al slot mostrado, punteado/atenuado por el que falta — MosaicDomain.site_ids menos raster.contributing da los ausentes. Mismo radio para todos los anillos del dominio (MosaicDomainRow.radius_m, única cifra que el contrato ofrece) — aproximación visual, no el alcance físico exacto de cada antena.
    • TimelineStrip.vue gana un prop showPlay (default true) en vez de duplicar el componente: sin animación, un botón play que no hace nada es peor que no tenerlo. Único llamador existente (viewerMachine) no lo pasa, así que su comportamiento no cambia.
    • Enlace cruzado entre vistas, no una navegación nueva de primer nivel. "ver mosaico" vive en RadarProductChip.vue (visible solo si el sitio actual pertenece a algún MosaicDomain.site_ids) y "ver radar único" en la cinta de marca del mosaico — ninguno de los dos lados obliga al usuario a saber que el otro existe.

Riesgos residuales

  1. Fidelidad del render GeoTIFF (riesgo #1 del plan original, sigue vivo): el color-mapping WebGL debe reproducir la semántica de las paletas legadas sobre productos nuevos. Mitigación: es la primera rebanada vertical (F2), goldens desde el día uno, validación QGIS del experto de dominio.
  2. Co-evolución del contrato: el schema D1 lo posee el pipeline. La extensión de attrs aterrizó recortada (decisión 22) y sus claves son contrato documentado en ambos repos; el riesgo residual es la semántica de tracks deducida de las grabaciones (decisión 25, pendiente del experto en M4). Mitigación: contract tests versionados que fallan CI ante drift + test canario de continuidad de tracks; cambios = migración negociada.
  3. Rendimiento WebGL en hardware objetivo con animación multi-frame y mosaico multi-radar. Mitigación: prefetch medido en F3, presupuesto de frames en la puerta de F5; el fallback server-side queda documentado como stage 2, no construido.
  4. ~~Límites del tier gratuito de Cloudflare (D1 lecturas/día, requests de Functions) bajo uso de demo público.~~ Materializado y resuelto (decisión 38): D1 agotó la cuota — migrado a Postgres self-hosted en el Swarm, sin límite de cuota de plataforma. Riesgo retirado.
  5. Animación del mosaico, diferida (decisión 44): MosaicMap.vue solo renderiza estático hoy. El riesgo no es de rendimiento (el COG compuesto es más liviano que uno de un solo sitio, mayormente nodata+DEFLATE) sino de corrección: la geometría por fila (decisión 43) puede cambiar dentro de una misma ventana de animación si el dominio se recalculó. Mitigación pendiente: detectar proj4 estable en la ventana antes de animar, degradar con aviso si no.

  6. Celdas multi-radar (P5): unión por sitio, sin endpoint nuevo, identidad SITE:ID. La decisión pendiente desde D44 ("celdas multi-radar etiquetadas llegan en P5") se resolvió reusando /api/phenomena/times + /api/phenomena por cada site_id del dominio en vez de construir un endpoint de unión server-side: el nearestWithin por sitio ya existe (utils/overlay/join.ts, el mismo que usa overlayMachine) y el contrato de Phenomenon ya trae site_id en cada fila — no había dato nuevo que diseñar, solo una orquestación cliente que fan-outea N fetches en paralelo (Promise.allSettled, un sitio que falla no tumba a los demás, mismo espíritu que D43).

    • ?cell=ID del viewer de un solo radar se vuelve ?cell=SITE:ID acá, decisión explícita, no accidental. El cell_id del RPG es un identificador LOCAL al sitio (dos radares del mismo dominio pueden reportar ambos una celda "A0" sin relación entre sí); sin el prefijo, el mapa, la tabla y la URL colisionarían — dos tormentas distintas compartiendo una fila/marcador. Es un contrato distinto a propósito, no una variante del mismo: por eso vive en MOSAIC_CELL_RE (composables/useMosaicRoute.ts), separado de CELL_ID_RE del viewer de un solo radar, y la región cells de mosaicViewerMachine es la única que lo entiende.
    • Sin deduplicar entre radares (decisión explícita del usuario, ver D43 para el precedente de "mostrar lo que hay"). Si dos radares ven la misma tormenta, el mapa y la tabla muestran dos marcadores — fusionarlos exigiría una heurística de distancia/tiempo que el contrato no pide y que escondería información real (qué radar la vio, con qué dbz_max cada uno). Coloreado por sitio (siteColor, ciclado por posición en MosaicDomain.site_ids) en vez de por severidad/selección como en phenomena-layer.ts, porque acá la pregunta es "¿qué radar?", no "¿qué tan severa?" — ese dato ya está en el número de dBZ junto al ID.
    • Capa y componentes propios (utils/map/mosaic-cells-layer.ts, lista en MosaicDomainChip.vue), no una extensión de phenomena-layer.ts/CellTable.vue. Esos dos son parte del camino crítico del viewer de un solo radar (F4, puerta M4 pendiente de validación del experto); retrofitearlos con awareness de sitio para una vista que ni siquiera los usa (D44: el mosaico no tiene meso/TVS/tracks) era el riesgo más alto del cambio para el beneficio más bajo. Única pieza compartida a propósito: dbzToRadius (exportado de phenomena-layer.ts) — es una curva de calibración, no lógica de capa, y duplicarla habría sido el error real.
    • Sin SSR para las celdas, a propósito. El primer fetchCells lo dispara el evento CELLS_INIT, enviado por la página en onMounted (nunca corre en el servidor) — si la región cells lo disparara sola desde su estado inicial, el actor lo invocaría también durante el render SSR (un fan-out de fetches reales contra las rutas del servidor) y el resultado se tiraría igual al hidratar en el cliente, porque el estado de un actor XState no viaja en el payload de Nuxt. MosaicMap.vue ya vive detrás de <ClientOnly>, así que no hay beneficio de primer pintado que perder.
    • Gotcha de XState v5 encontrado implementando esto, documentado en docs/maquinas-estado.md: una transición a un estado distinto con reenter: true explícito, en una región paralela donde otra región sibling también maneja el mismo evento, se descarta en silencio. reenter solo hace falta para forzar reentrada a un estado igual al actual; quitar el flag (el target ya era distinto, ya era transición externa) lo arregló. Canario: test "ROUTE_CHANGED a otro slot_time SÍ refetchea celdas".
  7. Cobertura del mosaico: máscara de opacidad en canvas, no anillos con borde de color (sustituye la mitad visual de D44). El overlay original (coverage-rings-layer.ts, buildCoverageFeatures+coverageRingStyle) dibujaba un Polygon con un agujero por radar (anillo sólido/teal contribuyente, punteado/atenuado ausente) — correcto para un radar, pero el mosaico tiene N y sus círculos de 230 km+ se solapan en cuanto dos sitios están cerca. Un solo Polygon con N agujeros vía winding rule (nonzero o evenodd, cualquiera de las dos) pinta mal la zona de solape: el conteo de cruces por agujero se duplica ahí y la región queda rellenada cuando debería quedar transparente (cobertura real de al menos un radar). Pedido explícito del usuario: igual criterio visual que la máscara de un solo radar en RadarMap.vue (updateCoverage(), decisión original sin numerar — oscurece el mapa entero salvo el alcance del radar).

    • Fix: capa canvas propia con destination-out (utils/map/coverage-mask-layer.ts, mismo patrón "custom canvas layer" que WindParticleLayer/LightningLayer), no geometría con agujeros. Pinta un rect oscuro full-extent y después "puncha" transparente el círculo (vértices geodésicos vía circular(), igual método que el anillo que sustituye) de cada radar CONTRIBUYENTE al slot — punchar dos veces la misma zona da el mismo resultado que una, así que el solape deja de ser un caso especial.
    • Semántica binaria, sin anillo intermedio. Contribuyente ⇒ círculo transparente + marcador/label teal (igual que antes). Ausente ⇒ sin círculo, sin marcador, sin label: su área queda cubierta por la máscara sin marca adicional — decisión del usuario, más simple que el punteado/atenuado anterior y consistente con "sin dato ⇒ oscuro" del radar único. buildCoverageCenterFeatures ya no recibe la lista completa de sitios del dominio, solo los contribuyentes (el filtro vive en MosaicMap.vue::updateCoverage).
    • Sin biblioteca de booleanos geométricos nueva (turf/polygon-clipping): el composite destination-out del canvas 2D resuelve la unión de círculos solapados gratis, sin calcular la geometría unida explícitamente.
  8. P6: validación del mosaico contra un episodio real, cierre del plan de mosaico multi-radar. Sin acceso de sistema a QGIS ni a la base del pipeline (regla org "no system access"), la validación se hizo bajando los 4 GeoTIFF reales de producción (mosaico GULF/N0B + los 3 crudos de sitio que aportaron: AMX/BYX/HDC, episodio slot_time=2026-10-11T13:15:00) vía las URLs públicas de R2 y comparando pixel a pixel con Python efímero (uv run --with tifffile --with numpy --with pyproj, sin instalar nada en el sistema) en vez de abrir QGIS a mano — mismo criterio de verificación que cualquier otro chequeo read-only de producción ya hecho en esta sesión.

    • Reproyección elipsoidal exacta (pyproj/WGS84), no esférica aproximada. Un primer intento con fórmulas AEQD esféricas a mano daba sesgos de varios dBZ que parecían "dilución de núcleos" — resultó ser error de proyección (hasta ~3-5 km a estas distancias, más que el pixel de 1 km del mosaico), no un problema real del pipeline. Con pyproj.Transformer exacto, el sesgo desaparece casi del todo: lección para cualquier verificación futura de georreferencia sin GDAL/QGIS a mano — la aproximación esférica no alcanza a esta escala.
    • Resultado: contrato de calibración confirmado. El GeoTIFF del mosaico se ve como Float32 en el panel de QGIS porque GDAL expone el tipo derivado de los tags SCALE=0.5/OFFSET=-33 embebidos (GDAL_METADATA); el storage real es Byte (uint8) y la fórmula es la misma que el crudo de sitio (físico = nivel·0.5 − 33, nivel<2 = nodata/range-folded). No hay unidad ni escala distinta para el compuesto — un posible punto de confusión para quien abra el archivo en QGIS sin mirar los tags.
    • Resultado: invariante de promedio ponderado verificado. En zona exclusiva (un solo radar a <250 km, reproyección elipsoidal) AMX y HDC igualan su crudo casi exacto (std < 1.6 dBZ). En zona de solape (2+ radares, n=14737 píxeles) el mosaico cae dentro de [min,max] de los crudos en 99.3% de los casos (±1 dBZ) y 100% con ±8 dBZ, correlación 0.957 contra el promedio simple — descarta bug de normalización de pesos o de signo en el blend.
    • BYX mostró sesgo mayor y creciente con la intensidad, acotado a un puñado de píxeles puntuales (no todo su territorio exclusivo) — coincide con tener el lag_s más grande de los 3 contribuyentes de este episodio (+44 s respecto al slot), compatible con el movimiento de la celda entre el barrido de BYX y el slot compuesto, no con un bug de peso. Queda anotado por si se repite en otro episodio.
    • h_ref=4000/r_ref=200000 quedan confirmados tal cual, sin recalibrar — el usuario (rol de experto de dominio en esta validación) dio el visto bueno con esta evidencia, sin pedir ajuste. Cierra el único punto abierto de D43/D45.
    • Puerta de P6 cerrada. Con esto el plan de mosaico multi-radar (P3-P6) queda completo; no queda trabajo pendiente de este plan salvo lo que surja de uso real futuro.
  9. El mosaico comparte el chrome del viewer de un solo radar: mismos componentes, mismo vocabulario de URL, solo se ocultan las piezas que no existen. Pedido explícito del usuario tras usar P4/P5: «quiero que entre la vista de mosaico y la vista por radar haya la menor diferencia posible». La vista de mosaico había crecido con controles propios (una caja de checkboxes arriba a la derecha, un selector de día suelto abajo a la izquierda, una cinta de marca distinta) y con simbología propia de celda. Esa divergencia no aportaba nada: el usuario cambia de vista con un enlace y pierde el mapa mental en cada salto.

    • LayersMenu.vue gana variant: 'radar' | 'mosaic' (default 'radar'), no un componente hermano. La caja ad-hoc de la vista de mosaico (cobertura/opacidad/mapa base) se borra y la vista monta EL MISMO menú, con el mismo pill «Menú», el mismo dock a la derecha y el mismo DayPicker dentro. El variant solo esconde lo que no existe acá: satélite, viento, rayos, meso/trayectorias, Tendencia/VWP, anotaciones, export, feedback y el suavizado del raster (MosaicMap.vue no lo implementa). Un componente hermano habría duplicado 500 líneas de plantilla para volver a divergir al primer cambio.
    • La capa de celdas pasa a ser una capa apagable, ?layers=cells, mismo query param que el viewer de un solo radar. Antes se dibujaban siempre y sin control. MOSAIC_LAYERS es el subconjunto honrado acá (solo cells); el resto de OVERLAY_LAYERS no se parsea. El evento TOGGLE_LAYER vive SOLO en la región cells de mosaicViewerMachine — en una máquina paralela, un on de la raíz queda ensombrecido en cuanto una región declara el suyo (gotcha ya documentado), así que esa región hace también el assign y el sync de la query. Apagar NO tira las celdas del contexto: reencender en el mismo slot no refetchea.
    • Default apagado, como en el viewer de un solo radar — y el fetch se gatea con la capa: con la capa apagada ni CELLS_INIT ni un cambio de slot disparan el fan-out de /api/phenomena por sitio. Antes se pagaba ese fan-out siempre.
    • La simbología de celda es la MISMA en las dos vistas; lo único propio del mosaico es la etiqueta. mosaic-cells-layer.ts dejaba un círculo relleno del color del sitio; ahora reusa cellIcon de phenomena-layer.ts (circunferencia navy + X, radio por dbz_max) y colorea solo el texto SITE:ID por sitio de origen. También gana declutter: true, como la capa de fenómenos de RadarMap.vue: con decenas de celdas de dos radares los labels se tapaban entre sí.
    • La cinta de marca no se toca entre vistas (misma geometría, mismo texto «LAMULA™ WebViewer»): el enlace de vuelta al radar único se mudó al chip de dominio, simétrico al enlace «mosaico» que RadarProductChip.vue ya tenía.
    • La timeline dibuja una ventana de animationFrames muestras, no el día entero. El viewer de un solo radar siempre mostró las últimas 12 observaciones alrededor del ancla (timelineWindowMeta); el mosaico dibujaba los 288 slots del día, que a 300 s son ~24 h de ticks ilegibles. Se portó la misma mecánica de ancla, y el ☰ de la strip abre TimelineMenu con show-speed="false" (no hay animación todavía, pero la cantidad de observaciones sí define la ventana).
    • Prefs compartidas, coverage desviado. La vista lee del mismo lamula:prefs las claves que le aplican (units, clock, animationFrames, showPalette) y monta el mismo PrefsDialog. Única costura: en el mosaico la cobertura no es una pref persistida sino estado de URL (?coverage=0), así que la página desvía esa clave del patch a TOGGLE_COVERAGE y deja pasar el resto. Se prefirió eso a añadirle un prop al diálogo para esconder la fila.
    • Lo que sigue divergiendo a propósito: la lista de celdas del mosaico vive en el chip de dominio, no en DataModal.vue — ese modal está acoplado a overlayMachine (series de tendencia, VWP) y el mosaico no tiene esas regiones. Mover la lista allí exigiría un DataModal genérico; queda pendiente si el uso lo pide.
  10. Satélite, viento y rayos vuelven a la vista de mosaico: el satélite tal cual, viento y rayos uniendo los N sitios del dominio. D48 dejó esas tres capas fuera del menú del mosaico porque MosaicMap.vue no las montaba (alcance recortado desde D44). El usuario las pidió de vuelta: «vamos a recuperarlos todos». Las tres tienen naturaleza distinta y por eso la solución no es la misma para cada una.

    • Satélite: nada que unir. GOES es un TileWMS de NOAA, global, sin site_id — la única capa de este grupo que no depende del dominio. Se monta igual que en RadarMap.vue (zIndex 3, bajo el raster) y su time se fija al slot_time mostrado, como el viewer de un solo radar lo fija al vol_time. Estado en la URL con los mismos params (?sat=1&satVariant&satOpacity).
    • Viento: una sola capa de partículas sobre la UNIÓN de las grillas, no N capas apiladas. El pipeline sirve un WindGridFile por sitio. Apilar una WindParticleLayer por radar duplicaría la densidad de partículas justo en el solape (donde hay dos grillas), que es exactamente donde el usuario mira. mergeWindGrids (utils/wind/grid.ts, pura y con tabla de tests) remuestrea las N fuentes sobre una lattice propia: bbox unión, paso el más fino de las fuentes, y cada celda la resuelve la PRIMERA grilla que la cubra. No se promedia: dos campos GFS del mismo ciclo no ganan nada promediados y el promedio escondería un desacuerdo entre ellos.
    • Las celdas que no cubre ninguna fuente quedan en NaN, no en 0. Un 0 sería viento en calma inventado. Para que el NaN se comporte como "fuera de la grilla", sampleWind ganó un guard: si el valor interpolado es NaN devuelve null, y la partícula respawnea igual que al salirse del dominio (antes el guard solo miraba las coordenadas, así que un hueco habría arrastrado NaN por el campo). El hueco se ve vacío, que es lo honesto.
    • Rayos: unión por ventana más deduplicación. Los cubos de 300 s son por sitio y el feed GLM se filtra por radio de sitio, no por territorio exclusivo: dos radares vecinos del mismo dominio reciben la MISMA descarga. strikesInWindow ya acepta N ficheros (no hubo que tocarlo); lo nuevo es dedupeStrikes por (lon, lat, progress), que solo usa el mosaico — en un solo radar no hay dos fuentes que puedan repetir.
    • La ventana de rayos sale de context.times, no de restar 300 s. El slot anterior es el anterior REALMENTE observado; un hueco del feed no debe inventar un intervalo. Es el mismo criterio que el prevVolTime del viewer de un solo radar (D31), con slots en vez de volúmenes.
    • Cuatro regiones nuevas en mosaicViewerMachine, no un on en la raíz. wind y lightning (índice propio por sitio, cache por clave, estados ready/noData/error) más una región layers que posee el assign de layers y el sync de la query. Esa región aparte existe porque el evento TOGGLE_LAYER se difunde a TODAS las regiones que lo declaran: si el assign viviera en una de las regiones de datos correría una vez por región. Los guards de cada región leen el PAYLOAD (y el contexto previo), nunca el contexto ya actualizado — la regla de siempre en esta máquina paralela.
    • SELECT_WIND_LEVEL se queda dentro de la región wind, no en layers: así su assign del nivel corre antes de entrar a .loading y el input del invoke ya lee el nivel nuevo. El orden entre acciones de regiones distintas no es algo sobre lo que convenga apoyarse.
    • Coste aceptado: viento y rayos hacen fan-out de N índices (uno por sitio) en el cliente, igual que ya hacían las celdas desde D45. Si un dominio creciera a muchos radares, lo correcto sería que el pipeline sirviera un grid y un índice por dominio; con 2-4 sitios no se justifica todavía.
  11. La bandeja de feedback es una sola para los dos productos, con columna app como discriminador; lamula-process no monta backend propio. lamula-process (reescritura del Vesta Proceso, SvelteKit adapter-static sobre Cloudflare Pages) necesitaba recoger feedback de los mismos usuarios — meteorólogos del Centro de Radares, profesores, estudiantes — y no tiene servidor: su meta declarada es "cliente puro, sin backend", y además apunta a correr en escritorio vía Tauri. Pedido explícito del usuario: «lo podemos poner a la misma bandeja del webviewer, solo que tenemos que asegurarnos de saber cuál es cuál». Se reusa todo lo de la D39 (schema viewer, identidad por token-hash, rate-limit, Turnstile, webhook, admin) en vez de levantar un segundo backend.

    • Columna app TEXT NOT NULL DEFAULT 'webviewer' CHECK (app IN ('webviewer','process')), no un campo dentro de context. El JSONB ya guarda el contexto automático, pero filtrar la bandeja por producto es una operación de triage de primera clase: quiere índice (idx_feedback_app) y quiere CHECK, no una clave opcional enterrada que cada consulta tenga que recordar. El DEFAULT deja correctas las filas anteriores a la migración sin backfill.
    • La migración 0002_app.sql es idempotente porque TIENE que serlo. server/plugins/migrate-feedback.ts corre su SQL en cada arranque del contenedor (no hay migration-runner): ADD COLUMN IF NOT EXISTS salta la cláusula entera si la columna existe, y con ella el CHECK, que si no se duplicaría en cada reinicio. El SQL vive dos veces — en db/viewer_migrations/0002_app.sql y embebido en el plugin — porque el contenedor de producción solo empaqueta .output/; mantenerlos en sync es manual, como ya avisaba el comentario de la D39.
    • app es obligatorio en el tipo, no estampado en el camino de envío. El primer intento lo inyectaba en el actor de XState ({ ...submission, app: 'webviewer' }) para que ningún camino lo olvidara; se descartó porque con zFeedbackApp.default('webviewer') el tipo de salida de Zod ya lo hace obligatorio y el compilador es la garantía — inyectarlo además en el actor solo serviría para pisar en silencio lo que mandara otro cliente. El .default() sigue ahí por el lado servidor: un cliente anterior a esta decisión que no mande el campo sigue validando y cae a 'webviewer'. Efecto lateral útil: el cambio destapó un literal de respaldo en machines/feedback.ts que no era una FeedbackSubmission válida y degradaba el tipo a una unión, dejando .profile/.roleOther fuera del narrowing.
    • Tres capas más de identificación, todas dentro de context (no columnas). platform ('web' | 'tauri' — Process corre en los dos y saber cuál cambia el triage), buildId, y un clientId por envío que permite deduplicar a ojo si la cola offline de Process reintenta un POST que sí había llegado. Nada de esto se filtra ni se indexa, así que no merece columna.
    • CORS nuevo, acotado a /api/feedback** y con allowlist explícita. Hasta ahora el repo no tenía ninguna cabecera CORS — verificado contra producción: GET /api/health con Origin ajeno responde 200 sin ninguna access-control-*, y OPTIONS /api/feedback responde 404 porque la ruta solo tiene index.post.ts. Como x-feedback-token no es una cabecera "simple", el navegador dispara preflight y sin esto el envío desde Process nunca sale. Se resuelve en middleware (corre antes del enrutado, así que contesta el OPTIONS sin añadir handlers por ruta) y no se extiende a /api/admin/**, que se autentica con cookie: abrirlo a otros orígenes sería un agujero. Sin access-control-allow-credentials (la identidad va en cabecera propia, no en cookie) y nunca *. La política pura vive en server/utils/cors.ts para poder testearla sin montar h3.
    • Límite aceptado: la identidad no cruza orígenes. El token vive en localStorage, que es por origen, así que la misma persona en el webviewer, en Process web y en Process/Tauri son tres filas distintas de feedback_users sin relación. No se arregla sin cuentas reales, y no afecta al triage (cada mensaje trae su perfil) — pero no se pueden contar usuarios únicos sumando filas de feedback_users. Efecto lateral bueno: la clave del rate-limit es ${ip}:${token}, así que los límites de las dos apps no se cruzan.
    • Descartes. (a) Worker + D1 propio en lamula-process: duplicaba DAL, migraciones, auth y admin, y partía la bandeja en dos — el usuario pidió explícitamente lo contrario. (b) Pages Functions dentro del propio Process: rompe su build estático y mete la UI de admin dentro de la app del meteorólogo. (c) Solo context.app sin columna: ver el primer punto. El coste que sí se compra con esta decisión es acoplar el feedback de Process al uptime de este Swarm; se acepta porque el envío de Process falla suave (cola local y reintento), no pierde el mensaje.
  12. lamula-process envía feedback sin Turnstile: se exime por app y queda defendido solo por el rate-limit. D50 asumía que Process montaría el mismo widget que el webviewer. Decisión del usuario al llegar a la fase de UI: «nada de dependencias en el desktop, vamos a confiar en el rate-limit por token/ip». Process es una app offline-first que apunta a escritorio vía Tauri; cargar el script de challenges.cloudflare.com al abrir el diálogo sería su única dependencia de red externa, y en un puesto sin salida a internet general el captcha no resolvería nunca — el usuario vería el formulario colgado, no un fallo claro.

    • La exención es por lista en server/utils/feedback-policy.ts (CAPTCHA_EXEMPT_APPS), no un flag en el cuerpo. El cliente dice de qué app viene; qué implica eso lo decide el servidor. requiresCaptcha(app) es puro y testeable sin h3, igual que cors.ts.
    • Riesgo asumido y explícito: cualquiera puede mandar app: 'process' y entrar sin captcha. No hay forma de evitarlo sin un secreto en el cliente, que en una SPA estática no es un secreto. Se acepta porque el daño techo es spam en una bandeja interna, no acceso a datos.
    • Por eso el camino sin captcha gana un cubo de rate-limit POR IP (noCaptcha:${ip}, 30/h), además del de ${ip}:${token}. El cubo por token no frena nada cuando no hay captcha: el token lo acuña el propio cliente, así que a un script le basta renovarlo para estrenar cubo. El de por IP es la única barrera real. 30/h deja holgura a un instituto entero detrás de un NAT (el feedback es raro) y a la vez acota un flood scriptado.
    • resolveClientIp cambia de criterio: cf-connecting-ip, si no el ÚLTIMO elemento de x-forwarded-for. El código anterior usaba la cabecera x-forwarded-for entera como clave. XFF es una lista que los proxys van appendeando, así que su primer elemento lo controla quien llama: bastaba variarlo para estrenar cubo en cada envío. Era una debilidad preexistente e inocua mientras el captcha era la defensa de verdad; con D51 el rate-limit pasa a ser la única, así que había que arreglarla antes. Cloudflare sí sobrescribe cf-connecting-ip, y el último elemento de la cadena XFF es el que añadió el proxy más cercano a nosotros.
    • Supuesto que hay que mantener cierto: el contenedor solo es alcanzable a través del proxy. Si alguien pudiera llegar directo al servicio en el Swarm, ambas cabeceras serían falseables y el cubo por IP dejaría de valer.
    • El webviewer no cambia: es público y sigue exigiendo Turnstile. La exención no se extiende a /api/admin/** ni a ningún otro camino.
    • Descartes. (a) Turnstile en Process: lo que el usuario rechazó, por la dependencia de red. (b) Secreto compartido en el cliente de Process: no es secreto en una SPA estática; daría falsa seguridad. (c) Allowlist de IPs del INSMET/IDEAM: las direcciones no son estables y dejaría fuera a quien trabaje desde casa. (d) Dejar el camino sin captcha con el límite por token y ya: sería un rate-limit decorativo, ver arriba.
  13. i18n es+en con el locale en la URL: prefix_except_default con es por defecto, y los deep links que ya circulan no cambian. F5 arranca con la plomería; la migración de los 24 componentes va por rebanadas detrás. La forma del catálogo y la estrategia de ruta se fijan acá porque condicionan todo lo que viene.

    • prefix_except_default con defaultLocale: 'es', no prefix ni no_prefix. Un deep link del viewer (/BYX/153/20260711T030818?layers=cells,wind&panel=cells) es contrato de facto desde D23: la gente lo pega en chat y lo guarda. prefix (todo con prefijo, /es/...) habría roto cada URL ya compartida; no_prefix (idioma solo en cookie) habría hecho que el mismo enlace se abriera en idiomas distintos según el navegador del receptor, que es justo lo que D23 rechaza. Con esta estrategia el español queda byte a byte igual que antes y el inglés vive bajo /en/....
    • La colisión /en vs /[site] no se resuelve con suerte, se vigila. La ruta del viewer es /[site]/[product]/[[time]] y validate() ya exige ^[A-Z0-9]{3}$ para el sitio, así que un locale de 2 letras no puede capturarla. Aun así el riesgo real no es hoy sino el día que entre un sitio o dominio de 2 caracteres: tests/unit/i18n-routes.spec.ts comprueba contra las fixtures grabadas que ningún site_id ni domain_id coincide con un código de locale. Si algún día falla, la salida es prefix o un prefijo propio (/l/en/...), no retocar el regex.
    • detectBrowserLanguage solo en / (redirectOn: 'root'). La autodetección es para quien llega a la portada sin más; un enlace compartido debe abrirse en el idioma que dice su URL, no en el del navegador de quien lo recibe. Cookie propia lamula_i18n, sin alwaysRedirect.
    • El idioma NO entra en lamula:prefs. El resto de preferencias de display (unidades, reloj, frames) viven en localStorage porque no son compartibles; el idioma sí lo es y ya lo posee la URL. Duplicarlo daría dos fuentes de verdad que se desincronizan en cuanto alguien abra un /en/... ajeno. El selector vive en PrefsDialog por ergonomía, pero llama a setLocale() (navega) en vez de emitir un patch de prefs.
    • fallbackLocale: 'es' en producción + paridad de claves exigida en CI. El fallback evita que una traducción olvidada imprima la clave cruda al usuario, pero esconde el agujero; por eso scripts/check-i18n.mjs (paso propio de CI) rompe el build si en.json y es.json no tienen exactamente el mismo conjunto de hojas. El sitio donde eso debe fallar es CI, no la pantalla del meteorólogo.
    • Lo que el script todavía NO cubre: strings crudos en .vue. La puerta de F5 pide "ningún string hardcodeado"; esa auditoría se añade al cerrar la última rebanada, porque hoy fallaría por diseño (23 de 24 componentes sin migrar).
    • Los módulos puros siguen sin saber de i18n. utils/export/chrome.ts ya recibe sus lines[] del llamador, así que el export se traduce sin que utils/export/ importe nada de Vue — misma frontera que impuso D40. shared/products/defs/*.ts conserva su name en español como dato del catálogo: pasará a clave de traducción con ese name de respaldo, para que un producto nuevo sin traducir muestre su nombre y no products.XXX.name.
    • baseUrl por env de ARRANQUE (NUXT_PUBLIC_I18N_BASE_URL), nunca hardcodeado ni leído en nuxt.config.ts. Sin él, useLocaleHead omite los hreflang alternos y avisa en cada SSR; con él salen canonical y las 5 alternas correctas. El dominio de producción es https://webviewer.ladetec.com (confirmado 2026-10-12); el valor vive en el entorno del servicio del Swarm, no en el repo. Un process.env dentro de nuxt.config.ts NO vale: ese fichero se evalúa en build, así que hornearía el valor de la máquina que construye la imagen. El módulo publica la opción en runtimeConfig.public.i18n.baseUrl, que es lo que esa variable sobreescribe al arrancar el contenedor.
    • Los tests de componente montan el catálogo REAL. tests/setup/i18n.ts expone un useI18n() global construido con createI18n sobre i18n/locales/es.json, no un stub que devuelve la clave: así un componente que pida una clave inexistente falla en unit, no en producción. Solo setLocale es espía — navega, y la navegación no es asunto de un test de componente.
  14. El catálogo i18n se parte por namespace, un fichero por dominio, y los módulos puros guardan su español como respaldo. Al migrar los 24 componentes de F5 aparecieron dos problemas que la plomería de D52 no cubría.

    • Un es.json monolítico no sirve. Con ~330 claves, un solo fichero por idioma es irrevisable en un diff y serializa el trabajo: dos cambios simultáneos chocan siempre en el mismo fichero. Los catálogos pasan a i18n/locales/<code>/<namespace>.json, cada uno con su clave raíz dentro (prefs.json contiene {"prefs": {…}}) porque el módulo los funde en la raíz; LOCALE_FILES en nuxt.config.ts es la lista única y scripts/check-i18n.mjs exige las mismas claves, los mismos ficheros y esa clave raíz en cada uno. Efecto práctico: la migración se repartió en seis tandas que no se pisaban.
    • shared/ y utils/ no pueden llamar a useI18n, así que su texto en español pasa a ser RESPALDO, no fuente. Tres casos: los nombres de producto (shared/products/defs/*.ts), el catálogo de VCP (shared/vcp/catalog.ts, 12 entradas con prosa adaptada de NOAA JetStream) y las etiquetas de mapa base / nivel de viento. El patrón es siempre el mismo: una clave products.<mnemonic>.name / vcp.catalog.<code>.<campo>, y en el componente te(key) ? t(key) : <el texto del catálogo> (composables/useProductLabel.ts). Un producto o un VCP nuevo en el feed muestra su nombre en español, nunca la clave cruda. Se descartó mover esos catálogos a los JSON de i18n: dejarían de ser importables desde node sin Vue, que es justo lo que los hace testeables.
    • utils/freshness.ts deja de devolver texto. formatFreshness() componía "hace 3 h"; ahora freshnessParts() devuelve { unit, value } y FreshnessBadge.vue pluraliza con vue-i18n. El índice de plural es el valor absoluto: con el reloj del cliente adelantado los minutos salen negativos y vue-i18n elegiría la forma equivocada. Se conservó el Math.floor sobre negativos del original (−90 min da −2 h, no −1): cambiar eso sería otro cambio, no una traducción.
    • Las etiquetas de los enums de feedback se traducen; los valores no. FEEDBACK_ROLES/KINDS/STATUSES tienen valores en español (meteorologo, mejora, leido) que se pintaban crudos en los <select> y en la bandeja. Ahora el value del <option> sigue siendo el valor del contrato — lo que viaja al servidor y a la base no cambia — y solo el texto pasa por feedback.roles.<valor> / admin.statuses.<valor>. Traducir el valor habría roto el CHECK de la tabla y el espejo del contrato en lamula-process.
    • La auditoría de strings crudos es un script aparte (scripts/check-i18n-raw.mjs), heurístico y con escape. Recorre el <template> de cada .vue de components/ y pages/ buscando texto suelto con acentos o palabras funcionales del español y atributos de UI estáticos (aria-label, title, placeholder, alt). Los comentarios HTML se blanquean conservando los saltos de línea — están llenos de prosa en español a propósito — y una línea se silencia con i18n-ignore. No es un parser: se eligió heurística con escape antes que @intlify/eslint-plugin-vue-i18n para no añadir otra dependencia de lint por una comprobación que corre en segundos.
    • Formato de hora: pendiente, y es i18n de verdad. TimelineStrip.vue formatea con Intl.DateTimeFormat('en-US', …) fijo, así que en español la hora sale como 3:08 PM en vez de 24 h. No se tocó en F5 porque no es un string traducible sino una decisión de producto: o el formato sigue al locale, o se añade una preferencia 12/24 h independiente del idioma (lo que hace Windy). Queda para el pulido de F5.