+++ title = "Visualizing Map Data with Leaflet JS" date = "FIXME" tags = ["golang"] categories = [ "golang" ] url = "FIXME" author = "mikit" +++
I'm close to my goal of hitting 1,000 kilometers of walking this year. Whenever I try a new route, I record it. The data that comes out of the recording application (Strava in my case) is in GPX format. Starva does visualize the route, but I like to do it on my own as well. Which brought me to this blog post about using Leaflet JS to visualize GPX data.
We're going to write an HTTP server that accepts a GPX file and returns an interactive map showing the points in the GPX. The map will look like:
Let's start by having a look at the GPX file first
Listing 1: Morning_Walk.gpx
01 <?xml version="1.0" encoding="UTF-8"?>
02 <gpx creator="StravaGPX Android" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd" version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
03 <metadata>
04 <time>2023-01-03T06:50:42Z</time>
05 </metadata>
06 <trk>
07 <name>Morning Walk</name>
08 <type>walking</type>
09 <trkseg>
10 <trkpt lat="32.5254730" lon="34.9429370">
11 <ele>19.5</ele>
12 <time>2023-01-03T06:50:42Z</time>
13 </trkpt>
14 <trkpt lat="32.5254790" lon="34.9429480">
15 <ele>19.5</ele>
16 <time>2023-01-03T06:50:43Z</time>
17 </trkpt>
...
10410 </trkseg>
10411 </trk>
10412 </gpx>
Listing 1 shows a truncated version of Morning_Walk.gpx
. You can see it's an XML format and the location data is in trkpt
elements under trkseg
element.
Let's run a quick query to see how many points are in this file:
Listing 2: Number of Points
$ grep '<trkpt' Morning_Walk.gpx | wc -l
2600
In listing 2 you use grep
and wc
to find how many points are in the GPX files. You can see it's 2,600 points which is too much to display. You are going to aggregate points by the minute in order to reduce the number of points on the map.
Note: Parsing XML with grep is not the best option, for this quick and dirty look it OK, but you should probably use tools such as XMLStarlet
Let's start by parsing the GPX file using the built-in encoing/xml
package.
Listing 3: Parsing GPX
09 // Point is a point in GPX data.
10 type Point struct {
11 Lat float64
12 Lng float64
13 Time time.Time
14 }
15
16 // GPX is data in a GPX file.
17 type GPX struct {
18 Name string
19 Time time.Time
20 Points []Point
21 }
22
23 // ParseGPX parses a GPX file, returns GPX.
24 func ParseGPX(r io.Reader) (GPX, error) {
25 var xmlData struct {
26 Time time.Time `xml:"metadata>time"`
27 Trk struct {
28 Name string `xml:"name"`
29 Points []struct {
30 Lat float64 `xml:"lat,attr"`
31 Lon float64 `xml:"lon,attr"`
32 Time time.Time `xml:"time"`
33 } `xml:"trkseg>trkpt"`
34 } `xml:"trk"`
35 }
36
37 dec := xml.NewDecoder(r)
38 if err := dec.Decode(&xmlData); err != nil {
39 return GPX{}, err
40 }
41
42 gpx := GPX{
43 Name: xmlData.Trk.Name,
44 Time: xmlData.Time,
45 Points: make([]Point, len(xmlData.Trk.Points)),
46 }
47
48 for i, pt := range xmlData.Trk.Points {
49 gpx.Points[i].Lat = pt.Lat
50 gpx.Points[i].Lng = pt.Lon
51 gpx.Points[i].Time = pt.Time
52 }
53
54 return gpx, nil
55 }
Listing 3 shows how to parse a GPX file. On lines 10 and 16 you define the Point
and GPX
structs. They are the types returned from parsing. As a general rule, don't expose the internal data structures (e.g. the one in the XML) to your API.
For example, GPX calls longitude lon
while leaflet uses lng
.
On line 24 you define the ParseGPX
function that accepts an io.Reader
.
On lines 25-35 you define an anonymous struct that corresponds to the structure of the GPX xml. There is no need to model the whole structure of the XML, only the elements you are interested in. encoding/xml
allows you to avoid nesting by using elem>elem
, like you do in line 26.
On line 30 and 31 you specify the Lat
and Lng
are not XML elements but attributes using ,attr
in the field tag.
On lines 37 to 40 you use an XML decoder to parse the data into the xmlData
struct.
On lines 42 to 52 you transform data in xmlData
to the API level GPX
type.
Finally, on line 54 you return the GPX.
Since there are too many points to display on the map, you are going to aggregate the points by minute.
This is similar to SQL GROUP BY
where you first group rows to buckets depending on a key (the time rounded to a minute in our case) and then run an aggregation on the values in each bucket (mean in our case). The SQL code (for SQLite3) can be something like:
SELECT
strftime('%H:%M', time),
AVG(lat),
AVG(lng)
FROM points
GROUP BY strftime('%H:%M', time)
;
Listing 4: Aggregation
57 // roundToMinute rounds time to minute granularity.
58 func roundToMinute(t time.Time) time.Time {
59 year, month, day := t.Year(), t.Month(), t.Day()
60 hour, minute := t.Hour(), t.Minute()
61
62 return time.Date(year, month, day, hour, minute, 0, 0, t.Location())
63 }
64
65 // meanByMinute aggregates points by the minute.
66 func meanByMinute(points []Point) []Point {
67 // Aggregate columns
68 lats := make(map[time.Time][]float64)
69 lngs := make(map[time.Time][]float64)
70
71 // Group by minute
72 for _, pt := range points {
73 key := roundToMinute(pt.Time)
74 lats[key] = append(lats[key], pt.Lat)
75 lngs[key] = append(lngs[key], pt.Lng)
76 }
77
78 // Average per minute
79 avgs := make([]Point, len(lngs))
80 i := 0
81 for time, lats := range lats {
82 avgs[i].Time = time
83 avgs[i].Lat = mean(lats)
84 avgs[i].Lng = mean(lngs[time])
85 i++
86 }
87
88 return avgs
89 }
Listing 4 shows meanByMinute
that aggregates points by minute.
On lines 69, and 69 you define the aggregation columns.
On lines 71-76 you group points by minute.
On lines 79-86 you create a new slice of points where each point has the group time and the average of latitude and longitude.
You are going to use html/template
to render the map. Most of the HTML is static and you'll generate the title, data and list of points dynamically.
Listing 5: Map HTML Template
01 <!doctype html>
02 <html>
03 <head>
04 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css"
05 integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65"
06 crossorigin="anonymous">
07 <meta name="viewport" content="width=device-width, initial-scale=1">
08 <link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css"
09 integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
10 crossorigin=""/>
11 <script src="https://unpkg.com/[email protected]/dist/leaflet.js"
12 integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
13 crossorigin=""></script>
14 </head>
15 <body>
16 <div class="container">
17 <div class="row text-center">
18 <h1 class="alert alert-primary" role="alert">GPX File Viewer</h1>
19 <h3 class="alert alert-secondary" role="alert">{{ .Name }} on {{ .Date }}</h1>
20 </div>
21 <div class="row">
22 <div class="col">
23 <div id="map" style="height: 600px; border: 1px solid black;"></div>
24 </div>
25 </div>
26 </div>
27 <script>
28 var points = [
29 {{- range $idx, $pt := .Points }}
30 {{ if $idx }},{{ end -}}
31 { "lat": {{ $pt.Lat }}, "lng": {{ $pt.Lng -}}, "time": {{ $pt.Time }} }
32 {{- end }}
33 ];
34 var center = [{{ .Center.Lat }}, {{ .Center.Lng }}];
35 </script>
36 <script src="/map.js"></script>
37 </body>
38 </html>
Listing 5 shows the map HTML template file.
On lines 04-13 you import bootstrap for the UI and also the leafletjs CSS and JS files.
On line 19 you use the template to set the name and date of the GPX file.
On lines 28-33 you generate a JavaScript array with the points from the input.
On line 34 you set the center
variable.
On lines 36 you import the map.js
JavaScript code which will use points
and center
.
Listing 6: Map JavaScript
02 function on_loaded() {
03 // Create map & tiles.
04 var map = L.map('map').setView(center, 15);
05 L.tileLayer(
06 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
07 {
08 maxZoom: 19,
09 attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
10 }
11 ).addTo(map);
12
13 // Add points with tooltip to map.
14 points.forEach((pt) => {
15 let circle = L.circle(
16 [pt.lat, pt.lng],
17 {
18 color: 'red',
19 radius: 20
20 }).addTo(map);
21 circle.bindPopup(pt.time);
22 });
23 }
24
25 document.addEventListener('DOMContentLoaded', on_loaded);
Listing 6 show the JavaScript to create the map. On line 04 we create the map with a center location and set the zoom level to 15. On lines 05-11 you load the tiles from OpenStreetMap. On lines 14-23 you iterate over the points, adding them to the map as a red circle and setting the tooltip to be the hour.
Listing 7: Map HTTP Handler
61 // mapHandler gets GPX file via HTML form and return map from mapTemplate.
62 func (a *API) mapHandler(w http.ResponseWriter, r *http.Request) {
63 a.log.Info("map called", "remote", r.RemoteAddr)
64 if r.Method != http.MethodPost {
65 a.log.Error("bad method", "method", r.Method)
66 http.Error(w, "bad method", http.StatusMethodNotAllowed)
67 return
68 }
69
70 if err := r.ParseMultipartForm(1 << 20); err != nil {
71 a.log.Error("bad form", "error", err)
72 http.Error(w, "bad form", http.StatusBadRequest)
73 return
74 }
75
76 file, _, err := r.FormFile("gpx")
77 if err != nil {
78 a.log.Error("missing gpx file", "error", err)
79 http.Error(w, "missing gpx file", http.StatusBadRequest)
80 return
81 }
82
83 gpx, err := ParseGPX(file)
84 if err != nil {
85 a.log.Error("bad gpx", "error", err)
86 http.Error(w, "bad gpx", http.StatusBadRequest)
87 return
88 }
89
90 a.log.Info("gpx parsed", "name", gpx.Name, "count", len(gpx.Points))
91 meanPts := meanByMinute(gpx.Points)
92 a.log.Info("minute agg", "count", len(meanPts))
93
94 // Data for template
95 points := make([]map[string]any, len(meanPts))
96 for i, pt := range meanPts {
97 points[i] = map[string]any{
98 "Lat": pt.Lat,
99 "Lng": pt.Lng,
100 "Time": pt.Time.Format("15:04"), // HH:MM
101 }
102 }
103
104 clat, clng := center(gpx.Points)
105 data := map[string]any{
106 "Name": gpx.Name,
107 "Date": gpx.Time.Format(time.DateOnly),
108 "Center": map[string]float64{"Lat": clat, "Lng": clng},
109 "Points": points,
110 }
111
112 w.Header().Set("content-type", "text/html")
113 if err := mapTemplate.Execute(w, data); err != nil {
114 a.log.Error("can't execute template", "error", err)
115 }
116 }
Listing 7 shows the map HTTP handler.
On lines 70-74 you parse the HTTP form and get the GPX file from the form.
On line 83-88 you parse the GPX and aggregate the points.
On lines 94-110 you generate the data for the template. On lines 95-102 you create the slice of points. On line 104 you calculate the center of the map and on lines 105-110 you create the data
map that contains all the elements.
One line 112 you set the content type and on lines 113-115 you execute the template with the data.
Note: The center
function is not shown here. You can view it in the GitHub repository.
Listing 8: HTTP Handler
12 var (
13 //go:embed index.html map.js
14 staticFS embed.FS
15
16 //go:embed map.html
17 mapHTML string
18 mapTemplate *template.Template
19 )
20
21 type API struct {
22 log *slog.Logger
23 }
…
104 func main() {
105 log := slog.New(slog.NewTextHandler(os.Stdout, nil))
106 tmpl, err := template.New("map").Parse(mapHTML)
107 if err != nil {
108 log.Error("can't parse map HTML", "error", err)
109 os.Exit(1)
110 }
111 mapTemplate = tmpl
112
113 api := API{
114 log: log,
115 }
116
117 mux := http.NewServeMux()
118 mux.Handle("/", http.FileServer(http.FS(staticFS)))
119 mux.HandleFunc("/map", api.mapHandler)
120
121 addr := ":8080"
122 srv := http.Server{
123 Addr: addr,
124 Handler: mux,
125 ReadHeaderTimeout: time.Second,
126 }
127
128 log.Info("server starting", "address", addr)
129 if err := srv.ListenAndServe(); err != nil {
130 log.Error("can't serve", "error", err)
131 os.Exit(1)
132 }
133 }
Listing 8 shows how you run the HTTP server.
On lines 12-18 you embed the HTML files, JavaScript files and the template in the executable using the embed
package.
On lines 21-23 you define the API struct which has a log field.
On line 105 you create a logger from the log/slog
package.
On lines 106-111 you parse the map HTML template and set the package level mapTemplate
variable.
On lines 113-115 you create an API and on lines 117-119 you set the routing, using http.FileServer
to serve index.html and map.js.
On lines 121-126 you create an HTTP server and on lines 128-132 you run it.
Leaflet JS is a great library for map visualization, it uses OpenStreetMap for which has many layers of detailed data. I find it very cool that it only took about 260 lines of Go and JavaScript code to generate an interactive map from raw GPX data.
Leaflet JS has many more capabilities, check out their web site for more demos.
There are two other takeaways from this blog. The first is the process: Decide on how you want the visualization to look and inspect the raw data. Once you have the end point and starting point you can start coding. The second takeaway is to not use the same data structures at all levels of your code. You don’t want to expose the data layer (GPX file format) structure to the business logic (aggregation) or to the API (UI layer). If you do that, changes in one layer will not be isolated.
The code is available at https://github.com/353words/leaflet.
How do you visualize map data? I'd love to hear from you at [email protected].