libgd-gis 0.4.0 → 0.4.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 74dd1161f2cc225b81f304e90a5e60c2bf249da1606c7a84798367f3d1d1c4bb
4
- data.tar.gz: 51a6d88d8f49e6e14a363c68fbd9a0837aae91aef028b456f4df20865831fce8
3
+ metadata.gz: fa4dd3c210385e6b89998eeccbc88c4e82d5c45b6081be260b98843b4bd01124
4
+ data.tar.gz: c0e6f00da6d017e7d30f8ccc6c14b4a16ee3a5e7799d5cbf8c4a4b72b137bde6
5
5
  SHA512:
6
- metadata.gz: 78987b17ccf96eb35607632956a97add64353d9500a5ec9b208028a7064a11501ee5d0b19ce0b1134e41062dd35387b891eb1d67316737a0f7c83279223d1e2c
7
- data.tar.gz: e2a9b4e8149839b5b9d7b834757e304979a81e3879a4332f97159e75236be79ea4c4e179a54ed3efd94231c24d290edc83812793b2962ffdbd40ae12549bf40a
6
+ metadata.gz: fd5c30cbcdae3f214a9f0fb9605edd1244ca161774fc6e60fabcff70688b9b3712bc7444be326fd88c5517d470bfa8c06d8bc0522ca470edc86e5608235a26e9
7
+ data.tar.gz: 1cfd799c2c1abfb457ad761de7b293a97b5f880ddd55cd4e02298820c91f344977ee176abf8d7da9b77594247a1c8c1a1f34db951f69f15ff00ed7748d3c4da5
@@ -0,0 +1,27 @@
1
+ module GD
2
+ module GIS
3
+ module BBoxResolver
4
+ def self.resolve(bbox)
5
+ case bbox
6
+ when Symbol, String
7
+ Extents.fetch(bbox)
8
+
9
+ when Array
10
+ validate!(bbox)
11
+ bbox.map(&:to_f)
12
+
13
+ else
14
+ raise ArgumentError,
15
+ "bbox must be Symbol, String or [min_lng, min_lat, max_lng, max_lat]"
16
+ end
17
+ end
18
+
19
+ def self.validate!(bbox)
20
+ unless bbox.is_a?(Array) && bbox.size == 4
21
+ raise ArgumentError,
22
+ "bbox must be [min_lng, min_lat, max_lng, max_lat]"
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -69,6 +69,9 @@ module GD
69
69
  when EPSG3857
70
70
  mercator_to_wgs84(lon, lat)
71
71
 
72
+ when "EPSG:22195"
73
+ gk_to_wgs84(lon, lat)
74
+
72
75
  else
73
76
  raise ArgumentError, "Unsupported CRS: #{@crs}"
74
77
  end
@@ -97,6 +100,70 @@ module GD
97
100
  lat = ((2 * Math.atan(Math.exp(y / r))) - (Math::PI / 2)) * 180.0 / Math::PI
98
101
  [lon, lat]
99
102
  end
