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 +4 -4
- data/README.md +33 -6
- data/app/models/abstract_feature.rb +79 -17
- data/app/models/aggregate_feature.rb +5 -2
- data/app/models/feature.rb +5 -11
- data/config/initializers/mime_types.rb +3 -0
- data/lib/spatial_features/controller_helpers/spatial_extensions.rb +20 -10
- data/lib/spatial_features/has_spatial_features/feature_import.rb +26 -2
- data/lib/spatial_features/has_spatial_features/queued_spatial_processing.rb +3 -3
- data/lib/spatial_features/has_spatial_features.rb +40 -20
- data/lib/spatial_features/importers/base.rb +2 -1
- data/lib/spatial_features/importers/esri_geo_json.rb +27 -0
- data/lib/spatial_features/importers/file.rb +9 -5
- data/lib/spatial_features/importers/{geojson.rb → geo_json.rb} +10 -4
- data/lib/spatial_features/importers/kml.rb +30 -3
- data/lib/spatial_features/importers/kml_file.rb +7 -3
- data/lib/spatial_features/unzip.rb +4 -0
- data/lib/spatial_features/utils.rb +1 -1
- data/lib/spatial_features/version.rb +1 -1
- data/lib/spatial_features.rb +3 -2
- metadata +19 -19
- data/config/initializers/chroma_serializers.rb +0 -15
- data/lib/spatial_features/importers/json_arcgis.rb +0 -29
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 6d3fe76f0ce82837de428e401ce4f6a20a84b97acb2085374a8bf8fe1daba5c8
|
4
|
+
data.tar.gz: fed058553913c07b27838424dc07b1b63677f28798fae7d5534b55de7c1aa713
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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,
|
34
|
-
geom_lowres geometry(Geometry,
|
35
|
-
|
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 => '
|
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.
|
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 =
|
138
|
-
|
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',
|
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
|
157
|
-
|
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
|
-
|
166
|
-
|
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
|
-
|
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
|
data/app/models/feature.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
56
|
-
|
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?
|
@@ -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
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
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
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
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)
|
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(
|
45
|
-
Delayed::Job.where(
|
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
|
-
|
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
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
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
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
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
|
-
|
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
|
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:
|
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:
|
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.
|
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(
|
8
|
+
@cache_key ||= Digest::MD5.hexdigest(geojson)
|
9
9
|
end
|
10
10
|
|
11
11
|
private
|
12
12
|
|
13
13
|
def each_record(&block)
|
14
|
-
|
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
|
-
|
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
|
-
|
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,
|
5
|
-
|
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
|
-
|
62
|
+
RGeo::GeoJSON.decode(geometry.to_json).as_text
|
63
63
|
end
|
64
64
|
end
|
65
65
|
end
|
data/lib/spatial_features.rb
CHANGED
@@ -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/
|
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:
|
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:
|
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:
|
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.
|
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.
|
75
|
+
version: 2.1.1
|
76
76
|
- !ruby/object:Gem::Dependency
|
77
|
-
name:
|
77
|
+
name: rubyzip
|
78
78
|
requirement: !ruby/object:Gem::Requirement
|
79
79
|
requirements:
|
80
|
-
- - "
|
80
|
+
- - ">="
|
81
81
|
- !ruby/object:Gem::Version
|
82
|
-
version:
|
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:
|
89
|
+
version: 1.0.0
|
90
90
|
- !ruby/object:Gem::Dependency
|
91
|
-
name:
|
91
|
+
name: nokogiri
|
92
92
|
requirement: !ruby/object:Gem::Requirement
|
93
93
|
requirements:
|
94
94
|
- - "~>"
|
95
95
|
- !ruby/object:Gem::Version
|
96
|
-
version:
|
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:
|
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/
|
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
|
-
|
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
|