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 +7 -0
- data/README.md +255 -0
- data/lib/gpx_doctor/builder.rb +152 -0
- data/lib/gpx_doctor/configuration.rb +17 -0
- data/lib/gpx_doctor/distance_calculator.rb +37 -0
- data/lib/gpx_doctor/elevation_client.rb +97 -0
- data/lib/gpx_doctor/models/bounds.rb +7 -0
- data/lib/gpx_doctor/models/copyright.rb +7 -0
- data/lib/gpx_doctor/models/email.rb +11 -0
- data/lib/gpx_doctor/models/link.rb +7 -0
- data/lib/gpx_doctor/models/metadata.rb +15 -0
- data/lib/gpx_doctor/models/person.rb +7 -0
- data/lib/gpx_doctor/models/route.rb +16 -0
- data/lib/gpx_doctor/models/track.rb +20 -0
- data/lib/gpx_doctor/models/track_segment.rb +12 -0
- data/lib/gpx_doctor/models/waypoint.rb +30 -0
- data/lib/gpx_doctor/parser.rb +270 -0
- data/lib/gpx_doctor/point_selector.rb +55 -0
- data/lib/gpx_doctor/segment_splitter.rb +48 -0
- data/lib/gpx_doctor/statistics_enhancer.rb +26 -0
- data/lib/gpx_doctor/version.rb +5 -0
- data/lib/gpx_doctor.rb +37 -0
- metadata +88 -0
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,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,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,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
|
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: []
|