103
+
104
+ # Converts Gauss–Krüger (GK) projected coordinates to WGS84.
105
+ #
106
+ # This method converts easting/northing coordinates from
107
+ # Gauss–Krüger Argentina Zone 5 (EPSG:22195) into
108
+ # WGS84 longitude/latitude (degrees).
109
+ #
110
+ # The implementation is intended for cartographic rendering
111
+ # and visualization purposes, not for high-precision geodesy.
112
+ #
113
+ # @param easting [Numeric]
114
+ # Easting value in meters.
115
+ #
116
+ # @param northing [Numeric]
117
+ # Northing value in meters.
118
+ #
119
+ # @return [Array<Float>]
120
+ # A `[longitude, latitude]` pair in decimal degrees (WGS84).
121
+ #
122
+ # @example Convert Gauss–Krüger coordinates
123
+ # gk_to_wgs84(580_000, 6_176_000)
124
+ # # => [longitude, latitude]
125
+ #
126
+ # @note
127
+ # This method assumes:
128
+ # - Central meridian: −60°
129
+ # - False easting: 500,000 m
130
+ # - WGS84-compatible ellipsoid
131
+ #
132
+ # @see https://epsg.io/22195
133
+
134
+ def gk_to_wgs84(easting, northing)
135
+ a = 6378137.0
136
+ f = 1 / 298.257223563
137
+ e2 = (2 * f) - (f * f)
138
+ lon0 = -60.0 * Math::PI / 180.0
139
+
140
+ x = easting - 500_000.0
141
+ y = northing - 10_000_000.0
142
+
143
+ m = y
144
+ mu = m / (a * (1 - (e2 / 4) - (3 * e2 * e2 / 64)))
145
+
146
+ e1 = (1 - Math.sqrt(1 - e2)) / (1 + Math.sqrt(1 - e2))
147
+
148
+ j1 = (3 * e1 / 2) - (27 * (e1**3) / 32)
149
+ j2 = (21 * (e1**2) / 16) - (55 * (e1**4) / 32)
150
+
151
+ fp = mu + (j1 * Math.sin(2 * mu)) + (j2 * Math.sin(4 * mu))
152
+
153
+ c1 = e2 * (Math.cos(fp)**2)
154
+ t1 = Math.tan(fp)**2
155
+ r1 = a * (1 - e2) / ((1 - (e2 * (Math.sin(fp)**2)))**1.5)
156
+ n1 = a / Math.sqrt(1 - (e2 * (Math.sin(fp)**2)))
157
+
158
+ d = x / n1
159
+
160
+ lat = fp - ((n1 * Math.tan(fp) / r1) *
161
+ (((d**2) / 2) - ((5 + (3 * t1) + (10 * c1)) * (d**4) / 24)))
162
+
163
+ lon = lon0 + ((d - ((1 + (2 * t1) + c1) * (d**3) / 6)) / Math.cos(fp))
164
+
165
+ [lon * 180.0 / Math::PI, lat * 180.0 / Math::PI]
166
+ end
100
167
  end
101
168
  end
102
169
  end
