geodetic 0.7.0 → 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0c20a680d6ffe0e6a82f3a0b7e8f6b1a428baa951b7122c0be75079f667f1099
4
- data.tar.gz: 28031d89b1a5705cc28f6ddc751034d982e51581544f44a1d46a8792d8fd0f50
3
+ metadata.gz: 542167ec9e7b27122d31d5287f967481dc9d0d4b293eaaaa349ca4203e2eb302
4
+ data.tar.gz: f544332a931f775df18ce4e0d2ed78e4d2619bbb946df6d287c8bc3a798d6c9f
5
5
  SHA512:
6
- metadata.gz: 4c41de7eb8e8bdb61921dfca83310a2d5ed0978d9bd6f0f1d3f06ce33bd877070581c873396a33b80b40722ec741f93f5e80cf2afb0e90561f5a28d2dc6e7953
7
- data.tar.gz: 8ce010a527e660a01fab3b93d8a9e4f90bceb688dd753a5a634cbf3d25ba3deaf2b8bc8038e8926767d8bd017a26e09074aa3be1d94df1813bc6407d56865ab0
6
+ metadata.gz: cca8fc5c8b38722ab95fecfeea576104c180352a9af854171858702d23a240368bef870b831c463601f06f6f04d76671aa8a5254edfc37880b3acef1aced476a
7
+ data.tar.gz: 29f289c88b14fcbe64091256d61afaa1752a4f405a5a3f88e55b966c7fbc50076c6db6a5e88bbe4f1e4840f3a424c88e37daf167acdca64b1872d81f8a2dcc01
data/CHANGELOG.md CHANGED
@@ -8,6 +8,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
  ## [Unreleased]
9
9
 
10
10
 
11
+ ## [0.8.0] - 2026-03-11
12
+
13
+ ### Added
14
+
15
+ - **Map adapter pattern** (`Geodetic::Map`) for rendering geodetic objects on maps with pluggable backends
16
+ - `Geodetic::Map::Base` — abstract adapter interface with `add(object, **style)` auto-dispatch by geometry type
17
+ - `Geodetic::Map::LibGdGis` — concrete adapter for raster PNG output via the `libgd-gis` gem
18
+ - `MapMethods` mixin — adds `add_to_map(map, **style)` to all coordinates, paths, segments, areas, and features
19
+ - Render block pattern: `map.render(path) { |gd_map| ... }` for custom GD drawing after adapter layers
20
+ - `gd_map` accessor for post-render access to the underlying `GD::GIS::Map`
21
+ - Accepts `BoundingBox` or `[west, south, east, north]` array for bbox
22
+ - Supports point, line, and polygon layers with style options (color, stroke, fill, width, label, icon, font, symbol, segments)
23
+ - GEOS map rendering example (`examples/14_geos_map_rendering.rb`) — visualizes 8 GEOS operation categories (boolean intersection/difference, point/path buffering, convex hull, simplification, nearest points, prepared geometry containment) on a single raster map with distinct colors and an embedded legend
24
+ - 28 new map adapter tests covering all geometry types, style handling, ENU/NED rejection, chaining, and LibGdGis bbox resolution
25
+ - Documentation: `docs/reference/map-rendering.md` (architecture, usage, style options, color format, prerequisites)
26
+
27
+ ### Changed
28
+
29
+ - Refactored `examples/05_map_rendering/demo.rb` to use the new `Map::LibGdGis` adapter instead of raw `GD::GIS::Map` calls
30
+ - Updated `docs/reference/geos-acceleration.md` with reference to example 14
31
+ - Updated `examples/README.md` with example 14 description
32
+ - Updated `CLAUDE.md` with map adapter architecture, file layout, and expanded examples range
33
+
11
34
  ## [0.7.0] - 2026-03-10
12
35
 
13
36
  ### Added
@@ -161,6 +161,12 @@ Run the benchmark yourself:
161
161
  ruby -Ilib examples/12_geos_benchmark.rb
162
162
  ```
163
163
 
164
+ For a visual demonstration of GEOS operations rendered on a map:
165
+
166
+ ```bash
167
+ ruby -Ilib examples/14_geos_map_rendering.rb
168
+ ```
169
+
164
170
  ## Architecture Notes
165
171
 
166
172
  - **Thread safety**: Uses the reentrant GEOS `_r` API with per-process context initialization
@@ -1,25 +1,106 @@
1
- # Map Rendering with libgd-gis
1
+ # Map Rendering
2
2
 
3
- Geodetic coordinates and areas can be rendered on raster maps using the [libgd-gis](https://rubygems.org/gems/libgd-gis) gem, which provides tile-based basemap rendering on top of [ruby-libgd](https://rubygems.org/gems/ruby-libgd).
3
+ Geodetic provides a map adapter pattern (`Geodetic::Map`) for rendering coordinates, paths, areas, and features on maps. The adapter abstracts the rendering backend so the same Geodetic objects work with different map engines.
4
4
 
5
- ## Overview
5
+ ## Architecture
6
6
 
7
- The `libgd-gis` gem downloads map tiles and stitches them into a single raster image for a given bounding box and zoom level. Geodetic's coordinate objects provide the geographic data, and `GD::GIS::Geometry.project` converts longitude/latitude pairs into pixel positions on the rendered map. From there, ruby-libgd primitives (lines, circles, polygons, text, image compositing) can draw overlays on top of the basemap.
7
+ ```
8
+ Geodetic::Map::Base # Abstract adapter interface
9
+ ├── Geodetic::Map::LibGdGis # Raster PNG output via libgd-gis
10
+ ├── (future) Leaflet # Interactive HTML/JS maps
11
+ ├── (future) GoogleMaps # Google Maps HTML or Static API
12
+ └── (future) KML # KML XML output
13
+ ```
8
14
 
9
- This combination supports:
15
+ `MapMethods` is a mixin applied to all coordinates, areas, paths, segments, and features, providing `add_to_map(map, **style)`.
10
16
 
11
- - **Point markers** for any Geodetic coordinate
12
- - **Polygon overlays** from `Geodetic::Areas::Polygon` boundaries
13
- - **Bearing arrows** computed with `Feature#bearing_to` and drawn as lines with arrowheads
14
- - **Distance labels** using `Feature#distance_to` for annotation
15
- - **Icon compositing** with scaled PNG images positioned at projected coordinates
16
- - **Light and dark basemaps** via `:carto_light` and `:carto_dark`
17
+ ## Usage
17
18
 
