libgd-gis 0.2.6 → 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,57 +8,151 @@ 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
 
101
+ # --------------------------------------------------
102
+ # 7. Style
103
+ # --------------------------------------------------
39
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
40
121
  end
41
122
 
42
123
  # -----------------------------------
43
- # GeoJSON input (unchanged)
124
+ # GeoJSON input (unchanged behavior)
44
125
  # -----------------------------------
45
-
46
126
  def add_geojson(path)
47
127
  features = LayerGeoJSON.load(path)
48
128
 
49
129
  features.each do |feature|
50
- if Classifier.water?(feature)
51
- kind = Classifier.water_kind(feature)
130
+ case feature.layer
131
+ when :water
132
+ kind =
133
+ case (feature.properties["objeto"] || feature.properties["waterway"]).to_s.downcase
134
+ when /river|río/ then :river
135
+ when /stream|arroyo/ then :stream
136
+ else :minor
137
+ end
138
+
52
139
  @layers[:water] << [kind, feature]
53
140
 
54
- elsif Classifier.park?(feature)
55
- @layers[:park] << feature
141
+ when :roads
142
+ @layers[:street] << feature
56
143
 
57
- elsif Classifier.rail?(feature)
58
- @layers[:rail] << feature
144
+ when :parks
145
+ @layers[:park] << feature
59
146
 
60
- elsif type = Classifier.road(feature)
61
- @layers[type] << feature
147
+ when :track
148
+ # elegí una:
149
+ @layers[:minor] << feature
150
+ # o @layers[:street] << feature
151
+ else
152
+ geom_type = feature.geometry["type"]
153
+ if geom_type == "LineString" || geom_type == "MultiLineString"
154
+ @layers[:minor] << feature
155
+ end
62
156
  end
63
157
  end
64
158
  end
@@ -66,35 +160,12 @@ module GD
66
160
  # -----------------------------------
67
161
  # Overlay layers
68
162
  # -----------------------------------
69
-
70
163
  def add_points(data, **opts)
71
164
  @points_layers << GD::GIS::PointsLayer.new(data, **opts)
72
165
  end
73
166
 
74
- def add_line(coords, **opts)
75
- feature = {
76
- "type" => "Feature",
77
- "geometry" => {
78
- "type" => "LineString",
79
- "coordinates" => coords
80
- },
81
- "properties" => {}
82
- }
83
-
84
- add_lines([feature], **opts)
85
- end
86
-
87
- def add_multiline(lines, **opts)
88
- feature = {
89
- "type" => "Feature",
90
- "geometry" => {
91
- "type" => "MultiLineString",
92
- "coordinates" => lines
93
- },
94
- "properties" => []
95
- }
96
-
97
- add_lines([feature], **opts)
167
+ def add_lines(features, **opts)
168
+ @lines_layers << GD::GIS::LinesLayer.new(features, **opts)
98
169
  end
99
170
 
100
171
  def add_polygons(polygons, **opts)
@@ -102,12 +173,21 @@ module GD
102
173
  end
103
174
 
104
175
  # -----------------------------------
105
- # Rendering
176
+ # Rendering (LEGACY, UNCHANGED)
106
177
  # -----------------------------------
107
-
108
178
  def render
109
179
  raise "map.style must be set" unless @style
110
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
+
111
191
  tiles, x_min, y_min = @basemap.fetch_tiles
112
192
 
113
193
  xs = tiles.map { |t| t[0] }
@@ -141,7 +221,7 @@ module GD
141
221
  [(x - origin_x).round, (y - origin_y).round]
142
222
  end
143
223
 
144
- # 1️⃣ Semantic GeoJSON layers (this is what was working)
224
+ # 1️⃣ GeoJSON semantic layers
145
225
  @style.order.each do |kind|
146
226
  draw_layer(kind, projection)
147
227
  end
@@ -152,6 +232,75 @@ module GD
152
232
  @points_layers.each { |l| l.render!(@image, projection) }
153
233
  end