@@ -0,0 +1,95 @@
1
+ {
2
+ "world": [-180.0, -90.0, 180.0, 90.0],
3
+
4
+ "northern_hemisphere": [-180.0, 0.0, 180.0, 90.0],
5
+ "southern_hemisphere": [-180.0, -90.0, 180.0, 0.0],
6
+ "eastern_hemisphere": [0.0, -90.0, 180.0, 90.0],
7
+ "western_hemisphere": [-180.0, -90.0, 0.0, 90.0],
8
+
9
+ "africa": [-25.0, -35.0, 51.0, 38.0],
10
+ "antarctica": [-180.0, -90.0, 180.0, -60.0],
11
+ "asia": [26.0, -11.0, 169.0, 78.0],
12
+ "europe": [-31.0, 34.0, 39.0, 72.0],
13
+ "north_america": [-168.0, 7.0, -52.0, 83.0],
14
+ "south_america": [-82.0, -56.0, -34.0, 13.0],
15
+ "oceania": [110.0, -50.0, 180.0, 0.0],
16
+
17
+ "argentina": [-73.6, -55.1, -53.6, -21.7],
18
+ "bolivia": [-69.7, -22.9, -57.5, -9.7],
19
+ "brazil": [-74.0, -34.0, -34.0, 5.3],
20
+ "chile": [-75.6, -56.0, -66.4, -17.5],
21
+ "colombia": [-79.0, -4.2, -66.9, 13.5],
22
+ "ecuador": [-81.0, -5.0, -75.0, 1.5],
23
+ "guyana": [-61.4, 1.2, -56.5, 8.6],
24
+ "paraguay": [-62.6, -27.6, -54.3, -19.3],
25
+ "peru": [-81.4, -18.4, -68.7, -0.0],
26
+ "suriname": [-58.1, 1.8, -53.9, 6.0],
27
+ "uruguay": [-58.4, -35.0, -53.1, -30.1],
28
+ "venezuela": [-73.4, 0.6, -59.8, 12.2],
29
+
30
+ "canada": [-141.0, 41.0, -52.6, 83.1],
31
+ "united_states": [-125.0, 24.0, -66.9, 49.4],
32
+ "mexico": [-118.4, 14.5, -86.7, 32.7],
33
+ "greenland": [-73.0, 59.8, -12.2, 83.6],
34
+
35
+ "iceland": [-24.5, 63.3, -13.5, 66.6],
36
+ "ireland": [-10.5, 51.4, -6.0, 55.5],
37
+ "united_kingdom": [-8.6, 49.8, 1.8, 60.9],
38
+ "portugal": [-9.6, 36.9, -6.2, 42.2],
39
+ "spain": [-9.5, 35.9, 3.3, 43.8],
40
+ "france": [-5.2, 41.3, 9.6, 51.1],
41
+ "belgium": [2.5, 49.5, 6.4, 51.5],
42
+ "netherlands": [3.3, 50.7, 7.2, 53.7],
43
+ "germany": [5.9, 47.3, 15.0, 55.1],
44
+ "switzerland": [5.9, 45.8, 10.5, 47.8],
45
+ "italy": [6.6, 36.6, 18.5, 47.1],
46
+ "austria": [9.5, 46.4, 17.2, 49.0],
47
+ "poland": [14.1, 49.0, 24.1, 54.8],
48
+ "czechia": [12.1, 48.5, 18.9, 51.1],
49
+ "hungary": [16.1, 45.7, 22.9, 48.6],
50
+ "greece": [19.4, 34.8, 28.2, 41.8],
51
+ "norway": [4.9, 58.0, 31.3, 71.2],
52
+ "sweden": [11.1, 55.3, 24.2, 69.1],
53
+ "finland": [20.6, 59.8, 31.6, 70.1],
54
+ "denmark": [8.0, 54.5, 12.7, 57.8],
55
+
56
+ "turkey": [26.0, 36.0, 45.0, 42.1],
57
+ "russia": [19.0, 41.0, 180.0, 82.0],
58
+
59
+ "saudi_arabia": [34.5, 16.3, 55.7, 32.2],
60
+ "iran": [44.0, 25.0, 63.3, 39.8],
61
+ "iraq": [38.8, 29.0, 48.6, 37.4],
62
+ "israel": [34.2, 29.5, 35.9, 33.3],
63
+ "jordan": [34.9, 29.2, 39.3, 33.4],
64
+ "syria": [35.7, 32.3, 42.4, 37.3],
65
+
66
+ "india": [68.1, 6.5, 97.4, 35.5],
67
+ "pakistan": [60.9, 23.7, 77.8, 37.1],
68
+ "bangladesh": [88.0, 20.7, 92.7, 26.6],
69
+ "china": [73.5, 18.1, 135.1, 53.6],
70
+ "mongolia": [87.7, 41.6, 119.9, 52.1],
71
+ "japan": [129.4, 31.0, 145.8, 45.5],
72
+ "south_korea": [125.0, 33.1, 131.9, 38.6],
73
+ "north_korea": [124.0, 37.7, 130.7, 43.0],
74
+
75
+ "thailand": [97.3, 5.6, 105.6, 20.5],
76
+ "vietnam": [102.1, 8.2, 109.5, 23.4],
77
+ "malaysia": [99.6, 0.8, 119.3, 7.4],
78
+ "indonesia": [95.0, -11.0, 141.0, 6.0],
79
+ "philippines": [116.9, 4.6, 126.6, 21.2],
80
+
81
+ "egypt": [24.7, 22.0, 36.9, 31.7],
82
+ "libya": [9.3, 19.5, 25.1, 33.2],
83
+ "algeria": [-8.7, 19.0, 12.0, 37.1],
84
+ "morocco": [-13.2, 27.7, -1.0, 35.9],
85
+ "tunisia": [7.5, 30.2, 11.6, 37.3],
86
+
87
+ "nigeria": [2.7, 4.2, 14.7, 13.9],
88
+ "ethiopia": [32.9, 3.4, 47.9, 14.9],
89
+ "kenya": [33.9, -4.7, 41.9, 5.0],
90
+ "south_africa": [16.4, -34.8, 32.9, -22.1],
91
+
92
+ "australia": [112.9, -43.7, 153.6, -10.7],
93
+ "new_zealand": [166.4, -47.3, 178.6, -34.4],
94
+ "papua_new_guinea": [140.8, -11.7, 156.0, -1.4]
95
+ }
@@ -0,0 +1,39 @@
1
+ require "json"
2
+
3
+ module GD
4
+ module GIS
5
+ module Extents
6
+ DATA_PATH = File.expand_path(
7
+ "data/extents_global.json",
8
+ __dir__
9
+ )
10
+
11
+ @extents = nil
12
+
13
+ class << self
14
+ def fetch(name)
15
+ load_data!
16
+ @extents.fetch(name.to_s.downcase) do
17
+ raise ArgumentError, "Unknown extent: #{name}"
18
+ end
19
+ end
20
+
21
+ def [](name)
22
+ fetch(name)
23
+ end
24
+
25
+ def all
26
+ load_data!
27
+ @extents.keys.map(&:to_sym)
28
+ end
29
+
30
+ private
31
+
32
+ def load_data!
33
+ return if @extents
34
+ @extents = JSON.parse(File.read(DATA_PATH))
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -101,16 +101,15 @@ module GD
101
101
  #
