libgd-gis 0.2.9 → 0.3.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/README.md +132 -98
- data/lib/gd/gis/basemap.rb +69 -16
- data/lib/gd/gis/classifier.rb +48 -9
- data/lib/gd/gis/color_helpers.rb +39 -17
- data/lib/gd/gis/crs_normalizer.rb +53 -7
- data/lib/gd/gis/feature.rb +119 -36
- data/lib/gd/gis/font_helper.rb +33 -0
- data/lib/gd/gis/geometry.rb +116 -42
- data/lib/gd/gis/layer_geojson.rb +28 -4
- data/lib/gd/gis/layer_lines.rb +27 -0
- data/lib/gd/gis/layer_points.rb +69 -21
- data/lib/gd/gis/layer_polygons.rb +43 -8
- data/lib/gd/gis/map.rb +160 -66
- data/lib/gd/gis/middleware.rb +81 -18
- data/lib/gd/gis/ontology.rb +45 -2
- data/lib/gd/gis/ontology.yml +8 -0
- data/lib/gd/gis/projection.rb +55 -5
- data/lib/gd/gis/style.rb +66 -3
- data/lib/gd/gis.rb +28 -0
- data/lib/libgd_gis.rb +60 -30
- metadata +31 -6
- data/lib/gd/gis/input/detector.rb +0 -34
- data/lib/gd/gis/input/geojson.rb +0 -0
- data/lib/gd/gis/input/kml.rb +0 -0
- data/lib/gd/gis/input/shapefile.rb +0 -0
data/lib/gd/gis/map.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require_relative "basemap"
|
|
2
4
|
require_relative "projection"
|
|
3
5
|
require_relative "classifier"
|
|
@@ -6,17 +8,58 @@ require_relative "layer_points"
|
|
|
6
8
|
require_relative "layer_lines"
|
|
7
9
|
require_relative "layer_polygons"
|
|
8
10
|
|
|
11
|
+
LINE_GEOMS = %w[LineString MultiLineString].freeze
|
|
12
|
+
POLY_GEOMS = %w[Polygon MultiPolygon].freeze
|
|
13
|
+
|
|
9
14
|
module GD
|
|
10
15
|
module GIS
|
|
11
|
-
|
|
12
|
-
|
|
16
|
+
# Represents a complete renderable map.
|
|
17
|
+
#
|
|
18
|
+
# A Map is responsible for:
|
|
19
|
+
# - Managing the geographic extent (bounding box + zoom)
|
|
20
|
+
# - Fetching and compositing basemap tiles
|
|
21
|
+
# - Managing semantic feature layers
|
|
22
|
+
# - Managing generic overlay layers (points, lines, polygons)
|
|
23
|
+
# - Executing the rendering pipeline
|
|
24
|
+
#
|
|
25
|
+
# The map can render either:
|
|
26
|
+
# - A full tile-based image, or
|
|
27
|
+
# - A fixed-size viewport clipped from the basemap
|
|
28
|
+
#
|
|
13
29
|
class Map
|
|
30
|
+
# Tile size in pixels (Web Mercator standard)
|
|
14
31
|
TILE_SIZE = 256
|
|
15
32
|
|
|
33
|
+
# @return [GD::Image, nil] rendered image
|
|
16
34
|
attr_reader :image
|
|
35
|
+
|
|
36
|
+
# @return [Hash<Symbol, Array>] semantic feature layers
|
|
17
37
|
attr_reader :layers
|
|
38
|
+
|
|
39
|
+
# @return [Object, nil] style object
|
|
18
40
|
attr_accessor :style
|
|
19
41
|
|
|
42
|
+
# @return [Boolean] enables debug rendering
|
|
43
|
+
attr_reader :debug
|
|
44
|
+
|
|
45
|
+
# Creates a new map.
|
|
46
|
+
#
|
|
47
|
+
# @param bbox [Array<Float>]
|
|
48
|
+
# bounding box [min_lng, min_lat, max_lng, max_lat]
|
|
49
|
+
# @param zoom [Integer]
|
|
50
|
+
# zoom level
|
|
51
|
+
# @param basemap [Symbol]
|
|
52
|
+
# basemap provider identifier
|
|
53
|
+
# @param width [Integer, nil]
|
|
54
|
+
# viewport width in pixels
|
|
55
|
+
# @param height [Integer, nil]
|
|
56
|
+
# viewport height in pixels
|
|
57
|
+
# @param crs [String, Symbol, nil]
|
|
58
|
+
# input CRS identifier
|
|
59
|
+
# @param fitted_bbox [Boolean]
|
|
60
|
+
# whether the provided bbox is already viewport-fitted
|
|
61
|
+
#
|
|
62
|
+
# @raise [ArgumentError] if parameters are invalid
|
|
20
63
|
def initialize(
|
|
21
64
|
bbox:,
|
|
22
65
|
zoom:,
|
|
@@ -26,25 +69,19 @@ module GD
|
|
|
26
69
|
crs: nil,
|
|
27
70
|
fitted_bbox: false
|
|
28
71
|
)
|
|
29
|
-
# --------------------------------------------------
|
|
30
72
|
# 1. Basic input validation
|
|
31
|
-
# --------------------------------------------------
|
|
32
73
|
raise ArgumentError, "bbox must be [min_lng, min_lat, max_lng, max_lat]" unless
|
|
33
74
|
bbox.is_a?(Array) && bbox.size == 4
|
|
34
75
|
|
|
35
76
|
raise ArgumentError, "zoom must be an Integer" unless zoom.is_a?(Integer)
|
|
36
77
|
|
|
37
|
-
if (width && !height) || (!width && height)
|
|
38
|
-
raise ArgumentError, "width and height must be provided together"
|
|
39
|
-
end
|
|
78
|
+
raise ArgumentError, "width and height must be provided together" if (width && !height) || (!width && height)
|
|
40
79
|
|
|
41
80
|
@zoom = zoom
|
|
42
81
|
@width = width
|
|
43
82
|
@height = height
|
|
44
83
|
|
|
45
|
-
# --------------------------------------------------
|
|
46
84
|
# 2. CRS normalization (input → WGS84 lon/lat)
|
|
47
|
-
# --------------------------------------------------
|
|
48
85
|
if crs
|
|
49
86
|
normalizer = GD::GIS::CRS::Normalizer.new(crs)
|
|
50
87
|
|
|
@@ -54,9 +91,7 @@ module GD
|
|
|
54
91
|
bbox = [min_lng, min_lat, max_lng, max_lat]
|
|
55
92
|
end
|
|
56
93
|
|
|
57
|
-
# --------------------------------------------------
|
|
58
94
|
# 3. Final bbox (viewport-aware if width/height)
|
|
59
|
-
# --------------------------------------------------
|
|
60
95
|
@bbox =
|
|
61
96
|
if width && height && !fitted_bbox
|
|
62
97
|
GD::GIS::Geometry.viewport_bbox(
|
|
@@ -69,14 +104,10 @@ module GD
|
|
|
69
104
|
bbox
|
|
70
105
|
end
|
|
71
106
|
|
|
72
|
-
# --------------------------------------------------
|
|
73
107
|
# 4. Basemap (uses FINAL bbox)
|
|
74
|
-
# --------------------------------------------------
|
|
75
108
|
@basemap = GD::GIS::Basemap.new(zoom, @bbox, basemap)
|
|
76
109
|
|
|
77
|
-
# --------------------------------------------------
|
|
78
110
|
# 5. Legacy semantic layers (REQUIRED by render)
|
|
79
|
-
# --------------------------------------------------
|
|
80
111
|
@layers = {
|
|
81
112
|
motorway: [],
|
|
82
113
|
primary: [],
|
|
@@ -91,21 +122,22 @@ module GD
|
|
|
91
122
|
# Optional alias (semantic clarity, no behavior change)
|
|
92
123
|
@road_layers = @layers
|
|
93
124
|
|
|
94
|
-
# --------------------------------------------------
|
|
95
125
|
# 6. Overlay layers (generic)
|
|
96
|
-
# --------------------------------------------------
|
|
97
126
|
@points_layers = []
|
|
98
127
|
@lines_layers = []
|
|
99
128
|
@polygons_layers = []
|
|
100
129
|
|
|
101
|
-
# --------------------------------------------------
|
|
102
130
|
# 7. Style
|
|
103
|
-
# --------------------------------------------------
|
|
104
131
|
@style = nil
|
|
105
132
|
|
|
106
|
-
|
|
133
|
+
@debug = false
|
|
134
|
+
@used_labels = {}
|
|
107
135
|
end
|
|
108
136
|
|
|
137
|
+
# Returns all features belonging to a given semantic layer.
|
|
138
|
+
#
|
|
139
|
+
# @param layer [Symbol]
|
|
140
|
+
# @return [Array<Feature>]
|
|
109
141
|
def features_by_layer(layer)
|
|
110
142
|
return [] unless @layers[layer]
|
|
111
143
|
|
|
@@ -114,25 +146,68 @@ module GD
|
|
|
114
146
|
end
|
|
115
147
|
end
|
|
116
148
|
|
|
149
|
+
# Returns all features in the map.
|
|
150
|
+
#
|
|
151
|
+
# @return [Array<Feature>]
|
|
117
152
|
def features
|
|
118
153
|
@layers.values.flatten.map do |item|
|
|
119
154
|
item.is_a?(Array) ? item.last : item
|
|
120
155
|
end
|
|
121
156
|
end
|
|
122
157
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
158
|
+
def maybe_create_line_label(feature)
|
|
159
|
+
geom = feature.geometry
|
|
160
|
+
return unless LINE_GEOMS.include?(geom["type"])
|
|
161
|
+
|
|
162
|
+
name = feature.properties["name"]
|
|
163
|
+
return if name.nil? || name.empty?
|
|
164
|
+
|
|
165
|
+
key = feature.properties["wikidata"] || name
|
|
166
|
+
return if @used_labels[key]
|
|
167
|
+
|
|
168
|
+
coords = geom["coordinates"]
|
|
169
|
+
coords = coords.flatten(1) if geom["type"] == "MultiLineString"
|
|
170
|
+
return if coords.size < 2
|
|
171
|
+
|
|
172
|
+
lon, lat = coords[coords.size / 2]
|
|
173
|
+
|
|
174
|
+
puts @style
|
|
175
|
+
|
|
176
|
+
@points_layers << GD::GIS::PointsLayer.new(
|
|
177
|
+
[feature],
|
|
178
|
+
lon: ->(_) { lon },
|
|
179
|
+
lat: ->(_) { lat },
|
|
180
|
+
icon: nil,
|
|
181
|
+
label: ->(_) { name },
|
|
182
|
+
font: GD::GIS::FontHelper.random,
|
|
183
|
+
size: 10,
|
|
184
|
+
color: GD::Color.rgb(0, 0, 0)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
@used_labels[key] = true
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Loads features from a GeoJSON file.
|
|
191
|
+
#
|
|
192
|
+
# This method:
|
|
193
|
+
# - Normalizes CRS
|
|
194
|
+
# - Classifies features into semantic layers
|
|
195
|
+
# - Creates overlay layers when needed (points)
|
|
196
|
+
#
|
|
197
|
+
# @param path [String] path to GeoJSON file
|
|
198
|
+
# @return [void]
|
|
126
199
|
def add_geojson(path)
|
|
127
200
|
features = LayerGeoJSON.load(path)
|
|
128
201
|
|
|
129
202
|
features.each do |feature|
|
|
203
|
+
maybe_create_line_label(feature)
|
|
204
|
+
|
|
130
205
|
case feature.layer
|
|
131
206
|
when :water
|
|
132
207
|
kind =
|
|
133
208
|
case (feature.properties["objeto"] || feature.properties["waterway"]).to_s.downcase
|
|
134
|
-
when /river|río/
|
|
135
|
-
when /stream|arroyo/
|
|
209
|
+
when /river|río|canal/ then :river
|
|
210
|
+
when /stream|arroyo/ then :stream
|
|
136
211
|
else :minor
|
|
137
212
|
end
|
|
138
213
|
|
|
@@ -146,25 +221,33 @@ module GD
|
|
|
146
221
|
|
|
147
222
|
when :track
|
|
148
223
|
# elegí una:
|
|
149
|
-
@layers[:minor]
|
|
224
|
+
@layers[:minor] << feature
|
|
150
225
|
# o @layers[:street] << feature
|
|
151
226
|
else
|
|
152
227
|
geom_type = feature.geometry["type"]
|
|
153
228
|
|
|
154
229
|
if geom_type == "Point"
|
|
155
|
-
points_style = @style.points
|
|
156
|
-
|
|
230
|
+
points_style = @style.points || begin
|
|
231
|
+
warn "Style error: missing 'points' section"
|
|
232
|
+
end
|
|
157
233
|
|
|
158
|
-
font = points_style[:font]
|
|
159
|
-
|
|
234
|
+
font = points_style[:font] || begin
|
|
235
|
+
warn "[libgd-gis] points.font not defined in style, using random system font"
|
|
236
|
+
GD::GIS::FontHelper.random
|
|
237
|
+
end
|
|
160
238
|
|
|
161
|
-
size = points_style[:size]
|
|
162
|
-
|
|
239
|
+
size = points_style[:size] || begin
|
|
240
|
+
warn "[libgd-gis] points.font size not defined in style, using random system font size"
|
|
241
|
+
(6..14).to_a.sample
|
|
242
|
+
end
|
|
163
243
|
|
|
164
244
|
raw_color = points_style[:color]
|
|
165
245
|
color = @style.normalize_color(raw_color)
|
|
166
246
|
|
|
167
|
-
icon = points_style.key?(:icon_fill) && points_style.key?(:icon_stroke)
|
|
247
|
+
icon = if points_style.key?(:icon_fill) && points_style.key?(:icon_stroke)
|
|
248
|
+
[points_style[:icon_stroke],
|
|
249
|
+
points_style[:icon_stroke]]
|
|
250
|
+
end
|
|
168
251
|
icon = points_style.key?(:icon) ? points_style[:icon] : nil if icon.nil?
|
|
169
252
|
|
|
170
253
|
@points_layers << GD::GIS::PointsLayer.new(
|
|
@@ -172,36 +255,52 @@ module GD
|
|
|
172
255
|
lon: ->(f) { f.geometry["coordinates"][0] },
|
|
173
256
|
lat: ->(f) { f.geometry["coordinates"][1] },
|
|
174
257
|
icon: icon,
|
|
175
|
-
label: ->(f) { f.properties["name"] },
|
|
258
|
+
label: ->(f) { f.properties["name"] },
|
|
176
259
|
font: font,
|
|
177
260
|
size: size,
|
|
178
261
|
color: color
|
|
179
262
|
)
|
|
180
|
-
elsif geom_type
|
|
263
|
+
elsif LINE_GEOMS.include?(geom_type)
|
|
181
264
|
@layers[:minor] << feature
|
|
182
265
|
end
|
|
183
266
|
end
|
|
184
267
|
end
|
|
185
268
|
end
|
|
186
269
|
|
|
187
|
-
#
|
|
188
|
-
#
|
|
189
|
-
#
|
|
270
|
+
# Adds a generic points overlay layer.
|
|
271
|
+
#
|
|
272
|
+
# @param data [Enumerable]
|
|
273
|
+
# @param opts [Hash]
|
|
274
|
+
# @return [void]
|
|
190
275
|
def add_points(data, **opts)
|
|
191
276
|
@points_layers << GD::GIS::PointsLayer.new(data, **opts)
|
|
192
277
|
end
|
|
193
278
|
|
|
279
|
+
# Adds a generic lines overlay layer.
|
|
280
|
+
#
|
|
281
|
+
# @param features [Array]
|
|
282
|
+
# @param opts [Hash]
|
|
283
|
+
# @return [void]
|
|
194
284
|
def add_lines(features, **opts)
|
|
195
285
|
@lines_layers << GD::GIS::LinesLayer.new(features, **opts)
|
|
196
286
|
end
|
|
197
287
|
|
|
288
|
+
# Adds a generic polygons overlay layer.
|
|
289
|
+
#
|
|
290
|
+
# @param polygons [Array]
|
|
291
|
+
# @param opts [Hash]
|
|
292
|
+
# @return [void]
|
|
198
293
|
def add_polygons(polygons, **opts)
|
|
199
294
|
@polygons_layers << GD::GIS::PolygonsLayer.new(polygons, **opts)
|
|
200
295
|
end
|
|
201
296
|
|
|
202
|
-
#
|
|
203
|
-
#
|
|
204
|
-
#
|
|
297
|
+
# Renders the map.
|
|
298
|
+
#
|
|
299
|
+
# Chooses between tile rendering and viewport rendering
|
|
300
|
+
# depending on whether width and height are set.
|
|
301
|
+
#
|
|
302
|
+
# @return [void]
|
|
303
|
+
# @raise [RuntimeError] if style is not set
|
|
205
304
|
def render
|
|
206
305
|
raise "map.style must be set" unless @style
|
|
207
306
|
|
|
@@ -211,7 +310,7 @@ module GD
|
|
|
211
310
|
render_tiles
|
|
212
311
|
end
|
|
213
312
|
end
|
|
214
|
-
|
|
313
|
+
|
|
215
314
|
def render_tiles
|
|
216
315
|
raise "map.style must be set" unless @style
|
|
217
316
|
|
|
@@ -248,12 +347,12 @@ module GD
|
|
|
248
347
|
[(x - origin_x).round, (y - origin_y).round]
|
|
249
348
|
end
|
|
250
349
|
|
|
251
|
-
# 1
|
|
350
|
+
# 1. GeoJSON semantic layers
|
|
252
351
|
@style.order.each do |kind|
|
|
253
352
|
draw_layer(kind, projection)
|
|
254
353
|
end
|
|
255
354
|
|
|
256
|
-
# 2
|
|
355
|
+
# 2. Generic overlays
|
|
257
356
|
@polygons_layers.each { |l| l.render!(@image, projection) }
|
|
258
357
|
@lines_layers.each { |l| l.render!(@image, projection) }
|
|
259
358
|
@points_layers.each { |l| l.render!(@image, projection) }
|
|
@@ -265,22 +364,16 @@ module GD
|
|
|
265
364
|
@image = GD::Image.new(@width, @height)
|
|
266
365
|
@image.antialias = false
|
|
267
366
|
|
|
268
|
-
# --------------------------------------------------
|
|
269
367
|
# 1. Compute global pixel bbox
|
|
270
|
-
# --------------------------------------------------
|
|
271
368
|
min_lng, min_lat, max_lng, max_lat = @bbox
|
|
272
369
|
|
|
273
370
|
x1, y1 = GD::GIS::Projection.lonlat_to_global_px(min_lng, max_lat, @zoom)
|
|
274
|
-
|
|
371
|
+
GD::GIS::Projection.lonlat_to_global_px(max_lng, min_lat, @zoom)
|
|
275
372
|
|
|
276
|
-
# --------------------------------------------------
|
|
277
373
|
# 2. Fetch tiles
|
|
278
|
-
# --------------------------------------------------
|
|
279
374
|
tiles, = @basemap.fetch_tiles
|
|
280
375
|
|
|
281
|
-
# --------------------------------------------------
|
|
282
376
|
# 3. Draw tiles clipped to viewport
|
|
283
|
-
# --------------------------------------------------
|
|
284
377
|
tiles.each do |x, y, file|
|
|
285
378
|
tile = GD::Image.open(file)
|
|
286
379
|
|
|
@@ -309,16 +402,12 @@ module GD
|
|
|
309
402
|
)
|
|
310
403
|
end
|
|
311
404
|
|
|
312
|
-
# --------------------------------------------------
|
|
313
405
|
# 4. Projection (viewport version)
|
|
314
|
-
# --------------------------------------------------
|
|
315
406
|
projection = lambda do |lon, lat|
|
|
316
407
|
GD::GIS::Geometry.project(lon, lat, @bbox, @zoom)
|
|
317
408
|
end
|
|
318
409
|
|
|
319
|
-
# --------------------------------------------------
|
|
320
410
|
# 5. REUSE the same render pipeline
|
|
321
|
-
# --------------------------------------------------
|
|
322
411
|
@style.order.each do |kind|
|
|
323
412
|
draw_layer(kind, projection)
|
|
324
413
|
end
|
|
@@ -328,10 +417,19 @@ module GD
|
|
|
328
417
|
@points_layers.each { |l| l.render!(@image, projection) }
|
|
329
418
|
end
|
|
330
419
|
|
|
420
|
+
# Saves the rendered image to disk.
|
|
421
|
+
#
|
|
422
|
+
# @param path [String]
|
|
423
|
+
# @return [void]
|
|
331
424
|
def save(path)
|
|
332
425
|
@image.save(path)
|
|
333
426
|
end
|
|
334
427
|
|
|
428
|
+
# Draws a semantic feature layer.
|
|
429
|
+
#
|
|
430
|
+
# @param kind [Symbol]
|
|
431
|
+
# @param projection [#call]
|
|
432
|
+
# @return [void]
|
|
335
433
|
def draw_layer(kind, projection)
|
|
336
434
|
items = @layers[kind]
|
|
337
435
|
return if items.nil? || items.empty?
|
|
@@ -374,24 +472,20 @@ module GD
|
|
|
374
472
|
f = item
|
|
375
473
|
geom = f.geometry["type"]
|
|
376
474
|
|
|
377
|
-
if geom
|
|
475
|
+
if POLY_GEOMS.include?(geom)
|
|
378
476
|
f.draw(@image, projection, nil, nil, style)
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
color = GD::Color.rgb(*style[:stroke])
|
|
477
|
+
elsif style[:stroke]
|
|
478
|
+
color = GD::Color.rgb(*style[:stroke])
|
|
382
479
|
|
|
383
|
-
|
|
480
|
+
color = GD::GIS::ColorHelpers.random_vivid if @debug
|
|
384
481
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
end
|
|
482
|
+
width = style[:stroke_width] ? style[:stroke_width].round : 1
|
|
483
|
+
width = 1 if width < 1
|
|
484
|
+
f.draw(@image, projection, color, width)
|
|
389
485
|
end
|
|
390
486
|
end
|
|
391
487
|
end
|
|
392
488
|
end
|
|
393
|
-
|
|
394
489
|
end
|
|
395
490
|
end
|
|
396
491
|
end
|
|
397
|
-
|
data/lib/gd/gis/middleware.rb
CHANGED
|
@@ -1,17 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module GD
|
|
2
4
|
module GIS
|
|
5
|
+
# Coordinate Reference System (CRS) helpers and normalizers.
|
|
6
|
+
#
|
|
7
|
+
# This module defines commonly used CRS identifiers and
|
|
8
|
+
# utilities for normalizing coordinates into a single,
|
|
9
|
+
# consistent representation.
|
|
10
|
+
#
|
|
3
11
|
module CRS
|
|
12
|
+
# OGC CRS84 (longitude, latitude)
|
|
4
13
|
CRS84 = "urn:ogc:def:crs:OGC:1.3:CRS84"
|
|
14
|
+
|
|
15
|
+
# EPSG:4326 (latitude, longitude axis order)
|
|
5
16
|
EPSG4326 = "EPSG:4326"
|
|
17
|
+
|
|
18
|
+
# EPSG:3857 (Web Mercator)
|
|
6
19
|
EPSG3857 = "EPSG:3857"
|
|
7
|
-
GK_ARGENTINA = "EPSG:22195" # Gauss–Krüger Argentina (zone 5 example)
|
|
8
20
|
|
|
9
|
-
#
|
|
21
|
+
# Gauss–Krüger Argentina, zone 5
|
|
22
|
+
#
|
|
23
|
+
# Note: This constant represents a *specific* GK zone
|
|
24
|
+
# and is not a generic Gauss–Krüger definition.
|
|
25
|
+
GK_ARGENTINA = "EPSG:22195"
|
|
26
|
+
|
|
27
|
+
# Normalizes coordinates from supported CRS definitions
|
|
28
|
+
# into CRS84 (longitude, latitude in degrees).
|
|
29
|
+
#
|
|
30
|
+
# Supported input CRS:
|
|
31
|
+
# - CRS84
|
|
32
|
+
# - EPSG:4326 (axis order normalization)
|
|
33
|
+
# - EPSG:3857 (Web Mercator)
|
|
34
|
+
# - EPSG:22195 (Gauss–Krüger Argentina, zone 5)
|
|
35
|
+
#
|
|
36
|
+
# All outputs are returned as:
|
|
37
|
+
# [longitude, latitude] in degrees
|
|
38
|
+
#
|
|
39
|
+
# ⚠️ Projection conversions are intended for mapping
|
|
40
|
+
# and visualization, not for high-precision geodesy.
|
|
41
|
+
#
|
|
10
42
|
class Normalizer
|
|
43
|
+
# Creates a new CRS normalizer.
|
|
44
|
+
#
|
|
45
|
+
# @param crs [String, Symbol, nil]
|
|
46
|
+
# CRS identifier; defaults to CRS84 if nil
|
|
11
47
|
def initialize(crs)
|
|
12
48
|
@crs = normalize_name(crs)
|
|
13
49
|
end
|
|
14
50
|
|
|
51
|
+
# Normalizes a coordinate pair into CRS84.
|
|
52
|
+
#
|
|
53
|
+
# @param lon [Numeric]
|
|
54
|
+
# first coordinate (meaning depends on input CRS)
|
|
55
|
+
# @param lat [Numeric]
|
|
56
|
+
# second coordinate (meaning depends on input CRS)
|
|
57
|
+
#
|
|
58
|
+
# @return [Array<Float>]
|
|
59
|
+
# normalized [longitude, latitude] in degrees
|
|
60
|
+
#
|
|
61
|
+
# @raise [RuntimeError]
|
|
62
|
+
# if the CRS is not supported
|
|
15
63
|
def normalize(lon, lat)
|
|
16
64
|
case @crs
|
|
17
65
|
when CRS84
|
|
@@ -34,52 +82,67 @@ module GD
|
|
|
34
82
|
|
|
35
83
|
private
|
|
36
84
|
|
|
85
|
+
# Normalizes a CRS name into a comparable string.
|
|
86
|
+
#
|
|
87
|
+
# @param name [Object]
|
|
88
|
+
# @return [String]
|
|
37
89
|
def normalize_name(name)
|
|
38
90
|
return CRS84 if name.nil?
|
|
91
|
+
|
|
39
92
|
name.to_s.strip
|
|
40
93
|
end
|
|
41
94
|
|
|
42
|
-
# Web Mercator
|
|
95
|
+
# Converts Web Mercator coordinates to WGS84.
|
|
96
|
+
#
|
|
97
|
+
# @param x [Numeric] X coordinate in meters
|
|
98
|
+
# @param y [Numeric] Y coordinate in meters
|
|
99
|
+
# @return [Array<Float>] [longitude, latitude] in degrees
|
|
43
100
|
def mercator_to_wgs84(x, y)
|
|
44
101
|
r = 6378137.0
|
|
45
102
|
lon = (x / r) * 180.0 / Math::PI
|
|
46
|
-
lat = (2 * Math.atan(Math.exp(y / r)) - Math::PI / 2) * 180.0 / Math::PI
|
|
103
|
+
lat = ((2 * Math.atan(Math.exp(y / r))) - (Math::PI / 2)) * 180.0 / Math::PI
|
|
47
104
|
[lon, lat]
|
|
48
105
|
end
|
|
49
106
|
|
|
50
|
-
# Gauss–Krüger Argentina (
|
|
51
|
-
#
|
|
107
|
+
# Converts Gauss–Krüger Argentina (zone 5) coordinates to WGS84.
|
|
108
|
+
#
|
|
109
|
+
# This implementation provides sufficient accuracy for
|
|
110
|
+
# cartographic rendering and visualization.
|
|
111
|
+
#
|
|
112
|
+
# @param easting [Numeric] easting (meters)
|
|
113
|
+
# @param northing [Numeric] northing (meters)
|
|
114
|
+
# @return [Array<Float>] [longitude, latitude] in degrees
|
|
52
115
|
def gk_to_wgs84(easting, northing)
|
|
53
116
|
# Parameters for Argentina GK Zone 5
|
|
54
117
|
a = 6378137.0
|
|
55
118
|
f = 1 / 298.257223563
|
|
56
|
-
e2 = 2*f - f*f
|
|
57
|
-
lon0 = -60.0 * Math::PI / 180.0
|
|
119
|
+
e2 = (2 * f) - (f * f)
|
|
120
|
+
lon0 = -60.0 * Math::PI / 180.0 # central meridian zone 5
|
|
58
121
|
|
|
59
122
|
x = easting - 500000.0
|
|
60
123
|
y = northing
|
|
61
124
|
|
|
62
125
|
m = y
|
|
63
|
-
mu = m / (a * (1 - e2/4 - 3*e2*e2/64))
|
|
126
|
+
mu = m / (a * (1 - (e2 / 4) - (3 * e2 * e2 / 64)))
|
|
64
127
|
|
|
65
128
|
e1 = (1 - Math.sqrt(1 - e2)) / (1 + Math.sqrt(1 - e2))
|
|
66
129
|
|
|
67
|
-
j1 = 3*e1/2 - 27*e1**3/32
|
|
68
|
-
j2 = 21*e1**2/16 - 55*e1**4/32
|
|
130
|
+
j1 = (3 * e1 / 2) - (27 * (e1**3) / 32)
|
|
131
|
+
j2 = (21 * (e1**2) / 16) - (55 * (e1**4) / 32)
|
|
69
132
|
|
|
70
|
-
fp = mu + j1*Math.sin(2*mu) + j2*Math.sin(4*mu)
|
|
133
|
+
fp = mu + (j1 * Math.sin(2 * mu)) + (j2 * Math.sin(4 * mu))
|
|
71
134
|
|
|
72
|
-
c1 = e2 * Math.cos(fp)**2
|
|
135
|
+
c1 = e2 * (Math.cos(fp)**2)
|
|
73
136
|
t1 = Math.tan(fp)**2
|
|
74
|
-
r1 = a * (1 - e2) / (1 - e2 * Math.sin(fp)**2)**1.5
|
|
75
|
-
n1 = a / Math.sqrt(1 - e2 * Math.sin(fp)**2)
|
|
137
|
+
r1 = a * (1 - e2) / ((1 - (e2 * (Math.sin(fp)**2)))**1.5)
|
|
138
|
+
n1 = a / Math.sqrt(1 - (e2 * (Math.sin(fp)**2)))
|
|
76
139
|
|
|
77
140
|
d = x / n1
|
|
78
141
|
|
|
79
|
-
lat = fp - (n1*Math.tan(fp)/r1) *
|
|
80
|
-
|
|
142
|
+
lat = fp - ((n1 * Math.tan(fp) / r1) *
|
|
143
|
+
(((d**2) / 2) - ((5 + (3 * t1) + (10 * c1)) * (d**4) / 24)))
|
|
81
144
|
|
|
82
|
-
lon = lon0 + (d - (1 + 2*t1 + c1)*d**3/6) / Math.cos(fp)
|
|
145
|
+
lon = lon0 + ((d - ((1 + (2 * t1) + c1) * (d**3) / 6)) / Math.cos(fp))
|
|
83
146
|
|
|
84
147
|
[lon * 180.0 / Math::PI, lat * 180.0 / Math::PI]
|
|
85
148
|
end
|
data/lib/gd/gis/ontology.rb
CHANGED
|
@@ -1,16 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require "yaml"
|
|
2
4
|
|
|
3
5
|
module GD
|
|
4
6
|
module GIS
|
|
7
|
+
# Classifies features into semantic layers using rule-based matching.
|
|
8
|
+
#
|
|
9
|
+
# An Ontology maps feature properties to logical layer identifiers
|
|
10
|
+
# (e.g. :water, :road, :park) based on a YAML rule definition.
|
|
11
|
+
#
|
|
12
|
+
# The ontology is intentionally simple and heuristic-based:
|
|
13
|
+
# - Rules are evaluated in order
|
|
14
|
+
# - The first matching rule wins
|
|
15
|
+
# - Matching is case-insensitive and substring-based
|
|
16
|
+
#
|
|
17
|
+
# This design favors robustness and flexibility over strict
|
|
18
|
+
# schema enforcement.
|
|
19
|
+
#
|
|
5
20
|
class Ontology
|
|
21
|
+
# Creates a new ontology.
|
|
22
|
+
#
|
|
23
|
+
# @param path [String, nil]
|
|
24
|
+
# path to a YAML ontology file; defaults to `ontology.yml`
|
|
25
|
+
# shipped with the gem
|
|
26
|
+
#
|
|
27
|
+
# @raise [Errno::ENOENT] if the file does not exist
|
|
28
|
+
# @raise [Psych::SyntaxError] if the YAML is invalid
|
|
6
29
|
def initialize(path = nil)
|
|
7
30
|
path ||= File.expand_path("ontology.yml", __dir__)
|
|
8
31
|
@rules = YAML.load_file(path)
|
|
9
32
|
end
|
|
10
33
|
|
|
34
|
+
# Classifies a feature into a semantic layer.
|
|
35
|
+
#
|
|
36
|
+
# Properties are matched against ontology rules using
|
|
37
|
+
# case-insensitive substring comparison.
|
|
38
|
+
#
|
|
39
|
+
# @param properties [Hash]
|
|
40
|
+
# feature attributes / tags
|
|
41
|
+
# @param geometry_type [String, nil]
|
|
42
|
+
# GeoJSON geometry type
|
|
43
|
+
#
|
|
44
|
+
# @return [Symbol, nil]
|
|
45
|
+
# semantic layer identifier, or nil if no rule matches
|
|
46
|
+
#
|
|
47
|
+
# @example
|
|
48
|
+
# ontology.classify({ "waterway" => "river" })
|
|
49
|
+
# #=> :water
|
|
50
|
+
#
|
|
51
|
+
# @example
|
|
52
|
+
# ontology.classify({}, geometry_type: "Point")
|
|
53
|
+
# #=> :points
|
|
11
54
|
def classify(properties, geometry_type: nil)
|
|
12
55
|
@rules.each do |layer, sources|
|
|
13
|
-
sources.
|
|
56
|
+
sources.each_value do |rules|
|
|
14
57
|
rules.each do |key, values|
|
|
15
58
|
v = (properties[key.to_s] || properties[key.to_sym]).to_s.strip.downcase
|
|
16
59
|
values = values.map { |x| x.to_s.downcase }
|
|
@@ -20,11 +63,11 @@ module GD
|
|
|
20
63
|
end
|
|
21
64
|
end
|
|
22
65
|
|
|
66
|
+
# Fallback classification
|
|
23
67
|
return :points if geometry_type == "Point"
|
|
24
68
|
|
|
25
69
|
nil
|
|
26
70
|
end
|
|
27
|
-
|
|
28
71
|
end
|
|
29
72
|
end
|
|
30
73
|
end
|