spatial_features 2.18.0 → 3.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 41b8326969b8e58e74200ad16194cbc2e6307ba9a1e1b81cc1bc0339ead5ecd5
4
- data.tar.gz: aeee4a1a92da17454c61f853cd6cb9917b727dbc4a5bc8095cc74d17846642f5
3
+ metadata.gz: 6d3fe76f0ce82837de428e401ce4f6a20a84b97acb2085374a8bf8fe1daba5c8
4
+ data.tar.gz: fed058553913c07b27838424dc07b1b63677f28798fae7d5534b55de7c1aa713
5
5
  SHA512:
6
- metadata.gz: 4f793c92a7c6018655bf8cd20f77f0b7b3b106cd8922fede6090c7c3e8513df966fedddbd1f1e5614a9b14f41b47fd3658e0b8db642cd7287138b611b398735a
7
- data.tar.gz: 4b965732b239b2fde43d1449fe31862255d03c5e84f34011707cf26ffffd3b9775646712f754863305f1375e3cb64136bef028558c40dc0f823d58db69408eed
6
+ metadata.gz: 0f6aebd3553502127a91f68d5f692bd6311a8c7af0aca22bccc54cec712aa8371696b0f8e19c0c7aaffa9e98c1ee809d478612a5f7c2441452a202bc9f53280a
7
+ data.tar.gz: fcf4f4eb0fc85f82ff726ec950936b9c45bdd9aaf85eb06067acac71b250185b6640e89f3536e5e5e1759b93efbee66e855460964ba112aa4f26a77cefae0abe
data/README.md CHANGED
@@ -30,10 +30,9 @@ Adds spatial methods to a model.
30
30
  name character varying(255),
31
31
  feature_type character varying(255),
32
32
  geog geography,
33
- geom geometry(Geometry,3005),
34
- geom_lowres geometry(Geometry,3005),
35
- kml text,
36
- kml_lowres text,
33
+ geom geometry(Geometry,4326),
34
+ geom_lowres geometry(Geometry,4326),
35
+ tilegeom geometry(Geometry,3857),
37
36
  metadata hstore,
38
37
  area double precision,
39
38
  north numeric(9,6),
@@ -41,7 +40,6 @@ Adds spatial methods to a model.
41
40
  south numeric(9,6),
42
41
  west numeric(9,6),
43
42
  centroid geography,
44
- kml_centroid text
45
43
  );
46
44
 
47
45
  CREATE SEQUENCE features_id_seq START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1;
@@ -53,6 +51,7 @@ Adds spatial methods to a model.
53
51
  CREATE INDEX index_features_on_spatial_model_id_and_spatial_model_type ON features USING btree (spatial_model_id, spatial_model_type);
54
52
  CREATE INDEX index_features_on_geom ON features USING gist (geom);
55
53
  CREATE INDEX index_features_on_geom_lowres ON features USING gist (geom_lowres);
54
+ CREATE INDEX index_features_on_tilegeom ON features USING gist (tilegeom);
56
55
 
