Build a Dead Reckoning App

by paopaolong9 in Circuits > Computers

38 Views, 0 Favorites, 0 Comments

Build a Dead Reckoning App

Screenshot 2026-05-06 184242.png

I built a mobile web app called Dead Reckoning that tracks your location using your phone's GPS, draws your route as a trail on a live map, and shows real time stats including distance traveled, compass heading, coordinates and GPS accuracy. You can start and stop tracking whenever you want, and export your route as a GPX file that opens in any mapping app. It has three switchable themes — Modern, Tactical and Nautical — each with its own map style, colors and fonts.

Dead reckoning is a centuries old navigation technique used by sailors and pilots to estimate their position using known speed, direction and distance when GPS isn't available or reliable. GPS alone is noisy and drifts, especially indoors or in areas with poor signal. I built this app to demonstrate that problem and show how combining GPS with device sensors like the compass and distance calculations using the Haversine formula produces a cleaner, more reliable position estimate.


Supplies

  1. A phone
  2. Visual Studio Code
  3. Github Account
  4. Vercel Account

Install VS Code and Live Server

Screenshot 2026-04-30 203505.png
Screenshot 2026-04-30 203601.png

Go to https://code.visualstudio.com and download it for your OS. Install it, all default settings are fine.


Install Live Server extension

  1. Open VS Code
  2. Click the Extensions icon on the left sidebar (looks like 4 squares)
  3. Search Live Server
  4. Install the one by Ritwick Dey — it has millions of downloads, you'll know it when you see it

Testing It Out

Screenshot 2026-04-30 203809.png

Test it works

  1. Create a folder somewhere on your computer called dead-reckoning
  2. Open VS Code, click File → Open Folder, select that folder
  3. Create a new file inside it called index.html
  4. Paste this in:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Dead Reckoning</title>
</head>
<body>
<h1>It works</h1>
</body>
</html>
  1. Right click index.html in the file explorer panel → click Open with Live Server.

Coding: Add Leaflet.js and Render a Map

Screenshot 2026-05-01 172200.png

Note: Full finished full available for copy and paste is in Step 9.


In this step, we added a fullscreen interactive map centered on New York City using Leaflet.js and free CartoDB map tiles that display in English.


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dead Reckoning</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<style>
body { margin: 0; padding: 0; }
#map { width: 100vw; height: 100vh; }
</style>
</head>
<body>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const map = L.map('map').setView([40.7128, -74.0060], 13);
L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors © CARTO',
subdomains: 'abcd',
maxZoom: 19
}).addTo(map);
</script>
</body>
</html>


Optional: Test on Your Phone

  1. Find your IP address
  2. On Windows: open Command Prompt, type ipconfig, look for IPv4 Address — something like 192.168.1.x
  3. On Mac: open Terminal, type ifconfig | grep inet, look for a 192.168.x.x address
  4. Type that IP + :5500 into your phone browser — example: 192.168.1.45:5500
  5. You should see the same map on your phone

Coding: Get GPS Position and Drop a Live Marker

Add the code below to the <script> section: This part drops a blue marker at your location and it moves along with you. I won't show this step as it will reveal my address.


<script>
const map = L.map('map').setView([40.7128, -74.0060], 13);

L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors © CARTO',
subdomains: 'abcd',
maxZoom: 19
}).addTo(map);

let marker = null;

function onPositionUpdate(position) {
const lat = position.coords.latitude;
const lng = position.coords.longitude;

if (marker) {
marker.setLatLng([lat, lng]);
} else {
marker = L.marker([lat, lng]).addTo(map);
map.setView([lat, lng], 16);
}
}

function onError(error) {
alert('GPS error: ' + error.message);
}

if (navigator.geolocation) {
navigator.geolocation.watchPosition(onPositionUpdate, onError, {
enableHighAccuracy: true,
maximumAge: 0,
timeout: 10000
});
} else {
alert('Geolocation is not supported by your browser');
}
</script>

Coding: Draw a Trail As You Walk

