libgd-gis 0.2.9 → 0.3.1
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 +92 -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 +71 -22
- data/lib/gd/gis/layer_polygons.rb +43 -8
- data/lib/gd/gis/map.rb +199 -74
- data/lib/gd/gis/middleware.rb +81 -18
- data/lib/gd/gis/ontology.rb +45 -2
- data/lib/gd/gis/ontology.yml +7 -0
- data/lib/gd/gis/projection.rb +55 -5
- data/lib/gd/gis/style.rb +76 -3
- data/lib/gd/gis.rb +28 -0
- data/lib/libgd_gis.rb +60 -30
- data/lib/test.rb +4 -0
- metadata +7 -11
- 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,19 +104,16 @@ 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: [],
|
|
83
114
|
secondary: [],
|
|
84
115
|
street: [],
|
|
116
|
+
track: [],
|
|
85
117
|
minor: [],
|
|
86
118
|
rail: [],
|
|
87
119
|
water: [],
|
|
@@ -91,21 +123,22 @@ module GD
|
|
|
91
123
|
# Optional alias (semantic clarity, no behavior change)
|
|
92
124
|
@road_layers = @layers
|
|
93
125
|
|
|
94
|
-
# --------------------------------------------------
|
|
95
126
|
# 6. Overlay layers (generic)
|
|
96
|
-
# --------------------------------------------------
|
|
97
127
|
@points_layers = []
|
|
98
128
|
@lines_layers = []
|
|
99
129
|
@polygons_layers = []
|
|
100
130
|
|
|
101
|
-
# --------------------------------------------------
|
|
102
131
|
# 7. Style
|
|
103
|
-
# --------------------------------------------------
|
|
104
132
|
@style = nil
|
|
105
133
|
|
|
106
|
-
|
|
134
|
+
@debug = false
|
|
135
|
+
@used_labels = {}
|
|
107
136
|
end
|
|
108
137
|
|
|
138
|
+
# Returns all features belonging to a given semantic layer.
|
|
139
|
+
#
|
|
140
|
+
# @param layer [Symbol]
|
|
141
|
+
# @return [Array<Feature>]
|
|
109
142
|
def features_by_layer(layer)
|
|
110
143
|
return [] unless @layers[layer]
|
|
111
144
|
|
|
@@ -114,25 +147,96 @@ module GD
|
|
|
114
147
|
end
|
|
115
148
|
end
|
|
116
149
|
|
|
150
|
+
# Returns all features in the map.
|
|
151
|
+
#
|
|
152
|
+
# @return [Array<Feature>]
|
|
117
153
|
def features
|
|
118
154
|
@layers.values.flatten.map do |item|
|
|
119
155
|
item.is_a?(Array) ? item.last : item
|
|
120
156
|
end
|
|
121
157
|
end
|
|
122
158
|
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
#
|
|
159
|
+
# Creates a single text label for a named linear feature (LineString or
|
|
160
|
+
# MultiLineString), avoiding duplicate labels for the same named entity.
|
|
161
|
+
#
|
|
162
|
+
# Many datasets (especially OSM) split a single logical entity
|
|
163
|
+
# (rivers, streets, railways, etc.) into multiple line features that
|
|
164
|
+
# all share the same name. This method ensures that:
|
|
165
|
+
#
|
|
166
|
+
# - Only one label is created per unique entity name
|
|
167
|
+
# - The label is placed on a representative segment of the geometry
|
|
168
|
+
# - The logic is independent of the feature's semantic layer (water, road, rail)
|
|
169
|
+
#
|
|
170
|
+
# Labels are rendered using a PointsLayer because libgd-gis does not
|
|
171
|
+
# support text rendering directly on line geometries.
|
|
172
|
+
#
|
|
173
|
+
# The label position is chosen as the midpoint of the line coordinates.
|
|
174
|
+
# This is a simple heuristic that provides a reasonable placement without
|
|
175
|
+
# requiring geometry merging or topological analysis.
|
|
176
|
+
#
|
|
177
|
+
# @param feature [GD::GIS::Feature]
|
|
178
|
+
# A feature with a linear geometry and a "name" property.
|
|
179
|
+
#
|
|
180
|
+
# @return [void]
|
|
181
|
+
# Adds a PointsLayer to @points_layers if a label is created.
|
|
182
|
+
#
|
|
183
|
+
# @note
|
|
184
|
+
# This method must be called during feature loading (add_geojson),
|
|
185
|
+
# before rendering. It intentionally does not depend on map style
|
|
186
|
+
# configuration, which is applied later during rendering.
|
|
187
|
+
def maybe_create_line_label(feature)
|
|
188
|
+
return true if @style.global[:label] == false || @style.global[:label].nil?
|
|
189
|
+
|
|
190
|
+
geom = feature.geometry
|
|
191
|
+
return unless LINE_GEOMS.include?(geom["type"])
|
|
192
|
+
|
|
193
|
+
name = feature.properties["name"]
|
|
194
|
+
return if name.nil? || name.empty?
|
|
195
|
+
|
|
196
|
+
key = feature.properties["wikidata"] || name
|
|
197
|
+
return if @used_labels[key]
|
|
198
|
+
|
|
199
|
+
coords = geom["coordinates"]
|
|
200
|
+
coords = coords.flatten(1) if geom["type"] == "MultiLineString"
|
|
201
|
+
return if coords.size < 2
|
|
202
|
+
|
|
203
|
+
lon, lat = coords[coords.size / 2]
|
|
204
|
+
|
|
205
|
+
@points_layers << GD::GIS::PointsLayer.new(
|
|
206
|
+
[feature],
|
|
207
|
+
lon: ->(_) { lon },
|
|
208
|
+
lat: ->(_) { lat },
|
|
209
|
+
icon: @style.global[:label][:icon],
|
|
210
|
+
label: ->(_) { name },
|
|
211
|
+
font: @style.global[:label][:font] || GD::GIS::FontHelper.random,
|
|
212
|
+
size: @style.global[:label][:size] || (6..20).to_a.sample,
|
|
213
|
+
color: @style.global[:label][:color] || GD::GIS::ColorHelpers.random_rgba
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
@used_labels[key] = true
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Loads features from a GeoJSON file.
|
|
220
|
+
#
|
|
221
|
+
# This method:
|
|
222
|
+
# - Normalizes CRS
|
|
223
|
+
# - Classifies features into semantic layers
|
|
224
|
+
# - Creates overlay layers when needed (points)
|
|
225
|
+
#
|
|
226
|
+
# @param path [String] path to GeoJSON file
|
|
227
|
+
# @return [void]
|
|
126
228
|
def add_geojson(path)
|
|
127
229
|
features = LayerGeoJSON.load(path)
|
|
128
230
|
|
|
129
231
|
features.each do |feature|
|
|
232
|
+
maybe_create_line_label(feature)
|
|
233
|
+
|
|
130
234
|
case feature.layer
|
|
131
235
|
when :water
|
|
132
236
|
kind =
|
|
133
237
|
case (feature.properties["objeto"] || feature.properties["waterway"]).to_s.downcase
|
|
134
|
-
when /river|río/
|
|
135
|
-
when /stream|arroyo/
|
|
238
|
+
when /river|río|canal/ then :river
|
|
239
|
+
when /stream|arroyo/ then :stream
|
|
136
240
|
else :minor
|
|
137
241
|
end
|
|
138
242
|
|
|
@@ -145,26 +249,32 @@ module GD
|
|
|
145
249
|
@layers[:park] << feature
|
|
146
250
|
|
|
147
251
|
when :track
|
|
148
|
-
|
|
149
|
-
@layers[:minor] << feature
|
|
150
|
-
# o @layers[:street] << feature
|
|
252
|
+
@layers[:track] << feature
|
|
151
253
|
else
|
|
152
254
|
geom_type = feature.geometry["type"]
|
|
153
255
|
|
|
154
256
|
if geom_type == "Point"
|
|
155
|
-
points_style = @style.points
|
|
156
|
-
|
|
257
|
+
points_style = @style.points || begin
|
|
258
|
+
warn "Style error: missing 'points' section"
|
|
259
|
+
end
|
|
157
260
|
|
|
158
|
-
font = points_style[:font]
|
|
159
|
-
|
|
261
|
+
font = points_style[:font] || begin
|
|
262
|
+
warn "[libgd-gis] points.font not defined in style, using random system font"
|
|
263
|
+
GD::GIS::FontHelper.random
|
|
264
|
+
end
|
|
160
265
|
|
|
161
|
-
size = points_style[:size]
|
|
162
|
-
|
|
266
|
+
size = points_style[:size] || begin
|
|
267
|
+
warn "[libgd-gis] points.font size not defined in style, using random system font size"
|
|
268
|
+
(6..14).to_a.sample
|
|
269
|
+
end
|
|
163
270
|
|
|
164
271
|
raw_color = points_style[:color]
|
|
165
272
|
color = @style.normalize_color(raw_color)
|
|
166
273
|
|
|
167
|
-
icon = points_style.key?(:icon_fill) && points_style.key?(:icon_stroke)
|
|
274
|
+
icon = if points_style.key?(:icon_fill) && points_style.key?(:icon_stroke)
|
|
275
|
+
[points_style[:icon_stroke],
|
|
276
|
+
points_style[:icon_stroke]]
|
|
277
|
+
end
|
|
168
278
|
icon = points_style.key?(:icon) ? points_style[:icon] : nil if icon.nil?
|
|
169
279
|
|
|
170
280
|
@points_layers << GD::GIS::PointsLayer.new(
|
|
@@ -172,36 +282,52 @@ module GD
|
|
|
172
282
|
lon: ->(f) { f.geometry["coordinates"][0] },
|
|
173
283
|
lat: ->(f) { f.geometry["coordinates"][1] },
|
|
174
284
|
icon: icon,
|
|
175
|
-
label: ->(f) { f.properties["name"] },
|
|
285
|
+
label: ->(f) { f.properties["name"] },
|
|
176
286
|
font: font,
|
|
177
287
|
size: size,
|
|
178
288
|
color: color
|
|
179
289
|
)
|
|
180
|
-
elsif geom_type
|
|
290
|
+
elsif LINE_GEOMS.include?(geom_type)
|
|
181
291
|
@layers[:minor] << feature
|
|
182
292
|
end
|
|
183
293
|
end
|
|
184
294
|
end
|
|
185
295
|
end
|
|
186
296
|
|
|
187
|
-
#
|
|
188
|
-
#
|
|
189
|
-
#
|
|
190
|
-
|
|
191
|
-
|
|
297
|
+
# Adds a generic points overlay layer.
|
|
298
|
+
#
|
|
299
|
+
# @param data [Enumerable]
|
|
300
|
+
# @param opts [Hash]
|
|
301
|
+
# @return [void]
|
|
302
|
+
def add_points(data, **)
|
|
303
|
+
@points_layers << GD::GIS::PointsLayer.new(data, **)
|
|
192
304
|
end
|
|
193
305
|
|
|
194
|
-
|
|
195
|
-
|
|
306
|
+
# Adds a generic lines overlay layer.
|
|
307
|
+
#
|
|
308
|
+
# @param features [Array]
|
|
309
|
+
# @param opts [Hash]
|
|
310
|
+
# @return [void]
|
|
311
|
+
def add_lines(features, **)
|
|
312
|
+
@lines_layers << GD::GIS::LinesLayer.new(features, **)
|
|
196
313
|
end
|
|
197
314
|
|
|
198
|
-
|
|
199
|
-
|
|
315
|
+
# Adds a generic polygons overlay layer.
|
|
316
|
+
#
|
|
317
|
+
# @param polygons [Array]
|
|
318
|
+
# @param opts [Hash]
|
|
319
|
+
# @return [void]
|
|
320
|
+
def add_polygons(polygons, **)
|
|
321
|
+
@polygons_layers << GD::GIS::PolygonsLayer.new(polygons, **)
|
|
200
322
|
end
|
|
201
323
|
|
|
202
|
-
#
|
|
203
|
-
#
|
|
204
|
-
#
|
|
324
|
+
# Renders the map.
|
|
325
|
+
#
|
|
326
|
+
# Chooses between tile rendering and viewport rendering
|
|
327
|
+
# depending on whether width and height are set.
|
|
328
|
+
#
|
|
329
|
+
# @return [void]
|
|
330
|
+
# @raise [RuntimeError] if style is not set
|
|
205
331
|
def render
|
|
206
332
|
raise "map.style must be set" unless @style
|
|
207
333
|
|
|
@@ -211,7 +337,7 @@ module GD
|
|
|
211
337
|
render_tiles
|
|
212
338
|
end
|
|
213
339
|
end
|
|
214
|
-
|
|
340
|
+
|
|
215
341
|
def render_tiles
|
|
216
342
|
raise "map.style must be set" unless @style
|
|
217
343
|
|
|
@@ -248,12 +374,12 @@ module GD
|
|
|
248
374
|
[(x - origin_x).round, (y - origin_y).round]
|
|
249
375
|
end
|
|
250
376
|
|
|
251
|
-
# 1
|
|
377
|
+
# 1. GeoJSON semantic layers
|
|
252
378
|
@style.order.each do |kind|
|
|
253
379
|
draw_layer(kind, projection)
|
|
254
380
|
end
|
|
255
381
|
|
|
256
|
-
# 2
|
|
382
|
+
# 2. Generic overlays
|
|
257
383
|
@polygons_layers.each { |l| l.render!(@image, projection) }
|
|
258
384
|
@lines_layers.each { |l| l.render!(@image, projection) }
|
|
259
385
|
@points_layers.each { |l| l.render!(@image, projection) }
|
|
@@ -265,22 +391,16 @@ module GD
|
|
|
265
391
|
@image = GD::Image.new(@width, @height)
|
|
266
392
|
@image.antialias = false
|
|
267
393
|
|
|
268
|
-
# --------------------------------------------------
|
|
269
394
|
# 1. Compute global pixel bbox
|
|
270
|
-
# --------------------------------------------------
|
|
271
395
|
min_lng, min_lat, max_lng, max_lat = @bbox
|
|
272
396
|
|
|
273
397
|
x1, y1 = GD::GIS::Projection.lonlat_to_global_px(min_lng, max_lat, @zoom)
|
|
274
|
-
|
|
398
|
+
GD::GIS::Projection.lonlat_to_global_px(max_lng, min_lat, @zoom)
|
|
275
399
|
|
|
276
|
-
# --------------------------------------------------
|
|
277
400
|
# 2. Fetch tiles
|
|
278
|
-
# --------------------------------------------------
|
|
279
401
|
tiles, = @basemap.fetch_tiles
|
|
280
402
|
|
|
281
|
-
# --------------------------------------------------
|
|
282
403
|
# 3. Draw tiles clipped to viewport
|
|
283
|
-
# --------------------------------------------------
|
|
284
404
|
tiles.each do |x, y, file|
|
|
285
405
|
tile = GD::Image.open(file)
|
|
286
406
|
|
|
@@ -309,16 +429,12 @@ module GD
|
|
|
309
429
|
)
|
|
310
430
|
end
|
|
311
431
|
|
|
312
|
-
# --------------------------------------------------
|
|
313
432
|
# 4. Projection (viewport version)
|
|
314
|
-
# --------------------------------------------------
|
|
315
433
|
projection = lambda do |lon, lat|
|
|
316
434
|
GD::GIS::Geometry.project(lon, lat, @bbox, @zoom)
|
|
317
435
|
end
|
|
318
436
|
|
|
319
|
-
# --------------------------------------------------
|
|
320
437
|
# 5. REUSE the same render pipeline
|
|
321
|
-
# --------------------------------------------------
|
|
322
438
|
@style.order.each do |kind|
|
|
323
439
|
draw_layer(kind, projection)
|
|
324
440
|
end
|
|
@@ -328,10 +444,19 @@ module GD
|
|
|
328
444
|
@points_layers.each { |l| l.render!(@image, projection) }
|
|
329
445
|
end
|
|
330
446
|
|
|
447
|
+
# Saves the rendered image to disk.
|
|
448
|
+
#
|
|
449
|
+
# @param path [String]
|
|
450
|
+
# @return [void]
|
|
331
451
|
def save(path)
|
|
332
452
|
@image.save(path)
|
|
333
453
|
end
|
|
334
454
|
|
|
455
|
+
# Draws a semantic feature layer.
|
|
456
|
+
#
|
|
457
|
+
# @param kind [Symbol]
|
|
458
|
+
# @param projection [#call]
|
|
459
|
+
# @return [void]
|
|
335
460
|
def draw_layer(kind, projection)
|
|
336
461
|
items = @layers[kind]
|
|
337
462
|
return if items.nil? || items.empty?
|
|
@@ -340,6 +465,8 @@ module GD
|
|
|
340
465
|
case kind
|
|
341
466
|
when :street, :primary, :motorway, :secondary, :minor
|
|
342
467
|
@style.roads[kind]
|
|
468
|
+
when :track
|
|
469
|
+
@style.track[kind]
|
|
343
470
|
when :rail
|
|
344
471
|
@style.rails
|
|
345
472
|
when :water
|
|
@@ -374,24 +501,22 @@ module GD
|
|
|
374
501
|
f = item
|
|
375
502
|
geom = f.geometry["type"]
|
|
376
503
|
|
|
377
|
-
if geom
|
|
504
|
+
if POLY_GEOMS.include?(geom)
|
|
378
505
|
f.draw(@image, projection, nil, nil, style)
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
506
|
+
elsif style[:stroke]
|
|
507
|
+
r, g, b, a = style[:stroke]
|
|
508
|
+
a = 0 if a.nil?
|
|
509
|
+
color = GD::Color.rgba(r, g, b, a)
|
|
382
510
|
|
|
383
|
-
|
|
511
|
+
color = GD::GIS::ColorHelpers.random_vivid if @debug
|
|
384
512
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
end
|
|
513
|
+
width = style[:stroke_width] ? style[:stroke_width].round : 1
|
|
514
|
+
width = 1 if width < 1
|
|
515
|
+
f.draw(@image, projection, color, width)
|
|
389
516
|
end
|
|
390
517
|
end
|
|
391
518
|
end
|
|
392
519
|
end
|
|
393
|
-
|
|
394
520
|
end
|
|
395
521
|
end
|
|
396
522
|
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
|