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 +4 -4
- data/CHANGELOG.md +23 -0
- data/docs/reference/geos-acceleration.md +6 -0
- data/docs/reference/map-rendering.md +102 -16
- data/examples/05_map_rendering/demo.rb +90 -105
- data/examples/14_geos_map_rendering.rb +323 -0
- data/examples/README.md +37 -0
- data/examples/geos_showcase.png +0 -0
- data/lib/geodetic/map/base.rb +132 -0
- data/lib/geodetic/map/lib_gd_gis.rb +149 -0
- data/lib/geodetic/map.rb +31 -0
- data/lib/geodetic/version.rb +1 -1
- data/lib/geodetic.rb +1 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 542167ec9e7b27122d31d5287f967481dc9d0d4b293eaaaa349ca4203e2eb302
|
|
4
|
+
data.tar.gz: f544332a931f775df18ce4e0d2ed78e4d2619bbb946df6d287c8bc3a798d6c9f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
1
|
+
# Map Rendering
|
|
2
2
|
|
|
3
|
-
Geodetic coordinates
|
|
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
|
-
##
|
|
5
|
+
## Architecture
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
15
|
+
`MapMethods` is a mixin applied to all coordinates, areas, paths, segments, and features, providing `add_to_map(map, **style)`.
|
|
10
16
|
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
+
### Bounding box
|
|
21
66
|
|
|
22
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
|
5
|
-
# Shows how Geodetic
|
|
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,
|
|
121
|
-
lats.min - padding,
|
|
122
|
-
|
|
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 =
|
|
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.
|
|
133
|
+
# --- 4. Add Central Park polygon via the adapter ---
|
|
139
134
|
|
|
140
135
|
if CENTRAL_PARK
|
|
141
|
-
|
|
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
|
-
#
|
|
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
|
-
# ---
|
|
166
|
+
# --- 6. Render and do custom drawing via the block ---
|
|
179
167
|
|
|
180
168
|
output_path = File.join(__dir__, "nyc_landmarks.png")
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
# Draw landmark icons and labels
|
|
189
|
-
landmarks.each do |feature|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/geodetic/map.rb
ADDED
|
@@ -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) }
|
data/lib/geodetic/version.rb
CHANGED
data/lib/geodetic.rb
CHANGED
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.
|
|
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.
|
|
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: []
|