SpeciesFileGroup/taxonworks

View on GitHub
app/models/cached_map.rb

Summary

Maintainability
A
0 mins
Test Coverage
# A CachedMap is a OTU specific map derived from AssertedDistribution and Georeference data via
# aggregation of the intermediate CachedMapItem level.
#
class CachedMap < ApplicationRecord
  include Housekeeping::Projects
  include Housekeeping::Timestamps
  include Shared::IsData

  # @param GeoJSON in string form
  attr_writer :geo_json_string

  # @return Boolean, nil
  attr_accessor :force_rebuild

  before_validation :rebuild, if: :force_rebuild, on: [ :update ]

  # TODO:
  # Current production strategy is to present "out of date" when
  # visualizing a CacheMap, then do complete rebuilds.
  #
  # This strategy can change when if/when
  # we manage to get a comprehensive number of hooks into models
  # such that state can be maintained.
  #
  # Since all hooks are syncronized through a change in a CachedMapItem
  # we can trigger syncronizations that Update CachedMaps with callback hooks here, in theory.

  def rebuild
    if !synced?
      self.geo_json_string = CachedMap.calculate_union(otu, cached_map_type: )
    end
  end

  belongs_to :otu, inverse_of: :cached_maps

  # All cached_map items used to compose this cached_map.
  def cached_map_items
    CachedMapItem.where(type: cached_map_type, otu: otu_scope)
  end

  validates_presence_of :otu
  validates_presence_of :geometry
  validates_presence_of :reference_count

  def geo_json_string=(value)
    # Would be nice to write as string and convert in a hook after save so that
    # we don't have to decode Ruby side.
    # self.foo = "ST_GeomFromGeoJSON('#{value}')"
    self.geometry = RGeo::GeoJSON.decode(value, json_parser: :json)
  end

  def synced?
    cached_map_items_reference_total == reference_count && latest_cached_map_item.created_at <= created_at
  end

  def latest_cached_map_item
    cached_map_items.order(:updated_at).first
  end

  def cached_map_items_reference_total
    CachedMapItem.where(otu: otu_scope).sum(:reference_count)
  end

  def otu_scope
    if otu.taxon_name
      Otu.descendant_of_taxon_name(otu.taxon_name_id)
    else
      Otu.coordinate_otus(otu.id)
    end
  end

  def geo_json_to_s
    if respond_to?(:geo_json) # loaded as string in query
      geo_json
    else
      CachedMap.select('ST_AsGeoJSON(geometry) geo_json').find(id).geo_json
    end
  end

  def self.union_sql(otu_scope, cached_map_type = 'CachedMapItem::WebLevel1')
    i = ::GeographicItem.select("#{GeographicItem::GEOMETRY_SQL.to_sql}")
      .joins('JOIN cached_map_items cmi on cmi.geographic_item_id = geographic_items.id')
      .joins('JOIN otu_scope AS otu_scope1 on otu_scope1.id = cmi.otu_id')
      .where('cmi.untranslated IS NULL OR cmi.untranslated <> true')
      .distinct

    s = "WITH otu_scope AS (#{otu_scope.to_sql}) " + i.to_sql

    # TODO: with untranslated handled we probable don't need Homogenize?
    # TODO: explore simplification optimization
    #   - 0.01 drops the number of points by > 5x

    sql = "SELECT
             ST_AsGeoJSON(
              ST_SimplifyPreserveTopology(
                 ST_CollectionHomogenize (
                   ST_CollectionExtract(
                     ST_Union(geom_array)
                   )
                 ),
                 0.01
               )
            ) AS geojson
          FROM (
            SELECT ARRAY(
              #{s}
            ) AS geom_array
          ) AS subquery;"
    sql
  end

  def self.calculate_union(otu_scope, cached_map_type = 'CachedMapItem::WebLevel1')
    sql = union_sql(otu_scope, cached_map_type = 'CachedMapItem::WebLevel1')
    begin
      r = ActiveRecord::Base.connection.execute(sql)
    rescue ActiveRecord::StatementInvalid => e
      if e.message.include?('GEOSUnaryUnion')
        return nil
      else
        raise e
      end
    end
    r[0]['geojson']
  end

end