tiletanic 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +116 -0
- data/exe/tiletanic +8 -0
- data/lib/tiletanic/base.rb +26 -0
- data/lib/tiletanic/cli.rb +277 -0
- data/lib/tiletanic/geos_bootstrap.rb +81 -0
- data/lib/tiletanic/tile_cover.rb +124 -0
- data/lib/tiletanic/tilecover.rb +3 -0
- data/lib/tiletanic/tileschemes.rb +255 -0
- data/lib/tiletanic/version.rb +5 -0
- data/lib/tiletanic.rb +31 -0
- data/test/cli_test.rb +146 -0
- data/test/test_helper.rb +34 -0
- data/test/tile_cover_test.rb +168 -0
- data/test/tileschemes_test.rb +186 -0
- metadata +102 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ad44a71edeceac24a8ee1bdcfbf6e27f5cd9b5db6a2015f6de3bea43789515d9
|
|
4
|
+
data.tar.gz: 0a6acb6902e9c9dfda5f88c915891ad91225262fb859e299f21c40421bdeb71c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ba3102d89e3debc3c2b41ec883d2ff6561d9824d2807b4115e9328c6a1ab7a68a7c35f9a8c2e895cd431632b46655b526c31c744f2c5879c36235011f738c29f
|
|
7
|
+
data.tar.gz: 98390891cc013f65043c656a1edda19f9498abcf0a82bbe74fd4be24a6e5aff4d4ebd5343018962d22a80ba334d20f49674d2140e3c7b0894a24137f7f160a8c
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DigitalGlobe
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Tiletanic for Ruby
|
|
2
|
+
[](https://github.com/DigitalGlobe/tiletanic-rb/actions/workflows/ci.yml)
|
|
3
|
+
|
|
4
|
+
This repository contains an idiomatic Ruby port of the original Python
|
|
5
|
+
[`digitalglobe/tiletanic`](https://github.com/DigitalGlobe/tiletanic)
|
|
6
|
+
library, packaged as a Ruby gem with a CLI and test suite.
|
|
7
|
+
|
|
8
|
+
It provides geospatial tiling utilities for converting between tile
|
|
9
|
+
coordinates, projected coordinates, quadkeys, and geometry coverings.
|
|
10
|
+
|
|
11
|
+
The upstream Python project remains the reference implementation. This
|
|
12
|
+
repository is the Ruby implementation packaged for RubyGems.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
GEOS must be available on the host system so `rgeo` can perform
|
|
17
|
+
topology operations.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
gem install tiletanic
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or add it to an application bundle:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
bundle add tiletanic
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or, while working on this repo:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
bundle install
|
|
33
|
+
bundle exec rake test
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
For developer linting:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
bundle exec rubocop
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Publishing
|
|
43
|
+
|
|
44
|
+
The public gem name is `tiletanic`, matching both `require "tiletanic"` and
|
|
45
|
+
the `tiletanic` command-line executable. See [RELEASING.md](RELEASING.md) for
|
|
46
|
+
the maintainer release checklist.
|
|
47
|
+
|
|
48
|
+
## Repository Layout
|
|
49
|
+
|
|
50
|
+
- `lib/` contains the Ruby library code.
|
|
51
|
+
- `exe/tiletanic` provides the command-line interface.
|
|
52
|
+
- `test/` contains the automated test suite.
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
require "tiletanic"
|
|
58
|
+
|
|
59
|
+
scheme = Tiletanic::TileSchemes::DGTiling.new
|
|
60
|
+
tile = scheme.tile(-102.3, 43.9, 9)
|
|
61
|
+
quadkey = scheme.quadkey(tile)
|
|
62
|
+
|
|
63
|
+
factory = Tiletanic.geos_factory(srid: 4326)
|
|
64
|
+
geometry = RGeo::GeoJSON.decode(
|
|
65
|
+
'{"type":"Point","coordinates":[-94.39453125,15.908203125]}',
|
|
66
|
+
geo_factory: factory,
|
|
67
|
+
json_parser: :json
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
cover = Tiletanic.cover_geometry(scheme, geometry, [11, 12]).to_a
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## CLI
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
bundle exec tiletanic --version
|
|
77
|
+
bundle exec tiletanic cover_geometry --zoom 9 aoi.geojson
|
|
78
|
+
cat aoi.geojson | bundle exec tiletanic cover_geometry -
|
|
79
|
+
bundle exec tiletanic cover_geometry --geojson aoi.geojson
|
|
80
|
+
bundle exec tiletanic cover_geometry --geojson --output tiles.geojson aoi.geojson
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## GeoJSON Precision
|
|
84
|
+
|
|
85
|
+
When GeoJSON encodes `DGTiling` tile boundaries in EPSG:4326, the minimum
|
|
86
|
+
decimal precision that preserves the exact same tile boundaries at zoom `z`
|
|
87
|
+
is:
|
|
88
|
+
|
|
89
|
+
```text
|
|
90
|
+
max(0, z - 3)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
This works because the `DGTiling` grid spacing at zoom `z` is
|
|
94
|
+
`360 / 2^z` degrees, which is also `45 / 2^(z - 3)`. Those coordinates
|
|
95
|
+
terminate in decimal after `z - 3` places, so writing fewer places can move a
|
|
96
|
+
tile edge enough to change tile coverage after a read/write round trip.
|
|
97
|
+
|
|
98
|
+
The `cover_geometry --geojson` output path rounds tile coordinates to this
|
|
99
|
+
precision.
|
|
100
|
+
|
|
101
|
+
This guarantee only applies to geometries already aligned to the `DGTiling`
|
|
102
|
+
grid, including GeoJSON generated by `tiletanic`. It does not apply to
|
|
103
|
+
arbitrary AOIs: if a boundary lies extremely close to a tile edge, no fixed
|
|
104
|
+
zoom-only decimal precision can guarantee identical coverage.
|
|
105
|
+
|
|
106
|
+
| Zoom | Digits | Zoom | Digits | Zoom | Digits |
|
|
107
|
+
| --- | ---: | --- | ---: | --- | ---: |
|
|
108
|
+
| 0 | 0 | 9 | 6 | 18 | 15 |
|
|
109
|
+
| 1 | 0 | 10 | 7 | 19 | 16 |
|
|
110
|
+
| 2 | 0 | 11 | 8 | 20 | 17 |
|
|
111
|
+
| 3 | 0 | 12 | 9 | 21 | 18 |
|
|
112
|
+
| 4 | 1 | 13 | 10 | 22 | 19 |
|
|
113
|
+
| 5 | 2 | 14 | 11 | 23 | 20 |
|
|
114
|
+
| 6 | 3 | 15 | 12 | 24 | 21 |
|
|
115
|
+
| 7 | 4 | 16 | 13 | 25 | 22 |
|
|
116
|
+
| 8 | 5 | 17 | 14 | 26 | 23 |
|
data/exe/tiletanic
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tiletanic
|
|
4
|
+
Tile = Struct.new(:x, :y, :z, keyword_init: true) do
|
|
5
|
+
def initialize(...)
|
|
6
|
+
super
|
|
7
|
+
freeze
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
Coords = Struct.new(:x, :y, keyword_init: true) do
|
|
12
|
+
def initialize(...)
|
|
13
|
+
super
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
CoordsBBox = Struct.new(:xmin, :ymin, :xmax, :ymax, keyword_init: true) do
|
|
19
|
+
def initialize(...)
|
|
20
|
+
super
|
|
21
|
+
freeze
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
CoordsBbox = CoordsBBox
|
|
26
|
+
end
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'optparse'
|
|
5
|
+
require 'rgeo'
|
|
6
|
+
require 'rgeo/geo_json'
|
|
7
|
+
|
|
8
|
+
module Tiletanic
|
|
9
|
+
class CLI
|
|
10
|
+
def self.start(argv = ARGV, stdin: $stdin, stdout: $stdout, stderr: $stderr)
|
|
11
|
+
new(argv, stdin:, stdout:, stderr:).run
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(argv, stdin:, stdout:, stderr:)
|
|
15
|
+
@argv = argv.dup
|
|
16
|
+
@stdin = stdin
|
|
17
|
+
@stdout = stdout
|
|
18
|
+
@stderr = stderr
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run
|
|
22
|
+
case @argv.first
|
|
23
|
+
when nil, '-h', '--help'
|
|
24
|
+
@stdout.puts(root_help)
|
|
25
|
+
0
|
|
26
|
+
when '-v', '--version'
|
|
27
|
+
@stdout.puts("tiletanic #{VERSION}")
|
|
28
|
+
0
|
|
29
|
+
when 'cover_geometry'
|
|
30
|
+
@argv.shift
|
|
31
|
+
run_cover_geometry(@argv)
|
|
32
|
+
else
|
|
33
|
+
@stderr.puts("Unknown command: #{@argv.first}")
|
|
34
|
+
@stderr.puts(root_help)
|
|
35
|
+
1
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def root_help
|
|
42
|
+
<<~HELP
|
|
43
|
+
Usage:
|
|
44
|
+
tiletanic [--help] [--version]
|
|
45
|
+
tiletanic cover_geometry [options] AOI_GEOJSON
|
|
46
|
+
|
|
47
|
+
Commands:
|
|
48
|
+
cover_geometry Calculate a tile covering for GeoJSON input.
|
|
49
|
+
HELP
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def run_cover_geometry(args)
|
|
53
|
+
options = {
|
|
54
|
+
tilescheme: 'DGTiling',
|
|
55
|
+
zoom: 9,
|
|
56
|
+
adjacent: false,
|
|
57
|
+
quadkey: true,
|
|
58
|
+
geojson: false,
|
|
59
|
+
output: nil
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
help_requested = false
|
|
63
|
+
|
|
64
|
+
parser = OptionParser.new do |opts|
|
|
65
|
+
opts.banner = 'Usage: tiletanic cover_geometry [options] AOI_GEOJSON'
|
|
66
|
+
|
|
67
|
+
opts.on('--tilescheme NAME', 'Tiling scheme to use (default: DGTiling)') do |value|
|
|
68
|
+
options[:tilescheme] = value
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
opts.on('--zoom ZOOM', Integer, 'Zoom level for the tile covering (default: 9)') do |value|
|
|
72
|
+
raise OptionParser::InvalidArgument, value.to_s if value.negative? || value > 26
|
|
73
|
+
|
|
74
|
+
options[:zoom] = value
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
opts.on('--[no-]adjacent', 'Include tiles that only touch the geometry boundary') do |value|
|
|
78
|
+
options[:adjacent] = value
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
opts.on('--[no-]quadkey', 'Output quadkeys instead of x,y,z lines') do |value|
|
|
82
|
+
options[:quadkey] = value
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
opts.on('--geojson', 'Output a GeoJSON FeatureCollection instead of text tiles') do
|
|
86
|
+
options[:geojson] = true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
opts.on('--output PATH', 'Write output to PATH instead of stdout ("-" for stdout)') do |value|
|
|
90
|
+
options[:output] = value
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
opts.on('-h', '--help', 'Show help') do
|
|
94
|
+
help_requested = true
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
parser.parse!(args)
|
|
99
|
+
if help_requested
|
|
100
|
+
@stdout.puts(parser)
|
|
101
|
+
return 0
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
input_path = args.shift
|
|
105
|
+
raise OptionParser::MissingArgument, 'AOI_GEOJSON' if input_path.nil?
|
|
106
|
+
raise OptionParser::InvalidArgument, args.join(' ') unless args.empty?
|
|
107
|
+
|
|
108
|
+
scheme = resolve_tilescheme(options[:tilescheme])
|
|
109
|
+
geometry = load_geojson_geometry(read_input(input_path), scheme)
|
|
110
|
+
tiles = Tiletanic.cover_geometry(scheme, geometry, options[:zoom])
|
|
111
|
+
tiles = filter_interior_tiles(scheme, tiles, geometry) unless options[:adjacent]
|
|
112
|
+
write_output(render_output(scheme, tiles, options), options[:output])
|
|
113
|
+
0
|
|
114
|
+
rescue JSON::ParserError => e
|
|
115
|
+
@stderr.puts("Invalid GeoJSON input: #{e.message}")
|
|
116
|
+
1
|
|
117
|
+
rescue OptionParser::ParseError => e
|
|
118
|
+
@stderr.puts(e.message)
|
|
119
|
+
@stderr.puts(parser)
|
|
120
|
+
1
|
|
121
|
+
rescue ArgumentError, Tiletanic::Error => e
|
|
122
|
+
@stderr.puts(e.message)
|
|
123
|
+
1
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def resolve_tilescheme(name)
|
|
127
|
+
case name
|
|
128
|
+
when 'DGTiling'
|
|
129
|
+
TileSchemes::DGTiling.new
|
|
130
|
+
else
|
|
131
|
+
raise ArgumentError, "tilescheme '#{name}' is unsupported."
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def read_input(path)
|
|
136
|
+
path == '-' ? @stdin.read : File.read(path)
|
|
137
|
+
rescue SystemCallError => e
|
|
138
|
+
raise ArgumentError, "Could not read input: #{e.message}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def load_geojson_geometry(payload, _scheme)
|
|
142
|
+
factory = Tiletanic.geos_factory(srid: 4326)
|
|
143
|
+
data = JSON.parse(payload)
|
|
144
|
+
|
|
145
|
+
case data['type']
|
|
146
|
+
when 'FeatureCollection'
|
|
147
|
+
geometries = Array(data['features']).filter_map do |feature|
|
|
148
|
+
geometry_data = feature['geometry']
|
|
149
|
+
next unless polygonal_geojson?(geometry_data)
|
|
150
|
+
|
|
151
|
+
decode_geometry(geometry_data, factory)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
geometries.reduce { |memo, geometry| memo.union(geometry) } || factory.collection([])
|
|
155
|
+
when 'Feature'
|
|
156
|
+
decode_geometry(data.fetch('geometry'), factory)
|
|
157
|
+
when String
|
|
158
|
+
decode_geometry(data, factory)
|
|
159
|
+
else
|
|
160
|
+
raise ArgumentError,
|
|
161
|
+
"The AOI_GEOJSON 'type' #{data['type'].inspect} " \
|
|
162
|
+
"is unsupported, it must be 'Feature', 'FeatureCollection', or a GeoJSON geometry"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def decode_geometry(geometry_data, factory)
|
|
167
|
+
geometry = RGeo::GeoJSON.decode(JSON.generate(geometry_data), geo_factory: factory, json_parser: :json)
|
|
168
|
+
raise ArgumentError, 'Could not decode GeoJSON geometry' if geometry.nil?
|
|
169
|
+
|
|
170
|
+
geometry
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def polygonal_geojson?(geometry_data)
|
|
174
|
+
%w[Polygon MultiPolygon].include?(geometry_data&.fetch('type', nil))
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def filter_interior_tiles(tile_scheme, tiles, geometry)
|
|
178
|
+
return enum_for(__method__, tile_scheme, tiles, geometry) unless block_given?
|
|
179
|
+
|
|
180
|
+
tiles.each do |tile|
|
|
181
|
+
tile_geometry = tile_polygon(tile_scheme, tile, geometry.factory)
|
|
182
|
+
yield tile unless geometry.touches?(tile_geometry)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def tile_polygon(tile_scheme, tile, factory)
|
|
187
|
+
coords = tile_scheme.bbox(tile)
|
|
188
|
+
ring = factory.linear_ring(
|
|
189
|
+
[
|
|
190
|
+
factory.point(coords.xmin, coords.ymin),
|
|
191
|
+
factory.point(coords.xmax, coords.ymin),
|
|
192
|
+
factory.point(coords.xmax, coords.ymax),
|
|
193
|
+
factory.point(coords.xmin, coords.ymax),
|
|
194
|
+
factory.point(coords.xmin, coords.ymin)
|
|
195
|
+
]
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
factory.polygon(ring)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def render_output(tile_scheme, tiles, options)
|
|
202
|
+
return "#{JSON.pretty_generate(tile_feature_collection(tile_scheme, tiles))}\n" if options[:geojson]
|
|
203
|
+
|
|
204
|
+
render_text_output(tile_scheme, tiles, options)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def render_text_output(tile_scheme, tiles, options)
|
|
208
|
+
lines = tiles.map do |tile|
|
|
209
|
+
options[:quadkey] ? tile_scheme.quadkey(tile) : [tile.x, tile.y, tile.z].join(',')
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
return nil if lines.empty?
|
|
213
|
+
|
|
214
|
+
"#{lines.join("\n")}\n"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def write_output(payload, path)
|
|
218
|
+
if path.nil? || path == '-'
|
|
219
|
+
@stdout.write(payload) unless payload.nil?
|
|
220
|
+
else
|
|
221
|
+
File.write(path, payload || '')
|
|
222
|
+
end
|
|
223
|
+
rescue SystemCallError => e
|
|
224
|
+
raise Tiletanic::Error, "Could not write output: #{e.message}"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def tile_feature_collection(tile_scheme, tiles)
|
|
228
|
+
{
|
|
229
|
+
type: 'FeatureCollection',
|
|
230
|
+
features: tiles.map { |tile| tile_feature(tile_scheme, tile) }
|
|
231
|
+
}
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def tile_feature(tile_scheme, tile)
|
|
235
|
+
bbox = tile_scheme.bbox(tile)
|
|
236
|
+
precision = geojson_precision_for_tile(tile)
|
|
237
|
+
coordinates = tile_polygon_coordinates(bbox, precision)
|
|
238
|
+
|
|
239
|
+
{
|
|
240
|
+
type: 'Feature',
|
|
241
|
+
properties: {
|
|
242
|
+
quadkey: tile_scheme.quadkey(tile),
|
|
243
|
+
x: tile.x,
|
|
244
|
+
y: tile.y,
|
|
245
|
+
z: tile.z
|
|
246
|
+
},
|
|
247
|
+
geometry: {
|
|
248
|
+
type: 'Polygon',
|
|
249
|
+
coordinates: [coordinates]
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def geojson_precision_for_tile(tile)
|
|
255
|
+
[tile.z - 3, 0].max
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def tile_polygon_coordinates(bbox, precision)
|
|
259
|
+
[
|
|
260
|
+
rounded_position(bbox.xmin, bbox.ymin, precision),
|
|
261
|
+
rounded_position(bbox.xmax, bbox.ymin, precision),
|
|
262
|
+
rounded_position(bbox.xmax, bbox.ymax, precision),
|
|
263
|
+
rounded_position(bbox.xmin, bbox.ymax, precision),
|
|
264
|
+
rounded_position(bbox.xmin, bbox.ymin, precision)
|
|
265
|
+
]
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def rounded_position(x, y, precision)
|
|
269
|
+
[round_coordinate(x, precision), round_coordinate(y, precision)]
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def round_coordinate(value, precision)
|
|
273
|
+
rounded = value.round(precision)
|
|
274
|
+
rounded.zero? ? 0.0 : rounded
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tiletanic
|
|
4
|
+
module GeosBootstrap
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def call
|
|
8
|
+
geos_c_library = ENV.fetch('GEOS_LIBRARY_PATH', nil)
|
|
9
|
+
geos_c_library = discover_library_path if geos_c_library.nil? || geos_c_library.empty?
|
|
10
|
+
return unless geos_c_library && File.file?(geos_c_library)
|
|
11
|
+
|
|
12
|
+
ENV['GEOS_LIBRARY_PATH'] = geos_c_library
|
|
13
|
+
preload_base_library(associated_base_library(geos_c_library))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def discover_library_path
|
|
17
|
+
candidate_paths.find { |path| path && File.file?(path) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def associated_base_library(geos_c_library)
|
|
21
|
+
directory = File.dirname(geos_c_library)
|
|
22
|
+
library_files(directory, 'libgeos').find { |path| path && File.file?(path) } || common_base_library
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def candidate_paths
|
|
26
|
+
executable = find_executable('geos-config')
|
|
27
|
+
|
|
28
|
+
candidates = []
|
|
29
|
+
if executable
|
|
30
|
+
bin_dir = File.dirname(executable)
|
|
31
|
+
candidates.concat(library_files(File.expand_path('../lib', bin_dir)))
|
|
32
|
+
candidates.concat(library_files(File.expand_path('../../lib', bin_dir)))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
candidates.concat(common_library_files)
|
|
36
|
+
candidates.compact.uniq
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def common_library_files
|
|
40
|
+
%w[
|
|
41
|
+
/usr/local/lib
|
|
42
|
+
/opt/local/lib
|
|
43
|
+
/opt/homebrew/lib
|
|
44
|
+
/usr/lib
|
|
45
|
+
].flat_map { |directory| library_files(directory) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def common_base_library
|
|
49
|
+
%w[
|
|
50
|
+
/usr/local/lib
|
|
51
|
+
/opt/local/lib
|
|
52
|
+
/opt/homebrew/lib
|
|
53
|
+
/usr/lib
|
|
54
|
+
].flat_map { |directory| library_files(directory, 'libgeos') }.find { |path| path && File.file?(path) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def library_files(directory, basename = 'libgeos_c')
|
|
58
|
+
Dir.glob(File.join(directory, "#{basename}.{dylib,so,so.*,dll}"))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def find_executable(name)
|
|
62
|
+
ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).each do |directory|
|
|
63
|
+
executable = File.join(directory, name)
|
|
64
|
+
return executable if File.executable?(executable) && !File.directory?(executable)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def preload_base_library(path)
|
|
71
|
+
return unless path && File.file?(path)
|
|
72
|
+
|
|
73
|
+
require 'ffi'
|
|
74
|
+
FFI::DynamicLibrary.open(path, FFI::DynamicLibrary::RTLD_LAZY | FFI::DynamicLibrary::RTLD_GLOBAL)
|
|
75
|
+
rescue LoadError, StandardError
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
Tiletanic::GeosBootstrap.call
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rgeo'
|
|
4
|
+
|
|
5
|
+
module Tiletanic
|
|
6
|
+
module TileCover
|
|
7
|
+
class << self
|
|
8
|
+
def cover_geometry(tile_scheme, geometry, zooms, &)
|
|
9
|
+
return enum_for(__method__, tile_scheme, geometry, zooms) unless block_given?
|
|
10
|
+
|
|
11
|
+
validate_geometry!(geometry)
|
|
12
|
+
return if geometry.empty?
|
|
13
|
+
|
|
14
|
+
zoom_levels = normalize_zoom_levels(zooms)
|
|
15
|
+
current_tile = Tile.new(x: 0, y: 0, z: 0)
|
|
16
|
+
|
|
17
|
+
if polygonal?(geometry)
|
|
18
|
+
cover_polygonal(tile_scheme, current_tile, geometry, zoom_levels, zoom_levels.max, &)
|
|
19
|
+
else
|
|
20
|
+
cover_non_polygonal(tile_scheme, current_tile, geometry, zoom_levels, &)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def validate_geometry!(geometry)
|
|
27
|
+
return if geometry.respond_to?(:factory) && geometry.respond_to?(:empty?) && geometry.respond_to?(:intersects?)
|
|
28
|
+
|
|
29
|
+
raise ArgumentError, 'Input geometry is not a known RGeo geometry type'
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def normalize_zoom_levels(zooms)
|
|
33
|
+
levels = Array(zooms).map { |zoom| Integer(zoom) }.uniq.sort
|
|
34
|
+
raise ArgumentError, 'at least one zoom level is required' if levels.empty?
|
|
35
|
+
|
|
36
|
+
levels
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def polygonal?(geometry)
|
|
40
|
+
RGeo::Feature::Polygon.check_type(geometry) || RGeo::Feature::MultiPolygon.check_type(geometry)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def cover_non_polygonal(tile_scheme, current_tile, geometry, zoom_levels, &block)
|
|
44
|
+
tile_geometry = tile_polygon(tile_scheme, current_tile, geometry.factory)
|
|
45
|
+
return unless geometry.intersects?(tile_geometry)
|
|
46
|
+
|
|
47
|
+
if zoom_levels.include?(current_tile.z)
|
|
48
|
+
yield current_tile
|
|
49
|
+
else
|
|
50
|
+
tile_scheme.children(current_tile).each do |child_tile|
|
|
51
|
+
cover_non_polygonal(tile_scheme, child_tile, geometry, zoom_levels, &block)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def cover_polygonal(tile_scheme, current_tile, geometry, zoom_levels, max_zoom, &block)
|
|
57
|
+
tile_geometry = tile_polygon(tile_scheme, current_tile, geometry.factory)
|
|
58
|
+
return unless geometry.intersects?(tile_geometry)
|
|
59
|
+
|
|
60
|
+
if current_tile.z == max_zoom
|
|
61
|
+
yield current_tile
|
|
62
|
+
elsif geometry.contains?(tile_geometry)
|
|
63
|
+
if zoom_levels.include?(current_tile.z)
|
|
64
|
+
yield current_tile
|
|
65
|
+
else
|
|
66
|
+
tile_scheme.children(current_tile).each do |child_tile|
|
|
67
|
+
containing_tiles(tile_scheme, child_tile, zoom_levels, &block)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
else
|
|
71
|
+
collected_tiles = []
|
|
72
|
+
coverage = 0
|
|
73
|
+
|
|
74
|
+
tile_scheme.children(current_tile).each do |child_tile|
|
|
75
|
+
child_tiles = []
|
|
76
|
+
cover_polygonal(tile_scheme, child_tile, geometry, zoom_levels, max_zoom) do |tile|
|
|
77
|
+
child_tiles << tile
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
if zoom_levels.include?(current_tile.z)
|
|
81
|
+
child_tiles.each do |tile|
|
|
82
|
+
collected_tiles << tile
|
|
83
|
+
coverage += 4**(max_zoom - tile.z)
|
|
84
|
+
end
|
|
85
|
+
else
|
|
86
|
+
child_tiles.each(&block)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
if zoom_levels.include?(current_tile.z) && coverage == 4**(max_zoom - current_tile.z)
|
|
91
|
+
yield current_tile
|
|
92
|
+
else
|
|
93
|
+
collected_tiles.each(&block)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def containing_tiles(tile_scheme, current_tile, zoom_levels, &block)
|
|
99
|
+
if zoom_levels.include?(current_tile.z)
|
|
100
|
+
yield current_tile
|
|
101
|
+
else
|
|
102
|
+
tile_scheme.children(current_tile).each do |child_tile|
|
|
103
|
+
containing_tiles(tile_scheme, child_tile, zoom_levels, &block)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def tile_polygon(tile_scheme, tile, factory)
|
|
109
|
+
coords = tile_scheme.bbox(tile)
|
|
110
|
+
ring = factory.linear_ring(
|
|
111
|
+
[
|
|
112
|
+
factory.point(coords.xmin, coords.ymin),
|
|
113
|
+
factory.point(coords.xmax, coords.ymin),
|
|
114
|
+
factory.point(coords.xmax, coords.ymax),
|
|
115
|
+
factory.point(coords.xmin, coords.ymax),
|
|
116
|
+
factory.point(coords.xmin, coords.ymin)
|
|
117
|
+
]
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
factory.polygon(ring)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|