├── .gitignore ├── LICENSE ├── README.md ├── bench.mjs ├── index.mjs ├── package.json ├── rollup.config.js └── test.mjs /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | index.js 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2018, Vladimir Agafonkin 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any purpose 6 | with or without fee is hereby granted, provided that the above copyright notice 7 | and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 11 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS 13 | OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER 14 | TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF 15 | THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## geoflatbush 2 | 3 | A geographic extension for [flatbush](https://github.com/mourner/flatbush), a very fast static 2D spatial index. 4 | Performs nearest neighbors queries for geographic bounding boxes, taking Earth curvature and date line wrapping into account. Similar to [geokdbush](https://github.com/mourner/geokdbush), but for boxes instead of points. 5 | 6 | ```js 7 | import {around} from 'geoflatbush'; 8 | 9 | around(index, 30.5, 50.5, 10); // return 10 nearest boxes to Kyiv 10 | ``` 11 | -------------------------------------------------------------------------------- /bench.mjs: -------------------------------------------------------------------------------- 1 | 2 | import cities from 'all-the-cities'; 3 | import FlatBush from 'flatbush'; 4 | import {around} from './index.mjs'; 5 | 6 | const n = cities.length; 7 | const k = 100000; 8 | 9 | const randomPoints = []; 10 | for (let i = 0; i < k; i++) randomPoints.push({ 11 | lon: -180 + 360 * Math.random(), 12 | lat: -60 + 140 * Math.random() 13 | }); 14 | 15 | console.time(`index ${n} points`); 16 | const index = new FlatBush(cities.length, 4); 17 | for (const {lon, lat} of cities) index.add(lon, lat, lon, lat); 18 | index.finish(); 19 | console.timeEnd(`index ${n} points`); 20 | 21 | console.time('query 1000 closest'); 22 | around(index, -119.7051, 34.4363, 1000); 23 | console.timeEnd('query 1000 closest'); 24 | 25 | console.time('query 50000 closest'); 26 | around(index, -119.7051, 34.4363, 50000); 27 | console.timeEnd('query 50000 closest'); 28 | 29 | console.time(`query all ${n}`); 30 | around(index, -119.7051, 34.4363); 31 | console.timeEnd(`query all ${n}`); 32 | 33 | console.time('2 closest for every point'); 34 | for (const c of cities) around(index, c.lon, c.lat, 2); 35 | console.timeEnd('2 closest for every point'); 36 | 37 | console.time(`${k} random queries of 1 closest`); 38 | for (let i = 0; i < k; i++) around(index, randomPoints[i].lon, randomPoints[i].lat, 1); 39 | console.timeEnd(`${k} random queries of 1 closest`); 40 | -------------------------------------------------------------------------------- /index.mjs: -------------------------------------------------------------------------------- 1 | 2 | import FlatQueue from 'flatqueue'; 3 | 4 | const earthRadius = 6371; 5 | const earthCircumference = 40007; 6 | const rad = Math.PI / 180; 7 | 8 | export function around(index, lng, lat, maxResults = Infinity, maxDistance = Infinity, filterFn) { 9 | const result = []; 10 | 11 | const cosLat = Math.cos(lat * rad); 12 | const sinLat = Math.sin(lat * rad); 13 | 14 | // a distance-sorted priority queue that will contain both points and tree nodes 15 | const q = new FlatQueue(); 16 | 17 | // index of the top tree node (the whole Earth) 18 | let nodeIndex = index._boxes.length - 4; 19 | 20 | while (nodeIndex !== undefined) { 21 | // find the end index of the node 22 | const end = Math.min(nodeIndex + index.nodeSize * 4, upperBound(nodeIndex, index._levelBounds)); 23 | 24 | // add child nodes to the queue 25 | for (let pos = nodeIndex; pos < end; pos += 4) { 26 | const childIndex = index._indices[pos >> 2] | 0; 27 | 28 | const minLng = index._boxes[pos]; 29 | const minLat = index._boxes[pos + 1]; 30 | const maxLng = index._boxes[pos + 2]; 31 | const maxLat = index._boxes[pos + 3]; 32 | 33 | const dist = boxDist(lng, lat, minLng, minLat, maxLng, maxLat, cosLat, sinLat); 34 | 35 | if (nodeIndex < index.numItems * 4) { // leaf node 36 | // put a negative index if it's an item rather than a node, to recognize later 37 | if (!filterFn || filterFn(childIndex)) q.push(-childIndex - 1, dist); 38 | } else { 39 | q.push(childIndex, dist); 40 | } 41 | } 42 | 43 | while (q.length && q.peek() < 0) { 44 | const dist = q.peekValue(); 45 | if (dist > maxDistance) return result; 46 | result.push(-q.pop() - 1); 47 | if (result.length === maxResults) return result; 48 | } 49 | 50 | nodeIndex = q.pop(); 51 | } 52 | 53 | return result; 54 | } 55 | 56 | // binary search for the first value in the array bigger than the given 57 | function upperBound(value, arr) { 58 | let i = 0; 59 | let j = arr.length - 1; 60 | while (i < j) { 61 | const m = (i + j) >> 1; 62 | if (arr[m] > value) { 63 | j = m; 64 | } else { 65 | i = m + 1; 66 | } 67 | } 68 | return arr[i]; 69 | } 70 | 71 | // lower bound for distance from a location to points inside a bounding box 72 | function boxDist(lng, lat, minLng, minLat, maxLng, maxLat, cosLat, sinLat) { 73 | if (minLng === maxLng && minLat === maxLat) { 74 | return greatCircleDist(lng, lat, minLng, minLat, cosLat, sinLat); 75 | } 76 | 77 | // query point is between minimum and maximum longitudes 78 | if (lng >= minLng && lng <= maxLng) { 79 | if (lat <= minLat) return earthCircumference * (minLat - lat) / 360; // south 80 | if (lat >= maxLat) return earthCircumference * (lat - maxLat) / 360; // north 81 | return 0; // inside the bbox 82 | } 83 | 84 | // query point is west or east of the bounding box; 85 | // calculate the extremum for great circle distance from query point to the closest longitude 86 | const closestLng = (minLng - lng + 360) % 360 <= (lng - maxLng + 360) % 360 ? minLng : maxLng; 87 | const cosLngDelta = Math.cos((closestLng - lng) * rad); 88 | const extremumLat = Math.atan(sinLat / (cosLat * cosLngDelta)) / rad; 89 | 90 | // calculate distances to lower and higher bbox corners and extremum (if it's within this range); 91 | // one of the three distances will be the lower bound of great circle distance to bbox 92 | let d = Math.max( 93 | greatCircleDistPart(minLat, cosLat, sinLat, cosLngDelta), 94 | greatCircleDistPart(maxLat, cosLat, sinLat, cosLngDelta)); 95 | 96 | if (extremumLat > minLat && extremumLat < maxLat) { 97 | d = Math.max(d, greatCircleDistPart(extremumLat, cosLat, sinLat, cosLngDelta)); 98 | } 99 | 100 | return earthRadius * Math.acos(d); 101 | } 102 | 103 | // distance using spherical law of cosines; should be precise enough for our needs 104 | function greatCircleDist(lng, lat, lng2, lat2, cosLat, sinLat) { 105 | const cosLngDelta = Math.cos((lng2 - lng) * rad); 106 | return earthRadius * Math.acos(greatCircleDistPart(lat2, cosLat, sinLat, cosLngDelta)); 107 | } 108 | 109 | // partial greatCircleDist to reduce trigonometric calculations 110 | function greatCircleDistPart(lat, cosLat, sinLat, cosLngDelta) { 111 | const d = sinLat * Math.sin(lat * rad) + cosLat * Math.cos(lat * rad) * cosLngDelta; 112 | return Math.min(d, 1); 113 | } 114 | 115 | export function distance(lng, lat, lng2, lat2) { 116 | return greatCircleDist(lng, lat, lng2, lat2, Math.cos(lat * rad), Math.sin(lat * rad)); 117 | } 118 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "geoflatbush", 3 | "version": "1.0.0", 4 | "main": "index", 5 | "module": "index.mjs", 6 | "author": "Vladimir Agafonkin ", 7 | "license": "ISC", 8 | "scripts": { 9 | "pretest": "eslint index.mjs bench.mjs test.mjs", 10 | "test": "node --experimental-modules test.mjs", 11 | "build": "rollup -c", 12 | "bench": "node --experimental-modules bench.mjs" 13 | }, 14 | "dependencies": { 15 | "flatqueue": "^1.1.0" 16 | }, 17 | "devDependencies": { 18 | "all-the-cities": "^2.0.1", 19 | "eslint": "^5.12.1", 20 | "eslint-config-mourner": "^3.0.0", 21 | "flatbush": "^3.1.1", 22 | "rollup": "^1.1.2", 23 | "rollup-plugin-buble": "^0.19.6", 24 | "rollup-plugin-node-resolve": "^4.0.0", 25 | "tape": "^4.9.2" 26 | }, 27 | "eslintConfig": { 28 | "extends": "mourner" 29 | }, 30 | "files": [ 31 | "index.js", 32 | "index.mjs" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve'; 2 | import buble from 'rollup-plugin-buble'; 3 | 4 | export default { 5 | input: 'index.mjs', 6 | output: { 7 | file: 'index.js', 8 | name: 'geoflatbush', 9 | format: 'umd', 10 | indent: false 11 | }, 12 | plugins: [resolve(), buble()] 13 | }; 14 | -------------------------------------------------------------------------------- /test.mjs: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import Flatbush from 'flatbush'; 3 | import cities from 'all-the-cities'; 4 | import {around, distance} from './index.mjs'; 5 | 6 | const index = new Flatbush(cities.length, 4); 7 | for (const {lon, lat} of cities) index.add(lon, lat, lon, lat); 8 | index.finish(); 9 | 10 | test('performs search according to maxResults', (t) => { 11 | const points = around(index, -119.7051, 34.4363, 5); 12 | t.same(points.map(i => cities[i].name).join(', '), 'Mission Canyon, Santa Barbara, Montecito, Summerland, Goleta'); 13 | t.end(); 14 | }); 15 | 16 | test('performs search within maxDistance', (t) => { 17 | const points = around(index, 30.5, 50.5, Infinity, 20); 18 | t.same(points.map(i => cities[i].name).join(', '), 19 | 'Kiev, Vyshhorod, Kotsyubyns’ke, Sofiyivska Borschagivka, Vyshneve, Kriukivschina, Irpin’, Hostomel’, Khotiv'); 20 | t.end(); 21 | }); 22 | 23 | test('performs search using filter function', (t) => { 24 | const points = around(index, 30.5, 50.5, 10, Infinity, i => cities[i].population > 1000000); 25 | t.same(points.map(i => cities[i].name).join(', '), 26 | 'Kiev, Dnipropetrovsk, Kharkiv, Minsk, Odessa, Donets’k, Warsaw, Bucharest, Moscow, Rostov-na-Donu'); 27 | t.end(); 28 | }); 29 | 30 | test('performs exhaustive search in correct order', (t) => { 31 | const points = around(index, 30.5, 50.5).map(i => cities[i]); 32 | 33 | const c = {lon: 30.5, lat: 50.5}; 34 | const sorted = cities 35 | .map(item => ({item, dist: distance(c.lon, c.lat, item.lon, item.lat)})) 36 | .sort((a, b) => a.dist - b.dist); 37 | 38 | for (let i = 0; i < sorted.length; i++) { 39 | const dist = distance(points[i].lon, points[i].lat, c.lon, c.lat); 40 | if (dist !== sorted[i].dist) { 41 | t.fail(`${points[i].name} vs ${sorted[i].item.name}`); 42 | break; 43 | } 44 | } 45 | t.pass('all points in correct order'); 46 | 47 | t.end(); 48 | }); 49 | 50 | test('calculates great circle distance', (t) => { 51 | t.equal(10131.7396, Math.round(1e4 * distance(30.5, 50.5, -119.7, 34.4)) / 1e4); 52 | t.end(); 53 | }); 54 | --------------------------------------------------------------------------------