Introduction
This 12 months I set a private aim of strolling for a complete of 1,000 kilometers and I’m proud to say I’m near hitting that aim. I’ve been monitoring all of the completely different routes I soak up an app named Strava. One good function of Strava is that the app offers entry to the uncooked information in a format referred to as GPX. Starva can visualize the route of their app, however I needed to try to carry out my very own visualization. That introduced me to this weblog put up about utilizing Leaflet JS to plot certainly one of my walks on a map.
On this put up, we’re going to write down a Go service that accepts a GPX file and returns an interactive map {that a} browser can render exhibiting the factors recorded within the GPX file.
The applying will seem like:
Uncooked Knowledge
Let’s begin by reviewing the information that’s recorded within the GPX file.
Itemizing 1: Morning_Walk.gpx
<?xml model="1.0" encoding="UTF-8"?>
<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" model="1.1" xmlns="http://www.topografix.com/GPX/1/1">
<metadata>
<time>2023-01-03T06:50:42Z</time>
</metadata>
<t1rk>
<identify>Morning Stroll</identify>
<sort>strolling</sort>
<trkseg>
<trkpt lat="32.5254730" lon="34.9429370">
<ele>19.5</ele>
<time>2023-01-03T06:50:42Z</time>
</trkpt>
<trkpt lat="32.5254790" lon="34.9429480">
<ele>19.5</ele>
<time>2023-01-03T06:50:43Z</time>
</trkpt>
...
</trkseg>
</trk>
</gpx>
Itemizing 1 reveals a truncated model of a GPX file for certainly one of my morning walks. You’ll be able to see its XML formatted information and the situation information is within the `trkpt/trkseq“ factor.
Let’s run a fast question utilizing grep
to see what number of trkpt
tags there are within the information file:
Itemizing 2: Variety of Factors
$ grep '<trkpt' Morning_Walk.gpx | wc -l
2600
In itemizing 2, you possibly can see there’s 2,600 location factors which is lots to show. Since many of those factors are mainly on high of one another, plotting all of them received’t give us a lot worth. One factor we will do to scale back the variety of factors we show by aggregating the factors by a time frequency. On this case, I’ll combination the factors by a minute.
Notice: Parsing XML with grep is just not the most suitable choice although for my use case it labored tremendous. There are higher instruments corresponding to XMLStarlet
Parsing GPX
To start out, we will parse the GPX information file utilizing the built-in encoing/xml
package deal from the Go commonplace library.
Itemizing 3: Parsing GPX
09 // Level is some extent in GPX information.
10 sort Level struct {
11 Lat float64
12 Lng float64
13 Time time.Time
14 }
15
16 // GPX is information in a GPX file.
17 sort GPX struct {
18 Title string
19 Time time.Time
20 Factors []Level
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 Title string `xml:"identify"`
29 Factors []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 Title: xmlData.Trk.Title,
44 Time: xmlData.Time,
45 Factors: make([]Level, len(xmlData.Trk.Factors)),
46 }
47
48 for i, pt := vary xmlData.Trk.Factors {
49 gpx.Factors[i].Lat = pt.Lat
50 gpx.Factors[i].Lng = pt.Lon
51 gpx.Factors[i].Time = pt.Time
52 }
53
54 return gpx, nil
55 }
Itemizing 3 reveals the way to parse a GPX information file utilizing the Go commonplace library. On strains 10 and 17, I outline the Level
and GPX
structs. They’re the categories returned from parsing. As a common rule, don’t export information buildings that can require particular tags to unmarshal inner information (e.g. the one within the XML). Outline clear information buildings for the API and shield the API from uncontrolled change.
For instance, GPX calls longitude lon
whereas leaflet makes use of lng
.
One different attention-grabbing design alternative is on line 25 contained in the ParseGPX
perform. Right here I outline a literal struct with the suitable tags to unmarshal the GPX information. There is no such thing as a have to mannequin the entire construction of the XML, solely the weather we want. Then on line 48, that information is marshaled into the ultimate struct worth for the API.
Knowledge Aggregation
Since there are too many factors to show on the map, we determined to combination the factors by each minute.
That is just like SQL GROUP BY
the place you first group rows to buckets relying on a key (the time rounded to a minute in our case) after which run an aggregation on the values in every bucket (imply in our case). The SQL code (for SQLite3) may be one thing like:
SELECT
strftime('%H:%M', time),
AVG(lat),
AVG(lng)
FROM factors
GROUP BY strftime('%H:%M', time);
We have to do the identical in Go code, so the next code can carry out that aggregation.
Itemizing 4: Aggregation
57 // roundToMinute rounds time to minute granularity.
58 func roundToMinute(t time.Time) time.Time {
59 12 months, month, day := t.12 months(), t.Month(), t.Day()
60 hour, minute := t.Hour(), t.Minute()
61
62 return time.Date(12 months, month, day, hour, minute, 0, 0, t.Location())
63 }
64
65 // meanByMinute aggregates factors by the minute.
66 func meanByMinute(factors []Level) []Level {
67 // Combination columns
68 lats := make(map[time.Time][]float64)
69 lngs := make(map[time.Time][]float64)
70
71 // Group by minute
72 for _, pt := vary factors {
73 key := roundToMinute(pt.Time)
74 lats[key] = append(lats[key], pt.Lat)
75 lngs[key] = append(lngs[key], pt.Lng)
76 }
77
78 // Common per minute
79 avgs := make([]Level, len(lngs))
80 i := 0
81 for time, lats := vary lats {
82 avgs[i].Time = time
83 avgs[i].Lat = imply(lats)
84 avgs[i].Lng = imply(lngs[time])
85 i++
86 }
87
88 return avgs
89 }
Itemizing 4 reveals the code wanted to combination the factors by a minute. The perform to name is meanByMinute
which makes use of the roundToMinute
perform.
Map HTML Template & JavaScript
We’re going to use the html/template
package deal to render the map. A lot of the HTML is static, however we’ll generate the title, information, and the factors dynamically.
Itemizing 5: Map HTML Template
01 <!doctype html>
02 <html>
03 <head>
04 <hyperlink rel="stylesheet" href="https://cdn.jsdelivr.web/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css"
05 integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65"
06 crossorigin="nameless">
07 <meta identify="viewport" content material="width=device-width, initial-scale=1">
08 <hyperlink rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
09 integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
10 crossorigin=""/>
11 <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
12 integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
13 crossorigin=""></script>
14 </head>
15 <physique>
16 <div class="container">
17 <div class="row text-center">
18 <h1 class="alert alert-primary" function="alert">GPX File Viewer</h1>
19 <h3 class="alert alert-secondary" function="alert">{{ .Title }} on {{ .Date }}</h1>
20 </div>
21 <div class="row">
22 <div class="col">
23 <div id="map" type="peak: 600px; border: 1px stable black;"></div>
24 </div>
25 </div>
26 </div>
27 <script>
28 let factors = [
29 {{- range $idx, $pt := .Points }}
30 {{ if $idx }},{{ end -}}
31 { "lat": {{ $pt.Lat }}, "lng": {{ $pt.Lng -}}, "time": {{ $pt.Time }} }
32 {{- end }}
33 ];
34 let middle = [{{ .Center.Lat }}, {{ .Center.Lng }}];
35 </script>
36 <script src="/map.js"></script>
37 </physique>
38 </html>
Itemizing 5 reveals the HTML template file. This code is probably not apparent so I’ll break it down.
On strains 04-13 we import bootstrap for the UI and likewise the leafletjs CSS and JS information. Then on line 19, we set the identify and date of the GPX file. On strains 28-33, we generate a JavaScript array with the factors from the enter and line 34 units the middle
variable. Lastly on line 36, we import the map.js
JavaScript code which can use factors
and middle
.
Subsequent we want JavaScript that may carry out the precise rendering of the factors.
Itemizing 6: Map JavaScript
02 perform on_loaded() {
03 // Create map & tiles.
04 var map = L.map('map').setView(middle, 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 factors with tooltip to map.
14 factors.forEach((pt) => {
15 let circle = L.circle(
16 [pt.lat, pt.lng],
17 {
18 shade: 'crimson',
19 radius: 20
20 }).addTo(map);
21 circle.bindPopup(pt.time);
22 });
23 }
24
25 doc.addEventListener('DOMContentLoaded', on_loaded);
Itemizing 6 reveals the JavaScript required to create the map. This code is probably not apparent so I’ll break it down.
On line 04, we create the map with a middle location and set the zoom degree to fifteen. Then on strains 05-11, we load the tiles from OpenStreetMap after which on strains 14-23, we iterate over the factors, including them to the map as a crimson circle and setting the tooltip to be the hour.
Nothing will work and not using a handler perform that may present the HTML, CSS, and JavaScript for rendering within the browser.
Map HTTP Handler
Itemizing 7: Map HTTP Handler
61 // mapHandler will get GPX file by way of HTML type and return map from mapTemplate.
62 func (a *API) mapHandler(w http.ResponseWriter, r *http.Request) {
63 a.log.Data("map referred to as", "distant", r.RemoteAddr)
64 if r.Methodology != http.MethodPost {
65 a.log.Error("unhealthy methodology", "methodology", r.Methodology)
66 http.Error(w, "unhealthy methodology", http.StatusMethodNotAllowed)
67 return
68 }
69
70 if err := r.ParseMultipartForm(1 << 20); err != nil {
71 a.log.Error("unhealthy type", "error", err)
72 http.Error(w, "unhealthy type", http.StatusBadRequest)
73 return
74 }
75
76 file, _, err := r.FormFile("gpx")
77 if err != nil {
78 a.log.Error("lacking gpx file", "error", err)
79 http.Error(w, "lacking gpx file", http.StatusBadRequest)
80 return
81 }
82
83 gpx, err := ParseGPX(file)
84 if err != nil {
85 a.log.Error("unhealthy gpx", "error", err)
86 http.Error(w, "unhealthy gpx", http.StatusBadRequest)
87 return
88 }
89
90 a.log.Data("gpx parsed", "identify", gpx.Title, "rely", len(gpx.Factors))
91 meanPts := meanByMinute(gpx.Factors)
92 a.log.Data("minute agg", "rely", len(meanPts))
93
94 // Knowledge for template
95 factors := make([]map[string]any, len(meanPts))
96 for i, pt := vary meanPts {
97 factors[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 := middle(gpx.Factors)
105 information := map[string]any{
106 "Title": gpx.Title,
107 "Date": gpx.Time.Format(time.DateOnly),
108 "Heart": map[string]float64{"Lat": clat, "Lng": clng},
109 "Factors": factors,
110 }
111
112 w.Header().Set("content-type", "textual content/html")
113 if err := mapTemplate.Execute(w, information); err != nil {
114 a.log.Error("cannot execute template", "error", err)
115 }
116 }
Itemizing 7 reveals the HTTP handler perform. All this code brings collectively the whole lot we did to supply the browser what it wants to visualise the path.
Lastly we have to write some code to begin the service to deal with a HTTP request to render the map.
Beginning The Server
Itemizing 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 sort API struct {
22 log *slog.Logger
23 }
104 func principal() {
105 log := slog.New(slog.NewTextHandler(os.Stdout, nil))
106 tmpl, err := template.New("map").Parse(mapHTML)
107 if err != nil {
108 log.Error("cannot 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.Deal with("/", 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.Data("server beginning", "tackle", addr)
129 if err := srv.ListenAndServe(); err != nil {
130 log.Error("cannot serve", "error", err)
131 os.Exit(1)
132 }
133 }
Itemizing 8 reveals the way to begin and run the HTTP service.
Conclusion
Leaflet JS is a good library for map visualization, it makes use of OpenStreetMap for which has many layers of detailed information. I discover it very cool that it solely took about 260 strains of Go and JavaScript code to generate an interactive map from uncooked GPX information.
Leaflet JS has many extra capabilities, try their website for extra demos.
There are two different takeaways from this weblog. The primary is the method: Resolve on the way you need the visualization to look and examine the uncooked information. After getting the top level and place to begin you can begin coding. The second takeaway is to not use the identical information buildings in any respect ranges of your code. You don’t need to expose the information layer (GPX file format) construction to the enterprise logic (aggregation) or to the API (UI layer). In case you do this, adjustments in a single layer won’t be remoted.
The code is out there at https://github.com/353words/leaflet.
How do you visualize map information? I’d love to listen to from you at miki@ardanlabs.com.