Crea un localizador de tiendas de pila completa con Google Maps Platform y Google Cloud

1. Introducción

Resumen

Imagina que tienes muchos lugares en un mapa y deseas que los usuarios puedan ver dónde están esos lugares e identificar qué lugares quieren visitar. A continuación, te mencionamos algunos ejemplos de casos como este:

  • un localizador de tiendas en el sitio web del minorista
  • un mapa de ubicaciones donde es posible votar para una próxima elección
  • un directorio de ubicaciones especiales, tal como aquellas donde hay contenedores para el reciclaje de baterías

Qué crearás

En este codelab, crearás un localizador que se basa en un feed de datos en vivo de ubicaciones especiales y ayuda al usuario a encontrar la ubicación más cercana a su punto de partida. Este localizador fullstack puede manejar cantidades mucho más grandes de lugares que el localizador de tiendas simple, el cual está limitado a 25 ubicaciones de tiendas o menos.

2ece59c64c06e9da.png

Qué aprenderás

Este codelab usa un conjunto de datos abierto a fin de simular metadatos prepropagados sobre una gran cantidad de ubicaciones de tiendas para que puedas enfocarte en aprender los conceptos técnicos clave.

  • API de Maps JavaScript: Permite mostrar una gran cantidad de ubicaciones en un mapa web personalizado.
  • GeoJSON: Es un formato que almacena metadatos sobre las ubicaciones.
  • Place Autocomplete: Ayuda a que los usuarios proporcionen ubicaciones iniciales de forma más rápida y precisa.
  • Go: Es el lenguaje de programación utilizado para desarrollar el backend de la aplicación. El backend interactuará con la base de datos y enviará los resultados de la consulta al frontend en un archivo JSON con formato.
  • App Engine: Se utiliza para alojar la aplicación web.

Requisitos previos

  • Conocimientos básicos de HTML y JavaScript
  • Una Cuenta de Google

2. Prepárate

En el paso 3 de la sección siguiente, habilita la API de Maps JavaScript, la API de Places y la API de Distance Matrix para este codelab.

Comienza a utilizar Google Maps Platform

Si nunca usaste Google Maps Platform, sigue la guía Cómo comenzar a utilizar Google Maps Platform o mira la lista de reproducción Cómo comenzar a utilizar Google Maps Platform para completar los siguientes pasos:

  1. Crear una cuenta de facturación
  2. Crear un proyecto
  3. Habilitar las API y los SDK de Google Maps Platform (enumerados en la sección anterior)
  4. Generar una clave de API

Activa Cloud Shell

En este codelab, usarás Cloud Shell, un entorno de línea de comandos que se ejecuta en Google Cloud y que proporciona acceso a productos y recursos que también se ejecutan en Google Cloud. Eso te permite alojar y ejecutar tu proyecto completamente en tu navegador web.

Para activar Cloud Shell en Cloud Console, haz clic en Activar Cloud Shell 89665d8d348105cd.png (el aprovisionamiento y la conexión al entorno debería llevar solo unos minutos).

5f504766b9b3be17.png

Esto abrirá una nueva shell en la parte inferior del navegador después de mostrar un anuncio intersticial introductorio.

d3bb67d514893d1f.png

Confirma tu proyecto

Una vez conectado a Cloud Shell, deberías ver que ya estás autenticado y que el proyecto ya tiene asignado el ID que seleccionaste durante la configuración.

$ gcloud auth list
Credentialed Accounts:
ACTIVE  ACCOUNT
  *     <myaccount>@<mydomain>.com
$ gcloud config list project
[core]
project = <YOUR_PROJECT_ID>

Si, por algún motivo, el proyecto no se configuró, ejecuta el siguiente comando:

gcloud config set project <YOUR_PROJECT_ID>

Habilita la API de App Engine Flex

La API de App Engine Flex se debe habilitar de forma manual en Cloud Console. Así, no solo se habilitará la API, sino que también se creará la Cuenta de servicio del entorno flexible de App Engine, la cuenta autenticada que interactuará con los servicios de Google (como las bases de datos SQL) en nombre del usuario.

3. Hello World

Backend: Hello World en Go

En tu instancia de Cloud Shell, comenzarás por crear una app de Go en el entorno flexible de App Engine que servirá como base para el resto del codelab.

En la barra de herramientas de Cloud Shell, haz clic en el botón Abrir editor (Open Editor) para abrir un editor de código en una pestaña nueva. Este editor de código basado en la Web te permite editar fácilmente archivos en la instancia de Cloud Shell.

b63f7baad67b6601.png

Luego, haz clic en el ícono Abrir en una ventana nueva (Open in new window) para mover el editor y la terminal a una pestaña nueva.

3f6625ff8461c551.png

En la terminal, en la parte inferior de la pestaña nueva, crea un directorio austin-recycling nuevo.

mkdir -p austin-recycling && cd $_

A continuación, crearás una pequeña app de App Engine en Go para asegurarte de que todo funcione. ¡Hola, mundo!

El directorio austin-recycling también debería aparecer en la lista de carpetas del editor, ubicada a la izquierda. En el directorio austin-recycling, crea un archivo llamado app.yaml. En ese archivo app.yaml, ingresa el siguiente contenido:

app.yaml

runtime: go
env: flex

manual_scaling:
  instances: 1
resources:
  cpu: 1
  memory_gb: 0.5
  disk_size_gb: 10

Este archivo de configuración establece que tu app de App Engine usará el entorno de ejecución de Go Flex. Para obtener información general sobre el significado de los elementos de configuración que se usan en este archivo, consulta la documentación del entorno estándar de Go en Google App Engine.

A continuación, crea un archivo main.go en el mismo directorio en el que se encuentra el archivo app.yaml:

main.go

package main

import (
        "fmt"
        "log"
        "net/http"
        "os"
)

func main() {
        http.HandleFunc("/", handle)
        port := os.Getenv("PORT")
        if port == "" {
                port = "8080"
        }
        log.Printf("Listening on port %s", port)
        if err := http.ListenAndServe(":"+port, nil); err != nil {
                log.Fatal(err)
        }
}

func handle(w http.ResponseWriter, r *http.Request) {
        if r.URL.Path != "/" {
                http.NotFound(w, r)
                return
        }
        fmt.Fprint(w, "Hello world!")
}

Vale la pena tomarse un momento para comprender qué hace este código, al menos, de forma general. Definiste un paquete main, el cual inicia un servidor http que escucha en el puerto 8080 y registra una función de controlador para las solicitudes HTTP que coinciden con la ruta "/".

La función del controlador, llamada handler, escribe la string de texto "Hello, world!". Este texto se retransmitirá a tu navegador, donde podrás leerlo. Más adelante en este codelab, crearás controladores que respondan con datos de GeoJSON en lugar de strings simples que estén hard-coded.

Después de seguir estos pasos, deberías tener un editor con el siguiente aspecto:

2084fdd5ef594ece.png

Pruébala

Para probar esta aplicación, puedes ejecutar el servidor de desarrollo de App Engine dentro de la instancia de Cloud Shell. Regresa a la línea de comandos de Cloud Shell y escribe lo siguiente:

go run *.go

Verás algunas líneas de resultados de registro que indican que, efectivamente, estás ejecutando el servidor de desarrollo en la instancia de Cloud Shell y que la aplicación web hello world está escuchando el puerto localhost 8080. Para abrir una pestaña del navegador web en esta app, presiona el botón Vista previa en la Web (Web Preview) y selecciona el elemento de menú Vista previa en el puerto 8080 en la barra de herramientas de Cloud Shell.

4155fc1dc717ac67.png

Si haces clic en este elemento del menú, se abrirá una pestaña nueva en tu navegador web con las palabras "Hello, world!" desde el servidor de desarrollo de App Engine.

En el próximo paso, agregarás los datos de reciclaje de la ciudad de Austin a esta app y comenzarás a visualizarla.

4. Obtén los datos actuales

GeoJSON, la lengua franca del mundo de los GIS

En el paso anterior, se mencionó que realizarás controladores con tu código Go, los cuales renderizarán datos de GeoJSON en el navegador web. Pero ¿qué es GeoJSON?

