tg_geometry 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +18 -79
- data/README.md +82 -191
- data/Rakefile +3 -3
- data/benchmark/falcon_concurrency.rb +1 -1
- data/benchmark/feature_source.rb +92 -0
- data/docs/ARCHITECTURE.md +29 -107
- data/docs/BENCHMARKING.md +20 -1
- data/docs/CASUAL_EXAMPLE.md +71 -458
- data/docs/CONCURRENCY.md +13 -7
- data/docs/ERROR_HANDLING.md +30 -0
- data/docs/FEATURE_SOURCE.md +166 -0
- data/docs/LIMITATIONS.md +11 -50
- data/docs/MEMORY_OWNERSHIP.md +20 -2
- data/ext/tg_geometry/extconf.rb +46 -4
- data/ext/tg_geometry/tg_geometry_ext.c +2453 -150
- data/ext/tg_geometry/tg_geometry_vendor_json.c +17 -0
- data/ext/tg_geometry/tg_geometry_vendor_tg.c +3 -0
- data/ext/tg_geometry/vendor/.vendored +8 -2
- data/ext/tg_geometry/vendor/json/LICENSE +20 -0
- data/ext/tg_geometry/vendor/json/VERSION +3 -0
- data/ext/tg_geometry/vendor/json/json.c +1024 -0
- data/ext/tg_geometry/vendor/json/json.h +207 -0
- data/lib/tg/geometry/registry.rb +3 -3
- data/lib/tg/geometry/version.rb +1 -1
- data/script/vendor_libs.rb +22 -6
- data/spec/{expansion_a_auto_strategy_spec.rb → auto_strategy_spec.rb} +1 -1
- data/spec/{block_12_batch_packed_spec.rb → batch_packed_spec.rb} +1 -1
- data/spec/{block_20_concurrency_spec.rb → concurrency_spec.rb} +1 -1
- data/spec/{block_13_error_hardening_spec.rb → error_hardening_spec.rb} +1 -1
- data/spec/feature_source_nogvl_spec.rb +51 -0
- data/spec/feature_source_spec.rb +268 -0
- data/spec/{expansion_d_format_coverage_spec.rb → format_coverage_spec.rb} +1 -1
- data/spec/{block_20_fuzz_spec.rb → fuzz_spec.rb} +1 -1
- data/spec/{block_4_geom_api_spec.rb → geom_api_spec.rb} +1 -1
- data/spec/{block_3_geom_parse_spec.rb → geom_parse_spec.rb} +1 -1
- data/spec/{block_8_index_borrowed_geometry_spec.rb → index_borrowed_geometry_spec.rb} +1 -1
- data/spec/{block_6_index_build_spec.rb → index_build_spec.rb} +2 -2
- data/spec/{block_9_flat_query_spec.rb → index_flat_query_spec.rb} +1 -1
- data/spec/{block_7_index_owned_geometry_spec.rb → index_owned_geometry_spec.rb} +1 -1
- data/spec/{block_10_rtree_strategy_spec.rb → index_rtree_accounting_spec.rb} +1 -1
- data/spec/{block_11_rtree_order_spec.rb → index_rtree_order_spec.rb} +1 -1
- data/spec/{block_1_skeleton_spec.rb → load_and_errors_spec.rb} +1 -1
- data/spec/{expansion_e_low_level_geometry_spec.rb → low_level_geometry_spec.rb} +1 -1
- data/spec/{block_14_memory_gc_hardening_spec.rb → memory_gc_spec.rb} +1 -1
- data/spec/{expansion_i_ractor_spec.rb → ractor_spec.rb} +1 -1
- data/spec/{block_5_rect_api_spec.rb → rect_api_spec.rb} +1 -1
- data/spec/{expansion_b_registry_spec.rb → registry_spec.rb} +1 -1
- data/spec/{expansion_j_full_tg_api_coverage_spec.rb → tg_api_coverage_spec.rb} +1 -1
- data/spec/{block_2_vendor_spec.rb → vendor_sources_spec.rb} +4 -4
- metadata +39 -38
- data/docs/ACTIVE_RECORD.md +0 -26
- data/docs/AUTO_STRATEGY.md +0 -15
- data/docs/EXPANSION_E_TO_H_STATUS.md +0 -51
- data/docs/FORMAT_COVERAGE.md +0 -23
- data/docs/FULL_TG_API_COVERAGE.md +0 -109
- data/docs/LOW_LEVEL_GEOMETRY.md +0 -121
- data/docs/RACTOR.md +0 -40
- data/docs/REGISTRY.md +0 -37
- data/docs/RELEASE_CHECKLIST.md +0 -39
- /data/spec/{expansion_c_active_record_source_spec.rb → active_record_source_spec.rb} +0 -0
data/docs/CASUAL_EXAMPLE.md
CHANGED
|
@@ -1,24 +1,10 @@
|
|
|
1
|
-
# Casual Example: Rails + ActiveRecord +
|
|
1
|
+
# Casual Example: Rails + ActiveRecord + GeoJSON FeatureSource
|
|
2
2
|
|
|
3
|
-
This example shows a
|
|
3
|
+
This example shows a small Rails integration for point-in-polygon lookups with real GeoJSON data.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
The recommended import path uses `TG::Geometry::FeatureSource` so the application does not parse the entire FeatureCollection into a Ruby Hash tree just to extract geometries.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- import them from OpenStreetMap / Overpass Turbo as GeoJSON;
|
|
9
|
-
- build a `TG::Geometry::Index` through a small registry;
|
|
10
|
-
- check which zone covers a given `(lon, lat)` point;
|
|
11
|
-
- expose that check through a Rails controller.
|
|
12
|
-
|
|
13
|
-
This is intentionally casual and application-oriented. It is not a full GIS setup and does not require PostGIS.
|
|
14
|
-
|
|
15
|
-
---
|
|
16
|
-
|
|
17
|
-
## 1. Create the `zones` table
|
|
18
|
-
|
|
19
|
-
For the first practical integration, store polygon geometry as raw GeoJSON text.
|
|
20
|
-
|
|
21
|
-
Generate a migration and use this schema:
|
|
7
|
+
## 1. Store zones
|
|
22
8
|
|
|
23
9
|
```ruby
|
|
24
10
|
class CreateZones < ActiveRecord::Migration[8.1]
|
|
@@ -26,68 +12,31 @@ class CreateZones < ActiveRecord::Migration[8.1]
|
|
|
26
12
|
create_table :zones do |t|
|
|
27
13
|
t.string :code, null: false
|
|
28
14
|
t.string :name
|
|
29
|
-
|
|
30
|
-
# Data source: "osm", "manual", "city_open_data", etc.
|
|
31
|
-
t.string :source, null: false, default: "manual"
|
|
32
|
-
|
|
33
|
-
# For OSM imports: "way/123", "relation/456", etc.
|
|
15
|
+
t.string :source, null: false, default: "osm"
|
|
34
16
|
t.string :source_uid
|
|
35
|
-
|
|
36
|
-
# park, school, hospital, residential, commercial, etc.
|
|
37
17
|
t.string :kind, null: false, default: "unknown"
|
|
38
|
-
|
|
39
|
-
# Raw GeoJSON geometry only: Polygon / MultiPolygon.
|
|
40
|
-
# Not Feature, not FeatureCollection.
|
|
41
18
|
t.text :geojson, null: false
|
|
42
|
-
|
|
43
|
-
# Original imported feature properties/tags.
|
|
44
19
|
t.jsonb :properties, null: false, default: {}
|
|
45
|
-
|
|
46
20
|
t.boolean :active, null: false, default: true
|
|
47
21
|
t.integer :priority, null: false, default: 100
|
|
48
|
-
|
|
49
22
|
t.datetime :imported_at
|
|
50
|
-
|
|
51
23
|
t.timestamps
|
|
52
24
|
end
|
|
53
25
|
|
|
54
26
|
add_index :zones, :code, unique: true
|
|
55
27
|
add_index :zones, [:source, :source_uid], unique: true, where: "source_uid IS NOT NULL"
|
|
56
28
|
add_index :zones, [:active, :priority, :id]
|
|
57
|
-
add_index :zones, :kind
|
|
58
29
|
end
|
|
59
30
|
end
|
|
60
31
|
```
|
|
61
32
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
```bash
|
|
65
|
-
bin/rails db:migrate
|
|
66
|
-
```
|
|
33
|
+
`geojson` stores only the raw Geometry object (`Polygon` / `MultiPolygon`), not the whole Feature and not the FeatureCollection.
|
|
67
34
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
`TG::Geometry::Index.build(..., via: :geojson)` expects a GeoJSON string. Keeping the geometry as text avoids converting `jsonb` back to JSON on every index reload.
|
|
71
|
-
|
|
72
|
-
---
|
|
73
|
-
|
|
74
|
-
## 2. Add the `Zone` model
|
|
75
|
-
|
|
76
|
-
The model validates GeoJSON through `TG::Geometry.parse_geojson` and provides a small importer for a GeoJSON FeatureCollection.
|
|
35
|
+
## 2. Import a FeatureCollection
|
|
77
36
|
|
|
78
37
|
```ruby
|
|
79
|
-
# app/models/zone.rb
|
|
80
38
|
class Zone < ApplicationRecord
|
|
81
|
-
OSM_KIND_KEYS = %w[
|
|
82
|
-
amenity
|
|
83
|
-
leisure
|
|
84
|
-
landuse
|
|
85
|
-
tourism
|
|
86
|
-
boundary
|
|
87
|
-
natural
|
|
88
|
-
building
|
|
89
|
-
shop
|
|
90
|
-
].freeze
|
|
39
|
+
OSM_KIND_KEYS = %w[amenity leisure landuse tourism boundary natural building shop].freeze
|
|
91
40
|
|
|
92
41
|
KIND_PRIORITY = {
|
|
93
42
|
"hospital" => 10,
|
|
@@ -106,67 +55,54 @@ class Zone < ApplicationRecord
|
|
|
106
55
|
"unknown" => 100
|
|
107
56
|
}.freeze
|
|
108
57
|
|
|
109
|
-
validates :code,
|
|
110
|
-
validates :source, presence: true
|
|
111
|
-
validates :kind, presence: true
|
|
112
|
-
validates :geojson, presence: true
|
|
58
|
+
validates :code, :source, :kind, :geojson, presence: true
|
|
113
59
|
validates :source_uid, uniqueness: { scope: :source }, allow_nil: true
|
|
114
|
-
validates :priority, numericality: { only_integer: true }
|
|
115
|
-
|
|
116
60
|
validate :geojson_must_be_parseable_polygon
|
|
117
61
|
|
|
118
62
|
class << self
|
|
119
63
|
def import_geojson_file!(path, source: "osm", replace: false)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
64
|
+
result = TG::Geometry::FeatureSource.read_features_file(
|
|
65
|
+
path,
|
|
66
|
+
id: ["properties", "@id"],
|
|
67
|
+
only: [:polygon, :multipolygon],
|
|
68
|
+
report: true,
|
|
69
|
+
on_invalid: :skip,
|
|
70
|
+
on_missing_id: :ordinal
|
|
71
|
+
)
|
|
125
72
|
|
|
126
73
|
imported = 0
|
|
127
|
-
skipped = 0
|
|
128
74
|
|
|
129
75
|
transaction do
|
|
130
76
|
where(source: source).delete_all if replace
|
|
131
77
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
unless geometry && %w[Polygon MultiPolygon].include?(geometry["type"])
|
|
137
|
-
skipped += 1
|
|
138
|
-
next
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
source_uid = osm_source_uid(feature, properties, index)
|
|
78
|
+
result[:features].each do |source_uid, geometry_json, properties_json|
|
|
79
|
+
# This is application-level parsing of one feature's properties only.
|
|
80
|
+
# The full FeatureCollection was not parsed through JSON.parse.
|
|
81
|
+
properties = JSON.parse(properties_json || "null") || {}
|
|
142
82
|
kind = kind_from_properties(properties)
|
|
143
|
-
geojson = geometry.to_json
|
|
144
|
-
|
|
145
|
-
# Fail fast using the same parser that the index uses later.
|
|
146
|
-
TG::Geometry.parse_geojson(geojson)
|
|
147
|
-
|
|
148
|
-
zone = find_or_initialize_by(source: source, source_uid: source_uid)
|
|
149
83
|
|
|
84
|
+
zone = find_or_initialize_by(source: source, source_uid: source_uid.to_s)
|
|
150
85
|
zone.assign_attributes(
|
|
151
86
|
code: code_for(source, source_uid),
|
|
152
87
|
name: name_from_properties(properties, kind, source_uid),
|
|
153
88
|
kind: kind,
|
|
154
|
-
geojson:
|
|
89
|
+
geojson: geometry_json,
|
|
155
90
|
properties: properties,
|
|
156
91
|
active: true,
|
|
157
92
|
priority: priority_for(kind),
|
|
158
93
|
imported_at: Time.current
|
|
159
94
|
)
|
|
160
|
-
|
|
161
95
|
zone.save!
|
|
162
96
|
imported += 1
|
|
163
|
-
rescue TG::Geometry::ParseError, ActiveRecord::RecordInvalid, JSON::ParserError => e
|
|
164
|
-
skipped += 1
|
|
165
|
-
warn "skip feature #{index}: #{e.class}: #{e.message}"
|
|
166
97
|
end
|
|
167
98
|
end
|
|
168
99
|
|
|
169
|
-
{
|
|
100
|
+
{
|
|
101
|
+
imported: imported,
|
|
102
|
+
skipped: result[:skipped],
|
|
103
|
+
filtered: result[:filtered],
|
|
104
|
+
errors: result[:errors]
|
|
105
|
+
}
|
|
170
106
|
end
|
|
171
107
|
|
|
172
108
|
def kind_from_properties(properties)
|
|
@@ -174,7 +110,6 @@ class Zone < ApplicationRecord
|
|
|
174
110
|
value = properties[key]
|
|
175
111
|
return value.to_s if value.present?
|
|
176
112
|
end
|
|
177
|
-
|
|
178
113
|
"unknown"
|
|
179
114
|
end
|
|
180
115
|
|
|
@@ -184,15 +119,6 @@ class Zone < ApplicationRecord
|
|
|
184
119
|
|
|
185
120
|
private
|
|
186
121
|
|
|
187
|
-
def osm_source_uid(feature, properties, index)
|
|
188
|
-
uid =
|
|
189
|
-
properties["@id"] ||
|
|
190
|
-
feature["id"] ||
|
|
191
|
-
properties["id"]
|
|
192
|
-
|
|
193
|
-
uid.present? ? uid.to_s : "feature/#{index}"
|
|
194
|
-
end
|
|
195
|
-
|
|
196
122
|
def code_for(source, source_uid)
|
|
197
123
|
"#{source}_#{source_uid}".parameterize(separator: "_")
|
|
198
124
|
end
|
|
@@ -209,22 +135,16 @@ class Zone < ApplicationRecord
|
|
|
209
135
|
|
|
210
136
|
def geojson_must_be_parseable_polygon
|
|
211
137
|
geom = TG::Geometry.parse_geojson(geojson)
|
|
212
|
-
|
|
213
|
-
unless %i[polygon multipolygon].include?(geom.type)
|
|
214
|
-
errors.add(:geojson, "must be Polygon or MultiPolygon")
|
|
215
|
-
end
|
|
138
|
+
errors.add(:geojson, "must be Polygon or MultiPolygon") unless %i[polygon multipolygon].include?(geom.type)
|
|
216
139
|
rescue TG::Geometry::ParseError => e
|
|
217
140
|
errors.add(:geojson, "is invalid: #{e.message}")
|
|
218
141
|
end
|
|
219
142
|
end
|
|
220
143
|
```
|
|
221
144
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
## 3. Add a rake task for import
|
|
145
|
+
## 3. Import task
|
|
225
146
|
|
|
226
147
|
```ruby
|
|
227
|
-
# lib/tasks/zones.rake
|
|
228
148
|
namespace :zones do
|
|
229
149
|
desc "Import zones from GeoJSON FeatureCollection"
|
|
230
150
|
task import_geojson: :environment do
|
|
@@ -237,382 +157,75 @@ namespace :zones do
|
|
|
237
157
|
replace: replace
|
|
238
158
|
)
|
|
239
159
|
|
|
240
|
-
puts "Imported: #{result[:imported]}, skipped: #{result[:skipped]}"
|
|
160
|
+
puts "Imported: #{result[:imported]}, filtered: #{result[:filtered]}, skipped: #{result[:skipped]}"
|
|
161
|
+
result[:errors].first(10).each { |error| warn error.inspect }
|
|
241
162
|
end
|
|
242
163
|
end
|
|
243
164
|
```
|
|
244
165
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
## 4. Download real polygons from Overpass Turbo
|
|
248
|
-
|
|
249
|
-
For a practical first dataset, use real OpenStreetMap polygons from [Overpass Turbo](https://overpass-turbo.eu/).
|
|
250
|
-
|
|
251
|
-
### 4.1. Paste the query
|
|
252
|
-
|
|
253
|
-
Open Overpass Turbo and paste this query:
|
|
254
|
-
|
|
255
|
-
```overpass
|
|
256
|
-
[out:json][timeout:90];
|
|
257
|
-
|
|
258
|
-
(
|
|
259
|
-
way["leisure"="park"](43.12,76.75,43.40,77.20);
|
|
260
|
-
relation["leisure"="park"](43.12,76.75,43.40,77.20);
|
|
261
|
-
|
|
262
|
-
way["landuse"~"residential|commercial|industrial|retail|grass|forest|cemetery"](43.12,76.75,43.40,77.20);
|
|
263
|
-
relation["landuse"~"residential|commercial|industrial|retail|grass|forest|cemetery"](43.12,76.75,43.40,77.20);
|
|
264
|
-
|
|
265
|
-
way["amenity"~"school|university|college|kindergarten|hospital|marketplace"](43.12,76.75,43.40,77.20);
|
|
266
|
-
relation["amenity"~"school|university|college|kindergarten|hospital|marketplace"](43.12,76.75,43.40,77.20);
|
|
267
|
-
|
|
268
|
-
way["tourism"~"zoo|attraction|theme_park"](43.12,76.75,43.40,77.20);
|
|
269
|
-
relation["tourism"~"zoo|attraction|theme_park"](43.12,76.75,43.40,77.20);
|
|
270
|
-
);
|
|
271
|
-
|
|
272
|
-
out tags geom;
|
|
273
|
-
```
|
|
274
|
-
|
|
275
|
-
Then click **Run**.
|
|
276
|
-
|
|
277
|
-

|
|
278
|
-
|
|
279
|
-
### 4.2. Export as GeoJSON
|
|
280
|
-
|
|
281
|
-
After the query finishes, click:
|
|
282
|
-
|
|
283
|
-
1. **Export**
|
|
284
|
-
2. **GeoJSON**
|
|
285
|
-
3. **download**
|
|
286
|
-
|
|
287
|
-
Save the file as:
|
|
288
|
-
|
|
289
|
-
```text
|
|
290
|
-
db/geo/almaty_zones.geojson
|
|
291
|
-
```
|
|
292
|
-
|
|
293
|
-

|
|
294
|
-
|
|
295
|
-
---
|
|
296
|
-
|
|
297
|
-
## 5. Import zones into the database
|
|
298
|
-
|
|
299
|
-
Create the directory if needed:
|
|
300
|
-
|
|
301
|
-
```bash
|
|
302
|
-
mkdir -p db/geo
|
|
303
|
-
```
|
|
304
|
-
|
|
305
|
-
Then run:
|
|
166
|
+
Run:
|
|
306
167
|
|
|
307
168
|
```bash
|
|
308
169
|
bundle exec rake zones:import_geojson PATH=db/geo/almaty_zones.geojson REPLACE=true
|
|
309
170
|
```
|
|
310
171
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
```text
|
|
314
|
-
Imported: 7469, skipped: 1
|
|
315
|
-
```
|
|
316
|
-
|
|
317
|
-
---
|
|
318
|
-
|
|
319
|
-
## 6. Create `ZonesRegistry`
|
|
320
|
-
|
|
321
|
-
For real overlapping data, ordering matters. `find_covering` returns the first matching id in index insertion order, so we build entries ordered by `priority ASC, id ASC`.
|
|
322
|
-
|
|
323
|
-
```ruby
|
|
324
|
-
# app/services/zones_registry.rb
|
|
325
|
-
class ZonesRegistry < TG::Geometry::Registry
|
|
326
|
-
source do
|
|
327
|
-
Zone
|
|
328
|
-
.where(active: true)
|
|
329
|
-
.order(:priority, :id)
|
|
330
|
-
.pluck(:id, :geojson)
|
|
331
|
-
end
|
|
332
|
-
|
|
333
|
-
index_options(
|
|
334
|
-
via: :geojson,
|
|
335
|
-
strategy: :rtree,
|
|
336
|
-
predicate: :covers,
|
|
337
|
-
geometry_index: :ystripes
|
|
338
|
-
)
|
|
339
|
-
end
|
|
340
|
-
```
|
|
341
|
-
|
|
342
|
-
Notes:
|
|
343
|
-
|
|
344
|
-
- `via: :geojson` means the index parses GeoJSON strings.
|
|
345
|
-
- `strategy: :rtree` is a good practical default for thousands of zones.
|
|
346
|
-
- `predicate: :covers` is usually the right default for geofencing because boundary points count as covered.
|
|
347
|
-
- Coordinates are passed as `(lon, lat)`, not `(lat, lon)`.
|
|
348
|
-
|
|
349
|
-
---
|
|
350
|
-
|
|
351
|
-
## 7. Check everything in `rails console`
|
|
352
|
-
|
|
353
|
-
Open console:
|
|
354
|
-
|
|
355
|
-
```bash
|
|
356
|
-
bin/rails console
|
|
357
|
-
```
|
|
358
|
-
|
|
359
|
-
### 7.1. Basic data checks
|
|
360
|
-
|
|
361
|
-
```ruby
|
|
362
|
-
Zone.count
|
|
363
|
-
Zone.group(:kind).count
|
|
364
|
-
```
|
|
365
|
-
|
|
366
|
-
### 7.2. Parse one geometry
|
|
367
|
-
|
|
368
|
-
```ruby
|
|
369
|
-
zone = Zone.first
|
|
370
|
-
geom = TG::Geometry.parse_geojson(zone.geojson)
|
|
371
|
-
|
|
372
|
-
geom.type
|
|
373
|
-
geom.bbox
|
|
374
|
-
geom.frozen?
|
|
375
|
-
```
|
|
376
|
-
|
|
377
|
-
Expected:
|
|
378
|
-
|
|
379
|
-
- `geom.type` returns `:polygon` or `:multipolygon`;
|
|
380
|
-
- `geom.bbox` returns `TG::Geometry::Rect`;
|
|
381
|
-
- `geom.frozen?` returns `true`.
|
|
382
|
-
|
|
383
|
-
### 7.3. Build the index
|
|
384
|
-
|
|
385
|
-
```ruby
|
|
386
|
-
registry = ZonesRegistry.new
|
|
387
|
-
index = registry.reload!
|
|
388
|
-
|
|
389
|
-
[index.size, index.strategy, index.predicate, index.frozen?]
|
|
390
|
-
```
|
|
391
|
-
|
|
392
|
-
Example:
|
|
172
|
+
## 4. Build an index from database rows
|
|
393
173
|
|
|
394
174
|
```ruby
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
### 7.4. Check a single point
|
|
399
|
-
|
|
400
|
-
Important: pass coordinates as `(lon, lat)`.
|
|
401
|
-
|
|
402
|
-
```ruby
|
|
403
|
-
lon = 76.945
|
|
404
|
-
lat = 43.238
|
|
405
|
-
|
|
406
|
-
id = registry.find_covering(lon, lat)
|
|
407
|
-
ids = registry.covering_ids(lon, lat)
|
|
408
|
-
|
|
409
|
-
[id, ids]
|
|
410
|
-
```
|
|
411
|
-
|
|
412
|
-
Inspect matched zones:
|
|
413
|
-
|
|
414
|
-
```ruby
|
|
415
|
-
Zone
|
|
416
|
-
.where(id: ids)
|
|
175
|
+
entries = Zone
|
|
176
|
+
.where(active: true)
|
|
417
177
|
.order(:priority, :id)
|
|
418
|
-
.pluck(:id, :
|
|
419
|
-
```
|
|
420
|
-
|
|
421
|
-
### 7.5. Check a rectangle
|
|
178
|
+
.pluck(:id, :geojson)
|
|
422
179
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
180
|
+
index = TG::Geometry::Index.build(
|
|
181
|
+
entries,
|
|
182
|
+
via: :geojson,
|
|
183
|
+
strategy: :rtree,
|
|
184
|
+
predicate: :covers,
|
|
185
|
+
geometry_index: :ystripes
|
|
186
|
+
)
|
|
428
187
|
```
|
|
429
188
|
|
|
430
|
-
|
|
189
|
+
`find_covering` returns the first matching id in insertion order, so sort rows in the order you want to use for priority.
|
|
431
190
|
|
|
432
|
-
|
|
433
|
-
sample_ids = ids.first(20)
|
|
434
|
-
zones_by_id = Zone.where(id: sample_ids).index_by(&:id)
|
|
191
|
+
## 5. Direct file-to-index path
|
|
435
192
|
|
|
436
|
-
|
|
437
|
-
zone = zones_by_id[id]
|
|
438
|
-
[zone.id, zone.kind, zone.name, zone.priority]
|
|
439
|
-
end
|
|
440
|
-
```
|
|
441
|
-
|
|
442
|
-
### 7.6. Check the packed batch API
|
|
443
|
-
|
|
444
|
-
The packed API expects a flat binary buffer of native-endian doubles:
|
|
445
|
-
|
|
446
|
-
```text
|
|
447
|
-
lon1, lat1, lon2, lat2, ...
|
|
448
|
-
```
|
|
449
|
-
|
|
450
|
-
Do not call `points.pack("d*")` on nested arrays. Flatten first:
|
|
193
|
+
When you do not need to store features first, build an index directly from a FeatureCollection file:
|
|
451
194
|
|
|
452
195
|
```ruby
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
[
|
|
456
|
-
[
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
196
|
+
index = TG::Geometry::FeatureSource.build_index_file(
|
|
197
|
+
"db/geo/almaty_zones.geojson",
|
|
198
|
+
id: ["properties", "@id"],
|
|
199
|
+
only: [:polygon, :multipolygon],
|
|
200
|
+
on_missing_id: :ordinal,
|
|
201
|
+
strategy: :rtree,
|
|
202
|
+
predicate: :covers,
|
|
203
|
+
geometry_index: :ystripes
|
|
204
|
+
)
|
|
462
205
|
```
|
|
463
206
|
|
|
464
|
-
|
|
207
|
+
The direct build path is fail-fast. It does not support report mode or skip mode because it returns a ready `TG::Geometry::Index`, not an import report.
|
|
465
208
|
|
|
466
|
-
|
|
467
|
-
scalar = points.map { |lon, lat| registry.find_covering(lon, lat) }
|
|
468
|
-
batch = registry.covering_ids_batch_packed(points.flatten.pack("d*"))
|
|
209
|
+
## 6. Query points and rectangles
|
|
469
210
|
|
|
470
|
-
|
|
471
|
-
```
|
|
472
|
-
|
|
473
|
-
Expected final element:
|
|
474
|
-
|
|
475
|
-
```ruby
|
|
476
|
-
true
|
|
477
|
-
```
|
|
478
|
-
|
|
479
|
-
---
|
|
480
|
-
|
|
481
|
-
## 8. Add a simple lookup service
|
|
482
|
-
|
|
483
|
-
```ruby
|
|
484
|
-
# app/services/zone_lookup.rb
|
|
485
|
-
class ZoneLookup
|
|
486
|
-
class << self
|
|
487
|
-
def registry
|
|
488
|
-
@registry ||= ZonesRegistry.new.tap(&:reload!)
|
|
489
|
-
end
|
|
490
|
-
|
|
491
|
-
def reload!
|
|
492
|
-
@registry = ZonesRegistry.new.tap(&:reload!)
|
|
493
|
-
end
|
|
494
|
-
|
|
495
|
-
def covering_zones(lon, lat)
|
|
496
|
-
ids = registry.covering_ids(lon, lat)
|
|
497
|
-
zones_by_id = Zone.where(id: ids).index_by(&:id)
|
|
498
|
-
|
|
499
|
-
ids.filter_map { |id| zones_by_id[id] }
|
|
500
|
-
end
|
|
501
|
-
|
|
502
|
-
def first_zone(lon, lat)
|
|
503
|
-
id = registry.find_covering(lon, lat)
|
|
504
|
-
id && Zone.find_by(id: id)
|
|
505
|
-
end
|
|
506
|
-
end
|
|
507
|
-
end
|
|
508
|
-
```
|
|
509
|
-
|
|
510
|
-
---
|
|
511
|
-
|
|
512
|
-
## 9. Check coordinates in a controller
|
|
513
|
-
|
|
514
|
-
A minimal controller example:
|
|
211
|
+
Coordinates are always `(lon, lat)`.
|
|
515
212
|
|
|
516
213
|
```ruby
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
lon = Float(params.require(:lon))
|
|
521
|
-
lat = Float(params.require(:lat))
|
|
522
|
-
|
|
523
|
-
zones = ZoneLookup.covering_zones(lon, lat)
|
|
524
|
-
|
|
525
|
-
render json: {
|
|
526
|
-
lon: lon,
|
|
527
|
-
lat: lat,
|
|
528
|
-
matched: zones.any?,
|
|
529
|
-
first_zone: serialize_zone(zones.first),
|
|
530
|
-
zones: zones.map { |zone| serialize_zone(zone) }
|
|
531
|
-
}
|
|
532
|
-
rescue KeyError, ActionController::ParameterMissing, ArgumentError
|
|
533
|
-
render json: { error: "invalid lon/lat params" }, status: :unprocessable_entity
|
|
534
|
-
end
|
|
535
|
-
|
|
536
|
-
private
|
|
537
|
-
|
|
538
|
-
def serialize_zone(zone)
|
|
539
|
-
return nil unless zone
|
|
540
|
-
|
|
541
|
-
{
|
|
542
|
-
id: zone.id,
|
|
543
|
-
code: zone.code,
|
|
544
|
-
name: zone.name,
|
|
545
|
-
kind: zone.kind,
|
|
546
|
-
priority: zone.priority,
|
|
547
|
-
source: zone.source,
|
|
548
|
-
source_uid: zone.source_uid
|
|
549
|
-
}
|
|
550
|
-
end
|
|
551
|
-
end
|
|
214
|
+
index.find_covering(76.945, 43.238)
|
|
215
|
+
index.covering_ids(76.945, 43.238)
|
|
216
|
+
index.intersecting_rect(76.90, 43.20, 77.00, 43.30)
|
|
552
217
|
```
|
|
553
218
|
|
|
554
|
-
|
|
219
|
+
For batch point checks, pass native-endian packed doubles:
|
|
555
220
|
|
|
556
221
|
```ruby
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
end
|
|
561
|
-
```
|
|
562
|
-
|
|
563
|
-
Now test it:
|
|
564
|
-
|
|
565
|
-
```bash
|
|
566
|
-
curl "http://localhost:3000/zones/check?lon=76.945&lat=43.238"
|
|
567
|
-
```
|
|
568
|
-
|
|
569
|
-
Example response:
|
|
570
|
-
|
|
571
|
-
```json
|
|
572
|
-
{
|
|
573
|
-
"lon": 76.945,
|
|
574
|
-
"lat": 43.238,
|
|
575
|
-
"matched": true,
|
|
576
|
-
"first_zone": {
|
|
577
|
-
"id": 7401,
|
|
578
|
-
"code": "osm_way_...",
|
|
579
|
-
"name": "Айнабулак",
|
|
580
|
-
"kind": "cemetery",
|
|
581
|
-
"priority": 100,
|
|
582
|
-
"source": "osm",
|
|
583
|
-
"source_uid": "way/..."
|
|
584
|
-
},
|
|
585
|
-
"zones": [
|
|
586
|
-
{
|
|
587
|
-
"id": 7401,
|
|
588
|
-
"code": "osm_way_...",
|
|
589
|
-
"name": "Айнабулак",
|
|
590
|
-
"kind": "cemetery",
|
|
591
|
-
"priority": 100,
|
|
592
|
-
"source": "osm",
|
|
593
|
-
"source_uid": "way/..."
|
|
594
|
-
}
|
|
595
|
-
]
|
|
596
|
-
}
|
|
222
|
+
points = [[76.945, 43.238], [76.900, 43.250], [80.000, 50.000]]
|
|
223
|
+
packed = points.flatten.pack("d*")
|
|
224
|
+
index.covering_ids_batch_packed(packed)
|
|
597
225
|
```
|
|
598
226
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
## 10. Summary
|
|
602
|
-
|
|
603
|
-
This example shows a practical end-to-end setup:
|
|
604
|
-
|
|
605
|
-
1. store polygon zones in PostgreSQL;
|
|
606
|
-
2. import real map polygons from OpenStreetMap / Overpass Turbo;
|
|
607
|
-
3. build a `TG::Geometry::Index`;
|
|
608
|
-
4. query by `(lon, lat)`;
|
|
609
|
-
5. expose the result in a Rails controller.
|
|
610
|
-
|
|
611
|
-
This simple setup is enough for many point-in-polygon lookup use cases:
|
|
612
|
-
|
|
613
|
-
- no PostGIS required;
|
|
614
|
-
- no external geometry service;
|
|
615
|
-
- no Redis;
|
|
616
|
-
- no background processing required for the first version.
|
|
227
|
+
## Notes
|
|
617
228
|
|
|
618
|
-
|
|
229
|
+
- `FeatureSource.read_features_file` reads the full source into memory, but it does not build a Ruby Hash tree for the whole FeatureCollection.
|
|
230
|
+
- `properties_json` is returned as a raw JSON string. Parse it at application level only if you need those attributes.
|
|
231
|
+
- `TG::Geometry` uses planar XY coordinates. It does not perform geocoding, routing, projections, or geodesic distance calculations.
|
data/docs/CONCURRENCY.md
CHANGED
|
@@ -20,17 +20,25 @@ A reader that already captured `old_index` can finish safely. Later readers can
|
|
|
20
20
|
|
|
21
21
|
## GVL policy
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
Most short operations keep the GVL:
|
|
24
24
|
|
|
25
|
-
- parse;
|
|
25
|
+
- parse helpers;
|
|
26
26
|
- writers;
|
|
27
27
|
- `Geom` predicates;
|
|
28
28
|
- Index point queries;
|
|
29
29
|
- Index rect queries;
|
|
30
30
|
- packed batch queries;
|
|
31
|
-
-
|
|
31
|
+
- rtree build/free and Ruby result materialization.
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
FeatureSource bulk methods are the exception. Their heavy source-processing phase runs without the GVL:
|
|
34
|
+
|
|
35
|
+
- `_file` methods open/read the file in C outside the GVL;
|
|
36
|
+
- JSON validation and `tidwall/json.c` traversal run outside the GVL;
|
|
37
|
+
- geometry validation/parsing with TG runs outside the GVL.
|
|
38
|
+
|
|
39
|
+
That phase uses only C-owned memory. It does not create Ruby objects, call Ruby methods, raise Ruby exceptions, or call `rb_gc_adjust_memory_usage`. Ruby ids/strings/reports and Index ownership transfer happen only after the GVL is reacquired.
|
|
40
|
+
|
|
41
|
+
FeatureSource uses only Ruby VM no-GVL APIs for this phase. On Rubies exposing `RB_NOGVL_OFFLOAD_SAFE`, the heavy function is marked offload-safe for the VM. On older Rubies it uses `rb_thread_call_without_gvl`. The gem does not call `rb_fiber_scheduler_block` / `rb_fiber_scheduler_unblock` and does not run a manual scheduler worker from C. Therefore FeatureSource releases the GVL for other Ruby threads, but explicit Fiber scheduler friendliness is only claimed when the Ruby VM provides the offload-safe no-GVL API.
|
|
34
42
|
|
|
35
43
|
## Rtree owner thread-local
|
|
36
44
|
|
|
@@ -52,9 +60,7 @@ Callback rules:
|
|
|
52
60
|
|
|
53
61
|
## Ractor
|
|
54
62
|
|
|
55
|
-
No Ractor support is claimed.
|
|
56
|
-
|
|
57
|
-
See `docs/RACTOR.md` for the unsupported-boundary notes and the requirements before this status can change.
|
|
63
|
+
No Ractor support is claimed. Frozen native wrappers are not advertised as Ractor-shareable objects. Normal Ruby thread read-only access is the supported concurrency model.
|
|
58
64
|
|
|
59
65
|
## Falcon / Async
|
|
60
66
|
|