gpx_doctor 0.2.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c1cba3a635e28b6b5731f9a178342323f6068e3954e03439c4c908045346d936
4
+ data.tar.gz: 0a9dd34154ee4c75abd3c9556bf69fb80e238ca0a4d940d9371607f299871b73
5
+ SHA512:
6
+ metadata.gz: e9981e204bdf04c176e500920be0ff75b14a011e50d3a149590545adf4351d6e5f00e37ab51bb998f43b97d2619afa0927accb606ab584eab84f0d4e29d2229e
7
+ data.tar.gz: 4b226863088aac9b8af344985426bc4beeee6693f1772b710537edb7e6c7059623f0824621f20f40cee55fe13114af7acdd306c524f51cd1fcdec38e50b44fb9
data/README.md ADDED
@@ -0,0 +1,255 @@
1
+ # GPX Doctor
2
+
3
+ A Ruby gem for parsing and manipulating GPX 1.1 routes.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem "gpx_doctor"
11
+ ```
12
+
13
+ Or install directly:
14
+
15
+ ```bash
16
+ gem install gpx_doctor
17
+ ```
18
+
19
+ ## Configuration
20
+
21
+ ```ruby
22
+ GpxDoctor.configure do |config|
23
+ config.elevation_server = true
24
+ config.elevation_server_url = "https://elevation.example.com"
25
+ config.elevation_server_user = "user"
26
+ config.elevation_server_password = "secret"
27
+ end
28
+
29
+ GpxDoctor.configuration.elevation_server_url # => "https://elevation.example.com"
30
+ GpxDoctor.reset_configuration! # resets to defaults
31
+ ```
32
+
33
+ | Option | Type | Default | Description |
34
+ |---|---|---|---|
35
+ | `elevation_server` | Boolean | `false` | Whether to use an elevation server |
36
+ | `elevation_server_url` | String | `nil` | URL of the elevation server |
37
+ | `elevation_server_user` | String | `nil` | Username for the elevation server |
38
+ | `elevation_server_password` | String | `nil` | Password for the elevation server |
39
+
40
+ ## Parsing
41
+
42
+ ### From a file
43
+
44
+ ```ruby
45
+ result = GpxDoctor::Parser.parse("path/to/file.gpx")
46
+ ```
47
+
48
+ ### From a string
49
+
50
+ ```ruby
51
+ result = GpxDoctor::Parser.parse_string(xml_string)
52
+ ```
53
+
54
+ ### Parsing with processing parameters
55
+
56
+ Both `parse` and `parse_string` accept an optional `params:` hash to enable post-processing:
57
+
58
+ ```ruby
59
+ result = GpxDoctor::Parser.parse("path/to/file.gpx", params: {
60
+ max_distance: 200, # insert interpolated points so no two consecutive points exceed this distance (metres)
61
+ max_points: 500, # reduce each segment to at most this many points
62
+ 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
64
+ })
65
+ ```
66
+
67
+ Processing is applied in the following order:
68
+
69
+ 1. `max_distance` — segment splitting (interpolates intermediate points)
70
+ 2. `max_points` — point reduction
71
+ 3. `segment_statistics` — per-point statistics (distance, bearing, elevation change)
72
+ 4. `enhance_elevation` — elevation lookup via the elevation server
73
+
74
+ `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
+
76
+ ## Accessing data
77
+
78
+ ```ruby
79
+ result.points # => [#<Waypoint lat=…, lon=…, ele=…>, …] (all geographic points)
80
+ result.waypoints # => [#<Waypoint …>] (top-level <wpt> elements only)
81
+ result.routes # => [#<Route …>]
82
+ result.tracks # => [#<Track …>]
83
+ result.metadata # => #<Metadata …> (or nil)
84
+ ```
85
+
86
+ `result.points` is a flat array containing **all** geographic points from:
87
+ - Top-level `<wpt>` elements
88
+ - `<rtept>` elements inside each `<rte>`
89
+ - `<trkpt>` elements inside each `<trkseg>` inside each `<trk>`
90
+
91
+ ## Model field reference
92
+
93
+ ### `Waypoint`
94
+
95
+ | Field | Type | Notes |
96
+ |---|---|---|
97
+ | `lat` | Float | Required |
98
+ | `lon` | Float | Required |
99
+ | `ele` | Float | Elevation in metres |
100
+ | `time` | Time | |
101
+ | `magvar` | Float | Magnetic variation |
102
+ | `geoidheight` | Float | |
103
+ | `name` | String | |
104
+ | `cmt` | String | Comment |
105
+ | `desc` | String | Description |
106
+ | `src` | String | Source |
107
+ | `links` | Array<Link> | |
108
+ | `sym` | String | Symbol |
109
+ | `type` | String | |
110
+ | `fix` | String | `none`, `2d`, `3d`, `dgps`, `pps` |
111
+ | `sat` | Integer | Number of satellites |
112
+ | `hdop` | Float | |
113
+ | `vdop` | Float | |
114
+ | `pdop` | Float | |
115
+ | `ageofdgpsdata` | Float | |
116
+ | `dgpsid` | Integer | 0–1023 |
117
+
118
+ `Waypoint#to_h` returns a hash of all non-nil fields.
119
+
120
+ ### `Metadata`
121
+
122
+ | Field | Type |
123
+ |---|---|
124
+ | `name` | String |
125
+ | `desc` | String |
126
+ | `author` | Person |
127
+ | `copyright` | Copyright |
128
+ | `links` | Array<Link> |
129
+ | `time` | Time |
130
+ | `keywords` | String |
131
+ | `bounds` | Bounds |
132
+
133
+ ### `Route`
134
+
135
+ | Field | Type |
136
+ |---|---|
137
+ | `name` | String |
138
+ | `cmt` | String |
139
+ | `desc` | String |
140
+ | `src` | String |
141
+ | `links` | Array<Link> |
142
+ | `number` | Integer |
143
+ | `type` | String |
144
+ | `points` | Array<Waypoint> |
145
+
146
+ ### `Track`
147
+
148
+ | Field | Type |
149
+ |---|---|
150
+ | `name` | String |
151
+ | `cmt` | String |
152
+ | `desc` | String |
153
+ | `src` | String |
154
+ | `links` | Array<Link> |
155
+ | `number` | Integer |
156
+ | `type` | String |
157
+ | `segments` | Array<TrackSegment> |
158
+ | `points` | Array<Waypoint> (all points across all segments) |
159
+
160
+ ### `TrackSegment`
161
+
162
+ | Field | Type |
163
+ |---|---|
164
+ | `points` | Array<Waypoint> |
165
+
166
+ ### `Person`
167
+
168
+ | Field | Type |
169
+ |---|---|
170
+ | `name` | String |
171
+ | `email` | Email |
172
+ | `link` | Link |
173
+
174
+ ### `Copyright`
175
+
176
+ | Field | Type |
177
+ |---|---|
178
+ | `author` | String |
179
+ | `year` | String |
180
+ | `license` | String |
181
+
182
+ ### `Link`
183
+
184
+ | Field | Type |
185
+ |---|---|
186
+ | `href` | String |
187
+ | `text` | String |
188
+ | `type` | String |
189
+
190
+ ### `Email`
191
+
192
+ | Field | Type |
193
+ |---|---|
194
+ | `id` | String |
195
+ | `domain` | String |
196
+
197
+ `Email#to_s` returns `"id@domain"`.
198
+
199
+ ### `Bounds`
200
+
201
+ | Field | Type |
202
+ |---|---|
203
+ | `minlat` | Float |
204
+ | `minlon` | Float |
205
+ | `maxlat` | Float |
206
+ | `maxlon` | Float |
207
+
208
+ ## Development
209
+
210
+ ### Building the gem
211
+
212
+ ```bash
213
+ gem build gpx_doctor.gemspec
214
+ ```
215
+
216
+ This produces a file like `gpx_doctor-0.1.0.gem` in the current directory.
217
+
218
+ ### Running tests
219
+
220
+ ```bash
221
+ bundle install
222
+ bundle exec rspec
223
+ ```
224
+
225
+ ### Publishing to RubyGems
226
+
227
+ 1. **Create an account** at <https://rubygems.org> if you don't have one.
228
+
229
+ 2. **Set up credentials** (one-time):
230
+
231
+ ```bash
232
+ gem signin
233
+ ```
234
+
235
+ This stores your API key in `~/.gem/credentials`.
236
+
237
+ 3. **Build and push**:
238
+
239
+ ```bash
240
+ gem build gpx_doctor.gemspec
241
+ gem push gpx_doctor-0.1.0.gem
242
+ ```
243
+
244
+ 4. **Verify** the release at `https://rubygems.org/gems/gpx_doctor`.
245
+
246
+ > **Tip:** Bump `GpxDoctor::VERSION` in `lib/gpx_doctor/version.rb` before each release and tag the commit:
247
+ >
248
+ > ```bash
249
+ > git tag -a v0.1.0 -m "Release 0.1.0"
250
+ > git push origin v0.1.0
251
+ > ```
252
+
253
+ ## License
254
+
255
+ MIT
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+
5
+ module GpxDoctor
6
+ class Builder
7
+ GPX_NS = 'http://www.topografix.com/GPX/1/1'
8
+ XSI_NS = 'http://www.w3.org/2001/XMLSchema-instance'
9
+ SCHEMA_LOCATION = "#{GPX_NS} http://www.topografix.com/GPX/1/1/gpx.xsd"
10
+
11
+ class << self
12
+ def build(result, creator: 'GPX Doctor')
13
+ new(result, creator: creator).build
14
+ end
15
+
16
+ def build_file(result, file_path, creator: 'GPX Doctor')
17
+ xml = build(result, creator: creator)
18
+ File.write(file_path, xml)
19
+ xml
20
+ end
21
+ end
22
+
23
+ def initialize(result, creator: 'GPX Doctor')
24
+ @result = result
25
+ @creator = creator
26
+ end
27
+
28
+ def build
29
+ builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
30
+ xml.gpx(
31
+ 'version' => '1.1',
32
+ 'creator' => @creator,
33
+ 'xmlns' => GPX_NS,
34
+ 'xmlns:xsi' => XSI_NS,
35
+ 'xsi:schemaLocation' => SCHEMA_LOCATION
36
+ ) do
37
+ build_metadata(xml, @result.metadata) if @result.metadata
38
+ @result.waypoints.each { |wpt| build_waypoint(xml, wpt, tag: 'wpt') }
39
+ @result.routes.each { |rte| build_route(xml, rte) }
40
+ @result.tracks.each { |trk| build_track(xml, trk) }
41
+ end
42
+ end
43
+ builder.to_xml
44
+ end
45
+
46
+ private
47
+
48
+ def build_metadata(xml, metadata)
49
+ xml.metadata do
50
+ xml.name metadata.name if metadata.name
51
+ xml.desc metadata.desc if metadata.desc
52
+ build_person(xml, metadata.author) if metadata.author
53
+ build_copyright(xml, metadata.copyright) if metadata.copyright
54
+ metadata.links.each { |link| build_link(xml, link) }
55
+ xml.time metadata.time.iso8601 if metadata.time
56
+ xml.keywords metadata.keywords if metadata.keywords
57
+ build_bounds(xml, metadata.bounds) if metadata.bounds
58
+ end
59
+ end
60
+
61
+ def build_person(xml, person)
62
+ xml.author do
63
+ xml.name person.name if person.name
64
+ build_email(xml, person.email) if person.email
65
+ build_link(xml, person.link) if person.link
66
+ end
67
+ end
68
+
69
+ def build_email(xml, email)
70
+ xml.email('id' => email.id, 'domain' => email.domain)
71
+ end
72
+
73
+ def build_copyright(xml, copyright)
74
+ xml.copyright('author' => copyright.author) do
75
+ xml.year copyright.year if copyright.year
76
+ xml.license copyright.license if copyright.license
77
+ end
78
+ end
79
+
80
+ def build_link(xml, link)
81
+ xml.link('href' => link.href) do
82
+ # 'text' is a reserved method in Nokogiri::XML::Builder, so we must
83
+ # route through method_missing to produce a <text> element.
84
+ xml.send(:method_missing, :text, link.text) if link.text
85
+ xml.type link.type if link.type
86
+ end
87
+ end
88
+
89
+ def build_bounds(xml, bounds)
90
+ xml.bounds(
91
+ 'minlat' => bounds.minlat,
92
+ 'minlon' => bounds.minlon,
93
+ 'maxlat' => bounds.maxlat,
94
+ 'maxlon' => bounds.maxlon
95
+ )
96
+ end
97
+
98
+ # Builds wpt / rtept / trkpt elements (all share the same field set).
99
+ def build_waypoint(xml, wpt, tag: 'wpt')
100
+ xml.send(tag, 'lat' => wpt.lat, 'lon' => wpt.lon) do
101
+ xml.ele wpt.ele if wpt.ele
102
+ xml.time wpt.time.iso8601 if wpt.time
103
+ xml.magvar wpt.magvar if wpt.magvar
104
+ xml.geoidheight wpt.geoidheight if wpt.geoidheight
105
+ xml.name wpt.name if wpt.name
106
+ xml.cmt wpt.cmt if wpt.cmt
107
+ xml.desc wpt.desc if wpt.desc
108
+ xml.src wpt.src if wpt.src
109
+ Array(wpt.links).each { |link| build_link(xml, link) }
110
+ xml.sym wpt.sym if wpt.sym
111
+ xml.type wpt.type if wpt.type
112
+ xml.fix wpt.fix if wpt.fix
113
+ xml.sat wpt.sat if wpt.sat
114
+ xml.hdop wpt.hdop if wpt.hdop
115
+ xml.vdop wpt.vdop if wpt.vdop
116
+ xml.pdop wpt.pdop if wpt.pdop
117
+ xml.ageofdgpsdata wpt.ageofdgpsdata if wpt.ageofdgpsdata
118
+ xml.dgpsid wpt.dgpsid if wpt.dgpsid
119
+ end
120
+ end
121
+
122
+ def build_route(xml, route)
123
+ xml.rte do
124
+ xml.name route.name if route.name
125
+ xml.cmt route.cmt if route.cmt
126
+ xml.desc route.desc if route.desc
127
+ xml.src route.src if route.src
128
+ Array(route.links).each { |link| build_link(xml, link) }
129
+ xml.number route.number if route.number
130
+ xml.type route.type if route.type
131
+ route.points.each { |pt| build_waypoint(xml, pt, tag: 'rtept') }
132
+ end
133
+ end
134
+
135
+ def build_track(xml, track)
136
+ xml.trk do
137
+ xml.name track.name if track.name
138
+ xml.cmt track.cmt if track.cmt
139
+ xml.desc track.desc if track.desc
140
+ xml.src track.src if track.src
141
+ Array(track.links).each { |link| build_link(xml, link) }
142
+ xml.number track.number if track.number
143
+ xml.type track.type if track.type
144
+ track.segments.each do |seg|
145
+ xml.trkseg do
146
+ seg.points.each { |pt| build_waypoint(xml, pt, tag: 'trkpt') }
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GpxDoctor
4
+ class Configuration
5
+ attr_accessor :elevation_server,
6
+ :elevation_server_url,
7
+ :elevation_server_user,
8
+ :elevation_server_password
9
+
10
+ def initialize
11
+ @elevation_server = false
12
+ @elevation_server_url = nil
13
+ @elevation_server_user = nil
14
+ @elevation_server_password = nil
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GpxDoctor
4
+ module DistanceCalculator
5
+ # Approximate degrees-to-meters conversion factors.
6
+ # 1 degree latitude ≈ 111_320 m
7
+ # 1 degree longitude ≈ 111_320 m * cos(latitude)
8
+ METERS_PER_DEGREE_LAT = 111_320.0
9
+
10
+ module_function
11
+
12
+ # Returns the flat-earth Pythagorean distance in meters between two waypoints.
13
+ def distance(a, b)
14
+ dlat_m, dlon_m = components(a, b)
15
+ Math.sqrt(dlat_m**2 + dlon_m**2)
16
+ end
17
+
18
+ # Returns geographic bearing in degrees (0 = North, 90 = East, 180 = South, 270 = West)
19
+ # between two waypoints.
20
+ def bearing(a, b)
21
+ dlat_m, dlon_m = components(a, b)
22
+ angle_rad = Math.atan2(dlon_m, dlat_m)
23
+ degrees = angle_rad * 180.0 / Math::PI
24
+ degrees % 360
25
+ end
26
+
27
+ # Returns [dlat_m, dlon_m] — the north and east displacement in meters between a and b.
28
+ def components(a, b)
29
+ dlat_m = (b.lat - a.lat) * METERS_PER_DEGREE_LAT
30
+ avg_lat_rad = (a.lat + b.lat) / 2.0 * Math::PI / 180.0
31
+ dlon_m = (b.lon - a.lon) * METERS_PER_DEGREE_LAT * Math.cos(avg_lat_rad)
32
+ [dlat_m, dlon_m]
33
+ end
34
+ module_function :components
35
+ private_class_method :components
36
+ end
37
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'json'
6
+
7
+ module GpxDoctor
8
+ class ElevationClient
9
+ MAX_REQUEST_LINE_BYTES = 1024
10
+ LOOKUP_PATH = '/api/v1/lookup'
11
+
12
+ def initialize(config = nil)
13
+ config = config || GpxDoctor.configuration
14
+ @base_url = config.elevation_server_url
15
+ @user = config.elevation_server_user
16
+ @password = config.elevation_server_password
17
+ end
18
+
19
+ # Enhances waypoints that have nil ele with elevation from the server.
20
+ # Mutates the waypoints in place.
21
+ def enhance(waypoints)
22
+ missing = waypoints.select { |wp| wp.ele.nil? }
23
+ return if missing.empty?
24
+
25
+ batches(missing).each do |batch|
26
+ elevations = fetch_elevations(batch)
27
+ batch.zip(elevations).each do |wp, elev|
28
+ wp.ele = elev if elev
29
+ end
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ # Splits points into batches that fit within the GET URL byte limit.
36
+ def batches(points)
37
+ base_uri_length = "#{@base_url}#{LOOKUP_PATH}?locations=".bytesize
38
+ available = MAX_REQUEST_LINE_BYTES - base_uri_length
39
+
40
+ result = []
41
+ current_batch = []
42
+ current_length = 0
43
+
44
+ points.each do |wp|
45
+ location = "#{wp.lat},#{wp.lon}"
46
+ # Account for the '|' separator between locations
47
+ entry_length = current_batch.empty? ? location.bytesize : (location.bytesize + 1)
48
+
49
+ if current_length + entry_length > available && !current_batch.empty?
50
+ result << current_batch
51
+ current_batch = [wp]
52
+ current_length = location.bytesize
53
+ else
54
+ current_batch << wp
55
+ current_length += entry_length
56
+ end
57
+ end
58
+
59
+ result << current_batch unless current_batch.empty?
60
+ result
61
+ end
62
+
63
+ def fetch_elevations(batch)
64
+ locations = batch.map { |wp| "#{wp.lat},#{wp.lon}" }.join('|')
65
+ uri = URI("#{@base_url}#{LOOKUP_PATH}?locations=#{locations}")
66
+
67
+ http = Net::HTTP.new(uri.host, uri.port)
68
+ http.use_ssl = (uri.scheme == 'https')
69
+ http.open_timeout = 10
70
+ http.read_timeout = 30
71
+
72
+ request = Net::HTTP::Get.new(uri)
73
+ request.basic_auth(@user, @password) if @user && @password
74
+
75
+ response = http.request(request)
76
+
77
+ unless response.is_a?(Net::HTTPSuccess)
78
+ return Array.new(batch.size)
79
+ end
80
+
81
+ parse_response(response.body, batch.size)
82
+ rescue StandardError => e
83
+ Array.new(batch.size)
84
+ end
85
+
86
+ def parse_response(body, expected_count)
87
+ data = JSON.parse(body)
88
+ results = data['results'] || []
89
+
90
+ elevations = results.map { |r| r['elevation']&.to_f }
91
+
92
+ # Pad with nils if the response has fewer results than expected
93
+ elevations.fill(nil, elevations.size...expected_count) if elevations.size < expected_count
94
+ elevations
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GpxDoctor
4
+ module Models
5
+ Bounds = Struct.new(:minlat, :minlon, :maxlat, :maxlon, keyword_init: true)
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GpxDoctor
4
+ module Models
5
+ Copyright = Struct.new(:author, :year, :license, keyword_init: true)
6
+ end
7
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GpxDoctor
4
+ module Models
5
+ Email = Struct.new(:id, :domain, keyword_init: true) do
6
+ def to_s
7
+ "#{id}@#{domain}"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GpxDoctor
4
+ module Models
5
+ Link = Struct.new(:href, :text, :type, keyword_init: true)
6
+ end
7
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GpxDoctor
4
+ module Models
5
+ Metadata = Struct.new(
6
+ :name, :desc, :author, :copyright, :links, :time, :keywords, :bounds,
7
+ keyword_init: true
8
+ ) do
9
+ def initialize(**)
10
+ super
11
+ self.links ||= []
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GpxDoctor
4
+ module Models
5
+ Person = Struct.new(:name, :email, :link, keyword_init: true)
6
+ end
7
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GpxDoctor
4
+ module Models
5
+ Route = Struct.new(
6
+ :name, :cmt, :desc, :src, :links, :number, :type, :points,
7
+ keyword_init: true
8
+ ) do
9
+ def initialize(**)
10
+ super
11
+ self.links ||= []
12
+ self.points ||= []
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GpxDoctor
4
+ module Models
5
+ Track = Struct.new(
6
+ :name, :cmt, :desc, :src, :links, :number, :type, :segments,
7
+ keyword_init: true
8
+ ) do
9
+ def initialize(**)
10
+ super
11
+ self.links ||= []
12
+ self.segments ||= []
13
+ end
14
+
15
+ def points
16
+ segments.flat_map(&:points)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GpxDoctor
4
+ module Models
5
+ TrackSegment = Struct.new(:points, keyword_init: true) do
6
+ def initialize(**)
7
+ super
8
+ self.points ||= []
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GpxDoctor
4
+ module Models
5
+ class Waypoint
6
+ FIELDS = %i[
7
+ lat lon ele time magvar geoidheight name cmt desc src links
8
+ sym type fix sat hdop vdop pdop ageofdgpsdata dgpsid
9
+ ].freeze
10
+
11
+ STATISTICS_FIELDS = %i[distance_to_next elevation_change direction].freeze
12
+
13
+ attr_accessor(*STATISTICS_FIELDS)
14
+
15
+ attr_accessor(*FIELDS)
16
+
17
+ def initialize(**attrs)
18
+ attrs.each { |k, v| public_send(:"#{k}=", v) }
19
+ @links ||= []
20
+ end
21
+
22
+ def to_h
23
+ (FIELDS + STATISTICS_FIELDS).each_with_object({}) do |field, hash|
24
+ value = public_send(field)
25
+ hash[field] = value unless value.nil?
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+ require 'time'
5
+
6
+ module GpxDoctor
7
+ class Parser
8
+ GPX_NS = 'http://www.topografix.com/GPX/1/1'
9
+
10
+ Result = Struct.new(:waypoints, :routes, :tracks, :metadata, keyword_init: true) do
11
+ def points
12
+ waypoints + routes.flat_map(&:points) + tracks.flat_map(&:points)
13
+ end
14
+ end
15
+
16
+ class << self
17
+ def parse(file_path, params: {})
18
+ xml = File.read(file_path)
19
+ parse_string(xml, params: params)
20
+ end
21
+
22
+ def parse_string(xml_string, params: {})
23
+ doc = Nokogiri::XML(xml_string)
24
+ ns = detect_namespace(doc)
25
+
26
+ new(doc, ns, params: params).parse
27
+ end
28
+
29
+ private
30
+
31
+ def detect_namespace(doc)
32
+ root_ns = doc.root&.namespace&.href
33
+ root_ns == GPX_NS ? GPX_NS : nil
34
+ end
35
+ end
36
+
37
+ def initialize(doc, ns, params: {})
38
+ @doc = doc
39
+ @ns = ns
40
+ @params = params
41
+ end
42
+
43
+ def parse
44
+ result = Result.new(
45
+ waypoints: parse_waypoints,
46
+ routes: parse_routes,
47
+ tracks: parse_tracks,
48
+ metadata: parse_metadata
49
+ )
50
+
51
+ eff_max_dist = @params[:max_distance] || effective_max_distance(result)
52
+ split_segments(result, eff_max_dist) if eff_max_dist
53
+ select_max_points(result) if @params[:max_points]
54
+ enhance_statistics(result) if @params[:segment_statistics]
55
+ enhance_elevations(result) if @params[:enhance_elevation]
56
+
57
+ result
58
+ end
59
+
60
+ private
61
+
62
+ def xpath(node, path)
63
+ if @ns
64
+ node.xpath(path, 'g' => @ns)
65
+ else
66
+ node.xpath(path.gsub('g:', ''))
67
+ end
68
+ end
69
+
70
+ def text_at(node, tag)
71
+ el = xpath(node, "g:#{tag}").first
72
+ el&.text&.strip
73
+ end
74
+
75
+ def float_at(node, tag)
76
+ v = text_at(node, tag)
77
+ v&.to_f
78
+ end
79
+
80
+ def int_at(node, tag)
81
+ v = text_at(node, tag)
82
+ v&.to_i
83
+ end
84
+
85
+ def time_at(node, tag)
86
+ v = text_at(node, tag)
87
+ v ? Time.parse(v) : nil
88
+ end
89
+
90
+ def parse_links(node)
91
+ xpath(node, 'g:link').map do |link_el|
92
+ Models::Link.new(
93
+ href: link_el['href'],
94
+ text: link_el.xpath('g:text', 'g' => @ns).first&.text&.strip,
95
+ type: link_el.xpath('g:type', 'g' => @ns).first&.text&.strip
96
+ )
97
+ end
98
+ end
99
+
100
+ def parse_waypoint(wpt_el)
101
+ Models::Waypoint.new(
102
+ lat: wpt_el['lat'].to_f,
103
+ lon: wpt_el['lon'].to_f,
104
+ ele: float_at(wpt_el, 'ele'),
105
+ time: time_at(wpt_el, 'time'),
106
+ magvar: float_at(wpt_el, 'magvar'),
107
+ geoidheight: float_at(wpt_el, 'geoidheight'),
108
+ name: text_at(wpt_el, 'name'),
109
+ cmt: text_at(wpt_el, 'cmt'),
110
+ desc: text_at(wpt_el, 'desc'),
111
+ src: text_at(wpt_el, 'src'),
112
+ links: parse_links(wpt_el),
113
+ sym: text_at(wpt_el, 'sym'),
114
+ type: text_at(wpt_el, 'type'),
115
+ fix: text_at(wpt_el, 'fix'),
116
+ sat: int_at(wpt_el, 'sat'),
117
+ hdop: float_at(wpt_el, 'hdop'),
118
+ vdop: float_at(wpt_el, 'vdop'),
119
+ pdop: float_at(wpt_el, 'pdop'),
120
+ ageofdgpsdata: float_at(wpt_el, 'ageofdgpsdata'),
121
+ dgpsid: int_at(wpt_el, 'dgpsid')
122
+ )
123
+ end
124
+
125
+ def parse_waypoints
126
+ xpath(@doc, '//g:gpx/g:wpt').map { |el| parse_waypoint(el) }
127
+ end
128
+
129
+ def parse_routes
130
+ xpath(@doc, '//g:gpx/g:rte').map do |rte_el|
131
+ Models::Route.new(
132
+ name: text_at(rte_el, 'name'),
133
+ cmt: text_at(rte_el, 'cmt'),
134
+ desc: text_at(rte_el, 'desc'),
135
+ src: text_at(rte_el, 'src'),
136
+ links: parse_links(rte_el),
137
+ number: int_at(rte_el, 'number'),
138
+ type: text_at(rte_el, 'type'),
139
+ points: xpath(rte_el, 'g:rtept').map { |el| parse_waypoint(el) }
140
+ )
141
+ end
142
+ end
143
+
144
+ def parse_tracks
145
+ xpath(@doc, '//g:gpx/g:trk').map do |trk_el|
146
+ Models::Track.new(
147
+ name: text_at(trk_el, 'name'),
148
+ cmt: text_at(trk_el, 'cmt'),
149
+ desc: text_at(trk_el, 'desc'),
150
+ src: text_at(trk_el, 'src'),
151
+ links: parse_links(trk_el),
152
+ number: int_at(trk_el, 'number'),
153
+ type: text_at(trk_el, 'type'),
154
+ segments: xpath(trk_el, 'g:trkseg').map do |seg_el|
155
+ Models::TrackSegment.new(
156
+ points: xpath(seg_el, 'g:trkpt').map { |el| parse_waypoint(el) }
157
+ )
158
+ end
159
+ )
160
+ end
161
+ end
162
+
163
+ def parse_metadata
164
+ meta_el = xpath(@doc, '//g:gpx/g:metadata').first
165
+ return nil unless meta_el
166
+
167
+ Models::Metadata.new(
168
+ name: text_at(meta_el, 'name'),
169
+ desc: text_at(meta_el, 'desc'),
170
+ author: parse_person(xpath(meta_el, 'g:author').first),
171
+ copyright: parse_copyright(xpath(meta_el, 'g:copyright').first),
172
+ links: parse_links(meta_el),
173
+ time: time_at(meta_el, 'time'),
174
+ keywords: text_at(meta_el, 'keywords'),
175
+ bounds: parse_bounds(xpath(meta_el, 'g:bounds').first)
176
+ )
177
+ end
178
+
179
+ def parse_person(person_el)
180
+ return nil unless person_el
181
+
182
+ Models::Person.new(
183
+ name: text_at(person_el, 'name'),
184
+ email: parse_email(xpath(person_el, 'g:email').first),
185
+ link: parse_links(person_el).first
186
+ )
187
+ end
188
+
189
+ def parse_email(email_el)
190
+ return nil unless email_el
191
+
192
+ Models::Email.new(
193
+ id: email_el['id'],
194
+ domain: email_el['domain']
195
+ )
196
+ end
197
+
198
+ def parse_copyright(copyright_el)
199
+ return nil unless copyright_el
200
+
201
+ Models::Copyright.new(
202
+ author: copyright_el['author'],
203
+ year: text_at(copyright_el, 'year'),
204
+ license: text_at(copyright_el, 'license')
205
+ )
206
+ end
207
+
208
+ def parse_bounds(bounds_el)
209
+ return nil unless bounds_el
210
+
211
+ Models::Bounds.new(
212
+ minlat: bounds_el['minlat'].to_f,
213
+ minlon: bounds_el['minlon'].to_f,
214
+ maxlat: bounds_el['maxlat'].to_f,
215
+ maxlon: bounds_el['maxlon'].to_f
216
+ )
217
+ end
218
+
219
+ def enhance_elevations(result)
220
+ config = GpxDoctor.configuration
221
+ return unless config.elevation_server && config.elevation_server_url
222
+
223
+ ElevationClient.new.enhance(result.points)
224
+ end
225
+
226
+ def effective_max_distance(result)
227
+ return nil unless @params[:max_points]
228
+
229
+ total = total_distance(result)
230
+ ratio = total / @params[:max_points].to_f
231
+ ratio > 1000 ? 500 : nil
232
+ end
233
+
234
+ def total_distance(result)
235
+ selector = PointSelector.new
236
+ all_point_collections(result).sum { |pts| selector.total_distance(pts) }
237
+ end
238
+
239
+ def all_point_collections(result)
240
+ collections = result.routes.map(&:points)
241
+ result.tracks.each { |t| t.segments.each { |s| collections << s.points } }
242
+ collections
243
+ end
244
+
245
+ def split_segments(result, max_dist)
246
+ splitter = SegmentSplitter.new
247
+ result.routes.each { |route| route.points = splitter.split(route.points, max_dist) }
248
+ result.tracks.each do |track|
249
+ track.segments.each { |seg| seg.points = splitter.split(seg.points, max_dist) }
250
+ end
251
+ end
252
+
253
+ def select_max_points(result)
254
+ selector = PointSelector.new
255
+ n = @params[:max_points]
256
+ result.routes.each { |route| route.points = selector.select(route.points, n) }
257
+ result.tracks.each do |track|
258
+ track.segments.each { |seg| seg.points = selector.select(seg.points, n) }
259
+ end
260
+ end
261
+
262
+ def enhance_statistics(result)
263
+ enhancer = StatisticsEnhancer.new
264
+ result.routes.each { |route| enhancer.enhance(route.points) }
265
+ result.tracks.each do |track|
266
+ track.segments.each { |seg| enhancer.enhance(seg.points) }
267
+ end
268
+ end
269
+ end
270
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GpxDoctor
4
+ class PointSelector
5
+ # Returns the total distance in meters for a sequence of +points+.
6
+ def total_distance(points)
7
+ return 0.0 if points.nil? || points.size < 2
8
+
9
+ total = 0.0
10
+ points.each_cons(2) { |a, b| total += DistanceCalculator.distance(a, b) }
11
+ total
12
+ end
13
+
14
+ # Selects up to +max_points+ points from +points+ with equal distance spread.
15
+ # Always includes the first and last points. Returns a new array; the original
16
+ # is not mutated. When +points+ already has fewer or equal elements than
17
+ # +max_points+, it is returned unchanged.
18
+ def select(points, max_points)
19
+ return points if points.nil? || max_points <= 0 || points.size <= max_points
20
+
21
+ m = points.size
22
+ n = max_points
23
+
24
+ # For n == 1 there is no denominator (n - 1 = 0), so just keep the first point.
25
+ return [points[0]] if n == 1
26
+
27
+ cumulative = [0.0]
28
+ points.each_cons(2) do |a, b|
29
+ cumulative << cumulative.last + DistanceCalculator.distance(a, b)
30
+ end
31
+ total = cumulative.last
32
+
33
+ # Degenerate case: all points are at the same location — fall back to index spread.
34
+ if total.zero?
35
+ indices = (0...n).map { |i| (i * (m - 1).to_f / (n - 1)).round }
36
+ return indices.uniq.map { |idx| points[idx] }
37
+ end
38
+
39
+ used = {}
40
+ selected_indices = (0...n).map do |i|
41
+ target = i * total / (n - 1).to_f
42
+ # Find the closest unused point to the target cumulative distance.
43
+ best_idx = cumulative
44
+ .each_with_index
45
+ .reject { |_, idx| used[idx] }
46
+ .min_by { |d, _| (d - target).abs }
47
+ .last
48
+ used[best_idx] = true
49
+ best_idx
50
+ end
51
+
52
+ selected_indices.sort.map { |idx| points[idx] }
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GpxDoctor
4
+ class SegmentSplitter
5
+ # Splits a sequence of waypoints so that no two consecutive points are
6
+ # farther apart than +max_distance+ meters. Pairs that are already within
7
+ # the limit are left untouched. When a pair exceeds the limit, evenly
8
+ # spaced intermediate points are interpolated between them (lat/lon always;
9
+ # ele and time only when both endpoints have values).
10
+ #
11
+ # Returns a new array; the original is not mutated.
12
+ def split(points, max_distance)
13
+ return points if points.nil? || points.size < 2
14
+
15
+ result = [points.first]
16
+
17
+ points.each_cons(2) do |current, nxt|
18
+ dist = DistanceCalculator.distance(current, nxt)
19
+
20
+ if dist > max_distance
21
+ n_segments = (dist / max_distance).ceil
22
+ (1...n_segments).each do |i|
23
+ fraction = i.to_f / n_segments
24
+ result << interpolate(current, nxt, fraction)
25
+ end
26
+ end
27
+
28
+ result << nxt
29
+ end
30
+
31
+ result
32
+ end
33
+
34
+ private
35
+
36
+ def interpolate(a, b, fraction)
37
+ ele = a.ele && b.ele ? a.ele + fraction * (b.ele - a.ele) : nil
38
+ time = a.time && b.time ? a.time + fraction * (b.time - a.time) : nil
39
+
40
+ Models::Waypoint.new(
41
+ lat: a.lat + fraction * (b.lat - a.lat),
42
+ lon: a.lon + fraction * (b.lon - a.lon),
43
+ ele: ele,
44
+ time: time
45
+ )
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GpxDoctor
4
+ class StatisticsEnhancer
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)
9
+ #
10
+ # The last point in the list receives nil for all three fields.
11
+ # Mutates waypoints in place.
12
+ def enhance(waypoints)
13
+ return if waypoints.nil? || waypoints.size < 2
14
+
15
+ waypoints.each_cons(2) do |current, nxt|
16
+ current.distance_to_next = DistanceCalculator.distance(current, nxt)
17
+
18
+ current.elevation_change = if current.ele && nxt.ele
19
+ nxt.ele - current.ele
20
+ end
21
+
22
+ current.direction = DistanceCalculator.bearing(current, nxt)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GpxDoctor
4
+ VERSION = '0.2.0'
5
+ end
data/lib/gpx_doctor.rb ADDED
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'gpx_doctor/version'
4
+ require 'gpx_doctor/configuration'
5
+ require 'gpx_doctor/models/email'
6
+ require 'gpx_doctor/models/link'
7
+ require 'gpx_doctor/models/copyright'
8
+ require 'gpx_doctor/models/person'
9
+ require 'gpx_doctor/models/bounds'
10
+ require 'gpx_doctor/models/waypoint'
11
+ require 'gpx_doctor/models/metadata'
12
+ require 'gpx_doctor/models/track_segment'
13
+ require 'gpx_doctor/models/route'
14
+ require 'gpx_doctor/models/track'
15
+ require 'gpx_doctor/distance_calculator'
16
+ require 'gpx_doctor/elevation_client'
17
+ require 'gpx_doctor/statistics_enhancer'
18
+ require 'gpx_doctor/segment_splitter'
19
+ require 'gpx_doctor/point_selector'
20
+ require 'gpx_doctor/parser'
21
+ require 'gpx_doctor/builder'
22
+
23
+ module GpxDoctor
24
+ class << self
25
+ def configure
26
+ yield(configuration)
27
+ end
28
+
29
+ def configuration
30
+ @configuration ||= Configuration.new
31
+ end
32
+
33
+ def reset_configuration!
34
+ @configuration = Configuration.new
35
+ end
36
+ end
37
+ end
metadata ADDED
@@ -0,0 +1,88 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gpx_doctor
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Poltrax
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: nokogiri
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.15'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.15'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rspec
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.12'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.12'
40
+ description: GPX Doctor helps with manipulation of GPX routes. It parses GPX 1.1 files
41
+ into Ruby objects.
42
+ executables: []
43
+ extensions: []
44
+ extra_rdoc_files: []
45
+ files:
46
+ - README.md
47
+ - lib/gpx_doctor.rb
48
+ - lib/gpx_doctor/builder.rb
49
+ - lib/gpx_doctor/configuration.rb
50
+ - lib/gpx_doctor/distance_calculator.rb
51
+ - lib/gpx_doctor/elevation_client.rb
52
+ - lib/gpx_doctor/models/bounds.rb
53
+ - lib/gpx_doctor/models/copyright.rb
54
+ - lib/gpx_doctor/models/email.rb
55
+ - lib/gpx_doctor/models/link.rb
56
+ - lib/gpx_doctor/models/metadata.rb
57
+ - lib/gpx_doctor/models/person.rb
58
+ - lib/gpx_doctor/models/route.rb
59
+ - lib/gpx_doctor/models/track.rb
60
+ - lib/gpx_doctor/models/track_segment.rb
61
+ - lib/gpx_doctor/models/waypoint.rb
62
+ - lib/gpx_doctor/parser.rb
63
+ - lib/gpx_doctor/point_selector.rb
64
+ - lib/gpx_doctor/segment_splitter.rb
65
+ - lib/gpx_doctor/statistics_enhancer.rb
66
+ - lib/gpx_doctor/version.rb
67
+ homepage: https://github.com/Poltrax-live/gpx-doctor
68
+ licenses:
69
+ - MIT
70
+ metadata: {}
71
+ rdoc_options: []
72
+ require_paths:
73
+ - lib
74
+ required_ruby_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '3.0'
79
+ required_rubygems_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ requirements: []
85
+ rubygems_version: 3.6.9
86
+ specification_version: 4
87
+ summary: Parse and manipulate GPX routes
88
+ test_files: []