diff --git a/CHANGELOG.md b/CHANGELOG.md
index a34d9ea6..be1d0faa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,13 +5,21 @@ 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.22.4 - 2025-01-15
+# 0.22.4 - 2025-01-20
+
+### Added
+
+- You can now drag-n-drop a point on the map to update its position. Enable the "Points" layer on the map to see the points.
### Changed
- Run seeds even in prod env so Unraid users could have default user.
- Precompile assets in production env using dummy secret key base.
+### Fixed
+
+- Fixed a bug where route wasn't highlighted when it was hovered or clicked.
+
# 0.22.3 - 2025-01-14
### Changed
diff --git a/app/controllers/api/v1/points_controller.rb b/app/controllers/api/v1/points_controller.rb
index a70dabdc..7905ca68 100644
--- a/app/controllers/api/v1/points_controller.rb
+++ b/app/controllers/api/v1/points_controller.rb
@@ -21,6 +21,14 @@ class Api::V1::PointsController < ApiController
render json: serialized_points
end
+ def update
+ point = current_api_user.tracked_points.find(params[:id])
+
+ point.update(point_params)
+
+ render json: point_serializer.new(point).call
+ end
+
def destroy
point = current_api_user.tracked_points.find(params[:id])
point.destroy
@@ -30,6 +38,10 @@ class Api::V1::PointsController < ApiController
private
+ def point_params
+ params.require(:point).permit(:latitude, :longitude)
+ end
+
def point_serializer
params[:slim] == 'true' ? Api::SlimPointSerializer : Api::PointSerializer
end
diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js
index 01fa6ad7..313b477d 100644
--- a/app/javascript/controllers/maps_controller.js
+++ b/app/javascript/controllers/maps_controller.js
@@ -61,6 +61,35 @@ export default class extends Controller {
this.map = L.map(this.containerTarget).setView([this.center[0], this.center[1]], 14);
+ // Add scale control
+ L.control.scale({
+ position: 'bottomright',
+ imperial: this.distanceUnit === 'mi',
+ metric: this.distanceUnit === 'km',
+ maxWidth: 120
+ }).addTo(this.map)
+
+ // Add stats control
+ const StatsControl = L.Control.extend({
+ options: {
+ position: 'bottomright'
+ },
+ onAdd: (map) => {
+ const div = L.DomUtil.create('div', 'leaflet-control-stats');
+ const distance = this.element.dataset.distance || '0';
+ const pointsNumber = this.element.dataset.points_number || '0';
+ const unit = this.distanceUnit === 'mi' ? 'mi' : 'km';
+ div.innerHTML = `${distance} ${unit} | ${pointsNumber} points`;
+ div.style.backgroundColor = 'white';
+ div.style.padding = '0 5px';
+ div.style.marginRight = '5px';
+ div.style.display = 'inline-block';
+ return div;
+ }
+ });
+
+ new StatsControl().addTo(this.map);
+
// Set the maximum bounds to prevent infinite scroll
var southWest = L.latLng(-120, -210);
var northEast = L.latLng(120, 210);
@@ -68,7 +97,7 @@ export default class extends Controller {
this.map.setMaxBounds(bounds);
- this.markersArray = createMarkersArray(this.markers, this.userSettings);
+ this.markersArray = createMarkersArray(this.markers, this.userSettings, this.apiKey);
this.markersLayer = L.layerGroup(this.markersArray);
this.heatmapMarkers = this.markersArray.map((element) => [element._latlng.lat, element._latlng.lng, 0.2]);
@@ -98,35 +127,41 @@ export default class extends Controller {
Photos: this.photoMarkers
};
- // Add this new custom control BEFORE the scale control
- const TestControl = L.Control.extend({
- onAdd: (map) => {
- const div = L.DomUtil.create('div', 'leaflet-control');
- const distance = this.element.dataset.distance || '0';
- const pointsNumber = this.element.dataset.points_number || '0';
- const unit = this.distanceUnit === 'mi' ? 'mi' : 'km';
- div.innerHTML = `${distance} ${unit} | ${pointsNumber} points`;
- div.style.backgroundColor = 'white';
- div.style.padding = '0 5px';
- div.style.marginRight = '5px';
- div.style.display = 'inline-block';
- return div;
+ // Initialize layer control first
+ this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
+
+ // Add the toggle panel button
+ this.addTogglePanelButton();
+
+ // Check if we should open the panel based on localStorage or URL params
+ const urlParams = new URLSearchParams(window.location.search);
+ const isPanelOpen = localStorage.getItem('mapPanelOpen') === 'true';
+ const hasDateParams = urlParams.has('start_at') && urlParams.has('end_at');
+
+ // Always create the panel first
+ this.toggleRightPanel();
+
+ // Then hide it if it shouldn't be open
+ if (!isPanelOpen && !hasDateParams) {
+ const panel = document.querySelector('.leaflet-right-panel');
+ if (panel) {
+ panel.style.display = 'none';
+ localStorage.setItem('mapPanelOpen', 'false');
+ }
+ }
+
+ // Update event handlers
+ this.map.on('moveend', () => {
+ if (document.getElementById('fog')) {
+ this.updateFog(this.markers, this.clearFogRadius);
}
});
- // Add the test control first
- new TestControl({ position: 'bottomright' }).addTo(this.map);
-
- // Then add scale control
- L.control.scale({
- position: 'bottomright',
- imperial: this.distanceUnit === 'mi',
- metric: this.distanceUnit === 'km',
- maxWidth: 120
- }).addTo(this.map)
-
- // Initialize layer control
- this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
+ this.map.on('zoomend', () => {
+ if (document.getElementById('fog')) {
+ this.updateFog(this.markers, this.clearFogRadius);
+ }
+ });
// Fetch and draw areas when the map is loaded
fetchAndDrawAreas(this.areasLayer, this.apiKey);
@@ -205,39 +240,6 @@ export default class extends Controller {
if (this.liveMapEnabled) {
this.setupSubscription();
}
-
- // Add the toggle panel button
- this.addTogglePanelButton();
-
- // Check if we should open the panel based on localStorage or URL params
- const urlParams = new URLSearchParams(window.location.search);
- const isPanelOpen = localStorage.getItem('mapPanelOpen') === 'true';
- const hasDateParams = urlParams.has('start_at') && urlParams.has('end_at');
-
- // Always create the panel first
- this.toggleRightPanel();
-
- // Then hide it if it shouldn't be open
- if (!isPanelOpen && !hasDateParams) {
- const panel = document.querySelector('.leaflet-right-panel');
- if (panel) {
- panel.style.display = 'none';
- localStorage.setItem('mapPanelOpen', 'false');
- }
- }
-
- // Update event handlers
- this.map.on('moveend', () => {
- if (document.getElementById('fog')) {
- this.updateFog(this.markers, this.clearFogRadius);
- }
- });
-
- this.map.on('zoomend', () => {
- if (document.getElementById('fog')) {
- this.updateFog(this.markers, this.clearFogRadius);
- }
- });
}
disconnect() {
@@ -786,164 +788,84 @@ export default class extends Controller {
}
updateMapWithNewSettings(newSettings) {
- console.log('Updating map settings:', {
- newSettings,
- currentSettings: this.userSettings,
- hasPolylines: !!this.polylinesLayer,
- isVisible: this.polylinesLayer && this.map.hasLayer(this.polylinesLayer)
- });
-
// Show loading indicator
const loadingDiv = document.createElement('div');
loadingDiv.className = 'map-loading-overlay';
loadingDiv.innerHTML = '
Updating map...
';
document.body.appendChild(loadingDiv);
- // Debounce the heavy operations
- const updateLayers = debounce(() => {
- try {
- // Store current layer visibility states
- const layerStates = {
- Points: this.map.hasLayer(this.markersLayer),
- Routes: this.map.hasLayer(this.polylinesLayer),
- Heatmap: this.map.hasLayer(this.heatmapLayer),
- "Fog of War": this.map.hasLayer(this.fogOverlay),
- "Scratch map": this.map.hasLayer(this.scratchLayer),
- Areas: this.map.hasLayer(this.areasLayer),
- Photos: this.map.hasLayer(this.photoMarkers)
- };
-
- // Check if speed_colored_routes setting has changed
- if (newSettings.speed_colored_routes !== this.userSettings.speed_colored_routes) {
- if (this.polylinesLayer) {
- updatePolylinesColors(
- this.polylinesLayer,
- newSettings.speed_colored_routes
- );
- }
+ try {
+ // Update settings first
+ if (newSettings.speed_colored_routes !== this.userSettings.speed_colored_routes) {
+ if (this.polylinesLayer) {
+ updatePolylinesColors(
+ this.polylinesLayer,
+ newSettings.speed_colored_routes
+ );
}
-
- // Update opacity if changed
- if (newSettings.route_opacity !== this.userSettings.route_opacity) {
- const newOpacity = parseFloat(newSettings.route_opacity) || 0.6;
- if (this.polylinesLayer) {
- updatePolylinesOpacity(this.polylinesLayer, newOpacity);
- }
- }
-
- // Update the local settings
- this.userSettings = { ...this.userSettings, ...newSettings };
- this.routeOpacity = parseFloat(newSettings.route_opacity) || 0.6;
- this.clearFogRadius = parseInt(newSettings.fog_of_war_meters) || 50;
-
- // Remove existing layer control
- if (this.layerControl) {
- this.map.removeControl(this.layerControl);
- }
-
- // Create new controls layer object with proper initialization
- const controlsLayer = {
- Points: this.markersLayer || L.layerGroup(),
- Routes: this.polylinesLayer || L.layerGroup(),
- Heatmap: this.heatmapLayer || L.heatLayer([]),
- "Fog of War": new this.fogOverlay(),
- "Scratch map": this.scratchLayer || L.layerGroup(),
- Areas: this.areasLayer || L.layerGroup(),
- Photos: this.photoMarkers || L.layerGroup()
- };
-
- // Add new layer control
- this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
-
- // Restore layer visibility states
- Object.entries(layerStates).forEach(([name, wasVisible]) => {
- const layer = controlsLayer[name];
- if (wasVisible && layer) {
- layer.addTo(this.map);
- } else if (layer && this.map.hasLayer(layer)) {
- this.map.removeLayer(layer);
- }
- });
-
- } catch (error) {
- console.error('Error updating map settings:', error);
- console.error(error.stack);
- } finally {
- // Remove loading indicator after all updates are complete
- setTimeout(() => {
- document.body.removeChild(loadingDiv);
- }, 500); // Give a small delay to ensure all batches are processed
}
- }, 250);
- updateLayers();
- }
-
- getLayerControlStates() {
- const controls = {};
-
- this.map.eachLayer((layer) => {
- const layerName = this.getLayerName(layer);
-
- if (layerName) {
- controls[layerName] = this.map.hasLayer(layer);
+ if (newSettings.route_opacity !== this.userSettings.route_opacity) {
+ const newOpacity = parseFloat(newSettings.route_opacity) || 0.6;
+ if (this.polylinesLayer) {
+ updatePolylinesOpacity(this.polylinesLayer, newOpacity);
+ }
}
- });
- return controls;
- }
+ // Update the local settings
+ this.userSettings = { ...this.userSettings, ...newSettings };
+ this.routeOpacity = parseFloat(newSettings.route_opacity) || 0.6;
+ this.clearFogRadius = parseInt(newSettings.fog_of_war_meters) || 50;
- getLayerName(layer) {
- const controlLayers = {
- Points: this.markersLayer,
- Routes: this.polylinesLayer,
- Heatmap: this.heatmapLayer,
- "Fog of War": this.fogOverlay,
- Areas: this.areasLayer,
- };
+ // Store current layer states
+ const layerStates = {
+ Points: this.map.hasLayer(this.markersLayer),
+ Routes: this.map.hasLayer(this.polylinesLayer),
+ Heatmap: this.map.hasLayer(this.heatmapLayer),
+ "Fog of War": this.map.hasLayer(this.fogOverlay),
+ "Scratch map": this.map.hasLayer(this.scratchLayer),
+ Areas: this.map.hasLayer(this.areasLayer),
+ Photos: this.map.hasLayer(this.photoMarkers)
+ };
- for (const [name, val] of Object.entries(controlLayers)) {
- if (val && val.hasLayer && layer && val.hasLayer(layer)) // Check if the group layer contains the current layer
- return name;
- }
+ // Remove only the layer control
+ if (this.layerControl) {
+ this.map.removeControl(this.layerControl);
+ }
- // Direct instance matching
- for (const [name, val] of Object.entries(controlLayers)) {
- if (val === layer) return name;
- }
+ // Create new controls layer object
+ const controlsLayer = {
+ Points: this.markersLayer || L.layerGroup(),
+ Routes: this.polylinesLayer || L.layerGroup(),
+ Heatmap: this.heatmapLayer || L.heatLayer([]),
+ "Fog of War": new this.fogOverlay(),
+ "Scratch map": this.scratchLayer || L.layerGroup(),
+ Areas: this.areasLayer || L.layerGroup(),
+ Photos: this.photoMarkers || L.layerGroup()
+ };
- return undefined; // Indicate no matching layer name found
- }
+ // Re-add the layer control in the same position
+ this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
- applyLayerControlStates(states) {
- console.log('Applying layer states:', states);
-
- const layerControl = {
- Points: this.markersLayer,
- Routes: this.polylinesLayer,
- Heatmap: this.heatmapLayer,
- "Fog of War": this.fogOverlay,
- Areas: this.areasLayer,
- };
-
- for (const [name, isVisible] of Object.entries(states)) {
- const layer = layerControl[name];
- console.log(`Processing layer ${name}:`, { layer, isVisible });
-
- if (layer) {
- if (isVisible && !this.map.hasLayer(layer)) {
- console.log(`Adding layer ${name} to map`);
- this.map.addLayer(layer);
- } else if (!isVisible && this.map.hasLayer(layer)) {
- console.log(`Removing layer ${name} from map`);
+ // Restore layer visibility states
+ Object.entries(layerStates).forEach(([name, wasVisible]) => {
+ const layer = controlsLayer[name];
+ if (wasVisible && layer) {
+ layer.addTo(this.map);
+ } else if (layer && this.map.hasLayer(layer)) {
this.map.removeLayer(layer);
}
- }
- }
+ });
- // Ensure the layer control reflects the current state
- this.map.removeControl(this.layerControl);
- this.layerControl = L.control.layers(this.baseMaps(), layerControl).addTo(this.map);
+ } catch (error) {
+ console.error('Error updating map settings:', error);
+ console.error(error.stack);
+ } finally {
+ // Remove loading indicator
+ setTimeout(() => {
+ document.body.removeChild(loadingDiv);
+ }, 500);
+ }
}
createPhotoMarker(photo) {
diff --git a/app/javascript/maps/fog_of_war.js b/app/javascript/maps/fog_of_war.js
index 482a161e..8e910274 100644
--- a/app/javascript/maps/fog_of_war.js
+++ b/app/javascript/maps/fog_of_war.js
@@ -25,7 +25,8 @@ export function initializeFogCanvas(map) {
export function drawFogCanvas(map, markers, clearFogRadius) {
const fog = document.getElementById('fog');
- if (!fog) return;
+ // Return early if fog element doesn't exist or isn't a canvas
+ if (!fog || !(fog instanceof HTMLCanvasElement)) return;
const ctx = fog.getContext('2d');
if (!ctx) return;
@@ -83,12 +84,25 @@ export function createFogOverlay() {
return L.Layer.extend({
onAdd: (map) => {
initializeFogCanvas(map);
+
+ // Add drag event handlers to update fog during marker movement
+ map.on('drag', () => {
+ const fog = document.getElementById('fog');
+ if (fog) {
+ // Update fog canvas position to match map position
+ const mapPos = map.getContainer().getBoundingClientRect();
+ fog.style.left = `${mapPos.left}px`;
+ fog.style.top = `${mapPos.top}px`;
+ }
+ });
},
onRemove: (map) => {
const fog = document.getElementById('fog');
if (fog) {
fog.remove();
}
+ // Clean up event listener
+ map.off('drag');
}
});
}
diff --git a/app/javascript/maps/markers.js b/app/javascript/maps/markers.js
index d55ee7fb..610a81dc 100644
--- a/app/javascript/maps/markers.js
+++ b/app/javascript/maps/markers.js
@@ -1,28 +1,163 @@
import { createPopupContent } from "./popups";
-export function createMarkersArray(markersData, userSettings) {
+export function createMarkersArray(markersData, userSettings, apiKey) {
// Create a canvas renderer
const renderer = L.canvas({ padding: 0.5 });
if (userSettings.pointsRenderingMode === "simplified") {
return createSimplifiedMarkers(markersData, renderer);
} else {
- return markersData.map((marker) => {
+ return markersData.map((marker, index) => {
const [lat, lon] = marker;
- const popupContent = createPopupContent(marker, userSettings.timezone, userSettings.distanceUnit);
- let markerColor = marker[5] < 0 ? "orange" : "blue";
+ const pointId = marker[6]; // ID is at index 6
+ const markerColor = marker[5] < 0 ? "orange" : "blue";
- return L.circleMarker([lat, lon], {
- renderer: renderer, // Use canvas renderer
- radius: 4,
- color: markerColor,
- zIndexOffset: 1000,
- pane: 'markerPane'
- }).bindPopup(popupContent, { autoClose: false });
+ return L.marker([lat, lon], {
+ icon: L.divIcon({
+ className: 'custom-div-icon',
+ html: ``,
+ iconSize: [8, 8],
+ iconAnchor: [4, 4]
+ }),
+ draggable: true,
+ autoPan: true,
+ pointIndex: index,
+ pointId: pointId,
+ originalLat: lat,
+ originalLng: lon,
+ markerData: marker, // Store the complete marker data
+ renderer: renderer
+ }).bindPopup(createPopupContent(marker, userSettings.timezone, userSettings.distanceUnit))
+ .on('dragstart', function(e) {
+ this.closePopup();
+ })
+ .on('drag', function(e) {
+ const newLatLng = e.target.getLatLng();
+ const map = e.target._map;
+ const pointIndex = e.target.options.pointIndex;
+ const originalLat = e.target.options.originalLat;
+ const originalLng = e.target.options.originalLng;
+ // Find polylines by iterating through all map layers
+ map.eachLayer((layer) => {
+ // Check if this is a LayerGroup containing polylines
+ if (layer instanceof L.LayerGroup) {
+ layer.eachLayer((featureGroup) => {
+ if (featureGroup instanceof L.FeatureGroup) {
+ featureGroup.eachLayer((segment) => {
+ if (segment instanceof L.Polyline) {
+ const coords = segment.getLatLngs();
+ const tolerance = 0.0000001;
+ let updated = false;
+
+ // Check and update start point
+ if (Math.abs(coords[0].lat - originalLat) < tolerance &&
+ Math.abs(coords[0].lng - originalLng) < tolerance) {
+ coords[0] = newLatLng;
+ updated = true;
+ }
+
+ // Check and update end point
+ if (Math.abs(coords[1].lat - originalLat) < tolerance &&
+ Math.abs(coords[1].lng - originalLng) < tolerance) {
+ coords[1] = newLatLng;
+ updated = true;
+ }
+
+ // Only update if we found a matching endpoint
+ if (updated) {
+ segment.setLatLngs(coords);
+ segment.redraw();
+ }
+ }
+ });
+ }
+ });
+ }
+ });
+
+ // Update the marker's original position for the next drag event
+ e.target.options.originalLat = newLatLng.lat;
+ e.target.options.originalLng = newLatLng.lng;
+ })
+ .on('dragend', function(e) {
+ const newLatLng = e.target.getLatLng();
+ const pointId = e.target.options.pointId;
+ const pointIndex = e.target.options.pointIndex;
+ const originalMarkerData = e.target.options.markerData;
+
+ fetch(`/api/v1/points/${pointId}`, {
+ method: 'PATCH',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json',
+ 'Authorization': `Bearer ${apiKey}`
+ },
+ body: JSON.stringify({
+ point: {
+ latitude: newLatLng.lat.toString(),
+ longitude: newLatLng.lng.toString()
+ }
+ })
+ })
+ .then(response => {
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ return response.json();
+ })
+ .then(data => {
+ const map = e.target._map;
+ if (map && map.mapsController && map.mapsController.markers) {
+ const markers = map.mapsController.markers;
+ if (markers[pointIndex]) {
+ markers[pointIndex][0] = parseFloat(data.latitude);
+ markers[pointIndex][1] = parseFloat(data.longitude);
+ }
+ }
+
+ // Create updated marker data array
+ const updatedMarkerData = [
+ parseFloat(data.latitude),
+ parseFloat(data.longitude),
+ originalMarkerData[2], // battery
+ originalMarkerData[3], // altitude
+ originalMarkerData[4], // timestamp
+ originalMarkerData[5], // velocity
+ data.id, // id
+ originalMarkerData[7] // country
+ ];
+
+ // Update the marker's stored data
+ e.target.options.markerData = updatedMarkerData;
+
+ // Update the popup content
+ if (this._popup) {
+ const updatedPopupContent = createPopupContent(
+ updatedMarkerData,
+ userSettings.timezone,
+ userSettings.distanceUnit
+ );
+ this.setPopupContent(updatedPopupContent);
+ }
+ })
+ .catch(error => {
+ console.error('Error updating point:', error);
+ this.setLatLng([e.target.options.originalLat, e.target.options.originalLng]);
+ alert('Failed to update point position. Please try again.');
+ });
+ });
});
}
}
+// Helper function to check if a point is connected to a polyline endpoint
+function isConnectedToPoint(latLng, originalPoint, tolerance) {
+ // originalPoint is [lat, lng] array
+ const latMatch = Math.abs(latLng.lat - originalPoint[0]) < tolerance;
+ const lngMatch = Math.abs(latLng.lng - originalPoint[1]) < tolerance;
+ return latMatch && lngMatch;
+}
+
export function createSimplifiedMarkers(markersData, renderer) {
const distanceThreshold = 50; // meters
const timeThreshold = 20000; // milliseconds (3 seconds)
@@ -35,7 +170,6 @@ export function createSimplifiedMarkers(markersData, renderer) {
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
@@ -53,14 +187,24 @@ export function createSimplifiedMarkers(markersData, renderer) {
const popupContent = createPopupContent(marker);
let markerColor = marker[5] < 0 ? "orange" : "blue";
- return L.circleMarker(
- [lat, lon],
- {
- renderer: renderer, // Use canvas renderer
- radius: 4,
- color: markerColor,
- zIndexOffset: 1000
- }
- ).bindPopup(popupContent);
+ // Use L.marker instead of L.circleMarker for better drag support
+ return L.marker([lat, lon], {
+ icon: L.divIcon({
+ className: 'custom-div-icon',
+ html: ``,
+ iconSize: [8, 8],
+ iconAnchor: [4, 4]
+ }),
+ draggable: true,
+ autoPan: true
+ }).bindPopup(popupContent)
+ .on('dragstart', function(e) {
+ this.closePopup();
+ })
+ .on('dragend', function(e) {
+ const newLatLng = e.target.getLatLng();
+ this.setLatLng(newLatLng);
+ this.openPopup();
+ });
});
}
diff --git a/app/javascript/maps/polylines.js b/app/javascript/maps/polylines.js
index ba7e15cf..e48479d3 100644
--- a/app/javascript/maps/polylines.js
+++ b/app/javascript/maps/polylines.js
@@ -169,54 +169,165 @@ export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, use
const endMarker = L.marker([endPoint[0], endPoint[1]], { icon: finishIcon });
let hoverPopup = null;
+ let clickedLayer = null;
- polylineGroup.on("mouseover", function (e) {
- let closestSegment = null;
- let minDistance = Infinity;
- let currentSpeed = 0;
+ // Add events to both group and individual polylines
+ polylineGroup.eachLayer((layer) => {
+ if (layer instanceof L.Polyline) {
+ layer.on("mouseover", function (e) {
+ handleMouseOver(e);
+ });
- polylineGroup.eachLayer((layer) => {
- if (layer instanceof L.Polyline) {
- const layerLatLngs = layer.getLatLngs();
- const distance = pointToLineDistance(e.latlng, layerLatLngs[0], layerLatLngs[1]);
+ layer.on("mouseout", function (e) {
+ handleMouseOut(e);
+ });
- if (distance < minDistance) {
- minDistance = distance;
- closestSegment = layer;
+ layer.on("click", function (e) {
+ handleClick(e);
+ });
+ }
+ });
- const startIdx = polylineCoordinates.findIndex(p => {
- const latMatch = Math.abs(p[0] - layerLatLngs[0].lat) < 0.0000001;
- const lngMatch = Math.abs(p[1] - layerLatLngs[0].lng) < 0.0000001;
- return latMatch && lngMatch;
- });
+ function handleMouseOver(e) {
+ // Handle both direct layer events and group propagated events
+ const layer = e.layer || e.target;
+ let speed = 0;
- if (startIdx !== -1 && startIdx < polylineCoordinates.length - 1) {
- currentSpeed = calculateSpeed(
- polylineCoordinates[startIdx],
- polylineCoordinates[startIdx + 1]
+ if (layer instanceof L.Polyline) {
+ // Get the coordinates array from the layer
+ const coords = layer.getLatLngs();
+ if (coords && coords.length >= 2) {
+ const startPoint = coords[0];
+ const endPoint = coords[coords.length - 1];
+
+ // Find the corresponding markers for these coordinates
+ const startMarkerData = polylineCoordinates.find(m =>
+ m[0] === startPoint.lat && m[1] === startPoint.lng
+ );
+ const endMarkerData = polylineCoordinates.find(m =>
+ m[0] === endPoint.lat && m[1] === endPoint.lng
);
- }
- }
- }
- });
- // Apply highlight style to all segments
+ // Calculate speed if we have both markers
+ if (startMarkerData && endMarkerData) {
+ speed = startMarkerData[5] || endMarkerData[5] || 0;
+ }
+ }
+ }
+
+ // Don't apply hover styles if this is the clicked layer
+ if (!clickedLayer) {
+ // Apply style to all segments in the group
+ polylineGroup.eachLayer((segment) => {
+ if (segment instanceof L.Polyline) {
+ const newStyle = {
+ weight: 8,
+ opacity: 1
+ };
+
+ // Only change color if speed-colored routes are not enabled
+ if (!userSettings.speed_colored_routes) {
+ newStyle.color = 'yellow'; // Highlight color
+ }
+
+ segment.setStyle(newStyle);
+ }
+ });
+
+ startMarker.addTo(map);
+ endMarker.addTo(map);
+
+ const popupContent = `
+ Start: ${firstTimestamp}
+ End: ${lastTimestamp}
+ Duration: ${timeOnRoute}
+ Total Distance: ${formatDistance(totalDistance, distanceUnit)}
+ Current Speed: ${Math.round(speed)} km/h
+ `;
+
+ if (hoverPopup) {
+ map.closePopup(hoverPopup);
+ }
+
+ hoverPopup = L.popup()
+ .setLatLng(e.latlng)
+ .setContent(popupContent)
+ .openOn(map);
+ }
+ }
+
+ function handleMouseOut(e) {
+ // If there's a clicked state, maintain it
+ if (clickedLayer && polylineGroup.clickedState) {
+ polylineGroup.eachLayer((layer) => {
+ if (layer instanceof L.Polyline) {
+ if (layer === clickedLayer || layer.options.originalPath === clickedLayer.options.originalPath) {
+ layer.setStyle(polylineGroup.clickedState.style);
+ }
+ }
+ });
+ return;
+ }
+
+ // Apply normal style only if there's no clicked layer
polylineGroup.eachLayer((layer) => {
- if (layer instanceof L.Polyline) {
- const highlightStyle = {
- weight: 5,
- opacity: 1
- };
-
- // Only change color to yellow if speed colors are disabled
- if (!userSettings.speed_colored_routes) {
- highlightStyle.color = '#ffff00';
+ if (layer instanceof L.Polyline) {
+ const originalStyle = {
+ weight: 3,
+ opacity: userSettings.route_opacity,
+ color: layer.options.originalColor
+ };
+ layer.setStyle(originalStyle);
}
-
- layer.setStyle(highlightStyle);
- }
});
+ if (hoverPopup && !clickedLayer) {
+ map.closePopup(hoverPopup);
+ map.removeLayer(startMarker);
+ map.removeLayer(endMarker);
+ }
+ }
+
+ function handleClick(e) {
+ const newClickedLayer = e.target;
+
+ // If clicking the same route that's already clicked, do nothing
+ if (clickedLayer === newClickedLayer) {
+ return;
+ }
+
+ // Store reference to previous clicked layer before updating
+ const previousClickedLayer = clickedLayer;
+
+ // Update clicked layer reference
+ clickedLayer = newClickedLayer;
+
+ // Reset previous clicked layer if it exists
+ if (previousClickedLayer) {
+ previousClickedLayer.setStyle({
+ weight: 3,
+ opacity: userSettings.route_opacity,
+ color: previousClickedLayer.options.originalColor
+ });
+ }
+
+ // Define style for clicked state
+ const clickedStyle = {
+ weight: 8,
+ opacity: 1,
+ color: userSettings.speed_colored_routes ? clickedLayer.options.originalColor : 'yellow'
+ };
+
+ // Apply style to new clicked layer
+ clickedLayer.setStyle(clickedStyle);
+ clickedLayer.bringToFront();
+
+ // Update clicked state
+ polylineGroup.clickedState = {
+ layer: clickedLayer,
+ style: clickedStyle
+ };
+
startMarker.addTo(map);
endMarker.addTo(map);
@@ -225,7 +336,7 @@ export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, use
End: ${lastTimestamp}
Duration: ${timeOnRoute}
Total Distance: ${formatDistance(totalDistance, distanceUnit)}
- Current Speed: ${Math.round(currentSpeed)} km/h
+ Current Speed: ${Math.round(clickedLayer.options.speed || 0)} km/h
`;
if (hoverPopup) {
@@ -233,40 +344,54 @@ export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, use
}
hoverPopup = L.popup()
- .setLatLng(e.latlng)
- .setContent(popupContent)
- .openOn(map);
- });
+ .setLatLng(e.latlng)
+ .setContent(popupContent)
+ .openOn(map);
- polylineGroup.on("mouseout", function () {
- // Restore original style
- polylineGroup.eachLayer((layer) => {
- if (layer instanceof L.Polyline) {
- const originalStyle = {
- weight: 3,
- opacity: userSettings.route_opacity,
- color: layer.options.originalColor // Use the stored original color
- };
+ // Prevent the click event from propagating to the map
+ L.DomEvent.stopPropagation(e);
+ }
- layer.setStyle(originalStyle);
- }
- });
-
- if (hoverPopup) {
- map.closePopup(hoverPopup);
+ // Reset highlight when clicking elsewhere on the map
+ map.on('click', function () {
+ if (clickedLayer) {
+ const clickedGroup = clickedLayer.polylineGroup || polylineGroup;
+ clickedGroup.eachLayer((layer) => {
+ if (layer instanceof L.Polyline) {
+ layer.setStyle({
+ weight: 3,
+ opacity: userSettings.route_opacity,
+ color: layer.options.originalColor
+ });
+ }
+ });
+ clickedLayer = null;
+ clickedGroup.clickedState = null;
+ }
+ if (hoverPopup) {
+ map.closePopup(hoverPopup);
+ map.removeLayer(startMarker);
+ map.removeLayer(endMarker);
}
- map.removeLayer(startMarker);
- map.removeLayer(endMarker);
});
- polylineGroup.on("click", function () {
- map.fitBounds(polylineGroup.getBounds());
- });
+ // Keep the original group events as a fallback
+ polylineGroup.on("mouseover", handleMouseOver);
+ polylineGroup.on("mouseout", handleMouseOut);
+ polylineGroup.on("click", handleClick);
}
export function createPolylinesLayer(markers, map, timezone, routeOpacity, userSettings, distanceUnit) {
- // Create a canvas renderer
- const renderer = L.canvas({ padding: 0.5 });
+ // Create a custom pane for our polylines with higher z-index
+ if (!map.getPane('polylinesPane')) {
+ map.createPane('polylinesPane');
+ map.getPane('polylinesPane').style.zIndex = 450; // Above the default overlay pane (400)
+ }
+
+ const renderer = L.canvas({
+ padding: 0.5,
+ pane: 'polylinesPane'
+ });
const splitPolylines = [];
let currentPolyline = [];
@@ -295,9 +420,11 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS
splitPolylines.push(currentPolyline);
}
- return L.layerGroup(
- splitPolylines.map((polylineCoordinates) => {
+ // Create the layer group with the polylines
+ const layerGroup = L.layerGroup(
+ splitPolylines.map((polylineCoordinates, groupIndex) => {
const segmentGroup = L.featureGroup();
+ const segments = [];
for (let i = 0; i < polylineCoordinates.length - 1; i++) {
const speed = calculateSpeed(polylineCoordinates[i], polylineCoordinates[i + 1]);
@@ -309,25 +436,74 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS
[polylineCoordinates[i + 1][0], polylineCoordinates[i + 1][1]]
],
{
- renderer: renderer, // Use canvas renderer
+ renderer: renderer,
color: color,
originalColor: color,
opacity: routeOpacity,
weight: 3,
speed: speed,
- startTime: polylineCoordinates[i][4],
- endTime: polylineCoordinates[i + 1][4]
+ interactive: true,
+ pane: 'polylinesPane',
+ bubblingMouseEvents: false
}
);
+ segments.push(segment);
segmentGroup.addLayer(segment);
}
+ // Add mouseover/mouseout to the entire group
+ segmentGroup.on('mouseover', function(e) {
+ L.DomEvent.stopPropagation(e);
+ segments.forEach(segment => {
+ segment.setStyle({
+ weight: 8,
+ opacity: 1
+ });
+ if (map.hasLayer(segment)) {
+ segment.bringToFront();
+ }
+ });
+ });
+
+ segmentGroup.on('mouseout', function(e) {
+ L.DomEvent.stopPropagation(e);
+ segments.forEach(segment => {
+ segment.setStyle({
+ weight: 3,
+ opacity: routeOpacity,
+ color: segment.options.originalColor
+ });
+ });
+ });
+
+ // Make the group interactive
+ segmentGroup.options.interactive = true;
+ segmentGroup.options.bubblingMouseEvents = false;
+
+ // Add the hover functionality to the group
addHighlightOnHover(segmentGroup, map, polylineCoordinates, userSettings, distanceUnit);
return segmentGroup;
})
- ).addTo(map);
+ );
+
+ // Add CSS to ensure our pane receives mouse events
+ const style = document.createElement('style');
+ style.textContent = `
+ .leaflet-polylinesPane-pane {
+ pointer-events: auto !important;
+ }
+ .leaflet-polylinesPane-pane canvas {
+ pointer-events: auto !important;
+ }
+ `;
+ document.head.appendChild(style);
+
+ // Add to map and return
+ layerGroup.addTo(map);
+
+ return layerGroup;
}
export function updatePolylinesColors(polylinesLayer, useSpeedColors) {
diff --git a/app/javascript/maps/popups.js b/app/javascript/maps/popups.js
index dee74dc5..cba49a22 100644
--- a/app/javascript/maps/popups.js
+++ b/app/javascript/maps/popups.js
@@ -8,6 +8,9 @@ export function createPopupContent(marker, timezone, distanceUnit) {
marker[3] = marker[3] * 3.28084;
}
+ // convert marker[5] from m/s to km/h and round to nearest integer
+ marker[5] = Math.round(marker[5] * 3.6);
+
return `
Timestamp: ${formatDate(marker[4], timezone)}
Latitude: ${marker[0]}
diff --git a/config/routes.rb b/config/routes.rb
index 8d28efde..0befcca4 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -67,7 +67,7 @@ Rails.application.routes.draw do
get 'settings', to: 'settings#index'
resources :areas, only: %i[index create update destroy]
- resources :points, only: %i[index destroy]
+ resources :points, only: %i[index destroy update]
resources :visits, only: %i[update]
resources :stats, only: :index