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>