En el mundo de los sistemas de información geográfica (GIS), es necesario que podamos comunicar el conocimiento sobre las entidades geográficas entre los sistemas informáticos. Los mapas son excelentes para que las personas puedan leerlos, pero las computadoras requieren que los datos estén en formatos más fáciles de procesar.

GeoJSON es un formato para codificar las estructuras de datos geográficos, como las coordenadas de las ubicaciones donde hay contenedores de reciclaje en Austin, Texas. GeoJSON quedó estandarizado en una norma publicada por Internet Engineering Task Force llamada RFC7946. GeoJSON se define en términos del formato JSON (notación de objeto de JavaScript), el cual quedó estandarizado en la norma ECMA-404 por parte de la misma organización que estandarizó JavaScript, Ecma International.

Lo importante es que GeoJSON es un formato de conexión de gran compatibilidad para comunicar el conocimiento geográfico. En este codelab, se usa GeoJSON de las siguientes maneras:

  • Se usan paquetes de Go para analizar los datos de Austin en una estructura de datos interna específica con información de los GIS que usarás para filtrar los datos solicitados.
  • Se serializan los datos solicitados para transmitirlos entre el servidor web y el navegador web.
  • Se usa una biblioteca de JavaScript para convertir la respuesta en marcadores en un mapa.

Esto te permitirá escribir mucho menos código, ya que no necesitarás escribir analizadores ni generadores para convertir el flujo de datos de transmisión en representaciones en la memoria.

Recupera los datos

El Portal de datos abiertos de la ciudad de Austin, Texas proporciona información geoespacial sobre los recursos abiertos disponibles para uso público. En este codelab, visualizarás el conjunto de datos sobre las ubicaciones donde hay contenedores de reciclaje.

Visualizarás los datos con marcadores en el mapa, renderizados a través de la capa de datos de la API de Maps JavaScript.

Primero, descarga los datos de GeoJSON del sitio web de la ciudad de Austin en tu app.

  1. En la ventana de línea de comandos de tu instancia de Cloud Shell, presiona [CTRL] + [C] para apagar el servidor.
  2. Crea un directorio data dentro del directorio austin-recycling, y pasa a ese directorio:
mkdir -p data && cd data

Ahora, usa curl para recuperar las ubicaciones de reciclaje:

curl "https://data.austintexas.gov/resource/qzi7-nx8g.geojson" -o recycling-locations.geojson

Por último, vuelve al directorio superior.

cd ..

5. Muestra las ubicaciones en el mapa

Primero, actualiza el archivo app.yaml para reflejar la app más sólida que estás por crear, que ya no es solo una aplicación de Hello World.

app.yaml

runtime: go
env: flex

handlers:
- url: /
  static_files: static/index.html
  upload: static/index.html
- url: /(.*\.(js|html|css))$
  static_files: static/\1
  upload: static/.*\.(js|html|css)$
- url: /.*
  script: auto

manual_scaling:
  instances: 1
resources:
  cpu: 1
  memory_gb: 0.5
  disk_size_gb: 10

