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 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
+ [![CI](https://github.com/DigitalGlobe/tiletanic-rb/actions/workflows/ci.yml/badge.svg)](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,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
5
+
6
+ require 'tiletanic'
7
+
8
+ exit(Tiletanic::CLI.start(ARGV))
@@ -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
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'tile_cover'