102
102
  # @return [void]
103
103
  def render!(img, projector)
104
- value = case @icon
105
- when "numeric"
106
- @symbol
107
- when "alphabetic"
108
- (@symbol + 96).chr
109
- when "symbol"
110
- @symbol
111
- else
112
- @icon
113
- end
104
+ value =
105
+ case @icon
106
+ when "numeric", "symbol"
107
+ @symbol
108
+ when "alphabetic"
109
+ (@symbol + 96).chr
110
+ else
111
+ @icon
112
+ end
114
113
 
115
114
  if @icon.is_a?(GD::Image)
116
115
  w = @icon.width
@@ -176,7 +175,7 @@ module GD
176
175
  # baseline = top_y + h = y + h/2
177
176
  text_x = (x - (w / 2.0)).round
178
177
  text_y = (y + (h / 2.0)).round
179
-
178
+
180
179
  # 4) Draw number
181
180
  img.text(
182
181
  text,
@@ -0,0 +1,38 @@
1
+ # lib/gd/gis/legend.rb
2
+
3
+ module GD
4
+ module GIS
5
+ LegendItem = Struct.new(:color, :label)
6
+
7
+ # Represents a map legend rendered as part of the final image.
8
+ #
9
+ # A Legend provides visual context for map elements by associating
10
+ # colors or symbols with human-readable labels.
11
+ #
12
+ # Legends are rendered server-side and embedded directly into the
13
+ # resulting map image, allowing the map to be self-explanatory
14
+ # without relying on external UI components.
15
+ #
16
+ # A Legend is typically created and configured via {Map#legend}
17
+ # and rendered automatically during the map rendering pipeline.
18
+ #
19
+ # @example Creating a legend
20
+ # map.legend do |l|
21
+ # l.add [76, 175, 80, 0], "Delivered"
22
+ # l.add [255, 193, 7, 0], "In transit"
23
+ # l.add [244, 67, 54, 0], "Delayed"
24
+ #
25
+ class Legend
26
+ attr_reader :items, :position
27
+
28
+ def initialize(position: :bottom_right)
29
+ @position = position
30
+ @items = []
31
+ end
32
+
33
+ def add(color, label)
34
+ @items << LegendItem.new(color, label)
35
+ end
36
+ end
37
+ end
38
+ end
data/lib/gd/gis/map.rb CHANGED
@@ -7,6 +7,7 @@ require_relative "layer_geojson"
7
7
  require_relative "layer_points"
8
8
  require_relative "layer_lines"
9
9
  require_relative "layer_polygons"
10
+ require_relative "legend"
10
11
 
11
12
  LINE_GEOMS = %w[LineString MultiLineString].freeze
12
13
  POLY_GEOMS = %w[Polygon MultiPolygon].freeze
@@ -69,6 +70,9 @@ module GD
69
70
  crs: nil,
70
71
  fitted_bbox: false
71
72
  )