Con esta configuración de app.yaml, las solicitudes de /, /*.js, /*.css y /*.html se dirigen a un conjunto de archivos estáticos. Esto significa que el contenido del componente HTML estático de tu app provendrá directamente de la infraestructura de entrega de archivos de App Engine y no de tu app de Go. Esto reduce la carga del servidor y aumenta la velocidad de entrega.

Ahora es el momento de crear el backend de tu aplicación en Go.

Crea el backend

Quizás hayas notado que tu archivo app.yaml no expone el archivo GeoJSON, lo cual es muy interesante. Eso se debe a que nuestro backend de Go es el encargado de procesar y enviar el GeoJSON, lo cual nos permitirá crear algunas características sofisticadas en pasos posteriores. Modifica tu archivo main.go para que tenga el contenido siguiente:

main.go

package main

import (
        "fmt"
        "io/ioutil"
        "log"
        "net/http"
        "os"
        "path/filepath"
)

var GeoJSON = make(map[string][]byte)

// cacheGeoJSON loads files under data into `GeoJSON`.
func cacheGeoJSON() {
        filenames, err := filepath.Glob("data/*")
        if err != nil {
                log.Fatal(err)
        }

        for _, f := range filenames {
                name := filepath.Base(f)
                dat, err := ioutil.ReadFile(f)
                if err != nil {
                        log.Fatal(err)
                }
                GeoJSON[name] = dat
        }
}

func main() {
        // Cache the JSON so it doesn't have to be reloaded every time a request is made.
        cacheGeoJSON()

        // Request for data should be handled by Go.  Everything else should be directed
        // to the folder of static files.
        http.HandleFunc("/data/dropoffs", dropoffsHandler)
        http.Handle("/", http.FileServer(http.Dir("./static/")))

        // Open up a port for the webserver.
        port := os.Getenv("PORT")
        if port == "" {
                port = "8080"
        }
        log.Printf("Listening on port %s", port)

        if err := http.ListenAndServe(":"+port, nil); err != nil {
                log.Fatal(err)
        }
}

func helloHandler(w http.ResponseWriter, r *http.Request) {
        // Writes Hello, World! to the user's web browser via `w`
        fmt.Fprint(w, "Hello, world!")
}

func dropoffsHandler(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-type", "application/json")
        w.Write(GeoJSON["recycling-locations.geojson"])
}

El backend de Go ya nos brinda una función valiosa: la instancia de App Engine almacena en caché todas esas ubicaciones en cuanto se inicia. Esto ahorra tiempo, ya que el backend no tiene que leer el archivo del disco cada vez que un usuario actualiza la vista.

Crea el frontend

Primero, crearemos una carpeta que contenga todos nuestros elementos estáticos. Crea una carpeta static, que será la superior de tu proyecto.

mkdir -p static && cd static

En ella, crearemos 3 archivos.

  • index.html contendrá todo el código HTML de tu app del localizador de tiendas de una página.
  • style.css, como su nombre lo indica, contendrá el estilo.
  • app.js se encargará de recuperar el GeoJSON, realizar llamadas a la API de Google Maps y colocar marcadores en tu mapa personalizado.

Crea estos 3 archivos y asegúrate de colocarlos en static/.

style.css

html,
body {
  height: 100%;
  margin: 0;
  padding: 0;
}

body {
  display: flex;
}

#map {
  height: 100%;
  flex-grow: 4;
  flex-basis: auto;
}

index.html

<html>
  <head>
    <title>Austin recycling drop-off locations</title>
    <link rel="stylesheet" type="text/css" href="style.css" />
    <script src="app.js"></script>

    <script
      defer
    src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY&v=weekly&libraries=places&callback=initialize&solution_channel=GMP_codelabs_fullstackstorelocator_v1_a"
    ></script>
  </head>

  <body>
    <div id="map"></div>
    <!-- Autocomplete div goes here -->
  </body>
</html>

Presta especial atención a la URL src en la etiqueta script del elemento head.

  • Reemplaza el texto del marcador de posición "YOUR_API_KEY" por la clave de API que generaste durante el paso de configuración. Puedes visitar la página API y servicios > Credenciales en Cloud Console para recuperar tu clave de API o generar una nueva.
  • Ten en cuenta que la URL contiene el parámetro callback=initialize. Ahora crearemos el archivo JavaScript que contiene esa función de devolución de llamada. Aquí es donde tu app cargará las ubicaciones del backend, las enviará a la API de Google Maps y usará el resultado para marcar las ubicaciones personalizadas en el mapa, todo bellamente renderizado en tu página web.
  • El parámetro libraries=places carga la biblioteca de Places, que es necesaria para funciones como el autocompletado de direcciones que se agregarán más adelante.

app.js

let distanceMatrixService;
let map;
let originMarker;
let infowindow;
let circles = [];
let stores = [];
// The location of Austin, TX
const AUSTIN = { lat: 30.262129, lng: -97.7468 };

async function initialize() {
  initMap();

  // TODO: Initialize an infoWindow

  // Fetch and render stores as circles on map
  fetchAndRenderStores(AUSTIN);

  // TODO: Initialize the Autocomplete widget
}

const initMap = () => {
  // TODO: Start Distance Matrix service

  // The map, centered on Austin, TX
  map = new google.maps.Map(document.querySelector("#map"), {
    center: AUSTIN,
    zoom: 14,
    // mapId: 'YOUR_MAP_ID_HERE',
    clickableIcons: false,
    fullscreenControl: false,
    mapTypeControl: false,
    rotateControl: true,
    scaleControl: false,
    streetViewControl: true,
    zoomControl: true,
  });
};

const fetchAndRenderStores = async (center) => {
  // Fetch the stores from the data source
  stores = (await fetchStores(center)).features;

  // Create circular markers based on the stores
  circles = stores.map((store) => storeToCircle(store, map));
};

const fetchStores = async (center) => {
  const url = `/data/dropoffs`;
  const response = await fetch(url);
  return response.json();
};

const storeToCircle = (store, map) => {
  const [lng, lat] = store.geometry.coordinates;
  const circle = new google.maps.Circle({
    radius: 50,
    strokeColor: "#579d42",
    strokeOpacity: 0.8,
    strokeWeight: 5,
    center: { lat, lng },
    map,
  });

  return circle;
};

Este código renderiza ubicaciones de tiendas en un mapa. Para probar lo que tenemos hasta ahora, desde la línea de comandos, regresa al directorio superior:

cd ..

Ahora, vuelve a ejecutar la app en modo de desarrollador usando el siguiente comando:

go run *.go

Muestra una vista previa tal como hiciste antes. Deberías ver un mapa con pequeños círculos verdes como este.

58a6680e9c8e7396.png

Ya estás renderizando ubicaciones en el mapa, y solo estamos a la mitad del codelab. Increíble. Ahora, agreguemos un poco de interactividad.

6. Muestra detalles a pedido

Responde a los eventos de clic en los marcadores de mapa

Mostrar varios marcadores en el mapa es un buen comienzo, pero realmente necesitamos que un visitante pueda hacer clic en uno de esos marcadores y ver información acerca de esa ubicación (como el nombre de la empresa, la dirección, etc.). Esa pequeña ventana que suele aparecer cuando se hace clic en un marcador de Google Maps es una ventana de información (Info Window).

Crea un objeto infoWindow. Agrega lo siguiente a la función initialize en reemplazo de la línea comentada que dice "// TODO: Initialize an info window".

app.js - initialize

  // Add an info window that pops up when user clicks on an individual
  // location. Content of info window is entirely up to us.
  infowindow = new google.maps.InfoWindow();

Reemplaza la definición de la función fetchAndRenderStores por una versión un poco diferente, con la que se cambia la línea final para llamar a storeToCircle con un argumento adicional, infowindow:

app.js - fetchAndRenderStores

const fetchAndRenderStores = async (center) => {
  // Fetch the stores from the data source
  stores = (await fetchStores(center)).features;

  // Create circular markers based on the stores
  circles = stores.map((store) => storeToCircle(store, map, infowindow));
};

Reemplaza la definición de storeToCircle por esta versión un poco más larga, que ahora toma una ventana de información como un tercer argumento:

app.js - storeToCircle

const storeToCircle = (store, map, infowindow) => {
  const [lng, lat] = store.geometry.coordinates;
  const circle = new google.maps.Circle({
    radius: 50,
    strokeColor: "#579d42",
    strokeOpacity: 0.8,
    strokeWeight: 5,
    center: { lat, lng },
    map,
  });
  circle.addListener("click", () => {
    infowindow.setContent(`${store.properties.business_name}<br />
      ${store.properties.address_address}<br />
      Austin, TX ${store.properties.zip_code}`);
    infowindow.setPosition({ lat, lng });
    infowindow.setOptions({ pixelOffset: new google.maps.Size(0, -30) });
    infowindow.open(map);
  });
  return circle;
};

En el código nuevo de arriba, se muestra una infoWindow con la información de la tienda seleccionada cada vez que se hace clic en un marcador de tienda en el mapa.

Si el servidor aún está ejecutándose, detenlo y reinícialo. Actualiza la página del mapa y prueba a hacer clic en un marcador de mapa. Debería aparecer una pequeña ventana de información con el nombre y la dirección de la empresa, con un aspecto similar al siguiente:

1af0ab72ad0eadc5.png

7. Obtén la ubicación inicial del usuario

Por lo general, los usuarios de localizadores de tiendas desean saber qué tienda está más cerca de ellos o de una dirección desde la que planean comenzar su recorrido. Agrega una barra de búsqueda de Place Autocomplete para permitir que el usuario ingrese fácilmente una dirección inicial. Place Autocomplete proporciona una funcionalidad de autocompletado similar a la que tienen otras barras de búsqueda de Google, pero las predicciones son todos lugares que están en Google Maps Platform.

Crea un campo de entrada del usuario

Vuelve a editar style.css para modificar el estilo de la barra de búsqueda con autocompletado y el panel lateral asociado de resultados. Mientras actualizamos los estilos CSS, también agregaremos estilos para una barra lateral futura que muestre información de la tienda, como una lista para acompañar el mapa.

Agrega el siguiente código al final del archivo.

style.css

#panel {
  height: 100%;
  flex-basis: 0;
  flex-grow: 0;
  overflow: auto;
  transition: all 0.2s ease-out;
}

#panel.open {
  flex-basis: auto;
}

#panel .place {
  font-family: "open sans", arial, sans-serif;
  font-size: 1.2em;
  font-weight: 500;
  margin-block-end: 0px;
  padding-left: 18px;
  padding-right: 18px;
}

#panel .distanceText {
  color: silver;
  font-family: "open sans", arial, sans-serif;
  font-size: 1em;
  font-weight: 400;
  margin-block-start: 0.25em;
  padding-left: 18px;
  padding-right: 18px;
}

/* Styling for Autocomplete search bar */
#pac-card {
  background-color: #fff;
  border-radius: 2px 0 0 2px;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
  box-sizing: border-box;
  font-family: Roboto;
  margin: 10px 10px 0 0;
  -moz-box-sizing: border-box;
  outline: none;
}

#pac-container {
  padding-top: 12px;
  padding-bottom: 12px;
  margin-right: 12px;
}

#pac-input {
  background-color: #fff;
  font-family: Roboto;
  font-size: 15px;
  font-weight: 300;
  margin-left: 12px;
  padding: 0 11px 0 13px;
  text-overflow: ellipsis;
  width: 400px;
}

#pac-input:focus {
  border-color: #4d90fe;
}

#pac-title {
  color: #fff;
  background-color: #acbcc9;
  font-size: 18px;
  font-weight: 400;
  padding: 6px 12px;
}

.hidden {
  display: none;
}

La barra de búsqueda con autocompletado y el panel deslizable están inicialmente ocultos y aparecen cuando se los necesita.

Prepara un div para el widget de autocompletado utilizando el siguiente código y colócalo en reemplazo del comentario en index.html que dice: "<!-- Autocomplete div goes here -->". Cuando hagamos este cambio, también agregaremos el div para el panel deslizante.

index.html

     <div id="panel" class="closed"></div>
     <div class="hidden">
      <div id="pac-card">
        <div id="pac-title">Find the nearest location</div>
        <div id="pac-container">
          <input
            id="pac-input"
            type="text"
            placeholder="Enter an address"
            class="pac-target-input"
            autocomplete="off"
          />
        </div>
      </div>
    </div>

Ahora, define una función para agregar el widget de autocompletado al mapa. Para ello, agrega el siguiente código al final de app.js.

app.js

const initAutocompleteWidget = () => {
  // Add search bar for auto-complete
  // Build and add the search bar
  const placesAutoCompleteCardElement = document.getElementById("pac-card");
  const placesAutoCompleteInputElement = placesAutoCompleteCardElement.querySelector(
    "input"
  );
  const options = {
    types: ["address"],
    componentRestrictions: { country: "us" },
    map,
  };
  map.controls[google.maps.ControlPosition.TOP_RIGHT].push(
    placesAutoCompleteCardElement
  );
  // Make the search bar into a Places Autocomplete search bar and select
  // which detail fields should be returned about the place that
  // the user selects from the suggestions.
  const autocomplete = new google.maps.places.Autocomplete(
    placesAutoCompleteInputElement,
    options
  );
  autocomplete.setFields(["address_components", "geometry", "name"]);
  map.addListener("bounds_changed", () => {
    autocomplete.setBounds(map.getBounds());
  });

  // TODO: Respond when a user selects an address
};

