The NYC Subway Station Locator solution is a slightly different take on the
store locator concept. It builds on the foundation that we created with the
Simple Store Locator, adding enhancements to handle
larger numbers of markers. This tutorial shows you how to do the following
things:
Use GeoJSON to define a static list of locations and associated metadata.
Display markers and polylines on a map.
Create a custom InfoWindow to display location metadata.
Integrate marker clustering to optimize the display for larger numbers of
markers.
Improve efficiency by returning only features within the visible area of the
map.
Get the code
Clone the NYC Subway Locator repo
to your Google Cloud Platform instance, or your local computer.
GeoJSON data sets
The data sets for subway stations and subway lines are publicly available,
thanks to NYC Open Data. Follow these steps
to download the GeoJSON files and add them to your project:
/*
* Copyright 2017 Google Inc. All rights reserved.
*
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this
* file except in compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
* ANY KIND, either express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/
package nycsubway
import (
"fmt"
"math"
rtree "github.com/dhconnelly/rtreego"
geojson "github.com/paulmach/go.geojson"
cluster "github.com/smira/go-point-clustering"
)
// The zoom level to stop clustering at
const minZoomLevelToShowUngroupedStations = 14
// Latitude of NYC, used to guestimate the size of a pixel at a specific
// zoom level.
const nycLatitude float64 = 40.7128
// Station marker image width.
const stationMarkerWidth float64 = 28
// EarthRadius is a rough estimate of earth's radius in km at latitude 0
// if earth was a perfect sphere.
const EarthRadius = 6378.137
// Point enables clustering over `Station`s.
func (s *Station) Point() cluster.Point {
var p cluster.Point
p[0] = s.feature.Geometry.Point[0]
p[1] = s.feature.Geometry.Point[1]
return p
}
func clusterStations(spatials []rtree.Spatial, zoom int) (*geojson.FeatureCollection, error) {
var pl cluster.PointList
for _, spatial := range spatials {
station := spatial.(*Station)
pl = append(pl, station.Point())
}
clusteringRadius, minClusterSize := getClusteringRadiusAndMinClusterSize(zoom)
// The following operation groups stations determined to be nearby into elements of
// "clusters". Some stations may end up not part of any cluster ("noise") - we
// present these as individual stations on the map.
clusters, noise := cluster.DBScan(pl, clusteringRadius, minClusterSize)
fc := geojson.NewFeatureCollection()
for _, id := range noise {
f := spatials[id].(*Station).feature
name, err := f.PropertyString("name")
if err != nil {
return nil, err
}
notes, err := f.PropertyString("notes")
if err != nil {
return nil, err
}
f.SetProperty("title", fmt.Sprintf("%v Station", name))
f.SetProperty("description", notes)
f.SetProperty("type", "station")
fc.AddFeature(f)
}
for _, clstr := range clusters {
ctr, _, _ := clstr.CentroidAndBounds(pl)
f := geojson.NewPointFeature([]float64{ctr[0], ctr[1]})
n := len(clstr.Points)
f.SetProperty("title", fmt.Sprintf("Station Cluster #%v", clstr.C+1))
f.SetProperty("description", fmt.Sprintf("Contains %v stations", n))
f.SetProperty("type", "cluster")
fc.AddFeature(f)
}
return fc, nil
}
func getClusteringRadiusAndMinClusterSize(zoom int) (float64, int) {
// For highest zoom levels, consider stations 10 meters apart as
// the same. Allow for groups of size 2.
if zoom >= minZoomLevelToShowUngroupedStations {
return 0.01, 2
}
groundResolution := groundResolutionByLatAndZoom(nycLatitude, zoom)
// Multiply ground resolution per pixel by the width (in pixels).. +
// "manually adjust".
clusteringRadius := groundResolution * stationMarkerWidth
// Set min group size to 3
return clusteringRadius, 3
}
// groundResolution indicates the distance in km on the ground that is
// represented by a single pixel in the map.
func groundResolutionByLatAndZoom(lat float64, zoom int) float64 {
// number of pixels for the width of the (square) world map in web
// mercator. i.e. for zoom level 0, this would give 256 pixels.
numPixels := math.Pow(2, float64(8+zoom))
// We return earth's circumference (at given latitude) divided by
// number of pixels for the map's width. Note: EarthRadius is given in
// km.
return cos(lat) * 2 * math.Pi * EarthRadius / numPixels
}
// cos returns the cosine function (like math.cos) but accepts degrees as input.
func cos(degree float64) float64 {
return math.Cos(degree * math.Pi / 180)
}
< > Show/Hide nycsubway.go
/*
* Copyright 2017 Google Inc. All rights reserved.
*
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this
* file except in compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
* ANY KIND, either express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/
package nycsubway
import (
"io/ioutil"
"log"
"net/http"
"path/filepath"
)
// GeoJSON is a cache of the NYC Subway Station and Line data.
var GeoJSON = make(map[string][]byte)
// cacheGeoJSON loads files under data into `GeoJSON`.
func cacheGeoJSON() {
filenames, err := filepath.Glob("data/*")
if err != nil {
// Note: this will take down the GAE instance by exiting this process.
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
}
}
// init is called from the App Engine runtime to initialize the app.
func init() {
cacheGeoJSON()
loadStations()
http.HandleFunc("/data/subway-stations", subwayStationsHandler)
http.HandleFunc("/data/subway-lines", subwayLinesHandler)
}
func subwayLinesHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-type", "application/json")
w.Write(GeoJSON["subway-lines.geojson"])
}
< > Show/Hide stations.go
/*
* Copyright 2017 Google Inc. All rights reserved.
*
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this
* file except in compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
* ANY KIND, either express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/
package nycsubway
import (
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"strconv"
"strings"
rtree "github.com/dhconnelly/rtreego"
geojson "github.com/paulmach/go.geojson"
)
// Stations is an RTree housing the stations
var Stations = rtree.NewTree(2, 25, 50)
// Station is a wrapper for `*geojson.Feature` so that we can implement
// `rtree.Spatial` interface type.
type Station struct {
feature *geojson.Feature
}
// Bounds implements `rtree.Spatial` so we can load
// stations into an `rtree.Rtree`.
func (s *Station) Bounds() *rtree.Rect {
return rtree.Point{
s.feature.Geometry.Point[0],
s.feature.Geometry.Point[1],
}.ToRect(1e-6)
}
// loadStations loads the geojson features from
// `subway-stations.geojson` into the `Stations` rtree.
func loadStations() {
stationsGeojson := GeoJSON["subway-stations.geojson"]
fc, err := geojson.UnmarshalFeatureCollection(stationsGeojson)
if err != nil {
// Note: this will take down the GAE instance by exiting this process.
log.Fatal(err)
}
for _, f := range fc.Features {
Stations.Insert(&Station{f})
}
}
// subwayStationsHandler reads r for a "viewport" query parameter
// and writes a GeoJSON response of the features contained in
// that viewport into w.
func subwayStationsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-type", "application/json")
vp := r.FormValue("viewport")
rect, err := newRect(vp)
if err != nil {
str := fmt.Sprintf("Couldn't parse viewport: %s", err)
http.Error(w, str, 400)
return
}
zm, err := strconv.ParseInt(r.FormValue("zoom"), 10, 0)
if err != nil {
str := fmt.Sprintf("Couldn't parse zoom: %s", err)
http.Error(w, str, 400)
return
}
s := Stations.SearchIntersect(rect)
fc, err := clusterStations(s, int(zm))
if err != nil {
str := fmt.Sprintf("Couldn't cluster results: %s", err)
http.Error(w, str, 500)
return
}
err = json.NewEncoder(w).Encode(fc)
if err != nil {
str := fmt.Sprintf("Couldn't encode results: %s", err)
http.Error(w, str, 500)
return
}
}
// newRect constructs a `*rtree.Rect` for a viewport.
func newRect(vp string) (*rtree.Rect, error) {
ss := strings.Split(vp, "|")
sw := strings.Split(ss[0], ",")
swLat, err := strconv.ParseFloat(sw[0], 64)
if err != nil {
return nil, err
}
swLng, err := strconv.ParseFloat(sw[1], 64)
if err != nil {
return nil, err
}
ne := strings.Split(ss[1], ",")
neLat, err := strconv.ParseFloat(ne[0], 64)
if err != nil {
return nil, err
}
neLng, err := strconv.ParseFloat(ne[1], 64)
if err != nil {
return nil, err
}
minLat := math.Min(swLat, neLat)
minLng := math.Min(swLng, neLng)
distLat := math.Max(swLat, neLat) - minLat
distLng := math.Max(swLng, neLng) - minLng
// Grow the rect to ameliorate issues with stations
// disappearing on Zoom in, and being slow to appear
// on Pan or Zoom out.
r, err := rtree.NewRect(
rtree.Point{
minLng - distLng/10,
minLat - distLat/10,
},
[]float64{
distLng * 1.2,
distLat * 1.2,
})
if err != nil {
return nil, err
}
return r, nil
}
YAML configuration file
< > Show/Hide app.yaml
# Copyright 2017 Google Inc. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this
# file except in compliance with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software distributed under
# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
# ANY KIND, either express or implied. See the License for the specific language governing
# permissions and limitations under the License.
runtime: go
api_version: go1
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: _go_app
Packages
Below is a list of the packages required to run this solution, with a summary
of their functionality.
Follow these steps to build and run your app using Google Cloud Platform:
If you have not done so yet, clone the solution repo to
your GCP machine or local computer (these instructions assume that you're using
a GCP machine).
From the top-level directory, run the following command to start the
App Engine development server:
$ goapp serve
Click the first item on the Cloud Shell toolbar, and select Preview on
port 8080. A new browser tab opens, displaying a map centered on New York
City, with markers representing subway stations, and polylines depicting the
subway lines.
Understand the code
This part of the tutorial explains the most significant parts of the NYC Subway
Locator application, to help you understand how to build a similar app.
Initialize the map
index.html is a minimal HTML file that loads the Maps JavaScript API
and app.js script, then displays the map in a div which is styled so that
the map takes up the entire page when it loads. When index.html loads for the
first time, the first script tag loads app.js, and the second script tag
loads the Maps JavaScript API, specifying the API key and
the name of the callback function to invoke once loading is complete:
The initMap() function contains all of the functionality for making data
requests and displaying the map. This function runs as soon as the main page
has loaded. The map constant gets the name of the div in which to display
the map, and specifies initial zoom level, the coordinates at which to center
the map, and the style declaration to use:
const map = new google.maps.Map(document.querySelector('#map'), {
zoom: 12,
center: {
// New York City
lat: 40.7305,
lng: -73.9091
},
styles: mapStyle
});
Load subway line data
subway-lines.geojson stores the data for subway lines. The following
call in app.js makes a request for subway line data:
map.data.loadGeoJson('/data/subway-lines');
Calling loadGeoJson() invokes the subwayLinesHandler() function in
nycsubway.go. Since there isn't too much data involved, the handler simply
returns the entire data set:
func subwayLinesHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-type", "application/json")
w.Write(GeoJSON["subway-lines.geojson"])
}
When the app receives line data, the setStyle() function in app.js applies
the appropriate color for each stroke. Jump ahead to learn more
about styling map elements.
Filtering and clustering
subway-stations.geojson stores the data for subway station locations.
For maximum efficiency, the application filters subway station location data on
the server side, returning only stations that appear within the map bounds at
the current zoom level. The following idle callback function in app.js
makes a request for data, scoped to the map bounds:
Calling loadGeoJson() invokes the subwayStationsHandler() function in
stations.go, which contains all of the functionality for clustering markers
based on zoom level.
The Bounds() and newRect() functions construct an RTree (rtree.Rect).
...
func (s *Station) Bounds() *rtree.Rect {
return rtree.Point{
s.feature.Geometry.Point[0],
s.feature.Geometry.Point[1],
}.ToRect(1e-6)
}
...
func newRect(vp string) (*rtree.Rect, error) {
ss := strings.Split(vp, "|")
sw := strings.Split(ss[0], ",")
swLat, err := strconv.ParseFloat(sw[0], 64)
if err != nil {
return nil, err
}
swLng, err := strconv.ParseFloat(sw[1], 64)
if err != nil {
return nil, err
}
ne := strings.Split(ss[1], ",")
neLat, err := strconv.ParseFloat(ne[0], 64)
if err != nil {
return nil, err
}
neLng, err := strconv.ParseFloat(ne[1], 64)
if err != nil {
return nil, err
}
minLat := math.Min(swLat, neLat)
minLng := math.Min(swLng, neLng)
distLat := math.Max(swLat, neLat) - minLat
distLng := math.Max(swLng, neLng) - minLng
// Grow the rect to ameliorate issues with stations
// disappearing on Zoom in, and being slow to appear
// on Pan or Zoom out.
r, err := rtree.NewRect(
rtree.Point{
minLng - distLng/10,
minLat - distLat/10,
},
[]float64{
distLng * 1.2,
distLat * 1.2,
})
if err != nil {
return nil, err
}
return r, nil
}
The loadStations() function loads all of the GeoJSON features into the
RTree, bounded by the coordinates passed in from loadGeoJson().
func loadStations() {
stationsGeojson := GeoJSON["subway-stations.geojson"]
fc, err := geojson.UnmarshalFeatureCollection(stationsGeojson)
if err != nil {
// Note: this will take down the GAE instance by exiting this process.
log.Fatal(err)
}
for _, f := range fc.Features {
Stations.Insert(&Station{f})
}
}
The subwayStationsHandler() function returns the constrained GeoJSON data,
applying marker clustering in the process.
The clusterStations() function in clusterer.go handles clustering
markers based on zoom level (clusterer.go contains a number of other
functions which are omitted for brevity.
func clusterStations(spatials []rtree.Spatial, zoom int) (*geojson.FeatureCollection, error) {
var pl cluster.PointList
for _, spatial := range spatials {
station := spatial.(*Station)
pl = append(pl, station.Point())
}
clusteringRadius, minClusterSize := getClusteringRadiusAndMinClusterSize(zoom)
// The following operation groups stations determined to be nearby into elements of
// "clusters". Some stations may end up not part of any cluster ("noise") - we
// present these as individual stations on the map.
clusters, noise := cluster.DBScan(pl, clusteringRadius, minClusterSize)
fc := geojson.NewFeatureCollection()
for _, id := range noise {
f := spatials[id].(*Station).feature
name, err := f.PropertyString("name")
if err != nil {
return nil, err
}
notes, err := f.PropertyString("notes")
if err != nil {
return nil, err
}
f.SetProperty("title", fmt.Sprintf("%v Station", name))
f.SetProperty("description", notes)
f.SetProperty("type", "station")
fc.AddFeature(f)
}
for _, clstr := range clusters {
ctr, _, _ := clstr.CentroidAndBounds(pl)
f := geojson.NewPointFeature([]float64{ctr[0], ctr[1]})
n := len(clstr.Points)
f.SetProperty("title", fmt.Sprintf("Station Cluster #%v", clstr.C+1))
f.SetProperty("description", fmt.Sprintf("Contains %v stations", n))
f.SetProperty("type", "cluster")
fc.AddFeature(f)
}
return fc, nil
}
Style map elements
Styling is an important aspect of any
Google Maps app. You can use styling to emphasize the information
you want to present while suppressing details that your users don't need, all
while making your map look more beautiful.
Style the map and features
The mapStyle constant contains the JSON style declaration, which defines the
styling options for the map. The style declaration sets the colors to use for
each map feature, and also specifies whether various features will be visible
on the map. The following example shows the JSON style declaration:
Subway station locations can be represented either as individual stations, or
as a cluster of stations. The setStyle() function in app.js styles each
marker based on its type (station or cluster), and uses SVG paths to draw the markers.
An info window is a pop up window
that the app uses to display information about each subway station when a user
clicks a marker. The info window shows the title and description for the
station selected by the user.
map.data.addListener('click', ev => {
const f = ev.feature;
const title = f.getProperty('title');
const description = f.getProperty('description');
if (!description) {
return;
}
infowindow.setContent(`<b>${title}</b><br/> ${description}`);
// Hat tip geocodezip: http://stackoverflow.com/questions/23814197
infowindow.setPosition(f.getGeometry().get());
infowindow.setOptions({
pixelOffset: new google.maps.Size(0, -30)
});
infowindow.open(map);
});