diff --git a/app/services/tracks/parallel_generator.rb b/app/services/tracks/parallel_generator.rb index ea8c8ac2..6c0b2a83 100644 --- a/app/services/tracks/parallel_generator.rb +++ b/app/services/tracks/parallel_generator.rb @@ -58,7 +58,8 @@ class Tracks::ParallelGenerator end_at: end_at&.iso8601, user_settings: { time_threshold_minutes: time_threshold_minutes, - distance_threshold_meters: distance_threshold_meters + distance_threshold_meters: distance_threshold_meters, + distance_threshold_behavior: 'ignored_for_frontend_parity' } } diff --git a/app/services/tracks/segmentation.rb b/app/services/tracks/segmentation.rb index cbc5b471..d7ca5522 100644 --- a/app/services/tracks/segmentation.rb +++ b/app/services/tracks/segmentation.rb @@ -3,22 +3,26 @@ # Track segmentation logic for splitting GPS points into meaningful track segments. # # This module provides the core algorithm for determining where one track ends -# and another begins, based on time gaps and distance jumps between consecutive points. +# and another begins, based primarily on time gaps between consecutive points. # # How it works: # 1. Analyzes consecutive GPS points to detect gaps that indicate separate journeys -# 2. Uses configurable time and distance thresholds to identify segment boundaries +# 2. Uses configurable time thresholds to identify segment boundaries # 3. Splits large arrays of points into smaller arrays representing individual tracks # 4. Provides utilities for handling both Point objects and hash representations # # Segmentation criteria: # - Time threshold: Gap longer than X minutes indicates a new track -# - Distance threshold: Jump larger than X meters indicates a new track # - Minimum segment size: Segments must have at least 2 points to form a track # +# ❗️ Frontend Parity (see CLAUDE.md "Route Drawing Implementation") +# The maps intentionally ignore the distance threshold because haversineDistance() +# returns kilometers while the UI exposes a value in meters. That unit mismatch +# effectively disables distance-based splitting, so we mirror that behavior on the +# backend to keep server-generated tracks identical to what users see on the map. +# # The module is designed to be included in classes that need segmentation logic -# and requires the including class to implement distance_threshold_meters and -# time_threshold_minutes methods. +# and requires the including class to implement time_threshold_minutes methods. # # Used by: # - Tracks::ParallelGenerator and related jobs for splitting points during parallel track generation @@ -28,7 +32,6 @@ # class MyTrackProcessor # include Tracks::Segmentation # -# def distance_threshold_meters; 500; end # def time_threshold_minutes; 60; end # # def process_points(points) @@ -90,70 +93,21 @@ module Tracks::Segmentation def should_start_new_segment?(current_point, previous_point) return false if previous_point.nil? - # Check time threshold (convert minutes to seconds) - current_timestamp = current_point.timestamp - previous_timestamp = previous_point.timestamp - - time_diff_seconds = current_timestamp - previous_timestamp - time_threshold_seconds = time_threshold_minutes.to_i * 60 - - return true if time_diff_seconds > time_threshold_seconds - - # Check distance threshold - convert km to meters to match frontend logic - distance_km = calculate_km_distance_between_points(previous_point, current_point) - distance_meters = distance_km * 1000 # Convert km to meters - - return true if distance_meters > distance_threshold_meters - - false + time_gap_exceeded?(current_point.timestamp, previous_point.timestamp) end # Alternative segmentation logic using Geocoder (no SQL dependency) def should_start_new_segment_geocoder?(current_point, previous_point) return false if previous_point.nil? - # Check time threshold (convert minutes to seconds) - current_timestamp = current_point.timestamp - previous_timestamp = previous_point.timestamp + time_gap_exceeded?(current_point.timestamp, previous_point.timestamp) + end + def time_gap_exceeded?(current_timestamp, previous_timestamp) time_diff_seconds = current_timestamp - previous_timestamp time_threshold_seconds = time_threshold_minutes.to_i * 60 - return true if time_diff_seconds > time_threshold_seconds - - # Check distance threshold using Geocoder - distance_km = calculate_km_distance_between_points_geocoder(previous_point, current_point) - distance_meters = distance_km * 1000 # Convert km to meters - - return true if distance_meters > distance_threshold_meters - - false - end - - def calculate_km_distance_between_points(point1, point2) - distance_meters = Point.connection.select_value( - 'SELECT ST_Distance(ST_GeomFromEWKT($1)::geography, ST_GeomFromEWKT($2)::geography)', - nil, - [point1.lonlat, point2.lonlat] - ) - - distance_meters.to_f / 1000.0 # Convert meters to kilometers - end - - # In-memory distance calculation using Geocoder (no SQL dependency) - def calculate_km_distance_between_points_geocoder(point1, point2) - begin - distance = point1.distance_to_geocoder(point2, :km) - - # Validate result - if !distance.finite? || distance < 0 - return 0 - end - - distance - rescue StandardError => e - 0 - end + time_diff_seconds > time_threshold_seconds end def should_finalize_segment?(segment_points, grace_period_minutes = 5) @@ -174,10 +128,6 @@ module Tracks::Segmentation [point.lat, point.lon] end - def distance_threshold_meters - raise NotImplementedError, "Including class must implement distance_threshold_meters" - end - def time_threshold_minutes raise NotImplementedError, "Including class must implement time_threshold_minutes" end diff --git a/spec/services/tracks/parallel_generator_spec.rb b/spec/services/tracks/parallel_generator_spec.rb index eebe107b..09b7bd7f 100644 --- a/spec/services/tracks/parallel_generator_spec.rb +++ b/spec/services/tracks/parallel_generator_spec.rb @@ -68,6 +68,7 @@ RSpec.describe Tracks::ParallelGenerator do expect(session_data['metadata']['chunk_size']).to eq('1 day') expect(session_data['metadata']['user_settings']['time_threshold_minutes']).to eq(30) expect(session_data['metadata']['user_settings']['distance_threshold_meters']).to eq(500) + expect(session_data['metadata']['user_settings']['distance_threshold_behavior']).to eq('ignored_for_frontend_parity') end it 'marks session as started with chunk count' do @@ -223,6 +224,7 @@ RSpec.describe Tracks::ParallelGenerator do user_settings = session_data['metadata']['user_settings'] expect(user_settings['time_threshold_minutes']).to eq(60) expect(user_settings['distance_threshold_meters']).to eq(1000) + expect(user_settings['distance_threshold_behavior']).to eq('ignored_for_frontend_parity') end it 'caches user settings' do diff --git a/spec/services/tracks/segmentation_spec.rb b/spec/services/tracks/segmentation_spec.rb new file mode 100644 index 00000000..bcbc2933 --- /dev/null +++ b/spec/services/tracks/segmentation_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Tracks::Segmentation do + let(:segmenter_class) do + Class.new do + include Tracks::Segmentation + + def initialize(time_threshold_minutes: 30) + @threshold = time_threshold_minutes + end + + private + + attr_reader :threshold + + def time_threshold_minutes + @threshold + end + end + end + + let(:segmenter) { segmenter_class.new(time_threshold_minutes: 60) } + + describe '#split_points_into_segments_geocoder' do + let(:base_time) { Time.zone.now.to_i } + + it 'keeps large spatial jumps within the same segment when time gap is below the threshold' do + points = [ + build(:point, timestamp: base_time, latitude: 0, longitude: 0, lonlat: 'POINT(0 0)'), + build(:point, timestamp: base_time + 5.minutes.to_i, latitude: 80, longitude: 170, lonlat: 'POINT(170 80)') + ] + + segments = segmenter.send(:split_points_into_segments_geocoder, points) + + expect(segments.length).to eq(1) + expect(segments.first).to eq(points) + end + + it 'splits segments only when the time gap exceeds the threshold' do + points = [ + build(:point, timestamp: base_time, latitude: 0, longitude: 0, lonlat: 'POINT(0 0)'), + build(:point, timestamp: base_time + 5.minutes.to_i, latitude: 0.1, longitude: 0.1, lonlat: 'POINT(0.1 0.1)'), + build(:point, timestamp: base_time + 2.hours.to_i, latitude: 1, longitude: 1, lonlat: 'POINT(1 1)'), + build(:point, timestamp: base_time + 2.hours.to_i + 10.minutes.to_i, latitude: 1.1, longitude: 1.1, lonlat: 'POINT(1.1 1.1)') + ] + + segments = segmenter.send(:split_points_into_segments_geocoder, points) + + expect(segments.length).to eq(2) + expect(segments.first).to eq(points.first(2)) + expect(segments.last).to eq(points.last(2)) + end + end +end