/* Mapa de calor de comunas (Valparaíso, RM y O'Higgins). * * La geometría se sirve desde /geo y se baja una sola vez por sesión: son * 241 KB que no cambian nunca. Se dibuja como SVG inline, sin librerías, para * que funcione igual en el monitor de supervisores y en el tablero de guías. * * Uso: * MapaComunas.dibujar(contenedor, comunas, { campo: 'n' }) * donde `comunas` es la lista que arma graficos.resumir() en "comunas_todas": * cada item con { clave, nombre, n, monto, entregadas, rechazadas }. */ window.MapaComunas = (function () { const URL_GEO = '/geo/zona_centro_comunas.geojson'; // Escala secuencial: el tono más oscuro es donde más se concentra const PALETA = ['#cfe3f5', '#9dc7e8', '#68a5d5', '#3a7fbd', '#1b5a95', '#0b3c6b']; let geo = null; let cargando = null; // Misma normalización que graficos.py: sin tildes, en mayúsculas y con los // espacios colapsados. Si las dos no calzan, la comuna se pinta como si no // tuviera entregas. const clave = s => (s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '') .toUpperCase().replace(/\s+/g, ' ').trim(); const esca = s => String(s === null || s === undefined ? '' : s) .replace(/[&<>"]/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[m])); /* Contenido de la etiqueta flotante de una comuna. * * Vive acá y no en cada pantalla para que el tablero de guías y el monitor * de supervisores muestren exactamente lo mismo al pasar el mouse. * `d` es un item de "comunas_todas" y `fmt` formatea moneda. */ function tooltip(d, fmt) { if (!d) return ''; const fila = (etq, val, cls) => `
${etq}${val}
`; let html = `
${esca(d.nombre)}
`; html += fila('Guías', d.n); html += fila('Entregadas', d.entregadas, 'ok'); html += fila('Pendientes', d.pendientes, 'warn'); html += fila('Rechazadas', d.rechazadas, 'bad'); // El porcentaje va sobre lo gestionado —entregadas + rechazadas—, así que // se dice de cuántas: "100%" con 22 guías todavía en ruta se leería como // si el día estuviera cerrado. const gest = (d.entregadas || 0) + (d.rechazadas || 0); html += fila('Cumplimiento', (d.tasa_entrega != null ? d.tasa_entrega : 0) + '% de ' + gest + ' gestionadas'); html += fila('Monto', fmt(d.monto)); if (d.choferes && d.choferes.length) { html += `
Choferes
`; d.choferes.forEach(c => { html += fila(esca(c.nombre), c.n + (c.n === 1 ? ' guía' : ' guías')); }); } if (d.clientes && d.clientes.length) { html += `
Clientes por monto
`; d.clientes.forEach(c => { html += fila(esca(c.nombre), fmt(c.monto)); }); } return html; } function cargar() { if (geo) return Promise.resolve(geo); if (!cargando) { cargando = fetch(URL_GEO) .then(r => { if (!r.ok) throw new Error(r.status); return r.json(); }) .then(d => (geo = d)); } return cargando; } function dibujar(cont, comunas, opciones) { if (!cont) return; const campo = (opciones && opciones.campo) || 'n'; const fmt = (opciones && opciones.fmt) || (v => String(v)); cont.innerHTML = '

Cargando mapa…

'; return cargar().then(() => pintar(cont, comunas || [], campo, fmt)) .catch(() => { cont.innerHTML = '

No se pudo cargar el mapa.

'; }); } function pintar(cont, comunas, campo, fmt) { const datos = {}; comunas.forEach(c => { datos[c.clave] = c; }); // Solo las comunas con movimiento. Al dejar fuera las 90 y tantas sin // despacho, el recuadro se ajusta a donde sí se reparte y el mapa se ve // mucho más grande: en un televisor esa diferencia es la que permite // distinguir una comuna de otra. const features = geo.features.filter(f => (datos[clave(f.properties.comuna)] || {})[campo]); if (!features.length) { cont.innerHTML = '

Todavía no hay despachos con comuna.

'; return; } // Proyección equirectangular con corrección por latitud: sin multiplicar // el largo por cos(lat), Chile sale más ancho de lo que es. let minLon = 180, maxLon = -180, minLat = 90, maxLat = -90; const recorrer = (f, cb) => { const g = f.geometry; const polis = g.type === 'Polygon' ? [g.coordinates] : g.coordinates; polis.forEach(p => p.forEach(anillo => anillo.forEach(cb))); }; features.forEach(f => recorrer(f, c => { if (c[0] < minLon) minLon = c[0]; if (c[0] > maxLon) maxLon = c[0]; if (c[1] < minLat) minLat = c[1]; if (c[1] > maxLat) maxLat = c[1]; })); const kLat = Math.cos((minLat + maxLat) / 2 * Math.PI / 180); const ANCHO = 1000; const esc = ANCHO / ((maxLon - minLon) * kLat); const ALTO = Math.round((maxLat - minLat) * esc); const px = c => ((c[0] - minLon) * kLat * esc).toFixed(1) + ' ' + ((maxLat - c[1]) * esc).toFixed(1); // Cortes por cuantiles y no lineales: una comuna concentra cientos de // guías y la mitad tiene menos de diez; con escala lineal el mapa queda // de un solo tono salvo una mancha. const valores = features .map(f => (datos[clave(f.properties.comuna)] || {})[campo] || 0) .filter(v => v > 0).sort((a, b) => a - b); const cortes = []; for (let i = 1; i < PALETA.length && valores.length; i++) { const v = valores[Math.floor(i * valores.length / PALETA.length)]; if (v > (cortes[cortes.length - 1] || 0)) cortes.push(v); } const tono = v => { let i = 0; while (i < cortes.length && v >= cortes[i]) i++; return PALETA[i]; }; const trazos = features.map(f => { const nombre = f.properties.comuna; const d = datos[clave(nombre)]; const val = d ? (d[campo] || 0) : 0; const g = f.geometry; const polis = g.type === 'Polygon' ? [g.coordinates] : g.coordinates; const ruta = polis.map(p => p.map(anillo => 'M' + anillo.map(px).join('L') + 'Z').join('')).join(''); return ``; }).join(''); // Leyenda: los tramos vacíos que deja el cuantil no se muestran const minimo = valores[0] || 0, maximo = valores[valores.length - 1] || 0; const rangos = []; for (let i = 0; i <= cortes.length; i++) { const desde = i === 0 ? minimo : cortes[i - 1]; const hasta = i < cortes.length ? cortes[i] - 1 : maximo; if (desde > hasta) continue; rangos.push({ color: PALETA[i], texto: desde === hasta ? String(desde) : `${desde} – ${hasta}` }); } cont.innerHTML = `
${trazos}
${rangos.map(x => ` ${x.texto}`).join('')}
`; const lienzo = cont.querySelector('.mapa-lienzo'); const tip = cont.querySelector('.mapa-tip'); lienzo.querySelectorAll('path').forEach(p => { p.addEventListener('mousemove', ev => { tip.innerHTML = tooltip(datos[p.dataset.clave], fmt); tip.style.display = 'block'; const caja = lienzo.getBoundingClientRect(); const x = ev.clientX - caja.left, y = ev.clientY - caja.top; tip.style.left = Math.max(4, Math.min(x + 14, caja.width - tip.offsetWidth - 6)) + 'px'; // Si abajo no cabe, sube: el globo creció y en las comunas del // sur del mapa se salía de la pantalla. const cabeAbajo = y + 14 + tip.offsetHeight <= caja.height; tip.style.top = (cabeAbajo ? y + 14 : Math.max(4, y - tip.offsetHeight - 14)) + 'px'; }); p.addEventListener('mouseleave', () => { tip.style.display = 'none'; }); }); } return { dibujar, tooltip }; })();