73
+ # resolve symbolic bbox
74
+ bbox = GD::GIS::BBoxResolver.resolve(bbox)
75
+
72
76
  # 1. Basic input validation
73
77
  raise ArgumentError, "bbox must be [min_lng, min_lat, max_lng, max_lat]" unless
74
78
  bbox.is_a?(Array) && bbox.size == 4
@@ -217,6 +221,110 @@ module GD
217
221
  @used_labels[key] = true
218
222
  end
219
223
 
224
+ def legend(position: :bottom_right)
225
+ @legend = Legend.new(position: position)
226
+ yield @legend
227
+ end
228
+
229
+ def legend_from_layers(position: :bottom_right)
230
+ @legend = Legend.new(position: position)
231
+
232
+ layers.each do |layer|
233
+ next unless layer.respond_to?(:color)
234
+
235
+ @legend.add(layer.color, layer.name)
236
+ end
237
+ end
238
+
239
+ def draw_legend
240
+ return unless @legend
241
+ return unless @image
242
+ return unless @style
243
+ return unless @style.global
244
+ return if @style.global[:label] == false
245
+
246
+ label_style = @style.global[:label] || {}
247
+
248
+ padding = 10
249
+ box_size = 12
250
+ line_height = 18
251
+ margin = 15
252
+
253
+ # --- font (from style) -----------------------------------
254
+
255
+ font_path =
256
+ case label_style[:font]
257
+ when nil, "default"
258
+ GD::GIS::FontHelper.random
259
+ else
260
+ label_style[:font]
261
+ end
262
+
263
+ font_size = label_style[:size] || 10
264
+ font_color = GD::Color.rgba(*(label_style[:color] || [0, 0, 0, 0]))
265
+
266
+ # --- measure text (CORRECT API) ---------------------------
267
+
268
+ text_widths = @legend.items.map do |i|
269
+ w, = @image.text_bbox(
270
+ i.label,
271
+ font: font_path,
272
+ size: font_size
273
+ )
274
+ w
275
+ end
276
+
277
+ width = (text_widths.max || 0) + box_size + (padding * 3)
278
+ height = (@legend.items.size * line_height) + (padding * 2)
279
+
280
+ # --- position --------------------------------------------
281
+
282
+ x, y =
283
+ case @legend.position
284
+ when :bottom_right
285
+ [@image.width - width - margin, @image.height - height - margin]
286
+ when :bottom_left
287
+ [margin, @image.height - height - margin]
288
+ when :top_right
289
+ [@image.width - width - margin, margin]
290
+ else
291
+ [margin, margin]
292
+ end
293
+
294
+ # --- background ------------------------------------------
295
+
296
+ bg = GD::Color.rgba(255, 255, 255, 80)
297
+ border = GD::Color.rgb(200, 200, 200)
298
+
299
+ @image.filled_rectangle(x, y, x + width, y + height, bg)
300
+ @image.rectangle(x, y, x + width, y + height, border)
301
+
302
+ # --- items -----------------------------------------------
303
+
304
+ @legend.items.each_with_index do |item, idx|
305
+ iy = y + padding + (idx * line_height)
306
+
307
+ # color box
308
+ @image.filled_rectangle(
309
+ x + padding,
310
+ iy,
311
+ x + padding + box_size,
312
+ iy + box_size,
313
+ GD::Color.rgba(*item.color)
314
+ )
315
+
316
+ # label text
317
+ @image.text_ft(
318
+ item.label,
319
+ x: x + padding + box_size + 8,
320
+ y: iy + box_size,
321
+ font: font_path,
322
+ size: font_size,
323
+ color: font_color
324
+ )
325
+ end
326
+ end
327
+
220
328
  # Loads features from a GeoJSON file.
