lib/gpx/gpx_file.rb
# frozen_string_literal: true
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/"
# 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 = {})
super()
@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.empty?
@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
# rubocop:disable Style/OptionalBooleanParameter
# 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
# rubocop:enable Style/OptionalBooleanParameter
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
Nokogiri::XML::Builder.new(encoding: 'UTF-8') 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
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
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
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
# rubocop:enable Metrics/BlockLength
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.empty? || @tracks[0].segments.nil? || @tracks[0].segments.empty?
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