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.
- checksums.yaml +7 -0
- data/README.md +188 -0
- data/lib/gd/gis/basemap.rb +178 -0
- data/lib/gd/gis/classifier.rb +57 -0
- data/lib/gd/gis/color_helpers.rb +65 -0
- data/lib/gd/gis/crs_normalizer.rb +57 -0
- data/lib/gd/gis/feature.rb +153 -0
- data/lib/gd/gis/geometry.rb +235 -0
- data/lib/gd/gis/input/detector.rb +34 -0
- data/lib/gd/gis/input/geojson.rb +0 -0
- data/lib/gd/gis/input/kml.rb +0 -0
- data/lib/gd/gis/input/shapefile.rb +0 -0
- data/lib/gd/gis/layer_geojson.rb +66 -0
- data/lib/gd/gis/layer_lines.rb +44 -0
- data/lib/gd/gis/layer_points.rb +78 -0
- data/lib/gd/gis/layer_polygons.rb +54 -0
- data/lib/gd/gis/map.rb +370 -0
- data/lib/gd/gis/middleware.rb +89 -0
- data/lib/gd/gis/ontology.rb +26 -0
- data/lib/gd/gis/ontology.yml +28 -0
- data/lib/gd/gis/projection.rb +39 -0
- data/lib/gd/gis/style.rb +45 -0
- data/lib/gd/gis.rb +15 -0
- data/lib/libgd_gis.rb +44 -0
- metadata +87 -0
|
@@ -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
|