El código restringe las sugerencias de autocompletado para que solo se muestren las direcciones (dado que Place Autocomplete también puede encontrar coincidencias con nombres de establecimientos y ubicaciones administrativas), y limita las direcciones que se muestran solo a las de EE.UU. Agregar estas especificaciones opcionales reducirá la cantidad de caracteres que el usuario deba ingresar a fin de limitar las predicciones para que se muestre la dirección que busca.

Luego, hace que el elemento div de autocompletado que creaste se mueva a la esquina superior derecha del mapa y especifica los campos que deben mostrarse sobre cada sitio que aparezca en la respuesta.

Por último, llama a la función initAutocompleteWidget al final de la función initialize y, con ese código, reemplaza el comentario que dice "// TODO: Initialize the Autocomplete widget".

app.js - initialize

 // Initialize the Places Autocomplete Widget
 initAutocompleteWidget();

Ejecuta el comando siguiente para reiniciar el servidor y, luego, actualiza la vista previa.

go run *.go

Deberías ver un widget de autocompletado en la esquina superior derecha del mapa con las direcciones de EE.UU. que coinciden con lo que escribes, seleccionadas en función del área visible del mapa.

58e9bbbcc4bf18d1.png

Actualiza el mapa cuando el usuario selecciona una dirección de inicio

Ahora, debes agregar un mecanismo que controle cuando el usuario selecciona una predicción del widget de autocompletado y usarla como base para calcular las distancias a tus tiendas.

Agrega el siguiente código al final de initAutocompleteWidget en app.js, en reemplazo del comentario "// TODO: Respond when a user selects an address".

app.js - initAutocompleteWidget

  // Respond when a user selects an address
  // Set the origin point when the user selects an address
  originMarker = new google.maps.Marker({ map: map });
  originMarker.setVisible(false);
  let originLocation = map.getCenter();
  autocomplete.addListener("place_changed", async () => {
    // circles.forEach((c) => c.setMap(null)); // clear existing stores
    originMarker.setVisible(false);
    originLocation = map.getCenter();
    const place = autocomplete.getPlace();

    if (!place.geometry) {
      // User entered the name of a Place that was not suggested and
      // pressed the Enter key, or the Place Details request failed.
      window.alert("No address available for input: '" + place.name + "'");
      return;
    }
    // Recenter the map to the selected address
    originLocation = place.geometry.location;
    map.setCenter(originLocation);
    map.setZoom(15);
    originMarker.setPosition(originLocation);
    originMarker.setVisible(true);

    // await fetchAndRenderStores(originLocation.toJSON());
    // TODO: Calculate the closest stores
  });

El código agrega un objeto de escucha para que, cuando el usuario hace clic en una de las sugerencias, el mapa se centre en la dirección seleccionada y se establezca ese origen como base para calcular las distancias. Implementarás los cálculos de distancia en un paso posterior.

Detén y reinicia tu servidor, y actualiza la vista previa para observar cómo el mapa se vuelve a centrar después de que ingresas una dirección en la barra de búsqueda de autocompletado.

8. Ajusta la escala con Cloud SQL

Por el momento, tenemos un excelente localizador de tiendas. Esta herramienta aprovecha la realidad de que hay solo alrededor de cien ubicaciones que la app usará, ya que las carga en la memoria del backend (en lugar de leerlas cada vez desde el archivo). Pero ¿qué pasa si el localizador necesita funcionar en una escala diferente? Si tienes cientos de ubicaciones dispersas por un área geográfica extensa (o miles en todo el mundo), mantener todas esas ubicaciones en la memoria ya no es la mejor idea. Por su parte, dividir las zonas en archivos individuales generaría otros problemas propios de esa solución.

Es hora de cargar tus ubicaciones desde una base de datos. Para este paso, migraremos todas las ubicaciones de tu archivo GeoJSON a una base de datos de Cloud SQL y actualizaremos el backend de Go para que extraiga los resultados de esa base de datos en lugar de su caché local cada vez que reciba una solicitud.

Crea una instancia de Cloud SQL con una base de datos PostGres

Puedes crear una instancia de Cloud SQL a través de Google Cloud Console, pero es aún más fácil usar la utilidad gcloud para crear una desde la línea de comandos. En Cloud Shell, crea una instancia de Cloud SQL con el siguiente comando:

gcloud sql instances create locations \
--database-version=POSTGRES_12 \
--tier=db-custom-1-3840 --region=us-central1
  • El argumento locations es el nombre que elegimos para esta instancia de Cloud SQL.
  • La marca tier nos permite seleccionar una de las máquinas convenientemente predefinidas.
  • El valor db-custom-1-3840 indica que la instancia que se está creando debe tener una CPU virtual y aproximadamente 3.75 GB de memoria.

La instancia de Cloud SQL se creará y se inicializará con una base de datos PostGresSQL, con el usuario predeterminado postgres. ¿Cuál es la contraseña de este usuario? Muy buena pregunta. No tiene. Debes configurar una para poder acceder.

Utiliza el siguiente comando para configurar la contraseña:

gcloud sql users set-password postgres \
    --instance=locations --prompt-for-password

Luego, ingresa la contraseña que hayas elegido cuando se te solicite hacerlo.

Habilita la extensión PostGIS

PostGIS es una extensión para PostGresSQL que facilita el almacenamiento de tipos de datos geoespaciales estandarizados. En circunstancias normales, habría sido necesario realizar un proceso de instalación completo para agregar PostGIS a nuestra base de datos. Afortunadamente, es una de las extensiones compatibles con Cloud SQL para PostGresSQL.

Conéctate a la instancia de la base de datos. Para ello, accede como el usuario postgres con el siguiente comando en la terminal de Cloud Shell.

gcloud sql connect locations --user=postgres --quiet

Ingresa la contraseña que acabas de crear. Ahora, agrega la extensión PostGIS cuando aparezca el símbolo del sistema postgres=>.

CREATE EXTENSION postgis;

Si todo sale bien, el resultado debería ser CREATE EXTENSION, tal como se muestra a continuación.

Resultado del comando de ejemplo

CREATE EXTENSION

Por último, cierra la conexión con la base de datos ingresando el comando postgres=> junto al símbolo del sistema.

\q

Importa datos geográficos a la base de datos

Ahora, tenemos que importar todos los datos de ubicación de los archivos GeoJSON a nuestra nueva base de datos.

Afortunadamente, este es un problema común, y se pueden encontrar varias herramientas en Internet para automatizar este proceso. Usaremos una herramienta llamada ogr2ogr, que permite realizar conversiones entre varios formatos comunes para almacenar datos geoespaciales. Una de sus opciones, por supuesto, es convertir de GeoJSON a un archivo de volcado de SQL. Luego, puedes usar ese archivo a fin de crear las tablas y columnas para la base de datos y cargarla con todos los datos que existían en tus archivos GeoJSON.

Crea un archivo de volcado de SQL

Primero, instala ogr2ogr.

sudo apt-get install gdal-bin

Luego, usa ogr2ogr para crear el archivo de volcado de SQL. Este archivo generará una tabla llamada austinrecycling.

ogr2ogr --config PG_USE_COPY YES -f PGDump datadump.sql \
data/recycling-locations.geojson -nln austinrecycling

El comando anterior se basa en ejecutar el código desde la carpeta austin-recycling. Si necesitas ejecutarlo desde otro directorio, reemplaza data por la ruta al directorio en el que se encuentra recycling-locations.geojson.

Propaga las ubicaciones de reciclaje en tu base de datos

Una vez que se completa ese último comando, deberías tener un archivo datadump.sql, en el mismo directorio en el que ejecutó el comando. Si lo abres, verás un poco más de cien líneas de SQL, con las que se crea una tabla austinrecycling y se propagan las ubicaciones en ella.

Ahora, abre una conexión a la base de datos y escribe el siguiente comando para ejecutar esa secuencia de comandos.

gcloud sql connect locations --user=postgres --quiet < datadump.sql

Si se ejecuta correctamente, las últimas líneas del resultado serán similares a las siguientes:

Resultado del comando de muestra

ALTER TABLE
ALTER TABLE
ATLER TABLE
ALTER TABLE
COPY 103
COMMIT
WARNING: there is no transaction in progress
COMMIT

Actualiza el backend de Go para usar Cloud SQL

