383 lines
10 KiB
HTML
383 lines
10 KiB
HTML
<!doctype html>
|
|
<html lang="da">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>GPS demo med trails</title>
|
|
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
|
|
<style>
|
|
html, body {
|
|
height: 100%;
|
|
margin: 0;
|
|
}
|
|
|
|
#map {
|
|
height: 100%;
|
|
}
|
|
|
|
.hud {
|
|
position: absolute;
|
|
top: 10px;
|
|
left: 10px;
|
|
z-index: 1000;
|
|
background: rgba(255,255,255,0.95);
|
|
padding: 10px 12px;
|
|
border-radius: 8px;
|
|
border: 1px solid #ccc;
|
|
font: 13px/1.35 system-ui, sans-serif;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
|
}
|
|
|
|
.hud button {
|
|
margin-right: 8px;
|
|
padding: 6px 10px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.car {
|
|
width: 24px;
|
|
height: 24px;
|
|
transform-origin: 12px 12px;
|
|
will-change: transform;
|
|
filter: drop-shadow(0 1px 1px rgba(0,0,0,.35));
|
|
}
|
|
|
|
.car svg {
|
|
width: 24px;
|
|
height: 24px;
|
|
display: block;
|
|
}
|
|
|
|
.car-label {
|
|
position: relative;
|
|
top: -2px;
|
|
left: 26px;
|
|
display: inline-block;
|
|
font: 12px/1.2 system-ui, sans-serif;
|
|
background: rgba(255,255,255,0.9);
|
|
padding: 2px 6px;
|
|
border-radius: 10px;
|
|
border: 1px solid #ccc;
|
|
white-space: nowrap;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div id="map"></div>
|
|
|
|
<div class="hud">
|
|
<button id="play">Play</button>
|
|
<button id="pause">Pause</button>
|
|
<button id="reset">Reset</button>
|
|
</div>
|
|
|
|
<script>
|
|
// 10 koordinatsæt (frames)
|
|
const FRAMES = [
|
|
{
|
|
"Bil 1": {lat:55.6761, lon:12.5683, speedMps:12},
|
|
"Bil 2": {lat:55.6748, lon:12.5650, speedMps: 9},
|
|
"Bil 3": {lat:55.6780, lon:12.5600, speedMps: 8},
|
|
"Bil 4": {lat:55.6728, lon:12.5710, speedMps: 7},
|
|
"Bil 5": {lat:55.6795, lon:12.5845, speedMps:11}
|
|
},
|
|
{
|
|
"Bil 1": {lat:55.6762, lon:12.5720, speedMps:13},
|
|
"Bil 2": {lat:55.6760, lon:12.5670, speedMps:10},
|
|
"Bil 3": {lat:55.6772, lon:12.5603, speedMps: 8},
|
|
"Bil 4": {lat:55.6736, lon:12.5698, speedMps: 8},
|
|
"Bil 5": {lat:55.6793, lon:12.5815, speedMps:12}
|
|
},
|
|
{
|
|
"Bil 1": {lat:55.6763, lon:12.5758, speedMps:14},
|
|
"Bil 2": {lat:55.6772, lon:12.5692, speedMps:11},
|
|
"Bil 3": {lat:55.6765, lon:12.5610, speedMps: 9},
|
|
"Bil 4": {lat:55.6746, lon:12.5688, speedMps: 8},
|
|
"Bil 5": {lat:55.6790, lon:12.5784, speedMps:12}
|
|
},
|
|
{
|
|
"Bil 1": {lat:55.6764, lon:12.5798, speedMps:14},
|
|
"Bil 2": {lat:55.6785, lon:12.5716, speedMps:12},
|
|
"Bil 3": {lat:55.6758, lon:12.5620, speedMps: 9},
|
|
"Bil 4": {lat:55.6757, lon:12.5682, speedMps: 9},
|
|
"Bil 5": {lat:55.6786, lon:12.5753, speedMps:11}
|
|
},
|
|
{
|
|
"Bil 1": {lat:55.6766, lon:12.5837, speedMps:15},
|
|
"Bil 2": {lat:55.6796, lon:12.5744, speedMps:12},
|
|
"Bil 3": {lat:55.6752, lon:12.5634, speedMps:10},
|
|
"Bil 4": {lat:55.6769, lon:12.5682, speedMps: 9},
|
|
"Bil 5": {lat:55.6781, lon:12.5722, speedMps:10}
|
|
},
|
|
{
|
|
"Bil 1": {lat:55.6768, lon:12.5875, speedMps:14},
|
|
"Bil 2": {lat:55.6804, lon:12.5776, speedMps:11},
|
|
"Bil 3": {lat:55.6747, lon:12.5652, speedMps:10},
|
|
"Bil 4": {lat:55.6781, lon:12.5688, speedMps:10},
|
|
"Bil 5": {lat:55.6776, lon:12.5692, speedMps:10}
|
|
},
|
|
{
|
|
"Bil 1": {lat:55.6770, lon:12.5912, speedMps:13},
|
|
"Bil 2": {lat:55.6810, lon:12.5808, speedMps:10},
|
|
"Bil 3": {lat:55.6744, lon:12.5673, speedMps: 9},
|
|
"Bil 4": {lat:55.6792, lon:12.5700, speedMps:10},
|
|
"Bil 5": {lat:55.6771, lon:12.5662, speedMps: 9}
|
|
},
|
|
{
|
|
"Bil 1": {lat:55.6772, lon:12.5948, speedMps:12},
|
|
"Bil 2": {lat:55.6812, lon:12.5842, speedMps:10},
|
|
"Bil 3": {lat:55.6742, lon:12.5696, speedMps: 8},
|
|
"Bil 4": {lat:55.6800, lon:12.5716, speedMps: 9},
|
|
"Bil 5": {lat:55.6766, lon:12.5634, speedMps: 9}
|
|
},
|
|
{
|
|
"Bil 1": {lat:55.6775, lon:12.5982, speedMps:11},
|
|
"Bil 2": {lat:55.6811, lon:12.5877, speedMps: 9},
|
|
"Bil 3": {lat:55.6742, lon:12.5720, speedMps: 8},
|
|
"Bil 4": {lat:55.6806, lon:12.5736, speedMps: 8},
|
|
"Bil 5": {lat:55.6761, lon:12.5608, speedMps: 8}
|
|
},
|
|
{
|
|
"Bil 1": {lat:55.6778, lon:12.6016, speedMps:10},
|
|
"Bil 2": {lat:55.6808, lon:12.5910, speedMps: 8},
|
|
"Bil 3": {lat:55.6744, lon:12.5742, speedMps: 7},
|
|
"Bil 4": {lat:55.6809, lon:12.5758, speedMps: 7},
|
|
"Bil 5": {lat:55.6756, lon:12.5584, speedMps: 8}
|
|
}
|
|
];
|
|
|
|
const COLORS = {
|
|
"Bil 1": "#ff0000",
|
|
"Bil 2": "#00c853",
|
|
"Bil 3": "#2979ff",
|
|
"Bil 4": "#2979ff",
|
|
"Bil 5": "#2979ff"
|
|
};
|
|
|
|
const STEP_MS = 1500;
|
|
|
|
const map = L.map("map").setView([55.6761, 12.5683], 13);
|
|
|
|
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
|
attribution: "© OpenStreetMap contributors",
|
|
maxZoom: 19
|
|
}).addTo(map);
|
|
|
|
function carIcon(label, color) {
|
|
return L.divIcon({
|
|
className: "",
|
|
iconSize: [1, 1],
|
|
iconAnchor: [12, 12],
|
|
html: `
|
|
<div class="car" data-role="car">
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
<path
|
|
d="M12 2 L20 22 L12 18 L4 22 Z"
|
|
fill="${color}"
|
|
stroke="#ffffff"
|
|
stroke-width="1.5"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div class="car-label">${label}</div>
|
|
`
|
|
});
|
|
}
|
|
|
|
function lerp(a, b, t) {
|
|
return a + (b - a) * t;
|
|
}
|
|
|
|
function smooth(t) {
|
|
return t * t * (3 - 2 * t);
|
|
}
|
|
|
|
function bearing(p0, p1) {
|
|
const dy = p1.lat - p0.lat;
|
|
const dx = p1.lon - p0.lon;
|
|
const angle = Math.atan2(dx, dy) * 180 / Math.PI;
|
|
return (angle + 360) % 360;
|
|
}
|
|
|
|
function setHeading(marker, deg) {
|
|
const el = marker.getElement();
|
|
if (!el) return;
|
|
const car = el.querySelector("[data-role=car]");
|
|
if (!car) return;
|
|
car.style.transform = `rotate(${deg}deg)`;
|
|
}
|
|
|
|
function formatPopupHtml(name, point, headingDeg, frameIndex) {
|
|
return `
|
|
<div style="font:13px/1.4 system-ui,sans-serif; min-width:160px;">
|
|
<div style="font-weight:600; margin-bottom:6px;">${name}</div>
|
|
<div><strong>Frame:</strong> ${frameIndex + 1} / ${FRAMES.length}</div>
|
|
<div><strong>Lat:</strong> ${point.lat.toFixed(5)}</div>
|
|
<div><strong>Lon:</strong> ${point.lon.toFixed(5)}</div>
|
|
<div><strong>Hastighed:</strong> ${point.speedMps} m/s</div>
|
|
<div><strong>Retning:</strong> ${headingDeg.toFixed(0)}°</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
const cars = Object.keys(FRAMES[0]);
|
|
const markers = {};
|
|
const trails = {};
|
|
const currentState = {};
|
|
|
|
for (const name of cars) {
|
|
const p = FRAMES[0][name];
|
|
|
|
const marker = L.marker([p.lat, p.lon], {
|
|
icon: carIcon(name, COLORS[name])
|
|
}).addTo(map);
|
|
|
|
const line = L.polyline([[p.lat, p.lon]], {
|
|
color: COLORS[name],
|
|
weight: 3,
|
|
opacity: 0.75
|
|
}).addTo(map);
|
|
|
|
const initialHeading = bearing(FRAMES[0][name], FRAMES[1][name]);
|
|
|
|
marker.bindPopup(formatPopupHtml(name, p, initialHeading, 0));
|
|
|
|
marker.on("click", () => {
|
|
const s = currentState[name];
|
|
marker.setPopupContent(formatPopupHtml(name, s.point, s.headingDeg, s.frameIndex));
|
|
});
|
|
|
|
markers[name] = marker;
|
|
trails[name] = line;
|
|
currentState[name] = {
|
|
point: p,
|
|
headingDeg: initialHeading,
|
|
frameIndex: 0
|
|
};
|
|
|
|
setHeading(marker, initialHeading);
|
|
}
|
|
|
|
let frame = 0;
|
|
let start = performance.now();
|
|
let running = true;
|
|
let rafId = null;
|
|
|
|
function resetDemo() {
|
|
frame = 0;
|
|
start = performance.now();
|
|
|
|
for (const name of cars) {
|
|
const p = FRAMES[0][name];
|
|
const initialHeading = bearing(FRAMES[0][name], FRAMES[1][name]);
|
|
|
|
markers[name].setLatLng([p.lat, p.lon]);
|
|
setHeading(markers[name], initialHeading);
|
|
|
|
trails[name].setLatLngs([[p.lat, p.lon]]);
|
|
|
|
currentState[name] = {
|
|
point: p,
|
|
headingDeg: initialHeading,
|
|
frameIndex: 0
|
|
};
|
|
|
|
markers[name].setPopupContent(formatPopupHtml(name, p, initialHeading, 0));
|
|
}
|
|
}
|
|
|
|
function tick(now) {
|
|
if (!running) return;
|
|
|
|
const next = frame + 1;
|
|
if (next >= FRAMES.length) return;
|
|
|
|
let tRaw = (now - start) / STEP_MS;
|
|
if (tRaw > 1) tRaw = 1;
|
|
const t = smooth(tRaw);
|
|
|
|
for (const name of cars) {
|
|
const p0 = FRAMES[frame][name];
|
|
const p1 = FRAMES[next][name];
|
|
|
|
const lat = lerp(p0.lat, p1.lat, t);
|
|
const lon = lerp(p0.lon, p1.lon, t);
|
|
const hdg = bearing(p0, p1);
|
|
|
|
markers[name].setLatLng([lat, lon]);
|
|
setHeading(markers[name], hdg);
|
|
|
|
currentState[name] = {
|
|
point: {
|
|
lat,
|
|
lon,
|
|
speedMps: p1.speedMps
|
|
},
|
|
headingDeg: hdg,
|
|
frameIndex: frame
|
|
};
|
|
}
|
|
|
|
if (tRaw >= 1) {
|
|
frame++;
|
|
start = now;
|
|
|
|
for (const name of cars) {
|
|
const p = FRAMES[frame][name];
|
|
const line = trails[name];
|
|
line.addLatLng([p.lat, p.lon]);
|
|
|
|
const nextHeading =
|
|
frame < FRAMES.length - 1
|
|
? bearing(FRAMES[frame][name], FRAMES[frame + 1][name])
|
|
: currentState[name].headingDeg;
|
|
|
|
currentState[name] = {
|
|
point: p,
|
|
headingDeg: nextHeading,
|
|
frameIndex: frame
|
|
};
|
|
|
|
markers[name].setPopupContent(formatPopupHtml(name, p, nextHeading, frame));
|
|
}
|
|
}
|
|
|
|
rafId = requestAnimationFrame(tick);
|
|
}
|
|
|
|
document.getElementById("play").addEventListener("click", () => {
|
|
if (running) return;
|
|
running = true;
|
|
rafId = requestAnimationFrame(tick);
|
|
});
|
|
|
|
document.getElementById("pause").addEventListener("click", () => {
|
|
running = false;
|
|
if (rafId) {
|
|
cancelAnimationFrame(rafId);
|
|
rafId = null;
|
|
}
|
|
});
|
|
|
|
document.getElementById("reset").addEventListener("click", () => {
|
|
running = false;
|
|
if (rafId) {
|
|
cancelAnimationFrame(rafId);
|
|
rafId = null;
|
|
}
|
|
resetDemo();
|
|
running = true;
|
|
rafId = requestAnimationFrame(tick);
|
|
});
|
|
|
|
resetDemo();
|
|
rafId = requestAnimationFrame(tick);
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|