lib/nswtopo/gis/arcgis/layer.rb



require_relative 'layer/query'
require_relative 'layer/map'
require_relative 'layer/statistics'
require_relative 'layer/renderer'

module NSWTopo
  module ArcGIS
    class Layer
      FIELD_TYPES = %W[esriFieldTypeOID esriFieldTypeInteger esriFieldTypeSmallInteger esriFieldTypeDouble esriFieldTypeSingle esriFieldTypeString esriFieldTypeGUID esriFieldTypeDate].to_set
      NoLayerError = Class.new RuntimeError

      def initialize(service, id: nil, layer: nil, where: nil, fields: nil, launder: nil, truncate: nil, decode: nil, mixed: true, geometry: nil, unique: nil)
        raise NoLayerError, "no ArcGIS layer name or url provided" unless layer || id
        @id, @name = service["layers"].find do |info|
          layer ? String(layer) == info["name"] : Integer(id) == info["id"]
        end&.values_at("id", "name")
        raise "ArcGIS layer does not exist: #{layer || id}" unless @id

        @service, @where, @decode, @mixed, @geometry, @unique = service, where, decode, mixed, geometry, unique

        @layer = get_json @id
        raise "ArcGIS layer is not a feature layer: #{@name}" unless @layer["type"] == "Feature Layer"

        @geometry_type = @layer["geometryType"]

        date_fields = @layer["fields"].select do |field|
          "esriFieldTypeDate" == field["type"]
        end.map do |field|
          field["name"]
        end.to_set

        @fields = fields&.map do |name|
          @layer["fields"].find(-> { raise "invalid field name: #{name}" }) do |field|
            field.values_at("alias", "name").include? name
          end.fetch("name")
        end

        [[%w[typeIdField], %w[subtypeField subtypeFieldName]], %w[types subtypes], %w[id code]].transpose.map do |name_keys, lookup_key, value_key|
          next @layer.values_at(*name_keys).compact.reject(&:empty?).first, @layer[lookup_key], value_key
        end.find do |name_or_alias, lookup, value_key|
          name_or_alias && lookup&.any?
        end&.tap do |name_or_alias, lookup, value_key|
          @type_field = @layer["fields"].find do |field|
            field.values_at("alias", "name").compact.include? name_or_alias
          end&.fetch("name")

          @type_values = lookup.map do |type|
            type.values_at value_key, "name"
          end.to_h

          @subtype_values = lookup.map do |type|
            type.values_at value_key, "domains"
          end.map do |code, domains|
            coded_values = domains.map do |name, domain|
              [name, domain["codedValues"]]
            end.select(&:last).map do |name, pairs|
              values = pairs.map do |pair|
                pair.values_at "code", "name"
              end.to_h
              [name, values]
            end.to_h
            [code, coded_values]
          end.to_h

          @subtype_fields = @subtype_values.values.flat_map(&:keys).uniq
        end

        @coded_values = @layer["fields"].map do |field|
          [field["name"], field.dig("domain", "codedValues")]
        end.select(&:last).map do |name, pairs|
          values = pairs.map do |pair|
            pair.values_at "code", "name"
          end.to_h
          [name, values]
        end.to_h

        @rename = @layer["fields"].map do |field|
          field["name"]
        end.map do |name|
          next name, launder ? name.downcase.gsub(/[^\w]+/, ?_) : name
        end.map do |name, substitute|
          next name, truncate ? substitute.slice(0...truncate) : substitute
        end.sort_by do |name, substitute|
          [@fields&.include?(name) ? 0 : 1, substitute == name ? 0 : 1]
        end.inject(Hash[]) do |lookup, (name, substitute)|
          suffix, index, candidate = "_2", 3, substitute
          while lookup.key? candidate
            suffix, index, candidate = "_#{index}", index + 1, (truncate ? substitute.slice(0, truncate - suffix.length) : substitute) + suffix
            raise "can't individualise field name: #{name}" if truncate && suffix.length >= truncate
          end
          lookup.merge candidate => name
        end.invert.to_proc

        @revalue = lambda do |name, value, properties|
          case
          when %w[null Null NULL <null> <Null> <NULL>].include?(value)
            nil
          when value.nil?
            nil
          when date_fields === name
            Time.at(value / 1000).utc.iso8601
          when !decode
            value
          when @type_field == name
            @type_values[value]
          when lookup = @subtype_values&.dig(properties[@type_field], name)
            lookup[value]
          when lookup = @coded_values.dig(name)
            lookup[value]
          else value
          end
        end

        case @layer["capabilities"]
        when /Query/ then extend Query, @layer["supportsStatistics"] ? Statistics : Renderer
        when /Map/   then extend Map, Renderer
        else raise "ArcGIS layer does not include Query or Map capability: #{@name}"
        end
      end

      extend Forwardable
      delegate %i[get get_json projection] => :@service
      attr_reader :count

      def extra_field
        case
        when !@decode || !@type_field || !@fields
        when @fields.include?(@type_field)
        when (@subtype_fields & @fields).any? then @type_field
        end
      end

      def decode(attributes)
        attributes.map do |name, value|
          [name, @revalue[name, value, attributes]]
        end.to_h.slice(*@fields)
      end

      def paged(per_page: nil)
        per_page = [*per_page, *@layer["maxRecordCount"], 500].min
        Enumerator::Lazy.new pages(per_page) do |yielder, page|
          page.map! do |feature|
            decoded = decode(feature.properties).transform_keys!(&@rename)
            feature.with_properties decoded
          end.then(&yielder)
        end
      end

      def features(**options, &block)
        paged(**options).inject do |collection, page|
          yield collection.count, self.count if block_given?
          collection.merge! page
        end
      end

      def join_clauses(*clauses)
        "(" << clauses.join(") AND (") << ")" if clauses.any?
      end

      def codes
        pairs = lambda do |hash|
          hash.keys.zip(hash.values.map(&:sort).map(&:zip)).to_h
        end
        @coded_values.then(&pairs).tap do |result|
          next unless @type_field
          codes, lookups = @subtype_values.sort.transpose
          result[@type_field] = @type_values.slice(*codes).zip lookups.map(&pairs)
        end
      end

      def counts
        classify(*@fields, *extra_field).group_by do |attributes, count|
          decode attributes
        end.map do |attributes, attributes_counts|
          [attributes, attributes_counts.sum(&:last)]
        end
      end

      def info
        @layer.slice("name", "id").tap do |info|
          info["geometry"] = case @geometry_type
          when "esriGeometryPoint" then "Point"
          when "esriGeometryMultipoint" then "Multipoint"
          when "esriGeometryPolyline" then "LineString"
          when "esriGeometryPolygon" then "Polygon"
          else @geometry_type.delete_prefix("esriGeometry")
          end
          info["EPSG"] = @service["spatialReference"].values_at("latestWkid", "wkid").compact.first
          info["features"] = count
          info["fields"] = @layer["fields"].map do |field|
            [field["name"], field["type"].delete_prefix("esriFieldType")]
          end.sort_by(&:first).to_h if @layer["fields"]&.any?
        end.compact
      end
    end
  end
end