From 2e18b35e3cd30b712dad1c5bbfd0effe0a2b2a10 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sat, 11 Jan 2025 00:42:44 +0100 Subject: [PATCH] Add settings for speed-colored polylines --- app/controllers/api/v1/settings_controller.rb | 3 +- app/javascript/controllers/maps_controller.js | 192 ++++++++------ app/javascript/maps/polylines.js | 238 +++++++++++++----- 3 files changed, 294 insertions(+), 139 deletions(-) diff --git a/app/controllers/api/v1/settings_controller.rb b/app/controllers/api/v1/settings_controller.rb index f87d9df7..b15bad16 100644 --- a/app/controllers/api/v1/settings_controller.rb +++ b/app/controllers/api/v1/settings_controller.rb @@ -27,7 +27,8 @@ class Api::V1::SettingsController < ApiController :meters_between_routes, :minutes_between_routes, :fog_of_war_meters, :time_threshold_minutes, :merge_threshold_minutes, :route_opacity, :preferred_map_layer, :points_rendering_mode, :live_map_enabled, - :immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key + :immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key, + :speed_colored_polylines ) end end diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 028240b2..fc60bf75 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -5,8 +5,13 @@ import consumer from "../channels/consumer"; import { createMarkersArray } from "../maps/markers"; -import { createPolylinesLayer } from "../maps/polylines"; -import { updatePolylinesOpacity } from "../maps/polylines"; +import { + createPolylinesLayer, + updatePolylinesOpacity, + updatePolylinesColors, + calculateSpeed, + getSpeedColor +} from "../maps/polylines"; import { fetchAndDrawAreas } from "../maps/areas"; import { handleAreaCreated } from "../maps/areas"; @@ -27,6 +32,18 @@ import { countryCodesMap } from "../maps/country_codes"; import "leaflet-draw"; +function debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +} + export default class extends Controller { static targets = ["container"]; @@ -48,6 +65,7 @@ export default class extends Controller { this.pointsRenderingMode = this.userSettings.points_rendering_mode || "raw"; this.liveMapEnabled = this.userSettings.live_map_enabled || false; this.countryCodesMap = countryCodesMap(); + this.speedColoredPolylines = this.userSettings.speed_colored_polylines || false; this.center = this.markers[this.markers.length - 1] || [52.514568, 13.350111]; @@ -677,6 +695,12 @@ export default class extends Controller { + + `; @@ -717,8 +741,13 @@ export default class extends Controller { } } + speedColoredPolylinesChecked() { + return this.userSettings.speed_colored_polylines ? 'checked' : ''; + } + updateSettings(event) { event.preventDefault(); + console.log('Form submitted'); fetch(`/api/v1/settings?api_key=${this.apiKey}`, { method: 'PATCH', @@ -732,12 +761,14 @@ export default class extends Controller { 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, - live_map_enabled: event.target.live_map_enabled.checked + live_map_enabled: event.target.live_map_enabled.checked, + speed_colored_polylines: event.target.speed_colored_polylines.checked }, }), }) .then((response) => response.json()) .then((data) => { + console.log('Settings update response:', data); if (data.status === 'success') { showFlashMessage('notice', data.message); this.updateMapWithNewSettings(data.settings); @@ -748,86 +779,101 @@ export default class extends Controller { } else { showFlashMessage('error', data.message); } + }) + .catch(error => { + console.error('Settings update error:', error); + showFlashMessage('error', 'Failed to update settings'); }); } updateMapWithNewSettings(newSettings) { + console.log('Updating map settings:', { + newSettings, + currentSettings: this.userSettings, + hasPolylines: !!this.polylinesLayer, + isVisible: this.polylinesLayer && this.map.hasLayer(this.polylinesLayer) + }); + + // Store current visibility state + const wasPolylinesVisible = this.polylinesLayer && this.map.hasLayer(this.polylinesLayer); const currentLayerStates = this.getLayerControlStates(); - // Update local state with new settings - this.clearFogRadius = parseInt(newSettings.fog_of_war_meters) || 50; - this.routeOpacity = parseFloat(newSettings.route_opacity) || 0.6; + // Show loading indicator + const loadingDiv = document.createElement('div'); + loadingDiv.className = 'map-loading-overlay'; + loadingDiv.innerHTML = '
Updating map...
'; + document.body.appendChild(loadingDiv); - // Preserve existing layers except polylines which need to be recreated - const preserveLayers = { - Points: this.markersLayer, - Heatmap: this.heatmapLayer, - "Fog of War": this.fogOverlay, - Areas: this.areasLayer, - }; + // Debounce the heavy operations + const updateLayers = debounce(() => { + try { + // Check if speed_colored_polylines setting has changed + if (newSettings.speed_colored_polylines !== this.userSettings.speed_colored_polylines) { + console.log('Speed colored polylines setting changed:', { + old: this.userSettings.speed_colored_polylines, + new: newSettings.speed_colored_polylines + }); - // Clear all layers except base layers - this.map.eachLayer((layer) => { - if (!(layer instanceof L.TileLayer)) { - this.map.removeLayer(layer); + if (this.polylinesLayer) { + console.log('Starting polylines color update'); + + // Update colors without removing the layer + this.polylinesLayer.eachLayer(groupLayer => { + if (groupLayer instanceof L.LayerGroup || groupLayer instanceof L.FeatureGroup) { + groupLayer.eachLayer(segment => { + if (segment instanceof L.Polyline) { + const latLngs = segment.getLatLngs(); + const point1 = [latLngs[0].lat, latLngs[0].lng]; + const point2 = [latLngs[1].lat, latLngs[1].lng]; + + const speed = calculateSpeed( + [...point1, 0, segment.options.startTime], + [...point2, 0, segment.options.endTime] + ); + + const newColor = newSettings.speed_colored_polylines ? + getSpeedColor(speed, true) : + '#0000ff'; + + segment.setStyle({ + color: newColor, + originalColor: newColor + }); + } + }); + } + }); + + console.log('Finished polylines color update'); + } + } + + // Check if route opacity has 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; + + // Reapply layer states + this.applyLayerControlStates(currentLayerStates); + + } catch (error) { + console.error('Error updating map settings:', error); + console.error(error.stack); + } finally { + // Remove loading indicator + document.body.removeChild(loadingDiv); } - }); + }, 250); - // Recreate polylines layer with new settings - this.polylinesLayer = createPolylinesLayer( - this.markers, - this.map, - this.timezone, - this.routeOpacity, - newSettings, - this.distanceUnit - ); - - // Redraw areas - fetchAndDrawAreas(this.areasLayer, this.apiKey); - - let fogEnabled = false; - document.getElementById('fog').style.display = 'none'; - - this.map.on('overlayadd', (e) => { - if (e.name === 'Fog of War') { - fogEnabled = true; - document.getElementById('fog').style.display = 'block'; - this.updateFog(this.markers, this.clearFogRadius); - } - }); - - this.map.on('overlayremove', (e) => { - if (e.name === 'Fog of War') { - fogEnabled = false; - document.getElementById('fog').style.display = 'none'; - } - }); - - this.map.on('zoomend moveend', () => { - if (fogEnabled) { - this.updateFog(this.markers, this.clearFogRadius); - } - }); - - this.addLastMarker(this.map, this.markers); - this.addEventListeners(); - this.initializeDrawControl(); - updatePolylinesOpacity(this.polylinesLayer, this.routeOpacity); - - this.map.on('overlayadd', (e) => { - if (e.name === 'Areas') { - this.map.addControl(this.drawControl); - } - }); - - this.map.on('overlayremove', (e) => { - if (e.name === 'Areas') { - this.map.removeControl(this.drawControl); - } - }); - - this.applyLayerControlStates(currentLayerStates); + updateLayers(); } getLayerControlStates() { diff --git a/app/javascript/maps/polylines.js b/app/javascript/maps/polylines.js index bd0a1595..eb9ca95f 100644 --- a/app/javascript/maps/polylines.js +++ b/app/javascript/maps/polylines.js @@ -4,50 +4,137 @@ import { getUrlParameter } from "../maps/helpers"; import { minutesToDaysHoursMinutes } from "../maps/helpers"; import { haversineDistance } from "../maps/helpers"; -function getSpeedColor(speedKmh) { - console.log('Speed to color:', speedKmh + ' km/h'); +function pointToLineDistance(point, lineStart, lineEnd) { + const x = point.lat; + const y = point.lng; + const x1 = lineStart.lat; + const y1 = lineStart.lng; + const x2 = lineEnd.lat; + const y2 = lineEnd.lng; - if (speedKmh > 100) { - console.log('Red - Very fast'); - return '#FF0000'; + const A = x - x1; + const B = y - y1; + const C = x2 - x1; + const D = y2 - y1; + + const dot = A * C + B * D; + const lenSq = C * C + D * D; + let param = -1; + + if (lenSq !== 0) { + param = dot / lenSq; } - if (speedKmh > 70) { - console.log('Orange - Fast'); - return '#FFA500'; + + let xx, yy; + + if (param < 0) { + xx = x1; + yy = y1; + } else if (param > 1) { + xx = x2; + yy = y2; + } else { + xx = x1 + param * C; + yy = y1 + param * D; } - if (speedKmh > 40) { - console.log('Yellow - Moderate'); - return '#FFFF00'; - } - if (speedKmh > 20) { - console.log('Light green - Normal'); - return '#90EE90'; - } - console.log('Green - Slow'); - return '#008000'; + + const dx = x - xx; + const dy = y - yy; + + return Math.sqrt(dx * dx + dy * dy); } -function calculateSpeed(point1, point2) { +export function calculateSpeed(point1, point2) { const distanceKm = haversineDistance(point1[0], point1[1], point2[0], point2[1]); // in kilometers const timeDiffSeconds = point2[4] - point1[4]; - // Convert to km/h: (kilometers / seconds) * (3600 seconds / hour) - const speed = (distanceKm / timeDiffSeconds) * 3600; + // Handle edge cases + if (timeDiffSeconds <= 0 || distanceKm <= 0) { + return 0; + } - console.log('Speed calculation:', { - distance: distanceKm + ' km', - timeDiff: timeDiffSeconds + ' seconds', - speed: speed + ' km/h', - point1: point1, - point2: point2 - }); + const speedKmh = (distanceKm / timeDiffSeconds) * 3600; // Convert to km/h - return speed; + // Cap speed at reasonable maximum (e.g., 150 km/h) + const MAX_SPEED = 150; + return Math.min(speedKmh, MAX_SPEED); +} + +export function getSpeedColor(speedKmh, useSpeedColors) { + if (!useSpeedColors) { + return '#0000ff'; // Default blue color + } + + // Existing speed-based color logic + const colorStops = [ + { speed: 0, color: '#00ff00' }, // Stationary/very slow (neon green) + { speed: 15, color: '#00ffff' }, // Walking/jogging (neon cyan) + { speed: 30, color: '#ff00ff' }, // Cycling/slow driving (neon magenta) + { speed: 50, color: '#ff3300' }, // Urban driving (neon orange-red) + { speed: 100, color: '#ffff00' } // Highway driving (neon yellow) + ]; + + // Find the appropriate color segment + for (let i = 1; i < colorStops.length; i++) { + if (speedKmh <= colorStops[i].speed) { + // Calculate how far we are between the two speeds (0-1) + const ratio = (speedKmh - colorStops[i-1].speed) / (colorStops[i].speed - colorStops[i-1].speed); + + // Convert hex to RGB for interpolation + const color1 = hexToRGB(colorStops[i-1].color); + const color2 = hexToRGB(colorStops[i].color); + + // Interpolate between the two colors + const r = Math.round(color1.r + (color2.r - color1.r) * ratio); + const g = Math.round(color1.g + (color2.g - color1.g) * ratio); + const b = Math.round(color1.b + (color2.b - color1.b) * ratio); + + return `rgb(${r}, ${g}, ${b})`; + } + } + + // If speed is higher than our highest threshold, return the last color + return colorStops[colorStops.length - 1].color; +} + +// Helper function to convert hex to RGB +function hexToRGB(hex) { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return { r, g, b }; +} + +// Add new function for batch processing +function processInBatches(items, batchSize, processFn) { + let index = 0; + + function processNextBatch() { + const batch = items.slice(index, index + batchSize); + batch.forEach(processFn); + + index += batchSize; + + if (index < items.length) { + // Schedule next batch using requestAnimationFrame + window.requestAnimationFrame(processNextBatch); + } + } + + processNextBatch(); } export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, userSettings, distanceUnit) { - const highlightStyle = { opacity: 1, weight: 5 }; - const normalStyle = { opacity: userSettings.routeOpacity, weight: 3 }; + const highlightStyle = { + opacity: 1, + weight: 5, + color: userSettings.speed_colored_polylines ? null : '#ffff00' // Yellow highlight if not using speed colors + }; + const normalStyle = { + opacity: userSettings.routeOpacity, + weight: 3, + color: userSettings.speed_colored_polylines ? null : '#0000ff' // Blue normal if not using speed colors + }; const startPoint = polylineCoordinates[0]; const endPoint = polylineCoordinates[polylineCoordinates.length - 1]; @@ -73,7 +160,6 @@ export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, use let hoverPopup = null; polylineGroup.on("mouseover", function (e) { - // Find the closest segment and its speed let closestSegment = null; let minDistance = Infinity; let currentSpeed = 0; @@ -81,53 +167,33 @@ export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, use polylineGroup.eachLayer((layer) => { if (layer instanceof L.Polyline) { const layerLatLngs = layer.getLatLngs(); - const distance = L.LineUtil.pointToSegmentDistance( - e.latlng, - layerLatLngs[0], - layerLatLngs[1] - ); + const distance = pointToLineDistance(e.latlng, layerLatLngs[0], layerLatLngs[1]); if (distance < minDistance) { minDistance = distance; closestSegment = layer; - // Get the coordinates of the segment - const startPoint = layerLatLngs[0]; - const endPoint = layerLatLngs[1]; - - console.log('Closest segment found:', { - startPoint, - endPoint, - distance - }); - - // Find matching points in polylineCoordinates const startIdx = polylineCoordinates.findIndex(p => { - const latMatch = Math.abs(p[0] - startPoint.lat) < 0.0000001; - const lngMatch = Math.abs(p[1] - startPoint.lng) < 0.0000001; + 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; }); - console.log('Start point index:', startIdx); - console.log('Original point:', startIdx !== -1 ? polylineCoordinates[startIdx] : 'not found'); - if (startIdx !== -1 && startIdx < polylineCoordinates.length - 1) { currentSpeed = calculateSpeed( polylineCoordinates[startIdx], polylineCoordinates[startIdx + 1] ); - console.log('Speed calculated:', currentSpeed); } } } }); - // Highlight all segments in the group polylineGroup.eachLayer((layer) => { if (layer instanceof L.Polyline) { layer.setStyle({ ...highlightStyle, - color: layer.options.originalColor + color: userSettings.speed_colored_polylines ? layer.options.originalColor : highlightStyle.color }); } }); @@ -154,12 +220,11 @@ export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, use }); polylineGroup.on("mouseout", function () { - // Restore original styles for all segments polylineGroup.eachLayer((layer) => { if (layer instanceof L.Polyline) { layer.setStyle({ ...normalStyle, - color: layer.options.originalColor + color: userSettings.speed_colored_polylines ? layer.options.originalColor : normalStyle.color }); } }); @@ -182,7 +247,6 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS const distanceThresholdMeters = parseInt(userSettings.meters_between_routes) || 500; const timeThresholdMinutes = parseInt(userSettings.minutes_between_routes) || 60; - // Split into separate polylines based on distance/time thresholds for (let i = 0, len = markers.length; i < len; i++) { if (currentPolyline.length === 0) { currentPolyline.push(markers[i]); @@ -209,10 +273,9 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS splitPolylines.map((polylineCoordinates) => { const segmentGroup = L.featureGroup(); - // Create segments with different colors based on speed for (let i = 0; i < polylineCoordinates.length - 1; i++) { const speed = calculateSpeed(polylineCoordinates[i], polylineCoordinates[i + 1]); - const color = getSpeedColor(speed); + const color = getSpeedColor(speed, userSettings.speed_colored_polylines); const segment = L.polyline( [ @@ -223,14 +286,15 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS color: color, originalColor: color, opacity: routeOpacity, - weight: 3 + weight: 3, + startTime: polylineCoordinates[i][4], + endTime: polylineCoordinates[i + 1][4] } ); segmentGroup.addLayer(segment); } - // Add hover effect to the entire group of segments addHighlightOnHover(segmentGroup, map, polylineCoordinates, userSettings, distanceUnit); return segmentGroup; @@ -238,14 +302,58 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS ).addTo(map); } -export function updatePolylinesOpacity(polylinesLayer, opacity) { +export function updatePolylinesColors(polylinesLayer, useSpeedColors) { + const segments = []; + + // Collect all segments first polylinesLayer.eachLayer((groupLayer) => { if (groupLayer instanceof L.LayerGroup) { groupLayer.eachLayer((segment) => { if (segment instanceof L.Polyline) { - segment.setStyle({ opacity: opacity }); + segments.push(segment); } }); } }); + + // Process segments in batches of 50 + processInBatches(segments, 50, (segment) => { + const latLngs = segment.getLatLngs(); + const point1 = [latLngs[0].lat, latLngs[0].lng]; + const point2 = [latLngs[1].lat, latLngs[1].lng]; + + const speed = calculateSpeed( + [...point1, 0, segment.options.startTime], + [...point2, 0, segment.options.endTime] + ); + + const newColor = useSpeedColors ? + getSpeedColor(speed, useSpeedColors) : + '#0000ff'; + + segment.setStyle({ + color: newColor, + originalColor: newColor + }); + }); +} + +export function updatePolylinesOpacity(polylinesLayer, opacity) { + const segments = []; + + // Collect all segments first + polylinesLayer.eachLayer((groupLayer) => { + if (groupLayer instanceof L.LayerGroup) { + groupLayer.eachLayer((segment) => { + if (segment instanceof L.Polyline) { + segments.push(segment); + } + }); + } + }); + + // Process segments in batches of 50 + processInBatches(segments, 50, (segment) => { + segment.setStyle({ opacity: opacity }); + }); }