- name
- leaflet-canvas-choropleth
- description
- Patrón completo de choropleth interactivo con Leaflet + Canvas renderer. Miles de polígonos sin lag. 4 escalas de color, pane system, lazy dataset switching. Base para dashboards geodatos vanilla JS.
- version
- 1.0.0
- author
- Mastermind
- tags
- ["leaflet","choropleth","canvas","geodata","map","vanilla-js","dashboard"]
- source
- espanatlas.es
# Leaflet Canvas Choropleth — Patrón de Referencia
## Cuándo usar
- Dashboard con mapa de áreas coloreadas por datos (municipios, barrios, países)
- >200 polígonos (Canvas obligatorio)
- Cambio de métrica en tiempo real
- Datos vanilla JS sin frameworks
## Stack mínimo
```html
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/topojson-client@3/dist/topojson-client.min.js"></script>
```
## 1. Mapa base con Canvas renderer
```javascript
// CRÍTICO: preferCanvas:true + renderer: L.canvas()
const map = L.map('map', {
center: [40.1, -3.7],
zoom: 6,
preferCanvas: true, // Canvas por defecto
zoomSnap: 0.25,
zoomDelta: 0.25,
wheelPxPerZoomLevel: 80 // zoom más suave con rueda
});
// Basemap dark
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png', {
attribution: '© CartoDB © OSM',
maxZoom: 19,
subdomains: 'abcd'
}).addTo(map);
```
## 2. Pane system (z-index controlado)
```javascript
// Labels siempre encima del choropleth
map.createPane('labelsPane');
map.getPane('labelsPane').style.zIndex = 450;
map.getPane('labelsPane').style.pointerEvents = 'none';
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_only_labels/{z}/{x}/{y}{r}.png', {
pane: 'labelsPane', attribution: '', maxZoom: 19, subdomains: 'abcd'
}).addTo(map);
```
**Por qué:** Sin panes, las etiquetas del basemap quedan debajo del choropleth. Con pane, controlas el orden de capas independientemente del orden de inserción.
## 3. 4 escalas de color
```javascript
// Escala RdBu divergente (ColorBrewer 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]
];
// 1. DIVERGENTE: azul → blanco → rojo (variaciones porcentuales)
function getColor(val, lo, hi) {
if (val == null) return '#1e293b';
const absMax = Math.max(Math.abs(lo), Math.abs(hi), 0.001);
const t = Math.max(-1, Math.min(1, val / absMax));
const s = (t + 1) / 2; // normalizar -1..1 → 0..1
const idx = s * (STOPS.length - 1);
const li = Math.floor(idx), hi_i = Math.min(li + 1, STOPS.length - 1);
const f = idx - li;
const [r, g, b] = [0,1,2].map(i =>
Math.round(STOPS[li][i] + (STOPS[hi_i][i] - STOPS[li][i]) * f)
);
return `rgb(${r},${g},${b})`;
}
// 2. SECUENCIAL: blanco → rojo (tasas absolutas)
function getColorSeq(val, lo, hi) {
if (val == null) return '#1e293b';
const t = Math.max(0, Math.min(1, (val - lo) / (hi - lo || 1)));
const idx = 4 + t * 4; // usa stops 4→8 (blanco→rojo)
const li = Math.floor(idx), hi_i = Math.min(li + 1, STOPS.length - 1);
const f = idx - li;
const [r, g, b] = [0,1,2].map(i =>
Math.round(STOPS[li][i] + (STOPS[hi_i][i] - STOPS[li][i]) * f)
);
return `rgb(${r},${g},${b})`;
}
// 3. SCORE: rojo → verde (0-100, scores/índices)
function getColorScore(val) {
if (val == null || !isFinite(val)) return '#1e293b';
const t = Math.max(0, Math.min(1, Number(val) / 100));
const stops = [[220,38,38],[249,115,22],[245,158,11],[132,204,22],[22,163,74]];
const idx = t * (stops.length - 1);
const li = Math.floor(idx), hi_i = Math.min(li + 1, stops.length - 1);
const f = idx - li;
const [r,g,b] = [0,1,2].map(i =>
Math.round(stops[li][i] + (stops[hi_i][i] - stops[li][i]) * f)
);
return `rgb(${r},${g},${b})`;
}
// 4. RISK: verde → rojo oscuro (0-100, riesgos)
function getColorRisk(val) {
if (val == null || !isFinite(val)) return '#1e293b';
const t = Math.max(0, Math.min(1, Number(val) / 100));
const stops = [[22,163,74],[132,204,22],[245,158,11],[249,115,22],[220,38,38],[127,29,29]];
const idx = t * (stops.length - 1);
const li = Math.floor(idx), hi_i = Math.min(li + 1, stops.length - 1);
const f = idx - li;
const [r,g,b] = [0,1,2].map(i =>
Math.round(stops[li][i] + (stops[hi_i][i] - stops[li][i]) * f)
);
return `rgb(${r},${g},${b})`;
}
// Percentil para rangos robustos
function pct95(arr, p) {
const s = arr.filter(v => v != null).sort((a,b) => a-b);
return s[Math.floor(s.length * p / 100)] ?? 0;
}
```
## 4. Categorías con colores fijos
```javascript
const CAT_COLORS = {
elecciones: { PSOE:'#ef4444', PP:'#2563eb', VOX:'#65a30d', SUMAR:'#ec4899' },
tipos: { A:'#f87171', B:'#f97316', C:'#fbbf24', D:'#94a3b8', E:'#34d399' },
};
// Función para buscar color categórico
function categoricalColor(metric, value) {
return CAT_COLORS[metric]?.[value] || '#94a3b8';
}
```
## 5. GeoJSON layer con Canvas + estilo dinámico
```javascript
let geoLayer, curMetric = 'variacion', lo = 0, hi = 0;
function styleF(feat) {
const d = feat.properties.data || {};
let fill;
if (curMetric in CAT_COLORS) {
fill = categoricalColor(curMetric, d[curMetric]);
} else if (curMetric.includes('score') || curMetric.includes('index')) {
fill = getColorScore(d[curMetric]);
} else if (curMetric.includes('risk') || curMetric.includes('flood')) {
fill = getColorRisk(d[curMetric]);
} else {
fill = getColor(d[curMetric], lo, hi); // divergente por defecto
}
return {
fillColor: fill,
fillOpacity: d.cod ? 0.83 : 0.07,
color: '#0f172a',
weight: 0.35,
opacity: 0.8
};
}
// Crear layer una sola vez
geoLayer = L.geoJSON(geoData, {
style: styleF,
onEachFeature: (feat, layer) => {
layer.on('mouseover', () => layer.setStyle({ weight: 2, color: '#c7d2fe', fillOpacity: 0.95 }));
layer.on('mouseout', () => geoLayer.resetStyle(layer));
layer.on('click', () => showMunicipalityInfo(feat.properties.cod));
},
renderer: L.canvas() // ← CRÍTICO para rendimiento
}).addTo(map);
```
## 6. Cambio de métrica SIN reconstruir (el truco clave)
```javascript
// NO HACER (lento):
// map.removeLayer(geoLayer);
// geoLayer = L.geoJSON(nuevoGeo, { style: styleF }).addTo(map);
// SÍ HACER (10ms):
async function updateMap() {
curMetric = document.getElementById('metric-select').value;
// Cargar dataset si no está cacheado
const ds = datasetForMetric(curMetric);
if (DATASET_STATUS[ds] !== 'loaded') {
await loadDataset(ds);
}
// Calcular rangos
const vals = Object.values(IDX).map(d => d[curMetric]).filter(Number.isFinite);
lo = pct95(vals, 5);
hi = pct95(vals, 95);
// RE-COLOREAR sin tocar geometría ← ¡la magia!
geoLayer.setStyle(styleF);
// Actualizar leyenda
updateLegend();
}
```
## 7. Tooltip informativo
```javascript
function onEachFeature(feat, layer) {
layer.bindTooltip(() => {
const d = feat.properties;
return `<b>${d.nom}</b><br>${curMetric}: ${formatValue(d[curMetric])}`;
}, { sticky: true, className: 'custom-tooltip' });
}
```
## Pitfalls
1. **Canvas + interactividad:** Los eventos de mouse en Canvas son por bounding box, no por forma exacta. Para polígonos muy pequeños, usar `tolerance` en `L.canvas({tolerance: 5})`
2. **No recrear el layer:** `setStyle()` es 50x más rápido que destruir+crear
3. **Percentiles, no min/max:** Usar pct5/pct95 para rangos, no min/max (outliers rompen la escala)
4. **Liberar memoria TopoJSON:** `topo = null` tras `topojson.feature()`
5. **Canvas no soporta CSS:** Los estilos CSS no funcionan en Canvas renderer
6. **clientWidth=0 en tabs lazy:** Cuando un contenedor está oculto (`display:none`), `container.clientWidth` es 0 → invisibles. Fix: `Math.max(600, container.clientWidth)` o `requestAnimationFrame` + retry.
7. **GeoJSON property names:** SpainLayers usa `id`/`name`, no `CODMUN`/`NOMBRE`. Siempre verificar y mapear propiedades al cargar.
8. **❌ NUNCA circle markers para choropleth** — David: "los cp en vez de puntos deberían ser áreas". Polígonos reales SIEMPRE. Si no hay GeoJSON, generar centroides con Douglas-Peucker como último recurso.
9. **❌ NUNCA dark theme para dashboards públicos** — David odia fondos oscuros y neón. Fondo claro, sidebar blanca, acentos en borders.
10. **❌ NUNCA "liquid glass" genérico** — backdrop-filter masivo = "diseño de IA". Usar cards sólidas con sombra sutil.
## GeoJSON de municipios españoles — SpainLayers
### Fuente fiable: `AlexGPlay/SpainLayers`
Tras probar 15+ fuentes (datos.comunidad.madrid, opendata-pmm, semmler23, Fonsloper, GADM, etc.), la **única fuente que funciona** para GeoJSON de municipios españoles a nivel provincial:
```
https://raw.githubusercontent.com/AlexGPlay/SpainLayers/master/municipalities/{PROVINCIA_CODE}.geojson
```
- **Madrid:** `28.geojson` → 179 municipios, ~5.9MB
- **Barcelona:** `08.geojson`
- **Valencia:** `46.geojson`
- **Código provincia:** 2 dígitos (01-52 + 57)
### Propiedades (¡OJO al mapeo!)
```json
{
"type": "Feature",
"properties": {
"id": "28079", // ← usa "id", NO "CODMUN"
"name": "Madrid" // ← usa "name", NO "NOMBRE"
},
"geometry": { "type": "MultiPolygon", "coordinates": [...] }
}
```
**Pitfall crítico:** Si tu dataset usa `CODMUN` o `NOMBRE`, hay que mapear:
```javascript
// Mapear propiedades al cargar
geo.features.forEach(f => {
f.properties.CODMUN = f.properties.id;
f.properties.NOMBRE = f.properties.name;
});
```
GitHubで見る