proxihash 0.0.1
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.
- data/.gitignore +2 -0
- data/.travis.yml +9 -0
- data/CHANGELOG +3 -0
- data/Gemfile +6 -0
- data/LICENSE +20 -0
- data/README.markdown +109 -0
- data/Rakefile +1 -0
- data/lib/proxihash.rb +232 -0
- data/lib/proxihash/version.rb +11 -0
- data/proxihash.gemspec +18 -0
- data/test/test_proxihash.rb +377 -0
- metadata +74 -0
data/.gitignore
ADDED
data/.travis.yml
ADDED
data/CHANGELOG
ADDED
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) George Ogata
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.markdown
ADDED
@@ -0,0 +1,109 @@
|
|
1
|
+
## Proxihash
|
2
|
+
|
3
|
+
A [GeoHash][geohash] implementation geared toward indexing data for performing
|
4
|
+
proximity searches at different radii.
|
5
|
+
|
6
|
+
The algorithm underlying Proxihash is the GeoHash algorithm, however the hashes
|
7
|
+
are returned offer the bit string as a numeric value, rather than a base-32
|
8
|
+
representation. This allows bit-precision, rather than 5-bit precision, which
|
9
|
+
comes in handy for variable-distance proximity searches.
|
10
|
+
|
11
|
+
Proxihash computes for you the tileset required for a given radius search from a
|
12
|
+
given point, within the specified bit precision range. This range lets you limit
|
13
|
+
your index to certain precisions, which reflects the search distances you need
|
14
|
+
to support.
|
15
|
+
|
16
|
+
[geohash]: http://en.wikipedia.org/wiki/Geohash
|
17
|
+
|
18
|
+
### Usage
|
19
|
+
|
20
|
+
Create a proxihash with 31 bits of precision:
|
21
|
+
|
22
|
+
proxihash = Proxihash.encode(42.6, -5.6, 31)
|
23
|
+
|
24
|
+
Get the numeric value, for storage:
|
25
|
+
|
26
|
+
proxihash.value # 939008154
|
27
|
+
proxihash.num_bits # 31
|
28
|
+
|
29
|
+
Compute the tile (bounding box) represented by the proxihash:
|
30
|
+
|
31
|
+
proxihash.tile # [42.5994873046875, 42.60498046875, -5.60302734375, -5.5975341796875]
|
32
|
+
|
33
|
+
Find neighbors at the same precision:
|
34
|
+
|
35
|
+
proxihash.neighbor(-1, 1) # tile to the south-east
|
36
|
+
proxihash.neighbor( 1, -1) # tile to the north-west
|
37
|
+
|
38
|
+
# Alternatively:
|
39
|
+
proxihash.north # same as proxihash.neighbor( 1, 0)
|
40
|
+
proxihash.east # same as proxihash.neighbor( 0, 1)
|
41
|
+
proxihash.west # same as proxihash.neighbor( 0, -1)
|
42
|
+
proxihash.south # same as proxihash.neighbor(-1, 0)
|
43
|
+
|
44
|
+
Find the midpoint of the tile:
|
45
|
+
|
46
|
+
proxihash.decode # [42.60223388671875, -5.60028076171875]
|
47
|
+
|
48
|
+
Note that roundtripping through `encode` then `decode` may not give you the same
|
49
|
+
initial point, as decoding gives you the *center* of the tile. It will give you
|
50
|
+
a nearby point, however. Roundtripping through `decode` then `encode` will give
|
51
|
+
you back the same tile.
|
52
|
+
|
53
|
+
## Indexing
|
54
|
+
|
55
|
+
The idea is to index your data with proxihashes at the precisions you care
|
56
|
+
about. These precisions depend on the search distances you need to support.
|
57
|
+
|
58
|
+
Consider that the radius of the earth is about 40,036 km, (24,873 miles). This
|
59
|
+
means 16 bits of longitude (16 + 15 = 31 bit proxihash) will give you
|
60
|
+
sub-kilometer precision. Sub-mile precision requires 29 bits. These fit nicely
|
61
|
+
into a 32-bit integer.
|
62
|
+
|
63
|
+
## Searching
|
64
|
+
|
65
|
+
Once you've indexed all your data by their proxihash at the precisions you care
|
66
|
+
about, you probably want to answer a query like "find all users within 25 miles
|
67
|
+
of `(lat,lng)`". To do this, you have to find the tiles to search for:
|
68
|
+
|
69
|
+
Proxihash.radius = :earth_in_miles
|
70
|
+
Proxihash.search_tiles(lat, lng, 25, min_bits: 11, max_bits: 31)
|
71
|
+
|
72
|
+
The first line sets the units to use (default is kilometers).
|
73
|
+
|
74
|
+
The second returns up to 4 tiles (proxihashes): the tile that `(lat,lng)` falls
|
75
|
+
on, plus the 3 neighbors that need to be searched. The 3 neighbors depend on
|
76
|
+
which quadrant of the center tile `(lat,lng)` falls on. The tile size is the
|
77
|
+
smallest that will cover the 25 mile search circle.
|
78
|
+
|
79
|
+
The precision of the tiles (proxihashes) computed depends on two things: the
|
80
|
+
distance, and the latitude.
|
81
|
+
|
82
|
+
Larger tiles (shorter proxihashes) are required for larger distances and
|
83
|
+
latitudes nearer the poles; smaller tiles (longer proxihashes) for shorter
|
84
|
+
distances and latitudes nearer the equator. The latitude makes a difference
|
85
|
+
because tiles nearer the poles are smaller, and so a circle of a given radius
|
86
|
+
can cover more tiles of the same angular size near the poles than near the
|
87
|
+
equator.
|
88
|
+
|
89
|
+
There is one caveat that arises from this: Finding neighboring tiles degenerates
|
90
|
+
toward the poles. If the search circle overlaps a pole, or finding a neighboring
|
91
|
+
tile requires crossing a pole, a `PoleWrapException` is raised.
|
92
|
+
|
93
|
+
The `:max_bits` option prevents you from computing an arbitrarily precise
|
94
|
+
proxihash if you take the distance from user input. It will simply cap the
|
95
|
+
precision of the proxihashes returned. The `:min_bits` option makes
|
96
|
+
`search_tiles` return nil if a larger tile is required. `search_tiles` currently
|
97
|
+
does not return more than 4 tiles to accomodate a smaller tileset.
|
98
|
+
|
99
|
+
## Contributing
|
100
|
+
|
101
|
+
* [Bug reports](https://github.com/howaboutwe/proxihash/issues)
|
102
|
+
* [Source](https://github.com/howaboutwe/proxihash)
|
103
|
+
* Patches: Fork on Github, send pull request.
|
104
|
+
* Include tests where practical.
|
105
|
+
* Leave the version alone, or bump it in a separate commit.
|
106
|
+
|
107
|
+
## Copyright
|
108
|
+
|
109
|
+
Copyright (c) George Ogata. See LICENSE for details.
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require 'ritual'
|
data/lib/proxihash.rb
ADDED
@@ -0,0 +1,232 @@
|
|
1
|
+
class Proxihash
|
2
|
+
autoload :VERSION, 'proxihash/version'
|
3
|
+
|
4
|
+
def initialize(value, num_bits)
|
5
|
+
@value = value
|
6
|
+
@num_bits = num_bits
|
7
|
+
num_bits.odd? or
|
8
|
+
raise ArgumentError, "bitlength must be odd"
|
9
|
+
value < 1 << num_bits or
|
10
|
+
raise ArgumentError, "value too large for #{num_bits} bits"
|
11
|
+
end
|
12
|
+
|
13
|
+
attr_reader :value, :num_bits
|
14
|
+
|
15
|
+
def id
|
16
|
+
value | 1 << num_bits
|
17
|
+
end
|
18
|
+
|
19
|
+
class << self
|
20
|
+
def radius=(radius)
|
21
|
+
@radius =
|
22
|
+
case radius
|
23
|
+
when :earth_in_miles
|
24
|
+
3958.761
|
25
|
+
when :earth_in_kilometers
|
26
|
+
6371.009
|
27
|
+
when :earth_in_meters
|
28
|
+
6371009
|
29
|
+
else
|
30
|
+
radius
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def angular_units=(units)
|
35
|
+
case units
|
36
|
+
when :radians
|
37
|
+
@min_lat = -Math::PI/2
|
38
|
+
@max_lat = Math::PI/2
|
39
|
+
@min_lng = -Math::PI
|
40
|
+
@max_lng = Math::PI
|
41
|
+
when :degrees
|
42
|
+
@min_lat = -90
|
43
|
+
@max_lat = 90
|
44
|
+
@min_lng = -180
|
45
|
+
@max_lng = 180
|
46
|
+
else
|
47
|
+
raise ArgumentError "angular units must be :radians or :degrees (#{units.inspect} given)"
|
48
|
+
end
|
49
|
+
@angular_units = units
|
50
|
+
end
|
51
|
+
|
52
|
+
attr_reader :radius, :angular_units
|
53
|
+
attr_reader :min_lat, :max_lat, :min_lng, :max_lng
|
54
|
+
|
55
|
+
def encode(lat, lng, num_bits=31)
|
56
|
+
lat = lat.to_f
|
57
|
+
lng = lng.to_f
|
58
|
+
|
59
|
+
value = 0
|
60
|
+
|
61
|
+
lat0 = min_lat
|
62
|
+
lat1 = max_lat
|
63
|
+
lng0 = min_lng
|
64
|
+
lng1 = max_lng
|
65
|
+
|
66
|
+
(num_bits - 1).downto(0) do |i|
|
67
|
+
if i.odd?
|
68
|
+
mid = 0.5 * (lat0 + lat1)
|
69
|
+
if lat > mid
|
70
|
+
value |= (1 << i)
|
71
|
+
lat0 = mid
|
72
|
+
else
|
73
|
+
lat1 = mid
|
74
|
+
end
|
75
|
+
else
|
76
|
+
mid = 0.5 * (lng0 + lng1)
|
77
|
+
if lng > mid
|
78
|
+
value |= (1 << i)
|
79
|
+
lng0 = mid
|
80
|
+
else
|
81
|
+
lng1 = mid
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
new(value, num_bits)
|
87
|
+
end
|
88
|
+
|
89
|
+
def search_tiles(lat, lng, distance, options={})
|
90
|
+
lat = lat.to_f
|
91
|
+
lng = lng.to_f
|
92
|
+
bits = 2*lng_bits(lat, distance.to_f) - 1
|
93
|
+
|
94
|
+
if (min_bits = options[:min_bits]) && bits < min_bits
|
95
|
+
return nil
|
96
|
+
elsif (max_bits = options[:max_bits]) && bits > max_bits
|
97
|
+
# TODO: avoid unnecessary neighboring tiles
|
98
|
+
bits = max_bits
|
99
|
+
end
|
100
|
+
|
101
|
+
# TODO: can use the next 2 bits to determine quadrant instead
|
102
|
+
center = encode(lat, lng, bits)
|
103
|
+
tile_lat, tile_lng = center.decode
|
104
|
+
dlat = lat < tile_lat ? -1 : 1
|
105
|
+
dlng = lng < tile_lng ? -1 : 1
|
106
|
+
[
|
107
|
+
center,
|
108
|
+
center.neighbor(0 , dlng),
|
109
|
+
center.neighbor(dlat, dlng),
|
110
|
+
center.neighbor(dlat, 0),
|
111
|
+
]
|
112
|
+
rescue PoleWrapException
|
113
|
+
nil
|
114
|
+
end
|
115
|
+
|
116
|
+
private
|
117
|
+
|
118
|
+
def lng_bits(lat, distance)
|
119
|
+
lat = Math::PI / 180.0 * lat if angular_units == :degrees
|
120
|
+
distance >= Float::EPSILON or
|
121
|
+
raise ArgumentError, "distance too small"
|
122
|
+
distance <= Proxihash.radius*(0.5*Math::PI - lat.abs) or
|
123
|
+
raise PoleWrapException, "cannot search across pole"
|
124
|
+
dlng = Math.asin(Math.sin(0.5*distance/Proxihash.radius)/Math.cos(lat))
|
125
|
+
Math.log2(Math::PI/dlng.abs).ceil - 1
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
[:min_lat, :max_lat, :min_lng, :max_lng].each do |name|
|
130
|
+
class_eval "def #{name}; self.class.#{name}; end"
|
131
|
+
end
|
132
|
+
|
133
|
+
def decode
|
134
|
+
lat0, lat1, lng0, lng1 = tile
|
135
|
+
[0.5 * (lat0 + lat1), 0.5 * (lng0 + lng1)]
|
136
|
+
end
|
137
|
+
|
138
|
+
def tile
|
139
|
+
lat0 = min_lat
|
140
|
+
lat1 = max_lat
|
141
|
+
lng0 = min_lng
|
142
|
+
lng1 = max_lng
|
143
|
+
|
144
|
+
(num_bits - 1).downto(0) do |i|
|
145
|
+
if i.odd?
|
146
|
+
mid = 0.5 * (lat0 + lat1)
|
147
|
+
if value[i] == 1
|
148
|
+
lat0 = mid
|
149
|
+
else
|
150
|
+
lat1 = mid
|
151
|
+
end
|
152
|
+
else
|
153
|
+
mid = 0.5 * (lng0 + lng1)
|
154
|
+
if value[i] == 1
|
155
|
+
lng0 = mid
|
156
|
+
else
|
157
|
+
lng1 = mid
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
[lat0, lat1, lng0, lng1]
|
163
|
+
end
|
164
|
+
|
165
|
+
def neighbor(dlat, dlng)
|
166
|
+
value = self.value
|
167
|
+
value = bump(value, 1, dlat, false) unless dlat.zero?
|
168
|
+
value = bump(value, 0, dlng, true ) unless dlng.zero?
|
169
|
+
self.class.new(value, num_bits)
|
170
|
+
end
|
171
|
+
|
172
|
+
def north
|
173
|
+
neighbor(1, 0)
|
174
|
+
end
|
175
|
+
|
176
|
+
def south
|
177
|
+
neighbor(-1, 0)
|
178
|
+
end
|
179
|
+
|
180
|
+
def east
|
181
|
+
neighbor(0, 1)
|
182
|
+
end
|
183
|
+
|
184
|
+
def west
|
185
|
+
neighbor(0, -1)
|
186
|
+
end
|
187
|
+
|
188
|
+
def ==(other)
|
189
|
+
other.is_a?(Proxihash) && value == other.value && num_bits == other.num_bits
|
190
|
+
end
|
191
|
+
|
192
|
+
def hash
|
193
|
+
value.hash ^ num_bits
|
194
|
+
end
|
195
|
+
alias eql? ==
|
196
|
+
|
197
|
+
def inspect
|
198
|
+
"Proxihash[#{value.to_s(2).rjust(num_bits, '0')}]"
|
199
|
+
end
|
200
|
+
|
201
|
+
private
|
202
|
+
|
203
|
+
def bump(value, offset, direction, wrap)
|
204
|
+
bit = offset
|
205
|
+
if direction > 0
|
206
|
+
while bit < num_bits
|
207
|
+
if value[bit] == 0
|
208
|
+
return value | (1 << bit)
|
209
|
+
else
|
210
|
+
value &= ~(1 << bit)
|
211
|
+
end
|
212
|
+
bit += 2
|
213
|
+
end
|
214
|
+
return value if wrap
|
215
|
+
else
|
216
|
+
while bit < num_bits
|
217
|
+
if value[bit] == 0
|
218
|
+
value |= 1 << bit
|
219
|
+
else
|
220
|
+
return value & ~(1 << bit)
|
221
|
+
end
|
222
|
+
bit += 2
|
223
|
+
end
|
224
|
+
return value if wrap
|
225
|
+
end
|
226
|
+
raise PoleWrapException, "can't wrap around pole"
|
227
|
+
end
|
228
|
+
|
229
|
+
PoleWrapException = Class.new(ArgumentError)
|
230
|
+
self.radius = :earth_in_kilometers
|
231
|
+
self.angular_units = :degrees
|
232
|
+
end
|
data/proxihash.gemspec
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.unshift File.expand_path('lib', File.dirname(__FILE__))
|
3
|
+
require 'proxihash/version'
|
4
|
+
|
5
|
+
Gem::Specification.new do |gem|
|
6
|
+
gem.name = 'proxihash'
|
7
|
+
gem.version = Proxihash::VERSION
|
8
|
+
gem.authors = ['George Ogata']
|
9
|
+
gem.email = ['george.ogata@gmail.com']
|
10
|
+
gem.summary = "Hashes for geospatial proximity searches."
|
11
|
+
gem.homepage = 'http://github.com/howaboutwe/proxisearch'
|
12
|
+
|
13
|
+
gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
14
|
+
gem.files = `git ls-files`.split("\n")
|
15
|
+
gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
16
|
+
|
17
|
+
gem.add_development_dependency 'ritual', '~> 0.4.1'
|
18
|
+
end
|
@@ -0,0 +1,377 @@
|
|
1
|
+
$:.unshift File.expand_path('../lib', File.dirname(__FILE__))
|
2
|
+
|
3
|
+
require 'minitest/spec'
|
4
|
+
require 'set'
|
5
|
+
require 'proxihash'
|
6
|
+
|
7
|
+
describe Proxihash do
|
8
|
+
describe '.new' do
|
9
|
+
it "creates a Proxihash with the given value and number of bits" do
|
10
|
+
proxihash = Proxihash.new(0b011, 3)
|
11
|
+
proxihash.value.must_equal 0b011
|
12
|
+
proxihash.num_bits.must_equal 3
|
13
|
+
end
|
14
|
+
|
15
|
+
it "raises an ArgumentError if the value is too large for the number of bits" do
|
16
|
+
->{ Proxihash.new(0b1000, 3) }.must_raise ArgumentError
|
17
|
+
end
|
18
|
+
|
19
|
+
it "raises an ArgumentError if the number of bits is even" do
|
20
|
+
->{ Proxihash.new(0b100, 2) }.must_raise ArgumentError
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
describe '#id' do
|
25
|
+
it "returns the value prefixed with a 1-bit to uniquify it across precisions" do
|
26
|
+
proxihash = Proxihash.new(0b011, 3)
|
27
|
+
proxihash.id.must_equal 0b1011
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
describe '.encode' do
|
32
|
+
it "returns a proxihash for the given lat/lng at the given precision" do
|
33
|
+
Proxihash.encode(0, 0, 9).must_equal Proxihash.new(0b001111111, 9)
|
34
|
+
end
|
35
|
+
|
36
|
+
it "returns a sub-kilometer (31-bit) proxihash by default" do
|
37
|
+
Proxihash.encode(0, 0).must_equal Proxihash.new(0x1f_ff_ff_ff, 31)
|
38
|
+
end
|
39
|
+
|
40
|
+
it "computes the proxihash correctly" do
|
41
|
+
Proxihash.encode(-35.15625, 122.34375, 13).must_equal Proxihash.new(0b1_01_10_01_00_11_11, 13)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
describe ".search_tiles" do
|
46
|
+
describe "when in the 1st quadrant of a tile" do
|
47
|
+
it "returns the center tile and the 3 neighbors to the top and right" do
|
48
|
+
center = Proxihash.new(0x6000, 15)
|
49
|
+
Proxihash.search_tiles(0.704, 0.704, 156).to_set.must_equal Set[
|
50
|
+
center, center.neighbor(1, 0), center.neighbor(1, 1), center.neighbor(0, 1)
|
51
|
+
]
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
describe "when in the 2nd quadrant of a tile" do
|
56
|
+
it "returns the center tile and the 3 neighbors to the top and left" do
|
57
|
+
center = Proxihash.new(0x6000, 15)
|
58
|
+
Proxihash.search_tiles(0.704, 0.703, 156).to_set.must_equal Set[
|
59
|
+
center, center.neighbor(1, 0), center.neighbor(1, -1), center.neighbor(0, -1)
|
60
|
+
]
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
describe "when in the 3rd quadrant of a tile" do
|
65
|
+
it "returns the center tile and the 3 neighbors to the bottom and left" do
|
66
|
+
center = Proxihash.new(0x6000, 15)
|
67
|
+
Proxihash.search_tiles(0.703, 0.703, 156).to_set.must_equal Set[
|
68
|
+
center, center.neighbor(0, -1), center.neighbor(-1, -1), center.neighbor(-1, 0)
|
69
|
+
]
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
describe "when in the 4th quadrant of a tile" do
|
74
|
+
it "returns the center tile and the 3 neighbors to the bottom and right" do
|
75
|
+
center = Proxihash.new(0x6000, 15)
|
76
|
+
Proxihash.search_tiles(0.703, 0.704, 156).to_set.must_equal Set[
|
77
|
+
center, center.neighbor(-1, 0), center.neighbor(-1, 1), center.neighbor(0, 1)
|
78
|
+
]
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
describe "larger-radius search" do
|
83
|
+
it "returns shorter proxihashes" do
|
84
|
+
center = Proxihash.new(0x1800, 13)
|
85
|
+
Proxihash.search_tiles(1.407, 1.407, 157).to_set.must_equal Set[
|
86
|
+
center, center.neighbor(1, 0), center.neighbor(1, 1), center.neighbor(0, 1)
|
87
|
+
]
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
describe "lower-radius search" do
|
92
|
+
it "returns longer proxihashes" do
|
93
|
+
center = Proxihash.new(0x18000, 17)
|
94
|
+
Proxihash.search_tiles(0.352, 0.352, 78).to_set.must_equal Set[
|
95
|
+
center, center.neighbor(1, 0), center.neighbor(1, 1), center.neighbor(0, 1)
|
96
|
+
]
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
describe "when :min_bits is given" do
|
101
|
+
it "returns nil if the hashes would be too short" do
|
102
|
+
Proxihash.search_tiles(0, 0, 20, min_bits: 21).must_be_nil
|
103
|
+
end
|
104
|
+
|
105
|
+
it "does not return nil if the hashes are ok" do
|
106
|
+
center = Proxihash.new(0x7ffff, 21)
|
107
|
+
Proxihash.search_tiles(0, 0, 19, max_bits: 21).to_set.must_equal Set[
|
108
|
+
center, center.neighbor(1, 0), center.neighbor(1, 1), center.neighbor(0, 1)
|
109
|
+
]
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
describe "when :max_bits is given" do
|
114
|
+
it "caps the hash length if they would be too long" do
|
115
|
+
center = Proxihash.new(0x7ffff, 21)
|
116
|
+
Proxihash.search_tiles(0, 0, 0.01, max_bits: 21).to_set.must_equal Set[
|
117
|
+
center, center.neighbor(1, 0), center.neighbor(1, 1), center.neighbor(0, 1)
|
118
|
+
]
|
119
|
+
end
|
120
|
+
|
121
|
+
it "does not cap the hash length if the hashes are ok" do
|
122
|
+
center = Proxihash.new(0x7ffff, 21)
|
123
|
+
Proxihash.search_tiles(0, 0, 19, max_bits: 21).to_set.must_equal Set[
|
124
|
+
center, center.neighbor(1, 0), center.neighbor(1, 1), center.neighbor(0, 1)
|
125
|
+
]
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
describe "when the search circle overlaps a pole" do
|
130
|
+
it "returns nil" do
|
131
|
+
Proxihash.search_tiles(89.999, 0, 100).must_be_nil
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
describe '.lng_bits' do
|
137
|
+
Proxihash.singleton_class.send :public, :lng_bits
|
138
|
+
|
139
|
+
it "is higher toward the poles" do
|
140
|
+
Proxihash.lng_bits( 0, 78.18).must_equal 9
|
141
|
+
Proxihash.lng_bits( 60, 78.18).must_equal 8
|
142
|
+
Proxihash.lng_bits(-60, 78.18).must_equal 8
|
143
|
+
end
|
144
|
+
|
145
|
+
it "is lower for larger radii" do
|
146
|
+
Proxihash.lng_bits(0, 78.18).must_equal 9
|
147
|
+
Proxihash.lng_bits(0, 78.19).must_equal 8
|
148
|
+
end
|
149
|
+
|
150
|
+
it "raises a PoleWrapException if the search circle overlaps a pole" do
|
151
|
+
Proxihash.lng_bits( 60, 3335)
|
152
|
+
->{ Proxihash.lng_bits( 60, 3336) }.must_raise Proxihash::PoleWrapException
|
153
|
+
|
154
|
+
Proxihash.lng_bits(-60, 3335)
|
155
|
+
->{ Proxihash.lng_bits(-60, 3336) }.must_raise Proxihash::PoleWrapException
|
156
|
+
end
|
157
|
+
|
158
|
+
it "raises a PoleWrapException at either pole" do
|
159
|
+
->{ Proxihash.lng_bits( 90, 0.1) }.must_raise Proxihash::PoleWrapException
|
160
|
+
->{ Proxihash.lng_bits(-90, 0.1) }.must_raise Proxihash::PoleWrapException
|
161
|
+
end
|
162
|
+
|
163
|
+
it "raises an ArgumentError if the radius is zero" do
|
164
|
+
->{ Proxihash.lng_bits(0, 0) }.must_raise ArgumentError
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
describe '#decode' do
|
169
|
+
it "returns the lat/lng for the center of the given proxihash's tile" do
|
170
|
+
Proxihash.new(0, 7).decode.must_equal [-78.75, -168.75]
|
171
|
+
Proxihash.new(0, 9).decode.must_equal [-84.375, -174.375]
|
172
|
+
end
|
173
|
+
|
174
|
+
it "computes the lat/lng correctly" do
|
175
|
+
Proxihash.new(0b0_01_10_01_00_11_11, 13).decode.must_equal [-35.15625, -57.65625]
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
describe "#tile" do
|
180
|
+
it "returns the lat/lng ranges of the given proxihash's tile" do
|
181
|
+
Proxihash.new(0b0_01_10_01_00_11_11, 13).tile.must_equal [-36.5625, -33.75, -59.0625, -56.25]
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
describe "#neighbor" do
|
186
|
+
describe "to the north" do
|
187
|
+
it "increments the latitude" do
|
188
|
+
Proxihash.new(0b0_00_00, 5).neighbor(1, 0).must_equal Proxihash.new(0b0_00_10, 5)
|
189
|
+
end
|
190
|
+
|
191
|
+
it "carries the 1 as necessary" do
|
192
|
+
Proxihash.new(0b0_00_00_10_10, 9).neighbor(1, 0).must_equal Proxihash.new(0b0_00_10_00_00, 9)
|
193
|
+
end
|
194
|
+
|
195
|
+
it "raises a PoleWrapException at the north pole" do
|
196
|
+
->{ Proxihash.new(0b0_11_10_11_10, 9).neighbor(1, 0) }.must_raise Proxihash::PoleWrapException
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
describe "to the south" do
|
201
|
+
it "decrements the latitude" do
|
202
|
+
Proxihash.new(0b1_11_11, 5).neighbor(-1, 0).must_equal Proxihash.new(0b1_11_01, 5)
|
203
|
+
end
|
204
|
+
|
205
|
+
it "borrows a 1 as necessary" do
|
206
|
+
Proxihash.new(0b1_11_11_01_01, 9).neighbor(-1, 0).must_equal Proxihash.new(0b1_11_01_11_11, 9)
|
207
|
+
end
|
208
|
+
|
209
|
+
it "raises a PoleWrapException at the south pole" do
|
210
|
+
->{ Proxihash.new(0b0_01_00_01_00, 9).neighbor(-1, 0) }.must_raise Proxihash::PoleWrapException
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
describe "to the east" do
|
215
|
+
it "increments the longitude" do
|
216
|
+
Proxihash.new(0b0_00_00, 5).neighbor(0, 1).must_equal Proxihash.new(0b00_01, 5)
|
217
|
+
end
|
218
|
+
|
219
|
+
it "carries the 1 as necessary" do
|
220
|
+
Proxihash.new(0b0_00_00_01_01, 9).neighbor(0, 1).must_equal Proxihash.new(0b00_01_00_00, 9)
|
221
|
+
end
|
222
|
+
|
223
|
+
it "wraps around at the prime meridian" do
|
224
|
+
Proxihash.new(0b1_11_01_11_01, 9).neighbor(0, 1).must_equal Proxihash.new(0b0_10_00_10_00, 9)
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
describe "to the west" do
|
229
|
+
it "decrements the longitude" do
|
230
|
+
Proxihash.new(0b0_11_11, 5).neighbor(0, -1).must_equal Proxihash.new(0b0_11_10, 5)
|
231
|
+
end
|
232
|
+
|
233
|
+
it "borrows a 1 as necessary" do
|
234
|
+
Proxihash.new(0b0_11_11_10_10, 9).neighbor(0, -1).must_equal Proxihash.new(0b0_11_10_11_11, 9)
|
235
|
+
end
|
236
|
+
|
237
|
+
it "wraps around at the prime meridian" do
|
238
|
+
Proxihash.new(0b0_10_00_10_00, 9).neighbor(0, -1).must_equal Proxihash.new(0b1_11_01_11_01, 9)
|
239
|
+
end
|
240
|
+
end
|
241
|
+
|
242
|
+
describe "to the north-east" do
|
243
|
+
it "increments both latitude and longitude" do
|
244
|
+
Proxihash.new(0b0_00_00, 5).neighbor(1, 1).must_equal Proxihash.new(0b0_00_11, 5)
|
245
|
+
end
|
246
|
+
end
|
247
|
+
|
248
|
+
describe "to the north-west" do
|
249
|
+
it "increments the latitude, decrements the longitude" do
|
250
|
+
Proxihash.new(0b0_00_01, 5).neighbor(1, -1).must_equal Proxihash.new(0b0_00_10, 5)
|
251
|
+
end
|
252
|
+
end
|
253
|
+
|
254
|
+
describe "to the south-east" do
|
255
|
+
it "decrements the latitude, increments the longitude" do
|
256
|
+
Proxihash.new(0b0_00_10, 5).neighbor(-1, 1).must_equal Proxihash.new(0b0_00_01, 5)
|
257
|
+
end
|
258
|
+
end
|
259
|
+
|
260
|
+
describe "to the south-west" do
|
261
|
+
it "decrements both latitude and longitude" do
|
262
|
+
Proxihash.new(0b0_11_11, 5).neighbor(-1, -1).must_equal Proxihash.new(0b0_11_00, 5)
|
263
|
+
end
|
264
|
+
end
|
265
|
+
|
266
|
+
describe "when both arguments are zero" do
|
267
|
+
it "returns itself" do
|
268
|
+
Proxihash.new(0b0_00_00, 5).neighbor(0, 0).must_equal Proxihash.new(0b0_00_00, 5)
|
269
|
+
end
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
describe "#north" do
|
274
|
+
it "returns the neighbor to the north" do
|
275
|
+
Proxihash.new(0b0_00_00, 5).north.must_equal Proxihash.new(0b0_00_10, 5)
|
276
|
+
end
|
277
|
+
end
|
278
|
+
|
279
|
+
describe "#south" do
|
280
|
+
it "returns the neighbor to the south" do
|
281
|
+
Proxihash.new(0b1_11_11, 5).south.must_equal Proxihash.new(0b1_11_01, 5)
|
282
|
+
end
|
283
|
+
end
|
284
|
+
|
285
|
+
describe "#east" do
|
286
|
+
it "returns the neighbor to the east" do
|
287
|
+
Proxihash.new(0b0_00_00, 5).east.must_equal Proxihash.new(0b00_01, 5)
|
288
|
+
end
|
289
|
+
end
|
290
|
+
|
291
|
+
describe "#west" do
|
292
|
+
it "returns the neighbor to the west" do
|
293
|
+
Proxihash.new(0b0_11_11, 5).west.must_equal Proxihash.new(0b0_11_10, 5)
|
294
|
+
end
|
295
|
+
end
|
296
|
+
|
297
|
+
describe "#inspect" do
|
298
|
+
it "shows the raw binary string to the correct precision" do
|
299
|
+
Proxihash.new(0b10011001100, 11).inspect.must_equal 'Proxihash[10011001100]'
|
300
|
+
end
|
301
|
+
end
|
302
|
+
|
303
|
+
describe "#hash and #eql?" do
|
304
|
+
it "allows proxihashes to be used as hash keys" do
|
305
|
+
hash = {}
|
306
|
+
hash[Proxihash.new(0b0001100, 7)] = 1
|
307
|
+
hash[Proxihash.new(0b0001100, 7)] = 2
|
308
|
+
hash[Proxihash.new(0b0001100, 7)].must_equal 2
|
309
|
+
end
|
310
|
+
|
311
|
+
it "does not treat Proxihashes with different precisions as the same hash key" do
|
312
|
+
hash = {}
|
313
|
+
hash[Proxihash.new(0b0001100, 7)] = 1
|
314
|
+
hash[Proxihash.new(0b0001100, 5)] = 2
|
315
|
+
hash[Proxihash.new(0b0001100, 7)].must_equal 1
|
316
|
+
end
|
317
|
+
end
|
318
|
+
|
319
|
+
describe "tile adjacency" do
|
320
|
+
it "returns tiles whose bounding boxes border the given tile" do
|
321
|
+
tile = Proxihash.new(0b1_10_00, 5)
|
322
|
+
|
323
|
+
tl = tile.neighbor( 1, -1).tile
|
324
|
+
tc = tile.neighbor( 1, 0).tile
|
325
|
+
tr = tile.neighbor( 1, 1).tile
|
326
|
+
ml = tile.neighbor( 0, -1).tile
|
327
|
+
mc = tile.neighbor( 0, 0).tile
|
328
|
+
mr = tile.neighbor( 0, 1).tile
|
329
|
+
bl = tile.neighbor(-1, -1).tile
|
330
|
+
bc = tile.neighbor(-1, 0).tile
|
331
|
+
br = tile.neighbor(-1, 1).tile
|
332
|
+
|
333
|
+
tl[0].must_be_close_to ml[1]
|
334
|
+
tc[0].must_be_close_to mc[1]
|
335
|
+
tr[0].must_be_close_to mr[1]
|
336
|
+
|
337
|
+
ml[0].must_be_close_to bl[1]
|
338
|
+
mc[0].must_be_close_to bc[1]
|
339
|
+
mr[0].must_be_close_to br[1]
|
340
|
+
|
341
|
+
tl[3].must_be_close_to tc[2]
|
342
|
+
ml[3].must_be_close_to mc[2]
|
343
|
+
bl[3].must_be_close_to bc[2]
|
344
|
+
|
345
|
+
tc[3].must_be_close_to tr[2]
|
346
|
+
mc[3].must_be_close_to mr[2]
|
347
|
+
bc[3].must_be_close_to br[2]
|
348
|
+
|
349
|
+
tl[0].must_be_close_to tc[0]
|
350
|
+
tl[1].must_be_close_to tc[1]
|
351
|
+
tl[0].must_be_close_to tr[0]
|
352
|
+
tl[1].must_be_close_to tr[1]
|
353
|
+
|
354
|
+
tl[2].must_be_close_to ml[2]
|
355
|
+
tl[3].must_be_close_to ml[3]
|
356
|
+
tl[2].must_be_close_to bl[2]
|
357
|
+
tl[3].must_be_close_to bl[3]
|
358
|
+
|
359
|
+
[tl,tc,tr,ml,mc,mr,bl,bc,br].each do |tile|
|
360
|
+
assert tile[0] < tile[1]
|
361
|
+
assert tile[2] < tile[3]
|
362
|
+
end
|
363
|
+
end
|
364
|
+
end
|
365
|
+
|
366
|
+
describe 'roundtripping' do
|
367
|
+
it "returns the original lat/lng if the lat/lng is in the center of a tile" do
|
368
|
+
proxihash = Proxihash.encode(-28.125, 61.875, 9)
|
369
|
+
proxihash.decode.must_equal [-28.125, 61.875]
|
370
|
+
end
|
371
|
+
|
372
|
+
it "returns the center of the tile of the given lat/lng" do
|
373
|
+
proxihash = Proxihash.encode(-28, 62, 9)
|
374
|
+
proxihash.decode.must_equal [-28.125, 61.875]
|
375
|
+
end
|
376
|
+
end
|
377
|
+
end
|
metadata
ADDED
@@ -0,0 +1,74 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: proxihash
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- George Ogata
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2013-08-17 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: ritual
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ~>
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: 0.4.1
|
22
|
+
type: :development
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ~>
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: 0.4.1
|
30
|
+
description:
|
31
|
+
email:
|
32
|
+
- george.ogata@gmail.com
|
33
|
+
executables: []
|
34
|
+
extensions: []
|
35
|
+
extra_rdoc_files: []
|
36
|
+
files:
|
37
|
+
- .gitignore
|
38
|
+
- .travis.yml
|
39
|
+
- CHANGELOG
|
40
|
+
- Gemfile
|
41
|
+
- LICENSE
|
42
|
+
- README.markdown
|
43
|
+
- Rakefile
|
44
|
+
- lib/proxihash.rb
|
45
|
+
- lib/proxihash/version.rb
|
46
|
+
- proxihash.gemspec
|
47
|
+
- test/test_proxihash.rb
|
48
|
+
homepage: http://github.com/howaboutwe/proxisearch
|
49
|
+
licenses: []
|
50
|
+
post_install_message:
|
51
|
+
rdoc_options: []
|
52
|
+
require_paths:
|
53
|
+
- lib
|
54
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
55
|
+
none: false
|
56
|
+
requirements:
|
57
|
+
- - ! '>='
|
58
|
+
- !ruby/object:Gem::Version
|
59
|
+
version: '0'
|
60
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
61
|
+
none: false
|
62
|
+
requirements:
|
63
|
+
- - ! '>='
|
64
|
+
- !ruby/object:Gem::Version
|
65
|
+
version: '0'
|
66
|
+
requirements: []
|
67
|
+
rubyforge_project:
|
68
|
+
rubygems_version: 1.8.25
|
69
|
+
signing_key:
|
70
|
+
specification_version: 3
|
71
|
+
summary: Hashes for geospatial proximity searches.
|
72
|
+
test_files:
|
73
|
+
- test/test_proxihash.rb
|
74
|
+
has_rdoc:
|