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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6559ec92e07ae18e0a783d5d822c676437c68896928d7e2a59143ffd0ef9bb7e
4
- data.tar.gz: d1d343b32dbef5a5093746577db4b61d678a27d42949737079f3ba8e4c840a9a
3
+ metadata.gz: 78e53ca4b47b3bd61cdc1a52268598fefbfa1797f2781a5296e4be856523787e
4
+ data.tar.gz: 2cbf66d159688eb784f2801527ba1856fc836c58211187c772ed1060ed702e36
5
5
  SHA512:
6
- metadata.gz: 40cdba7a5707db6afde50b1358e6ae65eb936e34880d4ba2d593acfa72945cb09dc25661bab3dfdf7eefc6ea7f260171f9c38bc396dbeb2c42c83fc573964bd7
7
- data.tar.gz: ac76fd31a7b28acae6067ed56ed4783d2dabb97992d19c841af25459204caf9e3180cd5bea9e74b9de8c583741ab187044ccbdcabd45ce1b2523438faf5c1b5b
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
+
@@ -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
- left = []
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 == 0
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 = dx / len
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
- # add last point
31
- x2,y2 = coords.last
117
+ x2, y2 = coords.last
32
118
  left << [x2, y2]
33
119
  right << [x2, y2]
34
120
 
35
- polygon = left + right.reverse
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
+
@@ -1,35 +1,43 @@
1
1
  module GD
2
2
  module GIS
3
3
  class LinesLayer
4
- def initialize(features, stroke:, width:)
5
- @features = features
4
+ attr_accessor :debug
5
+
6
+ def initialize(lines, stroke:, width:)
7
+ @lines = lines
6
8
  @stroke = stroke
7
- @width = width
9
+ @width = width
10
+ @debug = false
8
11
  end
9
12
 
10
13
  def render!(img, projection)
11
- @features.each do |f|
12
- geom = f["geometry"]
14
+ @lines.each do |line|
15
+ raise "Invalid line: #{line.inspect}" unless valid_line?(line)
13
16
 
14
- case geom["type"]
15
- when "LineString"
16
- draw_line(geom["coordinates"], img, projection)
17
- when "MultiLineString"
18
- geom["coordinates"].each do |line|
19
- draw_line(line, img, projection)
20
- end
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
- def draw_line(coords, img, projection)
26
- pts = coords.map { |p| projection.call(p[0], p[1]) }
34
+ private
27
35
 
28
- (0...pts.size-1).each do |i|
29
- x1,y1 = pts[i]
30
- x2,y2 = pts[i+1]
31
- img.line(x1,y1,x2,y2,@stroke, thickness: @width)
32
- end
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
@@ -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.alpha_blending = true
31
- img.save_alpha = true
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 |poly|
25
- pts = poly.map { |p| projection.call(p[0], p[1]) }
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
- img.filled_polygon(pts, @fill)
35
+ @stroke = GD::GIS::ColorHelpers.random_vivid if @debug
36
+ @fill = GD::GIS::ColorHelpers.random_vivid if @debug
28
37
 
29
- if @stroke
30
- pts.each_cons(2) do |a,b|
31
- img.line(a[0],a[1],b[0],b[1],@stroke, thickness: 1)
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