kort/kort7/app.js

506 lines
16 KiB
JavaScript

const COLORS = {
"Bil 1": "#ff0000",
"Bil 2": "#00c853",
"Bil 3": "#2979ff",
"Bil 4": "#2979ff",
"Bil 5": "#2979ff",
"Bil 8": "#00c853",
"Bil 9": "#00c853",
"Bil 10": "#00c853"
};
const statusEl = document.getElementById("status");
const selectedVehicleInfoEl = document.getElementById("selectedVehicleInfo");
const runtimeWarningEl = document.getElementById("runtimeWarning");
const tileWarningEl = document.getElementById("tileWarning");
const debugPanelEl = document.getElementById("debugPanel");
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");
if (window.location.protocol === "file:") {
runtimeWarningEl.hidden = false;
}
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 fmtTs(ts) {
return `${ts}s`;
}
function smoothstep(t) {
t = Math.max(0, Math.min(1, t));
return t * t * (3 - 2 * t);
}
function lerp(a, b, t) {
return a + (b - a) * t;
}
function lerpAngleDeg(a, b, t) {
let d = ((b - a + 540) % 360) - 180;
return (a + d * t + 360) % 360;
}
function metersBetween(lat1, lon1, lat2, lon2) {
const meanLatRad = ((lat1 + lat2) / 2) * Math.PI / 180;
const metersPerDegLat = 111320;
const metersPerDegLon = 111320 * Math.cos(meanLatRad);
const dLatM = (lat2 - lat1) * metersPerDegLat;
const dLonM = (lon2 - lon1) * metersPerDegLon;
return Math.hypot(dLatM, dLonM);
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function distanceToBoundsMeters(lat, lon, bounds) {
const clampedLat = clamp(lat, bounds.getSouth(), bounds.getNorth());
const clampedLon = clamp(lon, bounds.getWest(), bounds.getEast());
return metersBetween(lat, lon, clampedLat, clampedLon);
}
function maxDistanceToBoundsCornerMeters(lat, lon, bounds) {
const corners = [
[bounds.getSouth(), bounds.getWest()],
[bounds.getSouth(), bounds.getEast()],
[bounds.getNorth(), bounds.getWest()],
[bounds.getNorth(), bounds.getEast()]
];
return Math.max(...corners.map(([cornerLat, cornerLon]) =>
metersBetween(lat, lon, cornerLat, cornerLon)
));
}
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 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);
let tileErrors = 0;
let tileLoads = 0;
const tileLayer = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "&copy; OpenStreetMap contributors",
maxZoom: 19
}).addTo(map);
tileLayer.on("tileerror", () => {
tileErrors += 1;
tileWarningEl.hidden = false;
});
tileLayer.on("tileload", () => {
tileLoads += 1;
});
tileLayer.on("load", () => {
if (tileLoads > 0) {
tileWarningEl.hidden = true;
}
});
return {
id,
label,
positionKey,
alertsEl,
indicatorsEl: document.getElementById(indicatorsId),
map,
tileLayer,
getTileStats: () => ({ tileErrors, tileLoads }),
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: "possible", label: "muligvis" };
}
}
return { code: "outside", label: "nej" };
}
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,
weight: 2,
dashArray: "6,6",
opacity: 0.7,
fillColor: v.color,
fillOpacity: 0.08,
interactive: false
}).addTo(view.map);
}
function syncUncertaintyCircle(view, v) {
if (v.current.uncertaintyM > 0) ensureUncertaintyCircle(view, v);
if (!v.uncertaintyCircle) return;
if (v.current.uncertaintyM <= 0) {
if (view.map.hasLayer(v.uncertaintyCircle)) view.map.removeLayer(v.uncertaintyCircle);
return;
}
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") {
v.uncertaintyCircle.setStyle({ opacity: 0.28, fillOpacity: 0.05 });
} else if (v.presenceState === "possible") {
v.uncertaintyCircle.setStyle({ opacity: 0.35, fillOpacity: 0.03 });
} else {
v.uncertaintyCircle.setStyle({ opacity: 0.15, fillOpacity: 0.01 });
}
}
const vehicleEvents = buildVehicleBuckets(VEHICLE_EVENTS);
const vehicleNames = Object.keys(vehicleEvents);
const 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" })
];
function getEventLatLon(event, positionKey) {
if (positionKey === "true") return { lat: event.trueLat, lon: event.trueLon };
return { lat: event.approxLat, lon: event.approxLon };
}
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);
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"
};
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]);
}
}
let simTime = 0;
let lastFrameTime = null;
let running = true;
const TIME_SCALE = 0.175;
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;
}
}
function updateVehicleDisplay(view, v) {
advanceVehiclePointers(v);
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);
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);
}
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;
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 {
if (view.map.hasLayer(v.marker)) view.map.removeLayer(v.marker);
v.trail.setStyle({ opacity: v.presenceState === "covers" ? 0.4 : 0.25 });
}
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);
}
v.marker.setPopupContent(popupHtml(view, v.name, v));
syncUncertaintyCircle(view, v);
}
function updateViewAlerts(view) {
const alertVehicles = vehicleNames.map(name => view.vehicles[name]).filter(v => v.presenceState === "possible" || v.presenceState === "covers");
if (alertVehicles.length === 0) {
view.alertsEl.innerHTML = "";
view.indicatorsEl.innerHTML = "";
return;
}
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("");
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}`;
return `
<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><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 updateDebugPanel() {
const lines = views.map((view) => {
const el = document.getElementById(view.id);
const rect = el.getBoundingClientRect();
const center = view.map.getCenter();
const zoom = view.map.getZoom();
const stats = view.getTileStats();
return [
`${view.label}`,
` size: ${Math.round(rect.width)}x${Math.round(rect.height)}`,
` center: ${center.lat.toFixed(4)}, ${center.lng.toFixed(4)}`,
` zoom: ${zoom}`,
` tiles loaded/errors: ${stats.tileLoads}/${stats.tileErrors}`
].join("\n");
});
debugPanelEl.textContent = lines.join("\n\n");
}
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\nEvents passeret: ${passed} / ${total}\nAfspilning: 2x`;
updateDebugPanel();
}
function tick(now) {
if (!running) return;
if (lastFrameTime === null) lastFrameTime = now;
const dtReal = (now - lastFrameTime) / 1000;
lastFrameTime = now;
simTime += dtReal / TIME_SCALE;
for (const view of views) {
for (const name of vehicleNames) {
updateVehicleDisplay(view, view.vehicles[name]);
}
updateViewAlerts(view);
}
updateStatus();
requestAnimationFrame(tick);
}
function invalidateAllMaps() {
for (const view of views) {
view.map.invalidateSize();
}
}
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;
lastFrameTime = null;
requestAnimationFrame(tick);
});
pauseBtn.addEventListener("click", () => {
running = false;
});
resetBtn.addEventListener("click", () => {
running = false;
resetDemo();
running = true;
requestAnimationFrame(tick);
});
resetDemo();
requestAnimationFrame(() => {
invalidateAllMaps();
requestAnimationFrame(invalidateAllMaps);
});
window.addEventListener("resize", invalidateAllMaps);
requestAnimationFrame(tick);