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: '&copy; OpenStreetMap contributors; &copy; 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.

See all p5.js sketches