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): partículas de un ciclo fijo mientras los frames barren horas serían un sinsentido; al pausar vuelve con el grid del frame en reposo (cache lo hace instantáneo).
    • 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 (mismo contrato animPlaying que viento/satélite): 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. 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.
  34. 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.
  35. 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. Mitigada en e2e/animation.spec.ts (wait más largo antes de interactuar) pero no eliminada; fix de raíz (guard de gracia en MOVE_END tras engageAnimation()) queda pendiente, fuera de alcance de este cambio de layout.
    • 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.
  36. 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-80md: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).
  37. 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.

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

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.