init version
This commit is contained in:
commit
7133b57e24
|
|
@ -0,0 +1,289 @@
|
||||||
|
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: `
|
||||||
|
<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:170px;">
|
||||||
|
<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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
const VEHICLE_EVENTS = [
|
||||||
|
{ vehicle: "Bil 3", ts: 0, lat: 55.6780, lon: 12.5600, speedMps: 8, headingDeg: 190, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 2, lat: 55.6748, lon: 12.5650, speedMps: 9, headingDeg: 45, uncertaintyM: 1200 },
|
||||||
|
{ vehicle: "Bil 1", ts: 4, lat: 55.6761, lon: 12.5683, speedMps: 12, headingDeg: 90, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 5, lat: 55.6728, lon: 12.5710, speedMps: 7, headingDeg: 330, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 6, lat: 55.6795, lon: 12.5845, speedMps: 11, headingDeg: 260, uncertaintyM: 0 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 3", ts: 12, lat: 55.6772, lon: 12.5603, speedMps: 8, headingDeg: 195, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 13, lat: 55.6760, lon: 12.5670, speedMps: 10, headingDeg: 50, uncertaintyM: 1000 },
|
||||||
|
{ vehicle: "Bil 1", ts: 15, lat: 55.6762, lon: 12.5720, speedMps: 13, headingDeg: 95, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 16, lat: 55.6736, lon: 12.5698, speedMps: 8, headingDeg: 340, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 18, lat: 55.6793, lon: 12.5815, speedMps: 12, headingDeg: 255, uncertaintyM: 0 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 3", ts: 24, lat: 55.6765, lon: 12.5610, speedMps: 9, headingDeg: 200, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 26, lat: 55.6772, lon: 12.5692, speedMps: 11, headingDeg: 55, uncertaintyM: 900 },
|
||||||
|
{ vehicle: "Bil 1", ts: 27, lat: 55.6763, lon: 12.5758, speedMps: 14, headingDeg: 100, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 28, lat: 55.6746, lon: 12.5688, speedMps: 8, headingDeg: 350, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 29, lat: 55.6790, lon: 12.5784, speedMps: 12, headingDeg: 250, uncertaintyM: 0 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 3", ts: 38, lat: 55.6758, lon: 12.5620, speedMps: 9, headingDeg: 210, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 40, lat: 55.6785, lon: 12.5716, speedMps: 12, headingDeg: 60, uncertaintyM: 850 },
|
||||||
|
{ vehicle: "Bil 1", ts: 41, lat: 55.6764, lon: 12.5798, speedMps: 14, headingDeg: 105, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 42, lat: 55.6757, lon: 12.5682, speedMps: 9, headingDeg: 0, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 43, lat: 55.6786, lon: 12.5753, speedMps: 11, headingDeg: 245, uncertaintyM: 0 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 3", ts: 50, lat: 55.6752, lon: 12.5634, speedMps: 10, headingDeg: 220, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 54, lat: 55.6796, lon: 12.5744, speedMps: 12, headingDeg: 70, uncertaintyM: 800 },
|
||||||
|
{ vehicle: "Bil 1", ts: 55, lat: 55.6766, lon: 12.5837, speedMps: 15, headingDeg: 110, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 56, lat: 55.6769, lon: 12.5682, speedMps: 9, headingDeg: 10, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 57, lat: 55.6781, lon: 12.5722, speedMps: 10, headingDeg: 240, uncertaintyM: 0 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 3", ts: 66, lat: 55.6747, lon: 12.5652, speedMps: 10, headingDeg: 230, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 68, lat: 55.6804, lon: 12.5776, speedMps: 11, headingDeg: 85, uncertaintyM: 950 },
|
||||||
|
{ vehicle: "Bil 1", ts: 69, lat: 55.6768, lon: 12.5875, speedMps: 14, headingDeg: 115, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 70, lat: 55.6781, lon: 12.5688, speedMps: 10, headingDeg: 20, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 72, lat: 55.6776, lon: 12.5692, speedMps: 10, headingDeg: 235, uncertaintyM: 0 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 3", ts: 82, lat: 55.6744, lon: 12.5673, speedMps: 9, headingDeg: 240, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 84, lat: 55.6810, lon: 12.5808, speedMps: 10, headingDeg: 100, uncertaintyM: 1100 },
|
||||||
|
{ vehicle: "Bil 1", ts: 85, lat: 55.6770, lon: 12.5912, speedMps: 13, headingDeg: 120, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 87, lat: 55.6792, lon: 12.5700, speedMps: 10, headingDeg: 30, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 88, lat: 55.6771, lon: 12.5662, speedMps: 9, headingDeg: 230, uncertaintyM: 0 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 3", ts: 96, lat: 55.6742, lon: 12.5696, speedMps: 8, headingDeg: 250, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 98, lat: 55.6812, lon: 12.5842, speedMps: 10, headingDeg: 115, uncertaintyM: 1200 },
|
||||||
|
{ vehicle: "Bil 1", ts: 99, lat: 55.6772, lon: 12.5948, speedMps: 12, headingDeg: 125, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 101,lat: 55.6800, lon: 12.5716, speedMps: 9, headingDeg: 40, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 102,lat: 55.6766, lon: 12.5634, speedMps: 9, headingDeg: 225, uncertaintyM: 0 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 3", ts: 112,lat: 55.6742, lon: 12.5720, speedMps: 8, headingDeg: 260, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 114,lat: 55.6811, lon: 12.5877, speedMps: 9, headingDeg: 125, uncertaintyM: 1000 },
|
||||||
|
{ vehicle: "Bil 1", ts: 115,lat: 55.6775, lon: 12.5982, speedMps: 11, headingDeg: 130, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 117,lat: 55.6806, lon: 12.5736, speedMps: 8, headingDeg: 50, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 118,lat: 55.6761, lon: 12.5608, speedMps: 8, headingDeg: 220, uncertaintyM: 0 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 3", ts: 128,lat: 55.6744, lon: 12.5742, speedMps: 7, headingDeg: 270, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 130,lat: 55.6808, lon: 12.5910, speedMps: 8, headingDeg: 130, uncertaintyM: 950 },
|
||||||
|
{ vehicle: "Bil 1", ts: 131,lat: 55.6778, lon: 12.6016, speedMps: 10, headingDeg: 135, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 133,lat: 55.6809, lon: 12.5758, speedMps: 7, headingDeg: 60, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 134,lat: 55.6756, lon: 12.5584, speedMps: 8, headingDeg: 215, uncertaintyM: 0 }
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
// ===== Map setup =====
|
||||||
|
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);
|
||||||
|
|
||||||
|
// ===== UI =====
|
||||||
|
const statusEl = document.getElementById("status");
|
||||||
|
const alertsEl = document.getElementById("presenceAlerts");
|
||||||
|
|
||||||
|
// ===== State =====
|
||||||
|
let simTime = 0;
|
||||||
|
let running = false;
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
// Group events per vehicle
|
||||||
|
const vehicles = {};
|
||||||
|
for (const ev of VEHICLE_EVENTS) {
|
||||||
|
if (!vehicles[ev.vehicle]) vehicles[ev.vehicle] = [];
|
||||||
|
vehicles[ev.vehicle].push(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort each vehicle timeline
|
||||||
|
for (const v in vehicles) {
|
||||||
|
vehicles[v].sort((a, b) => a.ts - b.ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runtime objects
|
||||||
|
const runtime = {};
|
||||||
|
|
||||||
|
function metersBetween(lat1, lon1, lat2, lon2) {
|
||||||
|
const R = 6371000;
|
||||||
|
const toRad = x => x * Math.PI / 180;
|
||||||
|
const dLat = toRad(lat2 - lat1);
|
||||||
|
const dLon = toRad(lon2 - lon1);
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(toRad(lat1)) *
|
||||||
|
Math.cos(toRad(lat2)) *
|
||||||
|
Math.sin(dLon / 2) ** 2;
|
||||||
|
return 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
}
|
||||||
|
|
||||||
|
function distanceToBoundsMeters(lat, lon, bounds) {
|
||||||
|
const clampedLat = Math.max(bounds.getSouth(), Math.min(bounds.getNorth(), lat));
|
||||||
|
const clampedLon = Math.max(bounds.getWest(), Math.min(bounds.getEast(), lon));
|
||||||
|
return metersBetween(lat, lon, clampedLat, clampedLon);
|
||||||
|
}
|
||||||
|
|
||||||
|
function farthestCornerDistanceMeters(lat, lon, bounds) {
|
||||||
|
const corners = [
|
||||||
|
bounds.getSouthWest(),
|
||||||
|
bounds.getSouthEast(),
|
||||||
|
bounds.getNorthWest(),
|
||||||
|
bounds.getNorthEast()
|
||||||
|
];
|
||||||
|
let max = 0;
|
||||||
|
for (const c of corners) {
|
||||||
|
const d = metersBetween(lat, lon, c.lat, c.lng);
|
||||||
|
if (d > max) max = d;
|
||||||
|
}
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Core classification =====
|
||||||
|
function getPresenceState(lat, lon, uncertaintyM) {
|
||||||
|
const bounds = map.getBounds();
|
||||||
|
const inside = bounds.contains([lat, lon]);
|
||||||
|
|
||||||
|
if (inside) return "inside";
|
||||||
|
|
||||||
|
if (uncertaintyM > 0) {
|
||||||
|
const dist = distanceToBoundsMeters(lat, lon, bounds);
|
||||||
|
|
||||||
|
if (dist <= uncertaintyM) {
|
||||||
|
const far = farthestCornerDistanceMeters(lat, lon, bounds);
|
||||||
|
|
||||||
|
if (far <= uncertaintyM) return "cover_all";
|
||||||
|
return "overlap";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "outside";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Render =====
|
||||||
|
function updateVehicles() {
|
||||||
|
alertsEl.innerHTML = "";
|
||||||
|
|
||||||
|
for (const name in vehicles) {
|
||||||
|
const events = vehicles[name];
|
||||||
|
|
||||||
|
// Find latest event <= simTime
|
||||||
|
let ev = null;
|
||||||
|
for (const e of events) {
|
||||||
|
if (e.ts <= simTime) ev = e;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ev) continue; // not yet appeared
|
||||||
|
|
||||||
|
const state = getPresenceState(ev.lat, ev.lon, ev.uncertaintyM);
|
||||||
|
|
||||||
|
// Ensure runtime object exists
|
||||||
|
if (!runtime[name]) {
|
||||||
|
runtime[name] = {
|
||||||
|
marker: L.marker([ev.lat, ev.lon])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = runtime[name];
|
||||||
|
|
||||||
|
if (state === "inside") {
|
||||||
|
r.marker.setLatLng([ev.lat, ev.lon]);
|
||||||
|
if (!map.hasLayer(r.marker)) {
|
||||||
|
r.marker.addTo(map);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (map.hasLayer(r.marker)) {
|
||||||
|
map.removeLayer(r.marker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Corner panel =====
|
||||||
|
if (state === "overlap" || state === "cover_all") {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = "alert-item";
|
||||||
|
|
||||||
|
if (state === "cover_all") {
|
||||||
|
div.innerHTML = `<strong>${name}</strong> Usikkerhed dækker hele kortet`;
|
||||||
|
} else {
|
||||||
|
div.innerHTML = `<strong>${name}</strong> Kan være i kortudsnittet`;
|
||||||
|
}
|
||||||
|
|
||||||
|
alertsEl.appendChild(div);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusEl.textContent = `Tid: ${simTime}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Simulation =====
|
||||||
|
function tick() {
|
||||||
|
simTime += 1;
|
||||||
|
updateVehicles();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("playBtn").onclick = () => {
|
||||||
|
if (!running) {
|
||||||
|
running = true;
|
||||||
|
timer = setInterval(tick, 1000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("pauseBtn").onclick = () => {
|
||||||
|
running = false;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("resetBtn").onclick = () => {
|
||||||
|
simTime = 0;
|
||||||
|
running = false;
|
||||||
|
clearInterval(timer);
|
||||||
|
|
||||||
|
// remove markers
|
||||||
|
for (const name in runtime) {
|
||||||
|
if (map.hasLayer(runtime[name].marker)) {
|
||||||
|
map.removeLayer(runtime[name].marker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateVehicles();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial draw
|
||||||
|
updateVehicles();
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
const VEHICLE_EVENTS = [
|
||||||
|
|
||||||
|
{ vehicle: "Bil 3", ts: 0, lat: 55.6780, lon: 12.5600, speedMps: 8, headingDeg: 190, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 2, lat: 55.6748, lon: 12.5650, speedMps: 9, headingDeg: 45, uncertaintyM: 1200 },
|
||||||
|
{ vehicle: "Bil 1", ts: 4, lat: 55.6761, lon: 12.5683, speedMps: 12, headingDeg: 90, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 5, lat: 55.6728, lon: 12.5710, speedMps: 7, headingDeg: 330, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 6, lat: 55.6795, lon: 12.5845, speedMps: 11, headingDeg: 260, uncertaintyM: 0 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 6", ts: 8, lat: 55.7080, lon: 12.5050, speedMps: 0, headingDeg: 90, uncertaintyM: 15000 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 7", ts: 10, lat: 55.7065, lon: 12.6175, speedMps: 0, headingDeg: 270, uncertaintyM: 2800 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 3", ts: 12, lat: 55.6772, lon: 12.5603, speedMps: 8, headingDeg: 195, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 13, lat: 55.6760, lon: 12.5670, speedMps: 10, headingDeg: 50, uncertaintyM: 1000 },
|
||||||
|
{ vehicle: "Bil 1", ts: 15, lat: 55.6762, lon: 12.5720, speedMps: 13, headingDeg: 95, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 16, lat: 55.6736, lon: 12.5698, speedMps: 8, headingDeg: 340, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 18, lat: 55.6793, lon: 12.5815, speedMps: 12, headingDeg: 255, uncertaintyM: 0 }
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="da">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Vehicle stream demo</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; }
|
||||||
|
body { font-family: system-ui, sans-serif; }
|
||||||
|
#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);
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud button { margin-right: 8px; padding: 6px 10px; cursor: pointer; }
|
||||||
|
.hud .status { margin-top: 8px; white-space: pre-line; }
|
||||||
|
.hud .alerts { margin-top: 8px; display: grid; gap: 6px; max-width: 320px; }
|
||||||
|
|
||||||
|
.alert-item {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e0b100;
|
||||||
|
background: rgba(255,248,204,0.96);
|
||||||
|
color: #5f4b00;
|
||||||
|
font: 12px/1.35 system-ui,sans-serif;
|
||||||
|
}
|
||||||
|
.alert-item strong { display:block; margin-bottom:2px; }
|
||||||
|
|
||||||
|
.offscreen-panel {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
top: 10px;
|
||||||
|
z-index: 1000;
|
||||||
|
width: 320px;
|
||||||
|
max-width: calc(100vw - 20px);
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.offscreen-panel.hidden { display: none; }
|
||||||
|
|
||||||
|
.offscreen-header {
|
||||||
|
background: rgba(18, 24, 38, 0.92);
|
||||||
|
color: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.18);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.offscreen-header h3 { margin: 0 0 4px 0; font-size: 14px; }
|
||||||
|
.offscreen-header p { margin: 0; font-size: 12px; opacity: 0.9; }
|
||||||
|
|
||||||
|
.offscreen-list { display: grid; gap: 8px; }
|
||||||
|
|
||||||
|
.offscreen-card {
|
||||||
|
background: rgba(255,255,255,0.96);
|
||||||
|
border: 1px solid #d7dce5;
|
||||||
|
border-left: 6px solid var(--accent, #2979ff);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.10);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.offscreen-card .title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.offscreen-card .name { font-weight: 700; font-size: 14px; }
|
||||||
|
.offscreen-card .badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eef3ff;
|
||||||
|
color: #244a9a;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.offscreen-card .meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #28303d;
|
||||||
|
}
|
||||||
|
.offscreen-card .meta .k { color: #5c6778; }
|
||||||
|
|
||||||
|
.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 { display: block; width: 24px; height: 24px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="map"></div>
|
||||||
|
|
||||||
|
<div class="hud">
|
||||||
|
<div>
|
||||||
|
<button id="playBtn">Play</button>
|
||||||
|
<button id="pauseBtn">Pause</button>
|
||||||
|
<button id="resetBtn">Reset</button>
|
||||||
|
</div>
|
||||||
|
<div class="status" id="status"></div>
|
||||||
|
<div class="alerts" id="presenceAlerts"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside id="offscreenPanel" class="offscreen-panel hidden" aria-live="polite">
|
||||||
|
<div class="offscreen-header">
|
||||||
|
<h3>Biler udenfor kortet</h3>
|
||||||
|
<p>Viser kun biler hvor centrum er udenfor, men usikkerheden overlapper det viste område.</p>
|
||||||
|
</div>
|
||||||
|
<div id="offscreenList" class="offscreen-list"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<script src="data.js"></script>
|
||||||
|
<script src="app_fixed_v2.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,259 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="da">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>GPS demo (frames a→b→c)</title>
|
||||||
|
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||||
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||||
|
crossorigin=""
|
||||||
|
/>
|
||||||
|
<script
|
||||||
|
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||||
|
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||||
|
crossorigin=""
|
||||||
|
></script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
html, body { height: 100%; margin: 0; }
|
||||||
|
#map { height: 100%; }
|
||||||
|
.car { width: 22px; height: 22px; transform-origin: 11px 11px; will-change: transform; filter: drop-shadow(0 1px 1px rgba(0,0,0,.35)); }
|
||||||
|
.car svg { display:block; }
|
||||||
|
.car-label {
|
||||||
|
position: relative; top: -2px; left: 26px;
|
||||||
|
font: 12px/1.2 system-ui, sans-serif;
|
||||||
|
background: rgba(255,255,255,0.85);
|
||||||
|
padding: 2px 6px; border-radius: 10px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.15);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.hud {
|
||||||
|
position: absolute; z-index: 999; top: 10px; left: 10px;
|
||||||
|
background: rgba(255,255,255,0.9);
|
||||||
|
padding: 10px 12px; border-radius: 10px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.15);
|
||||||
|
font: 13px/1.35 system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
.hud button { margin-right: 8px; }
|
||||||
|
.hud small { color: #444; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="map"></div>
|
||||||
|
<div class="hud">
|
||||||
|
<div style="margin-bottom:6px;">
|
||||||
|
<button id="btnPlay">Play</button>
|
||||||
|
<button id="btnPause">Pause</button>
|
||||||
|
<button id="btnReset">Reset</button>
|
||||||
|
</div>
|
||||||
|
<div><strong>Data:</strong> 10 frames (a→b→c…) med 5 biler pr. frame</div>
|
||||||
|
<small>Justér STEP_MS i koden.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ---------- DATA: 10 frames ----------
|
||||||
|
/**
|
||||||
|
* FRAMES[k]["Bil 1"] = {lat, lon, headingDeg, speedMps}
|
||||||
|
* k=0..9 svarer til a..j
|
||||||
|
*/
|
||||||
|
const FRAMES = [
|
||||||
|
{ // a
|
||||||
|
"Bil 1": {lat:55.6761, lon:12.5683, headingDeg: 90, speedMps:12},
|
||||||
|
"Bil 2": {lat:55.6748, lon:12.5650, headingDeg: 40, speedMps: 9},
|
||||||
|
"Bil 3": {lat:55.6780, lon:12.5600, headingDeg:180, speedMps: 8},
|
||||||
|
"Bil 4": {lat:55.6728, lon:12.5710, headingDeg:330, speedMps: 7},
|
||||||
|
"Bil 5": {lat:55.6795, lon:12.5845, headingDeg:260, speedMps:11},
|
||||||
|
},
|
||||||
|
{ // b
|
||||||
|
"Bil 1": {lat:55.6762, lon:12.5720, headingDeg: 95, speedMps:13},
|
||||||
|
"Bil 2": {lat:55.6760, lon:12.5670, headingDeg: 45, speedMps:10},
|
||||||
|
"Bil 3": {lat:55.6772, lon:12.5603, headingDeg:190, speedMps: 8},
|
||||||
|
"Bil 4": {lat:55.6736, lon:12.5698, headingDeg:340, speedMps: 8},
|
||||||
|
"Bil 5": {lat:55.6793, lon:12.5815, headingDeg:255, speedMps:12},
|
||||||
|
},
|
||||||
|
{ // c
|
||||||
|
"Bil 1": {lat:55.6763, lon:12.5758, headingDeg:100, speedMps:14},
|
||||||
|
"Bil 2": {lat:55.6772, lon:12.5692, headingDeg: 50, speedMps:11},
|
||||||
|
"Bil 3": {lat:55.6765, lon:12.5610, headingDeg:200, speedMps: 9},
|
||||||
|
"Bil 4": {lat:55.6746, lon:12.5688, headingDeg:350, speedMps: 8},
|
||||||
|
"Bil 5": {lat:55.6790, lon:12.5784, headingDeg:250, speedMps:12},
|
||||||
|
},
|
||||||
|
{ // d
|
||||||
|
"Bil 1": {lat:55.6764, lon:12.5798, headingDeg:105, speedMps:14},
|
||||||
|
"Bil 2": {lat:55.6785, lon:12.5716, headingDeg: 55, speedMps:12},
|
||||||
|
"Bil 3": {lat:55.6758, lon:12.5620, headingDeg:210, speedMps: 9},
|
||||||
|
"Bil 4": {lat:55.6757, lon:12.5682, headingDeg: 0, speedMps: 9},
|
||||||
|
"Bil 5": {lat:55.6786, lon:12.5753, headingDeg:245, speedMps:11},
|
||||||
|
},
|
||||||
|
{ // e
|
||||||
|
"Bil 1": {lat:55.6766, lon:12.5837, headingDeg:110, speedMps:15},
|
||||||
|
"Bil 2": {lat:55.6796, lon:12.5744, headingDeg: 60, speedMps:12},
|
||||||
|
"Bil 3": {lat:55.6752, lon:12.5634, headingDeg:220, speedMps:10},
|
||||||
|
"Bil 4": {lat:55.6769, lon:12.5682, headingDeg: 10, speedMps: 9},
|
||||||
|
"Bil 5": {lat:55.6781, lon:12.5722, headingDeg:240, speedMps:10},
|
||||||
|
},
|
||||||
|
{ // f
|
||||||
|
"Bil 1": {lat:55.6768, lon:12.5875, headingDeg:115, speedMps:14},
|
||||||
|
"Bil 2": {lat:55.6804, lon:12.5776, headingDeg: 70, speedMps:11},
|
||||||
|
"Bil 3": {lat:55.6747, lon:12.5652, headingDeg:230, speedMps:10},
|
||||||
|
"Bil 4": {lat:55.6781, lon:12.5688, headingDeg: 20, speedMps:10},
|
||||||
|
"Bil 5": {lat:55.6776, lon:12.5692, headingDeg:235, speedMps:10},
|
||||||
|
},
|
||||||
|
{ // g
|
||||||
|
"Bil 1": {lat:55.6770, lon:12.5912, headingDeg:120, speedMps:13},
|
||||||
|
"Bil 2": {lat:55.6810, lon:12.5808, headingDeg: 85, speedMps:10},
|
||||||
|
"Bil 3": {lat:55.6744, lon:12.5673, headingDeg:240, speedMps: 9},
|
||||||
|
"Bil 4": {lat:55.6792, lon:12.5700, headingDeg: 30, speedMps:10},
|
||||||
|
"Bil 5": {lat:55.6771, lon:12.5662, headingDeg:230, speedMps: 9},
|
||||||
|
},
|
||||||
|
{ // h
|
||||||
|
"Bil 1": {lat:55.6772, lon:12.5948, headingDeg:125, speedMps:12},
|
||||||
|
"Bil 2": {lat:55.6812, lon:12.5842, headingDeg:100, speedMps:10},
|
||||||
|
"Bil 3": {lat:55.6742, lon:12.5696, headingDeg:250, speedMps: 8},
|
||||||
|
"Bil 4": {lat:55.6800, lon:12.5716, headingDeg: 40, speedMps: 9},
|
||||||
|
"Bil 5": {lat:55.6766, lon:12.5634, headingDeg:225, speedMps: 9},
|
||||||
|
},
|
||||||
|
{ // i
|
||||||
|
"Bil 1": {lat:55.6775, lon:12.5982, headingDeg:130, speedMps:11},
|
||||||
|
"Bil 2": {lat:55.6811, lon:12.5877, headingDeg:115, speedMps: 9},
|
||||||
|
"Bil 3": {lat:55.6742, lon:12.5720, headingDeg:260, speedMps: 8},
|
||||||
|
"Bil 4": {lat:55.6806, lon:12.5736, headingDeg: 50, speedMps: 8},
|
||||||
|
"Bil 5": {lat:55.6761, lon:12.5608, headingDeg:220, speedMps: 8},
|
||||||
|
},
|
||||||
|
{ // j
|
||||||
|
"Bil 1": {lat:55.6778, lon:12.6016, headingDeg:135, speedMps:10},
|
||||||
|
"Bil 2": {lat:55.6808, lon:12.5910, headingDeg:130, speedMps: 8},
|
||||||
|
"Bil 3": {lat:55.6744, lon:12.5742, headingDeg:270, speedMps: 7},
|
||||||
|
"Bil 4": {lat:55.6809, lon:12.5758, headingDeg: 60, speedMps: 7},
|
||||||
|
"Bil 5": {lat:55.6756, lon:12.5584, headingDeg:215, speedMps: 8},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const STEP_MS = 1500; // varighed fra frame k til k+1
|
||||||
|
|
||||||
|
// ---------- Leaflet ----------
|
||||||
|
const map = L.map('map').setView([55.6761, 12.5683], 13);
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
maxZoom: 19,
|
||||||
|
attribution: '© OpenStreetMap contributors'
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
function carDivIcon(label) {
|
||||||
|
const 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"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="car-label">${label}</div>
|
||||||
|
`;
|
||||||
|
return L.divIcon({ html, className:'', iconSize:[1,1], iconAnchor:[11,11] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function smoothstep(t){ if(t<0)t=0; if(t>1)t=1; 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 setMarkerHeading(marker, headingDeg) {
|
||||||
|
const el = marker.getElement();
|
||||||
|
if (!el) return;
|
||||||
|
const carEl = el.querySelector('[data-role="car"]');
|
||||||
|
if (!carEl) return;
|
||||||
|
carEl.style.transform = `rotate(${headingDeg}deg)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Build markers from frame 0 ----------
|
||||||
|
const carNames = Object.keys(FRAMES[0]);
|
||||||
|
const markers = new Map(); // name -> marker
|
||||||
|
|
||||||
|
for (const name of carNames) {
|
||||||
|
const f0 = FRAMES[0][name];
|
||||||
|
const m = L.marker([f0.lat, f0.lon], { icon: carDivIcon(name) }).addTo(map);
|
||||||
|
markers.set(name, m);
|
||||||
|
setMarkerHeading(m, f0.headingDeg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Animation state ----------
|
||||||
|
let playing = false;
|
||||||
|
let rafId = null;
|
||||||
|
let frameIdx = 0; // current frame k
|
||||||
|
let segStartT = performance.now();
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
playing = false;
|
||||||
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
|
rafId = null;
|
||||||
|
|
||||||
|
frameIdx = 0;
|
||||||
|
segStartT = performance.now();
|
||||||
|
|
||||||
|
for (const name of carNames) {
|
||||||
|
const f0 = FRAMES[0][name];
|
||||||
|
const m = markers.get(name);
|
||||||
|
m.setLatLng([f0.lat, f0.lon]);
|
||||||
|
setMarkerHeading(m, f0.headingDeg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function play() {
|
||||||
|
if (playing) return;
|
||||||
|
playing = true;
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
function pause() {
|
||||||
|
playing = false;
|
||||||
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
|
rafId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick(now) {
|
||||||
|
if (!playing) return;
|
||||||
|
|
||||||
|
const k = frameIdx;
|
||||||
|
const next = k + 1;
|
||||||
|
|
||||||
|
if (next >= FRAMES.length) {
|
||||||
|
// Stop på sidste frame
|
||||||
|
playing = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aRaw = (now - segStartT) / STEP_MS;
|
||||||
|
const a = smoothstep(aRaw);
|
||||||
|
|
||||||
|
for (const name of carNames) {
|
||||||
|
const p0 = FRAMES[k][name];
|
||||||
|
const p1 = FRAMES[next][name];
|
||||||
|
const lat = lerp(p0.lat, p1.lat, a);
|
||||||
|
const lon = lerp(p0.lon, p1.lon, a);
|
||||||
|
const hdg = lerpAngleDeg(p0.headingDeg, p1.headingDeg, a);
|
||||||
|
|
||||||
|
const m = markers.get(name);
|
||||||
|
m.setLatLng([lat, lon]);
|
||||||
|
setMarkerHeading(m, hdg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aRaw >= 1) {
|
||||||
|
frameIdx++;
|
||||||
|
segStartT = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
document.getElementById('btnPlay').addEventListener('click', play);
|
||||||
|
document.getElementById('btnPause').addEventListener('click', pause);
|
||||||
|
document.getElementById('btnReset').addEventListener('click', () => { reset(); play(); });
|
||||||
|
|
||||||
|
// Autostart
|
||||||
|
reset();
|
||||||
|
play();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,324 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="da">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>GPS demo med 5 biler og 10 position-sæt</title>
|
||||||
|
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||||
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||||
|
crossorigin=""
|
||||||
|
/>
|
||||||
|
<script
|
||||||
|
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||||
|
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||||
|
crossorigin=""
|
||||||
|
></script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#map {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1000;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
background: rgba(255,255,255,0.92);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.15);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud .status {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.car {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
transform-origin: 11px 11px;
|
||||||
|
will-change: transform;
|
||||||
|
filter: drop-shadow(0 1px 1px rgba(0,0,0,.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
.car svg {
|
||||||
|
display: block;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.car-label {
|
||||||
|
position: relative;
|
||||||
|
top: -2px;
|
||||||
|
left: 26px;
|
||||||
|
font: 12px/1.2 system-ui, sans-serif;
|
||||||
|
background: rgba(255,255,255,0.88);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.15);
|
||||||
|
white-space: nowrap;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="map"></div>
|
||||||
|
|
||||||
|
<div class="hud">
|
||||||
|
<div>
|
||||||
|
<button id="btnPlay">Play</button>
|
||||||
|
<button id="btnPause">Pause</button>
|
||||||
|
<button id="btnReset">Reset</button>
|
||||||
|
</div>
|
||||||
|
<div class="status">
|
||||||
|
10 sæt positioner (a → b → c ...) med 5 biler.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 10 "frames" / sæt positioner
|
||||||
|
// Hver frame indeholder 5 biler med lat, lon, headingDeg og speedMps
|
||||||
|
const FRAMES = [
|
||||||
|
{ // a
|
||||||
|
"Bil 1": {lat:55.6761, lon:12.5683, headingDeg: 90, speedMps:12},
|
||||||
|
"Bil 2": {lat:55.6748, lon:12.5650, headingDeg: 40, speedMps: 9},
|
||||||
|
"Bil 3": {lat:55.6780, lon:12.5600, headingDeg:180, speedMps: 8},
|
||||||
|
"Bil 4": {lat:55.6728, lon:12.5710, headingDeg:330, speedMps: 7},
|
||||||
|
"Bil 5": {lat:55.6795, lon:12.5845, headingDeg:260, speedMps:11},
|
||||||
|
},
|
||||||
|
{ // b
|
||||||
|
"Bil 1": {lat:55.6762, lon:12.5720, headingDeg: 95, speedMps:13},
|
||||||
|
"Bil 2": {lat:55.6760, lon:12.5670, headingDeg: 45, speedMps:10},
|
||||||
|
"Bil 3": {lat:55.6772, lon:12.5603, headingDeg:190, speedMps: 8},
|
||||||
|
"Bil 4": {lat:55.6736, lon:12.5698, headingDeg:340, speedMps: 8},
|
||||||
|
"Bil 5": {lat:55.6793, lon:12.5815, headingDeg:255, speedMps:12},
|
||||||
|
},
|
||||||
|
{ // c
|
||||||
|
"Bil 1": {lat:55.6763, lon:12.5758, headingDeg:100, speedMps:14},
|
||||||
|
"Bil 2": {lat:55.6772, lon:12.5692, headingDeg: 50, speedMps:11},
|
||||||
|
"Bil 3": {lat:55.6765, lon:12.5610, headingDeg:200, speedMps: 9},
|
||||||
|
"Bil 4": {lat:55.6746, lon:12.5688, headingDeg:350, speedMps: 8},
|
||||||
|
"Bil 5": {lat:55.6790, lon:12.5784, headingDeg:250, speedMps:12},
|
||||||
|
},
|
||||||
|
{ // d
|
||||||
|
"Bil 1": {lat:55.6764, lon:12.5798, headingDeg:105, speedMps:14},
|
||||||
|
"Bil 2": {lat:55.6785, lon:12.5716, headingDeg: 55, speedMps:12},
|
||||||
|
"Bil 3": {lat:55.6758, lon:12.5620, headingDeg:210, speedMps: 9},
|
||||||
|
"Bil 4": {lat:55.6757, lon:12.5682, headingDeg: 0, speedMps: 9},
|
||||||
|
"Bil 5": {lat:55.6786, lon:12.5753, headingDeg:245, speedMps:11},
|
||||||
|
},
|
||||||
|
{ // e
|
||||||
|
"Bil 1": {lat:55.6766, lon:12.5837, headingDeg:110, speedMps:15},
|
||||||
|
"Bil 2": {lat:55.6796, lon:12.5744, headingDeg: 60, speedMps:12},
|
||||||
|
"Bil 3": {lat:55.6752, lon:12.5634, headingDeg:220, speedMps:10},
|
||||||
|
"Bil 4": {lat:55.6769, lon:12.5682, headingDeg: 10, speedMps: 9},
|
||||||
|
"Bil 5": {lat:55.6781, lon:12.5722, headingDeg:240, speedMps:10},
|
||||||
|
},
|
||||||
|
{ // f
|
||||||
|
"Bil 1": {lat:55.6768, lon:12.5875, headingDeg:115, speedMps:14},
|
||||||
|
"Bil 2": {lat:55.6804, lon:12.5776, headingDeg: 70, speedMps:11},
|
||||||
|
"Bil 3": {lat:55.6747, lon:12.5652, headingDeg:230, speedMps:10},
|
||||||
|
"Bil 4": {lat:55.6781, lon:12.5688, headingDeg: 20, speedMps:10},
|
||||||
|
"Bil 5": {lat:55.6776, lon:12.5692, headingDeg:235, speedMps:10},
|
||||||
|
},
|
||||||
|
{ // g
|
||||||
|
"Bil 1": {lat:55.6770, lon:12.5912, headingDeg:120, speedMps:13},
|
||||||
|
"Bil 2": {lat:55.6810, lon:12.5808, headingDeg: 85, speedMps:10},
|
||||||
|
"Bil 3": {lat:55.6744, lon:12.5673, headingDeg:240, speedMps: 9},
|
||||||
|
"Bil 4": {lat:55.6792, lon:12.5700, headingDeg: 30, speedMps:10},
|
||||||
|
"Bil 5": {lat:55.6771, lon:12.5662, headingDeg:230, speedMps: 9},
|
||||||
|
},
|
||||||
|
{ // h
|
||||||
|
"Bil 1": {lat:55.6772, lon:12.5948, headingDeg:125, speedMps:12},
|
||||||
|
"Bil 2": {lat:55.6812, lon:12.5842, headingDeg:100, speedMps:10},
|
||||||
|
"Bil 3": {lat:55.6742, lon:12.5696, headingDeg:250, speedMps: 8},
|
||||||
|
"Bil 4": {lat:55.6800, lon:12.5716, headingDeg: 40, speedMps: 9},
|
||||||
|
"Bil 5": {lat:55.6766, lon:12.5634, headingDeg:225, speedMps: 9},
|
||||||
|
},
|
||||||
|
{ // i
|
||||||
|
"Bil 1": {lat:55.6775, lon:12.5982, headingDeg:130, speedMps:11},
|
||||||
|
"Bil 2": {lat:55.6811, lon:12.5877, headingDeg:115, speedMps: 9},
|
||||||
|
"Bil 3": {lat:55.6742, lon:12.5720, headingDeg:260, speedMps: 8},
|
||||||
|
"Bil 4": {lat:55.6806, lon:12.5736, headingDeg: 50, speedMps: 8},
|
||||||
|
"Bil 5": {lat:55.6761, lon:12.5608, headingDeg:220, speedMps: 8},
|
||||||
|
},
|
||||||
|
{ // j
|
||||||
|
"Bil 1": {lat:55.6778, lon:12.6016, headingDeg:135, speedMps:10},
|
||||||
|
"Bil 2": {lat:55.6808, lon:12.5910, headingDeg:130, speedMps: 8},
|
||||||
|
"Bil 3": {lat:55.6744, lon:12.5742, headingDeg:270, speedMps: 7},
|
||||||
|
"Bil 4": {lat:55.6809, lon:12.5758, headingDeg: 60, speedMps: 7},
|
||||||
|
"Bil 5": {lat:55.6756, lon:12.5584, headingDeg:215, speedMps: 8},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Varighed pr. overgang mellem a->b, b->c osv.
|
||||||
|
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', {
|
||||||
|
maxZoom: 19,
|
||||||
|
attribution: '© OpenStreetMap contributors'
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
function carDivIcon(label) {
|
||||||
|
const 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="#1565c0"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="car-label">${label}</div>
|
||||||
|
`;
|
||||||
|
return L.divIcon({
|
||||||
|
html,
|
||||||
|
className: '',
|
||||||
|
iconSize: [1, 1],
|
||||||
|
iconAnchor: [11, 11]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function smoothstep(t) {
|
||||||
|
if (t < 0) t = 0;
|
||||||
|
if (t > 1) t = 1;
|
||||||
|
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 setMarkerHeading(marker, headingDeg) {
|
||||||
|
const el = marker.getElement();
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const carEl = el.querySelector('[data-role="car"]');
|
||||||
|
if (!carEl) return;
|
||||||
|
|
||||||
|
carEl.style.transform = `rotate(${headingDeg}deg)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const carNames = Object.keys(FRAMES[0]);
|
||||||
|
const markers = new Map();
|
||||||
|
|
||||||
|
for (const name of carNames) {
|
||||||
|
const pos = FRAMES[0][name];
|
||||||
|
const marker = L.marker([pos.lat, pos.lon], {
|
||||||
|
icon: carDivIcon(name)
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
markers.set(name, marker);
|
||||||
|
setMarkerHeading(marker, pos.headingDeg);
|
||||||
|
}
|
||||||
|
|
||||||
|
let playing = false;
|
||||||
|
let rafId = null;
|
||||||
|
let frameIdx = 0;
|
||||||
|
let segStartT = performance.now();
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
playing = false;
|
||||||
|
|
||||||
|
if (rafId) {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
rafId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
frameIdx = 0;
|
||||||
|
segStartT = performance.now();
|
||||||
|
|
||||||
|
for (const name of carNames) {
|
||||||
|
const p = FRAMES[0][name];
|
||||||
|
const marker = markers.get(name);
|
||||||
|
marker.setLatLng([p.lat, p.lon]);
|
||||||
|
setMarkerHeading(marker, p.headingDeg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function play() {
|
||||||
|
if (playing) return;
|
||||||
|
playing = true;
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pause() {
|
||||||
|
playing = false;
|
||||||
|
if (rafId) {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
rafId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick(now) {
|
||||||
|
if (!playing) return;
|
||||||
|
|
||||||
|
const currentFrame = frameIdx;
|
||||||
|
const nextFrame = currentFrame + 1;
|
||||||
|
|
||||||
|
if (nextFrame >= FRAMES.length) {
|
||||||
|
playing = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawAlpha = (now - segStartT) / STEP_MS;
|
||||||
|
const alpha = smoothstep(rawAlpha);
|
||||||
|
|
||||||
|
for (const name of carNames) {
|
||||||
|
const p0 = FRAMES[currentFrame][name];
|
||||||
|
const p1 = FRAMES[nextFrame][name];
|
||||||
|
|
||||||
|
const lat = lerp(p0.lat, p1.lat, alpha);
|
||||||
|
const lon = lerp(p0.lon, p1.lon, alpha);
|
||||||
|
const heading = lerpAngleDeg(p0.headingDeg, p1.headingDeg, alpha);
|
||||||
|
|
||||||
|
const marker = markers.get(name);
|
||||||
|
marker.setLatLng([lat, lon]);
|
||||||
|
setMarkerHeading(marker, heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawAlpha >= 1) {
|
||||||
|
frameIdx += 1;
|
||||||
|
segStartT = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btnPlay').addEventListener('click', play);
|
||||||
|
document.getElementById('btnPause').addEventListener('click', pause);
|
||||||
|
document.getElementById('btnReset').addEventListener('click', () => {
|
||||||
|
reset();
|
||||||
|
play();
|
||||||
|
});
|
||||||
|
|
||||||
|
reset();
|
||||||
|
play();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,262 @@
|
||||||
|
<!doctype html>
|
||||||
|
|
||||||
|
<html lang="da">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
|
||||||
|
<title>GPS demo</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:white;
|
||||||
|
padding:10px;
|
||||||
|
border-radius:8px;
|
||||||
|
border:1px solid #ccc;
|
||||||
|
font-family:sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.car{
|
||||||
|
width:24px;
|
||||||
|
height:24px;
|
||||||
|
transform-origin:12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.car svg{
|
||||||
|
width:24px;
|
||||||
|
height:24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.car-label{
|
||||||
|
position:relative;
|
||||||
|
top:-2px;
|
||||||
|
left:26px;
|
||||||
|
font-size:12px;
|
||||||
|
background:white;
|
||||||
|
padding:2px 6px;
|
||||||
|
border-radius:10px;
|
||||||
|
border:1px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
</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>
|
||||||
|
|
||||||
|
/* --------- DATA --------- */
|
||||||
|
|
||||||
|
const FRAMES=[
|
||||||
|
|
||||||
|
{"Bil 1":{lat:55.6761,lon:12.5683},"Bil 2":{lat:55.6748,lon:12.5650},"Bil 3":{lat:55.6780,lon:12.5600},"Bil 4":{lat:55.6728,lon:12.5710},"Bil 5":{lat:55.6795,lon:12.5845}},
|
||||||
|
{"Bil 1":{lat:55.6762,lon:12.5720},"Bil 2":{lat:55.6760,lon:12.5670},"Bil 3":{lat:55.6772,lon:12.5603},"Bil 4":{lat:55.6736,lon:12.5698},"Bil 5":{lat:55.6793,lon:12.5815}},
|
||||||
|
{"Bil 1":{lat:55.6763,lon:12.5758},"Bil 2":{lat:55.6772,lon:12.5692},"Bil 3":{lat:55.6765,lon:12.5610},"Bil 4":{lat:55.6746,lon:12.5688},"Bil 5":{lat:55.6790,lon:12.5784}},
|
||||||
|
{"Bil 1":{lat:55.6764,lon:12.5798},"Bil 2":{lat:55.6785,lon:12.5716},"Bil 3":{lat:55.6758,lon:12.5620},"Bil 4":{lat:55.6757,lon:12.5682},"Bil 5":{lat:55.6786,lon:12.5753}},
|
||||||
|
{"Bil 1":{lat:55.6766,lon:12.5837},"Bil 2":{lat:55.6796,lon:12.5744},"Bil 3":{lat:55.6752,lon:12.5634},"Bil 4":{lat:55.6769,lon:12.5682},"Bil 5":{lat:55.6781,lon:12.5722}},
|
||||||
|
{"Bil 1":{lat:55.6768,lon:12.5875},"Bil 2":{lat:55.6804,lon:12.5776},"Bil 3":{lat:55.6747,lon:12.5652},"Bil 4":{lat:55.6781,lon:12.5688},"Bil 5":{lat:55.6776,lon:12.5692}},
|
||||||
|
{"Bil 1":{lat:55.6770,lon:12.5912},"Bil 2":{lat:55.6810,lon:12.5808},"Bil 3":{lat:55.6744,lon:12.5673},"Bil 4":{lat:55.6792,lon:12.5700},"Bil 5":{lat:55.6771,lon:12.5662}},
|
||||||
|
{"Bil 1":{lat:55.6772,lon:12.5948},"Bil 2":{lat:55.6812,lon:12.5842},"Bil 3":{lat:55.6742,lon:12.5696},"Bil 4":{lat:55.6800,lon:12.5716},"Bil 5":{lat:55.6766,lon:12.5634}},
|
||||||
|
{"Bil 1":{lat:55.6775,lon:12.5982},"Bil 2":{lat:55.6811,lon:12.5877},"Bil 3":{lat:55.6742,lon:12.5720},"Bil 4":{lat:55.6806,lon:12.5736},"Bil 5":{lat:55.6761,lon:12.5608}},
|
||||||
|
{"Bil 1":{lat:55.6778,lon:12.6016},"Bil 2":{lat:55.6808,lon:12.5910},"Bil 3":{lat:55.6744,lon:12.5742},"Bil 4":{lat:55.6809,lon:12.5758},"Bil 5":{lat:55.6756,lon:12.5584}}
|
||||||
|
|
||||||
|
];
|
||||||
|
|
||||||
|
/* --------- FARVER --------- */
|
||||||
|
|
||||||
|
const COLORS={
|
||||||
|
"Bil 1":"#ff0000",
|
||||||
|
"Bil 2":"#00c853",
|
||||||
|
"Bil 3":"#2979ff",
|
||||||
|
"Bil 4":"#2979ff",
|
||||||
|
"Bil 5":"#2979ff"
|
||||||
|
};
|
||||||
|
|
||||||
|
/* --------- KORT --------- */
|
||||||
|
|
||||||
|
const map=L.map("map").setView([55.6761,12.5683],13);
|
||||||
|
|
||||||
|
L.tileLayer(
|
||||||
|
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
|
{attribution:"© OpenStreetMap"}
|
||||||
|
).addTo(map);
|
||||||
|
|
||||||
|
/* --------- IKON --------- */
|
||||||
|
|
||||||
|
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">
|
||||||
|
<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>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------- BEARING --------- */
|
||||||
|
|
||||||
|
function bearing(p0,p1){
|
||||||
|
|
||||||
|
const dy=p1.lat-p0.lat;
|
||||||
|
const dx=p1.lon-p0.lon;
|
||||||
|
|
||||||
|
const a=Math.atan2(dx,dy)*180/Math.PI;
|
||||||
|
|
||||||
|
return (a+360)%360;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------- ROTATION --------- */
|
||||||
|
|
||||||
|
function setHeading(marker,deg){
|
||||||
|
|
||||||
|
const el=marker.getElement();
|
||||||
|
if(!el)return;
|
||||||
|
|
||||||
|
const car=el.querySelector("[data-role=car]");
|
||||||
|
car.style.transform=`rotate(${deg}deg)`;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------- MARKERS --------- */
|
||||||
|
|
||||||
|
const markers={};
|
||||||
|
const cars=Object.keys(FRAMES[0]);
|
||||||
|
|
||||||
|
for(const name of cars){
|
||||||
|
|
||||||
|
const p=FRAMES[0][name];
|
||||||
|
|
||||||
|
markers[name]=L.marker(
|
||||||
|
[p.lat,p.lon],
|
||||||
|
{icon:carIcon(name,COLORS[name])}
|
||||||
|
).addTo(map);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------- ANIMATION --------- */
|
||||||
|
|
||||||
|
let frame=0;
|
||||||
|
let start=performance.now();
|
||||||
|
let running=true;
|
||||||
|
|
||||||
|
const STEP=1500;
|
||||||
|
|
||||||
|
function lerp(a,b,t){
|
||||||
|
return a+(b-a)*t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* smooth GPS-agtig easing */
|
||||||
|
|
||||||
|
function smooth(t){
|
||||||
|
return t*t*(3-2*t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick(now){
|
||||||
|
|
||||||
|
if(!running)return;
|
||||||
|
|
||||||
|
const next=frame+1;
|
||||||
|
|
||||||
|
if(next>=FRAMES.length)return;
|
||||||
|
|
||||||
|
let t=(now-start)/STEP;
|
||||||
|
|
||||||
|
if(t>1)t=1;
|
||||||
|
|
||||||
|
t=smooth(t);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
const m=markers[name];
|
||||||
|
|
||||||
|
m.setLatLng([lat,lon]);
|
||||||
|
setHeading(m,hdg);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if(t>=1){
|
||||||
|
|
||||||
|
frame++;
|
||||||
|
start=now;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
/* --------- UI --------- */
|
||||||
|
|
||||||
|
play.onclick=()=>{
|
||||||
|
running=true;
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
|
||||||
|
pause.onclick=()=>{
|
||||||
|
running=false;
|
||||||
|
};
|
||||||
|
|
||||||
|
reset.onclick=()=>{
|
||||||
|
frame=0;
|
||||||
|
start=performance.now();
|
||||||
|
running=true;
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
|
@ -0,0 +1,382 @@
|
||||||
|
<!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>
|
||||||
|
|
@ -0,0 +1,240 @@
|
||||||
|
<!doctype html>
|
||||||
|
|
||||||
|
<html lang="da">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
|
||||||
|
<title>GPS demo</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%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.car{
|
||||||
|
width:24px;
|
||||||
|
height:24px;
|
||||||
|
transform-origin:12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.car svg{
|
||||||
|
width:24px;
|
||||||
|
height:24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="map"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
/* --------- 10 koordinatsæt --------- */
|
||||||
|
|
||||||
|
const FRAMES=[
|
||||||
|
|
||||||
|
{"Bil 1":{lat:55.6761,lon:12.5683},"Bil 2":{lat:55.6748,lon:12.5650},"Bil 3":{lat:55.6780,lon:12.5600},"Bil 4":{lat:55.6728,lon:12.5710},"Bil 5":{lat:55.6795,lon:12.5845}},
|
||||||
|
{"Bil 1":{lat:55.6762,lon:12.5720},"Bil 2":{lat:55.6760,lon:12.5670},"Bil 3":{lat:55.6772,lon:12.5603},"Bil 4":{lat:55.6736,lon:12.5698},"Bil 5":{lat:55.6793,lon:12.5815}},
|
||||||
|
{"Bil 1":{lat:55.6763,lon:12.5758},"Bil 2":{lat:55.6772,lon:12.5692},"Bil 3":{lat:55.6765,lon:12.5610},"Bil 4":{lat:55.6746,lon:12.5688},"Bil 5":{lat:55.6790,lon:12.5784}},
|
||||||
|
{"Bil 1":{lat:55.6764,lon:12.5798},"Bil 2":{lat:55.6785,lon:12.5716},"Bil 3":{lat:55.6758,lon:12.5620},"Bil 4":{lat:55.6757,lon:12.5682},"Bil 5":{lat:55.6786,lon:12.5753}},
|
||||||
|
{"Bil 1":{lat:55.6766,lon:12.5837},"Bil 2":{lat:55.6796,lon:12.5744},"Bil 3":{lat:55.6752,lon:12.5634},"Bil 4":{lat:55.6769,lon:12.5682},"Bil 5":{lat:55.6781,lon:12.5722}},
|
||||||
|
{"Bil 1":{lat:55.6768,lon:12.5875},"Bil 2":{lat:55.6804,lon:12.5776},"Bil 3":{lat:55.6747,lon:12.5652},"Bil 4":{lat:55.6781,lon:12.5688},"Bil 5":{lat:55.6776,lon:12.5692}},
|
||||||
|
{"Bil 1":{lat:55.6770,lon:12.5912},"Bil 2":{lat:55.6810,lon:12.5808},"Bil 3":{lat:55.6744,lon:12.5673},"Bil 4":{lat:55.6792,lon:12.5700},"Bil 5":{lat:55.6771,lon:12.5662}},
|
||||||
|
{"Bil 1":{lat:55.6772,lon:12.5948},"Bil 2":{lat:55.6812,lon:12.5842},"Bil 3":{lat:55.6742,lon:12.5696},"Bil 4":{lat:55.6800,lon:12.5716},"Bil 5":{lat:55.6766,lon:12.5634}},
|
||||||
|
{"Bil 1":{lat:55.6775,lon:12.5982},"Bil 2":{lat:55.6811,lon:12.5877},"Bil 3":{lat:55.6742,lon:12.5720},"Bil 4":{lat:55.6806,lon:12.5736},"Bil 5":{lat:55.6761,lon:12.5608}},
|
||||||
|
{"Bil 1":{lat:55.6778,lon:12.6016},"Bil 2":{lat:55.6808,lon:12.5910},"Bil 3":{lat:55.6744,lon:12.5742},"Bil 4":{lat:55.6809,lon:12.5758},"Bil 5":{lat:55.6756,lon:12.5584}}
|
||||||
|
|
||||||
|
];
|
||||||
|
|
||||||
|
/* --------- farver --------- */
|
||||||
|
|
||||||
|
const COLORS={
|
||||||
|
"Bil 1":"#ff0000",
|
||||||
|
"Bil 2":"#00c853",
|
||||||
|
"Bil 3":"#2979ff",
|
||||||
|
"Bil 4":"#2979ff",
|
||||||
|
"Bil 5":"#2979ff"
|
||||||
|
};
|
||||||
|
|
||||||
|
/* --------- kort --------- */
|
||||||
|
|
||||||
|
const map=L.map("map").setView([55.6761,12.5683],13);
|
||||||
|
|
||||||
|
L.tileLayer(
|
||||||
|
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
|
{attribution:"© OpenStreetMap"}
|
||||||
|
).addTo(map);
|
||||||
|
|
||||||
|
/* --------- ikon --------- */
|
||||||
|
|
||||||
|
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">
|
||||||
|
<path d="M12 2 L20 22 L12 18 L4 22 Z"
|
||||||
|
fill="${color}"
|
||||||
|
stroke="#ffffff"
|
||||||
|
stroke-width="1.5"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------- bearing --------- */
|
||||||
|
|
||||||
|
function bearing(p0,p1){
|
||||||
|
|
||||||
|
const dy=p1.lat-p0.lat;
|
||||||
|
const dx=p1.lon-p0.lon;
|
||||||
|
|
||||||
|
const a=Math.atan2(dx,dy)*180/Math.PI;
|
||||||
|
|
||||||
|
return (a+360)%360;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHeading(marker,deg){
|
||||||
|
|
||||||
|
const el=marker.getElement();
|
||||||
|
if(!el)return;
|
||||||
|
|
||||||
|
const car=el.querySelector("[data-role=car]");
|
||||||
|
car.style.transform=`rotate(${deg}deg)`;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------- markers --------- */
|
||||||
|
|
||||||
|
const markers={};
|
||||||
|
const trails={};
|
||||||
|
const uncertainty={};
|
||||||
|
|
||||||
|
const cars=Object.keys(FRAMES[0]);
|
||||||
|
|
||||||
|
for(const name of cars){
|
||||||
|
|
||||||
|
const p=FRAMES[0][name];
|
||||||
|
|
||||||
|
markers[name]=L.marker(
|
||||||
|
[p.lat,p.lon],
|
||||||
|
{icon:carIcon(COLORS[name])}
|
||||||
|
).addTo(map);
|
||||||
|
|
||||||
|
trails[name]=L.polyline(
|
||||||
|
[[p.lat,p.lon]],
|
||||||
|
{color:COLORS[name],weight:3}
|
||||||
|
).addTo(map);
|
||||||
|
|
||||||
|
/* usikkerhed kun for grøn bil */
|
||||||
|
|
||||||
|
if(name==="Bil 2"){
|
||||||
|
|
||||||
|
uncertainty[name]=L.circle(
|
||||||
|
[p.lat,p.lon],
|
||||||
|
{
|
||||||
|
radius:1200,
|
||||||
|
color:"#00c853",
|
||||||
|
fillColor:"#00c853",
|
||||||
|
fillOpacity:0.15,
|
||||||
|
weight:1
|
||||||
|
}
|
||||||
|
).addTo(map);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
markers[name].bindPopup(name);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------- animation --------- */
|
||||||
|
|
||||||
|
let frame=0;
|
||||||
|
let start=performance.now();
|
||||||
|
|
||||||
|
const STEP=1500;
|
||||||
|
|
||||||
|
function lerp(a,b,t){
|
||||||
|
return a+(b-a)*t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function smooth(t){
|
||||||
|
return t*t*(3-2*t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick(now){
|
||||||
|
|
||||||
|
const next=frame+1;
|
||||||
|
|
||||||
|
if(next>=FRAMES.length)return;
|
||||||
|
|
||||||
|
let t=(now-start)/STEP;
|
||||||
|
if(t>1)t=1;
|
||||||
|
|
||||||
|
t=smooth(t);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
if(uncertainty[name]){
|
||||||
|
uncertainty[name].setLatLng([lat,lon]);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if(t>=1){
|
||||||
|
|
||||||
|
frame++;
|
||||||
|
start=now;
|
||||||
|
|
||||||
|
for(const name of cars){
|
||||||
|
|
||||||
|
const p=FRAMES[frame][name];
|
||||||
|
trails[name].addLatLng([p.lat,p.lon]);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="da">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Vehicle stream demo</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud .status {
|
||||||
|
margin-top: 8px;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
display: block;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="map"></div>
|
||||||
|
|
||||||
|
<div class="hud">
|
||||||
|
<div>
|
||||||
|
<button id="playBtn">Play</button>
|
||||||
|
<button id="pauseBtn">Pause</button>
|
||||||
|
<button id="resetBtn">Reset</button>
|
||||||
|
</div>
|
||||||
|
<div class="status" id="status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="data.js"></script>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
# Arbejdsplan for kort7
|
||||||
|
|
||||||
|
## Mål
|
||||||
|
Bygge en løsning med to samtidige kortvisninger over samme positionsdata, hvor hvert kort repræsenterer sit eget perspektiv på usikkerhed.
|
||||||
|
|
||||||
|
## Principper
|
||||||
|
- Samme køretøjspositioner og tidslinje i begge kort
|
||||||
|
- Hvert kort har sit eget usikkerhedssæt
|
||||||
|
- Zoom og pan skal kunne ske uafhængigt i hvert kort
|
||||||
|
- Visning skal være viewport-drevet, ikke bundet til et bestemt geografisk udsnit
|
||||||
|
- UI'et skal beskytte læsbarheden, også ved meget store usikkerheder
|
||||||
|
|
||||||
|
## Funktionel plan
|
||||||
|
|
||||||
|
### 1. Datamodel
|
||||||
|
- Udvide `data.js`, så hver observation har:
|
||||||
|
- fælles position, retning, hastighed, timestamp
|
||||||
|
- `uncertaintyA`
|
||||||
|
- `uncertaintyB`
|
||||||
|
- Sikre at begge usikkerheder hører til samme observation i streamen
|
||||||
|
|
||||||
|
### 2. Layout
|
||||||
|
- Opdatere `kort7.html` til to separate kortcontainere
|
||||||
|
- Give hvert kort sin egen HUD/indikatorzone for køretøjer uden for viewport
|
||||||
|
- Beholde fælles styring til play/pause/reset
|
||||||
|
|
||||||
|
### 3. Rendering-arkitektur
|
||||||
|
- Omskrive `app.js`, så rendering sker per map-instans i stedet for globalt
|
||||||
|
- Lade begge kort abonnere på samme simulerede tid og samme positionsstream
|
||||||
|
- Lade hvert kort bruge sit eget usikkerhedsfelt
|
||||||
|
|
||||||
|
### 4. Viewport-logik pr. kort
|
||||||
|
For hvert køretøj og for hvert kort skal systemet afgøre:
|
||||||
|
- **inside**: køretøjet er inden for viewporten
|
||||||
|
- **possible**: køretøjet er uden for viewporten, men usikkerheden overlapper viewporten
|
||||||
|
- **outside**: køretøjet og dets usikkerhed er irrelevante for viewporten
|
||||||
|
|
||||||
|
Denne vurdering skal genberegnes:
|
||||||
|
- ved hver dataopdatering
|
||||||
|
- ved zoom
|
||||||
|
- ved pan
|
||||||
|
|
||||||
|
### 5. Visuel adfærd
|
||||||
|
- **Inside:**
|
||||||
|
- vis køretøjet på kortet
|
||||||
|
- vis trail
|
||||||
|
- vis transparent usikkerhedscirkel omkring køretøjet
|
||||||
|
- **Possible:**
|
||||||
|
- skjul køretøjets normale markør på kortet
|
||||||
|
- vis en hjørneindikator i det relevante kort
|
||||||
|
- indikatoren skal vise hvilket køretøj der muligvis kan være i området
|
||||||
|
- gerne med farve, navn og retning/side i forhold til viewporten
|
||||||
|
- usikkerhed på kortet kan evt. stadig vises diskret, hvis det giver mening
|
||||||
|
- **Outside:**
|
||||||
|
- vis hverken markør eller hjørneindikator
|
||||||
|
- trail kan tones ned eller skjules
|
||||||
|
|
||||||
|
### 6. Håndtering af store usikkerheder
|
||||||
|
- Undgå at enorme usikkerheder ødelægger læsbarheden
|
||||||
|
- Klip visning naturligt til viewporten
|
||||||
|
- Brug lav opacity for store usikkerhedsflader
|
||||||
|
- Lad hjørneindikatoren være den primære forklaring, når centrum ligger uden for kortet
|
||||||
|
|
||||||
|
### 7. Sammenligning mellem perspektiver
|
||||||
|
- Et køretøj må gerne være:
|
||||||
|
- synligt på kort A
|
||||||
|
- kun en mulig tilstedeværelse på kort B
|
||||||
|
- eller omvendt
|
||||||
|
- Dette er en ønsket effekt, fordi forskellen mellem perspektiverne netop skal være tydelig
|
||||||
|
|
||||||
|
## Implementeringsrækkefølge
|
||||||
|
1. Gemme arbejdsplanen
|
||||||
|
2. Opdatere datamodellen i `data.js`
|
||||||
|
3. Splitte `kort7.html` til to kortviews
|
||||||
|
4. Refaktorere `app.js` til to renderingskontekster
|
||||||
|
5. Implementere usikkerhedscirkler per kort
|
||||||
|
6. Implementere hjørneindikatorer per kort
|
||||||
|
7. Teste zoom/pan og dynamiske overgange mellem inside/possible/outside
|
||||||
|
8. Finjustere styling og læsbarhed
|
||||||
|
|
||||||
|
## Åbne designvalg
|
||||||
|
- Om hjørneindikatorer skal ligge i et fast hjørne eller langs den nærmeste kant
|
||||||
|
- Om store usikkerheder altid skal tegnes som cirkler på kortet, eller nogle gange kun repræsenteres via indikator
|
||||||
|
- Hvordan vi bedst navngiver de to perspektiver i UI’et
|
||||||
|
- Om trails skal vises ens i begge kort eller tones forskelligt
|
||||||
|
|
||||||
|
## Kort version
|
||||||
|
Samme data. To kort. To forskellige usikkerhedsperspektiver. Dynamisk viewport-logik. Hjørneindikatorer for mulige off-map køretøjer.
|
||||||
|
|
@ -0,0 +1,406 @@
|
||||||
|
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"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}).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 => `
|
||||||
|
<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);
|
||||||
|
|
@ -0,0 +1,406 @@
|
||||||
|
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"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 => `
|
||||||
|
<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);
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
const VEHICLE_EVENTS = [
|
||||||
|
{ vehicle: "Bil 3", ts: 0, lat: 55.6780, lon: 12.5600, speedMps: 8, headingDeg: 190, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 2, lat: 55.6748, lon: 12.5650, speedMps: 9, headingDeg: 45, uncertaintyM: 1200 },
|
||||||
|
{ vehicle: "Bil 1", ts: 4, lat: 55.6761, lon: 12.5683, speedMps: 12, headingDeg: 90, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 5, lat: 55.6728, lon: 12.5710, speedMps: 7, headingDeg: 330, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 6, lat: 55.6795, lon: 12.5845, speedMps: 11, headingDeg: 260, uncertaintyM: 0 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 6", ts: 8, lat: 55.7080, lon: 12.5050, speedMps: 0, headingDeg: 90, uncertaintyM: 15000 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 7", ts: 10, lat: 55.7065, lon: 12.6175, speedMps: 0, headingDeg: 270, uncertaintyM: 2800 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 3", ts: 12, lat: 55.6772, lon: 12.5603, speedMps: 8, headingDeg: 195, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 13, lat: 55.6760, lon: 12.5670, speedMps: 10, headingDeg: 50, uncertaintyM: 1000 },
|
||||||
|
{ vehicle: "Bil 1", ts: 15, lat: 55.6762, lon: 12.5720, speedMps: 13, headingDeg: 95, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 16, lat: 55.6736, lon: 12.5698, speedMps: 8, headingDeg: 340, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 18, lat: 55.6793, lon: 12.5815, speedMps: 12, headingDeg: 255, uncertaintyM: 0 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 3", ts: 24, lat: 55.6765, lon: 12.5610, speedMps: 9, headingDeg: 200, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 26, lat: 55.6772, lon: 12.5692, speedMps: 11, headingDeg: 55, uncertaintyM: 900 },
|
||||||
|
{ vehicle: "Bil 1", ts: 27, lat: 55.6763, lon: 12.5758, speedMps: 14, headingDeg: 100, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 28, lat: 55.6746, lon: 12.5688, speedMps: 8, headingDeg: 350, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 29, lat: 55.6790, lon: 12.5784, speedMps: 12, headingDeg: 250, uncertaintyM: 0 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 3", ts: 38, lat: 55.6758, lon: 12.5620, speedMps: 9, headingDeg: 210, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 40, lat: 55.6785, lon: 12.5716, speedMps: 12, headingDeg: 60, uncertaintyM: 850 },
|
||||||
|
{ vehicle: "Bil 1", ts: 41, lat: 55.6764, lon: 12.5798, speedMps: 14, headingDeg: 105, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 42, lat: 55.6757, lon: 12.5682, speedMps: 9, headingDeg: 0, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 43, lat: 55.6786, lon: 12.5753, speedMps: 11, headingDeg: 245, uncertaintyM: 0 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 6", ts: 44, lat: 55.7080, lon: 12.5050, speedMps: 0, headingDeg: 90, uncertaintyM: 15000 },
|
||||||
|
{ vehicle: "Bil 7", ts: 46, lat: 55.7065, lon: 12.6175, speedMps: 0, headingDeg: 270, uncertaintyM: 2800 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 3", ts: 50, lat: 55.6752, lon: 12.5634, speedMps: 10, headingDeg: 220, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 54, lat: 55.6796, lon: 12.5744, speedMps: 12, headingDeg: 70, uncertaintyM: 800 },
|
||||||
|
{ vehicle: "Bil 1", ts: 55, lat: 55.6766, lon: 12.5837, speedMps: 15, headingDeg: 110, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 56, lat: 55.6769, lon: 12.5682, speedMps: 9, headingDeg: 10, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 57, lat: 55.6781, lon: 12.5722, speedMps: 10, headingDeg: 240, uncertaintyM: 0 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 3", ts: 66, lat: 55.6747, lon: 12.5652, speedMps: 10, headingDeg: 230, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 68, lat: 55.6804, lon: 12.5776, speedMps: 11, headingDeg: 85, uncertaintyM: 950 },
|
||||||
|
{ vehicle: "Bil 1", ts: 69, lat: 55.6768, lon: 12.5875, speedMps: 14, headingDeg: 115, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 70, lat: 55.6781, lon: 12.5688, speedMps: 10, headingDeg: 20, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 72, lat: 55.6776, lon: 12.5692, speedMps: 10, headingDeg: 235, uncertaintyM: 0 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 3", ts: 82, lat: 55.6744, lon: 12.5673, speedMps: 9, headingDeg: 240, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 84, lat: 55.6810, lon: 12.5808, speedMps: 10, headingDeg: 100, uncertaintyM: 1100 },
|
||||||
|
{ vehicle: "Bil 1", ts: 85, lat: 55.6770, lon: 12.5912, speedMps: 13, headingDeg: 120, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 87, lat: 55.6792, lon: 12.5700, speedMps: 10, headingDeg: 30, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 88, lat: 55.6771, lon: 12.5662, speedMps: 9, headingDeg: 230, uncertaintyM: 0 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 6", ts: 90, lat: 55.7080, lon: 12.5050, speedMps: 0, headingDeg: 90, uncertaintyM: 15000 },
|
||||||
|
{ vehicle: "Bil 7", ts: 92, lat: 55.7065, lon: 12.6175, speedMps: 0, headingDeg: 270, uncertaintyM: 2800 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 3", ts: 96, lat: 55.6742, lon: 12.5696, speedMps: 8, headingDeg: 250, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 98, lat: 55.6812, lon: 12.5842, speedMps: 10, headingDeg: 115, uncertaintyM: 1200 },
|
||||||
|
{ vehicle: "Bil 1", ts: 99, lat: 55.6772, lon: 12.5948, speedMps: 12, headingDeg: 125, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 101,lat: 55.6800, lon: 12.5716, speedMps: 9, headingDeg: 40, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 102,lat: 55.6766, lon: 12.5634, speedMps: 9, headingDeg: 225, uncertaintyM: 0 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 3", ts: 112,lat: 55.6742, lon: 12.5720, speedMps: 8, headingDeg: 260, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 114,lat: 55.6811, lon: 12.5877, speedMps: 9, headingDeg: 125, uncertaintyM: 1000 },
|
||||||
|
{ vehicle: "Bil 1", ts: 115,lat: 55.6775, lon: 12.5982, speedMps: 11, headingDeg: 130, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 117,lat: 55.6806, lon: 12.5736, speedMps: 8, headingDeg: 50, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 118,lat: 55.6761, lon: 12.5608, speedMps: 8, headingDeg: 220, uncertaintyM: 0 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 3", ts: 128,lat: 55.6744, lon: 12.5742, speedMps: 7, headingDeg: 270, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 130,lat: 55.6808, lon: 12.5910, speedMps: 8, headingDeg: 130, uncertaintyM: 950 },
|
||||||
|
{ vehicle: "Bil 1", ts: 131,lat: 55.6778, lon: 12.6016, speedMps: 10, headingDeg: 135, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 133,lat: 55.6809, lon: 12.5758, speedMps: 7, headingDeg: 60, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 134,lat: 55.6756, lon: 12.5584, speedMps: 8, headingDeg: 215, uncertaintyM: 0 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 6", ts: 134,lat: 55.7080, lon: 12.5050, speedMps: 0, headingDeg: 90, uncertaintyM: 15000 },
|
||||||
|
{ vehicle: "Bil 7", ts: 134,lat: 55.7065, lon: 12.6175, speedMps: 0, headingDeg: 270, uncertaintyM: 2800 }
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
const VEHICLE_EVENTS = [
|
||||||
|
{ vehicle: "Bil 3", ts: 0, lat: 55.6780, lon: 12.5600, speedMps: 8, headingDeg: 190, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 2, lat: 55.6748, lon: 12.5650, speedMps: 9, headingDeg: 45, uncertaintyM: 1200 },
|
||||||
|
{ vehicle: "Bil 1", ts: 4, lat: 55.6761, lon: 12.5683, speedMps: 12, headingDeg: 90, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 5, lat: 55.6728, lon: 12.5710, speedMps: 7, headingDeg: 330, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 6, lat: 55.6795, lon: 12.5845, speedMps: 11, headingDeg: 260, uncertaintyM: 0 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 3", ts: 12, lat: 55.6772, lon: 12.5603, speedMps: 8, headingDeg: 195, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 13, lat: 55.6760, lon: 12.5670, speedMps: 10, headingDeg: 50, uncertaintyM: 1000 },
|
||||||
|
{ vehicle: "Bil 1", ts: 15, lat: 55.6762, lon: 12.5720, speedMps: 13, headingDeg: 95, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 16, lat: 55.6736, lon: 12.5698, speedMps: 8, headingDeg: 340, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 18, lat: 55.6793, lon: 12.5815, speedMps: 12, headingDeg: 255, uncertaintyM: 0 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 3", ts: 24, lat: 55.6765, lon: 12.5610, speedMps: 9, headingDeg: 200, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 26, lat: 55.6772, lon: 12.5692, speedMps: 11, headingDeg: 55, uncertaintyM: 900 },
|
||||||
|
{ vehicle: "Bil 1", ts: 27, lat: 55.6763, lon: 12.5758, speedMps: 14, headingDeg: 100, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 28, lat: 55.6746, lon: 12.5688, speedMps: 8, headingDeg: 350, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 29, lat: 55.6790, lon: 12.5784, speedMps: 12, headingDeg: 250, uncertaintyM: 0 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 3", ts: 38, lat: 55.6758, lon: 12.5620, speedMps: 9, headingDeg: 210, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 40, lat: 55.6785, lon: 12.5716, speedMps: 12, headingDeg: 60, uncertaintyM: 850 },
|
||||||
|
{ vehicle: "Bil 1", ts: 41, lat: 55.6764, lon: 12.5798, speedMps: 14, headingDeg: 105, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 42, lat: 55.6757, lon: 12.5682, speedMps: 9, headingDeg: 0, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 43, lat: 55.6786, lon: 12.5753, speedMps: 11, headingDeg: 245, uncertaintyM: 0 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 3", ts: 50, lat: 55.6752, lon: 12.5634, speedMps: 10, headingDeg: 220, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 54, lat: 55.6796, lon: 12.5744, speedMps: 12, headingDeg: 70, uncertaintyM: 800 },
|
||||||
|
{ vehicle: "Bil 1", ts: 55, lat: 55.6766, lon: 12.5837, speedMps: 15, headingDeg: 110, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 56, lat: 55.6769, lon: 12.5682, speedMps: 9, headingDeg: 10, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 57, lat: 55.6781, lon: 12.5722, speedMps: 10, headingDeg: 240, uncertaintyM: 0 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 3", ts: 66, lat: 55.6747, lon: 12.5652, speedMps: 10, headingDeg: 230, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 68, lat: 55.6804, lon: 12.5776, speedMps: 11, headingDeg: 85, uncertaintyM: 950 },
|
||||||
|
{ vehicle: "Bil 1", ts: 69, lat: 55.6768, lon: 12.5875, speedMps: 14, headingDeg: 115, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 70, lat: 55.6781, lon: 12.5688, speedMps: 10, headingDeg: 20, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 72, lat: 55.6776, lon: 12.5692, speedMps: 10, headingDeg: 235, uncertaintyM: 0 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 3", ts: 82, lat: 55.6744, lon: 12.5673, speedMps: 9, headingDeg: 240, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 84, lat: 55.6810, lon: 12.5808, speedMps: 10, headingDeg: 100, uncertaintyM: 1100 },
|
||||||
|
{ vehicle: "Bil 1", ts: 85, lat: 55.6770, lon: 12.5912, speedMps: 13, headingDeg: 120, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 87, lat: 55.6792, lon: 12.5700, speedMps: 10, headingDeg: 30, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 88, lat: 55.6771, lon: 12.5662, speedMps: 9, headingDeg: 230, uncertaintyM: 0 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 3", ts: 96, lat: 55.6742, lon: 12.5696, speedMps: 8, headingDeg: 250, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 98, lat: 55.6812, lon: 12.5842, speedMps: 10, headingDeg: 115, uncertaintyM: 1200 },
|
||||||
|
{ vehicle: "Bil 1", ts: 99, lat: 55.6772, lon: 12.5948, speedMps: 12, headingDeg: 125, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 101,lat: 55.6800, lon: 12.5716, speedMps: 9, headingDeg: 40, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 102,lat: 55.6766, lon: 12.5634, speedMps: 9, headingDeg: 225, uncertaintyM: 0 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 3", ts: 112,lat: 55.6742, lon: 12.5720, speedMps: 8, headingDeg: 260, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 114,lat: 55.6811, lon: 12.5877, speedMps: 9, headingDeg: 125, uncertaintyM: 1000 },
|
||||||
|
{ vehicle: "Bil 1", ts: 115,lat: 55.6775, lon: 12.5982, speedMps: 11, headingDeg: 130, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 117,lat: 55.6806, lon: 12.5736, speedMps: 8, headingDeg: 50, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 118,lat: 55.6761, lon: 12.5608, speedMps: 8, headingDeg: 220, uncertaintyM: 0 },
|
||||||
|
|
||||||
|
{ vehicle: "Bil 3", ts: 128,lat: 55.6744, lon: 12.5742, speedMps: 7, headingDeg: 270, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 130,lat: 55.6808, lon: 12.5910, speedMps: 8, headingDeg: 130, uncertaintyM: 950 },
|
||||||
|
{ vehicle: "Bil 1", ts: 131,lat: 55.6778, lon: 12.6016, speedMps: 10, headingDeg: 135, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 133,lat: 55.6809, lon: 12.5758, speedMps: 7, headingDeg: 60, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 5", ts: 134,lat: 55.6756, lon: 12.5584, speedMps: 8, headingDeg: 215, uncertaintyM: 0 }
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
--- data.js
|
||||||
|
+++ data.js
|
||||||
|
@@ -53,5 +53,15 @@
|
||||||
|
{ vehicle: "Bil 3", ts: 128,lat: 55.6744, lon: 12.5742, speedMps: 7, headingDeg: 270, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 130,lat: 55.6808, lon: 12.5910, speedMps: 8, headingDeg: 130, uncertaintyM: 950 },
|
||||||
|
{ vehicle: "Bil 1", ts: 131,lat: 55.6778, lon: 12.6016, speedMps: 10, headingDeg: 135, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 133,lat: 55.6809, lon: 12.5758, speedMps: 7, headingDeg: 60, uncertaintyM: 0 },
|
||||||
|
- { vehicle: "Bil 5", ts: 134,lat: 55.6756, lon: 12.5584, speedMps: 8, headingDeg: 215, uncertaintyM: 0 }
|
||||||
|
+ { vehicle: "Bil 5", ts: 134,lat: 55.6756, lon: 12.5584, speedMps: 8, headingDeg: 215, uncertaintyM: 0 },
|
||||||
|
+
|
||||||
|
+ { vehicle: "Bil 6", ts: 8, lat: 55.7080, lon: 12.5050, speedMps: 0, headingDeg: 90, uncertaintyM: 15000 },
|
||||||
|
+ { vehicle: "Bil 6", ts: 44, lat: 55.7080, lon: 12.5050, speedMps: 0, headingDeg: 90, uncertaintyM: 15000 },
|
||||||
|
+ { vehicle: "Bil 6", ts: 90, lat: 55.7080, lon: 12.5050, speedMps: 0, headingDeg: 90, uncertaintyM: 15000 },
|
||||||
|
+ { vehicle: "Bil 6", ts: 134,lat: 55.7080, lon: 12.5050, speedMps: 0, headingDeg: 90, uncertaintyM: 15000 },
|
||||||
|
+
|
||||||
|
+ { vehicle: "Bil 7", ts: 10, lat: 55.7065, lon: 12.6175, speedMps: 0, headingDeg: 270, uncertaintyM: 2800 },
|
||||||
|
+ { vehicle: "Bil 7", ts: 46, lat: 55.7065, lon: 12.6175, speedMps: 0, headingDeg: 270, uncertaintyM: 2800 },
|
||||||
|
+ { vehicle: "Bil 7", ts: 92, lat: 55.7065, lon: 12.6175, speedMps: 0, headingDeg: 270, uncertaintyM: 2800 },
|
||||||
|
+ { vehicle: "Bil 7", ts: 134,lat: 55.7065, lon: 12.6175, speedMps: 0, headingDeg: 270, uncertaintyM: 2800 }
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="da">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Vehicle stream demo</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud .status {
|
||||||
|
margin-top: 8px;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
display: block;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="map"></div>
|
||||||
|
|
||||||
|
<div class="hud">
|
||||||
|
<div>
|
||||||
|
<button id="playBtn">Play</button>
|
||||||
|
<button id="pauseBtn">Pause</button>
|
||||||
|
<button id="resetBtn">Reset</button>
|
||||||
|
</div>
|
||||||
|
<div class="status" id="status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="data.js"></script>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="da">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Vehicle stream demo</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud .status {
|
||||||
|
margin-top: 8px;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud .alerts {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-item {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e0b100;
|
||||||
|
background: rgba(255,248,204,0.96);
|
||||||
|
color: #5f4b00;
|
||||||
|
font: 12px/1.35 system-ui,sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-item strong { display:block; margin-bottom:2px; }
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
display: block;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="map"></div>
|
||||||
|
|
||||||
|
<div class="hud">
|
||||||
|
<div>
|
||||||
|
<button id="playBtn">Play</button>
|
||||||
|
<button id="pauseBtn">Pause</button>
|
||||||
|
<button id="resetBtn">Reset</button>
|
||||||
|
</div>
|
||||||
|
<div class="status" id="status"></div>
|
||||||
|
<div class="alerts" id="presenceAlerts"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="data.js"></script>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
--- a/kort6.html
|
||||||
|
+++ b/kort6.html
|
||||||
|
@@ -41,6 +41,24 @@
|
||||||
|
.hud .status {
|
||||||
|
margin-top: 8px;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
+
|
||||||
|
+ .hud .alerts {
|
||||||
|
+ margin-top: 8px;
|
||||||
|
+ display: grid;
|
||||||
|
+ gap: 6px;
|
||||||
|
+ max-width: 320px;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ .alert-item {
|
||||||
|
+ padding: 8px 10px;
|
||||||
|
+ border-radius: 8px;
|
||||||
|
+ border: 1px solid #e0b100;
|
||||||
|
+ background: rgba(255,248,204,0.96);
|
||||||
|
+ color: #5f4b00;
|
||||||
|
+ font: 12px/1.35 system-ui,sans-serif;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ .alert-item strong { display:block; margin-bottom:2px; }
|
||||||
|
|
||||||
|
.car {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
@@ -86,6 +104,7 @@
|
||||||
|
<button id="resetBtn">Reset</button>
|
||||||
|
</div>
|
||||||
|
<div class="status" id="status"></div>
|
||||||
|
+ <div class="alerts" id="presenceAlerts"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="data.js"></script>
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
--- a/data.js
|
||||||
|
+++ b/data.js
|
||||||
|
@@ -53,5 +53,15 @@
|
||||||
|
{ vehicle: "Bil 3", ts: 128,lat: 55.6744, lon: 12.5742, speedMps: 7, headingDeg: 270, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 2", ts: 130,lat: 55.6808, lon: 12.5910, speedMps: 8, headingDeg: 130, uncertaintyM: 950 },
|
||||||
|
{ vehicle: "Bil 1", ts: 131,lat: 55.6778, lon: 12.6016, speedMps: 10, headingDeg: 135, uncertaintyM: 0 },
|
||||||
|
{ vehicle: "Bil 4", ts: 133,lat: 55.6809, lon: 12.5758, speedMps: 7, headingDeg: 60, uncertaintyM: 0 },
|
||||||
|
- { vehicle: "Bil 5", ts: 134,lat: 55.6756, lon: 12.5584, speedMps: 8, headingDeg: 215, uncertaintyM: 0 }
|
||||||
|
+ { vehicle: "Bil 5", ts: 134,lat: 55.6756, lon: 12.5584, speedMps: 8, headingDeg: 215, uncertaintyM: 0 },
|
||||||
|
+
|
||||||
|
+ { vehicle: "Bil 6", ts: 8, lat: 55.7080, lon: 12.5050, speedMps: 0, headingDeg: 90, uncertaintyM: 15000 },
|
||||||
|
+ { vehicle: "Bil 6", ts: 44, lat: 55.7080, lon: 12.5050, speedMps: 0, headingDeg: 90, uncertaintyM: 15000 },
|
||||||
|
+ { vehicle: "Bil 6", ts: 90, lat: 55.7080, lon: 12.5050, speedMps: 0, headingDeg: 90, uncertaintyM: 15000 },
|
||||||
|
+ { vehicle: "Bil 6", ts: 134,lat: 55.7080, lon: 12.5050, speedMps: 0, headingDeg: 90, uncertaintyM: 15000 },
|
||||||
|
+
|
||||||
|
+ { vehicle: "Bil 7", ts: 10, lat: 55.7065, lon: 12.6175, speedMps: 0, headingDeg: 270, uncertaintyM: 2800 },
|
||||||
|
+ { vehicle: "Bil 7", ts: 46, lat: 55.7065, lon: 12.6175, speedMps: 0, headingDeg: 270, uncertaintyM: 2800 },
|
||||||
|
+ { vehicle: "Bil 7", ts: 92, lat: 55.7065, lon: 12.6175, speedMps: 0, headingDeg: 270, uncertaintyM: 2800 },
|
||||||
|
+ { vehicle: "Bil 7", ts: 134,lat: 55.7065, lon: 12.6175, speedMps: 0, headingDeg: 270, uncertaintyM: 2800 }
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
--- a/app.js
|
||||||
|
+++ b/app.js
|
||||||
|
@@ -77,7 +77,7 @@
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
let uncertaintyCircle = null;
|
||||||
|
- if (name === "Bil 2") {
|
||||||
|
+ if (first.uncertaintyM > 0) {
|
||||||
|
uncertaintyCircle = L.circle([first.lat, first.lon], {
|
||||||
|
radius: first.uncertaintyM,
|
||||||
|
color: color,
|
||||||
Loading…
Reference in New Issue