221
329
  #
222
330
  # This method:
@@ -376,10 +484,10 @@ module GD
376
484
 
377
485
  @points_layers << GD::GIS::PointsLayer.new(
378
486
  [row],
379
- lon: -> r { r[:lon] },
380
- lat: -> r { r[:lat] },
487
+ lon: ->(r) { r[:lon] },
488
+ lat: ->(r) { r[:lat] },
381
489
  icon: icon || @style.point[:icon],
382
- label: label ? -> r { r[:label] } : nil,
490
+ label: label ? ->(r) { r[:label] } : nil,
383
491
  font: font || @style.point[:font],
384
492
  size: size || @style.point[:size],
385
493
  color: color || @style.point[:color],
@@ -477,6 +585,8 @@ module GD
477
585
  @polygons_layers.each { |l| l.render!(@image, projection) }
478
586
  @lines_layers.each { |l| l.render!(@image, projection) }
479
587
  @points_layers.each { |l| l.render!(@image, projection) }
588
+
589
+ draw_legend
480
590
  end
481
591
 
482
592
  def render_viewport
@@ -536,6 +646,8 @@ module GD
536
646
  @polygons_layers.each { |l| l.render!(@image, projection) }
537
647
  @lines_layers.each { |l| l.render!(@image, projection) }
538
648
  @points_layers.each { |l| l.render!(@image, projection) }
649
+
650
+ draw_legend
539
651
  end
540
652
 
541
653
  # Saves the rendered image to disk.
data/lib/gd/gis.rb CHANGED
@@ -32,6 +32,9 @@ require_relative "gis/font_helper"
32
32
  require_relative "gis/style"
33
33
  require_relative "gis/classifier"
34
34
 
35
+ require_relative "gis/extents"
36
+ require_relative "gis/bbox_resolver"
37
+
35
38
  require_relative "gis/feature"
36
39
  require_relative "gis/map"
37
40
  require_relative "gis/basemap"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: libgd-gis
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Germán Alberto Giménez Silva
@@ -40,9 +40,12 @@ files:
40
40
  - README.md
41
41
  - lib/gd/gis.rb
42
42
  - lib/gd/gis/basemap.rb
43
+ - lib/gd/gis/bbox_resolver.rb
43
44
  - lib/gd/gis/classifier.rb
44
45
  - lib/gd/gis/color_helpers.rb
45
46
  - lib/gd/gis/crs_normalizer.rb
47
+ - lib/gd/gis/data/extents_global.json
48
+ - lib/gd/gis/extents.rb
46
49
  - lib/gd/gis/feature.rb
47
50
  - lib/gd/gis/font_helper.rb
48
51
  - lib/gd/gis/geometry.rb
@@ -50,8 +53,8 @@ files:
50
53
  - lib/gd/gis/layer_lines.rb
51
54
  - lib/gd/gis/layer_points.rb
52
55
  - lib/gd/gis/layer_polygons.rb
56
+ - lib/gd/gis/legend.rb
53
57
  - lib/gd/gis/map.rb
54
- - lib/gd/gis/middleware.rb
55
58
  - lib/gd/gis/ontology.rb
56
59
  - lib/gd/gis/ontology.yml
57
60
  - lib/gd/gis/projection.rb
@@ -76,7 +79,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
76
79
  - !ruby/object:Gem::Version
77
80
  version: '0'
78
81
  requirements: []
79
- rubygems_version: 4.0.5
82
+ rubygems_version: 4.0.6
80
83
  specification_version: 4
81
84
  summary: Geospatial raster rendering for Ruby using libgd
82
85
  test_files: []
@@ -1,152 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GD
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
- #
11
- module CRS
12
- # OGC CRS84 (longitude, latitude)
13
- CRS84 = "urn:ogc:def:crs:OGC:1.3:CRS84"
14
-
15
- # EPSG:4326 (latitude, longitude axis order)
16
- EPSG4326 = "EPSG:4326"
17
-
18
- # EPSG:3857 (Web Mercator)
19
- EPSG3857 = "EPSG:3857"
20
-
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
- #
42
- class Normalizer
43
- # Creates a new CRS normalizer.
44
- #
45
- # @param crs [String, Symbol, nil]
46
- # CRS identifier; defaults to CRS84 if nil
47
- def initialize(crs)
48
- @crs = normalize_name(crs)
49
- end
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
63
- def normalize(lon, lat)
64
- case @crs
65
- when CRS84
66
- [lon, lat]
67
-
68
- when EPSG4326
69
- # EPSG:4326 uses (lat, lon)
70
- [lat, lon]
71
-
72
- when GK_ARGENTINA
73
- gk_to_wgs84(lon, lat)
74
-
75
- when EPSG3857
76
- mercator_to_wgs84(lon, lat)
77
-
78
- else
79
- raise "Unsupported CRS: #{@crs}"
80
- end
81
- end
82
-
83
- private
84
-
85
- # Normalizes a CRS name into a comparable string.
86
- #
87
- # @param name [Object]
88
- # @return [String]
89
- def normalize_name(name)
90
- return CRS84 if name.nil?
91
-
92
- name.to_s.strip
93
- end
94
-
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
100
- def mercator_to_wgs84(x, y)
101
- r = 6378137.0
102
- lon = (x / r) * 180.0 / Math::PI
103
- lat = ((2 * Math.atan(Math.exp(y / r))) - (Math::PI / 2)) * 180.0 / Math::PI
104
- [lon, lat]
105
- end
106
-
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
115
- def gk_to_wgs84(easting, northing)
116
- # Parameters for Argentina GK Zone 5
117
- a = 6378137.0
118
- f = 1 / 298.257223563
119
- e2 = (2 * f) - (f * f)
120
- lon0 = -60.0 * Math::PI / 180.0 # central meridian zone 5
121
-
122
- x = easting - 500000.0
123
- y = northing
124
-
125
- m = y
126
- mu = m / (a * (1 - (e2 / 4) - (3 * e2 * e2 / 64)))
127
-
128
- e1 = (1 - Math.sqrt(1 - e2)) / (1 + Math.sqrt(1 - e2))
129
-
130
- j1 = (3 * e1 / 2) - (27 * (e1**3) / 32)
131
- j2 = (21 * (e1**2) / 16) - (55 * (e1**4) / 32)
132
-
133
- fp = mu + (j1 * Math.sin(2 * mu)) + (j2 * Math.sin(4 * mu))
134
-
135
- c1 = e2 * (Math.cos(fp)**2)
136
- t1 = Math.tan(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)))
139
-
140
- d = x / n1
141
-
142
- lat = fp - ((n1 * Math.tan(fp) / r1) *
143
- (((d**2) / 2) - ((5 + (3 * t1) + (10 * c1)) * (d**4) / 24)))
144
-
145
- lon = lon0 + ((d - ((1 + (2 * t1) + c1) * (d**3) / 6)) / Math.cos(fp))
146
-
147
- [lon * 180.0 / Math::PI, lat * 180.0 / Math::PI]
148
- end
149
- end
150
- end
151
- end
152
- end