spatial_features 2.20.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 639dee46fedd2cf507642b41119ff99e93506b54d891ab42d21a1f24fcf5a679
4
- data.tar.gz: 956175227c6a59acc867810a51796303218eac70c9bc49fe0d0f8222ea8558e6
3
+ metadata.gz: 6d3fe76f0ce82837de428e401ce4f6a20a84b97acb2085374a8bf8fe1daba5c8
4
+ data.tar.gz: fed058553913c07b27838424dc07b1b63677f28798fae7d5534b55de7c1aa713
5
5
  SHA512:
6
- metadata.gz: 6a1ac7a0dca15400e0e03f0f1c2192e22a4c1e818edde2a832f925e6ee803a9284c5236684228e25dbeabf2d147aacef4f4c05170216b0857941c87ed9b9a395
7
- data.tar.gz: da6fc8be7449202dfe46de18f0829e49664626ba17b90b6e3c1eae7a059f38a10d8310046e4f2bf44fb80bf78f2cdeea26624f429fc0d7b5873775b332086712
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,
@@ -118,7 +117,7 @@ def ImageImporter
118
117
  end
119
118
 
120
119
  class Location < ActiveRecord::Base
121
- 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' },
122
121
  :image_handlers => ['ImageImporter']
123
122
 
124
123
  def remote_kml_url
@@ -163,3 +162,22 @@ Feature.reset_column_information
163
162
  AbstractFeature.update_all(:type => 'Feature')
164
163
  Feature.refresh_aggregates
165
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
 
@@ -17,16 +17,6 @@ class Feature < AbstractFeature
17
17
 
18
18
  attr_accessor :importable_image_paths # :nodoc:
19
19
 
20
- # Features are used for display so we also cache their KML representation
21
- def self.cache_derivatives(options = {})
22
- super
23
- update_all <<-SQL.squish
24
- kml = ST_AsKML(geog, 6),
25
- kml_lowres = ST_AsKML(geom_lowres, #{options.fetch(:lowres_precision, lowres_precision)}),
26
- kml_centroid = ST_AsKML(centroid)
27
- SQL
28
- end
29
-
30
20
  def self.defer_aggregate_refresh(&block)
31
21
  start_at = Feature.maximum(:id).to_i + 1
32
22
  output = without_aggregate_refresh(&block)
@@ -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
@@ -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)
@@ -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
@@ -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)
@@ -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
@@ -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.20.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.20.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-12-27 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
@@ -59,6 +59,20 @@ dependencies:
59
59
  - - "~>"
60
60
  - !ruby/object:Gem::Version
61
61
  version: '3.0'
62
+ - !ruby/object:Gem::Dependency
63
+ name: rgeo-geojson
64
+ requirement: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 2.1.1
69
+ type: :runtime
70
+ prerelease: false
71
+ version_requirements: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 2.1.1
62
76
  - !ruby/object:Gem::Dependency
63
77
  name: rubyzip
64
78
  requirement: !ruby/object:Gem::Requirement
@@ -141,10 +155,10 @@ files:
141
155
  - lib/spatial_features/has_spatial_features/feature_import.rb
142
156
  - lib/spatial_features/has_spatial_features/queued_spatial_processing.rb
143
157
  - lib/spatial_features/importers/base.rb
158
+ - lib/spatial_features/importers/esri_geo_json.rb
144
159
  - lib/spatial_features/importers/file.rb
145
- - lib/spatial_features/importers/geojson.rb
160
+ - lib/spatial_features/importers/geo_json.rb
146
161
  - lib/spatial_features/importers/geomark.rb
147
- - lib/spatial_features/importers/json_arcgis.rb
148
162
  - lib/spatial_features/importers/kml.rb
149
163
  - lib/spatial_features/importers/kml_file.rb
150
164
  - lib/spatial_features/importers/kml_file_arcgis.rb
@@ -175,7 +189,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
175
189
  - !ruby/object:Gem::Version
176
190
  version: '0'
177
191
  requirements: []
178
- rubygems_version: 3.0.3
192
+ rubyforge_project:
193
+ rubygems_version: 2.7.6.3
179
194
  signing_key:
180
195
  specification_version: 4
181
196
  summary: Adds spatial methods to a model.
@@ -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