libgd-gis 0.2.7.pre.alpha.1 → 0.2.8

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.
data/lib/gd/gis/map.rb CHANGED
@@ -8,55 +8,130 @@ require_relative "layer_polygons"
8
8
 
9
9
  module GD
10
10
  module GIS
11
+ attr_accessor :debug
12
+
11
13
  class Map
12
14
  TILE_SIZE = 256
13
15
 
14
16
  attr_reader :image
17
+ attr_reader :layers
15
18
  attr_accessor :style
16
19
 
17
- def initialize(bbox:, zoom:, basemap:)
18
- @bbox = bbox
19
- @zoom = zoom
20
- @basemap = Basemap.new(zoom, bbox, basemap)
20
+ def initialize(
21
+ bbox:,
22
+ zoom:,
23
+ basemap:,
24
+ width: nil,
25
+ height: nil,
26
+ crs: nil,
27
+ fitted_bbox: false
28
+ )
29
+ # --------------------------------------------------
30
+ # 1. Basic input validation
31
+ # --------------------------------------------------
32
+ raise ArgumentError, "bbox must be [min_lng, min_lat, max_lng, max_lat]" unless
33
+ bbox.is_a?(Array) && bbox.size == 4
34
+
35
+ raise ArgumentError, "zoom must be an Integer" unless zoom.is_a?(Integer)
36
+
37
+ if (width && !height) || (!width && height)
38
+ raise ArgumentError, "width and height must be provided together"
39
+ end
40
+
41
+ @zoom = zoom
42
+ @width = width
43
+ @height = height
44
+
45
+ # --------------------------------------------------
46
+ # 2. CRS normalization (input → WGS84 lon/lat)
47
+ # --------------------------------------------------
48
+ if crs
49
+ normalizer = GD::GIS::CRS::Normalizer.new(crs)
50
+
51
+ min_lng, min_lat = normalizer.normalize(bbox[0], bbox[1])
52
+ max_lng, max_lat = normalizer.normalize(bbox[2], bbox[3])
53
+
54
+ bbox = [min_lng, min_lat, max_lng, max_lat]
55
+ end
56
+
57
+ # --------------------------------------------------
58
+ # 3. Final bbox (viewport-aware if width/height)
59
+ # --------------------------------------------------
60
+ @bbox =
61
+ if width && height && !fitted_bbox
62
+ GD::GIS::Geometry.viewport_bbox(
63
+ bbox: bbox,
64
+ zoom: zoom,
65
+ width: width,
66
+ height: height
67
+ )
68
+ else
69
+ bbox
70
+ end
71
+
72
+ # --------------------------------------------------
73
+ # 4. Basemap (uses FINAL bbox)
74
+ # --------------------------------------------------
75
+ @basemap = GD::GIS::Basemap.new(zoom, @bbox, basemap)
21
76
 
22
- # 🔒 DO NOT CHANGE — this is the working GeoJSON pipeline
77
+ # --------------------------------------------------
78
+ # 5. Legacy semantic layers (REQUIRED by render)
79
+ # --------------------------------------------------
23
80
  @layers = {
24
- motorway: [],
25
- primary: [],
81
+ motorway: [],
82
+ primary: [],
26
83
  secondary: [],
27
- street: [],
28
- minor: [],
29
- rail: [],
30
- water: [],
31
- park: []
84
+ street: [],
85
+ minor: [],
86
+ rail: [],
87
+ water: [],
88
+ park: []
32
89
  }
33
90
 
34
- # 🆕 overlay layers
91
+ # Optional alias (semantic clarity, no behavior change)
92
+ @road_layers = @layers
93
+
94
+ # --------------------------------------------------
95
+ # 6. Overlay layers (generic)
96
+ # --------------------------------------------------
35
97
  @points_layers = []
36
98
  @lines_layers = []
37
99
  @polygons_layers = []
38
100
 
39
- @dynamic_points = []
40
- @dynamic_lines = []
41
- @dynamic_polys = []
42
-
101
+ # --------------------------------------------------
102
+ # 7. Style
103
+ # --------------------------------------------------
43
104
  @style = nil
105
+
106
+ @debug = false
107
+ end
108
+
109
+ def features_by_layer(layer)
110
+ return [] unless @layers[layer]
111
+
112
+ @layers[layer].map do |item|
113
+ item.is_a?(Array) ? item.last : item
114
+ end
115
+ end
116
+
117
+ def features
118
+ @layers.values.flatten.map do |item|
119
+ item.is_a?(Array) ? item.last : item
120
+ end
44
121
  end
45
122
 
46
123
  # -----------------------------------
47
- # GeoJSON input (unchanged)
124
+ # GeoJSON input (unchanged behavior)
48
125
  # -----------------------------------
