Saltar al contenido

Como hacer un mapa interactivo con jQuery y SVG

Índice

Debía hacer un mapa interactivo para la Web y lo primero que pensé fue en que parecía la oportunidad perfecta para finalmente aprender a usar D3.js pero luego de ver los requerimientos no vi que valiera la pena.

Lo que el mapa tenía que mostrar eran tres datos por cada municipio del país al momento de pasar por encima el puntero del mouse o hacer click. Además de colorear, según una escala, cada municipio dependiendo el valor de uno de los datos.

El mapa

Ésta fue la parte más difícil para mi, dada mi ignorancia cartográfica. Se de los SVG pero al buscar por un mapa de mi país con bordes de municipios en este formato no lo pude encontrar. Terminé llegando a este sitio y ahí encontré el mapa llamado “Límites Municipales IGN” que cuenta con los 340 municipios y “Límites Departamentales de Guatemala”. Ambos se descargan en formato geoJSON.

Este es el mapa más actualizado a nivel municipal de Guatemala que pude encontrar

Este es el mapa más actualizado a nivel municipal de Guatemala que pude encontrar

Al buscar cómo convertir ese formato a SVG encontré un genial sitio llamado mapshaper que también tiene su aplicación en línea de comandos e instalable.

Cargué los dos mapas, municipal y departamental, como capas separadas

Cargué los dos mapas, municipal y departamental, como capas separadas

Si tu mapa es más simple sólo hay que exportar a SVG y todo listo. El mapa de Guatemala con todos sus 340 municipios no es simple ya que tiene muchas líneas y formas que siguen los bordes de cada municipio y del país en si. Por suerte mapshaper provee una herramienta para simplificar (Simplify), es decir, bajar el nivel de detalle.

Un acercamiento al original

Un acercamiento al original

Un acercamiento a la capa municipal simplificada, luego hice lo mismo para el departamental

Un acercamiento a la capa municipal simplificada, luego hice lo mismo para el departamental

Al final el resultado tiene una forma bastante similar y ya que es sólo para usarse como referencia y no para aplicar técnicas de cartografías o georeferencia o esas cosas que requieren más detalle entonces se agradece la posibilidad de simplificar el mapa. El tamaño del SVG bajó de 3.35MB a 246KB.

A la izquierda el simplificado, a la derecha el original

A la izquierda el simplificado, a la derecha el original

Como a un SVG se le pueden colocar atributos como el resto del documento HTML le agregué primero estilos:

<style id="style2" type="text/css"><![CDATA[

    .depa{
       stroke: black;
       fill: none;
       stroke-width:1px
    }

    .mun{
       stroke: #646464;
       fill: #fefee9;
       stroke-width:0.5px
    }

  ]]></style>

Cuyas clases depa asigné a los departamentos y mun a los municipios. Además, a cada polígono le agregué el id del tipo munXXXX donde mun es por municipo y XXXX es el código del municipio. Este código venía en el geoJSON entonces fue rápida la asignación escribiendo un pequeño programa de Python que busca los id por defecto (limites_municipales.1) y reemplaza por los nuevos (mun1420).

Parte del contenido del geoJSON original

Parte del contenido del geoJSON original

Este ejemplo del mapa de mi país es bastante específico pero sirve para explicar los pasos qué se podrían seguir dependiendo el tipo y complejidad de mapa.

Los JSON

El archivo de datos lleva la siguiente estructura:

{
    "101": "10",
    "102": "10",
    "103": "10",
    ...
}

El de escalas:

{
  "1": {
    "nombre": "Número 1",
    "color": "#feff99"
  },
  "2": {
    "nombre": "Número 2",
    "color": "#ff99ff"
  },
  ...
}

Y el de municipios:

{
  "101": {
    "nombre": "Guatemala"
  },
  "102": {
    "nombre": "Santa Catarina Pinula"
  },
  ...

El HTML y CSS

Siempre he pensado que si es poco CSS no hay problema si se incluye en etiquetas style dentro del mismo HTML:

<!doctype html>
<html lang="es">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
        <style>
            .mun {
                cursor: help;
            }

            .panel_info {
                background-color: rgba(255,255,255, .8);
                padding: 5px;
                font-size: 12px;
                font-family: Helvetica, Arial, sans-serif;
                position: absolute;
                border: 1px solid #333;
                color: #333;
                white-space: nowrap;
            }

            .panel_info::first-line {
                font-weight: bold;
            }
            
            svg {
                display: block;
                margin-left: auto;
                margin-right: auto;
            }
            
            #movil {
                margin-left:auto; 
                margin-right:auto;
                width: 25%
            }
            
            #leyenda_escala {
                width: 25%;
                margin-left:auto; 
                margin-right:auto;
            }
            
            #leyenda_escala .color {
                width: 50%;
                border: solid 1px black
            }
        </style>
        <title>Mapa interactivo SVG y jQuery</title>
    </head>
    <body>
        <div class="mapa"></div>
        <div id="movil"></div>
        <div>
            <table id="leyenda_escala">
                <thead>
                    <tr>
                        <th scope="col"></th>
                        <th scope="col"></th>
                    </tr>
                </thead>
                <tbody>
                </tbody>
            </table>
        </div>
        
        <script src="https://code.jquery.com/jquery-3.5.1.min.js" integrity="sha384-ZvpUoO/+PpLXR1lu4jmpXWu80pZlYUAfxl5NsBMWOEPSjUn/6Z/hRTt8+pR6L4N2" crossorigin="anonymous"></script>
        <script src="interaccion.js"></script>
    </body>
</html>

