libgd-gis 0.2.9 → 0.3.0
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/README.md +132 -98
- data/lib/gd/gis/basemap.rb +69 -16
- data/lib/gd/gis/classifier.rb +48 -9
- data/lib/gd/gis/color_helpers.rb +39 -17
- data/lib/gd/gis/crs_normalizer.rb +53 -7
- data/lib/gd/gis/feature.rb +119 -36
- data/lib/gd/gis/font_helper.rb +33 -0
- data/lib/gd/gis/geometry.rb +116 -42
- data/lib/gd/gis/layer_geojson.rb +28 -4
- data/lib/gd/gis/layer_lines.rb +27 -0
- data/lib/gd/gis/layer_points.rb +69 -21
- data/lib/gd/gis/layer_polygons.rb +43 -8
- data/lib/gd/gis/map.rb +160 -66
- data/lib/gd/gis/middleware.rb +81 -18
- data/lib/gd/gis/ontology.rb +45 -2
- data/lib/gd/gis/ontology.yml +8 -0
- data/lib/gd/gis/projection.rb +55 -5
- data/lib/gd/gis/style.rb +66 -3
- data/lib/gd/gis.rb +28 -0
- data/lib/libgd_gis.rb +60 -30
- metadata +31 -6
- data/lib/gd/gis/input/detector.rb +0 -34
- 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
|
@@ -1,20 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module GD
|
|
2
4
|
module GIS
|
|
5
|
+
# Coordinate Reference System (CRS) helpers and constants.
|
|
6
|
+
#
|
|
7
|
+
# This namespace defines common CRS identifiers and utilities
|
|
8
|
+
# used for normalizing coordinate order and projection.
|
|
9
|
+
#
|
|
3
10
|
module CRS
|
|
11
|
+
# OGC CRS84 (longitude, latitude)
|
|
4
12
|
CRS84 = "urn:ogc:def:crs:OGC:1.3:CRS84"
|
|
13
|
+
|
|
14
|
+
# EPSG:4326 (latitude, longitude axis order)
|
|
5
15
|
EPSG4326 = "EPSG:4326"
|
|
16
|
+
|
|
17
|
+
# EPSG:3857 (Web Mercator)
|
|
6
18
|
EPSG3857 = "EPSG:3857"
|
|
7
19
|
|
|
20
|
+
# Normalizes coordinates from different CRS definitions
|
|
21
|
+
# into a consistent [longitude, latitude] order.
|
|
22
|
+
#
|
|
23
|
+
# This class handles:
|
|
24
|
+
# - Axis order normalization (e.g. EPSG:4326)
|
|
25
|
+
# - Web Mercator (EPSG:3857) to WGS84 conversion
|
|
26
|
+
# - Flexible input formats
|
|
27
|
+
#
|
|
28
|
+
# The output is always expressed as:
|
|
29
|
+
# [longitude, latitude] in degrees
|
|
30
|
+
#
|
|
8
31
|
class Normalizer
|
|
32
|
+
# Creates a new CRS normalizer.
|
|
33
|
+
#
|
|
34
|
+
# @param crs [String, Symbol, nil]
|
|
35
|
+
# CRS identifier (e.g. "EPSG:4326", "EPSG:3857", CRS84)
|
|
9
36
|
def initialize(crs)
|
|
10
37
|
@crs = normalize_name(crs)
|
|
11
38
|
end
|
|
12
39
|
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
# normalize(
|
|
40
|
+
# Normalizes coordinates into [longitude, latitude].
|
|
41
|
+
#
|
|
42
|
+
# Accepted input forms:
|
|
43
|
+
#
|
|
44
|
+
# normalize(lon, lat)
|
|
45
|
+
# normalize(lon, lat, z)
|
|
46
|
+
# normalize([lon, lat])
|
|
47
|
+
# normalize([lon, lat, z])
|
|
48
|
+
#
|
|
49
|
+
# Extra dimensions (e.g. Z) are ignored.
|
|
50
|
+
#
|
|
51
|
+
# @param args [Array<Float, Array<Float>>]
|
|
52
|
+
# @return [Array<Float>, nil]
|
|
53
|
+
# normalized [lon, lat] or nil if input is invalid
|
|
18
54
|
def normalize(*args)
|
|
19
55
|
lon, lat = args.flatten
|
|
20
56
|
return nil if lon.nil? || lat.nil?
|
|
@@ -34,21 +70,31 @@ module GD
|
|
|
34
70
|
mercator_to_wgs84(lon, lat)
|
|
35
71
|
|
|
36
72
|
else
|
|
37
|
-
|
|
73
|
+
raise ArgumentError, "Unsupported CRS: #{@crs}"
|
|
38
74
|
end
|
|
39
75
|
end
|
|
40
76
|
|
|
41
77
|
private
|
|
42
78
|
|
|
79
|
+
# Normalizes a CRS name into a comparable string.
|
|
80
|
+
#
|
|
81
|
+
# @param name [Object]
|
|
82
|
+
# @return [String, nil]
|
|
43
83
|
def normalize_name(name)
|
|
44
84
|
return nil if name.nil?
|
|
85
|
+
|
|
45
86
|
name.to_s.strip
|
|
46
87
|
end
|
|
47
88
|
|
|
89
|
+
# Converts Web Mercator coordinates to WGS84.
|
|
90
|
+
#
|
|
91
|
+
# @param x [Float] X coordinate (meters)
|
|
92
|
+
# @param y [Float] Y coordinate (meters)
|
|
93
|
+
# @return [Array<Float>] [longitude, latitude] in degrees
|
|
48
94
|
def mercator_to_wgs84(x, y)
|
|
49
95
|
r = 6378137.0
|
|
50
96
|
lon = (x / r) * 180.0 / Math::PI
|
|
51
|
-
lat = (2 * Math.atan(Math.exp(y / r)) - Math::PI / 2) * 180.0 / Math::PI
|
|
97
|
+
lat = ((2 * Math.atan(Math.exp(y / r))) - (Math::PI / 2)) * 180.0 / Math::PI
|
|
52
98
|
[lon, lat]
|
|
53
99
|
end
|
|
54
100
|
end
|
data/lib/gd/gis/feature.rb
CHANGED
|
@@ -1,17 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module GD
|
|
2
4
|
module GIS
|
|
5
|
+
# Represents a single geographic feature with geometry and properties.
|
|
6
|
+
#
|
|
7
|
+
# A Feature acts as the rendering bridge between:
|
|
8
|
+
# - GeoJSON-like geometry data
|
|
9
|
+
# - Attribute properties
|
|
10
|
+
# - GD drawing primitives
|
|
11
|
+
#
|
|
12
|
+
# Features are responsible for drawing themselves onto a GD image
|
|
13
|
+
# using a provided projection and styling information.
|
|
14
|
+
#
|
|
3
15
|
class Feature
|
|
4
|
-
|
|
5
|
-
|
|
16
|
+
# @return [Hash] GeoJSON geometry object
|
|
17
|
+
attr_reader :geometry
|
|
18
|
+
|
|
19
|
+
# @return [Hash] feature properties (tags)
|
|
20
|
+
attr_reader :properties
|
|
21
|
+
|
|
22
|
+
# @return [Symbol, nil] logical layer identifier
|
|
23
|
+
attr_reader :layer
|
|
24
|
+
|
|
25
|
+
# Creates a new feature.
|
|
26
|
+
#
|
|
27
|
+
# @param geometry [Hash]
|
|
28
|
+
# GeoJSON-like geometry hash (type + coordinates)
|
|
29
|
+
# @param properties [Hash, nil]
|
|
30
|
+
# feature attributes / tags
|
|
31
|
+
# @param layer [Symbol, nil]
|
|
32
|
+
# optional logical layer identifier
|
|
6
33
|
def initialize(geometry, properties, layer = nil)
|
|
7
34
|
@geometry = geometry
|
|
8
35
|
@properties = properties || {}
|
|
9
36
|
@layer = layer
|
|
10
37
|
end
|
|
11
38
|
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
39
|
+
# Draws the feature onto a GD image.
|
|
40
|
+
#
|
|
41
|
+
# This is the main rendering entry point and dispatches
|
|
42
|
+
# to the appropriate drawing method based on geometry type
|
|
43
|
+
# and layer styling.
|
|
44
|
+
#
|
|
45
|
+
# Supported geometry types:
|
|
46
|
+
# - Polygon
|
|
47
|
+
# - MultiPolygon
|
|
48
|
+
# - LineString
|
|
49
|
+
# - MultiLineString
|
|
50
|
+
#
|
|
51
|
+
# @param img [GD::Image] target image
|
|
52
|
+
# @param projection [#call]
|
|
53
|
+
# callable object converting (lon, lat) → (x, y)
|
|
54
|
+
# @param color [GD::Color, nil] base color
|
|
55
|
+
# @param width [Integer] stroke width
|
|
56
|
+
# @param layer [Symbol, Hash, nil]
|
|
57
|
+
# layer identifier or style hash
|
|
58
|
+
#
|
|
59
|
+
# @return [void]
|
|
15
60
|
def draw(img, projection, color, width, layer = nil)
|
|
16
61
|
case geometry["type"]
|
|
17
62
|
when "Polygon"
|
|
@@ -37,43 +82,58 @@ module GD
|
|
|
37
82
|
end
|
|
38
83
|
end
|
|
39
84
|
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
85
|
+
# Draws a polygon with fill and stroke styling.
|
|
86
|
+
#
|
|
87
|
+
# @param img [GD::Image]
|
|
88
|
+
# @param projection [#call]
|
|
89
|
+
# @param rings [Array]
|
|
90
|
+
# polygon rings (GeoJSON format)
|
|
91
|
+
# @param style [Hash]
|
|
92
|
+
# style hash with :fill and/or :stroke RGB arrays
|
|
93
|
+
#
|
|
94
|
+
# @return [void]
|
|
43
95
|
def draw_polygon_styled(img, projection, rings, style)
|
|
44
96
|
fill = style[:fill] ? GD::Color.rgb(*style[:fill]) : nil
|
|
45
97
|
stroke = style[:stroke] ? GD::Color.rgb(*style[:stroke]) : nil
|
|
46
98
|
|
|
47
99
|
rings.each do |ring|
|
|
48
|
-
pts = ring.
|
|
49
|
-
x,y = projection.call(lon,lat)
|
|
100
|
+
pts = ring.filter_map do |lon, lat|
|
|
101
|
+
x, y = projection.call(lon, lat)
|
|
50
102
|
next if x.nil? || y.nil?
|
|
103
|
+
|
|
51
104
|
[x.to_i, y.to_i]
|
|
52
|
-
end
|
|
105
|
+
end
|
|
53
106
|
|
|
54
|
-
pts = pts.chunk_while { |a,b| a == b }.map(&:first)
|
|
107
|
+
pts = pts.chunk_while { |a, b| a == b }.map(&:first)
|
|
55
108
|
next if pts.length < 3
|
|
56
109
|
|
|
57
110
|
img.filled_polygon(pts, fill) if fill
|
|
58
111
|
|
|
59
112
|
if stroke
|
|
60
|
-
pts.each_cons(2) { |a,b| img.line(a[0],a[1], b[0],b[1], stroke) }
|
|
113
|
+
pts.each_cons(2) { |a, b| img.line(a[0], a[1], b[0], b[1], stroke) }
|
|
61
114
|
img.line(pts.last[0], pts.last[1], pts.first[0], pts.first[1], stroke)
|
|
62
115
|
end
|
|
63
116
|
end
|
|
64
117
|
end
|
|
65
118
|
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
119
|
+
# Draws only the outline of a polygon.
|
|
120
|
+
#
|
|
121
|
+
# Used primarily for water bodies.
|
|
122
|
+
#
|
|
123
|
+
# @param img [GD::Image]
|
|
124
|
+
# @param projection [#call]
|
|
125
|
+
# @param rings [Array]
|
|
126
|
+
# @param color [GD::Color]
|
|
127
|
+
# @param width [Integer]
|
|
128
|
+
# @return [void]
|
|
69
129
|
def draw_polygon_outline(img, projection, rings, color, width)
|
|
70
130
|
return if color.nil?
|
|
71
131
|
|
|
72
132
|
rings.each do |ring|
|
|
73
|
-
pts = ring.
|
|
133
|
+
pts = ring.filter_map do |lon, lat|
|
|
74
134
|
x, y = projection.call(lon, lat)
|
|
75
135
|
[x.to_i, y.to_i] if x && y
|
|
76
|
-
end
|
|
136
|
+
end
|
|
77
137
|
|
|
78
138
|
next if pts.size < 2
|
|
79
139
|
|
|
@@ -81,29 +141,39 @@ module GD
|
|
|
81
141
|
end
|
|
82
142
|
end
|
|
83
143
|
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
144
|
+
# Draws a filled polygon using a single color.
|
|
145
|
+
#
|
|
146
|
+
# @param img [GD::Image]
|
|
147
|
+
# @param projection [#call]
|
|
148
|
+
# @param rings [Array]
|
|
149
|
+
# @param color [GD::Color]
|
|
150
|
+
# @return [void]
|
|
87
151
|
def draw_polygon(img, projection, rings, color)
|
|
88
152
|
return if color.nil?
|
|
89
153
|
|
|
90
154
|
rings.each do |ring|
|
|
91
|
-
pts = ring.
|
|
92
|
-
x,y = projection.call(lon,lat)
|
|
155
|
+
pts = ring.filter_map do |lon, lat|
|
|
156
|
+
x, y = projection.call(lon, lat)
|
|
93
157
|
next if x.nil? || y.nil?
|
|
158
|
+
|
|
94
159
|
[x.to_i, y.to_i]
|
|
95
|
-
end
|
|
160
|
+
end
|
|
96
161
|
|
|
97
|
-
pts = pts.chunk_while { |a,b| a == b }.map(&:first)
|
|
162
|
+
pts = pts.chunk_while { |a, b| a == b }.map(&:first)
|
|
98
163
|
next if pts.length < 3
|
|
99
164
|
|
|
100
165
|
img.filled_polygon(pts, color)
|
|
101
166
|
end
|
|
102
167
|
end
|
|
103
168
|
|
|
104
|
-
#
|
|
105
|
-
#
|
|
106
|
-
#
|
|
169
|
+
# Draws line or multiline geometries.
|
|
170
|
+
#
|
|
171
|
+
# @param img [GD::Image]
|
|
172
|
+
# @param projection [#call]
|
|
173
|
+
# @param coords [Array]
|
|
174
|
+
# @param color [GD::Color]
|
|
175
|
+
# @param width [Integer]
|
|
176
|
+
# @return [void]
|
|
107
177
|
def draw_lines(img, projection, coords, color, width)
|
|
108
178
|
return if color.nil?
|
|
109
179
|
|
|
@@ -114,23 +184,36 @@ module GD
|
|
|
114
184
|
end
|
|
115
185
|
end
|
|
116
186
|
|
|
187
|
+
# Draws a single line geometry.
|
|
188
|
+
#
|
|
189
|
+
# @param img [GD::Image]
|
|
190
|
+
# @param projection [#call]
|
|
191
|
+
# @param coords [Array]
|
|
192
|
+
# @param color [GD::Color]
|
|
193
|
+
# @param width [Integer]
|
|
194
|
+
# @return [void]
|
|
117
195
|
def draw_line(img, projection, coords, color, width)
|
|
118
196
|
return if color.nil?
|
|
119
197
|
|
|
120
|
-
coords.each_cons(2) do |(lon1,lat1),(lon2,lat2)|
|
|
121
|
-
x1,y1 = projection.call(lon1,lat1)
|
|
122
|
-
x2,y2 = projection.call(lon2,lat2)
|
|
198
|
+
coords.each_cons(2) do |(lon1, lat1), (lon2, lat2)|
|
|
199
|
+
x1, y1 = projection.call(lon1, lat1)
|
|
200
|
+
x2, y2 = projection.call(lon2, lat2)
|
|
123
201
|
img.line(x1, y1, x2, y2, color, thickness: width)
|
|
124
202
|
end
|
|
125
203
|
end
|
|
126
204
|
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
#
|
|
205
|
+
# Returns the display label for the feature.
|
|
206
|
+
#
|
|
207
|
+
# Prefers Japanese names if present.
|
|
208
|
+
#
|
|
209
|
+
# @return [String, nil]
|
|
130
210
|
def label
|
|
131
211
|
properties["name:ja"] || properties["name"]
|
|
132
212
|
end
|
|
133
213
|
|
|
214
|
+
# Computes a simple centroid for line-based geometries.
|
|
215
|
+
#
|
|
216
|
+
# @return [Array<Float>, nil] [lon, lat] or nil
|
|
134
217
|
def centroid
|
|
135
218
|
pts = []
|
|
136
219
|
|
|
@@ -143,8 +226,8 @@ module GD
|
|
|
143
226
|
|
|
144
227
|
return nil if pts.empty?
|
|
145
228
|
|
|
146
|
-
lon = pts.
|
|
147
|
-
lat = pts.
|
|
229
|
+
lon = pts.sum(&:first) / pts.size
|
|
230
|
+
lat = pts.sum(&:last) / pts.size
|
|
148
231
|
|
|
149
232
|
[lon, lat]
|
|
150
233
|
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module GD
|
|
2
|
+
module GIS
|
|
3
|
+
module FontHelper
|
|
4
|
+
PATHS = [
|
|
5
|
+
"/usr/share/fonts",
|
|
6
|
+
"/usr/local/share/fonts",
|
|
7
|
+
File.expand_path("~/.fonts")
|
|
8
|
+
].freeze
|
|
9
|
+
|
|
10
|
+
EXTENSIONS = %w[ttf otf ttc].freeze
|
|
11
|
+
|
|
12
|
+
def self.all
|
|
13
|
+
@all ||= PATHS.flat_map do |path|
|
|
14
|
+
next [] unless Dir.exist?(path)
|
|
15
|
+
|
|
16
|
+
EXTENSIONS.flat_map do |ext|
|
|
17
|
+
Dir.glob("#{path}/**/*.#{ext}")
|
|
18
|
+
end
|
|
19
|
+
end.compact.uniq
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.random
|
|
23
|
+
all.sample or raise "GD::GIS::FontHelper: no fonts found on system"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.find(name)
|
|
27
|
+
all.find do |f|
|
|
28
|
+
File.basename(f).downcase.include?(name.downcase)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/gd/gis/geometry.rb
CHANGED
|
@@ -1,54 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module GD
|
|
2
4
|
module GIS
|
|
5
|
+
# Geometry and projection helpers for Web Mercator maps.
|
|
6
|
+
#
|
|
7
|
+
# This module provides low-level utilities for:
|
|
8
|
+
# - Web Mercator (EPSG:3857) projection math
|
|
9
|
+
# - Bounding box manipulation
|
|
10
|
+
# - Viewport fitting
|
|
11
|
+
# - Simple geometric operations for visualization
|
|
12
|
+
#
|
|
13
|
+
# All longitude/latitude values are assumed to be in WGS84
|
|
14
|
+
# (EPSG:4326), unless explicitly stated otherwise.
|
|
15
|
+
#
|
|
16
|
+
# ⚠️ These helpers are intended for *rendering and visualization*,
|
|
17
|
+
# not for precise geospatial analysis.
|
|
18
|
+
#
|
|
3
19
|
module Geometry
|
|
4
|
-
|
|
20
|
+
# Web Mercator tile size in pixels
|
|
5
21
|
TILE_SIZE = 256.0
|
|
6
|
-
MAX_LAT = 85.05112878
|
|
7
22
|
|
|
8
|
-
#
|
|
9
|
-
|
|
10
|
-
# --------------------------------------------------
|
|
23
|
+
# Maximum latitude supported by Web Mercator
|
|
24
|
+
MAX_LAT = 85.05112878
|
|
11
25
|
|
|
26
|
+
# Validates a bounding box.
|
|
27
|
+
#
|
|
28
|
+
# @param bbox [Array<Float>]
|
|
29
|
+
# [min_lng, min_lat, max_lng, max_lat]
|
|
30
|
+
# @raise [ArgumentError] if the bbox is invalid
|
|
31
|
+
# @return [void]
|
|
12
32
|
def self.validate_bbox!(bbox)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
33
|
+
return if bbox.is_a?(Array) && bbox.size == 4
|
|
34
|
+
|
|
35
|
+
raise ArgumentError, "bbox must be [min_lng, min_lat, max_lng, max_lat]"
|
|
16
36
|
end
|
|
17
37
|
|
|
38
|
+
# Validates a coordinate array.
|
|
39
|
+
#
|
|
40
|
+
# @param coords [Array<Array<Float>>]
|
|
41
|
+
# @raise [ArgumentError] if the coordinates are invalid
|
|
42
|
+
# @return [void]
|
|
18
43
|
def self.validate_coords!(coords)
|
|
19
|
-
|
|
20
|
-
raise ArgumentError, "coords must be an Array of at least 2 points"
|
|
21
|
-
end
|
|
22
|
-
end
|
|
44
|
+
return if coords.is_a?(Array) && coords.size >= 2
|
|
23
45
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
# --------------------------------------------------
|
|
46
|
+
raise ArgumentError, "coords must be an Array of at least 2 points"
|
|
47
|
+
end
|
|
27
48
|
|
|
49
|
+
# Converts longitude to Web Mercator X coordinate.
|
|
50
|
+
#
|
|
51
|
+
# @param lng [Float] longitude in degrees
|
|
52
|
+
# @param zoom [Integer] zoom level
|
|
53
|
+
# @return [Float] X coordinate in pixels
|
|
28
54
|
def self.lng_to_x(lng, zoom)
|
|
29
55
|
((lng + 180.0) / 360.0) * TILE_SIZE * (2**zoom)
|
|
30
56
|
end
|
|
31
57
|
|
|
58
|
+
# Converts latitude to Web Mercator Y coordinate.
|
|
59
|
+
#
|
|
60
|
+
# Latitude values are clamped to the valid Web Mercator range.
|
|
61
|
+
#
|
|
62
|
+
# @param lat [Float] latitude in degrees
|
|
63
|
+
# @param zoom [Integer] zoom level
|
|
64
|
+
# @return [Float] Y coordinate in pixels
|
|
32
65
|
def self.lat_to_y(lat, zoom)
|
|
33
|
-
lat =
|
|
66
|
+
lat = lat.clamp(-MAX_LAT, MAX_LAT)
|
|
34
67
|
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)
|
|
68
|
+
n = Math.log(Math.tan((Math::PI / 4.0) + (lat_rad / 2.0)))
|
|
69
|
+
(1.0 - (n / Math::PI)) / 2.0 * TILE_SIZE * (2**zoom)
|
|
37
70
|
end
|
|
38
71
|
|
|
72
|
+
# Converts Web Mercator X coordinate to longitude.
|
|
73
|
+
#
|
|
74
|
+
# @param x [Float] X coordinate in pixels
|
|
75
|
+
# @param zoom [Integer] zoom level
|
|
76
|
+
# @return [Float] longitude in degrees
|
|
39
77
|
def self.x_to_lng(x, zoom)
|
|
40
|
-
(x / (TILE_SIZE * (2**zoom))) * 360.0 - 180.0
|
|
78
|
+
((x / (TILE_SIZE * (2**zoom))) * 360.0) - 180.0
|
|
41
79
|
end
|
|
42
80
|
|
|
81
|
+
# Converts Web Mercator Y coordinate to latitude.
|
|
82
|
+
#
|
|
83
|
+
# @param y [Float] Y coordinate in pixels
|
|
84
|
+
# @param zoom [Integer] zoom level
|
|
85
|
+
# @return [Float] latitude in degrees
|
|
43
86
|
def self.y_to_lat(y, zoom)
|
|
44
|
-
n = Math::PI - 2.0 * Math::PI * y / (TILE_SIZE * (2**zoom))
|
|
87
|
+
n = Math::PI - (2.0 * Math::PI * y / (TILE_SIZE * (2**zoom)))
|
|
45
88
|
180.0 / Math::PI * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)))
|
|
46
89
|
end
|
|
47
90
|
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
|
|
91
|
+
# Computes a viewport bounding box fitted to an image size.
|
|
92
|
+
#
|
|
93
|
+
# @param bbox [Array<Float>]
|
|
94
|
+
# input bounding box [min_lng, min_lat, max_lng, max_lat]
|
|
95
|
+
# @param zoom [Integer] zoom level
|
|
96
|
+
# @param width [Integer] image width in pixels
|
|
97
|
+
# @param height [Integer] image height in pixels
|
|
98
|
+
# @return [Array<Float>] fitted bounding box
|
|
52
99
|
def self.viewport_bbox(bbox:, zoom:, width:, height:)
|
|
53
100
|
validate_bbox!(bbox)
|
|
54
101
|
|
|
@@ -76,19 +123,15 @@ module GD
|
|
|
76
123
|
]
|
|
77
124
|
end
|
|
78
125
|
|
|
79
|
-
#
|
|
80
|
-
# Geometry helpers
|
|
81
|
-
# --------------------------------------------------
|
|
82
|
-
|
|
83
|
-
# buffer_line(coords, meters)
|
|
126
|
+
# Creates a naive buffer polygon around a line.
|
|
84
127
|
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
# NOTE:
|
|
89
|
-
# - Naive meters-to-degrees conversion
|
|
90
|
-
# - Suitable for visualization, not analysis
|
|
128
|
+
# ⚠️ This uses an approximate meters-to-degrees conversion
|
|
129
|
+
# and is intended for visualization only.
|
|
91
130
|
#
|
|
131
|
+
# @param coords [Array<Array<Float>>]
|
|
132
|
+
# array of [lng, lat] points
|
|
133
|
+
# @param meters [Numeric] buffer distance (approximate)
|
|
134
|
+
# @return [Array<Array<Float>>] polygon coordinates
|
|
92
135
|
def self.buffer_line(coords, meters)
|
|
93
136
|
validate_coords!(coords)
|
|
94
137
|
|
|
@@ -102,7 +145,7 @@ module GD
|
|
|
102
145
|
dx = x2 - x1
|
|
103
146
|
dy = y2 - y1
|
|
104
147
|
|
|
105
|
-
len = Math.sqrt(dx * dx + dy * dy)
|
|
148
|
+
len = Math.sqrt((dx * dx) + (dy * dy))
|
|
106
149
|
next if len.zero?
|
|
107
150
|
|
|
108
151
|
nx = -dy / len
|
|
@@ -110,8 +153,8 @@ module GD
|
|
|
110
153
|
|
|
111
154
|
off = meters / 111_320.0
|
|
112
155
|
|
|
113
|
-
left << [x1 + nx * off, y1 + ny * off]
|
|
114
|
-
right << [x1 - nx * off, y1 - ny * off]
|
|
156
|
+
left << [x1 + (nx * off), y1 + (ny * off)]
|
|
157
|
+
right << [x1 - (nx * off), y1 - (ny * off)]
|
|
115
158
|
end
|
|
116
159
|
|
|
117
160
|
x2, y2 = coords.last
|
|
@@ -120,7 +163,14 @@ module GD
|
|
|
120
163
|
|
|
121
164
|
left + right.reverse
|
|
122
165
|
end
|
|
123
|
-
|
|
166
|
+
|
|
167
|
+
# Projects geographic coordinates into pixel space relative to a bbox.
|
|
168
|
+
#
|
|
169
|
+
# @param lng [Float] longitude
|
|
170
|
+
# @param lat [Float] latitude
|
|
171
|
+
# @param bbox [Array<Float>] reference bounding box
|
|
172
|
+
# @param zoom [Integer] zoom level
|
|
173
|
+
# @return [Array<Float>] [x, y] pixel coordinates
|
|
124
174
|
def self.project(lng, lat, bbox, zoom)
|
|
125
175
|
min_lng, _min_lat, _max_lng, max_lat = bbox
|
|
126
176
|
|
|
@@ -136,6 +186,18 @@ module GD
|
|
|
136
186
|
]
|
|
137
187
|
end
|
|
138
188
|
|
|
189
|
+
# Computes a bounding box that fits all features in a GeoJSON file.
|
|
190
|
+
#
|
|
191
|
+
# The resulting bbox is padded and adjusted to match the
|
|
192
|
+
# requested image aspect ratio.
|
|
193
|
+
#
|
|
194
|
+
# @param path [String] path to GeoJSON file
|
|
195
|
+
# @param zoom [Integer] zoom level
|
|
196
|
+
# @param width [Integer] image width in pixels
|
|
197
|
+
# @param height [Integer] image height in pixels
|
|
198
|
+
# @param padding_px [Integer] padding in pixels
|
|
199
|
+
# @return [Array<Float>] bounding box
|
|
200
|
+
# @raise [RuntimeError] if no coordinates are found
|
|
139
201
|
def self.bbox_for_image(path, zoom:, width:, height:, padding_px: 80)
|
|
140
202
|
data = JSON.parse(File.read(path))
|
|
141
203
|
points = []
|
|
@@ -143,6 +205,7 @@ module GD
|
|
|
143
205
|
data["features"].each do |f|
|
|
144
206
|
geom = f["geometry"]
|
|
145
207
|
next unless geom
|
|
208
|
+
|
|
146
209
|
collect_points(geom, points)
|
|
147
210
|
end
|
|
148
211
|
|
|
@@ -167,7 +230,7 @@ module GD
|
|
|
167
230
|
# --------------------------------------------------
|
|
168
231
|
# 2. Fit bbox to image aspect ratio
|
|
169
232
|
# --------------------------------------------------
|
|
170
|
-
target_ratio = width.to_f / height
|
|
233
|
+
target_ratio = width.to_f / height
|
|
171
234
|
current_ratio = (max_x - min_x) / (max_y - min_y)
|
|
172
235
|
|
|
173
236
|
if current_ratio > target_ratio
|
|
@@ -195,6 +258,11 @@ module GD
|
|
|
195
258
|
]
|
|
196
259
|
end
|
|
197
260
|
|
|
261
|
+
# Collects all coordinate points from a GeoJSON geometry.
|
|
262
|
+
#
|
|
263
|
+
# @param geom [Hash] GeoJSON geometry
|
|
264
|
+
# @param points [Array] accumulator array
|
|
265
|
+
# @return [void]
|
|
198
266
|
def self.collect_points(geom, points)
|
|
199
267
|
case geom["type"]
|
|
200
268
|
when "Point"
|
|
@@ -217,6 +285,14 @@ module GD
|
|
|
217
285
|
end
|
|
218
286
|
end
|
|
219
287
|
|
|
288
|
+
# Builds a bounding box around a point using a radius.
|
|
289
|
+
#
|
|
290
|
+
# Uses a simple spherical approximation.
|
|
291
|
+
#
|
|
292
|
+
# @param lon [Float] longitude
|
|
293
|
+
# @param lat [Float] latitude
|
|
294
|
+
# @param radius_km [Numeric] radius in kilometers
|
|
295
|
+
# @return [Array<Float>] bounding box
|
|
220
296
|
def self.bbox_around_point(lon, lat, radius_km:)
|
|
221
297
|
delta_lat = radius_km / 111.0
|
|
222
298
|
delta_lon = radius_km / (111.0 * Math.cos(lat * Math::PI / 180.0))
|
|
@@ -228,8 +304,6 @@ module GD
|
|
|
228
304
|
lat + delta_lat
|
|
229
305
|
]
|
|
230
306
|
end
|
|
231
|
-
|
|
232
307
|
end
|
|
233
308
|
end
|
|
234
309
|
end
|
|
235
|
-
|
data/lib/gd/gis/layer_geojson.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require "json"
|
|
2
4
|
require_relative "feature"
|
|
3
5
|
require_relative "crs_normalizer"
|
|
@@ -5,7 +7,24 @@ require_relative "ontology"
|
|
|
5
7
|
|
|
6
8
|
module GD
|
|
7
9
|
module GIS
|
|
10
|
+
# Loads GeoJSON files into renderable Feature objects.
|
|
11
|
+
#
|
|
12
|
+
# This class is responsible for:
|
|
13
|
+
# - Parsing GeoJSON files
|
|
14
|
+
# - Normalizing coordinates across CRS definitions
|
|
15
|
+
# - Classifying features using an ontology
|
|
16
|
+
# - Producing {GD::GIS::Feature} instances
|
|
17
|
+
#
|
|
18
|
+
# All coordinates are normalized to WGS84
|
|
19
|
+
# in [longitude, latitude] order.
|
|
20
|
+
#
|
|
8
21
|
class LayerGeoJSON
|
|
22
|
+
# Loads a GeoJSON file and returns normalized features.
|
|
23
|
+
#
|
|
24
|
+
# @param path [String] path to GeoJSON file
|
|
25
|
+
# @return [Array<GD::GIS::Feature>]
|
|
26
|
+
# @raise [JSON::ParserError] if the file is invalid JSON
|
|
27
|
+
# @raise [Errno::ENOENT] if the file does not exist
|
|
9
28
|
def self.load(path)
|
|
10
29
|
data = JSON.parse(File.read(path))
|
|
11
30
|
|
|
@@ -27,9 +46,15 @@ module GD
|
|
|
27
46
|
end
|
|
28
47
|
end
|
|
29
48
|
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
49
|
+
# Normalizes a GeoJSON geometry in-place.
|
|
50
|
+
#
|
|
51
|
+
# Supports 2D and 3D coordinate arrays.
|
|
52
|
+
# Any additional dimensions (e.g. Z) are preserved or ignored
|
|
53
|
+
# depending on the CRS normalizer.
|
|
54
|
+
#
|
|
55
|
+
# @param geometry [Hash] GeoJSON geometry object
|
|
56
|
+
# @param normalizer [CRS::Normalizer]
|
|
57
|
+
# @return [void]
|
|
33
58
|
def self.normalize_geometry!(geometry, normalizer)
|
|
34
59
|
case geometry["type"]
|
|
35
60
|
|
|
@@ -62,7 +87,6 @@ module GD
|
|
|
62
87
|
end
|
|
63
88
|
end
|
|
64
89
|
end
|
|
65
|
-
|
|
66
90
|
end
|
|
67
91
|
end
|
|
68
92
|
end
|