This post describes how I figured out how to ride to work while racing earlier versions of myself. Think of it like Mario Kart’s Ghost mode, only without cool 3D virtual reality and with a phone screen that shows your position and the position of 3 random rides along the same route.
- What does it look like?
- Don’t care about the details, just tell me how to do it
- What I learned (or: “I care more about your journey of learning than just taking what you’ve done and implementing it for myself”)
- Gratuitous gifs
- Your thoughts?
What does it look like?








These are all taken from one of my rides. The first one shows some poor phone GPS because I was actually on that big bridge. The rest were are different points on a ride I was pretty proud of, since I won! (note that I also came in second, third, and fourth!). The gray rectangle at the bottom is a mismatch between my html (see below) and my phone screen size that I haven’t bothered to fix yet.
The screen updates every time the phone gets a new GPS location. For my phone (Google Pixel 7 with Verizon) it’s about every 8 seconds. The map is set to keep all four markers in the frame, so it constantly adjusts the pan and zoom to do that.
Don’t care about the details, just tell me how to do it
I’ll put the details of what I learned below. Here’s how to do it yourself:
- Get yourself a device that can track your workout that allows you to export .tcx files. That’s what fitbit uses, but I gather it was invented by Garmin so I think there’s lots of ways to do that.
- Create a folder in Google Drive that will hold all the tcx files (make a different folder for different types of rides).
- Create a new Google Apps Script file.
- In code.gs, put in this:
var funcs=[];
var allData=[];
function doGet(e) {
var t=HtmlService.createTemplateFromFile("main");
t.funcs=funcs;
t.funcnames=t.funcs.map(f=>f.name);
prepData(e.parameter.dir);
var timeTotal=Math.max(...allData.map(m=>m.times).flat());
t.globals={allData:allData,
currentTime:0,
timeTotal:timeTotal,
colors:["red","blue","green"],
initTime:0,
map:null,
markers:[],
locationMarker:null,
locationCircle:null,
group:null,
};
return t.evaluate();
}
const getLocations=(time)=>
{
var locations=[];
allData.forEach(a=>
{
var targetIndex=a.times.findIndex(f=>f>=time);
if(targetIndex==-1) targetIndex=a.times.length-1;
locations.push([a.lats[targetIndex], a.longs[targetIndex]]);
})
return locations;
}
funcs.push(getLocations);
function prepData(dir="to")
{
var folder=DriveApp.getFolderById(dir=="to"?"[FOLDERID FOR THE 'to' RIDES]":"[FOLDERID FOR THE 'from' RIDES]");
var files=folder.getFiles();
var fileIds=[];
while(files.hasNext())
{
var file=files.next();
fileIds.push(file.getId());
}
var upToThree=[];
while(upToThree.length<3)
{
var id=fileIds[Math.floor(Math.random()*fileIds.length)];
if(!upToThree.includes(id)) upToThree.push(id);
}
allData=upToThree.map(id=>readTcxFile(id));
}
function readTcxFile(id)
{
var file=DriveApp.getFileById(id);
var text=file.getBlob().getDataAsString();
// Logger.log(text.slice(0,10));
var times=[...text.matchAll(/<Time>(.*)<\/Time>/g)].map(m=>new Date(m[1]).getTime());
var fT=times[0];
times=times.map(m=>m-fT);
var lats=[...text.matchAll(/<LatitudeDegrees>(.*)<\/LatitudeDegrees>/g)].map(m=>m[1]);
var longs=[...text.matchAll(/<LongitudeDegrees>(.*)<\/LongitudeDegrees>/g)].map(m=>m[1]);
var alts=[...text.matchAll(/<AltitudeMeters>(.*)<\/AltitudeMeters>/g)].map(m=>m[1]);
return {times:times, lats:lats, longs:longs, alts:alts};
}
- Change the “[FOLDERID FOR THE ‘to’ RIDES]” to the folder id for your “to” rides and similar for your “from” rides.
- note that you could change the logic on line 38 to do any number of different folders. I mostly use this for riding to and from work so I use this logic.
- Make a new file in the script and call it main.html

