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¶
-
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). -
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. -
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.
-
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. -
Paletas versionadas en este repo, no en la base. No existe (a propósito) tabla
paletteen D1. Módulo TS porproduct_codecon 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 porcode; D1productssolo aporta code/mnemonic/unit/kind. -
Radar-agnóstico, proyección dinámica. Ningún radar ni endpoint hardcodeado.
radars.proj4se registra tal cual (proj4.defs+register); el catálogo, el switch de radares y la frescura ("minutos desde último scan") salen de la tablaradars. -
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.
-
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 porcell_id(estable entre volúmenes) consultandophenomenacross-volumen. Las claves deattrsporkindse documentan como parte del contrato. -
VWP sin componente vertical. D1 trae dir/speed/rms/height por nivel; u/v se derivan en cliente,
wno existe en el contrato. Barbas y tabla completas; si el canvas legado usaba w, esa serie se omite. -
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.
-
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.
-
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.
-
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.
-
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.
-
i18n:
@nuxtjs/i18n, es + en, locale en URL, catálogo extensible. -
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.
-
Solo lectura, por disciplina. D1 no tiene roles: el contrato es que este proyecto solo ejecuta
SELECTy las migraciones viven endb/del pipeline. Cualquier cambio de schema es una migración negociada allí. -
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 enmachines/(puras, testeables concreateActorsin DOM); los componentes las consumen víauseSelector. 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. -
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 conreplacealvol_timeresuelto en cuanto se conoce — la URL nunca queda apuntando a un instante que no sea exactamente el frame mostrado. -
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 3vol_timeno hay señal suficiente para una mediana — no se computan huecos. -
Animación: pool de una
WebGLTileLayerpor frame, no una sola capa consetSource(). Verificado contraol10.9.0:setSource()dispone las texturas cacheadas de la capa (parpadeo en cada frame); N capas con el mismoclassName/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 convisible:true, opacity:0(sí carga tiles, no se ve) y swap instantáneo cuando ya está en GPU. "Frame listo" se lee delayer.getRenderer().renderComplete(propiedad semi-pública del renderer, no de la API documentada deol) — protegido por un test canario (tests/unit/render-complete-canary.spec.ts) que debe fallar primero si un upgrade deolla renombra o la quita; el plan B documentado es volver arendercompletesecuencial del mapa (más lento, 100% API pública). -
F4 recortado: dBZ máx en lugar de VIL/top/granizo por celda. Acordado con el pipeline (jul-2026,
db/README.mdde 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 pordbz_max(faltantes al final — el GAB de NST pagina de a 6 celdas), la tendencia graficadbz_max+dbz_max_height_kft, y la señal TVS es la columnatvsdel NMD (no hay markers de granizo). Si esas claves aterrizan algún día,shared/contract/attrs.tssuma claves opcionales sin migración ni cambio estructural. VIL y echo top sí existen como productos raster de grilla (DVL, EET). -
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 deviewerMachine(TOGGLE_LAYER/SELECT_PANEL/SELECT_CELL) con assign optimista + acción inyectablesyncOverlayQuery(replace inmediato, sin el debounce de?opacity— son acciones discretas, no un slider). Un cambio solo-query reentra porROUTE_CHANGEDy cae en el guardsameFrame: el raster no reparpadea. -
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 nuevosGET /api/{phenomena,vwp}/times?site&day(SELECT DISTINCT vol_time, cubierto poridx_*_lookup); el cliente casa el frame mostrado connearestWithin(times, t, 600 s)(empate → anterior, la regla depickClosest) 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) yclosest?t(untarbitrario por frame = cache pobre + roundtrip por frame de animación). -
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 (pastreciente→viejo,movement_degconvenció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 cambianutils/overlay/tracks.tsy su test. -
Panel derecho colapsable con rail de tabs (Celdas / Tendencia / VWP). Segundo
asidea 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). -
overlayMachineseparada, no una tercera región deviewerMachine. PatrónanimationMachine(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 deviewerMachine; (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. EnoverlayMachineningú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. -
Preferencias de usuario (alcance del radar / unidades / hora) en
lamula:prefsv2, 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
viewerMachinecon 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) ypersistPrefsya 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, yshowModal()da top-layer/focus-trap/Esc gratis. Reversible si las preferencias crecen. - Migración v1→v2 de
lamula:prefsen memoria al leer (rellena defaults, conserva lo guardado); shape inválido o versión desconocida degradan anullcomo siempre.
- Contexto de
-
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-windlleva ~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
windenoverlayMachinecon índice propio (/api/wind/times, día ±2 h) +nearestWithincon tolerancia 1 h (3 h dejaría viento de otra masa de aire como actual) + cache porr2_key(el ciclo va en la key ⇒ inmutable).'wind'entra enOVERLAY_LAYERS(?layers=wind— toda la plomería URL sale gratis) pero NO enPHENOMENA_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 entests/contract/proposed/— NO entests/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).
-
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(osmdefault + variantes CARTOvoyager/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.
basereutiliza la plomería existente (query?base+lamula:prefs+ contexto deviewerMachine): solo se amplió la unión ('osm' | 'off'→ catálogo) y se añadióSELECT_BASE(patrónSET_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 condevicePixelRatioal crear la fuente (OL no expande ese token). - Sin migración de prefs: v2 sigue válida, el validador acepta el catálogo ampliado.
-
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_count0 conr2_keyNULL 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], nonearestWithin(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_TIMEganaprevVolTimeopcional. - Región
lightningenoverlayMachinecon índice propio (/api/lightning/times, día ±900 s) + cache porr2_key+ fetch batch de cubos.'lightning'entra enOVERLAY_LAYERS(?layers=lightninggratis) pero NO enPHENOMENA_LAYERS. - Canvas 2D custom, no
VectorLayer+ style function: 500+ features con estilo reevaluado por frame a 60 fps genera basura de objetosStyley pelea con el declutter de fenómenos; el patrónWindParticleLayer(rAF víalayer.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
animPlayingque 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ó su0004_lightning_buckets.sql(2026-07-19, snapshot entests/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.
-
Suavizado de la capa raster, opcional y solo estático — excepción explícita a la decisión 4 — vía
interpolatede OL, nopalette. 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:truedirecto sobre el nivel crudo delGeoTIFF, con el estilo existente (rasterStyle, operadorpalette) — descartado: OpenLayers fija filtradoNEARESTen 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 comool/source/DataTilepremultiplicado) — funcionaba pero exigía un pipeline CPU/canvas propio por raster (segundoreadRasters(), 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-extSVGFilter+Laplaciansobre una capa Canvas2D (SVGFilternecesita un context 2D, no aplica aWebGLTileLayer— suWebGLRenderingContextno 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
paletteporinterpolate(lerp de color nativo del lenguaje de expresiones de OL) en el estilo (interpolatedPaletteStyle,utils/map/raster-style.ts), junto coninterpolate:trueen la fuenteGeoTIFF. 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 usandogetData()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. - Togglesmoothenlamula:prefsv3, mismo mecanismo de la decisión 28 (contexto deviewerMachinecon eventos raízPREFS_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. -
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 desmarcasmooth(defaultfalse/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.tsdecodifica el COG congeotiff.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íaModelPixelScale+ModelTiepoint) — el blob resultante entra a la mismaol/source/GeoTIFFde 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/DataTilecontileGridmanual (1 solo tile, 1 sola resolución, anclado algetBoundingBox()del GeoTIFF): reproyectaba conReprojDataTilede OL pero sin la malla de resoluciones auxiliar queol/source/GeoTIFFsiempre agrega (suconfigure_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
Uint8Arraycrudo dereadRasters()tal cual al loader de laDataTilela sube como texturaUNSIGNED_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 quenormalize:falseenol/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 unArrayBuffer+DataViewpor píxel (herencia de un port de UTIF.js pensado para imágenes chicas). Además, un segundo defecto: si no se pasaProjectedCSTypeGeoKey/GeographicTypeGeoKey,writeGeotiff()pisa elModelTiepointprovisto 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/GeoTIFFrecibe laprojectionexplícita por opción del constructor y nunca los lee), escrito directo aDataViewcon un soloUint8Array.set()para los píxeles (sin asignación por elemento). Mismo resultado byte-exacto verificado por round-trip congeotiff.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
smoothactivo, el nivel que reportalayer.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 tratabalevel <= 0como nodata ylevel === 1como 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 elvalue_offsettí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_offsetde 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ámetroclampNonPositive): cuando el producto esdBZysmoothestá activo (RadarMap.vuedecide,unit === 'dBZ'), cualquier valor físico≤ 0se 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 mientrassmoothesté activo. Consmoothdesactivado 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
smoothactivo — 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:
smoothdesactivado (radio queda sin efecto) — vuelve al lookup discreto nivel→color derasterStyle/D32 sininterpolate. - 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
smoothRadiusenlamula:prefsv4 (1|2|4|8, default 1 = comportamiento idéntico a antes de esta decisión), mismo mecanismo quesmooth(D32/D28) — selector en el panel izquierdo, visible solo consmoothactivo, 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 extiendewind_gridscon niveles de presión (850hPa/700hPa/500hPa, terna "steering flow") además de la superficie10mya 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 soportanALTERde PK — la migración reconstruye la tabla y backfillealevel='10m'en las filas existentes; el snapshot de0003_wind_grids.sqlno se toca (drift check byte a byte), la migración nueva vive aparte en0005_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/timesgana el query paramlevel(ausente →10m, no rompe URLs viejas) y la query SQL agregaAND level = ?, sin necesidad de traer ni fusionar payloads de varios niveles. windLevelenoverlayMachine(contexto de la regiónwind, default10m): cambiar de nivel con la capa activa invalidawindTimes/windCache/windGridy recarga el índice — mismo costo que unSET_SCOPE, pero acotado a esa región (un solo guard enSET_ACTIVEcubre tanto "ya activa, cambia nivel" como "off→on con nivel distinto al último usado", porque la página siempre reemiteSET_ACTIVEcompleto —layers+panel+windLevel— ante cualquiera de los tres cambios).?windLevelen la URL (shareable, nunca enlamula:prefs— mismo criterio quelayers/panel/cell, decisión 23): omitido cuando es el default10m, así las URLs de antes de esta fase siguen intactas.- Rollout desacoplado del viewer: el pipeline solo ingiere
10mpor ahora; el selector ya expone los 4 niveles pero 850/700/500 hPa devuelven[]hasta que el pipeline habilite esas descargas — comportamiento esperado, documentado enpipeline-viento.mdpara 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.mjsahora escribelevel:'10m'en cada fila, sin generar los otros tres hasta que el pipeline los ingiera de verdad.
- Intento 1 —
-
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.vuegenérico (título, lista de tabs, slot portab.id,testidPrefix) extraído como la pieza reusable — mismo patrón<dialog>nativo sin librería quePrefsDialog/TimelineMenu(D26/D28), no PrimeVue.VwpPanel.vuese parte enVwpChart.vue(grid de barbas) +VwpTable.vue(tabla numérica), compuestos porVwpModal.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ónvwpdeoverlayMachine(que solo carga datos con el panel abierto) — el modal se abre/cierra con unwatchsobre ese mismo valor en la página, y su cierre (botón ✕ o Esc, evento nativoclosedel<dialog>) despachaSELECT_PANELconpanel:null. El deep-link?panel=vwpsigue abriendo el modal igual que abría el tab antes. SidePanelqueda con 2 tabs (Celdas/Tendencia); su<aside>ahora solo se muestra sipanelcorresponde a uno de esos dos — evita un panel vacío sictx.panelqueda en'vwp'mientras el modal está abierto.
-
Mapa a pantalla completa:
asideizquierdo + 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) yctx.layers/sendsiguen 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 clasesmd:de Tailwind (flyout anclado vs. overlayfixed inset-0); sin composable de media query, porque SSR +matchMediameten flash de hidratación y CSS puro no.DataModal.vuereemplazaSidePanel.vueYVwpModal.vue(extiende la decisión 35: ya compartían el mismoPanelId). Un soloTabModalcon 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.activepasa a ser unv-modelopcional (retrocompatible si nadie lo bindea) para poder cambiar de tab con el modal ya abierto sin pasar poropen()(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 unmoveendespurio (fin de su propio render interno, no un pan real), que dispara elMOVE_ENDglobal deanimationMachiney 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 ene2e/animation.spec.ts(wait más largo antes de interactuar) pero no eliminada; fix de raíz (guard de gracia enMOVE_ENDtrasengageAnimation()) 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.
- Cero cambios de máquina de estados.
-
DataModaldeja de ser<dialog>modal: pasa al mismo dock derecho deLayersMenu, 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.vuese borra (quedó sin otro consumidor tras esta migración) — su lógica de tabs se inlinea directo enDataModal.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 deLayersMenu(botón "Menú" incluido) comparten el rincón superior derecho con el botón cerrar deDataModal— con el dock de Datos abierto,LayersMenuoculta sus pills vía proppanelOpen(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 esv-if="panel"directo sobrectx.panel— más simple que el patrón<dialog>.showModal()/.close()que exigía sincronizar unwatch+onMountedpara el deep-link inicial. - e2e ajustados: los tests que cerraban el modal con
Escape(aprovechando el<dialog>nativo) ahora cierran con el botóndata-modal-closeexplícito — ya no hay eventoclosenativo 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
panelsin abrir).
-
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 Workernexrad-l3-opsdel 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.tscambia deD1Like(binding) aPgLike(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.
- El viewer se mueve al Swarm como contenedor Node (
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¶
- 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.
- Co-evolución del contrato: el schema D1 lo posee el pipeline. La extensión de
attrsaterrizó 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. - 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.
- ~~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.