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

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,47 @@ 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
+
154
+ if geom_type == "Point"
155
+ points_style = @style.points or
156
+ raise ArgumentError, "Style error: missing 'points' section"
157
+
158
+ font = points_style[:font] or
159
+ raise ArgumentError, "Style error: points.font is required"
160
+
161
+ size = points_style[:size] or
162
+ raise ArgumentError, "Style error: points.size is required"
163
+
164
+ raw_color = points_style[:color]
165
+ color = @style.normalize_color(raw_color)
166
+
167
+ icon = points_style.key?(:icon_fill) && points_style.key?(:icon_stroke) ? [points_style[:icon_stroke], points_style[:icon_stroke]] : nil
168
+ icon = points_style.key?(:icon) ? points_style[:icon] : nil if icon.nil?
169
+
170
+ @points_layers << GD::GIS::PointsLayer.new(
171
+ [feature],
172
+ lon: ->(f) { f.geometry["coordinates"][0] },
173
+ lat: ->(f) { f.geometry["coordinates"][1] },
174
+ icon: icon,
175
+ label: ->(f) { f.properties["name"] }, # 👈 TEXTO
176
+ font: font,
177
+ size: size,
178
+ color: color
179
+ )
180
+ elsif geom_type == "LineString" || geom_type == "MultiLineString"
181
+ @layers[:minor] << feature
182
+ end
75
183
  end
76
184
  end
77
185
  end
@@ -79,37 +187,12 @@ module GD
79
187
  # -----------------------------------
80
188
  # Overlay layers
81
189
  # -----------------------------------
82
-
83
190
  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)
191
+ @points_layers << GD::GIS::PointsLayer.new(data, **opts)
100
192
  end
101
193
 
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)
194
+ def add_lines(features, **opts)
195
+ @lines_layers << GD::GIS::LinesLayer.new(features, **opts)
113
196
  end
114
197
 
115
198
  def add_polygons(polygons, **opts)
@@ -117,12 +200,21 @@ module GD
117
200
  end
118
201
 
119
202
  # -----------------------------------
120
- # Rendering
203
+ # Rendering (LEGACY, UNCHANGED)
121
204
  # -----------------------------------
122
-
123
205
  def render
124
206
  raise "map.style must be set" unless @style
125
207
 
208
+ if @width && @height
209
+ render_viewport
210
+ else
211
+ render_tiles
212
+ end
213
+ end
214
+
215
+ def render_tiles
216
+ raise "map.style must be set" unless @style
217
+
126
218
  tiles, x_min, y_min = @basemap.fetch_tiles
127
219
 
128
220
  xs = tiles.map { |t| t[0] }
@@ -151,26 +243,89 @@ module GD
151
243
  )
152
244
  end
153
245
 
154
- @projection = lambda do |lon, lat|
246
+ projection = lambda do |lon, lat|
155
247
  x, y = GD::GIS::Projection.lonlat_to_global_px(lon, lat, @zoom)
156
248
  [(x - origin_x).round, (y - origin_y).round]
157
249
  end
158
250
 
159
- # 1️⃣ Semantic GeoJSON layers (this is what was working)
251
+ # 1️⃣ GeoJSON semantic layers
160
252
  @style.order.each do |kind|
161
- draw_layer(kind, @projection)
253
+ draw_layer(kind, projection)
162
254
  end
163
255
 
164
256
  # 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) }
257
+ @polygons_layers.each { |l| l.render!(@image, projection) }
258
+ @lines_layers.each { |l| l.render!(@image, projection) }
259
+ @points_layers.each { |l| l.render!(@image, projection) }
260
+ end
261
+
262
+ def render_viewport
263
+ raise "map.style must be set" unless @style
264
+
265
+ @image = GD::Image.new(@width, @height)
266
+ @image.antialias = false
267
+
268
+ # --------------------------------------------------
269
+ # 1. Compute global pixel bbox
270
+ # --------------------------------------------------
271
+ min_lng, min_lat, max_lng, max_lat = @bbox
272
+
273
+ x1, y1 = GD::GIS::Projection.lonlat_to_global_px(min_lng, max_lat, @zoom)
274
+ x2, y2 = GD::GIS::Projection.lonlat_to_global_px(max_lng, min_lat, @zoom)
168
275
 
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) }
276
+ # --------------------------------------------------
277
+ # 2. Fetch tiles
278
+ # --------------------------------------------------
279
+ tiles, = @basemap.fetch_tiles
172
280
 