154
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)
248
+
249
+ # --------------------------------------------------
250
+ # 2. Fetch tiles
251
+ # --------------------------------------------------
252
+ tiles, = @basemap.fetch_tiles
253
+
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) }
302
+ end
303
+
155
304
  def save(path)
156
305
  @image.save(path)
157
306
  end
@@ -164,9 +313,12 @@ module GD
164
313
  case kind
165
314
  when :street, :primary, :motorway, :secondary, :minor
166
315
  @style.roads[kind]
167
- when :rail then @style.rails
168
- when :water then @style.water
169
- when :park then @style.parks
316
+ when :rail
317
+ @style.rails
318
+ when :water
319
+ @style.water
320
+ when :park
321
+ @style.parks
170
322
  else
171
323
  @style.extra[kind] if @style.respond_to?(:extra)
172
324
  end
@@ -186,20 +338,25 @@ module GD
186
338
 
187
339
  if style[:stroke]
188
340
  color = GD::Color.rgb(*style[:stroke])
341
+
342
+ color = GD::GIS::ColorHelpers.random_vivid if @debug
343
+
189
344
  f.draw(@image, projection, color, width, :water)
190
345
  end
191
-
192
346
  else
193
347
  f = item
194
348
  geom = f.geometry["type"]
195
349
 
196
350
  if geom == "Polygon" || geom == "MultiPolygon"
197
- # THIS is the critical fix
198
351
  f.draw(@image, projection, nil, nil, style)
199
352
  else
200
353
  if style[:stroke]
201
354
  color = GD::Color.rgb(*style[:stroke])
202
- width = style[:stroke_width] || 1
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
203
360
  f.draw(@image, projection, color, width)
204
361
  end
205
362
  end
@@ -207,18 +364,7 @@ module GD
207
364
  end
208
365
  end
209
366
 
210
- private
211
-
212
- def add_lines(features, **opts)
213
- stroke = opts.delete(:color) || opts.delete(:stroke)
214
- width = opts.delete(:width) || opts.delete(:stroke_width)
215
-
216
- raise ArgumentError, "missing :color or :stroke" unless stroke
217
- raise ArgumentError, "missing :width" unless width
218
-
219
- @lines_layers << GD::GIS::LinesLayer.new(features, :stroke => stroke, :width => width)
220
- end
221
-
222
367
  end
223
368
  end
224
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
@@ -0,0 +1,26 @@
1
+ require "yaml"
2
+
3
+ module GD
4
+ module GIS
5
+ class Ontology
6
+ def initialize(path = nil)
7
+ path ||= File.expand_path("ontology.yml", __dir__)
8
+ @rules = YAML.load_file(path)
9
+ end
10
+
11
+ def classify(properties)
12
+ @rules.each do |layer, sources|
13
+ sources.each do |source, rules|
14
+ rules.each do |key, values|
15
+ v = (properties[key.to_s] || properties[key.to_sym]).to_s.strip.downcase
16
+ values = values.map { |x| x.to_s.downcase }
17
+
18
+ return layer.to_sym if values.any? { |x| v.include?(x) }
19
+ end
20
+ end
21
+ end
22
+ nil
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,28 @@
1
+ water:
2
+ ign:
3
+ objeto:
4
+ - canal
5
+ - río
6
+ - arroyo
7
+ - embalse
8
+ - laguna
9
+ - dique
10
+ - represa
11
+ gna:
12
+ - canal
13
+ - río
14
+ - arroyo
15
+ - embalse
16
+ - laguna
17
+
18
+ natural_earth:
19
+ featurecla:
20
+ - river
21
+ - lake
22
+ - reservoir
23
+ - riverbank
24
+
25
+ track:
26
+ gps:
27
+ name:
28
+ - track
data/lib/gd/gis.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require "gd"
2
2
 
3
+ require_relative "gis/color_helpers"
3
4
  require_relative "gis/style"
4
5
  require_relative "gis/classifier"
5
6
 
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: libgd-gis
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.6
4
+ version: 0.2.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Germán Alberto Giménez Silva
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-01-19 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: ruby-libgd
@@ -15,20 +16,20 @@ dependencies:
15
16
  requirements:
16
17
  - - "~>"
