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 alertsEl = document.getElementById("presenceAlerts"); 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
På kortet: ${state.presenceStateLabel || "ja"}
`; } 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; } function metersBetween(lat1, lon1, lat2, lon2) { const meanLatRad = ((lat1 + lat2) / 2) * Math.PI / 180; const metersPerDegLat = 111320; const metersPerDegLon = 111320 * Math.cos(meanLatRad); const dLatM = (lat2 - lat1) * metersPerDegLat; const dLonM = (lon2 - lon1) * metersPerDegLon; return Math.hypot(dLatM, dLonM); } function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } function distanceToBoundsMeters(lat, lon, bounds) { const south = bounds.getSouth(); const north = bounds.getNorth(); const west = bounds.getWest(); const east = bounds.getEast(); const clampedLat = clamp(lat, south, north); const clampedLon = clamp(lon, west, east); return metersBetween(lat, lon, clampedLat, clampedLon); } function getPresenceState(lat, lon, uncertaintyM) { const bounds = map.getBounds(); const centerInside = bounds.contains([lat, lon]); if (centerInside) { return { code: "inside", label: "ja" }; } if (uncertaintyM > 0) { const distToBounds = distanceToBoundsMeters(lat, lon, bounds); if (distToBounds <= uncertaintyM) { return { code: "possible", label: "muligvis" }; } } return { code: "outside", label: "nej" }; } 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, presenceState: "inside", presenceStateLabel: "ja" }; marker.bindPopup(popupHtml(name, vehicles[name])); setHeading(marker, first.headingDeg); } let simTime = 0; let lastFrameTime = null; let running = true; const TIME_SCALE = 0.175; 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.presenceState = "inside"; v.presenceStateLabel = "ja"; 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]]); v.trail.setStyle({ opacity: 0.85 }); if (v.uncertaintyCircle) { v.uncertaintyCircle.setLatLng([first.lat, first.lon]); v.uncertaintyCircle.setRadius(first.uncertaintyM); v.uncertaintyCircle.setStyle({ opacity: 0.7, fillOpacity: 0.08 }); } if (!map.hasLayer(v.marker)) { v.marker.addTo(map); } } 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 { const age = simTime - v.current.ts; const predictFor = Math.min(age, 8); const meters = v.current.speedMps * predictFor; 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; const presence = getPresenceState(lat, lon, v.current.uncertaintyM); v.presenceState = presence.code; v.presenceStateLabel = presence.label; if (presence.code === "inside") { if (!map.hasLayer(v.marker)) { v.marker.addTo(map); } v.marker.setLatLng([lat, lon]); setHeading(v.marker, heading); v.trail.setStyle({ opacity: 0.85 }); } else { if (map.hasLayer(v.marker)) { map.removeLayer(v.marker); } v.trail.setStyle({ opacity: 0.25 }); } v.marker.setPopupContent(popupHtml(v.name, v)); if (v.uncertaintyCircle) { v.uncertaintyCircle.setLatLng([lat, lon]); v.uncertaintyCircle.setRadius(v.current.uncertaintyM); if (presence.code === "inside") { v.uncertaintyCircle.setStyle({ opacity: 0.7, fillOpacity: 0.08 }); } else if (presence.code === "possible") { v.uncertaintyCircle.setStyle({ opacity: 0.35, fillOpacity: 0.03 }); } else { v.uncertaintyCircle.setStyle({ opacity: 0.15, fillOpacity: 0.01 }); } } } function updateAlerts() { const possibleVehicles = vehicleNames .map(name => vehicles[name]) .filter(v => v.presenceState === "possible"); if (possibleVehicles.length === 0) { alertsEl.innerHTML = ""; return; } alertsEl.innerHTML = possibleVehicles.map(v => `
${v.name}: mulig tilstedeværelse Estimatet er udenfor kortet, men usikkerheden overlapper det viste område.
`).join(""); } 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}\n` + `Afspilning: 2x`; updateAlerts(); } 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); }); map.on("moveend zoomend", () => { for (const name of vehicleNames) { updateVehicleDisplay(vehicles[name]); } updateStatus(); }); resetDemo(); requestAnimationFrame(tick);