49
-
50
126
  def add_geojson(path)
51
127
  features = LayerGeoJSON.load(path)
52
128
 
53
129
  features.each do |feature|
54
130
  case feature.layer
55
131
  when :water
56
- # optional: detect river vs canal from properties
57
132
  kind =
58
133
  case (feature.properties["objeto"] || feature.properties["waterway"]).to_s.downcase
59
- when /river|río/ then :river
134
+ when /river|río/ then :river
60
135
  when /stream|arroyo/ then :stream
61
136
  else :minor
62
137
  end
@@ -64,14 +139,20 @@ module GD
64
139
  @layers[:water] << [kind, feature]
65
140
 
66
141
  when :roads
67
- # map to style categories if you want later
68
142
  @layers[:street] << feature
69
143
 
70
144
  when :parks
71
145
  @layers[:park] << feature
72
146
 
147
+ when :track
148
+ # elegí una:
149
+ @layers[:minor] << feature
150
+ # o @layers[:street] << feature
73
151
  else
74
- # ignore unclassified for now
152
+ geom_type = feature.geometry["type"]
153
+ if geom_type == "LineString" || geom_type == "MultiLineString"
154
+ @layers[:minor] << feature
155
+ end
75
156
  end
76
157
  end
77
158
  end
@@ -79,37 +160,12 @@ module GD
79
160
  # -----------------------------------
80
161
  # Overlay layers
81
162
  # -----------------------------------
82
-
83
163
  def add_points(data, **opts)
84
- layer = GD::GIS::PointsLayer.new(data, **opts)
85
- @points_layers << layer
86
- layer
87
- end
88
-
89
- def add_line(coords, **opts)
90
- feature = {
91
- "type" => "Feature",
92
- "geometry" => {
93
- "type" => "LineString",
94
- "coordinates" => coords
95
- },
96
- "properties" => {}
97
- }
98
-
99
- add_lines([feature], **opts)
164
+ @points_layers << GD::GIS::PointsLayer.new(data, **opts)
100
165
  end
101
166
 
102
- def add_multiline(lines, **opts)
103
- feature = {
104
- "type" => "Feature",
105
- "geometry" => {
106
- "type" => "MultiLineString",
107
- "coordinates" => lines
108
- },
109
- "properties" => []
110
- }
111
-
112
- add_lines([feature], **opts)
167
+ def add_lines(features, **opts)
168
+ @lines_layers << GD::GIS::LinesLayer.new(features, **opts)
113
169
  end
114
170
 
115
171
  def add_polygons(polygons, **opts)
@@ -117,12 +173,21 @@ module GD
117
173
  end
118
174
 
119
175
  # -----------------------------------
120
- # Rendering
176
+ # Rendering (LEGACY, UNCHANGED)
121
177
  # -----------------------------------
122
-
123
178
  def render
124
179
  raise "map.style must be set" unless @style
125
180
 
181
+ if @width && @height
182
+ render_viewport
183
+ else
184
+ render_tiles
185
+ end
186
+ end
187
+
188
+ def render_tiles
189
+ raise "map.style must be set" unless @style
190
+
126
191
  tiles, x_min, y_min = @basemap.fetch_tiles
127
192
 
128
193
  xs = tiles.map { |t| t[0] }
@@ -151,26 +216,89 @@ module GD
151
216
  )
152
217
  end
153
218
 
154
- @projection = lambda do |lon, lat|
219
+ projection = lambda do |lon, lat|
155
220
  x, y = GD::GIS::Projection.lonlat_to_global_px(lon, lat, @zoom)
156
221
  [(x - origin_x).round, (y - origin_y).round]
157
222
  end
158
223
 
159
- # 1️⃣ Semantic GeoJSON layers (this is what was working)
224
+ # 1️⃣ GeoJSON semantic layers
160
225
  @style.order.each do |kind|
161
- draw_layer(kind, @projection)
226
+ draw_layer(kind, projection)
162
227
  end
163
228
 
164
229
  # 2️⃣ Generic overlays
165
- @polygons_layers.each { |l| l.render!(@image, @projection) }
166
- @lines_layers.each { |l| l.render!(@image, @projection) }
167
- @points_layers.each { |l| l.render!(@image, @projection) }
230
+ @polygons_layers.each { |l| l.render!(@image, projection) }
231
+ @lines_layers.each { |l| l.render!(@image, projection) }
232
+ @points_layers.each { |l| l.render!(@image, projection) }
233
+ end
234
+
235
+ def render_viewport
236
+ raise "map.style must be set" unless @style
237
+
238
+ @image = GD::Image.new(@width, @height)
239
+ @image.antialias = false
240
+
241
+ # --------------------------------------------------
242
+ # 1. Compute global pixel bbox
243
+ # --------------------------------------------------
244
+ min_lng, min_lat, max_lng, max_lat = @bbox
245
+
246
+ x1, y1 = GD::GIS::Projection.lonlat_to_global_px(min_lng, max_lat, @zoom)
247
+ x2, y2 = GD::GIS::Projection.lonlat_to_global_px(max_lng, min_lat, @zoom)
168
248
 
