From b4db5f9376d70d283fb20ab13f8bd59cc6a21b3b Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 20 Oct 2024 20:23:58 +0200 Subject: [PATCH] Move the code for handling areas, markers and polylines to separate files --- app/controllers/api/v1/settings_controller.rb | 2 +- app/javascript/controllers/maps_controller.js | 390 ++---------------- app/javascript/maps/areas.js | 160 +++++++ app/javascript/maps/helpers.js | 1 + app/javascript/maps/markers.js | 45 ++ app/javascript/maps/polylines.js | 139 +++++++ app/javascript/maps/popups.js | 20 + 7 files changed, 399 insertions(+), 358 deletions(-) create mode 100644 app/javascript/maps/areas.js create mode 100644 app/javascript/maps/markers.js create mode 100644 app/javascript/maps/polylines.js create mode 100644 app/javascript/maps/popups.js 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 = ` -
-
-

New Area

-
-
- - -
- - - -
- -
-
-
-
- `; - - 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 = ` +
+
+

New Area

+
+
+ + +
+ + + +
+ +
+
+
+
+ `; + + 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] + `; +}