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

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.

Parameters:

  • lon (Float)

    longitude

  • lat (Float)

    latitude

  • radius_km (Numeric)

    radius in kilometers

Returns:

  • (Array<Float>)

    bounding box



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.

Parameters:

  • path (String)

    path to GeoJSON file

  • zoom (Integer)

    zoom level

  • width (Integer)

    image width in pixels

  • height (Integer)

    image height in pixels

  • padding_px (Integer) (defaults to: 80)

    padding in pixels

Returns:

  • (Array<Float>)

    bounding box

Raises:

  • (RuntimeError)

    if no coordinates are found



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.

Parameters:

  • coords (Array<Array<Float>>)

    array of [lng, lat] points

  • meters (Numeric)

    buffer distance (approximate)

Returns:

  • (Array<Array<Float>>)

    polygon coordinates



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.

Parameters:

  • geom (Hash)

    GeoJSON geometry

  • points (Array)

    accumulator array



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.

Parameters:

  • lat (Float)

    latitude in degrees

  • zoom (Integer)

    zoom level

Returns:

  • (Float)

    Y coordinate in pixels



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.

Parameters:

  • lng (Float)

    longitude in degrees

  • zoom (Integer)

    zoom level

Returns:

  • (Float)

    X coordinate in pixels



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.

Parameters:

  • lng (Float)

    longitude

  • lat (Float)

    latitude

  • bbox (Array<Float>)

    reference bounding box

  • zoom (Integer)

    zoom level

Returns:

  • (Array<Float>)
    x, y

    pixel coordinates



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.

Parameters:

  • bbox (Array<Float>)
    min_lng, min_lat, max_lng, max_lat

Raises:

  • (ArgumentError)

    if the bbox is invalid



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.

Parameters:

  • coords (Array<Array<Float>>)

Raises:

  • (ArgumentError)

    if the coordinates are invalid



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.

Parameters:

  • bbox (Array<Float>)

    input bounding box [min_lng, min_lat, max_lng, max_lat]

  • zoom (Integer)

    zoom level

  • width (Integer)

    image width in pixels

  • height (Integer)

    image height in pixels

Returns:

  • (Array<Float>)

    fitted bounding box



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.viewport_bbox(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.

Parameters:

  • x (Float)

    X coordinate in pixels

  • zoom (Integer)

    zoom level

Returns:

  • (Float)

    longitude in degrees



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.

Parameters:

  • y (Float)

    Y coordinate in pixels

  • zoom (Integer)

    zoom level

Returns:

  • (Float)

    latitude in degrees



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