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 |
annotationMachine |
machines/annotation.ts |
Anotaciones del usuario sobre el mapa: modo de dibujo, herramienta, colección por sitio | implementada |
feedbackMachine |
machines/feedback.ts |
Feedback de usuarios (D39): identidad/nudge, diálogo, envío, hilo de respuestas | implementada |
mosaicViewerMachine |
machines/mosaic-viewer.ts |
Raíz de pages/mosaic/[domain]/[product]/[[time]].vue (D43/D45): hermana reducida de viewerMachine, indexada por dominio+slot_time; región cells para celdas multi-radar (P5) |
implementada (sin animación — ver nota abajo) |
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 la mantiene visible durante la reproducción (decisión 33) — solo sat decide si se muestra |
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.
mosaicViewerMachine¶
Raíz de pages/mosaic/[domain]/[product]/[[time]].vue (D43/P4, D45/P5): mismo
patrón type: 'parallel' de viewerMachine (URL manda, sameFrame/sameDay
evitan refetch, assign optimista de time), pero indexada por
domain+product+slot_time contra /api/mosaic/* en vez de
site+product+vol_time. Tres regiones: raster/timeline (diagrama
idéntico en forma al de viewerMachine arriba, sustituir vol_time por
slot_time y site por domain — no se repite acá) y cells (P5, propia,
ver abajo).
Alcance recortado a propósito (no es una omisión): sin VWP, sin meso/
trayectorias, sin anotaciones/export/feedback. Satélite, viento y rayos SÍ
están desde D49: el satélite es un WMS global (nada que unir) y viento/rayos
se resuelven uniendo los N sitios del dominio (ver regiones abajo). Celdas de tormenta SÍ
llegan (P5, región cells), porque ahí la pregunta ("¿qué radares ven
tormentas ahora?") es del dominio, no de un radar. Sin animación
todavía: el pool de frames (utils/map/frame-pool.ts, genérico desde
D43 sobre CogFrame para aceptar tanto RasterMeta como MosaicRasterMeta)
asume una única proyección compartida por toda la ventana de frames, pero
la geometría del mosaico es por fila (añadir un radar recalcula la
malla del dominio) — animar una ventana que cruza ese recálculo
reproyectaría mal los frames viejos. MosaicMap.vue solo renderiza el modo
estático; TimelineStrip.vue gana un prop showPlay (default true, sin
tocar el llamador existente) para ocultar el botón play/pause acá en vez de
dejarlo inerte.
Contexto: domain, product, time (ISO naive; null = vista live),
nowT, raster: MosaicRasterMeta | null, rasterError, day, times:
MosaicRasterMeta[], timelineError, liveRefresh, atStart/atEnd,
opacity, base, coverage (máscara de cobertura — default true,
shareable vía ?coverage=0; no es una pref persistida acá, ver D48),
cogError, cells: Phenomenon[], cellsError, selectedCell (SITE:ID o
null), cellsKey (P5, ver abajo), layers: MosaicLayerId[] (D48/D49 —
'cells' | 'wind' | 'lightning', ?layers=, default vacío), sat/
satVariant/satOpacity (D49, ?sat=1&satVariant&satOpacity), windLevel
(?windLevel=), windGrid/windError/windKey y lightningStrikes/
lightningError/lightningKey y las prefs de display que
la vista comparte con el viewer de un solo radar vía lamula:prefs:
units, clock, animationFrames (tamaño de la ventana que dibuja la
strip), showPalette.
| Evento | Región | Efecto |
|---|---|---|
ROUTE_CHANGED |
raster |
igual que viewerMachine, sustituyendo vol_time→slot_time |
ROUTE_CHANGED |
timeline |
igual que viewerMachine |
ROUTE_CHANGED |
cells |
refetch si la capa cells viene encendida en el payload y route.time (o nowT) difiere de cellsKey. El guard lee event.route.layers, no el contexto: en la difusión a regiones paralelas los guards ven el snapshot previo (assignRoute corre en la región raster, que va primero) |
STEP(dir) |
raster |
igual que viewerMachine |
SELECT_TIME(time) / SELECT_DAY / SET_LIVE_REFRESH |
— / timeline |
igual que viewerMachine |
MOUNTED |
— | guard (time null + raster resuelto) → navigate replace al slot_time |
CELLS_INIT |
cells |
dispara el primer fetchCells — enviado por la página en onMounted, nunca en SSR; guard: solo con la capa cells encendida (D48) |
TOGGLE_LAYER(layer) |
layers + la región de la capa |
la región layers hace el assign de layers y el syncLayersQuery (?layers=, replace inmediato); cells/wind/lightning solo deciden su → loading. El evento se difunde a TODAS las regiones que lo declaran: por eso el assign vive en una región propia (si no, correría una vez por región) y los guards leen el PAYLOAD + contexto previo. Apagar NO tira el dato del contexto: reencender en el mismo slot no refetchea |
TOGGLE_SAT / SELECT_SAT_VARIANT / SET_SAT_OPACITY |
— | satélite GOES: puro display, sin fetch — assign + syncQuery (debounced, junto a opacity/base/coverage) |
SELECT_WIND_LEVEL(level) |
wind |
assign del nivel + syncLayersQuery; con la capa encendida además → loading. Vive en la región wind (no en layers) para que el assign corra antes del input del invoke |
PREFS_LOADED(prefs) |
— | asigna las prefs leídas de lamula:prefs post-mount; no persiste (sería write-on-read) |
SET_PREF(patch) |
— | asigna + persistPrefs del patch exacto (mismo contrato que viewerMachine) |
SELECT_DOMAIN / SELECT_PRODUCT |
— | efecto navigate push |
SET_OPACITY / SELECT_BASE |
— | asignan + syncQuery (?opacity&base&coverage, igual patrón de replace debounced 300 ms que viewerMachine, sin persistPrefs) |
TOGGLE_COVERAGE |
— | invierte context.coverage + syncQuery — controla la máscara de MosaicMap.vue (CoverageMaskLayer/coverageCenterLayer), no un fetch: la cobertura siempre se calcula, esto solo oculta el dibujo. En la UI se toca desde PrefsDialog (D48), no desde un checkbox propio |
SELECT_CELL(cellId) |
— | asigna selectedCell + syncCellQuery (?cell=SITE:ID, replace inmediato, sin debounce — igual criterio que SELECT_CELL en overlayMachine, D23: un click no debe sentirse con latencia de slider) |
COG_ERROR |
— | asigna contexto |
Celdas multi-radar (P5, D45): región cells propia. fetchCells (provisto
por la página, sin endpoint nuevo) resuelve, para cada site_id del dominio,
el vol_time de fenómenos más cercano al slot_time mostrado
(nearestWithin, mismo util que overlayMachine) y concatena las filas de
/api/phenomena de cada sitio — sin deduplicar: dos radares que vean la
misma tormenta aparecen dos veces, con su propio cell_id (el ID del RPG es
local al sitio, no global). Un sitio que falla o no tiene volumen en
tolerancia simplemente no aporta celdas — mismo espíritu que "un radar caído
no apaga el mosaico" (D43). Capa apagable y apagada por defecto (D48):
con ?layers=cells ausente no se dispara el fan-out de /api/phenomena por
sitio — ni en CELLS_INIT ni al cambiar de slot. Identidad compuesta SITE:ID
(utils/overlay/mosaic-cells.ts::mosaicCellId) para no colisionar entre
sitios; el marker es el MISMO de phenomena-layer.ts (cellIcon,
circunferencia navy + X, radio por dbz_max) y lo único propio es el color
del label SITE:ID (siteColor, ciclado por posición en
MosaicDomain.site_ids, no por hash) — D48. Sin SSR: el primer fetch lo dispara CELLS_INIT desde
onMounted (nunca en servidor), evitando un fetch servidor+cliente
duplicado — el dominio no cambia dentro de la vida de un actor (la página
remonta entera por key: route => route.params.domain), así que el refetch
por ROUTE_CHANGED solo mira si cambió el slot_time.
Viento y rayos del dominio (D49): regiones wind y lightning, mismo
patrón que cells — índice por SITIO (/api/wind/times, /api/lightning/times
por cada site_id del dominio), join en cliente y unión del resultado. El
viento une las N grillas u/v con mergeWindGrids (utils/wind/grid.ts: bbox
unión, paso el más fino, gana la primera fuente que cubra la celda, huecos en
NaN); los rayos concatenan los cubos que solapan la ventana (slot anterior,
slot] y deduplican con dedupeStrikes (dos radares vecinos reciben la
misma descarga del feed GLM). El slot anterior sale de context.times, no de
restar 300 s: un hueco del feed no debe inventar un intervalo observado.
Claves de cache: windKey = ${slot}|${level}, lightningKey = ${prev}|${slot}.
Gotcha de XState v5 encontrado acá (canario: el test "ROUTE_CHANGED a
otro slot_time SÍ refetchea celdas" de mosaic-viewer-machine.spec.ts falla
si se reintroduce): una transición de cells a un estado distinto del
activo (p. ej. ready/error → loading) con reenter: true explícito,
en una máquina type: 'parallel' donde otra región sibling también
maneja el mismo evento, se descarta en silencio — ni se re-invoca el actor
ni se re-ejecutan las acciones, sin error ni warning. Reproducido aislado
(dos regiones paralelas mínimas, sin guards de por medio). reenter solo
hace falta para forzar reentrada en una transición al mismo estado
(self-transition); acá el target ya era distinto del origen, así que ya era
una transición externa — quitar reenter: true lo arregla y además es el
uso correcto.
Overlay de cobertura (la pieza central de P4): raster.contributing
(MosaicContribution[], ya parseado por el DAL) da quién aportó al slot
mostrado; MosaicDomain.site_ids menos esos sites da los ausentes.
utils/map/coverage-mask-layer.ts (D46) oscurece el mapa entero y "puncha"
transparente el círculo de cada radar que SÍ aportó (circular() +
reproyección a 3857 — un círculo esférico centrado en el radar, no uno plano
en 3857 que a 460 km de radio ya se ve elíptico); coverage-rings-layer.ts
queda solo para el marcador+label de los contribuyentes.
MosaicDomainChip.vue repite el mismo dato en UNA línea de texto (D48):
todos los sitios del dominio, los que aportan en color de acento y los
ausentes en gris. Radio único por
dominio (MosaicDomainRow.radius_m): el contrato no trae un alcance
por-radar, así que los N anillos salen del mismo tamaño — aproximación
visual, no el alcance físico exacto de cada antena.
Proyección por fila: utils/map/projection.ts::registerDomainProjection
registra por el contenido del proj4 de la fila (hash FNV-1a), no por
domain_id — dos filas del mismo dominio pueden traer geometría distinta
si el dominio se recalculó entre medias, y una clave por domain_id
pisaría la definición vieja con la nueva bajo el mismo código ya en uso.
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. Solo llega desde un gesto real (decisión 41): RadarMap.vue filtra el moveend de OL por origen (pointerdown/wheel/keydown en el viewport), así que un moveend disparado por el propio render interno de OL — p.ej. al reconstruir el pool — ya no pausa la animación recién arrancada |
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.
annotationMachine¶
Anotaciones que el usuario dibuja sobre el mapa (F7.2): flecha, círculo, trazo libre y rótulo. Dos estados nada más — el modo de dibujo está encendido o apagado —, y toda la colección vive en el contexto.
stateDiagram-v2
[*] --> off
off --> drawing: ENABLE
drawing --> off: DISABLE
drawing --> drawing: SET_TOOL / ADD
note right of off
En la raíz (válidos en los dos estados):
SITE_CHANGED, LOADED, SET_COLOR, SET_TEXT,
UPDATE, REMOVE, UNDO, CLEAR
end note
Contexto: site (radar mostrado), tool, color, pendingText (rótulo del próximo punto con la herramienta de texto), bySite (Record<site_id, Annotation[]>).
| Evento | Efecto |
|---|---|
ENABLE / DISABLE |
entra/sale del modo dibujo. La página traduce drawing a la prop annotationTool de RadarMap, que crea o destruye las interacciones Draw/Modify de OL |
SET_TOOL(tool) |
solo en drawing — un Draw no cambia de tipo de geometría en caliente, así que RadarMap lo recrea entero |
ADD(annotation) |
solo en drawing: añade al sitio actual y persiste |
UPDATE(annotation) |
reemplaza por id sin reordenar (llega del modifyend de OL) y persiste |
REMOVE(id) / UNDO / CLEAR |
borran y persisten. Válidos con el modo apagado: corregir no obliga a volver a entrar a dibujar |
SITE_CHANGED(site) |
cambia de colección. Las anotaciones son por sitio: un trazo sobre una tormenta de BYX no pinta en KTLX |
LOADED(bySite) |
rehidratación desde localStorage tras montar (SSR no tiene localStorage). No persiste: escribir lo que se acaba de leer no aporta nada |
Persistencia inyectada. La acción persist la provee la página (saveAnnotations, clave lamula:annotations), igual que syncQuery en viewerMachine: la máquina sigue pura y los tests corren con createActor sin DOM.
Fuera de la URL — excepción a la decisión 23, documentada en la 40: lo compartible vive en la URL, pero un trazo a mano alzada son cientos de puntos y reventaría su longitud. Las coordenadas se guardan en metros EPSG:3857 (la proyección de la vista), no en píxeles: así la anotación queda clavada a la geografía y sobrevive a pan/zoom, a exportar con otro pixelRatio y a la secuencia de frames de F7.3.
feedbackMachine¶
Feedback de usuarios (F-aparte, decisión 39): identidad por token opaco, nudge ocasional, diálogo de envío/hilo. type: 'parallel' con cuatro regiones — identity, dialog, form, thread — no tres como anticipaba el plan original (docs/plan-feedback.md): el diálogo abierto/cerrado se separó en su propia región en vez de vivir dentro de form, y no hay región nudge aparte — la política de snooze/shownCount vive dentro de identity (evento NUDGE_ACTION), orquestada por utils/feedback/nudge.ts desde la página.
stateDiagram-v2
state identity {
[*] --> ready
ready --> ready: SAVE_PROFILE / NUDGE_ACTION
}
state dialog {
[*] --> closed
closed --> open: OPEN_DIALOG
open --> closed: CLOSE_DIALOG
open --> open: SWITCH_TAB
}
state form {
[*] --> idle
idle --> submitting: SUBMIT_FEEDBACK
submitting --> idle: onDone
submitting --> error: onError
error --> submitting: SUBMIT_FEEDBACK
}
state thread {
[*] --> idle
idle --> loading: FETCH_MINE / SUBMIT_FEEDBACK
loading --> ready: onDone
loading --> error: onError
ready --> loading: FETCH_MINE / SUBMIT_FEEDBACK
ready --> ready: MARK_READ
error --> loading: FETCH_MINE / SUBMIT_FEEDBACK
}
Contexto: identity (FeedbackStorageV1 — token, perfil, estado del nudge), sessionStartTimeMs, dialogOpen, activeTab ('submit' | 'mine'), submissionError, items (hilos con respuestas), unreadCount.
| Evento | Efecto |
|---|---|
OPEN_DIALOG / CLOSE_DIALOG / SWITCH_TAB |
región dialog — solo UI, nada de red |
SUBMIT_FEEDBACK |
región form: invoca submitFeedbackActor (POST /api/feedback con x-feedback-token); al terminar fusiona el perfil enviado en identity y marca nudge.submitted = true (el pill sigue, el nudge no vuelve a aparecer). La misma transición dispara thread a loading para refrescar el hilo con el envío nuevo |
FETCH_MINE |
región thread: invoca fetchMineActor (GET /api/feedback/mine); calcula unreadCount contando replies con readAt nulo |
MARK_READ |
región thread: limpia unreadCount de forma optimista y dispara sendMarkRead (POST /api/feedback/mine/read), fire-and-forget |
SAVE_PROFILE |
región identity: fusiona el perfil (precarga del formulario desde localStorage) |
NUDGE_ACTION('open'\|'later'\|'never') |
región identity: 'open' también abre el diálogo (dialogOpen: true); 'later'/'never' fijan nudge.snoozeUntil vía calcSnoozeUntil (utils/feedback/nudge.ts, reloj inyectado — tests deterministas) |
Identidad y persistencia inyectadas. loadFeedbackIdentity/saveFeedbackIdentity (composables/useFeedbackIdentity.ts) leen/escriben lamula:feedback en localStorage — clave propia, fuera de lamula:prefs (no es una preferencia de display) y fuera de la URL (no es estado compartible, mismo criterio que D28 para las prefs).
No va a la URL. Ningún evento se maneja en la raíz de la máquina (mismo patrón que overlayMachine, decisión 27): la máquina vive orquestada por la página, con ciclo de vida propio que no debe reiniciarse con el del raster o la animación.
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).