spatial_features 3.2.0 → 3.4.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: fabdd3f0f73eb21327f8e3deb1b7dda5969d97bdb7c053c60199a63fe5cd04b1
4
- data.tar.gz: 777c5dc188ba968ab4cba25e58fa2f361c9d3814781ecfb9e751cf0fc1b40b0e
3
+ metadata.gz: 60a558353e17d6facde9b9dccccb43022f3c46af98401bf6ad4ceccee6a62b65
4
+ data.tar.gz: f2bfcad2b407e9b9329d79650fa3098260524b083b7031caeaa323012fb2fa60
5
5
  SHA512:
6
- metadata.gz: e3f28e99e814f1fcc2ba49edf8a59ff957b9f92c9e0d990f93409c61f00d4c496e7ac5f60eb3616660df720c615033f62dbe02d707c1584cc4cee7d9cb88067c
7
- data.tar.gz: 18e776f2d1bc792e7a0313d5742345e93adca24f060472bb10a36611eb3e62cf7fe8fcf2d7d520db16cd9b1755e85b4e40e75cabcb4110e25c397e42087fd9e0
6
+ metadata.gz: 065fa148ffcdd4e221f9aa117117302aa51d805e3b5d0b984d76bae81cd163681a428dec37adf9c51e993065ac93c88c75487545ac353b309ff321b3934a3b62
7
+ data.tar.gz: 0ffb13eae72eacdc0a96ec06da6e1e4ee1ffd304f36f80f72fdadebb54269ed8e208b66279c3325217729b36d14727fef03246a989ab5172625fdd3f5694e7fb
data/README.md CHANGED
@@ -31,6 +31,7 @@ Adds spatial methods to a model.
31
31
  feature_type character varying(255),
32
32
  geog geography,
33
33
  geom geometry(Geometry,4326),
34
+ geom_lowres geometry(Geometry,4326),
34
35
  tilegeom geometry(Geometry,3857),
35
36
  metadata hstore,
36
37
  area double precision,
@@ -49,6 +50,7 @@ Adds spatial methods to a model.
49
50
  CREATE INDEX index_features_on_feature_type ON features USING btree (feature_type);
50
51
  CREATE INDEX index_features_on_spatial_model_id_and_spatial_model_type ON features USING btree (spatial_model_id, spatial_model_type);
51
52
  CREATE INDEX index_features_on_geom ON features USING gist (geom);
53
+ CREATE INDEX index_features_on_geom_lowres ON features USING gist (geom_lowres);
52
54
  CREATE INDEX index_features_on_tilegeom ON features USING gist (tilegeom);
53
55
 
