GPS / GPX Visualization with Map
Visualizing GPS track data in GPX format, overlaid on top of a map.
GPS tracks can be recorded by smartphone apps like Strava, as well as other devices such as GoPro and Insta360 cameras, and Garmin and Apple watches.
Map client: Leaflet.js; Data: ©OpenStreetMap contributors, SRTM, GEBCO, SONNY’s LiDAR DTM, NASADEM, ESA WorldCover; Images: © OpenStreetMap contributors © Tracestrack
Source Code for this Sketch
const playX = 670
const uiOffset = 30
let track
let speed = 0
let heightRatio = 1
let current
let playing = true
let progressBar
let lastLat = 0
let lastLong = 0
let lastSpeed = 0
let lastAvgSpeed = 0
let lastElevation = 0
let lastAvgElevation = 0
let map
let speedometer = new RollingAverage(30)
let altimeter = new RollingAverage(30)
function drawCoords() {
// Calculate coordinates using some brain-agonizing lat/long/x/y conversion
// at x,y = 0,0 we should have minLong and maxLat
// and by x,y = width-100,height-100 we should have maxLong and minLat
// or something like that.
let long = current.longitude
let lat = current.latitude
let speed = current.speed
let elevation = current.elevation
if (long && lat && speed && elevation) {
lastLong = long
lastLat = lat
lastSpeed = speed
lastElevation = elevation
}
if(progressBar.value % 10 === 0) {
lastAvgSpeed = speedometer.average(lastSpeed)
lastAvgElevation = altimeter.average(lastElevation)
} else {
speedometer.average(lastSpeed)
altimeter.average(lastElevation)
}
let speedWS = 5-lastAvgSpeed.toFixed(1).length
let elevWS = 5-lastAvgElevation.toFixed(1).length
let leftOffset = width/2-185
let bottomOffset = 40
// Tooltip window around the coordinates area
stroke(100)
fill(0)
rect(leftOffset-5, height-bottomOffset-15, 185, 50)
// Draw coordinates
noStroke()
textSize(12)
textFont("monospace")
fill(100)
text("Speed "+" ".repeat(speedWS)+lastAvgSpeed.toFixed(1)+"km/h", leftOffset, height-bottomOffset)
text("Elevation "+" ".repeat(elevWS)+lastAvgElevation.toFixed(0)+"m", leftOffset, height-bottomOffset+15)
text(lastLat.toFixed(8)+", "+lastLong.toFixed(8), leftOffset, height-bottomOffset+30)
strokeWeight(2)
stroke(100)
point(leftOffset+100, height-bottomOffset+4)
point(leftOffset+100, height-bottomOffset+19)
stroke(track.normalizeSpeed(lastSpeed)*240, 100, 50)
line(leftOffset, height-bottomOffset+4, leftOffset+track.normalizeSpeed(lastSpeed)*100, height-bottomOffset+4)
stroke(220-track.normalizeElevation(lastElevation)*220, 100, 50)
line(leftOffset, height-bottomOffset+19, leftOffset+track.normalizeElevation(lastElevation)*100, height-bottomOffset+19)
}
function inRect(x, y, w, h) {
return (mouseX >= x && mouseX < x+w && mouseY >= y && mouseY < y+h)
}
function rewind() {
progressBar.value = 0
background(0)
playing = false
}
function mouseClicked() {
if (inRect(playX, height-40, 20, 20)) {
if (progressBar.value === progressBar.max) {
rewind()
playing = true
} else {
playing = !playing
}
} else if (inRect(0, 0, width, height)) {
console.log(lastLat+", "+lastLong)
}
}
function playButton() {
noStroke()
const bottomOffset = uiOffset+10
if(!playing && progressBar.value === progressBar.max) {
// Rewind button
if (inRect(playX, height-bottomOffset, 20, 20)) {
fill(220, 100, 70)
} else {
fill(220, 100, 50)
}
triangle(playX+20, height-bottomOffset, playX+10, height-30, playX+20, height-20)
triangle(playX+10, height-bottomOffset, playX, height-30, playX+10, height-20)
} else if (!playing) {
// Play button
if(inRect(playX, height-bottomOffset, 20, 20)) {
fill(150, 100, 70)
} else {
fill(150, 100, 40)
}
triangle(playX, height-bottomOffset, playX+20, height-30, playX, height-20)
} else {
// Stop button
if(inRect(playX, height-bottomOffset, 20, 20)) {
fill(0, 100, 70)
} else {
fill(0, 100, 50)
}
rect(playX, height-bottomOffset, 20, 20)
}
}
function preload() {
track = loadGPX("/assets/malibu-track.gpx")
}
function numericFixed(number, precision) {
return Number((number).toFixed(precision))
}
function setup() {
heightRatio = track.latRange / track.longRange
createCanvas(800, 800*heightRatio+100, P2D, canvas);
colorMode(HSL)
background(0)
let container = document.getElementById("p5container")
// Add some style overrides for the canvas element so it responds to
// resizing the same way the map element does
canvas.style.position = "relative"
canvas.style.marginLeft = "-400px"
canvas.style.left = "50%"
// Create target HTML element for map rendering, which should be positioned
// under the p5.js canvas
let el = document.createElement("div")
el.id = 'map'
el.style.left = "50%"
el.style.margin = "0 0 0 -"+String(width/2)+"px"
el.style.position = 'absolute'
el.style.width = String(width)+'px'
el.style.height = String(height)+'px'
el.style.zIndex = '1'
container.insertBefore(el, canvas)
progressBar = new Slider(0, track.points.length-1, 1, 0)
progressBar.setPosition(500, height-uiOffset-7)
progressBar.setLabel((item) => {
return String(Math.round(item.value / item.max * 100))+"%"
})
// Initialize map so it's showing the area around our track
let wantBounds = [
[numericFixed(track.minLat, 3), numericFixed(track.minLong, 3)],
[numericFixed(track.maxLat, 3), numericFixed(track.maxLong, 3)]
]
map = L.map(el, {
zoomControl: false,
trackResize: false,
zoomSnap: .1,
})
map.fitBounds(wantBounds, {
// padding: [50, 50],
zoomSnap: .1,
});
// console.log(map.getZoom())
// TODO this is a magic number but it makes the map clearer and crops to the
// right area. I don't know why the map doesn't pick this automatically
// map.setZoom(11.5)
// console.log(map.getZoom())
track.setScreenSize(width, height)
track.setMapSize(map.getBounds())
// Swap OSM vs. Tracestrack
L.tileLayer('https://tile.tracestrack.com/topo__/{z}/{x}/{y}.webp?key=31c53d9e160a9cf8d2b5bb11f2af758e', {
// L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors; © Tracestrack'
}).addTo(map);
current = createVector(0, 0)
}
function draw() {
clear()
strokeWeight(1)
// Draw the route in reverse so we can color it in a different color as we
// make progress
stroke(0)
let loc
for (let i = track.points.length-1; i > 0; i--) {
loc = track.mapToScreen(track.points[i].latitude, track.points[i].longitude)
point(loc.x, loc.y)
}
// Stop drawing when we reach the end
if(progressBar.value >= progressBar.max) {
playing = false
current = track.points[progressBar.max]
} else {
current = track.points[progressBar.value]
}
stroke(100)
strokeWeight(1)
fill(0, 100, 50)
loc = track.mapToScreen(current.latitude, current.longitude)
circle(loc.x, loc.y, 10)
playButton()
drawCoords()
progressBar.draw()
if (!playing) {
return
}
progressBar.value++
}
You may use all or part of the p5 code seen on this page for your own creations, without restriction. Attribution is appreciated but not required.
This sketch also includes the following sources: p5common.js gpx.js leaflet.js . Sources are available under their respective licenses.