- Put this code in the “main.html” file:
<!DOCTYPE html>
<html lang="en">
<head>
<base target="_top">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mobile tutorial - Leaflet</title>
<link rel="shortcut icon" type="image/x-icon" href="docs/images/favicon.ico" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<style>
html, body {
height: 100%;
margin: 0;
}
.leaflet-container {
height: 400px;
width: 600px;
max-width: 100%;
max-height: 100%;
}
</style>
<style>body { padding: 0; margin: 0; } #map { height: 100%; width: 100vw; }</style>
</head>
<body onload="init()">
<div id='map'></div>
<script>
var globals = <?!= JSON.stringify(globals) ?>;
Object.keys(globals).forEach(key=>window[key]=globals[key]);
var funcnames=<?!= JSON.stringify(funcnames) ?>;
var funcs=[<?!= funcs ?>];
funcnames.forEach((fn,i)=>window[fn]=funcs[i]);
function init()
{
map = L.map('map');
map.on('load', ()=>
{
initTime=new Date().getTime();
console.log(`initTime onload=${initTime}`);
})
map.setView([45,-90],11);
const tiles = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
allData.forEach((d,j)=>
{
var latlngs=d.lats.map((m,i)=>[m,d.longs[i]]);
L.polyline(latlngs, {color: colors[j]}).addTo(map);
var marker=L.marker([d.lats[0], d.longs[0]]);
marker.addTo(map);
markers.push(marker);
});
locationMarker=L.marker([0,0]);
locationMarker.addTo(map);
locationCircle=L.circle([0,0],10);
locationCircle.addTo(map);
group = new L.featureGroup([locationMarker, ...markers]);
map.on('locationfound', onLocationFound);
map.on('locationerror', onLocationError);
// map.locate({setView: true, maxZoom: 16});
window.requestAnimationFrame(draw)
}
function draw()
{
map.locate();
window.requestAnimationFrame(draw)
}
function onLocationFound(e) {
const radius = e.accuracy / 2;
currentTime=new Date().getTime();
// console.log(`initTime=${initTime}, currentTime=${currentTime}, diff=${currentTime-initTime}`);
var newlocs=getLocations(currentTime-initTime);
console.log(newlocs);
newlocs.forEach((locs,i)=>
{
markers[i].setLatLng(locs);
})
// const locationMarker = L.marker(e.latlng).addTo(map)
// .bindPopup(`You are within ${radius} meters from this point`).openPopup();
locationMarker.setLatLng(e.latlng);
locationCircle.setRadius(radius);
locationCircle.setLatLng(e.latlng);
map.fitBounds(group.getBounds());
// const locationCircle = L.circle(e.latlng, radius).addTo(map);
}
function onLocationError(e) {
alert(e.message);
}
</script>
</body>
</html>
- Set up a new web app deployment with this script and you’re good to go! (note that you need to run the doGet function once just to get all the permissions set).
- To use the logic on line 38 of the code.gs, add “?dir=to” or “?dir=from” to the url you get from the deployment.
What I learned (or: “I care more about your journey of learning than just taking what you’ve done and implementing it for myself”)
Here’s a quick vid showing what I was originally excited to build. It shows what it does and walks through how to do the code:
Below I’ll talk about all the interesting technical things I learned how to do:
.tcx files
With my new Pixel Watch (technically the Pixel Watch 2 if you must know), you get fitbit for free (though they strongly encourage you to move up to premium which I haven’t done). Tracking a ride is really easy. Just wake up the watch, swipe once, then hit “bike” and it’s tracking. At the end of the day you just have to export the tcx file wherever you want it:



