gpx_doctor 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 367e09fc58af88bd756703fd5e2647632fd9991f753f3581e3bcb439f7733ce3
4
- data.tar.gz: 3a7922964bb774be1eb1a1d2af78aee4a7d56b868b7652c481fb186cc6438bce
3
+ metadata.gz: 804ebe2457e24c9688e6e8993d0dc3d4adab7b7fddc27478706eaf72a9d09f59
4
+ data.tar.gz: e1bb3e8d17ff968a1cd6414c736d9f444559f447d5e6cc8dd3c79175a394b68b
5
5
  SHA512:
6
- metadata.gz: 9bad3d27880e55e001fea734b7d481f19569f0e0d202958d4ae893a1722eef5b0fb7a5431030f970872358c812e5a83a492f99c945096652e9547f0945238624
7
- data.tar.gz: 0a4a032459ed9916bc8cd99c64d38676822efbed6e1a470d86a9806878f3ee65f5239827f1830eeb6dbdac573f845a64bcea70b7f24f5b686bc8ef440fb29325
6
+ metadata.gz: f8e5bd2ab41937b0faa004c5d1c1ae3c422ba9d332e3aa1ea353fe7f9c22706c0f53e5b3c8161361e8d38ead9bed4fa516d7415d580cc912bdc3976452107dfb
7
+ data.tar.gz: 68e78f3a4f84027272a1a96127b998d2a00c447e1f0b75daff611a8d3123c374df0ab6ebf92563e6ab516a4ff9d2bcaa2540dabdad8358a9d35f87fd4cd23860
data/README.md CHANGED
@@ -24,6 +24,7 @@ GpxDoctor.configure do |config|
24
24
  config.elevation_server_url = "https://elevation.example.com"
25
25
  config.elevation_server_user = "user"
26
26
  config.elevation_server_password = "secret"
27
+ config.unit_system = :imperial # or :metric (default)
27
28
  end
28
29
 
29
30
  GpxDoctor.configuration.elevation_server_url # => "https://elevation.example.com"
@@ -36,6 +37,33 @@ GpxDoctor.reset_configuration! # resets to defaults
36
37
  | `elevation_server_url` | String | `nil` | URL of the elevation server |
37
38
  | `elevation_server_user` | String | `nil` | Username for the elevation server |
38
39
  | `elevation_server_password` | String | `nil` | Password for the elevation server |
40
+ | `unit_system` | Symbol | `:metric` | Unit system (`:metric` or `:imperial`) for distance and elevation values |
41
+
42
+ ### Unit System
43
+
44
+ The `unit_system` configuration affects the following fields:
45
+
46
+ - **`:metric` (default)**:
47
+ - `distance_to_next` and `elevation_change` are in **meters**
48
+ - `cumulative_distance` is in **kilometers**
49
+ - **`:imperial`**:
50
+ - `distance_to_next` and `elevation_change` are in **feet**
51
+ - `cumulative_distance` is in **miles**
52
+
53
+ ```ruby
54
+ # Use imperial units
55
+ GpxDoctor.configure do |config|
56
+ config.unit_system = :imperial
57
+ end
58
+
59
+ result = GpxDoctor::Parser.parse("path/to/file.gpx", params: {
60
+ segment_statistics: true,
61
+ cumulative_distance: true
62
+ })
63
+
64
+ # distance_to_next and elevation_change will be in feet
65
+ # cumulative_distance will be in miles
66
+ ```
39
67
 
40
68
  ## Parsing
41
69
 
@@ -60,7 +88,9 @@ result = GpxDoctor::Parser.parse("path/to/file.gpx", params: {
60
88
  max_distance: 200, # insert interpolated points so no two consecutive points exceed this distance (metres)
61
89
  max_points: 500, # reduce each segment to at most this many points
62
90
  segment_statistics: true, # compute distance_to_next, elevation_change, direction for each point
63
- enhance_elevation: true # fetch missing elevations from the configured elevation server
91
+ cumulative_distance: true, # add cumulative distance from start of each segment/route
92
+ enhance_elevation: true, # fetch missing elevations from the configured elevation server
93
+ full_poi_data: true # populate result.pois with start/finish boundary data for the whole GPX and each track segment
64
94
  })
