lib/aixm/document.rb

Summary

Maintainability
A
0 mins
Test Coverage
using AIXM::Refinements

module AIXM

  # The AIXM-Snapshot or OFMX-Snapshot document is the root container for
  # aeronautical information such as airports or airspaces.
  #
  # ===Cheat Sheet in Pseudo Code:
  #   document = AIXM.document(
  #     namespace: String (UUID)
  #     created_at: Time or Date or String
  #     effective_at: Time or Date or String
  #     expiration_at: Time or Date or String or nil
  #   )
  #   document.add_feature(AIXM::Feature)
  #
  # @see https://gitlab.com/openflightmaps/ofmx/wikis/Snapshot
  class Document
    include AIXM::Concerns::Association

    NAMESPACE_RE = /\A[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}\z/.freeze

    # @!method features
    #   @return [Array<AIXM::Feature>] features (e.g. airport or airspace) present
    #     in this document
    #
    # @!method add_feature(feature)
    #   @param feature [AIXM::Feature]
    #   @return [self]
    has_many :features, accept: ['AIXM::Feature']

    # UUID to namespace the data contained in this document
    #
    # @overload namespace
    #   @return [String]
    # @overload namespace=(value)
    #   @param value [String]
    attr_reader :namespace

    # Creation date and UTC time
    #
    # @overload created_at
    #   @return [Time]
    # @overload created_at=(value)
    #   @param value [Time] default: {#effective_at} or now
    attr_reader :created_at

    # Effective after date and UTC time
    #
    # @overload effective_at
    #   @return [Time]
    # @overload effective_at=(value)
    #   @param value [Time] default: {#created_at} or now
    attr_reader :effective_at

    # Expiration after date and UTC time
    #
    # @overload expiration_at
    #   @return [Time, nil]
    # @overload expiration_at=(value)
    #   @param value [Time, nil]
    attr_reader :expiration_at

    # See the {cheat sheet}[AIXM::Document] for examples on how to create
    # instances of this class.
    def initialize(namespace: nil, created_at: nil, effective_at: nil, expiration_at: nil)
      self.namespace = namespace
      self.created_at, self.effective_at, self.expiration_at = created_at, effective_at, expiration_at
    end

    # @return [String]
    def inspect
      %Q(#<#{self.class} created_at=#{created_at.inspect}>)
    end

    def namespace=(value)
      fail(ArgumentError, "invalid namespace") unless value.nil? || value.match?(NAMESPACE_RE)
      @namespace = value || SecureRandom.uuid
    end

    def created_at=(value)
      @created_at = if time = value&.to_time
        time.round
      else
        Time.now.utc.round
      end
    end

    def effective_at=(value)
      @effective_at = if time = value&.to_time
        time.round
      else
        created_at || Time.now.utc.round
      end
    end

    def expiration_at=(value)
      @expiration_at = value&.to_time
      @expiration_at = if time = value&.to_time
        time.round
      end
    end

    # Regions used throughout this document.
    #
    # @return [Array<String>] white space separated list of region codes
    def regions
      features.map(&:region).uniq.sort
    end

    # Compare all ungrouped obstacles and create new obstacle groups whose
    # members are located within +max_distance+ pairwise.
    #
    # @note OFMX requires every obstacle, even single ones, to be part of an
    #   obstacle group which has a region assigned. For this to work, you must
    #   assure every obstacle has a region assigned when using this method.
    #
    # @param max_distance [AIXM::D] max distance between obstacle group member
    #   pairs (default: 1 NM)
    # @return [Integer] number of obstacle groups added
    def group_obstacles!(max_distance: AIXM.d(1, :nm))
      obstacles, list = features.find_by(:obstacle), {}
      while subject = obstacles.send(:shift)
        obstacles.each do |obstacle|
          if subject.xy.distance(obstacle.xy) <= max_distance
            [subject, obstacle].each { list[_1] = list[subject] || SecureRandom.uuid }
          end
        end
      end
      list.group_by(&:last).each do |_, grouped_list|
        first_obstacle = grouped_list.first.first
        obstacle_group = AIXM.obstacle_group(source: first_obstacle.source, region: first_obstacle.region)
        grouped_list.each { |o, _| obstacle_group.add_obstacle features.send(:delete, o) }
        add_feature obstacle_group
      end.count
    end

    # Validate the generated AIXM or OFMX against its XSD.
    #
    # @return [Boolean] whether valid or not
    def valid?
      errors.none?
    end

    # Validate the generated AIXM or OFMX against its XSD and return the
    # errors found.
    #
    # @return [Array<String>] validation errors
    def errors
      xsd = Nokogiri::XML::Schema(File.open(AIXM.schema(:xsd)))
      xsd.validate(Nokogiri::XML(to_xml)).reject do |error|
        AIXM.config.ignored_errors && error.message.match?(AIXM.config.ignored_errors)
      end
    end

    # @return [Nokogiri::XML::Document] Nokogiri AIXM or OFMX document
    def document
      meta = {
        'xmlns:xsi': AIXM.schema(:namespace),
        version: AIXM.schema(:version),
        origin: "rubygem aixm-#{AIXM::VERSION}",
        namespace: (namespace if AIXM.ofmx?),
        regions: (regions.join(' '.freeze) if AIXM.ofmx?),
        created: @created_at.utc.xmlschema,
        effective: @effective_at.utc.xmlschema,
        expiration: (@expiration_at&.utc&.xmlschema if AIXM.ofmx?)
      }.compact
      Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |builder|
        builder.send(AIXM.schema(:root), meta) do |root|
          root.text("\n")
          AIXM::Concerns::Memoize.method :to_uid do
            # TODO: indent is lost if added directly to the builder
            # features.each { _1.add_to(root) }
            features.each { root << _1.to_xml.indent(2) }
          end
          if AIXM.ofmx? && AIXM.config.mid
            AIXM::PayloadHash::Mid.new(builder.doc).insert_mid
          end
        end
      end.doc
    end

    # @return [String] AIXM or OFMX markup
    def to_xml
      document.to_xml
    end
  end
end