diff --git a/.app_version b/.app_version index 965065db..a602fc9e 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.9.3 +0.9.4 diff --git a/.gitignore b/.gitignore index 591f7232..909855de 100644 --- a/.gitignore +++ b/.gitignore @@ -26,8 +26,12 @@ !/tmp/storage/.keep /public/assets -/public/exports -/public/imports + +# We need directories for import and export files, but not the files themselves. +/public/exports/* +!/public/exports/.keep +/public/imports/* +!/public/imports/.keep # Ignore master key for decrypting credentials and more. /config/master.key diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a953daa..ad234c41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [0.9.4] — 2024-07-21 + +### Fixed + +- Added `public/imports` and `public/exports` folders to git to prevent errors when exporting data + +### Changed + +- Some code from `maps_controller.js` was extracted into separate files + +--- + ## [0.9.3] — 2024-07-19 diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 9cc55816..cc6bac1d 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -1,6 +1,14 @@ 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 { osmMapLayer } from "../maps/layers"; +import { osmHotMapLayer } from "../maps/layers"; +import { addTileLayer } from "../maps/layers"; export default class extends Controller { static targets = ["container"]; @@ -15,7 +23,7 @@ export default class extends Controller { const clearFogRadius = this.element.dataset.fog_of_war_meters; const map = L.map(this.containerTarget, { - layers: [this.osmMapLayer(), this.osmHotMapLayer()], + layers: [osmMapLayer(), osmHotMapLayer()], }).setView([center[0], center[1]], 14); const markersArray = this.createMarkersArray(markers); @@ -41,7 +49,7 @@ export default class extends Controller { }) .addTo(map); - const layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(map); + L.control.layers(this.baseMaps(), controlsLayer).addTo(map); let fogEnabled = false; @@ -110,7 +118,7 @@ export default class extends Controller { fog.appendChild(circle); } - this.addTileLayer(map); + addTileLayer(map); this.addLastMarker(map, markers); } @@ -118,24 +126,10 @@ export default class extends Controller { this.map.remove(); } - osmMapLayer() { - return L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { - maxZoom: 19, - attribution: "© OpenStreetMap", - }); - } - - osmHotMapLayer() { - return L.tileLayer("https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png", { - maxZoom: 19, - attribution: "© OpenStreetMap contributors, Tiles style by Humanitarian OpenStreetMap Team hosted by OpenStreetMap France", - }); - } - baseMaps() { return { - OpenStreetMap: this.osmMapLayer(), - "OpenStreetMap.HOT": this.osmHotMapLayer(), + OpenStreetMap: osmMapLayer(), + "OpenStreetMap.HOT": osmHotMapLayer(), }; } @@ -148,8 +142,9 @@ export default class extends Controller { } createPopupContent(marker) { + const timezone = this.element.dataset.timezone; return ` - Timestamp: ${this.formatDate(marker[4])}
+ Timestamp: ${formatDate(marker[4], timezone)}
Latitude: ${marker[0]}
Longitude: ${marker[1]}
Altitude: ${marker[3]}m
@@ -158,19 +153,6 @@ export default class extends Controller { `; } - formatDate(timestamp) { - const date = new Date(timestamp * 1000); - const timezone = this.element.dataset.timezone; - return date.toLocaleString("en-GB", { timeZone: timezone }); - } - - addTileLayer(map) { - L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { - maxZoom: 19, - attribution: "© OpenStreetMap", - }).addTo(map); - } - addLastMarker(map, markers) { if (markers.length > 0) { const lastMarker = markers[markers.length - 1].slice(0, 2); @@ -178,44 +160,6 @@ export default class extends Controller { } } - haversineDistance(lat1, lon1, lat2, lon2) { - const toRad = (x) => (x * Math.PI) / 180; - const R = 6371; // Radius of the Earth in kilometers - const dLat = toRad(lat2 - lat1); - const dLon = toRad(lon2 - lon1); - const a = - Math.sin(dLat / 2) * Math.sin(dLat / 2) + - Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * - Math.sin(dLon / 2) * Math.sin(dLon / 2); - const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - return R * c * 1000; // Distance in meters - } - - minutesToDaysHoursMinutes(minutes) { - const days = Math.floor(minutes / (24 * 60)); - const hours = Math.floor((minutes % (24 * 60)) / 60); - minutes = minutes % 60; - let result = ""; - - if (days > 0) { - result += `${days}d `; - } - - if (hours > 0) { - result += `${hours}h `; - } - - if (minutes > 0) { - result += `${minutes}min`; - } - - return result; - } - - getUrlParameter(name) { - return new URLSearchParams(window.location.search).get(name); - } - addHighlightOnHover(polyline, map, startPoint, endPoint, prevPoint, nextPoint, timezone) { const originalStyle = { color: "blue", opacity: 0.6, weight: 3 }; const highlightStyle = { color: "yellow", opacity: 1, weight: 5 }; @@ -226,11 +170,11 @@ export default class extends Controller { const lastTimestamp = new Date(endPoint[4] * 1000).toLocaleString("en-GB", { timeZone: timezone }); const minutes = Math.round((endPoint[4] - startPoint[4]) / 60); - const timeOnRoute = this.minutesToDaysHoursMinutes(minutes); - const distance = this.haversineDistance(startPoint[0], startPoint[1], endPoint[0], endPoint[1]); + const timeOnRoute = minutesToDaysHoursMinutes(minutes); + const distance = haversineDistance(startPoint[0], startPoint[1], endPoint[0], endPoint[1]); - const distanceToPrev = prevPoint ? this.haversineDistance(prevPoint[0], prevPoint[1], startPoint[0], startPoint[1]) : "N/A"; - const distanceToNext = nextPoint ? this.haversineDistance(endPoint[0], endPoint[1], nextPoint[0], nextPoint[1]) : "N/A"; + const distanceToPrev = prevPoint ? haversineDistance(prevPoint[0], prevPoint[1], startPoint[0], startPoint[1]) : "N/A"; + const distanceToNext = nextPoint ? haversineDistance(endPoint[0], endPoint[1], nextPoint[0], nextPoint[1]) : "N/A"; const timeBetweenPrev = prevPoint ? Math.round((startPoint[4] - prevPoint[4]) / 60) : "N/A"; const timeBetweenNext = nextPoint ? Math.round((nextPoint[4] - endPoint[4]) / 60) : "N/A"; @@ -238,19 +182,19 @@ export default class extends Controller { const startIcon = L.divIcon({ html: "🚥", className: "emoji-icon" }); const finishIcon = L.divIcon({ html: "🏁", className: "emoji-icon" }); - const isDebugMode = this.getUrlParameter("debug") === "true"; + const isDebugMode = getUrlParameter("debug") === "true"; let popupContent = ` Start: ${firstTimestamp}
End: ${lastTimestamp}
Duration: ${timeOnRoute}
- Distance: ${this.formatDistance(distance)}
+ Distance: ${formatDistance(distance)}
`; if (isDebugMode) { popupContent += ` - Prev Route: ${Math.round(distanceToPrev)}m and ${this.minutesToDaysHoursMinutes(timeBetweenPrev)} away
- Next Route: ${Math.round(distanceToNext)}m and ${this.minutesToDaysHoursMinutes(timeBetweenNext)} away
+ Prev Route: ${Math.round(distanceToPrev)}m and ${minutesToDaysHoursMinutes(timeBetweenPrev)} away
+ Next Route: ${Math.round(distanceToNext)}m and ${minutesToDaysHoursMinutes(timeBetweenNext)} away
`; } @@ -303,7 +247,7 @@ export default class extends Controller { } else { const lastPoint = currentPolyline[currentPolyline.length - 1]; const currentPoint = markers[i]; - const distance = this.haversineDistance(lastPoint[0], lastPoint[1], currentPoint[0], currentPoint[1]); + const distance = haversineDistance(lastPoint[0], lastPoint[1], currentPoint[0], currentPoint[1]); const timeDifference = (currentPoint[4] - lastPoint[4]) / 60; if (distance > distanceThresholdMeters || timeDifference > timeThresholdMinutes) { @@ -335,12 +279,4 @@ export default class extends Controller { }) ).addTo(map); } - - formatDistance(distance) { - if (distance >= 1000) { - return (distance / 1000).toFixed(2) + ' km'; - } else { - return distance.toFixed(0) + ' meters'; - } - } } diff --git a/app/javascript/maps/content.js b/app/javascript/maps/content.js new file mode 100644 index 00000000..899e5de8 --- /dev/null +++ b/app/javascript/maps/content.js @@ -0,0 +1,46 @@ +// Markers, polylines, and popups + +export function createPolylinesLayer(markers, map, timezone) { + const splitPolylines = []; + let currentPolyline = []; + const distanceThresholdMeters = parseInt(this.element.dataset.meters_between_routes) || 500; + const timeThresholdMinutes = parseInt(this.element.dataset.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, index) => { + const latLngs = polylineCoordinates.map((point) => [point[0], point[1]]); + const polyline = L.polyline(latLngs, { color: "blue", opacity: 0.6, weight: 3 }); + + const startPoint = polylineCoordinates[0]; + const endPoint = polylineCoordinates[polylineCoordinates.length - 1]; + const prevPoint = index > 0 ? splitPolylines[index - 1][splitPolylines[index - 1].length - 1] : null; + const nextPoint = index < splitPolylines.length - 1 ? splitPolylines[index + 1][0] : null; + + this.addHighlightOnHover(polyline, map, startPoint, endPoint, prevPoint, nextPoint, timezone); + + return polyline; + }) + ).addTo(map); +} diff --git a/app/javascript/maps/helpers.js b/app/javascript/maps/helpers.js new file mode 100644 index 00000000..4a7d6817 --- /dev/null +++ b/app/javascript/maps/helpers.js @@ -0,0 +1,51 @@ +// javascript/maps/helpers.js +export function formatDistance(distance) { + if (distance < 1000) { + return `${distance.toFixed(2)} meters`; + } else { + return `${(distance / 1000).toFixed(2)} km`; + } +} + +export function getUrlParameter(name) { + return new URLSearchParams(window.location.search).get(name); +} + +export function minutesToDaysHoursMinutes(minutes) { + const days = Math.floor(minutes / (24 * 60)); + const hours = Math.floor((minutes % (24 * 60)) / 60); + minutes = minutes % 60; + let result = ""; + + if (days > 0) { + result += `${days}d `; + } + + if (hours > 0) { + result += `${hours}h `; + } + + if (minutes > 0) { + result += `${minutes}min`; + } + + return result; +} + +export function formatDate(timestamp, timezone) { + const date = new Date(timestamp * 1000); + return date.toLocaleString("en-GB", { timeZone: timezone }); +} + +export function haversineDistance(lat1, lon1, lat2, lon2) { + const toRad = (x) => (x * Math.PI) / 180; + const R = 6371; // Radius of the Earth in kilometers + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * + Math.sin(dLon / 2) * Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c * 1000; // Distance in meters +} diff --git a/app/javascript/maps/layers.js b/app/javascript/maps/layers.js new file mode 100644 index 00000000..a5b2196b --- /dev/null +++ b/app/javascript/maps/layers.js @@ -0,0 +1,21 @@ + +export function osmMapLayer() { + return L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 19, + attribution: "© OpenStreetMap", + }); +} + +export function osmHotMapLayer() { + return L.tileLayer("https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png", { + maxZoom: 19, + attribution: "© OpenStreetMap contributors, Tiles style by Humanitarian OpenStreetMap Team hosted by OpenStreetMap France", + }); +} + +export function addTileLayer(map) { + L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 19, + attribution: "© OpenStreetMap", + }).addTo(map); +}