Ahora que tenemos todos estos datos en nuestra base de datos, es momento de actualizar el código.

Actualiza el frontend para que envíe información sobre la ubicación

Comencemos con una actualización muy pequeña en el frontend. Ahora, escribimos esta app para una escala en la que no queremos que se envíen al frontend todas las ubicaciones cada vez que se ejecuta una consulta. Por eso, necesitamos pasar información básica del frontend sobre la ubicación que le interesa al usuario.

Abre app.js y reemplaza la definición de la función fetchStores por esta versión para incluir la latitud y longitud de interés en la URL.

app.js - fetchStores

const fetchStores = async (center) => {
  const url = `/data/dropoffs?centerLat=${center.lat}&centerLng=${center.lng}`;
  const response = await fetch(url);
  return response.json();
};

Después de completar este paso del codelab, la respuesta solo mostrará las tiendas más cercanas a las coordenadas del mapa proporcionadas en el parámetro center. La función initialize hace una recuperación inicial de los datos. Para esa acción, el código de muestra proporcionado en este lab usa las coordenadas centrales de Austin, Texas.

Dado que fetchStores ahora solo mostrará un subconjunto de las ubicaciones de las tiendas, necesitaremos volver a cargar las tiendas cada vez que el usuario cambie su ubicación inicial.

Actualiza la función initAutocompleteWidget para que se actualicen las ubicaciones siempre que se establezca un origen nuevo. Esto requiere dos modificaciones:

  1. Dentro de initAutocompleteWidget, busca la devolución de llamada para el objeto de escucha place_changed. Quita los comentarios de la línea que borra los círculos existentes, de modo que esa línea se ejecute cada vez que el usuario seleccione una dirección en la barra de búsqueda de Place Autocomplete.