65
95
  ```
66
96
 
@@ -69,10 +99,16 @@ Processing is applied in the following order:
69
99
  1. `max_distance` — segment splitting (interpolates intermediate points)
70
100
  2. `max_points` — point reduction
71
101
  3. `segment_statistics` — per-point statistics (distance, bearing, elevation change)
72
- 4. `enhance_elevation` — elevation lookup via the elevation server
102
+ 4. `cumulative_distance` — cumulative distance from the start of each segment/route
103
+ 5. `enhance_elevation` — elevation lookup via the elevation server
104
+ 6. `full_poi_data` — POI boundary extraction (start, finish, and per-segment boundaries)
73
105
 
74
106
  `enhance_elevation: true` requires the elevation server to be configured (see **Configuration** above). It only fills in points that have no elevation value; existing elevations are left unchanged.
75
107
 
108
+ `cumulative_distance: true` adds a `cumulative_distance` field to each point, representing the cumulative distance in kilometers from the start of its segment or route. For tracks with multiple segments, each segment's cumulative distance starts at 0.0 (gaps between segments are not included in the calculation).
109
+
110
+ `full_poi_data: true` populates `result.pois` with the first and last geographic point of the entire GPX (across all routes and track segments), plus optional per-segment boundary data. Distances within each segment start at 0.0 and reflect that segment's length only. The `ele` key is omitted for points that have no elevation value. The global `finish` distance is the sum of all individual collection lengths. See **`result.pois`** below for the output shape.
111
+
76
112
  ## Accessing data
77
113
 
78
114
  ```ruby
@@ -81,6 +117,7 @@ result.waypoints # => [#<Waypoint …>] (top-level <wpt> elements only)
81
117
  result.routes # => [#<Route …>]
82
118
  result.tracks # => [#<Track …>]
83
119
  result.metadata # => #<Metadata …> (or nil)