This updated version draws a red trail line on the map as you walk, using the Haversine formula, which is used to calculate the distance between two points on a spherical surface, or the earth in this case. To filter out GPS drift, a point is only drawn after you have genuinely moved at least 10 meters.


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dead Reckoning</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<style>
body { margin: 0; padding: 0; }
#map { width: 100vw; height: 100vh; }
</style>
</head>
<body>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const map = L.map('map').setView([40.7128, -74.0060], 13);
L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors © CARTO',
subdomains: 'abcd',
maxZoom: 19
}).addTo(map);
let marker = null;
let trail = [];
let lastLat = null;
let lastLng = null;
let polyline = L.polyline([], { color: 'red', weight: 4 }).addTo(map);
function haversineDistance(lat1, lng1, lat2, lng2) {
const R = 6371000;
const toRad = deg => deg * Math.PI / 180;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
Math.sin(dLng/2) * Math.sin(dLng/2);
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
}
function onPositionUpdate(position) {
const lat = position.coords.latitude;
const lng = position.coords.longitude;
if (lastLat !== null) {
const distance = haversineDistance(lastLat, lastLng, lat, lng);
if (distance < 10) return;
}
lastLat = lat;
lastLng = lng;
if (marker) {
marker.setLatLng([lat, lng]);
} else {
marker = L.marker([lat, lng]).addTo(map);
map.setView([lat, lng], 16);
}
trail.push([lat, lng]);
polyline.setLatLngs(trail);
}
function onError(error) {
alert('GPS error: ' + error.message);
}
if (navigator.geolocation) {
navigator.geolocation.watchPosition(onPositionUpdate, onError, {
enableHighAccuracy: true,
maximumAge: 0,
timeout: 10000
});
} else {
alert('Geolocation is not supported by your browser');
}
</script>
</body>
</html>

Coding: Compass Heading and Live Stats HUD

Screenshot_2026-05-06-18-25-41-893_com.android.chrome[1].jpg

Warning: Most people will probably see the red line being drawn randomly within range and that is normal because it will only stabilize once we move outside because GPS accuracy is terrible indoors, and heading won't show on a desktop.


This adds a live HUD bar at the bottom of the screen showing your distance traveled, compass heading, coordinates, and GPS accuracy in real time.


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dead Reckoning</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<style>
body { margin: 0; padding: 0; }
#map { width: 100vw; height: 100vh; }
#hud {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 12px 24px;
border-radius: 12px;
font-family: monospace;
font-size: 14px;
z-index: 1000;
display: flex;
gap: 24px;
pointer-events: none;
}
.hud-item { text-align: center; }
.hud-label { font-size: 10px; opacity: 0.6; text-transform: uppercase; }
.hud-value { font-size: 18px; font-weight: bold; }
</style>
</head>
<body>
<div id="map"></div>
<div id="hud">
<div class="hud-item">
<div class="hud-label">Distance</div>
<div class="hud-value" id="hud-distance">0m</div>
</div>
<div class="hud-item">
<div class="hud-label">Heading</div>
<div class="hud-value" id="hud-heading">--°</div>
</div>
<div class="hud-item">
<div class="hud-label">Coordinates</div>
<div class="hud-value" id="hud-coords">--</div>
</div>
<div class="hud-item">
<div class="hud-label">Accuracy</div>
<div class="hud-value" id="hud-accuracy">--m</div>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const map = L.map('map').setView([40.7128, -74.0060], 13);
L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors © CARTO',
subdomains: 'abcd',
maxZoom: 19
}).addTo(map);
let marker = null;
let trail = [];
let lastLat = null;
let lastLng = null;
let totalDistance = 0;
let polyline = L.polyline([], { color: 'red', weight: 4 }).addTo(map);
function haversineDistance(lat1, lng1, lat2, lng2) {
const R = 6371000;
const toRad = deg => deg * Math.PI / 180;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
Math.sin(dLng/2) * Math.sin(dLng/2);
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
}
function formatDistance(meters) {
if (meters >= 1000) return (meters / 1000).toFixed(2) + 'km';
return Math.round(meters) + 'm';
}
function onPositionUpdate(position) {
const lat = position.coords.latitude;
const lng = position.coords.longitude;
const accuracy = position.coords.accuracy;
const heading = position.coords.heading;
document.getElementById('hud-coords').textContent =
lat.toFixed(5) + ', ' + lng.toFixed(5);
document.getElementById('hud-accuracy').textContent =
Math.round(accuracy) + 'm';
if (heading !== null && !isNaN(heading)) {
document.getElementById('hud-heading').textContent =
Math.round(heading) + '°';
}
if (lastLat !== null) {
const distance = haversineDistance(lastLat, lastLng, lat, lng);
if (distance < 10) return;
totalDistance += distance;
document.getElementById('hud-distance').textContent =
formatDistance(totalDistance);
}
lastLat = lat;
lastLng = lng;
if (marker) {
marker.setLatLng([lat, lng]);
} else {
marker = L.marker([lat, lng]).addTo(map);
map.setView([lat, lng], 16);
}
trail.push([lat, lng]);
polyline.setLatLngs(trail);
}
function onError(error) {
alert('GPS error: ' + error.message);
}
if (navigator.geolocation) {
navigator.geolocation.watchPosition(onPositionUpdate, onError, {
enableHighAccuracy: true,
maximumAge: 0,
timeout: 10000
});
} else {
alert('Geolocation is not supported by your browser');
}
if (window.DeviceOrientationEvent) {
window.addEventListener('deviceorientation', function(event) {
const heading = event.webkitCompassHeading || event.alpha;
if (heading !== null && !isNaN(heading)) {
document.getElementById('hud-heading').textContent =
Math.round(heading) + '°';
}
});
}
</script>
</body>
</html>

