442 lines
11 KiB
JavaScript
442 lines
11 KiB
JavaScript
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: `
|
|
<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>
|
|
`
|
|
});
|
|
}
|
|
|
|
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 `
|
|
<div style="font:13px/1.4 system-ui,sans-serif;min-width:190px;">
|
|
<div style="font-weight:600;margin-bottom:6px;">${name}</div>
|
|
<div><strong>Tid:</strong> ${fmtTs(state.current.ts)}</div>
|
|
<div><strong>Lat:</strong> ${state.displayLat.toFixed(5)}</div>
|
|
<div><strong>Lon:</strong> ${state.displayLon.toFixed(5)}</div>
|
|
<div><strong>Hastighed:</strong> ${state.current.speedMps} m/s</div>
|
|
<div><strong>Retning:</strong> ${state.displayHeading.toFixed(0)}°</div>
|
|
<div><strong>Usikkerhed:</strong> ${state.current.uncertaintyM} m</div>
|
|
<div><strong>På kortet:</strong> ${state.presenceStateLabel || "ja"}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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"
|
|
};
|
|
}
|
|
|
|
function ensureUncertaintyCircle(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(map);
|
|
}
|
|
|
|
function syncUncertaintyCircle(v) {
|
|
if (v.current.uncertaintyM > 0) {
|
|
ensureUncertaintyCircle(v);
|
|
}
|
|
|
|
if (!v.uncertaintyCircle) {
|
|
return;
|
|
}
|
|
|
|
if (v.current.uncertaintyM <= 0) {
|
|
if (map.hasLayer(v.uncertaintyCircle)) {
|
|
map.removeLayer(v.uncertaintyCircle);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!map.hasLayer(v.uncertaintyCircle)) {
|
|
v.uncertaintyCircle.addTo(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 === "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 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 (first.uncertaintyM > 0) {
|
|
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,
|
|
interactive: false
|
|
}).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 });
|
|
|
|
syncUncertaintyCircle(v);
|
|
|
|
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));
|
|
|
|
syncUncertaintyCircle(v);
|
|
}
|
|
|
|
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 => `
|
|
<div class="alert-item">
|
|
<strong>${v.name}: mulig tilstedeværelse</strong>
|
|
Estimatet er udenfor kortet, men usikkerheden overlapper det viste område.
|
|
</div>
|
|
`).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);
|