54
56
  CREATE TABLE spatial_caches (
@@ -188,6 +190,16 @@ queries can be optimized. Migrate existing SpatialProximity rows to this new sch
188
190
  SpatialProximity.normalize
189
191
  ```
190
192
 
193
+ ## Upgrading From 3.2/3.3 to 3.4
194
+ Features now record the source they were imported from in the new `source_identifier` column. This column is indexed and
195
+ can be used filter features by source.
196
+
197
+ ```ruby
198
+ add_column :features, :source_identifier, :string
199
+ add_index :features, :source_identifier
200
+ MyModel.update_features!(:force => true) # Force an `update_features!` will populate the source_identifier column.
201
+ ```
202
+
191
203
  ## Testing
192
204
 
193
205
  Create a postgres database:
@@ -49,8 +49,14 @@ class AbstractFeature < ActiveRecord::Base
49
49
  where(:feature_type => 'point')
50
50
  end
51
51
 
52
- def self.within_distance_of_point(lat, lng, distance_in_meters)
53
- where("ST_DWithin(features.geog, ST_Point(:lng, :lat), :distance)", :lat => lat, :lng => lng, :distance => distance_in_meters)
52
+ def self.within_distance_of_point(lat, lng, distance_in_meters, geom = 'geom_lowres')
53
+ point_sql =
54
+ case geom.to_s
55
+ when 'geog' then 'ST_Point(:lng, :lat)'
56
+ else "ST_Transform(ST_SetSRID(ST_Point(:lng, :lat), 4326), #{detect_srid(geom)})"
57
+ end
58
+
59
+ where("ST_DWithin(features.#{geom}, #{point_sql}, :distance)", :lat => lat, :lng => lng, :distance => distance_in_meters)
54
60
  end
55
61
 
56
62
  def self.area_in_square_meters(geom = 'geom_lowres')
@@ -248,7 +254,7 @@ class AbstractFeature < ActiveRecord::Base
248
254
  self.geog = SpatialFeatures::Utils.select_db_value("SELECT ST_Force2D('#{geog}')")
249
255
  end
250
256
 
251
- SRID_CACHE = {}
257
+ SRID_CACHE = HashWithIndifferentAccess.new
252
258
  def self.detect_srid(column_name)
253
259
  SRID_CACHE[column_name] ||= SpatialFeatures::Utils.select_db_value("SELECT Find_SRID('public', '#{table_name}', '#{column_name}')")
254
260
  end
@@ -13,14 +13,14 @@ module SpatialFeatures
13
13
  end
14
14
 
15
15
  module ClassMethods
16
- def update_features!(skip_invalid: false, **options)
16
+ def update_features!(**options)
17
17
  find_each do |record|
18
- record.update_features!(skip_invalid: skip_invalid, **options)
18
+ record.update_features!(**options)
19
19
  end
20
20
  end
21
21
  end
22
22
 
23
- def update_features!(skip_invalid: false, **options)
23
+ def update_features!(skip_invalid: false, allow_blank: false, force: false, **options)
24
24
  options = options.reverse_merge(spatial_features_options)
25
25
  tmpdir = options.fetch(:tmpdir) { Dir.mktmpdir("ruby_spatial_features") }
26
26
 
@@ -28,10 +28,10 @@ module SpatialFeatures
28
28
  imports = spatial_feature_imports(options[:import], options[:make_valid], options[:tmpdir])
29
29
  cache_key = Digest::MD5.hexdigest(imports.collect(&:cache_key).join)
30
30
 
31
- return if features_cache_key_matches?(cache_key)
31
+ return if !force && features_cache_key_matches?(cache_key)
32
32
 
33
33
  run_callbacks :update_features do
34
- import_features(imports, skip_invalid)
34
+ features = import_features(imports, skip_invalid)
35
35
  update_features_cache_key(cache_key)
36
36
  update_features_area
37
37
 
@@ -40,11 +40,17 @@ module SpatialFeatures
40
40
  else
41
41
  update_spatial_cache(options.slice(:spatial_cache))
42
42
  end
43
+
44
+ if imports.present? && features.compact_blank.empty? && !allow_blank
45
+ raise EmptyImportError, "No spatial features were found when updating"
46
+ end
43
47
  end
44
48
  end
45
49
 
46
50
  return true
47
51
  rescue StandardError => e
52
+ raise e if e.is_a?(EmptyImportError)
53
+
48
54
  if skip_invalid
49
55
  Rails.logger.warn "Error updating #{self.class} #{self.id}. #{e.message}"
50
56
  return nil
@@ -137,7 +143,7 @@ module SpatialFeatures
137
143
  raise ImportError, "Error updating #{self.class} #{self.id}. #{errors.to_sentence}"
138
144
  end
139
145
 
140
- return features
146
+ valid
141
147
  end
142
148
 
143
149
  def features_cache_key_matches?(cache_key)
@@ -235,14 +235,10 @@ module SpatialFeatures
235
235
  end
236
236
 
237
237
  def features_area_in_square_meters
238
- @features_area_in_square_meters ||=
239
- if has_attribute?(:area)
240
- area
241
- elsif association(:aggregate_feature).loaded?
242
- aggregate_feature&.area
243
- else
244
- aggregate_features.pluck(:area).first
245
- end
238
+ @features_area_in_square_meters ||= area if has_attribute?(:area) # Calculated area column
239
+ @features_area_in_square_meters ||= features_area if has_attribute?(:features_area) # Cached area column
240
+ @features_area_in_square_meters ||= aggregate_feature&.area if association(:aggregate_feature).loaded?
241
+ @features_area_in_square_meters ||= aggregate_features.pluck(:area).first
246
242
  end
247
243
 
248
244
  def total_intersection_area_in_square_meters(other)
@@ -4,12 +4,14 @@ module SpatialFeatures
4
4
  module Importers
5
5
  class Base
6
6
  attr_reader :errors
7
+ attr_accessor :source_identifier # An identifier for the source of the features. Used to differentiate groups of features on the spatial model.
7
8
 
8
- def initialize(data, make_valid: false, tmpdir: nil)
9
+ def initialize(data, make_valid: false, tmpdir: nil, source_identifier: nil)
9
10
  @make_valid = make_valid
10
11
  @data = data
11
12
  @errors = []
12
13
  @tmpdir = tmpdir
14
+ @source_identifier = source_identifier
13
15
  end
14
16
 
15
17
  def features
@@ -48,7 +50,15 @@ module SpatialFeatures
48
50
 
49
51
  def build_feature(record)
50
52
  importable_image_paths = record.importable_image_paths if record.respond_to?(:importable_image_paths)
51
- 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)
53
+ Feature.new do |feature|
54
+ feature.name = record.name
55
+ feature.metadata = record.metadata
56
+ feature.feature_type = record.feature_type
57
+ feature.geog = record.geog
58
+ feature.importable_image_paths = importable_image_paths
59
+ feature.make_valid = @make_valid
60
+ feature.source_identifier = source_identifier
61
+ end
52
62
  end
53
63
  end
54
64
  end
@@ -56,4 +66,5 @@ module SpatialFeatures
56
66
  # EXCEPTIONS
57
67
 
58
68
  class ImportError < StandardError; end
69
+ class EmptyImportError < ImportError; end
59
70
  end
@@ -23,22 +23,39 @@ module SpatialFeatures
23
23
  # If no `current_file` is passed then we just take the first valid file that we find.
24
24
  def initialize(data, current_file: nil, **options)
25
25
  begin
26
- current_file ||= Download.open_each(data, unzip: FILE_PATTERNS, downcase: true, tmpdir: options[:tmpdir]).first
26
+ @current_file = current_file || Download.open_each(data, unzip: FILE_PATTERNS, downcase: true, tmpdir: options[:tmpdir]).first
27
27
  rescue Unzip::PathNotFound
28
28
  raise ImportError, INVALID_ARCHIVE
29
29
  end
30
30
 
31
- case ::File.extname(current_file.path.downcase)
31
+ case ::File.extname(data).downcase
32
+ when '.kmz' # KMZ always has a single kml in it, so no need to show mention it
33
+ options[:source_identifier] = ::File.basename(data)
34
+ else
35
+ options[:source_identifier] = [::File.basename(data), ::File.basename(@current_file.path)].uniq.join('/')
36
+ end
37
+
38
+ case ::File.extname(filename)
32
39
  when '.kml'
33
- __setobj__(KMLFile.new(current_file, **options))
40
+ __setobj__(KMLFile.new(@current_file, **options))
34
41
  when '.shp'
35
- __setobj__(Shapefile.new(current_file, **options))
42
+ __setobj__(Shapefile.new(@current_file, **options))
36
43
  when '.json', '.geojson'
37
- __setobj__(ESRIGeoJSON.new(current_file.path, **options))
44
+ __setobj__(ESRIGeoJSON.new(@current_file.path, **options))
38
45
  else
39
- raise ImportError, "Could not import file. " + SUPPORTED_FORMATS
46
+ import_error
40
47
  end
41
48
  end
49
+
50
+ private
51
+
52
+ def import_error!
53
+ raise ImportError, "Could not import #{filename}. " + SUPPORTED_FORMATS
54
+ end
55
+
56
+ def filename
57
+ @filename ||= @current_file.path.downcase
58
+ end
42
59
  end
43
60
  end
44
61
  end
@@ -6,6 +6,12 @@ module SpatialFeatures
6
6
  # <SimpleData name> keys that may contain <img> tags
7
7
  IMAGE_METADATA_KEYS = %w[pdfmaps_photos].freeze
8
8
 
9
+ # matches a coordinate pair with an optional altitude, including invalid altitudes like NaN
10
+ # -118.1,50.9,NaN
11
+ # -118.1,50.9,0
12
+ # -118.1,50.9
13
+ COORDINATES_WITH_ALTITUDE = /((-?\d+\.\d+,-?\d+\.\d+)(,-?[a-zA-Z\d\.]+))/.freeze
14
+
9
15
  def initialize(data, base_dir: nil, **options)
10
16
  @base_dir = base_dir
11
17
  super data, **options
@@ -52,6 +58,8 @@ module SpatialFeatures
52
58
  geom = nil
53
59
  conn = nil
54
60
 
61
+ strip_altitude(kml)
62
+
55
63
  # 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)
56
64
  #
57
65
  # We manually checkout a new connection since Rails re-uses DB connections across threads.
@@ -106,6 +114,13 @@ module SpatialFeatures
106
114
  end
107
115
  return metadata
108
116
  end
117
+
118
+ def strip_altitude(kml)
119
+ kml.css('coordinates').each do |coordinates|
120
+ next unless COORDINATES_WITH_ALTITUDE.match?(coordinates.content)
121
+ coordinates.content = coordinates.content.gsub(COORDINATES_WITH_ALTITUDE, '\2')
122
+ end
123
+ end
109
124
  end
110
125
  end
111
126
  end
@@ -1,3 +1,3 @@
1
1
  module SpatialFeatures
2
- VERSION = "3.2.0"
2
+ VERSION = "3.4.0"
3
3
  end
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: 3.2.0
4
+ version: 3.4.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: 2023-08-12 00:00:00.000000000 Z
12
+ date: 2023-10-24 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rails