Coding: Adding Different Functions

Screenshot 2026-05-06 184132.png

This step adds a Start, Stop, Reset, and Export GPX button so you can control when tracking begins and ends, and you can download your route as an industry standard GPS file that opens in Google Maps, Strava, or any mapping app.


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dead Reckoning</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<style>
body { margin: 0; padding: 0; }
#map { width: 100vw; height: 100vh; }
#hud {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 12px 24px;
border-radius: 12px;
font-family: monospace;
font-size: 14px;
z-index: 1000;
display: flex;
gap: 24px;
pointer-events: none;
}
.hud-item { text-align: center; }
.hud-label { font-size: 10px; opacity: 0.6; text-transform: uppercase; }
.hud-value { font-size: 18px; font-weight: bold; }
#controls {
position: absolute;
top: 20px;
right: 20px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 10px;
}
button {
padding: 12px 20px;
border: none;
border-radius: 8px;
font-family: monospace;
font-size: 14px;
font-weight: bold;
cursor: pointer;
}
#btn-start { background: #00cc66; color: white; }
#btn-stop { background: #cc3300; color: white; display: none; }
#btn-reset { background: #333; color: white; }
#btn-export { background: #0066cc; color: white; }
</style>
</head>
<body>
<div id="map"></div>
<div id="controls">
<button id="btn-start">START</button>
<button id="btn-stop">STOP</button>
<button id="btn-reset">RESET</button>
<button id="btn-export">EXPORT GPX</button>
</div>
<div id="hud">
<div class="hud-item">
<div class="hud-label">Distance</div>
<div class="hud-value" id="hud-distance">0m</div>
</div>
<div class="hud-item">
<div class="hud-label">Heading</div>
<div class="hud-value" id="hud-heading">--°</div>
</div>
<div class="hud-item">
<div class="hud-label">Coordinates</div>
<div class="hud-value" id="hud-coords">--</div>
</div>
<div class="hud-item">
<div class="hud-label">Accuracy</div>
<div class="hud-value" id="hud-accuracy">--m</div>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const map = L.map('map').setView([40.7128, -74.0060], 13);
L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors © CARTO',
subdomains: 'abcd',
maxZoom: 19
}).addTo(map);
let marker = null;
let trail = [];
let lastLat = null;
let lastLng = null;
let totalDistance = 0;
let tracking = false;
let watchId = null;
let polyline = L.polyline([], { color: 'red', weight: 4 }).addTo(map);
function haversineDistance(lat1, lng1, lat2, lng2) {
const R = 6371000;
const toRad = deg => deg * Math.PI / 180;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
Math.sin(dLng/2) * Math.sin(dLng/2);
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
}
function formatDistance(meters) {
if (meters >= 1000) return (meters / 1000).toFixed(2) + 'km';
return Math.round(meters) + 'm';
}
function exportGPX() {
if (trail.length === 0) {
alert('No trail to export. Record a route first.');
return;
}
const timestamp = new Date().toISOString();
let gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Dead Reckoning App"
xmlns="http://www.topografix.com/GPX/1/1">
<metadata>
<name>Dead Reckoning Route</name>
<time>${timestamp}</time>
</metadata>
<trk>
<name>My Route</name>
<trkseg>
`;
trail.forEach(point => {
gpx += ` <trkpt lat="${point[0]}" lon="${point[1]}">
<time>${timestamp}</time>
</trkpt>\n`;
});
gpx += ` </trkseg>
</trk>
</gpx>`;
const blob = new Blob([gpx], { type: 'application/gpx+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'route.gpx';
a.click();
URL.revokeObjectURL(url);
}
function onPositionUpdate(position) {
if (!tracking) return;
const lat = position.coords.latitude;
const lng = position.coords.longitude;
const accuracy = position.coords.accuracy;
const heading = position.coords.heading;
document.getElementById('hud-coords').textContent =
lat.toFixed(5) + ', ' + lng.toFixed(5);
document.getElementById('hud-accuracy').textContent =
Math.round(accuracy) + 'm';
if (heading !== null && !isNaN(heading)) {
document.getElementById('hud-heading').textContent =
Math.round(heading) + '°';
}
if (lastLat !== null) {
const distance = haversineDistance(lastLat, lastLng, lat, lng);
if (distance < 10) return;
totalDistance += distance;
document.getElementById('hud-distance').textContent =
formatDistance(totalDistance);
}
lastLat = lat;
lastLng = lng;
if (marker) {
marker.setLatLng([lat, lng]);
} else {
marker = L.marker([lat, lng]).addTo(map);
map.setView([lat, lng], 16);
}
trail.push([lat, lng]);
polyline.setLatLngs(trail);
}
function onError(error) {
alert('GPS error: ' + error.message);
}
function startTracking() {
tracking = true;
document.getElementById('btn-start').style.display = 'none';
document.getElementById('btn-stop').style.display = 'block';
watchId = navigator.geolocation.watchPosition(onPositionUpdate, onError, {
enableHighAccuracy: true,
maximumAge: 0,
timeout: 10000
});
}
function stopTracking() {
tracking = false;
document.getElementById('btn-start').style.display = 'block';
document.getElementById('btn-stop').style.display = 'none';
if (watchId !== null) {
navigator.geolocation.clearWatch(watchId);
watchId = null;
}
}
function resetTracking() {
stopTracking();
trail = [];
totalDistance = 0;
lastLat = null;
lastLng = null;
polyline.setLatLngs([]);
document.getElementById('hud-distance').textContent = '0m';
document.getElementById('hud-heading').textContent = '--°';
document.getElementById('hud-coords').textContent = '--';
document.getElementById('hud-accuracy').textContent = '--m';
if (marker) {
map.removeLayer(marker);
marker = null;
}
}
document.getElementById('btn-start').addEventListener('click', startTracking);
document.getElementById('btn-stop').addEventListener('click', stopTracking);
document.getElementById('btn-reset').addEventListener('click', resetTracking);
document.getElementById('btn-export').addEventListener('click', exportGPX);
if (window.DeviceOrientationEvent) {
window.addEventListener('deviceorientation', function(event) {
const heading = event.webkitCompassHeading || event.alpha;
if (heading !== null && !isNaN(heading)) {
document.getElementById('hud-heading').textContent =
Math.round(heading) + '°';
}
});
}
</script>
</body>
</html>

Coding: Designing the UIs

Screenshot 2026-05-06 181658.png
Screenshot 2026-05-06 181708.png
Screenshot 2026-05-06 181731.png

This step adds three different customizations of the interface; Nautical, Modern, and Tactical.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dead Reckoning</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Inter:wght@400;600&family=IM+Fell+English:ital@0;1&display=swap" rel="stylesheet"/>
<style>
* { box-sizing: border-box; }
body { margin: 0; padding: 0; }
#map { width: 100vw; height: 100vh; }

/* ── CONTROLS ── */
#controls {
position: absolute;
top: 16px;
right: 16px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 8px;
}
button.ctrl-btn {
padding: 10px 16px;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: bold;
cursor: pointer;
min-width: 100px;
}

/* ── THEME TOGGLE ── */
#theme-toggle {
width: 100%;
height: 42px;
border-radius: 8px;
border: none;
cursor: pointer;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
}

/* ── THEME PANEL ── */
#theme-panel {
display: none;
flex-direction: column;
gap: 8px;
background: rgba(0,0,0,0.75);
padding: 10px;
border-radius: 12px;
backdrop-filter: blur(8px);
}
#theme-panel.open { display: flex; }
.theme-btn {
padding: 8px 16px;
border: none;
border-radius: 8px;
font-size: 12px;
font-weight: bold;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.2s;
white-space: nowrap;
}
.theme-btn.active { opacity: 1; }
.theme-btn.tactical { background: #00ff41; color: #000; font-family: 'Share Tech Mono', monospace; }
.theme-btn.modern { background: #ffffff; color: #000; font-family: 'Inter', sans-serif; }
.theme-btn.nautical { background: #c8a96e; color: #2c1a0e; font-family: 'IM Fell English', serif; }

/* ── HUD ── */
#hud {
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
display: grid;
grid-template-columns: repeat(4, 1fr);
pointer-events: none;
padding: 12px 8px;
padding-bottom: max(12px, env(safe-area-inset-bottom));
}
.hud-item { text-align: center; }
.hud-label { font-size: 9px; text-transform: uppercase; letter-spacing: 1px; opacity: 0.6; }
.hud-value { font-size: 16px; font-weight: bold; }

/* ── TACTICAL ── */
body.tactical #theme-toggle { background: #00ff41; color: #000; }
body.tactical #hud {
background: rgba(0,15,0,0.88);
border-top: 1px solid #00ff41;
font-family: 'Share Tech Mono', monospace;
color: #00ff41;
box-shadow: 0 -4px 20px rgba(0,255,65,0.2);
}
body.tactical .hud-label { color: #00aa2a; }
body.tactical .hud-value { color: #00ff41; }
body.tactical button.ctrl-btn {
font-family: 'Share Tech Mono', monospace;
letter-spacing: 1px;
font-size: 12px;
}
body.tactical #btn-start { background: #001a00; color: #00ff41; border: 1px solid #00ff41; }
body.tactical #btn-stop { background: #1a0000; color: #ff4141; border: 1px solid #ff4141; display: none; }
body.tactical #btn-reset { background: #0a0a0a; color: #666; border: 1px solid #333; }
body.tactical #btn-export { background: #001a1a; color: #00ffff; border: 1px solid #00ffff; }

/* ── MODERN ── */
body.modern #theme-toggle { background: #3b82f6; color: #fff; }
body.modern #hud {
background: rgba(255,255,255,0.92);
border-top: 1px solid #e5e7eb;
font-family: 'Inter', sans-serif;
color: #111;
box-shadow: 0 -4px 20px rgba(0,0,0,0.08);
backdrop-filter: blur(12px);
}
body.modern .hud-label { color: #9ca3af; }
body.modern .hud-value { color: #111; }
body.modern button.ctrl-btn {
font-family: 'Inter', sans-serif;
font-weight: 600;
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
border: none;
}
body.modern #btn-start { background: #22c55e; color: #fff; }
body.modern #btn-stop { background: #ef4444; color: #fff; display: none; }
body.modern #btn-reset { background: #f1f5f9; color: #374151; }
body.modern #btn-export { background: #3b82f6; color: #fff; }
body.modern #theme-toggle { background: #3b82f6; color: #fff; border: none; }

/* ── NAUTICAL ── */
body.nautical #theme-toggle { background: #c8a96e; color: #2c1a0e; }
body.nautical #hud {
background: rgba(30,18,8,0.92);
border-top: 2px solid #8b6914;
font-family: 'IM Fell English', serif;
color: #f5e6c8;
box-shadow: 0 -4px 20px rgba(0,0,0,0.4);
}
body.nautical .hud-label { color: #c8a96e; font-style: italic; }
body.nautical .hud-value { color: #f5e6c8; }
body.nautical button.ctrl-btn {
font-family: 'IM Fell English', serif;
background: rgba(30,18,8,0.9);
font-size: 14px;
}
body.nautical #btn-start { color: #c8a96e; border: 1px solid #c8a96e; }
body.nautical #btn-stop { color: #cc4444; border: 1px solid #cc4444; display: none; }
body.nautical #btn-reset { color: #666; border: 1px solid #444; }
body.nautical #btn-export { color: #6ab0c8; border: 1px solid #6ab0c8; }
</style>
</head>
<body class="modern">
<div id="map"></div>

<div id="controls">
<button id="btn-start" class="ctrl-btn">START</button>
<button id="btn-stop" class="ctrl-btn">STOP</button>
<button id="btn-reset" class="ctrl-btn">RESET</button>
<button id="btn-export" class="ctrl-btn">EXPORT GPX</button>
<button id="theme-toggle" onclick="toggleThemePanel()">🎨</button>
<div id="theme-panel">
<button class="theme-btn tactical" onclick="setTheme('tactical')">⬛ TACTICAL</button>
<button class="theme-btn modern active" onclick="setTheme('modern')">⬜ Modern</button>
<button class="theme-btn nautical" onclick="setTheme('nautical')">🗺 Nautical</button>
</div>
</div>

<div id="hud">
<div class="hud-item">
<div class="hud-label">Distance</div>
<div class="hud-value" id="hud-distance">0m</div>
</div>
<div class="hud-item">
<div class="hud-label">Heading</div>
<div class="hud-value" id="hud-heading">--°</div>
</div>
<div class="hud-item">
<div class="hud-label">Coords</div>
<div class="hud-value" id="hud-coords" style="font-size:11px; padding-top:4px;">--</div>
</div>
<div class="hud-item">
<div class="hud-label">Accuracy</div>
<div class="hud-value" id="hud-accuracy">--m</div>
</div>
</div>

<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const tileLayers = {
tactical: L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap © CARTO', maxZoom: 19
}),
modern: L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap © CARTO', subdomains: 'abcd', maxZoom: 19
}),
nautical: L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap © CARTO', subdomains: 'abcd', maxZoom: 19
})
};

const trailColors = {
tactical: '#00ff41',
modern: '#3b82f6',
nautical: '#c8a96e'
};

const map = L.map('map').setView([40.7128, -74.0060], 13);
let currentTheme = 'modern';
tileLayers.modern.addTo(map);

let marker = null;
let trail = [];
let lastLat = null;
let lastLng = null;
let totalDistance = 0;
let tracking = false;
let watchId = null;
let polyline = L.polyline([], { color: trailColors.modern, weight: 4 }).addTo(map);

function toggleThemePanel() {
document.getElementById('theme-panel').classList.toggle('open');
}

function setTheme(theme) {
document.body.className = theme;
currentTheme = theme;
document.querySelectorAll('.theme-btn').forEach(btn => {
btn.classList.toggle('active', btn.classList.contains(theme));
});
Object.values(tileLayers).forEach(layer => map.removeLayer(layer));
tileLayers[theme].addTo(map);
polyline.setStyle({ color: trailColors[theme] });
document.getElementById('theme-panel').classList.remove('open');
if (tracking) {
document.getElementById('btn-stop').style.display = 'block';
document.getElementById('btn-start').style.display = 'none';
}
}

function haversineDistance(lat1, lng1, lat2, lng2) {
const R = 6371000;
const toRad = deg => deg * Math.PI / 180;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
Math.sin(dLng/2) * Math.sin(dLng/2);
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
}

function formatDistance(meters) {
if (meters >= 1000) return (meters / 1000).toFixed(2) + 'km';
return Math.round(meters) + 'm';
}

function exportGPX() {
if (trail.length === 0) {
alert('No trail to export. Record a route first.');
return;
}
const timestamp = new Date().toISOString();
let gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Dead Reckoning App"
xmlns="http://www.topografix.com/GPX/1/1">
<metadata>
<name>Dead Reckoning Route</name>
<time>${timestamp}</time>
</metadata>
<trk>
<name>My Route</name>
<trkseg>
`;
trail.forEach(point => {
gpx += ` <trkpt lat="${point[0]}" lon="${point[1]}">
<time>${timestamp}</time>
</trkpt>\n`;
});
gpx += ` </trkseg>\n </trk>\n</gpx>`;
const blob = new Blob([gpx], { type: 'application/gpx+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'route.gpx';
a.click();
URL.revokeObjectURL(url);
}

function onPositionUpdate(position) {
if (!tracking) return;
const lat = position.coords.latitude;
const lng = position.coords.longitude;
const accuracy = position.coords.accuracy;
const heading = position.coords.heading;

document.getElementById('hud-coords').textContent =
lat.toFixed(4) + ', ' + lng.toFixed(4);
document.getElementById('hud-accuracy').textContent =
Math.round(accuracy) + 'm';

if (heading !== null && !isNaN(heading)) {
document.getElementById('hud-heading').textContent =
Math.round(heading) + '°';
}

if (lastLat !== null) {
const distance = haversineDistance(lastLat, lastLng, lat, lng);
if (distance < 10) return;
totalDistance += distance;
document.getElementById('hud-distance').textContent =
formatDistance(totalDistance);
}

lastLat = lat;
lastLng = lng;

if (marker) {
marker.setLatLng([lat, lng]);
} else {
marker = L.marker([lat, lng]).addTo(map);
map.setView([lat, lng], 16);
}

trail.push([lat, lng]);
polyline.setLatLngs(trail);
}

function onError(error) {
alert('GPS error: ' + error.message);
}

function startTracking() {
tracking = true;
document.getElementById('btn-start').style.display = 'none';
document.getElementById('btn-stop').style.display = 'block';
watchId = navigator.geolocation.watchPosition(onPositionUpdate, onError, {
enableHighAccuracy: true,
maximumAge: 0,
timeout: 10000
});
}

function stopTracking() {
tracking = false;
document.getElementById('btn-start').style.display = 'block';
document.getElementById('btn-stop').style.display = 'none';
if (watchId !== null) {
navigator.geolocation.clearWatch(watchId);
watchId = null;
}
}

function resetTracking() {
stopTracking();
trail = [];
totalDistance = 0;
lastLat = null;
lastLng = null;
polyline.setLatLngs([]);
document.getElementById('hud-distance').textContent = '0m';
document.getElementById('hud-heading').textContent = '--°';
document.getElementById('hud-coords').textContent = '--';
document.getElementById('hud-accuracy').textContent = '--m';
if (marker) {
map.removeLayer(marker);
marker = null;
}
}

document.getElementById('btn-start').addEventListener('click', startTracking);
document.getElementById('btn-stop').addEventListener('click', stopTracking);
document.getElementById('btn-reset').addEventListener('click', resetTracking);
document.getElementById('btn-export').addEventListener('click', exportGPX);

document.addEventListener('click', function(e) {
const panel = document.getElementById('theme-panel');
const toggle = document.getElementById('theme-toggle');
if (panel.classList.contains('open') &&
!panel.contains(e.target) &&
e.target !== toggle) {
panel.classList.remove('open');
}
});

if (window.DeviceOrientationEvent) {
window.addEventListener('deviceorientation', function(event) {
const heading = event.webkitCompassHeading || event.alpha;
if (heading !== null && !isNaN(heading)) {
document.getElementById('hud-heading').textContent =
Math.round(heading) + '°';
}
});
}
</script>
</body>
</html>



Deploying the App: Uploading Files to Github

Screenshot 2026-05-06 190803.png

Create a GitHub account

Go to https://github.com and sign up if you don't have one.


Create a new repository

  1. Click the + button top right → New repository
  2. Name it dead-reckoning
  3. Set it to Public
  4. Click Create repository

Upload your file

  1. On your new repo page click Add file → Upload files
  2. Drag your index.html into the box
  3. Scroll down and click Commit changes

Deploying the App: Run App Using Vercel

Screenshot 2026-05-06 183843.png

Create a Vercel account

Go to https://vercel.com and sign up — use your GitHub account to sign up, makes the next step easier.


Deploy

  1. On Vercel dashboard click Add New → Project
  2. You'll see your GitHub repos listed — select dead-reckoning
  3. Click Deploy — no settings to change, defaults are fine
  4. Wait about 30 seconds


Get your URL

Vercel gives you a live HTTPS URL like:

https://dead-reckoning-xyz.vercel.app

Open that on your phone.