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:
- Planear y crear la base de datos
- Desarrollar el backend con PHP que devolverá los datos a jQuery Mobile a través de JSON.
- 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>
<head>
<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>
</head>
<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 div
s de contenido con un botón de Cómo llegar.
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');
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 = '<option value="">seleccione</option>';
$.each(data,function(i,obj){
provincias += '<option value="'+ obj.id +'">' + obj.nombre + '</option>';
});
Localizador.el.$selectProvincias.html(provincias).selectmenu('refresh');
},
insertPoblaciones : function(data){
var poblaciones = '';
if (data.error){
poblaciones = '<option value="">seleccione</option>';
if(!Localizador.el.$selectPoblaciones.attr('disabled')){
Localizador.el.$selectPoblaciones.selectmenu('disable');
}
}
else {
Localizador.el.$selectPoblaciones.selectmenu('enable');
poblaciones = '<option value="">seleccione población</option>';
$.each(data,function(i,obj){
poblaciones += '<option value="'+ obj.id +'">' + obj.nombre + '</option>';
});
}
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 = '<p>' + obj.nombre_comercial + '</p>' +
'<p><a href="#" class="mas-informacion">detalle</a></p>';
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 = '<p>'+ obj.direccion +'</p>' +
'<p>' + [obj.poblacion,obj.provincia].join(', ') + '</p>' +
'<p>CP: ' + obj.cp + '</p>';
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 init
se 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 geoCodePosition
la 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 deleteMarkers
se 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.
via Función 13 http://www.funcion13.com/2013/04/13/localizador-tiendas-php-jquery-mobile-mysql-parte-3/?utm_source=feedburner&utm_medium=feed&utm_campaign=Feed%3A+Funcion13+%28Funci%C3%B3n+13%29
Comentaris
Publica un comentari a l'entrada