Here’s the top few lines of one of my rides:
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<TrainingCenterDatabase xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Biking">
<Id>2024-07-15T16:37:48.000-05:00</Id>
<Lap StartTime="2024-07-15T16:37:48.000-05:00">
<TotalTimeSeconds>1876.0</TotalTimeSeconds>
<DistanceMeters>10604.77</DistanceMeters>
<Calories>321</Calories>
<Intensity>Active</Intensity>
<TriggerMethod>Manual</TriggerMethod>
<Track>
<Trackpoint>
<Time>2024-07-15T16:37:48.000-05:00</Time>
<Position>
<LatitudeDegrees>44.96538329124451</LatitudeDegrees>
<LongitudeDegrees>-93.16418159008026</LongitudeDegrees>
</Position>
<AltitudeMeters>286.8</AltitudeMeters>
<DistanceMeters>0.0</DistanceMeters>
<HeartRateBpm>
<Value>83</Value>
</HeartRateBpm>
</Trackpoint>
<Trackpoint>
<Time>2024-07-15T16:37:53.000-05:00</Time>
<Position>
<LatitudeDegrees>44.96538329124451</LatitudeDegrees>
<LongitudeDegrees>-93.16418159008026</LongitudeDegrees>
</Position>
<AltitudeMeters>286.8</AltitudeMeters>
<DistanceMeters>0.0</DistanceMeters>
<HeartRateBpm>
<Value>85</Value>
</HeartRateBpm>
</Trackpoint>
Typically it seems I get a new “Trackpoint” every second. So most of what I did had to do with scraping all of the Times, Latitudes, Longitudes, Altitudes, Distances, and HeartRates. Typically I do that using some regex. In Google Apps Script you can see an example on lines 63-65 in the code up above. I just grab them all and store them in javascript arrays so that they’re all indexed the same (if I’m looking at the 51st time, then the 51st element of the “lats” array is the latitude at that time).
Maps
As you can likely tell, I ultimately went with Leaflet.js for controlling the maps on the web pages and with openstreetmaps for what are called the tiles that Leaflet needs to show. I started doing a ton with the Google Maps Static API but they’re static and not supposed to be used with Leaflet. Also I get to make 1000 Google static maps per day using Google Apps Script but to connect it with Leaflet would mean using their normal API and that starts costing money much quicker. OpenStreetMaps, at least for small projects like this, is a perfect choice, it seems to me.
Brief aside about how to choose the appropriate initial zoom when you know all your latitudes and longitudes: It turns out that all the mapping things I’ve played with for this project use the same calculation for zoom level. I was interested in making sure that I picked the integer zoom level that for sure contained all my position data. So I’d make sure to know the extremes (most northerly, southernly, westerly, and easterly) and then I needed to figure out what zoom level for sure contained that. At first I was constantly making 600×400 maps so I just needed to make sure that everything would fit. It turns out that the key is to know that at zero zoom level, the full equator should fit in 256 pixels. Every zoom after that leads to an additional factor of two. I knew I wanted all my horizontal stuff to fit in 600 pixels and all my vertical stuff to fit in 400 pixels. I’d figure out the decimal zoom that would achieve those (two different answers often) and take the most zoomed out one as the one that would capture everything. Then I would round that down to the nearest integer and use that for the initial zoom level. You can see that in the video above.
Once you tell Leaflet to use openstreetmaps, you can then load the map with additional markers and lines. What you see in all the things above are the lines of all the routes shown in the race and markers for the current location. Here’s some pseudo code that makes a map and adds some lines and markers:
// put this all in the <head> of your html:
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
// then put this in your script:
var map=L.map('map').setView([45,-90],11) // initial center and initial zoom level.
// 'map' is the id of the <div> you need to have in your <body> to place the map
const tiles = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map); // this actually gets the "tiles" to draw
var marker=L.marker[45,-90])
marker.addTo(map) // actually puts it on the map but it's also a variable you can adjust
var listOfLocations=[[45,-90], [45.001, -90], [45.002,-90]];
L.polyline(listOfLocations).addTo(map) // draws a boring line
Drawing on html canvas elements
At first I thought I had to do a bunch of external drawing (basically an html canvas element on top of the map) but Leaflet let’s me do all of that just like my pseudo-code above. However, for the speed, heart rate, altitude, and distance plots you see in the youtube vid above I still had to figure out how to make those (animated!) plots.
Because they all have all the data constantly and a moving set of markers, I actually use two canvas elements on top of each other. The lower one has the full plots and the top one is constantly redrawn (see the animation section below) with the moving markers.
To have stacked canvas elements, just put them in the same div and make sure their css uses absolute location:
<!-- put this in the head somewhere -->
<style>
.wrapper {
position: relative;
width: 600px;
height: 400px;
}
.wrapper canvas {
position: absolute;
top: 0;
left: 0;
}
</style>
<!-- put this in the body somewhere -->
<div id="speedDiv" class="wrapper col">
<canvas id="speedsCanvas" width="300" height="200"></canvas>
<canvas id="speedsCanvasDot" width="300" height="200"></canvas>
</div>
Here’s the code to draw a simple line to the bottom canvas and a marker on the top canvas:
var bottom = document.getElementById("speedsCanvas");
var top = document.getElementById("speedsCanvasDot");
var context = bottom.getContext('2d');
context.clearRect(0,0,canvas.width,canvas.height);
context.moveTo(100,50); // where to start the line
context.lineTo(110,60); // draw a line from the start to here
context.lineTo(120, 70); // draw a line from where you are now to here
context.stroke(); // actually draw the line
context=top.getContext('2d'); // now grab the other canvas and draw a marker
context.fillStyle="red";
context.fillRect(100,50,10); // at 100 from the left, 50 from the top, and 10 pixels wide
Here’s the part that sucks: the origin (of the pixels) is at the upper left of the canvas. So your horizontal instincts are all correct, but your vertical ones are all backwards.
Animation on a web page
For any tcx files that I’m trying to animate, I reset all of their times to start at zero. I also convert all time stamps to the number of milliseconds since 1/1/1970 using new Date(datestamp).getTime(). Then, when I’m animating things, I just have javascript constantly recheck the time, determine how much time has gone by since the animation started, and then choose the appropriate latitudes and longitudes to display on the map. All of this is done with the magical window.requestAnimationFrame().
Actually, for the ghost mode app, I don’t have it constantly recheck the time. Instead, I wait until there’s a new GPS measurement, and then I check the time. On my phone that’s about every eight seconds. There is vanilla javascript to check the gps location, but leaflet has it built in with the command map.locate(). It’s that command that I repeat over and over again, waiting for the results before updating the map. Here’s the pseudocode for that:
// globals
var map, locationMarker, group
map = L.map('map');
// this next part makes sure to set the initial time once the map is loaded
map.on('load', ()=>
{
initTime=new Date().getTime();
})
map.setView([45,-90],11);
const tiles = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
locationMarker=L.marker([0,0]);
locationMarker.addTo(map); // do this in two steps so it's a variable you can adjust
group=[locationMarker]; // sets up an easy way to pan/zoom to center you on the map
map.on('locationfound', onLocationFound); // set up what to do when a location is found
map.on('locationerror', onLocationError); // set up what to do if the location doesn't work
window.requestAnimationFrame(draw) // start the animation!
function draw() // function to call with every new animation frame request
{
map.locate(); // grabs the gps
window.requestAnimationFrame(draw) // go to next frame (but map.locate will take a while)
}
function onLocationFound(e) {
locationMarker.setLatLng(e.latlng); // moves the marker to your location
map.fitBounds(group.getBounds()); // cool way to center you
}
function onLocationError(e) {
alert(e.message);
}
Gratuitous gifs
Here’s a few fun gifs showing the races I’ve had so far. Here’s all the rides to work I’ve done since I got my watch:

Here’s all the rides home:

And here’s just the 2 fastest so you can see the interesting effects of stop lights:

Your thoughts?
Oof, that’s a lot! Hopefully you found it interesting. I’d love to hear from you. Here are some starter questions for you to consider:
- This is cool! What happens when you get so old that you can’t beat your old times any more?
- This is dumb! _______ does the same thing a lot better! They’re going to sue you
- This is cool! I don’t understand what you mean when you say leaflet and google static maps don’t work together
- This is dumb! I tried it and it doesn’t work. You suck
- This is cool! You should have the bearing always be up (apparently you can’t rotate the tiles in leaflet, sorry)
- This is dumb! I don’t always start my tracking at exactly the same point on my driveway so I can’t be sure I’ve won!
- This is cool! Could I use it with my car to find which way to something much more fun than work is best?
- This is dumb! I don’t think you should be tracking any of this, let alone letting the government get their hands on it!
- This is cool! Could you add _____? It would be so awesome!
- This is dumb! Until you remove this feature _______ it really sucks!
- I know why you have a gray box on the bottom of your phone. Here’s the quick fix.

































