const COLORS = { "Bil 1": "#ff0000", "Bil 2": "#00c853", "Bil 3": "#2979ff", "Bil 4": "#2979ff", "Bil 5": "#2979ff", "Bil 8": "#00c853", "Bil 9": "#00c853", "Bil 10": "#00c853" }; const POSITION_OFFSETS = { "Bil 1": { northM: 180, eastM: -120 }, "Bil 2": { northM: -260, eastM: 210 }, "Bil 3": { northM: 140, eastM: 160 }, "Bil 4": { northM: -180, eastM: -190 }, "Bil 5": { northM: 210, eastM: 110 }, "Bil 8": { northM: -320, eastM: 240 }, "Bil 9": { northM: -420, eastM: 460 }, "Bil 10": { northM: 520, eastM: -620 } }; const statusEl = document.getElementById("status"); const selectedVehicleInfoEl = document.getElementById("selectedVehicleInfo"); const runtimeWarningEl = document.getElementById("runtimeWarning"); const alertsApproxEl = document.getElementById("presenceAlertsApprox"); const alertsTrueEl = document.getElementById("presenceAlertsTrue"); const playBtn = document.getElementById("playBtn"); const pauseBtn = document.getElementById("pauseBtn"); const resetBtn = document.getElementById("resetBtn"); if (window.location.protocol === "file:") { runtimeWarningEl.hidden = false; } function metersToLatLon(lat, northM, eastM) { const metersPerDegLat = 111320; const metersPerDegLon = 111320 * Math.cos(lat * Math.PI / 180); return { lat: lat + northM / metersPerDegLat, lon: eastM / metersPerDegLon }; } function withTruePosition(event) { const offset = POSITION_OFFSETS[event.vehicle] || { northM: 0, eastM: 0 }; const shifted = metersToLatLon(event.lat, offset.northM, offset.eastM); return { ...event, approxLat: event.lat, approxLon: event.lon, trueLat: shifted.lat, trueLon: event.lon + shifted.lon }; } function buildVehicleBuckets(events) { const out = {}; for (const rawEvent of events) { const e = withTruePosition(rawEvent); 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 fmtTs(ts) { return `${ts}s`; } 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 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 clampedLat = clamp(lat, bounds.getSouth(), bounds.getNorth()); const clampedLon = clamp(lon, bounds.getWest(), bounds.getEast()); return metersBetween(lat, lon, clampedLat, clampedLon); } function maxDistanceToBoundsCornerMeters(lat, lon, bounds) { const corners = [ [bounds.getSouth(), bounds.getWest()], [bounds.getSouth(), bounds.getEast()], [bounds.getNorth(), bounds.getWest()], [bounds.getNorth(), bounds.getEast()] ]; return Math.max(...corners.map(([cornerLat, cornerLon]) => metersBetween(lat, lon, cornerLat, cornerLon) )); } function getDirectionInfo(lat, lon, bounds) { const centerLat = (bounds.getSouth() + bounds.getNorth()) / 2; const centerLon = (bounds.getWest() + bounds.getEast()) / 2; const dLat = lat - centerLat; const dLon = lon - centerLon; const vertical = Math.abs(dLat) > 0.01 ? (dLat > 0 ? "N" : "S") : ""; const horizontal = Math.abs(dLon) > 0.01 ? (dLon > 0 ? "Ø" : "V") : ""; const label = `${vertical}${horizontal}` || "Nær kanten"; const arrowMap = { "N": "↑", "S": "↓", "Ø": "→", "V": "←", "NØ": "↗", "NV": "↖", "SØ": "↘", "SV": "↙", "Nær kanten": "•" }; return { label, arrow: arrowMap[label] || "•" }; } 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 popupHtml(view, name, state) { return `
${name} · ${view.label}
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 renderSelectedVehicleInfo(selection) { if (!selection) { selectedVehicleInfoEl.textContent = "Klik på et køretøj eller en hjørneindikator for detaljer."; return; } selectedVehicleInfoEl.innerHTML = ` ${selection.name} · ${selection.viewLabel} Kilde: ${selection.source}
Tid: ${fmtTs(selection.state.current.ts)}
Position: ${selection.state.displayLat.toFixed(5)}, ${selection.state.displayLon.toFixed(5)}
Hastighed: ${selection.state.current.speedMps} m/s
Retning: ${selection.state.displayHeading.toFixed(0)}°
Usikkerhed: ${selection.state.current.uncertaintyM} m
Status: ${selection.state.presenceStateLabel} `; } function createView({ id, label, positionKey, alertsEl, indicatorsId }) { const map = L.map(id).setView([55.6761, 12.5683], 13); L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { attribution: "© OpenStreetMap contributors", maxZoom: 19 }).addTo(map); return { id, label, positionKey, alertsEl, indicatorsEl: document.getElementById(indicatorsId), map, vehicles: {} }; } function getPresenceState(view, lat, lon, uncertaintyM) { const bounds = view.map.getBounds(); if (bounds.contains([lat, lon])) { return { code: "inside", label: "ja" }; } if (uncertaintyM > 0) { const distToBounds = distanceToBoundsMeters(lat, lon, bounds); if (distToBounds <= uncertaintyM) { const maxCornerDistance = maxDistanceToBoundsCornerMeters(lat, lon, bounds); if (uncertaintyM >= maxCornerDistance) { return { code: "covers", label: "dækker hele kortet" }; } return { code: "possible", label: "muligvis" }; } } return { code: "outside", label: "nej" }; } function ensureUncertaintyCircle(view, v) { if (v.uncertaintyCircle || v.current.uncertaintyM <= 0) return; v.uncertaintyCircle = L.circle([v.displayLat, v.displayLon], { radius: v.current.uncertaintyM, color: v.color, weight: 2, dashArray: "6,6", opacity: 0.7, fillColor: v.color, fillOpacity: 0.08, interactive: false }).addTo(view.map); } function syncUncertaintyCircle(view, v) { if (v.current.uncertaintyM > 0) ensureUncertaintyCircle(view, v); if (!v.uncertaintyCircle) return; if (v.current.uncertaintyM <= 0) { if (view.map.hasLayer(v.uncertaintyCircle)) view.map.removeLayer(v.uncertaintyCircle); return; } if (!view.map.hasLayer(v.uncertaintyCircle)) v.uncertaintyCircle.addTo(view.map); v.uncertaintyCircle.setLatLng([v.displayLat, v.displayLon]); v.uncertaintyCircle.setRadius(v.current.uncertaintyM); if (v.presenceState === "inside") { v.uncertaintyCircle.setStyle({ opacity: 0.7, fillOpacity: 0.08 }); } else if (v.presenceState === "covers") { v.uncertaintyCircle.setStyle({ opacity: 0.28, fillOpacity: 0.05 }); } else if (v.presenceState === "possible") { v.uncertaintyCircle.setStyle({ opacity: 0.35, fillOpacity: 0.03 }); } else { v.uncertaintyCircle.setStyle({ opacity: 0.15, fillOpacity: 0.01 }); } } const vehicleEvents = buildVehicleBuckets(VEHICLE_EVENTS); const vehicleNames = Object.keys(vehicleEvents); const views = [ createView({ id: "mapApprox", label: "Tilnærmet position", positionKey: "approx", alertsEl: alertsApproxEl, indicatorsId: "offmapIndicatorsApprox" }), createView({ id: "mapTrue", label: "Præcis position", positionKey: "true", alertsEl: alertsTrueEl, indicatorsId: "offmapIndicatorsTrue" }) ]; function getEventLatLon(event, positionKey) { if (positionKey === "true") return { lat: event.trueLat, lon: event.trueLon }; return { lat: event.approxLat, lon: event.approxLon }; } for (const view of views) { for (const name of vehicleNames) { const first = vehicleEvents[name][0]; const color = COLORS[name] || "#2979ff"; const firstPos = getEventLatLon(first, view.positionKey); const marker = L.marker([firstPos.lat, firstPos.lon], { icon: carIcon(color) }).addTo(view.map); const trail = L.polyline([[firstPos.lat, firstPos.lon]], { color, weight: 3, opacity: 0.85 }).addTo(view.map); view.vehicles[name] = { name, color, events: vehicleEvents[name], marker, trail, uncertaintyCircle: null, currentIndex: 0, current: first, next: vehicleEvents[name][1] || null, displayLat: firstPos.lat, displayLon: firstPos.lon, displayHeading: first.headingDeg, presenceState: "inside", presenceStateLabel: "ja" }; marker.bindPopup(popupHtml(view, name, view.vehicles[name])); marker.on("click", () => { renderSelectedVehicleInfo({ name, viewLabel: view.label, source: "kort", state: view.vehicles[name] }); }); setHeading(marker, first.headingDeg); syncUncertaintyCircle(view, view.vehicles[name]); } } let simTime = 0; let lastFrameTime = null; let running = true; const TIME_SCALE = 0.175; 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; } } function updateVehicleDisplay(view, v) { advanceVehiclePointers(v); let latLon = getEventLatLon(v.current, view.positionKey); let lat = latLon.lat; let lon = latLon.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); const nextLatLon = getEventLatLon(v.next, view.positionKey); lat = lerp(latLon.lat, nextLatLon.lat, t); lon = lerp(latLon.lon, nextLatLon.lon, t); heading = lerpAngleDeg(v.current.headingDeg, v.next.headingDeg, t); } v.displayLat = lat; v.displayLon = lon; v.displayHeading = heading; v.presenceState = getPresenceState(view, lat, lon, v.current.uncertaintyM).code; v.presenceStateLabel = getPresenceState(view, lat, lon, v.current.uncertaintyM).label; if (v.presenceState === "inside") { if (!view.map.hasLayer(v.marker)) v.marker.addTo(view.map); v.marker.setLatLng([lat, lon]); setHeading(v.marker, heading); v.trail.setStyle({ opacity: 0.85 }); } else { if (view.map.hasLayer(v.marker)) view.map.removeLayer(v.marker); v.trail.setStyle({ opacity: v.presenceState === "covers" ? 0.4 : 0.25 }); } const trailPoints = v.events .slice(0, v.currentIndex + 1) .map(event => getEventLatLon(event, view.positionKey)) .map(pos => [pos.lat, pos.lon]); if (trailPoints.length > 0) { v.trail.setLatLngs(trailPoints); } v.marker.setPopupContent(popupHtml(view, v.name, v)); syncUncertaintyCircle(view, v); } function updateViewAlerts(view) { const alertVehicles = vehicleNames.map(name => view.vehicles[name]).filter(v => v.presenceState === "possible" || v.presenceState === "covers"); if (alertVehicles.length === 0) { view.alertsEl.innerHTML = ""; view.indicatorsEl.innerHTML = ""; return; } view.alertsEl.innerHTML = alertVehicles.map(v => `
${v.name}: ${v.presenceState === "covers" ? "usikkerhed dækker hele kortet" : "mulig tilstedeværelse"} ${v.presenceState === "covers" ? "Estimatet er udenfor kortet, men usikkerheden spænder over hele viewporten." : "Estimatet er udenfor kortet, men usikkerheden overlapper det viste område delvist."}
`).join(""); const bounds = view.map.getBounds(); view.indicatorsEl.innerHTML = alertVehicles.map(v => { const direction = getDirectionInfo(v.displayLat, v.displayLon, bounds); const subtitle = v.presenceState === "covers" ? "Usikkerheden dækker hele viewporten" : `Mulig tilstedeværelse fra ${direction.label}`; return ` `; }).join(""); } function resetDemo() { simTime = 0; lastFrameTime = null; renderSelectedVehicleInfo(null); for (const view of views) { for (const name of vehicleNames) { const v = view.vehicles[name]; const first = v.events[0]; const firstPos = getEventLatLon(first, view.positionKey); v.currentIndex = 0; v.current = first; v.next = v.events[1] || null; v.displayLat = firstPos.lat; v.displayLon = firstPos.lon; v.displayHeading = first.headingDeg; v.presenceState = "inside"; v.presenceStateLabel = "ja"; v.marker.setLatLng([firstPos.lat, firstPos.lon]); setHeading(v.marker, first.headingDeg); v.trail.setLatLngs([[firstPos.lat, firstPos.lon]]); if (!view.map.hasLayer(v.marker)) v.marker.addTo(view.map); syncUncertaintyCircle(view, v); } updateViewAlerts(view); } updateStatus(); } 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\nEvents passeret: ${passed} / ${total}\nAfspilning: 2x`; } function tick(now) { if (!running) return; if (lastFrameTime === null) lastFrameTime = now; const dtReal = (now - lastFrameTime) / 1000; lastFrameTime = now; simTime += dtReal / TIME_SCALE; for (const view of views) { for (const name of vehicleNames) { updateVehicleDisplay(view, view.vehicles[name]); } updateViewAlerts(view); } updateStatus(); requestAnimationFrame(tick); } for (const view of views) { view.indicatorsEl.addEventListener("click", (event) => { const button = event.target.closest(".offmap-indicator"); if (!button) return; const v = view.vehicles[button.dataset.vehicle]; renderSelectedVehicleInfo({ name: v.name, viewLabel: view.label, source: "hjørneindikator", state: v }); }); view.map.on("moveend zoomend", () => { for (const name of vehicleNames) { updateVehicleDisplay(view, view.vehicles[name]); } updateViewAlerts(view); }); } 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);