mapa es donde el SVG va a ser insertado. movil es para mostrar los datos de cada municipio el ser pulsado, ya que según mis conocimiento en un teléfono no se puede hacer mouseover porque normalmente no hay mouse. leyenda_escala donde se va a mostrar la tabla con los colores y nombres de las escalas en la leyenda del mapa.

JavaScript

jQuery ha decaído en los últimos años, a mi me sigue sirviendo en una mezcla con JavaScript plano. Más aún me ha sido útil el jQuery desde que salió la versión ya que se pueden usar promises y poner un orden a las llamadas ajax:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
$(document).ready(function () {

    var municipios, escalas, datos;
    
    function recuperarJsons(respuestaM, respuestaE, respuestaD){
        municipios = respuestaM;
        escalas = respuestaE;
        datos = respuestaD;
    }
    
    $.when(
        $.ajax({
            url: "municipios.json",
            dataType: 'json',
            type: 'GET'}),
        $.ajax({
            url: "escalas.json",
            dataType: 'json',
            type: 'GET'}),
        $.ajax({
            url: "datos.json",
            dataType: 'json',
            type: 'GET'}),
        $.ajax({
            url: "limites_municipales_codigos.svg",
            dataType: 'html',
            type: 'GET'})
        )
        .done(function(municipios, escalas, datos, mapa){
            recuperarJsons(municipios[0], escalas[0], datos[0]);
            $(".mapa").html(mapa[0]);
            rellenarMapa();
            generarLeyenda();
        });    
    
    function rellenarMapa() {
        for (var k in datos) {
            $('#mun' + k)
                    .css({'fill': '' + escalas[datos[k]].color + ''})
                    .data('area', [k, datos[k]]);
        }

        $('.mun').mouseover(function (e) {
            var datos_municipio = $(this).data('area');
            if (datos_municipio !== undefined) {
                $('<div class="panel_info">' +
                        municipios[datos_municipio[0]]["nombre"] + '<br>' +
                        'Escala: ' + escalas[datos_municipio[1]].nombre + 
                        '</div>'
                        )
                        .appendTo('body');
            }
        })
        .mouseleave(function () {
            $('.panel_info').remove();
        })
        .mousemove(function (e) {
            var mouseX = e.pageX, mouseY = e.pageY;

            $('.panel_info').css({
                top: mouseY - 50,
                left: mouseX - ($('.panel_info').width() / 2)
            });
        }).on('click tap touchstart', function (e) {
            var datos_municipio = $(this).data('area');
            
            if (datos_municipio !== undefined) {
                $('#movil').html('<strong>' + municipios[datos_municipio[0]]["nombre"] + '</strong><br>' +
                'Escala: ' + escalas[datos_municipio[1]].nombre);
            }
        });
    }
    
    function generarLeyenda(){
        var tabla_leyenda = $("#leyenda_escala").find('tbody');
        for (var k in escalas) {
            var fila = tabla_leyenda.append($('<tr>'));
            fila.append($('<td>').text(escalas[k]["nombre"]));
            fila.append($('<td class="color">').css({'background-color': '' + escalas[k]["color"] + ''}));
        }
    }
});

De línea 11 a 28 tenemos el $.when que es una forma de usar las promises ya que como los ajax son asíncronos en lugar de tener las llamadas cada una terminando en su propio tiempo, aquí se unen y cuando ya han terminado de obtener los datos (GET) de los JSON y el SVG se usa el .done para procesarlos. La línea 30 hace un callback a una función externa y le pasa los resultados de las 3 primeras llamadas para asignar esos datos a variables globales. En la 31 se inyecta el mapa, 32 se llama la función para pintar el mapa y pegar los eventos JavaScript. La 33 llama a la que genera la leyenda.

La función para pintar el mapa (rellenarMapa()) en la línea 36 comienza un bucle for para los diferentes elementos que tienen un id munXXXX y a cada uno primero lo rellena de un color según específica su dato respectivo en escalas. Luego agrega un atributo data llamado area que contiene un array de [llave, valor] para este caso llave es el código del municipio y valor es el número asignado, ambos del archivo de datos.

Ya que cada elemento tiene su dato asignado voy a crear la interactividad del mapa que consiste de los siguientes eventos para todos los eventos de la clase .mun:

  1. mouseover: con $(this).data(‘area’) se obtienen los datos previamente almacenados para cada elemento del tipo municipio, si no tiene datos (undefined) entonces no se procesa. Luego se crea un elemento temporal de la clase .panel_info que contendrá los datos formados para el elemento actual y que se agregará al body del documento.
  2. mouseleave: si el puntero del mouse deja el municipio actual entonces se removerá el elemento temporal .panel_info
  3. mousemove: primero se obtienen las coordenadas del puntero del mouse y luego se calculará una posición relativa al mouse para que se muestre el elemento temporal .panel_info mientras se mueve el mouse sobre el municipio.
  4. click, tap, touchstart: usando on se agregan estos eventos para que al pulsar (en PC o en teléfono) se muestre bajo el mapa un div con los datos del municipio.

En la función para crear la leyenda (generarLeyenda()) agregué un elemento td y le asigné un css a mano, una mejor opción sería asignar una clase dependiendo del color de la escala actual.

Resultado

Mapa interactivo con jQuery y SVG, haciendo click en un municipio y mouseover en otro

Mapa interactivo con jQuery y SVG, haciendo click en un municipio y mouseover en otro

Lo repito, que el SVG sea lo más simple que se pueda para que sea más rápida la descarga al usuario y si se vas a usar alguna herramienta en JavaScript para mover o acercar la imagen el navegador no tenga que trabajar tanto en especial en la versión móvil.

Todo el código, excepto por jQuery en si, y los datos de prueba están en un repositorio.