gpx_doctor 0.2.0 → 0.4.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 +4 -4
- data/README.md +152 -1
- data/lib/gpx_doctor/configuration.rb +10 -1
- data/lib/gpx_doctor/cumulative_distance_enhancer.rb +28 -0
- data/lib/gpx_doctor/errors.rb +6 -0
- data/lib/gpx_doctor/geojson_builder.rb +136 -0
- data/lib/gpx_doctor/models/waypoint.rb +1 -1
- data/lib/gpx_doctor/parser.rb +11 -1
- data/lib/gpx_doctor/statistics_enhancer.rb +9 -5
- data/lib/gpx_doctor/unit_converter.rb +32 -0
- data/lib/gpx_doctor/validator.rb +63 -0
- data/lib/gpx_doctor/version.rb +1 -1
- data/lib/gpx_doctor.rb +5 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e0ba71f5752f36af3efd9f3b12de47d843a85b3ecd7fbcabd7468a2eef43355e
|
|
4
|
+
data.tar.gz: b58cb9eb1f207c3ec26f127b615ba43f70efe741904c6e73aa2c4ab059a7b710
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 053df672b67de8623f4749e7a5d0e8419667abd7227e31a8937d2434ebad3a0af73d04ae54af95112ecffe1e7824314634316cc1bc1793863d5c9289057cf4bd
|
|
7
|
+
data.tar.gz: 2376c74be6339f8aeeb535e8f79dbd9dd876bca7526713f9be8e09da3793718eaba4e285a455e4a07e1f182768affcf206c6ae7ba81483e37c6468e59bf96e27
|
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,6 +88,7 @@ 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
|
|
91
|
+
cumulative_distance: true, # add cumulative distance from start of each segment/route
|
|
63
92
|
enhance_elevation: true # fetch missing elevations from the configured elevation server
|
|
64
93
|
})
|
|
65
94
|
```
|
|
@@ -69,10 +98,13 @@ Processing is applied in the following order:
|
|
|
69
98
|
1. `max_distance` — segment splitting (interpolates intermediate points)
|
|
70
99
|
2. `max_points` — point reduction
|
|
71
100
|
3. `segment_statistics` — per-point statistics (distance, bearing, elevation change)
|
|
72
|
-
4. `
|
|
101
|
+
4. `cumulative_distance` — cumulative distance from the start of each segment/route
|
|
102
|
+
5. `enhance_elevation` — elevation lookup via the elevation server
|
|
73
103
|
|
|
74
104
|
`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
105
|
|
|
106
|
+
`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).
|
|
107
|
+
|
|
76
108
|
## Accessing data
|
|
77
109
|
|
|
78
110
|
```ruby
|
|
@@ -88,6 +120,121 @@ result.metadata # => #<Metadata …> (or nil)
|
|
|
88
120
|
- `<rtept>` elements inside each `<rte>`
|
|
89
121
|
- `<trkpt>` elements inside each `<trkseg>` inside each `<trk>`
|
|
90
122
|
|
|
123
|
+
## Building GPX files
|
|
124
|
+
|
|
125
|
+
The `GpxDoctor::Builder` class generates GPX 1.1 XML from a `Result` object (the same structure returned by the parser).
|
|
126
|
+
|
|
127
|
+
### Build to a string
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
result = GpxDoctor::Parser::Result.new(
|
|
131
|
+
waypoints: [],
|
|
132
|
+
routes: [],
|
|
133
|
+
tracks: [],
|
|
134
|
+
metadata: nil
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
xml_string = GpxDoctor::Builder.build(result)
|
|
138
|
+
# => "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<gpx version=\"1.1\"..."
|
|
139
|
+
|
|
140
|
+
# Optional: specify a custom creator attribute (identifies the software that created the GPX file)
|
|
141
|
+
xml_string = GpxDoctor::Builder.build(result, creator: 'My Application')
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Build to a file
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
GpxDoctor::Builder.build_file(result, 'path/to/output.gpx')
|
|
148
|
+
# Writes the GPX XML to the file and returns the XML string
|
|
149
|
+
|
|
150
|
+
# Optional: specify a custom creator attribute
|
|
151
|
+
GpxDoctor::Builder.build_file(result, 'path/to/output.gpx', creator: 'My Application')
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Input structure
|
|
155
|
+
|
|
156
|
+
The builder expects a `GpxDoctor::Parser::Result` object with the following fields:
|
|
157
|
+
|
|
158
|
+
- **`waypoints`** — Array of `GpxDoctor::Waypoint` objects (top-level waypoints)
|
|
159
|
+
- **`routes`** — Array of `GpxDoctor::Route` objects
|
|
160
|
+
- **`tracks`** — Array of `GpxDoctor::Track` objects
|
|
161
|
+
- **`metadata`** — `GpxDoctor::Metadata` object (optional)
|
|
162
|
+
|
|
163
|
+
All model classes are simple Ruby objects with attributes matching the GPX 1.1 specification (see **Model field reference** below).
|
|
164
|
+
|
|
165
|
+
### Example: Creating a GPX file from scratch
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
require 'gpx_doctor'
|
|
169
|
+
|
|
170
|
+
# Create waypoints
|
|
171
|
+
waypoint = GpxDoctor::Waypoint.new(
|
|
172
|
+
lat: 48.2093723,
|
|
173
|
+
lon: 16.356099,
|
|
174
|
+
ele: 160.0,
|
|
175
|
+
name: 'Vienna',
|
|
176
|
+
desc: 'Capital of Austria'
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Create a route with points
|
|
180
|
+
route_point_1 = GpxDoctor::Waypoint.new(lat: 48.21, lon: 16.36, ele: 155.0)
|
|
181
|
+
route_point_2 = GpxDoctor::Waypoint.new(lat: 48.22, lon: 16.37, ele: 162.0)
|
|
182
|
+
|
|
183
|
+
route = GpxDoctor::Route.new(
|
|
184
|
+
name: 'City Tour',
|
|
185
|
+
desc: 'A route through the city',
|
|
186
|
+
points: [route_point_1, route_point_2]
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Create a track with segments
|
|
190
|
+
track_point_1 = GpxDoctor::Waypoint.new(lat: 48.23, lon: 16.38, ele: 170.0)
|
|
191
|
+
track_point_2 = GpxDoctor::Waypoint.new(lat: 48.24, lon: 16.39, ele: 175.0)
|
|
192
|
+
|
|
193
|
+
segment = GpxDoctor::TrackSegment.new(points: [track_point_1, track_point_2])
|
|
194
|
+
track = GpxDoctor::Track.new(
|
|
195
|
+
name: 'Morning Run',
|
|
196
|
+
desc: 'My morning jog',
|
|
197
|
+
segments: [segment]
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Create metadata (optional)
|
|
201
|
+
metadata = GpxDoctor::Metadata.new(
|
|
202
|
+
name: 'My GPX File',
|
|
203
|
+
desc: 'A custom GPX file',
|
|
204
|
+
time: Time.now
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Build the result object
|
|
208
|
+
result = GpxDoctor::Parser::Result.new(
|
|
209
|
+
waypoints: [waypoint],
|
|
210
|
+
routes: [route],
|
|
211
|
+
tracks: [track],
|
|
212
|
+
metadata: metadata
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Generate GPX XML
|
|
216
|
+
xml_string = GpxDoctor::Builder.build(result, creator: 'My Application')
|
|
217
|
+
|
|
218
|
+
# Or write directly to a file
|
|
219
|
+
GpxDoctor::Builder.build_file(result, 'my_route.gpx', creator: 'My Application')
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Round-trip workflow
|
|
223
|
+
|
|
224
|
+
You can parse an existing GPX file, modify it, and build it back:
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
# Parse existing file
|
|
228
|
+
result = GpxDoctor::Parser.parse('input.gpx')
|
|
229
|
+
|
|
230
|
+
# Modify data
|
|
231
|
+
result.routes.first.name = 'Updated Route Name'
|
|
232
|
+
result.waypoints << GpxDoctor::Waypoint.new(lat: 48.5, lon: 16.5, name: 'New Point')
|
|
233
|
+
|
|
234
|
+
# Build back to GPX
|
|
235
|
+
GpxDoctor::Builder.build_file(result, 'output.gpx')
|
|
236
|
+
```
|
|
237
|
+
|
|
91
238
|
## Model field reference
|
|
92
239
|
|
|
93
240
|
### `Waypoint`
|
|
@@ -114,6 +261,10 @@ result.metadata # => #<Metadata …> (or nil)
|
|
|
114
261
|
| `pdop` | Float | |
|
|
115
262
|
| `ageofdgpsdata` | Float | |
|
|
116
263
|
| `dgpsid` | Integer | 0–1023 |
|
|
264
|
+
| `distance_to_next` | Float | Distance to next point (metres or feet based on `unit_system`) — set by `segment_statistics: true` |
|
|
265
|
+
| `elevation_change` | Float | Elevation change to next point (metres or feet based on `unit_system`) — set by `segment_statistics: true` |
|
|
266
|
+
| `direction` | Float | Bearing to next point (0–360°) — set by `segment_statistics: true` |
|
|
267
|
+
| `cumulative_distance` | Float | Cumulative distance from segment/route start (kilometres or miles based on `unit_system`) — set by `cumulative_distance: true` |
|
|
117
268
|
|
|
118
269
|
`Waypoint#to_h` returns a hash of all non-nil fields.
|
|
119
270
|
|
|
@@ -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
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module GpxDoctor
|
|
6
|
+
# Builds GeoJSON output from parsed GPX data according to RFC 7946.
|
|
7
|
+
#
|
|
8
|
+
# RFC 7946 compliance:
|
|
9
|
+
# - Coordinate order: [longitude, latitude, elevation]
|
|
10
|
+
# - LineString geometries require minimum 2 positions
|
|
11
|
+
# - MultiLineString segments require minimum 2 positions each
|
|
12
|
+
# - Empty or invalid geometries are filtered out
|
|
13
|
+
# - Properties member is null when no properties exist
|
|
14
|
+
class GeoJsonBuilder
|
|
15
|
+
class << self
|
|
16
|
+
def build(result)
|
|
17
|
+
new(result).build
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def build_file(result, file_path)
|
|
21
|
+
json = build(result)
|
|
22
|
+
File.write(file_path, json)
|
|
23
|
+
json
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(result)
|
|
28
|
+
@result = result
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def build
|
|
32
|
+
features = []
|
|
33
|
+
features.concat(waypoint_features)
|
|
34
|
+
features.concat(route_features)
|
|
35
|
+
features.concat(track_features)
|
|
36
|
+
|
|
37
|
+
JSON.generate(
|
|
38
|
+
type: 'FeatureCollection',
|
|
39
|
+
features: features
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def waypoint_features
|
|
46
|
+
@result.waypoints.map { |wpt| point_feature(wpt) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def route_features
|
|
50
|
+
@result.routes.map do |route|
|
|
51
|
+
coords = route.points.map { |pt| coordinate(pt) }
|
|
52
|
+
# RFC 7946: LineString must have 2 or more positions
|
|
53
|
+
next if coords.length < 2
|
|
54
|
+
|
|
55
|
+
props = route_properties(route)
|
|
56
|
+
{
|
|
57
|
+
type: 'Feature',
|
|
58
|
+
properties: props.empty? ? nil : props,
|
|
59
|
+
geometry: {
|
|
60
|
+
type: 'LineString',
|
|
61
|
+
coordinates: coords
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
end.compact
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def track_features
|
|
68
|
+
@result.tracks.map do |track|
|
|
69
|
+
# RFC 7946: Each LineString in MultiLineString must have 2+ positions
|
|
70
|
+
coords = track.segments.map do |seg|
|
|
71
|
+
seg_coords = seg.points.map { |pt| coordinate(pt) }
|
|
72
|
+
seg_coords if seg_coords.length >= 2
|
|
73
|
+
end.compact
|
|
74
|
+
|
|
75
|
+
# Skip tracks with no valid segments
|
|
76
|
+
next if coords.empty?
|
|
77
|
+
|
|
78
|
+
props = track_properties(track)
|
|
79
|
+
{
|
|
80
|
+
type: 'Feature',
|
|
81
|
+
properties: props.empty? ? nil : props,
|
|
82
|
+
geometry: {
|
|
83
|
+
type: 'MultiLineString',
|
|
84
|
+
coordinates: coords
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
end.compact
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def point_feature(wpt)
|
|
91
|
+
props = waypoint_properties(wpt)
|
|
92
|
+
{
|
|
93
|
+
type: 'Feature',
|
|
94
|
+
properties: props.empty? ? nil : props,
|
|
95
|
+
geometry: {
|
|
96
|
+
type: 'Point',
|
|
97
|
+
coordinates: coordinate(wpt)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def coordinate(point)
|
|
103
|
+
coord = [point.lon, point.lat]
|
|
104
|
+
coord << point.ele if point.ele
|
|
105
|
+
coord
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def waypoint_properties(wpt)
|
|
109
|
+
props = {}
|
|
110
|
+
props[:name] = wpt.name if wpt.name
|
|
111
|
+
props[:desc] = wpt.desc if wpt.desc
|
|
112
|
+
props[:sym] = wpt.sym if wpt.sym
|
|
113
|
+
props[:type] = wpt.type if wpt.type
|
|
114
|
+
props[:time] = wpt.time.iso8601 if wpt.time
|
|
115
|
+
props
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def route_properties(route)
|
|
119
|
+
props = {}
|
|
120
|
+
props[:name] = route.name if route.name
|
|
121
|
+
props[:desc] = route.desc if route.desc
|
|
122
|
+
props[:type] = route.type if route.type
|
|
123
|
+
props[:number] = route.number if route.number
|
|
124
|
+
props
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def track_properties(track)
|
|
128
|
+
props = {}
|
|
129
|
+
props[:name] = track.name if track.name
|
|
130
|
+
props[:desc] = track.desc if track.desc
|
|
131
|
+
props[:type] = track.type if track.type
|
|
132
|
+
props[:number] = track.number if track.number
|
|
133
|
+
props
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
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
|
|
data/lib/gpx_doctor/parser.rb
CHANGED
|
@@ -20,7 +20,8 @@ module GpxDoctor
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def parse_string(xml_string, params: {})
|
|
23
|
-
|
|
23
|
+
Validator.validate!(xml_string)
|
|
24
|
+
doc = Nokogiri::XML(xml_string) { |config| config.nonet }
|
|
24
25
|
ns = detect_namespace(doc)
|
|
25
26
|
|
|
26
27
|
new(doc, ns, params: params).parse
|
|
@@ -52,6 +53,7 @@ module GpxDoctor
|
|
|
52
53
|
split_segments(result, eff_max_dist) if eff_max_dist
|
|
53
54
|
select_max_points(result) if @params[:max_points]
|
|
54
55
|
enhance_statistics(result) if @params[:segment_statistics]
|
|
56
|
+
enhance_cumulative_distance(result) if @params[:cumulative_distance]
|
|
55
57
|
enhance_elevations(result) if @params[:enhance_elevation]
|
|
56
58
|
|
|
57
59
|
result
|
|
@@ -266,5 +268,13 @@ module GpxDoctor
|
|
|
266
268
|
track.segments.each { |seg| enhancer.enhance(seg.points) }
|
|
267
269
|
end
|
|
268
270
|
end
|
|
271
|
+
|
|
272
|
+
def enhance_cumulative_distance(result)
|
|
273
|
+
enhancer = CumulativeDistanceEnhancer.new
|
|
274
|
+
result.routes.each { |route| enhancer.enhance(route.points) }
|
|
275
|
+
result.tracks.each do |track|
|
|
276
|
+
track.segments.each { |seg| enhancer.enhance(seg.points) }
|
|
277
|
+
end
|
|
278
|
+
end
|
|
269
279
|
end
|
|
270
280
|
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
|
|
7
|
-
# - elevation_change
|
|
8
|
-
# - direction
|
|
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
|
-
|
|
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
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nokogiri'
|
|
4
|
+
|
|
5
|
+
module GpxDoctor
|
|
6
|
+
class Validator
|
|
7
|
+
ALLOWED_VERSIONS = %w[1.0 1.1].freeze
|
|
8
|
+
|
|
9
|
+
# Matches a DOCTYPE declaration anywhere in the string (case-insensitive).
|
|
10
|
+
# DOCTYPE is rejected entirely because it can carry external-entity
|
|
11
|
+
# references (XXE) or internal entity bombs (billion-laughs).
|
|
12
|
+
DOCTYPE_PATTERN = /<!DOCTYPE/i.freeze
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def validate!(xml_string)
|
|
16
|
+
new(xml_string).validate!
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(xml_string)
|
|
21
|
+
@xml_string = xml_string
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Raises GpxDoctor::InvalidGpxError if the input is not a valid, safe GPX document.
|
|
25
|
+
def validate!
|
|
26
|
+
reject_doctype!
|
|
27
|
+
doc = parse_xml!
|
|
28
|
+
validate_root!(doc)
|
|
29
|
+
validate_version!(doc)
|
|
30
|
+
true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def reject_doctype!
|
|
36
|
+
return unless @xml_string.match?(DOCTYPE_PATTERN)
|
|
37
|
+
|
|
38
|
+
raise InvalidGpxError, 'DOCTYPE declarations are not allowed'
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def parse_xml!
|
|
42
|
+
doc = Nokogiri::XML(@xml_string) { |config| config.nonet }
|
|
43
|
+
return doc if doc.errors.empty?
|
|
44
|
+
|
|
45
|
+
raise InvalidGpxError, "Invalid XML: #{doc.errors.map(&:message).join('; ')}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def validate_root!(doc)
|
|
49
|
+
root = doc.root
|
|
50
|
+
return if root&.name == 'gpx'
|
|
51
|
+
|
|
52
|
+
raise InvalidGpxError, 'Root element must be <gpx>'
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def validate_version!(doc)
|
|
56
|
+
version = doc.root['version']
|
|
57
|
+
return if ALLOWED_VERSIONS.include?(version)
|
|
58
|
+
|
|
59
|
+
raise InvalidGpxError,
|
|
60
|
+
"Unsupported GPX version: #{version.inspect}. Supported versions: #{ALLOWED_VERSIONS.join(', ')}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
data/lib/gpx_doctor/version.rb
CHANGED
data/lib/gpx_doctor.rb
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'gpx_doctor/version'
|
|
4
|
+
require 'gpx_doctor/errors'
|
|
4
5
|
require 'gpx_doctor/configuration'
|
|
6
|
+
require 'gpx_doctor/validator'
|
|
5
7
|
require 'gpx_doctor/models/email'
|
|
6
8
|
require 'gpx_doctor/models/link'
|
|
7
9
|
require 'gpx_doctor/models/copyright'
|
|
@@ -13,12 +15,15 @@ require 'gpx_doctor/models/track_segment'
|
|
|
13
15
|
require 'gpx_doctor/models/route'
|
|
14
16
|
require 'gpx_doctor/models/track'
|
|
15
17
|
require 'gpx_doctor/distance_calculator'
|
|
18
|
+
require 'gpx_doctor/unit_converter'
|
|
16
19
|
require 'gpx_doctor/elevation_client'
|
|
17
20
|
require 'gpx_doctor/statistics_enhancer'
|
|
21
|
+
require 'gpx_doctor/cumulative_distance_enhancer'
|
|
18
22
|
require 'gpx_doctor/segment_splitter'
|
|
19
23
|
require 'gpx_doctor/point_selector'
|
|
20
24
|
require 'gpx_doctor/parser'
|
|
21
25
|
require 'gpx_doctor/builder'
|
|
26
|
+
require 'gpx_doctor/geojson_builder'
|
|
22
27
|
|
|
23
28
|
module GpxDoctor
|
|
24
29
|
class << self
|
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.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Poltrax
|
|
@@ -47,8 +47,11 @@ 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
|
|
53
|
+
- lib/gpx_doctor/errors.rb
|
|
54
|
+
- lib/gpx_doctor/geojson_builder.rb
|
|
52
55
|
- lib/gpx_doctor/models/bounds.rb
|
|
53
56
|
- lib/gpx_doctor/models/copyright.rb
|
|
54
57
|
- lib/gpx_doctor/models/email.rb
|
|
@@ -63,6 +66,8 @@ files:
|
|
|
63
66
|
- lib/gpx_doctor/point_selector.rb
|
|
64
67
|
- lib/gpx_doctor/segment_splitter.rb
|
|
65
68
|
- lib/gpx_doctor/statistics_enhancer.rb
|
|
69
|
+
- lib/gpx_doctor/unit_converter.rb
|
|
70
|
+
- lib/gpx_doctor/validator.rb
|
|
66
71
|
- lib/gpx_doctor/version.rb
|
|
67
72
|
homepage: https://github.com/Poltrax-live/gpx-doctor
|
|
68
73
|
licenses:
|