¡Hola! Me parece que andas usando un bloqueador de anuncios =( ¿Nos desbloqueas? ¿Por qué?
F13

Funcion 13

Localizador de tiendas con PHP, jQuery Mobile y MySQL - Parte 3

Esta es la segunda de tres partes de una serie de tutoriales sobre una aplicación completa usando PHP, jQuery Mobile, MySQL y Google Maps en el que podremos localizar establecimientos.

La idea es sencilla, tenemos varios establecimientos esparcidos por la geografía Española por lo que ofrecemos un listado de provincias, este carga un listado de poblaciones y finalmente ubicamos las tiendas disponibles en esa población o bien intentamos que nos localice tiendas cercanas a nuestra ubicación.

Dado que es una aplicación larga, vamos a dividirla por partes para no liarnos demasiado. En cada parte tocaremos un punto y seguiremos el orden que considero más lógico para crear una aplicación:

  1. Planear y crear la base de datos
  2. Desarrollar el backend con PHP que devolverá los datos a jQuery Mobile a través de JSON.
  3. Desarrollar la parte frontal con jQuery Mobile y Google Maps.

Echa un vistazo a lo que vamos a desarrollar:

Ahora que tenemos unos cimientos bien sólidos de la aplicación, podemos ponernos manos a la obra y crear la última parte, el front-end de nuestra pequeña aplicación.

Comencemos con…

El HTML

<!DOCTYPE html>  
<html>
<header> 
  <title>Localizador de tiendas</title>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
  <meta name="apple-mobile-web-app-capable" content="yes">
  <link rel="stylesheet" href="http://code.jquery.com/mobile/1.3.1/jquery.mobile-1.3.1.min.css"; />
  <link rel="stylesheet" href="css/app.css" />
  <script src="http://code.jquery.com/jquery-1.9.1.min.js"></script>;
  <script src="http://code.jquery.com/mobile/1.3.1/jquery.mobile-1.3.1.min.js"></script>;
  <script src="https://maps.googleapis.com/maps/api/js?v=3.exp&sensor=false"></script>;
</header>

<body>
  <div data-role="page" id="localiza">
    <div data-role="header">
      <h1>Localizador</h1> </div>
    <div data-role="content">
      <h2>Localiza tu tienda más cercana</h2>
      <form id="form_registro_datos" data-ajax="false">
        <input type="button" value="ubicar mi posicion" id="obtener_cerca" />
        <div data-role="fieldcontain">
          <label for="provincias">provincia</label>
          <select name="provincias" id="provincias" data-theme="c" data-mini="true"></select>
        </div>
        <div data-role="fieldcontain">
          <label for="poblaciones">poblacion</label>
          <select name="poblaciones" id="poblaciones" data-theme="c" data-mini="true" disabled>
            <option value="">seleccione provincia</option>
          </select>
        </div>
        <input type="submit" value="ver listado de tiendas" id="ver_listado" /> </form>
    </div>
    <div data-role="popup" id="errorLocalizacion" class="ui-content" data-theme="e">
      <p>No hemos podido obtener tu localización.</p>
    </div>
  </div>
  <div id="mapa" data-role="page">
    <div data-role="header">
      <h1>Mapa</h1> </div>
    <div data-role="content">
      <div id="map"></div>
      <div class="ui-grid-a" id="contenido-marker">
        <div class="ui-block-a" id="direccion-marker"> </div>
        <div class="ui-block-b">
          <button type="button" data-theme="c" id="llegar">como llegar</button>
        </div>
      </div>
    </div>
  </div>
  <script src="js/localiza.js"></script>
</body>
</html>

El archivo HTML es bastante simple. Incluímos los archivos necesarios de jQuery Mobile, algunas etiquetas meta para móviles y, por supuesto Google Maps.

El documento tiene dos páginas dentro: localiza y mapa. La primera la usaremos como filtro y en la segunda mostraremos los resultados.

En localiza tenemos un formulario cuyo uso de ajax desactivamos ya que nos haremos cargo nosotros de su envío. El formulario tiene un botón para activar la geolocalización del usuario y poder así encontrar las tiendas cercanas a él/ella así como dos desplegables: uno para provincias y otro para las poblaciones que, inicialmente está desactivado. Además del formulario, tenemos un popup que haremos aparecer en caso de que no podamos localizar al usuario.

En la página del mapa tan solo tenemos el mapa, y algunas divs de contenido con un botón de Cómo llegar.

Nota: Si aun no sabes bien como usar Google Maps, te recomiendo que eches un vistazo a los artículos sobre Google Maps que he escrito en el blog.

Por otro lado, si jQuery Mobile es un mundo para ti, hazte con el libro de Matt Doyle (¡En castellano!) para aprender mucho más sobre este lenguaje.

El CSS

El CSS de esta página no tiene nada, solamente retocamos el mapa y utilizamos algunas reglas media para hacerlo más grande en el caso de tablets ya que la pantalla queda totalmente desaprovechada en ese caso.

#map {  
  width: 100%;
  height: 282px;
}
/* iPads (landscape) ----------- */

