GPS / GPX Visualization

Visualizing GPS track data in GPX format.

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.

Click the map to copy the current lat/long coordinates.

Source Code for this Sketch

const border = 50
const playX = 750
const rewindX = 700

let track
let frame = 0
let speed = 0
let heightRatio = 1
let trail = []
let trailLength = 300
let current
let playing = true

let lastLat = 0
let lastLong = 0
let lastSpeed = 0
let lastAvgSpeed = 0
let lastElevation = 0
let lastAvgElevation = 0

// speedGraph draws speeds at position y, height of scale, and time offset
function speedGraph(y, scale, offset) {
	strokeWeight(2)
	let speedNow = track.normalizeSpeed(track.points[offset].speed)*scale
	let speedPrev = 0
	let speedPrev2 = 0
	let speedNext = 0

	for (let i = offset; i < track.points.length-1 && i-offset < width+1; i++) {
		// Lookahead for end of catmull rom
		if (track.points[i+1].speed) {
			speedNext = track.normalizeSpeed(track.points[i+1].speed)*scale
		} else {
			// If there is no forward data, reused the last good data
			speedNext = speedNow
		}


		stroke(speedNow/scale*140+220, 100, 50)

		// Use a curve if the speedNow gap is > 4 pixels, but not at the beginning
		// or end of the scale (to avoid looking outside the array bounds)
		if (i > 2) {
			// console.log("curving", i-2, speedPrev2, i-1, speedPrev, i, speedNow, i+1, speedNext)
			curve(i-offset-2, y+scale-speedPrev2,
				i-offset-1, y+scale-speedPrev,
				i-offset, y+scale-speedNow,
				i-offset+1, y+scale-speedNext)
		}

		// rolling window of speeds
		speedPrev2 = speedPrev
		speedPrev = speedNow
		speedNow = speedNext
	}
}

function drawTrail(newest) {
	strokeWeight(2)
	stroke(0)

	if(trail.length < trailLength){
		trail.push(createVector(newest.x, newest.y))
		for(let i = 0; i < trail.length; i++){
			point(trail[i].x, trail[i].y)
		}
	} else {
		for(let i = 0; i < trail.length-1; i++){
			trail[i] = trail[i+1]
			point(trail[i].x, trail[i].y)
		}
		trail[trail.length-1] = createVector(newest.x, newest.y)
		point(newest.x, newest.y)
	}
}

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(frame % 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 x = track.normalizeLong(current.longitude)*(800-border*2)+border
	let y = height-(track.normalizeLat(current.latitude)*(800-border*2)*heightRatio)-border

	let leftOffset = x-185/2
	let bottomOffset = height-y-30

	// keep on screen
	if(leftOffset < 20) {
		leftOffset = 20
	}
	if(leftOffset > width-20-185) {
		leftOffset = width-20-185
	}
	if (bottomOffset < 60) {
		bottomOffset = 60
	}
	// avoid the current position when the current position is within 80 units
	// of the bottom
	if(height-y < 100) {
		if (x < width/2) {
			leftOffset += 100
		} else {
			leftOffset -= 100
		}
	}

	// 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() {
	frame = 0
	background(0)
	playing = false
	trail = []
}

function mouseClicked() {
	// play button
	if (inRect(playX, height-40, 20, 20)) {
		playing = !playing
	} else if (inRect(rewindX, height-40, 20, 20)) {
		rewind()
	} else if (inRect(0, 0, width, height)) {
		console.log(lastLat+", "+lastLong)
		window.navigator.clipboard.writeText(lastLat+", "+lastLong)
	}
}

function playButton() {
	noStroke()
	if(playing) {
		if(inRect(playX, height-40, 20, 20)) {
			fill(0, 100, 70)
		} else {
			fill(0, 100, 50)
		}
		rect(playX, height-40, 20, 20)
	} else {
		fill(0, 0, 0)
		rect(playX, height-40, 20, 20)
		if(inRect(playX, height-40, 20, 20)) {
			fill(150, 100, 70)
		} else {
			fill(150, 100, 40)
		}
		triangle(playX, height-40, playX+20, height-30, playX, height-20)
	}
}

function rewindButton() {
	noStroke()
	if (frame === 0) {
		fill(200, 0, 50)
	} else {
		if (inRect(rewindX, height-40, 20, 20)) {
			fill(220, 100, 70)
		} else {
			fill(220, 100, 50)
		}
	}
	triangle(rewindX+20, height-40, rewindX+10, height-30, rewindX+20, height-20)
	triangle(rewindX+10, height-40, rewindX, height-30, rewindX+10, height-20)
}

function preload() {
	track = loadGPX("/assets/malibu-track.gpx")
}

function setup() {
	heightRatio = track.latRange / track.longRange
	createCanvas(800, 700*heightRatio+border*2, P2D, canvas);
	colorMode(HSL)
	background(0)

	current = createVector(0, 0)
}

function draw() {
	background(0)

	strokeWeight(1)
	// Draw the route in reverse so we can color it in a different color as we
	// make progress
	stroke(100)
	let pp
	for (let i = track.points.length-1; i > 0; i--) {
		pp = track.points[i]
		point(track.normalizeLong(pp.longitude)*(800-border*2)+border,
			height-(track.normalizeLat(pp.latitude)*(800-border*2)*heightRatio)-border)
	}

	current = track.points[frame]
	stroke(100)
	strokeWeight(1)
	fill(0, 100, 50)
	circle(track.normalizeLong(current.longitude)*(800-border*2)+border,
		height-(track.normalizeLat(current.latitude)*(800-border*2)*heightRatio)-border, 10)

	playButton()
	rewindButton()
	drawCoords()

	if (!playing) {
		return
	}

	// Stop drawing when we reach the end
	if(frame >= track.points.length-1) {
		rewind()
	}

	frame++
}

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 . Sources are available under their respective licenses.

See all p5.js sketches