diff --git a/kort7/app.js b/kort7/app.js
index 42b6a02..3fb50f1 100644
--- a/kort7/app.js
+++ b/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 = `
- ${v.name} · valgt fra ${sourceLabel}
- Tid: ${fmtTs(v.current.ts)}
- Position: ${v.displayLat.toFixed(5)}, ${v.displayLon.toFixed(5)}
- Hastighed: ${v.current.speedMps} m/s
- Retning: ${v.displayHeading.toFixed(0)}°
- Usikkerhed: ${v.current.uncertaintyM} m
- 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: `
-
- `
- });
-}
-
-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 `
-
-
${name}
-
Tid: ${fmtTs(state.current.ts)}
-
Lat: ${state.displayLat.toFixed(5)}
-
Lon: ${state.displayLon.toFixed(5)}
-
Hastighed: ${state.current.speedMps} m/s
-
Retning: ${state.displayHeading.toFixed(0)}°
-
Usikkerhed: ${state.current.uncertaintyM} m
-
På kortet: ${state.presenceStateLabel || "ja"}
-
- `;
-}
-
function smoothstep(t) {
t = Math.max(0, Math.min(1, t));
return t * t * (3 - 2 * t);
@@ -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: `
+
+ `
+ });
+}
+
+function setHeading(marker, deg) {
+ const el = marker.getElement();
+ if (!el) return;
+ const car = el.querySelector("[data-role=car]");
+ if (!car) return;
+ car.style.transform = `rotate(${deg}deg)`;
+}
+
+function popupHtml(view, name, state) {
+ return `
+
+
${name} · ${view.label}
+
Tid: ${fmtTs(state.current.ts)}
+
Lat: ${state.displayLat.toFixed(5)}
+
Lon: ${state.displayLon.toFixed(5)}
+
Hastighed: ${state.current.speedMps} m/s
+
Retning: ${state.displayHeading.toFixed(0)}°
+
Usikkerhed: ${state.current.uncertaintyM} m
+
På kortet: ${state.presenceStateLabel || "ja"}
+
+ `;
+}
+
+function renderSelectedVehicleInfo(selection) {
+ if (!selection) {
+ selectedVehicleInfoEl.textContent = "Klik på et køretøj eller en hjørneindikator for detaljer.";
+ return;
}
+ selectedVehicleInfoEl.innerHTML = `
+ ${selection.name} · ${selection.viewLabel}
+ Kilde: ${selection.source}
+ Tid: ${fmtTs(selection.state.current.ts)}
+ Position: ${selection.state.displayLat.toFixed(5)}, ${selection.state.displayLon.toFixed(5)}
+ Hastighed: ${selection.state.current.speedMps} m/s
+ Retning: ${selection.state.displayHeading.toFixed(0)}°
+ Usikkerhed: ${selection.state.current.uncertaintyM} m
+ Status: ${selection.state.presenceStateLabel}
+ `;
+}
+
+function createView({ id, label, positionKey, alertsEl, indicatorsId }) {
+ const map = L.map(id).setView([55.6761, 12.5683], 13);
+ L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
+ attribution: "© OpenStreetMap contributors",
+ maxZoom: 19
+ }).addTo(map);
+
+ return {
+ id,
+ label,
+ positionKey,
+ alertsEl,
+ indicatorsEl: document.getElementById(indicatorsId),
+ map,
+ vehicles: {}
+ };
+}
+
+function getPresenceState(view, lat, lon, uncertaintyM) {
+ const bounds = view.map.getBounds();
+ if (bounds.contains([lat, lon])) {
+ return { code: "inside", label: "ja" };
+ }
if (uncertaintyM > 0) {
const distToBounds = distanceToBoundsMeters(lat, lon, bounds);
if (distToBounds <= uncertaintyM) {
const maxCornerDistance = maxDistanceToBoundsCornerMeters(lat, lon, bounds);
if (uncertaintyM >= maxCornerDistance) {
- return {
- code: "covers",
- label: "dækker hele kortet"
- };
+ return { code: "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 `
-
- ${v.name}: usikkerhed dækker hele kortet
- Estimatet er udenfor kortet, men usikkerheden spænder over hele viewporten.
-
- `;
- }
+ view.alertsEl.innerHTML = alertVehicles.map(v => `
+
+ ${v.name}: ${v.presenceState === "covers" ? "usikkerhed dækker hele kortet" : "mulig tilstedeværelse"}
+ ${v.presenceState === "covers" ? "Estimatet er udenfor kortet, men usikkerheden spænder over hele viewporten." : "Estimatet er udenfor kortet, men usikkerheden overlapper det viste område delvist."}
+
+ `).join("");
- return `
-
- ${v.name}: mulig tilstedeværelse
- Estimatet er udenfor kortet, men usikkerheden overlapper det viste område delvist.
-
- `;
- }).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 `
-
+
${direction.arrow}
-
- ${v.name}
- ${subtitle}
-
+ ${v.name} ${subtitle}
`;
}).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);
diff --git a/kort7/kort7.html b/kort7/kort7.html
index 3bbd2ea..c18817c 100644
--- a/kort7/kort7.html
+++ b/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 @@
-
-
-
-
- Play
- Pause
- Reset
-
+
-
+
+
+
+ Tilnærmet position
+ Viser bevidst usikker og pædagogisk forskudt tracking.
+
+
+
+
+
+
+
+ Præcis position
+ Viser den reelle position for samme køretøjer og tidslinje.
+
+
+
+
+
+
+
diff --git a/kort7/tests/kort7.spec.js b/kort7/tests/kort7.spec.js
index 0d9581b..f716b89 100644
--- a/kort7/tests/kort7.spec.js
+++ b/kort7/tests/kort7.spec.js
@@ -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);
});
});