tawasuyu

tawasuyu/nahual

Open-with viewers (text/image/audio/video/svg/font/hex/…) in Rust, on Llimphi.

Rust 49TOML 23Markdown 19JSON 11062 Definitions

feat: nahual standalone — visores open-with sobre Llimphi (front-door, git-dep al monorepo)

Stack de visores extraído como front-door limpio: solo los crates nahual-*
(shell open-with + ~14 visores: texto/imagen/audio/video/svg/mapa/fuente/hex/
tabla/markdown/archivo/card/gallery/árbol + cores + meta-runtime). Todo lo
fundacional (discern, decoders media, fuentes content-addressed, hojas shared)
y Llimphi se consumen por git-dep del monorepo gioser.git — cero vendoring.
cargo check --workspace pasa (0 errores).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

f63e78141d856d65edfc06c793a3e00a7eff7bfe · by Sergio · (4 months ago) · browse the tree at this commit

Semantic delta · 1062

02_ruway/nahual/libs/meta-runtime/src/csv.rs

02_ruway/nahual/libs/meta-runtime/src/lib.rs

02_ruway/nahual/libs/meta-schema/src/lib.rs

02_ruway/nahual/nahual-archive-viewer-llimphi/src/lib.rs

02_ruway/nahual/nahual-audio-viewer-llimphi/examples/audio_viewer_demo.rs

02_ruway/nahual/nahual-audio-viewer-llimphi/src/lib.rs

02_ruway/nahual/nahual-card-viewer-llimphi/src/lib.rs

02_ruway/nahual/nahual-file-explorer-llimphi/src/lib.rs

02_ruway/nahual/nahual-font-viewer-llimphi/src/lib.rs

02_ruway/nahual/nahual-gallery-llimphi/src/main.rs

02_ruway/nahual/nahual-geo-core/src/lib.rs

02_ruway/nahual/nahual-geo-core/src/pmtiles.rs

02_ruway/nahual/nahual-geo-core/src/vt.rs

02_ruway/nahual/nahual-hex-viewer-llimphi/src/lib.rs

02_ruway/nahual/nahual-image-viewer-llimphi/examples/image_viewer_demo.rs

02_ruway/nahual/nahual-image-viewer-llimphi/src/lib.rs

02_ruway/nahual/nahual-map-viewer-llimphi/examples/probar.rs

02_ruway/nahual/nahual-map-viewer-llimphi/src/lib.rs

02_ruway/nahual/nahual-markdown-viewer-llimphi/src/lib.rs

02_ruway/nahual/nahual-shell-llimphi/src/main.rs

02_ruway/nahual/nahual-source-core/src/lib.rs

02_ruway/nahual/nahual-source-core/src/minga.rs

02_ruway/nahual/nahual-source-core/src/navigator.rs

02_ruway/nahual/nahual-source-core/src/nouser.rs

02_ruway/nahual/nahual-source-core/src/posix.rs

02_ruway/nahual/nahual-source-core/src/wawa.rs

02_ruway/nahual/nahual-table-viewer-llimphi/src/lib.rs

02_ruway/nahual/nahual-text-viewer-llimphi/src/lib.rs

02_ruway/nahual/nahual-thumb-core/src/lib.rs

02_ruway/nahual/nahual-tree-viewer-llimphi/src/lib.rs

02_ruway/nahual/nahual-video-viewer-llimphi/examples/video_viewer_demo.rs

02_ruway/nahual/nahual-video-viewer-llimphi/src/lib.rs

02_ruway/nahual/nahual-viewer-core/src/archive.rs

02_ruway/nahual/nahual-viewer-core/src/card.rs

02_ruway/nahual/nahual-viewer-core/src/hex.rs

02_ruway/nahual/nahual-viewer-core/src/lib.rs

02_ruway/nahual/nahual-viewer-core/src/markdown.rs

02_ruway/nahual/nahual-viewer-core/src/table.rs

02_ruway/nahual/nahual-viewer-core/src/tree.rs

100 files · +27657 −0

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b7141ea
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+/target
+**/*.rs.bk
+*.pdb
diff --git a/02_ruway/nahual/ARQUITECTURA.md b/02_ruway/nahual/ARQUITECTURA.md
new file mode 100644
index 0000000..f712db8
--- /dev/null
+++ b/02_ruway/nahual/ARQUITECTURA.md
@@ -0,0 +1,149 @@
+# ARQUITECTURA.md — nahual
+
+> Descripción técnico-arquitectónica densa, optimizada para consumo por IA.
+> Snapshot: 2026-05-30. Fuente autoritativa cuando difiera con la prosa de los READMEs.
+
+```yaml
+DOMINIO: nahual
+CUADRANTE: 02_ruway (HACER)
+NOMBRE: nahuatl "espíritu acompañante"
+TESIS: meta-app "open-with universal" — abre cualquier dato heterogéneo despachando al visor correcto
+       según la NATURALEZA DISCERNIDA del contenido, NO la extensión del archivo
+PARADIGMA: shell + file-explorer + preview-pane; despacho por contenido (shuma-discern → viewer_registry::pick)
+PRINCIPIO: VIEWERS, NO EDITORS — visualización read-only; para editar se usa otra app (nada/pineal)
+TAMAÑO: ~9.5 KLoC; 2 libs + 11 visores productivos (+1 stub SVG)
+```
+
+## Flujo central (el corazón del dominio)
+
+```
+Archivo → read(8 KB header) → shuma-discern (pipeline de probes) → Discernment{ty, mime, lens, confidence}
+        → viewer_registry::pick(Discernment) → ViewerKind → Shell::load_for → PreviewPane::<X>(state) → <X>_viewer_view()
+
+EJEMPLO CLAVE: un .png renombrado a .txt → MagicBytes detecta 0x89PNG (conf 0.99) → ViewerKind::Image.
+Acierta pese a la extensión mentirosa. Esa es la tesis entera: contenido > extensión.
+```
+
+## Detección: `shuma-discern` (en `02_ruway/shuma/sandbox/shuma-discern`)
+
+```
+DiscernPipeline::default = [MagicBytes, CardProbe, JsonProbe, TomlProbe, TabularProbe, Utf8Probe]
+ESTRATEGIA: primer discerner con confidence ≥ 0.9 gana; si ninguno, el de mayor confidence.
+
+MagicBytes (0.99): PNG/JPEG/PDF/ELF/WASM/gzip/ZIP/tar(ustar@257)/GIF/WEBP/WAVE/FLAC/Ogg/MP3/EBML/IVF/TTF/OTF/ttcf.
+CardProbe (0.97): JSON con keys {schema_version,id,payload} → TypeRef::Wit{package:"brahman:card"}, lens "card". ANTES que JsonProbe.
+JsonProbe (0.95): serde_json parsea → lens "tree".
+TomlProbe (0.93): secciones [..]/clave=valor → lens "tree".
+TabularProbe (0.93): hint path .csv/.tsv + delimitador en 1ª línea → lens "table".
+Utf8Probe (0.5): UTF-8 válido, controles <5%; lens por extensión (.md→markdown, .rs/.py/.go/.js/.ts→code). Fallback universal.
+
+Discernment{ ty: TypeRef, confidence: f32, mime: Option<String>, lens: Option<String> }   // TypeRef viene de card-core
+```
+
+## Despacho: `viewer_registry::pick` (nahual-shell-llimphi/src/viewer_registry.rs, 200 LOC, 11 tests)
+
+```
+PRECEDENCIA:
+  1. lens explícito  gallery→Image · video→Video · audio→Audio · card→Card · tree→Tree · table→Table · markdown→Markdown · font→Font
+     EXCEPCIÓN: image/gif SIEMPRE → Video (anima aunque el lens sea gallery)
+  2. mime prefix     image/* → Image · video/* → Video · audio/* → Audio
+  3. mime contenedor application/{zip,x-tar,gzip} → Archive
+  4. mime binario    application/{x-executable,wasm} → Hex
+  5. fallback        Text (nunca falla feo)
+```
+
+## Visores implementados (11) — cada uno 200–600 LOC, bajo acoplamiento
+
+```
+Text     fallback UTF-8 + syntax por extensión (read-only sobre widget text-editor)
+Image    PNG/JPEG/WebP + GIF estático; pan/zoom, fit, magnifier, EXIF
+Video    WebM/MKV(AV1 nativo puro-Rust)/IVF + GIF animado; demuxer por extensión
+Audio    WAV/MP3/FLAC/Opus/Vorbis; espectro 48 bandas (Goertzel); sink cpal !Send vive en Model
+Card     shared/card (schema_version/id/payload) como campos legibles
+Tree     JSON/TOML indentado (legible aun minificado)
+Hex      ELF/wasm/binarios: dump offset+hex+ascii, 16 B/fila, sin deps
+Table    CSV/TSV columnas alineadas
+Markdown .md vía pulldown-cmark 0.12 (encabezados h1..h6, código, listas, citas)
+Archive  ZIP/jar/apk/epub/OOXML (zip 2.4) · tar(ustar@257) · tar.gz(flate2 streaming)
+Font     TTF/OTF: metadatos + MUESTRA DIBUJADA — ttf_parser::OutlineBuilder → kurbo::BezPath → paint_with
+         (showcase de render vectorial directo; se ve aunque la fuente no esté instalada)
+```
+
+## Libs declarativas (presentes, aún NO usadas por el shell)
+
+```
+meta-schema  (1172) schema declarativo de UIs data-driven: Module · EntitySpec · View{List,Form,Detail,Dashboard,Report,Graph}
+meta-runtime (2360) helpers puros: parseo tipado, validación, delta, formato/presentación
+USO ACTUAL: consumidos por Nakui/otros, NO por nahual-shell. Reservados para definir visores en JSON sin código Rust (futuro).
+```
+
+## Cómo registrar un visor nuevo (procedimiento real)
+
+```
+1. crate nahual-<x>-viewer-llimphi: struct <X>Preview + fn load_<x>(path) + fn <x>_viewer_view()
+2. shell main.rs: import + variante en enum PreviewPane + arm en load_for
+3. viewer_registry: variante ViewerKind::<X> + fila en pick() (lens/mime) + test
+4. shuma-discern: nuevo Discerner si hay magic-bytes/heurística nueva, o reusar lens existente
+5. (futuro) meta-schema: cuando exista AppBus, registro dinámico en tabla en vez de hardcode in-process
+```
+
+## Faltantes y limitaciones conocidas
+
+```
+PDF      shuma lo detecta (%PDF-, lens "reader") pero NO hay rasterizador PDF puro-Rust en el workspace ⇒ cae a Text. BLOQUEADO.
+SVG      stub (1 LOC), en construcción por otro agente.
+Deck     presentaciones — futuro.
+seek/scrub  video/audio sólo Space play/pausa; audio estima playhead con reloj (AudioSource type-erased, falta exponer Seekable).
+AppBus   NO existe aún: el registro de visores es HARDCODE in-process. Sin visores out-of-process ni registro dinámico.
+```
+
+## Relaciones inter-dominio
+
+```
+shuma    : shuma-discern es el cerebro de detección (pipeline de probes por magic-bytes + heurística).
+llimphi  : toda la UI — llimphi-ui/theme/layout(taffy)/text/raster(kurbo,peniko) + widgets {list,splitter,text-editor,tree}.
+card     : card-core aporta TypeRef/Discernment; shared/card es el formato del Card viewer. "brahman:card" es nombre legacy.
+media    : media-source-{av1,webm,gif} + media-audio-cpal + media-core(AudioProbe, Spectrum) para video/audio.
+wawa-config: preferencias (theme/lang) compartidas con el monorepo; watcher reactivo sin reinicio.
+minga    : card-discovery (en minga) es el widget de descubrimiento de Cards consumido por nahual-shell.   ← NEXO BRAHMAN
+```
+
+## Estado (2026-05-31)
+
+### Hecho
+- 11 visores en-proceso (Text/Image/Video/Audio/Card/Tree/Hex/Table/Markdown/Archive/Font), cada uno 200–600 LOC, bajo acoplamiento.
+- Shell con split draggable, navegación por teclado (↑↓ Enter Backspace Space) y despacho por contenido (no extensión) vía `shuma-discern` → `viewer_registry::pick`.
+- Galería de miniaturas (`nahual-gallery-llimphi`): `thumb-core` (generación + cache en disco + planificador), fast-path EXIF embebido, orientación EXIF, zoom de grilla, eviction RAM, badge de tamaño, slideshow, ordenamiento, preview a tamaño completo.
+- Espina del front universal: trait `Source` + 4 adapters (POSIX, wawa-image, NouserSource/Mónadas, MingaSource/DAG de AST); el shell monta nouser y minga por atajo.
+- Render vectorial directo en el font viewer; GIF reusa el video viewer sin crate nuevo. Menú principal + contextual en las vitrinas.
+
+### Pendiente
+- PDF: detectado por `shuma` pero sin rasterizador puro-Rust en el workspace → cae a Text (BLOQUEADO).
+- SVG: stub (1 LOC), en construcción.
+- Deck (presentaciones), seek/scrub real en video/audio (hoy sólo play/pausa), play por click.
+- AppBus / registro dinámico: hoy el `viewer_registry` es hardcode in-process; sin visores out-of-process registrados por `(lens, mime, priority)`.
+- Libs `meta-schema`/`meta-runtime` presentes pero aún NO consumidas por el shell (reservadas para visores data-driven en JSON).
+
+## Estado vs aspiración
+
+```
+ASPIRA_A:
+  - PDF (bloqueado por rasterizador) · SVG (WIP) · Deck · seek/scrub · play por click.
+  - AppBus / EntityType: visores FUERA-DE-PROCESO que se registran con (lens, mime, priority);
+    shell publica EntitySelected, viewers suscriben; registro pasa de hardcode a TABLA DINÁMICA.
+  - mover viewer_registry a crate compartido si otra app además del shell lo necesita.
+
+NORTE_ARQUITECTÓNICO:
+  nahual es el "abridor universal" de la suite: dado cualquier byte, discierne su naturaleza y lo muestra bien.
+  Hoy el registro de visores es estático e in-process. Su destino declarado es el AppBus: que cualquier app/servicio
+  registre un visor por (lens, mime, priority) y el shell despache out-of-process — exactamente el patrón de discovery
+  tipado que Brahman ya implementa para datos. nahual+AppBus ES el caso de uso natural de las Cards a nivel de UI.
+```
+
+---
+
+**Síntesis de una línea para otra IA:** nahual es una meta-app "open-with universal" que lee los primeros 8 KB de
+cualquier archivo, discierne su naturaleza por contenido (no extensión) vía un pipeline de probes (`shuma-discern`), y
+despacha al visor correcto de entre 11 (`viewer_registry::pick`) — read-only por principio, con detección robusta ante
+archivos mal renombrados, cuyo norte es reemplazar el registro estático in-process por un AppBus de visores
+out-of-process registrados por `(lens, mime, priority)`, que es justamente el patrón de discovery tipado de Brahman llevado a la UI.
diff --git a/02_ruway/nahual/LEEME.md b/02_ruway/nahual/LEEME.md
new file mode 100644
index 0000000..df3237d
--- /dev/null
+++ b/02_ruway/nahual/LEEME.md
@@ -0,0 +1,35 @@
+# nahual
+
+> `nahual` (náhuatl: *espíritu acompañante*). Visores cotidianos sobre Llimphi.
+
+Conjunto mínimo de viewers que el usuario espera de un escritorio: shell de archivos, viewer de texto, viewer de imagen. Implementados con la misma framework de UI; comparten preferencias con el resto del monorepo via `wawa-config`. Más un meta-runtime para definir nuevos viewers via schema sin escribir Rust desde cero.
+
+## Instalación
+
+```sh
+cargo run --release -p nahual-shell-llimphi
+cargo run --release -p nahual-file-explorer-llimphi
+cargo run --release -p nahual-text-viewer-llimphi
+cargo run --release -p nahual-image-viewer-llimphi
+```
+
+## Compatibilidad
+
+- **Linux / macOS / Windows** — UI Llimphi nativa.
+- **Wawa** — los viewers compilan adentro del kernel; el file explorer habla con `wawa-fs`.
+
+## Crates
+
+| Crate | Rol |
+|---|---|
+| [`meta-schema`](libs/meta-schema/README.md) | Schema declarativo de viewers. |
+| [`meta-runtime`](libs/meta-runtime/README.md) | Runtime que monta un viewer desde schema. |
+| [`nahual-shell-llimphi`](nahual-shell-llimphi/README.md) | Shell de archivos: navegación + acciones básicas. |
+| [`nahual-file-explorer-llimphi`](nahual-file-explorer-llimphi/README.md) | File explorer con tree + previews. |
+| [`nahual-text-viewer-llimphi`](nahual-text-viewer-llimphi/README.md) | Viewer de texto plano. |
+| [`nahual-image-viewer-llimphi`](nahual-image-viewer-llimphi/README.md) | Viewer de imagen (PNG/JPEG/WebP). |
+
+## Consideraciones
+
+- **Visualizadores, no editores.** Si querés editar el archivo, `nada`. Si querés editar la imagen, `pineal` o un editor externo.
+- El meta-runtime permite **definir un viewer en JSON** y obtener una app Llimphi sin código.
diff --git a/02_ruway/nahual/README.md b/02_ruway/nahual/README.md
new file mode 100644
index 0000000..de83f47
--- /dev/null
+++ b/02_ruway/nahual/README.md
@@ -0,0 +1,26 @@
+# nahual
+
+> `nahual` (Nahuatl: *companion spirit*). Everyday viewers over Llimphi.
+
+Minimal set of viewers a desktop user expects: file shell, text viewer, image viewer. Built on the same UI framework; share preferences with the rest of the monorepo via `wawa-config`. Plus a meta-runtime to define new viewers via schema without writing Rust from scratch.
+
+## Install
+
+```sh
+cargo run --release -p nahual-shell-llimphi
+cargo run --release -p nahual-file-explorer-llimphi
+cargo run --release -p nahual-text-viewer-llimphi
+cargo run --release -p nahual-image-viewer-llimphi
+```
+
+## Compatibility
+
+- **Linux / macOS / Windows** — native Llimphi UI.
+- **Wawa** — viewers compile inside the kernel; file explorer speaks `wawa-fs`.
+
+Crates listed in [README.md](README.md).
+
+## Considerations
+
+- **Viewers, not editors.** Edit the file → `nada`. Edit the image → `pineal` or external.
+- The meta-runtime lets you **define a viewer in JSON** and get a Llimphi app without code.
diff --git a/02_ruway/nahual/README.qu.md b/02_ruway/nahual/README.qu.md
new file mode 100644
index 0000000..0e067d9
--- /dev/null
+++ b/02_ruway/nahual/README.qu.md
@@ -0,0 +1,28 @@
+<!-- Quechua (Cusco/Collao). Revisión bienvenida. -->
+
+# nahual
+
+> `nahual` (nahuatl: *kasqachay espíritu*). Sapanka viewerkuna Llimphi patapi.
+
+Minimo viewerkuna runa escritorio suyay: archivo shell, qillqa viewer, siq'i viewer. Kikin UI framework patapi; preferencias monorepupawan `wawa-config`-rayku huñunku. Hinaspa meta-runtime: musuq viewers schema-pi ruway, mana Rust ñawpaqmanta qillqaspa.
+
+## Churay
+
+```sh
+cargo run --release -p nahual-shell-llimphi
+cargo run --release -p nahual-file-explorer-llimphi
+cargo run --release -p nahual-text-viewer-llimphi
+cargo run --release -p nahual-image-viewer-llimphi
+```
+
+## Tinkuy
+
+- **Linux / macOS / Windows** — natural Llimphi UI.
+- **Wawa** — viewerkuna kernel ukhupi wiñankun; file explorer `wawa-fs`-wan rimaq.
+
+Crateskuna listako [README.md](README.md)-pi.
+
+## Yuyaykunaq
+
+- **Viewerkuna, mana editorkuna.** Tikrana archivo → `nada`. Tikrana siq'i → `pineal` utaq hawanka.
+- Meta-runtime: huk **viewer JSON-pi munakuy**, Llimphi app aypay manchu kódigo.
diff --git a/02_ruway/nahual/libs/meta-runtime/Cargo.toml b/02_ruway/nahual/libs/meta-runtime/Cargo.toml
new file mode 100644
index 0000000..b105d82
--- /dev/null
+++ b/02_ruway/nahual/libs/meta-runtime/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "nahual-meta-runtime"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+description = "Yahweh — meta-runtime: helpers puros (parse, delta, validación, format) que cualquier widget metainterfaz consume sobre `yahweh-meta-schema`. Sin GPUI, sin backend específico — toma cierres/closures para acceder al store."
+
+[dependencies]
+serde_json = { workspace = true }
+thiserror = { workspace = true }
+uuid = { workspace = true, features = ["serde"] }
+nahual-meta-schema = { path = "../meta-schema" }
diff --git a/02_ruway/nahual/libs/meta-runtime/LEEME.md b/02_ruway/nahual/libs/meta-runtime/LEEME.md
new file mode 100644
index 0000000..d2ece72
--- /dev/null
+++ b/02_ruway/nahual/libs/meta-runtime/LEEME.md
@@ -0,0 +1,9 @@
+# meta-runtime
+
+> Runtime que monta un viewer desde [`meta-schema`](../meta-schema/README.md) de [nahual](../../README.md).
+
+Toma un `Schema` y produce un `View<Msg>` Llimphi. Permite "definir un viewer en JSON" sin escribir Rust.
+
+## Deps
+
+- [`meta-schema`](../meta-schema/README.md), [`llimphi-ui`](../../../llimphi/)
diff --git a/02_ruway/nahual/libs/meta-runtime/README.md b/02_ruway/nahual/libs/meta-runtime/README.md
new file mode 100644
index 0000000..f357be9
--- /dev/null
+++ b/02_ruway/nahual/libs/meta-runtime/README.md
@@ -0,0 +1,9 @@
+# meta-runtime
+
+> Runtime mounting a viewer from [`meta-schema`](../meta-schema/README.md) of [nahual](../../README.md).
+
+Takes a `Schema` and produces a Llimphi `View<Msg>`. Lets you "define a viewer in JSON" without writing Rust.
+
+## Deps
+
+- [`meta-schema`](../meta-schema/README.md), [`llimphi-ui`](../../../llimphi/)
diff --git a/02_ruway/nahual/libs/meta-runtime/src/backend.rs b/02_ruway/nahual/libs/meta-runtime/src/backend.rs
new file mode 100644
index 0000000..6d72246
--- /dev/null
+++ b/02_ruway/nahual/libs/meta-runtime/src/backend.rs
@@ -0,0 +1,288 @@
+//! `MetaBackend` trait — la frontera entre el widget metainterfaz
+//! (nahual) y la implementación concreta de persistencia/ejecución
+//! (nakui-core, Surreal, mocks para tests).
+//!
+//! El widget consume este trait; el binario lo implementa con su
+//! stack particular. Esto es lo que hace que el widget sea reusable.
+//!
+//! Convenciones documentadas en el doc del trait abajo.
+
+use std::collections::BTreeMap;
+
+use serde_json::Value;
+use uuid::Uuid;
+
+/// Resultado uniforme de una operación de escritura del backend.
+///
+/// La UI lo usa para componer el toast: `id` para mostrar el
+/// short_uuid, `changed` para diferenciar "actualizado X (3 campos)"
+/// vs "sin cambios", `post_status` para concatenar mensajes
+/// emitidos por hooks internos del backend (ej. "auto-compact:
+/// snapshot @ seq 49") sin que la UI tenga que conocer el detalle.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct WriteOutcome {
+    /// Id del record afectado. `Some` para seed/update/delete;
+    /// `None` para morphism cuando afecta múltiples records.
+    pub id: Option<Uuid>,
+    /// Cantidad de cambios efectivos. `0` = no-op (edit que no
+    /// modificó ningún campo, etc.).
+    pub changed: usize,
+    /// Mensaje de status opcional para concatenar al toast del op
+    /// original con el separator estándar.
+    pub post_status: Option<String>,
+}
+
+impl WriteOutcome {
+    /// Constructor para no-op writes (edits sin cambios).
+    pub fn no_change(id: Uuid) -> Self {
+        Self {
+            id: Some(id),
+            changed: 0,
+            post_status: None,
+        }
+    }
+}
+
+/// Backend que un widget de metainterfaz usa para leer y mutar
+/// records. Decoupla el widget (nahual) de la implementación
+/// concreta (nakui-core, Surreal, mock para tests).
+///
+/// # Convención sobre ids
+///
+/// `Uuid` canónico. Backends que internamente usan otros tipos
+/// deben mapear via Uuid (hash determinista, wrapper, lo que sirva).
+/// Esto evita generic associated types que complicarían el dispatch
+/// en `cx.listener` de GPUI.
+///
+/// # Convención sobre validación
+///
+/// El backend ES la fuente de verdad sobre invariantes (KCL/Nickel
+/// post-checks, conservación, etc.). El widget pre-valida shape
+/// (nahual-meta-runtime: `parse_field_value`, `validate_entity_refs`)
+/// pero el backend puede rebotar con `Err(...)` si su validación
+/// adicional falla — el widget muestra el error al usuario.
+///
+/// # Convención sobre threading
+///
+/// `'static` (no `Send + Sync`): el widget vive en `Entity<MetaApp<B>>`
+/// que requiere `'static`, pero los handlers son single-threaded en
+/// el main UI thread de GPUI. Si en el futuro un backend necesita
+/// `cx.spawn`, agregamos los marker traits.
+///
+/// # Convención sobre delta computation
+///
+/// El widget pre-computa `set` y `clear` con
+/// [`crate::delta::compute_field_delta`] +
+/// [`crate::delta::compute_clear_fields`] *antes* de llamar a
+/// [`MetaBackend::update`]. El backend no recomputa: si recibe ambos
+/// vacíos devuelve `changed = 0` sin escribir nada. Esto evita
+/// double-roundtrip al store por el mismo dato.
+pub trait MetaBackend: 'static {
+    /// Snapshot ordenado de records de una entity.
+    /// Orden estable (lexicográfico por id) para UI determinista.
+    /// Vacío si no hay records.
+    fn list_records(&self, entity: &str) -> Vec<(Uuid, Value)>;
+
+    /// Lee un record por id. `None` si no existe.
+    fn load_record(&self, entity: &str, id: Uuid) -> Option<Value>;
+
+    /// Crea un record nuevo. El backend asigna el `Uuid`
+    /// (devuelve en `WriteOutcome.id`). `changed = 1` siempre.
+    fn seed(
+        &mut self,
+        entity: &str,
+        data: serde_json::Map<String, Value>,
+    ) -> Result<WriteOutcome, String>;
+
+    /// Edita un record existente. Aplica `set` (overrides) y
+    /// `clear` (key removal). `changed = set.len() + clear.len()`.
+    /// Si ambos están vacíos (no-op edit), devuelve
+    /// `WriteOutcome::no_change(id)` sin error y sin escribir al log.
+    fn update(
+        &mut self,
+        entity: &str,
+        id: Uuid,
+        set: serde_json::Map<String, Value>,
+        clear: Vec<String>,
+    ) -> Result<WriteOutcome, String>;
+
+    /// Borra un record. `changed = 1` si existía, error si no.
+    fn delete(&mut self, entity: &str, id: Uuid) -> Result<WriteOutcome, String>;
+
+    /// Ejecuta un morphism declarado por un módulo. El backend
+    /// resuelve la implementación, valida, computa ops, las aplica.
+    /// `changed = N ops aplicadas`.
+    ///
+    /// `module_id` ubica al módulo (el trait no asume estructura del
+    /// manifest — el backend lo resuelve internamente).
+    fn morphism(
+        &mut self,
+        module_id: &str,
+        name: &str,
+        inputs: BTreeMap<String, Uuid>,
+        params: Value,
+    ) -> Result<WriteOutcome, String>;
+}
+
+#[cfg(test)]
+mod tests {
+    //! Tests del trait via [`crate::testing::MockBackend`]. Verifican
+    //! el contrato genérico (object-safety, semantica de seed/update/
+    //! delete) sin atar a un backend concreto. Los tests del mock en
+    //! sí (constructores, with_morphism, etc.) viven en
+    //! `crate::testing::tests`.
+
+    use super::*;
+    use crate::testing::MockBackend;
+    use serde_json::json;
+
+    fn map_of(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
+        items
+            .iter()
+            .map(|(k, v)| (k.to_string(), v.clone()))
+            .collect()
+    }
+
+    #[test]
+    fn seed_then_load_round_trip() {
+        let mut b = MockBackend::new();
+        let out = b
+            .seed("Customer", map_of(&[("name", json!("Acme"))]))
+            .unwrap();
+        let id = out.id.expect("seed devuelve id");
+        assert_eq!(out.changed, 1);
+        assert!(out.post_status.is_none());
+
+        let rec = b.load_record("Customer", id).unwrap();
+        assert_eq!(rec.get("name"), Some(&json!("Acme")));
+    }
+
+    #[test]
+    fn list_records_filters_by_entity_and_orders_stably() {
+        let mut b = MockBackend::new();
+        let _ = b.seed("A", map_of(&[("k", json!(1))])).unwrap();
+        let _ = b.seed("B", map_of(&[("k", json!(2))])).unwrap();
+        let _ = b.seed("A", map_of(&[("k", json!(3))])).unwrap();
+
+        let a = b.list_records("A");
+        assert_eq!(a.len(), 2);
+        let b_only = b.list_records("B");
+        assert_eq!(b_only.len(), 1);
+        let none = b.list_records("Missing");
+        assert!(none.is_empty());
+
+        // Orden estable: re-llamadas devuelven mismo orden.
+        let a_again = b.list_records("A");
+        assert_eq!(
+            a.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
+            a_again.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
+        );
+    }
+
+    #[test]
+    fn update_with_set_changes_field() {
+        let mut b = MockBackend::new();
+        let id = b
+            .seed(
+                "Customer",
+                map_of(&[("name", json!("Acme")), ("notes", json!("x"))]),
+            )
+            .unwrap()
+            .id
+            .unwrap();
+
+        let out = b
+            .update(
+                "Customer",
+                id,
+                map_of(&[("name", json!("Acme S.A."))]),
+                vec![],
+            )
+            .unwrap();
+        assert_eq!(out.changed, 1);
+        assert_eq!(out.id, Some(id));
+
+        let rec = b.load_record("Customer", id).unwrap();
+        assert_eq!(rec.get("name"), Some(&json!("Acme S.A.")));
+        assert_eq!(rec.get("notes"), Some(&json!("x")), "notes intacto");
+    }
+
+    #[test]
+    fn update_with_clear_removes_key() {
+        let mut b = MockBackend::new();
+        let id = b
+            .seed(
+                "Customer",
+                map_of(&[("name", json!("Acme")), ("notes", json!("x"))]),
+            )
+            .unwrap()
+            .id
+            .unwrap();
+
+        let out = b
+            .update("Customer", id, serde_json::Map::new(), vec!["notes".into()])
+            .unwrap();
+        assert_eq!(out.changed, 1);
+
+        let rec = b.load_record("Customer", id).unwrap();
+        assert_eq!(rec.get("name"), Some(&json!("Acme")));
+        assert!(rec.get("notes").is_none(), "notes debería estar borrado");
+    }
+
+    #[test]
+    fn update_with_empty_set_and_clear_returns_no_change() {
+        let mut b = MockBackend::new();
+        let id = b
+            .seed("Customer", map_of(&[("name", json!("Acme"))]))
+            .unwrap()
+            .id
+            .unwrap();
+
+        let out = b
+            .update("Customer", id, serde_json::Map::new(), vec![])
+            .unwrap();
+        assert_eq!(out, WriteOutcome::no_change(id));
+    }
+
+    #[test]
+    fn update_on_missing_record_errors() {
+        let mut b = MockBackend::new();
+        let id = Uuid::new_v4();
+        let err = b
+            .update("Customer", id, map_of(&[("x", json!(1))]), vec![])
+            .unwrap_err();
+        assert!(err.contains("not found"));
+    }
+
+    #[test]
+    fn delete_removes_and_then_load_returns_none() {
+        let mut b = MockBackend::new();
+        let id = b
+            .seed("Customer", map_of(&[("name", json!("Acme"))]))
+            .unwrap()
+            .id
+            .unwrap();
+        let out = b.delete("Customer", id).unwrap();
+        assert_eq!(out.changed, 1);
+        assert_eq!(out.id, Some(id));
+        assert!(b.load_record("Customer", id).is_none());
+    }
+
+    #[test]
+    fn delete_on_missing_record_errors() {
+        let mut b = MockBackend::new();
+        let id = Uuid::new_v4();
+        assert!(b.delete("Customer", id).is_err());
+    }
+
+    /// Sanity: el trait acepta llamadas via `&mut dyn MetaBackend`
+    /// (object-safety). Esto permite que el widget tenga
+    /// `Box<dyn MetaBackend>` si el use case requiere borrado de
+    /// tipo (vs. el path normal con `MetaApp<B: MetaBackend>`).
+    #[test]
+    fn trait_is_object_safe() {
+        let mut b: Box<dyn MetaBackend> = Box::new(MockBackend::new());
+        let _ = b.seed("X", map_of(&[("k", json!(1))])).unwrap();
+        assert_eq!(b.list_records("X").len(), 1);
+    }
+}
diff --git a/02_ruway/nahual/libs/meta-runtime/src/csv.rs b/02_ruway/nahual/libs/meta-runtime/src/csv.rs
new file mode 100644
index 0000000..114d616
--- /dev/null
+++ b/02_ruway/nahual/libs/meta-runtime/src/csv.rs
@@ -0,0 +1,67 @@
+//! Serialización de filas a CSV (RFC 4180) para exportar listas.
+
+/// Arma un documento CSV: una línea de headers + una por fila. Cada
+/// celda se escapa si contiene coma, comilla o salto de línea.
+pub fn to_csv(headers: &[String], rows: &[Vec<String>]) -> String {
+    let mut out = String::new();
+    push_csv_line(&mut out, headers);
+    for row in rows {
+        push_csv_line(&mut out, row);
+    }
+    out
+}
+
+/// Agrega una línea CSV (celdas separadas por coma + `\n` final).
+fn push_csv_line(out: &mut String, cells: &[String]) {
+    for (i, cell) in cells.iter().enumerate() {
+        if i > 0 {
+            out.push(',');
+        }
+        out.push_str(&csv_escape(cell));
+    }
+    out.push('\n');
+}
+
+/// Escapa una celda: la envuelve en comillas y duplica las comillas
+/// internas si contiene coma, comilla, CR o LF. Si no, va tal cual.
+fn csv_escape(s: &str) -> String {
+    if s.contains([',', '"', '\n', '\r']) {
+        format!("\"{}\"", s.replace('"', "\"\""))
+    } else {
+        s.to_string()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn plain_cells_unquoted() {
+        let csv = to_csv(
+            &["Nombre".into(), "Edad".into()],
+            &[vec!["Ana".into(), "30".into()]],
+        );
+        assert_eq!(csv, "Nombre,Edad\nAna,30\n");
+    }
+
+    #[test]
+    fn cells_with_comma_or_quote_are_escaped() {
+        let csv = to_csv(
+            &["a".into(), "b".into()],
+            &[vec!["x,y".into(), "dijo \"hola\"".into()]],
+        );
+        assert_eq!(csv, "a,b\n\"x,y\",\"dijo \"\"hola\"\"\"\n");
+    }
+
+    #[test]
+    fn newline_in_cell_is_quoted() {
+        let csv = to_csv(&["n".into()], &[vec!["línea1\nlínea2".into()]]);
+        assert_eq!(csv, "n\n\"línea1\nlínea2\"\n");
+    }
+
+    #[test]
+    fn empty_rows_yields_just_header() {
+        assert_eq!(to_csv(&["x".into()], &[]), "x\n");
+    }
+}
diff --git a/02_ruway/nahual/libs/meta-runtime/src/delta.rs b/02_ruway/nahual/libs/meta-runtime/src/delta.rs
new file mode 100644
index 0000000..2b0e560
--- /dev/null
+++ b/02_ruway/nahual/libs/meta-runtime/src/delta.rs
@@ -0,0 +1,137 @@
+//! Cálculo del delta entre el record actual y la propuesta del form.
+//!
+//! Sirve a un runtime de edición para emitir SOLO los Set/Clear que
+//! cambian algo: log + apply minimales, no-op edits = 0 entries.
+
+use serde_json::Value;
+
+/// Calcula el delta entre el record actual y los valores propuestos
+/// del form. Devuelve un Map con sólo los campos cuyo valor difiere.
+///
+/// Comparación: igualdad estructural sobre `serde_json::Value`. Un
+/// `current=Value::Null` (record no encontrado) hace que todos los
+/// campos del `proposed` sean considerados nuevos. Un campo del
+/// proposed que coincide con el del current se omite. Campos que
+/// están en current pero NO en proposed se preservan tal cual (el
+/// edit no los toca; ver [`compute_clear_fields`] para borrar
+/// explícito desde un input vacío).
+pub fn compute_field_delta(
+    current: &Value,
+    proposed: &serde_json::Map<String, Value>,
+) -> serde_json::Map<String, Value> {
+    proposed
+        .iter()
+        .filter(|(field, value)| current.get(field.as_str()) != Some(*value))
+        .map(|(k, v)| (k.clone(), v.clone()))
+        .collect()
+}
+
+/// Decide cuáles fields del `to_clear` candidate list ameritan
+/// realmente un `FieldOp::Clear`: sólo los que existen en el current
+/// con un valor non-null. Para fields ausentes o ya null, Clear es
+/// no-op semántico (el post-state es el mismo) y dropearlos
+/// preserva la propiedad "1 op = 1 cambio efectivo" del log.
+///
+/// Preserva el orden del input para que el log entry sea estable.
+pub fn compute_clear_fields(current: &Value, to_clear: &[String]) -> Vec<String> {
+    to_clear
+        .iter()
+        .filter(|f| match current.get(f.as_str()) {
+            None | Some(Value::Null) => false,
+            Some(_) => true,
+        })
+        .cloned()
+        .collect()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use serde_json::json;
+
+    fn map(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
+        items
+            .iter()
+            .map(|(k, v)| (k.to_string(), v.clone()))
+            .collect()
+    }
+
+    #[test]
+    fn delta_empty_when_all_fields_match() {
+        let current = json!({"name": "Acme", "saldo": 100_i64, "currency": "USD"});
+        let proposed = map(&[
+            ("name", json!("Acme")),
+            ("saldo", json!(100_i64)),
+            ("currency", json!("USD")),
+        ]);
+        assert!(compute_field_delta(&current, &proposed).is_empty());
+    }
+
+    #[test]
+    fn delta_includes_only_changed_field() {
+        let current = json!({"name": "Acme", "saldo": 100_i64});
+        let proposed = map(&[("name", json!("Acme")), ("saldo", json!(200_i64))]);
+        let d = compute_field_delta(&current, &proposed);
+        assert_eq!(d.len(), 1);
+        assert_eq!(d.get("saldo"), Some(&json!(200_i64)));
+    }
+
+    #[test]
+    fn delta_treats_missing_record_as_all_new() {
+        let current = Value::Null;
+        let proposed = map(&[("name", json!("Acme")), ("saldo", json!(0_i64))]);
+        assert_eq!(compute_field_delta(&current, &proposed).len(), 2);
+    }
+
+    #[test]
+    fn delta_distinguishes_int_from_string_repr() {
+        let current = json!({"qty": 100_i64});
+        let proposed = map(&[("qty", json!(100_i64))]);
+        assert!(compute_field_delta(&current, &proposed).is_empty());
+
+        let current_str = json!({"qty": "100"});
+        let proposed_int = map(&[("qty", json!(100_i64))]);
+        assert_eq!(compute_field_delta(&current_str, &proposed_int).len(), 1);
+    }
+
+    #[test]
+    fn delta_skips_fields_absent_from_proposed() {
+        let current = json!({"name": "Acme", "saldo": 100_i64, "extra": "x"});
+        let proposed = map(&[("name", json!("Acme")), ("saldo", json!(150_i64))]);
+        let d = compute_field_delta(&current, &proposed);
+        assert_eq!(d.len(), 1);
+        assert!(!d.contains_key("extra"));
+    }
+
+    #[test]
+    fn clear_fields_skips_absent_and_null() {
+        let current = json!({"name": "Acme", "notes": "lorem", "tag": null});
+        let to_clear = vec![
+            "name".into(),
+            "notes".into(),
+            "tag".into(),
+            "missing".into(),
+        ];
+        assert_eq!(
+            compute_clear_fields(&current, &to_clear),
+            vec!["name".to_string(), "notes".to_string()]
+        );
+    }
+
+    #[test]
+    fn clear_fields_preserves_input_order() {
+        let current = json!({"a": 1, "b": 2, "c": 3});
+        let to_clear = vec!["c".into(), "a".into(), "b".into()];
+        assert_eq!(
+            compute_clear_fields(&current, &to_clear),
+            vec!["c", "a", "b"]
+        );
+    }
+
+    #[test]
+    fn clear_fields_empty_when_current_is_null() {
+        let current = Value::Null;
+        let to_clear = vec!["name".into()];
+        assert!(compute_clear_fields(&current, &to_clear).is_empty());
+    }
+}
diff --git a/02_ruway/nahual/libs/meta-runtime/src/format.rs b/02_ruway/nahual/libs/meta-runtime/src/format.rs
new file mode 100644
index 0000000..c8a7677
--- /dev/null
+++ b/02_ruway/nahual/libs/meta-runtime/src/format.rs
@@ -0,0 +1,344 @@
+//! Helpers de presentación humana para records y values.
+//!
+//! Sin GPUI: devuelven `String`s. El widget renderer los wrap-ea
+//! en `div().child(...)` o equivalente.
+
+use std::cmp::Ordering;
+
+use serde_json::Value;
+use uuid::Uuid;
+
+use nahual_meta_schema::ValueFormat;
+
+/// Compara dos valores de celda para ordenar una lista. `None`/`null`
+/// ordenan antes que cualquier valor. Números por valor numérico,
+/// strings case-insensitive, bools `false < true`; tipos mixtos por su
+/// forma string (orden estable, no semántico).
+pub fn cmp_values(a: Option<&Value>, b: Option<&Value>) -> Ordering {
+    let nullish = |v: Option<&Value>| matches!(v, None | Some(Value::Null));
+    match (nullish(a), nullish(b)) {
+        (true, true) => return Ordering::Equal,
+        (true, false) => return Ordering::Less,
+        (false, true) => return Ordering::Greater,
+        (false, false) => {}
+    }
+    match (a, b) {
+        (Some(Value::Number(x)), Some(Value::Number(y))) => x
+            .as_f64()
+            .partial_cmp(&y.as_f64())
+            .unwrap_or(Ordering::Equal),
+        (Some(Value::String(x)), Some(Value::String(y))) => x.to_lowercase().cmp(&y.to_lowercase()),
+        (Some(Value::Bool(x)), Some(Value::Bool(y))) => x.cmp(y),
+        (Some(x), Some(y)) => x.to_string().cmp(&y.to_string()),
+        // Inalcanzable: el chequeo nullish de arriba cubre los None.
+        _ => Ordering::Equal,
+    }
+}
+
+/// Etiqueta humana para representar un record en el selector de
+/// EntityRef y en columnas de referencia. Heurística: prefiere campos
+/// de nombre comunes (ES + EN); fallback al UUID corto.
+pub fn human_label_for_record(value: &Value, id: &Uuid) -> String {
+    for key in [
+        "name", "nombre", "label", "title", "titulo", "sku", "sku_id",
+    ] {
+        if let Some(v) = value.get(key).and_then(Value::as_str) {
+            if !v.is_empty() {
+                return format!("{} ({})", v, short_uuid(id));
+            }
+        }
+    }
+    short_uuid(id)
+}
+
+/// Render legible de un `Value` arbitrario para mostrar en una celda
+/// de lista. Strings van pelados; bools como ✓/✗; el resto via
+/// `Display`.
+pub fn render_value(v: Option<&Value>) -> String {
+    match v {
+        None | Some(Value::Null) => String::new(),
+        Some(Value::String(s)) => s.clone(),
+        Some(Value::Bool(b)) => if *b { "✓" } else { "✗" }.to_string(),
+        Some(Value::Number(n)) => n.to_string(),
+        Some(other) => other.to_string(),
+    }
+}
+
+/// Render de un valor de celda según un [`ValueFormat`]. `Plain`
+/// delega en [`render_value`]; `Number`/`Currency` agrupan miles. Un
+/// valor no numérico bajo `Number`/`Currency` cae a `render_value`.
+pub fn format_value(v: Option<&Value>, fmt: &ValueFormat) -> String {
+    match fmt {
+        ValueFormat::Plain => render_value(v),
+        ValueFormat::Number => match v {
+            Some(Value::Number(n)) => group_thousands(n),
+            _ => render_value(v),
+        },
+        ValueFormat::Currency { symbol } => match v {
+            Some(Value::Number(n)) => format!("{symbol}{}", group_thousands(n)),
+            _ => render_value(v),
+        },
+    }
+}
+
+/// Formatea un `Number` con separador de miles. Enteros sin decimales;
+/// flotantes con dos.
+fn group_thousands(n: &serde_json::Number) -> String {
+    if let Some(i) = n.as_i64() {
+        group_int(i)
+    } else if let Some(f) = n.as_f64() {
+        let neg = f.is_sign_negative();
+        let cents = (f.abs() * 100.0).round() as i64;
+        format!(
+            "{}{}.{:02}",
+            if neg { "-" } else { "" },
+            group_int(cents / 100),
+            cents % 100,
+        )
+    } else {
+        n.to_string()
+    }
+}
+
+/// Inserta comas cada tres dígitos en un entero con signo.
+fn group_int(i: i64) -> String {
+    let digits = i.unsigned_abs().to_string();
+    let bytes = digits.as_bytes();
+    let mut out = String::new();
+    for (idx, &b) in bytes.iter().enumerate() {
+        if idx > 0 && (bytes.len() - idx).is_multiple_of(3) {
+            out.push(',');
+        }
+        out.push(b as char);
+    }
+    if i < 0 {
+        format!("-{out}")
+    } else {
+        out
+    }
+}
+
+/// Conversión inversa a `parse_field_value`: del JSON al texto raw
+/// que un input puede tomar y volver a parsearse igual al submit.
+/// Usado para pre-llenar inputs en modo edit.
+pub fn value_to_input_text(v: &Value) -> String {
+    match v {
+        Value::Null => String::new(),
+        Value::String(s) => s.clone(),
+        Value::Bool(b) => if *b { "true" } else { "false" }.to_string(),
+        Value::Number(n) => n.to_string(),
+        other => other.to_string(),
+    }
+}
+
+/// Primeros 8 chars del UUID en forma canónica. Útil para logs y UI
+/// donde el UUID full es ruido visual.
+pub fn short_uuid(id: &Uuid) -> String {
+    id.to_string().chars().take(8).collect()
+}
+
+/// Hex string de los primeros 4 bytes de un hash SHA-256 (8
+/// caracteres). Útil para mostrar bundle/schema hashes en UI sin
+/// quemar pantalla con los 64 chars completos.
+pub fn short_hash(h: &[u8; 32]) -> String {
+    use std::fmt::Write;
+    let mut s = String::with_capacity(8);
+    for b in h.iter().take(4) {
+        let _ = write!(s, "{:02x}", b);
+    }
+    s
+}
+
+/// Renderea un `serde_json::Value` en una sola línea, truncado a
+/// `max` caracteres con `...` al final si excede. Para preview en
+/// timelines/cards/listas — NO para edición.
+///
+/// `max` es un upper-bound aproximado: el resultado nunca excede
+/// `max` chars, pero puede ser más corto si el value es chico.
+pub fn preview_value(v: &Value, max: usize) -> String {
+    let s = v.to_string();
+    if s.chars().count() <= max {
+        s
+    } else if max < 3 {
+        s.chars().take(max).collect()
+    } else {
+        let truncated: String = s.chars().take(max - 3).collect();
+        format!("{truncated}...")
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use serde_json::json;
+
+    #[test]
+    fn human_label_prefers_name_over_id() {
+        let id = Uuid::new_v4();
+        let v = json!({"name": "Acme S.A.", "email": "x@y.z"});
+        let label = human_label_for_record(&v, &id);
+        assert!(label.starts_with("Acme S.A."));
+        assert!(label.contains(&short_uuid(&id)));
+    }
+
+    #[test]
+    fn human_label_falls_back_through_label_title_sku() {
+        let id = Uuid::new_v4();
+        let only_label = json!({"label": "X"});
+        assert!(human_label_for_record(&only_label, &id).starts_with("X "));
+        let only_title = json!({"title": "Y"});
+        assert!(human_label_for_record(&only_title, &id).starts_with("Y "));
+        let only_sku = json!({"sku": "Z"});
+        assert!(human_label_for_record(&only_sku, &id).starts_with("Z "));
+        let only_sku_id = json!({"sku_id": "W"});
+        assert!(human_label_for_record(&only_sku_id, &id).starts_with("W "));
+    }
+
+    #[test]
+    fn human_label_falls_back_to_short_uuid_when_no_keys_match() {
+        let id = Uuid::new_v4();
+        let v = json!({"random": "field"});
+        assert_eq!(human_label_for_record(&v, &id), short_uuid(&id));
+    }
+
+    #[test]
+    fn human_label_recognizes_spanish_name_fields() {
+        let id = Uuid::new_v4();
+        assert!(human_label_for_record(&json!({"nombre": "Acme"}), &id).starts_with("Acme "));
+        assert!(human_label_for_record(&json!({"titulo": "Trato"}), &id).starts_with("Trato "));
+    }
+
+    #[test]
+    fn format_value_number_groups_thousands() {
+        assert_eq!(
+            format_value(Some(&json!(12000)), &ValueFormat::Number),
+            "12,000"
+        );
+        assert_eq!(format_value(Some(&json!(5)), &ValueFormat::Number), "5");
+        assert_eq!(
+            format_value(Some(&json!(-1234567)), &ValueFormat::Number),
+            "-1,234,567"
+        );
+    }
+
+    #[test]
+    fn format_value_currency_prefixes_symbol() {
+        let fmt = ValueFormat::Currency { symbol: "$".into() };
+        assert_eq!(format_value(Some(&json!(25000)), &fmt), "$25,000");
+    }
+
+    #[test]
+    fn format_value_float_gets_two_decimals() {
+        assert_eq!(
+            format_value(Some(&json!(1234.5)), &ValueFormat::Number),
+            "1,234.50"
+        );
+    }
+
+    #[test]
+    fn cmp_values_orders_numbers_strings_nulls() {
+        // Números por valor, no lexicográfico.
+        assert_eq!(
+            cmp_values(Some(&json!(2)), Some(&json!(10))),
+            Ordering::Less
+        );
+        // Strings case-insensitive.
+        assert_eq!(
+            cmp_values(Some(&json!("banana")), Some(&json!("Apple"))),
+            Ordering::Greater
+        );
+        // null/None ordena primero.
+        assert_eq!(cmp_values(None, Some(&json!(1))), Ordering::Less);
+        assert_eq!(
+            cmp_values(Some(&Value::Null), Some(&json!("x"))),
+            Ordering::Less
+        );
+        assert_eq!(
+            cmp_values(Some(&json!(5)), Some(&json!(5))),
+            Ordering::Equal
+        );
+        // Bools.
+        assert_eq!(
+            cmp_values(Some(&json!(false)), Some(&json!(true))),
+            Ordering::Less
+        );
+    }
+
+    #[test]
+    fn format_value_non_number_falls_back_to_render_value() {
+        assert_eq!(
+            format_value(Some(&json!("hola")), &ValueFormat::Plain),
+            "hola"
+        );
+        let fmt = ValueFormat::Currency { symbol: "$".into() };
+        assert_eq!(format_value(Some(&json!("x")), &fmt), "x");
+        assert_eq!(format_value(None, &ValueFormat::Number), "");
+    }
+
+    #[test]
+    fn render_value_handles_basic_kinds() {
+        assert_eq!(render_value(None), "");
+        assert_eq!(render_value(Some(&Value::Null)), "");
+        assert_eq!(render_value(Some(&json!("hola"))), "hola");
+        assert_eq!(render_value(Some(&json!(true))), "✓");
+        assert_eq!(render_value(Some(&json!(false))), "✗");
+        assert_eq!(render_value(Some(&json!(42))), "42");
+    }
+
+    #[test]
+    fn value_to_input_text_round_trip_with_strings_and_numbers() {
+        assert_eq!(value_to_input_text(&Value::Null), "");
+        assert_eq!(value_to_input_text(&json!("x")), "x");
+        assert_eq!(value_to_input_text(&json!(true)), "true");
+        assert_eq!(value_to_input_text(&json!(false)), "false");
+        assert_eq!(value_to_input_text(&json!(42)), "42");
+    }
+
+    #[test]
+    fn short_hash_takes_first_4_bytes_hex() {
+        let mut h = [0u8; 32];
+        h[0] = 0xaa;
+        h[1] = 0xbb;
+        h[2] = 0xcc;
+        h[3] = 0xdd;
+        assert_eq!(short_hash(&h), "aabbccdd");
+    }
+
+    #[test]
+    fn short_hash_zeros() {
+        let h = [0u8; 32];
+        assert_eq!(short_hash(&h), "00000000");
+    }
+
+    #[test]
+    fn preview_value_keeps_short_strings_intact() {
+        let v = json!({"a": 1});
+        assert_eq!(preview_value(&v, 30), "{\"a\":1}");
+    }
+
+    #[test]
+    fn preview_value_truncates_long_strings_with_ellipsis() {
+        let v = json!({"a": "x".repeat(200)});
+        let p = preview_value(&v, 30);
+        assert!(p.chars().count() <= 30);
+        assert!(p.ends_with("..."));
+    }
+
+    #[test]
+    fn preview_value_handles_max_smaller_than_ellipsis() {
+        // Edge case: max < 3 (no espacio para "..."). Devuelve
+        // los primeros `max` chars sin sufijo, sin panic.
+        let v = json!("xxxxxxxxxxxxxxxx");
+        let p = preview_value(&v, 2);
+        assert!(p.chars().count() <= 2);
+    }
+
+    #[test]
+    fn short_uuid_returns_first_8_chars() {
+        let id = Uuid::parse_str("01ARZ3ND-EKTS-V4RR-FFQ6-9G5FAV000000").ok();
+        // Si el parse falla, usamos uno fresco — el invariant es la
+        // longitud, no el contenido.
+        let id = id.unwrap_or_else(Uuid::new_v4);
+        assert_eq!(short_uuid(&id).len(), 8);
+    }
+}
diff --git a/02_ruway/nahual/libs/meta-runtime/src/lib.rs b/02_ruway/nahual/libs/meta-runtime/src/lib.rs
new file mode 100644
index 0000000..e3d4e38
--- /dev/null
+++ b/02_ruway/nahual/libs/meta-runtime/src/lib.rs
@@ -0,0 +1,46 @@
+//! `nahual-meta-runtime` — helpers puros para runtimes metainterfaz.
+//!
+//! Consume [`nahual_meta_schema`] (los tipos `Module`/`View`/`FieldSpec`/
+//! `FieldKind`/`Action`/etc.) y aporta funciones puras que cualquier
+//! widget renderer o backend ejecutor necesita:
+//!
+//! - **Parse**: convertir el texto de un input a `serde_json::Value`
+//!   tipado según el `FieldKind` del spec.
+//! - **Delta**: calcular qué cambió entre el estado actual y la
+//!   propuesta del form (Set + Clear).
+//! - **Validation**: verificar que cada EntityRef apunte a un record
+//!   que existe (toma cierre `load`, no trait).
+//! - **Format**: presentación humana de records (label heurístico,
+//!   render de values, UUID corto, round-trip a input text).
+//!
+//! Sin GPUI, sin acoplamiento a un backend específico. Cualquier
+//! implementación de store/log puede consumirlos.
+//!
+//! El widget render (form/list/modal) vive en otro crate nahual
+//! que esto consume; el runtime concreto (`nakui-ui`) implementa la
+//! conexión a su event-log/executor y compone ambos.
+
+#![forbid(unsafe_code)]
+
+pub mod backend;
+pub mod csv;
+pub mod delta;
+pub mod format;
+pub mod metric;
+pub mod parse;
+pub mod refs;
+pub mod testing;
+
+pub use backend::{MetaBackend, WriteOutcome};
+pub use csv::to_csv;
+pub use delta::{compute_clear_fields, compute_field_delta};
+pub use format::{
+    cmp_values, format_value, human_label_for_record, preview_value, render_value, short_hash,
+    short_uuid, value_to_input_text,
+};
+pub use metric::{
+    breakdown_to_csv, bucket_date, compute_metric, cumulative_breakdown, limit_breakdown,
+    record_matches, sort_breakdown_by_key, MetricResult, OTROS_LABEL,
+};
+pub use parse::{infer_param_value, parse_field_value, resolve_param_value};
+pub use refs::validate_entity_refs;
diff --git a/02_ruway/nahual/libs/meta-runtime/src/metric.rs b/02_ruway/nahual/libs/meta-runtime/src/metric.rs
new file mode 100644
index 0000000..1102d93
--- /dev/null
+++ b/02_ruway/nahual/libs/meta-runtime/src/metric.rs
@@ -0,0 +1,917 @@
+//! Cómputo de los agregados de un tablero (`DashboardCard`).
+
+use std::collections::BTreeMap;
+
+use serde_json::Value;
+use uuid::Uuid;
+
+use std::cmp::Ordering;
+
+use nahual_meta_schema::{CardFilter, DateBucket, FilterOp, Metric};
+
+/// Resultado de computar una [`Metric`] sobre un conjunto de records.
+#[derive(Debug, Clone, PartialEq)]
+pub enum MetricResult {
+    /// Un único número — `Count` / `Sum` / `Avg` / `Min` / `Max`.
+    Scalar(f64),
+    /// Conteo por grupo, ordenado de mayor a menor — `GroupBy`.
+    Breakdown(Vec<(String, usize)>),
+    /// Valor numérico agregado por grupo, ordenado de mayor a menor —
+    /// `SumBy` / `AvgBy`. Se formatea con el `ValueFormat` de la
+    /// tarjeta (p.ej. moneda), a diferencia del conteo de `Breakdown`.
+    ValueBreakdown(Vec<(String, f64)>),
+    /// Desglose de **dos dimensiones** — `SumBySeries`. `groups` es el
+    /// eje principal (x), ordenado; `series` es una lista de
+    /// `(etiqueta_serie, valores)` donde `valores[i]` es el agregado de
+    /// esa serie en `groups[i]` (0.0 si no hay datos). Cada serie queda
+    /// alineada 1:1 con `groups`. Las series se ordenan por su total
+    /// descendente.
+    MultiBreakdown {
+        groups: Vec<String>,
+        series: Vec<(String, Vec<f64>)>,
+    },
+}
+
+/// Computa el agregado de una tarjeta sobre `records`, aplicando el
+/// `filter` si lo hay.
+pub fn compute_metric(
+    metric: &Metric,
+    filter: Option<&CardFilter>,
+    records: &[(Uuid, Value)],
+) -> MetricResult {
+    let passes = |v: &Value| match filter {
+        None => true,
+        Some(f) => filter_passes(v, f),
+    };
+    match metric {
+        Metric::Count => {
+            let n = records.iter().filter(|(_, v)| passes(v)).count();
+            MetricResult::Scalar(n as f64)
+        }
+        Metric::Sum { field } => {
+            let total: f64 = records
+                .iter()
+                .filter(|(_, v)| passes(v))
+                .filter_map(|(_, v)| v.get(field).and_then(Value::as_f64))
+                .sum();
+            MetricResult::Scalar(total)
+        }
+        Metric::Avg { field } => {
+            let nums: Vec<f64> = records
+                .iter()
+                .filter(|(_, v)| passes(v))
+                .filter_map(|(_, v)| v.get(field).and_then(Value::as_f64))
+                .collect();
+            let avg = if nums.is_empty() {
+                0.0
+            } else {
+                nums.iter().sum::<f64>() / nums.len() as f64
+            };
+            MetricResult::Scalar(avg)
+        }
+        Metric::Min { field } => {
+            let m = records
+                .iter()
+                .filter(|(_, v)| passes(v))
+                .filter_map(|(_, v)| v.get(field).and_then(Value::as_f64))
+                .fold(f64::INFINITY, f64::min);
+            MetricResult::Scalar(if m.is_finite() { m } else { 0.0 })
+        }
+        Metric::Max { field } => {
+            let m = records
+                .iter()
+                .filter(|(_, v)| passes(v))
+                .filter_map(|(_, v)| v.get(field).and_then(Value::as_f64))
+                .fold(f64::NEG_INFINITY, f64::max);
+            MetricResult::Scalar(if m.is_finite() { m } else { 0.0 })
+        }
+        Metric::CountDistinct { field } => {
+            let distinct: std::collections::BTreeSet<String> = records
+                .iter()
+                .filter(|(_, v)| passes(v))
+                .filter_map(|(_, v)| field_as_text(v, field).filter(|s| !s.is_empty()))
+                .collect();
+            MetricResult::Scalar(distinct.len() as f64)
+        }
+        Metric::GroupBy { field } => {
+            let mut counts: BTreeMap<String, usize> = BTreeMap::new();
+            for (_, v) in records.iter().filter(|(_, v)| passes(v)) {
+                let key = field_as_text(v, field).unwrap_or_else(|| "(vacío)".to_string());
+                *counts.entry(key).or_default() += 1;
+            }
+            let mut ranked: Vec<(String, usize)> = counts.into_iter().collect();
+            // Mayor conteo primero; empates ordenados por nombre.
+            ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
+            MetricResult::Breakdown(ranked)
+        }
+        Metric::SumBy { group, value } => {
+            MetricResult::ValueBreakdown(grouped_aggregate(records, &passes, group, value, false))
+        }
+        Metric::AvgBy { group, value } => {
+            MetricResult::ValueBreakdown(grouped_aggregate(records, &passes, group, value, true))
+        }
+        Metric::SumBySeries {
+            group,
+            series,
+            value,
+        } => series_aggregate(records, &passes, group, series, value),
+    }
+}
+
+/// Acumula `value` por cada combinación de `group` × `series`,
+/// devolviendo un [`MetricResult::MultiBreakdown`]: el eje `groups`
+/// ordenado por total descendente y, por cada serie, sus valores
+/// alineados 1:1 con `groups` (0.0 donde no hay datos). Las series
+/// también se ordenan por total descendente.
+fn series_aggregate(
+    records: &[(Uuid, Value)],
+    passes: &impl Fn(&Value) -> bool,
+    group: &str,
+    series: &str,
+    value: &str,
+) -> MetricResult {
+    // (grupo, serie) → suma.
+    let mut acc: BTreeMap<(String, String), f64> = BTreeMap::new();
+    // Totales para ordenar ejes.
+    let mut group_total: BTreeMap<String, f64> = BTreeMap::new();
+    let mut series_total: BTreeMap<String, f64> = BTreeMap::new();
+    for (_, v) in records.iter().filter(|(_, v)| passes(v)) {
+        let Some(n) = v.get(value).and_then(Value::as_f64) else {
+            continue;
+        };
+        let g = field_as_text(v, group).unwrap_or_else(|| "(vacío)".to_string());
+        let s = field_as_text(v, series).unwrap_or_else(|| "(vacío)".to_string());
+        *acc.entry((g.clone(), s.clone())).or_default() += n;
+        *group_total.entry(g).or_default() += n;
+        *series_total.entry(s).or_default() += n;
+    }
+    // Ejes ordenados por total desc, empates por nombre.
+    let rank = |m: BTreeMap<String, f64>| -> Vec<String> {
+        let mut v: Vec<(String, f64)> = m.into_iter().collect();
+        v.sort_by(|a, b| {
+            b.1.partial_cmp(&a.1)
+                .unwrap_or(Ordering::Equal)
+                .then_with(|| a.0.cmp(&b.0))
+        });
+        v.into_iter().map(|(k, _)| k).collect()
+    };
+    let groups = rank(group_total);
+    let series_keys = rank(series_total);
+    let series: Vec<(String, Vec<f64>)> = series_keys
+        .into_iter()
+        .map(|s| {
+            let row = groups
+                .iter()
+                .map(|g| acc.get(&(g.clone(), s.clone())).copied().unwrap_or(0.0))
+                .collect();
+            (s, row)
+        })
+        .collect();
+    MetricResult::MultiBreakdown { groups, series }
+}
+
+/// Acumula `value` por cada valor distinto de `group`, devolviendo la
+/// suma (`avg = false`) o el promedio (`avg = true`) por grupo,
+/// ordenado de mayor a menor (empates por nombre de grupo).
+fn grouped_aggregate(
+    records: &[(Uuid, Value)],
+    passes: &impl Fn(&Value) -> bool,
+    group: &str,
+    value: &str,
+    avg: bool,
+) -> Vec<(String, f64)> {
+    // (suma, cuenta-de-numéricos) por grupo.
+    let mut acc: BTreeMap<String, (f64, usize)> = BTreeMap::new();
+    for (_, v) in records.iter().filter(|(_, v)| passes(v)) {
+        let key = field_as_text(v, group).unwrap_or_else(|| "(vacío)".to_string());
+        let entry = acc.entry(key).or_insert((0.0, 0));
+        if let Some(n) = v.get(value).and_then(Value::as_f64) {
+            entry.0 += n;
+            entry.1 += 1;
+        }
+    }
+    let mut ranked: Vec<(String, f64)> = acc
+        .into_iter()
+        .map(|(k, (sum, count))| {
+            let out = if avg && count > 0 {
+                sum / count as f64
+            } else if avg {
+                0.0
+            } else {
+                sum
+            };
+            (k, out)
+        })
+        .collect();
+    ranked.sort_by(|a, b| {
+        b.1.partial_cmp(&a.1)
+            .unwrap_or(std::cmp::Ordering::Equal)
+            .then_with(|| a.0.cmp(&b.0))
+    });
+    ranked
+}
+
+/// Versión pública del predicado de filtro: decide si un record entra
+/// dado un [`CardFilter`]. Útil para componer filtros fuera del motor
+/// (p.ej. controles interactivos que pre-filtran los records).
+pub fn record_matches(v: &Value, f: &CardFilter) -> bool {
+    filter_passes(v, f)
+}
+
+/// Decide si un record pasa el filtro de una tarjeta. Las comparaciones
+/// de orden (`gt`/`lt`/`between`) son numéricas cuando ambos lados
+/// parsean como número, y lexicográficas si no — lo que cubre rangos
+/// de fecha en ISO-8601 sin parser de fechas.
+fn filter_passes(v: &Value, f: &CardFilter) -> bool {
+    let cell = field_as_text(v, &f.field);
+    match f.op {
+        FilterOp::Eq => cell.as_deref() == f.value.as_deref(),
+        FilterOp::Ne => cell.as_deref() != f.value.as_deref(),
+        FilterOp::NonEmpty => cell.map(|s| !s.is_empty()).unwrap_or(false),
+        FilterOp::Gt | FilterOp::Gte | FilterOp::Lt | FilterOp::Lte => {
+            let (Some(cell), Some(bound)) = (cell, f.value.as_ref()) else {
+                return false;
+            };
+            let ord = cmp_text(&cell, bound);
+            match f.op {
+                FilterOp::Gt => ord == Ordering::Greater,
+                FilterOp::Gte => ord != Ordering::Less,
+                FilterOp::Lt => ord == Ordering::Less,
+                FilterOp::Lte => ord != Ordering::Greater,
+                _ => unreachable!(),
+            }
+        }
+        FilterOp::Between => {
+            let Some(cell) = cell else {
+                return false;
+            };
+            let lo_ok = f
+                .min
+                .as_ref()
+                .map_or(true, |lo| cmp_text(&cell, lo) != Ordering::Less);
+            let hi_ok = f
+                .max
+                .as_ref()
+                .map_or(true, |hi| cmp_text(&cell, hi) != Ordering::Greater);
+            lo_ok && hi_ok
+        }
+    }
+}
+
+/// Orden entre dos valores como texto: numérico si ambos parsean,
+/// lexicográfico en caso contrario.
+fn cmp_text(a: &str, b: &str) -> Ordering {
+    match (a.parse::<f64>(), b.parse::<f64>()) {
+        (Ok(x), Ok(y)) => x.partial_cmp(&y).unwrap_or(Ordering::Equal),
+        _ => a.cmp(b),
+    }
+}
+
+/// Serializa un desglose (`Breakdown` conteo o `ValueBreakdown` valor)
+/// a CSV de dos columnas. `value_header` rotula la segunda columna.
+/// Reusa el `to_csv` del runtime para el quoting.
+pub fn breakdown_to_csv(
+    result: &MetricResult,
+    group_header: &str,
+    value_header: &str,
+) -> Option<String> {
+    let rows: Vec<Vec<String>> = match result {
+        MetricResult::Breakdown(rows) => rows
+            .iter()
+            .map(|(k, n)| vec![k.clone(), n.to_string()])
+            .collect(),
+        MetricResult::ValueBreakdown(rows) => rows
+            .iter()
+            .map(|(k, v)| {
+                let n = if v.fract() == 0.0 {
+                    format!("{}", *v as i64)
+                } else {
+                    v.to_string()
+                };
+                vec![k.clone(), n]
+            })
+            .collect(),
+        // Matriz: una columna por serie. `value_header` se ignora — los
+        // encabezados de valor son los nombres de las series.
+        MetricResult::MultiBreakdown { groups, series } => {
+            let mut headers = vec![group_header.to_string()];
+            headers.extend(series.iter().map(|(name, _)| name.clone()));
+            let rows: Vec<Vec<String>> = groups
+                .iter()
+                .enumerate()
+                .map(|(i, g)| {
+                    let mut row = vec![g.clone()];
+                    for (_, vals) in series {
+                        let v = vals.get(i).copied().unwrap_or(0.0);
+                        let n = if v.fract() == 0.0 {
+                            format!("{}", v as i64)
+                        } else {
+                            v.to_string()
+                        };
+                        row.push(n);
+                    }
+                    row
+                })
+                .collect();
+            return Some(crate::csv::to_csv(&headers, &rows));
+        }
+        MetricResult::Scalar(_) => return None,
+    };
+    Some(crate::csv::to_csv(
+        &[group_header.to_string(), value_header.to_string()],
+        &rows,
+    ))
+}
+
+/// Etiqueta de la fila que agrupa el resto de un desglose recortado.
+pub const OTROS_LABEL: &str = "Otros";
+
+/// Recorta un desglose a sus `limit` filas de mayor valor (el motor ya
+/// las entrega ordenadas de mayor a menor) y colapsa el resto en una
+/// fila [`OTROS_LABEL`]. Devuelve `true` si efectivamente agregó esa
+/// fila (hubo más de `limit` grupos), para que la capa de presentación
+/// la marque como no-navegable.
+///
+/// El valor de "Otros" es la **suma** del resto para conteos
+/// (`GroupBy`) y para sumas (`SumBy`, `additive = true`); para
+/// promedios (`AvgBy`, `additive = false`) es el promedio aritmético de
+/// los valores de los grupos restantes (aproximación clara: la fila
+/// "Otros" no es un grupo real). `limit == 0` no recorta nada.
+pub fn limit_breakdown(result: &mut MetricResult, limit: usize, additive: bool) -> bool {
+    if limit == 0 {
+        return false;
+    }
+    match result {
+        MetricResult::Breakdown(rows) if rows.len() > limit => {
+            let tail: usize = rows[limit..].iter().map(|(_, n)| *n).sum();
+            rows.truncate(limit);
+            rows.push((OTROS_LABEL.to_string(), tail));
+            true
+        }
+        MetricResult::ValueBreakdown(rows) if rows.len() > limit => {
+            let rest = &rows[limit..];
+            let value = if additive {
+                rest.iter().map(|(_, v)| *v).sum()
+            } else {
+                let sum: f64 = rest.iter().map(|(_, v)| *v).sum();
+                sum / rest.len() as f64
+            };
+            rows.truncate(limit);
+            rows.push((OTROS_LABEL.to_string(), value));
+            true
+        }
+        // El recorte top-N no aplica a desgloses de dos dimensiones.
+        _ => false,
+    }
+}
+
+/// Trunca una fecha ISO-8601 a la granularidad pedida, para agrupar
+/// series temporales (`2026-01-15T10:30Z` / `2026-01-15` → `2026`,
+/// `2026-01` o `2026-01-15`). Si `raw` no empieza con un año de 4
+/// dígitos seguido de `-` (o no es una fecha reconocible), se devuelve
+/// sin cambios — el bucketing es inofensivo sobre datos no-fecha.
+pub fn bucket_date(raw: &str, bucket: DateBucket) -> String {
+    // Reconocer el prefijo `YYYY-`: 4 dígitos + guión.
+    let looks_like_date = raw.len() >= 5
+        && raw.as_bytes()[..4].iter().all(u8::is_ascii_digit)
+        && raw.as_bytes()[4] == b'-';
+    if !looks_like_date {
+        return raw.to_string();
+    }
+    // Fecha = los primeros 10 chars (`YYYY-MM-DD`) si los hay.
+    let date = raw.get(0..10).unwrap_or(raw);
+    let end = match bucket {
+        DateBucket::Year => 4,
+        DateBucket::Month => 7,
+        DateBucket::Day => 10,
+    };
+    date.get(0..end).unwrap_or(date).to_string()
+}
+
+/// Reordena las filas de un desglose por su clave de grupo ascendente
+/// (lexicográfico — coincide con el orden cronológico para claves ISO
+/// de [`bucket_date`]). No-op sobre escalares.
+pub fn sort_breakdown_by_key(result: &mut MetricResult) {
+    match result {
+        MetricResult::Breakdown(rows) => rows.sort_by(|a, b| a.0.cmp(&b.0)),
+        MetricResult::ValueBreakdown(rows) => rows.sort_by(|a, b| a.0.cmp(&b.0)),
+        // Reordena el eje `groups` por clave y permuta cada serie con la
+        // misma permutación, manteniendo la alineación 1:1.
+        MetricResult::MultiBreakdown { groups, series } => {
+            let mut perm: Vec<usize> = (0..groups.len()).collect();
+            perm.sort_by(|&a, &b| groups[a].cmp(&groups[b]));
+            *groups = perm.iter().map(|&i| groups[i].clone()).collect();
+            for (_, vals) in series.iter_mut() {
+                *vals = perm.iter().map(|&i| vals[i]).collect();
+            }
+        }
+        MetricResult::Scalar(_) => {}
+    }
+}
+
+/// Reescribe un desglose ordenado a su **acumulado** (running total):
+/// cada valor pasa a ser la suma corrida de sí mismo y todos los
+/// anteriores. Pensado para series temporales (orden cronológico, vía
+/// `bucket`) — p.ej. el saldo acumulado de tesorería mes a mes. En
+/// multi-serie, cada serie acumula de forma independiente a lo largo
+/// del eje de grupos. `Breakdown` (conteo) se acumula igual. No-op
+/// sobre `Scalar`. Asume que `result` ya está en el orden deseado
+/// (típicamente tras `sort_breakdown_by_key`); sólo tiene sentido sobre
+/// métricas aditivas (`Count`/`Sum`).
+pub fn cumulative_breakdown(result: &mut MetricResult) {
+    match result {
+        MetricResult::Breakdown(rows) => {
+            let mut acc = 0usize;
+            for (_, v) in rows.iter_mut() {
+                acc += *v;
+                *v = acc;
+            }
+        }
+        MetricResult::ValueBreakdown(rows) => {
+            let mut acc = 0.0;
+            for (_, v) in rows.iter_mut() {
+                acc += *v;
+                *v = acc;
+            }
+        }
+        MetricResult::MultiBreakdown { series, .. } => {
+            for (_, vals) in series.iter_mut() {
+                let mut acc = 0.0;
+                for v in vals.iter_mut() {
+                    acc += *v;
+                    *v = acc;
+                }
+            }
+        }
+        MetricResult::Scalar(_) => {}
+    }
+}
+
+/// Valor de un campo de nivel superior como texto plano, para comparar
+/// (filtros) o agrupar (`GroupBy`).
+fn field_as_text(v: &Value, field: &str) -> Option<String> {
+    match v.get(field)? {
+        Value::Null => None,
+        Value::String(s) => Some(s.clone()),
+        other => Some(other.to_string()),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use serde_json::json;
+
+    fn recs(items: &[Value]) -> Vec<(Uuid, Value)> {
+        items.iter().map(|v| (Uuid::new_v4(), v.clone())).collect()
+    }
+
+    #[test]
+    fn bucket_date_truncates_iso() {
+        assert_eq!(bucket_date("2026-01-15", DateBucket::Year), "2026");
+        assert_eq!(bucket_date("2026-01-15", DateBucket::Month), "2026-01");
+        assert_eq!(bucket_date("2026-01-15", DateBucket::Day), "2026-01-15");
+        // Con hora.
+        assert_eq!(bucket_date("2026-03-09T10:30:00Z", DateBucket::Month), "2026-03");
+        // No-fecha → sin cambios (inofensivo).
+        assert_eq!(bucket_date("activo", DateBucket::Month), "activo");
+        assert_eq!(bucket_date("", DateBucket::Year), "");
+    }
+
+    #[test]
+    fn sort_breakdown_by_key_is_chronological_for_iso() {
+        let mut r = MetricResult::ValueBreakdown(vec![
+            ("2026-03".into(), 30.0),
+            ("2026-01".into(), 10.0),
+            ("2026-02".into(), 20.0),
+        ]);
+        sort_breakdown_by_key(&mut r);
+        assert_eq!(
+            r,
+            MetricResult::ValueBreakdown(vec![
+                ("2026-01".into(), 10.0),
+                ("2026-02".into(), 20.0),
+                ("2026-03".into(), 30.0),
+            ])
+        );
+    }
+
+    #[test]
+    fn cumulative_breakdown_running_total() {
+        // Serie de valores → saldo acumulado.
+        let mut r = MetricResult::ValueBreakdown(vec![
+            ("2026-01".into(), 10.0),
+            ("2026-02".into(), 20.0),
+            ("2026-03".into(), 5.0),
+        ]);
+        cumulative_breakdown(&mut r);
+        assert_eq!(
+            r,
+            MetricResult::ValueBreakdown(vec![
+                ("2026-01".into(), 10.0),
+                ("2026-02".into(), 30.0),
+                ("2026-03".into(), 35.0),
+            ])
+        );
+        // Conteo acumulado.
+        let mut c = MetricResult::Breakdown(vec![("a".into(), 2), ("b".into(), 3), ("c".into(), 1)]);
+        cumulative_breakdown(&mut c);
+        assert_eq!(
+            c,
+            MetricResult::Breakdown(vec![("a".into(), 2), ("b".into(), 5), ("c".into(), 6)])
+        );
+        // Multi-serie: cada serie acumula por separado.
+        let mut m = MetricResult::MultiBreakdown {
+            groups: vec!["ene".into(), "feb".into(), "mar".into()],
+            series: vec![
+                ("pagado".into(), vec![1.0, 2.0, 3.0]),
+                ("no".into(), vec![10.0, 0.0, 5.0]),
+            ],
+        };
+        cumulative_breakdown(&mut m);
+        assert_eq!(
+            m,
+            MetricResult::MultiBreakdown {
+                groups: vec!["ene".into(), "feb".into(), "mar".into()],
+                series: vec![
+                    ("pagado".into(), vec![1.0, 3.0, 6.0]),
+                    ("no".into(), vec![10.0, 10.0, 15.0]),
+                ],
+            }
+        );
+        // No-op sobre escalar.
+        let mut s = MetricResult::Scalar(42.0);
+        cumulative_breakdown(&mut s);
+        assert_eq!(s, MetricResult::Scalar(42.0));
+    }
+
+    #[test]
+    fn limit_breakdown_counts_sums_the_tail() {
+        let mut r = MetricResult::Breakdown(vec![
+            ("a".into(), 10),
+            ("b".into(), 6),
+            ("c".into(), 3),
+            ("d".into(), 1),
+        ]);
+        let collapsed = limit_breakdown(&mut r, 2, true);
+        assert!(collapsed);
+        assert_eq!(
+            r,
+            MetricResult::Breakdown(vec![
+                ("a".into(), 10),
+                ("b".into(), 6),
+                (OTROS_LABEL.into(), 4), // 3 + 1
+            ])
+        );
+    }
+
+    #[test]
+    fn limit_breakdown_avg_bucket_is_mean_not_sum() {
+        let mut r = MetricResult::ValueBreakdown(vec![
+            ("a".into(), 100.0),
+            ("b".into(), 80.0),
+            ("c".into(), 60.0),
+            ("d".into(), 40.0),
+        ]);
+        // additive = false (AvgBy): el bucket promedia los restantes.
+        let collapsed = limit_breakdown(&mut r, 2, false);
+        assert!(collapsed);
+        assert_eq!(
+            r,
+            MetricResult::ValueBreakdown(vec![
+                ("a".into(), 100.0),
+                ("b".into(), 80.0),
+                (OTROS_LABEL.into(), 50.0), // (60 + 40) / 2
+            ])
+        );
+    }
+
+    #[test]
+    fn limit_breakdown_noop_when_within_limit() {
+        let mut r = MetricResult::Breakdown(vec![("a".into(), 3), ("b".into(), 1)]);
+        let before = r.clone();
+        assert!(!limit_breakdown(&mut r, 5, true));
+        assert!(!limit_breakdown(&mut r, 0, true));
+        assert_eq!(r, before);
+    }
+
+    #[test]
+    fn count_all_and_filtered() {
+        let rs = recs(&[
+            json!({"etapa": "ganada"}),
+            json!({"etapa": "ganada"}),
+            json!({"etapa": "perdida"}),
+        ]);
+        assert_eq!(
+            compute_metric(&Metric::Count, None, &rs),
+            MetricResult::Scalar(3.0)
+        );
+        let f = CardFilter {
+            field: "etapa".into(),
+            op: FilterOp::Eq,
+            value: Some("ganada".into()),
+            min: None,
+            max: None,
+        };
+        assert_eq!(
+            compute_metric(&Metric::Count, Some(&f), &rs),
+            MetricResult::Scalar(2.0)
+        );
+    }
+
+    #[test]
+    fn count_distinct_counts_unique_non_empty_values() {
+        let rs = recs(&[
+            json!({"cliente": "acme"}),
+            json!({"cliente": "acme"}),
+            json!({"cliente": "globex"}),
+            json!({"cliente": ""}),      // vacío → no cuenta
+            json!({"otro": "x"}),        // sin el campo → no cuenta
+        ]);
+        assert_eq!(
+            compute_metric(&Metric::CountDistinct { field: "cliente".into() }, None, &rs),
+            MetricResult::Scalar(2.0) // acme, globex
+        );
+        // Con filtro: sólo los records que pasan entran al set distinto.
+        let f = CardFilter {
+            field: "cliente".into(),
+            op: FilterOp::Eq,
+            value: Some("acme".into()),
+            min: None,
+            max: None,
+        };
+        assert_eq!(
+            compute_metric(&Metric::CountDistinct { field: "cliente".into() }, Some(&f), &rs),
+            MetricResult::Scalar(1.0)
+        );
+    }
+
+    fn filt(field: &str, op: FilterOp, value: Option<&str>) -> CardFilter {
+        CardFilter {
+            field: field.into(),
+            op,
+            value: value.map(Into::into),
+            min: None,
+            max: None,
+        }
+    }
+
+    #[test]
+    fn numeric_range_filters() {
+        let rs = recs(&[
+            json!({"monto": 100}),
+            json!({"monto": 500}),
+            json!({"monto": 900}),
+        ]);
+        // gte 500 → 500 y 900.
+        assert_eq!(
+            compute_metric(&Metric::Count, Some(&filt("monto", FilterOp::Gte, Some("500"))), &rs),
+            MetricResult::Scalar(2.0)
+        );
+        // lt 500 → solo 100.
+        assert_eq!(
+            compute_metric(&Metric::Count, Some(&filt("monto", FilterOp::Lt, Some("500"))), &rs),
+            MetricResult::Scalar(1.0)
+        );
+        // between [200, 800] → solo 500.
+        let between = CardFilter {
+            field: "monto".into(),
+            op: FilterOp::Between,
+            value: None,
+            min: Some("200".into()),
+            max: Some("800".into()),
+        };
+        assert_eq!(
+            compute_metric(&Metric::Count, Some(&between), &rs),
+            MetricResult::Scalar(1.0)
+        );
+    }
+
+    #[test]
+    fn date_range_is_lexicographic() {
+        let rs = recs(&[
+            json!({"fecha": "2026-01-15"}),
+            json!({"fecha": "2026-06-30"}),
+            json!({"fecha": "2027-02-01"}),
+        ]);
+        let q1_h1 = CardFilter {
+            field: "fecha".into(),
+            op: FilterOp::Between,
+            value: None,
+            min: Some("2026-01-01".into()),
+            max: Some("2026-12-31".into()),
+        };
+        assert_eq!(
+            compute_metric(&Metric::Count, Some(&q1_h1), &rs),
+            MetricResult::Scalar(2.0)
+        );
+    }
+
+    #[test]
+    fn non_empty_filter() {
+        let rs = recs(&[json!({"nota": "x"}), json!({"nota": ""}), json!({"otro": 1})]);
+        assert_eq!(
+            compute_metric(&Metric::Count, Some(&filt("nota", FilterOp::NonEmpty, None)), &rs),
+            MetricResult::Scalar(1.0)
+        );
+    }
+
+    #[test]
+    fn breakdown_csv_roundtrip() {
+        let res = MetricResult::ValueBreakdown(vec![
+            ("ACME".into(), 1500.0),
+            ("Globex".into(), 2000.0),
+        ]);
+        let csv = breakdown_to_csv(&res, "Cliente", "Monto").unwrap();
+        assert_eq!(csv, "Cliente,Monto\nACME,1500\nGlobex,2000\n");
+        assert!(breakdown_to_csv(&MetricResult::Scalar(1.0), "a", "b").is_none());
+    }
+
+    #[test]
+    fn sum_skips_missing_and_non_numeric() {
+        let rs = recs(&[
+            json!({"monto": 1000}),
+            json!({"monto": 2500}),
+            json!({"otro": 1}),
+        ]);
+        assert_eq!(
+            compute_metric(
+                &Metric::Sum {
+                    field: "monto".into()
+                },
+                None,
+                &rs
+            ),
+            MetricResult::Scalar(3500.0)
+        );
+    }
+
+    #[test]
+    fn group_by_counts_and_ranks_by_frequency() {
+        let rs = recs(&[
+            json!({"etapa": "prospecto"}),
+            json!({"etapa": "ganada"}),
+            json!({"etapa": "ganada"}),
+        ]);
+        assert_eq!(
+            compute_metric(
+                &Metric::GroupBy {
+                    field: "etapa".into()
+                },
+                None,
+                &rs
+            ),
+            MetricResult::Breakdown(vec![
+                ("ganada".to_string(), 2),
+                ("prospecto".to_string(), 1),
+            ])
+        );
+    }
+
+    #[test]
+    fn avg_min_max_over_numeric() {
+        let rs = recs(&[
+            json!({"monto": 100}),
+            json!({"monto": 300}),
+            json!({"otro": 1}), // ignorado
+        ]);
+        assert_eq!(
+            compute_metric(&Metric::Avg { field: "monto".into() }, None, &rs),
+            MetricResult::Scalar(200.0)
+        );
+        assert_eq!(
+            compute_metric(&Metric::Min { field: "monto".into() }, None, &rs),
+            MetricResult::Scalar(100.0)
+        );
+        assert_eq!(
+            compute_metric(&Metric::Max { field: "monto".into() }, None, &rs),
+            MetricResult::Scalar(300.0)
+        );
+    }
+
+    #[test]
+    fn avg_empty_is_zero_not_nan() {
+        let rs = recs(&[json!({"otro": 1})]);
+        assert_eq!(
+            compute_metric(&Metric::Avg { field: "monto".into() }, None, &rs),
+            MetricResult::Scalar(0.0)
+        );
+        assert_eq!(
+            compute_metric(&Metric::Min { field: "monto".into() }, None, &rs),
+            MetricResult::Scalar(0.0)
+        );
+    }
+
+    #[test]
+    fn sum_by_aggregates_and_ranks_by_value() {
+        let rs = recs(&[
+            json!({"cliente": "ACME", "monto": 1000}),
+            json!({"cliente": "ACME", "monto": 500}),
+            json!({"cliente": "Globex", "monto": 2000}),
+        ]);
+        assert_eq!(
+            compute_metric(
+                &Metric::SumBy {
+                    group: "cliente".into(),
+                    value: "monto".into()
+                },
+                None,
+                &rs
+            ),
+            MetricResult::ValueBreakdown(vec![
+                ("Globex".to_string(), 2000.0),
+                ("ACME".to_string(), 1500.0),
+            ])
+        );
+    }
+
+    #[test]
+    fn sum_by_series_builds_aligned_matrix() {
+        let rs = recs(&[
+            json!({"mes": "2026-01", "plan": "pro", "monto": 100}),
+            json!({"mes": "2026-01", "plan": "free", "monto": 30}),
+            json!({"mes": "2026-02", "plan": "pro", "monto": 200}),
+            // free no factura en feb → 0.0 alineado.
+        ]);
+        let r = compute_metric(
+            &Metric::SumBySeries {
+                group: "mes".into(),
+                series: "plan".into(),
+                value: "monto".into(),
+            },
+            None,
+            &rs,
+        );
+        // groups por total desc: feb(200) > ene(130). series por total
+        // desc: pro(300) > free(30).
+        assert_eq!(
+            r,
+            MetricResult::MultiBreakdown {
+                groups: vec!["2026-02".into(), "2026-01".into()],
+                series: vec![
+                    ("pro".into(), vec![200.0, 100.0]),
+                    ("free".into(), vec![0.0, 30.0]),
+                ],
+            }
+        );
+    }
+
+    #[test]
+    fn sort_multi_breakdown_permutes_series() {
+        let mut r = MetricResult::MultiBreakdown {
+            groups: vec!["2026-02".into(), "2026-01".into()],
+            series: vec![
+                ("pro".into(), vec![200.0, 100.0]),
+                ("free".into(), vec![0.0, 30.0]),
+            ],
+        };
+        sort_breakdown_by_key(&mut r);
+        // groups cronológicos; cada serie sigue la misma permutación.
+        assert_eq!(
+            r,
+            MetricResult::MultiBreakdown {
+                groups: vec!["2026-01".into(), "2026-02".into()],
+                series: vec![
+                    ("pro".into(), vec![100.0, 200.0]),
+                    ("free".into(), vec![30.0, 0.0]),
+                ],
+            }
+        );
+    }
+
+    #[test]
+    fn multi_breakdown_csv_is_a_matrix() {
+        let r = MetricResult::MultiBreakdown {
+            groups: vec!["2026-01".into(), "2026-02".into()],
+            series: vec![
+                ("pro".into(), vec![100.0, 200.0]),
+                ("free".into(), vec![30.0, 0.0]),
+            ],
+        };
+        let csv = breakdown_to_csv(&r, "Mes", "ignorado").unwrap();
+        assert_eq!(csv, "Mes,pro,free\n2026-01,100,30\n2026-02,200,0\n");
+    }
+
+    #[test]
+    fn avg_by_is_per_group_mean() {
+        let rs = recs(&[
+            json!({"plan": "pro", "monto": 100}),
+            json!({"plan": "pro", "monto": 300}),
+            json!({"plan": "free", "monto": 50}),
+        ]);
+        assert_eq!(
+            compute_metric(
+                &Metric::AvgBy {
+                    group: "plan".into(),
+                    value: "monto".into()
+                },
+                None,
+                &rs
+            ),
+            MetricResult::ValueBreakdown(vec![
+                ("pro".to_string(), 200.0),
+                ("free".to_string(), 50.0),
+            ])
+        );
+    }
+}
diff --git a/02_ruway/nahual/libs/meta-runtime/src/parse.rs b/02_ruway/nahual/libs/meta-runtime/src/parse.rs
new file mode 100644
index 0000000..5a94aba
--- /dev/null
+++ b/02_ruway/nahual/libs/meta-runtime/src/parse.rs
@@ -0,0 +1,263 @@
+//! Parseo de inputs del form a `serde_json::Value` tipado.
+
+use serde_json::{json, Value};
+use uuid::Uuid;
+
+use nahual_meta_schema::{FieldKind, FieldSpec};
+
+/// Convierte el texto raw de un input al `Value` tipado según el
+/// `kind` del spec.
+///
+/// - `Text` / `Multiline` / `Date` → string passthrough.
+/// - `EntityRef` → string del UUID **trimmed**, validado como UUID
+///   parseable. Falla con mensaje claro si no parsea.
+/// - `Boolean` → variantes comunes (`true/yes/1/on/y` y `false/no/0/off/n`).
+/// - `Number` → i64 si parsea, sino f64.
+pub fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
+    match kind {
+        // Select y AutoId guardan un string: el valor de la opción
+        // elegida y el UUID autogenerado, respectivamente.
+        FieldKind::Text
+        | FieldKind::Multiline
+        | FieldKind::Date
+        | FieldKind::Select
+        | FieldKind::AutoId => Ok(json!(raw)),
+        // EntityRef se almacena como string del UUID seleccionado.
+        // El selector clickable garantiza UUIDs válidos en happy
+        // path; este check protege paste manual o garbage tipeado.
+        FieldKind::EntityRef => {
+            let trimmed = raw.trim();
+            Uuid::parse_str(trimmed)
+                .map_err(|_| format!("'{raw}' no es UUID válido (usá el selector de records)"))?;
+            Ok(json!(trimmed))
+        }
+        FieldKind::Boolean => match raw.to_ascii_lowercase().as_str() {
+            "true" | "yes" | "1" | "on" | "y" => Ok(json!(true)),
+            "" | "false" | "no" | "0" | "off" | "n" => Ok(json!(false)),
+            other => Err(format!("'{other}' no es booleano")),
+        },
+        FieldKind::Number => {
+            if let Ok(i) = raw.parse::<i64>() {
+                Ok(json!(i))
+            } else if let Ok(f) = raw.parse::<f64>() {
+                Ok(json!(f))
+            } else {
+                Err(format!("'{raw}' no es número"))
+            }
+        }
+    }
+}
+
+/// Resuelve un param de morphism a su `Value` según el `FieldSpec`
+/// del form. **Strict path**: si hay spec, valida `required` y parsea
+/// con el `kind` declarado (ej. Boolean rebota con "abc" antes de
+/// llegar al morphism). **Fallback path**: si no hay spec (param
+/// declarado en `Action::Morphism.params` que no aparece en
+/// `form.fields`), usa la heurística [`infer_param_value`] para no
+/// quedar atado a un schema mal-formado.
+///
+/// Errores tienen el label legible del spec, así el toast de la UI
+/// es interpretable.
+pub fn resolve_param_value(
+    field_name: &str,
+    raw: &str,
+    spec: Option<&FieldSpec>,
+) -> Result<Value, String> {
+    let Some(s) = spec else {
+        return Ok(infer_param_value(raw));
+    };
+
+    let label = if s.label.is_empty() {
+        field_name
+    } else {
+        &s.label
+    };
+
+    if s.required && raw.trim().is_empty() {
+        return Err(format!("param '{label}' es obligatorio y está vacío"));
+    }
+    if raw.is_empty() && !s.required {
+        return Ok(Value::Null);
+    }
+    parse_field_value(s.kind, raw).map_err(|e| format!("param '{label}': {e}"))
+}
+
+/// Inferencia de tipo para values pasados como `params` a un
+/// morphism. Usada como fallback en [`resolve_param_value`] cuando el
+/// param declarado en `Action::Morphism.params` no aparece en los
+/// `form.fields` (módulo mal-formado).
+///
+/// Heurística simple: int → i64, float → f64, "true"/"false" → bool,
+/// resto → string.
+pub fn infer_param_value(raw: &str) -> Value {
+    if raw.is_empty() {
+        return Value::Null;
+    }
+    if let Ok(i) = raw.parse::<i64>() {
+        return json!(i);
+    }
+    if let Ok(f) = raw.parse::<f64>() {
+        return json!(f);
+    }
+    match raw {
+        "true" => return json!(true),
+        "false" => return json!(false),
+        _ => {}
+    }
+    json!(raw)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use nahual_meta_schema::FieldSpec;
+
+    fn spec(name: &str, kind: FieldKind, required: bool) -> FieldSpec {
+        FieldSpec {
+            name: name.into(),
+            label: name.into(),
+            kind,
+            default: None,
+            required,
+            help: None,
+            ref_entity: None,
+            options: Vec::new(),
+            section: None,
+        }
+    }
+
+    #[test]
+    fn infer_handles_basic_types() {
+        assert_eq!(infer_param_value(""), Value::Null);
+        assert_eq!(infer_param_value("42"), json!(42));
+        assert_eq!(infer_param_value("2.5"), json!(2.5));
+        assert_eq!(infer_param_value("true"), json!(true));
+        assert_eq!(infer_param_value("false"), json!(false));
+        assert_eq!(infer_param_value("hola"), json!("hola"));
+    }
+
+    #[test]
+    fn parse_text_passthrough() {
+        let v = parse_field_value(FieldKind::Text, "hola").unwrap();
+        assert_eq!(v, json!("hola"));
+    }
+
+    #[test]
+    fn parse_select_and_auto_id_passthrough() {
+        // Select guarda el valor de la opción elegida.
+        assert_eq!(
+            parse_field_value(FieldKind::Select, "ganada").unwrap(),
+            json!("ganada")
+        );
+        // AutoId guarda el UUID autogenerado tal cual.
+        let id = Uuid::new_v4().to_string();
+        assert_eq!(
+            parse_field_value(FieldKind::AutoId, &id).unwrap(),
+            json!(id)
+        );
+    }
+
+    #[test]
+    fn parse_number_i64_or_f64() {
+        assert_eq!(
+            parse_field_value(FieldKind::Number, "42").unwrap(),
+            json!(42)
+        );
+        assert_eq!(
+            parse_field_value(FieldKind::Number, "2.5").unwrap(),
+            json!(2.5)
+        );
+        assert!(parse_field_value(FieldKind::Number, "abc").is_err());
+    }
+
+    #[test]
+    fn parse_boolean_recognizes_variants() {
+        for s in ["true", "yes", "1", "on", "y"] {
+            assert_eq!(
+                parse_field_value(FieldKind::Boolean, s).unwrap(),
+                json!(true)
+            );
+        }
+        for s in ["false", "no", "0", "off", "n", ""] {
+            assert_eq!(
+                parse_field_value(FieldKind::Boolean, s).unwrap(),
+                json!(false)
+            );
+        }
+        assert!(parse_field_value(FieldKind::Boolean, "abc").is_err());
+    }
+
+    #[test]
+    fn parse_entity_ref_accepts_valid_uuid() {
+        let id = Uuid::new_v4();
+        let v = parse_field_value(FieldKind::EntityRef, &id.to_string()).unwrap();
+        assert_eq!(v, json!(id.to_string()));
+    }
+
+    #[test]
+    fn parse_entity_ref_trims_whitespace() {
+        let id = Uuid::new_v4();
+        let padded = format!("  {id}\n");
+        let v = parse_field_value(FieldKind::EntityRef, &padded).unwrap();
+        assert_eq!(v, json!(id.to_string()));
+    }
+
+    #[test]
+    fn parse_entity_ref_rejects_non_uuid() {
+        let err = parse_field_value(FieldKind::EntityRef, "abc-123").unwrap_err();
+        assert!(err.contains("'abc-123'"));
+        assert!(err.contains("UUID") || err.contains("uuid"));
+    }
+
+    #[test]
+    fn parse_entity_ref_rejects_empty_string() {
+        let err = parse_field_value(FieldKind::EntityRef, "").unwrap_err();
+        assert!(err.contains("UUID"));
+    }
+
+    #[test]
+    fn resolve_param_strict_number_parses_i64() {
+        let s = spec("qty", FieldKind::Number, true);
+        let v = resolve_param_value("qty", "42", Some(&s)).unwrap();
+        assert_eq!(v, json!(42));
+    }
+
+    #[test]
+    fn resolve_param_strict_boolean_rejects_non_boolean() {
+        let s = spec("active", FieldKind::Boolean, true);
+        let err = resolve_param_value("active", "abc", Some(&s)).unwrap_err();
+        assert!(err.contains("active"));
+    }
+
+    #[test]
+    fn resolve_param_required_empty_rejected() {
+        let s = spec("name", FieldKind::Text, true);
+        let err = resolve_param_value("name", "   ", Some(&s)).unwrap_err();
+        assert!(err.contains("obligatorio"));
+    }
+
+    #[test]
+    fn resolve_param_optional_empty_returns_null() {
+        let s = spec("notes", FieldKind::Text, false);
+        let v = resolve_param_value("notes", "", Some(&s)).unwrap();
+        assert_eq!(v, Value::Null);
+    }
+
+    #[test]
+    fn resolve_param_no_spec_falls_back_to_infer() {
+        let v = resolve_param_value("foo", "42", None).unwrap();
+        assert_eq!(v, json!(42));
+        let v = resolve_param_value("foo", "true", None).unwrap();
+        assert_eq!(v, json!(true));
+        let v = resolve_param_value("foo", "x", None).unwrap();
+        assert_eq!(v, json!("x"));
+    }
+
+    #[test]
+    fn resolve_param_strict_entity_ref_propagates_error() {
+        let s = spec("stock_ref", FieldKind::EntityRef, true);
+        let err = resolve_param_value("stock_ref", "not-a-uuid", Some(&s)).unwrap_err();
+        assert!(err.contains("stock_ref"));
+        assert!(err.contains("UUID"));
+    }
+}
diff --git a/02_ruway/nahual/libs/meta-runtime/src/refs.rs b/02_ruway/nahual/libs/meta-runtime/src/refs.rs
new file mode 100644
index 0000000..47d679f
--- /dev/null
+++ b/02_ruway/nahual/libs/meta-runtime/src/refs.rs
@@ -0,0 +1,108 @@
+//! Validación cross-field de EntityRefs contra el store actual.
+//!
+//! Decoupling: en vez de un `trait Store` que ate este crate a un
+//! backend específico, tomamos un cierre `load: Fn(&str, Uuid) ->
+//! Option<Value>`. El caller (nakui-ui o cualquier otro runtime)
+//! puede pasarlo trivialmente sobre cualquier store (MemoryStore,
+//! SurrealStore, mock, ...).
+
+use serde_json::Value;
+use uuid::Uuid;
+
+use crate::format::short_uuid;
+
+/// Valida que cada UUID en `refs` apunte a un record que realmente
+/// existe en el store bajo la entity esperada. Devuelve el primer
+/// error encontrado (fail-fast).
+///
+/// `refs` es una lista de `(label, target_entity, uuid)`. El label
+/// va al error message, así que conviene que sea legible (ej:
+/// `FieldSpec.label` en lugar de `FieldSpec.name`).
+///
+/// `load` es el cierre que el caller usa para mirar el store —
+/// típicamente `|e, id| store.load(e, id)`.
+pub fn validate_entity_refs<F>(load: F, refs: &[(String, String, Uuid)]) -> Result<(), String>
+where
+    F: Fn(&str, Uuid) -> Option<Value>,
+{
+    for (label, target, id) in refs {
+        if load(target, *id).is_none() {
+            return Err(format!(
+                "campo '{label}': record {} de '{target}' no existe en el store",
+                short_uuid(id)
+            ));
+        }
+    }
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use serde_json::json;
+    use std::collections::HashMap;
+
+    /// "Mock store" minimalista para tests: HashMap por (entity, uuid).
+    fn mk_load(records: HashMap<(String, Uuid), Value>) -> impl Fn(&str, Uuid) -> Option<Value> {
+        move |e, id| records.get(&(e.to_string(), id)).cloned()
+    }
+
+    #[test]
+    fn passes_when_all_records_exist() {
+        let stock = Uuid::new_v4();
+        let caja = Uuid::new_v4();
+        let mut records = HashMap::new();
+        records.insert(("Stock".into(), stock), json!({"sku_id": "abc"}));
+        records.insert(("Caja".into(), caja), json!({"name": "Principal"}));
+        let load = mk_load(records);
+
+        let refs = vec![
+            ("Stock".into(), "Stock".into(), stock),
+            ("Caja".into(), "Caja".into(), caja),
+        ];
+        assert!(validate_entity_refs(load, &refs).is_ok());
+    }
+
+    #[test]
+    fn fails_on_first_missing() {
+        let stock = Uuid::new_v4();
+        let mut records = HashMap::new();
+        records.insert(("Stock".into(), stock), json!({"sku_id": "abc"}));
+        let load = mk_load(records);
+
+        let missing_caja = Uuid::new_v4();
+        let refs = vec![
+            ("Stock".into(), "Stock".into(), stock),
+            ("Caja".into(), "Caja".into(), missing_caja),
+        ];
+        let err = validate_entity_refs(load, &refs).unwrap_err();
+        assert!(err.contains("Caja"));
+        assert!(err.contains(&short_uuid(&missing_caja)));
+    }
+
+    #[test]
+    fn uses_label_not_entity_in_msg() {
+        let load = |_: &str, _: Uuid| -> Option<Value> { None };
+        let id = Uuid::new_v4();
+        let refs = vec![("Stock origen".into(), "Stock".into(), id)];
+        let err = validate_entity_refs(load, &refs).unwrap_err();
+        assert!(err.contains("Stock origen"));
+    }
+
+    #[test]
+    fn empty_list_is_ok() {
+        let load = |_: &str, _: Uuid| -> Option<Value> { None };
+        assert!(validate_entity_refs(load, &[]).is_ok());
+    }
+
+    #[test]
+    fn distinguishes_target_from_other_entities() {
+        let id = Uuid::new_v4();
+        let mut records = HashMap::new();
+        // Mismo UUID bajo Customer pero NO bajo Stock.
+        records.insert(("Customer".into(), id), json!({"name": "Acme"}));
+        let load = mk_load(records);
+        let refs = vec![("Stock".into(), "Stock".into(), id)];
+        assert!(validate_entity_refs(load, &refs).is_err());
+    }
+}
diff --git a/02_ruway/nahual/libs/meta-runtime/src/testing.rs b/02_ruway/nahual/libs/meta-runtime/src/testing.rs
new file mode 100644
index 0000000..0da13bf
--- /dev/null
+++ b/02_ruway/nahual/libs/meta-runtime/src/testing.rs
@@ -0,0 +1,312 @@
+//! Utilidades de testing para code que consume [`MetaBackend`].
+//!
+//! Provee [`MockBackend`]: implementación in-memory minimalista
+//! del trait, sin acoplamiento a stores reales (event log,
+//! SurrealDB, etc.). Útil para:
+//!
+//! - Tests del widget [`nahual_widget_meta_form::MetaApp`] que
+//!   necesitan un backend funcional sin levantar nakui-core.
+//! - Tests de cualquier consumer que tome `B: MetaBackend` y quiera
+//!   asserts sobre lecturas/escrituras sin tocar disco.
+//! - Fixtures pre-pobladas para demos/screenshots/CI.
+//!
+//! Está bajo `pub mod testing` (no `#[cfg(test)]`) deliberadamente
+//! para que crates downstream puedan importarlo en sus dev/integ
+//! tests. No tiene overhead en producción si no se usa.
+
+use std::collections::{BTreeMap, HashMap};
+
+use serde_json::Value;
+use uuid::Uuid;
+
+use crate::backend::{MetaBackend, WriteOutcome};
+
+/// Backend in-memory para tests. Implementa el contrato completo
+/// del [`MetaBackend`] con semantica simple:
+///
+/// - `seed`: genera Uuid v4, inserta record. `changed = 1`.
+/// - `update`: aplica `set` (overrides) y `clear` (key removal).
+///   Si ambos vacíos → `changed = 0`. Falla si record no existe.
+/// - `delete`: remueve record. Falla si no existe.
+/// - `morphism`: por default rebota con error
+///   `"MockBackend no soporta morphism '<name>'"`. Si querés
+///   simular morphisms, registrá callbacks via
+///   [`MockBackend::with_morphism`].
+/// - `list_records`: orden lexicográfico por id (estable).
+/// - Sin `post_status`: el mock no tiene tick/compact.
+///
+/// Métodos de inspección públicos ([`total_records`],
+/// [`records_for`], etc.) facilitan asserts en tests sin necesidad
+/// de re-leer el state via las APIs del trait.
+pub struct MockBackend {
+    records: HashMap<(String, Uuid), Value>,
+    morphisms: HashMap<String, MorphismHandler>,
+}
+
+type MorphismHandler =
+    Box<dyn Fn(&BTreeMap<String, Uuid>, &Value) -> Result<usize, String> + Send + Sync>;
+
+impl Default for MockBackend {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl MockBackend {
+    /// Backend vacío.
+    pub fn new() -> Self {
+        Self {
+            records: HashMap::new(),
+            morphisms: HashMap::new(),
+        }
+    }
+
+    /// Pre-popula el backend con records `(entity, uuid, data)`.
+    /// Útil para fixtures: asserts sobre lecturas sin tener que
+    /// armar seeds via `seed()`.
+    pub fn with_records<I>(records: I) -> Self
+    where
+        I: IntoIterator<Item = (String, Uuid, Value)>,
+    {
+        let mut b = Self::new();
+        for (entity, id, data) in records {
+            b.records.insert((entity, id), data);
+        }
+        b
+    }
+
+    /// Registra un handler para un morphism de nombre `name`.
+    /// El handler recibe inputs + params y devuelve `changed` o
+    /// `Err` para simular fallo del morphism. Sobrescribe cualquier
+    /// handler previo del mismo nombre.
+    pub fn with_morphism<F>(mut self, name: impl Into<String>, handler: F) -> Self
+    where
+        F: Fn(&BTreeMap<String, Uuid>, &Value) -> Result<usize, String> + Send + Sync + 'static,
+    {
+        self.morphisms.insert(name.into(), Box::new(handler));
+        self
+    }
+
+    /// Cantidad total de records en el backend (todas las entities).
+    pub fn total_records(&self) -> usize {
+        self.records.len()
+    }
+
+    /// Records de una entity como `Vec<(Uuid, &Value)>` sin clones
+    /// (más liviano que `list_records` cuando el caller sólo quiere
+    /// inspeccionar).
+    pub fn records_for<'a>(&'a self, entity: &str) -> Vec<(Uuid, &'a Value)> {
+        self.records
+            .iter()
+            .filter(|((e, _), _)| e == entity)
+            .map(|((_, id), v)| (*id, v))
+            .collect()
+    }
+}
+
+impl MetaBackend for MockBackend {
+    fn list_records(&self, entity: &str) -> Vec<(Uuid, Value)> {
+        let mut out: Vec<(Uuid, Value)> = self
+            .records
+            .iter()
+            .filter(|((e, _), _)| e == entity)
+            .map(|((_, id), v)| (*id, v.clone()))
+            .collect();
+        out.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
+        out
+    }
+
+    fn load_record(&self, entity: &str, id: Uuid) -> Option<Value> {
+        self.records.get(&(entity.to_string(), id)).cloned()
+    }
+
+    fn seed(
+        &mut self,
+        entity: &str,
+        data: serde_json::Map<String, Value>,
+    ) -> Result<WriteOutcome, String> {
+        let id = Uuid::new_v4();
+        self.records
+            .insert((entity.to_string(), id), Value::Object(data));
+        Ok(WriteOutcome {
+            id: Some(id),
+            changed: 1,
+            post_status: None,
+        })
+    }
+
+    fn update(
+        &mut self,
+        entity: &str,
+        id: Uuid,
+        set: serde_json::Map<String, Value>,
+        clear: Vec<String>,
+    ) -> Result<WriteOutcome, String> {
+        if set.is_empty() && clear.is_empty() {
+            return Ok(WriteOutcome::no_change(id));
+        }
+        let rec = self
+            .records
+            .get_mut(&(entity.to_string(), id))
+            .ok_or_else(|| format!("not found: {entity}/{id}"))?;
+        let map = rec
+            .as_object_mut()
+            .ok_or_else(|| format!("not an object: {entity}/{id}"))?;
+        let changed = set.len() + clear.len();
+        for (k, v) in set {
+            map.insert(k, v);
+        }
+        for k in clear {
+            map.remove(&k);
+        }
+        Ok(WriteOutcome {
+            id: Some(id),
+            changed,
+            post_status: None,
+        })
+    }
+
+    fn delete(&mut self, entity: &str, id: Uuid) -> Result<WriteOutcome, String> {
+        self.records
+            .remove(&(entity.to_string(), id))
+            .ok_or_else(|| format!("not found: {entity}/{id}"))?;
+        Ok(WriteOutcome {
+            id: Some(id),
+            changed: 1,
+            post_status: None,
+        })
+    }
+
+    fn morphism(
+        &mut self,
+        _module_id: &str,
+        name: &str,
+        inputs: BTreeMap<String, Uuid>,
+        params: Value,
+    ) -> Result<WriteOutcome, String> {
+        match self.morphisms.get(name) {
+            Some(handler) => {
+                let changed = handler(&inputs, &params)?;
+                Ok(WriteOutcome {
+                    id: None,
+                    changed,
+                    post_status: None,
+                })
+            }
+            None => Err(format!("MockBackend no soporta morphism '{name}'")),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use serde_json::json;
+
+    fn map_of(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
+        items
+            .iter()
+            .map(|(k, v)| (k.to_string(), v.clone()))
+            .collect()
+    }
+
+    #[test]
+    fn with_records_populates_state() {
+        let id = Uuid::new_v4();
+        let b = MockBackend::with_records([("Customer".into(), id, json!({"name": "Acme"}))]);
+        assert_eq!(b.total_records(), 1);
+        assert_eq!(b.load_record("Customer", id), Some(json!({"name": "Acme"})));
+    }
+
+    #[test]
+    fn seed_then_load_round_trip_via_trait() {
+        let mut b = MockBackend::new();
+        let out = b.seed("X", map_of(&[("k", json!(1))])).unwrap();
+        let id = out.id.unwrap();
+        assert_eq!(out.changed, 1);
+        assert_eq!(b.load_record("X", id), Some(json!({"k": 1})));
+    }
+
+    #[test]
+    fn update_no_op_returns_no_change() {
+        let id = Uuid::new_v4();
+        let mut b = MockBackend::with_records([("X".into(), id, json!({"k": 1}))]);
+        let out = b.update("X", id, serde_json::Map::new(), vec![]).unwrap();
+        assert_eq!(out, WriteOutcome::no_change(id));
+    }
+
+    #[test]
+    fn update_set_and_clear_aplica_ambos() {
+        let id = Uuid::new_v4();
+        let mut b = MockBackend::with_records([("X".into(), id, json!({"a": 1, "b": 2}))]);
+        let out = b
+            .update("X", id, map_of(&[("a", json!(10))]), vec!["b".into()])
+            .unwrap();
+        assert_eq!(out.changed, 2);
+        let rec = b.load_record("X", id).unwrap();
+        assert_eq!(rec.get("a"), Some(&json!(10)));
+        assert!(rec.get("b").is_none());
+    }
+
+    #[test]
+    fn delete_then_load_returns_none() {
+        let id = Uuid::new_v4();
+        let mut b = MockBackend::with_records([("X".into(), id, json!({"k": 1}))]);
+        b.delete("X", id).unwrap();
+        assert!(b.load_record("X", id).is_none());
+    }
+
+    #[test]
+    fn morphism_without_handler_errors_clearly() {
+        let mut b = MockBackend::new();
+        let err = b
+            .morphism("mod", "foo", BTreeMap::new(), json!({}))
+            .unwrap_err();
+        assert!(err.contains("foo"));
+    }
+
+    #[test]
+    fn with_morphism_lets_caller_simulate_logic() {
+        let mut b = MockBackend::new().with_morphism("double_qty", |inputs, params| {
+            assert!(inputs.is_empty());
+            let qty = params.get("qty").and_then(|v| v.as_i64()).unwrap_or(0);
+            if qty <= 0 {
+                return Err("qty must be positive".into());
+            }
+            Ok(qty as usize)
+        });
+        let out = b
+            .morphism("mod", "double_qty", BTreeMap::new(), json!({"qty": 7}))
+            .unwrap();
+        assert_eq!(out.changed, 7);
+        assert!(out.id.is_none(), "morphism no devuelve id por convención");
+
+        let err = b
+            .morphism("mod", "double_qty", BTreeMap::new(), json!({"qty": 0}))
+            .unwrap_err();
+        assert!(err.contains("positive"));
+    }
+
+    #[test]
+    fn list_records_orders_lexicographically() {
+        let id_a = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
+        let id_b = Uuid::parse_str("ffffffff-0000-0000-0000-000000000000").unwrap();
+        let b = MockBackend::with_records([
+            ("X".into(), id_b, json!({"n": 2})),
+            ("X".into(), id_a, json!({"n": 1})),
+        ]);
+        let rows = b.list_records("X");
+        assert_eq!(rows.len(), 2);
+        assert_eq!(rows[0].0, id_a, "menor uuid primero (orden lex)");
+    }
+
+    #[test]
+    fn records_for_returns_borrowed_view() {
+        let id = Uuid::new_v4();
+        let b = MockBackend::with_records([("X".into(), id, json!({"k": 1}))]);
+        let view = b.records_for("X");
+        assert_eq!(view.len(), 1);
+        assert_eq!(view[0].0, id);
+        assert_eq!(view[0].1.get("k"), Some(&json!(1)));
+    }
+}
diff --git a/02_ruway/nahual/libs/meta-schema/Cargo.toml b/02_ruway/nahual/libs/meta-schema/Cargo.toml
new file mode 100644
index 0000000..ba3b12b
--- /dev/null
+++ b/02_ruway/nahual/libs/meta-schema/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "nahual-meta-schema"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+description = "Yahweh — meta-schema: descriptores declarativos de UI (entities, menús, listas, formularios, acciones) consumidos por widgets metainterfaz reusables. Independiente del backend: cualquier app que monte una UI dirigida por datos puede usarlo."
+
+[dependencies]
+serde = { workspace = true }
+serde_json = { workspace = true }
+thiserror = { workspace = true }
+
+[dev-dependencies]
+tempfile = { workspace = true }
diff --git a/02_ruway/nahual/libs/meta-schema/LEEME.md b/02_ruway/nahual/libs/meta-schema/LEEME.md
new file mode 100644
index 0000000..a2b62b7
--- /dev/null
+++ b/02_ruway/nahual/libs/meta-schema/LEEME.md
@@ -0,0 +1,9 @@
+# meta-schema
+
+> Schema declarativo de viewers de [nahual](../../README.md).
+
+Define la estructura JSON que describe un viewer: campos, layout, eventos. Lo que [`meta-runtime`](../meta-runtime/README.md) interpreta para montar la app.
+
+## Deps
+
+- `serde`, `serde_json`
diff --git a/02_ruway/nahual/libs/meta-schema/README.md b/02_ruway/nahual/libs/meta-schema/README.md
new file mode 100644
index 0000000..5de58a8
--- /dev/null
+++ b/02_ruway/nahual/libs/meta-schema/README.md
@@ -0,0 +1,9 @@
+# meta-schema
+
+> Declarative viewer schema for [nahual](../../README.md).
+
+Defines the JSON structure describing a viewer: fields, layout, events. What [`meta-runtime`](../meta-runtime/README.md) interprets to mount the app.
+
+## Deps
+
+- `serde`, `serde_json`
diff --git a/02_ruway/nahual/libs/meta-schema/src/lib.rs b/02_ruway/nahual/libs/meta-schema/src/lib.rs
new file mode 100644
index 0000000..ee80e2b
--- /dev/null
+++ b/02_ruway/nahual/libs/meta-schema/src/lib.rs
@@ -0,0 +1,1194 @@
+//! Schema declarativo de la metainterfaz (nahual meta-schema).
+//!
+//! Cada **módulo** declara aquí qué menús, vistas, listas y
+//! formularios expone, sin escribir código GPUI ni Rust. Cualquier
+//! runtime de UI dirigida por datos (Nakui hoy, otros mañana) lo
+//! carga y monta la UI correspondiente.
+//!
+//! ## Filosofía
+//!
+//! - **UI como datos**: agregar un módulo = escribir un JSON o un
+//!   `.ncl`. Ningún recompile, ningún acoplamiento con el binario
+//!   del runtime.
+//! - **Backend-agnostic**: este crate sólo describe la *forma* de
+//!   la UI. La conexión a un store/log/executor concretos vive en
+//!   el runtime que lo consume (ej: el meta-runtime de Nakui que
+//!   wirea esto a `nakui_core` + KCL post-checks).
+//! - **Schema primero, semántica después**: validación semántica
+//!   (referencias rotas a entities, campos faltantes, etc.) vive
+//!   en el runtime que lo carga, no acá.
+//!
+//! ## Anatomía de un módulo
+//!
+//! ```json
+//! {
+//!   "id": "customers",
+//!   "label": "Clientes",
+//!   "entities": [
+//!     { "name": "customer", "fields": [ ... ] }
+//!   ],
+//!   "menu": [
+//!     { "label": "Listar", "view": "list" },
+//!     { "label": "Nuevo", "view": "form" }
+//!   ],
+//!   "views": {
+//!     "list": { "kind": "list", "entity": "customer", "columns": [...] },
+//!     "form": { "kind": "form", "entity": "customer", "fields": [...] }
+//!   }
+//! }
+//! ```
+
+#![forbid(unsafe_code)]
+#![warn(rust_2018_idioms)]
+
+use std::collections::BTreeMap;
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+
+/// Manifiesto de un módulo declarativo de UI.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Module {
+    /// Identificador estable. Único dentro del directorio cargado.
+    pub id: String,
+
+    /// Nombre legible para mostrar en el sidebar.
+    pub label: String,
+
+    /// Descripción corta opcional (tooltip / subtítulo).
+    #[serde(default)]
+    pub description: Option<String>,
+
+    /// Entities que el módulo introduce o consume. El runtime las
+    /// usa para validar columns/fields y para inicializar el store
+    /// cuando son nuevas.
+    #[serde(default)]
+    pub entities: Vec<EntitySpec>,
+
+    /// Path opaco al backend que va a manejar este módulo. Lo
+    /// interpreta el runtime concreto (no este schema).
+    ///
+    /// Convención actual de Nakui: directorio con `nsmc.json` +
+    /// schemas KCL + scripts Rhai. Cuando está set, el runtime
+    /// Nakui carga un `Executor` para ese path y permite que las
+    /// acciones `Morphism { name }` despachen al pipeline real
+    /// (compute → log → apply). Otro backend puede ignorar este
+    /// campo o darle un significado distinto.
+    ///
+    /// Path resuelto relativo al directorio del `module.json`
+    /// o absoluto.
+    ///
+    /// Si es `None`, los backends que requieren manifest deberían
+    /// degradar (toast informativo, deshabilitar morphisms, etc.);
+    /// los `SeedEntity` siguen funcionando — son altas
+    /// administrativas que no necesitan validación de manifest.
+    ///
+    /// Nombre conservado por compat con módulos ya escritos.
+    /// Renombrar a `backend_module_dir` o similar si emerge un
+    /// segundo backend que también lo use.
+    #[serde(default, alias = "backend_module_dir")]
+    pub nakui_module_dir: Option<String>,
+
+    /// Items del menú. Cada uno apunta a una key de `views`. Orden
+    /// importa (es el orden en que se presentan en el sidebar).
+    pub menu: Vec<MenuItem>,
+
+    /// Vistas indexadas por key. Las keys son referenciadas por
+    /// `MenuItem.view` y por `Action::OpenView.view`.
+    pub views: BTreeMap<String, View>,
+}
+
+/// Item del menú lateral.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct MenuItem {
+    pub label: String,
+    /// Key de la vista a abrir. Debe existir en `Module.views`.
+    pub view: String,
+    /// Icono opcional (texto unicode o emoji; el runtime decide
+    /// renderización).
+    #[serde(default)]
+    pub icon: Option<String>,
+}
+
+/// Una vista renderizada en el área principal cuando su menú es seleccionado.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum View {
+    /// Tabla de instancias de una entity, columnas + acciones por fila.
+    List(ListView),
+    /// Formulario de creación / edición.
+    Form(FormView),
+    /// Ficha de un record: sus campos + listas de records relacionados.
+    Detail(DetailView),
+    /// Tablero de KPIs: una grilla de tarjetas de agregados.
+    Dashboard(DashboardView),
+    /// Reporte imprimible: los mismos agregados que un tablero, pero
+    /// dispuestos como documento de una columna (título + fecha de
+    /// generación) y exportable a Markdown.
+    Report(ReportView),
+    /// Grafo de dependencias: el DAG de morfismos del módulo nakui. Cada
+    /// morfismo es un nodo; los tokens que lee/escribe son sus pins; las
+    /// aristas de flujo de datos (escritura→lectura del mismo token) son
+    /// los cables. Visualiza la cascada reactiva que conecta el dato.
+    Graph(GraphView),
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ListView {
+    pub title: String,
+    /// Entity (del nakui store) cuyas instancias se listan.
+    pub entity: String,
+    /// Columnas a mostrar. Orden importa.
+    pub columns: Vec<Column>,
+    /// Acciones disponibles a nivel de la lista (ej. "Nuevo" → form).
+    /// Renderizadas como botones en el header.
+    #[serde(default)]
+    pub actions: Vec<Action>,
+    /// Cuando está set, se muestra una caja de búsqueda que filtra
+    /// las filas por substring contra los valores de estas columnas.
+    #[serde(default)]
+    pub search_in: Vec<String>,
+    /// Si está set, cada fila gana un botón 👁 que abre esta vista
+    /// (debe ser una `View::Detail`) para el record de la fila.
+    #[serde(default)]
+    pub row_detail: Option<String>,
+}
+
+/// Ficha de un record: sus campos + listas de records relacionados.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct DetailView {
+    pub title: String,
+    /// Entity del record que se muestra.
+    pub entity: String,
+    /// Campos a mostrar, en orden. Reusa [`Column`] (label + field +
+    /// `ref_entity` + `format`; el `weight` se ignora en la ficha).
+    #[serde(default)]
+    pub fields: Vec<Column>,
+    /// Listas de records relacionados (back-references).
+    #[serde(default)]
+    pub related: Vec<RelatedList>,
+    /// KPIs scopeados al record (el "360" de la ficha): agregados sobre
+    /// los records relacionados (`entity` cuyo `via_field` apunta al
+    /// record actual), p.ej. en la ficha de un cliente "Total facturado"
+    /// / "Órdenes" / "Ticket promedio". Renderizados como stat cards
+    /// arriba de las listas relacionadas.
+    #[serde(default)]
+    pub metrics: Vec<DetailMetric>,
+}
+
+/// Un KPI scopeado a un record dentro de una [`DetailView`]: computa
+/// `metric` sobre los records de `entity` cuyo `via_field` referencia
+/// al record que se está viendo (mismo criterio de scope que una
+/// [`RelatedList`]), con un `filter` opcional adicional.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct DetailMetric {
+    pub label: String,
+    /// Entity sobre cuyos records relacionados se computa el agregado.
+    pub entity: String,
+    /// Campo de esa entity que referencia (UUID) al record actual.
+    pub via_field: String,
+    pub metric: Metric,
+    /// Filtro adicional opcional (AND con el scope), p.ej. `pagado=true`
+    /// para un KPI "cobrado".
+    #[serde(default)]
+    pub filter: Option<CardFilter>,
+    /// Formato del número resultante (`Currency` para sumas de dinero).
+    #[serde(default)]
+    pub format: ValueFormat,
+}
+
+/// Una lista de records relacionados dentro de una [`DetailView`]: los
+/// records de otra entity cuyo campo `via_field` apunta al record que
+/// se está viendo.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RelatedList {
+    pub title: String,
+    /// Entity de los records relacionados.
+    pub entity: String,
+    /// Campo de esa entity cuyo valor (UUID) referencia al record
+    /// actual. El runtime filtra `record[via_field] == id_actual`.
+    pub via_field: String,
+    pub columns: Vec<Column>,
+}
+
+/// Tablero de KPIs: una grilla de tarjetas de agregados.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct DashboardView {
+    pub title: String,
+    pub cards: Vec<DashboardCard>,
+}
+
+/// Reporte imprimible: mismos agregados que un tablero, presentados
+/// como documento exportable. Opcionalmente un subtítulo/encabezado.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ReportView {
+    pub title: String,
+    /// Línea de subtítulo opcional bajo el título (período, área…).
+    #[serde(default)]
+    pub subtitle: Option<String>,
+    pub cards: Vec<DashboardCard>,
+    /// Controles interactivos: cada uno es un filtro etiquetado que el
+    /// usuario prende/apaga desde la UI. Cuando está activo, su filtro
+    /// se aplica (AND) sobre los records de cada card antes de agregar
+    /// —recortando el reporte sin tocar el `module.json`—.
+    #[serde(default)]
+    pub toggles: Vec<ReportToggle>,
+}
+
+/// Un control de filtro interactivo de un [`ReportView`].
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ReportToggle {
+    /// Texto del botón (p.ej. "Solo pagadas", "Q1 2026").
+    pub label: String,
+    /// Si está set, el toggle sólo afecta a las cards de esta entity
+    /// (evita vaciar cards de otra entity que no tiene el campo). Si es
+    /// `None`, se aplica a todas las cards.
+    #[serde(default)]
+    pub entity: Option<String>,
+    /// Filtro que se aplica cuando el control está activo.
+    pub filter: CardFilter,
+}
+
+/// Vista grafo: el DAG de morfismos del módulo nakui. No tiene
+/// parámetros más allá del título y un subtítulo opcional — el grafo se
+/// deriva en runtime del manifest del `Executor` del módulo (los
+/// morfismos y los tokens que lee/escribe cada uno), no se declara acá.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct GraphView {
+    pub title: String,
+    /// Línea de contexto opcional bajo el título.
+    #[serde(default)]
+    pub subtitle: Option<String>,
+}
+
+/// Una tarjeta de KPI del tablero.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct DashboardCard {
+    /// Etiqueta de la tarjeta.
+    pub label: String,
+    /// Entity sobre cuyos records se computa el agregado.
+    pub entity: String,
+    /// Qué se computa.
+    pub metric: Metric,
+    /// Filtro opcional: sólo entran los records que lo cumplen.
+    #[serde(default)]
+    pub filter: Option<CardFilter>,
+    /// Formato del número resultante (`Currency` para sumas de dinero).
+    /// Ignorado por `GroupBy`.
+    #[serde(default)]
+    pub format: ValueFormat,
+    /// Cuando el agregado produce un desglose por grupo (`GroupBy` /
+    /// `SumBy` / `AvgBy`) y la clave de grupo es un UUID que referencia
+    /// a otra entity, esta entity se usa para resolver cada clave a su
+    /// label legible (p.ej. "facturación por cliente" muestra nombres
+    /// en vez de UUIDs). El runtime de presentación hace la resolución;
+    /// el motor de métricas permanece agnóstico.
+    #[serde(default)]
+    pub group_ref: Option<String>,
+    /// Cómo se dibuja un desglose (`GroupBy` / `SumBy` / `AvgBy`):
+    /// barras ASCII (default), torta o dona. Ignorado por métricas
+    /// escalares (`Count` / `Sum` / `Avg` / `Min` / `Max`), que siempre
+    /// se muestran como número grande.
+    #[serde(default)]
+    pub chart: ChartKind,
+    /// Tope de filas de un desglose: se conservan las `limit` de mayor
+    /// valor (el motor ya las ordena de mayor a menor) y el resto se
+    /// colapsa en una fila "Otros" (suma para conteos/`SumBy`, promedio
+    /// de los grupos restantes para `AvgBy`). Mantiene legibles los
+    /// gráficos sobre dimensiones de muchos grupos. `None` = sin tope.
+    /// Ignorado cuando hay `bucket` (las series temporales no se recortan).
+    #[serde(default)]
+    pub limit: Option<usize>,
+    /// Cuando el campo de grupo de un desglose es una fecha ISO-8601,
+    /// trunca la clave a año/mes/día antes de agregar — convierte el
+    /// desglose en una **serie temporal** (p.ej. "facturación por mes").
+    /// El resultado se ordena cronológicamente (no por valor) y no se
+    /// recorta. `None` = agrupar por el valor crudo del campo.
+    #[serde(default)]
+    pub bucket: Option<DateBucket>,
+    /// Convierte un desglose ordenado en su **acumulado** (running
+    /// total): tras ordenar/recortar, cada valor pasa a ser la suma
+    /// corrida de sí mismo y los anteriores. El caso natural de
+    /// tesorería ("saldo acumulado por mes"); se combina con `bucket`
+    /// para series cronológicas, y en multi-serie cada serie acumula por
+    /// separado. Sólo tiene sentido sobre métricas aditivas
+    /// (`Count`/`Sum`/`SumBy`/`SumBySeries`). `false` = valores por
+    /// período (default).
+    #[serde(default)]
+    pub cumulative: bool,
+}
+
+/// Granularidad de truncado de una fecha ISO-8601 para series temporales.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum DateBucket {
+    /// Año: `2026-01-15` → `2026`.
+    Year,
+    /// Año-mes: `2026-01-15` → `2026-01`.
+    Month,
+    /// Día (fecha completa): `2026-01-15` → `2026-01-15`.
+    Day,
+}
+
+/// Forma visual de un desglose de tablero/reporte.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ChartKind {
+    /// Barras horizontales en caracteres de bloque. Default.
+    #[default]
+    Bars,
+    /// Gráfico de torta: cada grupo es un sector proporcional a su valor.
+    Pie,
+    /// Como `Pie` pero con el centro hueco (anillo).
+    Donut,
+    /// Columnas verticales, una por grupo (en el orden del desglose).
+    /// Apto para series ordenadas (p.ej. ingresos por mes). En un
+    /// desglose multi-serie (`SumBySeries`) las series se dibujan
+    /// **agrupadas** (lado a lado dentro de cada grupo).
+    Columns,
+    /// Línea que une los valores de cada grupo, con un punto por grupo.
+    /// Pensado para tendencias sobre un eje ordenado.
+    Line,
+    /// Columnas verticales **apiladas**: sólo aplica a desgloses
+    /// multi-serie (`SumBySeries`); cada grupo es una sola columna donde
+    /// los segmentos de cada serie se apilan, y la altura total es la
+    /// suma del grupo (composición sobre un eje ordenado). En un
+    /// desglose de una sola dimensión se comporta como `Columns`.
+    StackedColumns,
+}
+
+/// El agregado que computa una [`DashboardCard`].
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum Metric {
+    /// Cantidad de records.
+    Count,
+    /// Suma de un campo numérico.
+    Sum { field: String },
+    /// Promedio de un campo numérico (ignora records sin el campo).
+    Avg { field: String },
+    /// Mínimo de un campo numérico.
+    Min { field: String },
+    /// Máximo de un campo numérico.
+    Max { field: String },
+    /// Cantidad de **valores distintos** de un campo (ignora records sin
+    /// el campo o vacíos) — p.ej. "clientes distintos con órdenes",
+    /// "cajas con movimiento", "días con actividad".
+    CountDistinct { field: String },
+    /// Conteo de records por cada valor distinto de un campo.
+    GroupBy { field: String },
+    /// Suma de `value` por cada valor distinto de `group` — el reporte
+    /// ERP clásico ("facturación por cliente", "ingresos por mes").
+    SumBy { group: String, value: String },
+    /// Promedio de `value` por cada valor distinto de `group`
+    /// ("ticket promedio por plan").
+    AvgBy { group: String, value: String },
+    /// Suma de `value` por cada combinación de `group` × `series` — un
+    /// desglose de **dos dimensiones** ("facturación por mes, por
+    /// plan"). El eje principal es `group` (x); cada valor distinto de
+    /// `series` es una serie sobre ese eje. Se renderiza como
+    /// multi-línea o columnas agrupadas.
+    SumBySeries {
+        group: String,
+        series: String,
+        value: String,
+    },
+}
+
+/// Operador de comparación de un [`CardFilter`].
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum FilterOp {
+    /// Igualdad textual contra `value`. Default (compat con `equals`).
+    #[default]
+    Eq,
+    /// Desigualdad textual.
+    Ne,
+    /// Mayor que `value` (numérico, o fecha ISO comparada como texto).
+    Gt,
+    /// Mayor o igual.
+    Gte,
+    /// Menor que.
+    Lt,
+    /// Menor o igual.
+    Lte,
+    /// Dentro de `[min, max]` inclusivo (cualquiera de las dos cotas
+    /// puede omitirse para un rango abierto).
+    Between,
+    /// El campo existe y no está vacío.
+    NonEmpty,
+}
+
+/// Filtro de una [`DashboardCard`]: decide qué records entran al
+/// agregado. El default (`op: eq` + `value`) replica el viejo
+/// `{ field, equals }`, que sigue parseando por el alias `equals`.
+/// Para `gt`/`lt`/`between`, las comparaciones son numéricas si ambos
+/// lados parsean como número, y lexicográficas en caso contrario —
+/// suficiente para rangos de fecha en ISO-8601 (`YYYY-MM-DD`).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct CardFilter {
+    pub field: String,
+    /// Operador. Default `eq`.
+    #[serde(default)]
+    pub op: FilterOp,
+    /// Comparando para `eq`/`ne`/`gt`/`gte`/`lt`/`lte`. Acepta el alias
+    /// `equals` por compatibilidad con tableros ya escritos.
+    #[serde(default, alias = "equals")]
+    pub value: Option<String>,
+    /// Cota inferior para `between` (inclusiva). `None` = sin piso.
+    #[serde(default)]
+    pub min: Option<String>,
+    /// Cota superior para `between` (inclusiva). `None` = sin techo.
+    #[serde(default)]
+    pub max: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Column {
+    /// Path del campo dentro del record. Para tipos planos: nombre.
+    /// Para nested: puntos (`address.city`). El runtime navega.
+    pub field: String,
+    /// Texto del header.
+    pub label: String,
+    /// Ancho relativo (peso flex). Default 1.
+    #[serde(default = "default_weight")]
+    pub weight: f32,
+    /// Si está set, la celda resuelve su valor (un UUID) al label
+    /// legible del record de esta entity, en vez de mostrar el UUID
+    /// crudo. Para columnas que son referencias a otra entity.
+    #[serde(default)]
+    pub ref_entity: Option<String>,
+    /// Formato de presentación del valor de la celda.
+    #[serde(default)]
+    pub format: ValueFormat,
+}
+
+fn default_weight() -> f32 {
+    1.0
+}
+
+/// Formato de presentación de un valor en una celda de lista.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum ValueFormat {
+    /// Sin format — el valor se muestra crudo. Default.
+    #[default]
+    Plain,
+    /// Entero/decimal con separador de miles (`12000` → `12,000`).
+    Number,
+    /// Moneda: separador de miles + símbolo prefijo (`12000` → `$12,000`).
+    Currency { symbol: String },
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct FormView {
+    pub title: String,
+    /// Entity destino del seed/morphism al submit.
+    pub entity: String,
+    pub fields: Vec<FieldSpec>,
+    /// Acción al submit. Típicamente `Action::SeedEntity` para alta
+    /// directa o `Action::Morphism` cuando hay validación/cálculo.
+    pub on_submit: Action,
+}
+
+/// Especificación de un campo de formulario o columna implícita.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct FieldSpec {
+    /// Nombre del campo en el record (clave del JSON).
+    pub name: String,
+    /// Etiqueta legible.
+    pub label: String,
+    /// Tipo del valor — define el widget de input + parseo.
+    pub kind: FieldKind,
+    /// Valor por defecto al abrir el form (string raw; el parseo
+    /// según `kind` lo hace el runtime).
+    #[serde(default)]
+    pub default: Option<String>,
+    /// Si `true`, el form rechaza submit con campo vacío.
+    #[serde(default)]
+    pub required: bool,
+    /// Texto de ayuda mostrado bajo el input.
+    #[serde(default)]
+    pub help: Option<String>,
+    /// Si `kind == EntityRef`, indica qué entity referencia. Sin
+    /// esto, el runtime no sabe qué records ofrecer en el selector
+    /// y la validación `Module::validate` rechaza el manifest.
+    /// Para los demás kinds, este campo se ignora.
+    #[serde(default)]
+    pub ref_entity: Option<String>,
+    /// Opciones de un campo `kind == Select`. Ignorado para los demás
+    /// kinds. `Module::validate` exige que un Select las tenga.
+    #[serde(default)]
+    pub options: Vec<SelectOption>,
+    /// Sección del formulario a la que pertenece el campo. Campos
+    /// consecutivos con la misma sección se agrupan bajo un encabezado.
+    #[serde(default)]
+    pub section: Option<String>,
+}
+
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum FieldKind {
+    /// Texto libre.
+    Text,
+    /// Texto multilínea.
+    Multiline,
+    /// Número (i64 o f64; runtime intenta parsear como i64 primero).
+    Number,
+    /// Booleano (renderizado como checkbox).
+    Boolean,
+    /// Fecha (format ISO YYYY-MM-DD; almacenada como string).
+    Date,
+    /// Referencia a otro record. El runtime renderiza un selector
+    /// clickable de records existentes de la entity declarada en
+    /// `FieldSpec.ref_entity`; el value almacenado es el UUID del
+    /// seleccionado, parseable como cualquier text/UUID al submit.
+    EntityRef,
+    /// Valor elegido de un conjunto cerrado declarado en
+    /// `FieldSpec.options`. El runtime lo renderiza como selección
+    /// (no texto libre). `Module::validate` exige `options` no vacío.
+    Select,
+    /// Identificador autogenerado (UUID v4). El runtime lo rellena al
+    /// abrir el formulario; el usuario no lo teclea ni lo edita. Para
+    /// los ids de idempotencia que piden los morfismos.
+    AutoId,
+}
+
+/// Una opción de un campo [`FieldKind::Select`].
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct SelectOption {
+    /// Valor que se guarda (lo que recibe el backend o el morfismo).
+    pub value: String,
+    /// Etiqueta legible. Si se omite, se muestra el `value` crudo.
+    #[serde(default)]
+    pub label: Option<String>,
+}
+
+impl SelectOption {
+    /// Texto a mostrar: `label` si está, sino el `value`.
+    pub fn display(&self) -> &str {
+        self.label.as_deref().unwrap_or(&self.value)
+    }
+}
+
+/// Acciones disparables por menús, botones o submit de formularios.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum Action {
+    /// Cambia la vista activa a otra del mismo módulo.
+    OpenView {
+        view: String,
+        /// Etiqueta del botón / item; default = nombre humano de la vista.
+        #[serde(default)]
+        label: Option<String>,
+    },
+    /// Crea un seed directo en la entity con los valores del form.
+    /// Equivalente a `nakui_core::event_log::seed_and_log`.
+    /// Sin pipeline de morphism — alta administrativa.
+    SeedEntity {
+        entity: String,
+        /// Tras el submit exitoso, opcionalmente abrir esta vista
+        /// (por convención: `"list"` para volver al listado).
+        #[serde(default)]
+        next_view: Option<String>,
+    },
+    /// Ejecuta un morphism declarado en el manifest del módulo
+    /// nakui-core (cuyo path vive en `Module.nakui_module_dir`).
+    /// Inputs (records existentes) y params (valores escalares) se
+    /// mapean desde los campos del form.
+    Morphism {
+        /// Nombre del morphism declarado en `nsmc.json` del manifest
+        /// nakui apuntado por el módulo.
+        name: String,
+        /// Mapeo `role → field_name`: por cada input declarado en
+        /// el `MorphismSpec.inputs`, indica qué field del form
+        /// contiene el UUID del record. El runtime parsea el value
+        /// como `Uuid` y lo pasa como input al `execute_and_log`.
+        ///
+        /// Ej: `{ "stock": "stock_id", "caja": "caja_id" }` para un
+        /// morphism `vender` que toma roles `stock` y `caja`.
+        #[serde(default)]
+        inputs: BTreeMap<String, String>,
+        /// Lista de fields del form cuyos values van al `params`
+        /// JSON object pasado al morphism. Si está vacío, todos los
+        /// fields que no estén en `inputs` van a params.
+        #[serde(default)]
+        params: Vec<String>,
+        #[serde(default)]
+        next_view: Option<String>,
+    },
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct EntitySpec {
+    /// Nombre de la entity (clave en el store). Único dentro del módulo.
+    pub name: String,
+    /// Label legible (singular).
+    pub label: String,
+    /// Campos esperados en records de esta entity. Usados por el
+    /// runtime para inferir columnas / validar formularios.
+    #[serde(default)]
+    pub fields: Vec<FieldSpec>,
+}
+
+#[derive(Debug, Error)]
+pub enum SchemaError {
+    #[error("io leyendo {path}: {source}")]
+    Io {
+        path: PathBuf,
+        #[source]
+        source: std::io::Error,
+    },
+    #[error("parseo de {path}: {source}")]
+    Parse {
+        path: PathBuf,
+        #[source]
+        source: serde_json::Error,
+    },
+    #[error("módulo {id}: la vista '{view}' referenciada por menu_item '{item}' no existe")]
+    DanglingMenuView {
+        id: String,
+        item: String,
+        view: String,
+    },
+    #[error("módulos con id duplicado: '{id}' aparece en {first} y {second}")]
+    DuplicateModuleId {
+        id: String,
+        first: PathBuf,
+        second: PathBuf,
+    },
+    #[error(
+        "módulo {id} vista '{view}': field '{field}' tiene kind=entity_ref \
+         pero no declaró ref_entity"
+    )]
+    EntityRefMissingTarget {
+        id: String,
+        view: String,
+        field: String,
+    },
+    #[error(
+        "módulo {id} vista '{view}': field '{field}' tiene kind=select \
+         pero no declaró options"
+    )]
+    SelectMissingOptions {
+        id: String,
+        view: String,
+        field: String,
+    },
+    #[error(
+        "módulo {id} vista '{view}': row_detail='{target}' no apunta a \
+         una vista kind=detail"
+    )]
+    RowDetailInvalid {
+        id: String,
+        view: String,
+        target: String,
+    },
+}
+
+impl Module {
+    /// Carga un module.json desde disco. Validación estructural
+    /// posterior (vistas referenciadas existen, etc.) la ejecuta el
+    /// runtime — acá sólo parseamos.
+    pub fn from_path(path: impl AsRef<Path>) -> Result<Self, SchemaError> {
+        let path = path.as_ref();
+        let bytes = std::fs::read(path).map_err(|source| SchemaError::Io {
+            path: path.to_path_buf(),
+            source,
+        })?;
+        serde_json::from_slice(&bytes).map_err(|source| SchemaError::Parse {
+            path: path.to_path_buf(),
+            source,
+        })
+    }
+
+    /// Validación post-parse:
+    /// - Cada `MenuItem.view` debe existir en `views`.
+    /// - Cada `FieldSpec` con `kind=EntityRef` debe declarar
+    ///   `ref_entity`.
+    pub fn validate(&self) -> Result<(), SchemaError> {
+        for item in &self.menu {
+            if !self.views.contains_key(&item.view) {
+                return Err(SchemaError::DanglingMenuView {
+                    id: self.id.clone(),
+                    item: item.label.clone(),
+                    view: item.view.clone(),
+                });
+            }
+        }
+        for (view_key, view) in &self.views {
+            match view {
+                View::Form(form) => {
+                    for f in &form.fields {
+                        if f.kind == FieldKind::EntityRef && f.ref_entity.is_none() {
+                            return Err(SchemaError::EntityRefMissingTarget {
+                                id: self.id.clone(),
+                                view: view_key.clone(),
+                                field: f.name.clone(),
+                            });
+                        }
+                        if f.kind == FieldKind::Select && f.options.is_empty() {
+                            return Err(SchemaError::SelectMissingOptions {
+                                id: self.id.clone(),
+                                view: view_key.clone(),
+                                field: f.name.clone(),
+                            });
+                        }
+                    }
+                }
+                View::List(list) => {
+                    if let Some(target) = &list.row_detail {
+                        if !matches!(self.views.get(target), Some(View::Detail(_))) {
+                            return Err(SchemaError::RowDetailInvalid {
+                                id: self.id.clone(),
+                                view: view_key.clone(),
+                                target: target.clone(),
+                            });
+                        }
+                    }
+                }
+                View::Detail(_) | View::Dashboard(_) | View::Report(_) | View::Graph(_) => {}
+            }
+        }
+        Ok(())
+    }
+}
+
+/// Carga todos los `module.json` encontrados bajo `dir` (recursivo
+/// 1 nivel — espera `dir/<modulo>/module.json`). Devuelve la lista
+/// ordenada por id, validada.
+///
+/// Falla si: I/O, parseo, módulo inválido, o ids duplicados.
+pub fn load_modules_from_dir(dir: impl AsRef<Path>) -> Result<Vec<Module>, SchemaError> {
+    let dir = dir.as_ref();
+    let mut modules: Vec<(PathBuf, Module)> = Vec::new();
+    let entries = std::fs::read_dir(dir).map_err(|source| SchemaError::Io {
+        path: dir.to_path_buf(),
+        source,
+    })?;
+    for entry in entries.flatten() {
+        let p = entry.path();
+        if p.is_dir() {
+            let manifest = p.join("module.json");
+            if manifest.exists() {
+                let m = Module::from_path(&manifest)?;
+                m.validate()?;
+                modules.push((manifest, m));
+            }
+        }
+    }
+    // Detectar duplicados de id.
+    modules.sort_by(|a, b| a.1.id.cmp(&b.1.id));
+    let mut prev: Option<&(PathBuf, Module)> = None;
+    for cur in &modules {
+        if let Some(p) = prev {
+            if p.1.id == cur.1.id {
+                return Err(SchemaError::DuplicateModuleId {
+                    id: cur.1.id.clone(),
+                    first: p.0.clone(),
+                    second: cur.0.clone(),
+                });
+            }
+        }
+        prev = Some(cur);
+    }
+    Ok(modules.into_iter().map(|(_, m)| m).collect())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn sample_module() -> Module {
+        Module {
+            id: "customers".into(),
+            label: "Clientes".into(),
+            description: Some("Gestión de clientes".into()),
+            nakui_module_dir: None,
+            entities: vec![EntitySpec {
+                name: "customer".into(),
+                label: "Cliente".into(),
+                fields: vec![
+                    FieldSpec {
+                        name: "name".into(),
+                        label: "Nombre".into(),
+                        kind: FieldKind::Text,
+                        default: None,
+                        required: true,
+                        help: None,
+                        ref_entity: None,
+                        options: Vec::new(),
+                        section: None,
+                    },
+                    FieldSpec {
+                        name: "email".into(),
+                        label: "Email".into(),
+                        kind: FieldKind::Text,
+                        default: None,
+                        required: false,
+                        help: Some("Opcional".into()),
+                        ref_entity: None,
+                        options: Vec::new(),
+                        section: None,
+                    },
+                ],
+            }],
+            menu: vec![
+                MenuItem {
+                    label: "Listar".into(),
+                    view: "list".into(),
+                    icon: None,
+                },
+                MenuItem {
+                    label: "Nuevo".into(),
+                    view: "form".into(),
+                    icon: None,
+                },
+            ],
+            views: BTreeMap::from([
+                (
+                    "list".into(),
+                    View::List(ListView {
+                        title: "Clientes".into(),
+                        entity: "customer".into(),
+                        columns: vec![
+                            Column {
+                                field: "name".into(),
+                                label: "Nombre".into(),
+                                weight: 2.0,
+                                ref_entity: None,
+                                format: ValueFormat::Plain,
+                            },
+                            Column {
+                                field: "email".into(),
+                                label: "Email".into(),
+                                weight: 3.0,
+                                ref_entity: None,
+                                format: ValueFormat::Plain,
+                            },
+                        ],
+                        actions: vec![Action::OpenView {
+                            view: "form".into(),
+                            label: Some("Nuevo".into()),
+                        }],
+                        search_in: vec!["name".into(), "email".into()],
+                        row_detail: None,
+                    }),
+                ),
+                (
+                    "form".into(),
+                    View::Form(FormView {
+                        title: "Nuevo cliente".into(),
+                        entity: "customer".into(),
+                        fields: vec![FieldSpec {
+                            name: "name".into(),
+                            label: "Nombre".into(),
+                            kind: FieldKind::Text,
+                            default: None,
+                            required: true,
+                            help: None,
+                            ref_entity: None,
+                            options: Vec::new(),
+                            section: None,
+                        }],
+                        on_submit: Action::SeedEntity {
+                            entity: "customer".into(),
+                            next_view: Some("list".into()),
+                        },
+                    }),
+                ),
+            ]),
+        }
+    }
+
+    #[test]
+    fn module_serialize_deserialize_roundtrip() {
+        let m = sample_module();
+        let s = serde_json::to_string_pretty(&m).unwrap();
+        let m2: Module = serde_json::from_str(&s).unwrap();
+        assert_eq!(m.id, m2.id);
+        assert_eq!(m.menu.len(), m2.menu.len());
+        assert_eq!(m.views.len(), m2.views.len());
+    }
+
+    #[test]
+    fn validate_passes_for_well_formed_module() {
+        sample_module().validate().unwrap();
+    }
+
+    #[test]
+    fn validate_catches_dangling_menu_view() {
+        let mut m = sample_module();
+        m.menu.push(MenuItem {
+            label: "Roto".into(),
+            view: "no_existe".into(),
+            icon: None,
+        });
+        let err = m.validate().unwrap_err();
+        assert!(matches!(err, SchemaError::DanglingMenuView { .. }));
+    }
+
+    #[test]
+    fn from_path_loads_real_file() {
+        let tmp = tempfile::tempdir().unwrap();
+        let path = tmp.path().join("module.json");
+        std::fs::write(&path, serde_json::to_vec_pretty(&sample_module()).unwrap()).unwrap();
+        let m = Module::from_path(&path).unwrap();
+        assert_eq!(m.id, "customers");
+    }
+
+    #[test]
+    fn load_modules_from_dir_finds_subdirs() {
+        let tmp = tempfile::tempdir().unwrap();
+        let cust_dir = tmp.path().join("customers");
+        std::fs::create_dir(&cust_dir).unwrap();
+        let mut m1 = sample_module();
+        m1.id = "customers".into();
+        std::fs::write(
+            cust_dir.join("module.json"),
+            serde_json::to_vec_pretty(&m1).unwrap(),
+        )
+        .unwrap();
+        let prod_dir = tmp.path().join("products");
+        std::fs::create_dir(&prod_dir).unwrap();
+        let mut m2 = sample_module();
+        m2.id = "products".into();
+        std::fs::write(
+            prod_dir.join("module.json"),
+            serde_json::to_vec_pretty(&m2).unwrap(),
+        )
+        .unwrap();
+
+        let mods = load_modules_from_dir(tmp.path()).unwrap();
+        assert_eq!(mods.len(), 2);
+        assert_eq!(mods[0].id, "customers");
+        assert_eq!(mods[1].id, "products");
+    }
+
+    #[test]
+    fn validate_catches_entity_ref_without_target() {
+        let mut m = sample_module();
+        // Inyectamos un form con un campo EntityRef sin ref_entity.
+        m.views.insert(
+            "broken_form".into(),
+            View::Form(FormView {
+                title: "Roto".into(),
+                entity: "customer".into(),
+                fields: vec![FieldSpec {
+                    name: "ref_to_nowhere".into(),
+                    label: "Referencia".into(),
+                    kind: FieldKind::EntityRef,
+                    default: None,
+                    required: true,
+                    help: None,
+                    ref_entity: None,
+                    options: Vec::new(),
+                    section: None,
+                }],
+                on_submit: Action::SeedEntity {
+                    entity: "customer".into(),
+                    next_view: None,
+                },
+            }),
+        );
+        let err = m.validate().unwrap_err();
+        assert!(
+            matches!(err, SchemaError::EntityRefMissingTarget { .. }),
+            "got: {err:?}"
+        );
+    }
+
+    #[test]
+    fn entity_ref_with_target_validates_clean() {
+        let mut m = sample_module();
+        m.views.insert(
+            "ok_form".into(),
+            View::Form(FormView {
+                title: "OK".into(),
+                entity: "customer".into(),
+                fields: vec![FieldSpec {
+                    name: "supplier".into(),
+                    label: "Proveedor".into(),
+                    kind: FieldKind::EntityRef,
+                    default: None,
+                    required: true,
+                    help: None,
+                    ref_entity: Some("supplier".into()),
+                    options: Vec::new(),
+                    section: None,
+                }],
+                on_submit: Action::SeedEntity {
+                    entity: "customer".into(),
+                    next_view: None,
+                },
+            }),
+        );
+        m.menu.push(MenuItem {
+            label: "OK".into(),
+            view: "ok_form".into(),
+            icon: None,
+        });
+        m.validate().unwrap();
+    }
+
+    #[test]
+    fn select_without_options_is_rejected() {
+        let mut m = sample_module();
+        m.views.insert(
+            "sel_form".into(),
+            View::Form(FormView {
+                title: "Select roto".into(),
+                entity: "customer".into(),
+                fields: vec![FieldSpec {
+                    name: "estado".into(),
+                    label: "Estado".into(),
+                    kind: FieldKind::Select,
+                    default: None,
+                    required: true,
+                    help: None,
+                    ref_entity: None,
+                    options: Vec::new(),
+                    section: None,
+                }],
+                on_submit: Action::SeedEntity {
+                    entity: "customer".into(),
+                    next_view: None,
+                },
+            }),
+        );
+        let err = m.validate().unwrap_err();
+        assert!(
+            matches!(err, SchemaError::SelectMissingOptions { .. }),
+            "got: {err:?}"
+        );
+    }
+
+    #[test]
+    fn select_with_options_validates_clean() {
+        let mut m = sample_module();
+        m.views.insert(
+            "sel_form".into(),
+            View::Form(FormView {
+                title: "Select OK".into(),
+                entity: "customer".into(),
+                fields: vec![FieldSpec {
+                    name: "estado".into(),
+                    label: "Estado".into(),
+                    kind: FieldKind::Select,
+                    default: None,
+                    required: true,
+                    help: None,
+                    ref_entity: None,
+                    options: vec![
+                        SelectOption {
+                            value: "activo".into(),
+                            label: Some("Activo".into()),
+                        },
+                        SelectOption {
+                            value: "baja".into(),
+                            label: None,
+                        },
+                    ],
+                    section: None,
+                }],
+                on_submit: Action::SeedEntity {
+                    entity: "customer".into(),
+                    next_view: None,
+                },
+            }),
+        );
+        m.menu.push(MenuItem {
+            label: "Sel".into(),
+            view: "sel_form".into(),
+            icon: None,
+        });
+        m.validate().unwrap();
+    }
+
+    #[test]
+    fn select_option_display_falls_back_to_value() {
+        let with_label = SelectOption {
+            value: "x".into(),
+            label: Some("Equis".into()),
+        };
+        let bare = SelectOption {
+            value: "y".into(),
+            label: None,
+        };
+        assert_eq!(with_label.display(), "Equis");
+        assert_eq!(bare.display(), "y");
+    }
+
+    #[test]
+    fn chart_kind_defaults_to_bars_and_parses() {
+        // Card sin `chart` → default Bars (back-compat con tableros viejos).
+        let card: DashboardCard = serde_json::from_value(serde_json::json!({
+            "label": "Clientes por plan",
+            "entity": "customers",
+            "metric": { "kind": "group_by", "field": "tier" }
+        }))
+        .unwrap();
+        assert_eq!(card.chart, ChartKind::Bars);
+        assert_eq!(card.limit, None);
+
+        // `limit` parsea como entero opcional.
+        let capped: DashboardCard = serde_json::from_value(serde_json::json!({
+            "label": "Top clientes",
+            "entity": "orders",
+            "metric": { "kind": "sum_by", "group": "customer", "value": "monto" },
+            "limit": 8
+        }))
+        .unwrap();
+        assert_eq!(capped.limit, Some(8));
+        assert_eq!(card.bucket, None);
+
+        // `bucket` parsea en snake_case a DateBucket.
+        let serie: DashboardCard = serde_json::from_value(serde_json::json!({
+            "label": "Por mes",
+            "entity": "orders",
+            "metric": { "kind": "sum_by", "group": "fecha", "value": "monto" },
+            "chart": "line",
+            "bucket": "month"
+        }))
+        .unwrap();
+        assert_eq!(serie.bucket, Some(DateBucket::Month));
+
+        // Con `chart` explícito en snake_case.
+        let pie: DashboardCard = serde_json::from_value(serde_json::json!({
+            "label": "Facturación",
+            "entity": "orders",
+            "metric": { "kind": "sum_by", "group": "customer", "value": "monto" },
+            "chart": "pie"
+        }))
+        .unwrap();
+        assert_eq!(pie.chart, ChartKind::Pie);
+        assert_eq!(ChartKind::default(), ChartKind::Bars);
+
+        // Columns y Line también parsean en snake_case.
+        for (raw, want) in [("columns", ChartKind::Columns), ("line", ChartKind::Line)] {
+            let c: DashboardCard = serde_json::from_value(serde_json::json!({
+                "label": "x",
+                "entity": "orders",
+                "metric": { "kind": "sum_by", "group": "mes", "value": "monto" },
+                "chart": raw
+            }))
+            .unwrap();
+            assert_eq!(c.chart, want);
+        }
+    }
+
+    #[test]
+    fn load_modules_detects_duplicate_id() {
+        let tmp = tempfile::tempdir().unwrap();
+        let a_dir = tmp.path().join("a");
+        let b_dir = tmp.path().join("b");
+        std::fs::create_dir_all(&a_dir).unwrap();
+        std::fs::create_dir_all(&b_dir).unwrap();
+        let m = sample_module(); // id = "customers"
+        std::fs::write(a_dir.join("module.json"), serde_json::to_vec(&m).unwrap()).unwrap();
+        std::fs::write(b_dir.join("module.json"), serde_json::to_vec(&m).unwrap()).unwrap();
+        let err = load_modules_from_dir(tmp.path()).unwrap_err();
+        assert!(matches!(err, SchemaError::DuplicateModuleId { .. }));
+    }
+}
diff --git a/02_ruway/nahual/libs/meta-schema/tests/example_modules.rs b/02_ruway/nahual/libs/meta-schema/tests/example_modules.rs
new file mode 100644
index 0000000..12bdca6
--- /dev/null
+++ b/02_ruway/nahual/libs/meta-schema/tests/example_modules.rs
@@ -0,0 +1,99 @@
+//! Validación de los módulos demo que trae el shell `nakui-ui-llimphi`
+//! en `01_yachay/nakui/nakui-ui-llimphi/examples/nakui-modules/`.
+//!
+//! Si esto está verde, garantizamos que un usuario que clone el repo y
+//! corra `NAKUI_MODULES_DIR=01_yachay/nakui/nakui-ui-llimphi/examples/nakui-modules
+//! cargo run -p nakui-ui-llimphi` va a obtener los módulos cargados sin
+//! tocar nada.
+
+use nahual_meta_schema::{load_modules_from_dir, FieldKind, View};
+
+fn examples_dir() -> std::path::PathBuf {
+    // El crate vive en `02_ruway/nahual/libs/meta-schema`; el repo root
+    // queda 4 niveles arriba. Los módulos demo viven junto al shell.
+    let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
+    here.join("../../../..")
+        .join("01_yachay/nakui/nakui-ui-llimphi/examples/nakui-modules")
+}
+
+#[test]
+fn loads_demo_modules() {
+    let dir = examples_dir();
+    let mods = load_modules_from_dir(&dir).unwrap_or_else(|e| {
+        panic!("load failed for {}: {e}", dir.display());
+    });
+    let ids: Vec<&str> = mods.iter().map(|m| m.id.as_str()).collect();
+    assert_eq!(
+        ids,
+        vec!["tesoro", "ventas"],
+        "se esperaban los módulos demo 'tesoro' (tesorería) y 'ventas'"
+    );
+}
+
+#[test]
+fn every_demo_module_has_list_and_form_views() {
+    let mods = load_modules_from_dir(examples_dir()).unwrap();
+    for m in &mods {
+        let mut has_list = false;
+        let mut has_form = false;
+        for v in m.views.values() {
+            match v {
+                View::List(_) => has_list = true,
+                View::Form(_) => has_form = true,
+                View::Detail(_) | View::Dashboard(_) | View::Report(_) | View::Graph(_) => {}
+            }
+        }
+        assert!(
+            has_list && has_form,
+            "module {} should expose at least one list + one form view",
+            m.id
+        );
+    }
+}
+
+#[test]
+fn ventas_has_dashboard_and_report_views() {
+    let mods = load_modules_from_dir(examples_dir()).unwrap();
+    let ventas = mods.iter().find(|m| m.id == "ventas").expect("ventas");
+    let has_dashboard = ventas.views.values().any(|v| matches!(v, View::Dashboard(_)));
+    let has_report = ventas.views.values().any(|v| matches!(v, View::Report(_)));
+    assert!(has_dashboard, "ventas debería tener un tablero");
+    assert!(has_report, "ventas debería tener un reporte");
+}
+
+#[test]
+fn every_demo_form_field_kind_is_recognized() {
+    // Sanity: ningún módulo demo usa un kind que no esté en el enum
+    // (sería rechazado al parsear, pero check explícito no daña).
+    let mods = load_modules_from_dir(examples_dir()).unwrap();
+    for m in &mods {
+        for v in m.views.values() {
+            if let View::Form(form) = v {
+                for f in &form.fields {
+                    let _ok = matches!(
+                        f.kind,
+                        FieldKind::Text
+                            | FieldKind::Multiline
+                            | FieldKind::Number
+                            | FieldKind::Boolean
+                            | FieldKind::Date
+                            | FieldKind::Select
+                            | FieldKind::EntityRef
+                            | FieldKind::AutoId
+                    );
+                }
+            }
+        }
+    }
+}
+
+#[test]
+fn every_module_validates_clean() {
+    // validate() chequea que cada MenuItem.view exista en views y que
+    // los row_detail apunten a una vista Detail. Un typo haría fallar.
+    let mods = load_modules_from_dir(examples_dir()).unwrap();
+    for m in &mods {
+        m.validate()
+            .unwrap_or_else(|e| panic!("module {} failed validate: {e}", m.id));
+    }
+}
diff --git a/02_ruway/nahual/nahual-archive-viewer-llimphi/Cargo.toml b/02_ruway/nahual/nahual-archive-viewer-llimphi/Cargo.toml
new file mode 100644
index 0000000..cc82daa
--- /dev/null
+++ b/02_ruway/nahual/nahual-archive-viewer-llimphi/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "nahual-archive-viewer-llimphi"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "nahual-archive-viewer-llimphi — visor de archivos comprimidos sobre Llimphi. Lista las entradas (nombres, tamaño, ratio) de ZIP (y su familia .jar/.apk/.epub/.docx/.xlsx/.pptx), tar y tar.gz en vez del volcado hex. Décimo visor del shell nahual."
+
+[dependencies]
+nahual-viewer-core = { workspace = true }
+llimphi-ui = { workspace = true }
+llimphi-theme = { workspace = true }
diff --git a/02_ruway/nahual/nahual-archive-viewer-llimphi/src/lib.rs b/02_ruway/nahual/nahual-archive-viewer-llimphi/src/lib.rs
new file mode 100644
index 0000000..ed1f8ab
--- /dev/null
+++ b/02_ruway/nahual/nahual-archive-viewer-llimphi/src/lib.rs
@@ -0,0 +1,147 @@
+//! `nahual-archive-viewer-llimphi` — visor de archivos comprimidos.
+//!
+//! Décimo visor del shell meta-app. Un `.zip`/`.tar`/`.tar.gz` lo detecta
+//! `shuma-discern` por su magic, pero hasta ahora caían al **hex viewer**
+//! (o al texto) — bytes ilegibles. Un archivo comprimido es un
+//! *contenedor*: lo útil es ver qué hay **dentro**, no su entropía. Este
+//! visor lista cada entrada con su tamaño (y, para ZIP, su ratio).
+//!
+//! Soporta tres formatos, decidido por el **contenido** (no la extensión):
+//! - **ZIP** (`PK`): lee el directorio central con `by_index_raw`, sin
+//!   descomprimir. Cubre la familia entera — `.jar`/`.apk`/`.epub` y los
+//!   ofimáticos OOXML (`.docx`/`.xlsx`/`.pptx`) son ZIPs.
+//! - **tar** (`ustar` en off 257): recorre los headers en streaming.
+//! - **tar.gz** (`1f 8b`): descomprime en streaming con `flate2` y recorre
+//!   el tar interno; salta los datos de cada entrada (sólo lee headers),
+//!   así no carga el archivo entero en memoria.
+//!
+//! Patrón fino de los otros viewers: carga sync en [`load_archive`],
+//! render en [`archive_viewer_view`]. No conoce el AppBus: el caller
+//! pasa el path. MVP feo-primero: la lista es un bloque de texto
+//! monoespaciado, estático (sin extraer entradas con click todavía).
+
+#![forbid(unsafe_code)]
+
+use std::path::Path;
+
+use llimphi_ui::llimphi_layout::taffy::{
+    prelude::{length, percent, FlexDirection, Size, Style},
+    AlignItems, Rect,
+};
+use llimphi_ui::llimphi_raster::peniko::Color;
+use llimphi_ui::llimphi_text::Alignment;
+use llimphi_ui::View;
+
+// El dominio (parseo + tipos) vive en `nahual-viewer-core`; lo
+// re-exportamos para no romper a los consumidores.
+pub use nahual_viewer_core::archive::*;
+
+/// Paleta del viewer.
+#[derive(Debug, Clone, Copy)]
+pub struct ArchiveViewerPalette {
+    pub bg: Color,
+    pub fg_text: Color,
+    pub fg_muted: Color,
+    pub fg_error: Color,
+}
+
+impl Default for ArchiveViewerPalette {
+    fn default() -> Self {
+        Self::from_theme(&llimphi_theme::Theme::dark())
+    }
+}
+
+impl ArchiveViewerPalette {
+    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
+        Self {
+            bg: t.bg_app,
+            fg_text: t.fg_text,
+            fg_muted: t.fg_muted,
+            fg_error: t.fg_destructive,
+        }
+    }
+}
+
+/// Pinta header (nombre del archivo) + body con el listado monoespaciado.
+pub fn archive_viewer_view<Msg>(
+    state: &ArchivePreview,
+    path: Option<&Path>,
+    palette: &ArchiveViewerPalette,
+) -> View<Msg>
+where
+    Msg: Clone + 'static,
+{
+    let header_text = match path {
+        Some(p) => format!(
+            "archive · {}",
+            p.file_name()
+                .map(|s| s.to_string_lossy().to_string())
+                .unwrap_or_else(|| p.display().to_string())
+        ),
+        None => "(seleccioná un ZIP/tar/tar.gz)".to_string(),
+    };
+
+    let header = View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(20.0_f32),
+        },
+        padding: Rect {
+            left: length(12.0_f32),
+            right: length(12.0_f32),
+            top: length(0.0_f32),
+            bottom: length(0.0_f32),
+        },
+        align_items: Some(AlignItems::Center),
+        ..Default::default()
+    })
+    .text_aligned(header_text, 10.0, palette.fg_muted, Alignment::Start);
+
+    let (body_text, body_color) = match state {
+        ArchivePreview::Empty => ("—".to_string(), palette.fg_muted),
+        ArchivePreview::Listing(l) => (render_listing(l), palette.fg_text),
+        ArchivePreview::Error(e) => (format!("(no se pudo abrir: {e})"), palette.fg_error),
+    };
+
+    let body = View::new(Style {
+        flex_grow: 1.0,
+        size: Size {
+            width: percent(1.0_f32),
+            height: percent(1.0_f32),
+        },
+        padding: Rect {
+            left: length(12.0_f32),
+            right: length(12.0_f32),
+            top: length(6.0_f32),
+            bottom: length(12.0_f32),
+        },
+        ..Default::default()
+    })
+    .text_aligned_full(
+        body_text,
+        12.0,
+        body_color,
+        Alignment::Start,
+        false,
+        Some("monospace".to_string()),
+    );
+
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        flex_grow: 1.0,
+        size: Size {
+            width: percent(1.0_f32),
+            height: percent(1.0_f32),
+        },
+        padding: Rect {
+            left: length(0.0_f32),
+            right: length(0.0_f32),
+            top: length(6.0_f32),
+            bottom: length(0.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(palette.bg)
+    .clip(true)
+    .children(vec![header, body])
+}
diff --git a/02_ruway/nahual/nahual-audio-viewer-llimphi/Cargo.toml b/02_ruway/nahual/nahual-audio-viewer-llimphi/Cargo.toml
new file mode 100644
index 0000000..65faa87
--- /dev/null
+++ b/02_ruway/nahual/nahual-audio-viewer-llimphi/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "nahual-audio-viewer-llimphi"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "nahual-audio-viewer-llimphi — reproductor/visor de audio sobre Llimphi. Decodifica WAV/MP3/FLAC/Opus/Vorbis (media-source-*), reproduce por cpal (media-audio-cpal) y pinta un espectro log-band en vivo vía AudioProbe + Spectrum. Quinto visor del shell nahual."
+
+[dependencies]
+llimphi-ui = { workspace = true }
+llimphi-theme = { workspace = true }
+media-core = { workspace = true }
+media-audio-cpal = { workspace = true }
+media-source-wav = { workspace = true }
+media-source-mp3 = { workspace = true }
+media-source-flac = { workspace = true }
+media-source-opus = { workspace = true }
+media-source-vorbis = { workspace = true }
+parking_lot = { workspace = true }
+
+[[example]]
+name = "audio_viewer_demo"
+path = "examples/audio_viewer_demo.rs"
diff --git a/02_ruway/nahual/nahual-audio-viewer-llimphi/examples/audio_viewer_demo.rs b/02_ruway/nahual/nahual-audio-viewer-llimphi/examples/audio_viewer_demo.rs
new file mode 100644
index 0000000..439d188
--- /dev/null
+++ b/02_ruway/nahual/nahual-audio-viewer-llimphi/examples/audio_viewer_demo.rs
@@ -0,0 +1,81 @@
+//! Showcase de `nahual-audio-viewer-llimphi`.
+//!
+//! `cargo run -p nahual-audio-viewer-llimphi --example audio_viewer_demo --release -- /path/clip.mp3`
+//!
+//! Sin argumento abre nada (placeholder); con un archivo WAV/MP3/FLAC/
+//! Opus/OGG lo reproduce y muestra el espectro. Espacio: play/pausa.
+
+use std::path::PathBuf;
+use std::time::Duration;
+
+use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
+use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View};
+use nahual_audio_viewer_llimphi::{audio_viewer_view, AudioViewerPalette, AudioViewerState};
+
+const TICK: Duration = Duration::from_millis(33); // ~30 Hz
+
+struct Model {
+    state: AudioViewerState,
+}
+
+#[derive(Clone)]
+enum Msg {
+    Tick,
+    TogglePlay,
+}
+
+struct Showcase;
+
+impl App for Showcase {
+    type Model = Model;
+    type Msg = Msg;
+
+    fn title() -> &'static str {
+        "llimphi · audio viewer showcase"
+    }
+
+    fn initial_size() -> (u32, u32) {
+        (820, 420)
+    }
+
+    fn init(handle: &Handle<Msg>) -> Model {
+        handle.spawn_periodic(TICK, || Msg::Tick);
+        let state = match std::env::args().nth(1).map(PathBuf::from) {
+            Some(p) => AudioViewerState::open(&p),
+            None => AudioViewerState::default(),
+        };
+        Model { state }
+    }
+
+    fn on_key(_model: &Model, e: &KeyEvent) -> Option<Msg> {
+        if e.state == KeyState::Pressed && matches!(&e.key, Key::Named(NamedKey::Space)) {
+            return Some(Msg::TogglePlay);
+        }
+        None
+    }
+
+    fn update(mut model: Model, msg: Msg, _: &Handle<Msg>) -> Model {
+        match msg {
+            Msg::Tick => model.state.tick(TICK),
+            Msg::TogglePlay => model.state.toggle_play(),
+        }
+        model
+    }
+
+    fn view(model: &Model) -> View<Msg> {
+        let palette = AudioViewerPalette::default();
+        let viewer = audio_viewer_view::<Msg>(&model.state, &palette);
+        View::new(Style {
+            size: Size {
+                width: percent(1.0_f32),
+                height: percent(1.0_f32),
+            },
+            ..Default::default()
+        })
+        .children(vec![viewer])
+    }
+}
+
+fn main() {
+    llimphi_ui::run::<Showcase>();
+}
diff --git a/02_ruway/nahual/nahual-audio-viewer-llimphi/src/lib.rs b/02_ruway/nahual/nahual-audio-viewer-llimphi/src/lib.rs
new file mode 100644
index 0000000..a8d18da
--- /dev/null
+++ b/02_ruway/nahual/nahual-audio-viewer-llimphi/src/lib.rs
@@ -0,0 +1,467 @@
+//! `nahual-audio-viewer-llimphi` — reproductor/visor de audio.
+//!
+//! Quinto visor del shell meta-app (tras texto/imagen/video/card). Abre
+//! un archivo de audio (WAV/MP3/FLAC/Opus/Vorbis vía `media-source-*`),
+//! lo reproduce por el sink cpal (`media-audio-cpal`) y pinta un
+//! **espectro en vivo** — bandas log-espaciadas calculadas con el
+//! `Spectrum` (Goertzel) de `media-core` sobre los samples que un
+//! `AudioProbe` tapa del stream realtime.
+//!
+//! ## Cómo se sostiene el stream
+//!
+//! El [`AudioSink`] envuelve un `cpal::Stream` que es `!Send`/`!Sync` —
+//! por eso vive **dentro** del estado del visor (que la app guarda en su
+//! `Model`, sólo `'static`, no `Send`). Soltar el `AudioViewerState`
+//! (cambiar de archivo, navegar a otra cosa) dropea el sink y para el
+//! audio. No hay statics ni leaks: un visor = un stream.
+//!
+//! ## Posición
+//!
+//! La cadena `AudioSource → sink` está type-erased detrás de un
+//! `Arc<Mutex<dyn AudioSource>>`, así que el visor NO lee `Seekable` de
+//! la fuente: estima el playhead con su propio reloj (acumula `dt` en
+//! [`AudioViewerState::tick`], como el video viewer). Es suficiente para
+//! un meter; el seek real llegará cuando la cadena exponga `Seekable`.
+
+#![forbid(unsafe_code)]
+
+use std::path::Path;
+use std::sync::Arc;
+use std::time::Duration;
+
+use parking_lot::Mutex;
+
+use llimphi_ui::llimphi_layout::taffy::{
+    prelude::{length, percent, FlexDirection, Size, Style},
+    AlignItems, Rect,
+};
+use llimphi_ui::llimphi_raster::kurbo::{Affine, Rect as KurboRect};
+use llimphi_ui::llimphi_raster::peniko::{Color, Fill};
+use llimphi_ui::llimphi_text::Alignment;
+use llimphi_ui::View;
+
+use media_audio_cpal::AudioSink;
+use media_core::{AudioProbe, AudioSource, Pause, PausableAudio, ProbedAudioSource, Spectrum};
+
+/// Cantidad de bandas del espectro y su rango (Hz). 48 bandas entre
+/// 40 Hz y 16 kHz es un compromiso legible: cubre el grueso musical sin
+/// barras tan finas que no se distingan.
+const SPECTRUM_BANDS: usize = 48;
+const SPECTRUM_FMIN: f32 = 40.0;
+const SPECTRUM_FMAX: f32 = 16_000.0;
+/// Capacidad del ring del probe (samples intercalados). ≈ 8k da una
+/// ventana de ~85 ms a 48 kHz stereo — responsivo sin titilar.
+const PROBE_CAPACITY: usize = 8 * 1024;
+
+/// Estado del reproductor de audio. No es `Clone` (ni `Send`): contiene
+/// el `AudioSink`/`cpal::Stream`. La app lo guarda en su `Model`.
+pub struct AudioViewerState {
+    /// Mantiene vivo el stream cpal. `None` si no se pudo abrir.
+    _sink: Option<AudioSink>,
+    /// Tap de samples del stream para el espectro.
+    probe: AudioProbe,
+    /// Analizador log-band (Goertzel + release suave).
+    spectrum: Spectrum,
+    /// Buffer reusado para el snapshot del probe (evita realloc/tick).
+    scratch: Vec<f32>,
+    /// Handle de pausa compartido con el `PausableAudio` de la cadena.
+    pause: Pause,
+    name: String,
+    sample_rate: u32,
+    channels: u16,
+    duration: Option<Duration>,
+    position: Duration,
+    playing: bool,
+    error: Option<String>,
+}
+
+impl Default for AudioViewerState {
+    fn default() -> Self {
+        Self {
+            _sink: None,
+            probe: AudioProbe::new(PROBE_CAPACITY),
+            spectrum: Spectrum::log_bands(SPECTRUM_BANDS, SPECTRUM_FMIN, SPECTRUM_FMAX),
+            scratch: Vec::new(),
+            pause: Pause::new(),
+            name: String::new(),
+            sample_rate: 0,
+            channels: 0,
+            duration: None,
+            position: Duration::ZERO,
+            playing: false,
+            error: None,
+        }
+    }
+}
+
+/// Una fuente de audio ya decodificada + sus metadatos de presentación.
+/// La fuente se boxea para borrar el tipo concreto antes de entrar al
+/// `Arc<Mutex<dyn AudioSource>>` que el sink consume.
+struct DecodedAudio {
+    source: Box<dyn AudioSource + Send>,
+    channels: u16,
+    sample_rate: u32,
+    duration: Duration,
+}
+
+impl AudioViewerState {
+    /// Abre y reproduce un archivo de audio. La extensión elige el
+    /// decoder; el contenido ya fue discernido como audio por el shell.
+    /// Si falla el decode o el sink, queda en estado de error (lo
+    /// muestra el header) y sin sonido.
+    pub fn open(path: &Path) -> Self {
+        let name = path
+            .file_name()
+            .map(|s| s.to_string_lossy().to_string())
+            .unwrap_or_default();
+
+        let decoded = match decode(path) {
+            Ok(d) => d,
+            Err(e) => {
+                return Self {
+                    name,
+                    error: Some(e),
+                    ..Default::default()
+                };
+            }
+        };
+
+        let probe = AudioProbe::new(PROBE_CAPACITY);
+        let pause = Pause::new();
+        // Orden de la cadena: pausa primero (en pausa rellena silencio),
+        // probe después → el espectro ve silencio y decae al pausar.
+        let pausable = PausableAudio::new(decoded.source, pause.clone());
+        let probed = ProbedAudioSource::new(pausable, probe.clone());
+        let source: Arc<Mutex<dyn AudioSource + Send>> = Arc::new(Mutex::new(probed));
+
+        match AudioSink::open(source) {
+            Ok(sink) => Self {
+                _sink: Some(sink),
+                probe,
+                spectrum: Spectrum::log_bands(SPECTRUM_BANDS, SPECTRUM_FMIN, SPECTRUM_FMAX),
+                scratch: Vec::new(),
+                pause,
+                name,
+                sample_rate: decoded.sample_rate,
+                channels: decoded.channels,
+                duration: Some(decoded.duration),
+                position: Duration::ZERO,
+                playing: true,
+                error: None,
+            },
+            Err(e) => Self {
+                name,
+                error: Some(format!("sin salida de audio: {e}")),
+                ..Default::default()
+            },
+        }
+    }
+
+    pub fn position(&self) -> Duration {
+        self.position
+    }
+
+    pub fn duration(&self) -> Option<Duration> {
+        self.duration
+    }
+
+    pub fn is_playing(&self) -> bool {
+        self.playing
+    }
+
+    /// Play/pausa. Congela el stream (silencio) y detiene el avance del
+    /// reloj de posición.
+    pub fn toggle_play(&mut self) {
+        if self._sink.is_none() {
+            return;
+        }
+        self.pause.toggle();
+        self.playing = !self.playing;
+    }
+
+    /// Avanza el reloj y refresca el espectro con el último tramo del
+    /// stream. Sin efecto si está en pausa o en error.
+    pub fn tick(&mut self, dt: Duration) {
+        if !self.playing || self._sink.is_none() {
+            return;
+        }
+        let (sr, ch) = self.probe.snapshot(&mut self.scratch);
+        if sr > 0 {
+            self.spectrum.analyze(&self.scratch, ch, sr);
+        }
+        let next = self.position.saturating_add(dt);
+        self.position = match self.duration {
+            Some(d) if next > d => d,
+            _ => next,
+        };
+    }
+
+    /// Magnitudes actuales del espectro (una por banda, [0,1]).
+    pub fn magnitudes(&self) -> &[f32] {
+        self.spectrum.magnitudes()
+    }
+}
+
+/// Decodifica el archivo según extensión a una fuente de audio + metadatos.
+fn decode(path: &Path) -> Result<DecodedAudio, String> {
+    let ext = path
+        .extension()
+        .and_then(|s| s.to_str())
+        .map(str::to_ascii_lowercase);
+
+    macro_rules! build {
+        ($src:expr) => {{
+            let s = $src.map_err(|e| e.to_string())?;
+            DecodedAudio {
+                channels: s.source_channels(),
+                sample_rate: s.source_sample_rate(),
+                duration: Duration::from_secs_f32(s.duration_seconds().max(0.0)),
+                source: Box::new(s),
+            }
+        }};
+    }
+
+    let decoded = match ext.as_deref() {
+        Some("wav") => build!(media_source_wav::WavSource::from_path(path)),
+        Some("mp3") => build!(media_source_mp3::Mp3Source::from_path(path)),
+        Some("flac") => build!(media_source_flac::FlacSource::from_path(path)),
+        Some("opus") => build!(media_source_opus::OpusSource::from_path(path)),
+        Some("ogg" | "oga") => build!(media_source_vorbis::VorbisSource::from_path(path)),
+        other => {
+            return Err(format!(
+                "formato de audio no soportado: .{}",
+                other.unwrap_or("?")
+            ))
+        }
+    };
+    Ok(decoded)
+}
+
+/// Paleta del visor.
+#[derive(Debug, Clone, Copy)]
+pub struct AudioViewerPalette {
+    pub bg: Color,
+    pub fg: Color,
+    pub fg_muted: Color,
+    pub fg_error: Color,
+    pub accent: Color,
+}
+
+impl Default for AudioViewerPalette {
+    fn default() -> Self {
+        Self::from_theme(&llimphi_theme::Theme::dark())
+    }
+}
+
+impl AudioViewerPalette {
+    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
+        Self {
+            bg: t.bg_app,
+            fg: t.fg_text,
+            fg_muted: t.fg_muted,
+            fg_error: t.fg_destructive,
+            accent: t.accent,
+        }
+    }
+}
+
+fn fmt_time(d: Duration) -> String {
+    let total = d.as_secs();
+    format!("{:02}:{:02}", total / 60, total % 60)
+}
+
+/// Pinta header (nombre · rate/ch · ▶/⏸ · mm:ss/mm:ss) + body con el
+/// espectro (o un placeholder si no hay audio / hay error).
+pub fn audio_viewer_view<Msg>(
+    state: &AudioViewerState,
+    palette: &AudioViewerPalette,
+) -> View<Msg>
+where
+    Msg: Clone + 'static,
+{
+    let name = if state.name.is_empty() {
+        "(seleccioná un audio)".to_string()
+    } else {
+        state.name.clone()
+    };
+
+    let header_text = if let Some(e) = &state.error {
+        format!("{name} · error: {e}")
+    } else if state._sink.is_some() {
+        let glyph = if state.playing { "▶" } else { "⏸" };
+        let time = match state.duration {
+            Some(d) => format!("{} / {}", fmt_time(state.position), fmt_time(d)),
+            None => fmt_time(state.position),
+        };
+        format!(
+            "{name} · {} Hz · {} ch · {glyph} {time}",
+            state.sample_rate, state.channels
+        )
+    } else {
+        name
+    };
+
+    let header_color = if state.error.is_some() {
+        palette.fg_error
+    } else {
+        palette.fg_muted
+    };
+
+    let header = View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(20.0_f32),
+        },
+        padding: Rect {
+            left: length(12.0_f32),
+            right: length(12.0_f32),
+            top: length(0.0_f32),
+            bottom: length(0.0_f32),
+        },
+        align_items: Some(AlignItems::Center),
+        ..Default::default()
+    })
+    .text_aligned(header_text, 10.0, header_color, Alignment::Start);
+
+    let body = match (&state.error, state._sink.is_some()) {
+        (Some(e), _) => placeholder_body(&format!("(error: {e})"), palette.fg_error),
+        (None, true) => spectrum_body(state.magnitudes().to_vec(), palette),
+        (None, false) => placeholder_body("—", palette.fg_muted),
+    };
+
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        flex_grow: 1.0,
+        size: Size {
+            width: percent(1.0_f32),
+            height: percent(1.0_f32),
+        },
+        padding: Rect {
+            left: length(0.0_f32),
+            right: length(0.0_f32),
+            top: length(6.0_f32),
+            bottom: length(0.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(palette.bg)
+    .clip(true)
+    .children(vec![header, body])
+}
+
+fn placeholder_body<Msg>(text: &str, color: Color) -> View<Msg>
+where
+    Msg: Clone + 'static,
+{
+    View::new(Style {
+        flex_grow: 1.0,
+        size: Size {
+            width: percent(1.0_f32),
+            height: percent(1.0_f32),
+        },
+        padding: Rect {
+            left: length(12.0_f32),
+            right: length(12.0_f32),
+            top: length(6.0_f32),
+            bottom: length(12.0_f32),
+        },
+        ..Default::default()
+    })
+    .text_aligned(text.to_string(), 12.0, color, Alignment::Center)
+}
+
+/// Pinta las barras del espectro de abajo hacia arriba. `mags` se mueve
+/// al closure (es chico: una banda por float) para que el painter sea
+/// `Send + Sync` sin compartir el `Spectrum` mutable.
+fn spectrum_body<Msg>(mags: Vec<f32>, palette: &AudioViewerPalette) -> View<Msg>
+where
+    Msg: Clone + 'static,
+{
+    let accent = palette.accent;
+    let track = palette.fg_muted;
+    View::new(Style {
+        flex_grow: 1.0,
+        size: Size {
+            width: percent(1.0_f32),
+            height: percent(1.0_f32),
+        },
+        padding: Rect {
+            left: length(12.0_f32),
+            right: length(12.0_f32),
+            top: length(8.0_f32),
+            bottom: length(12.0_f32),
+        },
+        ..Default::default()
+    })
+    .paint_with(move |scene, _ts, rect| {
+        if rect.w <= 4.0 || rect.h <= 4.0 || mags.is_empty() {
+            return;
+        }
+        let n = mags.len();
+        let slot_w = rect.w / n as f32;
+        let bar_w = (slot_w * 0.7).max(1.0);
+        let baseline = rect.y + rect.h;
+        for (i, &m) in mags.iter().enumerate() {
+            let x0 = rect.x + i as f32 * slot_w;
+            // Piso tenue de 1 px para que se vea la grilla aun en silencio.
+            let h = (m.clamp(0.0, 1.0) * rect.h).max(1.0);
+            let bar = KurboRect::new(
+                x0 as f64,
+                (baseline - h) as f64,
+                (x0 + bar_w) as f64,
+                baseline as f64,
+            );
+            // Mezcla accent→track según altura: barras altas más vivas.
+            let t = m.clamp(0.0, 1.0);
+            let color = lerp_color(track, accent, t);
+            scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &bar);
+        }
+    })
+}
+
+/// Interpola linealmente dos colores en sRGB (suficiente para un meter).
+fn lerp_color(a: Color, b: Color, t: f32) -> Color {
+    let t = t.clamp(0.0, 1.0);
+    let ca = a.to_rgba8();
+    let cb = b.to_rgba8();
+    let mix = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t) as u8;
+    Color::from_rgba8(
+        mix(ca.r, cb.r),
+        mix(ca.g, cb.g),
+        mix(ca.b, cb.b),
+        255,
+    )
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn fmt_time_basico() {
+        assert_eq!(fmt_time(Duration::from_secs(0)), "00:00");
+        assert_eq!(fmt_time(Duration::from_secs(125)), "02:05");
+    }
+
+    #[test]
+    fn open_inexistente_es_error() {
+        let st = AudioViewerState::open(Path::new("/no/existe.wav"));
+        assert!(st.error.is_some());
+        assert!(st._sink.is_none());
+        assert!(!st.is_playing());
+    }
+
+    #[test]
+    fn formato_desconocido_es_error() {
+        let st = AudioViewerState::open(Path::new("/x.xyz"));
+        assert!(st.error.is_some());
+    }
+
+    #[test]
+    fn estado_default_sin_audio() {
+        let st = AudioViewerState::default();
+        assert!(st._sink.is_none());
+        assert_eq!(st.position(), Duration::ZERO);
+        assert_eq!(st.magnitudes().len(), SPECTRUM_BANDS);
+    }
+}
diff --git a/02_ruway/nahual/nahual-card-viewer-llimphi/Cargo.toml b/02_ruway/nahual/nahual-card-viewer-llimphi/Cargo.toml
new file mode 100644
index 0000000..2d6dba2
--- /dev/null
+++ b/02_ruway/nahual/nahual-card-viewer-llimphi/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "nahual-card-viewer-llimphi"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "nahual-card-viewer-llimphi — visor estructurado de Cards (shared/card) sobre Llimphi. Renderiza label/id/kind/payload/supervisión/capacidades/referencias como filas legibles en vez del JSON crudo. Análogo Llimphi del text/image/video viewer del shell nahual."
+
+[dependencies]
+nahual-viewer-core = { workspace = true }
+llimphi-ui = { workspace = true }
+llimphi-theme = { workspace = true }
+card-core = { workspace = true }
diff --git a/02_ruway/nahual/nahual-card-viewer-llimphi/src/lib.rs b/02_ruway/nahual/nahual-card-viewer-llimphi/src/lib.rs
new file mode 100644
index 0000000..d5315a3
--- /dev/null
+++ b/02_ruway/nahual/nahual-card-viewer-llimphi/src/lib.rs
@@ -0,0 +1,148 @@
+//! `nahual-card-viewer-llimphi` — visor estructurado de Cards.
+//!
+//! Cuarto visor del shell meta-app (tras texto/imagen/video). Una Card
+//! (`shared/card`) es JSON, así que el text viewer la abriría como tal;
+//! pero el `lens` `card` que `shuma-discern` produce sobre su contenido
+//! merece un visor que la **presente** — no el blob crudo. Este crate
+//! lee la Card, extrae los campos salientes (identidad, naturaleza,
+//! payload, supervisión, capacidades, permisos, referencias) y los pinta
+//! como filas legibles.
+//!
+//! Sigue el patrón fino de los otros viewers: la carga vive en
+//! [`load_card`] (sync — una Card es chica), el render en
+//! [`card_viewer_view`]. No conoce el AppBus: el caller pasa el path.
+//!
+//! MVP feo-primero: el cuerpo es un bloque de texto `clave  valor` por
+//! línea, no una tabla con layout. Es legible y autocontenido; cuando un
+//! widget de propiedades reusable exista en el elegance kit, se migra.
+
+#![forbid(unsafe_code)]
+
+use std::path::Path;
+
+use card_core::CardKind;
+use llimphi_ui::llimphi_layout::taffy::{
+    prelude::{length, percent, FlexDirection, Size, Style},
+    AlignItems, Rect,
+};
+use llimphi_ui::llimphi_raster::peniko::Color;
+use llimphi_ui::llimphi_text::Alignment;
+use llimphi_ui::View;
+
+// El dominio (parseo + tipos) vive en `nahual-viewer-core`; lo
+// re-exportamos para no romper a los consumidores.
+pub use nahual_viewer_core::card::*;
+
+/// Paleta del visor. Reusa los slots semánticos del tema.
+#[derive(Debug, Clone, Copy)]
+pub struct CardViewerPalette {
+    pub bg: Color,
+    pub fg_text: Color,
+    pub fg_muted: Color,
+    pub fg_error: Color,
+    pub accent: Color,
+}
+
+impl Default for CardViewerPalette {
+    fn default() -> Self {
+        Self::from_theme(&llimphi_theme::Theme::dark())
+    }
+}
+
+impl CardViewerPalette {
+    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
+        Self {
+            bg: t.bg_app,
+            fg_text: t.fg_text,
+            fg_muted: t.fg_muted,
+            fg_error: t.fg_destructive,
+            accent: t.accent,
+        }
+    }
+}
+
+/// Pinta header (label · naturaleza) + body con las filas de la Card.
+pub fn card_viewer_view<Msg>(
+    state: &CardPreview,
+    path: Option<&Path>,
+    palette: &CardViewerPalette,
+) -> View<Msg>
+where
+    Msg: Clone + 'static,
+{
+    let header_text = match state {
+        CardPreview::Card(c) => {
+            let kind = match c.kind {
+                CardKind::Ente => "ente",
+                CardKind::Data => "data",
+            };
+            format!("card · {} · {kind}", c.label)
+        }
+        _ => match path {
+            Some(p) => format!(
+                "card · {}",
+                p.file_name()
+                    .map(|s| s.to_string_lossy().to_string())
+                    .unwrap_or_else(|| p.display().to_string())
+            ),
+            None => "(seleccioná una card)".to_string(),
+        },
+    };
+
+    let header = View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(20.0_f32),
+        },
+        padding: Rect {
+            left: length(12.0_f32),
+            right: length(12.0_f32),
+            top: length(0.0_f32),
+            bottom: length(0.0_f32),
+        },
+        align_items: Some(AlignItems::Center),
+        ..Default::default()
+    })
+    .text_aligned(header_text, 10.0, palette.accent, Alignment::Start);
+
+    let (body_text, body_color) = match state {
+        CardPreview::Empty => ("—".to_string(), palette.fg_muted),
+        CardPreview::Card(c) => (summarize(c), palette.fg_text),
+        CardPreview::Error(e) => (format!("(card inválida: {e})"), palette.fg_error),
+    };
+
+    let body = View::new(Style {
+        flex_grow: 1.0,
+        size: Size {
+            width: percent(1.0_f32),
+            height: percent(1.0_f32),
+        },
+        padding: Rect {
+            left: length(12.0_f32),
+            right: length(12.0_f32),
+            top: length(6.0_f32),
+            bottom: length(12.0_f32),
+        },
+        ..Default::default()
+    })
+    .text_aligned(body_text, 12.0, body_color, Alignment::Start);
+
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        flex_grow: 1.0,
+        size: Size {
+            width: percent(1.0_f32),
+            height: percent(1.0_f32),
+        },
+        padding: Rect {
+            left: length(0.0_f32),
+            right: length(0.0_f32),
+            top: length(6.0_f32),
+            bottom: length(0.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(palette.bg)
+    .clip(true)
+    .children(vec![header, body])
+}
diff --git a/02_ruway/nahual/nahual-file-explorer-llimphi/Cargo.toml b/02_ruway/nahual/nahual-file-explorer-llimphi/Cargo.toml
new file mode 100644
index 0000000..c33c72b
--- /dev/null
+++ b/02_ruway/nahual/nahual-file-explorer-llimphi/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "nahual-file-explorer-llimphi"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "nahual-file-explorer-llimphi — explorador de directorios sobre Llimphi. Crate fino con la lógica de scan/navigation (cwd + Vec<Entry> + selected + visible_offset + wheel_accum) en `FileExplorerState` y un `file_explorer_view` que pinta la lista virtualizada con `llimphi-widget-list`."
+
+[dependencies]
+llimphi-ui = { workspace = true }
+llimphi-theme = { workspace = true }
+llimphi-widget-list = { workspace = true }
diff --git a/02_ruway/nahual/nahual-file-explorer-llimphi/LEEME.md b/02_ruway/nahual/nahual-file-explorer-llimphi/LEEME.md
new file mode 100644
index 0000000..047977c
--- /dev/null
+++ b/02_ruway/nahual/nahual-file-explorer-llimphi/LEEME.md
@@ -0,0 +1,15 @@
+# nahual-file-explorer-llimphi
+
+> File explorer con tree + previews de [nahual](../README.md).
+
+Tree de directorios a la izquierda, contenido + preview a la derecha. Preview por tipo: texto, imagen, MD, code.
+
+## Uso
+
+```sh
+cargo run --release -p nahual-file-explorer-llimphi
+```
+
+## Deps
+
+- [`llimphi-widget-tree`](../../llimphi/widgets/tree/README.md), [`splitter`](../../llimphi/widgets/splitter/README.md)
diff --git a/02_ruway/nahual/nahual-file-explorer-llimphi/README.md b/02_ruway/nahual/nahual-file-explorer-llimphi/README.md
new file mode 100644
index 0000000..bff4cda
--- /dev/null
+++ b/02_ruway/nahual/nahual-file-explorer-llimphi/README.md
@@ -0,0 +1,15 @@
+# nahual-file-explorer-llimphi
+
+> File explorer with tree + previews of [nahual](../README.md).
+
+Tree of directories on the left, content + preview on the right. Preview by type: text, image, MD, code.
+
+## Usage
+
+```sh
+cargo run --release -p nahual-file-explorer-llimphi
+```
+
+## Deps
+
+- [`llimphi-widget-tree`](../../llimphi/widgets/tree/README.md), [`splitter`](../../llimphi/widgets/splitter/README.md)
diff --git a/02_ruway/nahual/nahual-file-explorer-llimphi/src/lib.rs b/02_ruway/nahual/nahual-file-explorer-llimphi/src/lib.rs
new file mode 100644
index 0000000..60b6530
--- /dev/null
+++ b/02_ruway/nahual/nahual-file-explorer-llimphi/src/lib.rs
@@ -0,0 +1,287 @@
+//! `nahual-file-explorer-llimphi` — explorador de directorios sobre
+//! Llimphi.
+//!
+//! Reemplazo Llimphi del `nahual-file-explorer` GPUI. Crate fino que
+//! encapsula:
+//! - [`Entry`] / [`FileExplorerState`] — modelo (cwd, entries,
+//!   selección, scroll, ancho del pane si aplica).
+//! - Transiciones puras: [`FileExplorerState::up`], `down`,
+//!   `open_selected`, `parent`, `select`, `scroll` y `apply_wheel`
+//!   (cada una devuelve un boolean o un [`OpenedFile`] que el caller
+//!   usa para decidir si previsualizar/abrir).
+//! - [`file_explorer_view`] — pinta la lista virtualizada con
+//!   `llimphi-widget-list`, devolviendo Msgs vía un mapper de
+//!   `usize` (índice fila) a `Msg` que el caller provee.
+//!
+//! El estado es puro: no abre archivos, no llama a `fs::read` —
+//! sólo navega. La lectura/preview es responsabilidad del consumidor
+//! (`nahual-text-viewer-llimphi`, `nahual-image-viewer-llimphi`,
+//! etc.). Reaccionar a `OpenedFile` es típicamente "cargar preview
+//! con `load_preview`/`load_image` y guardarlo en mi modelo".
+
+#![forbid(unsafe_code)]
+
+use std::cmp::min;
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use llimphi_ui::View;
+use llimphi_widget_list::{list_view, ListPalette, ListRow, ListSpec};
+
+/// Una entrada del directorio actual.
+#[derive(Clone, Debug)]
+pub struct Entry {
+    pub name: String,
+    pub is_dir: bool,
+}
+
+/// Cuántas filas mostramos a la vez. Calibrado para un viewport típico
+/// (alto del pane ≈ 760 px ÷ 22 px/row ≈ 34 filas). El caller puede
+/// crear el state con otro valor si tiene viewports distintos.
+pub const DEFAULT_VISIBLE_ROWS: usize = 32;
+/// Alto en px de cada fila. Lo usa `ListSpec`.
+pub const DEFAULT_ROW_HEIGHT: f32 = 22.0;
+/// "Líneas" de la rueda que equivalen a una fila.
+pub const WHEEL_LINES_PER_ROW: f32 = 1.0;
+
+/// Resultado de [`FileExplorerState::open_selected`]: si la selección
+/// es un directorio, ya se hizo el `cd` (`Directory`); si es archivo,
+/// devuelve el path para que el caller decida qué hacer (preview,
+/// abrir con app externa, etc.).
+#[derive(Clone, Debug)]
+pub enum OpenedFile {
+    Directory,
+    File(PathBuf),
+}
+
+/// Estado del explorador. Puro: mutarlo es trivial y no toca IO
+/// excepto en `cd` (que sí relee el directorio nuevo). El caller lo
+/// mantiene en su `Model` y pasa `&mut state` a las transiciones.
+pub struct FileExplorerState {
+    pub cwd: PathBuf,
+    pub entries: Vec<Entry>,
+    pub selected: usize,
+    pub visible_offset: usize,
+    /// Acumulador fraccional de la rueda — para touchpads que mandan
+    /// deltas chicos. `apply_wheel` lo lee, calcula los pasos enteros
+    /// y deja la fracción residual.
+    pub wheel_accum: f32,
+    pub visible_rows: usize,
+}
+
+impl FileExplorerState {
+    /// Construye un explorador anclado en `cwd`. Si el path no se
+    /// puede leer, las entries quedan vacías; mostrar mensaje queda
+    /// para el caller.
+    pub fn new(cwd: PathBuf) -> Self {
+        let entries = scan_dir(&cwd);
+        Self {
+            cwd,
+            entries,
+            selected: 0,
+            visible_offset: 0,
+            wheel_accum: 0.0,
+            visible_rows: DEFAULT_VISIBLE_ROWS,
+        }
+    }
+
+    /// El path resultante de combinar `cwd` con la entrada
+    /// seleccionada. `None` si no hay selección válida.
+    pub fn selected_path(&self) -> Option<PathBuf> {
+        self.entries
+            .get(self.selected)
+            .map(|e| self.cwd.join(&e.name))
+    }
+
+    /// La entrada actualmente seleccionada (clonada).
+    pub fn selected_entry(&self) -> Option<Entry> {
+        self.entries.get(self.selected).cloned()
+    }
+
+    /// Mueve la selección una fila arriba si se puede.
+    pub fn up(&mut self) -> bool {
+        if self.selected == 0 {
+            return false;
+        }
+        self.selected -= 1;
+        self.sync_offset();
+        true
+    }
+
+    /// Mueve la selección una fila abajo si se puede.
+    pub fn down(&mut self) -> bool {
+        if self.selected + 1 >= self.entries.len() {
+            return false;
+        }
+        self.selected += 1;
+        self.sync_offset();
+        true
+    }
+
+    /// Selecciona la entrada en `idx` (con bound check + scroll sync).
+    pub fn select(&mut self, idx: usize) -> bool {
+        if idx >= self.entries.len() {
+            return false;
+        }
+        self.selected = idx;
+        self.sync_offset();
+        true
+    }
+
+    /// Si la selección es un directorio, hace `cd` (relee entries,
+    /// resetea selección/offset). Si es archivo, devuelve su path.
+    pub fn open_selected(&mut self) -> Option<OpenedFile> {
+        let entry = self.entries.get(self.selected).cloned()?;
+        if entry.is_dir {
+            let new_cwd = self.cwd.join(&entry.name);
+            // Canonicalize cuando se pueda: resuelve symlinks y "..".
+            // Si falla (permisos), nos quedamos con el join textual.
+            self.cwd = fs::canonicalize(&new_cwd).unwrap_or(new_cwd);
+            self.entries = scan_dir(&self.cwd);
+            self.selected = 0;
+            self.visible_offset = 0;
+            Some(OpenedFile::Directory)
+        } else {
+            Some(OpenedFile::File(self.cwd.join(&entry.name)))
+        }
+    }
+
+    /// Sube al directorio padre. Si estaba parado sobre un subdir, lo
+    /// re-selecciona al subir (UX típica: mantenés contexto).
+    pub fn parent(&mut self) -> bool {
+        let Some(parent) = self.cwd.parent().map(Path::to_path_buf) else {
+            return false;
+        };
+        let prev_name = self
+            .cwd
+            .file_name()
+            .map(|s| s.to_string_lossy().to_string());
+        self.cwd = parent;
+        self.entries = scan_dir(&self.cwd);
+        self.selected = prev_name
+            .and_then(|n| self.entries.iter().position(|e| e.name == n))
+            .unwrap_or(0);
+        self.visible_offset = 0;
+        self.sync_offset();
+        true
+    }
+
+    /// Aplica un delta de rueda y devuelve cuántos pasos enteros se
+    /// movieron (positivo = abajo, negativo = arriba). El acumulador
+    /// se ajusta para guardar la fracción residual — útil para
+    /// touchpads que mandan deltas sub-fila.
+    pub fn apply_wheel(&mut self, delta_y: f32) -> i32 {
+        let total = self.wheel_accum + delta_y;
+        let steps = (total / WHEEL_LINES_PER_ROW).trunc() as i32;
+        self.wheel_accum = total - (steps as f32 * WHEEL_LINES_PER_ROW);
+        if steps != 0 {
+            self.scroll(steps);
+        }
+        steps
+    }
+
+    /// Scroll por N pasos enteros (positivo = abajo). No mueve la
+    /// selección.
+    pub fn scroll(&mut self, steps: i32) {
+        if steps == 0 {
+            return;
+        }
+        let len = self.entries.len();
+        let max_offset = len.saturating_sub(self.visible_rows);
+        if steps > 0 {
+            self.visible_offset = min(self.visible_offset + steps as usize, max_offset);
+        } else {
+            let drop = (-steps) as usize;
+            self.visible_offset = self.visible_offset.saturating_sub(drop);
+        }
+    }
+
+    /// Asegura que la selección esté dentro del viewport visible.
+    fn sync_offset(&mut self) {
+        if self.selected < self.visible_offset {
+            self.visible_offset = self.selected;
+        }
+        let bottom = self.visible_offset + self.visible_rows;
+        if self.selected >= bottom {
+            self.visible_offset = self.selected + 1 - self.visible_rows;
+        }
+    }
+}
+
+/// Lee el directorio y devuelve entries ordenadas (dirs primero,
+/// luego por nombre case-insensitive). Si el `read_dir` falla,
+/// devuelve `Vec::new()` (mostrar mensaje queda al caller).
+pub fn scan_dir(path: &Path) -> Vec<Entry> {
+    let Ok(it) = fs::read_dir(path) else {
+        return Vec::new();
+    };
+    let mut entries: Vec<Entry> = it
+        .flatten()
+        .map(|e| {
+            let name = e.file_name().to_string_lossy().to_string();
+            let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
+            Entry { name, is_dir }
+        })
+        .collect();
+    entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
+        (true, false) => std::cmp::Ordering::Less,
+        (false, true) => std::cmp::Ordering::Greater,
+        _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
+    });
+    entries
+}
+
+/// Pinta la lista de entries del explorador como `llimphi-widget-list`.
+/// `on_select` recibe el índice de la fila clickeada y devuelve el
+/// Msg que el caller quiera dispatchear (típicamente
+/// `Msg::Select(idx)`).
+pub fn file_explorer_view<Msg, F>(
+    state: &FileExplorerState,
+    palette: ListPalette,
+    on_select: F,
+) -> View<Msg>
+where
+    Msg: Clone + 'static,
+    F: Fn(usize) -> Msg,
+{
+    let start = state.visible_offset;
+    let end = min(state.entries.len(), start + state.visible_rows);
+    let rows: Vec<ListRow<Msg>> = (start..end)
+        .map(|idx| {
+            let entry = &state.entries[idx];
+            let icon = if entry.is_dir { "▸ " } else { "  " };
+            let label = if entry.is_dir {
+                format!("{}{}/", icon, entry.name)
+            } else {
+                format!("{}{}", icon, entry.name)
+            };
+            ListRow {
+                label,
+                selected: idx == state.selected,
+                on_click: on_select(idx),
+            }
+        })
+        .collect();
+
+    let caption = format!(
+        "{} entradas · ↑↓ navega · Enter entra · ⌫ sube",
+        state.entries.len()
+    );
+    let truncated_hint = if state.entries.len() > end {
+        Some(format!(
+            "… y {} más (rueda o ↓ para ver más)",
+            state.entries.len() - end
+        ))
+    } else {
+        None
+    };
+
+    list_view(ListSpec {
+        rows,
+        total: state.entries.len(),
+        caption: Some(caption),
+        truncated_hint,
+        row_height: DEFAULT_ROW_HEIGHT,
+        palette,
+    })
+}
diff --git a/02_ruway/nahual/nahual-font-viewer-llimphi/Cargo.toml b/02_ruway/nahual/nahual-font-viewer-llimphi/Cargo.toml
new file mode 100644
index 0000000..f46c704
--- /dev/null
+++ b/02_ruway/nahual/nahual-font-viewer-llimphi/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "nahual-font-viewer-llimphi"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "nahual-font-viewer-llimphi — visor de fuentes TTF/OTF sobre Llimphi. Muestra los metadatos (familia, estilo, glifos, em) y renderiza una muestra DIBUJADA con la propia fuente del archivo, extrayendo los contornos de glifo con ttf-parser. Undécimo visor del shell nahual."
+
+[dependencies]
+llimphi-ui = { workspace = true }
+llimphi-theme = { workspace = true }
+ttf-parser = { workspace = true }
diff --git a/02_ruway/nahual/nahual-font-viewer-llimphi/src/lib.rs b/02_ruway/nahual/nahual-font-viewer-llimphi/src/lib.rs
new file mode 100644
index 0000000..78375b3
--- /dev/null
+++ b/02_ruway/nahual/nahual-font-viewer-llimphi/src/lib.rs
@@ -0,0 +1,399 @@
+//! `nahual-font-viewer-llimphi` — visor de fuentes TTF/OTF.
+//!
+//! Undécimo visor del shell meta-app. Un `.ttf`/`.otf` no lo cubría
+//! ningún visor rico: caía al text viewer como "(binario — sin preview)".
+//! Pero una fuente es para *verla*. Este visor parsea el archivo con
+//! `ttf-parser`, muestra sus metadatos (familia, estilo, nº de glifos,
+//! unidades por em) y —lo interesante— **renderiza una muestra dibujada
+//! con la propia fuente del archivo**: extrae los contornos de cada glifo
+//! a un `kurbo::BezPath` y los rellena en la escena vello vía `paint_with`.
+//!
+//! No pasa por parley (que sólo conoce las fuentes del sistema): los
+//! glifos se pintan directo desde los outlines del archivo, así ves
+//! exactamente la fuente que estás inspeccionando aunque no esté
+//! instalada.
+//!
+//! Patrón fino de los otros viewers: carga sync en [`load_font`], render
+//! en [`font_viewer_view`]. No conoce el AppBus: el caller pasa el path.
+//! MVP feo-primero: muestra fija (pangrama + dígitos), sin elegir tamaño
+//! ni texto todavía.
+
+#![forbid(unsafe_code)]
+
+use std::path::Path;
+
+use llimphi_ui::llimphi_layout::taffy::{
+    prelude::{length, percent, FlexDirection, Size, Style},
+    AlignItems, Rect,
+};
+use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath};
+use llimphi_ui::llimphi_raster::peniko::{Color, Fill};
+use llimphi_ui::llimphi_text::Alignment;
+use llimphi_ui::View;
+
+/// Tope de bytes a leer (32 MiB). Una fuente más grande es rara; el
+/// caller puede subirlo.
+pub const DEFAULT_FONT_BYTES_MAX: u64 = 32 * 1024 * 1024;
+
+/// Líneas de muestra que se renderizan con la fuente del archivo.
+const SAMPLE_LINES: &[&str] = &[
+    "Aa Bb Cc Dd Ee Ff Gg",
+    "The quick brown fox jumps",
+    "0123456789  !?.,;:&@#",
+];
+
+/// Una línea de muestra ya convertida a contornos, en unidades de fuente
+/// (eje Y hacia arriba, origen en la baseline). El render la escala.
+#[derive(Debug, Clone, PartialEq)]
+pub struct SampleLine {
+    pub path: BezPath,
+    /// Ancho total avanzado, en unidades de fuente.
+    pub width: f64,
+}
+
+/// Metadatos + muestras renderizables de una fuente abierta.
+#[derive(Debug, Clone, PartialEq)]
+pub struct FontInfo {
+    pub family: String,
+    pub subfamily: String,
+    pub num_glyphs: u16,
+    pub units_per_em: u16,
+    pub ascender: i16,
+    pub descender: i16,
+    pub lines: Vec<SampleLine>,
+}
+
+/// Estado del visor. Replica la forma de los otros.
+#[derive(Debug, Clone, Default, PartialEq)]
+pub enum FontPreview {
+    /// Sin archivo seleccionado.
+    #[default]
+    Empty,
+    /// Fuente parseada.
+    Font(Box<FontInfo>),
+    /// Excede el tope de tamaño.
+    TooBig(u64),
+    /// No se pudo abrir/parsear.
+    Error(String),
+}
+
+/// Implementa el sink de `ttf-parser` para volcar el contorno de un glifo
+/// a un `kurbo::BezPath`.
+struct OutlineToPath {
+    path: BezPath,
+}
+
+impl ttf_parser::OutlineBuilder for OutlineToPath {
+    fn move_to(&mut self, x: f32, y: f32) {
+        self.path.move_to((x as f64, y as f64));
+    }
+    fn line_to(&mut self, x: f32, y: f32) {
+        self.path.line_to((x as f64, y as f64));
+    }
+    fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
+        self.path.quad_to((x1 as f64, y1 as f64), (x as f64, y as f64));
+    }
+    fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
+        self.path
+            .curve_to((x1 as f64, y1 as f64), (x2 as f64, y2 as f64), (x as f64, y as f64));
+    }
+    fn close(&mut self) {
+        self.path.close_path();
+    }
+}
+
+/// Lee y parsea la fuente, construyendo las muestras de contorno.
+pub fn load_font(path: &Path, max_bytes: u64) -> FontPreview {
+    match std::fs::metadata(path) {
+        Ok(meta) if meta.len() > max_bytes => return FontPreview::TooBig(meta.len()),
+        Err(e) => return FontPreview::Error(e.to_string()),
+        _ => {}
+    }
+    let bytes = match std::fs::read(path) {
+        Ok(b) => b,
+        Err(e) => return FontPreview::Error(e.to_string()),
+    };
+    let face = match ttf_parser::Face::parse(&bytes, 0) {
+        Ok(f) => f,
+        Err(e) => return FontPreview::Error(format!("no parsea como fuente: {e}")),
+    };
+    FontPreview::Font(Box::new(build_info(&face)))
+}
+
+/// Extrae metadatos y arma las líneas de muestra a partir de una `Face`.
+fn build_info(face: &ttf_parser::Face<'_>) -> FontInfo {
+    let family = pick_name(face, 1).unwrap_or_else(|| "(sin nombre)".to_string());
+    let subfamily = pick_name(face, 2).unwrap_or_else(|| "Regular".to_string());
+    let em = face.units_per_em();
+    let lines = SAMPLE_LINES
+        .iter()
+        .map(|s| build_line(face, s))
+        .collect();
+    FontInfo {
+        family,
+        subfamily,
+        num_glyphs: face.number_of_glyphs(),
+        units_per_em: em,
+        ascender: face.ascender(),
+        descender: face.descender(),
+        lines,
+    }
+}
+
+/// Toma el primer `name` legible con el `name_id` pedido (1=familia,
+/// 2=subfamilia). `ttf-parser` sólo devuelve string para encodings
+/// Unicode/Mac, así que algunos nombres salen `None`.
+fn pick_name(face: &ttf_parser::Face<'_>, want_id: u16) -> Option<String> {
+    face.names()
+        .into_iter()
+        .filter(|n| n.name_id == want_id)
+        .find_map(|n| n.to_string())
+        .filter(|s| !s.is_empty())
+}
+
+/// Convierte una cadena en un único `BezPath` (todos los glifos
+/// trasladados a su posición de pen) en unidades de fuente.
+fn build_line(face: &ttf_parser::Face<'_>, text: &str) -> SampleLine {
+    let mut combined = BezPath::new();
+    let mut pen: f64 = 0.0;
+    let space = face.units_per_em() as f64 / 3.0;
+    for ch in text.chars() {
+        let gid = match face.glyph_index(ch) {
+            Some(g) => g,
+            None => {
+                pen += space;
+                continue;
+            }
+        };
+        let mut sink = OutlineToPath { path: BezPath::new() };
+        if face.outline_glyph(gid, &mut sink).is_some() {
+            sink.path.apply_affine(Affine::translate((pen, 0.0)));
+            combined.extend(sink.path.elements().iter().copied());
+        }
+        let adv = face.glyph_hor_advance(gid).unwrap_or(0) as f64;
+        // Un avance 0 (p.ej. espacio sin glifo) usa el ancho de fallback.
+        pen += if adv > 0.0 { adv } else { space };
+    }
+    SampleLine { path: combined, width: pen }
+}
+
+/// Paleta del viewer.
+#[derive(Debug, Clone, Copy)]
+pub struct FontViewerPalette {
+    pub bg: Color,
+    pub fg_text: Color,
+    pub fg_muted: Color,
+    pub fg_error: Color,
+    pub glyph: Color,
+}
+
+impl Default for FontViewerPalette {
+    fn default() -> Self {
+        Self::from_theme(&llimphi_theme::Theme::dark())
+    }
+}
+
+impl FontViewerPalette {
+    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
+        Self {
+            bg: t.bg_app,
+            fg_text: t.fg_text,
+            fg_muted: t.fg_muted,
+            fg_error: t.fg_destructive,
+            glyph: t.fg_text,
+        }
+    }
+}
+
+/// Pinta header + metadatos + lienzo con la muestra dibujada.
+pub fn font_viewer_view<Msg>(
+    state: &FontPreview,
+    path: Option<&Path>,
+    palette: &FontViewerPalette,
+) -> View<Msg>
+where
+    Msg: Clone + 'static,
+{
+    let header_text = match path {
+        Some(p) => format!(
+            "font · {}",
+            p.file_name()
+                .map(|s| s.to_string_lossy().to_string())
+                .unwrap_or_else(|| p.display().to_string())
+        ),
+        None => "(seleccioná una fuente TTF/OTF)".to_string(),
+    };
+
+    let header = View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(20.0_f32),
+        },
+        padding: pad(12.0, 0.0),
+        align_items: Some(AlignItems::Center),
+        ..Default::default()
+    })
+    .text_aligned(header_text, 10.0, palette.fg_muted, Alignment::Start);
+
+    let children = match state {
+        FontPreview::Empty => vec![header, info_line("—", palette.fg_muted)],
+        FontPreview::TooBig(n) => vec![
+            header,
+            info_line(&format!("(fuente muy grande: {n} bytes)"), palette.fg_muted),
+        ],
+        FontPreview::Error(e) => {
+            vec![header, info_line(&format!("(no se pudo abrir: {e})"), palette.fg_error)]
+        }
+        FontPreview::Font(info) => {
+            let meta = format!(
+                "{} · {}\n{} glifos · {} u/em · asc {} / desc {}",
+                info.family,
+                info.subfamily,
+                info.num_glyphs,
+                info.units_per_em,
+                info.ascender,
+                info.descender,
+            );
+            vec![
+                header,
+                info_line(&meta, palette.fg_text),
+                sample_canvas::<Msg>(info, palette),
+            ]
+        }
+    };
+
+    View::new(Style {
+        flex_direction: FlexDirection::Column,
+        flex_grow: 1.0,
+        size: Size {
+            width: percent(1.0_f32),
+            height: percent(1.0_f32),
+        },
+        padding: Rect {
+            left: length(0.0_f32),
+            right: length(0.0_f32),
+            top: length(6.0_f32),
+            bottom: length(0.0_f32),
+        },
+        ..Default::default()
+    })
+    .fill(palette.bg)
+    .clip(true)
+    .children(children)
+}
+
+/// Lienzo que dibuja las líneas de muestra rellenando los contornos de
+/// glifo. Los paths vienen en unidades de fuente; acá los escalamos para
+/// que entren a lo ancho y los apilamos verticalmente.
+fn sample_canvas<Msg>(info: &FontInfo, palette: &FontViewerPalette) -> View<Msg>
+where
+    Msg: Clone + 'static,
+{
+    let lines = info.lines.clone();
+    let em = info.units_per_em.max(1) as f64;
+    let ascender = info.ascender as f64;
+    let descender = info.descender as f64;
+    let glyph_color = palette.glyph;
+
+    View::new(Style {
+        flex_grow: 1.0,
+        size: Size {
+            width: percent(1.0_f32),
+            height: percent(1.0_f32),
+        },
+        padding: pad(16.0, 10.0),
+        ..Default::default()
+    })
+    .paint_with(move |scene, _ts, rect| {
+        if rect.w <= 8.0 || rect.h <= 8.0 || lines.is_empty() {
+            return;
+        }
+        let pad_x = 4.0_f64;
+        let avail_w = (rect.w as f64 - 2.0 * pad_x).max(1.0);
+        // Altura de cada renglón = alto del lienzo repartido (con tope para
+        // que un panel alto no infle los glifos hasta deformarlos).
+        let slot_h = ((rect.h as f64) / lines.len() as f64).min(96.0);
+        // El glifo ocupa la caja ascender..descender → escala por alto.
+        let line_units = (ascender - descender).max(em);
+        for (i, line) in lines.iter().enumerate() {
+            if line.path.elements().is_empty() {
+                continue;
+            }
+            // Escala que respeta tanto el alto del renglón como el ancho.
+            let scale_h = (slot_h * 0.72) / line_units;
+            let scale_w = if line.width > 0.0 {
+                avail_w / line.width
+            } else {
+                scale_h
+            };
+            let scale = scale_h.min(scale_w);
+            let baseline = rect.y as f64 + i as f64 * slot_h + slot_h * 0.5 + (ascender * scale) * 0.5;
+            let x0 = rect.x as f64 + pad_x;
+            // Font: Y arriba; pantalla: Y abajo → escala Y negativa.
+            let affine = Affine::new([scale, 0.0, 0.0, -scale, x0, baseline]);
+            scene.fill(Fill::NonZero, affine, glyph_color, None, &line.path);
+        }
+    })
+}
+
+/// Bloque de texto de una línea (metadatos / estados).
+fn info_line<Msg>(text: &str, color: Color) -> View<Msg>
+where
+    Msg: Clone + 'static,
+{
+    View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(40.0_f32),
+        },
+        padding: pad(12.0, 4.0),
+        ..Default::default()
+    })
+    .text_aligned(text.to_string(), 12.0, color, Alignment::Start)
+}
+
+/// Padding horizontal `h` + vertical `v`.
+fn pad(h: f32, v: f32) -> Rect<llimphi_ui::llimphi_layout::taffy::LengthPercentage> {
+    Rect {
+        left: length(h),
+        right: length(h),
+        top: length(v),
+        bottom: length(v),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn inexistente_es_error() {
+        assert!(matches!(
+            load_font(Path::new("/no/existe.ttf"), DEFAULT_FONT_BYTES_MAX),
+            FontPreview::Error(_)
+        ));
+    }
+
+    #[test]
+    fn basura_no_es_fuente() {
+        let tmp = std::env::temp_dir().join("nahual-font-viewer-test-bad.ttf");
+        std::fs::write(&tmp, b"no soy una fuente, soy texto cualquiera").unwrap();
+        assert!(matches!(
+            load_font(&tmp, DEFAULT_FONT_BYTES_MAX),
+            FontPreview::Error(_)
+        ));
+        let _ = std::fs::remove_file(&tmp);
+    }
+
+    #[test]
+    fn outline_builder_construye_path() {
+        // Verifica que el sink traduce los comandos a elementos de BezPath.
+        let mut sink = OutlineToPath { path: BezPath::new() };
+        use ttf_parser::OutlineBuilder;
+        sink.move_to(0.0, 0.0);
+        sink.line_to(10.0, 0.0);
+        sink.quad_to(10.0, 10.0, 0.0, 10.0);
+        sink.close();
+        assert_eq!(sink.path.elements().len(), 4);
+    }
+}
diff --git a/02_ruway/nahual/nahual-gallery-llimphi/Cargo.toml b/02_ruway/nahual/nahual-gallery-llimphi/Cargo.toml
new file mode 100644
index 0000000..0009a66
--- /dev/null
+++ b/02_ruway/nahual/nahual-gallery-llimphi/Cargo.toml
@@ -0,0 +1,23 @@
+[package]
+name = "nahual-gallery-llimphi"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "nahual-gallery-llimphi — galería de miniaturas tipo gThumb/FastStone sobre Llimphi. Cose llimphi-widget-grid (virtualización 2D) + nahual-thumb-core (generación + cache + planificador): lista miles de imágenes de una carpeta, decodifica en background con Handle::spawn priorizado al viewport, y pinta sólo la ventana visible."
+
+[[bin]]
+name = "nahual-gallery"
+path = "src/main.rs"
+
+[dependencies]
+llimphi-ui = { workspace = true }
+llimphi-theme = { workspace = true }
+llimphi-widget-grid = { workspace = true }
+llimphi-widget-breadcrumb = { workspace = true }
+nahual-thumb-core = { workspace = true }
+nahual-image-viewer-llimphi = { workspace = true }
+llimphi-widget-menubar = { workspace = true }
+llimphi-widget-context-menu = { workspace = true }
+app-bus = { workspace = true }
diff --git a/02_ruway/nahual/nahual-gallery-llimphi/src/main.rs b/02_ruway/nahual/nahual-gallery-llimphi/src/main.rs
new file mode 100644
index 0000000..ff8d5e9
--- /dev/null
+++ b/02_ruway/nahual/nahual-gallery-llimphi/src/main.rs
@@ -0,0 +1,1025 @@
+//! `nahual-gallery-llimphi` — galería de miniaturas tipo gThumb / FastStone.
+//!
+//! Cose las dos piezas del pipeline de galería:
+//! - [`llimphi_widget_grid`] → virtualización 2D (sólo monta la ventana
+//!   visible, aunque la carpeta tenga miles de imágenes).
+//! - [`nahual_thumb_core`] → genera las miniaturas (decode + downscale),
+//!   las cachea en RAM y planifica la cola priorizada al viewport.
+//!
+//! La concurrencia la pone `Handle::spawn`: por cada path que el
+//! planificador entrega, lanzamos un thread que decodifica y reentra con
+//! `Msg::ThumbListo`. Mientras tanto la celda muestra un placeholder.
+//!
+//! Navegación: la grilla mezcla subcarpetas (ícono 📁, primero) e
+//! imágenes. Un clic en carpeta entra; en imagen selecciona (⏎/espacio
+//! abre el preview). `⌫` sube al padre y el breadcrumb salta a cualquier
+//! ancestro. `o` cicla el orden de las imágenes (nombre/tamaño/fecha).
+//!
+//! Uso: `cargo run -p nahual-gallery-llimphi --release -- <carpeta>`
+//! (sin argumento usa el directorio actual).
+//!
+//! Limitación MVP: el tamaño del viewport se asume fijo (= `initial_size`)
+//! porque el trait `App` de Llimphi todavía no expone un hook de resize —
+//! mismo atajo que `nahual-file-explorer`. Al achicar/agrandar la ventana
+//! las columnas no se recalculan hasta que eso exista.
+
+use std::collections::{HashMap, HashSet};
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+use std::time::Duration;
+
+use llimphi_theme::Theme;
+use llimphi_ui::llimphi_layout::taffy::{
+    prelude::{length, percent, FlexDirection, Size, Style},
+    AlignItems, JustifyContent, Rect,
+};
+use llimphi_ui::llimphi_raster::peniko::{Blob, Image, ImageFormat};
+use llimphi_ui::llimphi_text::Alignment;
+use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, Modifiers, NamedKey, View, WheelDelta};
+use llimphi_widget_breadcrumb::{breadcrumb_view, BreadcrumbPalette};
+use llimphi_widget_grid::{grid_view, ventana_visible, GridCell, GridMetrics, GridPalette};
+use nahual_image_viewer_llimphi::{
+    image_viewer_view, load_image, ImagePreviewState, ImageViewerPalette,
+};
+use nahual_thumb_core::{
+    generar_thumb_de_archivo, obtener_o_generar, CacheDisco, Planificador, ThumbRgba,
+};
+use llimphi_widget_context_menu::{
+    context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
+};
+use llimphi_widget_menubar::{
+    menubar_overlay, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H,
+};
+use app_bus::{AppMenu, Menu, MenuItem};
+
+/// Lado máximo de la miniatura generada (px). Un poco mayor que el tile
+/// para que se vea nítida; el grid la reduce al pintar.
+const THUMB_LADO: u32 = 224;
+/// Cuántas decodificaciones concurrentes permitir (~núcleos).
+const MAX_EN_VUELO: usize = 6;
+/// Alto reservado para el header en px (descontado del viewport útil).
+const HEADER_H: f32 = 44.0;
+/// Alto de la barra de breadcrumb (ruta navegable).
+const BREADCRUMB_H: f32 = 30.0;
+
+/// Una entrada de la carpeta actual: subcarpeta navegable o archivo de
+/// imagen con miniatura (y su tamaño en disco, para el badge). Las carpetas
+/// se listan primero.
+#[derive(Clone)]
+enum Entrada {
+    Carpeta(PathBuf),
+    Imagen { path: PathBuf, size: u64 },
+}
+
+impl Entrada {
+    fn path(&self) -> &Path {
+        match self {
+            Entrada::Carpeta(p) => p,
+            Entrada::Imagen { path, .. } => path,
+        }
+    }
+    fn es_carpeta(&self) -> bool {
+        matches!(self, Entrada::Carpeta(_))
+    }
+}
+
+/// Formatea un tamaño en bytes de forma humana (B/KB/MB/GB).
+fn humano(n: u64) -> String {
+    const U: [&str; 4] = ["B", "KB", "MB", "GB"];
+    let mut v = n as f64;
+    let mut i = 0;
+    while v >= 1024.0 && i < 3 {
+        v /= 1024.0;
+        i += 1;
+    }
+    if i == 0 {
+        format!("{n} B")
+    } else {
+        format!("{v:.1} {}", U[i])
+    }
+}
+
+/// Extensiones que tratamos como imagen (alineadas con las features del
+/// crate `image` en el workspace: png/jpeg/webp).
+const EXTS: &[&str] = &["png", "jpg", "jpeg", "webp"];
+/// Tope para abrir una imagen a tamaño completo en el preview (64 MB de
+/// archivo — un PNG/JPEG así decodifica a cientos de MB, pero es el límite
+/// del visor existente).
+const MAX_PREVIEW_BYTES: u64 = 64 * 1024 * 1024;
+/// Tope de miniaturas en RAM. Al pasarlo, se descartan las más lejanas a la
+/// ventana visible (con margen) — acota la memoria en carpetas enormes.
+const CAP_THUMBS: usize = 800;
+/// Rango de tile permitido al hacer zoom con +/−.
+const TILE_MIN: f32 = 80.0;
+const TILE_MAX: f32 = 280.0;
+/// Alto del label bajo la miniatura (nombre + tamaño).
+const LABEL_H: f32 = 20.0;
+/// Intervalo del slideshow.
+const SLIDE_SECS: u64 = 3;
+
+/// Criterio de orden de la grilla.
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum Orden {
+    Nombre,
+    Tamano,
+    Fecha,
+}
+
+impl Orden {
+    fn siguiente(self) -> Self {
+        match self {
+            Orden::Nombre => Orden::Tamano,
+            Orden::Tamano => Orden::Fecha,
+            Orden::Fecha => Orden::Nombre,
+        }
+    }
+    fn etiqueta(self) -> &'static str {
+        match self {
+            Orden::Nombre => "nombre",
+            Orden::Tamano => "tamaño",
+            Orden::Fecha => "fecha",
+        }
+    }
+}
+
+#[derive(Clone)]
+enum Msg {
+    /// Desplaza la grilla por N filas (positivo = hacia abajo).
+    Scroll(i32),
+    /// Marca una celda como seleccionada.
+    Seleccionar(usize),
+    /// Miniatura lista (llega desde el thread de decodificación).
+    ThumbListo(PathBuf, ThumbRgba),
+    /// Falló la generación de una miniatura.
+    ThumbFallo(PathBuf, String),
+    /// Tecla (navegación con flechas / página).
+    Tecla(KeyEvent),
+    /// Abre la imagen `i` a tamaño completo (overlay de preview).
+    AbrirPreview(usize),
+    /// Imagen del preview cargada (llega desde el thread de carga).
+    PreviewListo(PathBuf, ImagePreviewState),
+    /// Mueve el preview a la imagen vecina (±1) y la carga.
+    PreviewVecino(i32),
+    /// Cierra el overlay de preview.
+    CerrarPreview,
+    /// Cambia el criterio de orden de la grilla.
+    CiclarOrden,
+    /// Activa la entrada `i`: entra a la carpeta o abre el preview.
+    Activar(usize),
+    /// Sube a la carpeta padre.
+    Subir,
+    /// Navega al segmento `i` del breadcrumb (raíz → hoja).
+    NavegarSegmento(usize),
+    /// Ajusta el tamaño del tile (zoom de grilla) por el delta dado en px.
+    Zoom(f32),
+    /// Alterna el slideshow (sólo con el preview abierto).
+    ToggleSlideshow,
+    /// Tick del slideshow: avanza a la imagen siguiente.
+    SlideAvanzar,
+    /// Abre/cierra un menú raíz de la barra (índice del menú).
+    MenuOpen(Option<usize>),
+    /// Comando del menú principal (command id → acción real).
+    MenuCommand(String),
+    /// Cierra todo overlay de menú (raíz y contextual).
+    CloseMenus,
+    /// Abre el menú contextual en coords de ventana, sobre la selección.
+    ContextMenuOpen(f32, f32),
+    /// Cicla la paleta de tema.
+    CiclarTema,
+}
+
+struct Model {
+    dir: PathBuf,
+    entries: Vec<Entrada>,
+    scroll_fila: usize,
+    seleccionado: Option<usize>,
+    /// Miniaturas ya listas para pintar (peniko). Es el cache RAM del lado
+    /// de la app; el cache atado a mtime de `nahual-thumb-core` se enchufa
+    /// en el paso 3 (disco).
+    thumbs: HashMap<PathBuf, Image>,
+    /// Paths cuya generación falló — pintan un ⚠ en vez de reintentar.
+    fallidos: HashSet<PathBuf>,
+    plan: Planificador,
+    /// Cache en disco de miniaturas (reabrir sin re-decodificar). `None`
+    /// si no se pudo crear la carpeta — se cae a generar en RAM siempre.
+    cache_disco: Option<CacheDisco>,
+    metrics: GridMetrics,
+    /// Viewport útil asumido (de `initial_size`, menos el header).
+    vw: f32,
+    vh: f32,
+    /// Imagen abierta a tamaño completo (overlay). `None` = grilla normal.
+    preview: Option<(PathBuf, ImagePreviewState)>,
+    /// Slideshow activo (avanza solo en el preview).
+    slideshow: bool,
+    orden: Orden,
+    estado: String,
+    theme: Theme,
+    /// Menú raíz abierto en la barra (índice), si lo hay.
+    menu_open: Option<usize>,
+    /// Menú contextual abierto en (x, y) de ventana, si lo hay.
+    context_menu: Option<(f32, f32)>,
+}
+
+struct Gallery;
+
+impl App for Gallery {
+    type Model = Model;
+    type Msg = Msg;
+
+    fn title() -> &'static str {
+        "nahual · galería"
+    }
+
+    fn initial_size() -> (u32, u32) {
+        (1180, 760)
+    }
+
+    fn init(handle: &Handle<Msg>) -> Model {
+        let dir = std::env::args()
+            .nth(1)
+            .map(PathBuf::from)
+            .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
+        let orden = Orden::Nombre;
+        let entries = listar(&dir, orden);
+        let (w, h) = Self::initial_size();
+        let metrics = GridMetrics {
+            tile_w: 140.0,
+            tile_h: 140.0 + LABEL_H,
+            gap: 10.0,
+            pad: 12.0,
+        };
+        let estado = format!("{} ítems", entries.len());
+        let mut m = Model {
+            dir,
+            entries,
+            scroll_fila: 0,
+            seleccionado: None,
+            thumbs: HashMap::new(),
+            fallidos: HashSet::new(),
+            plan: Planificador::nuevo(MAX_EN_VUELO),
+            cache_disco: CacheDisco::por_defecto().ok(),
+            metrics,
+            vw: w as f32,
+            vh: h as f32 - MENU_H - HEADER_H - BREADCRUMB_H,
+            preview: None,
+            slideshow: false,
+            orden,
+            estado,
+            theme: Theme::dark(),
+            menu_open: None,
+            context_menu: None,
+        };
+        bombear(&mut m, handle);
+        m
+    }
+
+    fn on_wheel(_: &Model, delta: WheelDelta, _: (f32, f32), _: Modifiers) -> Option<Msg> {
+        // delta.y positivo = rueda hacia arriba ⇒ scroll hacia arriba
+        // (filas menores). Convertimos a pasos enteros de fila.
+        let pasos = -delta.y.round() as i32;
+        if pasos == 0 {
+            None
+        } else {
+            Some(Msg::Scroll(pasos))
+        }
+    }
+
+    fn on_key(model: &Model, e: &KeyEvent) -> Option<Msg> {
+        if e.state != KeyState::Pressed {
+            return None;
+        }
+        // Con el preview abierto, las teclas controlan el preview, no la
+        // grilla: Esc cierra, ←/→ saltan a la vecina.
+        if model.preview.is_some() {
+            return match &e.key {
+                Key::Named(NamedKey::Escape) => Some(Msg::CerrarPreview),
+                Key::Named(NamedKey::ArrowRight) => Some(Msg::PreviewVecino(1)),
+                Key::Named(NamedKey::ArrowLeft) => Some(Msg::PreviewVecino(-1)),
+                Key::Character(c) if c == "s" => Some(Msg::ToggleSlideshow),
+                _ => None,
+            };
+        }
+        // Enter / Espacio activan la entrada seleccionada (entrar a la
+        // carpeta o abrir el preview); Backspace sube a la carpeta padre.
+        match &e.key {
+            Key::Named(NamedKey::Enter) | Key::Named(NamedKey::Space) => {
+                model.seleccionado.map(Msg::Activar)
+            }
+            Key::Named(NamedKey::Backspace) => Some(Msg::Subir),
+            Key::Character(c) if c == "o" => Some(Msg::CiclarOrden),
+            // Zoom de grilla: + / = agrandan, - achica.
+            Key::Character(c) if c == "+" || c == "=" => Some(Msg::Zoom(20.0)),
+            Key::Character(c) if c == "-" => Some(Msg::Zoom(-20.0)),
+            _ => Some(Msg::Tecla(e.clone())),
+        }
+    }
+
+    fn update(model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
+        let mut m = model;
+        match msg {
+            Msg::Scroll(pasos) => {
+                let nueva = (m.scroll_fila as i32 + pasos).max(0) as usize;
+                m.scroll_fila = nueva;
+                bombear(&mut m, handle);
+            }
+            Msg::Seleccionar(i) => {
+                if i < m.entries.len() {
+                    m.seleccionado = Some(i);
+                    m.estado = nombre(m.entries[i].path());
+                }
+            }
+            Msg::ThumbListo(path, t) => {
+                m.plan.completar(&path);
+                let img = Image::new(Blob::from(t.rgba), ImageFormat::Rgba8, t.w, t.h);
+                m.thumbs.insert(path, img);
+                // Encadenar: liberado un cupo, pedir el próximo lote.
+                bombear(&mut m, handle);
+            }
+            Msg::ThumbFallo(path, e) => {
+                m.plan.completar(&path);
+                m.estado = format!("falló {}: {e}", nombre(&path));
+                m.fallidos.insert(path);
+                bombear(&mut m, handle);
+            }
+            Msg::Tecla(ev) => {
+                manejar_tecla(&mut m, &ev, handle);
+            }
+            Msg::AbrirPreview(i) => {
+                abrir_preview(&mut m, i, handle);
+            }
+            Msg::PreviewListo(path, st) => {
+                // Sólo aplicar si seguimos viendo la misma imagen (evita que
+                // una carga lenta pise a otra más nueva).
+                if let Some((actual, _)) = &m.preview {
+                    if actual == &path {
+                        m.estado = nombre(&path);
+                        m.preview = Some((path, st));
+                    }
+                }
+            }
+            Msg::PreviewVecino(d) => {
+                avanzar_imagen(&mut m, d, false, handle);
+            }
+            Msg::CerrarPreview => {
+                m.preview = None;
+                m.slideshow = false;
+            }
+            Msg::Zoom(d) => {
+                let w = (m.metrics.tile_w + d).clamp(TILE_MIN, TILE_MAX);
+                m.metrics.tile_w = w;
+                m.metrics.tile_h = w + LABEL_H;
+                m.estado = format!("tile {:.0}px", w);
+                bombear(&mut m, handle);
+            }
+            Msg::ToggleSlideshow => {
+                if m.preview.is_some() {
+                    m.slideshow = !m.slideshow;
+                    m.estado = if m.slideshow {
+                        "slideshow ▶".into()
+                    } else {
+                        "slideshow ⏸".into()
+                    };
+                    if m.slideshow {
+                        programar_slide(handle);
+                    }
+                }
+            }
+            Msg::SlideAvanzar => {
+                // Sólo avanza si el slideshow sigue activo y hay preview.
+                if m.slideshow && m.preview.is_some() {
+                    avanzar_imagen(&mut m, 1, true, handle);
+                    programar_slide(handle);
+                }
+            }
+            Msg::CiclarOrden => {
+                // Conservar la selección por path al re-listar (que reordena).
+                let sel_path = m.seleccionado.and_then(|i| m.entries.get(i).map(|e| e.path().to_path_buf()));
+                m.orden = m.orden.siguiente();
+                m.entries = listar(&m.dir, m.orden);
+                m.seleccionado =
+                    sel_path.and_then(|p| m.entries.iter().position(|e| e.path() == p));
+                m.estado = format!("orden: {}", m.orden.etiqueta());
+                bombear(&mut m, handle);
+            }
+            Msg::Activar(i) => {
+                if let Some(e) = m.entries.get(i) {
+                    if e.es_carpeta() {
+                        let dir = e.path().to_path_buf();
+                        navegar_a(&mut m, dir, handle);
+                    } else {
+                        abrir_preview(&mut m, i, handle);
+                    }
+                }
+            }
+            Msg::Subir => {
+                if let Some(padre) = m.dir.parent().map(|p| p.to_path_buf()) {
+                    navegar_a(&mut m, padre, handle);
+                }
+            }
+            Msg::NavegarSegmento(i) => {
+                if let Some(dir) = ancestros(&m.dir).get(i).cloned() {
+                    navegar_a(&mut m, dir, handle);
+                }
+            }
+            Msg::MenuOpen(which) => {
+                m.menu_open = which;
+                m.context_menu = None;
+            }
+            Msg::MenuCommand(cmd) => {
+                m.menu_open = None;
+                return handle_menu_command(m, &cmd, handle);
+            }
+            Msg::CloseMenus => {
+                m.menu_open = None;
+                m.context_menu = None;
+            }
+            Msg::ContextMenuOpen(x, y) => {
+                m.menu_open = None;
+                m.context_menu = Some((x, y));
+            }
+            Msg::CiclarTema => {
+                m.theme = Theme::next_after(m.theme.name);
+            }
+        }
+        m
+    }
+
+    fn view_overlay(model: &Model) -> Option<View<Msg>> {
+        // El preview a pantalla completa manda mientras esté abierto.
+        if let Some((path, st)) = model.preview.as_ref() {
+            let pal = ImageViewerPalette::from_theme(&model.theme);
+            let viewer = image_viewer_view::<Msg>(st, Some(path), &pal);
+            // Scrim a pantalla completa: cubre la grilla y, al clickear (fuera o
+            // sobre la imagen), cierra el preview. Esc y ←/→ los maneja on_key.
+            return Some(
+                View::new(Style {
+                    size: Size {
+                        width: percent(1.0_f32),
+                        height: percent(1.0_f32),
+                    },
+                    ..Default::default()
+                })
+                .fill(model.theme.bg_app)
+                .on_click(Msg::CerrarPreview)
+                .children(vec![viewer]),
+            );
+        }
+
+        // Menú contextual sobre la entrada seleccionada: prioridad sobre el
+        // dropdown del menú principal.
+        if let Some((x, y)) = model.context_menu {
+            let sel = model.seleccionado.and_then(|i| model.entries.get(i));
+            let header = sel
+                .map(|e| nombre(e.path()))
+                .unwrap_or_else(|| "galería".to_string());
+            let es_carpeta = sel.map(|e| e.es_carpeta()).unwrap_or(false);
+            // Acciones reales según la entrada: carpeta ⇒ entrar; imagen ⇒
+            // abrir preview. Más reset de zoom / ciclar orden, siempre útiles.
+            let items = if es_carpeta {
+                vec![
+                    ContextMenuItem::action("Entrar a la carpeta"),
+                    ContextMenuItem::action("Reiniciar zoom"),
+                    ContextMenuItem::action("Ciclar orden"),
+                ]
+            } else if sel.is_some() {
+                vec![
+                    ContextMenuItem::action("Abrir imagen"),
+                    ContextMenuItem::action("Reiniciar zoom"),
+                    ContextMenuItem::action("Ciclar orden"),
+                ]
+            } else {
+                vec![
+                    ContextMenuItem::action("Reiniciar zoom"),
+                    ContextMenuItem::action("Ciclar orden"),
+                ]
+            };
+            let idx = model.seleccionado;
+            let tile_w = model.metrics.tile_w;
+            let tiene_sel = sel.is_some();
+            let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> =
+                Arc::new(move |i: usize| {
+                    if tiene_sel {
+                        match i {
+                            0 => idx.map(Msg::Activar).unwrap_or(Msg::CloseMenus),
+                            1 => Msg::Zoom(140.0 - tile_w),
+                            _ => Msg::CiclarOrden,
+                        }
+                    } else {
+                        match i {
+                            0 => Msg::Zoom(140.0 - tile_w),
+                            _ => Msg::CiclarOrden,
+                        }
+                    }
+                });
+            return Some(context_menu_view(ContextMenuSpec {
+                anchor: (x, y),
+                viewport: viewport_of(model),
+                header: Some(header),
+                items,
+                active: usize::MAX,
+                on_pick,
+                on_dismiss: Msg::CloseMenus,
+                palette: ContextMenuPalette::from_theme(&model.theme),
+            }));
+        }
+
+        // Si no, el dropdown del menú principal.
+        let menu = app_menu();
+        menubar_overlay(&menubar_spec(&menu, model, &model.theme))
+    }
+
+    fn view(model: &Model) -> View<Msg> {
+        let theme = model.theme;
+        let v = ventana_visible(
+            model.entries.len(),
+            model.vw,
+            model.vh,
+            model.scroll_fila,
+            &model.metrics,
+        );
+
+        let menu = app_menu();
+        let menubar = menubar_view(&menubar_spec(&menu, model, &theme));
+        let header = encabezado(model, &v);
+        let ruta = barra_ruta(model);
+
+        let cuerpo: View<Msg> = if model.entries.is_empty() {
+            View::new(Style {
+                size: Size {
+                    width: percent(1.0_f32),
+                    height: percent(1.0_f32),
+                },
+                align_items: Some(AlignItems::Center),
+                justify_content: Some(JustifyContent::Center),
+                ..Default::default()
+            })
+            .fill(theme.bg_panel)
+            .text(
+                format!("sin imágenes ni subcarpetas en {}", model.dir.display()),
+                14.0,
+                theme.fg_muted,
+            )
+        } else {
+            let cells: Vec<GridCell<Msg>> = (v.first..v.first + v.count)
+                .map(|i| {
+                    let e = &model.entries[i];
+                    let label = match e {
+                        Entrada::Imagen { path, size } => {
+                            format!("{}  ·  {}", nombre(path), humano(*size))
+                        }
+                        Entrada::Carpeta(p) => nombre(p),
+                    };
+                    GridCell {
+                        content: celda_contenido(model, e),
+                        label: Some(label),
+                        selected: model.seleccionado == Some(i),
+                        // Un clic en carpeta entra; en imagen selecciona
+                        // (⏎/espacio la abre en preview).
+                        on_click: if e.es_carpeta() {
+                            Msg::Activar(i)
+                        } else {
+                            Msg::Seleccionar(i)
+                        },
+                    }
+                })
+                .collect();
+            let mostrados = v.first + v.count;
+            grid_view(llimphi_widget_grid::GridSpec {
+                cells,
+                cols: v.cols,
+                metrics: model.metrics,
+                caption: None,
+                truncated_hint: (mostrados < model.entries.len())
+                    .then(|| format!("… y {} más abajo", model.entries.len() - mostrados)),
+                palette: GridPalette::from_theme(&theme),
+            })
+        };
+
+        View::new(Style {
+            flex_direction: FlexDirection::Column,
+            size: Size {
+                width: percent(1.0_f32),
+                height: percent(1.0_f32),
+            },
+            ..Default::default()
+        })
+        .fill(theme.bg_app)
+        // Right-click en la raíz (origen 0,0 ⇒ local == ventana) abre el
+        // menú contextual sobre la entrada seleccionada.
+        .on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
+        .children(vec![menubar, header, ruta, cuerpo])
+    }
+}
+
+/// Cuerpo de una celda: un ícono 📁 para carpetas; para imágenes la
+/// miniatura si está lista, un ⚠ si falló, o un placeholder mientras se
+/// genera.
+fn celda_contenido(model: &Model, e: &Entrada) -> View<Msg> {
+    let theme = &model.theme;
+    let lado = model.metrics.tile_w - 8.0;
+    let base = || Style {
+        size: Size {
+            width: length(lado),
+            height: length(lado),
+        },
+        align_items: Some(AlignItems::Center),
+        justify_content: Some(JustifyContent::Center),
+        ..Default::default()
+    };
+    if e.es_carpeta() {
+        return View::new(base())
+            .fill(theme.bg_panel_alt)
+            .text("📁".to_string(), 44.0, theme.fg_text);
+    }
+    let path = e.path();
+    if let Some(img) = model.thumbs.get(path) {
+        View::new(base()).image(img.clone())
+    } else if model.fallidos.contains(path) {
+        View::new(base())
+            .fill(theme.bg_panel_alt)
+            .text("⚠".to_string(), 20.0, theme.fg_muted)
+    } else {
+        // Placeholder: rectángulo tenue mientras decodifica (MVP — sin
+        // animación; el widget-skeleton se puede enchufar acá luego).
+        View::new(base()).fill(theme.bg_panel_alt)
+    }
+}
+
+/// Breadcrumb de la ruta actual: cada segmento (raíz → hoja) es clicable y
+/// navega a ese ancestro.
+fn barra_ruta(model: &Model) -> View<Msg> {
+    let anc = ancestros(&model.dir);
+    let segs: Vec<String> = anc
+        .iter()
+        .map(|p| {
+            p.file_name()
+                .map(|n| n.to_string_lossy().into_owned())
+                .unwrap_or_else(|| p.to_string_lossy().into_owned())
+        })
+        .collect();
+    let refs: Vec<&str> = segs.iter().map(|s| s.as_str()).collect();
+    let pal = BreadcrumbPalette::from_theme(&model.theme);
+    let bc = breadcrumb_view::<Msg, _>(&refs, Msg::NavegarSegmento, &pal);
+    View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(BREADCRUMB_H),
+        },
+        padding: Rect {
+            left: length(8.0_f32),
+            right: length(8.0_f32),
+            top: length(0.0_f32),
+            bottom: length(0.0_f32),
+        },
+        align_items: Some(AlignItems::Center),
+        ..Default::default()
+    })
+    .fill(model.theme.bg_panel)
+    .children(vec![bc])
+}
+
+/// Ancestros de un directorio en orden raíz → hoja (incluye el propio dir).
+fn ancestros(dir: &Path) -> Vec<PathBuf> {
+    let mut v: Vec<PathBuf> = dir.ancestors().map(|p| p.to_path_buf()).collect();
+    v.reverse();
+    v
+}
+
+/// Cambia la carpeta actual: re-lista, resetea scroll/selección/preview y
+/// arranca la generación de miniaturas de la nueva ventana.
+fn navegar_a(m: &mut Model, dir: PathBuf, handle: &Handle<Msg>) {
+    m.dir = dir;
+    m.entries = listar(&m.dir, m.orden);
+    m.scroll_fila = 0;
+    m.seleccionado = None;
+    m.preview = None;
+    m.estado = format!("{} ítems", m.entries.len());
+    bombear(m, handle);
+}
+
+/// Barra superior: carpeta, conteo, fila actual y estado.
+fn encabezado(model: &Model, v: &llimphi_widget_grid::VisibleWindow) -> View<Msg> {
+    let theme = &model.theme;
+    let texto = format!(
+        "{} ítems · fila {}/{} · orden:{} (o) · ⏎ abrir · ⌫ subir · +/− zoom · s slideshow · {}",
+        model.entries.len(),
+        v.first_row + 1,
+        v.total_rows.max(1),
+        model.orden.etiqueta(),
+        model.estado,
+    );
+    View::new(Style {
+        size: Size {
+            width: percent(1.0_f32),
+            height: length(HEADER_H),
+        },
+        padding: Rect {
+            left: length(12.0_f32),
+            right: length(12.0_f32),
+            top: length(0.0_f32),
+            bottom: length(0.0_f32),
+        },
+        align_items: Some(AlignItems::Center),
+        ..Default::default()
+    })
+    .fill(theme.bg_panel_alt)
+    .text_aligned(texto, 12.0, theme.fg_text, Alignment::Start)
+}
+
+/// Maneja navegación por teclado: flechas mueven la selección (y arrastran
+/// el scroll para mantenerla visible); Re Pág / Av Pág saltan una pantalla.
+fn manejar_tecla(m: &mut Model, ev: &KeyEvent, handle: &Handle<Msg>) {
+    if m.entries.is_empty() {
+        return;
+    }
+    let v = ventana_visible(m.entries.len(), m.vw, m.vh, m.scroll_fila, &m.metrics);
+    let cols = v.cols.max(1);
+    let filas_pantalla = v.filas_visibles.saturating_sub(1).max(1);
+    let ultimo = m.entries.len() - 1;
+    let cur = m.seleccionado.unwrap_or(0);
+
+    let nuevo: Option<usize> = match &ev.key {
+        Key::Named(NamedKey::ArrowRight) => Some((cur + 1).min(ultimo)),
+        Key::Named(NamedKey::ArrowLeft) => Some(cur.saturating_sub(1)),
+        Key::Named(NamedKey::ArrowDown) => Some((cur + cols).min(ultimo)),
+        Key::Named(NamedKey::ArrowUp) => Some(cur.saturating_sub(cols)),
+        Key::Named(NamedKey::Home) => Some(0),
+        Key::Named(NamedKey::End) => Some(ultimo),
+        Key::Named(NamedKey::PageDown) => {
+            m.scroll_fila = (m.scroll_fila + filas_pantalla).min(v.total_rows.saturating_sub(1));
+            bombear(m, handle);
+            return;
+        }
+        Key::Named(NamedKey::PageUp) => {
+            m.scroll_fila = m.scroll_fila.saturating_sub(filas_pantalla);
+            bombear(m, handle);
+            return;
+        }
+        _ => None,
+    };
+
+    if let Some(sel) = nuevo {
+        m.seleccionado = Some(sel);
+        m.estado = nombre(m.entries[sel].path());
+        asegurar_visible(m, sel, cols, filas_pantalla);
+        bombear(m, handle);
+    }
+}
+
+/// Ajusta `scroll_fila` para que el índice `sel` quede dentro de la
+/// ventana visible (lo arrastra mínimamente arriba o abajo).
+fn asegurar_visible(m: &mut Model, sel: usize, cols: usize, filas_pantalla: usize) {
+    let fila_sel = sel / cols;
+    if fila_sel < m.scroll_fila {
+        m.scroll_fila = fila_sel;
+    } else if fila_sel >= m.scroll_fila + filas_pantalla {
+        m.scroll_fila = fila_sel + 1 - filas_pantalla;
+    }
+}
+
+/// Abre el preview de la imagen `i`: marca el placeholder de carga y lanza
+/// la decodificación full-res en un thread, que reentra con `PreviewListo`.
+fn abrir_preview(m: &mut Model, i: usize, handle: &Handle<Msg>) {
+    let Some(path) = m.entries.get(i).map(|e| e.path().to_path_buf()) else {
+        return;
+    };
+    m.seleccionado = Some(i);
+    m.estado = format!("cargando {}…", nombre(&path));
+    // Placeholder mientras carga (el viewer pinta "—").
+    m.preview = Some((path.clone(), ImagePreviewState::Empty));
+    let p = path.clone();
+    handle.spawn(move || {
+        let st = load_image(&p, MAX_PREVIEW_BYTES);
+        Msg::PreviewListo(p, st)
+    });
+}
+
+/// Mueve el preview a la imagen anterior/siguiente (`d` = ±1), saltando
+/// carpetas. Con `wrap`, da la vuelta en los extremos (para el slideshow).
+fn avanzar_imagen(m: &mut Model, d: i32, wrap: bool, handle: &Handle<Msg>) {
+    let Some((actual, _)) = m.preview.as_ref() else {
+        return;
+    };
+    let Some(cur) = m.entries.iter().position(|e| e.path() == actual) else {
+        return;
+    };
+    let n = m.entries.len() as i32;
+    let mut i = cur as i32 + d;
+    // A lo sumo `n` pasos: evita un loop infinito si todo son carpetas.
+    for _ in 0..n {
+        if i < 0 || i >= n {
+            if wrap {
+                i = (i % n + n) % n;
+            } else {
+                return;
+            }
+        }
+        if !m.entries[i as usize].es_carpeta() {
+            let idx = i as usize;
+            m.seleccionado = Some(idx);
+            abrir_preview(m, idx, handle);
+            return;
+        }
+        i += d;
+    }
+}
+
+/// Programa un tick de slideshow: un thread duerme `SLIDE_SECS` y reentra
+/// con `SlideAvanzar` (que reprograma el siguiente si el slideshow sigue).
+fn programar_slide(handle: &Handle<Msg>) {
+    handle.spawn(move || {
+        std::thread::sleep(Duration::from_secs(SLIDE_SECS));
+        Msg::SlideAvanzar
+    });
+}
+
+/// Ordena una lista de paths según el criterio. `Tamano`/`Fecha` consultan
+/// `metadata` (un `stat` por archivo — se paga una vez por reorden).
+fn ordenar_paths(paths: &mut [PathBuf], orden: Orden) {
+    match orden {
+        Orden::Nombre => paths.sort(),
+        Orden::Tamano => {
+            paths.sort_by_key(|p| std::fs::metadata(p).map(|m| m.len()).unwrap_or(0))
+        }
+        Orden::Fecha => {
+            paths.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok())
+        }
+    }
+}
+
+/// Recalcula la ventana visible, encola los thumbs que falten, olvida los
+/// que se fueron de pantalla y lanza el próximo lote de generación.
+fn bombear(m: &mut Model, handle: &Handle<Msg>) {
+    let v = ventana_visible(m.entries.len(), m.vw, m.vh, m.scroll_fila, &m.metrics);
+    // Persistir el clamp (no scrollear más allá del fondo).
+    m.scroll_fila = v.first_row;
+
+    let mut visibles: HashSet<PathBuf> = HashSet::with_capacity(v.count);
+    for (orden, i) in (v.first..v.first + v.count).enumerate() {
+        // Las carpetas no generan miniatura (pintan un ícono).
+        if m.entries[i].es_carpeta() {
+            continue;
+        }
+        let path = m.entries[i].path().to_path_buf();
+        visibles.insert(path.clone());
+        if !m.thumbs.contains_key(&path) && !m.fallidos.contains(&path) {
+            // Prioridad = orden de aparición en la ventana (lo de arriba
+            // primero).
+            m.plan.solicitar(path, orden as u64);
+        }
+    }
+    m.plan.olvidar_excepto(&visibles);
+
+    // Eviction: si el cache RAM creció demasiado, conservar sólo las
+    // miniaturas de un rango de entradas alrededor de la ventana visible
+    // (con margen de varias pantallas) — el resto se regenera al volver.
+    if m.thumbs.len() > CAP_THUMBS {
+        let margen = v.cols.saturating_mul(30).max(60);
+        let lo = v.first.saturating_sub(margen);
+        let hi = (v.first + v.count + margen).min(m.entries.len());
+        let mantener: HashSet<PathBuf> = m.entries[lo..hi]
+            .iter()
+            .filter(|e| !e.es_carpeta())
+            .map(|e| e.path().to_path_buf())
+            .collect();
+        m.thumbs.retain(|p, _| mantener.contains(p));
+    }
+
+    for path in m.plan.proximos() {
+        let p = path.clone();
+        let cache = m.cache_disco.clone();
+        handle.spawn(move || {
+            // Pasa por el cache en disco si lo hay: hit ⇒ sin decodificar;
+            // miss ⇒ genera y puebla el disco para la próxima sesión.
+            let res = match &cache {
+                Some(c) => obtener_o_generar(c, &p, THUMB_LADO),
+                None => generar_thumb_de_archivo(&p, THUMB_LADO),
+            };
+            match res {
+                Ok(t) => Msg::ThumbListo(p, t),
+                Err(e) => Msg::ThumbFallo(p, e.to_string()),
+            }
+        });
+    }
+}
+
+/// Lista el contenido navegable de un directorio: subcarpetas (ordenadas
+/// por nombre) primero, luego los archivos de imagen (ordenados por
+/// `orden`). Lo demás se ignora.
+fn listar(dir: &Path, orden: Orden) -> Vec<Entrada> {
+    let rd = match std::fs::read_dir(dir) {
+        Ok(rd) => rd,
+        Err(_) => return Vec::new(),
+    };
+    let mut carpetas: Vec<PathBuf> = Vec::new();
+    let mut imagenes: Vec<PathBuf> = Vec::new();
+    for entrada in rd.flatten() {
+        let p = entrada.path();
+        if p.is_dir() {
+            carpetas.push(p);
+        } else if es_imagen(&p) {
+            imagenes.push(p);
+        }
+    }
+    carpetas.sort();
+    ordenar_paths(&mut imagenes, orden);
+    carpetas
+        .into_iter()
+        .map(Entrada::Carpeta)
+        .chain(imagenes.into_iter().map(|path| {
+            let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
+            Entrada::Imagen { path, size }
+        }))
+        .collect()
+}
+
+fn es_imagen(p: &Path) -> bool {
+    p.extension()
+        .and_then(|e| e.to_str())
+        .map(|e| EXTS.contains(&e.to_ascii_lowercase().as_str()))
+        .unwrap_or(false)
+}
+
+fn nombre(p: &Path) -> String {
+    p.file_name()
+        .and_then(|n| n.to_str())
+        .unwrap_or("?")
+        .to_string()
+}
+
+/// Viewport para clampear overlays. La galería trackea `vw` pero `vh` es
+/// el área útil bajo las barras; reconstruimos el alto total de ventana.
+fn viewport_of(model: &Model) -> (f32, f32) {
+    (model.vw, model.vh + MENU_H + HEADER_H + BREADCRUMB_H)
+}
+
+/// Arma el `MenuBarSpec` compartido por `menubar_view` y `menubar_overlay`.
+fn menubar_spec<'a>(menu: &'a AppMenu, model: &Model, theme: &'a Theme) -> MenuBarSpec<'a, Msg> {
+    MenuBarSpec {
+        menu,
+        open: model.menu_open,
+        theme,
+        viewport: viewport_of(model),
+        height: MENU_H,
+        on_open: Arc::new(Msg::MenuOpen),
+        on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
+    }
+}
+
+/// Menú principal de la galería. Archivo / Ver / Ayuda — sólo comandos que
+/// mapean a `Msg` reales. Sin "Editar": no hay campos de texto editables.
+fn app_menu() -> AppMenu {
+    AppMenu::new()
+        .menu(
+            Menu::new("Archivo")
+                .item(MenuItem::new("Subir a carpeta padre", "file.up").shortcut("Backspace"))
+                .item(MenuItem::new("Salir", "file.quit").shortcut("Ctrl+Q").separated()),
+        )
+        .menu(
+            Menu::new("Ver")
+                .item(MenuItem::new("Acercar (zoom +)", "view.zoom_in").shortcut("+"))
+                .item(MenuItem::new("Alejar (zoom −)", "view.zoom_out").shortcut("-"))
+                .item(MenuItem::new("Reiniciar zoom", "view.zoom_reset"))
+                .item(MenuItem::new("Ciclar orden", "view.orden").shortcut("o").separated())
+                .item(MenuItem::new("Cambiar tema", "view.theme")),
+        )
+        .menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
+}
+
+/// Traduce un command id del menú principal a su efecto real.
+fn handle_menu_command(model: Model, cmd: &str, handle: &Handle<Msg>) -> Model {
+    match cmd {
+        "file.up" => {
+            handle.dispatch(Msg::Subir);
+            model
+        }
+        "file.quit" => std::process::exit(0),
+        "view.zoom_in" => {
+            handle.dispatch(Msg::Zoom(20.0));
+            model
+        }
+        "view.zoom_out" => {
+            handle.dispatch(Msg::Zoom(-20.0));
+            model
+        }
+        "view.zoom_reset" => {
+            // 140px es el tile inicial; pedimos el delta hasta ahí.
+            handle.dispatch(Msg::Zoom(140.0 - model.metrics.tile_w));
+            model
+        }
+        "view.orden" => {
+            handle.dispatch(Msg::CiclarOrden);
+            model
+        }
+        "view.theme" => {
+            handle.dispatch(Msg::CiclarTema);
+            model
+        }
+        // help.about y desconocidos: no-op.
+        _ => model,
+    }
+}
+
+fn main() {
+    llimphi_ui::run::<Gallery>();
+}
diff --git a/02_ruway/nahual/nahual-geo-core/Cargo.toml b/02_ruway/nahual/nahual-geo-core/Cargo.toml
new file mode 100644
index 0000000..0553b54
--- /dev/null
+++ b/02_ruway/nahual/nahual-geo-core/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "nahual-geo-core"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "nahual-geo-core — núcleo geoespacial agnóstico de GUI del visor de mapas de nahual. Parsea GeoJSON/GPX/KML y tiles vectoriales (PMTiles v3 + MVT/Mapbox), modela el mundo (MapData/BBox/FeatureProps/MapView), proyecta equirectangular fit-to-bounds con cámara (zoom/pan), y resuelve hit-test, búsqueda, ruteo A* y el basemap por viewport con cache LRU. Sin dependencias de render: el frontend (`nahual-map-viewer-llimphi` u otro: TUI/web/headless) sólo lo pinta."
+
+[dependencies]
+serde_json = { workspace = true }
+quick-xml = { workspace = true }
+flate2 = { workspace = true }
diff --git a/02_ruway/nahual/nahual-geo-core/assets/world-countries.geojson b/02_ruway/nahual/nahual-geo-core/assets/world-countries.geojson
new file mode 100644
index 0000000..7f90602
--- /dev/null
+++ b/02_ruway/nahual/nahual-geo-core/assets/world-countries.geojson
@@ -0,0 +1 @@
+{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"featurecla":"Admin-0 country","scalerank":1,"labelrank":5,"sovereignt":"Costa Rica","sov_a3":"CRI","adm0_dif":0,"level":2,"type":"Sovereign country","tlc":"1","admin":"Costa Rica","adm0_a3":"CRI","geou_dif":0,"geounit":"Costa Rica","gu_a3":"CRI","su_dif":0,"subunit":"Costa Rica","su_a3":"CRI","brk_diff":0,"name":"Costa Rica","name_long":"Costa Rica","brk_a3":"CRI","brk_name":"Costa Rica","brk_group":null,"abbrev":"C.R.","postal":"CR","formal_en":"Republic of Costa Rica","formal_fr":null,"name_ciawf":"Costa Rica","note_adm0":null,"note_brk":null,"name_sort":"Costa Rica","name_alt":null,"mapcolor7":3,"mapcolor8":2,"mapcolor9":4,"mapcolor13":2,"pop_est":5047561,"pop_rank":13,"pop_year":2019,"gdp_md":61801,"gdp_year":2019,"economy":"5. Emerging region: G20","income_grp":"3. Upper middle income","fips_10":"CS","iso_a2":"CR","iso_a2_eh":"CR","iso_a3":"CRI","iso_a3_eh":"CRI","iso_n3":"188","iso_n3_eh":"188","un_a3":"188","wb_a2":"CR","wb_a3":"CRI","woe_id":23424791,"woe_id_eh":23424791,"woe_note":"Exact WOE match as country","adm0_iso":"CRI","adm0_diff":null,"adm0_tlc":"CRI","adm0_a3_us":"CRI","adm0_a3_fr":"CRI","adm0_a3_ru":"CRI","adm0_a3_es":"CRI","adm0_a3_cn":"CRI","adm0_a3_tw":"CRI","adm0_a3_in":"CRI","adm0_a3_np":"CRI","adm0_a3_pk":"CRI","adm0_a3_de":"CRI","adm0_a3_gb":"CRI","adm0_a3_br":"CRI","adm0_a3_il":"CRI","adm0_a3_ps":"CRI","adm0_a3_sa":"CRI","adm0_a3_eg":"CRI","adm0_a3_ma":"CRI","adm0_a3_pt":"CRI","adm0_a3_ar":"CRI","adm0_a3_jp":"CRI","adm0_a3_ko":"CRI","adm0_a3_vn":"CRI","adm0_a3_tr":"CRI","adm0_a3_id":"CRI","adm0_a3_pl":"CRI","adm0_a3_gr":"CRI","adm0_a3_it":"CRI","adm0_a3_nl":"CRI","adm0_a3_se":"CRI","adm0_a3_bd":"CRI","adm0_a3_ua":"CRI","adm0_a3_un":-99,"adm0_a3_wb":-99,"continent":"North America","region_un":"Americas","subregion":"Central America","region_wb":"Latin America & Caribbean","name_len":10,"long_len":10,"abbrev_len":4,"tiny":-99,"homepart":1,"min_zoom":0,"min_label":2.5,"max_label":8,"label_x":-84.077922,"label_y":10.0651,"ne_id":1159320525,"wikidataid":"Q800","name_ar":"كوستاريكا","name_bn":"কোস্টা রিকা","name_de":"Costa Rica","name_en":"Costa Rica","name_es":"Costa Rica","name_fa":"کاستاریکا","name_fr":"Costa Rica","name_el":"Κόστα Ρίκα","name_he":"קוסטה ריקה","name_hi":"कोस्टा रीका","name_hu":"Costa Rica","name_id":"Kosta Rika","name_it":"Costa Rica","name_ja":"コスタリカ","name_ko":"코스타리카","name_nl":"Costa Rica","name_pl":"Kostaryka","name_pt":"Costa Rica","name_ru":"Коста-Рика","name_sv":"Costa Rica","name_tr":"Kosta Rika","name_uk":"Коста-Рика","name_ur":"کوسٹاریکا","name_vi":"Costa Rica","name_zh":"哥斯达黎加","name_zht":"哥斯大黎加","fclass_iso":"Admin-0 country","tlc_diff":null,"fclass_tlc":"Admin-0 country","fclass_us":null,"fclass_fr":null,"fclass_ru":null,"fclass_es":null,"fclass_cn":null,"fclass_tw":null,"fclass_in":null,"fclass_np":null,"fclass_pk":null,"fclass_de":null,"fclass_gb":null,"fclass_br":null,"fclass_il":null,"fclass_ps":null,"fclass_sa":null,"fclass_eg":null,"fclass_ma":null,"fclass_pt":null,"fclass_ar":null,"fclass_jp":null,"fclass_ko":null,"fclass_vn":null,"fclass_tr":null,"fclass_id":null,"fclass_pl":null,"fclass_gr":null,"fclass_it":null,"fclass_nl":null,"fclass_se":null,"fclass_bd":null,"fclass_ua":null,"filename":"CRI.geojson"},"geometry":{"type":"Polygon","coordinates":[[[-82.54619625520348,9.566134751824677],[-82.93289099804358,9.476812038608173],[-82.92715491405916,9.074330145702916],[-82.71918311230053,8.925708726431495],[-82.86865719270477,8.807266343618522],[-82.82977067740516,8.62629547773237],[-82.91317643912421,8.42351715741907],[-82.96578304719736,8.225027980985985],[-83.50843726269431,8.446926581247283],[-83.71147396516908,8.656836249216866],[-83.59631303580665,8.830443223501419],[-83.63264156770784,9.051385809765321],[-83.90988562695374,9.29080272057358],[-84.30340165885636,9.487354030795714],[-84.64764421256866,9.61553742109571],[-84.71335079622777,9.908051866083852],[-84.97566036654133,10.086723130733006],[-84.91137488477024,9.795991522658923],[-85.11092342806532,9.55703969974131],[-85.33948828809227,9.83454214114866],[-85.66078650586698,9.933347479690724],[-85.79744483106285,10.134885565629034],[-85.79170874707843,10.439337266476613],[-85.65931372754667,10.75433095951172],[-85.94172543002176,10.895278428587801],[-85.7125404528073,11.088444932494824],[-85.5618519762442,11.217119248901597],[-84.90300330273895,10.952303371621896],[-84.67306901725627,11.082657172078143],[-84.35593075228104,10.999225572142905],[-84.19017859570485,10.793450018756674],[-83.89505449088595,10.726839097532446],[-83.65561174186158,10.938764146361422],[-83.40231970898296,10.395438137244653],[-83.01567664257517,9.992982082555557],[-82.54619625520348,9.566134751824677]]]}},{"type":"Feature","properties":{"featurecla":"Admin-0 country","scalerank":1,"labelrank":5,"sovereignt":"Nicaragua","sov_a3":"NIC","adm0_dif":0,"level":2,"type":"Sovereign country","tlc":"1","admin":"Nicaragua","adm0_a3":"NIC","geou_dif":0,"geounit":"Nicaragua","gu_a3":"NIC","su_dif":0,"subunit":"Nicaragua","su_a3":"NIC","brk_diff":0,"name":"Nicaragua","name_long":"Nicaragua","brk_a3":"NIC","brk_name":"Nicaragua","brk_group":null,"abbrev":"Nic.","postal":"NI","formal_en":"Republic of Nicaragua","formal_fr":null,"name_ciawf":"Nicaragua","note_adm0":null,"note_brk":null,"name_sort":"Nicaragua","name_alt":null,"mapcolor7":1,"mapcolor8":4,"mapcolor9":1,"mapcolor13":9,"pop_est":6545502,"pop_rank":13,"pop_year":2019,"gdp_md":12520,"gdp_year":2019,"economy":"6. Developing region","income_grp":"4. Lower middle income","fips_10":"NU","iso_a2":"NI","iso_a2_eh":"NI","iso_a3":"NIC","iso_a3_eh":"NIC","iso_n3":"558","iso_n3_eh":"558","un_a3":"558","wb_a2":"NI","wb_a3":"NIC","woe_id":23424915,"woe_id_eh":23424915,"woe_note":"Exact WOE match as country","adm0_iso":"NIC","adm0_diff":null,"adm0_tlc":"NIC","adm0_a3_us":"NIC","adm0_a3_fr":"NIC","adm0_a3_ru":"NIC","adm0_a3_es":"NIC","adm0_a3_cn":"NIC","adm0_a3_tw":"NIC","adm0_a3_in":"NIC","adm0_a3_np":"NIC","adm0_a3_pk":"NIC","adm0_a3_de":"NIC","adm0_a3_gb":"NIC","adm0_a3_br":"NIC","adm0_a3_il":"NIC","adm0_a3_ps":"NIC","adm0_a3_sa":"NIC","adm0_a3_eg":"NIC","adm0_a3_ma":"NIC","adm0_a3_pt":"NIC","adm0_a3_ar":"NIC","adm0_a3_jp":"NIC","adm0_a3_ko":"NIC","adm0_a3_vn":"NIC","adm0_a3_tr":"NIC","adm0_a3_id":"NIC","adm0_a3_pl":"NIC","adm0_a3_gr":"NIC","adm0_a3_it":"NIC","adm0_a3_nl":"NIC","adm0_a3_se":"NIC","adm0_a3_bd":"NIC","adm0_a3_ua":"NIC","adm0_a3_un":-99,"adm0_a3_wb":-99,"continent":"North America","region_un":"Americas","subregion":"Central America","region_wb":"Latin America & Caribbean","name_len":9,"long_len":9,"abbrev_len":4,"tiny":-99,"homepart":1,"min_zoom":0,"min_label":4,"max_label":9,"label_x":-85.069347,"label_y":12.670697,"ne_id":1159321091,"wikidataid":"Q811","name_ar":"نيكاراغوا","name_bn":"নিকারাগুয়া","name_de":"Nicaragua","name_en":"Nicaragua","name_es":"Nicaragua","name_fa":"نیکاراگوئه","name_fr":"Nicaragua","name_el":"Νικαράγουα","name_he":"ניקרגואה","name_hi":"निकारागुआ","name_hu":"Nicaragua","name_id":"Nikaragua","name_it":"Nicaragua","name_ja":"ニカラグア","name_ko":"니카라과","name_nl":"Nicaragua","name_pl":"Nikaragua","name_pt":"Nicarágua","name_ru":"Никарагуа","name_sv":"Nicaragua","name_tr":"Nikaragua","name_uk":"Нікарагуа","name_ur":"نکاراگوا","name_vi":"Nicaragua","name_zh":"尼加拉瓜","name_zht":"尼加拉瓜","fclass_iso":"Admin-0 country","tlc_diff":null,"fclass_tlc":"Admin-0 country","fclass_us":null,"fclass_fr":null,"fclass_ru":null,"fclass_es":null,"fclass_cn":null,"fclass_tw":null,"fclass_in":null,"fclass_np":null,"fclass_pk":null,"fclass_de":null,"fclass_gb":null,"fclass_br":null,"fclass_il":null,"fclass_ps":null,"fclass_sa":null,"fclass_eg":null,"fclass_ma":null,"fclass_pt":null,"fclass_ar":null,"fclass_jp":null,"fclass_ko":null,"fclass_vn":null,"fclass_tr":null,"fclass_id":null,"fclass_pl":null,"fclass_gr":null,"fclass_it":null,"fclass_nl":null,"fclass_se":null,"fclass_bd":null,"fclass_ua":null,"filename":"NIC.geojson"},"geometry":{"type":"Polygon","coordinates":[[[-83.65561174186158,10.938764146361422],[-83.89505449088595,10.726839097532446],[-84.19017859570485,10.793450018756674],[-84.35593075228104,10.999225572142905],[-84.67306901725627,11.082657172078143],[-84.90300330273895,10.952303371621896],[-85.5618519762442,11.217119248901597],[-85.7125404528073,11.088444932494824],[-86.05848832878526,11.403438625529944],[-86.52584998243296,11.806876532432597],[-86.74599158399633,12.143961900272487],[-87.16751624220116,12.458257961471658],[-87.66849341505471,12.909909979702633],[-87.55746660027562,13.064551703336065],[-87.39238623731923,12.914018256069838],[-87.31665442579549,12.984685777228975],[-87.00576900912758,13.025794379117158],[-86.88055701368438,13.254204209847217],[-86.7338217841916,13.263092556201443],[-86.75508663607971,13.754845485890913],[-86.52070817741992,13.778487453664468],[-86.31214209668993,13.77135610600817],[-86.09626380079061,14.038187364147234],[-85.80129472526859,13.83605499923759],[-85.69866533073696,13.960078436738002],[-85.51441301140028,14.079011745657908],[-85.16536454948482,14.354369615125051],[-85.14875057650296,14.560196844943617],[-85.05278744173694,14.551541042534723],[-84.9245006985724,14.79049286545235],[-84.82003679069436,14.81958669683267],[-84.64958207877964,14.666805324761867],[-84.4493359036486,14.621614284722511],[-84.22834164095241,14.74876414637663],[-83.97572140169359,14.749435939996488],[-83.62858496777292,14.880073960830302],[-83.48998877636612,15.016267198135537],[-83.14721900097413,14.99582916916411],[-83.23323442252394,14.899866034398102],[-83.2841615465476,14.6766238468972],[-83.18212643098728,14.31070302983845],[-83.41249996614445,13.970077826386557],[-83.51983191601468,13.567699286345883],[-83.55220720084554,13.127054348193086],[-83.49851538769427,12.869292303921227],[-83.47332312695198,12.419087225794428],[-83.62610449902292,12.320850328007566],[-83.71961300325506,11.893124497927728],[-83.65085751009072,11.62903209070012],[-83.8554703437504,11.373311265503787],[-83.80893571647155,11.103043524617275],[-83.65561174186158,10.938764146361422]]]}},{"type":"Feature","properties":{"featurecla":"Admin-0 country","scalerank":1,"labelrank":5,"sovereignt":"Haiti","sov_a3":"HTI","adm0_dif":0,"level":2,"type":"Sovereign country","tlc":"1","admin":"Haiti","adm0_a3":"HTI","geou_dif":0,"geounit":"Haiti","gu_a3":"HTI","su_dif":0,"subunit":"Haiti","su_a3":"HTI","brk_diff":0,"name":"Haiti","name_long":"Haiti","brk_a3":"HTI","brk_name":"Haiti","brk_group":null,"abbrev":"Haiti","postal":"HT","formal_en":"Republic of Haiti","formal_fr":null,"name_ciawf":"Haiti","note_adm0":null,"note_brk":null,"name_sort":"Haiti","name_alt":null,"mapcolor7":2,"mapcolor8":1,"mapcolor9":7,"mapcolor13":2,"pop_est":11263077,"pop_rank":14,"pop_year":2019,"gdp_md":14332,"gdp_year":2019,"economy":"7. Least developed region","income_grp":"5. Low income","fips_10":"HA","iso_a2":"HT","iso_a2_eh":"HT","iso_a3":"HTI","iso_a3_eh":"HTI","iso_n3":"332","iso_n3_eh":"332","un_a3":"332","wb_a2":"HT","wb_a3":"HTI","woe_id":23424839,"woe_id_eh":23424839,"woe_note":"Exact WOE match as country","adm0_iso":"HTI","adm0_diff":null,"adm0_tlc":"HTI","adm0_a3_us":"HTI","adm0_a3_fr":"HTI","adm0_a3_ru":"HTI","adm0_a3_es":"HTI","adm0_a3_cn":"HTI","adm0_a3_tw":"HTI","adm0_a3_in":"HTI","adm0_a3_np":"HTI","adm0_a3_pk":"HTI","adm0_a3_de":"HTI","adm0_a3_gb":"HTI","adm0_a3_br":"HTI","adm0_a3_il":"HTI","adm0_a3_ps":"HTI","adm0_a3_sa":"HTI","adm0_a3_eg":"HTI","adm0_a3_ma":"HTI","adm0_a3_pt":"HTI","adm0_a3_ar":"HTI","adm0_a3_jp":"HTI","adm0_a3_ko":"HTI","adm0_a3_vn":"HTI","adm0_a3_tr":"HTI","adm0_a3_id":"HTI","adm0_a3_pl":"HTI","adm0_a3_gr":"HTI","adm0_a3_it":"HTI","adm0_a3_nl":"HTI","adm0_a3_se":"HTI","adm0_a3_bd":"HTI","adm0_a3_ua":"HTI","adm0_a3_un":-99,"adm0_a3_wb":-99,"continent":"North America","region_un":"Americas","subregion":"Caribbean","region_wb":"Latin America & Caribbean","name_len":5,"long_len":5,"abbrev_len":5,"tiny":-99,"homepart":1,"min_zoom":0,"min_label":4,"max_label":9,"label_x":-72.224051,"label_y":19.263784,"ne_id":1159320839,"wikidataid":"Q790","name_ar":"هايتي","name_bn":"হাইতি","name_de":"Haiti","name_en":"Haiti","name_es":"Haití","name_fa":"هائیتی","name_fr":"Haïti","name_el":"Αϊτή","name_he":"האיטי","name_hi":"हैती","name_hu":"Haiti","name_id":"Haiti","name_it":"Haiti","name_ja":"ハイチ","name_ko":"아이티","name_nl":"Haïti","name_pl":"Haiti","name_pt":"Haiti","name_ru":"Республика Гаити","name_sv":"Haiti","name_tr":"Haiti","name_uk":"Гаїті","name_ur":"ہیٹی","name_vi":"Haiti","name_zh":"海地","name_zht":"海地","fclass_iso":"Admin-0 country","tlc_diff":null,"fclass_tlc":"Admin-0 country","fclass_us":null,"fclass_fr":null,"fclass_ru":null,"fclass_es":null,"fclass_cn":null,"fclass_tw":null,"fclass_in":null,"fclass_np":null,"fclass_pk":null,"fclass_de":null,"fclass_gb":null,"fclass_br":null,"fclass_il":null,"fclass_ps":null,"fclass_sa":null,"fclass_eg":null,"fclass_ma":null,"fclass_pt":null,"fclass_ar":null,"fclass_jp":null,"fclass_ko":null,"fclass_vn":null,"fclass_tr":null,"fclass_id":null,"fclass_pl":null,"fclass_gr":null,"fclass_it":null,"fclass_nl":null,"fclass_se":null,"fclass_bd":null,"fclass_ua":null,"filename":"HTI.geojson"},"geometry":{"type":"Polygon","coordinates":[[[-71.71236141629296,19.714455878167357],[-71.62487321642283,19.169837958243306],[-71.7013026597825,18.785416978424053],[-71.94511206733556,18.61690013272026],[-71.68773759630588,18.31666006110447],[-71.70830481635805,18.044997056546094],[-72.37247616238935,18.21496084235406],[-72.84441118029488,18.145611070218365],[-73.45455481636503,18.2179063989947],[-73.92243323433566,18.030992743395004],[-74.45803361682478,18.342549953682706],[-74.36992529976713,18.66490753831941],[-73.44954220243272,18.526052964751145],[-72.69493709989064,18.445799465401862],[-72.334881557897,18.668421535715254],[-72.79164954292489,19.10162506761803],[-72.78410478381028,19.48359141690341],[-73.41502234566175,19.639550889560283],[-73.18979061551762,19.915683905511912],[-72.57967281766362,19.871500555902358],[-71.71236141629296,19.714455878167357]]]}},{"type":"Feature","properties":{"featurecla":"Admin-0 country","scalerank":1,"labelrank":5,"sovereignt":"Dominican Republic","sov_a3":"DOM","adm0_dif":0,"level":2,"type":"Sovereign country","tlc":"1","admin":"Dominican Republic","adm0_a3":"DOM","geou_dif":0,"geounit":"Dominican Republic","gu_a3":"DOM","su_dif":0,"subunit":"Dominican Republic","su_a3":"DOM","brk_diff":0,"name":"Dominican Rep.","name_long":"Dominican Republic","brk_a3":"DOM","brk_name":"Dominican Rep.","brk_group":null,"abbrev":"Dom. Rep.","postal":"DO","formal_en":"Dominican Republic","formal_fr":null,"name_ciawf":"Dominican Republic","note_adm0":null,"note_brk":null,"name_sort":"Dominican Republic","name_alt":null,"mapcolor7":5,"mapcolor8":2,"mapcolor9":5,"mapcolor13":7,"pop_est":10738958,"pop_rank":14,"pop_year":2019,"gdp_md":88941,"gdp_year":2019,"economy":"6. Developing region","income_grp":"3. Upper middle income","fips_10":"DR","iso_a2":"DO","iso_a2_eh":"DO","iso_a3":"DOM","iso_a3_eh":"DOM","iso_n3":"214","iso_n3_eh":"214","un_a3":"214","wb_a2":"DO","wb_a3":"DOM","woe_id":23424800,"woe_id_eh":23424800,"woe_note":"Exact WOE match as country","adm0_iso":"DOM","adm0_diff":null,"adm0_tlc":"DOM","adm0_a3_us":"DOM","adm0_a3_fr":"DOM","adm0_a3_ru":"DOM","adm0_a3_es":"DOM","adm0_a3_cn":"DOM","adm0_a3_tw":"DOM","adm0_a3_in":"DOM","adm0_a3_np":"DOM","adm0_a3_pk":"DOM","adm0_a3_de":"DOM","adm0_a3_gb":"DOM","adm0_a3_br":"DOM","adm0_a3_il":"DOM","adm0_a3_ps":"DOM","adm0_a3_sa":"DOM","adm0_a3_eg":"DOM","adm0_a3_ma":"DOM","adm0_a3_pt":"DOM","adm0_a3_ar":"DOM","adm0_a3_jp":"DOM","adm0_a3_ko":"DOM","adm0_a3_vn":"DOM","adm0_a3_tr":"DOM","adm0_a3_id":"DOM","adm0_a3_pl":"DOM","adm0_a3_gr":"DOM","adm0_a3_it":"DOM","adm0_a3_nl":"DOM","adm0_a3_se":"DOM","adm0_a3_bd":"DOM","adm0_a3_ua":"DOM","adm0_a3_un":-99,"adm0_a3_wb":-99,"continent":"North America","region_un":"Americas","subregion":"Caribbean","region_wb":"Latin America & Caribbean","name_len":14,"long_len":18,"abbrev_len":9,"tiny":-99,"homepart":1,"min_zoom":0,"min_label":4.5,"max_label":9.5,"label_x":-70.653998,"label_y":19.104137,"ne_id":1159320563,"wikidataid":"Q786","name_ar":"جمهورية الدومينيكان","name_bn":"ডোমিনিকান প্রজাতন্ত্র","name_de":"Dominikanische Republik","name_en":"Dominican Republic","name_es":"República Dominicana","name_fa":"جمهوری دومینیکن","name_fr":"République dominicaine","name_el":"Δομινικανή Δημοκρατία","name_he":"הרפובליקה הדומיניקנית","name_hi":"डोमिनिकन गणराज्य","name_hu":"Dominikai Köztársaság","name_id":"Republik Dominika","name_it":"Repubblica Dominicana","name_ja":"ドミニカ共和国","name_ko":"도미니카 공화국","name_nl":"Dominicaanse Republiek","name_pl":"Dominikana","name_pt":"República Dominicana","name_ru":"Доминиканская Республика","name_sv":"Dominikanska republiken","name_tr":"Dominik Cumhuriyeti","name_uk":"Домініканська Республіка","name_ur":"جمہوریہ ڈومینیکن","name_vi":"Cộng hòa Dominica","name_zh":"多米尼加","name_zht":"多明尼加","fclass_iso":"Admin-0 country","tlc_diff":null,"fclass_tlc":"Admin-0 country","fclass_us":null,"fclass_fr":null,"fclass_ru":null,"fclass_es":null,"fclass_cn":null,"fclass_tw":null,"fclass_in":null,"fclass_np":null,"fclass_pk":null,"fclass_de":null,"fclass_gb":null,"fclass_br":null,"fclass_il":null,"fclass_ps":null,"fclass_sa":null,"fclass_eg":null,"fclass_ma":null,"fclass_pt":null,"fclass_ar":null,"fclass_jp":null,"fclass_ko":null,"fclass_vn":null,"fclass_tr":null,"fclass_id":null,"fclass_pl":null,"fclass_gr":null,"fclass_it":null,"fclass_nl":null,"fclass_se":null,"fclass_bd":null,"fclass_ua":null,"filename":"DOM.geojson"},"geometry":{"type":"Polygon","coordinates":[[[-71.70830481635805,18.044997056546094],[-71.68773759630588,18.31666006110447],[-71.94511206733556,18.61690013272026],[-71.7013026597825,18.785416978424053],[-71.62487321642283,19.169837958243306],[-71.71236141629296,19.714455878167357],[-71.58730445014663,19.8849105900821],[-70.80670610216174,19.880285549391985],[-70.21436499701613,19.62288524014616],[-69.95081519232758,19.64799998624001],[-69.76925004747008,19.29326711677244],[-69.22212582057988,19.313214219637103],[-69.25434607611385,19.015196234609874],[-68.80941199408083,18.979074408437853],[-68.31794328476897,18.612197577381693],[-68.68931596543452,18.205142320218613],[-69.16494584824892,18.42264842373511],[-69.62398759629764,18.38071299893025],[-69.95293392605154,18.42830699307106],[-70.1332329983179,18.245915025296895],[-70.51713721381422,18.184290879788833],[-70.66929846869763,18.426885891183034],[-70.99995012071719,18.283328762276213],[-71.4002099270339,17.5985643579766],[-71.65766191271202,17.7575727401387],[-71.70830481635805,18.044997056546094]]]}},{"type":"Feature","properties":{"featurecla":"Admin-0 country","scalerank":1,"labelrank":6,"sovereignt":"El Salvador","sov_a3":"SLV","adm0_dif":0,"level":2,"type":"Sovereign country","tlc":"1","admin":"El Salvador","adm0_a3":"SLV","geou_dif":0,"geounit":"El Salvador","gu_a3":"SLV","su_dif":0,"subunit":"El Salvador","su_a3":"SLV","brk_diff":0,"name":"El Salvador","name_long":"El Salvador","brk_a3":"SLV","brk_name":"El Salvador","brk_group":null,"abbrev":"El. S.","postal":"SV","formal_en":"Republic of El Salvador","formal_fr":null,"name_ciawf":"El Salvador","note_adm0":null,"note_brk":null,"name_sort":"El Salvador","name_alt":null,"mapcolor7":1,"mapcolor8":4,"mapcolor9":6,"mapcolor13":8,"pop_est":6453553,"pop_rank":13,"pop_year":2019,"gdp_md":27022,"gdp_year":2019,"economy":"6. Developing region","income_grp":"4. Lower middle income","fips_10":"ES","iso_a2":"SV","iso_a2_eh":"SV","iso_a3":"SLV","iso_a3_eh":"SLV","iso_n3":"222","iso_n3_eh":"222","un_a3":"222","wb_a2":"SV","wb_a3":"SLV","woe_id":23424807,"woe_id_eh":23424807,"woe_note":"Exact WOE match as country","adm0_iso":"SLV","adm0_diff":null,"adm0_tlc":"SLV","adm0_a3_us":"SLV","adm0_a3_fr":"SLV","adm0_a3_ru":"SLV","adm0_a3_es":"SLV","adm0_a3_cn":"SLV","adm0_a3_tw":"SLV","adm0_a3_in":"SLV","adm0_a3_np":"SLV","adm0_a3_pk":"SLV","adm0_a3_de":"SLV","adm0_a3_gb":"SLV","adm0_a3_br":"SLV","adm0_a3_il":"SLV","adm0_a3_ps":"SLV","adm0_a3_sa":"SLV","adm0_a3_eg":"SLV","adm0_a3_ma":"SLV","adm0_a3_pt":"SLV","adm0_a3_ar":"SLV","adm0_a3_jp":"SLV","adm0_a3_ko":"SLV","adm0_a3_vn":"SLV","adm0_a3_tr":"SLV","adm0_a3_id":"SLV","adm0_a3_pl":"SLV","adm0_a3_gr":"SLV","adm0_a3_it":"SLV","adm0_a3_nl":"SLV","adm0_a3_se":"SLV","adm0_a3_bd":"SLV","adm0_a3_ua":"SLV","adm0_a3_un":-99,"adm0_a3_wb":-99,"continent":"North America","region_un":"Americas","subregion":"Central America","region_wb":"Latin America & Caribbean","name_len":11,"long_len":11,"abbrev_len":6,"tiny":-99,"homepart":1,"min_zoom":0,"min_label":5,"max_label":10,"label_x":-88.890124,"label_y":13.685371,"ne_id":1159321253,"wikidataid":"Q792","name_ar":"السلفادور","name_bn":"এল সালভাদোর","name_de":"El Salvador","name_en":"El Salvador","name_es":"El Salvador","name_fa":"السالوادور","name_fr":"Salvador","name_el":"Ελ Σαλβαδόρ","name_he":"אל סלוודור","name_hi":"अल साल्वाडोर","name_hu":"Salvador","name_id":"El Salvador","name_it":"El Salvador","name_ja":"エルサルバドル","name_ko":"엘살바도르","name_nl":"El Salvador","name_pl":"Salwador","name_pt":"El Salvador","name_ru":"Сальвадор","name_sv":"El Salvador","name_tr":"El Salvador","name_uk":"Сальвадор","name_ur":"ایل سیلواڈور","name_vi":"El Salvador","name_zh":"萨尔瓦多","name_zht":"薩爾瓦多","fclass_iso":"Admin-0 country","tlc_diff":null,"fclass_tlc":"Admin-0 country","fclass_us":null,"fclass_fr":null,"fclass_ru":null,"fclass_es":null,"fclass_cn":null,"fclass_tw":null,"fclass_in":null,"fclass_np":null,"fclass_pk":null,"fclass_de":null,"fclass_gb":null,"fclass_br":null,"fclass_il":null,"fclass_ps":null,"fclass_sa":null,"fclass_eg":null,"fclass_ma":null,"fclass_pt":null,"fclass_ar":null,"fclass_jp":null,"fclass_ko":null,"fclass_vn":null,"fclass_tr":null,"fclass_id":null,"fclass_pl":null,"fclass_gr":null,"fclass_it":null,"fclass_nl":null,"fclass_se":null,"fclass_bd":null,"fclass_ua":null,"filename":"SLV.geojson"},"geometry":{"type":"Polygon","coordinates":[[[-89.35332597528281,14.424132798719086],[-89.05851192905766,14.340029405164087],[-88.84307288283284,14.14050670008517],[-88.541230841816,13.980154730683479],[-88.50399797234971,13.845485948130857],[-88.06534257684014,13.964625962779778],[-87.85951534702161,13.893312486217098],[-87.7235029772294,13.785050360565506],[-87.79311113152657,13.384480495655055],[-87.90411210808952,13.149016831917137],[-88.48330156121682,13.163951320849492],[-88.84322791212972,13.259733588102478],[-89.2567427233293,13.458532823129303],[-89.81239356154767,13.520622056527998],[-90.09555457229098,13.735337632700734],[-90.0646779039966,13.881969509328925],[-89.72193396682073,14.134228013561696],[-89.53421932652051,14.244815578666305],[-89.58734269891656,14.36258616785949],[-89.35332597528281,14.424132798719086]]]}},{"type":"Feature","properties":{"featurecla":"Admin-0 country","scalerank":1,"labelrank":3,"sovereignt":"Guatemala","sov_a3":"GTM","adm0_dif":0,"level":2,"type":"Sovereign country","tlc":"1","admin":"Guatemala","adm0_a3":"GTM","geou_dif":0,"geounit":"Guatemala","gu_a3":"GTM","su_dif":0,"subunit":"Guatemala","su_a3":"GTM","brk_diff":0,"name":"Guatemala","name_long":"Guatemala","brk_a3":"GTM","brk_name":"Guatemala","brk_group":null,"abbrev":"Guat.","postal":"GT","formal_en":"Republic of Guatemala","formal_fr":null,"name_ciawf":"Guatemala","note_adm0":null,"note_brk":null,"name_sort":"Guatemala","name_alt":null,"mapcolor7":3,"mapcolor8":3,"mapcolor9":3,"mapcolor13":6,"pop_est":16604026,"pop_rank":14,"pop_year":2019,"gdp_md":76710,"gdp_year":2019,"economy":"6. Developing region","income_grp":"4. Lower middle income","fips_10":"GT","iso_a2":"GT","iso_a2_eh":"GT","iso_a3":"GTM","iso_a3_eh":"GTM","iso_n3":"320","iso_n3_eh":"320","un_a3":"320","wb_a2":"GT","wb_a3":"GTM","woe_id":23424834,"woe_id_eh":23424834,"woe_note":"Exact WOE match as country","adm0_iso":"GTM","adm0_diff":null,"adm0_tlc":"GTM","adm0_a3_us":"GTM","adm0_a3_fr":"GTM","adm0_a3_ru":"GTM","adm0_a3_es":"GTM","adm0_a3_cn":"GTM","adm0_a3_tw":"GTM","adm0_a3_in":"GTM","adm0_a3_np":"GTM","adm0_a3_pk":"GTM","adm0_a3_de":"GTM","adm0_a3_gb":"GTM","adm0_a3_br":"GTM","adm0_a3_il":"GTM","adm0_a3_ps":"GTM","adm0_a3_sa":"GTM","adm0_a3_eg":"GTM","adm0_a3_ma":"GTM","adm0_a3_pt":"GTM","adm0_a3_ar":"GTM","adm0_a3_jp":"GTM","adm0_a3_ko":"GTM","adm0_a3_vn":"GTM","adm0_a3_tr":"GTM","adm0_a3_id":"GTM","adm0_a3_pl":"GTM","adm0_a3_gr":"GTM","adm0_a3_it":"GTM","adm0_a3_nl":"GTM","adm0_a3_se":"GTM","adm0_a3_bd":"GTM","adm0_a3_ua":"GTM","adm0_a3_un":-99,"adm0_a3_wb":-99,"continent":"North America","region_un":"Americas","subregion":"Central America","region_wb":"Latin America & Caribbean","name_len":9,"long_len":9,"abbrev_len":5,"tiny":4,"homepart":1,"min_zoom":0,"min_label":3,"max_label":8,"label_x":-90.497134,"label_y":14.982133,"ne_id":1159320815,"wikidataid":"Q774","name_ar":"غواتيمالا","name_bn":"গুয়াতেমালা","name_de":"Guatemala","name_en":"Guatemala","name_es":"Guatemala","name_fa":"گواتمالا","name_fr":"Guatemala","name_el":"Γουατεμάλα","name_he":"גואטמלה","name_hi":"ग्वाटेमाला","name_hu":"Guatemala","name_id":"Guatemala","name_it":"Guatemala","name_ja":"グアテマラ","name_ko":"과테말라","name_nl":"Guatemala","name_pl":"Gwatemala","name_pt":"Guatemala","name_ru":"Гватемала","name_sv":"Guatemala","name_tr":"Guatemala","name_uk":"Гватемала","name_ur":"گواتیمالا","name_vi":"Guatemala","name_zh":"危地马拉","name_zht":"瓜地馬拉","fclass_iso":"Admin-0 country","tlc_diff":null,"fclass_tlc":"Admin-0 country","fclass_us":null,"fclass_fr":null,"fclass_ru":null,"fclass_es":null,"fclass_cn":null,"fclass_tw":null,"fclass_in":null,"fclass_np":null,"fclass_pk":null,"fclass_de":null,"fclass_gb":null,"fclass_br":null,"fclass_il":null,"fclass_ps":null,"fclass_sa":null,"fclass_eg":null,"fclass_ma":null,"fclass_pt":null,"fclass_ar":null,"fclass_jp":null,"fclass_ko":null,"fclass_vn":null,"fclass_tr":null,"fclass_id":null,"fclass_pl":null,"fclass_gr":null,"fclass_it":null,"fclass_nl":null,"fclass_se":null,"fclass_bd":null,"fclass_ua":null,"filename":"GTM.geojson"},"geometry":{"type":"Polygon","coordinates":[[[-92.22775000686983,14.538828640190928],[-92.20322953974733,14.830102850804069],[-92.08721594925207,15.064584662328441],[-92.2292486234063,15.251446641495875],[-91.74796017125595,16.066564846251765],[-90.46447262242266,16.069562079324655],[-90.43886695022204,16.410109768128095],[-90.60084672724093,16.47077789963879],[-90.71182186558772,16.687483018454728],[-91.08167009150065,16.918476670799407],[-91.45392127151516,17.252177232324172],[-91.00226925328421,17.25465770107418],[-91.00151994501596,17.81759491624571],[-90.06793351923098,17.819326076727478],[-89.14308041050333,17.808318996649405],[-89.15080603713095,17.015576687075836],[-89.22912167026928,15.886937567605171],[-88.93061275913527,15.887273464415076],[-88.60458614780585,15.70638011317736],[-88.51836402052686,15.855389105690975],[-88.22502275262202,15.727722479713904],[-88.68067969435563,15.346247056535304],[-89.15481096063357,15.06641917567481],[-89.22522009963127,14.874286200413621],[-89.14553504103719,14.678019110569153],[-89.35332597528281,14.424132798719086],[-89.58734269891656,14.36258616785949],[-89.53421932652051,14.244815578666305],[-89.72193396682073,14.134228013561696],[-90.0646779039966,13.881969509328925],[-90.09555457229098,13.735337632700734],[-90.60862403030085,13.909771429901951],[-91.23241024449605,13.927832342987957],[-91.68974667027913,14.126218166556455],[-92.22775000686983,14.538828640190928]]]}},{"type":"Feature","properties":{"featurecla":"Admin-0 country","scalerank":1,"labelrank":3,"sovereignt":"Cuba","sov_a3":"CU1","adm0_dif":1,"level":1,"type":"Sovereignty","tlc":"1","admin":"Cuba","adm0_a3":"CUB","geou_dif":0,"geounit":"Cuba","gu_a3":"CUB","su_dif":0,"subunit":"Cuba","su_a3":"CUB","brk_diff":0,"name":"Cuba","name_long":"Cuba","brk_a3":"CUB","brk_name":"Cuba","brk_group":null,"abbrev":"Cuba","postal":"CU","formal_en":"Republic of Cuba","formal_fr":null,"name_ciawf":"Cuba","note_adm0":null,"note_brk":null,"name_sort":"Cuba","name_alt":null,"mapcolor7":3,"mapcolor8":5,"mapcolor9":3,"mapcolor13":4,"pop_est":11333483,"pop_rank":14,"pop_year":2019,"gdp_md":100023,"gdp_year":2018,"economy":"5. Emerging region: G20","income_grp":"3. Upper middle income","fips_10":"CU","iso_a2":"CU","iso_a2_eh":"CU","iso_a3":"CUB","iso_a3_eh":"CUB","iso_n3":"192","iso_n3_eh":"192","un_a3":"192","wb_a2":"CU","wb_a3":"CUB","woe_id":23424793,"woe_id_eh":23424793,"woe_note":"Exact WOE match as country","adm0_iso":"CUB","adm0_diff":null,"adm0_tlc":"CUB","adm0_a3_us":"CUB","adm0_a3_fr":"CUB","adm0_a3_ru":"CUB","adm0_a3_es":"CUB","adm0_a3_cn":"CUB","adm0_a3_tw":"CUB","adm0_a3_in":"CUB","adm0_a3_np":"CUB","adm0_a3_pk":"CUB","adm0_a3_de":"CUB","adm0_a3_gb":"CUB","adm0_a3_br":"CUB","adm0_a3_il":"CUB","adm0_a3_ps":"CUB","adm0_a3_sa":"CUB","adm0_a3_eg":"CUB","adm0_a3_ma":"CUB","adm0_a3_pt":"CUB","adm0_a3_ar":"CUB","adm0_a3_jp":"CUB","adm0_a3_ko":"CUB","adm0_a3_vn":"CUB","adm0_a3_tr":"CUB","adm0_a3_id":"CUB","adm0_a3_pl":"CUB","adm0_a3_gr":"CUB","adm0_a3_it":"CUB","adm0_a3_nl":"CUB","adm0_a3_se":"CUB","adm0_a3_bd":"CUB","adm0_a3_ua":"CUB","adm0_a3_un":-99,"adm0_a3_wb":-99,"continent":"North America","region_un":"Americas","subregion":"Caribbean","region_wb":"Latin America & Caribbean","name_len":4,"long_len":4,"abbrev_len":4,"tiny":-99,"homepart":1,"min_zoom":0,"min_label":2.7,"max_label":8,"label_x":-77.975855,"label_y":21.334024,"ne_id":1159320527,"wikidataid":"Q241","name_ar":"كوبا","name_bn":"কিউবা","name_de":"Kuba","name_en":"Cuba","name_es":"Cuba","name_fa":"کوبا","name_fr":"Cuba","name_el":"Κούβα","name_he":"קובה","name_hi":"क्यूबा","name_hu":"Kuba","name_id":"Kuba","name_it":"Cuba","name_ja":"キューバ","name_ko":"쿠바","name_nl":"Cuba","name_pl":"Kuba","name_pt":"Cuba","name_ru":"Куба","name_sv":"Kuba","name_tr":"Küba","name_uk":"Куба","name_ur":"کیوبا","name_vi":"Cuba","name_zh":"古巴","name_zht":"古巴","fclass_iso":"Admin-0 country","tlc_diff":null,"fclass_tlc":"Admin-0 country","fclass_us":null,"fclass_fr":null,"fclass_ru":null,"fclass_es":null,"fclass_cn":null,"fclass_tw":null,"fclass_in":null,"fclass_np":null,"fclass_pk":null,"fclass_de":null,"fclass_gb":null,"fclass_br":null,"fclass_il":null,"fclass_ps":null,"fclass_sa":null,"fclass_eg":null,"fclass_ma":null,"fclass_pt":null,"fclass_ar":null,"fclass_jp":null,"fclass_ko":null,"fclass_vn":null,"fclass_tr":null,"fclass_id":null,"fclass_pl":null,"fclass_gr":null,"fclass_it":null,"fclass_nl":null,"fclass_se":null,"fclass_bd":null,"fclass_ua":null,"filename":"CUB.geojson"},"geometry":{"type":"Polygon","coordinates":[[[-82.26815121125706,23.188610744717707],[-81.40445716014683,23.117271429938782],[-80.6187686835812,23.105980129483],[-79.67952368846025,22.76530324959883],[-79.28148596873208,22.399201565027056],[-78.34743445505649,22.512166246017088],[-77.99329586456028,22.277193508385935],[-77.14642249216105,21.657851467367834],[-76.52382483590856,21.206819566324373],[-76.19462012399319,21.220565497314013],[-75.59822241891267,21.016624457274133],[-75.67106035022806,20.735091254148003],[-74.9338960435845,20.693905137611385],[-74.17802486845126,20.28462779385974],[-74.29664811877726,20.05037852628068],[-74.96159461129294,19.92343537035569],[-75.63468014189459,19.873774318923196],[-76.323656175426,19.95289093676206],[-77.75548092315307,19.855480861891877],[-77.08510840524674,20.413353786698792],[-77.49265458851661,20.673105373613893],[-78.13729224314159,20.739948838783434],[-78.48282670766119,21.02861338956585],[-78.71986650258401,21.598113511638434],[-79.28499996612794,21.5591753199065],[-80.21747534861865,21.827324327069036],[-80.51753455272141,22.03707896574176],[-81.82094336620318,22.19205658618507],[-82.16999182811864,22.387109279870753],[-81.79500179719267,22.636964830001958],[-82.77589799674085,22.688150336187064],[-83.49445878775936,22.16851797127613],[-83.90880042187563,22.154565334557333],[-84.05215084505326,21.910575059491254],[-84.54703019889638,21.801227728761646],[-84.97491105827311,21.89602814380109],[-84.44706214062776,22.204949856041907],[-84.23035702181178,22.565754706303764],[-83.7782399156902,22.788118394455694],[-83.26754757356575,22.983041897060644],[-82.51043616405751,23.078746649665188],[-82.26815121125706,23.188610744717707]]]}},{"type":"Feature","properties":{"featurecla":"Admin-0 country","scalerank":1,"labelrank":5,"sovereignt":"Honduras","sov_a3":"HND","adm0_dif":0,"level":2,"type":"Sovereign country","tlc":"1","admin":"Honduras","adm0_a3":"HND","geou_dif":0,"geounit":"Honduras","gu_a3":"HND","su_dif":0,"subunit":"Honduras","su_a3":"HND","brk_diff":0,"name":"Honduras","name_long":"Honduras","brk_a3":"HND","brk_name":"Honduras","brk_group":null,"abbrev":"Hond.","postal":"HN","formal_en":"Republic of Honduras","formal_fr":null,"name_ciawf":"Honduras","note_adm0":null,"note_brk":null,"name_sort":"Honduras","name_alt":null,"mapcolor7":2,"mapcolor8":5,"mapcolor9":2,"mapcolor13":5,"pop_est":9746117,"pop_rank":13,"pop_year":2019,"gdp_md":25095,"gdp_year":2019,"economy":"6. Developing region","income_grp":"4. Lower middle income","fips_10":"HO","iso_a2":"HN","iso_a2_eh":"HN","iso_a3":"HND","iso_a3_eh":"HND","iso_n3":"340","iso_n3_eh":"340","un_a3":"340","wb_a2":"HN","wb_a3":"HND","woe_id":23424841,"woe_id_eh":23424841,"woe_note":"Exact WOE match as country","adm0_iso":"HND","adm0_diff":null,"adm0_tlc":"HND","adm0_a3_us":"HND","adm0_a3_fr":"HND","adm0_a3_ru":"HND","adm0_a3_es":"HND","adm0_a3_cn":"HND","adm0_a3_tw":"HND","adm0_a3_in":"HND","adm0_a3_np":"HND","adm0_a3_pk":"HND","adm0_a3_de":"HND","adm0_a3_gb":"HND","adm0_a3_br":"HND","adm0_a3_il":"HND","adm0_a3_ps":"HND","adm0_a3_sa":"HND","adm0_a3_eg":"HND","adm0_a3_ma":"HND","adm0_a3_pt":"HND","adm0_a3_ar":"HND","adm0_a3_jp":"HND","adm0_a3_ko":"HND","adm0_a3_vn":"HND","adm0_a3_tr":"HND","adm0_a3_id":"HND","adm0_a3_pl":"HND","adm0_a3_gr":"HND","adm0_a3_it":"HND","adm0_a3_nl":"HND","adm0_a3_se":"HND","adm0_a3_bd":"HND","adm0_a3_ua":"HND","adm0_a3_un":-99,"adm0_a3_wb":-99,"continent":"North America","region_un":"Americas","subregion":"Central America","region_wb":"Latin America & Caribbean","name_len":8,"long_len":8,"abbrev_len":5,"tiny":-99,"homepart":1,"min_zoom":0,"min_label":4.5,"max_label":9.5,"label_x":-86.887604,"label_y":14.794801,"ne_id":1159320827,"wikidataid":"Q783","name_ar":"هندوراس","name_bn":"হন্ডুরাস","name_de":"Honduras","name_en":"Honduras","name_es":"Honduras","name_fa":"هندوراس","name_fr":"Honduras","name_el":"Ονδούρα","name_he":"הונדורס","name_hi":"हौण्डुरस","name_hu":"Honduras","name_id":"Honduras","name_it":"Honduras","name_ja":"ホンジュラス","name_ko":"온두라스","name_nl":"Honduras","name_pl":"Honduras","name_pt":"Honduras","name_ru":"Гондурас","name_sv":"Honduras","name_tr":"Honduras","name_uk":"Гондурас","name_ur":"ہونڈوراس","name_vi":"Honduras","name_zh":"洪都拉斯","name_zht":"宏都拉斯","fclass_iso":"Admin-0 country","tlc_diff":null,"fclass_tlc":"Admin-0 country","fclass_us":null,"fclass_fr":null,"fclass_ru":null,"fclass_es":null,"fclass_cn":null,"fclass_tw":null,"fclass_in":null,"fclass_np":null,"fclass_pk":null,"fclass_de":null,"fclass_gb":null,"fclass_br":null,"fclass_il":null,"fclass_ps":null,"fclass_sa":null,"fclass_eg":null,"fclass_ma":null,"fclass_pt":null,"fclass_ar":null,"fclass_jp":null,"fclass_ko":null,"fclass_vn":null,"fclass_tr":null,"fclass_id":null,"fclass_pl":null,"fclass_gr":null,"fclass_it":null,"fclass_nl":null,"fclass_se":null,"fclass_bd":null,"fclass_ua":null,"filename":"HND.geojson"},"geometry":{"type":"Polygon","coordinates":[[[-83.14721900097413,14.99582916916411],[-83.48998877636612,15.016267198135537],[-83.62858496777292,14.880073960830302],[-83.97572140169359,14.749435939996488],[-84.22834164095241,14.74876414637663],[-84.4493359036486,14.621614284722511],[-84.64958207877964,14.666805324761867],[-84.82003679069436,14.81958669683267],[-84.9245006985724,14.79049286545235],[-85.05278744173694,14.551541042534723],[-85.14875057650296,14.560196844943617],[-85.16536454948482,14.354369615125051],[-85.51441301140028,14.079011745657908],[-85.69866533073696,13.960078436738002],[-85.80129472526859,13.83605499923759],[-86.09626380079061,14.038187364147234],[-86.31214209668993,13.77135610600817],[-86.52070817741992,13.778487453664468],[-86.75508663607971,13.754845485890913],[-86.7338217841916,13.263092556201443],[-86.88055701368438,13.254204209847217],[-87.00576900912758,13.025794379117158],[-87.31665442579549,12.984685777228975],[-87.48940873894713,13.297534898323931],[-87.79311113152657,13.384480495655055],[-87.7235029772294,13.785050360565506],[-87.85951534702161,13.893312486217098],[-88.06534257684014,13.964625962779778],[-88.50399797234971,13.845485948130857],[-88.541230841816,13.980154730683479],[-88.84307288283284,14.14050670008517],[-89.05851192905766,14.340029405164087],[-89.35332597528281,14.424132798719086],[-89.14553504103719,14.678019110569153],[-89.22522009963127,14.874286200413621],[-89.15481096063357,15.06641917567481],[-88.68067969435563,15.346247056535304],[-88.22502275262202,15.727722479713904],[-88.12115312371537,15.688655096901357],[-87.90181250685242,15.864458319558196],[-87.61568010125234,15.878798529519202],[-87.52292090528846,15.797278957578783],[-87.36776241733213,15.846940009011291],[-86.90319129102818,15.75671295822957],[-86.44094560417739,15.782835394753192],[-86.11923397494434,15.893448798073962],[-86.00195431185784,16.00540578863439],[-85.68331743034628,15.953651841693954],[-85.44400387240256,15.885749009662447],[-85.18244361035721,15.909158433490632],[-84.98372188997882,15.995923163308703],[-84.52697974316715,15.857223619037427],[-84.36825558138258,15.835157782448732],[-84.06305457226682,15.648244126849136],[-83.77397661002612,15.424071763566872],[-83.41038123242038,15.270902818253774],[-83.14721900097413,14.99582916916411]]]}},{"type":"Feature","properties":{"featurecla":"Admin-0 country","scalerank":1,"labelrank":2,"sovereignt":"United States of America","sov_a3":"US1","adm0_dif":1,"level":2,"type":"Country","tlc":"1","admin":"United States of America","adm0_a3":"USA","geou_dif":0,"geounit":"United States of America","gu_a3":"USA","su_dif":0,"subunit":"United States","su_a3":"USA","brk_diff":0,"name":"United States of America","name_long":"United States","brk_a3":"USA","brk_name":"United States","brk_group":null,"abbrev":"U.S.A.","postal":"US","formal_en":"United States of America","formal_fr":null,"name_ciawf":"United States","note_adm0":null,"note_brk":null,"name_sort":"United States of America","name_alt":null,"mapcolor7":4,"mapcolor8":5,"mapcolor9":1,"mapcolor13":1,"pop_est":328239523,"pop_rank":17,"pop_year":2019,"gdp_md":21433226,"gdp_year":2019,"economy":"1. Developed region: G7","income_grp":"1. High income: OECD","fips_10":"US","iso_a2":"US","iso_a2_eh":"US","iso_a3":"USA","iso_a3_eh":"USA","iso_n3":"840","iso_n3_eh":"840","un_a3":"840","wb_a2":"US","wb_a3":"USA","woe_id":23424977,"woe_id_eh":23424977,"woe_note":"Exact WOE match as country","adm0_iso":"USA","adm0_diff":null,"adm0_tlc":"USA","adm0_a3_us":"USA","adm0_a3_fr":"USA","adm0_a3_ru":"USA","adm0_a3_es":"USA","adm0_a3_cn":"USA","adm0_a3_tw":"USA","adm0_a3_in":"USA","adm0_a3_np":"USA","adm0_a3_pk":"USA","adm0_a3_de":"USA","adm0_a3_gb":"USA","adm0_a3_br":"USA","adm0_a3_il":"USA","adm0_a3_ps":"USA","adm0_a3_sa":"USA","adm0_a3_eg":"USA","adm0_a3_ma":"USA","adm0_a3_pt":"USA","adm0_a3_ar":"USA","adm0_a3_jp":"USA","adm0_a3_ko":"USA","adm0_a3_vn":"USA","adm0_a3_tr":"USA","adm0_a3_id":"USA","adm0_a3_pl":"USA","adm0_a3_gr":"USA","adm0_a3_it":"USA","adm0_a3_nl":"USA","adm0_a3_se":"USA","adm0_a3_bd":"USA","adm0_a3_ua":"USA","adm0_a3_un":-99,"adm0_a3_wb":-99,"continent":"North America","region_un":"Americas","subregion":"Northern America","region_wb":"North America","name_len":24,"long_len":13,"abbrev_len":6,"tiny":-99,"homepart":1,"min_zoom":0,"min_label":1.7,"max_label":5.7,"label_x":-97.482602,"label_y":39.538479,"ne_id":1159321369,"wikidataid":"Q30","name_ar":"الولايات المتحدة","name_bn":"মার্কিন যুক্তরাষ্ট্র","name_de":"Vereinigte Staaten","name_en":"United States of America","name_es":"Estados Unidos","name_fa":"ایالات متحده آمریکا","name_fr":"États-Unis","name_el":"Ηνωμένες Πολιτείες Αμερικής","name_he":"ארצות הברית","name_hi":"संयुक्त राज्य अमेरिका","name_hu":"Amerikai Egyesült Államok","name_id":"Amerika Serikat","name_it":"Stati Uniti d'America","name_ja":"アメリカ合衆国","name_ko":"미국","name_nl":"Verenigde Staten van Amerika","name_pl":"Stany Zjednoczone","name_pt":"Estados Unidos","name_ru":"США","name_sv":"USA","name_tr":"Amerika Birleşik Devletleri","name_uk":"Сполучені Штати Америки","name_ur":"ریاستہائے متحدہ امریکا","name_vi":"Hoa Kỳ","name_zh":"美国","name_zht":"美國","fclass_iso":"Admin-0 country","tlc_diff":null,"fclass_tlc":"Admin-0 country","fclass_us":null,"fclass_fr":null,"fclass_ru":null,"fclass_es":null,"fclass_cn":null,"fclass_tw":null,"fclass_in":null,"fclass_np":null,"fclass_pk":null,"fclass_de":null,"fclass_gb":null,"fclass_br":null,"fclass_il":null,"fclass_ps":null,"fclass_sa":null,"fclass_eg":null,"fclass_ma":null,"fclass_pt":null,"fclass_ar":null,"fclass_jp":null,"fclass_ko":null,"fclass_vn":null,"fclass_tr":null,"fclass_id":null,"fclass_pl":null,"fclass_gr":null,"fclass_it":null,"fclass_nl":null,"fclass_se":null,"fclass_bd":null,"fclass_ua":null,"filename":"USA.geojson"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-122.84,49.000000000000114],[-120,49.000000000000114],[-117.03121,49],[-116.04818,49],[-113,49],[-110.05,49],[-107.05,49],[-104.04826,48.99986],[-100.65,49.000000000000114],[-97.2287200000048,49.0007],[-95.15906950917206,49],[-95.15609,49.38425],[-94.81758,49.38905],[-94.64,48.84],[-94.32914,48.67074],[-93.63087,48.60926],[-92.61,48.45],[-91.64,48.14],[-90.83,48.27],[-89.6,48.010000000000105],[-89.27291744663665,48.01980825458281],[-88.37811418328671,48.302917588893706],[-87.43979262330028,47.94],[-86.46199083122826,47.553338019392],[-85.65236324740341,47.22021881773051],[-84.8760798815149,46.90008331968238],[-84.77923824739992,46.63710195574902],[-84.54374874544584,46.538684190449146],[-84.6049,46.4396],[-84.3367,46.40877],[-84.1421195136734,46.51222585711571],[-84.0918512641615,46.27541860613826],[-83.89076534700574,46.116926988299014],[-83.61613094759059,46.116926988299014],[-83.46955074739469,45.994686387712534],[-83.59285071484311,45.81689362241252],[-82.55092464875821,45.34751658790543],[-82.33776312543114,44.44],[-82.13764238150395,43.57108755143997],[-82.43,42.98],[-82.9,42.43],[-83.12,42.08],[-83.14199968131264,41.975681057292874],[-83.029810146807,41.83279572200598],[-82.69008928092023,41.675105088867326],[-82.4392777167916,41.675105088867326],[-81.27774654816716,42.209025987306816],[-80.24744767934794,42.36619985612255],[-78.93936214874375,42.86361135514798],[-78.92,42.965],[-79.01,43.27],[-79.17167355011186,43.46633942318426],[-78.72027991404235,43.62508942318493],[-77.7378850979577,43.62905558936328],[-76.82003414580558,43.628784288093755],[-76.5,44.01845889375865],[-76.375,44.09631],[-75.31821,44.81645],[-74.867,45.00048],[-73.34783,45.00738],[-71.50506,45.0082],[-71.405,45.255],[-71.08482,45.30524000000014],[-70.66,45.46],[-70.305,45.915],[-69.99997,46.69307],[-69.237216,47.447781],[-68.905,47.185],[-68.23444,47.35486],[-67.79046,47.06636],[-67.79134,45.70281],[-67.13741,45.13753],[-66.96466,44.809700000000134],[-68.03252,44.3252],[-69.06,43.98],[-70.11617,43.68405000000013],[-70.64547563341102,43.09023834896402],[-70.81489,42.8653],[-70.825,42.335],[-70.495,41.805],[-70.08,41.78],[-70.185,42.145],[-69.88497,41.92283],[-69.96503,41.63717000000014],[-70.64,41.475],[-71.12039,41.49445000000014],[-71.86,41.32],[-72.295,41.27],[-72.87643,41.22065],[-73.71,40.93110235165449],[-72.24126,41.119480000000124],[-71.94499999999988,40.93],[-73.345,40.63],[-73.982,40.628],[-73.952325,40.75075],[-74.25671,40.47351],[-73.96244,40.42763],[-74.17838,39.70926],[-74.90604,38.93954],[-74.98041,39.1964],[-75.20002,39.248450000000105],[-75.52805,39.4985],[-75.32,38.96],[-75.07183476478986,38.782032230179254],[-75.05673,38.40412],[-75.37747,38.01551],[-75.94023,37.21689],[-76.03127,37.2566],[-75.72204999999985,37.93705000000011],[-76.23287,38.319215],[-76.35,39.15],[-76.542725,38.717615],[-76.32933,38.08326],[-76.98999793161352,38.23999176691336],[-76.30162,37.917945],[-76.25874,36.9664],[-75.9718,36.89726],[-75.8680399999999,36.55125],[-75.72749,35.55074000000013],[-76.36318,34.80854000000011],[-77.397635,34.51201],[-78.05496,33.92547],[-78.55434999999989,33.86133000000012],[-79.06067,33.49395],[-79.20357,33.15839],[-80.301325,32.509355],[-80.86498,32.0333],[-81.33629,31.44049],[-81.49042,30.7299900000001],[-81.31371,30.03552],[-80.98,29.18000000000012],[-80.535585,28.47213],[-80.52999999999986,28.04],[-80.05653928497759,26.88000000000011],[-80.088015,26.205765],[-80.13156,25.816775],[-80.38103,25.20616],[-80.68,25.08],[-81.17213,25.201260000000104],[-81.33,25.64],[-81.70999999999987,25.87],[-82.24,26.730000000000132],[-82.70515,27.49504],[-82.85526,27.88624],[-82.65,28.550000000000125],[-82.93,29.1],[-83.70959,29.93656],[-84.1,30.09],[-85.10882,29.63615],[-85.28784,29.68612000000013],[-85.7731,30.15261],[-86.4,30.4],[-87.53036,30.27433],[-88.41782,30.3849],[-89.18049,30.31598],[-89.5938311784198,30.159994004836847],[-89.413735,29.89419],[-89.43,29.48864],[-89.21767,29.29108],[-89.40823,29.15961],[-89.77928,29.307140000000118],[-90.15463,29.11743],[-90.880225,29.148535],[-91.626785,29.677000000000135],[-92.49906,29.5523],[-93.22637,29.78375],[-93.84842,29.71363],[-94.69,29.480000000000132],[-95.60026,28.73863],[-96.59404,28.30748],[-97.13999999999987,27.83],[-97.37,27.38],[-97.38,26.69],[-97.33,26.21],[-97.13999999999987,25.87],[-97.53,25.84],[-98.24,26.06],[-99.02,26.37],[-99.3,26.84],[-99.52,27.54],[-100.11,28.110000000000127],[-100.45584,28.69612000000012],[-100.9576,29.380710000000136],[-101.6624,29.7793],[-102.48,29.76],[-103.11,28.97],[-103.94,29.27],[-104.4569699999999,29.57196],[-104.70575,30.12173],[-105.03737,30.64402],[-105.63159,31.08383],[-106.1429,31.39995],[-106.50758999999988,31.75452],[-108.24,31.754853718166373],[-108.24194,31.34222],[-109.035,31.341940000000136],[-111.02361,31.33472],[-113.30498,32.03914],[-114.815,32.52528],[-114.72139,32.72083],[-115.99135,32.61239000000012],[-117.12775999999985,32.53534],[-117.29593769127393,33.04622461520387],[-117.944,33.621236431201396],[-118.41060227589753,33.74090922312445],[-118.51989482279976,34.02778157757575],[-119.081,34.078],[-119.43884064201671,34.34847717828427],[-120.36778,34.44711],[-120.62286,34.60855],[-120.74433,35.15686000000011],[-121.71457,36.16153],[-122.54747,37.551760000000115],[-122.51201,37.78339000000011],[-122.95319,38.11371],[-123.7272,38.95166000000012],[-123.86517,39.76699],[-124.39807,40.3132],[-124.17886,41.142020000000116],[-124.2137,41.99964000000011],[-124.53284,42.7659900000001],[-124.14214,43.70838],[-124.020535,44.615895],[-123.89893,45.52341],[-124.079635,46.86475],[-124.39567,47.72017000000011],[-124.68721008300781,48.18443298339855],[-124.56610107421875,48.37971496582037],[-123.12,48.04],[-122.58736,47.096],[-122.34,47.36],[-122.5,48.18],[-122.84,49.000000000000114]]],[[[-155.40214,20.07975],[-155.22452,19.99302],[-155.06226,19.8591],[-154.80741,19.50871],[-154.83147,19.45328],[-155.22217,19.23972],[-155.54211,19.08348],[-155.68817,18.91619],[-155.93665,19.05939],[-155.90806,19.33888],[-156.07347,19.70294],[-156.02368,19.81422],[-155.85008,19.97729],[-155.91907,20.17395],[-155.86108,20.26721],[-155.78505,20.2487],[-155.40214,20.07975]]],[[[-155.99566,20.76404],[-156.07926,20.64397],[-156.41445,20.57241],[-156.58673,20.783],[-156.70167,20.8643],[-156.71055,20.92676],[-156.61258,21.01249],[-156.25711,20.91745],[-155.99566,20.76404]]],[[[-156.75824,21.17684],[-156.78933,21.06873],[-157.32521,21.09777],[-157.25027,21.21958],[-156.75824,21.17684]]],[[[-158.0252,21.71696],[-157.94161,21.65272],[-157.65283,21.32217],[-157.70703,21.26442],[-157.7786,21.27729],[-158.12667,21.31244],[-158.2538,21.53919],[-158.29265,21.57912],[-158.0252,21.71696]]],[[[-159.36569,22.21494],[-159.34512,21.982],[-159.46372,21.88299],[-159.80051,22.06533],[-159.74877,22.1382],[-159.5962,22.23618],[-159.36569,22.21494]]],[[[-166.46779212142462,60.384169826897754],[-165.67442969466364,60.29360687930625],[-165.57916419173358,59.90998688418753],[-166.19277014876727,59.75444082298899],[-166.84833736882197,59.941406155020985],[-167.45527706609008,60.21306915957936],[-166.46779212142462,60.384169826897754]]],[[[-153.22872941792113,57.96896841087248],[-152.56479061583514,57.901427313866996],[-152.1411472239064,57.591058661522],[-153.00631405333692,57.11584219016593],[-154.0050902984581,56.734676825581076],[-154.51640275777004,56.99274892844669],[-154.67099280497118,57.46119578717253],[-153.7627795074415,57.81657461204373],[-153.22872941792113,57.96896841087248]]],[[[-140.98598761037601,69.71199839952635],[-140.986,69.712],[-140.9925,66.00003],[-140.99778,60.30639],[-140.013,60.27682],[-139.039,60],[-138.34089,59.56211],[-137.4525,58.905],[-136.47972,59.46389],[-135.47583,59.78778],[-134.945,59.2705600000001],[-134.27111,58.86111],[-133.35556,58.41028],[-132.73042,57.69289],[-131.70781,56.55212],[-130.00778,55.91583],[-129.98,55.285],[-130.53611,54.80278],[-130.53610895273684,54.80275447679924],[-130.5361101894673,54.8027534043494],[-131.08581823797215,55.17890615500204],[-131.9672114671423,55.497775580459006],[-132.2500107428595,56.3699962428974],[-133.53918108435641,57.17888743756214],[-134.07806292029608,58.12306753196691],[-135.0382110322791,58.18771474876394],[-136.62806230995471,58.21220937767043],[-137.800006279686,58.49999542910376],[-139.867787041413,59.53776154238915],[-140.825273817133,59.727517401765056],[-142.57444353556446,60.08444651960497],[-143.9588809948799,59.999180406323376],[-145.92555681682788,60.45860972761426],[-147.11437394914665,60.884656073644635],[-148.22430620012761,60.67298940697714],[-148.01806555885082,59.97832896589364],[-148.57082251686086,59.914172675203304],[-149.72785783587585,59.70565827090553],[-150.60824337461642,59.368211168039466],[-151.7163927886833,59.15582103131993],[-151.85943315326722,59.744984035879554],[-151.40971900124717,60.72580272077937],[-150.3469414947325,61.03358755150987],[-150.62111080625704,61.2844249538544],[-151.89583919981683,60.727197984451266],[-152.57832984109558,60.061657212964235],[-154.01917212625764,59.35027944603428],[-153.28751135965317,58.86472768821977],[-154.23249243875847,58.14637360293051],[-155.3074914215102,57.727794501366304],[-156.30833472392305,57.422774359763594],[-156.55609737854638,56.97998484967064],[-158.11721655986779,56.46360809999419],[-158.43332129619714,55.99415355083852],[-159.60332739971741,55.56668610292013],[-160.28971961163427,55.643580634170576],[-161.22304765525777,55.364734605523495],[-162.23776607974105,55.02418691672011],[-163.06944658104638,54.68973704692712],[-164.78556922102717,54.40417308208214],[-164.94222632552007,54.57222483989534],[-163.84833960676565,55.03943146424609],[-162.87000139061595,55.34804311789321],[-161.80417497459607,55.89498647727038],[-160.5636047027812,56.00805451112501],[-160.07055986228448,56.41805532492873],[-158.6844429189195,57.01667511659787],[-158.46109737855403,57.21692129172885],[-157.72277035218391,57.57000051536306],[-157.55027442119362,58.328326321030204],[-157.04167497457698,58.91888458926172],[-158.19473120830554,58.61580231386978],[-158.51721798402303,58.78778148053732],[-159.0586061269288,58.42418610293163],[-159.71166704001737,58.93139028587632],[-159.98128882550017,58.572549140041644],[-160.3552711659965,59.07112335879361],[-161.3550034251151,58.670837714260756],[-161.96889360252632,58.67166453717738],[-162.05498653872465,59.26692536074745],[-161.8741707021354,59.63362132429057],[-162.51805904849212,59.98972361921386],[-163.8183414378202,59.79805573184336],[-164.66221757714652,60.26748444278263],[-165.3463877024748,60.50749563256238],[-165.3508318756519,61.073895168697504],[-166.12137915755602,61.50001902937623],[-165.73445187077058,62.074996853271784],[-164.9191786367179,62.63307648380794],[-164.56250790103934,63.14637848576302],[-163.75333248599708,63.21944896102377],[-163.06722449445786,63.05945872664802],[-162.26055538638175,63.54193573674115],[-161.53444983624863,63.455816962326764],[-160.7725066803211,63.766108100023246],[-160.9583351308426,64.22279857040274],[-161.51806840721218,64.40278758407527],[-160.77777767641481,64.78860382756642],[-161.39192623598765,64.77723501246231],[-162.4530500966689,64.55944468856819],[-162.75778601789415,64.33860545516876],[-163.54639421288428,64.5591604681905],[-164.96082984114514,64.44694509546883],[-166.42528825586447,64.68667206487066],[-166.8450042389391,65.08889557561452],[-168.11056006576715,65.66999705673675],[-166.70527116602193,66.08831777613938],[-164.47470964257548,66.5766600612975],[-163.65251176659564,66.5766600612975],[-163.78860165103623,66.07720734319668],[-161.67777442121013,66.11611969671242],[-162.48971452538004,66.73556509059512],[-163.71971696679117,67.11639455837008],[-164.4309913808565,67.61633820257777],[-165.39028683170673,68.04277212185025],[-166.76444068099605,68.35887685817966],[-166.20470740462667,68.88303091091615],[-164.43081051334346,68.91553538682774],[-163.1686136546145,69.37111481391287],[-162.930566169262,69.85806183539927],[-161.90889726463556,70.33332998318764],[-160.93479651593367,70.44768992784958],[-159.03917578838713,70.89164215766891],[-158.11972286683394,70.82472117785102],[-156.58082455139808,71.35776357694175],[-155.06779029032427,71.14777639432367],[-154.3441652089412,70.69640859647018],[-153.9000062733926,70.88998851183567],[-152.21000606993528,70.82999217394485],[-152.27000240782613,70.60000621202983],[-150.73999243874448,70.43001658800569],[-149.7200030181675,70.53001048449045],[-147.61336157935705,70.2140349392418],[-145.68998980022533,70.12000967068673],[-144.9200109590764,69.98999176704046],[-143.58944618042523,70.15251414659832],[-142.07251034871348,69.85193817817265],[-140.98598752156073,69.71199839952635],[-140.98598761037601,69.71199839952635]]],[[[-171.73165686753944,63.782515367275934],[-171.1144335602453,63.59219106714495],[-170.4911124339407,63.694975490973505],[-169.6825054596536,63.43111562769119],[-168.6894394603007,63.297506212000556],[-168.77194088445466,63.18859813094544],[-169.5294398672051,62.97693146427792],[-170.29055620021595,63.194437567794424],[-170.67138566799093,63.3758218451389],[-171.55306311753873,63.317789211675105],[-171.79111060289122,63.40584585230046],[-171.73165686753944,63.782515367275934]]]]}},{"type":"Feature","properties":{"featurecla":"Admin-0 country","scalerank":1,"labelrank":2,"sovereignt":"Canada","sov_a3":"CAN","adm0_dif":0,"level":2,"type":"Sovereign country","tlc":"1","admin":"Canada","adm0_a3":"CAN","geou_dif":0,"geounit":"Canada","gu_a3":"CAN","su_dif":0,"subunit":"Canada","su_a3":"CAN","brk_diff":0,"name":"Canada","name_long":"Canada","brk_a3":"CAN","brk_name":"Canada","brk_group":null,"abbrev":"Can.","postal":"CA","formal_en":"Canada","formal_fr":null,"name_ciawf":"Canada","note_adm0":null,"note_brk":null,"name_sort":"Canada","name_alt":null,"mapcolor7":6,"mapcolor8":6,"mapcolor9":2,"mapcolor13":2,"pop_est":37589262,"pop_rank":15,"pop_year":2019,"gdp_md":1736425,"gdp_year":2019,"economy":"1. Developed region: G7","income_grp":"1. High income: OECD","fips_10":"CA","iso_a2":"CA","iso_a2_eh":"CA","iso_a3":"CAN","iso_a3_eh":"CAN","iso_n3":"124","iso_n3_eh":"124","un_a3":"124","wb_a2":"CA","wb_a3":"CAN","woe_id":23424775,"woe_id_eh":23424775,"woe_note":"Exact WOE match as country","adm0_iso":"CAN","adm0_diff":null,"adm0_tlc":"CAN","adm0_a3_us":"CAN","adm0_a3_fr":"CAN","adm0_a3_ru":"CAN","adm0_a3_es":"CAN","adm0_a3_cn":"CAN","adm0_a3_tw":"CAN","adm0_a3_in":"CAN","adm0_a3_np":"CAN","adm0_a3_pk":"CAN","adm0_a3_de":"CAN","adm0_a3_gb":"CAN","adm0_a3_br":"CAN","adm0_a3_il":"CAN","adm0_a3_ps":"CAN","adm0_a3_sa":"CAN","adm0_a3_eg":"CAN","adm0_a3_ma":"CAN","adm0_a3_pt":"CAN","adm0_a3_ar":"CAN","adm0_a3_jp":"CAN","adm0_a3_ko":"CAN","adm0_a3_vn":"CAN","adm0_a3_tr":"CAN","adm0_a3_id":"CAN","adm0_a3_pl":"CAN","adm0_a3_gr":"CAN","adm0_a3_it":"CAN","adm0_a3_nl":"CAN","adm0_a3_se":"CAN","adm0_a3_bd":"CAN","adm0_a3_ua":"CAN","adm0_a3_un":-99,"adm0_a3_wb":-99,"continent":"North America","region_un":"Americas","subregion":"Northern America","region_wb":"North America","name_len":6,"long_len":6,"abbrev_len":4,"tiny":-99,"homepart":1,"min_zoom":0,"min_label":1.7,"max_label":5.7,"label_x":-101.9107,"label_y":60.324287,"ne_id":1159320467,"wikidataid":"Q16","name_ar":"كندا","name_bn":"কানাডা","name_de":"Kanada","name_en":"Canada","name_es":"Canadá","name_fa":"کانادا","name_fr":"Canada","name_el":"Καναδάς","name_he":"קנדה","name_hi":"कनाडा","name_hu":"Kanada","name_id":"Kanada","name_it":"Canada","name_ja":"カナダ","name_ko":"캐나다","name_nl":"Canada","name_pl":"Kanada","name_pt":"Canadá","name_ru":"Канада","name_sv":"Kanada","name_tr":"Kanada","name_uk":"Канада","name_ur":"کینیڈا","name_vi":"Canada","name_zh":"加拿大","name_zht":"加拿大","fclass_iso":"Admin-0 country","tlc_diff":null,"fclass_tlc":"Admin-0 country","fclass_us":null,"fclass_fr":null,"fclass_ru":null,"fclass_es":null,"fclass_cn":null,"fclass_tw":null,"fclass_in":null,"fclass_np":null,"fclass_pk":null,"fclass_de":null,"fclass_gb":null,"fclass_br":null,"fclass_il":null,"fclass_ps":null,"fclass_sa":null,"fclass_eg":null,"fclass_ma":null,"fclass_pt":null,"fclass_ar":null,"fclass_jp":null,"fclass_ko":null,"fclass_vn":null,"fclass_tr":null,"fclass_id":null,"fclass_pl":null,"fclass_gr":null,"fclass_it":null,"fclass_nl":null,"fclass_se":null,"fclass_bd":null,"fclass_ua":null,"filename":"CAN.geojson"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-122.84,49.000000000000114],[-122.97421,49.00253777777778],[-124.91024,49.98456],[-125.62461,50.41656],[-127.43561,50.83061],[-127.99276,51.71583],[-127.85032,52.32961],[-129.12979,52.75538],[-129.30523,53.56159],[-130.51497,54.28757],[-130.53610895273684,54.80275447679924],[-130.53611,54.80278],[-129.98,55.285],[-130.00778,55.91583],[-131.70781,56.55212],[-132.73042,57.69289],[-133.35556,58.41028],[-134.27111,58.86111],[-134.945,59.2705600000001],[-135.47583,59.78778],[-136.47972,59.46389],[-137.4525,58.905],[-138.34089,59.56211],[-139.039,60],[-140.013,60.27682],[-140.99778,60.30639],[-140.9925,66.00003],[-140.986,69.712],[-140.98598761037601,69.71199839952635],[-139.12052,69.47102],[-137.54636,68.99002],[-136.50358,68.89804],[-135.62576,69.31512],[-134.41464,69.62743],[-132.92925,69.50534],[-131.43136,69.94451],[-129.79471,70.19369],[-129.10773,69.77927],[-128.36156,70.01286],[-128.13817,70.48384],[-127.44712,70.37721],[-125.75632,69.48058],[-124.42483,70.1584],[-124.28968,69.39969],[-123.06108,69.56372],[-122.6835,69.85553],[-121.47226,69.79778],[-119.94288,69.37786],[-117.60268,69.01128],[-116.22643,68.84151],[-115.2469,68.90591],[-113.89794,68.3989],[-115.30489,67.90261],[-113.49727,67.68815],[-110.798,67.80612],[-109.94619,67.98104],[-108.8802,67.38144],[-107.79239,67.88736],[-108.81299,68.31164],[-108.16721,68.65392],[-106.95,68.7],[-106.15,68.8],[-105.34282,68.56122],[-104.33791,68.018],[-103.22115,68.09775],[-101.45433,67.64689],[-99.90195,67.80566],[-98.4432,67.78165],[-98.5586,68.40394],[-97.66948,68.57864],[-96.11991,68.23939],[-96.12588,67.29338],[-95.48943,68.0907],[-94.685,68.06383],[-94.23282,69.06903],[-95.30408,69.68571],[-96.47131,70.08976],[-96.39115,71.19482],[-95.2088,71.92053],[-93.88997,71.76015],[-92.87818,71.31869],[-91.51964,70.19129],[-92.40692,69.69997],[-90.5471,69.49766],[-90.55151,68.47499],[-89.21515,69.25873],[-88.01966,68.61508],[-88.31749,67.87338],[-87.35017,67.19872],[-86.30607,67.92146],[-85.57664,68.78456],[-85.52197,69.88211],[-84.10081,69.80539],[-82.62258,69.65826],[-81.28043,69.16202],[-81.2202,68.66567],[-81.96436,68.13253],[-81.25928,67.59716],[-81.38653,67.11078],[-83.34456,66.41154],[-84.73542,66.2573],[-85.76943,66.55833],[-86.0676,66.05625],[-87.03143,65.21297],[-87.32324,64.77563],[-88.48296,64.09897],[-89.91444,64.03273],[-90.70398,63.61017],[-90.77004,62.96021],[-91.93342,62.83508],[-93.15698,62.02469],[-94.24153,60.89865],[-94.62931,60.11021],[-94.6846,58.94882],[-93.21502,58.78212],[-92.76462,57.84571],[-92.29703,57.08709],[-90.89769,57.28468],[-89.03953,56.85172],[-88.03978,56.47162],[-87.32421,55.99914],[-86.07121,55.72383],[-85.01181,55.3026],[-83.36055,55.24489],[-82.27285,55.14832],[-82.4362,54.28227],[-82.12502,53.27703],[-81.40075,52.15788],[-79.91289,51.20842],[-79.14301,51.53393],[-78.60191,52.56208],[-79.12421,54.14145],[-79.82958,54.66772],[-78.22874,55.13645],[-77.0956,55.83741],[-76.54137,56.53423],[-76.62319,57.20263],[-77.30226,58.05209],[-78.51688,58.80458],[-77.33676,59.85261],[-77.77272,60.75788],[-78.10687,62.31964],[-77.41067,62.55053],[-75.69621,62.2784],[-74.6682,62.18111],[-73.83988,62.4438],[-72.90853,62.10507],[-71.67708,61.52535],[-71.37369,61.13717],[-69.59042,61.06141],[-69.62033,60.22125],[-69.2879,58.95736],[-68.37455,58.80106],[-67.64976,58.21206],[-66.20178,58.76731],[-65.24517,59.87071],[-64.58352,60.33558],[-63.80475,59.4426],[-62.50236,58.16708],[-61.39655,56.96745],[-61.79866,56.33945],[-60.46853,55.77548],[-59.56962,55.20407],[-57.97508,54.94549],[-57.3332,54.6265],[-56.93689,53.78032],[-56.15811,53.64749],[-55.75632,53.27036],[-55.68338,52.14664],[-56.40916,51.7707],[-57.12691,51.41972],[-58.77482,51.0643],[-60.03309,50.24277],[-61.72366,50.08046],[-63.86251,50.29099],[-65.36331,50.2982],[-66.39905,50.22897],[-67.23631,49.51156],[-68.51114,49.06836],[-69.95362,47.74488],[-71.10458,46.82171],[-70.25522,46.98606],[-68.65,48.3],[-66.55243,49.1331],[-65.05626,49.23278],[-64.17099,48.74248],[-65.11545,48.07085],[-64.79854,46.99297],[-64.47219,46.23849],[-63.17329,45.73902],[-61.52072,45.88377],[-60.51815,47.00793],[-60.4486,46.28264],[-59.80287,45.9204],[-61.03988,45.26525],[-63.25471,44.67014],[-64.24656,44.26553],[-65.36406,43.54523],[-66.1234,43.61867],[-66.16173,44.46512],[-64.42549,45.29204],[-66.02605,45.25931],[-67.13741,45.13753],[-67.79134,45.70281],[-67.79046,47.06636],[-68.23444,47.35486],[-68.905,47.185],[-69.237216,47.447781],[-69.99997,46.69307],[-70.305,45.915],[-70.66,45.46],[-71.08482,45.30524000000014],[-71.405,45.255],[-71.50506,45.0082],[-73.34783,45.00738],[-74.867,45.00048],[-75.31821,44.81645],[-76.375,44.09631],[-76.5,44.01845889375865],[-76.82003414580558,43.628784288093755],[-77.7378850979577,43.62905558936328],[-78.72027991404235,43.62508942318493],[-79.17167355011186,43.46633942318426],[-79.01,43.27],[-78.92,42.965],[-78.93936214874375,42.86361135514798],[-80.24744767934794,42.36619985612255],[-81.27774654816716,42.209025987306816],[-82.4392777167916,41.675105088867326],[-82.69008928092023,41.675105088867326],[-83.029810146807,41.83279572200598],[-83.14199968131264,41.975681057292874],[-83.12,42.08],[-82.9,42.43],[-82.43,42.98],[-82.13764238150395,43.57108755143997],[-82.33776312543114,44.44],[-82.55092464875821,45.34751658790543],[-83.59285071484311,45.81689362241252],[-83.46955074739469,45.994686387712534],[-83.61613094759059,46.116926988299014],[-83.89076534700574,46.116926988299014],[-84.0918512641615,46.27541860613826],[-84.1421195136734,46.51222585711571],[-84.3367,46.40877],[-84.6049,46.4396],[-84.54374874544584,46.538684190449146],[-84.77923824739992,46.63710195574902],[-84.8760798815149,46.90008331968238],[-85.65236324740341,47.22021881773051],[-86.46199083122826,47.553338019392],[-87.43979262330028,47.94],[-88.37811418328671,48.302917588893706],[-89.27291744663665,48.01980825458281],[-89.6,48.010000000000105],[-90.83,48.27],[-91.64,48.14],[-92.61,48.45],[-93.63087,48.60926],[-94.32914,48.67074],[-94.64,48.84],[-94.81758,49.38905],[-95.15609,49.38425],[-95.15906950917206,49],[-97.2287200000048,49.0007],[-100.65,49.000000000000114],[-104.04826,48.99986],[-107.05,49],[-110.05,49],[-113,49],[-116.04818,49],[-117.03121,49],[-120,49.000000000000114],[-122.84,49.000000000000114]]],[[[-83.99367,62.4528],[-83.25048,62.91409],[-81.87699,62.90458],[-81.89825,62.7108],[-83.06857,62.15922],[-83.77462,62.18231],[-83.99367,62.4528]]],[[[-79.77583312988281,72.8029022216797],[-80.87609863281251,73.33318328857422],[-80.83388519287111,73.69318389892578],[-80.35305786132812,73.75971984863281],[-78.06443786621094,73.65193176269531],[-76.34,73.10268498995305],[-76.25140380859375,72.82638549804688],[-77.31443786621094,72.85554504394531],[-78.39167022705078,72.87665557861328],[-79.4862518310547,72.74220275878906],[-79.77583312988281,72.8029022216797]]],[[[-80.315395,62.085565],[-79.92939,62.3856],[-79.52002,62.36371],[-79.26582,62.158675],[-79.65752,61.63308],[-80.09956,61.7181],[-80.36215,62.01649],[-80.315395,62.085565]]],[[[-93.61275590694046,74.97999726022438],[-94.15690873897391,74.59234650338688],[-95.60868058956564,74.66686391875176],[-96.82093217648455,74.92762319609658],[-96.28858740922982,75.37782827422338],[-94.85081987178917,75.64721751576089],[-93.97774654821797,75.29648956979595],[-93.61275590694046,74.97999726022438]]],[[[-93.84000301794399,77.51999726023455],[-94.29560828324529,77.49134267852868],[-96.16965410031007,77.55511139597685],[-96.43630449093614,77.83462921824362],[-94.42257727738641,77.820004787905],[-93.7206562975659,77.63433136668031],[-93.84000301794399,77.51999726023455]]],[[[-96.75439876990876,78.76581268992702],[-95.5592779202946,78.41831452098033],[-95.83029496944934,78.05694122996324],[-97.30984290239799,77.85059723582181],[-98.12428931353404,78.08285696075761],[-98.55286780474668,78.45810537384507],[-98.63198442258553,78.87193024363837],[-97.33723141151266,78.83198436147676],[-96.75439876990876,78.76581268992702]]],[[[-88.15035030796028,74.39230703398503],[-89.7647220527584,74.51555532500116],[-92.42244096552946,74.83775788034099],[-92.76828548864282,75.38681997344214],[-92.88990597204175,75.88265534128267],[-93.89382402217599,76.31924367950056],[-95.9624574450358,76.4413809272224],[-97.1213789538295,76.7510777859476],[-96.74512285031237,77.16138865834507],[-94.68408586299944,77.09787832305837],[-93.57392106807313,76.77629588490605],[-91.6050231595366,76.7785179714946],[-90.7418458727493,76.44959747995681],[-90.96966142450802,76.07401317005947],[-89.82223792189926,75.84777374948565],[-89.18708289259985,75.61016551380762],[-87.83827633334965,75.56618886992725],[-86.37919226758864,75.4824213731821],[-84.78962521029058,75.69920400664653],[-82.75344458691006,75.78431509063124],[-81.12853084992436,75.71398346628199],[-80.05751095245915,75.33684886341591],[-79.83393286814837,74.92312734648716],[-80.45777075877587,74.65730377877777],[-81.94884253612557,74.44245901152432],[-83.22889360221143,74.56402781849094],[-86.09745235873332,74.41003205026117],[-88.15035030796028,74.39230703398503]]],[[[-111.26444332563088,78.15295604116154],[-109.85445187054711,77.99632477488488],[-110.18693803591302,77.69701487905034],[-112.0511911690585,77.4092288276169],[-113.53427893761912,77.73220652944111],[-112.7245867582539,78.05105011668196],[-111.26444332563088,78.15295604116154]]],[[[-110.96366065147602,78.8044408230652],[-109.6631457182026,78.60197256134565],[-110.88131425661892,78.40691986765997],[-112.54209143761516,78.4079017198735],[-112.52589087609164,78.55055451121522],[-111.5000103422334,78.8499935981305],[-110.96366065147602,78.8044408230652]]],[[[-55.600218268442056,51.31707469339794],[-56.13403581401709,50.68700979267928],[-56.795881720595276,49.81230866149089],[-56.14310502788433,50.15011749938286],[-55.471492275603,49.93581533466846],[-55.82240108908096,49.58712860777905],[-54.935142584845636,49.3130109726868],[-54.473775397343786,49.556691189159125],[-53.47654944519137,49.24913890237404],[-53.786013759971254,48.516780503933624],[-53.08613399922626,48.68780365660358],[-52.958648240762216,48.15716421161447],[-52.64809872090421,47.53554840757552],[-53.069158291218386,46.65549876564492],[-53.521456264853,46.61829173439477],[-54.17893551290251,46.80706574155698],[-53.9618686590605,47.62520701760193],[-54.24048214376214,47.752279364607645],[-55.40077307801157,46.884993801453135],[-55.99748084168583,46.919720363953275],[-55.29121904155279,47.38956248635099],[-56.250798712780586,47.632545070987376],[-57.32522925477708,47.57280711525797],[-59.26601518414682,47.60334788674247],[-59.419494188053676,47.899453843774886],[-58.79658647320744,48.25152537697942],[-59.23162451845657,48.52318838153781],[-58.3918049790652,49.12558055276418],[-57.35868974468606,50.71827403421587],[-56.738650071832026,51.28743825947855],[-55.87097693543532,51.63209422464921],[-55.40697424988659,51.5882726100657],[-55.600218268442056,51.31707469339794]]],[[[-83.88262630891977,65.10961782496354],[-82.78757687043883,64.76669302027467],[-81.6420137193926,64.45513580998697],[-81.55344031444432,63.97960928003714],[-80.81736121287886,64.057485663501],[-80.10345130076664,63.72598135034862],[-80.99101986359572,63.41124603947496],[-82.54717810741704,63.65172231714521],[-83.10879757356511,64.10187571883971],[-84.10041663281388,63.569711819098],[-85.52340471061905,63.052379055424055],[-85.8667687649824,63.63725291610349],[-87.22198320183678,63.54123810490519],[-86.35275977247133,64.0358332383707],[-86.2248864407651,64.82291697860823],[-85.88384782585486,65.7387783881171],[-85.1613079495499,65.6572846543928],[-84.97576371940592,65.21751821558898],[-84.4640120104195,65.37177236598022],[-83.88262630891977,65.10961782496354]]],[[[-78.77063859731078,72.35217316353418],[-77.8246239895596,72.74961660429098],[-75.60584469267573,72.2436784939374],[-74.228616095665,71.76714427355789],[-74.09914079455771,71.33084015571758],[-72.24222571479768,71.55692454699452],[-71.20001542833518,70.92001251899718],[-68.7860542466849,70.52502370877427],[-67.91497046575694,70.12194753689765],[-66.9690333726542,69.18608734809182],[-68.8051228502006,68.72019847276444],[-66.4498660956339,68.06716339789203],[-64.86231441919524,67.84753856065159],[-63.424934454996794,66.92847321234059],[-61.851981370680605,66.86212067327783],[-62.16317684594226,66.16025136988962],[-63.918444383384184,64.9986685248329],[-65.14886023625368,65.42603261988667],[-66.72121904159852,66.38804108343219],[-68.015016038674,66.26272573512439],[-68.1412874009792,65.68978913030439],[-67.08964616562342,65.10845510523696],[-65.73208045109976,64.64840566675856],[-65.32016760930125,64.38273712834605],[-64.66940629744968,63.392926744227495],[-65.01380388045888,62.67418508569598],[-66.27504472519048,62.94509878198612],[-68.7831862046927,63.74567007105183],[-67.36968075221309,62.88396556258484],[-66.32829728866726,62.28007477482201],[-66.16556820338015,61.93089712182582],[-68.87736650254465,62.330149237712824],[-71.02343705919385,62.91070811629588],[-72.23537858751902,63.39783600529522],[-71.88627844917127,63.67998932560887],[-73.37830624051838,64.19396312118384],[-74.83441891142263,64.6790756293238],[-74.81850257027673,64.38909332951793],[-77.70997982452008,64.22954234481678],[-78.5559488593542,64.57290639918013],[-77.89728105336198,65.30919220647475],[-76.01827429879717,65.32696889918314],[-73.95979529488268,65.45476471624094],[-74.29388342964964,65.81177134872938],[-73.94491248238262,66.31057811142666],[-72.65116716173942,67.28457550726391],[-72.92605994331605,67.72692576768235],[-73.31161780464572,68.06943716091287],[-74.84330725777684,68.55462718370127],[-76.86910091826672,68.89473562283025],[-76.22864905465738,69.14776927354741],[-77.28736996123715,69.76954010688321],[-78.1686339993266,69.82648753526887],[-78.95724219431673,70.16688019477543],[-79.49245500356366,69.87180776638884],[-81.30547095409176,69.74318512641436],[-84.94470618359851,69.96663401964442],[-87.06000342481789,70.26000112576538],[-88.68171322300148,70.4107412787608],[-89.51341956252303,70.76203766548095],[-88.46772111688082,71.21818553332132],[-89.88815121128755,71.22255219184997],[-90.20516028518205,72.23507436796079],[-89.436576707705,73.12946421985238],[-88.40824154331287,73.53788890247121],[-85.82615108920098,73.80381582304518],[-86.56217851433412,73.15744700793844],[-85.77437130404454,72.53412588163387],[-84.85011247428822,73.34027822538708],[-82.31559017610101,73.7509508328106],[-80.60008765330768,72.71654368762417],[-80.74894161652443,72.06190664335072],[-78.77063859731078,72.35217316353418]]],[[[-94.50365759965237,74.13490672473922],[-92.42001217321173,74.1000251329422],[-90.50979285354263,73.85673248971206],[-92.00396521682987,72.96624420845852],[-93.19629553910026,72.77199249947334],[-94.26904659704726,72.02459625923599],[-95.40985551632266,72.06188080513458],[-96.03374508338244,72.94027680123183],[-96.01826799191102,73.43742991809582],[-95.49579342322404,73.86241689726417],[-94.50365759965237,74.13490672473922]]],[[[-122.85492448615902,76.11654287383568],[-122.85492529360326,76.11654287383568],[-121.15753536032824,76.86450755482828],[-119.1039389718211,77.51221995717462],[-117.570130784966,77.4983189968881],[-116.19858659550738,77.6452867703262],[-116.33581336145845,76.87696157501061],[-117.10605058476882,76.53003184681911],[-118.04041215703819,76.48117178008714],[-119.89931758688572,76.053213406062],[-121.49999507712648,75.90001862253276],[-122.85492448615902,76.11654287383568]]],[[[-132.71000788443126,54.04000931542356],[-131.74998958400334,54.12000438090922],[-132.049480347351,52.98462148702447],[-131.1790425218266,52.180432847698285],[-131.57782954982298,52.18237071390928],[-132.18042842677852,52.639707139692405],[-132.54999243231384,53.100014960332146],[-133.05461117875552,53.411468817755406],[-133.2396644827927,53.851080227262344],[-133.1800040417117,54.169975490935315],[-132.71000788443126,54.04000931542356]]],[[[-105.4922891914932,79.30159393992916],[-103.52928239623795,79.16534902619163],[-100.8251580472688,78.80046173777872],[-100.0601918200522,78.32475434031589],[-99.67093909381364,77.90754466420744],[-101.30394019245301,78.01898489044486],[-102.94980872273302,78.34322866486023],[-105.17613277873151,78.3803323432458],[-104.21042945027713,78.67742015249176],[-105.41958045125853,78.91833567983649],[-105.4922891914932,79.30159393992916]]],[[[-123.51000158755119,48.51001089130341],[-124.01289078839955,48.37084625914139],[-125.65501277733838,48.8250045843385],[-125.95499446679275,49.17999583596759],[-126.85000443587185,49.53000031188043],[-127.02999344954443,49.81499583597008],[-128.0593363043662,49.9949590114266],[-128.44458410710214,50.539137681676095],[-128.35841365625546,50.77064809834371],[-127.30858109602994,50.552573554071955],[-126.69500097721235,50.400903225295394],[-125.7550066738232,50.29501821552935],[-125.4150015875588,49.95000051533259],[-124.92076818911934,49.475274970083376],[-123.92250870832106,49.06248362893581],[-123.51000158755119,48.51001089130341]]],[[[-121.53788,74.44893],[-120.10978,74.24135],[-117.55564,74.18577],[-116.58442,73.89607],[-115.51081,73.47519],[-116.76794,73.22292],[-119.22,72.52],[-120.46,71.82],[-120.46,71.38360179308756],[-123.09219,70.90164],[-123.62,71.34],[-125.92894873747338,71.86868846301138],[-125.5,72.29226081179502],[-124.80729,73.02256],[-123.94,73.68000000000012],[-124.91775,74.29275000000013],[-121.53788,74.44893]]],[[[-107.81943,75.84552],[-106.92893,76.01282],[-105.881,75.9694],[-105.70498,75.47951],[-106.31347,75.00527],[-109.7,74.85],[-112.22307,74.41696],[-113.74381,74.39427],[-113.87135,74.72029],[-111.79421,75.1625],[-116.31221,75.04343],[-117.7104,75.2222],[-116.34602,76.19903],[-115.40487,76.47887],[-112.59056,76.14134],[-110.81422,75.54919],[-109.0671,75.47321],[-110.49726,76.42982],[-109.5811,76.79417],[-108.54859,76.67832],[-108.21141,76.20168],[-107.81943,75.84552]]],[[[-106.52259,73.07601],[-105.40246,72.67259],[-104.77484,71.6984000000001],[-104.4647599999999,70.99297],[-102.78537,70.49776],[-100.98078,70.02432],[-101.08929,69.58447000000012],[-102.73116,69.50402],[-102.09329,69.11962000000011],[-102.43024,68.75282],[-104.24,68.91],[-105.96,69.18000000000012],[-107.12254,69.11922],[-109,68.78],[-111.53414887520017,68.63005915681794],[-113.3132,68.53554],[-113.85495999999989,69.00744],[-115.22,69.28],[-116.10794,69.16821],[-117.34,69.96],[-116.67473,70.06655],[-115.13112,70.2373],[-113.72141,70.19237],[-112.4161,70.36638],[-114.35,70.6],[-116.48684,70.52045],[-117.9048,70.54056000000014],[-118.43238,70.9092],[-116.11311,71.30918],[-117.65568,71.2952],[-119.40199,71.55859],[-118.56267,72.30785],[-117.86642,72.70594],[-115.18909,73.31459000000012],[-114.16717,73.12145],[-114.66634,72.65277],[-112.44102,72.95540000000011],[-111.05039,72.4504],[-109.92035,72.96113],[-109.00654,72.63335],[-108.18835,71.65089],[-107.68599,72.06548],[-108.39639,73.08953],[-107.51645,73.23598],[-106.52259,73.07601]]],[[[-100.43836,72.70588],[-101.54,73.36],[-100.35642,73.84389],[-99.16387,73.63339],[-97.38,73.76],[-97.12,73.47],[-98.05359,72.99052],[-96.54,72.56],[-96.72,71.66],[-98.35966,71.27285],[-99.32286,71.35639],[-100.01482,71.73827],[-102.5,72.51],[-102.48,72.83],[-100.43836,72.70588]]],[[[-106.6,73.6],[-105.26,73.64],[-104.5,73.42],[-105.38,72.76],[-106.94,73.46],[-106.6,73.6]]],[[[-98.5,76.72],[-97.735585,76.25656],[-97.704415,75.74344],[-98.16,75],[-99.80874,74.89744],[-100.88366,75.05736],[-100.86292,75.64075],[-102.50209,75.5638],[-102.56552,76.3366],[-101.48973,76.30537],[-99.98349,76.64634],[-98.57699,76.58859],[-98.5,76.72]]],[[[-96.01644,80.60233],[-95.32345,80.90729],[-94.29843,80.97727],[-94.73542,81.20646],[-92.40984,81.25739],[-91.13289,80.72345],[-89.45,80.50932203389831],[-87.81,80.32],[-87.02,79.66],[-85.81435,79.3369],[-87.18756,79.0393],[-89.03535,78.28723],[-90.80436,78.21533],[-92.87669,78.34333],[-93.95116,78.75099],[-93.93574,79.11373],[-93.14524,79.3801],[-94.974,79.37248],[-96.07614,79.70502],[-96.70972,80.15777],[-96.01644,80.60233]]],[[[-91.58702,81.89429],[-90.1,82.085],[-88.93227,82.11751],[-86.97024,82.27961],[-85.5,82.65227345805702],[-84.260005,82.6],[-83.18,82.32],[-82.42,82.86],[-81.1,83.02],[-79.30664,83.13056],[-76.25,83.17205882352941],[-75.71878,83.06404],[-72.83153,83.23324],[-70.665765,83.16978075838284],[-68.5,83.10632151676572],[-65.82735,83.02801],[-63.68,82.9],[-61.85,82.6286],[-61.89388,82.36165],[-64.334,81.92775],[-66.75342,81.72527],[-67.65755,81.50141],[-65.48031,81.50657],[-67.84,80.9],[-69.4697,80.61683],[-71.18,79.8],[-73.2428,79.63415],[-73.88,79.43016220480206],[-76.90773,79.32309],[-75.52924,79.19766],[-76.22046,79.01907],[-75.39345,78.52581],[-76.34354,78.18296],[-77.88851,77.89991],[-78.36269,77.50859],[-79.75951,77.20968],[-79.61965,76.98336],[-77.91089,77.022045],[-77.88911,76.777955],[-80.56125,76.17812],[-83.17439,76.45403],[-86.11184,76.29901],[-87.6,76.42],[-89.49068,76.47239],[-89.6161,76.95213],[-87.76739,77.17833],[-88.26,77.9],[-87.65,77.97022222222223],[-84.97634,77.53873],[-86.34,78.18],[-87.96192,78.37181],[-87.15198,78.75867],[-85.37868,78.9969],[-85.09495,79.34543],[-86.50734,79.73624],[-86.93179,80.25145],[-84.19844,80.20836],[-83.40869565217389,80.1],[-81.84823,80.46442],[-84.1,80.58],[-87.59895,80.51627],[-89.36663,80.85569],[-90.2,81.26],[-91.36786,81.5531],[-91.58702,81.89429]]],[[[-75.21597,67.44425],[-75.86588,67.14886],[-76.98687,67.09873],[-77.2364,67.58809],[-76.81166,68.14856],[-75.89521,68.28721],[-75.1145,68.01036],[-75.10333,67.58202],[-75.21597,67.44425]]],[[[-96.25740120380055,69.49003035832177],[-95.64768120380054,69.10769035832178],[-96.26952120380055,68.75704035832177],[-97.61740120380055,69.06003035832177],[-98.43180120380055,68.95070035832177],[-99.79740120380055,69.40003035832177],[-98.91740120380055,69.71003035832177],[-98.21826120380055,70.14354035832177],[-97.15740120380055,69.86003035832177],[-96.55740120380055,69.68003035832177],[-96.25740120380055,69.49003035832177]]],[[[-64.51912,49.87304],[-64.17322,49.95718],[-62.85829,49.70641],[-61.835585,49.28855],[-61.806305,49.10506],[-62.29318,49.08717],[-63.58926,49.40069],[-64.51912,49.87304]]],[[[-64.01486,47.03601],[-63.6645,46.55001],[-62.9393,46.41587],[-62.01208,46.44314],[-62.50391,46.03339],[-62.87433,45.96818],[-64.1428,46.39265],[-64.39261,46.72747],[-64.01486,47.03601]]]]}},{"type":"Feature","properties":{"featurecla":"Admin-0 country","scalerank":1,"labelrank":2,"sovereignt":"Mexico","sov_a3":"MEX","adm0_dif":0,"level":2,"type":"Sovereign country","tlc":"1","admin":"Mexico","adm0_a3":"MEX","geou_dif":0,"geounit":"Mexico","gu_a3":"MEX","su_dif":0,"subunit":"Mexico","su_a3":"MEX","brk_diff":0,"name":"Mexico","name_long":"Mexico","brk_a3":"MEX","brk_name":"Mexico","brk_group":null,"abbrev":"Mex.","postal":"MX","formal_en":"United Mexican States","formal_fr":null,"name_ciawf":"Mexico","note_adm0":null,"note_brk":null,"name_sort":"Mexico","name_alt":null,"mapcolor7":6,"mapcolor8":1,"mapcolor9":7,"mapcolor13":3,"pop_est":127575529,"pop_rank":17,"pop_year":2019,"gdp_md":1268870,"gdp_year":2019,"economy":"4. Emerging region: MIKT","income_grp":"3. Upper middle income","fips_10":"MX","iso_a2":"MX","iso_a2_eh":"MX","iso_a3":"MEX","iso_a3_eh":"MEX","iso_n3":"484","iso_n3_eh":"484","un_a3":"484","wb_a2":"MX","wb_a3":"MEX","woe_id":23424900,"woe_id_eh":23424900,"woe_note":"Exact WOE match as country","adm0_iso":"MEX","adm0_diff":null,"adm0_tlc":"MEX","adm0_a3_us":"MEX","adm0_a3_fr":"MEX","adm0_a3_ru":"MEX","adm0_a3_es":"MEX","adm0_a3_cn":"MEX","adm0_a3_tw":"MEX","adm0_a3_in":"MEX","adm0_a3_np":"MEX","adm0_a3_pk":"MEX","adm0_a3_de":"MEX","adm0_a3_gb":"MEX","adm0_a3_br":"MEX","adm0_a3_il":"MEX","adm0_a3_ps":"MEX","adm0_a3_sa":"MEX","adm0_a3_eg":"MEX","adm0_a3_ma":"MEX","adm0_a3_pt":"MEX","adm0_a3_ar":"MEX","adm0_a3_jp":"MEX","adm0_a3_ko":"MEX","adm0_a3_vn":"MEX","adm0_a3_tr":"MEX","adm0_a3_id":"MEX","adm0_a3_pl":"MEX","adm0_a3_gr":"MEX","adm0_a3_it":"MEX","adm0_a3_nl":"MEX","adm0_a3_se":"MEX","adm0_a3_bd":"MEX","adm0_a3_ua":"MEX","adm0_a3_un":-99,"adm0_a3_wb":-99,"continent":"North America","region_un":"Americas","subregion":"Central America","region_wb":"Latin America & Caribbean","name_len":6,"long_len":6,"abbrev_len":4,"tiny":-99,"homepart":1,"min_zoom":0,"min_label":2,"max_label":6.7,"label_x":-102.289448,"label_y":23.919988,"ne_id":1159321055,"wikidataid":"Q96","name_ar":"المكسيك","name_bn":"মেক্সিকো","name_de":"Mexiko","name_en":"Mexico","name_es":"México","name_fa":"مکزیک","name_fr":"Mexique","name_el":"Μεξικό","name_he":"מקסיקו","name_hi":"मेक्सिको","name_hu":"Mexikó","name_id":"Meksiko","name_it":"Messico","name_ja":"メキシコ","name_ko":"멕시코","name_nl":"Mexico","name_pl":"Meksyk","name_pt":"México","name_ru":"Мексика","name_sv":"Mexiko","name_tr":"Meksika","name_uk":"Мексика","name_ur":"میکسیکو","name_vi":"México","name_zh":"墨西哥","name_zht":"墨西哥","fclass_iso":"Admin-0 country","tlc_diff":null,"fclass_tlc":"Admin-0 country","fclass_us":null,"fclass_fr":null,"fclass_ru":null,"fclass_es":null,"fclass_cn":null,"fclass_tw":null,"fclass_in":null,"fclass_np":null,"fclass_pk":null,"fclass_de":null,"fclass_gb":null,"fclass_br":null,"fclass_il":null,"fclass_ps":null,"fclass_sa":null,"fclass_eg":null,"fclass_ma":null,"fclass_pt":null,"fclass_ar":null,"fclass_jp":null,"fclass_ko":null,"fclass_vn":null,"fclass_tr":null,"fclass_id":null,"fclass_pl":null,"fclass_gr":null,"fclass_it":null,"fclass_nl":null,"fclass_se":null,"fclass_bd":null,"fclass_ua":null,"filename":"MEX.geojson"},"geometry":{"type":"Polygon","coordinates":[[[-117.12775999999985,32.53534],[-115.99135,32.61239000000012],[-114.72139,32.72083],[-114.815,32.52528],[-113.30498,32.03914],[-111.02361,31.33472],[-109.035,31.341940000000136],[-108.24194,31.34222],[-108.24,31.754853718166373],[-106.50758999999988,31.75452],[-106.1429,31.39995],[-105.63159,31.08383],[-105.03737,30.64402],[-104.70575,30.12173],[-104.4569699999999,29.57196],[-103.94,29.27],[-103.11,28.97],[-102.48,29.76],[-101.6624,29.7793],[-100.9576,29.380710000000136],[-100.45584,28.69612000000012],[-100.11,28.110000000000127],[-99.52,27.54],[-99.3,26.84],[-99.02,26.37],[-98.24,26.06],[-97.53,25.84],[-97.14000830767071,25.8699974634784],[-97.52807247596655,24.9921440699203],[-97.70294552284224,24.272343044526735],[-97.77604183631905,22.93257986092766],[-97.87236670611111,22.44421173755336],[-97.69904395220419,21.898689480064263],[-97.38895952023677,21.411018988525825],[-97.18933346229329,20.635433254473128],[-96.52557552772032,19.890930894444068],[-96.29212724484177,19.320371405509547],[-95.90088497595997,18.82802419684873],[-94.83906348344271,18.562717393462208],[-94.42572953975622,18.144370835843347],[-93.5486512926824,18.423836981677937],[-92.7861138577835,18.52483856859226],[-92.0373481920904,18.704569200103435],[-91.40790340855926,18.87608327888023],[-90.77186987991087,19.28412038825678],[-90.53358985061305,19.8674181177513],[-90.45147599970124,20.707521877520435],[-90.2786183336849,20.999855454995554],[-89.60132117385149,21.26172577563449],[-88.54386633986286,21.49367544197662],[-87.65841651075772,21.45884552661198],[-87.05189022494807,21.5435431991383],[-86.81198238803296,21.331514797444754],[-86.84590796583262,20.849864610268355],[-87.38329118523586,20.25540477139873],[-87.62105445021075,19.64655304613592],[-87.43675045444178,19.47240346931227],[-87.58656043165593,19.04013011319074],[-87.83719112827151,18.25981598558343],[-88.09066402866318,18.51664785407405],[-88.3000310940937,18.4999822046599],[-88.49012285027935,18.486830552641607],[-88.84834387892661,17.883198147040233],[-89.02985734735182,18.001511338772488],[-89.15090938999553,17.95546763760042],[-89.14308041050333,17.808318996649405],[-90.06793351923098,17.819326076727478],[-91.00151994501596,17.81759491624571],[-91.00226925328421,17.25465770107418],[-91.45392127151516,17.252177232324172],[-91.08167009150065,16.918476670799407],[-90.71182186558772,16.687483018454728],[-90.60084672724093,16.47077789963879],[-90.43886695022204,16.410109768128095],[-90.46447262242266,16.069562079324655],[-91.74796017125595,16.066564846251765],[-92.2292486234063,15.251446641495875],[-92.08721594925207,15.064584662328441],[-92.20322953974733,14.830102850804069],[-92.22775000686983,14.538828640190928],[-93.35946387406176,15.615429592343673],[-93.87516883011853,15.940164292865916],[-94.69165646033014,16.200975246642884],[-95.25022701697304,16.128318182840644],[-96.05338212765332,15.752087917539598],[-96.55743404822829,15.653515122942792],[-97.26359249549665,15.917064927631316],[-98.01302995480961,16.107311713113916],[-98.94767574745651,16.566043402568766],[-99.69739742714705,16.70616404872817],[-100.82949886758132,17.17107107184205],[-101.66608862995446,17.649026394109626],[-101.91852800170022,17.916090196193977],[-102.47813208698892,17.975750637275098],[-103.50098954955808,18.29229462327885],[-103.91752743204682,18.74857168220001],[-104.9920096504755,19.316133938061682],[-105.49303849976144,19.946767279535436],[-105.73139604370766,20.434101874264115],[-105.39777299683135,20.531718654863425],[-105.50066077352443,20.81689504646613],[-105.27075232625793,21.07628489835514],[-105.26581722697404,21.42210358325235],[-105.6031609769754,21.87114594165257],[-105.69341386597313,22.269080308516152],[-106.02871639689897,22.773752346278627],[-106.90998043498837,23.767774359628902],[-107.91544877809139,24.54891531015295],[-108.40190487347098,25.172313951105934],[-109.26019873740665,25.58060944264406],[-109.44408932171734,25.82488393808768],[-109.29164384645628,26.442934068298428],[-109.80145768923182,26.676175645447927],[-110.3917317370857,27.16211497650454],[-110.64101884646163,27.859876003525528],[-111.17891883018785,27.94124054616907],[-111.75960689985163,28.46795258230395],[-112.2282346260904,28.95440867768349],[-112.27182369672869,29.266844387320077],[-112.80959448937398,30.02111359305235],[-113.16381059451868,30.786880804969428],[-113.14866939985717,31.170965887978923],[-113.87188106978186,31.567608344035193],[-114.20573666060352,31.524045111613134],[-114.77645117883503,31.79953217216115],[-114.93669979537214,31.393484605427602],[-114.7712318591735,30.913617255165263],[-114.67389929895177,30.162681179315996],[-114.33097449426293,29.750432440707414],[-113.58887508833544,29.061611436473015],[-113.42405310754054,28.82617361095123],[-113.27196936730553,28.7547826197399],[-113.14003943566439,28.41128937429596],[-112.9622983467965,28.42519033458251],[-112.76158708377488,27.780216783147523],[-112.45791052941166,27.52581370697476],[-112.2449519519368,27.17172679291076],[-111.61648902061921,26.662817287700477],[-111.28467464887302,25.732589830014433],[-110.9878193835724,25.294606228124564],[-110.71000688357134,24.82600434010186],[-110.65504899782889,24.298594672131117],[-110.17285620811344,24.265547593680424],[-109.77184709352855,23.811182562754198],[-109.40910437705571,23.36467234953625],[-109.43339230023292,23.1855876734287],[-109.85421932660171,22.818271592698068],[-110.03139197471444,22.823077500901206],[-110.29507097048366,23.43097321216669],[-110.94950130902805,24.000964260345995],[-111.6705684070127,24.484423122652515],[-112.18203589562148,24.738412787367167],[-112.14898881717085,25.47012523040405],[-112.3007108223797,26.012004299416617],[-112.77729671919155,26.32195954030317],[-113.46467078332194,26.768185533143424],[-113.59672990604383,26.639459540304472],[-113.84893673384425,26.90006378835244],[-114.46574662968004,27.142090358991368],[-115.05514217818501,27.72272675222291],[-114.98225257043742,27.798200181585116],[-114.57036556685495,27.74148529714489],[-114.19932878299926,28.115002549750557],[-114.16201839888464,28.566111965442303],[-114.93184221073665,29.27947927501549],[-115.518653937627,29.5563615992354],[-115.88736528202958,30.180793768834178],[-116.25835038945291,30.836464341753583],[-116.72152625208497,31.635743720012044],[-117.12775999999985,32.53534]]]}},{"type":"Feature","properties":{"featurecla":"Admin-0 country","scalerank":1,"labelrank":6,"sovereignt":"Belize","sov_a3":"BLZ","adm0_dif":0,"level":2,"type":"Sovereign country","tlc":"1","admin":"Belize","adm0_a3":"BLZ","geou_dif":0,"geounit":"Belize","gu_a3":"BLZ","su_dif":0,"subunit":"Belize","su_a3":"BLZ","brk_diff":0,"name":"Belize","name_long":"Belize","brk_a3":"BLZ","brk_name":"Belize","brk_group":null,"abbrev":"Belize","postal":"BZ","formal_en":"Belize","formal_fr":null,"name_ciawf":"Belize","note_adm0":null,"note_brk":null,"name_sort":"Belize","name_alt":null,"mapcolor7":1,"mapcolor8":4,"mapcolor9":5,"mapcolor13":7,"pop_est":390353,"pop_rank":10,"pop_year":2019,"gdp_md":1879,"gdp_year":2019,"economy":"6. Developing region","income_grp":"4. Lower middle income","fips_10":"BH","iso_a2":"BZ","iso_a2_eh":"BZ","iso_a3":"BLZ","iso_a3_eh":"BLZ","iso_n3":"084","iso_n3_eh":"084","un_a3":"084","wb_a2":"BZ","wb_a3":"BLZ","woe_id":23424760,"woe_id_eh":23424760,"woe_note":"Exact WOE match as country","adm0_iso":"BLZ","adm0_diff":null,"adm0_tlc":"BLZ","adm0_a3_us":"BLZ","adm0_a3_fr":"BLZ","adm0_a3_ru":"BLZ","adm0_a3_es":"BLZ","adm0_a3_cn":"BLZ","adm0_a3_tw":"BLZ","adm0_a3_in":"BLZ","adm0_a3_np":"BLZ","adm0_a3_pk":"BLZ","adm0_a3_de":"BLZ","adm0_a3_gb":"BLZ","adm0_a3_br":"BLZ","adm0_a3_il":"BLZ","adm0_a3_ps":"BLZ","adm0_a3_sa":"BLZ","adm0_a3_eg":"BLZ","adm0_a3_ma":"BLZ","adm0_a3_pt":"BLZ","adm0_a3_ar":"BLZ","adm0_a3_jp":"BLZ","adm0_a3_ko":"BLZ","adm0_a3_vn":"BLZ","adm0_a3_tr":"BLZ","adm0_a3_id":"BLZ","adm0_a3_pl":"BLZ","adm0_a3_gr":"BLZ","adm0_a3_it":"BLZ","adm0_a3_nl":"BLZ","adm0_a3_se":"BLZ","adm0_a3_bd":"BLZ","adm0_a3_ua":"BLZ","adm0_a3_un":-99,"adm0_a3_wb":-99,"continent":"North America","region_un":"Americas","subregion":"Central America","region_wb":"Latin America & Caribbean","name_len":6,"long_len":6,"abbrev_len":6,"tiny":-99,"homepart":1,"min_zoom":0,"min_label":5,"max_label":10,"label_x":-88.712962,"label_y":17.202068,"ne_id":1159320431,"wikidataid":"Q242","name_ar":"بليز","name_bn":"বেলিজ","name_de":"Belize","name_en":"Belize","name_es":"Belice","name_fa":"بلیز","name_fr":"Belize","name_el":"Μπελίζ","name_he":"בליז","name_hi":"बेलीज़","name_hu":"Belize","name_id":"Belize","name_it":"Belize","name_ja":"ベリーズ","name_ko":"벨리즈","name_nl":"Belize","name_pl":"Belize","name_pt":"Belize","name_ru":"Белиз","name_sv":"Belize","name_tr":"Belize","name_uk":"Беліз","name_ur":"بیلیز","name_vi":"Belize","name_zh":"伯利兹","name_zht":"貝里斯","fclass_iso":"Admin-0 country","tlc_diff":null,"fclass_tlc":"Admin-0 country","fclass_us":null,"fclass_fr":null,"fclass_ru":null,"fclass_es":null,"fclass_cn":null,"fclass_tw":null,"fclass_in":null,"fclass_np":null,"fclass_pk":null,"fclass_de":null,"fclass_gb":null,"fclass_br":null,"fclass_il":null,"fclass_ps":null,"fclass_sa":null,"fclass_eg":null,"fclass_ma":null,"fclass_pt":null,"fclass_ar":null,"fclass_jp":null,"fclass_ko":null,"fclass_vn":null,"fclass_tr":null,"fclass_id":null,"fclass_pl":null,"fclass_gr":null,"fclass_it":null,"fclass_nl":null,"fclass_se":null,"fclass_bd":null,"fclass_ua":null,"filename":"BLZ.geojson"},"geometry":{"type":"Polygon","coordinates":[[[-89.14308041050333,17.808318996649405],[-89.15090938999553,17.95546763760042],[-89.02985734735182,18.001511338772488],[-88.84834387892661,17.883198147040233],[-88.49012285027935,18.486830552641607],[-88.3000310940937,18.4999822046599],[-88.29633622918482,18.35327281338327],[-88.10681291375438,18.348673610909287],[-88.1234785631685,18.07667470954101],[-88.2853549873228,17.644142971258034],[-88.19786678745265,17.489475409408456],[-88.30264075392444,17.131693630435663],[-88.23951799187991,17.036066392479555],[-88.35542822951057,16.530774237529627],[-88.55182451043585,16.265467434143147],[-88.73243364129594,16.233634751851355],[-88.93061275913527,15.887273464415076],[-89.22912167026928,15.886937567605171],[-89.15080603713095,17.015576687075836],[-89.14308041050333,17.808318996649405]]]}},{"type":"Feature","properties":{"featurecla":"Admin-0 country","scalerank":1,"labelrank":4,"sovereignt":"Panama","sov_a3":"PAN","adm0_dif":0,"level":2,"type":"Sovereign country","tlc":"1","admin":"Panama","adm0_a3":"PAN","geou_dif":0,"geounit":"Panama","gu_a3":"PAN","su_dif":0,"subunit":"Panama","su_a3":"PAN","brk_diff":0,"name":"Panama","name_long":"Panama","brk_a3":"PAN","brk_name":"Panama","brk_group":null,"abbrev":"Pan.","postal":"PA","formal_en":"Republic of Panama","formal_fr":null,"name_ciawf":"Panama","note_adm0":null,"note_brk":null,"name_sort":"Panama","name_alt":null,"mapcolor7":4,"mapcolor8":4,"mapcolor9":6,"mapcolor13":3,"pop_est":4246439,"pop_rank":12,"pop_year":2019,"gdp_md":66800,"gdp_year":2019,"economy":"6. Developing region","income_grp":"3. Upper middle income","fips_10":"PM","iso_a2":"PA","iso_a2_eh":"PA","iso_a3":"PAN","iso_a3_eh":"PAN","iso_n3":"591","iso_n3_eh":"591","un_a3":"591","wb_a2":"PA","wb_a3":"PAN","woe_id":23424924,"woe_id_eh":23424924,"woe_note":"Exact WOE match as country","adm0_iso":"PAN","adm0_diff":null,"adm0_tlc":"PAN","adm0_a3_us":"PAN","adm0_a3_fr":"PAN","adm0_a3_ru":"PAN","adm0_a3_es":"PAN","adm0_a3_cn":"PAN","adm0_a3_tw":"PAN","adm0_a3_in":"PAN","adm0_a3_np":"PAN","adm0_a3_pk":"PAN","adm0_a3_de":"PAN","adm0_a3_gb":"PAN","adm0_a3_br":"PAN","adm0_a3_il":"PAN","adm0_a3_ps":"PAN","adm0_a3_sa":"PAN","adm0_a3_eg":"PAN","adm0_a3_ma":"PAN","adm0_a3_pt":"PAN","adm0_a3_ar":"PAN","adm0_a3_jp":"PAN","adm0_a3_ko":"PAN","adm0_a3_vn":"PAN","adm0_a3_tr":"PAN","adm0_a3_id":"PAN","adm0_a3_pl":"PAN","adm0_a3_gr":"PAN","adm0_a3_it":"PAN","adm0_a3_nl":"PAN","adm0_a3_se":"PAN","adm0_a3_bd":"PAN","adm0_a3_ua":"PAN","adm0_a3_un":-99,"adm0_a3_wb":-99,"continent":"North America","region_un":"Americas","subregion":"Central America","region_wb":"Latin America & Caribbean","name_len":6,"long_len":6,"abbrev_len":4,"tiny":-99,"homepart":1,"min_zoom":0,"min_label":4,"max_label":9,"label_x":-80.352106,"label_y":8.72198,"ne_id":1159321161,"wikidataid":"Q804","name_ar":"بنما","name_bn":"পানামা","name_de":"Panama","name_en":"Panama","name_es":"Panamá","name_fa":"پاناما","name_fr":"Panama","name_el":"Παναμάς","name_he":"פנמה","name_hi":"पनामा","name_hu":"Panama","name_id":"Panama","name_it":"Panama","name_ja":"パナマ","name_ko":"파나마","name_nl":"Panama","name_pl":"Panama","name_pt":"Panamá","name_ru":"Панама","name_sv":"Panama","name_tr":"Panama","name_uk":"Панама","name_ur":"پاناما","name_vi":"Panama","name_zh":"巴拿马","name_zht":"巴拿馬","fclass_iso":"Admin-0 country","tlc_diff":null,"fclass_tlc":"Admin-0 country","fclass_us":null,"fclass_fr":null,"fclass_ru":null,"fclass_es":null,"fclass_cn":null,"fclass_tw":null,"fclass_in":null,"fclass_np":null,"fclass_pk":null,"fclass_de":null,"fclass_gb":null,"fclass_br":null,"fclass_il":null,"fclass_ps":null,"fclass_sa":null,"fclass_eg":null,"fclass_ma":null,"fclass_pt":null,"fclass_ar":null,"fclass_jp":null,"fclass_ko":null,"fclass_vn":null,"fclass_tr":null,"fclass_id":null,"fclass_pl":null,"fclass_gr":null,"fclass_it":null,"fclass_nl":null,"fclass_se":null,"fclass_bd":null,"fclass_ua":null,"filename":"PAN.geojson"},"geometry":{"type":"Polygon","coordinates":[[[-77.35336076527386,8.67050466555807],[-77.47472286651133,8.524286200388218],[-77.24256649444008,7.935278225125444],[-77.43110795765699,7.638061224798734],[-77.7534138658614,7.709839789252143],[-77.88157141794525,7.223771267114785],[-78.21493608266012,7.512254950384161],[-78.42916073272607,8.052041123888927],[-78.18209570993864,8.319182440621773],[-78.4354652574657,8.38770538984079],[-78.62212053090394,8.718124497915028],[-79.12030717641375,8.996092027213024],[-79.55787736684519,8.932374986197146],[-79.76057817251005,8.5845150822244],[-80.16448116730334,8.333315944853595],[-80.38265906443962,8.298408514840432],[-80.4806892564973,8.09030752200107],[-80.00368994822716,7.547524115423372],[-80.276670701809,7.419754136581716],[-80.42115800649708,7.271571966984765],[-80.8864009264208,7.220541490096537],[-81.05954281281473,7.817921047390597],[-81.18971574575795,7.64790558515034],[-81.51951473664468,7.706610012233909],[-81.72131120474447,8.108962714058435],[-82.13144120962892,8.175392767769637],[-82.39093441438257,8.29236237226229],[-82.82008134635042,8.290863755725823],[-82.85095801464482,8.073822740099956],[-82.96578304719736,8.225027980985985],[-82.91317643912421,8.42351715741907],[-82.82977067740516,8.62629547773237],[-82.86865719270477,8.807266343618522],[-82.71918311230053,8.925708726431495],[-82.92715491405916,9.074330145702916],[-82.93289099804358,9.476812038608173],[-82.54619625520348,9.566134751824677],[-82.18712256542341,9.20744863528678],[-82.20758643261097,8.9955752628901],[-81.80856686066929,8.950616766796173],[-81.71415401887204,9.031955471223583],[-81.43928707551154,8.786234035675719],[-80.94730160187676,8.858503526235907],[-80.52190121125008,9.111072089062432],[-79.91459977895599,9.31276520429762],[-79.57330278188431,9.611610012241528],[-79.02119177927793,9.552931423374105],[-79.05845048696037,9.454565334506526],[-78.50088762074719,9.420458889193881],[-78.05592770049802,9.2477304142583],[-77.72951351592641,8.946844387238869],[-77.35336076527386,8.67050466555807]]]}},{"type":"Feature","properties":{"featurecla":"Admin-0 country","scalerank":1,"labelrank":3,"sovereignt":"Denmark","sov_a3":"DN1","adm0_dif":1,"level":2,"type":"Country","tlc":"1","admin":"Greenland","adm0_a3":"GRL","geou_dif":0,"geounit":"Greenland","gu_a3":"GRL","su_dif":0,"subunit":"Greenland","su_a3":"GRL","brk_diff":0,"name":"Greenland","name_long":"Greenland","brk_a3":"GRL","brk_name":"Greenland","brk_group":null,"abbrev":"Grlnd.","postal":"GL","formal_en":"Greenland","formal_fr":null,"name_ciawf":"Greenland","note_adm0":"Den.","note_brk":null,"name_sort":"Greenland","name_alt":null,"mapcolor7":4,"mapcolor8":1,"mapcolor9":3,"mapcolor13":12,"pop_est":56225,"pop_rank":8,"pop_year":2019,"gdp_md":3051,"gdp_year":2018,"economy":"2. Developed region: nonG7","income_grp":"2. High income: nonOECD","fips_10":"GL","iso_a2":"GL","iso_a2_eh":"GL","iso_a3":"GRL","iso_a3_eh":"GRL","iso_n3":"304","iso_n3_eh":"304","un_a3":"304","wb_a2":"GL","wb_a3":"GRL","woe_id":23424828,"woe_id_eh":23424828,"woe_note":"Exact WOE match as country","adm0_iso":"GRL","adm0_diff":null,"adm0_tlc":"GRL","adm0_a3_us":"GRL","adm0_a3_fr":"GRL","adm0_a3_ru":"GRL","adm0_a3_es":"GRL","adm0_a3_cn":"GRL","adm0_a3_tw":"GRL","adm0_a3_in":"GRL","adm0_a3_np":"GRL","adm0_a3_pk":"GRL","adm0_a3_de":"GRL","adm0_a3_gb":"GRL","adm0_a3_br":"GRL","adm0_a3_il":"GRL","adm0_a3_ps":"GRL","adm0_a3_sa":"GRL","adm0_a3_eg":"GRL","adm0_a3_ma":"GRL","adm0_a3_pt":"GRL","adm0_a3_ar":"GRL","adm0_a3_jp":"GRL","adm0_a3_ko":"GRL","adm0_a3_vn":"GRL","adm0_a3_tr":"GRL","adm0_a3_id":"GRL","adm0_a3_pl":"GRL","adm0_a3_gr":"GRL","adm0_a3_it":"GRL","adm0_a3_nl":"GRL","adm0_a3_se":"GRL","adm0_a3_bd":"GRL","adm0_a3_ua":"GRL","adm0_a3_un":-99,"adm0_a3_wb":-99,"continent":"North America","region_un":"Americas","subregion":"Northern America","region_wb":"Europe & Central Asia","name_len":9,"long_len":9,"abbrev_len":6,"tiny":-99,"homepart":-99,"min_zoom":0,"min_label":1.7,"max_label":6.7,"label_x":-39.335251,"label_y":74.319387,"ne_id":1159320551,"wikidataid":"Q223","name_ar":"جرينلاند","name_bn":"গ্রিনল্যান্ড","name_de":"Grönland","name_en":"Greenland","name_es":"Groenlandia","name_fa":"گرینلند","name_fr":"Groenland","name_el":"Γροιλανδία","name_he":"גרינלנד","name_hi":"ग्रीनलैण्ड","name_hu":"Grönland","name_id":"Greenland","name_it":"Groenlandia","name_ja":"グリーンランド","name_ko":"그린란드","name_nl":"Groenland","name_pl":"Grenlandia","name_pt":"Groenlândia","name_ru":"Гренландия","name_sv":"Grönland","name_tr":"Grönland","name_uk":"Гренландія","name_ur":"گرین لینڈ","name_vi":"Greenland","name_zh":"格陵兰","name_zht":"格陵蘭","fclass_iso":"Admin-0 dependency","tlc_diff":null,"fclass_tlc":"Admin-0 dependency","fclass_us":null,"fclass_fr":null,"fclass_ru":null,"fclass_es":null,"fclass_cn":null,"fclass_tw":null,"fclass_in":null,"fclass_np":null,"fclass_pk":null,"fclass_de":null,"fclass_gb":null,"fclass_br":null,"fclass_il":null,"fclass_ps":null,"fclass_sa":null,"fclass_eg":null,"fclass_ma":null,"fclass_pt":null,"fclass_ar":null,"fclass_jp":null,"fclass_ko":null,"fclass_vn":null,"fclass_tr":null,"fclass_id":null,"fclass_pl":null,"fclass_gr":null,"fclass_it":null,"fclass_nl":null,"fclass_se":null,"fclass_bd":null,"fclass_ua":null,"filename":"GRL.geojson"},"geometry":{"type":"Polygon","coordinates":[[[-46.76379,82.62796],[-43.40644,83.22516],[-39.89753,83.18018],[-38.62214,83.54905],[-35.08787,83.64513],[-27.10046,83.51966],[-20.84539,82.72669],[-22.69182,82.34165],[-26.51753,82.29765],[-31.9,82.2],[-31.39646,82.02154],[-27.85666,82.13178],[-24.84448,81.78697],[-22.90328,82.09317],[-22.07175,81.73449],[-23.16961,81.15271],[-20.62363,81.52462],[-15.76818,81.91245],[-12.77018,81.71885],[-12.20855,81.29154],[-16.28533,80.58004],[-16.85,80.35],[-20.04624,80.17708],[-17.73035,80.12912],[-18.9,79.4],[-19.70499,78.75128],[-19.67353,77.63859],[-18.47285,76.98565],[-20.03503,76.94434],[-21.67944,76.62795],[-19.83407,76.09808],[-19.59896,75.24838],[-20.66818,75.15585],[-19.37281,74.29561],[-21.59422,74.22382],[-20.43454,73.81713],[-20.76234,73.46436],[-22.17221,73.30955],[-23.56593,73.30663],[-22.31311,72.62928],[-22.29954,72.18409],[-24.27834,72.59788],[-24.79296,72.3302],[-23.44296,72.08016],[-22.13281,71.46898],[-21.75356,70.66369],[-23.53603,70.471],[-24.30702,70.85649],[-25.54341,71.43094],[-25.20135,70.75226],[-26.36276,70.22646],[-23.72742,70.18401],[-22.34902,70.12946],[-25.02927,69.2588],[-27.74737,68.47046],[-30.67371,68.12503],[-31.77665,68.12078],[-32.81105,67.73547],[-34.20196,66.67974],[-36.35284,65.9789],[-37.04378,65.93768],[-38.37505,65.69213],[-39.81222,65.45848],[-40.66899,64.83997],[-40.68281,64.13902],[-41.1887,63.48246],[-42.81938,62.68233],[-42.41666,61.90093],[-42.86619,61.07404],[-43.3784,60.09772],[-44.7875,60.03676],[-46.26364,60.85328],[-48.26294,60.85843],[-49.23308,61.40681],[-49.90039,62.38336],[-51.63325,63.62691],[-52.14014,64.27842],[-52.27659,65.1767],[-53.66166,66.09957],[-53.30161,66.8365],[-53.96911,67.18899],[-52.9804,68.35759],[-51.47536,68.72958],[-51.08041,69.14781],[-50.87122,69.9291],[-52.013585,69.574925],[-52.55792,69.42616],[-53.45629,69.283625],[-54.68336,69.61003],[-54.75001,70.28932],[-54.35884,70.821315],[-53.431315,70.835755],[-51.39014,70.56978],[-53.10937,71.20485],[-54.00422,71.54719],[-55,71.40653696727257],[-55.83468,71.65444],[-54.71819,72.58625],[-55.32634,72.95861],[-56.12003,73.64977],[-57.32363,74.71026],[-58.59679,75.09861],[-58.58516,75.51727],[-61.26861,76.10238],[-63.39165,76.1752],[-66.06427,76.13486],[-68.50438,76.06141],[-69.66485,76.37975],[-71.40257,77.00857],[-68.77671,77.32312],[-66.76397,77.37595],[-71.04293,77.63595],[-73.297,78.04419],[-73.15938,78.43271],[-69.37345,78.91388],[-65.7107,79.39436],[-65.3239,79.75814],[-68.02298,80.11721],[-67.15129,80.51582],[-63.68925,81.21396],[-62.23444,81.3211],[-62.65116,81.77042],[-60.28249,82.03363],[-57.20744,82.19074],[-54.13442,82.19962],[-53.04328,81.88833],[-50.39061,82.43883],[-48.00386,82.06481],[-46.59984,81.985945],[-44.523,81.6607],[-46.9007,82.19979],[-46.76379,82.62796]]]}},{"type":"Feature","properties":{"featurecla":"Admin-0 country","scalerank":1,"labelrank":4,"sovereignt":"The Bahamas","sov_a3":"BHS","adm0_dif":0,"level":2,"type":"Sovereign country","tlc":"1","admin":"The Bahamas","adm0_a3":"BHS","geou_dif":0,"geounit":"The Bahamas","gu_a3":"BHS","su_dif":0,"subunit":"The Bahamas","su_a3":"BHS","brk_diff":0,"name":"Bahamas","name_long":"Bahamas","brk_a3":"BHS","brk_name":"Bahamas","brk_group":null,"abbrev":"Bhs.","postal":"BS","formal_en":"Commonwealth of the Bahamas","formal_fr":null,"name_ciawf":"Bahamas, The","note_adm0":null,"note_brk":null,"name_sort":"Bahamas, The","name_alt":null,"mapcolor7":1,"mapcolor8":1,"mapcolor9":2,"mapcolor13":5,"pop_est":389482,"pop_rank":10,"pop_year":2019,"gdp_md":13578,"gdp_year":2019,"economy":"6. Developing region","income_grp":"2. High income: nonOECD","fips_10":"BF","iso_a2":"BS","iso_a2_eh":"BS","iso_a3":"BHS","iso_a3_eh":"BHS","iso_n3":"044","iso_n3_eh":"044","un_a3":"044","wb_a2":"BS","wb_a3":"BHS","woe_id":23424758,"woe_id_eh":23424758,"woe_note":"Exact WOE match as country","adm0_iso":"BHS","adm0_diff":null,"adm0_tlc":"BHS","adm0_a3_us":"BHS","adm0_a3_fr":"BHS","adm0_a3_ru":"BHS","adm0_a3_es":"BHS","adm0_a3_cn":"BHS","adm0_a3_tw":"BHS","adm0_a3_in":"BHS","adm0_a3_np":"BHS","adm0_a3_pk":"BHS","adm0_a3_de":"BHS","adm0_a3_gb":"BHS","adm0_a3_br":"BHS","adm0_a3_il":"BHS","adm0_a3_ps":"BHS","adm0_a3_sa":"BHS","adm0_a3_eg":"BHS","adm0_a3_ma":"BHS","adm0_a3_pt":"BHS","adm0_a3_ar":"BHS","adm0_a3_jp":"BHS","adm0_a3_ko":"BHS","adm0_a3_vn":"BHS","adm0_a3_tr":"BHS","adm0_a3_id":"BHS","adm0_a3_pl":"BHS","adm0_a3_gr":"BHS","adm0_a3_it":"BHS","adm0_a3_nl":"BHS","adm0_a3_se":"BHS","adm0_a3_bd":"BHS","adm0_a3_ua":"BHS","adm0_a3_un":-99,"adm0_a3_wb":-99,"continent":"North America","region_un":"Americas","subregion":"Caribbean","region_wb":"Latin America & Caribbean","name_len":7,"long_len":7,"abbrev_len":4,"tiny":-99,"homepart":1,"min_zoom":0,"min_label":4,"max_label":9,"label_x":-77.146688,"label_y":26.401789,"ne_id":1159320415,"wikidataid":"Q778","name_ar":"باهاماس","name_bn":"বাহামা দ্বীপপুঞ্জ","name_de":"Bahamas","name_en":"The Bahamas","name_es":"Bahamas","name_fa":"باهاما","name_fr":"Bahamas","name_el":"Μπαχάμες","name_he":"איי בהאמה","name_hi":"बहामास","name_hu":"Bahama-szigetek","name_id":"Bahama","name_it":"Bahamas","name_ja":"バハマ","name_ko":"바하마","name_nl":"Bahama's","name_pl":"Bahamy","name_pt":"Bahamas","name_ru":"Багамские Острова","name_sv":"Bahamas","name_tr":"Bahamalar","name_uk":"Багамські Острови","name_ur":"بہاماس","name_vi":"Bahamas","name_zh":"巴哈马","name_zht":"巴哈馬","fclass_iso":"Admin-0 country","tlc_diff":null,"fclass_tlc":"Admin-0 country","fclass_us":null,"fclass_fr":null,"fclass_ru":null,"fclass_es":null,"fclass_cn":null,"fclass_tw":null,"fclass_in":null,"fclass_np":null,"fclass_pk":null,"fclass_de":null,"fclass_gb":null,"fclass_br":null,"fclass_il":null,"fclass_ps":null,"fclass_sa":null,"fclass_eg":null,"fclass_ma":null,"fclass_pt":null,"fclass_ar":null,"fclass_jp":null,"fclass_ko":null,"fclass_vn":null,"fclass_tr":null,"fclass_id":null,"fclass_pl":null,"fclass_gr":null,"fclass_it":null,"fclass_nl":null,"fclass_se":null,"fclass_bd":null,"fclass_ua":null,"filename":"BHS.geojson"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-78.98,26.79],[-78.51,26.87],[-77.85,26.84],[-77.82,26.58],[-78.91,26.42],[-78.98,26.79]]],[[[-77.79,27.04],[-77,26.59],[-77.17255,25.87918],[-77.35641,26.00735],[-77.34,26.53],[-77.78802,26.92516],[-77.79,27.04]]],[[[-78.19087,25.2103],[-77.89,25.17],[-77.54,24.34],[-77.53466,23.75975],[-77.78,23.71],[-78.03405,24.28615],[-78.40848,24.57564],[-78.19087,25.2103]]]]}},{"type":"Feature","properties":{"featurecla":"Admin-0 country","scalerank":1,"labelrank":5,"sovereignt":"Trinidad and Tobago","sov_a3":"TTO","adm0_dif":0,"level":2,"type":"Sovereign country","tlc":"1","admin":"Trinidad and Tobago","adm0_a3":"TTO","geou_dif":0,"geounit":"Trinidad and Tobago","gu_a3":"TTO","su_dif":0,"subunit":"Trinidad and Tobago","su_a3":"TTO","brk_diff":0,"name":"Trinidad and Tobago","name_long":"Trinidad and Tobago","brk_a3":"TTO","brk_name":"Trinidad and Tobago","brk_group":null,"abbrev":"Tr.T.","postal":"TT","formal_en":"Republic of Trinidad and Tobago","formal_fr":null,"name_ciawf":"Trinidad and Tobago","note_adm0":null,"note_brk":null,"name_sort":"Trinidad and Tobago","name_alt":null,"mapcolor7":5,"mapcolor8":6,"mapcolor9":2,"mapcolor13":5,"pop_est":1394973,"pop_rank":12,"pop_year":2019,"gdp_md":24269,"gdp_year":2019,"economy":"6. Developing region","income_grp":"2. High income: nonOECD","fips_10":"TD","iso_a2":"TT","iso_a2_eh":"TT","iso_a3":"TTO","iso_a3_eh":"TTO","iso_n3":"780","iso_n3_eh":"780","un_a3":"780","wb_a2":"TT","wb_a3":"TTO","woe_id":23424958,"woe_id_eh":23424958,"woe_note":"Exact WOE match as country","adm0_iso":"TTO","adm0_diff":null,"adm0_tlc":"TTO","adm0_a3_us":"TTO","adm0_a3_fr":"TTO","adm0_a3_ru":"TTO","adm0_a3_es":"TTO","adm0_a3_cn":"TTO","adm0_a3_tw":"TTO","adm0_a3_in":"TTO","adm0_a3_np":"TTO","adm0_a3_pk":"TTO","adm0_a3_de":"TTO","adm0_a3_gb":"TTO","adm0_a3_br":"TTO","adm0_a3_il":"TTO","adm0_a3_ps":"TTO","adm0_a3_sa":"TTO","adm0_a3_eg":"TTO","adm0_a3_ma":"TTO","adm0_a3_pt":"TTO","adm0_a3_ar":"TTO","adm0_a3_jp":"TTO","adm0_a3_ko":"TTO","adm0_a3_vn":"TTO","adm0_a3_tr":"TTO","adm0_a3_id":"TTO","adm0_a3_pl":"TTO","adm0_a3_gr":"TTO","adm0_a3_it":"TTO","adm0_a3_nl":"TTO","adm0_a3_se":"TTO","adm0_a3_bd":"TTO","adm0_a3_ua":"TTO","adm0_a3_un":-99,"adm0_a3_wb":-99,"continent":"North America","region_un":"Americas","subregion":"Caribbean","region_wb":"Latin America & Caribbean","name_len":19,"long_len":19,"abbrev_len":5,"tiny":2,"homepart":1,"min_zoom":0,"min_label":4.5,"max_label":9.5,"label_x":-60.9184,"label_y":10.9989,"ne_id":1159321321,"wikidataid":"Q754","name_ar":"ترينيداد وتوباغو","name_bn":"ত্রিনিদাদ ও টোবাগো","name_de":"Trinidad und Tobago","name_en":"Trinidad and Tobago","name_es":"Trinidad y Tobago","name_fa":"ترینیداد و توباگو","name_fr":"Trinité-et-Tobago","name_el":"Τρινιντάντ και Τομπάγκο","name_he":"טרינידד וטובגו","name_hi":"त्रिनिदाद और टोबैगो","name_hu":"Trinidad és Tobago","name_id":"Trinidad dan Tobago","name_it":"Trinidad e Tobago","name_ja":"トリニダード・トバゴ","name_ko":"트리니다드 토바고","name_nl":"Trinidad en Tobago","name_pl":"Trynidad i Tobago","name_pt":"Trinidad e Tobago","name_ru":"Тринидад и Тобаго","name_sv":"Trinidad och Tobago","name_tr":"Trinidad ve Tobago","name_uk":"Тринідад і Тобаго","name_ur":"ٹرینیڈاڈ و ٹوباگو","name_vi":"Trinidad và Tobago","name_zh":"特立尼达和多巴哥","name_zht":"千里達及托巴哥","fclass_iso":"Admin-0 country","tlc_diff":null,"fclass_tlc":"Admin-0 country","fclass_us":null,"fclass_fr":null,"fclass_ru":null,"fclass_es":null,"fclass_cn":null,"fclass_tw":null,"fclass_in":null,"fclass_np":null,"fclass_pk":null,"fclass_de":null,"fclass_gb":null,"fclass_br":null,"fclass_il":null,"fclass_ps":null,"fclass_sa":null,"fclass_eg":null,"fclass_ma":null,"fclass_pt":null,"fclass_ar":null,"fclass_jp":null,"fclass_ko":null,"fclass_vn":null,"fclass_tr":null,"fclass_id":null,"fclass_pl":null,"fclass_gr":null,"fclass_it":null,"fclass_nl":null,"fclass_se":null,"fclass_bd":null,"fclass_ua":null,"filename":"TTO.geojson"},"geometry":{"type":"Polygon","coordinates":[[[-61.68,10.76],[-61.105,10.89],[-60.895,10.855],[-60.935,10.11],[-61.77,10],[-61.95,10.09],[-61.66,10.365],[-61.68,10.76]]]}},{"type":"Feature","properties":{"featurecla":"Admin-0 country","scalerank":1,"labelrank":5,"sovereignt":"United States of America","sov_a3":"US1","adm0_dif":1,"level":2,"type":"Dependency","tlc":"1","admin":"Puerto Rico","adm0_a3":"PRI","geou_dif":0,"geounit":"Puerto Rico","gu_a3":"PRI","su_dif":0,"subunit":"Puerto Rico","su_a3":"PRI","brk_diff":0,"name":"Puerto Rico","name_long":"Puerto Rico","brk_a3":"PRI","brk_name":"Puerto Rico","brk_group":null,"abbrev":"P.R.","postal":"PR","formal_en":"Commonwealth of Puerto Rico","formal_fr":null,"name_ciawf":"Puerto Rico","note_adm0":"U.S.A.","note_brk":null,"name_sort":"Puerto Rico","name_alt":null,"mapcolor7":4,"mapcolor8":5,"mapcolor9":1,"mapcolor13":1,"pop_est":3193694,"pop_rank":12,"pop_year":2019,"gdp_md":104988,"gdp_year":2019,"economy":"6. Developing region","income_grp":"2. High income: nonOECD","fips_10":"RQ","iso_a2":"PR","iso_a2_eh":"PR","iso_a3":"PRI","iso_a3_eh":"PRI","iso_n3":"630","iso_n3_eh":"630","un_a3":"630","wb_a2":"PR","wb_a3":"PRI","woe_id":23424935,"woe_id_eh":23424935,"woe_note":"Exact WOE match as country","adm0_iso":"PRI","adm0_diff":null,"adm0_tlc":"PRI","adm0_a3_us":"PRI","adm0_a3_fr":"PRI","adm0_a3_ru":"PRI","adm0_a3_es":"PRI","adm0_a3_cn":"PRI","adm0_a3_tw":"PRI","adm0_a3_in":"PRI","adm0_a3_np":"PRI","adm0_a3_pk":"PRI","adm0_a3_de":"PRI","adm0_a3_gb":"PRI","adm0_a3_br":"PRI","adm0_a3_il":"PRI","adm0_a3_ps":"PRI","adm0_a3_sa":"PRI","adm0_a3_eg":"PRI","adm0_a3_ma":"PRI","adm0_a3_pt":"PRI","adm0_a3_ar":"PRI","adm0_a3_jp":"PRI","adm0_a3_ko":"PRI","adm0_a3_vn":"PRI","adm0_a3_tr":"PRI","adm0_a3_id":"PRI","adm0_a3_pl":"PRI","adm0_a3_gr":"PRI","adm0_a3_it":"PRI","adm0_a3_nl":"PRI","adm0_a3_se":"PRI","adm0_a3_bd":"PRI","adm0_a3_ua":"PRI","adm0_a3_un":-99,"adm0_a3_wb":-99,"continent":"North America","region_un":"Americas","subregion":"Caribbean","region_wb":"Latin America & Caribbean","name_len":11,"long_len":11,"abbrev_len":4,"tiny":-99,"homepart":-99,"min_zoom":0,"min_label":3,"max_label":8,"label_x":-66.481065,"label_y":18.234668,"ne_id":1159321363,"wikidataid":"Q1183","name_ar":"بورتوريكو","name_bn":"পুয়ের্তো রিকো","name_de":"Puerto Rico","name_en":"Puerto Rico","name_es":"Puerto Rico","name_fa":"پورتوریکو","name_fr":"Porto Rico","name_el":"Πουέρτο Ρίκο","name_he":"פוארטו ריקו","name_hi":"पोर्टो रीको","name_hu":"Puerto Rico","name_id":"Puerto Riko","name_it":"Porto Rico","name_ja":"プエルトリコ","name_ko":"푸에르토리코","name_nl":"Puerto Rico","name_pl":"Portoryko","name_pt":"Porto Rico","name_ru":"Пуэрто-Рико","name_sv":"Puerto Rico","name_tr":"Porto Riko","name_uk":"Пуерто-Рико","name_ur":"پورٹو ریکو","name_vi":"Puerto Rico","name_zh":"波多黎各","name_zht":"波多黎各","fclass_iso":"Admin-0 dependency","tlc_diff":null,"fclass_tlc":"Admin-0 dependency","fclass_us":null,"fclass_fr":null,"fclass_ru":null,"fclass_es":null,"fclass_cn":null,"fclass_tw":null,"fclass_in":null,"fclass_np":null,"fclass_pk":null,"fclass_de":null,"fclass_gb":null,"fclass_br":null,"fclass_il":null,"fclass_ps":null,"fclass_sa":null,"fclass_eg":null,"fclass_ma":null,"fclass_pt":null,"fclass_ar":null,"fclass_jp":null,"fclass_ko":null,"fclass_vn":null,"fclass_tr":null,"fclass_id":null,"fclass_pl":null,"fclass_gr":null,"fclass_it":null,"fclass_nl":null,"fclass_se":null,"fclass_bd":null,"fclass_ua":null,"filename":"PRI.geojson"},"geometry":{"type":"Polygon","coordinates":[[[-66.28243445500821,18.514761664295364],[-65.7713028632093,18.426679185453878],[-65.59100379094295,18.228034979723915],[-65.84716386581377,17.97590566657186],[-66.59993445500949,17.981822618069273],[-67.18416236028527,17.946553453030077],[-67.24242753769435,18.374460150622937],[-67.10067908391774,18.52060110114435],[-66.28243445500821,18.514761664295364]]]}},{"type":"Feature","properties":{"featurecla":"Admin-0 country","scalerank":1,"labelrank":4,"sovereignt":"Jamaica","sov_a3":"JAM","adm0_dif":0,"level":2,"type":"Sovereign country","tlc":"1","admin":"Jamaica","adm0_a3":"JAM","geou_dif":0,"geounit":"Jamaica","gu_a3":"JAM","su_dif":0,"subunit":"Jamaica","su_a3":"JAM","brk_diff":0,"name":"Jamaica","name_long":"Jamaica","brk_a3":"JAM","brk_name":"Jamaica","brk_group":null,"abbrev":"Jam.","postal":"J","formal_en":"Jamaica","formal_fr":null,"name_ciawf":"Jamaica","note_adm0":null,"note_brk":null,"name_sort":"Jamaica","name_alt":null,"mapcolor7":1,"mapcolor8":2,"mapcolor9":4,"mapcolor13":10,"pop_est":2948279,"pop_rank":12,"pop_year":2019,"gdp_md":16458,"gdp_year":2019,"economy":"6. Developing region","income_grp":"3. Upper middle income","fips_10":"JM","iso_a2":"JM","iso_a2_eh":"JM","iso_a3":"JAM","iso_a3_eh":"JAM","iso_n3":"388","iso_n3_eh":"388","un_a3":"388","wb_a2":"JM","wb_a3":"JAM","woe_id":23424858,"woe_id_eh":23424858,"woe_note":"Exact WOE match as country","adm0_iso":"JAM","adm0_diff":null,"adm0_tlc":"JAM","adm0_a3_us":"JAM","adm0_a3_fr":"JAM","adm0_a3_ru":"JAM","adm0_a3_es":"JAM","adm0_a3_cn":"JAM","adm0_a3_tw":"JAM","adm0_a3_in":"JAM","adm0_a3_np":"JAM","adm0_a3_pk":"JAM","adm0_a3_de":"JAM","adm0_a3_gb":"JAM","adm0_a3_br":"JAM","adm0_a3_il":"JAM","adm0_a3_ps":"JAM","adm0_a3_sa":"JAM","adm0_a3_eg":"JAM","adm0_a3_ma":"JAM","adm0_a3_pt":"JAM","adm0_a3_ar":"JAM","adm0_a3_jp":"JAM","adm0_a3_ko":"JAM","adm0_a3_vn":"JAM","adm0_a3_tr":"JAM","adm0_a3_id":"JAM","adm0_a3_pl":"JAM","adm0_a3_gr":"JAM","adm0_a3_it":"JAM","adm0_a3_nl":"JAM","adm0_a3_se":"JAM","adm0_a3_bd":"JAM","adm0_a3_ua":"JAM","adm0_a3_un":-99,"adm0_a3_wb":-99,"continent":"North America","region_un":"Americas","subregion":"Caribbean","region_wb":"Latin America & Caribbean","name_len":7,"long_len":7,"abbrev_len":4,"tiny":-99,"homepart":1,"min_zoom":0,"min_label":4,"max_label":9,"label_x":-77.318767,"label_y":18.137124,"ne_id":1159320931,"wikidataid":"Q766","name_ar":"جامايكا","name_bn":"জ্যামাইকা","name_de":"Jamaika","name_en":"Jamaica","name_es":"Jamaica","name_fa":"جامائیکا","name_fr":"Jamaïque","name_el":"Τζαμάικα","name_he":"ג'מייקה","name_hi":"जमैका","name_hu":"Jamaica","name_id":"Jamaika","name_it":"Giamaica","name_ja":"ジャマイカ","name_ko":"자메이카","name_nl":"Jamaica","name_pl":"Jamajka","name_pt":"Jamaica","name_ru":"Ямайка","name_sv":"Jamaica","name_tr":"Jamaika","name_uk":"Ямайка","name_ur":"جمیکا","name_vi":"Jamaica","name_zh":"牙买加","name_zht":"牙買加","fclass_iso":"Admin-0 country","tlc_diff":null,"fclass_tlc":"Admin-0 country","fclass_us":null,"fclass_fr":null,"fclass_ru":null,"fclass_es":null,"fclass_cn":null,"fclass_tw":null,"fclass_in":null,"fclass_np":null,"fclass_pk":null,"fclass_de":null,"fclass_gb":null,"fclass_br":null,"fclass_il":null,"fclass_ps":null,"fclass_sa":null,"fclass_eg":null,"fclass_ma":null,"fclass_pt":null,"fclass_ar":null,"fclass_jp":null,"fclass_ko":null,"fclass_vn":null,"fclass_tr":null,"fclass_id":null,"fclass_pl":null,"fclass_gr":null,"fclass_it":null,"fclass_nl":null,"fclass_se":null,"fclass_bd":null,"fclass_ua":null,"filename":"JAM.geojson"},"geometry":{"type":"Polygon","coordinates":[[[-77.56960079619921,18.490525417550487],[-76.89661861846213,18.400866807524082],[-76.36535905628554,18.160700588447597],[-76.19965857614164,17.886867173732966],[-76.9025614081757,17.868237819891746],[-77.20634131540348,17.70111623785982],[-77.76602291534061,17.86159739834224],[-78.33771928578561,18.225967922432233],[-78.21772661000388,18.454532782459196],[-77.79736467152563,18.524218451404778],[-77.56960079619921,18.490525417550487]]]}},{"type":"Feature","properties":{"featurecla":"Admin-0 country","scalerank":1,"labelrank":2,"sovereignt":"Indonesia","sov_a3":"IDN","adm0_dif":0,"level":2,"type":"Sovereign country","tlc":"1","admin":"Indonesia","adm0_a3":"IDN","geou_dif":0,"geounit":"Indonesia","gu_a3":"IDN","su_dif":0,"subunit":"Indonesia","su_a3":"IDN","brk_diff":0,"name":"Indonesia","name_long":"Indonesia","brk_a3":"IDN","brk_name":"Indonesia","brk_group":null,"abbrev":"Indo.","postal":"INDO","formal_en":"Republic of Indonesia","formal_fr":null,"name_ciawf":"Indonesia","note_adm0":null,"note_brk":null,"name_sort":"Indonesia","name_alt":null,"mapcolor7":6,"mapcolor8":6,"mapcolor9":6,"mapcolor13":11,"pop_est":270625568,"pop_rank":17,"pop_year":2019,"gdp_md":1119190,"gdp_year":2019,"economy":"4. Emerging region: MIKT","income_grp":"4. Lower middle income","fips_10":"ID","iso_a2":"ID","iso_a2_eh":"ID","iso_a3":"IDN","iso_a3_eh":"IDN","iso_n3":"360","iso_n3_eh":"360","un_a3":"360","wb_a2":"ID","wb_a3":"IDN","woe_id":23424846,"woe_id_eh":23424846,"woe_note":"Exact WOE match as country","adm0_iso":"IDN","adm0_diff":null,"adm0_tlc":"IDN","adm0_a3_us":"IDN","adm0_a3_fr":"IDN","adm0_a3_ru":"IDN","adm0_a3_es":"IDN","adm0_a3_cn":"IDN","adm0_a3_tw":"IDN","adm0_a3_in":"IDN","adm0_a3_np":"IDN","adm0_a3_pk":"IDN","adm0_a3_de":"IDN","adm0_a3_gb":"IDN","adm0_a3_br":"IDN","adm0_a3_il":"IDN","adm0_a3_ps":"IDN","adm0_a3_sa":"IDN","adm0_a3_eg":"IDN","adm0_a3_ma":"IDN","adm0_a3_pt":"IDN","adm0_a3_ar":"IDN","adm0_a3_jp":"IDN","adm0_a3_ko":"IDN","adm0_a3_vn":"IDN","adm0_a3_tr":"IDN","adm0_a3_id":"IDN","adm0_a3_pl":"IDN","adm0_a3_gr":"IDN","adm0_a3_it":"IDN","adm0_a3_nl":"IDN","adm0_a3_se":"IDN","adm0_a3_bd":"IDN","adm0_a3_ua":"IDN","adm0_a3_un":-99,"adm0_a3_wb":-99,"continent":"Asia","region_un":"Asia","subregion":"South-Eastern Asia","region_wb":"East Asia & Pacific","name_len":9,"long_len":9,"abbrev_len":5,"tiny":-99,"homepart":1,"min_zoom":0,"min_label":1.7,"max_label":6.7,"label_x":101.892949,"label_y":-0.954404,"ne_id":1159320845,"wikidataid":"Q252","name_ar":"إندونيسيا","name_bn":"ইন্দোনেশিয়া","name_de":"Indonesien","name_en":"Indonesia","name_es":"Indonesia","name_fa":"اندونزی","name_fr":"Indonésie","name_el":"Ινδονησία","name_he":"אינדונזיה","name_hi":"इंडोनेशिया","name_hu":"Indonézia","name_id":"Indonesia","name_it":"Indonesia","name_ja":"インドネシア","name_ko":"인도네시아","name_nl":"Indonesië","name_pl":"Indonezja","name_pt":"Indonésia","name_ru":"Индонезия","name_sv":"Indonesien","name_tr":"Endonezya","name_uk":"Індонезія","name_ur":"انڈونیشیا","name_vi":"Indonesia","name_zh":"印度尼西亚","name_zht":"印度尼西亞","fclass_iso":"Admin-0 country","tlc_diff":null,"fclass_tlc":"Admin-0 country","fclass_us":null,"fclass_fr":null,"fclass_ru":null,"fclass_es":null,"fclass_cn":null,"fclass_tw":null,"fclass_in":null,"fclass_np":null,"fclass_pk":null,"fclass_de":null,"fclass_gb":null,"fclass_br":null,"fclass_il":null,"fclass_ps":null,"fclass_sa":null,"fclass_eg":null,"fclass_ma":null,"fclass_pt":null,"fclass_ar":null,"fclass_jp":null,"fclass_ko":null,"fclass_vn":null,"fclass_tr":null,"fclass_id":null,"fclass_pl":null,"fclass_gr":null,"fclass_it":null,"fclass_nl":null,"fclass_se":null,"fclass_bd":null,"fclass_ua":null,"filename":"IDN.geojson"},"geometry":{"type":"MultiPolygon","coordinates":[[[[141.00021040259185,-2.60015105551566],[141.01705691951895,-5.859021905138071],[141.03385176001382,-9.117892754760483],[140.14341515519254,-8.29716765710095],[139.1277665549281,-8.096042982620979],[138.881476678625,-8.380935153846075],[137.61447391169287,-8.41168263105974],[138.03909915583517,-7.597882175327321],[138.66862145401478,-7.320224704623087],[138.4079138531023,-6.232849216337485],[137.92783979711078,-5.393365573756],[135.98925011611345,-4.54654387778907],[135.16459760959975,-4.462931410340822],[133.66288048719787,-3.538853448097541],[133.36770470594672,-4.024818617370315],[132.98395551974727,-4.112978610860253],[132.75694095268904,-3.746282647317123],[132.75378869031925,-3.31178720460705],[131.98980431531618,-2.820551039240499],[133.0668445171434,-2.460417982598436],[133.78003095920354,-2.479848321140182],[133.69621178602614,-2.214541517753702],[132.23237348849426,-2.212526136894319],[131.83622195854474,-1.617161960459647],[130.94283979708285,-1.432522067880783],[130.5195581401801,-0.937720228686089],[131.8675378765136,-0.695461114101789],[132.3801164084167,-0.369537855636949],[133.98554813042836,-0.780210463060456],[134.14336795464772,-1.151867364103623],[134.42262739475302,-2.769184665542376],[135.45760298069467,-3.367752780779149],[136.29331424371884,-2.307042331556154],[137.44073774632756,-1.703513278819365],[138.3297274110447,-1.702686455902693],[139.18492068904288,-2.051295668143673],[139.92668419816044,-2.409051608900313],[141.00021040259185,-2.60015105551566]]],[[[124.96868248911623,-8.892790215697083],[125.07001997284061,-9.089987481322872],[125.08852013560109,-9.393173109579294],[124.43595014861933,-10.140000909061449],[123.57998172413669,-10.359987481327956],[123.459989048355,-10.239994805546223],[123.55000939340744,-9.900015557497987],[123.9800089865081,-9.290026950724716],[124.96868248911623,-8.892790215697083]]],[[[134.21013390516885,-6.89523772545472],[134.11277550673094,-6.142467136259],[134.29033572808584,-5.783057549669017],[134.49962527886788,-5.445042006047871],[134.72700158095216,-5.737582289252167],[134.7246244650667,-6.214400730009288],[134.21013390516885,-6.89523772545472]]],[[[117.88203494677018,4.137551377779516],[117.3132324565335,3.234428208830593],[118.0483297058854,2.287690131027333],[117.87562706916597,1.827640692548925],[118.99674726773816,0.902219143066063],[117.8118583517178,0.784241848143708],[117.47833865770603,0.102474676917026],[117.52164350796664,-0.803723239753268],[116.56004845587947,-1.48766082113621],[116.5337968282752,-2.483517347832901],[116.14808393764866,-4.012726332214022],[116.00085778204911,-3.657037448749058],[114.86480309454456,-4.106984144714396],[114.46865156459506,-3.495703627133828],[113.75567182826407,-3.43916961020652],[113.25699425664752,-3.118775729996905],[112.06812625534067,-3.478392022316051],[111.70329064336005,-2.994442233902654],[111.04824018762824,-3.049425957861211],[110.223846063276,-2.934032484553455],[110.07093550012434,-1.592874037282463],[109.571947869914,-1.314906507984475],[109.0918738139225,-0.459506524257094],[108.9526575053282,0.415375474444318],[109.06913618371408,1.341933905437614],[109.66326012577375,2.006466986494956],[109.83022667850881,1.338135687664163],[110.51406090702716,0.773131415200965],[111.15913781132662,0.976478176269481],[111.79754845586041,0.904441229654608],[112.38025190638359,1.410120957846743],[112.85980919805218,1.497790025229904],[113.8058496440195,1.217548732911069],[114.62135542201753,1.430688177898901],[115.13403730678522,2.821481838386234],[115.51907840379198,3.169238389494396],[115.8655172058767,4.3065591495901],[117.01521447150628,4.306094061699469],[117.88203494677018,4.137551377779516]]],[[[129.37099775606094,-2.802154229344595],[130.47134402885177,-3.093764336767634],[130.83483605359282,-3.858472181822776],[129.99054650280817,-3.446300957862796],[129.15524865124235,-3.362636813982248],[128.59068362845363,-3.428679294451264],[127.89889122936235,-3.393435967628207],[128.13587934785284,-2.843650404474971],[129.37099775606094,-2.802154229344595]]],[[[126.87492272349886,-3.790982761249587],[126.18380211802736,-3.607376397316564],[125.98903364471926,-3.177273451351305],[127.00065148326497,-3.129317722184446],[127.24921512258891,-3.45906503663889],[126.87492272349886,-3.790982761249587]]],[[[127.93237755748748,2.174596258956569],[128.00415612194087,1.628531398928345],[128.5945593608755,1.540810655112878],[128.6882487326207,1.132385972494063],[128.63595218314134,0.258485826006194],[128.1201697124361,0.356412665199286],[127.96803429576886,-0.252077325037519],[128.3799988139997,-0.7800037573313],[128.1000159038423,-0.899996433113031],[127.69647464407507,-0.266598402511534],[127.39949018769369,1.011721503092545],[127.60051150930906,1.810690822757195],[127.93237755748748,2.174596258956569]]],[[[122.9275667664518,0.875192368977409],[124.07752241424288,0.917101955566125],[125.0659892111218,1.64325918213153],[125.2405005229715,1.419836127117605],[124.4370353536974,0.427881171058957],[123.6855049988767,0.235593166500891],[122.72308312387287,0.431136786293337],[121.05672488818911,0.381217352699394],[120.18308312386272,0.237246812334234],[120.04086958219548,-0.519657891444837],[120.93590538949073,-1.408905938323393],[121.4758207540762,-0.95596200928513],[123.34056481332846,-0.615672702643138],[123.25839928598441,-1.076213067228309],[122.82271528533161,-0.930950616055853],[122.38852990121529,-1.516858005381117],[121.50827355355551,-1.904482924002458],[122.4545723816843,-3.186058444840924],[122.2718961935325,-3.529500013852712],[123.17096276254655,-4.683693129091701],[123.1623327983538,-5.340603936385996],[122.62851525277875,-5.634591159694466],[122.23639448454801,-5.282933037948268],[122.71956912647701,-4.464171644715826],[121.73823367725436,-4.851331475446543],[121.48946333220127,-4.574552504091265],[121.61917117725386,-4.188477878438682],[120.89818159391766,-3.602105401222794],[120.97238895068878,-2.627642917494939],[120.30545291552986,-2.931603692235733],[120.39004723519167,-4.097579034037274],[120.43071658740537,-5.528241062037793],[119.79654341031949,-5.673400160345665],[119.36690555224489,-5.37987802492782],[119.65360639860017,-4.459417412944973],[119.49883548388601,-3.494411716326532],[119.07834435432704,-3.487021986508793],[118.76776899625287,-2.801999200047718],[119.18097374885869,-2.147103773612805],[119.3233939962551,-1.353147067880464],[119.82599897672587,0.154254462073482],[120.0357019389663,0.566477362465761],[120.88577925016762,1.30922272379685],[121.66681684782696,1.013943589681091],[122.9275667664518,0.875192368977409]]],[[[120.29501427620689,-10.258649997603591],[118.96780846565471,-9.557969252158074],[119.90030968636157,-9.361340427287502],[120.42575564990534,-9.665921319215798],[120.77550174365675,-9.96967538822743],[120.71560875863045,-10.239581394087885],[120.29501427620689,-10.258649997603591]]],[[[121.34166873584651,-8.536739597206072],[122.00736453663043,-8.460620212440148],[122.90353722543607,-8.094234307490765],[122.75698286345632,-8.649807631060696],[121.2544905945701,-8.933666273639957],[119.92439090380958,-8.81041798262384],[119.92092858284605,-8.444858900591122],[120.71509199430757,-8.236964613480914],[121.34166873584651,-8.536739597206072]]],[[[118.26061648974044,-8.362383314653293],[118.87845991422208,-8.280682875199844],[119.12650678922307,-8.705824883665088],[117.97040164598928,-8.906639499551304],[117.27773074754901,-9.040894870645594],[116.74014082241665,-9.032936700072646],[117.0837374207253,-8.45715789147659],[117.6320243673421,-8.449303073768228],[117.90001834520776,-8.09568124759494],[118.26061648974044,-8.362383314653293]]],[[[108.48684614464926,-6.42198495852574],[108.62347863162896,-6.777673841990705],[110.53922732955328,-6.877357679881726],[110.75957563684585,-6.465186455921747],[112.6148112325564,-6.946035658397626],[112.97876834518806,-7.594213148634594],[114.47893517462114,-7.776527601760328],[115.70552697150106,-8.370806573116873],[114.56451134649649,-8.751816908404855],[113.46473351446085,-8.348947442257405],[112.55967247930097,-8.376180922075221],[111.52206139531245,-8.302128594600973],[110.58614953007432,-8.122604668819001],[109.42766727095511,-7.740664157749762],[108.69365522668133,-7.641600437046243],[108.27776329959633,-7.766657403192576],[106.45410200401612,-7.354899590690934],[106.28062422081231,-6.924899997590252],[105.36548628135552,-6.851416110871206],[106.05164594932702,-5.895918877794472],[107.2650085795402,-5.954985039904081],[108.07209109907467,-6.345762220895224],[108.48684614464926,-6.42198495852574]]],[[[104.36999148968489,-1.084843031421059],[104.53949018760221,-1.782371514496766],[104.88789269411402,-2.340425306816705],[105.62211144411697,-2.428843682468099],[106.10859337771265,-3.061776625178965],[105.85744591677414,-4.305524997579774],[105.8176550639094,-5.85235564537242],[104.71038414919144,-5.873284600450632],[103.86821333213078,-5.037314955264996],[102.5842606954069,-4.220258884298183],[102.156173130301,-3.614146009946801],[101.39911339722507,-2.799777113459164],[100.90250288290015,-2.050262139497832],[100.14198082886065,-0.650347588710986],[99.26373986206028,0.183141587724634],[98.97001102091326,1.042882391764536],[98.60135135294306,1.823506577965574],[97.69959760944985,2.45318390544206],[97.17694217324984,3.308790594898596],[96.42401655475726,3.868859768077925],[95.3808760925135,4.970782172053688],[95.29302615761729,5.479820868344788],[95.93686282754174,5.439513251157123],[97.4848820332771,5.246320909033955],[98.36916914265566,4.268370266126396],[99.1425586283358,3.590349636240873],[99.69399783732241,3.174328518075143],[100.64143354696162,2.099381211755741],[101.65801232300734,2.083697414555161],[102.49827111207323,1.398700466310231],[103.07684044801303,0.561361395668868],[103.83839603069836,0.104541734208695],[103.4376452982749,-0.711945896002902],[104.01078860882404,-1.059211521004286],[104.36999148968489,-1.084843031421059]]]]}},{"type":"Feature","properties":{"featurecla":"Admin-0 country","scalerank":1,"labelrank":3,"sovereignt":"Malaysia","sov_a3":"MYS","adm0_dif":0,"level":2,"type":"Sovereign country","tlc":"1","admin":"Malaysia","adm0_a3":"MYS","geou_dif":0,"geounit":"Malaysia","gu_a3":"MYS","su_dif":0,"subunit":"Malaysia","su_a3":"MYS","brk_diff":0,"name":"Malaysia","name_long":"Malaysia","brk_a3":"MYS","brk_name":"Malaysia","brk_group":null,"abbrev":"Malay.","postal":"MY","formal_en":"Malaysia","formal_fr":null,"name_ciawf":"Malaysia","note_adm0":null,"note_brk":null,"name_sort":"Malaysia","name_alt":null,"mapcolor7":2,"mapcolor8":4,"mapcolor9":3,"mapcolor13":6,"pop_est":31949777,"pop_rank":15,"pop_year":2019,"gdp_md":364681,"gdp_year":2019,"economy":"6. Developing region","income_grp":"3. Upper middle income","fips_10":"MY","iso_a2":"MY","iso_a2_eh":"MY","iso_a3":"MYS","iso_a3_eh":"MYS","iso_n3":"458","iso_n3_eh":"458","un_a3":"458","wb_a2":"MY","wb_a3":"MYS","woe_id":23424901,"woe_id_eh":23424901,"woe_note":"Exact WOE match as country","adm0_iso":"MYS","adm0_diff":null,"adm0_tlc":"MYS","adm0_a3_us":"MYS","adm0_a3_fr":"MYS","adm0_a3_ru":"MYS","adm0_a3_es":"MYS","adm0_a3_cn":"MYS","adm0_a3_tw":"MYS","adm0_a3_in":"MYS","adm0_a3_np":"MYS","adm0_a3_pk":"MYS","adm0_a3_de":"MYS","adm0_a3_gb":"MYS","adm0_a3_br":"MYS","adm0_a3_il":"MYS","adm0_a3_ps":"MYS","adm0_a3_sa":"MYS","adm0_a3_eg":"MYS","adm0_a3_ma":"MYS","adm0_a3_pt":"MYS","adm0_a3_ar":"MYS","adm0_a3_jp":"MYS","adm0_a3_ko":"MYS","adm0_a3_vn":"MYS","adm0_a3_tr":"MYS","adm0_a3_id":"MYS","adm0_a3_pl":"MYS","adm0_a3_gr":"MYS","adm0_a3_it":"MYS","adm0_a3_nl":"MYS","adm0_a3_se":"MYS","adm0_a3_bd":"MYS","adm0_a3_ua":"MYS","adm0_a3_un":-99,"adm0_a3_wb":-99,"continent":"Asia","region_un":"Asia","subregion":"South-Eastern Asia","region_wb":"East Asia & Pacific","name_len":8,"long_len":8,"abbrev_len":6,"tiny":-99,"homepart":1,"min_zoom":0,"min_label":3,"max_label":8,"label_x":113.83708,"label_y":2.528667,"ne_id":1159321083,"wikidataid":"Q833","name_ar":"ماليزيا","name_bn":"মালয়েশিয়া","name_de":"Malaysia","name_en":"Malaysia","name_es":"Malasia","name_fa":"مالزی","name_fr":"Malaisie","name_el":"Μαλαισία","name_he":"מלזיה","name_hi":"मलेशिया","name_hu":"Malajzia","name_id":"Malaysia","name_it":"Malaysia","name_ja":"マレーシア","name_ko":"말레이시아","name_nl":"Maleisië","name_pl":"Malezja","name_pt":"Malásia","name_ru":"Малайзия","name_sv":"Malaysia","name_tr":"Malezya","name_uk":"Малайзія","name_ur":"ملائیشیا","name_vi":"Malaysia","name_zh":"马来西亚","name_zht":"馬來西亞","fclass_iso":"Admin-0 country","tlc_diff":null,"fclass_tlc":"Admin-0 country","fclass_us":null,"fclass_fr":null,"fclass_ru":null,"fclass_es":null,"fclass_cn":null,"fclass_tw":null,"fclass_in":null,"fclass_np":null,"fclass_pk":null,"fclass_de":null,"fclass_gb":null,"fclass_br":null,"fclass_il":null,"fclass_ps":null,"fclass_sa":null,"fclass_eg":null,"fclass_ma":null,"fclass_pt":null,"fclass_ar":null,"fclass_jp":null,"fclass_ko":null,"fclass_vn":null,"fclass_tr":null,"fclass_id":null,"fclass_pl":null,"fclass_gr":null,"fclass_it":null,"fclass_nl":null,"fclass_se":null,"fclass_bd":null,"fclass_ua":null,"filename":"MYS.geojson"},"geometry":{"type":"MultiPolygon","coordinates":[[[[100.0857568705271,6.464489447450291],[100.25959638875696,6.642824815289543],[101.07551557821336,6.204867051615921],[101.15421878459387,5.691384182147715],[101.81428185425798,5.810808417174242],[102.14118696493638,6.221636053894628],[102.37114708863521,6.128205064310919],[102.96170535686673,5.524495144061106],[103.38121463421217,4.855001125503748],[103.43857547405612,4.181605536308339],[103.33212202353486,3.726697902842986],[103.42942874554049,3.382868760589005],[103.50244754436889,2.791018581550176],[103.8546741068703,2.515454006353764],[104.24793175661145,1.631141058759084],[104.22881147666348,1.293048000489492],[103.51970747275439,1.226333726400682],[102.57361535035477,1.967115383304687],[101.39063846232919,2.760813706875581],[101.27353966675582,3.270291652841152],[100.69543541870664,3.93913971599487],[100.55740766805502,4.767280381688295],[100.19670617065775,5.312492580583708],[100.30626020711652,6.040561835143905],[100.0857568705271,6.464489447450291]]],[[[117.88203494677018,4.137551377779516],[117.01521447150628,4.306094061699469],[115.8655172058767,4.3065591495901],[115.51907840379198,3.169238389494396],[115.13403730678522,2.821481838386234],[114.62135542201753,1.430688177898901],[113.8058496440195,1.217548732911069],[112.85980919805218,1.497790025229904],[112.38025190638359,1.410120957846743],[111.79754845586041,0.904441229654608],[111.15913781132662,0.976478176269481],[110.51406090702716,0.773131415200965],[109.83022667850881,1.338135687664163],[109.66326012577375,2.006466986494956],[110.3961352885371,1.663774725751395],[111.16885298059748,1.850636704918813],[111.37008100794205,2.697303371588859],[111.7969283386729,2.885896511238059],[112.99561486211522,3.102394924324855],[113.71293541875868,3.893509426281156],[114.20401655482837,4.525873928236805],[114.65959598191353,4.007636826997754],[114.8695573263154,4.348313706881925],[115.34746097215066,4.316636053887009],[115.40570031134361,4.955227565933839],[115.45071048386981,5.447729803891534],[116.22074100145102,6.143191229675566],[116.72510298061971,6.924771429873999],[117.12962609260047,6.92805288332454],[117.64339318244627,6.422166449403249],[117.68907514859231,5.987490139180154],[118.34769127815224,5.708695786965492],[119.18190392463997,5.407835598162207],[119.11069380094176,5.016128241389808],[118.43972700406408,4.966518866389606],[118.6183207540648,4.478202419447555],[117.88203494677018,4.137551377779516]]]]}},{"type":"Feature","properties":{"featurecla":"Admin-0 country","scalerank":1,"labelrank":5,"sovereignt":"Cyprus","sov_a3":"CYP","adm0_dif":0,"level":2,"type":"Sovereign country","tlc":"1","admin":"Cyprus","adm0_a3":"CYP","geou_dif":0,"geounit":"Cyprus","gu_a3":"CYP","su_dif":0,"subunit":"Cyprus","su_a3":"CYP","brk_diff":0,"name":"Cyprus","name_long":"Cyprus","brk_a3":"CYP","brk_name":"Cyprus","brk_group":null,"abbrev":"Cyp.","postal":"CY","formal_en":"Republic of Cyprus","formal_fr":null,"name_ciawf":"Cyprus","note_adm0":null,"note_brk":null,"name_sort":"Cyprus","name_alt":null,"mapcolor7":1,"mapcolor8":2,"mapcolor9":3,"mapcolor13":7,"pop_est":1198575,"pop_rank":12,"pop_year":2019,"gdp_md":24948,"gdp_year":2019,"economy":"6. Developing region","income_grp":"2. High income: nonOECD","fips_10":"CY","iso_a2":"CY","iso_a2_eh":"CY","iso_a3":"CYP","iso_a3_eh":"CYP","iso_n3":"196","iso_n3_eh":"196","un_a3":"196","wb_a2":"CY","wb_a3":"CYP","woe_id":-90,"woe_id_eh":23424994,"woe_note":"WOE lists as subunit of united Cyprus","adm0_iso":"CYP","adm0_diff":null,"adm0_tlc":"CYP","adm0_a3_us":"CYP","adm0_a3_fr":"CYP","adm0_a3_ru":"CYP","adm0_a3_es":"CYP","adm0_a3_cn":"CYP","adm0_a3_tw":"CYP","adm0_a3_in":"CYP","adm0_a3_np":"CYP","adm0_a3_pk":"CYP","adm0_a3_de":"CYP","adm0_a3_gb":"CYP","adm0_a3_br":"CYP","adm0_a3_il":"CYP","adm0_a3_ps":"CYP","adm0_a3_sa":"CYP","adm0_a3_eg":"CYP","adm0_a3_ma":"CYP","adm0_a3_pt":"CYP","adm0_a3_ar":"CYP","adm0_a3_jp":"CYP","adm0_a3_ko":"CYP","adm0_a3_vn":"CYP","adm0_a3_tr":"CYP","adm0_a3_id":"CYP","adm0_a3_pl":"CYP","adm0_a3_gr":"CYP","adm0_a3_it":"CYP","adm0_a3_nl":"CYP","adm0_a3_se":"CYP","adm0_a3_bd":"CYP","adm0_a3_ua":"CYP","adm0_a3_un":-99,"adm0_a3_wb":-99,"continent":"Asia","region_un":"Asia","subregion":"Western Asia","region_wb":"Europe & Central Asia","name_len":6,"long_len":6,"abbrev_len":4,"tiny":-99,"homepart":1,"min_zoom":0,"min_label":4.5,"max_label":9.5,"label_x":33.084182,"label_y":34.913329,"ne_id":1159320533,"wikidataid":"Q229","name_ar":"قبرص","name_bn":"সাইপ্রাস","name_de":"Republik Zypern","name_en":"Cyprus","name_es":"Chipre","name_fa":"قبرس","name_fr":"Chypre","name_el":"Κύπρος","name_he":"קפריסין","name_hi":"साइप्रस","name_hu":"Ciprus","name_id":"Siprus","name_it":"Cipro","name_ja":"キプロス","name_ko":"키프로스","name_nl":"Cyprus","name_pl":"Cypr","name_pt":"Chipre","name_ru":"Кипр","name_sv":"Cypern","name_tr":"Kıbrıs Cumhuriyeti","name_uk":"Кіпр","name_ur":"قبرص","name_vi":"Cộng hòa Síp","name_zh":"塞浦路斯","name_zht":"賽普勒斯","fclass_iso":"Admin-0 country","tlc_diff":null,"fclass_tlc":"Admin-0 country","fclass_us":null,"fclass_fr":null,"fclass_ru":null,"fclass_es":null,"fclass_cn":null,"fclass_tw":null,"fclass_in":null,"fclass_np":null,"fclass_pk":null,"fclass_de":null,"fclass_gb":null,"fclass_br":null,"fclass_il":null,"fclass_ps":null,"fclass_sa":null,"fclass_eg":null,"fclass_ma":null,"fclass_pt":null,"fclass_ar":null,"fclass_jp":null,"fclass_ko":null,"fclass_vn":null,"fclass_tr":null,"fclass_id":null,"fclass_pl":null,"fclass_gr":null,"fclass_it":null,"fclass_nl":null,"fclass_se":null,"fclass_bd":null,"fclass_ua":null,"filename":"CYP.geojson"},"geometry":{"type":"Polygon","coordinates":[[[32.73178022637745,35.14002594658844],[32.919572381326134,35.08783274997364],[33.19097700372305,35.17312470147138],[33.3838334490363,35.16271190036457],[33.45592207208347,35.10142365166641],[33.47581749851585,35.000344550103506],[33.5256852556775,35.03868846286407],[33.675391880027064,35.01786286065045],[33.86643965021011,35.09359467217419],[33.97361657078346,35.058506374648005],[34.00488081232004,34.97809784600186],[32.97982710137845,34.57186941175544],[32.49029625827754,34.701654771456475],[32.25666710788596,35.10323232679663],[32.73178022637745,35.14002594658844]]]}},{"type":"Feature","properties":{"featurecla":"Admin-0 country","scalerank":1,"labelrank":2,"sovereignt":"India","sov_a3":"IND","adm0_dif":0,"level":2,"type":"Sovereign country","tlc":"1","admin":"India","adm0_a3":"IND","geou_dif":0,"geounit":"India","gu_a3":"IND","su_dif":0,"subunit":"India","su_a3":"IND","brk_diff":0,"name":"India","name_long":"India","brk_a3":"IND","brk_name":"India","brk_group":null,"abbrev":"India","postal":"IND","formal_en":"Republic of India","formal_fr":null,"name_ciawf":"India","note_adm0":null,"note_brk":null,"name_sort":"India","name_alt":null,"mapcolor7":1,"mapcolor8":3,"mapcolor9":2,"mapcolor13":2,"pop_est":1366417754,"pop_rank":18,"pop_year":2019,"gdp_md":2868929,"gdp_year":2019,"economy":"3. Emerging region: BRIC","income_grp":"4. Lower middle income","fips_10":"IN","iso_a2":"IN","iso_a2_eh":"IN","iso_a3":"IND","iso_a3_eh":"IND","iso_n3":"356","iso_n3_eh":"356","un_a3":"356","wb_a2":"IN","wb_a3":"IND","woe_id":23424848,"woe_id_eh":23424848,"woe_note":"Exact WOE match as country","adm0_iso":"IND","adm0_diff":null,"adm0_tlc":"IND","adm0_a3_us":"IND","adm0_a3_fr":"IND","adm0_a3_ru":"IND","adm0_a3_es":"IND","adm0_a3_cn":"IND","adm0_a3_tw":"IND","adm0_a3_in":"IND","adm0_a3_np":"IND","adm0_a3_pk":"IND","adm0_a3_de":"IND","adm0_a3_gb":"IND","adm0_a3_br":"IND","adm0_a3_il":"IND","adm0_a3_ps":"IND","adm0_a3_sa":"IND","adm0_a3_eg":"IND","adm0_a3_ma":"IND","adm0_a3_pt":"IND","adm0_a3_ar":"IND","adm0_a3_jp":"IND","adm0_a3_ko":"IND","adm0_a3_vn":"IND","adm0_a3_tr":"IND","adm0_a3_id":"IND","adm0_a3_pl":"IND","adm0_a3_gr":"IND","adm0_a3_it":"IND","adm0_a3_nl":"IND","adm0_a3_se":"IND","adm0_a3_bd":"IND","adm0_a3_ua":"IND","adm0_a3_un":-99,"adm0_a3_wb":-99,"continent":"Asia","region_un":"Asia","subregion":"Southern Asia","region_wb":"South Asia","name_len":5,"long_len":5,"abbrev_len":5,"tiny":-99,"homepart":1,"min_zoom":0,"min_label":1.7,"max_label":6.7,"label_x":79.358105,"label_y":22.686852,"ne_id":1159320847,"wikidataid":"Q668","name_ar":"الهند","name_bn":"ভারত","name_de":"Indien","name_en":"India","name_es":"India","name_fa":"هند","name_fr":"Inde","name_el":"Ινδία","name_he":"הודו","name_hi":"भारत","name_hu":"India","name_id":"India","name_it":"India","name_ja":"インド","name_ko":"인도","name_nl":"India","name_pl":"Indie","name_pt":"Índia","name_ru":"Индия","name_sv":"Indien","name_tr":"Hindistan","name_uk":"Індія","name_ur":"بھارت","name_vi":"Ấn Độ","name_zh":"印度","name_zht":"印度","fclass_iso":"Admin-0 country","tlc_diff":null,"fclass_tlc":"Admin-0 country","fclass_us":null,"fclass_fr":null,"fclass_ru":null,"fclass_es":null,"fclass_cn":null,"fclass_tw":null,"fclass_in":null,"fclass_np":null,"fclass_pk":null,"fclass_de":null,"fclass_gb":null,"fclass_br":null,"fclass_il":null,"fclass_ps":null,"fclass_sa":null,"fclass_eg":null,"fclass_ma":null,"fclass_pt":null,"fclass_ar":null,"fclass_jp":null,"fclass_ko":null,"fclass_vn":null,"fclass_tr":null,"fclass_id":null,"fclass_pl":null,"fclass_gr":null,"fclass_it":null,"fclass_nl":null,"fclass_se":null,"fclass_bd":null,"fclass_ua":null,"filename":"IND.geojson"},"geometry":{"type":"Polygon","coordinates":[[[97.32711388549004,28.26158274994634],[97.40256147663614,27.882536119085444],[97.0519885599681,27.69905894623315],[97.1339990580153,27.083773505149964],[96.41936567585097,27.264589341739224],[95.12476769407496,26.5735720891323],[95.1551534362626,26.001307277932085],[94.60324913938538,25.162495428970402],[94.55265791217164,24.675238348890336],[94.10674197792507,23.85074087167348],[93.3251876159428,24.078556423432204],[93.28632693885928,23.043658352139005],[93.06029422401463,22.70311066333557],[93.16612755734837,22.278459580977103],[92.67272098182556,22.041238918541254],[92.14603478390681,23.627498684172593],[91.86992760617132,23.624346421802784],[91.70647505083211,22.985263983649187],[91.15896325069973,23.50352692310439],[91.46772993364368,24.072639471934792],[91.91509280799443,24.130413723237112],[92.37620161333481,24.976692816664965],[91.79959598182208,25.147431748957317],[90.87221072791212,25.132600612889547],[89.92069258012185,25.26974986419218],[89.83248091019962,25.96508209889548],[89.35509402868729,26.014407253518073],[88.56304935094977,26.446525580342723],[88.2097892598025,25.768065700782714],[88.93155398962308,25.238692328384776],[88.30637251175602,24.866079413344206],[88.08442223506242,24.501657212821925],[88.69994022009092,24.23371491138856],[88.52976972855379,23.631141872649167],[88.87631188350309,22.87914642993783],[89.03196129756623,22.055708319582976],[88.88876590368542,21.690588487224748],[88.20849734899522,21.703171698487807],[86.97570438024027,21.49556163175521],[87.03316857294887,20.743307806882413],[86.49935102737379,20.151638495356607],[85.0602657409097,19.4785788029711],[83.94100589390001,18.302009792549725],[83.18921715691785,17.67122142177898],[82.19279218946592,17.016636053937816],[82.19124189649719,16.556664130107848],[81.69271935417748,16.310219224507904],[80.79199913933014,15.951972357644493],[80.32489586784388,15.89918488205835],[80.02506920768644,15.136414903214147],[80.2332735533904,13.835770778859981],[80.28629357292186,13.006260687710835],[79.8625468281285,12.056215318240888],[79.85799930208682,10.35727509199711],[79.340511509116,10.30885427493962],[78.88534549348918,9.546135972527722],[79.1897196796883,9.216543687370148],[78.2779407083305,8.933046779816934],[77.94116539908435,8.252959092639742],[77.53989790233794,7.965534776232332],[76.59297895702167,8.89927623131419],[76.13006147655108,10.299630031775521],[75.7464673196485,11.308250637248307],[75.39610110870959,11.781245022015824],[74.86481570831683,12.741935736537897],[74.61671715688354,13.99258291264968],[74.44385949086723,14.617221787977698],[73.5341992532334,15.99065216721496],[73.11990929554943,17.928570054592498],[72.82090945830865,19.208233547436166],[72.8244751321368,20.419503282141534],[72.6305334817454,21.356009426351008],[71.17527347197395,20.757441311114235],[70.4704586119451,20.877330634031384],[69.16413008003883,22.0892980005727],[69.6449276060824,22.450774644454338],[69.34959679553435,22.84317963306269],[68.1766451353734,23.69196503345671],[68.84259931831878,24.35913361256094],[71.04324018746823,24.3565239527302],[70.84469933460284,25.21510203704352],[70.2828731627256,25.72222870533983],[70.16892662952202,26.491871649678842],[69.51439293811313,26.940965684511372],[70.61649620960193,27.989196275335868],[71.77766564320032,27.913180243434525],[72.8237516620847,28.961591701772054],[73.45063846221743,29.97641347911987],[74.42138024282028,30.979814764931177],[74.40592898956501,31.69263947196528],[75.25864179881322,32.2711054550405],[74.45155927927871,32.7648996038055],[74.10429365427734,33.44147329358685],[73.74994835805197,34.31769887952785],[74.24020267120497,34.748887030571254],[75.75706098826834,34.50492259372132],[76.87172163280403,34.653544012

The patch goes on, but was cut here: fetch the commit with git to see it whole.