Merge pull request #347 from Freika/feature/points_reducer

Points rendering mode
This commit is contained in:
Evgenii Burmakin 2024-10-22 12:12:23 +02:00 committed by GitHub
commit fcc581df54
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 517 additions and 426 deletions

View file

@ -1 +1 @@
0.15.7
0.15.8

View file

@ -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

View file

@ -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

View file

@ -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 });
}
});
}
}

View 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);
});
}

View file

@ -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';
}
}

View 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);
});
}

View 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 });
}
});
}

View 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>
`;
}

View file

@ -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>

View file

@ -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
View file

@ -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"