const COLORS = { "Bil 1": "#ff0000", "Bil 2": "#00c853", "Bil 3": "#2979ff", "Bil 4": "#2979ff", "Bil 5": "#2979ff" }; 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); const statusEl = document.getElementById("status"); const playBtn = document.getElementById("playBtn"); const pauseBtn = document.getElementById("pauseBtn"); const resetBtn = document.getElementById("resetBtn"); function carIcon(color) { return L.divIcon({ className: "", iconSize: [24, 24], iconAnchor: [12, 12], html: `
` }); } 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 fmtTs(ts) { return `${ts}s`; } function popupHtml(name, state) { return `
${name}
Tid: ${fmtTs(state.current.ts)}
Lat: ${state.displayLat.toFixed(5)}
Lon: ${state.displayLon.toFixed(5)}
Hastighed: ${state.current.speedMps} m/s
Retning: ${state.displayHeading.toFixed(0)}°
Usikkerhed: ${state.current.uncertaintyM} m
`; } function smoothstep(t) { t = Math.max(0, Math.min(1, t)); return t * t * (3 - 2 * t); } function lerp(a, b, t) { return a + (b - a) * t; } function lerpAngleDeg(a, b, t) { let d = ((b - a + 540) % 360) - 180; return (a + d * t + 360) % 360; } function buildVehicleBuckets(events) { const out = {}; for (const e of events) { if (!out[e.vehicle]) out[e.vehicle] = []; out[e.vehicle].push({ ...e }); } for (const k of Object.keys(out)) { out[k].sort((a, b) => a.ts - b.ts); } return out; } const vehicleEvents = buildVehicleBuckets(VEHICLE_EVENTS); const vehicleNames = Object.keys(vehicleEvents); const vehicles = {}; for (const name of vehicleNames) { const first = vehicleEvents[name][0]; const color = COLORS[name] || "#2979ff"; const marker = L.marker([first.lat, first.lon], { icon: carIcon(color) }).addTo(map); const trail = L.polyline([[first.lat, first.lon]], { color, weight: 3, opacity: 0.85 }).addTo(map); let uncertaintyCircle = null; if (name === "Bil 2") { uncertaintyCircle = L.circle([first.lat, first.lon], { radius: first.uncertaintyM, color: color, weight: 2, dashArray: "6,6", opacity: 0.7, fillColor: color, fillOpacity: 0.08 }).addTo(map); } vehicles[name] = { name, color, events: vehicleEvents[name], marker, trail, uncertaintyCircle, currentIndex: 0, current: first, next: vehicleEvents[name][1] || null, displayLat: first.lat, displayLon: first.lon, displayHeading: first.headingDeg, lastTrailLat: first.lat, lastTrailLon: first.lon }; marker.bindPopup(popupHtml(name, vehicles[name])); setHeading(marker, first.headingDeg); } let simTime = 0; let lastFrameTime = null; let running = true; const TIME_SCALE = 0.175; // 2x hurtigere end før (tidligere 0.35) function resetDemo() { simTime = 0; lastFrameTime = null; for (const name of vehicleNames) { const v = vehicles[name]; const first = v.events[0]; v.currentIndex = 0; v.current = first; v.next = v.events[1] || null; v.displayLat = first.lat; v.displayLon = first.lon; v.displayHeading = first.headingDeg; v.lastTrailLat = first.lat; v.lastTrailLon = first.lon; v.marker.setLatLng([first.lat, first.lon]); setHeading(v.marker, first.headingDeg); v.marker.setPopupContent(popupHtml(name, v)); v.trail.setLatLngs([[first.lat, first.lon]]); if (v.uncertaintyCircle) { v.uncertaintyCircle.setLatLng([first.lat, first.lon]); v.uncertaintyCircle.setRadius(first.uncertaintyM); } } updateStatus(); } function advanceVehiclePointers(v) { while (v.next && simTime >= v.next.ts) { v.currentIndex += 1; v.current = v.events[v.currentIndex]; v.next = v.events[v.currentIndex + 1] || null; v.trail.addLatLng([v.current.lat, v.current.lon]); v.lastTrailLat = v.current.lat; v.lastTrailLon = v.current.lon; } } function updateVehicleDisplay(v) { advanceVehiclePointers(v); let lat = v.current.lat; let lon = v.current.lon; let heading = v.current.headingDeg; if (v.next) { const dt = v.next.ts - v.current.ts; const raw = dt > 0 ? (simTime - v.current.ts) / dt : 1; const t = smoothstep(raw); lat = lerp(v.current.lat, v.next.lat, t); lon = lerp(v.current.lon, v.next.lon, t); heading = lerpAngleDeg(v.current.headingDeg, v.next.headingDeg, t); } else { // ingen nyere observation: fortsæt kort frem med speed + heading const age = simTime - v.current.ts; const predictFor = Math.min(age, 8); // max 8 sekunders prediction const meters = v.current.speedMps * predictFor; // grov lokal projektion til demo-formål const rad = v.current.headingDeg * Math.PI / 180; const northM = Math.cos(rad) * meters; const eastM = Math.sin(rad) * meters; const metersPerDegLat = 111320; const metersPerDegLon = 111320 * Math.cos(v.current.lat * Math.PI / 180); lat = v.current.lat + northM / metersPerDegLat; lon = v.current.lon + eastM / metersPerDegLon; heading = v.current.headingDeg; } v.displayLat = lat; v.displayLon = lon; v.displayHeading = heading; v.marker.setLatLng([lat, lon]); setHeading(v.marker, heading); v.marker.setPopupContent(popupHtml(v.name, v)); if (v.uncertaintyCircle) { v.uncertaintyCircle.setLatLng([lat, lon]); v.uncertaintyCircle.setRadius(v.current.uncertaintyM); // vis modelens værdi uændret } } function updateStatus() { const total = VEHICLE_EVENTS.length; const passed = VEHICLE_EVENTS.filter(e => e.ts <= simTime).length; statusEl.textContent = `Simuleret tid: ${simTime.toFixed(1)} s\n` + `Events passeret: ${passed} / ${total}`; } function tick(now) { if (!running) return; if (lastFrameTime === null) { lastFrameTime = now; } const dtReal = (now - lastFrameTime) / 1000; lastFrameTime = now; simTime += dtReal / TIME_SCALE; for (const name of vehicleNames) { updateVehicleDisplay(vehicles[name]); } updateStatus(); requestAnimationFrame(tick); } playBtn.addEventListener("click", () => { if (running) return; running = true; lastFrameTime = null; requestAnimationFrame(tick); }); pauseBtn.addEventListener("click", () => { running = false; }); resetBtn.addEventListener("click", () => { running = false; resetDemo(); running = true; requestAnimationFrame(tick); }); resetDemo(); requestAnimationFrame(tick);