120
+ result.pois # => Hash (only when parsed with full_poi_data: true, otherwise nil)
84
121
  ```
85
122
 
86
123
  `result.points` is a flat array containing **all** geographic points from:
@@ -88,6 +125,146 @@ result.metadata # => #<Metadata …> (or nil)
88
125
  - `<rtept>` elements inside each `<rte>`
89
126
  - `<trkpt>` elements inside each `<trkseg>` inside each `<trk>`
90
127
 
128
+ ### `result.pois`
129
+
130
+ When parsed with `full_poi_data: true`, `result.pois` contains boundary data for the entire GPX and each track segment:
131
+
132
+ ```ruby
133
+ result = GpxDoctor::Parser.parse("route.gpx", params: { full_poi_data: true })
134
+ result.pois
135
+ # =>
136
+ # {
137
+ # start: { lon: 16.38, lat: 48.23, ele: 170.0, distance: 0.0 },
138
+ # finish: { lon: 16.39, lat: 48.24, ele: 175.0, distance: 1.42 },
139
+ # segments: [
140
+ # {
141
+ # start: { lon: 16.38, lat: 48.23, ele: 170.0, distance: 0.0 },
142
+ # finish: { lon: 16.39, lat: 48.24, ele: 175.0, distance: 1.42 }
143
+ # }
144
+ # ]
145
+ # }
146
+ ```
147
+
148
+ - **`start`** / **`finish`** — first and last geographic point across all routes and track segments, each with `lon`, `lat`, `distance` (and `ele` when present). `start` always has `distance: 0.0`; `finish` distance is the sum of all individual route/segment lengths.
149
+ - **`segments`** — present only when the GPX contains track segments. Each entry is `{start:, finish:}` for one `<trkseg>`, with distances measured from the beginning of that segment (`start` is always `distance: 0.0`).
150
+ - The `ele` key is omitted for points that have no elevation value.
151
+ - Distance values respect the configured `unit_system` (kilometres for `:metric`, miles for `:imperial`).
152
+
153
+ ## Building GPX files
154
+
155
+ The `GpxDoctor::Builder` class generates GPX 1.1 XML from a `Result` object (the same structure returned by the parser).
156
+
157
+ ### Build to a string
158
+
159
+ ```ruby
160
+ result = GpxDoctor::Parser::Result.new(
161
+ waypoints: [],
162
+ routes: [],
163
+ tracks: [],
164
+ metadata: nil
165
+ )
166
+
167
+ xml_string = GpxDoctor::Builder.build(result)
168
+ # => "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<gpx version=\"1.1\"..."
169
+
170
+ # Optional: specify a custom creator attribute (identifies the software that created the GPX file)
171
+ xml_string = GpxDoctor::Builder.build(result, creator: 'My Application')
172
+ ```
173
+
174
+ ### Build to a file
175
+
176
+ ```ruby
177
+ GpxDoctor::Builder.build_file(result, 'path/to/output.gpx')
178
+ # Writes the GPX XML to the file and returns the XML string
179
+
180
+ # Optional: specify a custom creator attribute
181
+ GpxDoctor::Builder.build_file(result, 'path/to/output.gpx', creator: 'My Application')
182
+ ```
183
+
184
+ ### Input structure
185
+
186
+ The builder expects a `GpxDoctor::Parser::Result` object with the following fields:
187
+
188
+ - **`waypoints`** — Array of `GpxDoctor::Waypoint` objects (top-level waypoints)
189
+ - **`routes`** — Array of `GpxDoctor::Route` objects
190
+ - **`tracks`** — Array of `GpxDoctor::Track` objects
191
+ - **`metadata`** — `GpxDoctor::Metadata` object (optional)
192
+
193
+ All model classes are simple Ruby objects with attributes matching the GPX 1.1 specification (see **Model field reference** below).
194
+
195
+ ### Example: Creating a GPX file from scratch
196
+
197
+ ```ruby
198
+ require 'gpx_doctor'
199
+
200
+ # Create waypoints
201
+ waypoint = GpxDoctor::Waypoint.new(
202
+ lat: 48.2093723,
203
+ lon: 16.356099,
204
+ ele: 160.0,
205
+ name: 'Vienna',
206
+ desc: 'Capital of Austria'
207
+ )
208
+
209
+ # Create a route with points
210
+ route_point_1 = GpxDoctor::Waypoint.new(lat: 48.21, lon: 16.36, ele: 155.0)
211
+ route_point_2 = GpxDoctor::Waypoint.new(lat: 48.22, lon: 16.37, ele: 162.0)
212
+
213
+ route = GpxDoctor::Route.new(
214
+ name: 'City Tour',
215
+ desc: 'A route through the city',
216
+ points: [route_point_1, route_point_2]
217
+ )
218
+
219
+ # Create a track with segments
220
+ track_point_1 = GpxDoctor::Waypoint.new(lat: 48.23, lon: 16.38, ele: 170.0)
221
+ track_point_2 = GpxDoctor::Waypoint.new(lat: 48.24, lon: 16.39, ele: 175.0)
222
+
223
+ segment = GpxDoctor::TrackSegment.new(points: [track_point_1, track_point_2])
224
+ track = GpxDoctor::Track.new(
225
+ name: 'Morning Run',
226
+ desc: 'My morning jog',
227
+ segments: [segment]
228
+ )
229
+
230
+ # Create metadata (optional)
231
+ metadata = GpxDoctor::Metadata.new(
232
+ name: 'My GPX File',
233
+ desc: 'A custom GPX file',
234
+ time: Time.now
235
+ )
236
+
237
+ # Build the result object
238
+ result = GpxDoctor::Parser::Result.new(
239
+ waypoints: [waypoint],
240
+ routes: [route],
241
+ tracks: [track],
242
+ metadata: metadata
243
+ )
244
+
245
+ # Generate GPX XML
246
+ xml_string = GpxDoctor::Builder.build(result, creator: 'My Application')
247
+
248
+ # Or write directly to a file
249
+ GpxDoctor::Builder.build_file(result, 'my_route.gpx', creator: 'My Application')
250
+ ```
251
+
252
+ ### Round-trip workflow
253
+
254
+ You can parse an existing GPX file, modify it, and build it back:
255
+
256
+ ```ruby
257
+ # Parse existing file
258
+ result = GpxDoctor::Parser.parse('input.gpx')
259
+
260
+ # Modify data
261
+ result.routes.first.name = 'Updated Route Name'
262
+ result.waypoints << GpxDoctor::Waypoint.new(lat: 48.5, lon: 16.5, name: 'New Point')
263
+
264
+ # Build back to GPX
265
+ GpxDoctor::Builder.build_file(result, 'output.gpx')
266
+ ```
267
+
91
268
  ## Model field reference
92
269
 
93
270
  ### `Waypoint`
@@ -114,6 +291,10 @@ result.metadata # => #<Metadata …> (or nil)
114
291
  | `pdop` | Float | |
115
292
  | `ageofdgpsdata` | Float | |
116
293
  | `dgpsid` | Integer | 0–1023 |
294
+ | `distance_to_next` | Float | Distance to next point (metres or feet based on `unit_system`) — set by `segment_statistics: true` |
295
+ | `elevation_change` | Float | Elevation change to next point (metres or feet based on `unit_system`) — set by `segment_statistics: true` |
296
+ | `direction` | Float | Bearing to next point (0–360°) — set by `segment_statistics: true` |
297
+ | `cumulative_distance` | Float | Cumulative distance from segment/route start (kilometres or miles based on `unit_system`) — set by `cumulative_distance: true` |
117
298
 
118
299
  `Waypoint#to_h` returns a hash of all non-nil fields.
