dawarich/app/services/tracks/segmentation.rb
Evgenii Burmakin c3c5410258
Add tracks to map v2 (#2142)
* Add tracks to map v2

* Remove console log

* Update tracks generation behavior to ignore distance threshold for frontend parity

* Extract logic to services from TracksController#index and add tests

* Move query logic for track listing into a service object.
2026-01-11 13:31:36 +01:00

134 lines
4.3 KiB
Ruby

# frozen_string_literal: true
# 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 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 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
# - 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 time_threshold_minutes methods.
#
# Used by:
# - Tracks::ParallelGenerator and related jobs for splitting points during parallel track generation
# - Tracks::BoundaryDetector for cross-chunk track merging
#
# Example usage:
# class MyTrackProcessor
# include Tracks::Segmentation
#
# def time_threshold_minutes; 60; end
#
# def process_points(points)
# segments = split_points_into_segments(points)
# # Process each segment...
# end
# end
#
module Tracks::Segmentation
extend ActiveSupport::Concern
private
def split_points_into_segments(points)
return [] if points.empty?
segments = []
current_segment = []
points.each do |point|
if should_start_new_segment?(point, current_segment.last)
# Finalize current segment if it has enough points
segments << current_segment if current_segment.size >= 2
current_segment = [point]
else
current_segment << point
end
end
# Don't forget the last segment
segments << current_segment if current_segment.size >= 2
segments
end
# Alternative segmentation using Geocoder (no SQL dependency)
def split_points_into_segments_geocoder(points)
return [] if points.empty?
segments = []
current_segment = []
points.each do |point|
if should_start_new_segment_geocoder?(point, current_segment.last)
# Finalize current segment if it has enough points
segments << current_segment if current_segment.size >= 2
current_segment = [point]
else
current_segment << point
end
end
# Don't forget the last segment
segments << current_segment if current_segment.size >= 2
segments
end
def should_start_new_segment?(current_point, previous_point)
return false if previous_point.nil?
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?
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
time_diff_seconds > time_threshold_seconds
end
def should_finalize_segment?(segment_points, grace_period_minutes = 5)
return false if segment_points.size < 2
last_point = segment_points.last
last_timestamp = last_point.timestamp
current_time = Time.current.to_i
# Don't finalize if the last point is too recent (within grace period)
time_since_last_point = current_time - last_timestamp
grace_period_seconds = grace_period_minutes * 60
time_since_last_point > grace_period_seconds
end
def point_coordinates(point)
[point.lat, point.lon]
end
def time_threshold_minutes
raise NotImplementedError, "Including class must implement time_threshold_minutes"
end
end