18
- ## Feature Class
19
+ ### Adding objects to a map
20
+
21
+ Two equivalent APIs — use whichever reads better:
22
+
23
+ ```ruby
24
+ # Map-centric
25
+ map = Geodetic::Map::LibGdGis.new(bbox: bbox, zoom: 12, basemap: :carto_dark)
26
+ map.add(polygon, fill: [0, 200, 120, 170], stroke: [0, 200, 120, 30], width: 2)
27
+ map.add(path, color: [255, 220, 50], width: 3)
28
+ map.add(coordinate, color: [255, 0, 0], label: "Marker")
29
+
30
+ # Object-centric (via MapMethods mixin)
31
+ polygon.add_to_map(map, fill: [0, 200, 120, 170])
32
+ path.add_to_map(map, color: [255, 220, 50], width: 3)
33
+ coordinate.add_to_map(map, color: [255, 0, 0], label: "Marker")
34
+ ```
35
+
36
+ The `add` method auto-detects the object type and dispatches to the appropriate handler. Supported types:
37
+
38
+ | Object | Layer type | Notes |
39
+ |--------|-----------|-------|
40
+ | Any coordinate (18 systems) | `:point` | Converts to LLA automatically |
41
+ | `Path` | `:line` | Renders all waypoints as a line |
42
+ | `Segment` | `:line` | Two-point line |
43
+ | `Polygon` (and subclasses) | `:polygon` | Includes Triangle, Rectangle, Hexagon, etc. |
44
+ | `Circle` | `:polygon` | Approximated as N-gon (default 32 segments) |
45
+ | `BoundingBox` | `:polygon` | Four corners as a rectangle |
46
+ | `Feature` | delegates | Extracts geometry; merges label into style |
47
+ | `ENU` / `NED` | rejected | Raises `ArgumentError` (relative systems) |
48
+
49
+ ### Rendering
50
+
51
+ ```ruby
52
+ # Simple render to file
53
+ map.render("output.png")
54
+
55
+ # Render with custom drawing block (LibGdGis-specific)
56
+ map.render("output.png") do |gd_map|
57
+ img = gd_map.image
58
+ # Use GD::Image primitives for custom markers, labels, arrows, etc.
59
+ end
60
+
61
+ # Render without saving (returns GD::GIS::Map)
62
+ gd_map = map.render
63
+ ```
19
64
 
20
- `Geodetic::Feature` wraps a geometry (any coordinate or area) with a label and a metadata hash. It delegates `distance_to` and `bearing_to` to its geometry, using the centroid for area geometries. This makes it straightforward to attach display properties like icon paths and categories alongside the spatial data.
65
+ ### Bounding box
21
66
 
22
- ## Prerequisites
67
+ The LibGdGis adapter accepts bounding boxes as either:
68
+
69
+ ```ruby
70
+ # Geodetic BoundingBox object
71
+ bbox = Geodetic::Areas::BoundingBox.new(nw: nw_point, se: se_point)
72
+ map = Geodetic::Map::LibGdGis.new(bbox: bbox, zoom: 12)
73
+
74
+ # Raw [west, south, east, north] array
75
+ map = Geodetic::Map::LibGdGis.new(bbox: [-74.05, 40.65, -73.75, 40.82], zoom: 12)
76
+ ```
77
+
78
+ ## LibGdGis Adapter
79
+
80
+ ### Color format
81
+
82
+ Colors are `[r, g, b]` or `[r, g, b, alpha]` arrays. The alpha channel follows libgd convention:
83
+
84
+ - `0` = fully opaque
85
+ - `255` = fully transparent
86
+
87
+ ### Style options
88
+
89
+ | Option | Point | Line | Polygon |
90
+ |--------|:-----:|:----:|:-------:|
91
+ | `color` | marker color | stroke color | — |
92
+ | `stroke` | — | stroke color | outline color |
93
+ | `fill` | — | — | fill color |
94
+ | `width` | — | line width (px) | outline width (px) |
95
+ | `label` | text label | — | — |
96
+ | `icon` | image path | — | — |
97
+ | `font` | font path | — | — |
98
+ | `size` | font size | — | — |
99
+ | `font_color` | label color | — | — |
100
+ | `symbol` | marker style | — | — |
101
+ | `segments` | — | — | circle approximation (default 32) |
102
+
103
+ ### Prerequisites
23
104
 
24
105
  ```bash
25
106
  gem install libgd-gis
@@ -27,6 +108,11 @@ brew install gd # macOS
27
108
  # apt install libgd-dev # Linux
28
109
  ```
29
110
 
30
- ## Example
111
+ ## Feature Class
112
+
113
+ `Geodetic::Feature` wraps a geometry (any coordinate or area) with a label and a metadata hash. It delegates `distance_to` and `bearing_to` to its geometry, using the centroid for area geometries. When added to a map, the label is automatically included in the style.
114
+
115
+ ## Examples
31
116
 