169
- @dynamic_polys.each { |l| l.render!(@image, @projection) }
170
- @dynamic_lines.each { |l| l.render!(@image, @projection) }
171
- @dynamic_points.each { |l| l.render!(@image, @projection) }
249
+ # --------------------------------------------------
250
+ # 2. Fetch tiles
251
+ # --------------------------------------------------
252
+ tiles, = @basemap.fetch_tiles
172
253
 
173
- @image
254
+ # --------------------------------------------------
255
+ # 3. Draw tiles clipped to viewport
256
+ # --------------------------------------------------
257
+ tiles.each do |x, y, file|
258
+ tile = GD::Image.open(file)
259
+
260
+ tile_x = x * TILE_SIZE
261
+ tile_y = y * TILE_SIZE
262
+
263
+ dst_x = tile_x - x1
264
+ dst_y = tile_y - y1
265
+
266
+ src_x = [0, -dst_x].max
267
+ src_y = [0, -dst_y].max
268
+
269
+ draw_w = [TILE_SIZE - src_x, @width - dst_x - src_x].min
270
+ draw_h = [TILE_SIZE - src_y, @height - dst_y - src_y].min
271
+
272
+ next if draw_w <= 0 || draw_h <= 0
273
+
274
+ @image.copy(
275
+ tile,
276
+ dst_x + src_x,
277
+ dst_y + src_y,
278
+ src_x,
279
+ src_y,
280
+ draw_w,
281
+ draw_h
282
+ )
283
+ end
284
+
285
+ # --------------------------------------------------
286
+ # 4. Projection (viewport version)
287
+ # --------------------------------------------------
288
+ projection = lambda do |lon, lat|
289
+ GD::GIS::Geometry.project(lon, lat, @bbox, @zoom)
290
+ end
291
+
292
+ # --------------------------------------------------
293
+ # 5. REUSE the same render pipeline
294
+ # --------------------------------------------------
295
+ @style.order.each do |kind|
296
+ draw_layer(kind, projection)
297
+ end
298
+
299
+ @polygons_layers.each { |l| l.render!(@image, projection) }
300
+ @lines_layers.each { |l| l.render!(@image, projection) }
301
+ @points_layers.each { |l| l.render!(@image, projection) }
174
302
  end
175
303
 
176
304
  def save(path)
@@ -185,9 +313,12 @@ module GD
185
313
  case kind
186
314
  when :street, :primary, :motorway, :secondary, :minor
187
315
  @style.roads[kind]
188
- when :rail then @style.rails
189
- when :water then @style.water
190
- when :park then @style.parks
316
+ when :rail
317
+ @style.rails
318
+ when :water
319
+ @style.water
320
+ when :park
321
+ @style.parks
191
322
  else
192
323
  @style.extra[kind] if @style.respond_to?(:extra)
193
324
  end
@@ -207,77 +338,33 @@ module GD
207
338
 
208
339
  if style[:stroke]
209
340
  color = GD::Color.rgb(*style[:stroke])
210
- f.draw(@image, @projection, color, width, :water)
211
- end
212
341
 
342
+ color = GD::GIS::ColorHelpers.random_vivid if @debug
343
+
344
+ f.draw(@image, projection, color, width, :water)
345
+ end
213
346
  else
214
347
  f = item
215
348
  geom = f.geometry["type"]
216
349
 
217
350
  if geom == "Polygon" || geom == "MultiPolygon"
218
- # THIS is the critical fix
219
- f.draw(@image, @projection, nil, nil, style)
351
+ f.draw(@image, projection, nil, nil, style)
220
352
  else
221
353
  if style[:stroke]
222
354
  color = GD::Color.rgb(*style[:stroke])
223
- width = style[:stroke_width] || 1
224
- f.draw(@image, @projection, color, width)
355
+
356
+ color = GD::GIS::ColorHelpers.random_vivid if @debug
357
+
358
+ width = style[:stroke_width] ? style[:stroke_width].round : 1
359
+ width = 1 if width < 1
360
+ f.draw(@image, projection, color, width)
225
361
  end
226
362
  end
227
363
  end
228
364
  end
229
365
  end
230
366
 