57
56
  CREATE TABLE spatial_caches (
58
57
  id integer NOT NULL,
@@ -109,8 +108,17 @@ Person.new(:features => [Feature.new(:geog => 'some binary PostGIS Geography str
109
108
  You can specify multiple import sources for geometry. Each key is a method that returns the data for the Importer, and
110
109
  each value is the Importer to use to parse the data. See each Importer for more details.
111
110
  ```ruby
111
+ def ImageImporter
112
+ def self.call(feature, image_paths)
113
+ image_paths.each do |pathname|
114
+ # ...
115
+ end
116
+ end
117
+ end
118
+
112
119
  class Location < ActiveRecord::Base
113
- has_spatial_features :import => { :remote_kml_url => 'KMLFile', :file => 'File', :geojson => 'GeoJSON' }
120
+ has_spatial_features :import => { :remote_kml_url => 'KMLFile', :file => 'File', :geojson => 'ESRIGeoJSON' },
121
+ :image_handlers => ['ImageImporter']
114
122
 
115
123
  def remote_kml_url
116
124
  "www.test.com/kml/#{id}.kml"
@@ -154,3 +162,22 @@ Feature.reset_column_information
154
162
  AbstractFeature.update_all(:type => 'Feature')
155
163
  Feature.refresh_aggregates
156
164
  ```
165
+
166
+ ## Upgrading From 2.8.x to 3.0
167
+ Cached KML layers are no longer generated as Mapbox Vector Tile is the primary expected output. The columns can be left
168
+ in place or you can remove the KML cache columns.
169
+
170
+ ```ruby
171
+ remove_column :features, :kml
172
+ remove_column :features, :kml_lowres
173
+ remove_column :features, :kml_centroid
174
+ ```
175
+
176
+ A new `tilegeom` column has been added to support MVT tile output, which is now the preferred map output format instead
177
+ of GeoJSON or KML. It keeps memory usage low and is fast to generate.
178
+
179
+ ```ruby
180
+ add_column :features, :tilegeom, :geometry
181
+ add_index :features, :tilegeom, :using => :gist
182
+ Feature.update_all('tilegeom = ST_Transform(geom, 3857)')
183
+ ```
@@ -117,7 +117,8 @@ class AbstractFeature < ActiveRecord::Base
117
117
  SQL
118
118
 
119
119
  update_all <<-SQL.squish
120
- geom_lowres = ST_SimplifyPreserveTopology(geom, #{options.fetch(:lowres_simplification, lowres_simplification)})
120
+ geom_lowres = ST_SimplifyPreserveTopology(geom, #{options.fetch(:lowres_simplification, lowres_simplification)}),
121
+ tilegeom = ST_Transform(geom, 3857)
121
122
  SQL
122
123
 
123
124
  invalid('geom_lowres').update_all <<-SQL.squish
@@ -125,7 +126,44 @@ class AbstractFeature < ActiveRecord::Base
125
126
  SQL
126
127
  end
127
128
 
128
- def self.geojson(lowres: false, precision: 6, properties: true, srid: 4326, centroids: false) # default srid is 4326 so output is Google Maps compatible
129
+ def self.mvt(*args)
130
+ select_sql = mvt_sql(*args)
131
+
132
+ # Result is a hex string representing the desired binary output so we need to convert it to binary
133
+ result = SpatialFeatures::Utils.select_db_value(select_sql)
134
+ result.remove!(/^\\x/)
135
+ result = result.scan(/../).map(&:hex).pack('c*')
136
+
137
+ return result
138
+ end
139
+
140
+ def self.mvt_sql(tile_x, tile_y, zoom, properties: true, centroids: false, metadata: {}, scope: nil)
141
+ if centroids
142
+ column = 'ST_Transform(centroid::geometry, 3857)' # MVT works in SRID 3857
143
+ else
144
+ column = 'tilegeom'
145
+ end
146
+
147
+ subquery = select(:id)
148
+ .select("ST_AsMVTGeom(#{column}, ST_TileEnvelope(#{zoom}, #{tile_x}, #{tile_y}), extent => 4096, buffer => 64) AS geom")
149
+ .where("#{column} && ST_TileEnvelope(#{zoom}, #{tile_x}, #{tile_y}, margin => (64.0 / 4096))")
150
+ .order(:id)
151
+
152
+ # Merge additional scopes in to allow joins and other columns to be included in the feature output
153
+ subquery = subquery.merge(scope) unless scope.nil?
154
+
155
+ # Add metadata
156
+ metadata.each do |column, value|
157
+ subquery = subquery.select("#{value} AS #{column}")
158
+ end
159
+
160
+ select_sql = <<~SQL
161
+ SELECT ST_AsMVT(mvtgeom.*, 'default', 4096, 'geom', 'id') AS mvt
162
+ FROM (#{subquery.to_sql}) mvtgeom
163
+ SQL
164
+ end
165
+
166
+ def self.geojson(lowres: false, precision: 6, properties: true, srid: 4326, centroids: false, features_only: false, include_record_identifiers: false) # default srid is 4326 so output is Google Maps compatible
129
167
  if centroids
130
168
  column = 'centroid'
131
169
  elsif lowres
@@ -134,27 +172,52 @@ class AbstractFeature < ActiveRecord::Base
134
172
  column = 'geog'
135
173
  end
136
174
 
137
- properties_sql = <<~SQL if properties
138
- , 'properties', hstore_to_json(metadata)
175
+ properties_sql = []
176
+
177
+ if include_record_identifiers
178
+ properties_sql << "hstore(ARRAY['feature_name', name::varchar, 'feature_id', id::varchar, 'spatial_model_type', spatial_model_type::varchar, 'spatial_model_id', spatial_model_id::varchar])"
179
+ end
180
+
181
+ if properties
182
+ properties_sql << "metadata"
183
+ end
184
+
185
+ if properties.is_a?(Hash)
186
+ properties_sql << <<~SQL
187
+ hstore(ARRAY[#{properties.flatten.map {|e| "'#{e.to_s}'" }.join(',')}])
188
+ SQL
189
+ end
190
+
191
+ properties_sql = <<~SQL if properties_sql.present?
192
+ , 'properties', hstore_to_json(#{properties_sql.join(' || ')})
139
193
  SQL
140
194
 
141
195
  sql = <<~SQL
196
+ json_agg(
197
+ json_build_object(
198
+ 'type', 'Feature',
199
+ 'geometry', ST_AsGeoJSON(#{column}, #{precision})::json
200
+ #{properties_sql}
201
+ )
202
+ )
203
+ SQL
204
+
205
+ sql = <<~SQL unless features_only
142
206
  json_build_object(
143
207
  'type', 'FeatureCollection',
144
- 'features', json_agg(
145
- json_build_object(
146
- 'type', 'Feature',
147
- 'geometry', ST_AsGeoJSON(#{column}, #{precision})::json
148
- #{properties_sql}
149
- )
150
- )
208
+ 'features', #{sql}
151
209
  )
152
210
  SQL
153
211
  SpatialFeatures::Utils.select_db_value(all.select(sql))
154
212
  end
155
213
 
156
- def feature_bounds
157
- slice(:north, :east, :south, :west)
214
+ def self.bounds
215
+ values = pluck('MAX(north) AS north, MAX(east) AS east, MIN(south) AS south, MIN(west) AS west').first
216
+ [:north, :east, :south, :west].zip(values).to_h.with_indifferent_access.transform_values!(&:to_f) if values&.compact.present?
217
+ end
218
+
219
+ def bounds
220
+ slice(:north, :east, :south, :west).with_indifferent_access.transform_values!(&:to_f)
158
221
  end
159
222
 
160
223
  def cache_derivatives(*args)
@@ -162,9 +225,8 @@ class AbstractFeature < ActiveRecord::Base
162
225
  end
163
226
 
164
227
  def kml(options = {})
165
- geometry = options[:lowres] ? kml_lowres : super()
166
- geometry = "<MultiGeometry>#{geometry}#{kml_centroid}</MultiGeometry>" if options[:centroid]
167
- return geometry
228
+ column = options[:lowres] ? 'geom_lowres' : 'geog'
229
+ return SpatialFeatures::Utils.select_db_value(self.class.where(:id => id).select("ST_AsKML(#{column}, 6)"))
168
230
  end
169
231
 
170
232
  def geojson(*args)
@@ -210,7 +272,7 @@ class AbstractFeature < ActiveRecord::Base
210
272
 
211
273
  def geometry_validation_message
212
274
  klass = self.class.base_class # Use the base class because we don't want to have to include a type column in our select
213
- error = klass.connection.select_one(klass.unscoped.invalid.from("(SELECT '#{sanitize_input_for_sql(self.geog)}'::geometry AS geog) #{klass.table_name}"))
275
+ error = klass.connection.select_one(klass.unscoped.invalid.from("(SELECT '#{sanitize_input_for_sql(self.geog)}'::geography::geometry AS geog) #{klass.table_name}")) # Ensure we cast to geography because the geog attribute value may not have been coerced to geography yet, so we want it to apply the +-180/90 bounds to any odd geometry that will happen when we save to the database
214
276
  return error.fetch('invalid_geometry_message') if error
215
277
  end
216
278
 
@@ -4,7 +4,11 @@ class AggregateFeature < AbstractFeature
4
4
  has_many :features, lambda { |aggregate| where(:spatial_model_type => aggregate.spatial_model_type) }, :foreign_key => :spatial_model_id, :primary_key => :spatial_model_id
5
5
 
6
6
  # Aggregate the features for the spatial model into a single feature
7
- def refresh
7
+ before_validation :set_geog, :on => :create, :unless => :geog?
8
+
9
+ private
10
+
11
+ def set_geog
8
12
  feature_array_sql = <<~SQL
9
13
  ARRAY[
10
14
  (#{features.select('ST_UNION(ST_CollectionExtract(geog::geometry, 1))').to_sql}),
@@ -19,6 +23,5 @@ class AggregateFeature < AbstractFeature
19
23
  FROM (SELECT unnest(#{feature_array_sql})) AS features
20
24
  WHERE NOT ST_IsEmpty(unnest)
21
25
  SQL
22
- self.save!
23
26
  end
24
27
  end
@@ -1,3 +1,5 @@
1
+ require_dependency SpatialFeatures::Engine.root.join('app/models/abstract_feature')
2
+
1
3
  class Feature < AbstractFeature
2
4
  class_attribute :automatically_refresh_aggregate
3
5
  self.automatically_refresh_aggregate = true
@@ -13,15 +15,7 @@ class Feature < AbstractFeature
13
15
 
14
16
  after_save :refresh_aggregate, if: :automatically_refresh_aggregate?
15
17
 
16
- # Features are used for display so we also cache their KML representation
17
- def self.cache_derivatives(options = {})
18
- super
19
- update_all <<-SQL.squish
20
- kml = ST_AsKML(geog, 6),
21
- kml_lowres = ST_AsKML(geom_lowres, #{options.fetch(:lowres_precision, lowres_precision)}),
22
- kml_centroid = ST_AsKML(centroid)
23
- SQL
24
- end
18
+ attr_accessor :importable_image_paths # :nodoc:
25
19
 
26
20
  def self.defer_aggregate_refresh(&block)
27
21
  start_at = Feature.maximum(:id).to_i + 1
@@ -52,8 +46,8 @@ class Feature < AbstractFeature
52
46
  end
53
47
 
54
48
  def refresh_aggregate
55
- build_aggregate_feature unless aggregate_feature&.persisted?
56
- aggregate_feature.refresh
49
+ aggregate_feature&.destroy # Destroy the existing aggregate feature to ensure its cache key changes when it is refreshed
50
+ create_aggregate_feature!
57
51
  end
58
52
 
59
53
  def automatically_refresh_aggregate?
@@ -1 +1,4 @@
1
1
  Mime::Type.register "application/vnd.geo+json", :geojson
2
+ Mime::Type.register "application/vnd.mapbox-vector-tile", :mvt
3
+ Mime::Type.register "application/vnd.google-earth.kml+xml", :kml
4
+ Mime::Type.register "application/vnd.google-earth.kmz", :kmz
@@ -12,11 +12,13 @@ module SpatialExtensions
12
12
  @nearby_records = scope_for_search(scope).within_buffer(target, distance, :distance => true, :intersection_area => true).order('distance_in_meters ASC, intersection_area_in_square_meters DESC, id ASC')
13
13
  @target = target
14
14
 
15
- yield if block_given?
16
-
17
- respond_to do |format|
18
- format.html { render :template => 'shared/spatial/feature_proximity', :layout => false }
19
- format.kml { render :template => 'shared/spatial/feature_proximity' }
15
+ if block_given?
16
+ block.call(@nearby_records)
17
+ else
18
+ respond_to do |format|
19
+ format.html { render :template => 'shared/spatial/feature_proximity', :layout => false }
20
+ format.kml { render :template => 'shared/spatial/feature_proximity' }
21
+ end
20
22
  end
21
23
  end
22
24
 
@@ -25,10 +27,12 @@ module SpatialExtensions
25
27
  @klass = klass_for_search(scope)
26
28
  @target = target
27
29
 
28
- yield if block_given?
29
-
30
- respond_to do |format|
31
- format.kml { render :template => 'shared/spatial/feature_venn_polygons' }
30
+ if block_given?
31
+ block.call(@venn_polygons)
32
+ else
33
+ respond_to do |format|
34
+ format.kml { render :template => 'shared/spatial/feature_venn_polygons' }
35
+ end
32
36
  end
33
37
  end
34
38
 
@@ -37,6 +41,12 @@ module SpatialExtensions
37
41
  end
38
42
 
39
43
  def scope_for_search(scope)
40
- params.key?(:ids) ? scope.where(:id => params[:ids]) : scope
44
+ if params.key?(:ids)
45
+ ids = params[:ids]
46
+ ids = ids.split(/\D/) if ids.is_a?(String)
47
+ scope.where(:id => ids)
48
+ else
49
+ scope
50
+ end
41
51
  end
42
52
  end
@@ -8,7 +8,7 @@ module SpatialFeatures
8
8
  included do
9
9
  extend ActiveModel::Callbacks
10
10
  define_model_callbacks :update_features
11
- spatial_features_options.reverse_merge!(:import => {}, spatial_cache: [])
11
+ spatial_features_options.reverse_merge!(:import => {}, :spatial_cache => [], :image_handlers => [])
12
12
  end
13
13
 
14
14
  module ClassMethods
@@ -42,6 +42,13 @@ module SpatialFeatures
42
42
 
43
43
  return true
44
44
  end
45
+ rescue StandardError => e
46
+ if skip_invalid
47
+ Rails.logger.warn "Error updating #{self.class} #{self.id}. #{e.message}"
48
+ return nil
49
+ else
50
+ raise ImportError, e.message, e.backtrace
51
+ end
45
52
  end
46
53
 
47
54
  def update_features_cache_key(cache_key)
@@ -78,13 +85,30 @@ module SpatialFeatures
78
85
  "SpatialFeatures::Importers::#{importer_name}".constantize
79
86
  end
80
87
 
88
+ def handle_images(feature)
89
+ return if feature.importable_image_paths.nil? || feature.importable_image_paths.empty?
90
+
91
+ Array(spatial_features_options[:image_handlers]).each do |image_handler|
92
+ image_handler_from_name(image_handler).call(feature, feature.importable_image_paths)
93
+ end
94
+ end
95
+
96
+ def image_handler_from_name(handler_name)
97
+ handler_name.to_s.constantize
98
+ end
99
+
81
100
  def import_features(imports, skip_invalid)
82
101
  features.delete_all
83
102
  valid, invalid = Feature.defer_aggregate_refresh do
84
103
  Feature.without_caching_derivatives do
85
104
  imports.flat_map(&:features).partition do |feature|
86
105
  feature.spatial_model = self
87
- feature.save
106
+ if feature.save
107
+ handle_images(feature)
108
+ true
109
+ else
110
+ false
111
+ end
88
112
  end
89
113
  end
90
114
  end
@@ -41,8 +41,8 @@ module SpatialFeatures
41
41
  spatial_processing_jobs('update_features!').where.not(failed_at: nil)
42
42
  end
43
43
 
44
- def spatial_processing_jobs(suffix = nil)
45
- Delayed::Job.where(:queue => "#{spatial_processing_queue_name}#{suffix}")
44
+ def spatial_processing_jobs(method_name = nil)
45
+ Delayed::Job.where('queue LIKE ?', "#{spatial_processing_queue_name}#{method_name}%")
46
46
  end
47
47
 
48
48
  private
@@ -77,7 +77,7 @@ module SpatialFeatures
77
77
  update_cached_status(:success)
78
78
  end
79
79
 
80
- def error(job)
80
+ def error(job, exception)
81
81
  update_cached_status(:failure)
82
82
  end
83
83
 
@@ -15,6 +15,8 @@ module SpatialFeatures
15
15
 
16
16
  scope :with_features, lambda { joins(:features).uniq }
17
17
  scope :without_features, lambda { joins("LEFT OUTER JOIN features ON features.spatial_model_type = '#{Utils.base_class(name)}' AND features.spatial_model_id = #{table_name}.id").where("features.id IS NULL") }
18
+ scope :include_bounds, lambda { SQLHelpers.append_select(joins(:aggregate_feature), :north, :east, :south, :west) }
19
+ scope :include_area, lambda { SQLHelpers.append_select(joins(:aggregate_feature), :area) }
18
20
 
19
21
  scope :with_spatial_cache, lambda {|klass| joins(:spatial_caches).where(:spatial_caches => { :intersection_model_type => Utils.class_name_with_ancestors(klass) }).uniq }
20
22
  scope :without_spatial_cache, lambda {|klass| joins("LEFT OUTER JOIN #{SpatialCache.table_name} ON #{SpatialCache.table_name}.spatial_model_id = #{table_name}.id AND #{SpatialCache.table_name}.spatial_model_type = '#{Utils.base_class(name)}' and intersection_model_type IN ('#{Utils.class_name_with_ancestors(klass).join("','") }')").where("#{SpatialCache.table_name}.spatial_model_id IS NULL") }
@@ -31,6 +33,16 @@ module SpatialFeatures
31
33
  end
32
34
  end
33
35
 
36
+ module SQLHelpers
37
+ # Add select fields without replacing the implicit `table_name.*`
38
+ def self.append_select(scope, *fields)
39
+ if(!scope.select_values.any?)
40
+ fields.unshift(scope.arel_table[Arel.star])
41
+ end
42
+ scope.select(*fields)
43
+ end
44
+ end
45
+
34
46
  module ClassMethods
35
47
  def acts_like_spatial_features?
36
48
  true
@@ -44,7 +56,7 @@ module SpatialFeatures
44
56
  within_buffer(other, 0, options)
45
57
  end
46
58
 
47
- def within_buffer(other, buffer_in_meters = 0, options = {})
59
+ def within_buffer(other, buffer_in_meters = 0, **options)
48
60
  return none if other.is_a?(ActiveRecord::Base) && other.new_record?
49
61
 
50
62
  # Cache only works on single records, not scopes.
@@ -68,6 +80,10 @@ module SpatialFeatures
68
80
  features.points
69
81
  end
70
82
 
83
+ def bounds
84
+ aggregate_features.bounds
85
+ end
86
+
71
87
  def features
72
88
  type = base_class.to_s # Rails stores polymorphic foreign keys as the base class
73
89
  if all == unscoped
@@ -97,24 +113,22 @@ module SpatialFeatures
97
113
  end
98
114
 
99
115
  def area_in_square_meters
100
- features.area
116
+ abstract_features.area
101
117
  end
102
118
 
103
119
  private
104
120
 
105
121
  def cached_within_buffer_scope(other, buffer_in_meters, options)
106
122
  options = options.reverse_merge(:columns => "#{table_name}.*")
107
-
108
- # Don't use the cache if it doesn't exist
109
- unless other.class.unscoped { other.spatial_cache_for?(Utils.class_of(self), buffer_in_meters) } # Unscope so if we're checking for same class intersections the scope doesn't affect this lookup
110
- return none.extending(UncachedResult)
111
- end
112
-
113
123
  scope = cached_spatial_join(other)
114
124
  scope = scope.select(options[:columns])
115
125
  scope = scope.where("spatial_proximities.distance_in_meters <= ?", buffer_in_meters) if buffer_in_meters
116
126
  scope = scope.select("spatial_proximities.distance_in_meters") if options[:distance]
117
127
  scope = scope.select("spatial_proximities.intersection_area_in_square_meters") if options[:intersection_area]
128
+
129
+ # Don't use the cache if it doesn't exist, but make sure we keep the same select columns in case chained scopes reference them, e.g. order by
130
+ scope = scope.none.extending(UncachedResult) unless other.class.unscoped { other.spatial_cache_for?(Utils.class_of(self), buffer_in_meters) } # Unscope so if we're checking for same class intersections the scope doesn't affect this lookup
131
+
118
132
  return scope
119
133
  end
120
134
 
@@ -174,7 +188,7 @@ module SpatialFeatures
174
188
  end
175
189
 
176
190
  def features_cache_key
177
- "#{self.class.name}/#{id}-#{has_spatial_features_hash? ? features_hash : aggregate_feature.cache_key}"
191
+ "#{self.class.name}/#{id}-#{has_spatial_features_hash? ? features_hash : (aggregate_feature || features).cache_key}"
178
192
  end
179
193
 
180
194
  def polygons?
@@ -202,12 +216,15 @@ module SpatialFeatures
202
216
  end
203
217
 
204
218
  def bounds
205
- if association(:aggregate_feature).loaded?
206
- aggregate_feature&.feature_bounds
207
- else
208
- result = aggregate_features.pluck(:north, :east, :south, :west).first
209
- [:north, :east, :south, :west].zip(result.map {|bound| BigDecimal(bound) }).to_h.with_indifferent_access if result
210
- end
219
+ @bounds ||=
220
+ if has_attribute?(:north) && has_attribute?(:east) && has_attribute?(:south) && has_attribute?(:west)
221
+ slice(:north, :east, :south, :west).with_indifferent_access.transform_values!(&:to_f)
222
+ elsif association(:aggregate_feature).loaded?
223
+ # Aggregate features can be very large and take a while to load. Avoid loading one just to load the bounds.
224
+ aggregate_feature&.bounds
225
+ else
226
+ aggregate_features.bounds
227
+ end
211
228
  end
212
229
 
213
230
  def total_intersection_area_percentage(klass)
@@ -217,11 +234,14 @@ module SpatialFeatures
217
234
  end
218
235
 
219
236
  def features_area_in_square_meters
220
- if association(:aggregate_feature).loaded?
221
- aggregate_feature&.area
222
- else
223
- aggregate_features.pluck(:area).first
224
- end
237
+ @features_area_in_square_meters ||=
238
+ if has_attribute?(:area)
239
+ area
240
+ elsif association(:aggregate_feature).loaded?
241
+ aggregate_feature&.area
242
+ else
243
+ aggregate_features.pluck(:area).first
244
+ end
225
245
  end
226
246
 
227
247
  def total_intersection_area_in_square_meters(other)
@@ -46,7 +46,8 @@ module SpatialFeatures
46
46
  end
47
47
 
48
48
  def build_feature(record)
49
- Feature.new(:name => record.name, :metadata => record.metadata, :feature_type => record.feature_type, :geog => record.geog, :make_valid => @make_valid)
49
+ importable_image_paths = record.importable_image_paths if record.respond_to?(:importable_image_paths)
50
+ Feature.new(:name => record.name, :metadata => record.metadata, :feature_type => record.feature_type, :geog => record.geog, :importable_image_paths => importable_image_paths, :make_valid => @make_valid)
50
51
  end
51
52
  end
52
53
  end
@@ -0,0 +1,27 @@
1
+ require 'ostruct'
2
+ require 'digest/md5'
3
+ require 'spatial_features/importers/geo_json'
4
+
5
+ module SpatialFeatures
6
+ module Importers
7
+ class ESRIGeoJSON < GeoJSON
8
+ def parsed_geojson
9
+ @parsed_geojson ||= JSON.parse(geojson)
10
+ end
11
+
12
+ def geojson
13
+ @geojson ||= esri_json_to_geojson(@data)
14
+ end
15
+
16
+ private
17
+
18
+ def esri_json_to_geojson(url)
19
+ if URI.parse(url).relative?
20
+ `ogr2ogr -t_srs EPSG:4326 -f GeoJSON /dev/stdout "#{url}"` # It is a local file path
21
+ else
22
+ `ogr2ogr -t_srs EPSG:4326 -f GeoJSON /dev/stdout "#{url}" OGRGeoJSON`
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -3,14 +3,16 @@ require 'open-uri'
3
3
  module SpatialFeatures
4
4
  module Importers
5
5
  class File < SimpleDelegator
6
- INVALID_ARCHIVE = "Archive did not contain a .kml or .shp file. Supported formats are KMZ, KML, and zipped ArcGIS shapefiles.".freeze
6
+ INVALID_ARCHIVE = "Archive did not contain a .kml, .shp, .json, or .geojson file.".freeze
7
+ SUPPORTED_FORMATS = "Supported formats are KMZ, KML, zipped ArcGIS shapefiles, ESRI JSON, and GeoJSON.".freeze
7
8
 
9
+ FILE_PATTERNS = [/\.kml$/, /\.shp$/, /\.json$/, /\.geojson$/]
8
10
  def self.create_all(data, **options)
9
- Download.open_each(data, unzip: [/\.kml$/, /\.shp$/], downcase: true).map do |file|
11
+ Download.open_each(data, unzip: FILE_PATTERNS, downcase: true).map do |file|
10
12
  new(data, **options, current_file: file)
11
13
  end
12
14
  rescue Unzip::PathNotFound
13
- raise ImportError, INVALID_ARCHIVE
15
+ raise ImportError, INVALID_ARCHIVE + " " + SUPPORTED_FORMATS
14
16
  end
15
17
 
16
18
  # The File importer may be initialized multiple times by `::create_all` if it
@@ -21,7 +23,7 @@ module SpatialFeatures
21
23
  # If no `current_file` is passed then we just take the first valid file that we find.
22
24
  def initialize(data, *args, current_file: nil, **options)
23
25
  begin
24
- current_file ||= Download.open_each(data, unzip: [/\.kml$/, /\.shp$/], downcase: true).first
26
+ current_file ||= Download.open_each(data, unzip: FILE_PATTERNS, downcase: true).first
25
27
  rescue Unzip::PathNotFound
26
28
  raise ImportError, INVALID_ARCHIVE
27
29
  end
@@ -31,8 +33,10 @@ module SpatialFeatures
31
33
  __setobj__(KMLFile.new(current_file, *args, **options))
32
34
  when '.shp'
33
35
  __setobj__(Shapefile.new(current_file, *args, **options))
36
+ when '.json', '.geojson'
37
+ __setobj__(ESRIGeoJSON.new(current_file.path, *args, **options))
34
38
  else
35
- raise ImportError, "Could not import file. Supported formats are KMZ, KML, and zipped ArcGIS shapefiles"
39
+ raise ImportError, "Could not import file. " + SUPPORTED_FORMATS
36
40
  end
37
41
  end
38
42
  end
@@ -5,15 +5,13 @@ module SpatialFeatures
5
5
  module Importers
6
6
  class GeoJSON < Base
7
7
  def cache_key
8
- @cache_key ||= Digest::MD5.hexdigest(@data.to_json)
8
+ @cache_key ||= Digest::MD5.hexdigest(geojson)
9
9
  end
10
10
 
11
11
  private
12
12
 
13
13
  def each_record(&block)
14
- return unless @data
15
-
16
- @data.fetch('features', []).each do |record|
14
+ parsed_geojson.fetch('features', []).each do |record|
17
15
  metadata = record['properties'] || {}
18
16
  name = metadata.delete('name')
19
17
  yield OpenStruct.new(
@@ -24,6 +22,14 @@ module SpatialFeatures
24
22
  )
25
23
  end
26
24
  end
25
+
26
+ def parsed_geojson
27
+ @parsed_geojson ||= (@data.is_a?(String) ? JSON.parse(@data) : @data) || {}
28
+ end
29
+
30
+ def geojson
31
+ @geojson ||= @data.is_a?(String) ? @data : @data.to_json
32
+ end
27
33
  end
28
34
  end
29
35
  end
@@ -3,6 +3,14 @@ require 'ostruct'
3
3
  module SpatialFeatures
4
4
  module Importers
5
5
  class KML < Base
6
+ # <SimpleData name> keys that may contain <img> tags
7
+ IMAGE_METADATA_KEYS = %w[pdfmaps_photos].freeze
8
+
9
+ def initialize(data, base_dir: nil, **args)
10
+ @base_dir = base_dir
11
+ super data, **args
12
+ end
13
+
6
14
  private
7
15
 
8
16
  def each_record(&block)
@@ -18,10 +26,11 @@ module SpatialFeatures
18
26
  next if blank_feature?(feature)
19
27
 
20
28
  geog = geom_from_kml(feature)
21
-
22
29
  next if geog.blank?
23
30
 
24
- yield OpenStruct.new(:feature_type => sql_type, :geog => geog, :name => name, :metadata => metadata)
31
+ importable_image_paths = images_from_metadata(metadata)
32
+
33
+ yield OpenStruct.new(:feature_type => sql_type, :geog => geog, :name => name, :metadata => metadata, :importable_image_paths => importable_image_paths)
25
34
  end
26
35
  end
27
36
  end
@@ -32,17 +41,35 @@ module SpatialFeatures
32
41
 
33
42
  def geom_from_kml(kml)
34
43
  geom = nil
44
+ conn = nil
35
45
 
36
46
  # Do query in a new thread so we use a new connection (if the query fails it will poison the transaction of the current connection)
47
+ #
48
+ # We manually checkout a new connection since Rails re-uses DB connections across threads.
37
49
  Thread.new do
38
- geom = SpatialFeatures::Utils.select_db_value("SELECT ST_GeomFromKML(#{ActiveRecord::Base.connection.quote(kml.to_s)})")
50
+ conn = ActiveRecord::Base.connection_pool.checkout
51
+ geom = conn.select_value("SELECT ST_GeomFromKML(#{conn.quote(kml.to_s)})")
39
52
  rescue ActiveRecord::StatementInvalid => e # Discard Invalid KML features
40
53
  geom = nil
54
+ ensure
55
+ ActiveRecord::Base.connection_pool.checkin(conn) if conn
41
56
  end.join
42
57
 
43
58
  return geom
44
59
  end
45
60
 
61
+ def images_from_metadata(metadata)
62
+ IMAGE_METADATA_KEYS.flat_map do |key|
63
+ images = metadata.delete(key)
64
+ next unless images
65
+
66
+ Nokogiri::HTML.fragment(images).css("img").map do |img|
67
+ next unless (src = img["src"])
68
+ @base_dir.join(src.downcase)
69
+ end
70
+ end.compact
71
+ end
72
+
46
73
  def extract_metadata(placemark)
47
74
  metadata = {}
48
75
  metadata.merge! extract_table(placemark)
@@ -1,13 +1,17 @@
1
1
  module SpatialFeatures
2
2
  module Importers
3
3
  class KMLFile < KML
4
- def initialize(path_or_url, *args)
5
- super Download.read(path_or_url, unzip: '.kml'), *args
6
-
4
+ def initialize(path_or_url, **options)
5
+ path = Download.open_each(path_or_url, unzip: [/\.kml$/], downcase: true).first
6
+ super ::File.read(path), base_dir: Pathname.new(path).dirname, **options
7
7
  rescue SocketError, Errno::ECONNREFUSED, OpenURI::HTTPError
8
8
  url = URI(path_or_url)
9
9
  raise ImportError, "KML server is not responding. Ensure server is running and accessible at #{[url.scheme, "//#{url.host}", url.port].select(&:present?).join(':')}."
10
10
  end
11
+
12
+ def cache_key
13
+ @cache_key ||= Digest::MD5.hexdigest(@data)
14
+ end
11
15
  end
12
16
  end
13
17
  end
@@ -2,6 +2,9 @@ require 'fileutils'
2
2
 
3
3
  module SpatialFeatures
4
4
  module Unzip
5
+ # paths containing '__macosx' or beginning with a '.'
6
+ IGNORED_ENTRY_PATHS = /(\A|\/)(__macosx|\.)/i.freeze
7
+
5
8
  def self.paths(file_path, find: nil, **extract_options)
6
9
  paths = extract(file_path, **extract_options)
7
10
 
@@ -16,6 +19,7 @@ module SpatialFeatures
16
19
  def self.extract(file_path, output_dir = Dir.mktmpdir, downcase: false)
17
20
  [].tap do |paths|
18
21
  entries(file_path).each do |entry|
22
+ next if entry.name =~ IGNORED_ENTRY_PATHS
19
23
  output_filename = entry.name
20
24
  output_filename = output_filename.downcase if downcase
21
25
  path = "#{output_dir}/#{output_filename}"
@@ -59,7 +59,7 @@ module SpatialFeatures
59
59
 
60
60
  # Convert a hash of GeoJSON data into a PostGIS geometry object
61
61
  def geom_from_json(geometry)
62
- select_db_value("SELECT ST_GeomFromGeoJSON('#{geometry.to_json}')")
62
+ RGeo::GeoJSON.decode(geometry.to_json).as_text
63
63
  end
64
64
  end
65
65
  end
@@ -1,3 +1,3 @@
1
1
  module SpatialFeatures
2
- VERSION = "2.18.0"
2
+ VERSION = "3.0.0"
3
3
  end
@@ -1,6 +1,7 @@
1
1
  # Gems
2
2
  require 'delayed_job_active_record'
3
3
  require 'rgeo/shapefile'
4
+ require 'rgeo/geo_json'
4
5
  require 'nokogiri'
5
6
  require 'zip'
6
7
 
@@ -20,11 +21,11 @@ require 'spatial_features/has_spatial_features/feature_import'
20
21
 
21
22
  require 'spatial_features/importers/base'
22
23
  require 'spatial_features/importers/file'
23
- require 'spatial_features/importers/geojson'
24
+ require 'spatial_features/importers/geo_json'
25
+ require 'spatial_features/importers/esri_geo_json'
24
26
  require 'spatial_features/importers/kml'
25
27
  require 'spatial_features/importers/kml_file'
26
28
  require 'spatial_features/importers/kml_file_arcgis'
27
- require 'spatial_features/importers/json_arcgis'
28
29
  require 'spatial_features/importers/geomark'
29
30
  require 'spatial_features/importers/shapefile'
30
31
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spatial_features
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.18.0
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Wallace
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2021-11-22 00:00:00.000000000 Z
12
+ date: 2022-01-29 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rails
@@ -60,47 +60,47 @@ dependencies:
60
60
  - !ruby/object:Gem::Version
61
61
  version: '3.0'
62
62
  - !ruby/object:Gem::Dependency
63
- name: rubyzip
63
+ name: rgeo-geojson
64
64
  requirement: !ruby/object:Gem::Requirement
65
65
  requirements:
66
- - - ">="
66
+ - - "~>"
67
67
  - !ruby/object:Gem::Version
68
- version: 1.0.0
68
+ version: 2.1.1
69
69
  type: :runtime
70
70
  prerelease: false
71
71
  version_requirements: !ruby/object:Gem::Requirement
72
72
  requirements:
73
- - - ">="
73
+ - - "~>"
74
74
  - !ruby/object:Gem::Version
75
- version: 1.0.0
75
+ version: 2.1.1
76
76
  - !ruby/object:Gem::Dependency
77
- name: nokogiri
77
+ name: rubyzip
78
78
  requirement: !ruby/object:Gem::Requirement
79
79
  requirements:
80
- - - "~>"
80
+ - - ">="
81
81
  - !ruby/object:Gem::Version
82
- version: '1.6'
82
+ version: 1.0.0
83
83
  type: :runtime
84
84
  prerelease: false
85
85
  version_requirements: !ruby/object:Gem::Requirement
86
86
  requirements:
87
- - - "~>"
87
+ - - ">="
88
88
  - !ruby/object:Gem::Version
89
- version: '1.6'
89
+ version: 1.0.0
90
90
  - !ruby/object:Gem::Dependency
91
- name: chroma
91
+ name: nokogiri
92
92
  requirement: !ruby/object:Gem::Requirement
93
93
  requirements:
94
94
  - - "~>"
95
95
  - !ruby/object:Gem::Version
96
- version: 0.1.0
96
+ version: '1.6'
97
97
  type: :runtime
98
98
  prerelease: false
99
99
  version_requirements: !ruby/object:Gem::Requirement
100
100
  requirements:
101
101
  - - "~>"
102
102
  - !ruby/object:Gem::Version
103
- version: 0.1.0
103
+ version: '1.6'
104
104
  - !ruby/object:Gem::Dependency
105
105
  name: pg
106
106
  requirement: !ruby/object:Gem::Requirement
@@ -144,7 +144,6 @@ files:
144
144
  - app/models/feature.rb
145
145
  - app/models/spatial_cache.rb
146
146
  - app/models/spatial_proximity.rb
147
- - config/initializers/chroma_serializers.rb
148
147
  - config/initializers/mime_types.rb
149
148
  - config/initializers/register_oids.rb
150
149
  - lib/spatial_features.rb
@@ -156,10 +155,10 @@ files:
156
155
  - lib/spatial_features/has_spatial_features/feature_import.rb
157
156
  - lib/spatial_features/has_spatial_features/queued_spatial_processing.rb
158
157
  - lib/spatial_features/importers/base.rb
158
+ - lib/spatial_features/importers/esri_geo_json.rb
159
159
  - lib/spatial_features/importers/file.rb
160
- - lib/spatial_features/importers/geojson.rb
160
+ - lib/spatial_features/importers/geo_json.rb
161
161
  - lib/spatial_features/importers/geomark.rb
162
- - lib/spatial_features/importers/json_arcgis.rb
163
162
  - lib/spatial_features/importers/kml.rb
164
163
  - lib/spatial_features/importers/kml_file.rb
165
164
  - lib/spatial_features/importers/kml_file_arcgis.rb
@@ -190,7 +189,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
190
189
  - !ruby/object:Gem::Version
191
190
  version: '0'
192
191
  requirements: []
193
- rubygems_version: 3.0.3.1
192
+ rubyforge_project:
193
+ rubygems_version: 2.7.6.3
194
194
  signing_key:
195
195
  specification_version: 4
196
196
  summary: Adds spatial methods to a model.
@@ -1,15 +0,0 @@
1
- module Chroma
2
- class Color
3
- module Serializers
4
- # Google's Fusion Table colouring expects the alpha value in the last position, not the first
5
- def to_ft_hex
6
- [
7
- to_2char_hex(@rgb.r),
8
- to_2char_hex(@rgb.g),
9
- to_2char_hex(@rgb.b),
10
- to_2char_hex(alpha * 255)
11
- ].join('')
12
- end
13
- end
14
- end
15
- end
@@ -1,29 +0,0 @@
1
- require 'ostruct'
2
- require 'digest/md5'
3
-
4
- module SpatialFeatures
5
- module Importers
6
- class JsonArcGIS < Base
7
- def cache_key
8
- @cache_key ||= Digest::MD5.hexdigest(features.to_json)
9
- end
10
-
11
- private
12
-
13
- def each_record(&block)
14
- json = esri_json_to_geojson(@data)
15
- json['features'].each do |record|
16
- yield OpenStruct.new(
17
- :feature_type => record['geometry']['type'],
18
- :geog => SpatialFeatures::Utils.geom_from_json(record['geometry']),
19
- :metadata => record['properties']
20
- )
21
- end
22
- end
23
-
24
- def esri_json_to_geojson(url)
25
- JSON.parse(`ogr2ogr -f GeoJSON /dev/stdout "#{url}" OGRGeoJSON`)
26
- end
27
- end
28
- end
29
- end