app.js - initAutocompleteWidget

  autocomplete.addListener("place_changed", async () => {
    circles.forEach((c) => c.setMap(null)); // clear existing stores
    // ...
  1. Cada vez que se cambia el origen seleccionado, la variable originLocation se actualiza. Al final de la devolución de llamada "place_changed", quita el comentario que se encuentra sobre la línea "// TODO: Calculate the closest stores" para pasar este nuevo origen en una llamada nueva a la función fetchAndRenderStores.

app.js - initAutocompleteWidget

    await fetchAndRenderStores(originLocation.toJSON());
    // TODO: Calculate the closest stores

Actualiza el backend para usar Cloud SQL en lugar de un archivo JSON sin formato

Quita el almacenamiento en caché y la lectura del archivo GeoJSON sin formato

Primero, modifica main.go para quitar el código que carga y almacena en caché el archivo GeoJSON sin formato. También podemos deshacernos de la función dropoffsHandler, ya que escribiremos una con la tecnología de Cloud SQL en un archivo diferente.

Tu nuevo archivo main.go será mucho más corto.

main.go

package main

import (

        "log"
        "net/http"
        "os"
)

func main() {

        initConnectionPool()

        // Request for data should be handled by Go.  Everything else should be directed
        // to the folder of static files.
        http.HandleFunc("/data/dropoffs", dropoffsHandler)
        http.Handle("/", http.FileServer(http.Dir("./static/")))

        // Open up a port for the webserver.
        port := os.Getenv("PORT")
        if port == "" {
                port = "8080"
        }
        log.Printf("Listening on port %s", port)
        if err := http.ListenAndServe(":"+port, nil); err != nil {
                log.Fatal(err)
        }
}

Crea un nuevo controlador para las solicitudes de ubicación

Ahora, creemos otro archivo, locations.go, en el directorio austin-recycling. Primero, vuelve a implementar el controlador para las solicitudes de ubicación.

locations.go

package main

import (
        "database/sql"
        "fmt"
        "log"
        "net/http"
        "os"

        _ "github.com/jackc/pgx/stdlib"
)

// queryBasic demonstrates issuing a query and reading results.
func dropoffsHandler(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-type", "application/json")
        centerLat := r.FormValue("centerLat")
        centerLng := r.FormValue("centerLng")
        geoJSON, err := getGeoJSONFromDatabase(centerLat, centerLng)
        if err != nil {
                str := fmt.Sprintf("Couldn't encode results: %s", err)
                http.Error(w, str, 500)
                return
        }
        fmt.Fprintf(w, geoJSON)
}

El controlador realiza las siguientes tareas significativas:

  • Extrae la latitud y longitud del objeto de la solicitud (¿recuerdas cómo agregamos esos datos a la URL? ).
  • Activa la llamada a getGeoJsonFromDatabase, que devuelve una string GeoJSON (escribiremos esto más adelante).
  • Usa ResponseWriter para imprimir esa string GeoJSON en la respuesta.

A continuación, crearemos un grupo de conexiones para ayudar a que el uso de la base de datos pueda adaptarse adecuadamente a las solicitudes de usuarios simultáneos.

Crea un grupo de conexiones

Un grupo de conexiones es un conjunto de conexiones activas a la base de datos que el servidor puede reutilizar para responder las solicitudes de los usuarios. Reduce enormemente la sobrecarga a medida que aumenta la cantidad de usuarios activos, ya que el servidor no necesita dedicar tiempo a la creación y destrucción de conexiones para cada usuario activo. Quizá notaste en la sección anterior que importamos la biblioteca github.com/jackc/pgx/stdlib. Se trata de una biblioteca popular para trabajar con grupos de conexiones en Go.

Al final de locations.go, crea una función initConnectionPool (a la que se llama desde main.go) para que inicialice un grupo de conexiones. Para mayor claridad, se usan varios métodos auxiliares en este fragmento. configureConnectionPool proporciona un lugar práctico donde ajustar la configuración del grupo, como la cantidad de conexiones y la vida útil por conexión. mustGetEnv envuelve las llamadas para obtener las variables de entorno necesarias, a fin de que se puedan arrojar mensajes de error útiles si a la instancia le falta información importante (como la IP o el nombre de la base de datos a la que se debe conectar).

locations.go

// The connection pool
var db *sql.DB

// Each struct instance contains a single row from the query result.
type result struct {
        featureCollection string
}

func initConnectionPool() {
        // If the optional DB_TCP_HOST environment variable is set, it contains
        // the IP address and port number of a TCP connection pool to be created,
        // such as "127.0.0.1:5432". If DB_TCP_HOST is not set, a Unix socket
        // connection pool will be created instead.
        if os.Getenv("DB_TCP_HOST") != "" {
                var (
                        dbUser    = mustGetenv("DB_USER")
                        dbPwd     = mustGetenv("DB_PASS")
                        dbTCPHost = mustGetenv("DB_TCP_HOST")
                        dbPort    = mustGetenv("DB_PORT")
                        dbName    = mustGetenv("DB_NAME")
                )

                var dbURI string
                dbURI = fmt.Sprintf("host=%s user=%s password=%s port=%s database=%s", dbTCPHost, dbUser, dbPwd, dbPort, dbName)

                // dbPool is the pool of database connections.
                dbPool, err := sql.Open("pgx", dbURI)
                if err != nil {
                        dbPool = nil
                        log.Fatalf("sql.Open: %v", err)
                }

                configureConnectionPool(dbPool)

                if err != nil {

                        log.Fatalf("initConnectionPool: unable to connect: %s", err)
                }
                db = dbPool
        }
}

// configureConnectionPool sets database connection pool properties.
// For more information, see https://golang.org/pkg/database/sql
func configureConnectionPool(dbPool *sql.DB) {
        // Set maximum number of connections in idle connection pool.
        dbPool.SetMaxIdleConns(5)
        // Set maximum number of open connections to the database.
        dbPool.SetMaxOpenConns(7)
        // Set Maximum time (in seconds) that a connection can remain open.
        dbPool.SetConnMaxLifetime(1800)
}

// mustGetEnv is a helper function for getting environment variables.
// Displays a warning if the environment variable is not set.
func mustGetenv(k string) string {
        v := os.Getenv(k)
        if v == "" {
                log.Fatalf("Warning: %s environment variable not set.\n", k)
        }
        return v
}

Consulta la base de datos para conocer las ubicaciones y obtén un JSON en respuesta

Ahora, escribiremos una consulta a la base de datos que tome coordenadas de mapas y muestre las 25 ubicaciones más cercanas. No solo eso, sino que, gracias a algunas funcionalidades modernas de la base de datos, esos datos se devolverán como GeoJSON. El resultado final de todo esto es que, en lo que respecta al frontend, nada cambió. Antes enviaba una solicitud a una URL y obtenía varios datos GeoJSON. Ahora envía una solicitud a una URL y… obtiene varios datos GeoJSON.

La función para lograr esa magia es la siguiente. Agrégala después del código del controlador y el agrupador de conexiones que acabas de escribir en la parte inferior de locations.go.

locations.go

func getGeoJSONFromDatabase(centerLat string, centerLng string) (string, error) {

        // Obviously you can one-line this, but for testing purposes let's make it easy to modify on the fly.
        const milesRadius = 10
        const milesToMeters = 1609
        const radiusInMeters = milesRadius * milesToMeters

        const tableName = "austinrecycling"

        var queryStr = fmt.Sprintf(
                `SELECT jsonb_build_object(
                        'type',
                        'FeatureCollection',
                        'features',
                        jsonb_agg(feature)
                )
        FROM (
                        SELECT jsonb_build_object(
                                        'type',
                                        'Feature',
                                        'id',
                                        ogc_fid,
                                        'geometry',
                                        ST_AsGeoJSON(wkb_geometry)::jsonb,
                                        'properties',
                                        to_jsonb(row) - 'ogc_fid' - 'wkb_geometry'
                                ) AS feature
                        FROM (
                                        SELECT *,
                                                ST_Distance(
                                                        ST_GEOGFromWKB(wkb_geometry),
                                                        -- Los Angeles (LAX)
                                                        ST_GEOGFromWKB(st_makepoint(%v, %v))
                                                ) as distance
                                        from %v
                                        order by distance
                                        limit 25
                                ) row
                        where distance < %v
                ) features
                `, centerLng, centerLat, tableName, radiusInMeters)

        log.Println(queryStr)

        rows, err := db.Query(queryStr)

        defer rows.Close()

        rows.Next()
        queryResult := result{}
        err = rows.Scan(&queryResult.featureCollection)
        return queryResult.featureCollection, err
}

La principal tarea de esta función es realizar las acciones de configuración, desconexión y manejo de errores relacionadas con el envío de una solicitud a la base de datos. Veamos el SQL en sí, el cual hace muchas cosas interesantes en la capa de la base de datos, para que no tengas que preocuparte por implementarlas en el código.

La consulta sin procesar que se envía, una vez que se analiza la string y todos sus literales se insertan donde corresponde, se ve de la siguiente manera:

parsed.sql

SELECT jsonb_build_object(
        'type',
        'FeatureCollection',
        'features',
        jsonb_agg(feature)
    )
FROM (
        SELECT jsonb_build_object(
                'type',
                'Feature',
                'id',
                ogc_fid,
                'geometry',
                ST_AsGeoJSON(wkb_geometry)::jsonb,
                'properties',
                to_jsonb(row) - 'ogc_fid' - 'wkb_geometry'
            ) AS feature
        FROM (
                SELECT *,
                    ST_Distance(
                        ST_GEOGFromWKB(wkb_geometry),
                        -- Los Angeles (LAX)
                        ST_GEOGFromWKB(st_makepoint(-97.7624043, 30.523725))
                    ) as distance
                from austinrecycling
                order by distance
                limit 25
            ) row
        where distance < 16090
    ) features

Esta consulta se puede ver como una consulta principal y algunas funciones que envuelven el JSON.

SELECT * ... LIMIT 25 selecciona todos los campos para cada ubicación. Luego, utiliza la función ST_DISTANCE (parte del conjunto de funciones de medición geográfica de PostGIS) para determinar la distancia entre cada ubicación de la base de datos y el par de latitud y longitud de la ubicación que el usuario proporcionó en el frontend. Recuerda que, a diferencia de la matriz de distancia, que puede darte la distancia en automóvil, estas son distancias geoespaciales. Para mayor eficiencia, el código utiliza esa distancia para ordenar las ubicaciones y mostrar las 25 más cercanas a la ubicación especificada del usuario.

**SELECT json_build_object(‘type', ‘F**eature') envuelve la consulta anterior, se toman los resultados y se utilizan para crear un objeto Feature de GeoJSON. De manera inesperada, en esta consulta también se aplica el radio máximo "16090", que es la cantidad de metros equivalente a 10 millas, el límite estricto especificado en el backend de Go. Si te preguntas por qué esta cláusula WHERE no se agregó a la consulta interna (donde se determina la distancia de cada ubicación), se debe a la forma en que SQL se ejecuta en segundo plano, por la cual es posible que no haya existido el cálculo de ese campo cuando se examinó la cláusula WHERE. De hecho, si intentas mover esta cláusula WHERE a la consulta interna, arrojará un error.

**SELECT json_build_object(‘type', ‘FeatureColl**ection') envuelve todas las filas resultantes de la consulta que genera el JSON en un objeto FeatureCollection de GeoJSON.

Agrega una biblioteca de PGX a tu proyecto

Debemos agregar una dependencia a tu proyecto: PostGres Driver and Toolkit, que habilita la agrupación de conexiones. La manera más fácil de hacerlo es mediante los módulos de Go. Escribe este comando en Cloud Shell para inicializar un módulo:

go mod init my_locator

A continuación, ejecuta este comando para analizar el código en busca de dependencias, agregar una lista de dependencias al archivo mod y descargarlas.

go mod tidy

Por último, ejecuta este comando para extraer las dependencias directamente en el directorio de tu proyecto a fin de que el contenedor pueda compilarse con facilidad para App Engine Flex.

go mod vendor

Todo listo para probarlo.

Pruébalo

Hicimos MUCHO. Veamos cómo funciona.

Para que tu máquina de desarrollo (sí, incluso Cloud Shell) se conecte a la base de datos, tenemos que usar el proxy de Cloud SQL a fin de administrar la conexión de la base de datos. Para configurar el proxy de Cloud SQL, haz lo siguiente:

  1. Ve aquí para habilitar la API de Cloud SQL Admin.
  2. Si estás en una máquina de desarrollo local, instala la herramienta proxy de Cloud SQL. Si usas Cloud Shell, puedes omitir este paso, porque ya está instalado. Ten en cuenta que las instrucciones se refieren a una cuenta de servicio. Ya se creó una para ti. En la sección siguiente, veremos cómo agregar los permisos necesarios a esa cuenta.
  3. Crea una pestaña nueva (en Cloud Shell o tu propia terminal) para iniciar el proxy.

bcca42933bfbd497.png

  1. Visita https://console.cloud.google.com/sql/instances/locations/overview y desplázate hacia abajo hasta encontrar el campo Nombre de la conexión. Copia ese nombre para usarlo en el siguiente comando.
  2. En esa pestaña, ejecuta el proxy de Cloud SQL con este comando y reemplaza CONNECTION_NAME por el nombre de conexión que se muestra en el paso anterior.
cloud_sql_proxy -instances=CONNECTION_NAME=tcp:5432

Regresa a la primera pestaña de tu Cloud Shell y define las variables de entorno que Go necesitará para comunicarse con el backend de la base de datos. Luego, ejecuta el servidor de la misma forma que antes:

Navega al directorio raíz del proyecto si aún no lo hiciste.

cd YOUR_PROJECT_ROOT

Crea las siguientes cinco variables de entorno (reemplaza YOUR_PASSWORD_HERE por la contraseña que creaste anteriormente).

export DB_USER=postgres
export DB_PASS=YOUR_PASSWORD_HERE
export DB_TCP_HOST=127.0.0.1 # Proxy
export DB_PORT=5432 #Default for PostGres
export DB_NAME=postgres

Ejecuta tu instancia local.

go run *.go

Abre la ventana de vista previa. Debería funcionar como si nada hubiese cambiado: puedes ingresar una dirección inicial, hacer zoom en el mapa y hacer clic en las ubicaciones de reciclaje. Ahora, cuenta con el respaldo de una base de datos y está preparado para el escalamiento.

9. Muestra las tiendas más cercanas

La API de Directions funciona de manera muy similar a lo que sucede cuando se solicitan instrucciones sobre cómo llegar en la app de Google Maps. Es decir, se debe ingresar un origen y un destino para recibir una ruta entre ambos. La API de Distance Matrix amplía este concepto y permite identificar las combinaciones óptimas entre varios orígenes posibles y múltiples destinos posibles según los tiempos de viaje y la distancia. En este caso, para ayudar a que el usuario encuentre la tienda más cercana a la dirección seleccionada, debes proporcionar un origen y un array de ubicaciones de las tiendas como destinos.

Agrega la distancia desde el origen hasta cada tienda

Al comienzo de la definición de la función initMap, reemplaza el comentario "// TODO: Start Distance Matrix service" por el siguiente código:

app.js - initMap

distanceMatrixService = new google.maps.DistanceMatrixService();

Agrega una función nueva al final de app.js llamada calculateDistances.

app.js

async function calculateDistances(origin, stores) {
  // Retrieve the distances of each store from the origin
  // The returned list will be in the same order as the destinations list
  const response = await getDistanceMatrix({
    origins: [origin],
    destinations: stores.map((store) => {
      const [lng, lat] = store.geometry.coordinates;
      return { lat, lng };
    }),
    travelMode: google.maps.TravelMode.DRIVING,
    unitSystem: google.maps.UnitSystem.METRIC,
  });
  response.rows[0].elements.forEach((element, index) => {
    stores[index].properties.distanceText = element.distance.text;
    stores[index].properties.distanceValue = element.distance.value;
  });
}

const getDistanceMatrix = (request) => {
  return new Promise((resolve, reject) => {
    const callback = (response, status) => {
      if (status === google.maps.DistanceMatrixStatus.OK) {
        resolve(response);
      } else {
        reject(response);
      }
    };
    distanceMatrixService.getDistanceMatrix(request, callback);
  });
};

La función llama a la API de Distance Matrix utilizando el origen que se le pasó como un único origen y las ubicaciones de las tiendas como un array de destinos. Con esa información, crea un array de objetos que almacena el ID de la tienda, la distancia expresada en una string escrita en lenguaje natural y la distancia expresada en metros como un valor numérico. Luego, ordena el array.

Actualiza la función initAutocompleteWidget para que se calculen las distancias a la tienda cada vez que se selecciona un origen nuevo en la barra de búsqueda de Place Autocomplete. En la parte inferior de la función initAutocompleteWidget, reemplaza el comentario "// TODO: Calculate the closest stores" por el siguiente código:

app.js - initAutocompleteWidget

    // Use the selected address as the origin to calculate distances
    // to each of the store locations
    await calculateDistances(originLocation, stores);
    renderStoresPanel();

Muestra una vista de lista de las tiendas ordenadas por distancia

El usuario espera ver una lista de las tiendas ordenadas en función de su distancia: de la más cercana a la más lejana. Utiliza la lista modificada por la función calculateDistances para conocer el orden en que deben mostrarse las tiendas a fin de propagar los datos de cada tienda en la ficha del panel lateral.

Agrega dos funciones nuevas al final de app.js llamadas renderStoresPanel() y storeToPanelRow().

app.js

function renderStoresPanel() {
  const panel = document.getElementById("panel");

  if (stores.length == 0) {
    panel.classList.remove("open");
    return;
  }

  // Clear the previous panel rows
  while (panel.lastChild) {
    panel.removeChild(panel.lastChild);
  }
  stores
    .sort((a, b) => a.properties.distanceValue - b.properties.distanceValue)
    .forEach((store) => {
      panel.appendChild(storeToPanelRow(store));
    });
  // Open the panel
  panel.classList.add("open");
  return;
}

const storeToPanelRow = (store) => {
  // Add store details with text formatting
  const rowElement = document.createElement("div");
  const nameElement = document.createElement("p");
  nameElement.classList.add("place");
  nameElement.textContent = store.properties.business_name;
  rowElement.appendChild(nameElement);
  const distanceTextElement = document.createElement("p");
  distanceTextElement.classList.add("distanceText");
  distanceTextElement.textContent = store.properties.distanceText;
  rowElement.appendChild(distanceTextElement);
  return rowElement;
};

Ejecuta el comando siguiente para reiniciar el servidor y actualizar la vista previa.

go run *.go

Por último, escribe una dirección de Austin, Texas en la barra de búsqueda con autocompletado y haz clic en una de las sugerencias.

El mapa debe centrarse en esa dirección, y debe aparecer una barra lateral que muestre las ubicaciones de las tiendas en función de la distancia hasta la dirección seleccionada. En la siguiente imagen, puede verse un ejemplo:

96e35794dd0e88c9.png

10. Dale estilo al mapa

Una forma de lograr que el mapa se destaque de forma visual es agregarle estilo. La personalización de tus mapas se controla desde Cloud Console mediante el diseño de mapas basado en la nube (beta). Si prefieres utilizar una función que no sea beta, puedes consultar la documentación sobre ajustes de estilo de mapas a fin de generar código JSON para definir el estilo del mapa de manera programática. En las siguientes instrucciones, se brinda una guía para usar el diseño de mapas basado en la nube (beta).

Crea un ID de mapa

Primero, abre Cloud Console y, en el cuadro de búsqueda, escribe "Administración de mapas" (Map Management). Haz clic en el resultado que dice "Administración de mapas (Google Maps)" [Map Management (Google Maps)]. 64036dd0ed200200.png

Verás un botón cerca de la parte superior (justo debajo del cuadro de búsqueda) que dice: Crea un ID de mapa nuevo (Create Map ID). Haz clic en él y escribe el nombre que desees. En Tipo de mapa (Map type), asegúrate de seleccionar JavaScript y, cuando aparezcan más opciones, selecciona Vector en la lista. El resultado final debería verse como la imagen siguiente.

70f55a759b4c4212.png

Haz clic en "Siguiente" (Next) y aparecerá un nuevo ID de mapa. Puedes copiarlo ahora, pero no te preocupes, ya que podrás buscarlo fácilmente más adelante.

A continuación, crearemos un estilo para aplicarlo a ese mapa.

Crea un estilo de mapa

Si todavía estás en la sección Maps de Cloud Console, haz clic en "Estilos de mapa" en la parte inferior del menú de navegación de la izquierda. De no ser así, puedes hacer lo mismo que para crear un ID de mapa. Escribe "Estilos de mapa" (Map Styles) en el cuadro de búsqueda para encontrar la página adecuada y selecciona "Estilos de mapa (Google Maps)" [Map Styles (Google Maps)] en los resultados, como se ve en la siguiente imagen.

9284cd200f1a9223.png

Luego, haz clic en el botón que está cerca de la parte superior y dice: "+ Create New Map Style".

  1. Si deseas usar el estilo de mapa que se muestra en este lab, haz clic en la pestaña "IMPORTAR JSON" y pega el BLOB de JSON a continuación. Si deseas crear tu propio estilo, selecciona el estilo de mapa que quieras. Luego, haz clic en Siguiente.
  2. Selecciona el ID de mapa que acabas de crear para asociarlo con este estilo y vuelve a hacer clic en Siguiente.
  3. En este punto, tienes la opción de personalizar aún más el estilo de tu mapa. Si es un tema que te gustaría explorar, haz clic en Customize in Style Editor y prueba los colores y opciones hasta que tengas un estilo de mapa que te guste. De lo contrario, haz clic en Omitir.
  4. En el siguiente paso, ingresa el nombre y la descripción del estilo y, luego, haz clic en Guardar y publicar.

Este es un BLOB de JSON opcional para importar en el primer paso.

[
  {
    "elementType": "geometry",
    "stylers": [
      {
        "color": "#d6d2c4"
      }
    ]
  },
  {
    "elementType": "labels.icon",
    "stylers": [
      {
        "visibility": "off"
      }
    ]
  },
  {
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#616161"
      }
    ]
  },
  {
    "elementType": "labels.text.stroke",
    "stylers": [
      {
        "color": "#f5f5f5"
      }
    ]
  },
  {
    "featureType": "administrative.land_parcel",
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#bdbdbd"
      }
    ]
  },
  {
    "featureType": "landscape.man_made",
    "elementType": "geometry.fill",
    "stylers": [
      {
        "color": "#c0baa5"
      },
      {
        "visibility": "on"
      }
    ]
  },
  {
    "featureType": "landscape.man_made",
    "elementType": "geometry.stroke",
    "stylers": [
      {
        "color": "#9cadb7"
      },
      {
        "visibility": "on"
      }
    ]
  },
  {
    "featureType": "poi",
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#757575"
      }
    ]
  },
  {
    "featureType": "poi.park",
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#9e9e9e"
      }
    ]
  },
  {
    "featureType": "road",
    "elementType": "geometry",
    "stylers": [
      {
        "color": "#ffffff"
      }
    ]
  },
  {
    "featureType": "road.arterial",
    "elementType": "geometry",
    "stylers": [
      {
        "weight": 1
      }
    ]
  },
  {
    "featureType": "road.arterial",
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#757575"
      }
    ]
  },
  {
    "featureType": "road.highway",
    "elementType": "geometry",
    "stylers": [
      {
        "color": "#bf5700"
      }
    ]
  },
  {
    "featureType": "road.highway",
    "elementType": "geometry.stroke",
    "stylers": [
      {
        "visibility": "off"
      }
    ]
  },
  {
    "featureType": "road.highway",
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#616161"
      }
    ]
  },
  {
    "featureType": "road.local",
    "elementType": "geometry",
    "stylers": [
      {
        "weight": 0.5
      }
    ]
  },
  {
    "featureType": "road.local",
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#9e9e9e"
      }
    ]
  },
  {
    "featureType": "transit.line",
    "elementType": "geometry",
    "stylers": [
      {
        "color": "#e5e5e5"
      }
    ]
  },
  {
    "featureType": "transit.station",
    "elementType": "geometry",
    "stylers": [
      {
        "color": "#eeeeee"
      }
    ]
  },
  {
    "featureType": "water",
    "elementType": "geometry",
    "stylers": [
      {
        "color": "#333f48"
      }
    ]
  },
  {
    "featureType": "water",
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#9e9e9e"
      }
    ]
  }
]

