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.
@@ -0,0 +1,153 @@
1
+ module GD
2
+ module GIS
3
+ class Feature
4
+ attr_reader :geometry, :properties, :layer
5
+
6
+ def initialize(geometry, properties, layer = nil)
7
+ @geometry = geometry
8
+ @properties = properties || {}
9
+ @layer = layer
10
+ end
11
+
12
+ # -------------------------------------------------
13
+ # Main draw entry point
14
+ # -------------------------------------------------
15
+ def draw(img, projection, color, width, layer = nil)
16
+ case geometry["type"]
17
+ when "Polygon"
18
+ if layer == :water
19
+ draw_polygon_outline(img, projection, geometry["coordinates"], color, width)
20
+ elsif layer.is_a?(Hash)
21
+ draw_polygon_styled(img, projection, geometry["coordinates"], layer)
22
+ else
23
+ draw_polygon(img, projection, geometry["coordinates"], color)
24
+ end
25
+ when "MultiPolygon"
26
+ geometry["coordinates"].each do |poly|
27
+ if layer == :water
28
+ draw_polygon_outline(img, projection, poly, color, width)
29
+ elsif layer.is_a?(Hash)
30
+ draw_polygon_styled(img, projection, poly, layer)
31
+ else
32
+ draw_polygon(img, projection, poly, color)
33
+ end
34
+ end
35
+ when "LineString", "MultiLineString"
36
+ draw_lines(img, projection, geometry["coordinates"], color, width)
37
+ end
38
+ end
39
+
40
+ # -------------------------------------------------
41
+ # Styled polygon rendering (fill + stroke)
42
+ # -------------------------------------------------
43
+ def draw_polygon_styled(img, projection, rings, style)
44
+ fill = style[:fill] ? GD::Color.rgb(*style[:fill]) : nil
45
+ stroke = style[:stroke] ? GD::Color.rgb(*style[:stroke]) : nil
46
+
47
+ rings.each do |ring|
48
+ pts = ring.map do |lon,lat|
49
+ x,y = projection.call(lon,lat)
50
+ next if x.nil? || y.nil?
51
+ [x.to_i, y.to_i]
52
+ end.compact
53
+
54
+ pts = pts.chunk_while { |a,b| a == b }.map(&:first)
55
+ next if pts.length < 3
56
+
57
+ img.filled_polygon(pts, fill) if fill
58
+
59
+ if stroke
60
+ pts.each_cons(2) { |a,b| img.line(a[0],a[1], b[0],b[1], stroke) }
61
+ img.line(pts.last[0], pts.last[1], pts.first[0], pts.first[1], stroke)
62
+ end
63
+ end
64
+ end
65
+
66
+ # -------------------------------------------------
67
+ # Polygon outline (used for water)
68
+ # -------------------------------------------------
69
+ def draw_polygon_outline(img, projection, rings, color, width)
70
+ return if color.nil?
71
+
72
+ rings.each do |ring|
73
+ pts = ring.map do |lon, lat|
74
+ x, y = projection.call(lon, lat)
75
+ [x.to_i, y.to_i] if x && y
76
+ end.compact
77
+
78
+ next if pts.size < 2
79
+
80
+ img.lines(pts, color, width)
81
+ end
82
+ end
83
+
84
+ # -------------------------------------------------
85
+ # Legacy filled polygon (single color)
86
+ # -------------------------------------------------
87
+ def draw_polygon(img, projection, rings, color)
88
+ return if color.nil?
89
+
90
+ rings.each do |ring|
91
+ pts = ring.map do |lon,lat|
92
+ x,y = projection.call(lon,lat)
93
+ next if x.nil? || y.nil?
94
+ [x.to_i, y.to_i]
95
+ end.compact
96
+
97
+ pts = pts.chunk_while { |a,b| a == b }.map(&:first)
98
+ next if pts.length < 3
99
+
100
+ img.filled_polygon(pts, color)
101
+ end
102
+ end
103
+
104
+ # -------------------------------------------------
105
+ # Lines
106
+ # -------------------------------------------------
107
+ def draw_lines(img, projection, coords, color, width)
108
+ return if color.nil?
109
+
110
+ if coords.first.is_a?(Array) && coords.first.first.is_a?(Array)
111
+ coords.each { |line| draw_line(img, projection, line, color, width) }
112
+ else
113
+ draw_line(img, projection, coords, color, width)
114
+ end
115
+ end
116
+
117
+ def draw_line(img, projection, coords, color, width)
118
+ return if color.nil?
119
+
120
+ coords.each_cons(2) do |(lon1,lat1),(lon2,lat2)|
121
+ x1,y1 = projection.call(lon1,lat1)
122
+ x2,y2 = projection.call(lon2,lat2)
123
+ img.line(x1, y1, x2, y2, color, thickness: width)
124
+ end
125
+ end
126
+
127
+ # -------------------------------------------------
128
+ # Metadata helpers
129
+ # -------------------------------------------------
130
+ def label
131
+ properties["name:ja"] || properties["name"]
132
+ end
133
+
134
+ def centroid
135
+ pts = []
136
+
137
+ case geometry["type"]
138
+ when "LineString"
139
+ pts = geometry["coordinates"]
140
+ when "MultiLineString"
141
+ pts = geometry["coordinates"].flatten(1)
142
+ end
143
+
144
+ return nil if pts.empty?
145
+
146
+ lon = pts.map(&:first).sum / pts.size
147
+ lat = pts.map(&:last).sum / pts.size
148
+
149
+ [lon, lat]
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,235 @@
1
+ module GD
2
+ module GIS
3
+ module Geometry
4
+
5
+ TILE_SIZE = 256.0
6
+ MAX_LAT = 85.05112878
7
+
8
+ # --------------------------------------------------
9
+ # Validation
10
+ # --------------------------------------------------
11
+
12
+ def self.validate_bbox!(bbox)
13
+ unless bbox.is_a?(Array) && bbox.size == 4
14
+ raise ArgumentError, "bbox must be [min_lng, min_lat, max_lng, max_lat]"
15
+ end
16
+ end
17
+
18
+ def self.validate_coords!(coords)
19
+ unless coords.is_a?(Array) && coords.size >= 2
20
+ raise ArgumentError, "coords must be an Array of at least 2 points"
21
+ end
22
+ end
23
+
24
+ # --------------------------------------------------
25
+ # Web Mercator Projection
26
+ # --------------------------------------------------
27
+
28
+ def self.lng_to_x(lng, zoom)
29
+ ((lng + 180.0) / 360.0) * TILE_SIZE * (2**zoom)
30
+ end
31
+
32
+ def self.lat_to_y(lat, zoom)
33
+ lat = [[lat, MAX_LAT].min, -MAX_LAT].max
34
+ lat_rad = lat * Math::PI / 180.0
35
+ n = Math.log(Math.tan(Math::PI / 4.0 + lat_rad / 2.0))
36
+ (1.0 - n / Math::PI) / 2.0 * TILE_SIZE * (2**zoom)
37
+ end
38
+
39
+ def self.x_to_lng(x, zoom)
40
+ (x / (TILE_SIZE * (2**zoom))) * 360.0 - 180.0
41
+ end
42
+
43
+ def self.y_to_lat(y, zoom)
44
+ n = Math::PI - 2.0 * Math::PI * y / (TILE_SIZE * (2**zoom))
45
+ 180.0 / Math::PI * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)))
46
+ end
47
+
48
+ # --------------------------------------------------
49
+ # Viewport
50
+ # --------------------------------------------------
51
+
52
+ def self.viewport_bbox(bbox:, zoom:, width:, height:)
53
+ validate_bbox!(bbox)
54
+
55
+ min_lng, min_lat, max_lng, max_lat = bbox
56
+
57
+ center_lng = (min_lng + max_lng) / 2.0
58
+ center_lat = (min_lat + max_lat) / 2.0
59
+
60
+ center_x = lng_to_x(center_lng, zoom)
61
+ center_y = lat_to_y(center_lat, zoom)
62
+
63
+ half_w = width / 2.0
64
+ half_h = height / 2.0
65
+
66
+ min_x = center_x - half_w
67
+ max_x = center_x + half_w
68
+ min_y = center_y - half_h
69
+ max_y = center_y + half_h
70
+
71
+ [
72
+ x_to_lng(min_x, zoom),
73
+ y_to_lat(max_y, zoom),
74
+ x_to_lng(max_x, zoom),
75
+ y_to_lat(min_y, zoom)
76
+ ]
77
+ end
78
+
79
+ # --------------------------------------------------
80
+ # Geometry helpers
81
+ # --------------------------------------------------
82
+
83
+ # buffer_line(coords, meters)
84
+ #
85
+ # coords :: Array<[lng, lat]> in WGS84
86
+ # meters :: Numeric (approximate)
87
+ #
88
+ # NOTE:
89
+ # - Naive meters-to-degrees conversion
90
+ # - Suitable for visualization, not analysis
91
+ #
92
+ def self.buffer_line(coords, meters)
93
+ validate_coords!(coords)
94
+
95
+ left = []
96
+ right = []
97
+
98
+ coords.each_cons(2) do |a, b|
99
+ x1, y1 = a
100
+ x2, y2 = b
101
+
102
+ dx = x2 - x1
103
+ dy = y2 - y1
104
+
105
+ len = Math.sqrt(dx * dx + dy * dy)
106
+ next if len.zero?
107
+
108
+ nx = -dy / len
109
+ ny = dx / len
110
+
111
+ off = meters / 111_320.0
112
+
113
+ left << [x1 + nx * off, y1 + ny * off]
114
+ right << [x1 - nx * off, y1 - ny * off]
115
+ end
116
+
117
+ x2, y2 = coords.last
118
+ left << [x2, y2]
119
+ right << [x2, y2]
120
+
121
+ left + right.reverse
122
+ end
123
+
124
+ def self.project(lng, lat, bbox, zoom)
125
+ min_lng, _min_lat, _max_lng, max_lat = bbox
126
+
127
+ world_x = lng_to_x(lng, zoom)
128
+ world_y = lat_to_y(lat, zoom)
129
+
130
+ offset_x = lng_to_x(min_lng, zoom)
131
+ offset_y = lat_to_y(max_lat, zoom)
132
+
133
+ [
134
+ world_x - offset_x,
135
+ world_y - offset_y
136
+ ]
137
+ end
138
+
139
+ def self.bbox_for_image(path, zoom:, width:, height:, padding_px: 80)
140
+ data = JSON.parse(File.read(path))
141
+ points = []
142
+
143
+ data["features"].each do |f|
144
+ geom = f["geometry"]
145
+ next unless geom
146
+ collect_points(geom, points)
147
+ end
148
+
149
+ raise "No coordinates found in GeoJSON" if points.empty?
150
+
151
+ # --------------------------------------------------
152
+ # 1. Project to pixel space
153
+ # --------------------------------------------------
154
+ xs = []
155
+ ys = []
156
+
157
+ points.each do |lon, lat|
158
+ xs << lng_to_x(lon, zoom)
159
+ ys << lat_to_y(lat, zoom)
160
+ end
161
+
162
+ min_x = xs.min - padding_px
163
+ max_x = xs.max + padding_px
164
+ min_y = ys.min - padding_px
165
+ max_y = ys.max + padding_px
166
+
167
+ # --------------------------------------------------
168
+ # 2. Fit bbox to image aspect ratio
169
+ # --------------------------------------------------
170
+ target_ratio = width.to_f / height.to_f
171
+ current_ratio = (max_x - min_x) / (max_y - min_y)
172
+
173
+ if current_ratio > target_ratio
174
+ # too wide → expand vertically
175
+ new_h = (max_x - min_x) / target_ratio
176
+ delta = (new_h - (max_y - min_y)) / 2.0
177
+ min_y -= delta
178
+ max_y += delta
179
+ else
180
+ # too tall → expand horizontally
181
+ new_w = (max_y - min_y) * target_ratio
182
+ delta = (new_w - (max_x - min_x)) / 2.0
183
+ min_x -= delta
184
+ max_x += delta
185
+ end
186
+
187
+ # --------------------------------------------------
188
+ # 3. Convert back to lon/lat
189
+ # --------------------------------------------------
190
+ [
191
+ x_to_lng(min_x, zoom),
192
+ y_to_lat(max_y, zoom),
193
+ x_to_lng(max_x, zoom),
194
+ y_to_lat(min_y, zoom)
195
+ ]
196
+ end
197
+
198
+ def self.collect_points(geom, points)
199
+ case geom["type"]
200
+ when "Point"
201
+ points << geom["coordinates"]
202
+
203
+ when "MultiPoint", "LineString"
204
+ geom["coordinates"].each { |c| points << c }
205
+
206
+ when "MultiLineString", "Polygon"
207
+ geom["coordinates"].each do |line|
208
+ line.each { |c| points << c }
209
+ end
210
+
211
+ when "MultiPolygon"
212
+ geom["coordinates"].each do |poly|
213
+ poly.each do |ring|
214
+ ring.each { |c| points << c }
215
+ end
216
+ end
217
+ end
218
+ end
219
+
220
+ def self.bbox_around_point(lon, lat, radius_km:)
221
+ delta_lat = radius_km / 111.0
222
+ delta_lon = radius_km / (111.0 * Math.cos(lat * Math::PI / 180.0))
223
+
224
+ [
225
+ lon - delta_lon,
226
+ lat - delta_lat,
227
+ lon + delta_lon,
228
+ lat + delta_lat
229
+ ]
230
+ end
231
+
232
+ end
233
+ end
234
+ end
235
+
@@ -0,0 +1,34 @@
1
+ module GD
2
+ module GIS
3
+ module Input
4
+ module Detector
5
+ def self.detect(path)
6
+ return :geojson if geojson?(path)
7
+ return :kml if kml?(path)
8
+ return :shapefile if shapefile?(path)
9
+ return :osm_pbf if pbf?(path)
10
+ :unknown
11
+ end
12
+
13
+ def self.geojson?(path)
14
+ File.open(path) do |f|
15
+ head = f.read(2048)
16
+ head.include?('"FeatureCollection"') || head.include?('"GeometryCollection"')
17
+ end
18
+ end
19
+
20
+ def self.kml?(path)
21
+ File.open(path) { |f| f.read(512).include?("<kml") }
22
+ end
23
+
24
+ def self.shapefile?(path)
25
+ File.open(path, "rb") { |f| f.read(4) == "\x00\x00\x27\x0A" }
26
+ end
27
+
28
+ def self.pbf?(path)
29
+ File.open(path, "rb") { |f| f.read(2) == "\x1f\x8b" }
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
File without changes
File without changes
File without changes
@@ -0,0 +1,66 @@
1
+ require "json"
2
+ require_relative "feature"
3
+ require_relative "crs_normalizer"
4
+ require_relative "ontology"
5
+
6
+ module GD
7
+ module GIS
8
+ class LayerGeoJSON
9
+
10
+ def self.load(path)
11
+ data = JSON.parse(File.read(path))
12
+
13
+ # 1) Detect CRS
14
+ crs_name = data["crs"]&.dig("properties", "name")
15
+ normalizer = CRS::Normalizer.new(crs_name)
16
+
17
+ # 2) Load ontology
18
+ ontology = Ontology.new
19
+
20
+ # 3) Normalize geometries + classify
21
+ data["features"].map do |f|
22
+ normalize_geometry!(f["geometry"], normalizer)
23
+ layer = ontology.classify(f["properties"] || {})
24
+ Feature.new(f["geometry"], f["properties"], layer)
25
+ end
26
+ end
27
+
28
+ # --------------------------------------------
29
+ # CRS normalization (2D + 3D safe)
30
+ # --------------------------------------------
31
+ def self.normalize_geometry!(geometry, normalizer)
32
+ case geometry["type"]
33
+
34
+ when "Point"
35
+ geometry["coordinates"] =
36
+ normalizer.normalize(geometry["coordinates"])
37
+
38
+ when "LineString"
39
+ geometry["coordinates"] =
40
+ geometry["coordinates"].map { |c| normalizer.normalize(c) }
41
+
42
+ when "MultiLineString"
43
+ geometry["coordinates"] =
44
+ geometry["coordinates"].map do |line|
45
+ line.map { |c| normalizer.normalize(c) }
46
+ end
47
+
48
+ when "Polygon"
49
+ geometry["coordinates"] =
50
+ geometry["coordinates"].map do |ring|
51
+ ring.map { |c| normalizer.normalize(c) }
52
+ end
53
+
54
+ when "MultiPolygon"
55
+ geometry["coordinates"] =
56
+ geometry["coordinates"].map do |poly|
57
+ poly.map do |ring|
58
+ ring.map { |c| normalizer.normalize(c) }
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,44 @@
1
+ module GD
2
+ module GIS
3
+ class LinesLayer
4
+ attr_accessor :debug
5
+
6
+ def initialize(lines, stroke:, width:)
7
+ @lines = lines
8
+ @stroke = stroke
9
+ @width = width
10
+ @debug = false
11
+ end
12
+
13
+ def render!(img, projection)
14
+ @lines.each do |line|
15
+ raise "Invalid line: #{line.inspect}" unless valid_line?(line)
16
+
17
+ pts = line.map do |lng, lat|
18
+ projection.call(lng, lat)
19
+ end
20
+
21
+ color = @debug ? ColorHelpers.random_vivid : @stroke
22
+
23
+ pts.each_cons(2) do |a, b|
24
+ img.line(
25
+ a[0], a[1],
26
+ b[0], b[1],
27
+ color,
28
+ thickness: @width
29
+ )
30
+ end
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def valid_line?(line)
37
+ line.is_a?(Array) &&
38
+ line.size >= 2 &&
39
+ line.first.is_a?(Array) &&
40
+ line.first.size == 2
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,78 @@
1
+ module GD
2
+ module GIS
3
+ class PointsLayer
4
+
5
+ def initialize(data, lon:, lat:, icon:, label: nil, font: nil, size: 12, color: [0,0,0])
6
+ @data = data
7
+ @lon = lon
8
+ @lat = lat
9
+
10
+ if icon
11
+ @icon = GD::Image.open(icon)
12
+ else
13
+ @icon = build_default_marker
14
+ end
15
+
16
+ @label = label
17
+ @font = font
18
+ @size = size
19
+ @color = color
20
+
21
+ @icon.alpha_blending = true
22
+ @icon.save_alpha = true
23
+ end
24
+
25
+ def build_default_marker
26
+ size = 32
27
+ img = GD::Image.new(size, size)
28
+ img.antialias = true
29
+
30
+ white = GD::Color.rgb(255,255,255)
31
+ black = GD::Color.rgb(0,0,0)
32
+
33
+ cx = size / 2
34
+ cy = size / 2
35
+ r = 12
36
+
37
+ # borde blanco
38
+ img.arc(cx, cy, r*2+4, r*2+4, 0, 360, white)
39
+
40
+ # relleno negro
41
+ img.filled_arc(cx, cy, r*2, r*2, 0, 360, black)
42
+
43
+ img
44
+ end
45
+
46
+ def render!(img, projector)
47
+ w = @icon.width
48
+ h = @icon.height
49
+
50
+ @data.each do |row|
51
+ lon = @lon.call(row)
52
+ lat = @lat.call(row)
53
+
54
+ x,y = projector.call(lon,lat)
55
+
56
+ # icono
57
+ img.copy(@icon, x - w/2, y - h/2, 0,0,w,h)
58
+
59
+ # etiqueta opcional
60
+ if @label && @font
61
+ text = @label.call(row)
62
+ unless text.nil? || text.strip.empty?
63
+ font_h = @size * 1.1
64
+
65
+ img.text(text,
66
+ x: x + w/2 + 4,
67
+ y: y + font_h/2,
68
+ size: @size,
69
+ color: @color,
70
+ font: @font
71
+ )
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,54 @@
1
+ module GD
2
+ module GIS
3
+ class PolygonsLayer
4
+ attr_accessor :debug
5
+
6
+ def initialize(polygons, fill:, stroke: nil, width: nil)
7
+ @polygons = polygons
8
+ @fill = fill
9
+ @stroke = stroke
10
+ @width = width
11
+
12
+ @debug = false
13
+ end
14
+
15
+ def self.from_lines(features, stroke:, fill:, width:)
16
+ polys = []
17
+
18
+ features.each do |f|
19
+ coords = f["geometry"]["coordinates"]
20
+ poly = Geometry.buffer_line(coords, width)
21
+ polys << poly
22
+ end
23
+
24
+ new(polys, fill: fill, stroke: stroke)
25
+ end
26
+
27
+ def render!(img, projection)
28
+ @polygons.each do |polygon|
29
+ # polygon = [ ring, ring, ... ]
30
+ polygon.each_with_index do |ring, idx|
31
+ pts = ring.map do |lng, lat|
32
+ projection.call(lng, lat)
33
+ end
34
+
35
+ @stroke = GD::GIS::ColorHelpers.random_vivid if @debug
36
+ @fill = GD::GIS::ColorHelpers.random_vivid if @debug
37
+
38
+ if idx == 0
39
+ # ring exterior
40
+ img.filled_polygon(pts, @fill)
41
+ end
42
+
43
+ if @stroke
44
+ pts.each_cons(2) do |a, b|
45
+ img.line(a[0], a[1], b[0], b[1], @stroke, thickness: (@width || 1))
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ end
53
+ end
54
+ end