spatial_features 2.7.8 → 2.8.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 +1 -0
- data/app/models/abstract_feature.rb +167 -0
- data/app/models/aggregate_feature.rb +17 -0
- data/app/models/feature.rb +36 -144
- data/lib/spatial_features/has_spatial_features/feature_import.rb +5 -3
- data/lib/spatial_features/has_spatial_features.rb +4 -19
- data/lib/spatial_features/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b3acf24e6695a55827725efd2ca6290b96a4ba54d230e8aa176a7bbd356fe6fe
|
4
|
+
data.tar.gz: 48e9527307f5daabd632acf5e76f9d544bcfc3beaeadacb349c37184986321fa
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 668a6afb0582a4470d1cab7b2e582392ae8fe392514cc0ac363488458052dd6f90dff13c5004aa5423b25f843bc7b79eec0e1e3e056802d80963b40bd4c6f7e9
|
7
|
+
data.tar.gz: 7c17ddc09d78bd90ef1d75a5f0cb67ee0ebb47fe286c6a272f3812f5b733e2e29dfce265e2ea8b91512d6dabcb1d2ccad089acf5b3e15c0a7368c41eda2a11e3
|
data/README.md
CHANGED
@@ -0,0 +1,167 @@
|
|
1
|
+
class AbstractFeature < ActiveRecord::Base
|
2
|
+
self.table_name = 'features'
|
3
|
+
|
4
|
+
class_attribute :lowres_simplification
|
5
|
+
self.lowres_simplification = 2 # Threshold in meters
|
6
|
+
|
7
|
+
belongs_to :spatial_model, :polymorphic => :true, :autosave => false
|
8
|
+
|
9
|
+
attr_writer :make_valid
|
10
|
+
|
11
|
+
FEATURE_TYPES = %w(polygon point line)
|
12
|
+
|
13
|
+
before_validation :sanitize_feature_type
|
14
|
+
validates_presence_of :geog
|
15
|
+
validate :validate_geometry
|
16
|
+
before_save :sanitize
|
17
|
+
after_save :cache_derivatives
|
18
|
+
|
19
|
+
def self.cache_key
|
20
|
+
"#{maximum(:id)}-#{count}"
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.with_metadata(k, v)
|
24
|
+
if k.present? && v.present?
|
25
|
+
where('metadata->? = ?', k, v)
|
26
|
+
else
|
27
|
+
all
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.polygons
|
32
|
+
where(:feature_type => 'polygon')
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.lines
|
36
|
+
where(:feature_type => 'line')
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.points
|
40
|
+
where(:feature_type => 'point')
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.area_in_square_meters(geom = 'geom_lowres')
|
44
|
+
current_scope = all.polygons
|
45
|
+
unscoped { connection.select_value(select("ST_Area(ST_Union(#{geom}))").from(current_scope, :features)).to_f }
|
46
|
+
end
|
47
|
+
|
48
|
+
def self.total_intersection_area_in_square_meters(other_features, geom = 'geom_lowres')
|
49
|
+
scope = unscope(:select).select("ST_Union(#{geom}) AS geom").polygons
|
50
|
+
other_scope = other_features.polygons
|
51
|
+
|
52
|
+
query = base_class.unscoped.select('ST_Area(ST_Intersection(ST_Union(features.geom), ST_Union(other_features.geom)))')
|
53
|
+
.from(scope, "features")
|
54
|
+
.joins("INNER JOIN (#{other_scope.to_sql}) AS other_features ON ST_Intersects(features.geom, other_features.geom)")
|
55
|
+
return connection.select_value(query).to_f
|
56
|
+
end
|
57
|
+
|
58
|
+
def self.intersecting(other)
|
59
|
+
join_other_features(other).where('ST_Intersects(features.geom_lowres, other_features.geom_lowres)').uniq
|
60
|
+
end
|
61
|
+
|
62
|
+
def self.invalid(column = 'geog::geometry')
|
63
|
+
select("features.*, ST_IsValidReason(#{column}) AS invalid_geometry_message").where.not("ST_IsValid(#{column})")
|
64
|
+
end
|
65
|
+
|
66
|
+
def self.valid
|
67
|
+
where('ST_IsValid(geog::geometry)')
|
68
|
+
end
|
69
|
+
|
70
|
+
def envelope(buffer_in_meters = 0)
|
71
|
+
envelope_json = JSON.parse(self.class.select("ST_AsGeoJSON(ST_Envelope(ST_Buffer(features.geog, #{buffer_in_meters})::geometry)) AS result").where(:id => id).first.result)
|
72
|
+
envelope_json = envelope_json["coordinates"].first
|
73
|
+
|
74
|
+
raise "Can't calculate envelope for Feature #{self.id}" if envelope_json.blank?
|
75
|
+
|
76
|
+
return envelope_json.values_at(0,2)
|
77
|
+
end
|
78
|
+
|
79
|
+
def self.cache_derivatives(options = {})
|
80
|
+
update_all <<-SQL.squish
|
81
|
+
geom = ST_Transform(geog::geometry, #{detect_srid('geom')}),
|
82
|
+
north = ST_YMax(geog::geometry),
|
83
|
+
east = ST_XMax(geog::geometry),
|
84
|
+
south = ST_YMin(geog::geometry),
|
85
|
+
west = ST_XMin(geog::geometry),
|
86
|
+
area = ST_Area(geog),
|
87
|
+
centroid = ST_PointOnSurface(geog::geometry)
|
88
|
+
SQL
|
89
|
+
|
90
|
+
invalid('geom').update_all <<-SQL.squish
|
91
|
+
geom = ST_Buffer(geom, 0)
|
92
|
+
SQL
|
93
|
+
|
94
|
+
update_all <<-SQL.squish
|
95
|
+
geom_lowres = ST_SimplifyPreserveTopology(geom, #{options.fetch(:lowres_simplification, lowres_simplification)})
|
96
|
+
SQL
|
97
|
+
|
98
|
+
invalid('geom_lowres').update_all <<-SQL.squish
|
99
|
+
geom_lowres = ST_Buffer(geom_lowres, 0)
|
100
|
+
SQL
|
101
|
+
end
|
102
|
+
|
103
|
+
def feature_bounds
|
104
|
+
{n: north, e: east, s: south, w: west}
|
105
|
+
end
|
106
|
+
|
107
|
+
def cache_derivatives(*args)
|
108
|
+
self.class.where(:id => self.id).cache_derivatives(*args)
|
109
|
+
end
|
110
|
+
|
111
|
+
def kml(options = {})
|
112
|
+
geometry = options[:lowres] ? kml_lowres : super()
|
113
|
+
geometry = "<MultiGeometry>#{geometry}#{kml_centroid}</MultiGeometry>" if options[:centroid]
|
114
|
+
return geometry
|
115
|
+
end
|
116
|
+
|
117
|
+
def make_valid?
|
118
|
+
@make_valid
|
119
|
+
end
|
120
|
+
|
121
|
+
private
|
122
|
+
|
123
|
+
def make_valid
|
124
|
+
self.geog = ActiveRecord::Base.connection.select_value("SELECT ST_Buffer('#{sanitize}', 0)")
|
125
|
+
end
|
126
|
+
|
127
|
+
# Use ST_Force2D to discard z-coordinates that cause failures later in the process
|
128
|
+
def sanitize
|
129
|
+
self.geog = ActiveRecord::Base.connection.select_value("SELECT ST_Force2D('#{geog}')")
|
130
|
+
end
|
131
|
+
|
132
|
+
SRID_CACHE = {}
|
133
|
+
def self.detect_srid(column_name)
|
134
|
+
SRID_CACHE[column_name] ||= connection.select_value("SELECT Find_SRID('public', '#{table_name}', '#{column_name}')")
|
135
|
+
end
|
136
|
+
|
137
|
+
def self.join_other_features(other)
|
138
|
+
joins('INNER JOIN features AS other_features ON true').where(:other_features => {:id => other})
|
139
|
+
end
|
140
|
+
|
141
|
+
def validate_geometry
|
142
|
+
return unless geog?
|
143
|
+
|
144
|
+
error = geometry_validation_message
|
145
|
+
if error && make_valid?
|
146
|
+
make_valid
|
147
|
+
self.make_valid = false
|
148
|
+
validate_geometry
|
149
|
+
elsif error
|
150
|
+
errors.add :geog, error
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
def geometry_validation_message
|
155
|
+
klass = self.class.base_class # Use the base class because we don't want to have to include a type column in our select
|
156
|
+
error = klass.connection.select_one(klass.unscoped.invalid.from("(SELECT '#{sanitize_input_for_sql(self.geog)}'::geometry AS geog) #{klass.table_name}"))
|
157
|
+
return error.fetch('invalid_geometry_message') if error
|
158
|
+
end
|
159
|
+
|
160
|
+
def sanitize_feature_type
|
161
|
+
self.feature_type = FEATURE_TYPES.find {|type| self.feature_type.to_s.strip.downcase.include?(type) }
|
162
|
+
end
|
163
|
+
|
164
|
+
def sanitize_input_for_sql(input)
|
165
|
+
self.class.send(:sanitize_sql_for_conditions, input)
|
166
|
+
end
|
167
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
require_dependency SpatialFeatures::Engine.root.join('app/models/abstract_feature')
|
2
|
+
|
3
|
+
class AggregateFeature < AbstractFeature
|
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
|
+
|
6
|
+
# Aggregate the features for the spatial model into a single feature
|
7
|
+
def refresh
|
8
|
+
self.geog = ActiveRecord::Base.connection.select_value <<~SQL
|
9
|
+
SELECT ST_Collect(ARRAY[
|
10
|
+
(#{features.select('ST_UNION(ST_CollectionExtract(geog::geometry, 1))').to_sql}),
|
11
|
+
(#{features.select('ST_UNION(ST_CollectionExtract(geog::geometry, 2))').to_sql}),
|
12
|
+
(#{features.select('ST_UNION(ST_CollectionExtract(geog::geometry, 3))').to_sql})
|
13
|
+
])::geography
|
14
|
+
SQL
|
15
|
+
self.save!
|
16
|
+
end
|
17
|
+
end
|
data/app/models/feature.rb
CHANGED
@@ -1,170 +1,62 @@
|
|
1
|
-
class Feature <
|
2
|
-
|
1
|
+
class Feature < AbstractFeature
|
2
|
+
class_attribute :automatically_refresh_aggregate
|
3
|
+
self.automatically_refresh_aggregate = true
|
3
4
|
|
4
|
-
|
5
|
+
class_attribute :lowres_precision
|
6
|
+
self.lowres_precision = 5
|
5
7
|
|
6
|
-
|
8
|
+
has_one :aggregate_feature, lambda { |feature| where(:spatial_model_type => feature.spatial_model_type) }, :foreign_key => :spatial_model_id, :primary_key => :spatial_model_id
|
7
9
|
|
8
|
-
before_validation :sanitize_feature_type
|
9
|
-
validates_presence_of :geog
|
10
|
-
validate :validate_geometry
|
11
10
|
validates_inclusion_of :feature_type, :in => FEATURE_TYPES
|
12
|
-
before_save :sanitize
|
13
|
-
after_save :cache_derivatives
|
14
11
|
|
15
|
-
|
16
|
-
"#{maximum(:id)}-#{count}"
|
17
|
-
end
|
18
|
-
|
19
|
-
def self.with_metadata(k, v)
|
20
|
-
if k.present? && v.present?
|
21
|
-
where('metadata->? = ?', k, v)
|
22
|
-
else
|
23
|
-
all
|
24
|
-
end
|
25
|
-
end
|
12
|
+
after_save :refresh_aggregate, if: :automatically_refresh_aggregate?
|
26
13
|
|
27
|
-
def self.
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
def self.lines
|
32
|
-
where(:feature_type => 'line')
|
33
|
-
end
|
14
|
+
def self.defer_aggregate_refresh(&block)
|
15
|
+
start_at = Feature.maximum(:id).to_i + 1
|
16
|
+
output = without_aggregate_refresh(&block)
|
34
17
|
|
35
|
-
|
36
|
-
where(:feature_type => 'point')
|
37
|
-
end
|
18
|
+
where(:id => start_at..Float::INFINITY).refresh_aggregates
|
38
19
|
|
39
|
-
|
40
|
-
current_scope = all.polygons
|
41
|
-
unscoped { connection.select_value(select("ST_Area(ST_Union(#{geom}))").from(current_scope, :features)).to_f }
|
20
|
+
return output
|
42
21
|
end
|
43
22
|
|
44
|
-
def self.
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
.joins("INNER JOIN (#{other_scope.to_sql}) AS other_features ON ST_Intersects(features.geom, other_features.geom)")
|
51
|
-
return connection.select_value(query).to_f
|
23
|
+
def self.without_aggregate_refresh
|
24
|
+
old = Feature.automatically_refresh_aggregate
|
25
|
+
Feature.automatically_refresh_aggregate = false
|
26
|
+
yield
|
27
|
+
ensure
|
28
|
+
Feature.automatically_refresh_aggregate = old
|
52
29
|
end
|
53
30
|
|
54
|
-
def self.
|
55
|
-
|
56
|
-
|
31
|
+
def self.refresh_aggregates
|
32
|
+
# Find one feature from each spatial model and trigger the aggregate feature refresh
|
33
|
+
ids = select('MAX(id)')
|
34
|
+
.where.not(:spatial_model_type => nil, :spatial_model_id => nil)
|
35
|
+
.group('spatial_model_type, spatial_model_id')
|
57
36
|
|
58
|
-
|
59
|
-
select("features.*, ST_IsValidReason(#{column}) AS invalid_geometry_message").where.not("ST_IsValid(#{column})")
|
37
|
+
where(:id => ids).find_each(&:refresh_aggregate)
|
60
38
|
end
|
61
39
|
|
62
|
-
def
|
63
|
-
|
40
|
+
def refresh_aggregate
|
41
|
+
# puts "Refreshing AggregateFeature for #{spatial_model_type} #{spatial_model_id}"
|
42
|
+
build_aggregate_feature unless aggregate_feature&.persisted?
|
43
|
+
aggregate_feature.refresh
|
64
44
|
end
|
65
45
|
|
66
|
-
def
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
return envelope_json.values_at(0,2)
|
46
|
+
def automatically_refresh_aggregate?
|
47
|
+
# Check if there is a spatial model id because nothing prevents is from creating a Feature without one. Depending on
|
48
|
+
# how you assign a feature to a record, you may end up saving it before assigning it to a record, thereby leaving
|
49
|
+
# this field blank.
|
50
|
+
spatial_model_id? && automatically_refresh_aggregate
|
73
51
|
end
|
74
52
|
|
53
|
+
# Features are used for display so we also cache their KML representation
|
75
54
|
def self.cache_derivatives(options = {})
|
76
|
-
|
77
|
-
|
78
|
-
update_all <<-SQL.squish
|
79
|
-
geom = ST_Transform(geog::geometry, #{detect_srid('geom')}),
|
80
|
-
north = ST_YMax(geog::geometry),
|
81
|
-
east = ST_XMax(geog::geometry),
|
82
|
-
south = ST_YMin(geog::geometry),
|
83
|
-
west = ST_XMin(geog::geometry),
|
84
|
-
area = ST_Area(geog),
|
85
|
-
centroid = ST_PointOnSurface(geog::geometry)
|
86
|
-
SQL
|
87
|
-
|
88
|
-
invalid('geom').update_all <<-SQL.squish
|
89
|
-
geom = ST_Buffer(geom, 0)
|
90
|
-
SQL
|
91
|
-
|
92
|
-
update_all <<-SQL.squish
|
93
|
-
geom_lowres = ST_SimplifyPreserveTopology(geom, #{options[:lowres_simplification]})
|
94
|
-
SQL
|
95
|
-
|
96
|
-
invalid('geom_lowres').update_all <<-SQL.squish
|
97
|
-
geom_lowres = ST_Buffer(geom_lowres, 0)
|
98
|
-
SQL
|
99
|
-
|
55
|
+
super
|
100
56
|
update_all <<-SQL.squish
|
101
57
|
kml = ST_AsKML(geog, 6),
|
102
|
-
kml_lowres = ST_AsKML(geom_lowres, #{options
|
58
|
+
kml_lowres = ST_AsKML(geom_lowres, #{options.fetch(:lowres_precision, lowres_precision)}),
|
103
59
|
kml_centroid = ST_AsKML(centroid)
|
104
60
|
SQL
|
105
61
|
end
|
106
|
-
|
107
|
-
def feature_bounds
|
108
|
-
{n: north, e: east, s: south, w: west}
|
109
|
-
end
|
110
|
-
|
111
|
-
def cache_derivatives(*args)
|
112
|
-
self.class.where(:id => self.id).cache_derivatives(*args)
|
113
|
-
end
|
114
|
-
|
115
|
-
def kml(options = {})
|
116
|
-
geometry = options[:lowres] ? kml_lowres : super()
|
117
|
-
geometry = "<MultiGeometry>#{geometry}#{kml_centroid}</MultiGeometry>" if options[:centroid]
|
118
|
-
return geometry
|
119
|
-
end
|
120
|
-
|
121
|
-
def make_valid?
|
122
|
-
@make_valid
|
123
|
-
end
|
124
|
-
|
125
|
-
private
|
126
|
-
|
127
|
-
def make_valid
|
128
|
-
self.geog = ActiveRecord::Base.connection.select_value("SELECT ST_Buffer('#{sanitize}', 0)")
|
129
|
-
end
|
130
|
-
|
131
|
-
# Use ST_Force2D to discard z-coordinates that cause failures later in the process
|
132
|
-
def sanitize
|
133
|
-
self.geog = ActiveRecord::Base.connection.select_value("SELECT ST_Force2D('#{geog}')")
|
134
|
-
end
|
135
|
-
|
136
|
-
SRID_CACHE = {}
|
137
|
-
def self.detect_srid(column_name)
|
138
|
-
SRID_CACHE[column_name] ||= connection.select_value("SELECT Find_SRID('public', '#{table_name}', '#{column_name}')")
|
139
|
-
end
|
140
|
-
|
141
|
-
def self.join_other_features(other)
|
142
|
-
joins('INNER JOIN features AS other_features ON true').where(:other_features => {:id => other})
|
143
|
-
end
|
144
|
-
|
145
|
-
def validate_geometry
|
146
|
-
return unless geog?
|
147
|
-
|
148
|
-
error = geometry_validation_message
|
149
|
-
if error && make_valid?
|
150
|
-
make_valid
|
151
|
-
self.make_valid = false
|
152
|
-
validate_geometry
|
153
|
-
elsif error
|
154
|
-
errors.add :geog, error
|
155
|
-
end
|
156
|
-
end
|
157
|
-
|
158
|
-
def geometry_validation_message
|
159
|
-
error = self.class.connection.select_one(self.class.unscoped.invalid.from("(SELECT '#{sanitize_input_for_sql(self.geog)}'::geometry AS geog) #{self.class.table_name}"))
|
160
|
-
return error.fetch('invalid_geometry_message') if error
|
161
|
-
end
|
162
|
-
|
163
|
-
def sanitize_feature_type
|
164
|
-
self.feature_type = FEATURE_TYPES.find {|type| self.feature_type.to_s.strip.downcase.include?(type) }
|
165
|
-
end
|
166
|
-
|
167
|
-
def sanitize_input_for_sql(input)
|
168
|
-
self.class.send(:sanitize_sql_for_conditions, input)
|
169
|
-
end
|
170
62
|
end
|
@@ -79,9 +79,11 @@ module SpatialFeatures
|
|
79
79
|
|
80
80
|
def import_features(imports, skip_invalid)
|
81
81
|
self.features.delete_all
|
82
|
-
valid, invalid =
|
83
|
-
|
84
|
-
|
82
|
+
valid, invalid = Feature.defer_aggregate_refresh do
|
83
|
+
imports.flat_map(&:features).partition do |feature|
|
84
|
+
feature.spatial_model = self
|
85
|
+
feature.save
|
86
|
+
end
|
85
87
|
end
|
86
88
|
|
87
89
|
errors = imports.flat_map(&:errors)
|
@@ -11,6 +11,7 @@ module SpatialFeatures
|
|
11
11
|
include FeatureImport
|
12
12
|
|
13
13
|
has_many :features, lambda { extending FeaturesAssociationExtensions }, :as => :spatial_model, :dependent => :delete_all
|
14
|
+
has_one :aggregate_feature, lambda { extending FeaturesAssociationExtensions }, :as => :spatial_model, :dependent => :delete
|
14
15
|
|
15
16
|
scope :with_features, lambda { joins(:features).uniq }
|
16
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") }
|
@@ -128,24 +129,8 @@ module SpatialFeatures
|
|
128
129
|
scope = spatial_join(other, buffer_in_meters)
|
129
130
|
scope = scope.select(options[:columns])
|
130
131
|
|
131
|
-
|
132
|
-
|
133
|
-
scope = scope.group("#{table_name}.#{primary_key}") # Aggregate functions require grouping
|
134
|
-
else
|
135
|
-
scope = scope.distinct
|
136
|
-
end
|
137
|
-
|
138
|
-
scope = scope.select("MIN(ST_Distance(features.geom, other_features.geom)) AS distance_in_meters") if options[:distance]
|
139
|
-
scope = scope.select <<~SQL if options[:intersection_area]
|
140
|
-
ST_Area(
|
141
|
-
ST_Intersection(
|
142
|
-
/* Extract only polygons to calculations since we're calculating area */
|
143
|
-
/* Union to aggregate features from all records in the query before intersection to flatten geometry and avoid possible self intersection */
|
144
|
-
ST_Union(ST_CollectionExtract(features.geom, 3)),
|
145
|
-
ST_Union(ST_CollectionExtract(other_features.geom, 3))
|
146
|
-
)
|
147
|
-
) AS intersection_area_in_square_meters
|
148
|
-
SQL
|
132
|
+
scope = scope.select("ST_Distance(features.geom, other_features.geom) AS distance_in_meters") if options[:distance]
|
133
|
+
scope = scope.select("ST_Area(ST_Intersection(features.geom, other_features.geom)) AS intersection_area_in_square_meters") if options[:intersection_area]
|
149
134
|
|
150
135
|
return scope
|
151
136
|
end
|
@@ -165,7 +150,7 @@ module SpatialFeatures
|
|
165
150
|
end
|
166
151
|
|
167
152
|
def features_scope(other)
|
168
|
-
scope =
|
153
|
+
scope = AggregateFeature
|
169
154
|
scope = scope.where(:spatial_model_type => Utils.base_class_of(other).to_s)
|
170
155
|
scope = scope.where(:spatial_model_id => other) unless Utils.class_of(other) == other
|
171
156
|
return scope
|
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.
|
4
|
+
version: 2.8.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: 2019-
|
12
|
+
date: 2019-10-21 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: rails
|
@@ -167,6 +167,8 @@ files:
|
|
167
167
|
- MIT-LICENSE
|
168
168
|
- README.md
|
169
169
|
- Rakefile
|
170
|
+
- app/models/abstract_feature.rb
|
171
|
+
- app/models/aggregate_feature.rb
|
170
172
|
- app/models/feature.rb
|
171
173
|
- app/models/spatial_cache.rb
|
172
174
|
- app/models/spatial_proximity.rb
|