- name
- espanatlas-architecture
- description
- Arquitectura completa de España Atlas (espanatlas.es) — atlas municipal español con 8.132 municipios, choropleth Leaflet, TopoJSON, 20+ datasets, Canvas renderer, lazy loading. Patrón de referencia para dashboards geodatos vanilla JS.
- version
- 1.0.0
- author
- Mastermind
- tags
- ["espanatlas","geodatos","leaflet","choropleth","dashboard","vanilla-js","topjson","cartodb"]
# España Atlas — Arquitectura de Referencia
## Resumen
**espanatlas.es** es un atlas municipal español con 8.132 municipios, 20+ datasets temáticos, rankings, correlaciones y análisis automatizados. Toda la app está en **un solo HTML** (~1MB, 16.379 líneas) con JS vanilla, sin frameworks.
**Autor original:** @CalcetinLetal
**Fuentes:** INE, SEPE, Min. Interior, AEAT, AEMET, SNCZI, DGT
## Stack Tecnológico
| Componente | Tecnología | Versión |
|---|---|---|
| **Mapa** | Leaflet.js | 1.9.4 |
| **Basemap** | CartoDB dark (nolabels + labels separados) | — |
| **Geometrías** | TopoJSON → GeoJSON (topojson-client 3.x) | — |
| **Renderer** | L.canvas() (NO SVG — rendimiento con miles de polígonos) | — |
| **Framework** | Ninguno — vanilla JS + DOM directo | — |
| **Hosting** | Estático (sin backend) | — |
## Arquitectura de Datos
### Datos geográficos
- **Archivo:** `./data/municipios_topo.json` (~5.9 MB TopoJSON)
- **Objeto:** `topo.objects.municipios`
- **Conversión:** `topojson.feature(topo, topo.objects.municipios)` → FeatureCollection
- **Cada feature** tiene `properties.cod` (código municipal INE, 5 dígitos)
- Se liberan memoria inmediatamente: `topo = null` tras conversión
### Datos temáticos (20+ archivos lazy-loaded)
Cada dataset es un JSON array con objetos `[{cod, nom, prov, acom, ...metricas}]`:
| Archivo | Contenido | ~Tamaño |
|---|---|---|
| `budget_municipal_metrics_sparse_part1.json` | Presupuestos (parte 1) | ~5 MB |
| `budget_municipal_metrics_sparse_part2.json` | Presupuestos (parte 2) | ~5 MB |
| `health_municipal_metrics.json` | Salud (7.4 MB) | 8.132 filas |
| `elecciones_2023.json` | Elecciones municipales 2023 | ~10 MB |
| `census_income_municipal.json` | Renta censal | — |
| `business_type_metrics.json` | Tejido empresarial | — |
| `immigration_municipal_metrics.json` | Inmigración | — |
| `roma_municipal_metrics.json` | Población gitana | — |
| `air_quality_municipal_metrics.json` | Calidad del aire | — |
| `road_safety_municipal_metrics.json` | Siniestralidad vial | — |
| `wildfire_municipal_metrics.json` | Incendios forestales | — |
| `irpf_municipal_metrics.json` | IRPF AEAT | — |
| `flood_municipal_metrics.json` | Inundaciones SNCZI | — |
| `broadband_municipal_metrics.json` | Conectividad digital | — |
| `aemet_temperature_municipal_metrics.json` | Temperatura AEMET | — |
| `policy_budget_model.json` | Modelo política-presupuesto | — |
| `nap_gtfs_municipal_metrics.json` | Transporte público GTFS | — |
| `education_centers_municipal_metrics.json` | Centros educativos | — |
| `historical_population_ageing_metrics.json` | Historia municipal | — |
| `birthplace_relation_metrics.json` | Origen residencial | — |
### Datos complementarios
- **Secciones censales:** Lazy-loaded desde INE GeoServer (`geoserver/ogc/features/v1/collections/WMS_INE_SECCIONES_G01`)
- **Pirámides demográficas:** `./data/pyramids_proj_2035_2050/index.json`
- **Secciones censales geo:** `./data/census_sections_2023/index.json` + archivos por provincia
- **Geopolítica:** GeoJSON embebido para Sáhara Occidental y Palestina
## Patrón de Carga de Datos
```javascript
// 1. Datos principales se cargan por categorías (lazy)
const DATASETS = {
base: async () => {
// Dataset core: siempre se carga
await fetchWithProgress('./data/main_metrics.json', 'Base', 0, 8);
},
health: async () => mergeMetricRows(
await fetchWithProgress('./data/health_municipal_metrics.json', 'Salud', 8, 18),
'Salud'
),
elec: async () => { /* elecciones */ },
// ... 20+ datasets
};
// 2. Carga escalonada para no bloquear
async function preloadDatasetsStaggered(keys, onDone) {
for (const key of keys) {
await runIdle(() => ensureDataset(key));
}
onDone?.();
}
// 3. requestIdleCallback para no bloquear UI
function runIdle(fn, timeout = 2000) {
if ('requestIdleCallback' in window) requestIdleCallback(fn, { timeout });
else setTimeout(fn, 100);
}
```
## Sistema de Colores
### Paleta RdBu (divergente) — 9 paradas
```javascript
const STOPS = [
[5,48,97],[33,102,172],[67,147,195],[146,197,222],[247,247,247],
[244,165,130],[214,96,77],[178,24,43],[103,0,31]
];
// Azul (negativo) → Blanco (0) → Rojo (positivo)
```
### Funciones de color (4 escalas)
1. **`getColor(val, lo, hi)`** — Divergente RdBu para variaciones
2. **`getColorSeq(val, lo, hi)`** — Secuencial blanco→rojo para tasas absolutas
3. **`getColorScore(val)`** — Verde→rojo (0-100) para scores/índices
4. **`getColorRisk(val)`** — Verde→rojo→rojo oscuro (0-100) para riesgos
### Colores categóricos
```javascript
const CAT_COLORS = {
elec23_ganador: { PSOE:'#ef4444', PP:'#2563eb', VOX:'#65a30d', SUMAR:'#ec4899', ... },
tipoCluster: {'0':'#f87171','1':'#f97316','2':'#fbbf24','3':'#94a3b8','4':'#34d399','5':'#60a5fa','6':'#818cf8'},
crecTipo: { dinamico:'#4ade80', atraccion:'#60a5fa', nativo:'#34d399', ... },
};
```
### Rank map (bandas percentiles)
```javascript
const RANK_BAND_COLORS = {
top1:'#14532d', top5:'#15803d', top10:'#22c55e', high:'#86efac',
mid:'#94a3b8',
low:'#fdba74', bot10:'#f97316', bot5:'#dc2626', bot1:'#7f1d1d'
};
```
## Gestión de Páneas Leaflet
```javascript
// Labels pane — z-index 450, siempre encima del choropleth
map.createPane('labelsPane');
map.getPane('labelsPane').style.zIndex = 450;
map.getPane('labelsPane').style.pointerEvents = 'none';
L.tileLayer('dark_only_labels', { pane: 'labelsPane' }).addTo(map);
// Census pane — z-index 430, entre choropleth y labels
map.createPane('censusPane');
map.getPane('censusPane').style.zIndex = 430;
// Territory pane — z-index 455, encima de todo
map.createPane('territoryPane');
map.getPane('territoryPane').style.zIndex = 455;
```
## Canvas Renderer (CRÍTICO para rendimiento)
```javascript
// SIEMPRE usar Canvas para miles de polígonos
geoLayer = L.geoJSON(geo, {
style: styleF,
onEachFeature: onEach,
renderer: L.canvas() // NO L.svg()
}).addTo(map);
```
**¿Por qué?** SVG genera un DOM node por polígono. Con 8.132 municipios = 8.132 nodos SVG → lag. Canvas pinta todo en un solo elemento `<canvas>`.
## Choropleth Pattern
```javascript
function styleF(feat) {
const cod = feat.properties.cod;
const d = cod ? (IDX[cod] || {}) : {};
let fill;
if (RANK_MAP_ON && rankMapAvailable(curM)) {
fill = rankMapColor(d);
} else if (CATEGORICAL_M.has(curM)) {
const cv = metricValueFor(d);
fill = cv != null ? ((CAT_COLORS[curM]?.[cv]) || '#94a3b8') : '#1e293b';
} else if (SCORE_M.has(curM)) {
fill = getColorScore(metricValueFor(d));
} else {
fill = getColor(metricValueFor(d), lo, hi); // divergente por defecto
}
return {
fillColor: fill,
fillOpacity: cod ? 0.83 : 0.07,
color: '#0f172a',
weight: 0.35,
opacity: 0.8
};
}
```
## Layout de UI
### Top bar (controles)
```css
#ctrl {
position: absolute;
top: 12px;
left: 50%; transform: translateX(-50%);
background: rgba(15,23,42,.95);
backdrop-filter: blur(8px);
border-radius: 14px;
border: 1px solid rgba(99,102,241,.3);
box-shadow: 0 8px 32px rgba(0,0,0,.7);
}
```
### Panel lateral (colapsable)
- Secciones: Resumen Nacional, Rankings, Correlaciones, Índices Sintéticos
- Toggle con clase `collapsed` + botón `+`/`-`
- Cada sección tiene `renderTab` lazy
### Búsqueda
- Ctrl+K para buscar municipio
- Panel de selección de mapa con búsqueda por nombre
- Sinónimos para métricas: `MAP_SEARCH_SYNONYMS`
## Funcionalidades Principales
1. **Choropleth interactivo** — cambio de métrica en tiempo real
2. **Rankings** — top 10 / bottom 10 de cualquier métrica
3. **Correlaciones** — scatter plots entre métricas (Chart.js)
4. **Índices Sintéticos** — scoring compuesto 0-100
5. **Municipio Ideal** — filtros combinados para encontrar el "mejor"
6. **Comparación** — `/comparar/?a=XXX&b=YYY`
7. **Municipios similares** — grafo kNN con comunidades Leiden
8. **Secciones censales** — zoom > 8 carga datos del INE
9. **Descarga** — export PNG del mapa
10. **Compartir** — URL con estado codificado (m, cod, lat, lng, z)
## Fuentes de Datos (APIs)
| Fuente | Tipo | Uso |
|---|---|---|
| INE | CSV/API | Población, renta, censos, secciones censales |
| SEPE | CSV | Empleo, paro registrado |
| Min. Interior | CSV | Elecciones municipales |
| AEAT | CSV | IRPF municipal |
| AEMET | API | Temperaturas, calidad del aire |
| SNCZI | WFS | Zonas inundables |
| DGT | CSV | Siniestralidad vial |
| Catastro | API | Valor catastral, tipología |
| GTFS | Archivos | Transporte público |
| BDNS | API | Subvenciones públicas |
## Arquitectura de Rendimiento (Deep Dive)
Cómo 8.132 municipios + 20+ datasets caben en un single-page vanilla JS con TTFB 158ms.
### 1. TopoJSON → GeoJSON con liberación inmediata
TopoJSON comparte bordes entre polígonos vecinos (~70% menos que GeoJSON puro). Se convierte una sola vez y se libera:
```javascript
let topo = await fetch('./data/municipios_topo.json'); // 5.9 MB
const geo = topojson.feature(topo, topo.objects.municipios);
topo = null; // ← libera 5.9MB de memoria inmediatamente
```
**Regla:** Siempre TopoJSON para geometrías. GeoJSON puro = 15-20 MB para los mismos datos.
### 2. Canvas renderer (el truco más importante)
```javascript
L.geoJSON(geo, { renderer: L.canvas() }) // NO L.svg()
```
SVG genera **un nodo DOM por polígono** → 8.132 nodos SVG = lag. Canvas pinta todo en **un solo `<canvas>`** como un videojuego. Diferencia: ~50x en rendimiento para >500 polígonos.
**Regla:** Siempre Canvas para >500 polígonos. SVG solo para <100 con interacciones complejas por feature.
### 3. Hash index `IDX` — acceso O(1) por código
```javascript
let IDX = {};
muni.forEach(d => { IDX[d.cod] = d; });
// Después: IDX['28079'] → acceso directo, sin búsqueda
```
No hay arrays que recorrer para encontrar un municipio. Merge de datasets posteriores usa `Object.assign(IDX[cod], d)` para añadir métricas al objeto existente.
### 4. Re-render sin reconstruir — `setStyle()`
Cuando el usuario cambia de métrica:
```javascript
// ❌ MALO: destruir y recrear la capa completa
map.removeLayer(geoLayer);
geoLayer = L.geoJSON(nuevoGeo, { style: nuevoStyle }).addTo(map);
// ✅ BUENO: re-aplicar estilo a geometría existente
geoLayer.setStyle(styleF); // ← re-colorea 8.132 polígonos SIN tocar geometría
```
La geometría (pesada, 5.9MB) no se toca. Solo cambian los colores (~10ms).
### 5. Sparse rows — compresión de datos tabulares
Los archivos de presupuestos usan formato compacto para evitar repetir nombres de métricas:
```javascript
// Formato sparse: keys se escribe UNA vez, cada fila solo referencia índices
{ keys: ["gastoTotal","inversion","deuda",...], // 50+ nombres, una vez
GitHub에서 보기