diff --git a/.app_version b/.app_version index f6de0017..df47809d 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.23.5 +0.23.6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f117299..95de7bbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ 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.23.6 - 2025-01-23 + +### Added + +- Enabled Postgis extension for PostgreSQL. +- Trips are now store their paths in the database independently of the points. +- Trips are now being rendered on the map using their precalculated paths instead of list of coordinates. + +### Changed + +- Requesting photos on the Map page now uses the start and end dates from the URL params. #589 + # 0.23.5 - 2025-01-22 ### Added diff --git a/Gemfile b/Gemfile index 1066cbfa..592c2fd3 100644 --- a/Gemfile +++ b/Gemfile @@ -19,9 +19,11 @@ gem 'lograge' gem 'oj' gem 'pg' gem 'prometheus_exporter' +gem 'activerecord-postgis-adapter', github: 'StoneGod/activerecord-postgis-adapter', branch: 'rails-8' gem 'puma' gem 'pundit' gem 'rails', '~> 8.0' +gem 'rgeo' gem 'rswag-api' gem 'rswag-ui' gem 'shrine', '~> 3.6' diff --git a/Gemfile.lock b/Gemfile.lock index 43f74521..8407dd3a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,12 @@ +GIT + remote: https://github.com/StoneGod/activerecord-postgis-adapter.git + revision: 147fd43191ef703e2a1b3654f31d9139201a87e8 + branch: rails-8 + specs: + activerecord-postgis-adapter (10.0.1) + activerecord (~> 8.0.0) + rgeo-activerecord (~> 8.0.0) + GIT remote: https://github.com/alexreisner/geocoder.git revision: 04ee2936a30b30a23ded5231d7faf6cf6c27c099 @@ -314,6 +323,10 @@ GEM actionpack (>= 5.2) railties (>= 5.2) rexml (3.3.8) + rgeo (3.0.1) + rgeo-activerecord (8.0.0) + activerecord (>= 7.0) + rgeo (>= 3.0) rspec-core (3.13.2) rspec-support (~> 3.13.0) rspec-expectations (3.13.3) @@ -443,6 +456,7 @@ PLATFORMS x86_64-linux DEPENDENCIES + activerecord-postgis-adapter! bootsnap chartkick data_migrate @@ -470,6 +484,7 @@ DEPENDENCIES pundit rails (~> 8.0) redis + rgeo rspec-rails rswag-api rswag-specs diff --git a/app/assets/stylesheets/actiontext.css b/app/assets/stylesheets/actiontext.css index b849676e..ae5522ab 100644 --- a/app/assets/stylesheets/actiontext.css +++ b/app/assets/stylesheets/actiontext.css @@ -40,6 +40,7 @@ background-color: white !important; } -.trix-content { +.trix-content-editor { min-height: 10rem; + width: 100%; } diff --git a/app/controllers/api/v1/tracks_controller.rb b/app/controllers/api/v1/tracks_controller.rb new file mode 100644 index 00000000..2a1be882 --- /dev/null +++ b/app/controllers/api/v1/tracks_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class Api::V1::TracksController < ApiController + def index + @tracks = + current_api_user.tracks.where(id: params[:ids]).page(params[:page]) + .per(params[:per_page] || 100) + + serialized_tracks = + @tracks.map { |track| Api::TrackSerializer.new(track).call } + + response.set_header('X-Current-Page', @tracks.current_page.to_s) + response.set_header('X-Total-Pages', @tracks.total_pages.to_s) + + render json: serialized_tracks + end + + def show + @track = current_api_user.tracks.find(params[:id]) + + render json: Api::TrackSerializer.new(@track).call + end +end diff --git a/app/controllers/map_controller.rb b/app/controllers/map_controller.rb index bad160d5..10491be1 100644 --- a/app/controllers/map_controller.rb +++ b/app/controllers/map_controller.rb @@ -6,9 +6,12 @@ class MapController < ApplicationController def index @points = points.where('timestamp >= ? AND timestamp <= ?', start_at, end_at) - @coordinates = - @points.pluck(:latitude, :longitude, :battery, :altitude, :timestamp, :velocity, :id, :country) - .map { [_1.to_f, _2.to_f, _3.to_s, _4.to_s, _5.to_s, _6.to_s, _7.to_s, _8.to_s] } + @track_ids = track_ids + + @coordinates = [] + # @coordinates = + # @points.pluck(:latitude, :longitude, :battery, :altitude, :timestamp, :velocity, :id, :country) + # .map { [_1.to_f, _2.to_f, _3.to_s, _4.to_s, _5.to_s, _6.to_s, _7.to_s, _8.to_s] } @distance = distance @start_at = Time.zone.at(start_at) @end_at = Time.zone.at(end_at) @@ -53,4 +56,16 @@ def points_from_import def points_from_user current_user.tracked_points.without_raw_data.order(timestamp: :asc) end + + def track_ids + started_at = params[:start_at].present? ? Time.zone.parse(params[:start_at]) : Time.zone.today.beginning_of_day + ended_at = params[:end_at].present? ? Time.zone.parse(params[:end_at]) : Time.zone.today.end_of_day + + current_user + .tracks + .where(started_at: started_at..ended_at) + .or(current_user.tracks.where(ended_at: started_at..ended_at)) + .order(started_at: :asc) + .pluck(:id) + end end diff --git a/app/controllers/trips_controller.rb b/app/controllers/trips_controller.rb index 2a9a26d2..038d4842 100644 --- a/app/controllers/trips_controller.rb +++ b/app/controllers/trips_controller.rb @@ -10,11 +10,6 @@ def index end def show - @coordinates = @trip.points.pluck( - :latitude, :longitude, :battery, :altitude, :timestamp, :velocity, :id, - :country - ).map { [_1.to_f, _2.to_f, _3.to_s, _4.to_s, _5.to_s, _6.to_s, _7.to_s, _8.to_s] } - @photo_previews = Rails.cache.fetch("trip_photos_#{@trip.id}", expires_in: 1.day) do @trip.photo_previews end diff --git a/app/helpers/api/v1/tracks_helper.rb b/app/helpers/api/v1/tracks_helper.rb new file mode 100644 index 00000000..81a9cf03 --- /dev/null +++ b/app/helpers/api/v1/tracks_helper.rb @@ -0,0 +1,2 @@ +module Api::V1::TracksHelper +end diff --git a/app/javascript/controllers/datetime_controller.js b/app/javascript/controllers/datetime_controller.js index 04c9061b..b56f07e3 100644 --- a/app/javascript/controllers/datetime_controller.js +++ b/app/javascript/controllers/datetime_controller.js @@ -1,3 +1,7 @@ +// This controller is being used on: +// - trips/new +// - trips/edit + import { Controller } from "@hotwired/stimulus" export default class extends Controller { diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 313b477d..c178914b 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -10,7 +10,8 @@ import { updatePolylinesOpacity, updatePolylinesColors, calculateSpeed, - getSpeedColor + getSpeedColor, + createTrackPolyline } from "../maps/polylines"; import { fetchAndDrawAreas } from "../maps/areas"; @@ -56,6 +57,7 @@ export default class extends Controller { this.liveMapEnabled = this.userSettings.live_map_enabled || false; this.countryCodesMap = countryCodesMap(); this.speedColoredPolylines = this.userSettings.speed_colored_routes || false; + this.fetchAndRenderTracks(); this.center = this.markers[this.markers.length - 1] || [52.514568, 13.350111]; @@ -76,14 +78,8 @@ export default class extends Controller { }, 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'; + this.statsDiv = div; // Store reference to the div + this.updateStats(); // Initial update return div; } }); @@ -102,6 +98,7 @@ export default class extends Controller { this.heatmapMarkers = this.markersArray.map((element) => [element._latlng.lat, element._latlng.lng, 0.2]); this.polylinesLayer = createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity, this.userSettings, this.distanceUnit); + this.updateStats(); this.heatmapLayer = L.heatLayer(this.heatmapMarkers, { radius: 20 }).addTo(this.map); // Create a proper Leaflet layer for fog @@ -218,8 +215,8 @@ export default class extends Controller { } const urlParams = new URLSearchParams(window.location.search); - const startDate = urlParams.get('start_at')?.split('T')[0] || new Date().toISOString().split('T')[0]; - const endDate = urlParams.get('end_at')?.split('T')[0] || new Date().toISOString().split('T')[0]; + const startDate = urlParams.get('start_at') || new Date().toISOString(); + const endDate = urlParams.get('end_at')|| new Date().toISOString(); await fetchAndDisplayPhotos({ map: this.map, photoMarkers: this.photoMarkers, @@ -240,6 +237,11 @@ export default class extends Controller { if (this.liveMapEnabled) { this.setupSubscription(); } + + // After map is initialized, fetch and render tracks + this.map.whenReady(() => { + this.fetchAndRenderTracks(); + }); } disconnect() { @@ -295,6 +297,7 @@ export default class extends Controller { this.userSettings, this.distanceUnit ); + this.updateStats(); // Pan map to new location this.map.setView([newPoint[0], newPoint[1]], 16); @@ -475,6 +478,7 @@ export default class extends Controller { this.userSettings, this.distanceUnit ); + this.updateStats(); if (wasPolyLayerVisible) { // Add new polylines layer to map and to layer control this.polylinesLayer.addTo(this.map); @@ -802,6 +806,7 @@ export default class extends Controller { this.polylinesLayer, newSettings.speed_colored_routes ); + this.updateStats(); } } @@ -809,6 +814,7 @@ export default class extends Controller { const newOpacity = parseFloat(newSettings.route_opacity) || 0.6; if (this.polylinesLayer) { updatePolylinesOpacity(this.polylinesLayer, newOpacity); + this.updateStats(); } } @@ -1291,5 +1297,113 @@ export default class extends Controller { } return `${hours}h`; } + + updateStats() { + if (!this.statsDiv) return; + + const distance = this.element.dataset.distance || '0'; + const pointsNumber = this.element.dataset.points_number || '0'; + const unit = this.distanceUnit === 'mi' ? 'mi' : 'km'; + const polylinesCount = this.polylinesLayer ? this.polylinesLayer.getLayers().length : 0; + + this.statsDiv.innerHTML = `${distance} ${unit} | ${pointsNumber} points | ${polylinesCount} routes`; + this.statsDiv.style.backgroundColor = 'white'; + this.statsDiv.style.padding = '0 5px'; + this.statsDiv.style.marginRight = '5px'; + this.statsDiv.style.display = 'inline-block'; + } + + async fetchAndRenderTracks() { + const trackIdsString = this.element.dataset.track_ids; + console.log('Track IDs string:', trackIdsString); + if (!trackIdsString || !this.map) { + console.log('Early return - missing data:', { trackIdsString: !!trackIdsString, map: !!this.map }); + return; + } + + const trackIds = trackIdsString.replace(/[\[\]]/g, '').split(',').map(id => id.trim()); + console.log(`Total tracks to fetch: ${trackIds.length}`); + + try { + // Create the layer group and store it as a class property + if (!this.tracksLayer) { + console.log('Creating new tracks layer'); + this.tracksLayer = L.layerGroup(); + if (this.map) { + console.log('Adding tracks layer to map'); + this.tracksLayer.addTo(this.map); + + if (this.layerControl) { + this.layerControl.addOverlay(this.tracksLayer, 'Tracks'); + } + } + } + + // Create shared renderer + const renderer = L.canvas({ padding: 0.5, pane: 'overlayPane' }); + + // Process tracks in smaller chunks to avoid URL length limits + const CHUNK_SIZE = 50; // Number of track IDs to include in each request + const BATCH_SIZE = 10; // Number of tracks to process at once from the response + + for (let i = 0; i < trackIds.length; i += CHUNK_SIZE) { + const chunk = trackIds.slice(i, i + CHUNK_SIZE); + console.log(`Fetching chunk ${Math.floor(i/CHUNK_SIZE) + 1}/${Math.ceil(trackIds.length/CHUNK_SIZE)}`); + + try { + const response = await fetch( + `/api/v1/tracks?ids=${chunk.join(',')}&per_page=${CHUNK_SIZE}&api_key=${this.apiKey}`, + { + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + credentials: 'same-origin' + } + ); + + if (!response.ok) { + console.warn(`Failed to fetch chunk: ${response.status}`); + continue; + } + + const tracks = await response.json(); + console.log(`Rendering ${tracks.length} tracks from chunk`); + + // Process tracks in smaller batches + for (let j = 0; j < tracks.length; j += BATCH_SIZE) { + const batch = tracks.slice(j, j + BATCH_SIZE); + + batch.forEach(track => { + try { + const trackLayer = createTrackPolyline(track, this.map, this.userSettings, renderer); + if (trackLayer && this.tracksLayer) { + this.tracksLayer.addLayer(trackLayer); + } + } catch (error) { + console.warn(`Failed to render track ${track.id}:`, error); + } + }); + + // Small delay between batches to allow browser to breathe + if (j + BATCH_SIZE < tracks.length) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + } + + } catch (error) { + console.error(`Error processing chunk:`, error); + } + + // Small delay between chunks + if (i + CHUNK_SIZE < trackIds.length) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + } + + } catch (error) { + console.error('Error in track fetching/rendering:', error); + } + } } diff --git a/app/javascript/controllers/trip_map_controller.js b/app/javascript/controllers/trip_map_controller.js index b2a18bfb..1bbdc207 100644 --- a/app/javascript/controllers/trip_map_controller.js +++ b/app/javascript/controllers/trip_map_controller.js @@ -1,10 +1,13 @@ +// This controller is being used on: +// - trips/index + import { Controller } from "@hotwired/stimulus" import L from "leaflet" export default class extends Controller { static values = { tripId: Number, - coordinates: Array, + path: String, apiKey: String, userSettings: Object, timezone: String, @@ -12,6 +15,8 @@ export default class extends Controller { } connect() { + console.log("TripMap controller connected") + setTimeout(() => { this.initializeMap() }, 100) @@ -23,7 +28,7 @@ export default class extends Controller { zoomControl: false, dragging: false, scrollWheelZoom: false, - attributionControl: true // Disable default attribution control + attributionControl: true }) // Add the tile layer @@ -33,24 +38,69 @@ export default class extends Controller { }).addTo(this.map) // If we have coordinates, show the route - if (this.hasCoordinatesValue && this.coordinatesValue.length > 0) { + if (this.hasPathValue && this.pathValue) { this.showRoute() + } else { + console.log("No path value available") } } showRoute() { - const points = this.coordinatesValue.map(coord => [coord[0], coord[1]]) + const points = this.parseLineString(this.pathValue) - const polyline = L.polyline(points, { - color: 'blue', - opacity: 0.8, - weight: 3, - zIndexOffset: 400 - }).addTo(this.map) + // Only create polyline if we have points + if (points.length > 0) { + const polyline = L.polyline(points, { + color: 'blue', + opacity: 0.8, + weight: 3, + zIndexOffset: 400 + }) - this.map.fitBounds(polyline.getBounds(), { - padding: [20, 20] - }) + // Add the polyline to the map + polyline.addTo(this.map) + + // Fit the map bounds + this.map.fitBounds(polyline.getBounds(), { + padding: [20, 20] + }) + } else { + console.error("No valid points to create polyline") + } + } + + parseLineString(linestring) { + try { + // Remove 'LINESTRING (' from start and ')' from end + const coordsString = linestring + .replace(/LINESTRING\s*\(/, '') // Remove LINESTRING and opening parenthesis + .replace(/\)$/, '') // Remove closing parenthesis + .trim() // Remove any leading/trailing whitespace + + // Split into coordinate pairs and parse + const points = coordsString.split(',').map(pair => { + // Clean up any extra whitespace and remove any special characters + const cleanPair = pair.trim().replace(/[()"\s]+/g, ' ') + const [lng, lat] = cleanPair.split(' ').filter(Boolean).map(Number) + + // Validate the coordinates + if (isNaN(lat) || isNaN(lng) || !lat || !lng) { + console.error("Invalid coordinates:", cleanPair) + return null + } + + return [lat, lng] // Leaflet uses [lat, lng] order + }).filter(point => point !== null) // Remove any invalid points + + // Validate we have points before returning + if (points.length === 0) { + return [] + } + + return points + } catch (error) { + return [] + } } disconnect() { diff --git a/app/javascript/controllers/trips_controller.js b/app/javascript/controllers/trips_controller.js index 602c04be..974feb30 100644 --- a/app/javascript/controllers/trips_controller.js +++ b/app/javascript/controllers/trips_controller.js @@ -1,17 +1,26 @@ +// This controller is being used on: +// - trips/show +// - trips/edit +// - trips/new + import { Controller } from "@hotwired/stimulus" import L from "leaflet" -import { osmMapLayer } from "../maps/layers" +import { + osmMapLayer, + osmHotMapLayer, + OPNVMapLayer, + openTopoMapLayer, + cyclOsmMapLayer, + esriWorldStreetMapLayer, + esriWorldTopoMapLayer, + esriWorldImageryMapLayer, + esriWorldGrayCanvasMapLayer +} from "../maps/layers" import { createPopupContent } from "../maps/popups" -import { osmHotMapLayer } from "../maps/layers" -import { OPNVMapLayer } from "../maps/layers" -import { openTopoMapLayer } 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 { fetchAndDisplayPhotos } from '../maps/helpers'; -import { showFlashMessage } from "../maps/helpers"; +import { + fetchAndDisplayPhotos, + showFlashMessage +} from '../maps/helpers'; export default class extends Controller { static targets = ["container", "startedAt", "endedAt"] @@ -23,9 +32,9 @@ export default class extends Controller { } console.log("Trips controller connected") - this.coordinates = JSON.parse(this.containerTarget.dataset.coordinates) + this.apiKey = this.containerTarget.dataset.api_key - this.userSettings = JSON.parse(this.containerTarget.dataset.user_settings) + this.userSettings = JSON.parse(this.containerTarget.dataset.user_settings || '{}') this.timezone = this.containerTarget.dataset.timezone this.distanceUnit = this.containerTarget.dataset.distance_unit @@ -34,7 +43,6 @@ export default class extends Controller { // Add event listener for coordinates updates this.element.addEventListener('coordinates-updated', (event) => { - console.log("Coordinates updated:", event.detail.coordinates) this.updateMapWithCoordinates(event.detail.coordinates) }) } @@ -42,16 +50,12 @@ export default class extends Controller { // Move map initialization to separate method initializeMap() { // Initialize layer groups - this.markersLayer = L.layerGroup() this.polylinesLayer = L.layerGroup() this.photoMarkers = L.layerGroup() // Set default center and zoom for world view - const hasValidCoordinates = this.coordinates && Array.isArray(this.coordinates) && this.coordinates.length > 0 - const center = hasValidCoordinates - ? [this.coordinates[0][0], this.coordinates[0][1]] - : [20, 0] // Roughly centers the world map - const zoom = hasValidCoordinates ? 14 : 2 + const center = [20, 0] // Roughly centers the world map + const zoom = 2 // Initialize map this.map = L.map(this.containerTarget).setView(center, zoom) @@ -68,7 +72,6 @@ export default class extends Controller { }).addTo(this.map) const overlayMaps = { - "Points": this.markersLayer, "Route": this.polylinesLayer, "Photos": this.photoMarkers } @@ -80,6 +83,15 @@ export default class extends Controller { this.map.on('overlayadd', (e) => { if (e.name !== 'Photos') return; + const startedAt = this.element.dataset.started_at; + const endedAt = this.element.dataset.ended_at; + + console.log('Dataset values:', { + startedAt, + endedAt, + path: this.element.dataset.path + }); + if ((!this.userSettings.immich_url || !this.userSettings.immich_api_key) && (!this.userSettings.photoprism_url || !this.userSettings.photoprism_api_key)) { showFlashMessage( 'error', @@ -88,13 +100,26 @@ export default class extends Controller { return; } - if (!this.coordinates?.length) return; - - const firstCoord = this.coordinates[0]; - const lastCoord = this.coordinates[this.coordinates.length - 1]; + // Try to get dates from coordinates first, then fall back to path data + let startDate, endDate; - const startDate = new Date(firstCoord[4] * 1000).toISOString().split('T')[0]; - const endDate = new Date(lastCoord[4] * 1000).toISOString().split('T')[0]; + if (this.coordinates?.length) { + const firstCoord = this.coordinates[0]; + const lastCoord = this.coordinates[this.coordinates.length - 1]; + startDate = new Date(firstCoord[4] * 1000).toISOString().split('T')[0]; + endDate = new Date(lastCoord[4] * 1000).toISOString().split('T')[0]; + } else if (startedAt && endedAt) { + // Parse the dates and format them correctly + startDate = new Date(startedAt).toISOString().split('T')[0]; + endDate = new Date(endedAt).toISOString().split('T')[0]; + } else { + console.log('No date range available for photos'); + showFlashMessage( + 'error', + 'No date range available for photos. Please ensure the trip has start and end dates.' + ); + return; + } fetchAndDisplayPhotos({ map: this.map, @@ -112,6 +137,27 @@ export default class extends Controller { this.addPolyline() this.fitMapToBounds() } + + // After map initialization, add the path if it exists + if (this.containerTarget.dataset.path) { + const pathData = this.containerTarget.dataset.path.replace(/^"|"$/g, ''); // Remove surrounding quotes + const coordinates = this.parseLineString(pathData); + + const polyline = L.polyline(coordinates, { + color: 'blue', + opacity: 0.8, + weight: 3, + zIndexOffset: 400 + }); + + polyline.addTo(this.polylinesLayer); + this.polylinesLayer.addTo(this.map); + + // Fit the map to the polyline bounds + if (coordinates.length > 0) { + this.map.fitBounds(polyline.getBounds(), { padding: [50, 50] }); + } + } } disconnect() { @@ -149,9 +195,7 @@ export default class extends Controller { const popupContent = createPopupContent(coord, this.timezone, this.distanceUnit) marker.bindPopup(popupContent) - - // Add to markers layer instead of directly to map - marker.addTo(this.markersLayer) + marker.addTo(this.polylinesLayer) }) } @@ -175,7 +219,7 @@ export default class extends Controller { this.map.fitBounds(bounds, { padding: [50, 50] }) } - // Add this new method to update coordinates and refresh the map + // Update coordinates and refresh the map updateMapWithCoordinates(newCoordinates) { // Transform the coordinates to match the expected format this.coordinates = newCoordinates.map(point => [ @@ -187,7 +231,6 @@ export default class extends Controller { ]).sort((a, b) => a[4] - b[4]); // Clear existing layers - this.markersLayer.clearLayers() this.polylinesLayer.clearLayers() this.photoMarkers.clearLayers() @@ -198,4 +241,17 @@ export default class extends Controller { this.fitMapToBounds() } } + + // Add this method to parse the LineString format + parseLineString(lineString) { + // Remove LINESTRING and parentheses, then split into coordinate pairs + const coordsString = lineString.replace('LINESTRING (', '').replace(')', ''); + const coords = coordsString.split(', '); + + // Convert each coordinate pair to [lat, lng] format + return coords.map(coord => { + const [lng, lat] = coord.split(' ').map(Number); + return [lat, lng]; // Swap to lat, lng for Leaflet + }); + } } diff --git a/app/javascript/maps/polylines.js b/app/javascript/maps/polylines.js index e48479d3..1318a7eb 100644 --- a/app/javascript/maps/polylines.js +++ b/app/javascript/maps/polylines.js @@ -3,46 +3,6 @@ import { formatDistance } from "../maps/helpers"; import { minutesToDaysHoursMinutes } from "../maps/helpers"; import { haversineDistance } from "../maps/helpers"; -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; - - 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; - } - - 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; - } - - const dx = x - xx; - const dy = y - yy; - - return Math.sqrt(dx * dx + dy * dy); -} - export function calculateSpeed(point1, point2) { if (!point1 || !point2 || !point1[4] || !point2[4]) { console.warn('Invalid points for speed calculation:', { point1, point2 }); @@ -567,3 +527,134 @@ export function updatePolylinesOpacity(polylinesLayer, opacity) { segment.setStyle({ opacity: opacity }); }); } + +// New function to create a single track polyline +export function createTrackPolyline(track, map, userSettings, renderer) { + if (!track.path) return null; + + const coordinates = parseLineString(track.path); + if (coordinates.length < 2) return null; + + // Create a feature group for this track + const trackGroup = L.featureGroup(); + + // Create a polyline for the track + const trackLine = L.polyline(coordinates, { + renderer: renderer, + color: userSettings.speed_colored_routes ? + getSpeedColor(calculateAverageSpeed(track), true) : + '#0000ff', + weight: 3, + opacity: userSettings.route_opacity || 0.6, + interactive: true + }); + + // Add the track to the feature group + trackGroup.addLayer(trackLine); + + // Add highlight functionality + addHighlightOnHover( + trackGroup, + map, + coordinates.map(coord => [ + coord[0], + coord[1], + null, + null, + new Date(track.started_at).getTime() / 1000 + ]), + userSettings, + 'km' + ); + + // Add track metadata + trackGroup.trackData = { + id: track.id, + startedAt: track.started_at, + endedAt: track.ended_at, + speed: calculateAverageSpeed(track) + }; + + return trackGroup; +} + +// Update the original function to use the new single track function +export function createTrackPolylinesLayer(tracks, map, userSettings) { + const layerGroup = L.layerGroup(); + const renderer = L.canvas({ padding: 0.5, pane: 'overlayPane' }); + + tracks.forEach(track => { + const trackLayer = createTrackPolyline(track, map, userSettings, renderer); + if (trackLayer) { + layerGroup.addLayer(trackLayer); + } + }); + + return layerGroup; +} + +// Helper function to calculate average speed for a track +function calculateAverageSpeed(track) { + const startTime = new Date(track.started_at).getTime() / 1000; + const endTime = new Date(track.ended_at).getTime() / 1000; + const coordinates = parseLineString(track.path); + + let totalDistance = 0; + for (let i = 1; i < coordinates.length; i++) { + totalDistance += haversineDistance( + coordinates[i-1][0], coordinates[i-1][1], + coordinates[i][0], coordinates[i][1] + ); + } + + const duration = endTime - startTime; + if (duration <= 0) return 0; + + const speedKmh = (totalDistance / duration) * 3600; + return Math.min(speedKmh, 150); // Cap at 150 km/h +} + +// Helper function to format duration +function formatDuration(startTime, endTime) { + const duration = (new Date(endTime) - new Date(startTime)) / 1000; // in seconds + const hours = Math.floor(duration / 3600); + const minutes = Math.floor((duration % 3600) / 60); + return `${hours}h ${minutes}m`; +} + +// Helper function to parse LineString format +function parseLineString(pathString) { + try { + // Handle null or undefined input + if (!pathString) { + console.warn('Invalid pathString:', pathString); + return []; + } + + // Remove LINESTRING wrapper and split coordinates + const coordsString = pathString.replace(/LINESTRING\s*\((.*)\)/, '$1'); + const coordPairs = coordsString.split(',').map(pair => pair.trim()); + + return coordPairs.map(pair => { + const [lon, lat] = pair.split(' ').map(str => parseFloat(str.trim())); + return [lat, lon]; // Leaflet uses [lat, lon] order + }).filter(coord => isValidLatLng(coord[0], coord[1])); + } catch (error) { + console.error('Error parsing LineString:', error, { pathString }); + return []; + } +} + +// Helper function to validate coordinates +function isValidLatLng(lat, lng) { + return ( + typeof lat === 'number' && + typeof lng === 'number' && + !isNaN(lat) && + !isNaN(lng) && + lat >= -90 && + lat <= 90 && + lng >= -180 && + lng <= 180 + ); +} diff --git a/app/jobs/tracks/create_job.rb b/app/jobs/tracks/create_job.rb new file mode 100644 index 00000000..1b3aad5f --- /dev/null +++ b/app/jobs/tracks/create_job.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class Tracks::CreateJob < ApplicationJob + queue_as :default + + def perform(user_id, points_ids) + coordinates = + Point + .where(user_id: user_id, id: points_ids) + .order(timestamp: :asc) + .pluck(:latitude, :longitude, :timestamp) + + path = Tracks::BuildPath.new(coordinates.map { |c| [c[0], c[1]] }).call + + Track.create!( + user_id: user_id, + started_at: Time.zone.at(coordinates.first.last), + ended_at: Time.zone.at(coordinates.last.last), + path: path + ) + end +end diff --git a/app/jobs/tracks/create_path_job.rb b/app/jobs/tracks/create_path_job.rb new file mode 100644 index 00000000..37e3924d --- /dev/null +++ b/app/jobs/tracks/create_path_job.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +class Tracks::CreatePathJob < ApplicationJob + queue_as :default + + def perform(user_id) + user = User.find(user_id) + + # Get all points ordered by timestamp + points = user.tracked_points.order(timestamp: :asc) + + # Skip if no points + return if points.empty? + + # Initialize variables for grouping + current_group = [] + last_point = nil + + points.find_each do |point| + if should_start_new_group?(last_point, point) + # Create track from current group if it's valid + build_track_from_points(current_group, user) if current_group.size > 1 + + # Start new group + current_group = [point] + else + # Add to current group + current_group << point + end + + last_point = point + end + + # Don't forget to process the last group + build_track_from_points(current_group, user) if current_group.size > 1 + end + + private + + def should_start_new_group?(last_point, current_point) + return true if last_point.nil? + + # Calculate time and distance between points + time_diff_minutes = (current_point.timestamp - last_point.timestamp) / 60.0 + distance_meters = calculate_distance(last_point, current_point) + + # Use the same thresholds as frontend for consistency + time_diff_minutes > (DawarichSettings.minutes_between_tracks || 60) || + distance_meters > (DawarichSettings.meters_between_tracks || 500) + end + + def calculate_distance(point1, point2) + # Use Haversine formula to calculate distance between points + rad_per_deg = Math::PI / 180 + earth_radius = 6_371_000 # Earth's radius in meters + + lat1_rad = point1.latitude * rad_per_deg + lat2_rad = point2.latitude * rad_per_deg + lon1_rad = point1.longitude * rad_per_deg + lon2_rad = point2.longitude * rad_per_deg + + lat_diff = lat2_rad - lat1_rad + lon_diff = lon2_rad - lon1_rad + + a = Math.sin(lat_diff / 2) * Math.sin(lat_diff / 2) + + Math.cos(lat1_rad) * Math.cos(lat2_rad) * + Math.sin(lon_diff / 2) * Math.sin(lon_diff / 2) + c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) + + earth_radius * c # Distance in meters + end + + def build_track_from_points(points, user) + return if points.empty? + + coordinates = points.map { |p| [p.latitude, p.longitude, p.timestamp] } + + path = Tracks::BuildPath.new(coordinates.map { |c| [c[0], c[1]] }).call + + { + user_id: user.id, + started_at: Time.zone.at(coordinates.first.last), + ended_at: Time.zone.at(coordinates.last.last), + path: path + } + end +end diff --git a/app/jobs/trips/create_path_job.rb b/app/jobs/trips/create_path_job.rb new file mode 100644 index 00000000..fda0f188 --- /dev/null +++ b/app/jobs/trips/create_path_job.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class Trips::CreatePathJob < ApplicationJob + queue_as :default + + def perform(trip_id) + trip = Trip.find(trip_id) + trip.path = Tracks::BuildPath.new(trip.points.pluck(:latitude, :longitude)).call + + trip.save! + end +end diff --git a/app/models/track.rb b/app/models/track.rb new file mode 100644 index 00000000..f006806e --- /dev/null +++ b/app/models/track.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Track < ApplicationRecord + belongs_to :user + + validates :path, :started_at, :ended_at, presence: true + + before_save :set_path + + def points + user.tracked_points.where(timestamp: started_at.to_i..ended_at.to_i).order(timestamp: :asc) + end + + def set_path + self.path = Tracks::BuildPath.new(points.pluck(:latitude, :longitude)).call + end +end diff --git a/app/models/trip.rb b/app/models/trip.rb index 4a2b0302..6d0a9e72 100644 --- a/app/models/trip.rb +++ b/app/models/trip.rb @@ -7,7 +7,12 @@ class Trip < ApplicationRecord validates :name, :started_at, :ended_at, presence: true - before_save :calculate_distance + before_save :set_path_and_distance + + def set_path_and_distance + calculate_path + calculate_distance + end def points user.tracked_points.where(timestamp: started_at.to_i..ended_at.to_i).order(:timestamp) @@ -49,4 +54,8 @@ def calculate_distance self.distance = distance.round end + + def calculate_path + self.path = Tracks::BuildPath.new(points.pluck(:latitude, :longitude)).call + end end diff --git a/app/models/user.rb b/app/models/user.rb index b3112130..90ff2fb0 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -13,7 +13,8 @@ class User < ApplicationRecord has_many :visits, dependent: :destroy has_many :points, through: :imports has_many :places, through: :visits - has_many :trips, dependent: :destroy + has_many :trips, dependent: :destroy + has_many :tracks, dependent: :destroy after_create :create_api_key before_save :strip_trailing_slashes diff --git a/app/serializers/api/track_serializer.rb b/app/serializers/api/track_serializer.rb new file mode 100644 index 00000000..8651a942 --- /dev/null +++ b/app/serializers/api/track_serializer.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Api::TrackSerializer + EXCLUDED_ATTRIBUTES = %w[created_at updated_at user_id].freeze + + def initialize(track) + @track = track + end + + def call + track.attributes.except(*EXCLUDED_ATTRIBUTES) + end + + private + + attr_reader :track +end diff --git a/app/services/tracks/build_path.rb b/app/services/tracks/build_path.rb new file mode 100644 index 00000000..0513b468 --- /dev/null +++ b/app/services/tracks/build_path.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class Tracks::BuildPath + def initialize(coordinates) + @coordinates = coordinates # [[lat, lon], [lat, lon], ...] + end + + def call + factory.line_string( + coordinates.map { |point| factory.point(point[1].to_f.round(5), point[0].to_f.round(5)) } + ) + end + + private + + attr_reader :coordinates + + def factory + @factory ||= RGeo::Geographic.spherical_factory(srid: 3857) + end +end diff --git a/app/views/map/index.html.erb b/app/views/map/index.html.erb index d3c39f80..5fc37f10 100644 --- a/app/views/map/index.html.erb +++ b/app/views/map/index.html.erb @@ -53,6 +53,7 @@ data-coordinates="<%= @coordinates %>" data-distance="<%= @distance %>" data-points_number="<%= @points_number %>" + data-track_ids="<%= @track_ids %>" data-timezone="<%= Rails.configuration.time_zone %>">