2024-06-19 15:16:06 -04:00
|
|
|
import { Controller } from "@hotwired/stimulus";
|
|
|
|
|
import L from "leaflet";
|
|
|
|
|
import "leaflet.heat";
|
2024-07-21 09:13:16 -04:00
|
|
|
import { formatDistance } from "../maps/helpers";
|
|
|
|
|
import { getUrlParameter } from "../maps/helpers";
|
|
|
|
|
import { minutesToDaysHoursMinutes } from "../maps/helpers";
|
|
|
|
|
import { formatDate } from "../maps/helpers";
|
|
|
|
|
import { haversineDistance } from "../maps/helpers";
|
|
|
|
|
import { osmMapLayer } from "../maps/layers";
|
|
|
|
|
import { osmHotMapLayer } from "../maps/layers";
|
|
|
|
|
import { addTileLayer } from "../maps/layers";
|
2024-03-15 20:07:20 -04:00
|
|
|
|
|
|
|
|
export default class extends Controller {
|
2024-06-19 15:16:06 -04:00
|
|
|
static targets = ["container"];
|
2024-03-15 20:07:20 -04:00
|
|
|
|
|
|
|
|
connect() {
|
2024-06-19 15:16:06 -04:00
|
|
|
console.log("Map controller connected");
|
2024-05-30 05:50:12 -04:00
|
|
|
|
2024-06-19 15:16:06 -04:00
|
|
|
const markers = JSON.parse(this.element.dataset.coordinates);
|
2024-06-30 07:16:19 -04:00
|
|
|
// The default map center is Victory Column in Berlin
|
2024-07-19 01:55:45 -04:00
|
|
|
let center = markers[markers.length - 1] || [52.514568, 13.350111];
|
2024-06-19 15:16:06 -04:00
|
|
|
const timezone = this.element.dataset.timezone;
|
2024-06-25 16:30:11 -04:00
|
|
|
const clearFogRadius = this.element.dataset.fog_of_war_meters;
|
2024-05-30 05:50:12 -04:00
|
|
|
|
2024-06-19 15:16:06 -04:00
|
|
|
const map = L.map(this.containerTarget, {
|
2024-07-21 09:13:16 -04:00
|
|
|
layers: [osmMapLayer(), osmHotMapLayer()],
|
2024-06-19 15:16:06 -04:00
|
|
|
}).setView([center[0], center[1]], 14);
|
2024-05-30 05:50:12 -04:00
|
|
|
|
2024-06-19 15:16:06 -04:00
|
|
|
const markersArray = this.createMarkersArray(markers);
|
|
|
|
|
const markersLayer = L.layerGroup(markersArray);
|
|
|
|
|
const heatmapMarkers = markers.map((element) => [element[0], element[1], 0.3]);
|
2024-05-30 05:50:12 -04:00
|
|
|
|
2024-06-19 15:16:06 -04:00
|
|
|
const polylinesLayer = this.createPolylinesLayer(markers, map, timezone);
|
|
|
|
|
const heatmapLayer = L.heatLayer(heatmapMarkers, { radius: 20 }).addTo(map);
|
2024-06-25 16:30:11 -04:00
|
|
|
const fogOverlay = L.layerGroup(); // Initialize fog layer
|
2024-06-19 15:16:06 -04:00
|
|
|
const controlsLayer = {
|
|
|
|
|
Points: markersLayer,
|
|
|
|
|
Polylines: polylinesLayer,
|
|
|
|
|
Heatmap: heatmapLayer,
|
2024-07-19 01:55:45 -04:00
|
|
|
"Fog of War": fogOverlay,
|
2024-05-29 17:00:35 -04:00
|
|
|
};
|
2024-03-15 20:07:20 -04:00
|
|
|
|
2024-06-19 15:16:06 -04:00
|
|
|
L.control
|
|
|
|
|
.scale({
|
|
|
|
|
position: "bottomright",
|
|
|
|
|
metric: true,
|
|
|
|
|
imperial: false,
|
|
|
|
|
maxWidth: 120,
|
|
|
|
|
})
|
|
|
|
|
.addTo(map);
|
2024-05-31 14:10:22 -04:00
|
|
|
|
2024-07-21 09:13:16 -04:00
|
|
|
L.control.layers(this.baseMaps(), controlsLayer).addTo(map);
|
2024-05-25 16:14:55 -04:00
|
|
|
|
2024-06-25 15:57:22 -04:00
|
|
|
let fogEnabled = false;
|
|
|
|
|
|
2024-06-25 16:30:11 -04:00
|
|
|
// Hide fog by default
|
|
|
|
|
document.getElementById('fog').style.display = 'none';
|
|
|
|
|
|
2024-06-25 15:57:22 -04:00
|
|
|
// Toggle fog layer visibility
|
2024-07-19 01:55:45 -04:00
|
|
|
map.on('overlayadd', function (e) {
|
2024-06-25 15:57:22 -04:00
|
|
|
if (e.name === 'Fog of War') {
|
|
|
|
|
fogEnabled = true;
|
|
|
|
|
document.getElementById('fog').style.display = 'block';
|
2024-06-25 16:30:11 -04:00
|
|
|
updateFog(markers, clearFogRadius);
|
2024-06-25 15:57:22 -04:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2024-07-19 01:55:45 -04:00
|
|
|
map.on('overlayremove', function (e) {
|
2024-06-25 15:57:22 -04:00
|
|
|
if (e.name === 'Fog of War') {
|
|
|
|
|
fogEnabled = false;
|
|
|
|
|
document.getElementById('fog').style.display = 'none';
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Update fog circles on zoom and move
|
2024-07-19 01:55:45 -04:00
|
|
|
map.on('zoomend moveend', function () {
|
2024-06-25 15:57:22 -04:00
|
|
|
if (fogEnabled) {
|
2024-06-25 16:30:11 -04:00
|
|
|
updateFog(markers, clearFogRadius);
|
2024-06-25 15:57:22 -04:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2024-06-25 16:30:11 -04:00
|
|
|
function updateFog(markers, clearFogRadius) {
|
2024-06-25 15:57:22 -04:00
|
|
|
if (fogEnabled) {
|
|
|
|
|
var fog = document.getElementById('fog');
|
|
|
|
|
fog.innerHTML = ''; // Clear previous circles
|
2024-07-19 01:55:45 -04:00
|
|
|
markers.forEach(function (point) {
|
2024-06-25 16:30:11 -04:00
|
|
|
const radiusInPixels = metersToPixels(map, clearFogRadius);
|
|
|
|
|
clearFog(point[0], point[1], radiusInPixels);
|
2024-06-25 15:57:22 -04:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-06-25 16:30:11 -04:00
|
|
|
function metersToPixels(map, meters) {
|
|
|
|
|
const zoom = map.getZoom();
|
|
|
|
|
const latLng = map.getCenter(); // Get map center for correct projection
|
|
|
|
|
const metersPerPixel = getMetersPerPixel(latLng.lat, zoom);
|
|
|
|
|
return meters / metersPerPixel;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getMetersPerPixel(latitude, zoom) {
|
|
|
|
|
// Might be a total bullshit, generated by ChatGPT, but works
|
|
|
|
|
const earthCircumference = 40075016.686; // Earth's circumference in meters
|
|
|
|
|
const metersPerPixel = earthCircumference * Math.cos(latitude * Math.PI / 180) / Math.pow(2, zoom + 8);
|
|
|
|
|
return metersPerPixel;
|
|
|
|
|
}
|
|
|
|
|
|
2024-06-25 15:57:22 -04:00
|
|
|
function clearFog(lat, lng, radius) {
|
|
|
|
|
var fog = document.getElementById('fog');
|
|
|
|
|
var point = map.latLngToContainerPoint([lat, lng]);
|
|
|
|
|
var size = radius * 2;
|
|
|
|
|
var circle = document.createElement('div');
|
|
|
|
|
circle.className = 'unfogged-circle';
|
|
|
|
|
circle.style.width = size + 'px';
|
|
|
|
|
circle.style.height = size + 'px';
|
|
|
|
|
circle.style.left = (point.x - radius) + 'px';
|
|
|
|
|
circle.style.top = (point.y - radius) + 'px';
|
2024-06-25 16:30:11 -04:00
|
|
|
circle.style.backdropFilter = 'blur(0px)'; // Remove blur for the circles
|
2024-06-25 15:57:22 -04:00
|
|
|
fog.appendChild(circle);
|
|
|
|
|
}
|
|
|
|
|
|
2024-07-21 09:13:16 -04:00
|
|
|
addTileLayer(map);
|
2024-03-21 18:54:19 -04:00
|
|
|
this.addLastMarker(map, markers);
|
2024-03-15 20:07:20 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
disconnect() {
|
|
|
|
|
this.map.remove();
|
|
|
|
|
}
|
2024-03-21 18:24:47 -04:00
|
|
|
|
2024-04-17 16:00:23 -04:00
|
|
|
baseMaps() {
|
|
|
|
|
return {
|
2024-07-21 09:13:16 -04:00
|
|
|
OpenStreetMap: osmMapLayer(),
|
|
|
|
|
"OpenStreetMap.HOT": osmHotMapLayer(),
|
2024-06-19 15:16:06 -04:00
|
|
|
};
|
2024-04-17 16:00:23 -04:00
|
|
|
}
|
|
|
|
|
|
2024-06-19 15:16:06 -04:00
|
|
|
createMarkersArray(markersData) {
|
|
|
|
|
return markersData.map((marker) => {
|
|
|
|
|
const [lat, lon] = marker;
|
|
|
|
|
const popupContent = this.createPopupContent(marker);
|
|
|
|
|
return L.circleMarker([lat, lon], { radius: 4 }).bindPopup(popupContent);
|
|
|
|
|
});
|
2024-04-17 16:00:23 -04:00
|
|
|
}
|
|
|
|
|
|
2024-06-19 15:16:06 -04:00
|
|
|
createPopupContent(marker) {
|
2024-07-21 09:13:16 -04:00
|
|
|
const timezone = this.element.dataset.timezone;
|
2024-03-21 18:24:47 -04:00
|
|
|
return `
|
2024-07-21 09:13:16 -04:00
|
|
|
<b>Timestamp:</b> ${formatDate(marker[4], timezone)}<br>
|
2024-03-21 18:24:47 -04:00
|
|
|
<b>Latitude:</b> ${marker[0]}<br>
|
|
|
|
|
<b>Longitude:</b> ${marker[1]}<br>
|
2024-03-22 17:57:53 -04:00
|
|
|
<b>Altitude:</b> ${marker[3]}m<br>
|
|
|
|
|
<b>Velocity:</b> ${marker[5]}km/h<br>
|
2024-03-28 10:11:59 -04:00
|
|
|
<b>Battery:</b> ${marker[2]}%
|
2024-03-21 18:24:47 -04:00
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
2024-03-21 18:54:19 -04:00
|
|
|
addLastMarker(map, markers) {
|
|
|
|
|
if (markers.length > 0) {
|
2024-06-19 15:16:06 -04:00
|
|
|
const lastMarker = markers[markers.length - 1].slice(0, 2);
|
2024-03-21 18:54:19 -04:00
|
|
|
L.marker(lastMarker).addTo(map);
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-06-19 15:16:06 -04:00
|
|
|
|
|
|
|
|
addHighlightOnHover(polyline, map, startPoint, endPoint, prevPoint, nextPoint, timezone) {
|
|
|
|
|
const originalStyle = { color: "blue", opacity: 0.6, weight: 3 };
|
|
|
|
|
const highlightStyle = { color: "yellow", opacity: 1, weight: 5 };
|
|
|
|
|
|
|
|
|
|
polyline.setStyle(originalStyle);
|
|
|
|
|
|
|
|
|
|
const firstTimestamp = new Date(startPoint[4] * 1000).toLocaleString("en-GB", { timeZone: timezone });
|
|
|
|
|
const lastTimestamp = new Date(endPoint[4] * 1000).toLocaleString("en-GB", { timeZone: timezone });
|
|
|
|
|
|
|
|
|
|
const minutes = Math.round((endPoint[4] - startPoint[4]) / 60);
|
2024-07-21 09:13:16 -04:00
|
|
|
const timeOnRoute = minutesToDaysHoursMinutes(minutes);
|
|
|
|
|
const distance = haversineDistance(startPoint[0], startPoint[1], endPoint[0], endPoint[1]);
|
2024-06-19 15:16:06 -04:00
|
|
|
|
2024-07-21 09:13:16 -04:00
|
|
|
const distanceToPrev = prevPoint ? haversineDistance(prevPoint[0], prevPoint[1], startPoint[0], startPoint[1]) : "N/A";
|
|
|
|
|
const distanceToNext = nextPoint ? haversineDistance(endPoint[0], endPoint[1], nextPoint[0], nextPoint[1]) : "N/A";
|
2024-06-19 15:16:06 -04:00
|
|
|
|
|
|
|
|
const timeBetweenPrev = prevPoint ? Math.round((startPoint[4] - prevPoint[4]) / 60) : "N/A";
|
|
|
|
|
const timeBetweenNext = nextPoint ? Math.round((nextPoint[4] - endPoint[4]) / 60) : "N/A";
|
|
|
|
|
|
|
|
|
|
const startIcon = L.divIcon({ html: "🚥", className: "emoji-icon" });
|
|
|
|
|
const finishIcon = L.divIcon({ html: "🏁", className: "emoji-icon" });
|
|
|
|
|
|
2024-07-21 09:13:16 -04:00
|
|
|
const isDebugMode = getUrlParameter("debug") === "true";
|
2024-06-19 15:16:06 -04:00
|
|
|
|
|
|
|
|
let popupContent = `
|
|
|
|
|
<b>Start:</b> ${firstTimestamp}<br>
|
|
|
|
|
<b>End:</b> ${lastTimestamp}<br>
|
|
|
|
|
<b>Duration:</b> ${timeOnRoute}<br>
|
2024-07-21 09:13:16 -04:00
|
|
|
<b>Distance:</b> ${formatDistance(distance)}<br>
|
2024-06-19 15:16:06 -04:00
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
if (isDebugMode) {
|
|
|
|
|
popupContent += `
|
2024-07-21 09:13:16 -04:00
|
|
|
<b>Prev Route:</b> ${Math.round(distanceToPrev)}m and ${minutesToDaysHoursMinutes(timeBetweenPrev)} away<br>
|
|
|
|
|
<b>Next Route:</b> ${Math.round(distanceToNext)}m and ${minutesToDaysHoursMinutes(timeBetweenNext)} away<br>
|
2024-06-19 15:16:06 -04:00
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const startMarker = L.marker([startPoint[0], startPoint[1]], { icon: startIcon }).bindPopup(`Start: ${firstTimestamp}`);
|
|
|
|
|
const endMarker = L.marker([endPoint[0], endPoint[1]], { icon: finishIcon }).bindPopup(popupContent);
|
|
|
|
|
|
2024-07-19 01:55:45 -04:00
|
|
|
let hoverPopup = null;
|
|
|
|
|
|
|
|
|
|
polyline.on("mouseover", function (e) {
|
2024-06-19 15:16:06 -04:00
|
|
|
polyline.setStyle(highlightStyle);
|
|
|
|
|
startMarker.addTo(map);
|
2024-07-19 01:55:45 -04:00
|
|
|
endMarker.addTo(map);
|
|
|
|
|
|
|
|
|
|
const latLng = e.latlng;
|
|
|
|
|
if (hoverPopup) {
|
|
|
|
|
map.closePopup(hoverPopup);
|
|
|
|
|
}
|
|
|
|
|
hoverPopup = L.popup()
|
|
|
|
|
.setLatLng(latLng)
|
|
|
|
|
.setContent(popupContent)
|
|
|
|
|
.openOn(map);
|
2024-06-19 15:16:06 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
polyline.on("mouseout", function () {
|
|
|
|
|
polyline.setStyle(originalStyle);
|
2024-07-19 01:55:45 -04:00
|
|
|
map.closePopup(hoverPopup);
|
2024-06-19 15:16:06 -04:00
|
|
|
map.removeLayer(startMarker);
|
|
|
|
|
map.removeLayer(endMarker);
|
|
|
|
|
});
|
2024-07-19 01:55:45 -04:00
|
|
|
|
|
|
|
|
polyline.on("click", function () {
|
|
|
|
|
map.fitBounds(polyline.getBounds());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Close the popup when clicking elsewhere on the map
|
|
|
|
|
map.on("click", function () {
|
|
|
|
|
map.closePopup(hoverPopup);
|
|
|
|
|
});
|
2024-06-19 15:16:06 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
createPolylinesLayer(markers, map, timezone) {
|
|
|
|
|
const splitPolylines = [];
|
|
|
|
|
let currentPolyline = [];
|
2024-06-20 17:57:00 -04:00
|
|
|
const distanceThresholdMeters = parseInt(this.element.dataset.meters_between_routes) || 500;
|
|
|
|
|
const timeThresholdMinutes = parseInt(this.element.dataset.minutes_between_routes) || 60;
|
2024-06-19 15:16:06 -04:00
|
|
|
|
|
|
|
|
for (let i = 0, len = markers.length; i < len; i++) {
|
|
|
|
|
if (currentPolyline.length === 0) {
|
|
|
|
|
currentPolyline.push(markers[i]);
|
|
|
|
|
} else {
|
|
|
|
|
const lastPoint = currentPolyline[currentPolyline.length - 1];
|
|
|
|
|
const currentPoint = markers[i];
|
2024-07-21 09:13:16 -04:00
|
|
|
const distance = haversineDistance(lastPoint[0], lastPoint[1], currentPoint[0], currentPoint[1]);
|
2024-06-19 15:16:06 -04:00
|
|
|
const timeDifference = (currentPoint[4] - lastPoint[4]) / 60;
|
|
|
|
|
|
|
|
|
|
if (distance > distanceThresholdMeters || timeDifference > timeThresholdMinutes) {
|
|
|
|
|
splitPolylines.push([...currentPolyline]);
|
|
|
|
|
currentPolyline = [currentPoint];
|
|
|
|
|
} else {
|
|
|
|
|
currentPolyline.push(currentPoint);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (currentPolyline.length > 0) {
|
|
|
|
|
splitPolylines.push(currentPolyline);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return L.layerGroup(
|
|
|
|
|
splitPolylines.map((polylineCoordinates, index) => {
|
|
|
|
|
const latLngs = polylineCoordinates.map((point) => [point[0], point[1]]);
|
|
|
|
|
const polyline = L.polyline(latLngs, { color: "blue", opacity: 0.6, weight: 3 });
|
|
|
|
|
|
|
|
|
|
const startPoint = polylineCoordinates[0];
|
|
|
|
|
const endPoint = polylineCoordinates[polylineCoordinates.length - 1];
|
|
|
|
|
const prevPoint = index > 0 ? splitPolylines[index - 1][splitPolylines[index - 1].length - 1] : null;
|
|
|
|
|
const nextPoint = index < splitPolylines.length - 1 ? splitPolylines[index + 1][0] : null;
|
|
|
|
|
|
|
|
|
|
this.addHighlightOnHover(polyline, map, startPoint, endPoint, prevPoint, nextPoint, timezone);
|
|
|
|
|
|
|
|
|
|
return polyline;
|
|
|
|
|
})
|
|
|
|
|
).addTo(map);
|
|
|
|
|
}
|
2024-03-15 20:07:20 -04:00
|
|
|
}
|