map_view 0.0.1a

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 ADDED
@@ -0,0 +1,370 @@
1
+ require_relative "basemap"
2
+ require_relative "projection"
3
+ require_relative "classifier"
4
+ require_relative "layer_geojson"
5
+ require_relative "layer_points"
6
+ require_relative "layer_lines"
7
+ require_relative "layer_polygons"
8
+
9
+ module GD
10
+ module GIS
11
+ attr_accessor :debug
12
+
13
+ class Map
14
+ TILE_SIZE = 256
15
+
16
+ attr_reader :image
17
+ attr_reader :layers
18
+ attr_accessor :style
19
+
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)
76
+
77
+ # --------------------------------------------------
78
+ # 5. Legacy semantic layers (REQUIRED by render)
79
+ # --------------------------------------------------
80
+ @layers = {
81
+ motorway: [],
82
+ primary: [],
83
+ secondary: [],
84
+ street: [],
85
+ minor: [],
86
+ rail: [],
87
+ water: [],
88
+ park: []
89
+ }
90
+
91
+ # Optional alias (semantic clarity, no behavior change)
92
+ @road_layers = @layers
93
+
94
+ # --------------------------------------------------
95
+ # 6. Overlay layers (generic)
96
+ # --------------------------------------------------
97
+ @points_layers = []
98
+ @lines_layers = []
99
+ @polygons_layers = []
100
+
101
+ # --------------------------------------------------
102
+ # 7. Style
103
+ # --------------------------------------------------
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
121
+ end
122
+
123
+ # -----------------------------------
124
+ # GeoJSON input (unchanged behavior)
125
+ # -----------------------------------
126
+ def add_geojson(path)
127
+ features = LayerGeoJSON.load(path)
128
+
129
+ features.each do |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
+
139
+ @layers[:water] << [kind, feature]
140
+
141
+ when :roads
142
+ @layers[:street] << feature
143
+
144
+ when :parks
145
+ @layers[:park] << feature
146
+
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
156
+ end
157
+ end
158
+ end
159
+
160
+ # -----------------------------------
161
+ # Overlay layers
162
+ # -----------------------------------
163
+ def add_points(data, **opts)
164
+ @points_layers << GD::GIS::PointsLayer.new(data, **opts)
165
+ end
166
+
167
+ def add_lines(features, **opts)
168
+ @lines_layers << GD::GIS::LinesLayer.new(features, **opts)
169
+ end
170
+
171
+ def add_polygons(polygons, **opts)
172
+ @polygons_layers << GD::GIS::PolygonsLayer.new(polygons, **opts)
173
+ end
174
+
175
+ # -----------------------------------
176
+ # Rendering (LEGACY, UNCHANGED)
177
+ # -----------------------------------
178
+ def render
179
+ raise "map.style must be set" unless @style
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
+
191
+ tiles, x_min, y_min = @basemap.fetch_tiles
192
+
193
+ xs = tiles.map { |t| t[0] }
194
+ ys = tiles.map { |t| t[1] }
195
+
196
+ cols = xs.max - xs.min + 1
197
+ rows = ys.max - ys.min + 1
198
+
199
+ width = cols * TILE_SIZE
200
+ height = rows * TILE_SIZE
201
+
202
+ origin_x = x_min * TILE_SIZE
203
+ origin_y = y_min * TILE_SIZE
204
+
205
+ @image = GD::Image.new(width, height)
206
+ @image.antialias = false
207
+
208
+ # Basemap
209
+ tiles.each do |x, y, file|
210
+ tile = GD::Image.open(file)
211
+ @image.copy(
212
+ tile,
213
+ (x - x_min) * TILE_SIZE,
214
+ (y - y_min) * TILE_SIZE,
215
+ 0, 0, TILE_SIZE, TILE_SIZE
216
+ )
217
+ end
218
+
219
+ projection = lambda do |lon, lat|
220
+ x, y = GD::GIS::Projection.lonlat_to_global_px(lon, lat, @zoom)
221
+ [(x - origin_x).round, (y - origin_y).round]
222
+ end
223
+
224
+ # 1️⃣ GeoJSON semantic layers
225
+ @style.order.each do |kind|
226
+ draw_layer(kind, projection)
227
+ end
228
+
229
+ # 2️⃣ Generic overlays
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)
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
+
304
+ def save(path)
305
+ @image.save(path)
306
+ end
307
+
308
+ def draw_layer(kind, projection)
309
+ items = @layers[kind]
310
+ return if items.nil? || items.empty?
311
+
312
+ style =
313
+ case kind
314
+ when :street, :primary, :motorway, :secondary, :minor
315
+ @style.roads[kind]
316
+ when :rail
317
+ @style.rails
318
+ when :water
319
+ @style.water
320
+ when :park
321
+ @style.parks
322
+ else
323
+ @style.extra[kind] if @style.respond_to?(:extra)
324
+ end
325
+
326
+ return if style.nil?
327
+
328
+ items.each do |item|
329
+ if kind == :water
330
+ water_kind, f = item
331
+
332
+ width =
333
+ case water_kind
334
+ when :river then 2.5
335
+ when :stream then 1.5
336
+ else 1
337
+ end
338
+
339
+ if style[:stroke]
340
+ color = GD::Color.rgb(*style[:stroke])
341
+
342
+ color = GD::GIS::ColorHelpers.random_vivid if @debug
343
+
344
+ f.draw(@image, projection, color, width, :water)
345
+ end
346
+ else
347
+ f = item
348
+ geom = f.geometry["type"]
349
+
350
+ if geom == "Polygon" || geom == "MultiPolygon"
351
+ f.draw(@image, projection, nil, nil, style)
352
+ else
353
+ if style[:stroke]
354
+ color = GD::Color.rgb(*style[:stroke])
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)
361
+ end
362
+ end
363
+ end
364
+ end
365
+ end
366
+
367
+ end
368
+ end
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
@@ -0,0 +1,39 @@
1
+ module GD
2
+ module GIS
3
+ module Projection
4
+ R = 6378137.0
5
+
6
+ def self.mercator_x(lon)
7
+ lon * Math::PI / 180.0 * R
8
+ end
9
+
10
+ def self.mercator_y(lat)
11
+ Math.log(Math.tan(Math::PI/4 + lat * Math::PI / 360.0)) * R
12
+ end
13
+
14
+ def self.lonlat_to_pixel(lon, lat, min_x, max_x, min_y, max_y, width, height)
15
+ x = mercator_x(lon)
16
+ y = mercator_y(lat)
17
+
18
+ px = (x - min_x) / (max_x - min_x) * width
19
+ py = height - (y - min_y) / (max_y - min_y) * height
20
+
21
+ [px.to_i, py.to_i]
22
+ end
23
+
24
+ TILE_SIZE = 256
25
+
26
+ def self.lonlat_to_global_px(lon, lat, zoom)
27
+ lat = [[lat, 85.05112878].min, -85.05112878].max
28
+ n = 2.0 ** zoom
29
+
30
+ x = (lon + 180.0) / 360.0 * n * TILE_SIZE
31
+
32
+ lat_rad = lat * Math::PI / 180.0
33
+ y = (1.0 - Math.log(Math.tan(lat_rad) + 1.0 / Math.cos(lat_rad)) / Math::PI) / 2.0 * n * TILE_SIZE
34
+
35
+ [x, y]
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,45 @@
1
+ require "yaml"
2
+
3
+ module GD
4
+ module GIS
5
+ class Style
6
+ attr_reader :roads, :rails, :water, :parks, :order
7
+
8
+ def initialize(definition)
9
+ @roads = definition[:roads] || {}
10
+ @rails = definition[:rails] || {}
11
+ @water = definition[:water] || {}
12
+ @parks = definition[:parks] || {}
13
+ @order = definition[:order] || []
14
+ end
15
+
16
+ def self.load(name, from: "styles")
17
+ path = File.join(from, "#{name}.yml")
18
+ raise "Style not found: #{path}" unless File.exist?(path)
19
+
20
+ data = YAML.load_file(path)
21
+ data = deep_symbolize(data)
22
+
23
+ new(
24
+ roads: data[:roads],
25
+ rails: data[:rail] || data[:rails],
26
+ water: data[:water],
27
+ parks: data[:park] || data[:parks],
28
+ order: (data[:order] || []).map(&:to_sym)
29
+ )
30
+ end
31
+
32
+ def self.deep_symbolize(obj)
33
+ case obj
34
+ when Hash
35
+ obj.transform_keys(&:to_sym)
36
+ .transform_values { |v| deep_symbolize(v) }
37
+ when Array
38
+ obj.map { |v| deep_symbolize(v) }
39
+ else
40
+ obj
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
data/lib/gd/gis.rb ADDED
@@ -0,0 +1,15 @@
1
+ require "gd"
2
+
3
+ require_relative "gis/color_helpers"
4
+ require_relative "gis/style"
5
+ require_relative "gis/classifier"
6
+
7
+ require_relative "gis/feature"
8
+ require_relative "gis/map"
9
+ require_relative "gis/basemap"
10
+ require_relative "gis/projection"
11
+ require_relative "gis/geometry"
12
+ require_relative "gis/layer_points"
13
+ require_relative "gis/layer_lines"
14
+ require_relative "gis/layer_polygons"
15
+ require_relative "gis/layer_geojson"
data/lib/libgd_gis.rb ADDED
@@ -0,0 +1,44 @@
1
+ require "libgd_gis"
2
+
3
+ require "open-uri"
4
+ require "tempfile"
5
+ require "gd"
6
+
7
+ module LibGD
8
+ module GIS
9
+ class Tile
10
+ attr_reader :z, :x, :y, :image
11
+
12
+ def self.osm(z:, x:, y:)
13
+ new(
14
+ z: z,
15
+ x: x,
16
+ y: y,
17
+ source: "https://api.maptiler.com/maps/basic/#{z}/#{x}/#{y}.png?key=GetYourOwnKey"
18
+ )
19
+ end
20
+
21
+ def initialize(z:, x:, y:, source:)
22
+ @z = z
23
+ @x = x
24
+ @y = y
25
+ @source = source
26
+ end
27
+
28
+ def render
29
+ tmp = Tempfile.new(["tile", ".png"])
30
+ tmp.binmode
31
+ tmp.write URI.open(@source).read
32
+ tmp.flush
33
+
34
+ @image = GD::Image.open(tmp.path)
35
+ ensure
36
+ tmp.close
37
+ end
38
+
39
+ def save(path)
40
+ @image.save(path)
41
+ end
42
+ end
43
+ end
44
+ end