mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 17:21:38 -05:00
Merge pull request #347 from Freika/feature/points_reducer
Points rendering mode
This commit is contained in:
commit
fcc581df54
13 changed files with 517 additions and 426 deletions
|
|
@ -1 +1 @@
|
|||
0.15.7
|
||||
0.15.8
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
# 0.15.8 - 2024-10-22
|
||||
|
||||
### Added
|
||||
|
||||
- User can now select between "Raw" and "Simplified" mode in the map controls. "Simplified" mode will show less points, improving the map performance. "Raw" mode will show all points.
|
||||
|
||||
# 0.15.7 - 2024-10-19
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,30 +1,27 @@
|
|||
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 { showFlashMessage } from "../maps/helpers";
|
||||
|
||||
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";
|
||||
import { esriWorldImageryMapLayer } from "../maps/layers";
|
||||
import { esriWorldGrayCanvasMapLayer } from "../maps/layers";
|
||||
|
||||
import "leaflet-draw";
|
||||
|
||||
export default class extends Controller {
|
||||
|
|
@ -43,6 +40,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 +53,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 +86,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 +142,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 +156,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 `
|
||||
<b>Timestamp:</b> ${formatDate(marker[4], timezone)}<br>
|
||||
<b>Latitude:</b> ${marker[0]}<br>
|
||||
<b>Longitude:</b> ${marker[1]}<br>
|
||||
<b>Altitude:</b> ${marker[3]}m<br>
|
||||
<b>Velocity:</b> ${marker[5]}km/h<br>
|
||||
<b>Battery:</b> ${marker[2]}%<br>
|
||||
<a href="#" data-id="${marker[6]}" class="delete-point">[Delete]</a>
|
||||
`;
|
||||
}
|
||||
|
||||
removeEventListeners() {
|
||||
document.removeEventListener('click', this.handleDeleteClick);
|
||||
}
|
||||
|
|
@ -236,9 +196,9 @@ console.log(selectedLayerName);
|
|||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.status === 'success') {
|
||||
this.showFlashMessage('notice', `Preferred map layer updated to: ${selectedLayerName}`);
|
||||
showFlashMessage('notice', `Preferred map layer updated to: ${selectedLayerName}`);
|
||||
} else {
|
||||
this.showFlashMessage('error', data.message);
|
||||
showFlashMessage('error', data.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -320,133 +280,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 = `
|
||||
<b>Start:</b> ${firstTimestamp}<br>
|
||||
<b>End:</b> ${lastTimestamp}<br>
|
||||
<b>Duration:</b> ${timeOnRoute}<br>
|
||||
<b>Total Distance:</b> ${formatDistance(totalDistance, this.distanceUnit)}<br>
|
||||
`;
|
||||
|
||||
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 += `
|
||||
<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>
|
||||
<b>Points:</b> ${pointsNumber}<br>
|
||||
`;
|
||||
}
|
||||
|
||||
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 +308,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 = `
|
||||
<div class="card w-96 max-w-sm bg-content-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">New Area</h2>
|
||||
<form id="circle-form">
|
||||
<div class="form-control">
|
||||
<label for="circle-name" class="label">
|
||||
<span class="label-text">Name</span>
|
||||
</label>
|
||||
<input type="text" id="circle-name" name="area[name]" class="input input-bordered input-ghost focus:input-ghost w-full max-w-xs" required>
|
||||
</div>
|
||||
<input type="hidden" name="area[latitude]" value="${center.lat}">
|
||||
<input type="hidden" name="area[longitude]" value="${center.lng}">
|
||||
<input type="hidden" name="area[radius]" value="${radius}">
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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}<br>
|
||||
Radius: ${Math.round(data.radius)} meters<br>
|
||||
<a href="#" data-id="${marker[6]}" class="delete-area">[Delete]</a>
|
||||
`).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}<br>
|
||||
Radius: ${Math.round(area.radius)} meters<br>
|
||||
<a href="#" data-id="${area.id}" class="delete-area">[Delete]</a>
|
||||
`);
|
||||
|
||||
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;
|
||||
|
||||
|
|
@ -736,6 +411,19 @@ console.log(selectedLayerName);
|
|||
</div>
|
||||
|
||||
|
||||
<label for="points_rendering_mode">
|
||||
Points rendering mode
|
||||
<label for="points_rendering_mode_info" class="btn-xs join-item inline">?</label>
|
||||
</label>
|
||||
<label for="raw">
|
||||
<input type="radio" id="raw" name="points_rendering_mode" class='w-4' style="width: 20px;" value="raw" ${this.pointsRenderingModeChecked('raw')} />
|
||||
Raw
|
||||
</label>
|
||||
|
||||
<label for="simplified">
|
||||
<input type="radio" id="simplified" name="points_rendering_mode" class='w-4' style="width: 20px;" value="simplified" ${this.pointsRenderingModeChecked('simplified')}/>
|
||||
Simplified
|
||||
</label>
|
||||
|
||||
<button type="submit">Update</button>
|
||||
</form>
|
||||
|
|
@ -761,6 +449,14 @@ console.log(selectedLayerName);
|
|||
this.map.addControl(this.settingsPanel);
|
||||
}
|
||||
|
||||
pointsRenderingModeChecked(value) {
|
||||
if (value === this.pointsRenderingMode) {
|
||||
return 'checked';
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
updateSettings(event) {
|
||||
event.preventDefault();
|
||||
|
||||
|
|
@ -775,80 +471,21 @@ 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
|
||||
},
|
||||
}),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.status === 'success') {
|
||||
this.showFlashMessage('notice', data.message);
|
||||
showFlashMessage('notice', data.message);
|
||||
this.updateMapWithNewSettings(data.settings);
|
||||
} else {
|
||||
this.showFlashMessage('error', data.message);
|
||||
showFlashMessage('error', data.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
showFlashMessage(type, message) {
|
||||
// Create the outer flash container div
|
||||
const flashDiv = document.createElement('div');
|
||||
flashDiv.setAttribute('data-controller', 'removals');
|
||||
flashDiv.className = `flex items-center fixed top-5 right-5 ${this.classesForFlash(type)} py-3 px-5 rounded-lg`;
|
||||
|
||||
// Create the message div
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = 'mr-4';
|
||||
messageDiv.innerText = message;
|
||||
|
||||
// Create the close button
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.setAttribute('type', 'button');
|
||||
closeButton.setAttribute('data-action', 'click->removals#remove');
|
||||
|
||||
// Create the SVG icon for the close button
|
||||
const closeIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
closeIcon.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
closeIcon.setAttribute('class', 'h-6 w-6');
|
||||
closeIcon.setAttribute('fill', 'none');
|
||||
closeIcon.setAttribute('viewBox', '0 0 24 24');
|
||||
closeIcon.setAttribute('stroke', 'currentColor');
|
||||
|
||||
const closeIconPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
closeIconPath.setAttribute('stroke-linecap', 'round');
|
||||
closeIconPath.setAttribute('stroke-linejoin', 'round');
|
||||
closeIconPath.setAttribute('stroke-width', '2');
|
||||
closeIconPath.setAttribute('d', 'M6 18L18 6M6 6l12 12');
|
||||
|
||||
// Append the path to the SVG
|
||||
closeIcon.appendChild(closeIconPath);
|
||||
// Append the SVG to the close button
|
||||
closeButton.appendChild(closeIcon);
|
||||
|
||||
// Append the message and close button to the flash div
|
||||
flashDiv.appendChild(messageDiv);
|
||||
flashDiv.appendChild(closeButton);
|
||||
|
||||
// Append the flash message to the body or a specific flash container
|
||||
document.body.appendChild(flashDiv);
|
||||
|
||||
// Optional: Automatically remove the flash message after 5 seconds
|
||||
setTimeout(() => {
|
||||
flashDiv.remove();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Helper function to get flash classes based on type
|
||||
classesForFlash(type) {
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return 'bg-red-100 text-red-700 border-red-300';
|
||||
case 'notice':
|
||||
return 'bg-blue-100 text-blue-700 border-blue-300';
|
||||
default:
|
||||
return 'bg-blue-100 text-blue-700 border-blue-300';
|
||||
}
|
||||
}
|
||||
|
||||
updateMapWithNewSettings(newSettings) {
|
||||
const currentLayerStates = this.getLayerControlStates();
|
||||
|
||||
|
|
@ -873,14 +510,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 +546,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') {
|
||||
|
|
@ -962,7 +599,6 @@ console.log(selectedLayerName);
|
|||
return undefined; // Indicate no matching layer name found
|
||||
}
|
||||
|
||||
|
||||
applyLayerControlStates(states) {
|
||||
const layerControl = {
|
||||
Points: this.markersLayer,
|
||||
|
|
@ -986,13 +622,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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
160
app/javascript/maps/areas.js
Normal file
160
app/javascript/maps/areas.js
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
export function handleAreaCreated(areasLayer, layer, apiKey) {
|
||||
const radius = layer.getRadius();
|
||||
const center = layer.getLatLng();
|
||||
|
||||
const formHtml = `
|
||||
<div class="card w-96 max-w-sm bg-content-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">New Area</h2>
|
||||
<form id="circle-form">
|
||||
<div class="form-control">
|
||||
<label for="circle-name" class="label">
|
||||
<span class="label-text">Name</span>
|
||||
</label>
|
||||
<input type="text" id="circle-name" name="area[name]" class="input input-bordered input-ghost focus:input-ghost w-full max-w-xs" required>
|
||||
</div>
|
||||
<input type="hidden" name="area[latitude]" value="${center.lat}">
|
||||
<input type="hidden" name="area[longitude]" value="${center.lng}">
|
||||
<input type="hidden" name="area[radius]" value="${radius}">
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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}<br>
|
||||
Radius: ${Math.round(data.radius)} meters<br>
|
||||
<a href="#" data-id="${data.id}" class="delete-area">[Delete]</a>
|
||||
`).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}<br>
|
||||
Radius: ${Math.round(area.radius)} meters<br>
|
||||
<a href="#" data-id="${area.id}" class="delete-area">[Delete]</a>
|
||||
`);
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -76,3 +77,62 @@ export function haversineDistance(lat1, lon1, lat2, lon2, unit = 'km') {
|
|||
return R_km * c; // Distance in kilometers
|
||||
}
|
||||
}
|
||||
|
||||
export function showFlashMessage(type, message) {
|
||||
// Create the outer flash container div
|
||||
const flashDiv = document.createElement('div');
|
||||
flashDiv.setAttribute('data-controller', 'removals');
|
||||
flashDiv.className = `flex items-center fixed top-5 right-5 ${classesForFlash(type)} py-3 px-5 rounded-lg`;
|
||||
|
||||
// Create the message div
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = 'mr-4';
|
||||
messageDiv.innerText = message;
|
||||
|
||||
// Create the close button
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.setAttribute('type', 'button');
|
||||
closeButton.setAttribute('data-action', 'click->removals#remove');
|
||||
|
||||
// Create the SVG icon for the close button
|
||||
const closeIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
closeIcon.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
closeIcon.setAttribute('class', 'h-6 w-6');
|
||||
closeIcon.setAttribute('fill', 'none');
|
||||
closeIcon.setAttribute('viewBox', '0 0 24 24');
|
||||
closeIcon.setAttribute('stroke', 'currentColor');
|
||||
|
||||
const closeIconPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
closeIconPath.setAttribute('stroke-linecap', 'round');
|
||||
closeIconPath.setAttribute('stroke-linejoin', 'round');
|
||||
closeIconPath.setAttribute('stroke-width', '2');
|
||||
closeIconPath.setAttribute('d', 'M6 18L18 6M6 6l12 12');
|
||||
|
||||
// Append the path to the SVG
|
||||
closeIcon.appendChild(closeIconPath);
|
||||
// Append the SVG to the close button
|
||||
closeButton.appendChild(closeIcon);
|
||||
|
||||
// Append the message and close button to the flash div
|
||||
flashDiv.appendChild(messageDiv);
|
||||
flashDiv.appendChild(closeButton);
|
||||
|
||||
// Append the flash message to the body or a specific flash container
|
||||
document.body.appendChild(flashDiv);
|
||||
|
||||
// Optional: Automatically remove the flash message after 5 seconds
|
||||
setTimeout(() => {
|
||||
flashDiv.remove();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function classesForFlash(type) {
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return 'bg-red-100 text-red-700 border-red-300';
|
||||
case 'notice':
|
||||
return 'bg-blue-100 text-blue-700 border-blue-300';
|
||||
default:
|
||||
return 'bg-blue-100 text-blue-700 border-blue-300';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
45
app/javascript/maps/markers.js
Normal file
45
app/javascript/maps/markers.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
139
app/javascript/maps/polylines.js
Normal file
139
app/javascript/maps/polylines.js
Normal file
|
|
@ -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 = `
|
||||
<strong>Start:</strong> ${firstTimestamp}<br>
|
||||
<strong>End:</strong> ${lastTimestamp}<br>
|
||||
<strong>Duration:</strong> ${timeOnRoute}<br>
|
||||
<strong>Total Distance:</strong> ${formatDistance(totalDistance, userSettings.distanceUnit)}<br>
|
||||
`;
|
||||
|
||||
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 += `
|
||||
<strong>Prev Route:</strong> ${Math.round(distanceToPrev)}m and ${minutesToDaysHoursMinutes(timeBetweenPrev)} away<br>
|
||||
<strong>Next Route:</strong> ${Math.round(distanceToNext)}m and ${minutesToDaysHoursMinutes(timeBetweenNext)} away<br>
|
||||
<strong>Points:</strong> ${pointsNumber}<br>
|
||||
`;
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
20
app/javascript/maps/popups.js
Normal file
20
app/javascript/maps/popups.js
Normal file
|
|
@ -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 `
|
||||
<strong>Timestamp:</strong> ${formatDate(marker[4], timezone)}<br>
|
||||
<strong>Latitude:</strong> ${marker[0]}<br>
|
||||
<strong>Longitude:</strong> ${marker[1]}<br>
|
||||
<strong>Altitude:</strong> ${marker[3]}m<br>
|
||||
<strong>Velocity:</strong> ${marker[5]}km/h<br>
|
||||
<strong>Battery:</strong> ${marker[2]}%<br>
|
||||
<a href="#" data-id="${marker[6]}" class="delete-point">[Delete]</a>
|
||||
`;
|
||||
}
|
||||
|
|
@ -95,3 +95,20 @@
|
|||
</div>
|
||||
<label class="modal-backdrop" for="merge_threshold_minutes_info">Close</label>
|
||||
</div>
|
||||
|
||||
<input type="checkbox" id="points_rendering_mode_info" class="modal-toggle" />
|
||||
<div class="modal focus:z-99" role="dialog">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">Points rendering mode</h3>
|
||||
<p class="py-4">
|
||||
To improve map performance, you can set the rendering mode for points to "Simplified".
|
||||
</p>
|
||||
<p class="py-4">
|
||||
In this mode, the points that are closer to each other than 20 seconds or 50 meters are not being rendered. This can significantly improve the performance of the map, especially if you have a lot of points on the map.
|
||||
</p>
|
||||
<p class="py-4">
|
||||
The "Raw" mode will render all points on the map, regardless of the distance in space and time between them.
|
||||
</p>
|
||||
</div>
|
||||
<label class="modal-backdrop" for="points_rendering_mode_info">Close</label>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddPointsRenderingModeToSettings < ActiveRecord::Migration[7.2]
|
||||
def up
|
||||
User.find_each do |user|
|
||||
user.settings = user.settings.merge(points_rendering_mode: 'raw')
|
||||
user.save!
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
raise ActiveRecord::IrreversibleMigration
|
||||
end
|
||||
end
|
||||
5
db/schema.rb
generated
5
db/schema.rb
generated
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2024_08_22_092405) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2024_08_22_092405) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
|
||||
|
|
@ -53,6 +53,9 @@ ActiveRecord::Schema[7.1].define(version: 2024_08_22_092405) do
|
|||
t.index ["user_id"], name: "index_areas_on_user_id"
|
||||
end
|
||||
|
||||
create_table "data_migrations", primary_key: "version", id: :string, force: :cascade do |t|
|
||||
end
|
||||
|
||||
create_table "exports", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "url"
|
||||
|
|
|
|||
Loading…
Reference in a new issue