Interactive Maps in the DrRacket REPL

:: racket, data visualization

I updated the map-widget package to allow map objects to snip%’s so it can be inserted into a pasteboard% and a side benefit of this work is that maps can now be embedded in the DrRacket REPL — while this was not why I did these modifications, it does make for a cool demo.

Typing (new map-snip%) in the Racket REPL1 will create a map-snip% instance, and since DrRacket will print out the result of any evaluation, the resulting map snip will be displayed in the REPL, as shown in the image below. snip% objects can be interactive, so you can actually drag the map around with the mouse and zoom in and out using the “up” and “down” keys — you cannot see that in the image, you will have to try it out yourself:

Map Snip Demo in the DrRacket REPL

Map Snip Demo in the DrRacket REPL

User interaction with a map-snip% in the REPL

Since each map-snip% is an individual value that is printed out, multiple maps can be shown in the REPL, each with their own positions and zoom level. You can also specify the initial position for the map using a pair of GPS coordinates, as well as an initial data track to load and display on the map snip, here is how it all works:

GPS tracks are loaded from GPX files using the data-frame package: the df-read/gpx function will read a GPX file into a data frame, and the df-select* function can be used to extract the latitude, longitude track to use for the map snip:

1
2
3
4
(require data-frame)
(define df (df-read/gpx "tarn-shelf.gpx"))
(define track (df-select* df "lat" "lon"))
(new map-snip% [track track])

To quickly find out what data series are available in the data frame, you can use the df-describe function:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
> (df-describe df)
data-frame: 5 columns, 16287 rows
properties:
  waypoints ((1485442935 -42.67913189716637 146.58711176365614 1126.94 Lap 1) 
  laps      (1485442935 1485444483 1485445975 1485447502 1485449146 1485450523
  name      Tarn Shelf, Mt Field NP (Hiking)
series:
              NAs           min           max          mean        stddev
  alt           0        854.93       1278.35       1089.94        122.06
  dst           0             0      14903.55       7184.98       4192.82
  lat           0        -42.69        -42.65        -42.67          0.01
  lon           0        146.56        146.59        146.58          0.01
  timestamp     0    1485470647    1485490648  1485480878.4       5984.35

Using location data from other sources

The map-snip% can also have an initial position specified and, as an example, I used a simple, predefined location:

1
2
(define new-york #(40.6943 -73.9249))
(new map-snip% [position new-york])

When presenting these demos on racket-users, a suggestion was made to provide a database of common location names. This might be a worthwhile project, but the data-frame package can also be used with the world cities database to query location names directly2:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
;; Load the world cities data from the CSV file
(define world-cities (df-read/csv "worldcities.csv" #:quoted-numbers? #t))

;; A filter function to check for a city name:
(define ((is-city? name) v)
  (equal? (vector-ref v 0) name))

;; Select the city name, country and latitude/longitude coordinates for a city:

(df-select* world-cities "city" "country" "lat" "lng" #:filter (is-city? "Perth"))

;; Produces: '#(#("Perth" "Australia" -31.955 115.84)
;;              #("Perth" "United Kingdom" 56.4003 -3.47))

(df-select* world-cities "city" "country" "lat" "lng" #:filter (is-city? "New York"))
;; Produces: '#(#("New York" "United States" 40.6943 -73.9249))

From the two example cities above it should be evident that the city name alone is not necessarily unique so any lookup code must account for multiple matches.

Program interaction with a map-snip% in the REPL

If you try out the map snip demos, it will quickly become evident one of the limitations of the REPL: each time a snip is displayed, DrRacket will create a copy of the snip to display, leaving the original as a separate object. This means that the program below will not do what you expect:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
(require map-widget data-frame)
;; Create a map snip, and bind it to a variable
(define map (new map-snip%))
map   ;; show the map in the REPL

;; Load a data track and add it to the displayed map
(define df (df-read/gpx "tarn-shelf.gpx"))
(define track (df-select* df "lat" "lon"))
(send map add-track track)

;; The original map is not updated with the track!

The reason for this, is that the map shown in the REPL is actually a copy of the map snip defined earlier and since the original is updated, the copy remains unchanged. Essentially, DrRacket allows either the program or the user to interact with the snips in the REPL — and there are good reasons for that — but we can work around this limitation: the video below shows a re-work of the original map-widget demo, where the elevation plot is linked to the map snips displayed inside the REPL, showing the location on the map for a position on the elevation plot:

The above code works by defining a class derived from map-snip%, which keeps track of all the copies in a global all-map-snips variable:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
(define all-map-snips '())  ;; a list of all the map snips shown in the REPL

(define cmap-snip%
  (class map-snip% (init) (super-new)
    ;; Override copy and add the copy to the `all-map-snips' list before
    ;; returning it.
    (define/override (copy)
      (define c (super copy))
      (set! all-map-snips (cons c all-map-snips))
      c)))

The functions that update the current location will than update the location on all the map snips in all-map-snips, rather than on the first map snip that was created.

1
2
3
4
5
6
7
(define df (df-read/gpx "mt clarence.gpx"))

(define (put-current-location dst)
  (let ([location (df-lookup df "dst" '("lat" "lon") dst)])
    (for ([m all-map-snips]) (send m current-location location))))
(define (clear-current-location)
  (for ([m all-map-snips]) (send m current-location #f))

This type of interaction is specific to the example in the video, and any user program which needs to interact with snips in the REPL will need to devise a strategy which deals with the fact that snips are copied before being displayed — an obvious limitation of the example above is that all map snips are linked to the plot, and if you want to have other map snips which show a different area, a new strategy will need to be devised for dealing with these copies.

While using map-snip% objects in the REPL is interesting and makes for a cool demo, their real usefulness comes from being able to use them inside pasteboard% objects to build more complex GUI applications. But that is a topic for another blog post.


  1. The map-widget and data-frame packages must be installed and loaded 

  2. Thanks to Laurent for providing the link to this database