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 ` - `; }).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 @@ -
- -
-
- - - -
+
+ + +
-
Klik på et køretøj eller en hjørneindikator for detaljer.
- -
-
+
+
+
+ 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); }); });