119
300
 
@@ -5,13 +5,22 @@ module GpxDoctor
5
5
  attr_accessor :elevation_server,
6
6
  :elevation_server_url,
7
7
  :elevation_server_user,
8
- :elevation_server_password
8
+ :elevation_server_password,
9
+ :unit_system
9
10
 
10
11
  def initialize
11
12
  @elevation_server = false
12
13
  @elevation_server_url = nil
13
14
  @elevation_server_user = nil
14
15
  @elevation_server_password = nil
16
+ @unit_system = :metric
17
+ end
18
+
19
+ def unit_system=(value)
20
+ unless [:metric, :imperial].include?(value)
21
+ raise ArgumentError, "unit_system must be :metric or :imperial"
22
+ end
23
+ @unit_system = value
15
24
  end
16
25
  end
17
26
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GpxDoctor
4
+ class CumulativeDistanceEnhancer
5
+ # Enhances each waypoint with cumulative distance from the start of the segment/route.
6
+ # The first point receives cumulative_distance = 0.0
7
+ # Each subsequent point receives cumulative_distance = previous.cumulative_distance + distance from previous
8
+ # Distance is stored in kilometers for metric and miles for imperial.
9
+ #
10
+ # Mutates waypoints in place.
11
+ def enhance(waypoints)
12
+ return if waypoints.nil? || waypoints.empty?
13
+
14
+ unit_system = GpxDoctor.configuration.unit_system
15
+
16
+ cumulative = 0.0
17
+ waypoints.first.cumulative_distance = cumulative
18
+
19
+ waypoints.each_cons(2) do |current, nxt|
20
+ distance_meters = DistanceCalculator.distance(current, nxt)
21
+ distance_km = distance_meters / 1000.0
22
+ distance_converted = UnitConverter.convert_cumulative_distance(distance_km, unit_system)
23
+ cumulative += distance_converted
24
+ nxt.cumulative_distance = cumulative
25
+ end
26
+ end
27
+ end
28
+ end
@@ -8,7 +8,7 @@ module GpxDoctor
8
8
  sym type fix sat hdop vdop pdop ageofdgpsdata dgpsid
9
9
  ].freeze
10
10
 
11
- STATISTICS_FIELDS = %i[distance_to_next elevation_change direction].freeze
11
+ STATISTICS_FIELDS = %i[distance_to_next elevation_change direction cumulative_distance].freeze
12
12
 
