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
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tiletanic
|
|
4
|
+
module TileSchemes
|
|
5
|
+
class BasicTiling
|
|
6
|
+
QUADKEY_PATTERN = /\A[0-3]+\z/
|
|
7
|
+
|
|
8
|
+
attr_reader :bounds
|
|
9
|
+
|
|
10
|
+
def initialize(xmin, ymin, xmax, ymax)
|
|
11
|
+
raise ArgumentError, 'xmax must be greater than xmin' unless xmax > xmin
|
|
12
|
+
raise ArgumentError, 'ymax must be greater than ymin' unless ymax > ymin
|
|
13
|
+
|
|
14
|
+
@functional_bounds = CoordsBBox.new(
|
|
15
|
+
xmin: Float(xmin),
|
|
16
|
+
ymin: Float(ymin),
|
|
17
|
+
xmax: Float(xmax),
|
|
18
|
+
ymax: Float(ymax)
|
|
19
|
+
)
|
|
20
|
+
@bounds = CoordsBBox.new(
|
|
21
|
+
xmin: Float(xmin),
|
|
22
|
+
ymin: Float(ymin),
|
|
23
|
+
xmax: Float(xmax),
|
|
24
|
+
ymax: Float(ymax)
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def tile(xcoord, ycoord, zoom)
|
|
29
|
+
zoom = Integer(zoom)
|
|
30
|
+
Tile.new(
|
|
31
|
+
x: x_index(Float(xcoord), zoom),
|
|
32
|
+
y: y_index(Float(ycoord), zoom),
|
|
33
|
+
z: zoom
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def parent(*tile)
|
|
38
|
+
x, y, z = extract_tile(*tile)
|
|
39
|
+
Tile.new(x: x / 2, y: y / 2, z: z - 1)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def children(*tile)
|
|
43
|
+
x, y, z = extract_tile(*tile)
|
|
44
|
+
[[0, 0], [1, 0], [0, 1], [1, 1]].map do |delta_x, delta_y|
|
|
45
|
+
Tile.new(x: (2 * x) + delta_x, y: (2 * y) + delta_y, z: z + 1)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def bbox(*tile)
|
|
50
|
+
upper_left = ul(*tile)
|
|
51
|
+
bottom_right = br(*tile)
|
|
52
|
+
|
|
53
|
+
CoordsBBox.new(
|
|
54
|
+
xmin: upper_left.x,
|
|
55
|
+
ymin: bottom_right.y,
|
|
56
|
+
xmax: bottom_right.x,
|
|
57
|
+
ymax: upper_left.y
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def quadkey(*tile)
|
|
62
|
+
x, y, z = extract_tile(*tile)
|
|
63
|
+
return '' if z.zero?
|
|
64
|
+
|
|
65
|
+
z.downto(1).map do |zoom|
|
|
66
|
+
digit = 0
|
|
67
|
+
mask = 1 << (zoom - 1)
|
|
68
|
+
digit += 1 if x.anybits?(mask)
|
|
69
|
+
digit += 2 if y.anybits?(mask)
|
|
70
|
+
digit
|
|
71
|
+
end.join
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def quadkey_to_tile(quadkey)
|
|
75
|
+
raise ArgumentError, 'Input quadkey is invalid.' unless QUADKEY_PATTERN.match?(quadkey)
|
|
76
|
+
|
|
77
|
+
x = 0
|
|
78
|
+
y = 0
|
|
79
|
+
|
|
80
|
+
quadkey.reverse.each_char.with_index do |digit, index|
|
|
81
|
+
mask = 1 << index
|
|
82
|
+
|
|
83
|
+
case digit
|
|
84
|
+
when '1'
|
|
85
|
+
x |= mask
|
|
86
|
+
when '2'
|
|
87
|
+
y |= mask
|
|
88
|
+
when '3'
|
|
89
|
+
x |= mask
|
|
90
|
+
y |= mask
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
Tile.new(x: x, y: y, z: quadkey.length)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
attr_reader :functional_bounds
|
|
100
|
+
|
|
101
|
+
def extract_tile(*tile)
|
|
102
|
+
values =
|
|
103
|
+
if tile.length == 1
|
|
104
|
+
candidate = tile.first
|
|
105
|
+
raise ArgumentError, 'expected a Tile or x/y/z components' unless candidate.respond_to?(:to_a)
|
|
106
|
+
|
|
107
|
+
candidate.to_a
|
|
108
|
+
else
|
|
109
|
+
tile
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
raise ArgumentError, 'expected exactly three tile components' unless values.length == 3
|
|
113
|
+
|
|
114
|
+
values.map { |value| Integer(value) }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def x_coord(x, zoom)
|
|
118
|
+
((x / (2.0**zoom) * (functional_bounds.xmax - functional_bounds.xmin)) + functional_bounds.xmin)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def x_index(xcoord, zoom)
|
|
122
|
+
((2.0**zoom) * (xcoord - functional_bounds.xmin) / (functional_bounds.xmax - functional_bounds.xmin)).floor
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
class BasicTilingBottomLeft < BasicTiling
|
|
127
|
+
def ul(*tile)
|
|
128
|
+
x, y, z = send(:extract_tile, *tile)
|
|
129
|
+
Coords.new(x: send(:x_coord, x, z), y: y_coord(y + 1, z))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def br(*tile)
|
|
133
|
+
x, y, z = send(:extract_tile, *tile)
|
|
134
|
+
Coords.new(x: send(:x_coord, x + 1, z), y: y_coord(y, z))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def y_coord(y, zoom)
|
|
140
|
+
bounds = send(:functional_bounds)
|
|
141
|
+
((y / (2.0**zoom) * (bounds.ymax - bounds.ymin)) + bounds.ymin)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def y_index(ycoord, zoom)
|
|
145
|
+
bounds = send(:functional_bounds)
|
|
146
|
+
((2.0**zoom) * (ycoord - bounds.ymin) / (bounds.ymax - bounds.ymin)).floor
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
class BasicTilingTopLeft < BasicTiling
|
|
151
|
+
def ul(*tile)
|
|
152
|
+
x, y, z = send(:extract_tile, *tile)
|
|
153
|
+
Coords.new(x: send(:x_coord, x, z), y: y_coord(y, z))
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def br(*tile)
|
|
157
|
+
x, y, z = send(:extract_tile, *tile)
|
|
158
|
+
Coords.new(x: send(:x_coord, x + 1, z), y: y_coord(y + 1, z))
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def y_coord(y, zoom)
|
|
164
|
+
bounds = send(:functional_bounds)
|
|
165
|
+
bounds.ymax - (y / (2.0**zoom) * (bounds.ymax - bounds.ymin))
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def y_index(ycoord, zoom)
|
|
169
|
+
bounds = send(:functional_bounds)
|
|
170
|
+
((2.0**zoom) * (bounds.ymax - ycoord) / (bounds.ymax - bounds.ymin)).floor
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
class DGTiling < BasicTilingBottomLeft
|
|
175
|
+
def initialize
|
|
176
|
+
super(-180, -90, 180, 270)
|
|
177
|
+
@bounds = CoordsBBox.new(xmin: -180.0, ymin: -90.0, xmax: 180.0, ymax: 90.0)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def children(*tile)
|
|
181
|
+
x, y, z = send(:extract_tile, *tile)
|
|
182
|
+
return [Tile.new(x: 0, y: 0, z: 1), Tile.new(x: 1, y: 0, z: 1)] if z.zero?
|
|
183
|
+
|
|
184
|
+
super(x, y, z)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
class WebMercatorBL < BasicTilingBottomLeft
|
|
189
|
+
WEB_MERCATOR_LIMIT = 20_037_508.342789244
|
|
190
|
+
|
|
191
|
+
def initialize
|
|
192
|
+
super(-WEB_MERCATOR_LIMIT, -WEB_MERCATOR_LIMIT, WEB_MERCATOR_LIMIT, WEB_MERCATOR_LIMIT)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def quadkey(*tile)
|
|
196
|
+
x, y, z = send(:extract_tile, *tile)
|
|
197
|
+
return '' if z.zero?
|
|
198
|
+
|
|
199
|
+
z.downto(1).map do |zoom|
|
|
200
|
+
digit = 0
|
|
201
|
+
mask = 1 << (zoom - 1)
|
|
202
|
+
digit += 1 if x.anybits?(mask)
|
|
203
|
+
digit += 2 if y.nobits?(mask)
|
|
204
|
+
digit
|
|
205
|
+
end.join
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def quadkey_to_tile(quadkey)
|
|
209
|
+
tile = super
|
|
210
|
+
Tile.new(x: tile.x, y: (2**quadkey.length) - tile.y - 1, z: tile.z)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
class WebMercator < BasicTilingTopLeft
|
|
215
|
+
WEB_MERCATOR_LIMIT = WebMercatorBL::WEB_MERCATOR_LIMIT
|
|
216
|
+
|
|
217
|
+
def initialize
|
|
218
|
+
super(-WEB_MERCATOR_LIMIT, -WEB_MERCATOR_LIMIT, WEB_MERCATOR_LIMIT, WEB_MERCATOR_LIMIT)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
class UTMTiling < BasicTilingTopLeft
|
|
223
|
+
attr_reader :tile_size, :zoom
|
|
224
|
+
|
|
225
|
+
def initialize(tile_size)
|
|
226
|
+
raise ArgumentError, 'tile_size must be positive' unless tile_size.positive?
|
|
227
|
+
|
|
228
|
+
@tile_size = tile_size
|
|
229
|
+
internal_zoom = Math.log2(10_000_000.0 / tile_size).ceil
|
|
230
|
+
map_size = tile_size * (2**internal_zoom)
|
|
231
|
+
@zoom = internal_zoom + 1
|
|
232
|
+
|
|
233
|
+
super(-map_size + 500_000.0, -map_size, map_size + 500_000.0, map_size)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
class UTM5kmTiling < UTMTiling
|
|
238
|
+
def initialize
|
|
239
|
+
super(5_000)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
class UTM10kmTiling < UTMTiling
|
|
244
|
+
def initialize
|
|
245
|
+
super(10_000)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
class UTM100kmTiling < UTMTiling
|
|
250
|
+
def initialize
|
|
251
|
+
super(100_000)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
data/lib/tiletanic.rb
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'tiletanic/version'
|
|
4
|
+
require_relative 'tiletanic/base'
|
|
5
|
+
require_relative 'tiletanic/tileschemes'
|
|
6
|
+
require_relative 'tiletanic/geos_bootstrap'
|
|
7
|
+
require_relative 'tiletanic/tile_cover'
|
|
8
|
+
require_relative 'tiletanic/tilecover'
|
|
9
|
+
require_relative 'tiletanic/cli'
|
|
10
|
+
|
|
11
|
+
module Tiletanic
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def cover_geometry(...)
|
|
17
|
+
TileCover.cover_geometry(...)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def geos_factory(**options)
|
|
21
|
+
factory_options = { uses_lenient_assertions: true }.merge(options)
|
|
22
|
+
|
|
23
|
+
if defined?(RGeo::Geos) && (!RGeo::Geos.respond_to?(:supported?) || RGeo::Geos.supported?)
|
|
24
|
+
return RGeo::Geos.factory(factory_options)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
RGeo::Cartesian.preferred_factory(options)
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
raise Error, "Could not create an RGeo geometry factory: #{e.message}"
|
|
30
|
+
end
|
|
31
|
+
end
|
data/test/cli_test.rb
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'test_helper'
|
|
4
|
+
|
|
5
|
+
class CLITest < Minitest::Test
|
|
6
|
+
ROOT = File.expand_path('..', __dir__)
|
|
7
|
+
EXECUTABLE = File.join(ROOT, 'exe', 'tiletanic')
|
|
8
|
+
FEATURE_COLLECTION_PAYLOAD = <<~JSON
|
|
9
|
+
{"type":"FeatureCollection","features":[{"geometry":{"type":"Polygon","coordinates":[[[-101.953125,43.59375],[-101.953125,44.296875],[-102.65625,44.296875],[-102.65625,43.59375],[-101.953125,43.59375]]]},"type":"Feature","properties":{}}]}
|
|
10
|
+
JSON
|
|
11
|
+
FEATURE_PAYLOAD = <<~JSON
|
|
12
|
+
{"geometry":{"coordinates":[[[-101.953125,43.59375],[-101.953125,44.296875],[-102.65625,44.296875],[-102.65625,43.59375],[-101.953125,43.59375]]],"type":"Polygon"},"type":"Feature"}
|
|
13
|
+
JSON
|
|
14
|
+
MULTIPOLYGON_PAYLOAD = <<~JSON
|
|
15
|
+
{"type":"MultiPolygon","coordinates":[[[[-101.953125,43.59375],[-101.953125,44.296875],[-102.65625,44.296875],[-102.65625,43.59375],[-101.953125,43.59375]]]]}
|
|
16
|
+
JSON
|
|
17
|
+
ZOOM_14_TILE_PAYLOAD = <<~JSON
|
|
18
|
+
{"geometry":{"coordinates":[[[0.0,0.0],[0.02197265625,0.0],[0.02197265625,0.02197265625],[0.0,0.02197265625],[0.0,0.0]]],"type":"Polygon"},"type":"Feature"}
|
|
19
|
+
JSON
|
|
20
|
+
|
|
21
|
+
def run_cli(*args, stdin_data: nil)
|
|
22
|
+
Open3.capture3(
|
|
23
|
+
Gem.ruby,
|
|
24
|
+
'-I',
|
|
25
|
+
File.join(ROOT, 'lib'),
|
|
26
|
+
EXECUTABLE,
|
|
27
|
+
*args,
|
|
28
|
+
stdin_data: stdin_data
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def test_root_command
|
|
33
|
+
stdout, stderr, status = run_cli
|
|
34
|
+
|
|
35
|
+
assert status.success?, stderr
|
|
36
|
+
assert_includes stdout, 'cover_geometry'
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def test_version
|
|
40
|
+
stdout, stderr, status = run_cli('--version')
|
|
41
|
+
|
|
42
|
+
assert status.success?, stderr
|
|
43
|
+
assert_equal "tiletanic #{Tiletanic::VERSION}\n", stdout
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def test_cover_geometry_feature_collection
|
|
47
|
+
stdout, stderr, status = run_cli('cover_geometry', '-', stdin_data: FEATURE_COLLECTION_PAYLOAD)
|
|
48
|
+
|
|
49
|
+
assert status.success?, stderr
|
|
50
|
+
assert_equal "021323330\n", stdout
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def test_cover_geometry_multipolygon_geometry
|
|
54
|
+
stdout, stderr, status = run_cli('cover_geometry', '-', stdin_data: MULTIPOLYGON_PAYLOAD)
|
|
55
|
+
|
|
56
|
+
assert status.success?, stderr
|
|
57
|
+
assert_equal "021323330\n", stdout
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def test_cover_geometry_adjacent_tiles
|
|
61
|
+
stdout, stderr, status = run_cli('cover_geometry', '--adjacent', '-', stdin_data: FEATURE_PAYLOAD)
|
|
62
|
+
|
|
63
|
+
assert status.success?, stderr
|
|
64
|
+
assert_equal <<~OUTPUT, stdout
|
|
65
|
+
021323303
|
|
66
|
+
021323312
|
|
67
|
+
021323313
|
|
68
|
+
021323321
|
|
69
|
+
021323323
|
|
70
|
+
021323330
|
|
71
|
+
021323331
|
|
72
|
+
021323332
|
|
73
|
+
021323333
|
|
74
|
+
OUTPUT
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def test_cover_geometry_geojson_stdout
|
|
78
|
+
stdout, stderr, status = run_cli('cover_geometry', '--geojson', '-', stdin_data: FEATURE_COLLECTION_PAYLOAD)
|
|
79
|
+
|
|
80
|
+
assert status.success?, stderr
|
|
81
|
+
|
|
82
|
+
payload = JSON.parse(stdout)
|
|
83
|
+
assert_equal 'FeatureCollection', payload['type']
|
|
84
|
+
assert_equal 1, payload['features'].length
|
|
85
|
+
|
|
86
|
+
feature = payload['features'].first
|
|
87
|
+
assert_equal 'Feature', feature['type']
|
|
88
|
+
assert_equal(
|
|
89
|
+
{ 'quadkey' => '021323330', 'x' => 110, 'y' => 190, 'z' => 9 },
|
|
90
|
+
feature['properties']
|
|
91
|
+
)
|
|
92
|
+
assert_equal(
|
|
93
|
+
[[[-102.65625, 43.59375], [-101.953125, 43.59375], [-101.953125, 44.296875],
|
|
94
|
+
[-102.65625, 44.296875], [-102.65625, 43.59375]]],
|
|
95
|
+
feature.dig('geometry', 'coordinates')
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def test_cover_geometry_geojson_file_output
|
|
100
|
+
Tempfile.create(%w[tiles .geojson]) do |file|
|
|
101
|
+
file.close
|
|
102
|
+
|
|
103
|
+
stdout, stderr, status = run_cli(
|
|
104
|
+
'cover_geometry',
|
|
105
|
+
'--geojson',
|
|
106
|
+
'--output',
|
|
107
|
+
file.path,
|
|
108
|
+
'-',
|
|
109
|
+
stdin_data: FEATURE_COLLECTION_PAYLOAD
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
assert status.success?, stderr
|
|
113
|
+
assert_equal '', stdout
|
|
114
|
+
|
|
115
|
+
payload = JSON.parse(File.read(file.path))
|
|
116
|
+
assert_equal 'FeatureCollection', payload['type']
|
|
117
|
+
assert_equal 1, payload['features'].length
|
|
118
|
+
assert_equal '021323330', payload.dig('features', 0, 'properties', 'quadkey')
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def test_cover_geometry_geojson_rounds_tile_edges_to_zoom_precision
|
|
123
|
+
stdout, stderr, status = run_cli(
|
|
124
|
+
'cover_geometry',
|
|
125
|
+
'--zoom',
|
|
126
|
+
'14',
|
|
127
|
+
'--geojson',
|
|
128
|
+
'-',
|
|
129
|
+
stdin_data: ZOOM_14_TILE_PAYLOAD
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
assert status.success?, stderr
|
|
133
|
+
assert_includes stdout, '0.02197265625'
|
|
134
|
+
|
|
135
|
+
payload = JSON.parse(stdout)
|
|
136
|
+
feature = payload.fetch('features').first
|
|
137
|
+
assert_equal(
|
|
138
|
+
{ 'quadkey' => '12000000000000', 'x' => 8192, 'y' => 4096, 'z' => 14 },
|
|
139
|
+
feature['properties']
|
|
140
|
+
)
|
|
141
|
+
assert_equal(
|
|
142
|
+
[[[0.0, 0.0], [0.02197265625, 0.0], [0.02197265625, 0.02197265625], [0.0, 0.02197265625], [0.0, 0.0]]],
|
|
143
|
+
feature.dig('geometry', 'coordinates')
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
end
|
data/test/test_helper.rb
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler/setup'
|
|
4
|
+
require 'minitest/autorun'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'open3'
|
|
7
|
+
require 'set'
|
|
8
|
+
require 'tempfile'
|
|
9
|
+
|
|
10
|
+
require 'tiletanic'
|
|
11
|
+
|
|
12
|
+
module GeometryAssertions
|
|
13
|
+
def geos_factory(srid: 4326)
|
|
14
|
+
@geos_factories ||= {}
|
|
15
|
+
@geos_factories[srid] ||= Tiletanic.geos_factory(srid: srid)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def decode_geometry(hash = nil, srid: 4326, **kwargs)
|
|
19
|
+
hash ||= kwargs
|
|
20
|
+
RGeo::GeoJSON.decode(JSON.generate(hash), geo_factory: geos_factory(srid:), json_parser: :json)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def assert_coords(expected_x, expected_y, actual, delta: 1e-9)
|
|
24
|
+
assert_in_delta(expected_x, actual.x, delta)
|
|
25
|
+
assert_in_delta(expected_y, actual.y, delta)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def assert_bbox(expected, actual, delta: 1e-9)
|
|
29
|
+
assert_in_delta(expected[:xmin], actual.xmin, delta)
|
|
30
|
+
assert_in_delta(expected[:ymin], actual.ymin, delta)
|
|
31
|
+
assert_in_delta(expected[:xmax], actual.xmax, delta)
|
|
32
|
+
assert_in_delta(expected[:ymax], actual.ymax, delta)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'test_helper'
|
|
4
|
+
|
|
5
|
+
class TileCoverTest < Minitest::Test
|
|
6
|
+
include GeometryAssertions
|
|
7
|
+
|
|
8
|
+
def setup
|
|
9
|
+
@dg = Tiletanic::TileSchemes::DGTiling.new
|
|
10
|
+
@wm = Tiletanic::TileSchemes::WebMercator.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def test_rejects_non_rgeo_input
|
|
14
|
+
assert_raises(ArgumentError) { Tiletanic.cover_geometry(@dg, nil, 4).to_a }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def test_point_cover_matches_python_reference_values
|
|
18
|
+
point = decode_geometry(type: 'Point', coordinates: [-94.39453125, 15.908203125])
|
|
19
|
+
|
|
20
|
+
assert_equal [Tiletanic::Tile.new(x: 3, y: 4, z: 4)], Tiletanic.cover_geometry(@dg, point, 4).to_a
|
|
21
|
+
assert_equal Set[
|
|
22
|
+
Tiletanic::Tile.new(x: 973, y: 1204, z: 12),
|
|
23
|
+
Tiletanic::Tile.new(x: 973, y: 1205, z: 12),
|
|
24
|
+
Tiletanic::Tile.new(x: 974, y: 1204, z: 12),
|
|
25
|
+
Tiletanic::Tile.new(x: 974, y: 1205, z: 12)
|
|
26
|
+
], Tiletanic.cover_geometry(@dg, point, 12).to_a.to_set
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def test_multi_zoom_cover_collapses_to_larger_tiles_when_possible
|
|
30
|
+
point = decode_geometry(type: 'Point', coordinates: [-94.39453125, 15.908203125])
|
|
31
|
+
|
|
32
|
+
assert_equal Set[
|
|
33
|
+
Tiletanic::Tile.new(x: 486, y: 602, z: 11),
|
|
34
|
+
Tiletanic::Tile.new(x: 487, y: 602, z: 11)
|
|
35
|
+
], Tiletanic.cover_geometry(@dg, point, [11, 12]).to_a.to_set
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def test_polygon_cover_matches_python_reference_values
|
|
39
|
+
polygon = decode_geometry(
|
|
40
|
+
type: 'Polygon',
|
|
41
|
+
coordinates: [[
|
|
42
|
+
[74.5751953125, 46.86019101567027],
|
|
43
|
+
[73.916015625, 46.45299704748289],
|
|
44
|
+
[73.564453125, 46.21785176740299],
|
|
45
|
+
[73.32275390625, 45.920587344733654],
|
|
46
|
+
[73.289794921875, 45.56021795715051],
|
|
47
|
+
[73.465576171875, 45.27488643704894],
|
|
48
|
+
[74.036865234375, 44.96479793033104],
|
|
49
|
+
[74.256591796875, 45.07352060670971],
|
|
50
|
+
[74.322509765625, 45.48324350868221],
|
|
51
|
+
[74.46533203125, 45.91294412737392],
|
|
52
|
+
[74.849853515625, 46.057985244793024],
|
|
53
|
+
[74.99267578125, 46.354510837365254],
|
|
54
|
+
[75.465087890625, 46.40756396630067],
|
|
55
|
+
[76.102294921875, 46.42271253466719],
|
|
56
|
+
[76.904296875, 46.34692761055676],
|
|
57
|
+
[77.376708984375, 46.27863122156088],
|
|
58
|
+
[78.2666015625, 46.195042108660154],
|
|
59
|
+
[78.94775390625, 46.31658418182218],
|
|
60
|
+
[79.29931640625, 46.5286346952717],
|
|
61
|
+
[79.398193359375, 46.77749276376827],
|
|
62
|
+
[79.12353515625, 47.017716353979225],
|
|
63
|
+
[78.42041015625, 46.912750956378915],
|
|
64
|
+
[77.574462890625, 46.76244305208004],
|
|
65
|
+
[76.46484375, 46.81509864599243],
|
|
66
|
+
[76.036376953125, 46.98025235521883],
|
|
67
|
+
[75.399169921875, 46.830133640447386],
|
|
68
|
+
[74.893798828125, 46.93526088057719],
|
|
69
|
+
[74.5751953125, 46.86019101567027]
|
|
70
|
+
]]
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
assert_equal Set[
|
|
74
|
+
Tiletanic::Tile.new(x: 90, y: 48, z: 7),
|
|
75
|
+
Tiletanic::Tile.new(x: 91, y: 48, z: 7),
|
|
76
|
+
Tiletanic::Tile.new(x: 180, y: 95, z: 8),
|
|
77
|
+
Tiletanic::Tile.new(x: 184, y: 96, z: 8),
|
|
78
|
+
Tiletanic::Tile.new(x: 184, y: 97, z: 8)
|
|
79
|
+
], Tiletanic.cover_geometry(@dg, polygon, [7, 8]).to_a.to_set
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def test_polygon_with_hole_cover_matches_python_reference_values
|
|
83
|
+
polygon = decode_geometry(
|
|
84
|
+
type: 'Polygon',
|
|
85
|
+
coordinates: [
|
|
86
|
+
[
|
|
87
|
+
[31.794433593749996, -28.979312036722447],
|
|
88
|
+
[31.289062500000004, -29.401319510041485],
|
|
89
|
+
[31.036376953125, -29.897805610155864],
|
|
90
|
+
[30.377197265625, -30.845647420182598],
|
|
91
|
+
[29.278564453125, -31.886886525780806],
|
|
92
|
+
[26.74072265625, -31.94283997285307],
|
|
93
|
+
[23.9501953125, -31.184609135743237],
|
|
94
|
+
[23.917236328125, -28.98892237190413],
|
|
95
|
+
[25.5322265625, -27.615406013399603],
|
|
96
|
+
[27.894287109374996, -27.019984007982554],
|
|
97
|
+
[30.377197265625, -27.32297494724568],
|
|
98
|
+
[31.794433593749996, -28.979312036722447]
|
|
99
|
+
],
|
|
100
|
+
[
|
|
101
|
+
[28.652343749999996, -28.584521719370393],
|
|
102
|
+
[28.399658203125, -28.632746799225856],
|
|
103
|
+
[28.3282470703125, -28.724313406473463],
|
|
104
|
+
[28.1634521484375, -28.729130483430154],
|
|
105
|
+
[28.015136718749996, -28.8831596093235],
|
|
106
|
+
[27.745971679687496, -28.92163128242129],
|
|
107
|
+
[27.531738281249996, -29.200123477644983],
|
|
108
|
+
[27.410888671874996, -29.382175075145277],
|
|
109
|
+
[27.257080078125, -29.54956657394792],
|
|
110
|
+
[26.987915039062496, -29.640320395351402],
|
|
111
|
+
[27.1966552734375, -30.002516938570686],
|
|
112
|
+
[27.31201171875, -30.140376821599734],
|
|
113
|
+
[27.3834228515625, -30.14987731644208],
|
|
114
|
+
[27.366943359375, -30.249577240467637],
|
|
115
|
+
[27.39990234375, -30.334953881988564],
|
|
116
|
+
[27.454833984375, -30.33021268543272],
|
|
117
|
+
[27.745971679687496, -30.60954979719083],
|
|
118
|
+
[27.8997802734375, -30.619004797647793],
|
|
119
|
+
[28.108520507812496, -30.680439786468128],
|
|
120
|
+
[28.1744384765625, -30.50075098029068],
|
|
121
|
+
[28.14697265625, -30.462879341709876],
|
|
122
|
+
[28.229370117187496, -30.410781790845878],
|
|
123
|
+
[28.2513427734375, -30.29701788337205],
|
|
124
|
+
[28.3612060546875, -30.202113679097216],
|
|
125
|
+
[28.71826171875, -30.135626231134587],
|
|
126
|
+
[28.9544677734375, -30.026299582223675],
|
|
127
|
+
[29.179687499999996, -29.912090918781477],
|
|
128
|
+
[29.1192626953125, -29.831113764737136],
|
|
129
|
+
[29.141235351562504, -29.67850809103362],
|
|
130
|
+
[29.256591796874996, -29.640320395351402],
|
|
131
|
+
[29.300537109374996, -29.492206334848714],
|
|
132
|
+
[29.410400390625, -29.40610505570927],
|
|
133
|
+
[29.443359375, -29.31993078977759],
|
|
134
|
+
[29.393920898437496, -29.195328267099118],
|
|
135
|
+
[29.2950439453125, -29.08977693862319],
|
|
136
|
+
[29.091796875, -28.936054482136658],
|
|
137
|
+
[28.976440429687496, -28.90239722855847],
|
|
138
|
+
[28.916015625, -28.762843805266016],
|
|
139
|
+
[28.800659179687496, -28.772474183943018],
|
|
140
|
+
[28.789672851562496, -28.6905876542507],
|
|
141
|
+
[28.7017822265625, -28.656851034203406],
|
|
142
|
+
[28.652343749999996, -28.584521719370393]
|
|
143
|
+
]
|
|
144
|
+
]
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
assert_equal 11, Tiletanic.cover_geometry(@dg, polygon, 7).to_a.size
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def test_web_mercator_cover_uses_top_left_tilescheme
|
|
151
|
+
bbox = @wm.bbox(3, 5, 4)
|
|
152
|
+
polygon = decode_geometry(
|
|
153
|
+
{
|
|
154
|
+
type: 'Polygon',
|
|
155
|
+
coordinates: [[
|
|
156
|
+
[bbox.xmin + 10, bbox.ymin + 10],
|
|
157
|
+
[bbox.xmax - 10, bbox.ymin + 10],
|
|
158
|
+
[bbox.xmax - 10, bbox.ymax - 10],
|
|
159
|
+
[bbox.xmin + 10, bbox.ymax - 10],
|
|
160
|
+
[bbox.xmin + 10, bbox.ymin + 10]
|
|
161
|
+
]]
|
|
162
|
+
},
|
|
163
|
+
srid: 3857
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
assert_equal [Tiletanic::Tile.new(x: 3, y: 5, z: 4)], Tiletanic.cover_geometry(@wm, polygon, 4).to_a
|
|
167
|
+
end
|
|
168
|
+
end
|