| 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
const DATASETS = {
base: async () => {
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 () => { },
};
async function preloadDatasetsStaggered(keys, onDone) {
for (const key of keys) {
await runIdle(() => ensureDataset(key));
}
onDone?.();
}
function runIdle(fn, timeout = 2000) {
if ('requestIdleCallback' in window) requestIdleCallback(fn, { timeout });
else setTimeout(fn, 100);
}
Sistema de Colores
Paleta RdBu (divergente) — 9 paradas
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]
];
Funciones de color (4 escalas)
getColor(val, lo, hi) — Divergente RdBu para variaciones
getColorSeq(val, lo, hi) — Secuencial blanco→rojo para tasas absolutas
getColorScore(val) — Verde→rojo (0-100) para scores/índices
getColorRisk(val) — Verde→rojo→rojo oscuro (0-100) para riesgos
Colores categóricos
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)
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
map.createPane('labelsPane');
map.getPane('labelsPane').style.zIndex = 450;
map.getPane('labelsPane').style.pointerEvents = 'none';
L.tileLayer('dark_only_labels', { pane: 'labelsPane' }).addTo(map);
map.createPane('censusPane');
map.getPane('censusPane').style.zIndex = 430;
map.createPane('territoryPane');
map.getPane('territoryPane').style.zIndex = 455;
Canvas Renderer (CRÍTICO para rendimiento)
geoLayer = L.geoJSON(geo, {
style: styleF,
onEachFeature: onEach,
renderer: L.canvas()
}).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
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);
}
return {
fillColor: fill,
fillOpacity: cod ? 0.83 : 0.07,
color: '#0f172a',
weight: 0.35,
opacity: 0.8
};
}
Layout de UI
Top bar (controles)
#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
- Choropleth interactivo — cambio de métrica en tiempo real
- Rankings — top 10 / bottom 10 de cualquier métrica
- Correlaciones — scatter plots entre métricas (Chart.js)
- Índices Sintéticos — scoring compuesto 0-100
- Municipio Ideal — filtros combinados para encontrar el "mejor"
- Comparación —
/comparar/?a=XXX&b=YYY
- Municipios similares — grafo kNN con comunidades Leiden
- Secciones censales — zoom > 8 carga datos del INE
- Descarga — export PNG del mapa
- 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:
let topo = await fetch('./data/municipios_topo.json');
const geo = topojson.feature(topo, topo.objects.municipios);
topo = null;
Regla: Siempre TopoJSON para geometrías. GeoJSON puro = 15-20 MB para los mismos datos.
2. Canvas renderer (el truco más importante)
L.geoJSON(geo, { renderer: L.canvas() })
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
let IDX = {};
muni.forEach(d => { IDX[d.cod] = d; });
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:
map.removeLayer(geoLayer);
geoLayer = L.geoJSON(nuevoGeo, { style: nuevoStyle }).addTo(map);
geoLayer.setStyle(styleF);
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:
{ keys: ["gastoTotal","inversion","deuda",...],