libgd-gis 0.2.7.pre.alpha.1 → 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.
- checksums.yaml +4 -4
- data/lib/gd/gis/color_helpers.rb +65 -0
- data/lib/gd/gis/geometry.rb +211 -16
- data/lib/gd/gis/layer_lines.rb +27 -19
- data/lib/gd/gis/layer_points.rb +2 -8
- data/lib/gd/gis/layer_polygons.rb +22 -6
- data/lib/gd/gis/map.rb +209 -122
- data/lib/gd/gis/middleware.rb +89 -0
- data/lib/gd/gis/ontology.yml +3 -242
- data/lib/gd/gis.rb +1 -1
- metadata +8 -8
- data/lib/gd/gis/input.rb +0 -0
- data/lib/gd/gis/path_sampler.rb +0 -68
- data/lib/gd/gis/style/dark.rb +0 -49
- data/lib/gd/gis/style/light.rb +0 -49
- data/lib/gd/gis/style/solarized.rb +0 -49
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 78e53ca4b47b3bd61cdc1a52268598fefbfa1797f2781a5296e4be856523787e
|
|
4
|
+
data.tar.gz: 2cbf66d159688eb784f2801527ba1856fc836c58211187c772ed1060ed702e36
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 55886e495283ecac661a25773d87fc98420cd38abaf8ca0f39fc9896b42c58d7e3da40f0aeaecb977def3c9d0eb721fc2520f7eb7d07fe6b5de89506cc1fae50
|
|
7
|
+
data.tar.gz: 90e030366511f4cc4c8aa8de17ac0ce17a129c56d71c2c3f46d2fb6a03a882c8408870c9ec5bc132c837d4a3a8bf4681385c77862cfd0fdde4b5f03378688c0e
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module GD
|
|
2
|
+
module GIS
|
|
3
|
+
module ColorHelpers
|
|
4
|
+
# --------------------------------------------------
|
|
5
|
+
# Random RGB color
|
|
6
|
+
# --------------------------------------------------
|
|
7
|
+
def self.random_rgb(min: 0, max: 255)
|
|
8
|
+
GD::Color.rgb(
|
|
9
|
+
rand(min..max),
|
|
10
|
+
rand(min..max),
|
|
11
|
+
rand(min..max)
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# --------------------------------------------------
|
|
16
|
+
# Random RGBA color
|
|
17
|
+
# --------------------------------------------------
|
|
18
|
+
def self.random_rgba(min: 0, max: 255, alpha: nil)
|
|
19
|
+
GD::Color.rgba(
|
|
20
|
+
rand(min..max),
|
|
21
|
+
rand(min..max),
|
|
22
|
+
rand(min..max),
|
|
23
|
+
alpha || rand(50..255)
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# --------------------------------------------------
|
|
28
|
+
# Random vivid color (avoid gray/mud)
|
|
29
|
+
# --------------------------------------------------
|
|
30
|
+
def self.random_vivid
|
|
31
|
+
h = rand
|
|
32
|
+
s = rand(0.6..1.0)
|
|
33
|
+
v = rand(0.7..1.0)
|
|
34
|
+
|
|
35
|
+
r, g, b = hsv_to_rgb(h, s, v)
|
|
36
|
+
GD::Color.rgb(r, g, b)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# --------------------------------------------------
|
|
40
|
+
# HSV → RGB
|
|
41
|
+
# --------------------------------------------------
|
|
42
|
+
def self.hsv_to_rgb(h, s, v)
|
|
43
|
+
i = (h * 6).floor
|
|
44
|
+
f = h * 6 - i
|
|
45
|
+
p = v * (1 - s)
|
|
46
|
+
q = v * (1 - f * s)
|
|
47
|
+
t = v * (1 - (1 - f) * s)
|
|
48
|
+
|
|
49
|
+
r, g, b =
|
|
50
|
+
case i % 6
|
|
51
|
+
when 0 then [v, t, p]
|
|
52
|
+
when 1 then [q, v, p]
|
|
53
|
+
when 2 then [p, v, t]
|
|
54
|
+
when 3 then [p, q, v]
|
|
55
|
+
when 4 then [t, p, v]
|
|
56
|
+
when 5 then [v, p, q]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
[(r * 255).to_i, (g * 255).to_i, (b * 255).to_i]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
data/lib/gd/gis/geometry.rb
CHANGED
|
@@ -1,40 +1,235 @@
|
|
|
1
1
|
module GD
|
|
2
2
|
module GIS
|
|
3
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
|
+
#
|
|
4
92
|
def self.buffer_line(coords, meters)
|
|
5
|
-
|
|
93
|
+
validate_coords!(coords)
|
|
94
|
+
|
|
95
|
+
left = []
|
|
6
96
|
right = []
|
|
7
97
|
|
|
8
|
-
coords.each_cons(2) do |a,b|
|
|
9
|
-
x1,y1 = a
|
|
10
|
-
x2,y2 = b
|
|
98
|
+
coords.each_cons(2) do |a, b|
|
|
99
|
+
x1, y1 = a
|
|
100
|
+
x2, y2 = b
|
|
11
101
|
|
|
12
102
|
dx = x2 - x1
|
|
13
103
|
dy = y2 - y1
|
|
14
104
|
|
|
15
|
-
len = Math.sqrt(dx*dx + dy*dy)
|
|
16
|
-
next if len
|
|
105
|
+
len = Math.sqrt(dx * dx + dy * dy)
|
|
106
|
+
next if len.zero?
|
|
17
107
|
|
|
18
|
-
# normal vector
|
|
19
108
|
nx = -dy / len
|
|
20
|
-
ny =
|
|
109
|
+
ny = dx / len
|
|
21
110
|
|
|
22
|
-
# offset in degrees (approx)
|
|
23
|
-
# 1 meter ≈ 1 / 111_320 degrees
|
|
24
111
|
off = meters / 111_320.0
|
|
25
112
|
|
|
26
|
-
left << [x1 + nx*off, y1 + ny*off]
|
|
27
|
-
right << [x1 - nx*off, y1 - ny*off]
|
|
113
|
+
left << [x1 + nx * off, y1 + ny * off]
|
|
114
|
+
right << [x1 - nx * off, y1 - ny * off]
|
|
28
115
|
end
|
|
29
116
|
|
|
30
|
-
|
|
31
|
-
x2,y2 = coords.last
|
|
117
|
+
x2, y2 = coords.last
|
|
32
118
|
left << [x2, y2]
|
|
33
119
|
right << [x2, y2]
|
|
34
120
|
|
|
35
|
-
|
|
36
|
-
polygon
|
|
121
|
+
left + right.reverse
|
|
37
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
|
+
|
|
38
232
|
end
|
|
39
233
|
end
|
|
40
234
|
end
|
|
235
|
+
|
data/lib/gd/gis/layer_lines.rb
CHANGED
|
@@ -1,35 +1,43 @@
|
|
|
1
1
|
module GD
|
|
2
2
|
module GIS
|
|
3
3
|
class LinesLayer
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
attr_accessor :debug
|
|
5
|
+
|
|
6
|
+
def initialize(lines, stroke:, width:)
|
|
7
|
+
@lines = lines
|
|
6
8
|
@stroke = stroke
|
|
7
|
-
@width
|
|
9
|
+
@width = width
|
|
10
|
+
@debug = false
|
|
8
11
|
end
|
|
9
12
|
|
|
10
13
|
def render!(img, projection)
|
|
11
|
-
@
|
|
12
|
-
|
|
14
|
+
@lines.each do |line|
|
|
15
|
+
raise "Invalid line: #{line.inspect}" unless valid_line?(line)
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
+
)
|
|
21
30
|
end
|
|
22
31
|
end
|
|
23
32
|
end
|
|
24
33
|
|
|
25
|
-
|
|
26
|
-
pts = coords.map { |p| projection.call(p[0], p[1]) }
|
|
34
|
+
private
|
|
27
35
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
33
41
|
end
|
|
34
42
|
end
|
|
35
43
|
end
|
data/lib/gd/gis/layer_points.rb
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
module GD
|
|
2
2
|
module GIS
|
|
3
3
|
class PointsLayer
|
|
4
|
-
attr_accessor :data, :size
|
|
5
4
|
|
|
6
5
|
def initialize(data, lon:, lat:, icon:, label: nil, font: nil, size: 12, color: [0,0,0])
|
|
7
6
|
@data = data
|
|
@@ -17,7 +16,6 @@ module GD
|
|
|
17
16
|
@label = label
|
|
18
17
|
@font = font
|
|
19
18
|
@size = size
|
|
20
|
-
@data = data
|
|
21
19
|
@color = color
|
|
22
20
|
|
|
23
21
|
@icon.alpha_blending = true
|
|
@@ -27,12 +25,8 @@ module GD
|
|
|
27
25
|
def build_default_marker
|
|
28
26
|
size = 32
|
|
29
27
|
img = GD::Image.new(size, size)
|
|
30
|
-
img.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
transparent = GD::Color.rgba(0,0,0,0)
|
|
34
|
-
img.filled_rectangle(0,0,size,size,transparent)
|
|
35
|
-
|
|
28
|
+
img.antialias = true
|
|
29
|
+
|
|
36
30
|
white = GD::Color.rgb(255,255,255)
|
|
37
31
|
black = GD::Color.rgb(0,0,0)
|
|
38
32
|
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
module GD
|
|
2
2
|
module GIS
|
|
3
3
|
class PolygonsLayer
|
|
4
|
+
attr_accessor :debug
|
|
5
|
+
|
|
4
6
|
def initialize(polygons, fill:, stroke: nil, width: nil)
|
|
5
7
|
@polygons = polygons
|
|
6
8
|
@fill = fill
|
|
7
9
|
@stroke = stroke
|
|
8
10
|
@width = width
|
|
11
|
+
|
|
12
|
+
@debug = false
|
|
9
13
|
end
|
|
10
14
|
|
|
11
15
|
def self.from_lines(features, stroke:, fill:, width:)
|
|
@@ -21,18 +25,30 @@ module GD
|
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
def render!(img, projection)
|
|
24
|
-
@polygons.each do |
|
|
25
|
-
|
|
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
|
|
26
34
|
|
|
27
|
-
|
|
35
|
+
@stroke = GD::GIS::ColorHelpers.random_vivid if @debug
|
|
36
|
+
@fill = GD::GIS::ColorHelpers.random_vivid if @debug
|
|
28
37
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
img.
|
|
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
|
|
32
47
|
end
|
|
33
48
|
end
|
|
34
49
|
end
|
|
35
50
|
end
|
|
51
|
+
|
|
36
52
|
end
|
|
37
53
|
end
|
|
38
54
|
end
|