173
- @image
281
+ # --------------------------------------------------
282
+ # 3. Draw tiles clipped to viewport
283
+ # --------------------------------------------------
284
+ tiles.each do |x, y, file|
285
+ tile = GD::Image.open(file)
286
+
287
+ tile_x = x * TILE_SIZE
288
+ tile_y = y * TILE_SIZE
289
+
290
+ dst_x = tile_x - x1
291
+ dst_y = tile_y - y1
292
+
293
+ src_x = [0, -dst_x].max
294
+ src_y = [0, -dst_y].max
295
+
296
+ draw_w = [TILE_SIZE - src_x, @width - dst_x - src_x].min
297
+ draw_h = [TILE_SIZE - src_y, @height - dst_y - src_y].min
298
+
299
+ next if draw_w <= 0 || draw_h <= 0
300
+
301
+ @image.copy(
302
+ tile,
303
+ dst_x + src_x,
304
+ dst_y + src_y,
305
+ src_x,
306
+ src_y,
307
+ draw_w,
308
+ draw_h
309
+ )
310
+ end
311
+
312
+ # --------------------------------------------------
313
+ # 4. Projection (viewport version)
314
+ # --------------------------------------------------
315
+ projection = lambda do |lon, lat|
316
+ GD::GIS::Geometry.project(lon, lat, @bbox, @zoom)
317
+ end
318
+
319
+ # --------------------------------------------------
320
+ # 5. REUSE the same render pipeline
321
+ # --------------------------------------------------
322
+ @style.order.each do |kind|
323
+ draw_layer(kind, projection)
324
+ end
325
+
326
+ @polygons_layers.each { |l| l.render!(@image, projection) }
327
+ @lines_layers.each { |l| l.render!(@image, projection) }
328
+ @points_layers.each { |l| l.render!(@image, projection) }
174
329
  end
175
330
 
176
331
  def save(path)
@@ -185,9 +340,12 @@ module GD
185
340
  case kind
186
341
  when :street, :primary, :motorway, :secondary, :minor
187
342
  @style.roads[kind]
188
- when :rail then @style.rails
189
- when :water then @style.water
190
- when :park then @style.parks
343
+ when :rail
344
+ @style.rails
345
+ when :water
346
+ @style.water
347
+ when :park
348
+ @style.parks
191
349
  else
192
350
  @style.extra[kind] if @style.respond_to?(:extra)
193
351
  end
@@ -207,77 +365,33 @@ module GD
207
365
 
208
366
  if style[:stroke]
209
367
  color = GD::Color.rgb(*style[:stroke])
210
- f.draw(@image, @projection, color, width, :water)
211
- end
212
368
 
369
+ color = GD::GIS::ColorHelpers.random_vivid if @debug
370
+
371
+ f.draw(@image, projection, color, width, :water)
372
+ end
213
373
  else
214
374
  f = item
215
375
  geom = f.geometry["type"]
216
376
 
217
377
  if geom == "Polygon" || geom == "MultiPolygon"
218
- # THIS is the critical fix
219
- f.draw(@image, @projection, nil, nil, style)
378
+ f.draw(@image, projection, nil, nil, style)
220
379
  else
221
380
  if style[:stroke]
222
381
  color = GD::Color.rgb(*style[:stroke])
223
- width = style[:stroke_width] || 1
224
- f.draw(@image, @projection, color, width)
382
+
383
+ color = GD::GIS::ColorHelpers.random_vivid if @debug
384
+
385
+ width = style[:stroke_width] ? style[:stroke_width].round : 1
386
+ width = 1 if width < 1
387
+ f.draw(@image, projection, color, width)
225
388
  end
226
389
  end
227
390
  end
228
391
  end
229
392
  end
230
393
 
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
394
  end
282
395
  end
283
396
  end
397
+
@@ -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
@@ -8,7 +8,7 @@ module GD
8
8
  @rules = YAML.load_file(path)
9
9
  end
10
10
 
11
- def classify(properties)
11
+ def classify(properties, geometry_type: nil)
12
12
  @rules.each do |layer, sources|
13
13
  sources.each do |source, rules|
14
14
  rules.each do |key, values|
@@ -19,8 +19,12 @@ module GD
19
19
  end
20
20
  end
21
21
  end
22
+
23
+ return :points if geometry_type == "Point"
24
+
22
25
  nil
23
26
  end
27
+
24
28
  end
25
29
  end
26
30
  end