13
13
  attr_accessor(*STATISTICS_FIELDS)
14
14
 
@@ -7,7 +7,7 @@ module GpxDoctor
7
7
  class Parser
8
8
  GPX_NS = 'http://www.topografix.com/GPX/1/1'
9
9
 
10
- Result = Struct.new(:waypoints, :routes, :tracks, :metadata, keyword_init: true) do
10
+ Result = Struct.new(:waypoints, :routes, :tracks, :metadata, :pois, keyword_init: true) do
11
11
  def points
12
12
  waypoints + routes.flat_map(&:points) + tracks.flat_map(&:points)
13
13
  end
@@ -53,7 +53,9 @@ module GpxDoctor
53
53
  split_segments(result, eff_max_dist) if eff_max_dist
54
54
  select_max_points(result) if @params[:max_points]
55
55
  enhance_statistics(result) if @params[:segment_statistics]
56
+ enhance_cumulative_distance(result) if @params[:cumulative_distance]
56
57
  enhance_elevations(result) if @params[:enhance_elevation]
58
+ result.pois = build_pois(result) if @params[:full_poi_data]
57
59
 
58
60
  result
59
61
  end
@@ -267,5 +269,59 @@ module GpxDoctor
267
269
  track.segments.each { |seg| enhancer.enhance(seg.points) }
268
270
  end
269
271
  end
272
+
273
+ def enhance_cumulative_distance(result)
274
+ enhancer = CumulativeDistanceEnhancer.new
275
+ result.routes.each { |route| enhancer.enhance(route.points) }
276
+ result.tracks.each do |track|
277
+ track.segments.each { |seg| enhancer.enhance(seg.points) }
278
+ end
279
+ end
280
+
281
+ def point_to_poi(point, distance)
282
+ poi = { lon: point.lon, lat: point.lat }
283
+ poi[:ele] = point.ele if point.ele
284
+ poi[:distance] = distance
285
+ poi
286
+ end
287
+
288
+ def build_pois(result)
289
+ collections = result.routes.map(&:points) +
290
+ result.tracks.flat_map { |t| t.segments.map(&:points) }
291
+ non_empty = collections.reject(&:empty?)
292
+
293
+ return {} if non_empty.empty?
294
+
295
+ total_distance = non_empty.sum { |pts| collection_distance(pts) }
296
+
297
+ pois = {
298
+ start: point_to_poi(non_empty.first.first, 0.0),
299
+ finish: point_to_poi(non_empty.last.last, total_distance)
300
+ }
301
+
302
+ segments_pois = result.tracks.flat_map do |track|
303
+ track.segments.filter_map do |seg|
304
+ next if seg.points.empty?
305
+
306
+ {
307
+ start: point_to_poi(seg.points.first, 0.0),
308
+ finish: point_to_poi(seg.points.last, collection_distance(seg.points))
309
+ }
310
+ end
311
+ end
312
+
313
+ pois[:segments] = segments_pois unless segments_pois.empty?
314
+ pois
315
+ end
316
+
317
+ def collection_distance(points)
318
+ return 0.0 if points.length < 2
319
+
320
+ unit_system = GpxDoctor.configuration.unit_system
321
+ points.each_cons(2).sum do |a, b|
322
+ dist_km = DistanceCalculator.distance(a, b) / 1000.0
323
+ UnitConverter.convert_cumulative_distance(dist_km, unit_system)
324
+ end
325
+ end
270
326
  end
271
327
  end
@@ -3,20 +3,24 @@
3
3
  module GpxDoctor
4
4
  class StatisticsEnhancer
5
5
  # Enhances each consecutive pair of waypoints with statistics:
6
- # - distance_to_next (meters, flat-earth Pythagorean approximation)
7
- # - elevation_change (meters, next.ele - current.ele; nil when elevation missing)
8
- # - direction (degrees 0-360, geographic bearing to next point)
6
+ # - distance_to_next (meters or feet depending on configuration)
7
+ # - elevation_change (meters or feet depending on configuration)
8
+ # - direction (degrees 0-360, geographic bearing to next point)
9
9
  #
