Add synchronized approximate and precise maps
This commit is contained in:
parent
0d2bca0a3e
commit
75d15394c8
699
kort7/app.js
699
kort7/app.js
|
|
@ -9,19 +9,22 @@ const COLORS = {
|
|||
"Bil 10": "#00c853"
|
||||
};
|
||||
|
||||
const map = L.map("map").setView([55.6761, 12.5683], 13);
|
||||
window.__debugMap = map;
|
||||
|
||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
attribution: "© OpenStreetMap contributors",
|
||||
maxZoom: 19
|
||||
}).addTo(map);
|
||||
const POSITION_OFFSETS = {
|
||||
"Bil 1": { northM: 180, eastM: -120 },
|
||||
"Bil 2": { northM: -260, eastM: 210 },
|
||||
"Bil 3": { northM: 140, eastM: 160 },
|
||||
"Bil 4": { northM: -180, eastM: -190 },
|
||||
"Bil 5": { northM: 210, eastM: 110 },
|
||||
"Bil 8": { northM: -320, eastM: 240 },
|
||||
"Bil 9": { northM: -420, eastM: 460 },
|
||||
"Bil 10": { northM: 520, eastM: -620 }
|
||||
};
|
||||
|
||||
const statusEl = document.getElementById("status");
|
||||
const alertsEl = document.getElementById("presenceAlerts");
|
||||
const selectedVehicleInfoEl = document.getElementById("selectedVehicleInfo");
|
||||
const runtimeWarningEl = document.getElementById("runtimeWarning");
|
||||
const offmapIndicatorsEl = document.getElementById("offmapIndicators");
|
||||
const alertsApproxEl = document.getElementById("presenceAlertsApprox");
|
||||
const alertsTrueEl = document.getElementById("presenceAlertsTrue");
|
||||
const playBtn = document.getElementById("playBtn");
|
||||
const pauseBtn = document.getElementById("pauseBtn");
|
||||
const resetBtn = document.getElementById("resetBtn");
|
||||
|
|
@ -30,149 +33,44 @@ if (window.location.protocol === "file:") {
|
|||
runtimeWarningEl.hidden = false;
|
||||
}
|
||||
|
||||
let forcedPopupVehicle = null;
|
||||
|
||||
function highlightOffmapIndicator(name) {
|
||||
const buttons = offmapIndicatorsEl.querySelectorAll('.offmap-indicator');
|
||||
buttons.forEach((button) => {
|
||||
button.classList.toggle('active', button.dataset.vehicle === name);
|
||||
});
|
||||
function metersToLatLon(lat, northM, eastM) {
|
||||
const metersPerDegLat = 111320;
|
||||
const metersPerDegLon = 111320 * Math.cos(lat * Math.PI / 180);
|
||||
return {
|
||||
lat: lat + northM / metersPerDegLat,
|
||||
lon: eastM / metersPerDegLon
|
||||
};
|
||||
}
|
||||
|
||||
function renderSelectedVehicleInfo(v, sourceLabel = "kort") {
|
||||
if (!v) {
|
||||
selectedVehicleInfoEl.textContent = "Klik på et køretøj eller en hjørneindikator for detaljer.";
|
||||
return;
|
||||
function withTruePosition(event) {
|
||||
const offset = POSITION_OFFSETS[event.vehicle] || { northM: 0, eastM: 0 };
|
||||
const shifted = metersToLatLon(event.lat, offset.northM, offset.eastM);
|
||||
return {
|
||||
...event,
|
||||
approxLat: event.lat,
|
||||
approxLon: event.lon,
|
||||
trueLat: shifted.lat,
|
||||
trueLon: event.lon + shifted.lon
|
||||
};
|
||||
}
|
||||
|
||||
function buildVehicleBuckets(events) {
|
||||
const out = {};
|
||||
for (const rawEvent of events) {
|
||||
const e = withTruePosition(rawEvent);
|
||||
if (!out[e.vehicle]) out[e.vehicle] = [];
|
||||
out[e.vehicle].push(e);
|
||||
}
|
||||
|
||||
selectedVehicleInfoEl.innerHTML = `
|
||||
<strong>${v.name} · valgt fra ${sourceLabel}</strong>
|
||||
Tid: ${fmtTs(v.current.ts)}<br>
|
||||
Position: ${v.displayLat.toFixed(5)}, ${v.displayLon.toFixed(5)}<br>
|
||||
Hastighed: ${v.current.speedMps} m/s<br>
|
||||
Retning: ${v.displayHeading.toFixed(0)}°<br>
|
||||
Usikkerhed: ${v.current.uncertaintyM} m<br>
|
||||
Status: ${v.presenceStateLabel || "ja"}
|
||||
`;
|
||||
}
|
||||
|
||||
function flashVehicleMarker(v) {
|
||||
const markerEl = v.marker.getElement();
|
||||
if (!markerEl) return;
|
||||
|
||||
markerEl.classList.remove("flash-marker");
|
||||
void markerEl.offsetWidth;
|
||||
markerEl.classList.add("flash-marker");
|
||||
}
|
||||
|
||||
function openVehiclePopup(name) {
|
||||
const v = vehicles[name];
|
||||
if (!v) return;
|
||||
|
||||
forcedPopupVehicle = name;
|
||||
highlightOffmapIndicator(name);
|
||||
renderSelectedVehicleInfo(v, "hjørneindikator");
|
||||
|
||||
if (!map.hasLayer(v.marker)) {
|
||||
v.marker.addTo(map);
|
||||
for (const k of Object.keys(out)) {
|
||||
out[k].sort((a, b) => a.ts - b.ts);
|
||||
}
|
||||
|
||||
const focusBounds = L.latLng(v.displayLat, v.displayLon).toBounds(Math.max(v.current.uncertaintyM * 0.6, 1200));
|
||||
map.fitBounds(focusBounds, {
|
||||
animate: true,
|
||||
duration: 0.5,
|
||||
paddingTopLeft: [290, 60],
|
||||
paddingBottomRight: [80, 80],
|
||||
maxZoom: 13
|
||||
});
|
||||
|
||||
v.marker.setOpacity(1);
|
||||
v.marker.setZIndexOffset(1000);
|
||||
v.marker.setLatLng([v.displayLat, v.displayLon]);
|
||||
setHeading(v.marker, v.displayHeading);
|
||||
v.marker.setPopupContent(popupHtml(v.name, v));
|
||||
|
||||
window.setTimeout(() => {
|
||||
v.marker.openPopup();
|
||||
flashVehicleMarker(v);
|
||||
}, 180);
|
||||
}
|
||||
|
||||
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)`;
|
||||
return out;
|
||||
}
|
||||
|
||||
function fmtTs(ts) {
|
||||
return `${ts}s`;
|
||||
}
|
||||
|
||||
function getDirectionInfo(lat, lon, bounds) {
|
||||
const centerLat = (bounds.getSouth() + bounds.getNorth()) / 2;
|
||||
const centerLon = (bounds.getWest() + bounds.getEast()) / 2;
|
||||
const dLat = lat - centerLat;
|
||||
const dLon = lon - centerLon;
|
||||
|
||||
const vertical = Math.abs(dLat) > 0.01 ? (dLat > 0 ? "N" : "S") : "";
|
||||
const horizontal = Math.abs(dLon) > 0.01 ? (dLon > 0 ? "Ø" : "V") : "";
|
||||
const label = `${vertical}${horizontal}` || "Nær kanten";
|
||||
|
||||
const arrowMap = {
|
||||
"N": "↑",
|
||||
"S": "↓",
|
||||
"Ø": "→",
|
||||
"V": "←",
|
||||
"NØ": "↗",
|
||||
"NV": "↖",
|
||||
"SØ": "↘",
|
||||
"SV": "↙",
|
||||
"Nær kanten": "•"
|
||||
};
|
||||
|
||||
return {
|
||||
label,
|
||||
arrow: arrowMap[label] || "•"
|
||||
};
|
||||
}
|
||||
|
||||
function 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);
|
||||
|
|
@ -187,26 +85,12 @@ function lerpAngleDeg(a, b, t) {
|
|||
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);
|
||||
}
|
||||
|
||||
|
|
@ -215,14 +99,8 @@ function clamp(value, min, max) {
|
|||
}
|
||||
|
||||
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);
|
||||
|
||||
const clampedLat = clamp(lat, bounds.getSouth(), bounds.getNorth());
|
||||
const clampedLon = clamp(lon, bounds.getWest(), bounds.getEast());
|
||||
return metersBetween(lat, lon, clampedLat, clampedLon);
|
||||
}
|
||||
|
||||
|
|
@ -233,52 +111,117 @@ function maxDistanceToBoundsCornerMeters(lat, lon, bounds) {
|
|||
[bounds.getNorth(), bounds.getWest()],
|
||||
[bounds.getNorth(), bounds.getEast()]
|
||||
];
|
||||
|
||||
return Math.max(...corners.map(([cornerLat, cornerLon]) =>
|
||||
metersBetween(lat, lon, cornerLat, cornerLon)
|
||||
));
|
||||
}
|
||||
|
||||
function getPresenceState(lat, lon, uncertaintyM) {
|
||||
const bounds = map.getBounds();
|
||||
const centerInside = bounds.contains([lat, lon]);
|
||||
function getDirectionInfo(lat, lon, bounds) {
|
||||
const centerLat = (bounds.getSouth() + bounds.getNorth()) / 2;
|
||||
const centerLon = (bounds.getWest() + bounds.getEast()) / 2;
|
||||
const dLat = lat - centerLat;
|
||||
const dLon = lon - centerLon;
|
||||
const vertical = Math.abs(dLat) > 0.01 ? (dLat > 0 ? "N" : "S") : "";
|
||||
const horizontal = Math.abs(dLon) > 0.01 ? (dLon > 0 ? "Ø" : "V") : "";
|
||||
const label = `${vertical}${horizontal}` || "Nær kanten";
|
||||
const arrowMap = { "N": "↑", "S": "↓", "Ø": "→", "V": "←", "NØ": "↗", "NV": "↖", "SØ": "↘", "SV": "↙", "Nær kanten": "•" };
|
||||
return { label, arrow: arrowMap[label] || "•" };
|
||||
}
|
||||
|
||||
if (centerInside) {
|
||||
return {
|
||||
code: "inside",
|
||||
label: "ja"
|
||||
};
|
||||
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 popupHtml(view, 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} · ${view.label}</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 renderSelectedVehicleInfo(selection) {
|
||||
if (!selection) {
|
||||
selectedVehicleInfoEl.textContent = "Klik på et køretøj eller en hjørneindikator for detaljer.";
|
||||
return;
|
||||
}
|
||||
|
||||
selectedVehicleInfoEl.innerHTML = `
|
||||
<strong>${selection.name} · ${selection.viewLabel}</strong>
|
||||
Kilde: ${selection.source}<br>
|
||||
Tid: ${fmtTs(selection.state.current.ts)}<br>
|
||||
Position: ${selection.state.displayLat.toFixed(5)}, ${selection.state.displayLon.toFixed(5)}<br>
|
||||
Hastighed: ${selection.state.current.speedMps} m/s<br>
|
||||
Retning: ${selection.state.displayHeading.toFixed(0)}°<br>
|
||||
Usikkerhed: ${selection.state.current.uncertaintyM} m<br>
|
||||
Status: ${selection.state.presenceStateLabel}
|
||||
`;
|
||||
}
|
||||
|
||||
function createView({ id, label, positionKey, alertsEl, indicatorsId }) {
|
||||
const map = L.map(id).setView([55.6761, 12.5683], 13);
|
||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
attribution: "© OpenStreetMap contributors",
|
||||
maxZoom: 19
|
||||
}).addTo(map);
|
||||
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
positionKey,
|
||||
alertsEl,
|
||||
indicatorsEl: document.getElementById(indicatorsId),
|
||||
map,
|
||||
vehicles: {}
|
||||
};
|
||||
}
|
||||
|
||||
function getPresenceState(view, lat, lon, uncertaintyM) {
|
||||
const bounds = view.map.getBounds();
|
||||
if (bounds.contains([lat, lon])) {
|
||||
return { code: "inside", label: "ja" };
|
||||
}
|
||||
if (uncertaintyM > 0) {
|
||||
const distToBounds = distanceToBoundsMeters(lat, lon, bounds);
|
||||
if (distToBounds <= uncertaintyM) {
|
||||
const maxCornerDistance = maxDistanceToBoundsCornerMeters(lat, lon, bounds);
|
||||
if (uncertaintyM >= maxCornerDistance) {
|
||||
return {
|
||||
code: "covers",
|
||||
label: "dækker hele kortet"
|
||||
};
|
||||
return { code: "covers", label: "dækker hele kortet" };
|
||||
}
|
||||
|
||||
return {
|
||||
code: "possible",
|
||||
label: "muligvis"
|
||||
};
|
||||
return { code: "possible", label: "muligvis" };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
code: "outside",
|
||||
label: "nej"
|
||||
};
|
||||
return { code: "outside", label: "nej" };
|
||||
}
|
||||
|
||||
function ensureUncertaintyCircle(v) {
|
||||
if (v.uncertaintyCircle || v.current.uncertaintyM <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
function ensureUncertaintyCircle(view, v) {
|
||||
if (v.uncertaintyCircle || v.current.uncertaintyM <= 0) return;
|
||||
v.uncertaintyCircle = L.circle([v.displayLat, v.displayLon], {
|
||||
radius: v.current.uncertaintyM,
|
||||
color: v.color,
|
||||
|
|
@ -288,32 +231,19 @@ function ensureUncertaintyCircle(v) {
|
|||
fillColor: v.color,
|
||||
fillOpacity: 0.08,
|
||||
interactive: false
|
||||
}).addTo(map);
|
||||
}).addTo(view.map);
|
||||
}
|
||||
|
||||
function syncUncertaintyCircle(v) {
|
||||
if (v.current.uncertaintyM > 0) {
|
||||
ensureUncertaintyCircle(v);
|
||||
}
|
||||
|
||||
if (!v.uncertaintyCircle) {
|
||||
return;
|
||||
}
|
||||
|
||||
function syncUncertaintyCircle(view, v) {
|
||||
if (v.current.uncertaintyM > 0) ensureUncertaintyCircle(view, v);
|
||||
if (!v.uncertaintyCircle) return;
|
||||
if (v.current.uncertaintyM <= 0) {
|
||||
if (map.hasLayer(v.uncertaintyCircle)) {
|
||||
map.removeLayer(v.uncertaintyCircle);
|
||||
}
|
||||
if (view.map.hasLayer(v.uncertaintyCircle)) view.map.removeLayer(v.uncertaintyCircle);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!map.hasLayer(v.uncertaintyCircle)) {
|
||||
v.uncertaintyCircle.addTo(map);
|
||||
}
|
||||
|
||||
if (!view.map.hasLayer(v.uncertaintyCircle)) v.uncertaintyCircle.addTo(view.map);
|
||||
v.uncertaintyCircle.setLatLng([v.displayLat, v.displayLon]);
|
||||
v.uncertaintyCircle.setRadius(v.current.uncertaintyM);
|
||||
|
||||
if (v.presenceState === "inside") {
|
||||
v.uncertaintyCircle.setStyle({ opacity: 0.7, fillOpacity: 0.08 });
|
||||
} else if (v.presenceState === "covers") {
|
||||
|
|
@ -328,62 +258,53 @@ function syncUncertaintyCircle(v) {
|
|||
const vehicleEvents = buildVehicleBuckets(VEHICLE_EVENTS);
|
||||
const vehicleNames = Object.keys(vehicleEvents);
|
||||
|
||||
const vehicles = {};
|
||||
const views = [
|
||||
createView({ id: "mapApprox", label: "Tilnærmet position", positionKey: "approx", alertsEl: alertsApproxEl, indicatorsId: "offmapIndicatorsApprox" }),
|
||||
createView({ id: "mapTrue", label: "Præcis position", positionKey: "true", alertsEl: alertsTrueEl, indicatorsId: "offmapIndicatorsTrue" })
|
||||
];
|
||||
|
||||
for (const name of vehicleNames) {
|
||||
const first = vehicleEvents[name][0];
|
||||
const color = COLORS[name] || "#2979ff";
|
||||
function getEventLatLon(event, positionKey) {
|
||||
if (positionKey === "true") return { lat: event.trueLat, lon: event.trueLon };
|
||||
return { lat: event.approxLat, lon: event.approxLon };
|
||||
}
|
||||
|
||||
const marker = L.marker([first.lat, first.lon], {
|
||||
icon: carIcon(color)
|
||||
}).addTo(map);
|
||||
for (const view of views) {
|
||||
for (const name of vehicleNames) {
|
||||
const first = vehicleEvents[name][0];
|
||||
const color = COLORS[name] || "#2979ff";
|
||||
const firstPos = getEventLatLon(first, view.positionKey);
|
||||
const marker = L.marker([firstPos.lat, firstPos.lon], { icon: carIcon(color) }).addTo(view.map);
|
||||
const trail = L.polyline([[firstPos.lat, firstPos.lon]], { color, weight: 3, opacity: 0.85 }).addTo(view.map);
|
||||
|
||||
const trail = L.polyline([[first.lat, first.lon]], {
|
||||
color,
|
||||
weight: 3,
|
||||
opacity: 0.85
|
||||
}).addTo(map);
|
||||
view.vehicles[name] = {
|
||||
name,
|
||||
color,
|
||||
events: vehicleEvents[name],
|
||||
marker,
|
||||
trail,
|
||||
uncertaintyCircle: null,
|
||||
currentIndex: 0,
|
||||
current: first,
|
||||
next: vehicleEvents[name][1] || null,
|
||||
displayLat: firstPos.lat,
|
||||
displayLon: firstPos.lon,
|
||||
displayHeading: first.headingDeg,
|
||||
presenceState: "inside",
|
||||
presenceStateLabel: "ja"
|
||||
};
|
||||
|
||||
let uncertaintyCircle = null;
|
||||
if (first.uncertaintyM > 0) {
|
||||
uncertaintyCircle = L.circle([first.lat, first.lon], {
|
||||
radius: first.uncertaintyM,
|
||||
color: color,
|
||||
weight: 2,
|
||||
dashArray: "6,6",
|
||||
opacity: 0.7,
|
||||
fillColor: color,
|
||||
fillOpacity: 0.08,
|
||||
interactive: false
|
||||
}).addTo(map);
|
||||
marker.bindPopup(popupHtml(view, name, view.vehicles[name]));
|
||||
marker.on("click", () => {
|
||||
renderSelectedVehicleInfo({
|
||||
name,
|
||||
viewLabel: view.label,
|
||||
source: "kort",
|
||||
state: view.vehicles[name]
|
||||
});
|
||||
});
|
||||
setHeading(marker, first.headingDeg);
|
||||
syncUncertaintyCircle(view, view.vehicles[name]);
|
||||
}
|
||||
|
||||
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]));
|
||||
marker.on("click", () => {
|
||||
forcedPopupVehicle = name;
|
||||
highlightOffmapIndicator(null);
|
||||
renderSelectedVehicleInfo(vehicles[name], "kort");
|
||||
});
|
||||
setHeading(marker, first.headingDeg);
|
||||
}
|
||||
|
||||
let simTime = 0;
|
||||
|
|
@ -391,201 +312,158 @@ let lastFrameTime = null;
|
|||
let running = true;
|
||||
const TIME_SCALE = 0.175;
|
||||
|
||||
function resetDemo() {
|
||||
simTime = 0;
|
||||
lastFrameTime = null;
|
||||
forcedPopupVehicle = null;
|
||||
highlightOffmapIndicator(null);
|
||||
renderSelectedVehicleInfo(null);
|
||||
|
||||
for (const name of vehicleNames) {
|
||||
const v = vehicles[name];
|
||||
const first = v.events[0];
|
||||
|
||||
v.currentIndex = 0;
|
||||
v.current = first;
|
||||
v.next = v.events[1] || null;
|
||||
v.displayLat = first.lat;
|
||||
v.displayLon = first.lon;
|
||||
v.displayHeading = first.headingDeg;
|
||||
v.lastTrailLat = first.lat;
|
||||
v.lastTrailLon = first.lon;
|
||||
v.presenceState = "inside";
|
||||
v.presenceStateLabel = "ja";
|
||||
|
||||
v.marker.setLatLng([first.lat, first.lon]);
|
||||
setHeading(v.marker, first.headingDeg);
|
||||
v.marker.setPopupContent(popupHtml(name, v));
|
||||
|
||||
v.trail.setLatLngs([[first.lat, first.lon]]);
|
||||
v.trail.setStyle({ opacity: 0.85 });
|
||||
|
||||
syncUncertaintyCircle(v);
|
||||
|
||||
if (!map.hasLayer(v.marker)) {
|
||||
v.marker.addTo(map);
|
||||
}
|
||||
}
|
||||
|
||||
updateStatus();
|
||||
}
|
||||
|
||||
function advanceVehiclePointers(v) {
|
||||
while (v.next && simTime >= v.next.ts) {
|
||||
v.currentIndex += 1;
|
||||
v.current = v.events[v.currentIndex];
|
||||
v.next = v.events[v.currentIndex + 1] || null;
|
||||
|
||||
v.trail.addLatLng([v.current.lat, v.current.lon]);
|
||||
v.lastTrailLat = v.current.lat;
|
||||
v.lastTrailLon = v.current.lon;
|
||||
}
|
||||
}
|
||||
|
||||
function updateVehicleDisplay(v) {
|
||||
function updateVehicleDisplay(view, v) {
|
||||
advanceVehiclePointers(v);
|
||||
|
||||
let lat = v.current.lat;
|
||||
let lon = v.current.lon;
|
||||
let latLon = getEventLatLon(v.current, view.positionKey);
|
||||
let lat = latLon.lat;
|
||||
let lon = latLon.lon;
|
||||
let heading = v.current.headingDeg;
|
||||
|
||||
if (v.next) {
|
||||
const dt = v.next.ts - v.current.ts;
|
||||
const raw = dt > 0 ? (simTime - v.current.ts) / dt : 1;
|
||||
const t = smoothstep(raw);
|
||||
|
||||
lat = lerp(v.current.lat, v.next.lat, t);
|
||||
lon = lerp(v.current.lon, v.next.lon, t);
|
||||
const nextLatLon = getEventLatLon(v.next, view.positionKey);
|
||||
lat = lerp(latLon.lat, nextLatLon.lat, t);
|
||||
lon = lerp(latLon.lon, nextLatLon.lon, t);
|
||||
heading = lerpAngleDeg(v.current.headingDeg, v.next.headingDeg, t);
|
||||
} 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;
|
||||
v.presenceState = getPresenceState(view, lat, lon, v.current.uncertaintyM).code;
|
||||
v.presenceStateLabel = getPresenceState(view, lat, lon, v.current.uncertaintyM).label;
|
||||
|
||||
const presence = getPresenceState(lat, lon, v.current.uncertaintyM);
|
||||
v.presenceState = presence.code;
|
||||
v.presenceStateLabel = presence.label;
|
||||
|
||||
const shouldForcePopup = forcedPopupVehicle === v.name;
|
||||
|
||||
if (presence.code === "inside" || shouldForcePopup) {
|
||||
if (!map.hasLayer(v.marker)) {
|
||||
v.marker.addTo(map);
|
||||
}
|
||||
v.marker.setOpacity(1);
|
||||
v.marker.setZIndexOffset(shouldForcePopup ? 1000 : 0);
|
||||
if (v.presenceState === "inside") {
|
||||
if (!view.map.hasLayer(v.marker)) v.marker.addTo(view.map);
|
||||
v.marker.setLatLng([lat, lon]);
|
||||
setHeading(v.marker, heading);
|
||||
v.trail.setStyle({ opacity: 0.85 });
|
||||
} else {
|
||||
v.marker.closePopup();
|
||||
v.marker.setZIndexOffset(0);
|
||||
if (map.hasLayer(v.marker)) {
|
||||
map.removeLayer(v.marker);
|
||||
}
|
||||
v.trail.setStyle({ opacity: presence.code === "covers" ? 0.4 : 0.25 });
|
||||
if (view.map.hasLayer(v.marker)) view.map.removeLayer(v.marker);
|
||||
v.trail.setStyle({ opacity: v.presenceState === "covers" ? 0.4 : 0.25 });
|
||||
}
|
||||
|
||||
v.marker.setPopupContent(popupHtml(v.name, v));
|
||||
const trailPoints = v.events
|
||||
.slice(0, v.currentIndex + 1)
|
||||
.map(event => getEventLatLon(event, view.positionKey))
|
||||
.map(pos => [pos.lat, pos.lon]);
|
||||
if (trailPoints.length > 0) {
|
||||
v.trail.setLatLngs(trailPoints);
|
||||
}
|
||||
|
||||
syncUncertaintyCircle(v);
|
||||
v.marker.setPopupContent(popupHtml(view, v.name, v));
|
||||
syncUncertaintyCircle(view, v);
|
||||
}
|
||||
|
||||
function updateAlerts() {
|
||||
const alertVehicles = vehicleNames
|
||||
.map(name => vehicles[name])
|
||||
.filter(v => v.presenceState === "possible" || v.presenceState === "covers");
|
||||
|
||||
function updateViewAlerts(view) {
|
||||
const alertVehicles = vehicleNames.map(name => view.vehicles[name]).filter(v => v.presenceState === "possible" || v.presenceState === "covers");
|
||||
if (alertVehicles.length === 0) {
|
||||
alertsEl.innerHTML = "";
|
||||
offmapIndicatorsEl.innerHTML = "";
|
||||
view.alertsEl.innerHTML = "";
|
||||
view.indicatorsEl.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
alertsEl.innerHTML = alertVehicles.map(v => {
|
||||
if (v.presenceState === "covers") {
|
||||
return `
|
||||
<div class="alert-item">
|
||||
<strong>${v.name}: usikkerhed dækker hele kortet</strong>
|
||||
Estimatet er udenfor kortet, men usikkerheden spænder over hele viewporten.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
view.alertsEl.innerHTML = alertVehicles.map(v => `
|
||||
<div class="alert-item">
|
||||
<strong>${v.name}: ${v.presenceState === "covers" ? "usikkerhed dækker hele kortet" : "mulig tilstedeværelse"}</strong>
|
||||
${v.presenceState === "covers" ? "Estimatet er udenfor kortet, men usikkerheden spænder over hele viewporten." : "Estimatet er udenfor kortet, men usikkerheden overlapper det viste område delvist."}
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
return `
|
||||
<div class="alert-item">
|
||||
<strong>${v.name}: mulig tilstedeværelse</strong>
|
||||
Estimatet er udenfor kortet, men usikkerheden overlapper det viste område delvist.
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
const bounds = map.getBounds();
|
||||
offmapIndicatorsEl.innerHTML = alertVehicles.map(v => {
|
||||
const bounds = view.map.getBounds();
|
||||
view.indicatorsEl.innerHTML = alertVehicles.map(v => {
|
||||
const direction = getDirectionInfo(v.displayLat, v.displayLon, bounds);
|
||||
const subtitle = v.presenceState === "covers"
|
||||
? "Usikkerheden dækker hele viewporten"
|
||||
: `Mulig tilstedeværelse fra ${direction.label}`;
|
||||
|
||||
const subtitle = v.presenceState === "covers" ? "Usikkerheden dækker hele viewporten" : `Mulig tilstedeværelse fra ${direction.label}`;
|
||||
return `
|
||||
<button type="button" class="offmap-indicator ${v.presenceState === "covers" ? "covers" : ""}" data-vehicle="${v.name}">
|
||||
<button type="button" class="offmap-indicator ${v.presenceState === "covers" ? "covers" : ""}" data-view="${view.id}" data-vehicle="${v.name}">
|
||||
<div class="offmap-arrow" style="background:${v.color};">${direction.arrow}</div>
|
||||
<div class="offmap-meta">
|
||||
<strong>${v.name}</strong>
|
||||
<span>${subtitle}</span>
|
||||
</div>
|
||||
<div><strong>${v.name}</strong><span>${subtitle}</span></div>
|
||||
</button>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function resetDemo() {
|
||||
simTime = 0;
|
||||
lastFrameTime = null;
|
||||
renderSelectedVehicleInfo(null);
|
||||
for (const view of views) {
|
||||
for (const name of vehicleNames) {
|
||||
const v = view.vehicles[name];
|
||||
const first = v.events[0];
|
||||
const firstPos = getEventLatLon(first, view.positionKey);
|
||||
v.currentIndex = 0;
|
||||
v.current = first;
|
||||
v.next = v.events[1] || null;
|
||||
v.displayLat = firstPos.lat;
|
||||
v.displayLon = firstPos.lon;
|
||||
v.displayHeading = first.headingDeg;
|
||||
v.presenceState = "inside";
|
||||
v.presenceStateLabel = "ja";
|
||||
v.marker.setLatLng([firstPos.lat, firstPos.lon]);
|
||||
setHeading(v.marker, first.headingDeg);
|
||||
v.trail.setLatLngs([[firstPos.lat, firstPos.lon]]);
|
||||
if (!view.map.hasLayer(v.marker)) v.marker.addTo(view.map);
|
||||
syncUncertaintyCircle(view, v);
|
||||
}
|
||||
updateViewAlerts(view);
|
||||
}
|
||||
updateStatus();
|
||||
}
|
||||
|
||||
function updateStatus() {
|
||||
const total = VEHICLE_EVENTS.length;
|
||||
const passed = VEHICLE_EVENTS.filter(e => e.ts <= simTime).length;
|
||||
|
||||
statusEl.textContent =
|
||||
`Simuleret tid: ${simTime.toFixed(1)} s\n` +
|
||||
`Events passeret: ${passed} / ${total}\n` +
|
||||
`Afspilning: 2x`;
|
||||
|
||||
updateAlerts();
|
||||
statusEl.textContent = `Simuleret tid: ${simTime.toFixed(1)} s\nEvents passeret: ${passed} / ${total}\nAfspilning: 2x`;
|
||||
}
|
||||
|
||||
function tick(now) {
|
||||
if (!running) return;
|
||||
|
||||
if (lastFrameTime === null) {
|
||||
lastFrameTime = now;
|
||||
}
|
||||
|
||||
if (lastFrameTime === null) lastFrameTime = now;
|
||||
const dtReal = (now - lastFrameTime) / 1000;
|
||||
lastFrameTime = now;
|
||||
simTime += dtReal / TIME_SCALE;
|
||||
|
||||
for (const name of vehicleNames) {
|
||||
updateVehicleDisplay(vehicles[name]);
|
||||
for (const view of views) {
|
||||
for (const name of vehicleNames) {
|
||||
updateVehicleDisplay(view, view.vehicles[name]);
|
||||
}
|
||||
updateViewAlerts(view);
|
||||
}
|
||||
|
||||
updateStatus();
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
for (const view of views) {
|
||||
view.indicatorsEl.addEventListener("click", (event) => {
|
||||
const button = event.target.closest(".offmap-indicator");
|
||||
if (!button) return;
|
||||
const v = view.vehicles[button.dataset.vehicle];
|
||||
renderSelectedVehicleInfo({
|
||||
name: v.name,
|
||||
viewLabel: view.label,
|
||||
source: "hjørneindikator",
|
||||
state: v
|
||||
});
|
||||
});
|
||||
|
||||
view.map.on("moveend zoomend", () => {
|
||||
for (const name of vehicleNames) {
|
||||
updateVehicleDisplay(view, view.vehicles[name]);
|
||||
}
|
||||
updateViewAlerts(view);
|
||||
});
|
||||
}
|
||||
|
||||
playBtn.addEventListener("click", () => {
|
||||
if (running) return;
|
||||
running = true;
|
||||
|
|
@ -604,30 +482,5 @@ resetBtn.addEventListener("click", () => {
|
|||
requestAnimationFrame(tick);
|
||||
});
|
||||
|
||||
offmapIndicatorsEl.addEventListener("click", (event) => {
|
||||
const button = event.target.closest(".offmap-indicator");
|
||||
if (!button) return;
|
||||
|
||||
const vehicleName = button.dataset.vehicle;
|
||||
openVehiclePopup(vehicleName);
|
||||
});
|
||||
|
||||
map.on("popupclose", (event) => {
|
||||
const popupVehicle = Object.values(vehicles).find(v => v.marker === event.popup._source);
|
||||
if (popupVehicle && forcedPopupVehicle === popupVehicle.name) {
|
||||
forcedPopupVehicle = null;
|
||||
highlightOffmapIndicator(null);
|
||||
renderSelectedVehicleInfo(null);
|
||||
updateVehicleDisplay(popupVehicle);
|
||||
}
|
||||
});
|
||||
|
||||
map.on("moveend zoomend", () => {
|
||||
for (const name of vehicleNames) {
|
||||
updateVehicleDisplay(vehicles[name]);
|
||||
}
|
||||
updateStatus();
|
||||
});
|
||||
|
||||
resetDemo();
|
||||
requestAnimationFrame(tick);
|
||||
|
|
|
|||
225
kort7/kort7.html
225
kort7/kort7.html
|
|
@ -12,82 +12,72 @@
|
|||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
#map {
|
||||
height: 100%;
|
||||
body {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.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);
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255,255,255,0.96);
|
||||
border-bottom: 1px solid #d1d5db;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.hud button {
|
||||
margin-right: 8px;
|
||||
padding: 6px 10px;
|
||||
.toolbar button {
|
||||
padding: 7px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hud .status {
|
||||
margin-top: 8px;
|
||||
.toolbar .status {
|
||||
margin-left: 6px;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.hud .selection {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(0,0,0,0.08);
|
||||
max-width: 320px;
|
||||
font: 12px/1.4 system-ui, sans-serif;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.hud .selection strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 320px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.map-panel {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
border-right: 1px solid #d1d5db;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.map-title {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 1000;
|
||||
background: rgba(255,255,255,0.94);
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.hud .alerts {
|
||||
margin-top: 8px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
max-width: 320px;
|
||||
.map-title strong {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
.runtime-warning {
|
||||
margin-top: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #c2410c;
|
||||
background: rgba(255, 237, 213, 0.96);
|
||||
color: #7c2d12;
|
||||
font: 12px/1.35 system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.runtime-warning[hidden] {
|
||||
display: none;
|
||||
.map {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.offmap-indicators {
|
||||
|
|
@ -97,7 +87,7 @@
|
|||
z-index: 1000;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-width: 260px;
|
||||
max-width: 240px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
|
@ -119,15 +109,9 @@
|
|||
text-align: left;
|
||||
}
|
||||
|
||||
.offmap-indicator:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,0.28);
|
||||
}
|
||||
|
||||
.offmap-indicator.active {
|
||||
outline: 2px solid rgba(255,255,255,0.9);
|
||||
outline-offset: 2px;
|
||||
transform: translateY(-1px) scale(1.02);
|
||||
}
|
||||
|
||||
.offmap-indicator.covers {
|
||||
|
|
@ -144,34 +128,63 @@
|
|||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
box-shadow: inset 0 0 0 2px rgba(255,255,255,0.18);
|
||||
}
|
||||
|
||||
.offmap-meta strong {
|
||||
.sidebar {
|
||||
background: rgba(255,255,255,0.97);
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 12px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.panel strong {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
margin-bottom: 6px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.offmap-meta span {
|
||||
display: block;
|
||||
opacity: 0.82;
|
||||
.alerts {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.flash-marker {
|
||||
animation: flash-marker-pulse 1.2s ease-out 1;
|
||||
.alert-item {
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e0b100;
|
||||
background: rgba(255,248,204,0.96);
|
||||
color: #5f4b00;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
@keyframes flash-marker-pulse {
|
||||
0% { transform: scale(1); filter: drop-shadow(0 1px 1px rgba(0,0,0,.35)); }
|
||||
20% { transform: scale(1.8); filter: drop-shadow(0 0 0 rgba(0,0,0,0)); }
|
||||
100% { transform: scale(1); filter: drop-shadow(0 1px 1px rgba(0,0,0,.35)); }
|
||||
.runtime-warning {
|
||||
border: 1px solid #c2410c;
|
||||
background: rgba(255, 237, 213, 0.96);
|
||||
color: #7c2d12;
|
||||
}
|
||||
|
||||
.runtime-warning[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.car {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
transform-origin: 12px 12px;
|
||||
will-change: transform;
|
||||
filter: drop-shadow(0 1px 1px rgba(0,0,0,.35));
|
||||
}
|
||||
|
||||
|
|
@ -183,23 +196,47 @@
|
|||
</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="toolbar">
|
||||
<button id="playBtn">Play</button>
|
||||
<button id="pauseBtn">Pause</button>
|
||||
<button id="resetBtn">Reset</button>
|
||||
<div class="status" id="status"></div>
|
||||
<div class="selection" id="selectedVehicleInfo">Klik på et køretøj eller en hjørneindikator for detaljer.</div>
|
||||
<div class="runtime-warning" id="runtimeWarning" hidden>
|
||||
Du kører appen via <code>file://</code>. Brug en lokal webserver i stedet, fx <code>python3 -m http.server 4173</code>, og åbn <code>http://127.0.0.1:4173/kort7.html</code>.
|
||||
</div>
|
||||
<div class="alerts" id="presenceAlerts"></div>
|
||||
</div>
|
||||
|
||||
<div class="offmap-indicators" id="offmapIndicators"></div>
|
||||
<div class="layout">
|
||||
<section class="map-panel">
|
||||
<div class="map-title">
|
||||
<strong>Tilnærmet position</strong>
|
||||
Viser bevidst usikker og pædagogisk forskudt tracking.
|
||||
</div>
|
||||
<div id="mapApprox" class="map"></div>
|
||||
<div class="offmap-indicators" id="offmapIndicatorsApprox"></div>
|
||||
</section>
|
||||
|
||||
<section class="map-panel">
|
||||
<div class="map-title">
|
||||
<strong>Præcis position</strong>
|
||||
Viser den reelle position for samme køretøjer og tidslinje.
|
||||
</div>
|
||||
<div id="mapTrue" class="map"></div>
|
||||
<div class="offmap-indicators" id="offmapIndicatorsTrue"></div>
|
||||
</section>
|
||||
|
||||
<aside class="sidebar">
|
||||
<div class="panel" id="selectedVehicleInfo">Klik på et køretøj eller en hjørneindikator for detaljer.</div>
|
||||
<div class="panel runtime-warning" id="runtimeWarning" hidden>
|
||||
Du kører appen via <code>file://</code>. Brug en lokal webserver i stedet, fx <code>python3 -m http.server 4173</code>, og åbn <code>http://127.0.0.1:4173/kort7.html</code>.
|
||||
</div>
|
||||
<div class="panel">
|
||||
<strong>Mulige off-map køretøjer, tilnærmet kort</strong>
|
||||
<div class="alerts" id="presenceAlertsApprox"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<strong>Mulige off-map køretøjer, præcist kort</strong>
|
||||
<div class="alerts" id="presenceAlertsTrue"></div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<script src="data.js"></script>
|
||||
<script src="app.js"></script>
|
||||
|
|
|
|||
|
|
@ -1,109 +1,52 @@
|
|||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
async function waitForVehicleMarkers(page) {
|
||||
await page.waitForSelector('.leaflet-marker-icon', { state: 'attached' });
|
||||
async function waitForMaps(page) {
|
||||
await expect(page.locator('#mapApprox')).toBeVisible();
|
||||
await expect(page.locator('#mapTrue')).toBeVisible();
|
||||
await expect.poll(async () => page.locator('.leaflet-marker-icon').count(), {
|
||||
timeout: 10000
|
||||
}).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
test.describe('kort7 vehicle map', () => {
|
||||
test('loads map and renders vehicles', async ({ page }) => {
|
||||
test.describe('kort7 dual map demo', () => {
|
||||
test('loads both maps and renders vehicles', async ({ page }) => {
|
||||
await page.goto('/kort7.html');
|
||||
await expect(page.locator('#map')).toBeVisible();
|
||||
await waitForVehicleMarkers(page);
|
||||
await waitForMaps(page);
|
||||
await expect(page.locator('#status')).toContainText('Simuleret tid');
|
||||
await expect(page.locator('#runtimeWarning')).toBeHidden();
|
||||
});
|
||||
|
||||
test('shows vehicle popup with details when clicking a marker', async ({ page }) => {
|
||||
test('shows different map titles for approximate and precise views', async ({ page }) => {
|
||||
await page.goto('/kort7.html');
|
||||
await waitForVehicleMarkers(page);
|
||||
await page.getByRole('button', { name: 'Pause' }).click();
|
||||
|
||||
const firstMarker = page.locator('.leaflet-marker-icon').first();
|
||||
await firstMarker.click({ force: true });
|
||||
|
||||
const popup = page.locator('.leaflet-popup');
|
||||
await expect(popup).toBeVisible();
|
||||
await expect(popup).toContainText(/Bil \d/);
|
||||
await expect(popup).toContainText('Tid:');
|
||||
await expect(popup).toContainText('Lat:');
|
||||
await expect(popup).toContainText('Lon:');
|
||||
await expect(popup).toContainText('Hastighed:');
|
||||
await expect(popup).toContainText('Retning:');
|
||||
await expect(popup).toContainText('Usikkerhed:');
|
||||
await expect(popup).toContainText('På kortet:');
|
||||
await expect(page.locator('.map-title').first()).toContainText('Tilnærmet position');
|
||||
await expect(page.locator('.map-title').nth(1)).toContainText('Præcis position');
|
||||
});
|
||||
|
||||
test('renders uncertainty circles for vehicles with uncertainty', async ({ page }) => {
|
||||
await page.goto('/kort7.html');
|
||||
await waitForVehicleMarkers(page);
|
||||
|
||||
await expect.poll(async () => {
|
||||
return await page.locator('path').evaluateAll((nodes) =>
|
||||
nodes.filter((node) => {
|
||||
const strokeDasharray = node.getAttribute('stroke-dasharray');
|
||||
return strokeDasharray && strokeDasharray.length > 0;
|
||||
}).length
|
||||
);
|
||||
}).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('playback updates simulation status over time', async ({ page }) => {
|
||||
await page.goto('/kort7.html');
|
||||
|
||||
const status = page.locator('#status');
|
||||
const initialText = await status.textContent();
|
||||
const initialMatch = initialText.match(/Simuleret tid: ([\d.]+) s/);
|
||||
expect(initialMatch).not.toBeNull();
|
||||
const initialTime = Number(initialMatch[1]);
|
||||
|
||||
await page.waitForTimeout(1800);
|
||||
|
||||
const text = await status.textContent();
|
||||
const match = text.match(/Simuleret tid: ([\d.]+) s/);
|
||||
expect(match).not.toBeNull();
|
||||
expect(Number(match[1])).toBeGreaterThan(initialTime + 0.5);
|
||||
});
|
||||
|
||||
test('shows off-map corner indicators for partial overlap and full cover', async ({ page }) => {
|
||||
await page.goto('/kort7.html');
|
||||
|
||||
await expect.poll(async () => page.locator('.offmap-indicator').count(), {
|
||||
timeout: 10000
|
||||
}).toBeGreaterThan(0);
|
||||
|
||||
const bil9 = page.locator('.offmap-indicator').filter({ hasText: 'Bil 9' });
|
||||
const bil10 = page.locator('.offmap-indicator').filter({ hasText: 'Bil 10' });
|
||||
const bil8 = page.locator('.offmap-indicator').filter({ hasText: 'Bil 8' });
|
||||
|
||||
await expect(bil9).toContainText('Mulig tilstedeværelse');
|
||||
await expect(bil10).toContainText('dækker hele viewporten');
|
||||
await expect(bil8).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('shows matching alert text for off-map overlap states', async ({ page }) => {
|
||||
await page.goto('/kort7.html');
|
||||
|
||||
const alerts = page.locator('#presenceAlerts');
|
||||
await expect(alerts).toContainText('Bil 9: mulig tilstedeværelse');
|
||||
await expect(alerts).toContainText('Bil 10: usikkerhed dækker hele kortet');
|
||||
await expect(alerts).not.toContainText('Bil 8');
|
||||
});
|
||||
|
||||
test('updates left info panel when clicking an off-map corner indicator', async ({ page }) => {
|
||||
test('clicking off-map indicator updates detail panel', async ({ page }) => {
|
||||
await page.goto('/kort7.html');
|
||||
await page.getByRole('button', { name: 'Pause' }).click();
|
||||
|
||||
const indicator = page.locator('.offmap-indicator').filter({ hasText: 'Bil 9' });
|
||||
const indicator = page.locator('#offmapIndicatorsApprox .offmap-indicator').filter({ hasText: 'Bil 9' });
|
||||
await expect(indicator).toBeVisible();
|
||||
await indicator.click();
|
||||
|
||||
const selection = page.locator('#selectedVehicleInfo');
|
||||
await expect(selection).toContainText('Bil 9');
|
||||
await expect(selection).toContainText('valgt fra hjørneindikator');
|
||||
await expect(selection).toContainText('Usikkerhed: 9000 m');
|
||||
await expect(indicator).toHaveClass(/active/);
|
||||
await expect(selection).toContainText('Tilnærmet position');
|
||||
await expect(selection).toContainText('hjørneindikator');
|
||||
});
|
||||
|
||||
test('playback updates simulation status over time', async ({ page }) => {
|
||||
await page.goto('/kort7.html');
|
||||
const status = page.locator('#status');
|
||||
const initialText = await status.textContent();
|
||||
const initialMatch = initialText.match(/Simuleret tid: ([\d.]+) s/);
|
||||
expect(initialMatch).not.toBeNull();
|
||||
const initialTime = Number(initialMatch[1]);
|
||||
await page.waitForTimeout(1800);
|
||||
const text = await status.textContent();
|
||||
const match = text.match(/Simuleret tid: ([\d.]+) s/);
|
||||
expect(match).not.toBeNull();
|
||||
expect(Number(match[1])).toBeGreaterThan(initialTime + 0.5);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue