/
gpx_file.rb
352 lines (320 loc) · 12.1 KB
/
gpx_file.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
module GPX
class GPXFile < Base
attr_accessor :tracks,
:routes, :waypoints, :bounds, :lowest_point, :highest_point, :duration, :ns, :time, :name, :version, :creator, :description, :moving_duration
DEFAULT_CREATOR = "GPX RubyGem #{GPX::VERSION} -- http://dougfales.github.io/gpx/".freeze
# This initializer can be used to create a new GPXFile from an existing
# file or to create a new GPXFile instance with no data (so that you can
# add tracks and points and write it out to a new file later).
# To read an existing GPX file, do this:
# gpx_file = GPXFile.new(:gpx_file => 'mygpxfile.gpx')
# puts "Speed: #{gpx_file.average_speed}"
# puts "Duration: #{gpx_file.duration}"
# puts "Bounds: #{gpx_file.bounds}"
#
# To read a GPX file from a string, use :gpx_data.
# gpx_file = GPXFile.new(:gpx_data => '<xml ...><gpx>...</gpx>)
# To create a new blank GPXFile instance:
# gpx_file = GPXFile.new
# Note that you can pass in any instance variables to this form of the initializer, including Tracks or Segments:
# some_track = get_track_from_csv('some_other_format.csv')
# gpx_file = GPXFile.new(:tracks => [some_track])
#
def initialize(opts = {})
@duration = 0
@attributes = {}
@namespace_defs = []
@tracks = []
@time = nil
if opts[:gpx_file] || opts[:gpx_data]
if opts[:gpx_file]
gpx_file = opts[:gpx_file]
gpx_file = File.open(gpx_file) unless gpx_file.is_a?(File)
@xml = Nokogiri::XML(gpx_file)
else
@xml = Nokogiri::XML(opts[:gpx_data])
end
gpx_element = @xml.at('gpx')
@attributes = gpx_element.attributes
@namespace_defs = gpx_element.namespace_definitions
@version = gpx_element['version']
reset_meta_data
bounds_element = (begin
@xml.at('metadata/bounds')
rescue StandardError
nil
end)
if bounds_element
@bounds.min_lat = get_bounds_attr_value(bounds_element, %w[min_lat minlat minLat])
@bounds.min_lon = get_bounds_attr_value(bounds_element, %w[min_lon minlon minLon])
@bounds.max_lat = get_bounds_attr_value(bounds_element, %w[max_lat maxlat maxLat])
@bounds.max_lon = get_bounds_attr_value(bounds_element, %w[max_lon maxlon maxLon])
else
get_bounds = true
end
@time = begin
Time.parse(@xml.at('metadata/time').inner_text)
rescue StandardError
nil
end
@name = begin
@xml.at('metadata/name').inner_text
rescue StandardError
nil
end
@description = begin
@xml.at('metadata/desc').inner_text
rescue StandardError
nil
end
@xml.search('trk').each do |trk|
trk = Track.new(element: trk, gpx_file: self)
update_meta_data(trk, get_bounds)
@tracks << trk
end
@waypoints = []
@xml.search('wpt').each { |wpt| @waypoints << Waypoint.new(element: wpt, gpx_file: self) }
@routes = []
@xml.search('rte').each { |rte| @routes << Route.new(element: rte, gpx_file: self) }
@tracks.delete_if(&:empty?)
calculate_duration
else
reset_meta_data
opts.each { |attr_name, value| instance_variable_set("@#{attr_name}", value) }
unless @tracks.nil? || @tracks.size.zero?
@tracks.each { |trk| update_meta_data(trk) }
calculate_duration
end
end
@tracks ||= []
@routes ||= []
@waypoints ||= []
end
def get_bounds_attr_value(el, possible_names)
result = nil
possible_names.each do |name|
result = el[name]
break unless result.nil?
end
(begin
result.to_f
rescue StandardError
nil
end)
end
# Returns the distance, in kilometers, meters, or miles, of all of the
# tracks and segments contained in this GPXFile.
def distance(opts = { units: 'kilometers' })
case opts[:units]
when /kilometers/i
@distance
when /meters/i
(@distance * 1000)
when /miles/i
(@distance * 0.62)
end
end
# Returns the average speed, in km/hr, meters/hr, or miles/hr, of this
# GPXFile. The calculation is based on the total distance divided by the
# sum of duration of all segments of all tracks
# (not taking into accounting pause time).
def average_speed(opts = { units: 'kilometers' })
case opts[:units]
when /kilometers/i
distance / (moving_duration / 3600.0)
when /meters/i
(distance * 1000) / (moving_duration / 3600.0)
when /miles/i
(distance * 0.62) / (moving_duration / 3600.0)
end
end
# Crops any points falling within a rectangular area. Identical to the
# delete_area method in every respect except that the points outside of
# the given area are deleted. Note that this method automatically causes
# the meta data to be updated after deletion.
def crop(area)
reset_meta_data
keep_tracks = []
tracks.each do |trk|
trk.crop(area)
unless trk.empty?
update_meta_data(trk)
keep_tracks << trk
end
end
@tracks = keep_tracks
routes.each { |rte| rte.crop(area) }
waypoints.each { |wpt| wpt.crop(area) }
end
# Deletes any points falling within a rectangular area. The "area"
# parameter is usually an instance of the Bounds class. Note that this
# method cascades into similarly named methods of subordinate classes
# (i.e. Track, Segment), which means, if you want the deletion to apply
# to all the data, you only call this one (and not the one in Track or
# Segment classes). Note that this method automatically causes the meta
# data to be updated after deletion.
def delete_area(area)
reset_meta_data
keep_tracks = []
tracks.each do |trk|
trk.delete_area(area)
unless trk.empty?
update_meta_data(trk)
keep_tracks << trk
end
end
@tracks = keep_tracks
routes.each { |rte| rte.delete_area(area) }
waypoints.each { |wpt| wpt.delete_area(area) }
end
# Resets the meta data for this GPX file. Meta data includes the bounds,
# the high and low points, and the distance.
def reset_meta_data
@bounds = Bounds.new
@highest_point = nil
@lowest_point = nil
@distance = 0.0
@moving_duration = 0.0
end
# Updates the meta data for this GPX file. Meta data includes the
# bounds, the high and low points, and the distance. This is useful when
# you modify the GPX data (i.e. by adding or deleting points) and you
# want the meta data to accurately reflect the new data.
def update_meta_data(trk, get_bounds = true)
@lowest_point = trk.lowest_point if @lowest_point.nil? || (!trk.lowest_point.nil? && (trk.lowest_point.elevation < @lowest_point.elevation))
@highest_point = trk.highest_point if @highest_point.nil? || (!trk.highest_point.nil? && (trk.highest_point.elevation > @highest_point.elevation))
@bounds.add(trk.bounds) if get_bounds
@distance += trk.distance
@moving_duration += trk.moving_duration
end
# Serialize the current GPXFile to a gpx file named <filename>.
# If the file does not exist, it is created. If it does exist, it is overwritten.
def write(filename, update_time = true)
@time = Time.now if @time.nil? || update_time
@name ||= File.basename(filename)
doc = generate_xml_doc
File.open(filename, 'w+') { |f| f.write(doc.to_xml) }
end
def to_s(update_time = true)
@time = Time.now if @time.nil? || update_time
doc = generate_xml_doc
doc.to_xml
end
def inspect
"<#{self.class.name}:...>"
end
def recalculate_distance
@distance = 0
@tracks.each do |track|
track.recalculate_distance
@distance += track.distance
end
end
private
def attributes_and_nsdefs_as_gpx_attributes
# $stderr.puts @namespace_defs.inspect
gpx_header = {}
@attributes.each do |k, v|
k = v.namespace.prefix + ':' + k if v.namespace
gpx_header[k] = v.value
end
@namespace_defs.each do |nsd|
tag = 'xmlns'
tag += ':' + nsd.prefix if nsd.prefix
gpx_header[tag] = nsd.href
end
gpx_header
end
def generate_xml_doc
@version ||= '1.1'
version_dir = version.tr('.', '/')
gpx_header = attributes_and_nsdefs_as_gpx_attributes
gpx_header['version'] = @version.to_s unless gpx_header['version']
gpx_header['creator'] = DEFAULT_CREATOR unless gpx_header['creator']
gpx_header['xsi:schemaLocation'] = "http://www.topografix.com/GPX/#{version_dir} http://www.topografix.com/GPX/#{version_dir}/gpx.xsd" unless gpx_header['xsi:schemaLocation']
gpx_header['xmlns:xsi'] = 'http://www.w3.org/2001/XMLSchema-instance' if !gpx_header['xsi'] && !gpx_header['xmlns:xsi']
# $stderr.puts gpx_header.keys.inspect
# rubocop:disable Metrics/BlockLength
doc = Nokogiri::XML::Builder.new do |xml|
xml.gpx(gpx_header) do
# version 1.0 of the schema doesn't support the metadata element, so push them straight to the root 'gpx' element
if @version == '1.0'
xml.name @name
xml.time @time.xmlschema
xml.bound(
minlat: bounds.min_lat,
minlon: bounds.min_lon,
maxlat: bounds.max_lat,
maxlon: bounds.max_lon
)
else
xml.metadata do
xml.name @name
xml.time @time.xmlschema
xml.bound(
minlat: bounds.min_lat,
minlon: bounds.min_lon,
maxlat: bounds.max_lat,
maxlon: bounds.max_lon
)
end
end
unless tracks.nil?
tracks.each do |t|
xml.trk do
xml.name t.name
t.segments.each do |seg|
xml.trkseg do
seg.points.each do |p|
xml.trkpt(lat: p.lat, lon: p.lon) do
xml.time p.time.xmlschema unless p.time.nil?
xml.ele p.elevation unless p.elevation.nil?
xml << p.extensions.to_xml unless p.extensions.nil?
end
end
end
end
end
end
end
unless waypoints.nil?
waypoints.each do |w|
xml.wpt(lat: w.lat, lon: w.lon) do
xml.time w.time.xmlschema unless w.time.nil?
Waypoint::SUB_ELEMENTS.each do |sub_elem|
xml.send(sub_elem, w.send(sub_elem)) if w.respond_to?(sub_elem) && !w.send(sub_elem).nil?
end
end
end
end
unless routes.nil?
routes.each do |r|
xml.rte do
xml.name r.name
r.points.each do |p|
xml.rtept(lat: p.lat, lon: p.lon) do
xml.time p.time.xmlschema unless p.time.nil?
xml.ele p.elevation unless p.elevation.nil?
end
end
end
end
end
end
end
# rubocop:enable Metrics/BlockLength
doc
end
# Calculates and sets the duration attribute by subtracting the time on
# the very first point from the time on the very last point.
def calculate_duration
@duration = 0
if @tracks.nil? || @tracks.size.zero? || @tracks[0].segments.nil? || @tracks[0].segments.size.zero?
return @duration
end
@duration = (@tracks[-1].segments[-1].points[-1].time - @tracks.first.segments.first.points.first.time)
rescue StandardError
@duration = 0
end
end
end