231
- def clear_dynamic_layers
232
- @dynamic_points.clear
233
- @dynamic_lines.clear
234
- @dynamic_polys.clear
235
- end
236
-
237
- def add_dynamic_point(data, **opts)
238
- @dynamic_points << GD::GIS::PointsLayer.new(data, **opts)
239
- end
240
-
241
- def add_dynamic_line(coords, **opts)
242
- feature = {
243
- "type" => "Feature",
244
- "geometry" => {
245
- "type" => "LineString",
246
- "coordinates" => coords
247
- },
248
- "properties" => {}
249
- }
250
- @dynamic_lines << GD::GIS::LinesLayer.new([feature], **opts)
251
- end
252
-
253
- def render_base
254
- render
255
- @base_image = @image
256
- end
257
-
258
- def render_with_base
259
- img = GD::Image.new(@base_image.width, @base_image.height)
260
- img.copy(@base_image, 0,0, 0,0, @base_image.width, @base_image.height)
261
-
262
- @points_layers.each { |l| l.render!(img, @projection) }
263
- @lines_layers.each { |l| l.render!(img, @projection) }
264
- @polygons_layers.each{ |l| l.render!(img, @projection) }
265
-
266
- img
267
- end
268
-
269
- private
270
-
271
- def add_lines(features, **opts)
272
- stroke = opts.delete(:color) || opts.delete(:stroke)
273
- width = opts.delete(:width) || opts.delete(:stroke_width)
274
-
275
- raise ArgumentError, "missing :color or :stroke" unless stroke
276
- raise ArgumentError, "missing :width" unless width
277
-
278
- @lines_layers << GD::GIS::LinesLayer.new(features, :stroke => stroke, :width => width)
279
- end
280
-
281
367
  end
282
368
  end
283
369
  end
370
+
@@ -0,0 +1,89 @@
1
+ module GD
2
+ module GIS
3
+ module CRS
4
+ CRS84 = "urn:ogc:def:crs:OGC:1.3:CRS84"
5
+ EPSG4326 = "EPSG:4326"
6
+ EPSG3857 = "EPSG:3857"
7
+ GK_ARGENTINA = "EPSG:22195" # Gauss–Krüger Argentina (zone 5 example)
8
+
9
+ # Normalize any CRS → CRS84 (lon,lat in degrees)
10
+ class Normalizer
11
+ def initialize(crs)
12
+ @crs = normalize_name(crs)
13
+ end
14
+
15
+ def normalize(lon, lat)
16
+ case @crs
17
+ when CRS84
18
+ [lon, lat]
19
+
20
+ when EPSG4326
21
+ # EPSG:4326 uses (lat, lon)
22
+ [lat, lon]
23
+
24
+ when GK_ARGENTINA
25
+ gk_to_wgs84(lon, lat)
26
+
27
+ when EPSG3857
28
+ mercator_to_wgs84(lon, lat)
29
+
30
+ else
31
+ raise "Unsupported CRS: #{@crs}"
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def normalize_name(name)
38
+ return CRS84 if name.nil?
39
+ name.to_s.strip
40
+ end
41
+
42
+ # Web Mercator → WGS84
43
+ def mercator_to_wgs84(x, y)
44
+ r = 6378137.0
45
+ lon = (x / r) * 180.0 / Math::PI
46
+ lat = (2 * Math.atan(Math.exp(y / r)) - Math::PI / 2) * 180.0 / Math::PI
47
+ [lon, lat]
48
+ end
49
+
50
+ # Gauss–Krüger Argentina (Zone 5) → WGS84
51
+ # This is enough precision for mapping
52
+ def gk_to_wgs84(easting, northing)
53
+ # Parameters for Argentina GK Zone 5
54
+ a = 6378137.0
55
+ f = 1 / 298.257223563
56
+ e2 = 2*f - f*f
57
+ lon0 = -60.0 * Math::PI / 180.0 # central meridian zone 5
58
+
59
+ x = easting - 500000.0
60
+ y = northing
61
+
62
+ m = y
63
+ mu = m / (a * (1 - e2/4 - 3*e2*e2/64))
64
+
65
+ e1 = (1 - Math.sqrt(1 - e2)) / (1 + Math.sqrt(1 - e2))
66
+
67
+ j1 = 3*e1/2 - 27*e1**3/32
68
+ j2 = 21*e1**2/16 - 55*e1**4/32
69
+
70
+ fp = mu + j1*Math.sin(2*mu) + j2*Math.sin(4*mu)
71
+
72
+ c1 = e2 * Math.cos(fp)**2
73
+ 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)
76
+
77
+ d = x / n1
78
+
79
+ lat = fp - (n1*Math.tan(fp)/r1) *
80
+ (d**2/2 - (5 + 3*t1 + 10*c1)*d**4/24)
81
+
82
+ lon = lon0 + (d - (1 + 2*t1 + c1)*d**3/6) / Math.cos(fp)
83
+
84
+ [lon * 180.0 / Math::PI, lat * 180.0 / Math::PI]
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end