libgd-gis 0.2.8 → 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 +32 -6
- data/lib/gd/gis/layer_lines.rb +27 -0
- data/lib/gd/gis/layer_points.rb +77 -30
- data/lib/gd/gis/layer_polygons.rb +43 -8
- data/lib/gd/gis/map.rb +180 -59
- data/lib/gd/gis/middleware.rb +81 -18
- data/lib/gd/gis/ontology.rb +49 -2
- data/lib/gd/gis/ontology.yml +8 -0
- data/lib/gd/gis/projection.rb +55 -5
- data/lib/gd/gis/style.rb +91 -1
- 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:,
|
|
@@ -24,27 +67,21 @@ module GD
|
|
|
24
67
|
width: nil,
|
|
25
68
|
height: nil,
|
|
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,35 +221,86 @@ 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
|
+
|
|
229
|
+
if geom_type == "Point"
|
|
230
|
+
points_style = @style.points || begin
|
|
231
|
+
warn "Style error: missing 'points' section"
|
|
232
|
+
end
|
|
233
|
+
|
|
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
|
|
238
|
+
|
|
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
|
|
243
|
+
|
|
244
|
+
raw_color = points_style[:color]
|
|
245
|
+
color = @style.normalize_color(raw_color)
|
|
246
|
+
|
|
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
|
|
251
|
+
icon = points_style.key?(:icon) ? points_style[:icon] : nil if icon.nil?
|
|
252
|
+
|
|
253
|
+
@points_layers << GD::GIS::PointsLayer.new(
|
|
254
|
+
[feature],
|
|
255
|
+
lon: ->(f) { f.geometry["coordinates"][0] },
|
|
256
|
+
lat: ->(f) { f.geometry["coordinates"][1] },
|
|
257
|
+
icon: icon,
|
|
258
|
+
label: ->(f) { f.properties["name"] },
|
|
259
|
+
font: font,
|
|
260
|
+
size: size,
|
|
261
|
+
color: color
|
|
262
|
+
)
|
|
263
|
+
elsif LINE_GEOMS.include?(geom_type)
|
|
154
264
|
@layers[:minor] << feature
|
|
155
265
|
end
|
|
156
266
|
end
|
|
157
267
|
end
|
|
158
268
|
end
|
|
159
269
|
|
|
160
|
-
#
|
|
161
|
-
#
|
|
162
|
-
#
|
|
270
|
+
# Adds a generic points overlay layer.
|
|
271
|
+
#
|
|
272
|
+
# @param data [Enumerable]
|
|
273
|
+
# @param opts [Hash]
|
|
274
|
+
# @return [void]
|
|
163
275
|
def add_points(data, **opts)
|
|
164
276
|
@points_layers << GD::GIS::PointsLayer.new(data, **opts)
|
|
165
277
|
end
|
|
166
278
|
|
|
279
|
+
# Adds a generic lines overlay layer.
|
|
280
|
+
#
|
|
281
|
+
# @param features [Array]
|
|
282
|
+
# @param opts [Hash]
|
|
283
|
+
# @return [void]
|
|
167
284
|
def add_lines(features, **opts)
|
|
168
285
|
@lines_layers << GD::GIS::LinesLayer.new(features, **opts)
|
|
169
286
|
end
|
|
170
287
|
|
|
288
|
+
# Adds a generic polygons overlay layer.
|
|
289
|
+
#
|
|
290
|
+
# @param polygons [Array]
|
|
291
|
+
# @param opts [Hash]
|
|
292
|
+
# @return [void]
|
|
171
293
|
def add_polygons(polygons, **opts)
|
|
172
294
|
@polygons_layers << GD::GIS::PolygonsLayer.new(polygons, **opts)
|
|
173
295
|
end
|
|
174
296
|
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
#
|
|
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
|
|
178
304
|
def render
|
|
179
305
|
raise "map.style must be set" unless @style
|
|
180
306
|
|
|
@@ -184,7 +310,7 @@ module GD
|
|
|
184
310
|
render_tiles
|
|
185
311
|
end
|
|
186
312
|
end
|
|
187
|
-
|
|
313
|
+
|
|
188
314
|
def render_tiles
|
|
189
315
|
raise "map.style must be set" unless @style
|
|
190
316
|
|
|
@@ -221,12 +347,12 @@ module GD
|
|
|
221
347
|
[(x - origin_x).round, (y - origin_y).round]
|
|
222
348
|
end
|
|
223
349
|
|
|
224
|
-
# 1
|
|
350
|
+
# 1. GeoJSON semantic layers
|
|
225
351
|
@style.order.each do |kind|
|
|
226
352
|
draw_layer(kind, projection)
|
|
227
353
|
end
|
|
228
354
|
|
|
229
|
-
# 2
|
|
355
|
+
# 2. Generic overlays
|
|
230
356
|
@polygons_layers.each { |l| l.render!(@image, projection) }
|
|
231
357
|
@lines_layers.each { |l| l.render!(@image, projection) }
|
|
232
358
|
@points_layers.each { |l| l.render!(@image, projection) }
|
|
@@ -238,22 +364,16 @@ module GD
|
|
|
238
364
|
@image = GD::Image.new(@width, @height)
|
|
239
365
|
@image.antialias = false
|
|
240
366
|
|
|
241
|
-
# --------------------------------------------------
|
|
242
367
|
# 1. Compute global pixel bbox
|
|
243
|
-
# --------------------------------------------------
|
|
244
368
|
min_lng, min_lat, max_lng, max_lat = @bbox
|
|
245
369
|
|
|
246
370
|
x1, y1 = GD::GIS::Projection.lonlat_to_global_px(min_lng, max_lat, @zoom)
|
|
247
|
-
|
|
371
|
+
GD::GIS::Projection.lonlat_to_global_px(max_lng, min_lat, @zoom)
|
|
248
372
|
|
|
249
|
-
# --------------------------------------------------
|
|
250
373
|
# 2. Fetch tiles
|
|
251
|
-
# --------------------------------------------------
|
|
252
374
|
tiles, = @basemap.fetch_tiles
|
|
253
375
|
|
|
254
|
-
# --------------------------------------------------
|
|
255
376
|
# 3. Draw tiles clipped to viewport
|
|
256
|
-
# --------------------------------------------------
|
|
257
377
|
tiles.each do |x, y, file|
|
|
258
378
|
tile = GD::Image.open(file)
|
|
259
379
|
|
|
@@ -282,16 +402,12 @@ module GD
|
|
|
282
402
|
)
|
|
283
403
|
end
|
|
284
404
|
|
|
285
|
-
# --------------------------------------------------
|
|
286
405
|
# 4. Projection (viewport version)
|
|
287
|
-
# --------------------------------------------------
|
|
288
406
|
projection = lambda do |lon, lat|
|
|
289
407
|
GD::GIS::Geometry.project(lon, lat, @bbox, @zoom)
|
|
290
408
|
end
|
|
291
409
|
|
|
292
|
-
# --------------------------------------------------
|
|
293
410
|
# 5. REUSE the same render pipeline
|
|
294
|
-
# --------------------------------------------------
|
|
295
411
|
@style.order.each do |kind|
|
|
296
412
|
draw_layer(kind, projection)
|
|
297
413
|
end
|
|
@@ -301,10 +417,19 @@ module GD
|
|
|
301
417
|
@points_layers.each { |l| l.render!(@image, projection) }
|
|
302
418
|
end
|
|
303
419
|
|
|
420
|
+
# Saves the rendered image to disk.
|
|
421
|
+
#
|
|
422
|
+
# @param path [String]
|
|
423
|
+
# @return [void]
|
|
304
424
|
def save(path)
|
|
305
425
|
@image.save(path)
|
|
306
426
|
end
|
|
307
427
|
|
|
428
|
+
# Draws a semantic feature layer.
|
|
429
|
+
#
|
|
430
|
+
# @param kind [Symbol]
|
|
431
|
+
# @param projection [#call]
|
|
432
|
+
# @return [void]
|
|
308
433
|
def draw_layer(kind, projection)
|
|
309
434
|
items = @layers[kind]
|
|
310
435
|
return if items.nil? || items.empty?
|
|
@@ -347,24 +472,20 @@ module GD
|
|
|
347
472
|
f = item
|
|
348
473
|
geom = f.geometry["type"]
|
|
349
474
|
|
|
350
|
-
if geom
|
|
475
|
+
if POLY_GEOMS.include?(geom)
|
|
351
476
|
f.draw(@image, projection, nil, nil, style)
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
color = GD::Color.rgb(*style[:stroke])
|
|
477
|
+
elsif style[:stroke]
|
|
478
|
+
color = GD::Color.rgb(*style[:stroke])
|
|
355
479
|
|
|
356
|
-
|
|
480
|
+
color = GD::GIS::ColorHelpers.random_vivid if @debug
|
|
357
481
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
end
|
|
482
|
+
width = style[:stroke_width] ? style[:stroke_width].round : 1
|
|
483
|
+
width = 1 if width < 1
|
|
484
|
+
f.draw(@image, projection, color, width)
|
|
362
485
|
end
|
|
363
486
|
end
|
|
364
487
|
end
|
|
365
488
|
end
|
|
366
|
-
|
|
367
489
|
end
|
|
368
490
|
end
|
|
369
491
|
end
|
|
370
|
-
|
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
|
|
|
11
|
-
|
|
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
|
|
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 }
|
|
@@ -19,6 +62,10 @@ module GD
|
|
|
19
62
|
end
|
|
20
63
|
end
|
|
21
64
|
end
|
|
65
|
+
|
|
66
|
+
# Fallback classification
|
|
67
|
+
return :points if geometry_type == "Point"
|
|
68
|
+
|
|
22
69
|
nil
|
|
23
70
|
end
|
|
24
71
|
end
|