17
18
  - !ruby/object:Gem::Version
18
- version: 0.2.1
19
+ version: 0.2.3
19
20
  - - ">="
20
21
  - !ruby/object:Gem::Version
21
- version: 0.2.1
22
+ version: 0.2.3
22
23
  type: :runtime
23
24
  prerelease: false
24
25
  version_requirements: !ruby/object:Gem::Requirement
25
26
  requirements:
26
27
  - - "~>"
27
28
  - !ruby/object:Gem::Version
28
- version: 0.2.1
29
+ version: 0.2.3
29
30
  - - ">="
30
31
  - !ruby/object:Gem::Version
31
- version: 0.2.1
32
+ version: 0.2.3
32
33
  description: A native GIS raster engine for Ruby built on libgd. Render maps, GeoJSON,
33
34
  heatmaps and tiles.
34
35
  email:
@@ -41,23 +42,30 @@ files:
41
42
  - lib/gd/gis.rb
42
43
  - lib/gd/gis/basemap.rb
43
44
  - lib/gd/gis/classifier.rb
45
+ - lib/gd/gis/color_helpers.rb
46
+ - lib/gd/gis/crs_normalizer.rb
44
47
  - lib/gd/gis/feature.rb
45
48
  - lib/gd/gis/geometry.rb
49
+ - lib/gd/gis/input/detector.rb
50
+ - lib/gd/gis/input/geojson.rb
51
+ - lib/gd/gis/input/kml.rb
52
+ - lib/gd/gis/input/shapefile.rb
46
53
  - lib/gd/gis/layer_geojson.rb
47
54
  - lib/gd/gis/layer_lines.rb
48
55
  - lib/gd/gis/layer_points.rb
49
56
  - lib/gd/gis/layer_polygons.rb
50
57
  - lib/gd/gis/map.rb
58
+ - lib/gd/gis/middleware.rb
59
+ - lib/gd/gis/ontology.rb
60
+ - lib/gd/gis/ontology.yml
51
61
  - lib/gd/gis/projection.rb
52
62
  - lib/gd/gis/style.rb
53
- - lib/gd/gis/style/dark.rb
54
- - lib/gd/gis/style/light.rb
55
- - lib/gd/gis/style/solarized.rb
56
63
  - lib/libgd_gis.rb
57
64
  homepage: https://github.com/ggerman/libgd-gis
58
65
  licenses:
59
66
  - MIT
60
67
  metadata: {}
68
+ post_install_message:
61
69
  rdoc_options: []
62
70
  require_paths:
63
71
  - lib
@@ -72,7 +80,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
72
80
  - !ruby/object:Gem::Version
73
81
  version: '0'
74
82
  requirements: []
75
- rubygems_version: 4.0.3
83
+ rubygems_version: 3.5.22
84
+ signing_key:
76
85
  specification_version: 4
77
86
  summary: Geospatial raster rendering for Ruby using libgd
78
87
  test_files: []
@@ -1,49 +0,0 @@
1
- module GD
2
- module GIS
3
- class Style
4
- DARK = Style.new(
5
- roads: {
6
- motorway: {
7
- stroke: [255,255,255],
8
- stroke_width: 10,
9
- fill: [60,60,60],
10
- fill_width: 6
11
- },
12
- primary: {
13
- stroke: [200,200,200],
14
- stroke_width: 7,
15
- fill: [80,80,80],
16
- fill_width: 4
17
- },
18
- street: {
19
- stroke: [120,120,120],
20
- stroke_width: 1
21
- }
22
- },
23
- rail: {
24
- stroke: [255,255,255],
25
- stroke_width: 6,
26
- fill: [220,50,50],
27
- fill_width: 4,
28
- center: [255,255,255],
29
- center_width: 1
30
- },
31
- water: {
32
- fill: [40,80,120],
33
- stroke: [100,160,220]
34
- },
35
- park: {
36
- fill: [40,80,40]
37
- },
38
- order: [
39
- :water,
40
- :park,
41
- :street,
42
- :primary,
43
- :motorway,
44
- :rail
45
- ]
46
- )
47
- end
48
- end
49
- end