Agrega un ID de mapa a tu código

Ahora que te ocupaste de crear este estilo de mapa, ¿cómo lo USAS realmente en tu propio mapa? Debes hacer dos cambios pequeños:

  1. Agrega el ID de mapa como un parámetro de URL a la etiqueta script en index.html.
  2. Add el ID de mapa como un argumento de constructor cuando creas el mapa en tu método initMap().

Reemplaza la etiqueta script que carga la API de Maps JavaScript en el archivo HTML con la URL del cargador que se muestra a continuación y reemplaza los marcadores de posición por "YOUR_API_KEY" y "YOUR_MAP_ID":

index.html

...
<script async defer src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY&v=weekly&libraries=places&callback=initialize&map_ids=YOUR_MAP_ID&solution_channel=GMP_codelabs_fullstackstorelocator_v1_a">
  </script>
...

En el método initMap de app.js, donde se define la constante map, quita el comentario de la línea donde se encuentra la propiedad mapId y reemplaza "YOUR_MAP_ID_HERE" por el ID de mapa que acabas de crear:

app.js - initMap

...

// The map, centered on Austin, TX
 const map = new google.maps.Map(document.querySelector('#map'), {
   center: austin,
   zoom: 14,
   mapId: 'YOUR_MAP_ID_HERE',
// ...
});
...