10
10
  # The last point in the list receives nil for all three fields.
11
11
  # Mutates waypoints in place.
12
12
  def enhance(waypoints)
13
13
  return if waypoints.nil? || waypoints.size < 2
14
14
 
15
+ unit_system = GpxDoctor.configuration.unit_system
16
+
15
17
  waypoints.each_cons(2) do |current, nxt|
16
- current.distance_to_next = DistanceCalculator.distance(current, nxt)
18
+ distance_meters = DistanceCalculator.distance(current, nxt)
19
+ current.distance_to_next = UnitConverter.convert_distance(distance_meters, unit_system)
17
20
 
18
21
  current.elevation_change = if current.ele && nxt.ele
19
- nxt.ele - current.ele
22
+ elevation_change_meters = nxt.ele - current.ele
23
+ UnitConverter.convert_elevation(elevation_change_meters, unit_system)
20
24
  end
21
25
 
22
26
  current.direction = DistanceCalculator.bearing(current, nxt)
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GpxDoctor
4
+ module UnitConverter
5
+ METERS_TO_FEET = 3.28084
6
+ METERS_TO_MILES = 0.000621371
7
+ KILOMETERS_TO_MILES = 0.621371
8
+
9
+ module_function
10
+
11
+ # Convert meters to the configured unit system
12
+ # Returns feet for imperial, meters for metric
13
+ def convert_distance(meters, unit_system = :metric)
14
+ return meters if unit_system == :metric
15
+ meters * METERS_TO_FEET
16
+ end
17
+
18
+ # Convert meters to the configured unit system
19
+ # Returns feet for imperial, meters for metric
20
+ def convert_elevation(meters, unit_system = :metric)
21
+ return meters if unit_system == :metric
22
+ meters * METERS_TO_FEET
23
+ end
24
+
25
+ # Convert kilometers to the configured unit system
26
+ # Returns miles for imperial, kilometers for metric
27
+ def convert_cumulative_distance(kilometers, unit_system = :metric)
28
+ return kilometers if unit_system == :metric
29
+ kilometers * KILOMETERS_TO_MILES
30
+ end
31
+ end
32
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GpxDoctor
4
- VERSION = '0.3.0'
4
+ VERSION = '0.5.0'
5
5
  end
data/lib/gpx_doctor.rb CHANGED
@@ -15,8 +15,10 @@ require 'gpx_doctor/models/track_segment'
15
15
  require 'gpx_doctor/models/route'
16
16
  require 'gpx_doctor/models/track'
17
17
  require 'gpx_doctor/distance_calculator'
18
+ require 'gpx_doctor/unit_converter'
18
19
  require 'gpx_doctor/elevation_client'
19
20
  require 'gpx_doctor/statistics_enhancer'
21
+ require 'gpx_doctor/cumulative_distance_enhancer'
20
22
  require 'gpx_doctor/segment_splitter'
21
23
  require 'gpx_doctor/point_selector'
22
24
  require 'gpx_doctor/parser'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gpx_doctor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Poltrax
@@ -47,6 +47,7 @@ files:
47
47
  - lib/gpx_doctor.rb
48
48
  - lib/gpx_doctor/builder.rb
49
49
  - lib/gpx_doctor/configuration.rb
50
+ - lib/gpx_doctor/cumulative_distance_enhancer.rb
50
51
  - lib/gpx_doctor/distance_calculator.rb
51
52
  - lib/gpx_doctor/elevation_client.rb
52
53
  - lib/gpx_doctor/errors.rb
@@ -65,6 +66,7 @@ files:
65
66
  - lib/gpx_doctor/point_selector.rb
66
67
  - lib/gpx_doctor/segment_splitter.rb
67
68
  - lib/gpx_doctor/statistics_enhancer.rb
69
+ - lib/gpx_doctor/unit_converter.rb
68
70
  - lib/gpx_doctor/validator.rb
69
71
  - lib/gpx_doctor/version.rb
70
72
  homepage: https://github.com/Poltrax-live/gpx-doctor