diff --git a/app/controllers/api/v1/settings_controller.rb b/app/controllers/api/v1/settings_controller.rb
index 5ca7a809..4834178f 100644
--- a/app/controllers/api/v1/settings_controller.rb
+++ b/app/controllers/api/v1/settings_controller.rb
@@ -31,7 +31,7 @@ class Api::V1::SettingsController < ApiController
params.require(:settings).permit(
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
- :preferred_map_layer
+ :preferred_map_layer, :points_rendering_mode
)
end
end
diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js
index aaa29716..bdbb45b9 100644
--- a/app/javascript/controllers/maps_controller.js
+++ b/app/javascript/controllers/maps_controller.js
@@ -1,25 +1,19 @@
import { Controller } from "@hotwired/stimulus";
import L from "leaflet";
import "leaflet.heat";
-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 { createMarkersArray } from "../maps/markers";
+
+import { createPolylinesLayer } from "../maps/polylines";
+import { updatePolylinesOpacity } from "../maps/polylines";
+
+import { fetchAndDrawAreas } from "../maps/areas";
+import { handleAreaCreated } from "../maps/areas";
+
import { osmMapLayer } from "../maps/layers";
import { osmHotMapLayer } from "../maps/layers";
import { OPNVMapLayer } from "../maps/layers";
import { openTopoMapLayer } from "../maps/layers";
-// import { stadiaAlidadeSmoothMapLayer } from "../maps/layers";
-// import { stadiaAlidadeSmoothDarkMapLayer } from "../maps/layers";
-// import { stadiaAlidadeSatelliteMapLayer } from "../maps/layers";
-// import { stadiaOsmBrightMapLayer } from "../maps/layers";
-// import { stadiaOutdoorMapLayer } from "../maps/layers";
-// import { stadiaStamenTonerMapLayer } from "../maps/layers";
-// import { stadiaStamenTonerBackgroundMapLayer } from "../maps/layers";
-// import { stadiaStamenTonerLiteMapLayer } from "../maps/layers";
-// import { stadiaStamenWatercolorMapLayer } from "../maps/layers";
-// import { stadiaStamenTerrainMapLayer } from "../maps/layers";
import { cyclOsmMapLayer } from "../maps/layers";
import { esriWorldStreetMapLayer } from "../maps/layers";
import { esriWorldTopoMapLayer } from "../maps/layers";
@@ -43,6 +37,7 @@ export default class extends Controller {
this.clearFogRadius = parseInt(this.userSettings.fog_of_war_meters) || 50;
this.routeOpacity = parseFloat(this.userSettings.route_opacity) || 0.6;
this.distanceUnit = this.element.dataset.distance_unit || "km";
+ this.pointsRenderingMode = this.userSettings.points_rendering_mode || "raw";
this.center = this.markers[this.markers.length - 1] || [52.514568, 13.350111];
@@ -55,11 +50,11 @@ export default class extends Controller {
this.map.setMaxBounds(bounds);
- this.markersArray = this.createMarkersArray(this.markers);
+ this.markersArray = createMarkersArray(this.markers, this.userSettings);
this.markersLayer = L.layerGroup(this.markersArray);
- this.heatmapMarkers = this.markers.map((element) => [element[0], element[1], 0.2]);
+ this.heatmapMarkers = this.markersArray.map((element) => [element._latlng.lat, element._latlng.lng, 0.2]);
- this.polylinesLayer = this.createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity);
+ this.polylinesLayer = createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity, this.userSettings);
this.heatmapLayer = L.heatLayer(this.heatmapMarkers, { radius: 20 }).addTo(this.map);
this.fogOverlay = L.layerGroup(); // Initialize fog layer
this.areasLayer = L.layerGroup(); // Initialize areas layer
@@ -88,7 +83,7 @@ export default class extends Controller {
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
// Fetch and draw areas when the map is loaded
- this.fetchAndDrawAreas(this.apiKey);
+ fetchAndDrawAreas(this.areasLayer, this.apiKey);
let fogEnabled = false;
@@ -144,22 +139,12 @@ export default class extends Controller {
baseMaps() {
let selectedLayerName = this.userSettings.preferred_map_layer || "OpenStreetMap";
-console.log(selectedLayerName);
+
return {
OpenStreetMap: osmMapLayer(this.map, selectedLayerName),
"OpenStreetMap.HOT": osmHotMapLayer(this.map, selectedLayerName),
OPNV: OPNVMapLayer(this.map, selectedLayerName),
openTopo: openTopoMapLayer(this.map, selectedLayerName),
- // stadiaAlidadeSmooth: stadiaAlidadeSmoothMapLayer(this.map, selectedLayerName),
- // stadiaAlidadeSmoothDark: stadiaAlidadeSmoothDarkMapLayer(this.map, selectedLayerName),
- // stadiaAlidadeSatellite: stadiaAlidadeSatelliteMapLayer(this.map, selectedLayerName),
- // stadiaOsmBright: stadiaOsmBrightMapLayer(this.map, selectedLayerName),
- // stadiaOutdoor: stadiaOutdoorMapLayer(this.map, selectedLayerName),
- // stadiaStamenToner: stadiaStamenTonerMapLayer(this.map, selectedLayerName),
- // stadiaStamenTonerBackground: stadiaStamenTonerBackgroundMapLayer(this.map, selectedLayerName),
- // stadiaStamenTonerLite: stadiaStamenTonerLiteMapLayer(this.map, selectedLayerName),
- // stadiaStamenWatercolor: stadiaStamenWatercolorMapLayer(this.map, selectedLayerName),
- // stadiaStamenTerrain: stadiaStamenTerrainMapLayer(this.map, selectedLayerName),
cyclOsm: cyclOsmMapLayer(this.map, selectedLayerName),
esriWorldStreet: esriWorldStreetMapLayer(this.map, selectedLayerName),
esriWorldTopo: esriWorldTopoMapLayer(this.map, selectedLayerName),
@@ -168,34 +153,6 @@ console.log(selectedLayerName);
};
}
- createMarkersArray(markersData) {
- return markersData.map((marker) => {
- const [lat, lon] = marker;
- const popupContent = this.createPopupContent(marker);
- return L.circleMarker([lat, lon], { radius: 4 }).bindPopup(popupContent);
- });
- }
-
- createPopupContent(marker) {
- const timezone = this.element.dataset.timezone;
- if (this.distanceUnit === "mi") {
- // convert marker[5] from km/h to mph
- marker[5] = marker[5] * 0.621371;
- // convert marker[3] from meters to feet
- marker[3] = marker[3] * 3.28084;
- }
-
- return `
- Timestamp: ${formatDate(marker[4], timezone)}
- Latitude: ${marker[0]}
- Longitude: ${marker[1]}
- Altitude: ${marker[3]}m
- Velocity: ${marker[5]}km/h
- Battery: ${marker[2]}%
- [Delete]
- `;
- }
-
removeEventListeners() {
document.removeEventListener('click', this.handleDeleteClick);
}
@@ -320,133 +277,6 @@ console.log(selectedLayerName);
fog.appendChild(circle);
}
- addHighlightOnHover(polyline, map, polylineCoordinates, timezone, routeOpacity) {
- const originalStyle = { color: "blue", opacity: routeOpacity, weight: 3 };
- const highlightStyle = { color: "yellow", opacity: 1, weight: 5 };
-
- polyline.setStyle(originalStyle);
-
- const startPoint = polylineCoordinates[0];
- const endPoint = polylineCoordinates[polylineCoordinates.length - 1];
-
- 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);
- const timeOnRoute = minutesToDaysHoursMinutes(minutes);
-
- const totalDistance = polylineCoordinates.reduce((acc, curr, index, arr) => {
- if (index === 0) return acc;
- const dist = haversineDistance(arr[index - 1][0], arr[index - 1][1], curr[0], curr[1]);
- return acc + dist;
- }, 0);
-
- const startIcon = L.divIcon({ html: "🚥", className: "emoji-icon" });
- const finishIcon = L.divIcon({ html: "🏁", className: "emoji-icon" });
-
- const isDebugMode = getUrlParameter("debug") === "true";
-
- let popupContent = `
- Start: ${firstTimestamp}
- End: ${lastTimestamp}
- Duration: ${timeOnRoute}
- Total Distance: ${formatDistance(totalDistance, this.distanceUnit)}
- `;
-
- if (isDebugMode) {
- const prevPoint = polylineCoordinates[0];
- const nextPoint = polylineCoordinates[polylineCoordinates.length - 1];
- const distanceToPrev = haversineDistance(prevPoint[0], prevPoint[1], startPoint[0], startPoint[1]);
- const distanceToNext = haversineDistance(endPoint[0], endPoint[1], nextPoint[0], nextPoint[1]);
-
- const timeBetweenPrev = Math.round((startPoint[4] - prevPoint[4]) / 60);
- const timeBetweenNext = Math.round((endPoint[4] - nextPoint[4]) / 60);
- const pointsNumber = polylineCoordinates.length;
-
- popupContent += `
- Prev Route: ${Math.round(distanceToPrev)}m and ${minutesToDaysHoursMinutes(timeBetweenPrev)} away
- Next Route: ${Math.round(distanceToNext)}m and ${minutesToDaysHoursMinutes(timeBetweenNext)} away
- Points: ${pointsNumber}
- `;
- }
-
- 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);
-
- let hoverPopup = null;
-
- polyline.on("mouseover", function (e) {
- polyline.setStyle(highlightStyle);
- startMarker.addTo(map);
- endMarker.addTo(map);
-
- const latLng = e.latlng;
- if (hoverPopup) {
- map.closePopup(hoverPopup);
- }
- hoverPopup = L.popup()
- .setLatLng(latLng)
- .setContent(popupContent)
- .openOn(map);
- });
-
- polyline.on("mouseout", function () {
- polyline.setStyle(originalStyle);
- map.closePopup(hoverPopup);
- map.removeLayer(startMarker);
- map.removeLayer(endMarker);
- });
-
- polyline.on("click", function () {
- map.fitBounds(polyline.getBounds());
- });
-
- // Close the popup when clicking elsewhere on the map
- map.on("click", function () {
- map.closePopup(hoverPopup);
- });
- }
-
- createPolylinesLayer(markers, map, timezone, routeOpacity) {
- const splitPolylines = [];
- let currentPolyline = [];
- const distanceThresholdMeters = parseInt(this.userSettings.meters_between_routes) || 500;
- const timeThresholdMinutes = parseInt(this.userSettings.minutes_between_routes) || 60;
-
- 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];
- const distance = haversineDistance(lastPoint[0], lastPoint[1], currentPoint[0], currentPoint[1]);
- 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) => {
- const latLngs = polylineCoordinates.map((point) => [point[0], point[1]]);
- const polyline = L.polyline(latLngs, { color: "blue", opacity: 0.6, weight: 3 });
-
- this.addHighlightOnHover(polyline, map, polylineCoordinates, timezone, routeOpacity);
-
- return polyline;
- })
- ).addTo(map);
- }
-
initializeDrawControl() {
// Initialize the FeatureGroup to store editable layers
this.drawnItems = new L.FeatureGroup();
@@ -475,171 +305,13 @@ console.log(selectedLayerName);
const layer = event.layer;
if (event.layerType === 'circle') {
- this.handleCircleCreated(layer);
+ handleAreaCreated(this.areasLayer, layer, this.apiKey);
}
this.drawnItems.addLayer(layer);
});
}
- handleCircleCreated(layer) {
- const radius = layer.getRadius();
- const center = layer.getLatLng();
-
- const formHtml = `
-
- `;
-
- layer.bindPopup(
- formHtml, {
- maxWidth: "auto",
- minWidth: 300
- }
- ).openPopup();
-
- layer.on('popupopen', () => {
- const form = document.getElementById('circle-form');
- form.addEventListener('submit', (e) => {
- e.preventDefault();
- this.saveCircle(new FormData(form), layer, this.apiKey);
- });
- });
-
- // Add the layer to the areas layer group
- this.areasLayer.addLayer(layer);
- }
-
- saveCircle(formData, layer, apiKey) {
- const data = {};
- formData.forEach((value, key) => {
- const keys = key.split('[').map(k => k.replace(']', ''));
- if (keys.length > 1) {
- if (!data[keys[0]]) data[keys[0]] = {};
- data[keys[0]][keys[1]] = value;
- } else {
- data[keys[0]] = value;
- }
- });
-
- fetch(`/api/v1/areas?api_key=${apiKey}`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json'},
- body: JSON.stringify(data)
- })
- .then(response => {
- if (!response.ok) {
- throw new Error('Network response was not ok');
- }
- return response.json();
- })
- .then(data => {
- layer.closePopup();
- layer.bindPopup(`
- Name: ${data.name}
- Radius: ${Math.round(data.radius)} meters
- [Delete]
- `).openPopup();
-
- // Add event listener for the delete button
- layer.on('popupopen', () => {
- document.querySelector('.delete-area').addEventListener('click', () => {
- this.deleteArea(data.id, layer);
- });
- });
- })
- .catch(error => {
- console.error('There was a problem with the save request:', error);
- });
- }
-
- deleteArea(id, layer, apiKey) {
- fetch(`/api/v1/areas/${id}?api_key=${apiKey}`, {
- method: 'DELETE',
- headers: {
- 'Content-Type': 'application/json'
- }
- })
- .then(response => {
- if (!response.ok) {
- throw new Error('Network response was not ok');
- }
- return response.json();
- })
- .then(data => {
- this.areasLayer.removeLayer(layer); // Remove the layer from the areas layer group
- })
- .catch(error => {
- console.error('There was a problem with the delete request:', error);
- });
- }
-
- fetchAndDrawAreas(apiKey) {
- fetch(`/api/v1/areas?api_key=${apiKey}`, {
- method: 'GET',
- headers: {
- 'Content-Type': 'application/json'
- }
- })
- .then(response => {
- if (!response.ok) {
- throw new Error('Network response was not ok');
- }
- return response.json();
- })
- .then(data => {
- data.forEach(area => {
- // Check if necessary fields are present
- if (area.latitude && area.longitude && area.radius && area.name && area.id) {
- const layer = L.circle([area.latitude, area.longitude], {
- radius: area.radius,
- color: 'red',
- fillColor: '#f03',
- fillOpacity: 0.5
- }).bindPopup(`
- Name: ${area.name}
- Radius: ${Math.round(area.radius)} meters
- [Delete]
- `);
-
- this.areasLayer.addLayer(layer); // Add to areas layer group
-
- // Add event listener for the delete button
- layer.on('popupopen', () => {
- document.querySelector('.delete-area').addEventListener('click', (e) => {
- e.preventDefault();
- if (confirm('Are you sure you want to delete this area?')) {
- this.deleteArea(area.id, layer, this.apiKey);
- }
- });
- });
- } else {
- console.error('Area missing required fields:', area);
- }
- });
- })
- .catch(error => {
- console.error('There was a problem with the fetch request:', error);
- });
- }
-
addSettingsButton() {
if (this.settingsButtonAdded) return;
@@ -735,7 +407,19 @@ console.log(selectedLayerName);
+
+
+
+
+
+
+
+
+
+
+
+
@@ -775,6 +459,7 @@ console.log(selectedLayerName);
minutes_between_routes: event.target.minutes_between_routes.value,
time_threshold_minutes: event.target.time_threshold_minutes.value,
merge_threshold_minutes: event.target.merge_threshold_minutes.value,
+ points_rendering_mode: event.target.points_rendering_mode.value
},
}),
})
@@ -873,14 +558,14 @@ console.log(selectedLayerName);
});
// Recreate layers only if they don't exist
- this.markersLayer = preserveLayers.Points || L.layerGroup(this.createMarkersArray(this.markers));
- this.polylinesLayer = preserveLayers.Polylines || this.createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity);
+ this.markersLayer = preserveLayers.Points || L.layerGroup(createMarkersArray(this.markers, newSettings));
+ this.polylinesLayer = preserveLayers.Polylines || createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity, this.userSettings);
this.heatmapLayer = preserveLayers.Heatmap || L.heatLayer(this.markers.map((element) => [element[0], element[1], 0.2]), { radius: 20 });
this.fogOverlay = preserveLayers["Fog of War"] || L.layerGroup();
this.areasLayer = preserveLayers.Areas || L.layerGroup();
// Redraw areas
- this.fetchAndDrawAreas(this.apiKey);
+ fetchAndDrawAreas(this.areasLayer, this.apiKey);
let fogEnabled = false;
document.getElementById('fog').style.display = 'none';
@@ -909,7 +594,7 @@ console.log(selectedLayerName);
this.addLastMarker(this.map, this.markers);
this.addEventListeners();
this.initializeDrawControl();
- this.updatePolylinesOpacity(this.routeOpacity);
+ updatePolylinesOpacity(this.polylinesLayer, this.routeOpacity);
this.map.on('overlayadd', (e) => {
if (e.name === 'Areas') {
@@ -986,13 +671,4 @@ console.log(selectedLayerName);
this.map.removeControl(this.layerControl);
this.layerControl = L.control.layers(this.baseMaps(), layerControl).addTo(this.map);
}
-
- updatePolylinesOpacity(opacity) {
- this.polylinesLayer.eachLayer((layer) => {
- if (layer instanceof L.Polyline) {
- layer.setStyle({ opacity: opacity });
- }
- });
- }
-
}
diff --git a/app/javascript/maps/areas.js b/app/javascript/maps/areas.js
new file mode 100644
index 00000000..10402c13
--- /dev/null
+++ b/app/javascript/maps/areas.js
@@ -0,0 +1,160 @@
+export function handleAreaCreated(areasLayer, layer, apiKey) {
+ const radius = layer.getRadius();
+ const center = layer.getLatLng();
+
+ const formHtml = `
+
+ `;
+
+ layer.bindPopup(
+ formHtml, {
+ maxWidth: "auto",
+ minWidth: 300
+ }
+ ).openPopup();
+
+ layer.on('popupopen', () => {
+ const form = document.getElementById('circle-form');
+
+ if (!form) return;
+
+ form.addEventListener('submit', (e) => {
+ e.preventDefault();
+ saveArea(new FormData(form), areasLayer, layer, apiKey);
+ });
+ });
+
+ // Add the layer to the areas layer group
+ areasLayer.addLayer(layer);
+}
+
+export function saveArea(formData, areasLayer, layer, apiKey) {
+ const data = {};
+ formData.forEach((value, key) => {
+ const keys = key.split('[').map(k => k.replace(']', ''));
+ if (keys.length > 1) {
+ if (!data[keys[0]]) data[keys[0]] = {};
+ data[keys[0]][keys[1]] = value;
+ } else {
+ data[keys[0]] = value;
+ }
+ });
+
+ fetch(`/api/v1/areas?api_key=${apiKey}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json'},
+ body: JSON.stringify(data)
+ })
+ .then(response => {
+ if (!response.ok) {
+ throw new Error('Network response was not ok');
+ }
+ return response.json();
+ })
+ .then(data => {
+ layer.closePopup();
+ layer.bindPopup(`
+ Name: ${data.name}
+ Radius: ${Math.round(data.radius)} meters
+ [Delete]
+ `).openPopup();
+
+ // Add event listener for the delete button
+ layer.on('popupopen', () => {
+ document.querySelector('.delete-area').addEventListener('click', () => {
+ deleteArea(data.id, areasLayer, layer, apiKey);
+ });
+ });
+ })
+ .catch(error => {
+ console.error('There was a problem with the save request:', error);
+ });
+}
+
+export function deleteArea(id, areasLayer, layer, apiKey) {
+ fetch(`/api/v1/areas/${id}?api_key=${apiKey}`, {
+ method: 'DELETE',
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ })
+ .then(response => {
+ if (!response.ok) {
+ throw new Error('Network response was not ok');
+ }
+ return response.json();
+ })
+ .then(data => {
+ areasLayer.removeLayer(layer); // Remove the layer from the areas layer group
+ })
+ .catch(error => {
+ console.error('There was a problem with the delete request:', error);
+ });
+}
+
+export function fetchAndDrawAreas(areasLayer, apiKey) {
+ fetch(`/api/v1/areas?api_key=${apiKey}`, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ })
+ .then(response => {
+ if (!response.ok) {
+ throw new Error('Network response was not ok');
+ }
+ return response.json();
+ })
+ .then(data => {
+ data.forEach(area => {
+ // Check if necessary fields are present
+ if (area.latitude && area.longitude && area.radius && area.name && area.id) {
+ const layer = L.circle([area.latitude, area.longitude], {
+ radius: area.radius,
+ color: 'red',
+ fillColor: '#f03',
+ fillOpacity: 0.5
+ }).bindPopup(`
+ Name: ${area.name}
+ Radius: ${Math.round(area.radius)} meters
+ [Delete]
+ `);
+
+ areasLayer.addLayer(layer); // Add to areas layer group
+
+ // Add event listener for the delete button
+ layer.on('popupopen', () => {
+ document.querySelector('.delete-area').addEventListener('click', (e) => {
+ e.preventDefault();
+ if (confirm('Are you sure you want to delete this area?')) {
+ deleteArea(area.id, areasLayer, layer, apiKey);
+ }
+ });
+ });
+ } else {
+ console.error('Area missing required fields:', area);
+ }
+ });
+ })
+ .catch(error => {
+ console.error('There was a problem with the fetch request:', error);
+ });
+}
diff --git a/app/javascript/maps/helpers.js b/app/javascript/maps/helpers.js
index 7aef91c3..f2f310f2 100644
--- a/app/javascript/maps/helpers.js
+++ b/app/javascript/maps/helpers.js
@@ -59,6 +59,7 @@ export function formatDate(timestamp, timezone) {
}
export function haversineDistance(lat1, lon1, lat2, lon2, unit = 'km') {
+ // Haversine formula to calculate the distance between two points
const toRad = (x) => (x * Math.PI) / 180;
const R_km = 6371; // Radius of the Earth in kilometers
const R_miles = 3959; // Radius of the Earth in miles
diff --git a/app/javascript/maps/markers.js b/app/javascript/maps/markers.js
new file mode 100644
index 00000000..e19780b4
--- /dev/null
+++ b/app/javascript/maps/markers.js
@@ -0,0 +1,45 @@
+import { createPopupContent } from "./popups";
+
+export function createMarkersArray(markersData, userSettings) {
+ if (userSettings.pointsRenderingMode === "simplified") {
+ return createSimplifiedMarkers(markersData);
+ } else {
+ return markersData.map((marker) => {
+ const [lat, lon] = marker;
+ const popupContent = createPopupContent(marker, userSettings.timezone, userSettings.distanceUnit);
+ return L.circleMarker([lat, lon], { radius: 4 }).bindPopup(popupContent);
+ });
+ }
+}
+
+export function createSimplifiedMarkers(markersData) {
+ const distanceThreshold = 50; // meters
+ const timeThreshold = 20000; // milliseconds (3 seconds)
+
+ const simplifiedMarkers = [];
+ let previousMarker = markersData[0]; // Start with the first marker
+ simplifiedMarkers.push(previousMarker); // Always keep the first marker
+
+ markersData.forEach((currentMarker, index) => {
+ if (index === 0) return; // Skip the first marker
+
+ const [prevLat, prevLon, prevTimestamp] = previousMarker;
+ const [currLat, currLon, currTimestamp] = currentMarker;
+
+ const timeDiff = currTimestamp - prevTimestamp;
+ const distance = haversineDistance(prevLat, prevLon, currLat, currLon, 'km') * 1000; // Convert km to meters
+
+ // Keep the marker if it's far enough in distance or time
+ if (distance >= distanceThreshold || timeDiff >= timeThreshold) {
+ simplifiedMarkers.push(currentMarker);
+ previousMarker = currentMarker;
+ }
+ });
+
+ // Now create markers for the simplified data
+ return simplifiedMarkers.map((marker) => {
+ const [lat, lon] = marker;
+ const popupContent = this.createPopupContent(marker);
+ return L.circleMarker([lat, lon], { radius: 4 }).bindPopup(popupContent);
+ });
+}
diff --git a/app/javascript/maps/polylines.js b/app/javascript/maps/polylines.js
new file mode 100644
index 00000000..2bcaa428
--- /dev/null
+++ b/app/javascript/maps/polylines.js
@@ -0,0 +1,139 @@
+import { formatDistance } from "../maps/helpers";
+import { getUrlParameter } from "../maps/helpers";
+import { minutesToDaysHoursMinutes } from "../maps/helpers";
+import { haversineDistance } from "../maps/helpers";
+
+export function addHighlightOnHover(polyline, map, polylineCoordinates, userSettings) {
+ const originalStyle = { color: "blue", opacity: userSettings.routeOpacity, weight: 3 };
+ const highlightStyle = { color: "yellow", opacity: 1, weight: 5 };
+
+ polyline.setStyle(originalStyle);
+
+ const startPoint = polylineCoordinates[0];
+ const endPoint = polylineCoordinates[polylineCoordinates.length - 1];
+
+ const firstTimestamp = new Date(startPoint[4] * 1000).toLocaleString("en-GB", { timeZone: userSettings.timezone });
+ const lastTimestamp = new Date(endPoint[4] * 1000).toLocaleString("en-GB", { timeZone: userSettings.timezone });
+
+ const minutes = Math.round((endPoint[4] - startPoint[4]) / 60);
+ const timeOnRoute = minutesToDaysHoursMinutes(minutes);
+
+ const totalDistance = polylineCoordinates.reduce((acc, curr, index, arr) => {
+ if (index === 0) return acc;
+ const dist = haversineDistance(arr[index - 1][0], arr[index - 1][1], curr[0], curr[1]);
+ return acc + dist;
+ }, 0);
+
+ const startIcon = L.divIcon({ html: "🚥", className: "emoji-icon" });
+ const finishIcon = L.divIcon({ html: "🏁", className: "emoji-icon" });
+
+ const isDebugMode = getUrlParameter("debug") === "true";
+
+ let popupContent = `
+ Start: ${firstTimestamp}
+ End: ${lastTimestamp}
+ Duration: ${timeOnRoute}
+ Total Distance: ${formatDistance(totalDistance, userSettings.distanceUnit)}
+ `;
+
+ if (isDebugMode) {
+ const prevPoint = polylineCoordinates[0];
+ const nextPoint = polylineCoordinates[polylineCoordinates.length - 1];
+ const distanceToPrev = haversineDistance(prevPoint[0], prevPoint[1], startPoint[0], startPoint[1]);
+ const distanceToNext = haversineDistance(endPoint[0], endPoint[1], nextPoint[0], nextPoint[1]);
+
+ const timeBetweenPrev = Math.round((startPoint[4] - prevPoint[4]) / 60);
+ const timeBetweenNext = Math.round((endPoint[4] - nextPoint[4]) / 60);
+ const pointsNumber = polylineCoordinates.length;
+
+ popupContent += `
+ Prev Route: ${Math.round(distanceToPrev)}m and ${minutesToDaysHoursMinutes(timeBetweenPrev)} away
+ Next Route: ${Math.round(distanceToNext)}m and ${minutesToDaysHoursMinutes(timeBetweenNext)} away
+ Points: ${pointsNumber}
+ `;
+ }
+
+ 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);
+
+ let hoverPopup = null;
+
+ polyline.on("mouseover", function (e) {
+ polyline.setStyle(highlightStyle);
+ startMarker.addTo(map);
+ endMarker.addTo(map);
+
+ const latLng = e.latlng;
+ if (hoverPopup) {
+ map.closePopup(hoverPopup);
+ }
+ hoverPopup = L.popup()
+ .setLatLng(latLng)
+ .setContent(popupContent)
+ .openOn(map);
+ });
+
+ polyline.on("mouseout", function () {
+ polyline.setStyle(originalStyle);
+ map.closePopup(hoverPopup);
+ map.removeLayer(startMarker);
+ map.removeLayer(endMarker);
+ });
+
+ polyline.on("click", function () {
+ map.fitBounds(polyline.getBounds());
+ });
+
+ // Close the popup when clicking elsewhere on the map
+ map.on("click", function () {
+ map.closePopup(hoverPopup);
+ });
+}
+
+export function createPolylinesLayer(markers, map, userSettings) {
+ const splitPolylines = [];
+ let currentPolyline = [];
+ const distanceThresholdMeters = parseInt(userSettings.meters_between_routes) || 500;
+ const timeThresholdMinutes = parseInt(userSettings.minutes_between_routes) || 60;
+
+ 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];
+ const distance = haversineDistance(lastPoint[0], lastPoint[1], currentPoint[0], currentPoint[1]);
+ 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) => {
+ const latLngs = polylineCoordinates.map((point) => [point[0], point[1]]);
+ const polyline = L.polyline(latLngs, { color: "blue", opacity: 0.6, weight: 3 });
+
+ addHighlightOnHover(polyline, map, polylineCoordinates, userSettings);
+
+ return polyline;
+ })
+ ).addTo(map);
+}
+
+export function updatePolylinesOpacity(polylinesLayer, opacity) {
+ polylinesLayer.eachLayer((layer) => {
+ if (layer instanceof L.Polyline) {
+ layer.setStyle({ opacity: opacity });
+ }
+ });
+}
diff --git a/app/javascript/maps/popups.js b/app/javascript/maps/popups.js
new file mode 100644
index 00000000..31af3ac0
--- /dev/null
+++ b/app/javascript/maps/popups.js
@@ -0,0 +1,20 @@
+import { formatDate } from "./helpers";
+
+export function createPopupContent(marker, timezone, distanceUnit) {
+ if (distanceUnit === "mi") {
+ // convert marker[5] from km/h to mph
+ marker[5] = marker[5] * 0.621371;
+ // convert marker[3] from meters to feet
+ marker[3] = marker[3] * 3.28084;
+ }
+
+ return `
+ Timestamp: ${formatDate(marker[4], timezone)}
+ Latitude: ${marker[0]}
+ Longitude: ${marker[1]}
+ Altitude: ${marker[3]}m
+ Velocity: ${marker[5]}km/h
+ Battery: ${marker[2]}%
+ [Delete]
+ `;
+}