Reinicia el servidor.

go run *.go

Cuando actualices la vista previa, el estilo del mapa debería ser acorde a tus preferencias. La siguiente imagen es un ejemplo en el que se aplica el estilo del JSON de más arriba.

2ece59c64c06e9da.png

11. Implementa para producción

Si deseas que tu app se ejecute desde App Engine Flex (y no solo un servidor web local en tu máquina de desarrollo/Cloud Shell, que es lo que estás haciendo), es muy fácil lograrlo. Solo necesitamos agregar algunos elementos para que el acceso a la base de datos funcione en el entorno de producción. Esto se describe en la página de documentación Conéctate a Cloud SQL desde el entorno flexible de App Engine.

Agrega variables del entorno a app.yaml

Primero, todas las variables de entorno que usaste para realizar una prueba local deben agregarse localmente al final del archivo app.yaml de tu aplicación.

  1. Visita https://console.cloud.google.com/sql/instances/locations/overview para buscar el nombre de conexión de la instancia.
  2. Pega el siguiente código al final de app.yaml.
  3. Reemplaza YOUR_DB_PASSWORD_HERE por la contraseña que creaste para el nombre de usuario de postgres antes.
  4. Reemplaza YOUR_CONNECTION_NAME_HERE por el valor del paso 1.

app.yaml

# ...
# Set environment variables
env_variables:
    DB_USER: postgres
    DB_PASS: YOUR_DB_PASSWORD_HERE
    DB_NAME: postgres
    DB_TCP_HOST: 172.17.0.1
    DB_PORT: 5432

#Enable TCP Port
# You can look up your instance connection name by going to the page for
# your instance in the Cloud Console here : https://console.cloud.google.com/sql/instances/
beta_settings:
  cloud_sql_instances: YOUR_CONNECTION_NAME_HERE=tcp:5432

Ten en cuenta que DB_TCP_HOST debe tener el valor 172.17.0.1, ya que esta app se conecta mediante App Engine Flex**.** Esto se debe a que se comunicará con Cloud SQL a través de un proxy, similar a como lo hacías antes.

Agrega permisos del cliente SQL a la cuenta de servicio de App Engine Flex

Ve a la página de IAM y administración en Cloud Console y busca una cuenta de servicio cuyo nombre coincida con el formato service-PROJECT_NUMBER@gae-api-prod.google.com.iam.gserviceaccount.com. Esta es la cuenta de servicio que App Engine Flex usará para conectarse a la base de datos. Haz clic en el botón Editar al final de la fila y agrega la función “Cliente de Cloud SQL” (Cloud SQL Client).

b04ccc0b4022b905.png

Copia el código de tu proyecto en la ruta de acceso de Go

Para que App Engine ejecute tu código, debe encontrar archivos relevantes en la ruta de acceso de Go. Asegúrate de estar en el directorio raíz del proyecto.

cd YOUR_PROJECT_ROOT

Copia el directorio a la ruta de acceso de Go.

mkdir -p ~/gopath/src/austin-recycling
cp -r ./ ~/gopath/src/austin-recycling

Cambia a ese directorio.

cd ~/gopath/src/austin-recycling

Implementa la app

Usa la herramienta de gcloud para implementar tu app. La implementación tardará un tiempo en procesarse.

gcloud app deploy

Usa el comando browse para obtener un vínculo en el que puedes hacer clic y ver el localizador de tiendas de estilo profesional, completamente implementado, estéticamente increíble y en funcionamiento.

gcloud app browse

Si ejecutas gcloud fuera de Cloud Shell, la ejecución de gcloud app browse abrirá una nueva pestaña del navegador.

12. (Recomendado) Haz una limpieza

Este codelab se mantendrá dentro de los límites del nivel gratuito de procesamiento de BigQuery y llamadas a la API de Maps Platform. Sin embargo, si solo realizaste este ejercicio con fines educativos y deseas evitar que se generen cargos futuros, la manera más fácil de borrar los recursos asociados con este proyecto es borrar todo el proyecto.

Borra el proyecto

En GCP Console, ve a la página Cloud Resource Manager.

En la lista de proyectos, selecciona el proyecto en el que hemos estado trabajando y haz clic en Borrar. Se te pedirá que ingreses el ID del proyecto. Ingrésalo y haz clic en Cerrar.

Como alternativa, puedes borrar todo el proyecto directamente desde Cloud Shell con gcloud. Para ello, ejecuta el siguiente comando y reemplaza el marcador de posición GOOGLE_CLOUD_PROJECT por el ID del proyecto:

gcloud projects delete GOOGLE_CLOUD_PROJECT

13. Felicitaciones

¡Felicitaciones! Completaste el codelab correctamente.

O llegaste a la última página. ¡Felicitaciones! Llegaste a la última página.

Durante el codelab, trabajaste con las siguientes tecnologías:

Material de lectura adicional

Todavía hay mucho por aprender sobre todas estas tecnologías. A continuación, te mostramos algunos vínculos útiles de temas que no tuvimos tiempo de ver en este codelab, pero, sin duda, podrían resultar útiles para crear una solución de localizador de tiendas que se adapte a tus necesidades específicas.