Module: GD::GIS::Geometry
- Defined in:
- lib/gd/gis/geometry.rb
Overview
Geometry and projection helpers for Web Mercator maps.
This module provides low-level utilities for:
-
Web Mercator (EPSG:3857) projection math
-
Bounding box manipulation
-
Viewport fitting
-
Simple geometric operations for visualization
All longitude/latitude values are assumed to be in WGS84 (EPSG:4326), unless explicitly stated otherwise.
⚠️ These helpers are intended for *rendering and visualization*, not for precise geospatial analysis.
Constant Summary collapse
- TILE_SIZE =
Web Mercator tile size in pixels
256.0- MAX_LAT =
Maximum latitude supported by Web Mercator
85.05112878
Class Method Summary collapse
-
.bbox_around_point(lon, lat, radius_km:) ⇒ Array<Float>
Builds a bounding box around a point using a radius.
-
.bbox_for_image(path, zoom:, width:, height:, padding_px: 80) ⇒ Array<Float>
Computes a bounding box that fits all features in a GeoJSON file.
-
.buffer_line(coords, meters) ⇒ Array<Array<Float>>
Creates a naive buffer polygon around a line.
-
.collect_points(geom, points) ⇒ void
Collects all coordinate points from a GeoJSON geometry.
-
.lat_to_y(lat, zoom) ⇒ Float
Converts latitude to Web Mercator Y coordinate.
-
.lng_to_x(lng, zoom) ⇒ Float
Converts longitude to Web Mercator X coordinate.
-
.project(lng, lat, bbox, zoom) ⇒ Array<Float>
Projects geographic coordinates into pixel space relative to a bbox.
-
.validate_bbox!(bbox) ⇒ void
Validates a bounding box.
-
.validate_coords!(coords) ⇒ void
Validates a coordinate array.
-
.viewport_bbox(bbox:, zoom:, width:, height:) ⇒ Array<Float>
Computes a viewport bounding box fitted to an image size.
-
.x_to_lng(x, zoom) ⇒ Float
Converts Web Mercator X coordinate to longitude.
-
.y_to_lat(y, zoom) ⇒ Float
Converts Web Mercator Y coordinate to latitude.
Class Method Details
.bbox_around_point(lon, lat, radius_km:) ⇒ Array<Float>
Builds a bounding box around a point using a radius.
Uses a simple spherical approximation.
296 297 298 299 300 301 302 303 304 305 306 |
# File 'lib/gd/gis/geometry.rb', line 296 def self.bbox_around_point(lon, lat, radius_km:) delta_lat = radius_km / 111.0 delta_lon = radius_km / (111.0 * Math.cos(lat * Math::PI / 180.0)) [ lon - delta_lon, lat - delta_lat, lon + delta_lon, lat + delta_lat ] end |
.bbox_for_image(path, zoom:, width:, height:, padding_px: 80) ⇒ Array<Float>
Computes a bounding box that fits all features in a GeoJSON file.
The resulting bbox is padded and adjusted to match the requested image aspect ratio.
201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 |
# File 'lib/gd/gis/geometry.rb', line 201 def self.bbox_for_image(path, zoom:, width:, height:, padding_px: 80) data = JSON.parse(File.read(path)) points = [] data["features"].each do |f| geom = f["geometry"] next unless geom collect_points(geom, points) end raise "No coordinates found in GeoJSON" if points.empty? # -------------------------------------------------- # 1. Project to pixel space # -------------------------------------------------- xs = [] ys = [] points.each do |lon, lat| xs << lng_to_x(lon, zoom) ys << lat_to_y(lat, zoom) end min_x = xs.min - padding_px max_x = xs.max + padding_px min_y = ys.min - padding_px max_y = ys.max + padding_px # -------------------------------------------------- # 2. Fit bbox to image aspect ratio # -------------------------------------------------- target_ratio = width.to_f / height current_ratio = (max_x - min_x) / (max_y - min_y) if current_ratio > target_ratio # too wide → expand vertically new_h = (max_x - min_x) / target_ratio delta = (new_h - (max_y - min_y)) / 2.0 min_y -= delta max_y += delta else # too tall → expand horizontally new_w = (max_y - min_y) * target_ratio delta = (new_w - (max_x - min_x)) / 2.0 min_x -= delta max_x += delta end # -------------------------------------------------- # 3. Convert back to lon/lat # -------------------------------------------------- [ x_to_lng(min_x, zoom), y_to_lat(max_y, zoom), x_to_lng(max_x, zoom), y_to_lat(min_y, zoom) ] end |
.buffer_line(coords, meters) ⇒ Array<Array<Float>>
Creates a naive buffer polygon around a line.
⚠️ This uses an approximate meters-to-degrees conversion and is intended for visualization only.
135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 |
# File 'lib/gd/gis/geometry.rb', line 135 def self.buffer_line(coords, meters) validate_coords!(coords) left = [] right = [] coords.each_cons(2) do |a, b| x1, y1 = a x2, y2 = b dx = x2 - x1 dy = y2 - y1 len = Math.sqrt((dx * dx) + (dy * dy)) next if len.zero? nx = -dy / len ny = dx / len off = meters / 111_320.0 left << [x1 + (nx * off), y1 + (ny * off)] right << [x1 - (nx * off), y1 - (ny * off)] end x2, y2 = coords.last left << [x2, y2] right << [x2, y2] left + right.reverse end |
.collect_points(geom, points) ⇒ void
This method returns an undefined value.
Collects all coordinate points from a GeoJSON geometry.
266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 |
# File 'lib/gd/gis/geometry.rb', line 266 def self.collect_points(geom, points) case geom["type"] when "Point" points << geom["coordinates"] when "MultiPoint", "LineString" geom["coordinates"].each { |c| points << c } when "MultiLineString", "Polygon" geom["coordinates"].each do |line| line.each { |c| points << c } end when "MultiPolygon" geom["coordinates"].each do |poly| poly.each do |ring| ring.each { |c| points << c } end end end end |
.lat_to_y(lat, zoom) ⇒ Float
Converts latitude to Web Mercator Y coordinate.
Latitude values are clamped to the valid Web Mercator range.
65 66 67 68 69 70 |
# File 'lib/gd/gis/geometry.rb', line 65 def self.lat_to_y(lat, zoom) lat = lat.clamp(-MAX_LAT, MAX_LAT) lat_rad = lat * Math::PI / 180.0 n = Math.log(Math.tan((Math::PI / 4.0) + (lat_rad / 2.0))) (1.0 - (n / Math::PI)) / 2.0 * TILE_SIZE * (2**zoom) end |
.lng_to_x(lng, zoom) ⇒ Float
Converts longitude to Web Mercator X coordinate.
54 55 56 |
# File 'lib/gd/gis/geometry.rb', line 54 def self.lng_to_x(lng, zoom) ((lng + 180.0) / 360.0) * TILE_SIZE * (2**zoom) end |
.project(lng, lat, bbox, zoom) ⇒ Array<Float>
Projects geographic coordinates into pixel space relative to a bbox.
174 175 176 177 178 179 180 181 182 183 184 185 186 187 |
# File 'lib/gd/gis/geometry.rb', line 174 def self.project(lng, lat, bbox, zoom) min_lng, _min_lat, _max_lng, max_lat = bbox world_x = lng_to_x(lng, zoom) world_y = lat_to_y(lat, zoom) offset_x = lng_to_x(min_lng, zoom) offset_y = lat_to_y(max_lat, zoom) [ world_x - offset_x, world_y - offset_y ] end |
.validate_bbox!(bbox) ⇒ void
This method returns an undefined value.
Validates a bounding box.
32 33 34 35 36 |
# File 'lib/gd/gis/geometry.rb', line 32 def self.validate_bbox!(bbox) return if bbox.is_a?(Array) && bbox.size == 4 raise ArgumentError, "bbox must be [min_lng, min_lat, max_lng, max_lat]" end |
.validate_coords!(coords) ⇒ void
This method returns an undefined value.
Validates a coordinate array.
43 44 45 46 47 |
# File 'lib/gd/gis/geometry.rb', line 43 def self.validate_coords!(coords) return if coords.is_a?(Array) && coords.size >= 2 raise ArgumentError, "coords must be an Array of at least 2 points" end |
.viewport_bbox(bbox:, zoom:, width:, height:) ⇒ Array<Float>
Computes a viewport bounding box fitted to an image size.
99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 |
# File 'lib/gd/gis/geometry.rb', line 99 def self.(bbox:, zoom:, width:, height:) validate_bbox!(bbox) min_lng, min_lat, max_lng, max_lat = bbox center_lng = (min_lng + max_lng) / 2.0 center_lat = (min_lat + max_lat) / 2.0 center_x = lng_to_x(center_lng, zoom) center_y = lat_to_y(center_lat, zoom) half_w = width / 2.0 half_h = height / 2.0 min_x = center_x - half_w max_x = center_x + half_w min_y = center_y - half_h max_y = center_y + half_h [ x_to_lng(min_x, zoom), y_to_lat(max_y, zoom), x_to_lng(max_x, zoom), y_to_lat(min_y, zoom) ] end |
.x_to_lng(x, zoom) ⇒ Float
Converts Web Mercator X coordinate to longitude.
77 78 79 |
# File 'lib/gd/gis/geometry.rb', line 77 def self.x_to_lng(x, zoom) ((x / (TILE_SIZE * (2**zoom))) * 360.0) - 180.0 end |
.y_to_lat(y, zoom) ⇒ Float
Converts Web Mercator Y coordinate to latitude.
86 87 88 89 |
# File 'lib/gd/gis/geometry.rb', line 86 def self.y_to_lat(y, zoom) n = Math::PI - (2.0 * Math::PI * y / (TILE_SIZE * (2**zoom))) 180.0 / Math::PI * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))) end |