32
- See [`examples/05_map_rendering/demo.rb`](https://github.com/madbomber/geodetic/tree/main/examples/05_map_rendering) for a complete working demo that renders NYC landmarks with icons, a Central Park polygon boundary, and bearing arrows between landmarks. The demo supports light/dark themes, icon scaling, and CLI flags for toggling features.
117
+ - [`examples/05_map_rendering/demo.rb`](https://github.com/madbomber/geodetic/tree/main/examples/05_map_rendering) NYC landmarks with icons, Central Park polygon, bearing arrows, light/dark themes
118
+ - [`examples/14_geos_map_rendering.rb`](https://github.com/madbomber/geodetic/tree/main/examples/14_geos_map_rendering.rb) — GEOS operations (intersection, difference, buffer, convex hull, simplification, nearest points, prepared geometry) all visualized on a single map with distinct colors
@@ -1,15 +1,16 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
3
  # Demonstration of rendering geodetic coordinates on a map
4
- # using the libgd-gis gem for native Ruby map generation.
5
- # Shows how Geodetic::Feature wraps named coordinates and areas.
4
+ # using the Geodetic::Map::LibGdGis adapter.
5
+ # Shows how Geodetic objects flow directly into map rendering
6
+ # via the adapter pattern.
6
7
  #
7
8
  # Prerequisites:
8
9
  # gem install libgd-gis
9
10
  # brew install gd (macOS) or apt install libgd-dev (Linux)
10
11
  #
11
12
  # Usage:
12
- # ruby -Ilib examples/05_map_rendering.rb
13
+ # ruby -Ilib examples/05_map_rendering/demo.rb
13
14
 
14
15
  if ARGV.include?("-h") || ARGV.include?("--help")
15
16
  puts <<~HELP
@@ -32,7 +33,6 @@ if ARGV.include?("-h") || ARGV.include?("--help")
32
33
  end
33
34
 
34
35
  require_relative "../../lib/geodetic"
35
- require "gd/gis"
36
36
 
37
37
  include Geodetic
38
38
  LLA = Coordinate::LLA
@@ -78,12 +78,9 @@ landmarks = [
78
78
  ]
79
79
 
80
80
  # An area-based Feature: Central Park as a polygon boundary
81
- # Vertices trace the park perimeter (59th St to 110th St)
82
81
  central_park_area = Feature.new(
83
82
  label: "Central Park",
84
83
  geometry: Areas::Polygon.new(boundary: [
85
- # Vertices from OpenStreetMap data (way 427818536), simplified.
86
- # Central Park is a parallelogram tilted ~29° with Manhattan's grid.
87
84
  LLA.new(lat: 40.7679, lng: -73.9818, alt: 0), # SW corner (Columbus Circle, 59th & CPW)
88
85
  LLA.new(lat: 40.7649, lng: -73.9727, alt: 0), # SE corner (Grand Army Plaza, 59th & 5th Ave)
89
86
  LLA.new(lat: 40.7691, lng: -73.9697, alt: 0), # E edge — 65th St
@@ -116,18 +113,16 @@ lats = all_points.map(&:lat)
116
113
  lngs = all_points.map(&:lng)
117
114
  padding = 0.03
118
115
 
119
- bbox = [
120
- lngs.min - padding, # west
121
- lats.min - padding, # south
122
- lngs.max + padding, # east
123
- lats.max + padding # north
124
- ]
116
+ bbox = Areas::BoundingBox.new(
117
+ nw: LLA.new(lat: lats.max + padding, lng: lngs.min - padding, alt: 0),
118
+ se: LLA.new(lat: lats.min - padding, lng: lngs.max + padding, alt: 0)
119
+ )
125
120
 
126
- # --- 3. Create the map ---
121
+ # --- 3. Create the map via the adapter ---
127
122
 
128
123
  ZOOM = 12
129
124
 
130
- map = GD::GIS::Map.new(
125
+ map = Map::LibGdGis.new(
131
126
  bbox: bbox,
132
127
  zoom: ZOOM,
133
128
  basemap: BASEMAP,
@@ -135,23 +130,17 @@ map = GD::GIS::Map.new(
135
130
  height: 768
136
131
  )
137
132
 
138
- # --- 4. Optionally draw Central Park polygon on the map ---
133
+ # --- 4. Add Central Park polygon via the adapter ---
139
134
 
140
135
  if CENTRAL_PARK
141
- park_coords = central_park_area.geometry.boundary.map { |pt| [pt.lng, pt.lat] }
142
-
143
- map.add_polygons(
144
- [[park_coords]], # polygons > rings > [lng, lat] points
136
+ central_park_area.add_to_map(map,
145
137
  fill: [80, 255, 120, 60],
146
138
  stroke: [50, 200, 80, 255],
147
139
  width: 2
148
140
  )
149
141
  end
150
142
 
151
- # Style must be set (add_polygons sets it as a side effect, but just in case)
152
- map.style ||= GD::GIS::Style.default
153
-
154
- # --- 6. Show Feature info using delegation ---
143
+ # --- 5. Show Feature info using delegation ---
155
144
 
156
145
  liberty = landmarks.first
157
146
 
@@ -171,94 +160,90 @@ landmarks.each do |feature|
171
160
  INFO
172
161
  end
173
162
 
174
- # Distance from an area Feature to a point Feature
175
163
  puts "#{central_park_area.label} -> #{liberty.label}: #{central_park_area.distance_to(liberty).to_km}"
176
164
  puts
177
165
 
178
- # --- 7. Render, draw markers/areas/labels, and save ---
166
+ # --- 6. Render and do custom drawing via the block ---
179
167
 
180
168
  output_path = File.join(__dir__, "nyc_landmarks.png")
181
- map.render
182
-
183
- img = map.image
184
- map_bbox = map.instance_variable_get(:@bbox)
185
- zoom = ZOOM
186
- FONT = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"
187
-
188
- # Draw landmark icons and labels
189
- landmarks.each do |feature|
190
- pt = feature.geometry
191
- px, py = GD::GIS::Geometry.project(pt.lng, pt.lat, map_bbox, zoom)
192
- x, y = px.round, py.round
193
-
194
- # Load and scale the icon
195
- icon = GD::Image.open(feature.metadata[:icon])
196
- icon.alpha_blending = true
197
- icon.save_alpha = true
198
- iw = (icon.width * ICON_SCALE).round
199
- ih = (icon.height * ICON_SCALE).round
200
- scaled = icon.scale(iw, ih)
201
- scaled.alpha_blending = true
202
- scaled.save_alpha = true
203
-
204
- # On dark basemaps, add a white disc so black icons are visible
205
- if ICON_BG
206
- radius = (iw / 2 * 1.3).round
207
- img.filled_circle(x, y, radius, [255, 255, 255])
169
+ FONT = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"
170
+
171
+ map.render(output_path) do |gd_map|
172
+ img = gd_map.image
173
+ map_bbox = gd_map.instance_variable_get(:@bbox)
174
+ gd_map.style ||= GD::GIS::Style.default
175
+
176
+ # Draw landmark icons and labels
177
+ landmarks.each do |feature|
178
+ pt = feature.geometry
179
+ px, py = GD::GIS::Geometry.project(pt.lng, pt.lat, map_bbox, ZOOM)
180
+ x, y = px.round, py.round
181
+
182
+ # Load and scale the icon
183
+ icon = GD::Image.open(feature.metadata[:icon])
184
+ icon.alpha_blending = true
185
+ icon.save_alpha = true
186
+ iw = (icon.width * ICON_SCALE).round
187
+ ih = (icon.height * ICON_SCALE).round
188
+ scaled = icon.scale(iw, ih)
189
+ scaled.alpha_blending = true
190
+ scaled.save_alpha = true
191
+
192
+ # On dark basemaps, add a white disc so black icons are visible
193
+ if ICON_BG
194
+ radius = (iw / 2 * 1.3).round
195
+ img.filled_circle(x, y, radius, [255, 255, 255])
196
+ end
197
+
198
+ img.copy(scaled, x - iw / 2, y - ih / 2, 0, 0, iw, ih)
199
+
200
+ # Label to the right of the icon
201
+ img.text(feature.label, x: x + iw / 2 + 6, y: y + 5,
202
+ font: FONT, size: 16, color: LABEL_COLOR)
208
203
  end
209
204
 
210
- img.copy(scaled, x - iw / 2, y - ih / 2, 0, 0, iw, ih)
211
-
212
- # Label to the right of the icon
213
- img.text(feature.label, x: x + iw / 2 + 6, y: y + 5,
214
- font: FONT, size: 16, color: LABEL_COLOR)
215
- end
216
-
217
- # --- 8. Draw bearing arrows ---
218
-
219
- def draw_bearing_arrow(img, from_feature, to_feature, map_bbox, zoom, color, font)
220
- bearing = from_feature.bearing_to(to_feature)
221
-
222
- x1, y1 = GD::GIS::Geometry.project(from_feature.geometry.lng, from_feature.geometry.lat, map_bbox, zoom).map(&:round)
223
- x2, y2 = GD::GIS::Geometry.project(to_feature.geometry.lng, to_feature.geometry.lat, map_bbox, zoom).map(&:round)
224
-
225
- # Line (doubled for thickness)
226
- img.line(x1, y1, x2, y2, color)
227
- img.line(x1 + 1, y1, x2 + 1, y2, color)
228
-
229
- # Arrowhead at destination
230
- dx, dy = (x2 - x1).to_f, (y2 - y1).to_f
231
- len = Math.sqrt(dx * dx + dy * dy)
232
- ux, uy = dx / len, dy / len
233
-
234
- tip_x = x2 - (ux * 20).round
235
- tip_y = y2 - (uy * 20).round
236
- base_x = tip_x - (ux * 14).round
237
- base_y = tip_y - (uy * 14).round
238
- px, py = -uy, ux
239
- p1x = base_x + (px * 7).round
240
- p1y = base_y + (py * 7).round
241
- p2x = base_x - (px * 7).round
242
- p2y = base_y - (py * 7).round
243
- img.filled_polygon([[tip_x, tip_y], [p1x, p1y], [p2x, p2y]], color)
244
-
245
- # Label at midpoint
246
- mid_x = (x1 + x2) / 2
247
- mid_y = (y1 + y2) / 2
248
- img.text("#{bearing.degrees.round(1)}\u00B0", x: mid_x + 8, y: mid_y + 5,
249
- font: font, size: 14, color: color)
205
+ # --- Draw bearing arrows ---
206
+ img.antialias = true
207
+ brooklyn = landmarks.find { |f| f.label == "Brooklyn Bridge" }
208
+ empire = landmarks.find { |f| f.label == "Empire State Bldg" }
209
+
210
+ arrow_color = DARK_MODE ? [255, 100, 100] : [200, 30, 30]
211
+
212
+ [
213
+ [brooklyn, liberty],
214
+ [brooklyn, empire],
215
+ ].each do |from_feature, to_feature|
216
+ bearing = from_feature.bearing_to(to_feature)
217
+
218
+ x1, y1 = GD::GIS::Geometry.project(from_feature.geometry.lng, from_feature.geometry.lat, map_bbox, ZOOM).map(&:round)
219
+ x2, y2 = GD::GIS::Geometry.project(to_feature.geometry.lng, to_feature.geometry.lat, map_bbox, ZOOM).map(&:round)
220
+
221
+ # Line (doubled for thickness)
222
+ img.line(x1, y1, x2, y2, arrow_color)
223
+ img.line(x1 + 1, y1, x2 + 1, y2, arrow_color)
224
+
225
+ # Arrowhead at destination
226
+ dx, dy = (x2 - x1).to_f, (y2 - y1).to_f
227
+ len = Math.sqrt(dx * dx + dy * dy)
228
+ ux, uy = dx / len, dy / len
229
+
230
+ tip_x = x2 - (ux * 20).round
231
+ tip_y = y2 - (uy * 20).round
232
+ base_x = tip_x - (ux * 14).round
233
+ base_y = tip_y - (uy * 14).round
234
+ px, py = -uy, ux
235
+ p1x = base_x + (px * 7).round
236
+ p1y = base_y + (py * 7).round
237
+ p2x = base_x - (px * 7).round
238
+ p2y = base_y - (py * 7).round
239
+ img.filled_polygon([[tip_x, tip_y], [p1x, p1y], [p2x, p2y]], arrow_color)
240
+
241
+ # Label at midpoint
242
+ mid_x = (x1 + x2) / 2
243
+ mid_y = (y1 + y2) / 2
244
+ img.text("#{bearing.degrees.round(1)}\u00B0", x: mid_x + 8, y: mid_y + 5,
245
+ font: FONT, size: 14, color: arrow_color)
246
+ end
250
247
  end
251
248
 
252
- img.antialias = true
253
- brooklyn = landmarks.find { |f| f.label == "Brooklyn Bridge" }
254
- liberty = landmarks.find { |f| f.label == "Statue of Liberty" }
255
- empire = landmarks.find { |f| f.label == "Empire State Bldg" }
256
-
257
- arrow_color = DARK_MODE ? [255, 100, 100] : [200, 30, 30]
258
-
259
- draw_bearing_arrow(img, brooklyn, liberty, map_bbox, zoom, arrow_color, FONT)
260
- draw_bearing_arrow(img, brooklyn, empire, map_bbox, zoom, arrow_color, FONT)
261
-
262
- map.save(output_path)
263
-
264
249
  puts "Map saved to #{output_path}"
@@ -0,0 +1,323 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Demonstration of GEOS operations visualized on a single LibGdGis map.
4
+ # Shows boolean operations, buffering, convex hull, simplification,
5
+ # nearest points, and prepared geometry — all rendered with distinct colors.
6
+ #
7
+ # Prerequisites:
8
+ # gem install libgd-gis
9
+ # brew install gd geos
10
+ #
11
+ # Usage:
12
+ # ruby -Ilib examples/14_geos_map_rendering.rb
13
+ # ruby -Ilib examples/14_geos_map_rendering.rb --dark
14
+
15
+ require_relative "../lib/geodetic"
16
+
17
+ include Geodetic
18
+ LLA = Coordinate::LLA
19
+ Polygon = Areas::Polygon
20
+
21
+ unless Geos.available?
22
+ abort "libgeos_c not found. Install with: brew install geos"
23
+ end
24
+
25
+ # --- Theme ---
26
+
27
+ def detect_macos_dark_mode
28
+ `defaults read -g AppleInterfaceStyle 2>/dev/null`.strip == "Dark"
29
+ rescue
30
+ false
31
+ end
32
+
33
+ DARK_MODE = if ARGV.include?("--dark") then true
34
+ elsif ARGV.include?("--light") then false
35
+ else detect_macos_dark_mode
36
+ end
37
+
38
+ BASEMAP = DARK_MODE ? :carto_dark : :carto_light
39
+ LABEL_COLOR = DARK_MODE ? [255, 255, 255] : [20, 20, 20]
40
+
41
+ # ─── Color palette ─────────────────────────────────────────────────────
42
+
43
+ # libgd alpha convention: 0 = opaque, 255 = fully transparent
44
+ # (ruby-libgd converts to GD's internal 0=opaque/127=transparent scale)
45
+ COLORS = {
46
+ lower: { fill: [70, 130, 255, 180], stroke: [70, 130, 255, 40] }, # blue
47
+ midtown: { fill: [255, 160, 40, 180], stroke: [255, 160, 40, 40] }, # orange
48
+ overlap: { fill: [255, 50, 50, 140], stroke: [255, 50, 50, 0] }, # red
49
+ difference: { fill: [180, 80, 220, 170], stroke: [180, 80, 220, 40] }, # purple
50
+ buffer_point: { fill: [0, 200, 120, 170], stroke: [0, 200, 120, 30] }, # green
51
+ buffer_path: { fill: [255, 220, 50, 180], stroke: [255, 220, 50, 40] }, # yellow
52
+ hull: { fill: [0, 220, 220, 245], stroke: [0, 220, 220, 40] }, # cyan — very transparent fill
53
+ simplified: { fill: [255, 100, 180, 170], stroke: [255, 100, 180, 40] }, # pink
54
+ nearest_line: { stroke: [255, 255, 0, 0] }, # bright yellow (opaque)
55
+ inside_point: [0, 255, 100], # green dot
56
+ outside_point: [255, 60, 60], # red dot
57
+ landmark_point: [255, 255, 255], # white dot
58
+ }
59
+
60
+ # ─── 1. Build geometry ─────────────────────────────────────────────────
61
+
62
+ # Two overlapping polygons covering Manhattan
63
+ lower = Polygon.new(boundary: [
64
+ LLA.new(lat: 40.700, lng: -74.020, alt: 0),
65
+ LLA.new(lat: 40.700, lng: -73.970, alt: 0),
66
+ LLA.new(lat: 40.730, lng: -73.970, alt: 0),
67
+ LLA.new(lat: 40.730, lng: -74.020, alt: 0),
68
+ ])
69
+
70
+ midtown = Polygon.new(boundary: [
71
+ LLA.new(lat: 40.720, lng: -74.010, alt: 0),
72
+ LLA.new(lat: 40.720, lng: -73.960, alt: 0),
73
+ LLA.new(lat: 40.760, lng: -73.960, alt: 0),
74
+ LLA.new(lat: 40.760, lng: -74.010, alt: 0),
75
+ ])
76
+
77
+ # ─── 2. GEOS boolean operations ────────────────────────────────────────
78
+
79
+ puts "Computing GEOS operations..."
80
+
81
+ overlap = Geos.intersection(lower, midtown)
82
+ diff_lower = Geos.difference(lower, midtown)
83
+
84
+ puts " intersection: #{overlap.class}"
85
+ puts " difference: #{diff_lower.class}"
86
+
87
+ # ─── 3. GEOS buffering ─────────────────────────────────────────────────
88
+
89
+ # Buffer a point (Empire State Building) — creates a circle-like polygon
90
+ empire = LLA.new(lat: 40.7484, lng: -73.9857, alt: 0)
91
+ point_buffer = Geos.buffer(empire, 0.008, quad_segs: 16)
92
+ puts " point buffer: #{point_buffer.class}"
93
+
94
+ # Buffer a path — creates a corridor
95
+ route = Path.new(coordinates: [
96
+ LLA.new(lat: 40.7061, lng: -73.9969, alt: 0), # Brooklyn Bridge
97
+ LLA.new(lat: 40.7128, lng: -74.0060, alt: 0), # One World Trade
98
+ LLA.new(lat: 40.7484, lng: -73.9857, alt: 0), # Empire State
99
+ LLA.new(lat: 40.7580, lng: -73.9855, alt: 0), # Times Square
100
+ ])
101
+ path_buffer = Geos.buffer(route, 0.0015)
102
+ puts " path buffer: #{path_buffer.class}"
103
+
104
+ # ─── 4. Convex hull of scattered landmarks ─────────────────────────────
105
+
106
+ landmarks = {
107
+ "Statue of Liberty" => LLA.new(lat: 40.6892, lng: -74.0445, alt: 0),
108
+ "Brooklyn Bridge" => LLA.new(lat: 40.7061, lng: -73.9969, alt: 0),
109
+ "One World Trade" => LLA.new(lat: 40.7128, lng: -74.0060, alt: 0),
110
+ "Empire State" => LLA.new(lat: 40.7484, lng: -73.9857, alt: 0),
111
+ "Times Square" => LLA.new(lat: 40.7580, lng: -73.9855, alt: 0),
112
+ "Central Park S" => LLA.new(lat: 40.7649, lng: -73.9727, alt: 0),
113
+ "JFK Airport" => LLA.new(lat: 40.6413, lng: -73.7781, alt: 0),
114
+ "Coney Island" => LLA.new(lat: 40.5749, lng: -73.9857, alt: 0),
115
+ }
116
+
117
+ landmark_path = Path.new(coordinates: landmarks.values)
118
+ hull = Geos.convex_hull(landmark_path)
119
+ puts " convex hull: #{hull.class} (from #{landmarks.size} landmarks)"
120
+
121
+ # ─── 5. Simplification ─────────────────────────────────────────────────
122
+
123
+ # Build a detailed 60-vertex circle
124
+ step = 360.0 / 60
125
+ detailed_circle = Polygon.new(boundary: 60.times.map { |i|
126
+ angle = i * step * RAD_PER_DEG
127
+ LLA.new(lat: 40.66 + 0.015 * Math.sin(angle),
128
+ lng: -74.04 + 0.015 * Math.cos(angle), alt: 0)
129
+ })
130
+
131
+ simplified = Geos.simplify(detailed_circle, 0.004)
132
+ puts " simplify: 60 vertices -> #{simplified.is_a?(Polygon) ? simplified.boundary.length - 1 : '?'} vertices"
133
+
134
+ # ─── 6. Nearest points between non-overlapping geometries ──────────────
135
+
136
+ brooklyn_poly = Polygon.new(boundary: [
137
+ LLA.new(lat: 40.630, lng: -73.990, alt: 0),
138
+ LLA.new(lat: 40.630, lng: -73.940, alt: 0),
139
+ LLA.new(lat: 40.660, lng: -73.940, alt: 0),
140
+ LLA.new(lat: 40.660, lng: -73.990, alt: 0),
141
+ ])
142
+
143
+ nearest = Geos.nearest_points(lower, brooklyn_poly)
144
+ nearest_dist = nearest[0].distance_to(nearest[1])
145
+ puts " nearest pts: #{nearest_dist}"
146
+
147
+ # ─── 7. Prepared geometry — batch point-in-polygon ─────────────────────
148
+
149
+ srand(42)
150
+ test_points = 30.times.map do
151
+ LLA.new(lat: 40.56 + rand * 0.22, lng: -74.06 + rand * 0.30, alt: 0)
152
+ end
153
+
154
+ prepared = Geos.prepare(hull)
155
+ inside_pts = test_points.select { |pt| prepared.contains?(pt) }
156
+ outside_pts = test_points.reject { |pt| prepared.contains?(pt) }
157
+ prepared.release
158
+
159
+ puts " prepared test: #{inside_pts.size} inside hull, #{outside_pts.size} outside"
160
+
161
+ # ─── 8. Compute bounding box for the map ───────────────────────────────
162
+
163
+ all_points = landmarks.values + test_points +
164
+ [LLA.new(lat: 40.56, lng: -74.06, alt: 0),
165
+ LLA.new(lat: 40.78, lng: -73.75, alt: 0)]
166
+ lats = all_points.map(&:lat)
167
+ lngs = all_points.map(&:lng)
168
+ padding = 0.02
169
+
170
+ bbox = Areas::BoundingBox.new(
171
+ nw: LLA.new(lat: lats.max + padding, lng: lngs.min - padding, alt: 0),
172
+ se: LLA.new(lat: lats.min - padding, lng: lngs.max + padding, alt: 0)
173
+ )
174
+
175
+ # ─── 9. Build the map and add all layers ───────────────────────────────
176
+
177
+ puts
178
+ puts "Building map..."
179
+
180
+ map = Map::LibGdGis.new(
181
+ bbox: bbox,
182
+ zoom: 12,
183
+ basemap: BASEMAP,
184
+ width: 1400,
185
+ height: 1000
186
+ )
187
+
188
+ # Layer 1: Convex hull (background — largest area)
189
+ if hull.is_a?(Polygon)
190
+ hull.add_to_map(map, **COLORS[:hull])
191
+ end
192
+
193
+ # Layer 2: Input polygons
194
+ lower.add_to_map(map, **COLORS[:lower], width: 2)
195
+ midtown.add_to_map(map, **COLORS[:midtown], width: 2)
196
+
197
+ # Layer 3: Boolean intersection (overlap zone)
198
+ if overlap.is_a?(Polygon)
199
+ overlap.add_to_map(map, **COLORS[:overlap], width: 2)
200
+ end
201
+
202
+ # Layer 4: Difference (lower minus midtown)
203
+ if diff_lower.is_a?(Polygon)
204
+ diff_lower.add_to_map(map, **COLORS[:difference], width: 2)
205
+ end
206
+
207
+ # Layer 5: Point buffer (Empire State)
208
+ if point_buffer.is_a?(Polygon)
209
+ point_buffer.add_to_map(map, **COLORS[:buffer_point], width: 2)
210
+ end
211
+
212
+ # Layer 6: Path buffer (route corridor)
213
+ if path_buffer.is_a?(Polygon)
214
+ path_buffer.add_to_map(map, **COLORS[:buffer_path], width: 1)
215
+ end
216
+
217
+ # Layer 7: The route path itself
218
+ route.add_to_map(map, color: [255, 220, 50, 255], width: 2)
219
+
220
+ # Layer 8: Simplified polygon near Statue of Liberty
221
+ if simplified.is_a?(Polygon)
222
+ simplified.add_to_map(map, **COLORS[:simplified], width: 2)
223
+ end
224
+
225
+ # Layer 9: Nearest-points connecting line
226
+ nearest_seg = Segment.new(nearest[0], nearest[1])
227
+ nearest_seg.add_to_map(map, color: COLORS[:nearest_line][:stroke], width: 2)
228
+
229
+ # Layer 10: Brooklyn polygon (for nearest-points context)
230
+ brooklyn_poly.add_to_map(map, fill: [100, 100, 100, 40], stroke: [150, 150, 150, 180], width: 1)
231
+
232
+ # ─── 10. Render and draw custom markers ────────────────────────────────
233
+
234
+ output_path = File.join(__dir__, "geos_showcase.png")
235
+ FONT = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"
236
+
237
+ map.render(output_path) do |gd_map|
238
+ img = gd_map.image
239
+ map_bbox = gd_map.instance_variable_get(:@bbox)
240
+ zoom = 12
241
+
242
+ # Helper: project LLA to pixel coords
243
+ project = ->(lla) {
244
+ GD::GIS::Geometry.project(lla.lng, lla.lat, map_bbox, zoom).map(&:round)
245
+ }
246
+
247
+ # Draw landmark dots with labels
248
+ landmarks.each do |name, pt|
249
+ x, y = project.call(pt)
250
+ img.filled_circle(x, y, 6, COLORS[:landmark_point])
251
+ img.circle(x, y, 6, [0, 0, 0])
252
+ img.text(name, x: x + 8, y: y + 4, font: FONT, size: 11, color: LABEL_COLOR)
253
+ end
254
+
255
+ # Draw test points (green inside hull, red outside)
256
+ inside_pts.each do |pt|
257
+ x, y = project.call(pt)
258
+ img.filled_circle(x, y, 5, COLORS[:inside_point])
259
+ img.circle(x, y, 5, [0, 80, 40])
260
+ end
261
+
262
+ outside_pts.each do |pt|
263
+ x, y = project.call(pt)
264
+ img.filled_circle(x, y, 5, COLORS[:outside_point])
265
+ img.circle(x, y, 5, [150, 30, 30])
266
+ end
267
+
268
+ # Draw nearest-points markers
269
+ nearest.each do |pt|
270
+ x, y = project.call(pt)
271
+ img.filled_circle(x, y, 7, [255, 255, 0])
272
+ img.circle(x, y, 7, [0, 0, 0])
273
+ end
274
+
275
+ # Legend
276
+ img.antialias = true
277
+ lx, ly = 12, 12
278
+ line_h = 20
279
+ entries = [
280
+ [[70, 130, 255], "Lower Manhattan (input)"],
281
+ [[255, 160, 40], "Midtown (input)"],
282
+ [[255, 50, 50], "Intersection (overlap)"],
283
+ [[180, 80, 220], "Difference (lower - midtown)"],
284
+ [[0, 200, 120], "Point buffer (Empire State)"],
285
+ [[255, 220, 50], "Path buffer (route corridor)"],
286
+ [[0, 220, 220], "Convex hull (8 landmarks)"],
287
+ [[255, 100, 180], "Simplified polygon (60->few vertices)"],
288
+ [[255, 255, 0], "Nearest points (Lower<->Brooklyn)"],
289
+ [[100, 100, 100], "Brooklyn (nearest-points target)"],
290
+ [[0, 255, 100], "Test point: inside hull"],
291
+ [[255, 60, 60], "Test point: outside hull"],
292
+ ]
293
+
294
+ # Legend background
295
+ legend_w = 310
296
+ legend_h = entries.size * line_h + 10
297
+ bg_color = DARK_MODE ? [30, 30, 30, 200] : [255, 255, 255, 200]
298
+ img.filled_rectangle(lx, ly, lx + legend_w, ly + legend_h, bg_color)
299
+ img.rectangle(lx, ly, lx + legend_w, ly + legend_h, [100, 100, 100])
300
+
301
+ entries.each_with_index do |(color, label), i|
302
+ y = ly + 6 + i * line_h
303
+ img.filled_rectangle(lx + 6, y, lx + 20, y + 12, color)
304
+ img.rectangle(lx + 6, y, lx + 20, y + 12, [60, 60, 60])
305
+ img.text(label, x: lx + 26, y: y + 11, font: FONT, size: 10, color: LABEL_COLOR)
306
+ end
307
+ end
308
+
309
+ puts "Map saved to #{output_path}"
310
+
311
+ puts <<~HEREDOC
312
+
313
+ GEOS operations rendered:
314
+ 1. intersection(lower, midtown) -> red overlay
315
+ 2. difference(lower, midtown) -> purple area
316
+ 3. buffer(empire_state, 0.004) -> green circle
317
+ 4. buffer(route, 0.0015) -> yellow corridor
318
+ 5. convex_hull(8 landmarks) -> cyan boundary
319
+ 6. simplify(60-vertex circle, 0.004) -> pink polygon
320
+ 7. nearest_points(lower, brooklyn) -> yellow dots + line
321
+ 8. prepare(hull).contains?(30 pts) -> green/red dots
322
+
323
+ HEREDOC
data/examples/README.md CHANGED
@@ -213,3 +213,40 @@ Run:
213
213
  ```bash
214
214
  ruby -Ilib examples/13_geos_operations.rb
215
215
  ```
216
+
217
+ ## 14 - GEOS Map Rendering
218
+
219
+ Visualizes GEOS spatial operations on a single raster map using the `Geodetic::Map::LibGdGis` adapter. All eight GEOS operation categories rendered with distinct colors on a dark or light basemap, with an embedded legend. Covers:
220
+
221
+ - **Boolean intersection** (red) of two overlapping Manhattan polygons
222
+ - **Boolean difference** (purple) showing lower Manhattan minus midtown
223
+ - **Point buffer** (green) around the Empire State Building via `Geos.buffer`
224
+ - **Path buffer** (yellow corridor) along a 4-point route via `Geos.buffer`
225
+ - **Convex hull** (cyan, transparent) enclosing 8 NYC landmarks via `Geos.convex_hull`
226
+ - **Simplification** (pink) reducing a 60-vertex circle to 8 vertices via `Geos.simplify`
227
+ - **Nearest points** (yellow dots + line) between Lower Manhattan and Brooklyn via `Geos.nearest_points`
228
+ - **Prepared geometry containment** (green/red dots) testing 30 random points against the hull via `Geos.prepare`
229
+ - **Map adapter pattern** using `Geodetic::Map::LibGdGis` with `add_to_map` on Geodetic objects
230
+ - **Custom drawing** via render block for landmark labels, test point markers, and color legend
231
+ - **Light/dark theme** switching with macOS system detection
232
+
233
+ Prerequisites:
234
+
235
+ ```bash
236
+ gem install libgd-gis
237
+ brew install gd geos # macOS
238
+ ```
239
+
240
+ Run:
241
+
242
+ ```bash
243
+ ruby -Ilib examples/14_geos_map_rendering.rb
244
+ ```
245
+
246
+ CLI flags:
247
+
248
+ ```
249
+ --light / --dark Select basemap theme (default: macOS system setting)
250
+ ```
251
+
252
+ Output: `examples/geos_showcase.png`
Binary file
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Geodetic
4
+ module Map
5
+ class Base
6
+ attr_reader :layers
7
+
8
+ def initialize(**options)
9
+ @layers = []
10
+ @options = options
11
+ end
12
+
13
+ # --- Adding geometry ---
14
+
15
+ def add(object, **style)
16
+ case object
17
+ when Feature
18
+ add_feature(object, **style)
19
+ when Path
20
+ add_path(object, **style)
21
+ when Segment
22
+ add_segment(object, **style)
23
+ when Areas::Circle
24
+ add_circle(object, **style)
25
+ when Areas::BoundingBox
26
+ add_bounding_box(object, **style)
27
+ when Areas::Polygon
28
+ add_polygon(object, **style)
29
+ else
30
+ if coordinate?(object)
31
+ add_coordinate(object, **style)
32
+ else
33
+ raise ArgumentError, "unsupported object type: #{object.class}"
34
+ end
35
+ end
36
+
37
+ self
38
+ end
39
+
40
+ def add_coordinate(coordinate, **style)
41
+ lla = to_lla(coordinate)
42
+ @layers << { type: :point, lla: lla, style: style }
43
+ self
44
+ end
45
+
46
+ def add_path(path, **style)
47
+ llas = path.map { |c| to_lla(c) }
48
+ @layers << { type: :line, llas: llas, style: style }
49
+ self
50
+ end
51
+
52
+ def add_segment(segment, **style)
53
+ llas = [segment.start_point, segment.end_point]
54
+ @layers << { type: :line, llas: llas, style: style }
55
+ self
56
+ end
57
+
58
+ def add_polygon(polygon, **style)
59
+ llas = polygon.boundary.map { |c| to_lla(c) }
60
+ @layers << { type: :polygon, llas: llas, style: style }
61
+ self
62
+ end
63
+
64
+ def add_circle(circle, **style)
65
+ segments = style.delete(:segments) || 32
66
+ step = 360.0 / segments
67
+ llas = segments.times.map do |i|
68
+ Vector.new(distance: circle.radius, bearing: step * i).destination_from(circle.centroid)
69
+ end
70
+ llas << llas.first
71
+ @layers << { type: :polygon, llas: llas, style: style }
72
+ self
73
+ end
74
+
75
+ def add_bounding_box(bbox, **style)
76
+ llas = [bbox.nw, bbox.ne, bbox.se, bbox.sw, bbox.nw]
77
+ @layers << { type: :polygon, llas: llas, style: style }
78
+ self
79
+ end
80
+
81
+ def add_feature(feature, **style)
82
+ merged_style = feature_style(feature, style)
83
+ add(feature.geometry, **merged_style)
84
+ end
85
+
86
+ # --- Output ---
87
+
88
+ def render(output_path)
89
+ raise NotImplementedError, "#{self.class}#render must be implemented by subclass"
90
+ end
91
+
92
+ # --- Query ---
93
+
94
+ def size
95
+ @layers.size
96
+ end
97
+
98
+ def empty?
99
+ @layers.empty?
100
+ end
101
+
102
+ def clear
103
+ @layers.clear
104
+ self
105
+ end
106
+
107
+ private
108
+
109
+ def coordinate?(object)
110
+ Coordinate.systems.any? { |klass| object.is_a?(klass) }
111
+ end
112
+
113
+ def to_lla(coord)
114
+ return coord if coord.is_a?(Coordinate::LLA)
115
+
116
+ if coord.is_a?(Coordinate::ENU) || coord.is_a?(Coordinate::NED)
117
+ raise ArgumentError,
118
+ "#{coord.class.name.split('::').last} is a relative coordinate system. " \
119
+ "Convert to an absolute system before adding to a map."
120
+ end
121
+
122
+ coord.to_lla
123
+ end
124
+
125
+ def feature_style(feature, explicit_style)
126
+ style = {}
127
+ style[:label] = feature.label if feature.label
128
+ style.merge(explicit_style)
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Geodetic
6
+ module Map
7
+ class LibGdGis < Base
8
+ def initialize(bbox: nil, zoom: 10, width: nil, height: nil, basemap: :osm, **options)
9
+ super(**options)
10
+ @bbox = resolve_bbox(bbox)
11
+ @zoom = zoom
12
+ @width = width
13
+ @height = height
14
+ @basemap = basemap
15
+ end
16
+
17
+ def render(output_path = nil, &block)
18
+ require_gd_gis!
19
+
20
+ @gd_map = build_map
21
+ apply_layers(@gd_map)
22
+ @gd_map.render
23
+
24
+ yield @gd_map if block_given?
25
+
26
+ if output_path
27
+ @gd_map.save(output_path)
28
+ end
29
+
30
+ @gd_map
31
+ end
32
+
33
+ # Access the underlying GD::GIS::Map after render.
34
+ attr_reader :gd_map
35
+
36
+ private
37
+
38
+ def require_gd_gis!
39
+ require "gd/gis"
40
+ rescue LoadError
41
+ raise LoadError,
42
+ "libgd-gis is required for Geodetic::Map::LibGdGis. " \
43
+ "Add `gem 'libgd-gis'` to your Gemfile."
44
+ end
45
+
46
+ def build_map
47
+ args = { zoom: @zoom, basemap: @basemap }
48
+ args[:bbox] = @bbox if @bbox
49
+ args[:width] = @width if @width
50
+ args[:height] = @height if @height
51
+
52
+ ::GD::GIS::Map.new(**args)
53
+ end
54
+
55
+ def apply_layers(map)
56
+ @layers.each do |layer|
57
+ case layer[:type]
58
+ when :point
59
+ apply_point(map, layer)
60
+ when :line
61
+ apply_line(map, layer)
62
+ when :polygon
63
+ apply_polygon(map, layer)
64
+ end
65
+ end
66
+ end
67
+
68
+ def apply_point(map, layer)
69
+ lla = layer[:lla]
70
+ style = layer[:style]
71
+
72
+ map.add_point(
73
+ lon: lla.longitude,
74
+ lat: lla.latitude,
75
+ **point_options(style)
76
+ )
77
+ end
78
+
79
+ def apply_line(map, layer)
80
+ coords = layer[:llas].map { |p| [p.longitude, p.latitude] }
81
+ style = layer[:style]
82
+
83
+ map.add_lines(
84
+ [coords],
85
+ **line_options(style)
86
+ )
87
+ end
88
+
89
+ def apply_polygon(map, layer)
90
+ ring = layer[:llas].map { |p| [p.longitude, p.latitude] }
91
+ style = layer[:style]
92
+
93
+ map.add_polygons(
94
+ [[ring]],
95
+ **polygon_options(style)
96
+ )
97
+ end
98
+
99
+ # --- Style translation ---
100
+
101
+ def point_options(style)
102
+ opts = {}
103
+ opts[:label] = style[:label] if style[:label]
104
+ opts[:icon] = style[:icon] if style[:icon]
105
+ opts[:font] = style[:font] if style[:font]
106
+ opts[:size] = style[:size] if style[:size]
107
+ opts[:color] = style[:color] if style[:color]
108
+ opts[:font_color] = style[:font_color] if style[:font_color]
109
+ opts[:symbol] = style[:symbol] if style[:symbol]
110
+ opts
111
+ end
112
+
113
+ def line_options(style)
114
+ opts = {}
115
+ opts[:stroke] = style[:stroke] || style[:color] || [255, 0, 0]
116
+ opts[:width] = style[:width] || 2
117
+ opts
118
+ end
119
+
120
+ def polygon_options(style)
121
+ opts = {}
122
+ opts[:fill] = style[:fill] || style[:color] || [0, 0, 255, 80]
123
+ opts[:stroke] = style[:stroke] if style[:stroke]
124
+ opts[:width] = style[:width] if style[:width]
125
+ opts
126
+ end
127
+
128
+ # --- BBox resolution ---
129
+
130
+ def resolve_bbox(bbox)
131
+ case bbox
132
+ when nil
133
+ nil
134
+ when Array
135
+ bbox
136
+ when Areas::BoundingBox
137
+ [bbox.nw.longitude, bbox.se.latitude, bbox.se.longitude, bbox.nw.latitude]
138
+ else
139
+ if Coordinate.systems.any? { |klass| bbox.is_a?(klass) }
140
+ raise ArgumentError,
141
+ "single coordinate is not a valid bbox. " \
142
+ "Use Geodetic::Areas::BoundingBox or a [west, south, east, north] array."
143
+ end
144
+ bbox
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "map/base"
4
+ require_relative "map/lib_gd_gis"
5
+
6
+ module Geodetic
7
+ module Map
8
+ # Mixin that adds add_to_map to geometry classes.
9
+ # Applied to coordinates, areas, paths, segments, and features.
10
+ module MapMethods
11
+ def add_to_map(map, **style)
12
+ map.add(self, **style)
13
+ end
14
+ end
15
+ end
16
+ end
17
+
18
+ # Apply MapMethods to all coordinate classes
19
+ Geodetic::Coordinate.systems.each do |klass|
20
+ klass.include(Geodetic::Map::MapMethods)
21
+ end
22
+
23
+ # Apply MapMethods to geometry types
24
+ [
25
+ Geodetic::Path,
26
+ Geodetic::Segment,
27
+ Geodetic::Feature,
28
+ Geodetic::Areas::Polygon,
29
+ Geodetic::Areas::Circle,
30
+ Geodetic::Areas::BoundingBox
31
+ ].each { |klass| klass.include(Geodetic::Map::MapMethods) }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Geodetic
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  end
data/lib/geodetic.rb CHANGED
@@ -19,3 +19,4 @@ require_relative "geodetic/geojson"
19
19
  require_relative "geodetic/wkt"
20
20
  require_relative "geodetic/wkb"
21
21
  require_relative "geodetic/geos"
22
+ require_relative "geodetic/map"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: geodetic
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dewayne VanHoozer
@@ -85,11 +85,13 @@ files:
85
85
  - examples/11_wkb_serialization.rb
86
86
  - examples/12_geos_benchmark.rb
87
87
  - examples/13_geos_operations.rb
88
+ - examples/14_geos_map_rendering.rb
88
89
  - examples/README.md
89
90
  - examples/geodetic_demo.geojson
90
91
  - examples/geodetic_demo.wkb
91
92
  - examples/geodetic_demo.wkt
92
93
  - examples/geodetic_demo_output.wkb.hex
94
+ - examples/geos_showcase.png
93
95
  - examples/sample_geometries.wkb
94
96
  - examples/sample_geometries.wkb.hex
95
97
  - fiddle_pointer_buffer_pool.md
@@ -131,6 +133,9 @@ files:
131
133
  - lib/geodetic/geoid_height.rb
132
134
  - lib/geodetic/geojson.rb
133
135
  - lib/geodetic/geos.rb
136
+ - lib/geodetic/map.rb
137
+ - lib/geodetic/map/base.rb
138
+ - lib/geodetic/map/lib_gd_gis.rb
134
139
  - lib/geodetic/path.rb
135
140
  - lib/geodetic/segment.rb
136
141
  - lib/geodetic/vector.rb
@@ -162,7 +167,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
162
167
  - !ruby/object:Gem::Version
163
168
  version: '0'
164
169
  requirements: []
165
- rubygems_version: 4.0.7
170
+ rubygems_version: 4.0.8
166
171
  specification_version: 4
167
172
  summary: Convert between geodetic coordinate systems with distance calculations
168
173
  test_files: []