Máquinas de estado de la interfaz¶
Todo el estado de la UI se modela con XState v5 (decisión 18). Esta página es la referencia viva de cada máquina: diagrama, eventos y efectos. Convención: el commit que toca una máquina actualiza su diagrama aquí — si el diagrama y machines/ divergen, es un bug de revisión.
Principios:
- La URL manda. La ruta (
/{site}/{product}/{time}+?opacity&base&sat&satVar&satOp) es la fuente de verdad de lo compartible. Los cambios de ruta —incluido back/forward del navegador— entran a la máquina como eventoROUTE_CHANGED; las transiciones que cambian la selección navegan como efecto (push/replace). La máquina nunca contradice la barra de direcciones. - Máquinas puras. Viven en
machines/, sin tocar DOM ni router: los efectos (navegación, localStorage, fetch) se inyectan como input/actores, así los unit tests corren concreateActorsin montar nada. - Estado efímero también aquí. Opacidad, cursor o progreso de buffer no viajan en la URL, pero sí viven en el contexto de la máquina (eventos
SET_OPACITY,CURSOR_MOVE, …).
Inventario¶
| Máquina | Fichero | Responsabilidad | Estado |
|---|---|---|---|
viewerMachine |
machines/viewer.ts |
Raíz de la página viewer: selección, carga del raster, timeline, prefs, toggles de overlays | implementada |
animationMachine |
machines/animation.ts |
Playback: buffering, play/pause, reloj, dwell | implementada |
frameMachine |
machines/frame.ts |
Ciclo de vida de un frame del pool (pending → ready/failed) | implementada |
overlayMachine |
machines/overlay.ts |
Fenómenos + VWP: índices del día, join temporal, serie de celda, perfiles | implementada |
viewerMachine¶
Raíz de la página pages/[site]/[product]/[[time]].vue, type: 'parallel' con
dos regiones concurrentes: raster (el frame mostrado) y timeline
(los rasters del día). Cada región deriva su estado inicial de un fetch hecho
en SSR (initialRaster/initialError y initialTimes/initialTimelineError)
vía su propio pseudo-estado init — sin trabajo async, el snapshot pre-start
es idéntico en servidor y cliente (hidratación segura; el actor arranca en
onMounted).
Nota de implementación (XState v5): en una máquina paralela, un on a
nivel raíz queda ensombrecido en cuanto cualquier región define su propio
on para el mismo evento — el evento se da por manejado en esa región y no
burbujea al padre. Por eso ROUTE_CHANGED se maneja dentro de cada
región, no en la raíz; assignRoute/persistPrefs corren en la región
raster (declarada primero, se ejecuta antes que timeline en el mismo
micropaso, así timeline ya lee el contexto actualizado). Confirmado con un
test canario antes de fiarse de este comportamiento.
stateDiagram-v2
state "viewerMachine" as VM {
state "raster" as R {
[*] --> r_init
r_init --> r_shown: initialRaster ≠ null
r_init --> r_empty: closest SSR dio 404
r_init --> r_error: fallo del closest SSR
r_loading --> r_shown: fetchClosest → raster
r_loading --> r_empty: fetchClosest → 404
r_loading --> r_error: fetchClosest falla
r_shown --> r_loading: ROUTE_CHANGED ¬sameFrame
r_empty --> r_loading: ROUTE_CHANGED ¬sameFrame
r_error --> r_loading: ROUTE_CHANGED ¬sameFrame
r_loading --> r_loading: ROUTE_CHANGED ¬sameFrame (cancela el fetch en vuelo)
r_shown --> r_steppingNext: STEP +1 (sin vecino local)
r_shown --> r_steppingPrev: STEP -1 (sin vecino local)
r_steppingNext --> r_shown: fetchStep → raster (navigate replace) / 404 (atEnd)
r_steppingPrev --> r_shown: fetchStep → raster (navigate replace) / 404 (atStart)
}
--
state "timeline" as T {
[*] --> t_init
t_init --> t_ready: initialTimes.length > 0
t_init --> t_empty: initialTimes vacío
t_init --> t_error: fallo del /api/rasters/day SSR
t_loading --> t_ready: fetchDay → times
t_loading --> t_empty: fetchDay → []
t_loading --> t_error: fetchDay falla
t_ready --> t_loading: ROUTE_CHANGED ¬sameDay
t_empty --> t_loading: ROUTE_CHANGED ¬sameDay
t_ready --> t_jumping: SELECT_DAY ¬sameDaySelected
t_empty --> t_jumping: SELECT_DAY ¬sameDaySelected
t_jumping --> t_ready: fetchDay → times (+ navigate push al último vol_time)
t_jumping --> t_empty: fetchDay → [] (sin frame al que saltar, la URL no cambia)
t_ready --> t_refreshingTick: after LIVE_REFRESH_INTERVAL, liveRefresh=true
t_ready --> t_ready: after LIVE_REFRESH_INTERVAL, liveRefresh=false (reenter, sigue polleando)
t_refreshingTick --> t_ready: fetchDay → times (replace al último SI cambió)
t_refreshingTick --> t_empty: fetchDay → []
T --> t_refreshingTick: SET_LIVE_REFRESH(true) (salto inmediato, no espera el after)
}
}
Contexto: radars, products, site, product, time (ISO naive; null = vista live), nowT (instante SSR), raster, rasterError, day (YYYY-MM-DD que la región timeline tiene cargado/objetivo), times, timelineError, liveRefresh (checkbox "en vivo" de la barra de tiempo — default true; ver fila SET_LIVE_REFRESH abajo), atStart/atEnd (404 ya confirmado en esa dirección — deshabilita el botón), opacity, base, sat/satVariant/satOpacity (capa de fondo GOES — NOAA nowcoast WMS, ?sat&satVar&satOp; shareable como layers/panel/cell pero nunca persistida en lamula:prefs, a diferencia de opacity/base), cursor, cogError, y las preferencias de display coverage/units/clock/smooth/smoothRadius (smooth: suavizado de la capa raster estática, decisión 32; smoothRadius: 1/2/4/8, radio de ese suavizado — decisión 33, sin efecto si smooth es false — los iniciales son placeholders SSR deterministas — clock:'utc' aunque el default real de la pref sea 'local', porque el server no conoce la zona del navegador; los valores reales entran por PREFS_LOADED tras montar).
| Evento | Región | Efecto |
|---|---|---|
ROUTE_CHANGED |
raster |
guard sameFrame (site+product+time sin cambios reales) → asigna contexto + persistPrefs; si el time solicitado existe en context.times (fast-path) → asigna contexto y el raster directamente desde la timeline sin hacer fetch; si no → .loading (reentrar cancela el fetch en vuelo, resetea atStart/atEnd) + persistPrefs |
ROUTE_CHANGED |
timeline |
guard sameDay (site+product+día sin cambios — cubre stepping dentro del día) → nada; si no → .loading con el day derivado del nuevo time |
STEP(dir) |
raster |
vecino local en context.times → navigate replace directo (sin roundtrip); si no hay vecino y esa dirección ya está confirmada (atStart/atEnd) → no-op; si no → .steppingNext/.steppingPrev (llama /api/rasters/{next,prev}: éxito navega replace, 404 marca atStart/atEnd y se queda en el frame actual, sin error visible) |
SELECT_TIME(time) |
— | click/drag en la timeline → navigate replace directo + apaga liveRefresh (tocar la barra a mano es la señal de "quiero ver este instante fijo", el loop no debe pisarlo) |
SELECT_DAY |
timeline |
guard sameDaySelected → nada; si no → .jumping: al resolver, si el día tiene datos, salta (push) al último vol_time; si no, se queda en empty sin tocar la URL (nada a lo que saltar). No toca liveRefresh (gesto de navegación distinto de "tocar la barra") |
SET_LIVE_REFRESH(value) |
timeline (checkbox) |
asigna liveRefresh. true por defecto y se restaura solo al cambiar de site/product (assignRoute, sesión nueva); se apaga también al arrancar una animación (engageAnimation, página) además de por SELECT_TIME. Mientras el estado timeline está en ready, un after: LIVE_REFRESH_INTERVAL (30 s, cachea con el max-age de /api/rasters/day) se reprograma solo (reenter: true) y, si liveRefresh es true, entra a refreshingTick: repite fetchDay del mismo día (sin el guard "ya activo" de SELECT_DAY — ese es justo el caso de uso) y, si el más reciente difiere del time mostrado (no del último times cacheado — cubre también reactivar el checkbox tras scrubbear hacia atrás sin dato nuevo), salta ahí con replace; si ya coincide, no navega. A diferencia del viejo botón manual, siempre sigue al más reciente mientras el checkbox esté encendido — no conserva posición intermedia (si el usuario quería fijar un instante, ya apagó el checkbox al tocar la barra). value=true además dispara target: '.refreshingTick' de una — sombrea el on de la raíz para esta región (nota XState v5 de arriba) para no depender del próximo after (hasta 30 s de retraso si no) |
MOUNTED |
— | guard (time null + raster resuelto) → efecto navigate replace al vol_time (la URL siempre contiene el frame exacto) |
SELECT_SITE / SELECT_PRODUCT |
— | efecto navigate push — la máquina no refetchea aquí; el refetch llega por ROUTE_CHANGED en cada región (URL manda) |
SET_OPACITY |
— | asigna contexto + persistPrefs + syncQuery (query ?opacity con replace debounced 300 ms, omitida si es el default 0.8) |
SELECT_BASE(base) |
— | asigna base (catálogo shared/basemaps.ts: osm default, variantes CARTO en par *_nolabels + capa de nombres en zIndex 18 — sobre raster/cobertura/viento, bajo fenómenos; off solo entra por query, goldens/e2e) + persistPrefs + syncQuery (?base, omitida si es osm) — mismo doble efecto que SET_OPACITY |
TOGGLE_SATELLITE / SELECT_SAT_VARIANT / SET_SAT_OPACITY |
— | asignan sat/satVariant/satOpacity + syncQuery (mismo replace debounced 300 ms que opacity/base, ahora con ?sat&satVar&satOp — sin persistPrefs: la capa de satélite es estado compartible, no una pref personal). RadarMap.vue oculta la capa mientras el pool de animación está activo (incl. en pausa) independientemente de sat |
CURSOR_MOVE / COG_ERROR |
— | asignan contexto |
TOGGLE_LAYER(layer) |
— | añade/quita la capa en context.layers + syncOverlayQuery (replace inmediato de ?layers — D23; sin debounce, es acción discreta). Incluye los toggles de grupo trackPast/trackFuture (trayectorias pasada/futura de TODAS las celdas, default-off como cells/meso). El cambio solo-query reentra por ROUTE_CHANGED y cae en sameFrame: el raster no reparpadea |
SELECT_PANEL(panel\|null) |
— | asigna context.panel + syncOverlayQuery (?panel) |
SELECT_CELL(cellId\|null) |
— | asigna context.cell (+ si el panel está cerrado, lo abre en trend — el gesto pide ver esa celda) + syncOverlayQuery (?cell). RadarMap.vue centra la vista (view.animate, sin cambio de zoom) sobre el marker de la celda seleccionada |
SELECT_WIND_LEVEL(level) |
— | asigna context.windLevel + syncOverlayQuery (?windLevel, omitido si es el default 10m — mismo patrón shareable que layers/panel/cell, nunca en lamula:prefs). Selector en el <fieldset> de viento, visible solo con la capa activa (0005_wind_levels.sql, fase 2 — spec jul-2026) |
TOGGLE_CELL_TRACK(cellId, kind: 'past'\|'future') |
— | override individual de trayectoria: añade/quita cellId en context.pastCells/futureCells (independiente del toggle de grupo) + syncOverlayQuery (?pastCells/?futureCells, CSV de cell ids). Visibilidad efectiva en phenomena-layer.ts = toggle de grupo OR override individual — checkbox por fila en CellTable.vue, deshabilitado si la celda no tiene puntos de esa trayectoria |
PREFS_LOADED(prefs) |
— | asigna coverage/units/clock/smooth/smoothRadius leídos de localStorage (post-mount). Nunca persiste — sería write-on-read |
SET_PREF(patch) |
— | asigna el patch + persistPrefs(patch) (diálogo de preferencias; efecto en vivo, sin Guardar/Cancelar) |
Corrección de URL generalizada: cuando fetchClosest resuelve, si el vol_time devuelto difiere del time pedido (vista live, o cualquier instante que no coincide con un vol_time real — p.ej. el SELECT_DAY pide "fin de día" implícitamente vía /api/rasters/day y salta al último real), se hace replace/push al vol_time exacto. La URL nunca muestra un instante distinto del frame realmente exhibido (puerta M3).
Assign optimista de context.time: toda acción que navega con un time (STEP, SELECT_TIME, MOUNTED, la corrección de mismatch, el éxito de steppingNext/Prev/jumping) asigna context.time en el mismo paso, no solo el efecto navigate. router.replace()/push() resuelve async y el watcher de ruta de la página reacciona un tick después (evento ROUTE_CHANGED); sin el assign optimista, un segundo evento disparado de inmediato (doble click, tecla repetida) leería context.time desactualizado y calcularía el siguiente salto desde la posición vieja. Reproducido con un e2e de stepping rápido por teclado antes de aplicar el fix — cuando ROUTE_CHANGED finalmente llega, assignRoute reconfirma el mismo valor (no-op).
Prefs (lamula:prefs en localStorage, nunca el time ni el day): toda navegación (ROUTE_CHANGED) y todo cambio de opacidad disparan persistPrefs con {site, product, opacity, base} — así / siempre redirige a la última selección real, no a un valor mudo. Desde v2 el shape suma las preferencias de display coverage/units/clock; desde v3 suma smooth (decisión 32); desde v4 suma smoothRadius (decisión 33). persistPrefs acepta patches parciales (savePrefs mergea sobre lo guardado con PREF_DEFAULTS de fallback) y un v1/v2/v3 válido migra en memoria al leer (se materializa como v4 en el primer write). composables/useViewerPrefs.ts sigue validando versión/shape (corrupto o v desconocida → null, no rompe la redirección). Las prefs de display viven en el contexto de viewerMachine con eventos raíz — sin máquina aparte (no tienen ciclo de vida propio; el criterio D27 no aplica) — y jamás en la URL (D28).
Dependencias inyectadas (.provide() en la página; mocks en tests): actores fetchClosest ($fetch a /api/rasters/closest, 404 → null), fetchDay ($fetch a /api/rasters/day) y fetchStep ($fetch a /api/rasters/{next,prev}, 404 → null — solo se llama al agotar los vecinos locales de context.times, es decir al cruzar el día); acciones navigate (router push/replace conservando query, composables/useViewerRoute.ts), persistPrefs (savePrefs) y syncQuery (replace debounced de ?opacity&base&sat&satVar&satOp, ambas en la página).
Day picker (components/DayPicker.vue): botones de día UTC sobre la ventana de 72h anclada a radar.last_seen_at (utils/time-window.ts::dayWindow72h, decisión 11) — no wall-clock, así un radar muerto sigue mostrando sus días con datos y las fixtures no se pudren. Click → evento SELECT_DAY.
Timeline strip (components/TimelineStrip.vue): strip proporcional al rango [times[0], times.at(-1)] del día cargado; un tick por vol_time (click → SELECT_TIME), huecos marcados cuando el intervalo excede max(2×mediana, 10 min) (utils/timeline/gaps.ts::computeGaps — con menos de 3 times no hay señal para una mediana, no se marcan huecos), botones prev/next (→ STEP) deshabilitados según atStart/atEnd. Teclado: ←/→ en window disparan STEP, ignorados con foco en input/select/textarea.
overlayMachine¶
Overlays de fenómenos + panel VWP (F4, decisiones 24/27) + capa de viento
GFS (región wind) + capa de rayos GLM (región lightning). Separada de
viewerMachine (patrón animationMachine): el vol_time efectivo durante la
animación vive en la página (times[activeFrameIndex]), no en viewerMachine.
La página la orquesta con watchers — SET_SCOPE (site/día), SET_TIME (frame
mostrado, animación o estático), SET_ACTIVE (toggles parseados de la URL),
SELECT_CELL (celda de ?cell). Arranca con todo idle y sin fetch en
SSR y cliente por igual (la activación llega por eventos tras el mount) — sin
mismatch de hidratación.
type: 'parallel', seis regiones. Todos los eventos se manejan a nivel de
región (ninguno en la raíz — la lección del ensombrecido de viewerMachine
aplicada por diseño): varias regiones pueden manejar el mismo evento porque en
XState v5 los eventos se difunden a todas las regiones activas. Dos sutilezas
de esa difusión, aprovechadas aquí: (1) los guards de todas las regiones se
evalúan contra el snapshot previo al micropaso — por eso los guards de
SET_ACTIVE leen el payload del evento y no context.layers/panel (el assign
corre en index); (2) las acciones sí se ejecutan en orden de documento
sobre el contexto ya actualizado — por eso el entry de vwp.sync puede leer
el volTime que frame asignó en el mismo micropaso.
stateDiagram-v2
state "overlayMachine" as OM {
state "index — índices del día (fetchTimes)" as I {
[*] --> i_idle
i_idle --> i_loading: SET_ACTIVE con algo activo e índice sin cargar
i_loading --> i_ready: phen+vwp times (raise INDEX_READY)
i_loading --> i_error: fetchTimes falla
i_ready --> i_deciding: SET_SCOPE (limpia todo)
i_deciding --> i_loading: algo sigue activo
i_deciding --> i_idle: nada activo
}
--
state "frame — fenómenos del frame mostrado" as F {
[*] --> f_idle
f_idle --> f_join: SET_TIME / SET_ACTIVE / INDEX_READY (activo + índice cargado)
f_join --> f_noData: joined = null (nada ≤ tolerancia)
f_join --> f_resolved: cache hit por vol_time
f_join --> f_fetching: volumen casado sin cache
f_fetching --> f_resolved: fetchPhenomena → filas (cachea)
f_fetching --> f_error: fetchPhenomena falla
f_resolved --> f_shown: filas > 0
f_resolved --> f_noData: volumen sin fenómenos (joined presente)
f_shown --> f_join: SET_TIME (reentra, last-wins)
f_noData --> f_join: SET_TIME
f_shown --> f_idle: SET_ACTIVE sin capas ni panel de celdas / SET_SCOPE
}
--
state "series — tendencia por cell_id" as S {
[*] --> s_idle
s_idle --> s_loading: SELECT_CELL(id)
s_loading --> s_shown: fetchSeries → serie
s_loading --> s_error: fetchSeries falla
s_shown --> s_idle: SELECT_CELL(null) / SET_SCOPE
s_shown --> s_loading: SELECT_CELL(otro id)
}
--
state "vwp — perfiles del día (solo panel=vwp)" as V {
[*] --> v_idle
v_idle --> v_sync: SET_ACTIVE panel=vwp / INDEX_READY / SET_TIME
v_sync --> v_empty: sin perfiles hasta el frame
v_sync --> v_shown: ventana completa en cache
v_sync --> v_loading: faltan perfiles
v_loading --> v_shown: fetchVwp (batch de los que faltan)
v_loading --> v_error: fetchVwp falla
v_shown --> v_sync: SET_TIME (recalcula ventana/joined)
v_shown --> v_idle: SET_ACTIVE panel≠vwp / SET_SCOPE
}
--
state "wind — grilla de viento GFS (capa 'wind')" as W {
[*] --> w_idle
w_idle --> w_loadingIndex: SET_ACTIVE off→on con índice sin cargar
w_idle --> w_join: SET_ACTIVE off→on con índice cargado
w_loadingIndex --> w_join: fetchWindTimes → índice del día ±2 h
w_loadingIndex --> w_error: fetchWindTimes falla
w_join --> w_noData: windJoined = null (nada ≤ 1 h)
w_join --> w_shown: cache hit por r2_key
w_join --> w_fetching: valid_time casado sin cache
w_fetching --> w_shown: fetchWindGrid → JSON u/v (cachea)
w_fetching --> w_error: fetchWindGrid falla
w_shown --> w_join: SET_TIME (re-join, casi siempre mismo ciclo)
w_noData --> w_join: SET_TIME
w_shown --> w_idle: SET_ACTIVE sin 'wind' (limpia grid)
w_shown --> w_deciding: SET_SCOPE
w_deciding --> w_loadingIndex: 'wind' sigue activo
w_deciding --> w_idle: inactivo
w_shown --> w_loadingIndex: SET_ACTIVE con windLevel distinto (invalida índice/cache del nivel anterior)
w_noData --> w_loadingIndex: SET_ACTIVE con windLevel distinto
}
--
state "lightning — cubos de rayos GLM (capa 'lightning')" as L {
[*] --> l_idle
l_idle --> l_loadingIndex: SET_ACTIVE off→on con índice sin cargar
l_idle --> l_join: SET_ACTIVE off→on con índice cargado
l_loadingIndex --> l_join: fetchLightningTimes → cubos del día ±900 s
l_loadingIndex --> l_error: fetchLightningTimes falla
l_join --> l_noData: ningún cubo con strikes toca la ventana
l_join --> l_shown: todos los cubos de la ventana en cache
l_join --> l_fetching: faltan ficheros de cubo
l_fetching --> l_shown: fetchLightningBuckets (batch, cachea)
l_fetching --> l_error: fetchLightningBuckets falla
l_shown --> l_join: SET_TIME (nueva ventana)
l_noData --> l_join: SET_TIME
l_shown --> l_idle: SET_ACTIVE sin 'lightning' (limpia strikes)
l_shown --> l_deciding: SET_SCOPE
l_deciding --> l_loadingIndex: 'lightning' sigue activo
l_deciding --> l_idle: inactivo
}
}
Contexto: site, day, volTime (frame mostrado; SET_SCOPE lo anula —
el de un scope anterior no vale, la página reemite SET_TIME), layers,
panel, cellId, phenTimes/vwpTimes (índices; null = sin cargar),
joined (vol_time de fenómenos casado; null con estado noData distingue
"nada en tolerancia" de "volumen sin fenómenos" — phenomena: []),
phenomena, phenCache (inmutable por vol_time → cache sin invalidación),
series, vwpWindow (≤12 columnas del día hasta el frame), vwpJoined,
vwpProfiles (cache por vol_time), windLevel (nivel de altura elegido —
'10m' | '850hPa' | '700hPa' | '500hPa', default '10m' — 0005_wind_levels.sql,
fase 2; solo 10m ingerido en producción por ahora), windTimes (índice
WindGridMeta[] del día ±2 h del nivel vigente; null = sin cargar),
windJoined (valid_time casado a ≤1 h), windGrid (JSON u/v del frame),
windCache (por r2_key — el ciclo y el nivel van en la key, inmutable de
verdad), prevVolTime (frame anterior del timeline —
define la ventana de observación de rayos; lo asigna frame junto a
volTime), lightningBuckets (índice LightningBucketMeta[] del día
±900 s; null = sin cargar), lightningWindow (ventana (prev, vol] en
epoch ms), lightningStrikes (strikes normalizados a progreso 0–1; null
= capa limpia, [] = ventana cubierta sin descargas), lightningCache
(ficheros de cubo por r2_key, inmutables), errores por región.
| Evento | Regiones que lo manejan | Efecto |
|---|---|---|
SET_SCOPE(site, day) |
todas | index asigna scope y limpia caches/índices/serie/volTime (única región que asigna); las demás vuelven a idle; index.deciding/wind.deciding/lightning.deciding recargan si lo suyo sigue activo |
SET_TIME(volTime, prevVolTime?) |
frame, vwp, wind, lightning |
frame asigna volTime y prevVolTime y, si está activo con índice cargado, reentra .join (last-wins: reentrar cancela el fetch en vuelo); vwp recalcula ventana/joined si el panel está abierto; wind re-joinea con tolerancia de 1 h; lightning recalcula la ventana de observación |
SET_ACTIVE(layers, panel, windLevel) |
index, frame, vwp, wind, lightning |
index asigna toggles y carga índices si hacen falta; frame/vwp/wind/lightning activan o vuelven a idle (guards sobre el payload del evento, ver arriba). 'wind' y 'lightning' NO cuentan para needsPhenomena (PHENOMENA_LAYERS): activar solo viento/rayos no fetchea fenómenos y viceversa. wind tiene un guard extra: windLevel distinto del vigente reinicia índice/cache y recarga sin importar si la capa ya estaba activa (dropdown) u off→on — un solo evento cubre ambos casos porque la página emite SET_ACTIVE completo en cada cambio de layers/panel/windLevel |
SELECT_CELL(cellId\|null) |
series |
carga la serie cross-volumen o la limpia |
INDEX_READY (interno, raise) |
frame, vwp |
resuelve lo que quedó pendiente mientras cargaba el índice (wind/lightning no lo necesitan: su índice es propio) |
Join temporal (D24): joined = nearestWithin(phenTimes, volTime, 600 s)
(utils/overlay/join.ts; empate → anterior, la regla de pickClosest). Fuera
de tolerancia el overlay se limpia (noData) — nunca celdas de otro momento
presentadas como actuales. El viento usa la misma función con
WIND_JOIN_TOLERANCE_S = 3600 (valid_times horarios GFS); por eso su índice
se consulta con el día ±2 h (WIND_DAY_PAD_S) — un frame pegado a
medianoche debe poder casar con el valid_time del día vecino.
Join por ventana (rayos): a diferencia del resto, los rayos de un frame
no son "el volumen más cercano" sino los caídos durante su intervalo de
observación (prevVolTime, volTime]
(utils/overlay/lightning-join.ts::observationWindow; sin prevVolTime, o
con un hueco del feed > 600 s, la ventana se recorta a 600 s — comprimir
1 h de rayos en el bucle de 5 s los presentaría como una tormenta irreal).
bucketsInWindow selecciona los cubos de 300 s que la solapan (los de
strike_count 0 ni se fetchean) y strikesInWindow filtra y normaliza
cada descarga a progreso 0–1 dentro de la ventana — el progresoReal que
consume el bucle visual (utils/lightning/anim.ts). El índice se consulta
con el día ±900 s (LIGHTNING_DAY_PAD_S: ventana máxima + un cubo).
Congelado durante playback: mientras la animación reproduce, SET_TIME
no se emite por frame — la página (pages/[site]/[product]/[[time]].vue)
frena el watcher si animPlaying, así que fenómenos/VWP quedan clavados en
el vol_time que tenían al arrancar el play; solo el raster anima. Al pausar
(o detenerse la animación), tras OVERLAY_RESUME_DELAY_MS (3 s, debounce
para no disparar si el usuario alterna play/pause rápido) se emite un
SET_TIME único con el vol_time del frame que quedó visible, y de ahí en
más el comportamiento es el mismo que fuera de animación (cada paso de
frame resincroniza al toque). Antes de esto, SET_TIME se emitía en cada
tick y se apoyaba solo en el cache de phenCache/vwpProfiles para no
pegarle a la red — pero el cálculo del join corría igual en cada frame;
ahora directamente no corre.
Dependencias inyectadas: actores fetchTimes (Promise.all de
/api/phenomena/times + /api/vwp/times), fetchPhenomena
(/api/phenomena), fetchSeries (/api/phenomena/series), fetchVwp
(batch de /api/vwp para los vol_times sin cache), fetchWindTimes
(/api/wind/times, con level del windLevel vigente), fetchWindGrid (JSON u/v con fetch directo a R2
vía meta.wind_url — como los COGs — validado con zWindGridFile),
fetchLightningTimes (/api/lightning/times) y fetchLightningBuckets
(batch de ficheros de cubo con fetch directo a R2 vía
meta.lightning_url, validados con zLightningBucketFile).
frameMachine¶
Ciclo de vida de UN frame del pool de animación, spawneado por animationMachine (uno por índice, no hay estado duplicado fuera del árbol de actores). El driver real (utils/map/frame-pool.ts) es quien decide cuándo un frame está listo — detecta renderComplete de su capa WebGL y llama FRAME_READY/FRAME_FAILED en la máquina padre, que reenvía READY/FAILED al hijo correspondiente.
stateDiagram-v2
[*] --> pending
pending --> ready: READY
pending --> failed: FAILED
ready --> pending: INVALIDATE
failed --> pending: INVALIDATE
INVALIDATE ya no lo envía nadie en producción — quedó del diseño previo a getCogBlob/cog-cache.ts (cuando el frame se armaba de tiles OL, sí inválidos bajo un extent nuevo). Desde que cada frame trae su COG entero como blob (fetch único, ver frame-pool.ts), pan/zoom no invalida nada real. animationMachine lo llegó a enviar en MOVE_END, pero como invalidateInactive() solo oculta capas (no refetchea ni vuelve a llamar onFrameReady), esos hijos quedaban en pending para siempre y la animación se congelaba al reanudar — bug real, fix: MOVE_END ya no invalida, solo pausa.
animationMachine¶
Playback de la serie del día: idle → buffering → paused ⇄ playing. Pura — no toca OL ni DOM; el pool real (utils/map/frame-pool.ts) decide cuándo un frame está listo, esta máquina decide qué mostrar y cuándo avanzar. SET_FRAMES spawnea un frameMachine hijo por índice (ids únicos por generación — evita colisión al reemplazar la serie de un día por la de otro).
stateDiagram-v2
[*] --> idle
idle --> buffering: SET_FRAMES
buffering --> buffering: SET_FRAMES (nueva generación)
buffering --> paused: frame(index) ready
buffering --> paused: todos resueltos (ready/failed) — salta a uno ready si hay, si no se queda igual
paused --> playing: PLAY / TOGGLE
playing --> playing: FRAME_DELAY (avanza si el siguiente está ready; hold si pending; salta si failed)
playing --> paused: PAUSE / TOGGLE
paused --> paused: MOVE_END (no-op, ya pausado)
playing --> paused: MOVE_END (pausa; ya no invalida frames)
Contexto: frames (refs a frameMachine hijos), gen (generación, para ids únicos al respawnear), index (frame mostrado), fps, lastFrameDwellMs.
| Evento | Efecto |
|---|---|
SET_FRAMES(count, startIndex?) |
detiene los hijos de la generación anterior, spawnea count nuevos, index = clamp(startIndex ?? 0) → .buffering. startIndex es obligatorio en la práctica: sin él, el buffer siempre esperaría el frame 0 aunque el viewer ya estuviera mostrando otro — bug real encontrado en el primer e2e de animación (ver más abajo) |
FRAME_READY(i) / FRAME_FAILED(i, msg) |
reenvía READY/FAILED al frameMachine hijo i — válido en cualquier estado |
PLAY / TOGGLE (en paused) |
→ playing |
PAUSE / TOGGLE (en playing) |
→ paused |
SEEK(i) |
asigna index (clamped); manejado en buffering/paused/playing |
MOVE_END |
→ paused (no sigue animando durante pan/zoom); ya no invalida frames — el pool solo oculta capas inactivas, los blobs siguen siendo válidos bajo cualquier extent |
SPEED(fps) |
asigna fps — válido en cualquier estado (selector .5x/1x/2x/3x en AnimationControls.vue, 1x = ANIM_BASE_FPS ya afinado antes de esto, el resto escala sobre esa base). En playing además hace reenter sobre el mismo estado para recalcular FRAME_DELAY de inmediato — el after en curso no se actualiza solo, así que sin el reenter el cambio de velocidad tardaría hasta un frame completo (con la velocidad vieja) en notarse |
Salida de buffering sin bloqueo permanente: el caso feliz es "el frame objetivo (context.index) está ready". Si ese frame específico falla (404 real, no un hueco transitorio), esperar solo por él colgaría la UI para siempre — por eso hay una segunda condición: en cuanto todos los hijos terminan de resolver (ninguno sigue pending), se sale igual, saltando a un índice ready si existe alguno; si absolutamente todos fallaron, se sale sin más (nada que mostrar, degradación vía rasterError del pool, no un buffering infinito).
Avance en playing (nextPlayableIndex): un frame failed (hueco real) se salta de forma transparente; uno pending frena el avance (se reintenta el próximo tick, no se salta — una descarga lenta no se confunde con un hueco permanente). El delay entre frames (FRAME_DELAY) es 1000/fps, salvo en el último índice de la serie, donde usa lastFrameDwellMs antes de reiniciar el ciclo.
Bug real encontrado con e2e (no solo unitario): el primer intento de SET_FRAMES reseteaba index a 0 incondicionalmente; la página mandaba un SEEK aparte para corregirlo, pero buffering no manejaba SEEK en ese momento (evento ignorado silenciosamente) — el buffer esperaba el frame equivocado durante ~1 s hasta que el fallback de "todos resueltos" lo rescataba. Fix: SET_FRAMES acepta startIndex y fija el índice correcto en el mismo paso (además, buffering ahora sí maneja SEEK por robustez). Ilustra por qué la puerta de animación necesita e2e real, no solo tests de la máquina en aislamiento — el bug era de integración (dos eventos separados con una ventana de estado inválida en medio), invisible probando la máquina con un solo evento a la vez.
Orquestación en la página (pages/[site]/[product]/[[time]].vue): modo estático (F2, RadarMap con :raster) hasta que el usuario presiona play por primera vez (animationEngaged); a partir de ahí RadarMap pasa a modo pool (:frames) y lo mantiene aun en pausa (el scrubbing reutiliza el mismo pool, sin destruir/recrear capas). Mientras la animación está pausada, context.time de viewerMachine y context.index de animationMachine se mantienen sincronizados en ambas direcciones (stepping externo → SEEK; PLAY/TOGGLE al pausar → SELECT_TIME con replace) — decisión F3: durante playback la URL no se toca, solo al pausar.
Pool de capas WebGL (utils/map/frame-pool.ts)¶
No es una máquina XState — es el driver imperativo que habla con OpenLayers y alimenta FRAME_READY/FRAME_FAILED. Verificado contra node_modules/ol 10.9.0:
- Una
WebGLTileLayer+GeoTIFFpor frame (no una sola capa consetSource(): eso dispone las texturas cacheadas y produce parpadeo). visible:falseno carga tiles;opacity:0sí — el prefetch en segundo plano es una capavisible:true, opacity:0; al quedar lista y no ser la activa, se oculta (visible:false, cero costo de render).- N capas con el mismo
className/zIndex contiguo comparten un solo contexto WebGL — sin límite práctico de contextos, y ayuda en CI (SwiftShader pierde contexto con concurrencia). - "Frame listo" =
layer.getRenderer().renderComplete, muestreado en elpostrenderdel mapa. Es una propiedad semi-pública del renderer (no forma parte de la API documentada de ol) — protegida por el canariotests/unit/render-complete-canary.spec.ts; si un upgrade deolla renombra o la quita, ese test falla antes que el pool deje de avanzar en silencio. Plan B si se rompe:rendercompletesecuencial del mapa (más lento, 100 % API pública). - Prefetch acotado a
PREFETCH_CONCURRENCY = 3concurrentes; tope de seguridadMAX_POOL = 24frames (memoria) — 20 frames típicos caben sin evicción. invalidateInactive()(llamado enmoveenddel mapa): el frame activo se conserva; el resto solo se oculta (setVisible(false)) para no pagar su render bajo el nuevo extent — elstatede la entry no cambia, no hay refetch (el blob ya es válido para cualquier extent).
Limitación conocida de los goldens/fixtures para e2e: solo el vol_time más reciente de cada (site, product) tiene un COG golden commiteado (tests/fixtures/cogs/r2/) — el resto 404 en el entorno offline de CI. Los e2e de animación (e2e/animation.spec.ts) validan que la máquina no se cuelga y el ciclado es correcto ante fallos reales, pero no pueden validar el ciclado fluido de múltiples frames reales cargando a la vez; eso es la puerta manual contra datos vivos (docs/validaciones.md).