@media only screen and (min-device-width: 768px) and (max-device-width: 1024px) and (orientation: landscape) {
  #map {
    height: 400px;
  }
}
/* iPads (portrait) ----------- */

@media only screen and (min-device-width: 768px) and (max-device-width: 1024px) and (orientation: portrait) {
  #map {
    height: 600px;
  }
}

Y ahora vamos al apartado final, el código JavaScript.

Código JavaScript

Antes de empezar, me gustaría indicar que todas las llamadas asíncronas que se hacen en la aplicación tienen una estructura similar, de manera que hay una función que llama a la función que hace la petición y gracias al uso de deferreds, llama a la función que se encarga de gestionar la respuesta del servicio que sea. Quizá pueda parecer más código inicialmente, pero nos ofrece una idea clara de qué hace qué.

(function($) {  
  var $page = $('#localiza');

  var Localizador = {
    el: {
      $page: $page,
      $formulario: $page.find('form'),
      $selectProvincias: $('#provincias'),
      $selectPoblaciones: $('#poblaciones'),
      $botonObtener: $page.find('#ver_listado'),
      $botonCerca: $page.find('#obtener_cerca'),
      $errorLocalizacion: $page.find('#errorLocalizacion'),
      $mapDiv: $('#mapa'),
      $contenidoMarker: $('#contenido-marker').hide(),
      $direccionMarker: $('#direccion-marker'),
      $botonLlegar: $('#llegar')
    },
    API_URL: 'controller.php',
    REVERSE_GEOCODING_URL: 'http://maps.googleapis.com/maps/api/geocode/json',
    FIT_BOUNDS_TIMEOUT: 500,
    geocoder: null,
    viewed_marker: null,
    markers: [],
    stores: [],
    dir_display: new google.maps.DirectionsRenderer(),
    dir_service: new google.maps.DirectionsService(),
    infowindow: new google.maps.InfoWindow({
      content: ''
    }),

    init: function() {
      Localizador.el.$botonObtener.button('disable');

      // Google
      Localizador.geocoder = new google.maps.Geocoder();
      Localizador.dir_display = new google.maps.DirectionsRenderer();
      Localizador.dir_service = new google.maps.DirectionsService();
      Localizador.infowindow = new google.maps.InfoWindow({
        content: ''
      });

      Localizador.el.$errorLocalizacion.popup({
        positionTo: "window"
      });

      if (!"geolocation" in navigator) {
        // Si no hay geolocalizador en el navegador ocultamos el botón
        Localizador.el.$botonCerca.hide();
      }

      Localizador.bindEvents();
    },
    bindEvents: function() {
      this.el.$page.on('pageshow', Localizador.loadProvincias);
      this.el.$selectProvincias.on('change', Localizador.loadPoblaciones);
      this.el.$selectPoblaciones.on('change', Localizador.allowSubmit);
      this.el.$formulario.on('submit', function(e) {
        e.preventDefault();
        Localizador.requestTiendas().done(Localizador.loadMap);
      });
      this.el.$botonCerca.on('click', function(e) {
        e.preventDefault();
        navigator.geolocation.getCurrentPosition(Localizador.geoCodePosition, Localizador.errorPosition);
      });
      this.el.$botonLlegar.on('click', function(e) {
        e.preventDefault();
        navigator.geolocation.getCurrentPosition(Localizador.getDirection);
      });
      this.el.$mapDiv.on('click', 'a.mas-informacion', Localizador.showInfoAboutMarker);
    },
    initMap: function() {
      if (Localizador.markers.length) {
        var mapOptions = {
          zoom: 13,
          center: new google.maps.LatLng(40.41694, -3.70081), // Madrid
          mapTypeId: google.maps.MapTypeId.ROADMAP,
          disableDefaultUI: true // Disabling buttons and stuff
        };

        Localizador.map = new google.maps.Map(document.getElementById('map'), mapOptions);
        Localizador.dir_display.setMap(Localizador.map);
        google.maps.event.addListenerOnce(Localizador.map, 'idle', Localizador.associateMarkersToMap);
      } else {
        $.mobile.changePage('#localiza');
      }
    },
    errorPosition: function() {
      Localizador.el.$errorLocalizacion.popup("open");
    },
    geoCodePosition: function(position) {
      var latLng = new google.maps.LatLng(
        position.coords.latitude,
        position.coords.longitude
      );

      Localizador.geocoder.geocode({
        'latLng': latLng
      }, function(results, status) {
        if (status  google.maps.GeocoderStatus.OK) {
          var provincia = results[0].address_components[3].long_name,
            poblacion = results[0].address_components[2].long_name;

          Localizador.requestNearStores(
            provincia,
            poblacion,
            position.coords.latitude,
            position.coords.longitude
          ).done(Localizador.loadMap);
        } else {
          Localizador.errorPosition();
        }
      });
    },
    getDirection: function(position) {
      var origin = new google.maps.LatLng(
          position.coords.latitude,
          position.coords.longitude
        ),
        destination = new google.maps.LatLng(
          Localizador.viewed_marker.lat,
          Localizador.viewed_marker.lng
        );

      Localizador.dir_service.route({
        origin: origin,
        destination: destination,
        travelMode: google.maps.TravelMode.DRIVING
      }, function(response, status) {
        Localizador.hideAllButCurrent();
        if (status  google.maps.DirectionsStatus.OK) {
          Localizador.dir_display.setDirections(response);
        }
      })
    },
    loadProvincias: function() {
      Localizador.requestProvincias().done(Localizador.insertProvincias);
    },
    loadPoblaciones: function() {
      Localizador.requestPoblaciones().done(Localizador.insertPoblaciones);
    },
    requestProvincias: function() {
      return $.getJSON(Localizador.API_URL, {
        accion: 'obtenerProvincias'
      });
    },
    requestPoblaciones: function() {
      return $.getJSON(Localizador.API_URL, {
        accion: 'obtenerPoblaciones',
        provincia: Localizador.el.$selectProvincias.val()
      });
    },
    requestTiendas: function() {
      return $.getJSON(Localizador.API_URL, {
        accion: 'obtenerTiendas',
        poblacion: Localizador.el.$selectPoblaciones.val()
      });
    },
    requestNearStores: function(provincia, poblacion, lat, lng) {
      return $.getJSON(Localizador.API_URL, {
        accion: 'obtenerTiendasCercanas',
        provincia: provincia,
        poblacion: poblacion,
        lat: lat,
        lng: lng
      });
    },
    insertProvincias: function(data) {
      var provincias = '';
      $.each(data, function(i, obj) {
        provincias += '';
      });

      Localizador.el.$selectProvincias.html(provincias).selectmenu('refresh');
    },
    insertPoblaciones: function(data) {
      var poblaciones = '';

      if (data.error) {
        poblaciones = '';

        if (!Localizador.el.$selectPoblaciones.attr('disabled')) {
          Localizador.el.$selectPoblaciones.selectmenu('disable');
        }
      } else {
        Localizador.el.$selectPoblaciones.selectmenu('enable');
        poblaciones = '';

        $.each(data, function(i, obj) {
          poblaciones += '';
        });
      }

      Localizador.el.$selectPoblaciones.html(poblaciones).selectmenu('refresh');
    },
    allowSubmit: function() {
      if (Localizador.el.$selectPoblaciones.val()) {
        Localizador.el.$botonObtener.button('enable');
      } else {
        Localizador.el.$botonObtener.button('disable');
      }
    },
    loadMap: function(data) {
      if (data.error) {
        Localizador.el.$selectProvincias.val('').trigger('change');
        Localizador.el.$botonObtener.val('').trigger('change').button('disable');
      } else {
        Localizador.insertMarkers(data);
        $.mobile.changePage('#mapa');
      }
    },
    getIcon: function(type) {
      var icon = '';
      switch (type) {
        case 'Apple Store':
          icon = 'img/default_blk_apple_pin.png';
          break;
      }

      return icon;
    },
    insertMarkers: function(data) {
      Localizador.deleteMarkers();
      var mapBounds = new google.maps.LatLngBounds();

      $.each(data, function(i, obj) {
        var marker = new google.maps.Marker({
          map: null,
          position: new google.maps.LatLng(obj.lat, obj.lng),
          array_index: Localizador.markers.length,
          icon: Localizador.getIcon(obj.tipo),
          shop_id: obj.id
        });
        Localizador.markers.push(marker);
        Localizador.stores.push(obj);
        mapBounds.extend(marker.position);
        google.maps.event.addListener(marker, 'click', Localizador.openInfoWindow);
      });

      setTimeout(function() {
        Localizador.map.fitBounds(mapBounds);
      }, Localizador.FIT_BOUNDS_TIMEOUT);
    },
    openInfoWindow: function() {
      var obj = Localizador.stores[this.array_index];

      Localizador.viewed_marker = obj;

      var html = '

' + obj.nombre_comercial + '

' + '

detalle

'; Localizador.infowindow.setContent(html); Localizador.infowindow.open(Localizador.map, this); }, associateMarkersToMap: function() { $.each(Localizador.markers, function(i, marker) { marker.setMap(Localizador.map); }); }, deleteMarkers: function() { $.each(Localizador.markers, function(i, marker) { marker.setMap(null); google.maps.event.clearInstanceListeners(marker); }); Localizador.markers = []; Localizador.stores = []; }, hideAllButCurrent: function() { var current = Localizador.viewed_marker; $.each(Localizador.markers, function(i, marker) { if (current.id !== marker.id) { marker.setVisible(false); } }); }, showInfoAboutMarker: function(e) { e.preventDefault(); Localizador.el.$contenidoMarker.show(); var obj = Localizador.viewed_marker, html = '

' + obj.direccion + '

' + '

' + [obj.poblacion, obj.provincia].join(', ') + '

' + '

CP: ' + obj.cp + '

'; Localizador.el.$direccionMarker.html(html); } }; Localizador.el.$page.on('pageinit', Localizador.init); Localizador.el.$mapDiv.on('pageshow', Localizador.initMap); })(jQuery);

Empecemos poco a poco. Lo primero que vemos es que todo el código está en una función anónima autoinvocada para asegurarnos de que no hay conflictos (en principio no tendría porqué haberlos claro).

Lo siguiente que hacemos es cachear la página principal que, recordad, llamamos localiza. Justo después, creamos nuestro objeto Localizador, que tendrá todas las funciones y referencias a elementos con los que trabajar. Si tuviéramos otro componente, por ejemplo una barra de búsqueda, pues crearíamos algo como Buscador y que contuviera toda la funcionalidad relacionada con el buscador en éste.

Dentro del objeto, lo primero que vemos es un sub-objeto llamado el. En él (valga la redundancia), nos encargaremos de guardar todos los elementos “cacheados” para usarlos posteriormente. Esto está considerado una buena práctica ya que cada vez que jQuery tiene que buscar en el DOM algún elemento, tarda un tiempo X que nos podemos ahorrar si guardamos la referencia a esa variable.

Después definimos una serie de constantes así como algunos objetos que dejamos null o vacíos y que luego, por supuesto, rellenaremos. Esto nos permite aumentar la mantenibilidad del código, imaginad que mañana renombramos el archivo controller.php por controller.asp porque cambiamos el controlador de lenguaje. De esta forma, nos evitamos tener que cambiarla en las 4 funciones en las que se utiliza.

La función initse encarga de realizar todo el trabajo de inicialización el sí de código, es decir, todo el trabajo sin el que la aplicación no puede funcionar. En nuestro caso desactivamos el botón de obtener tiendas ya que no queremos que nadie lo pulse si no nos facilita provincia y población. Además, inicializamos el geocodificador de Google y creamos un InfoWindow (que ya hemos visto) y unos objetos para encontrar la dirección hasta un punto (que también hemos visto). Si detectamos que no tenemos geolocalización en el móvil o dispositivo, simplemente escondemos el botón de obtener tiendas cercanas, para evitar que sea pulsado. Finalmente, llamamos a la función bindEvents, que se encargará de escuchar los eventos del DOM que nos interesan y asignarles gestores para que reaccionen acordemente.

En la función bindEvents nos encargamos de escuchar los siguientes eventos:

  • Cuando la página se muestra
  • Cuando cambia el valor del selector de provincias
  • Cuando cambia el valor del selector de poblaciones
  • Cuando se envía el formulario
  • Cuando pulsamos el botón de obtener tiendas cercanas
  • Cuando pulsamos el botón de cómo llegar
  • Cuando pulsamos sobre más información (dentro de un InfoWindow)

La función initMap se encarga de inicializar el mapa en caso de que tengamos marcadores dentro. Esto evita que se acceda a la página del mapa sin más ya que sin los marcadores que generaremos tras realizar una búsqueda, no tiene mucho sentido ir a ver el mapa y, de hecho, los mandamos a la página principal. Como pequeña novedad, escuchamos una sola vez el evento idle que genera el mapa para asociar los marcadores al mapa ya que, de lo contrario, pueden pasar cosas extrañas, especialmente en móviles y con poca cobertura.

La función errorPosition se encarga de abrir un popup para los casos en que no podamos determinar la ubicación del usuario.

La función geoCodePositionla usaremos cuando un usuario use el botón de obtener tiendas cercanas. De esta forma podemos obtener la provincia y la población en la que se encuentra para pasárselos a nuestro controlador PHP. Como parámetro recibe la posición del usuario.

Seguimos con getDirection que se encarga de obtener la forma de llegar hasta la tienda que hayamos seleccionado, definimos un origen y un destino y se lo pasamos a Google para que haga el resto.

Las siguientes 2 funciones son las encargadas de la cadena de funciones para solicitar provincias y poblaciones respectivamentes tal y como hemos explicado un poco más arriba.

Las siguientes 4 funciones son las encargadas de hacer la propia llamada a los servicios para obtener provincias, poblaciones, tiendas y tiendas cercanas. Devuelven una promesa por lo que, cuando esta se resuelva, el callback entrará en acción.

insertProvincias se encarga de insertar las provincias en el desplegable cuando las obtenemos, el proceso es muy sencillo y, una vez modificado el marcado, nos encargamos de refrescar el widget para que su visualización sea correcta.

insertPoblaciones se encarga de insertar las poblaciones en el desplegable. En caso de que tengamos algún error en la respuesta, lo vacía y lo desactiva. En caso contrario, introduce todas las poblaciones.

allowSubmit es una función que se encargará de activar o desactivar el botón de envío del formulario para obtener tiendas de una población determinada. Solo tiene en cuenta si tenemos algún valor de población seleccionado.

La función loadMap es la que es llamada cuando obtenemos (o no) los datos sobre las tiendas que hemos pedido, en caso de haber algún error, resetea los seleccionables ya que, lo habitual es que no haya tiendas. En caso contrario, guardamos los marcadores y cambiamos a la página del mapa.

getIcon será la encargada de determinar si damos algún icono especial de Google Maps, me he tomado la libertad de coger uno de Apple para las tiendas Apple Store oficiales. En caso de que no pasemos algo que concuerde con el switch, devolverá una cadena vacía que para Google significa que use el marcador por defecto.

En insertMarkers es donde nos encargamos de guardar los marcadores en la memoria. Lo primero que hacemos es borrarlos, por si quedara alguno por ahí y creamos un objeto de límites de mapas para que el mapa se ajuste a todos los marcadores que le pongamos. La función se encarga de recorrer los datos e ir creando marcadores. Guarda referencias de los marcadores y el objeto completo que, como veréis, usaremos más tarde. Además, nos quedamos atentos del evento click para poder abrir el InfoWindow. Finalmente, hacemos que el mapa tenga el nivel de zoom necesario para que se vean todos los marcadores.

La función openInfoWindow es la que se encarga de… bueno… ¡abrir un InfoWindow! Pero antes de abrirlo, se encarga de meterle contenido que es, básicamente el nombre de la tienda y un enlace de más información. En esta función, dado que es gestora de evento click del marcador, this apunta al propio marcador en sí, por lo que tendremos acceso fácilmente a todas las propiedades que hemos definido en la función anterior.

La función associateMarkersToMap recorre todos los marcadores y los asocia al mapa. Esto ocurre cuando el mapa ya está completamente cargado para que la sensación de cargado sea mejor.

La función deleteMarkersse encarga de borrar todos los marcadores. Primero vacía el puntero al mapa que tienen, y luego les dice a todos los que estén escuchando a sus eventos que dejen de hacerlo. Finalmente, tanto markers como stores son reinicializadas.

La función hideAllButCurrent es la que se encarga de ocultar todos los marcadores menos aquel que estamos visualizando. Esta función es llamada cuando obtenemos las direcciones hacia la tienda que hemos seleccionado, está pensado para que sea más claro el camino y no haya nada que entorpezca en el mapa.

Finalmente, la función showInfoAboutMarker es la que se encarga de mostrar la dirección bajo el mapa si el usuario hace click en el enlace que hay dentro del InfoWindow.

Fuera del objeto, quedamos a la escucha de dos eventos de jQuery Mobile sobre las dos páginas que tenemos. En el caso de la principal, inicializaremos el objeto mientras que con el mapa, intentaremos cargarlo.

Conclusión

Después de 3 artículos ya tenemos una aplicación web totalmente funcional. Quiero decir que esta aplicación es parte de alguna que he usado para un cliente para un localizador de tiendas de toda España así que, ¡funciona bastante bien!

El código lo he subido a GitHub al completo para que podáis descargarlo y… forkearlo si queréis.

Espero que te haya parecido interesante y que hayas aprendido. La idea del artículo es que intentes entender cómo funciona y por qué cada cosa está en cada sitio y, como siempre, si hay algo que esté mal o no quede lo suficientemente claro, me gustaría que alguien lo comentara.

Programador Front-end en First + Third y Potato. Trabajando con JavaScript y HTML5 desde el corazón de Sevilla.

Comentarios ¡Únete a la conversación!