diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..bc2900e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,24 @@ +name: test + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Build + run: cargo build --verbose + - name: Run clippy + run: cargo clippy --all-targets --all-features -- -D warnings + - name: Run tests + run: cargo test --verbose diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4966965 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +debug/ +target/ +Cargo.lock + +**/*.rs.bk +*.pdb + +.env +/.idea +/.vscode +.DS_Store diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..abda8b0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "supercluster" +description = "A very fast Rust crate for geospatial point clustering" +version = "1.0.5" +edition = "2021" +license = "MIT" +readme = "README.md" +authors = ["Chargetrip "] +keywords = ["supercluster", "geospatial", "geo", "tile", "mvt"] +exclude = [".github/**"] +documentation = "https://docs.rs/supercluster/1.0.5" +repository = "https://github.com/chargetrip/supercluster-rust" + +[dependencies] +serde = { version = "1.0.190", features = ["derive"] } + +[dev-dependencies] +serde_json = "1.0.108" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c4aaacf --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Chargetrip + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 199659d..498620e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,116 @@ -# supercluster-rust -A very fast Rust crate for geospatial point clustering +# Supercluster + +A very fast Rust crate for geospatial point clustering. + +This crate is deeply inspired by Mapbox's supercluster [JS package](https://www.npmjs.com/package/supercluster) and [blog post](https://www.mapbox.com/blog/supercluster/). + +## Reference implementation + +[![test](https://github.com/chargetrip/supercluster-rust/actions/workflows/test.yml/badge.svg)](https://github.com/chargetrip/supercluster-rust/actions/workflows/test.yml) +[![docs](https://docs.rs/supercluster/badge.svg)](https://docs.rs/supercluster) +[![crate](https://img.shields.io/crates/v/supercluster.svg)](https://crates.io/crates/supercluster) +![Crates.io (recent)](https://img.shields.io/crates/dr/supercluster) +![GitHub](https://img.shields.io/github/license/chargetrip/supercluster-rust) + +![Features](https://cloud.githubusercontent.com/assets/25395/11857351/43407b46-a40c-11e5-8662-e99ab1cd2cb7.gif) + +## Features + +- `load(points)`: Loads an array of [GeoJSON Feature](https://tools.ietf.org/html/rfc7946#section-3.2) objects. Each feature's `geometry` must be a [GeoJSON Point](https://tools.ietf.org/html/rfc7946#section-3.1.2). + +- `get_clusters(bbox, zoom)`: For the given `bbox` array (`[west_lng, south_lat, east_lng, north_lat]`) and `zoom`, returns an array of clusters and points as [GeoJSON Feature](https://tools.ietf.org/html/rfc7946#section-3.2) objects. + +- `get_tile(z, x, y)`: For a given zoom and x/y coordinates, returns a [geojson-vt](https://github.com/mapbox/geojson-vt)-compatible JSON tile object with cluster/point features. + +- `get_children(cluster_id)`: Returns the children of a cluster (on the next zoom level) given its id (`cluster_id` value from feature properties). + +- `get_leaves(cluster_id, limit, offset)`: Returns all the points of a cluster (given its `cluster_id`), with pagination support. + +- `get_cluster_expansion_zoom(cluster_id)`: Returns the zoom on which the cluster expands into several children (useful for "click to zoom" feature) given the cluster's `cluster_id`. + +## Options + +| Option | Description | +|--------------|-------------------------------------------------------------------| +| `min_zoom` | Minimum zoom level at which clusters are generated. | +| `max_zoom` | Maximum zoom level at which clusters are generated. | +| `min_points` | Minimum number of points to form a cluster. | +| `radius` | Cluster radius, in pixels. | +| `extent` | (Tiles) Tile extent. Radius is calculated relative to this value. | +| `node_size` | Size of the KD-tree leaf node. Affects performance. | + +## Safety + +This crate uses `#![forbid(unsafe_code)]` to ensure everything is implemented in +100% safe Rust. + +## Usage + +Run the following Cargo command in your project directory: + +```bash +cargo add supercluster +``` + +```rust +extern crate supercluster; + +use supercluster::{ Supercluster, Options }; + +fn main() { + let options = Options { + max_zoom: 16, + min_zoom: 0, + min_points: 2, + radius: 40.0, + node_size: 64, + extent: 512.0, + }; + + // Create a new instance with the specified configuration settings + let mut cluster = Supercluster::new(options); + + // Load the input GeoJSON points into the Supercluster instance + let points = Vec::new(); // your points + let index = cluster.load(points); + + // Retrieve a vector of features within a tile at the given zoom level and tile coordinates + let tile = index.get_tile(0, 0.0, 0.0).expect("cannot get a tile"); + + ... +} +``` + +## Contributing + +Build: + +```bash +cargo build +``` + +Test: + +```bash +cargo test +``` + +Run [clippy](https://github.com/rust-lang/rust-clippy): + +```bash +cargo clippy --all-targets --all-features -- -D warnings +``` + +Generate documentation in HTML format: + +```bash +cargo doc --open +``` + +## License + +This project is licensed under the [MIT license][license]. + +## Sponsors + +[![Chargetrip logo](https://chargetrip-files.s3.eu-central-1.amazonaws.com/logo-1.png)](https://www.chargetrip.com) diff --git a/src/kdbush.rs b/src/kdbush.rs new file mode 100644 index 0000000..ef4865f --- /dev/null +++ b/src/kdbush.rs @@ -0,0 +1,513 @@ +/// Array of coordinates with longitude as first value and latitude as second one +type Point = [f64; 2]; + +/// A very fast static spatial index for 2D points based on a flat KD-tree +#[derive(Debug)] +pub struct KDBush { + /// Node size for the KD-tree. Determines the number of points in a leaf node + pub node_size: usize, + + /// A list of point IDs used to reference points in the KD-tree + pub ids: Vec, + + /// A flat array containing the X and Y coordinates of all points in interleaved order + pub coords: Vec, + + /// A list of 2D points represented as an array of [longitude, latitude] coordinates + pub points: Vec, + + /// A list of additional data associated with the points (e.g., properties) + pub data: Vec, +} + +impl KDBush { + /// Create a new KDBush index with the specified node size and the size hint for allocating memory + pub fn new(size_hint: usize, node_size: usize) -> Self { + KDBush { + node_size, + ids: Vec::with_capacity(size_hint), + points: Vec::with_capacity(size_hint), + coords: Vec::with_capacity(size_hint), + data: Vec::with_capacity(size_hint), + } + } + + /// Add a 2D point to the KDBush index + pub fn add_point(&mut self, x: f64, y: f64) { + self.points.push([x, y]); + } + + /// Build the KD-tree index from the added points + pub fn build_index(&mut self) { + self.coords = vec![0.0; 2 * self.points.len()]; + + for (i, point) in self.points.iter().enumerate() { + self.ids.push(i); + + self.coords[i * 2] = point[0]; + self.coords[i * 2 + 1] = point[1]; + } + + self.sort(0, self.ids.len() - 1, 0); + } + + /// Find all point indices within the specified bounding box defined by minimum and maximum coordinates + pub fn range(&self, min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> Vec { + let mut stack = vec![(0, self.ids.len() - 1, 0)]; + let mut result: Vec = Vec::new(); + let mut x: f64; + let mut y: f64; + + while let Some((axis, right, left)) = stack.pop() { + if right - left <= self.node_size { + for i in left..=right { + x = self.coords[i * 2]; + y = self.coords[i * 2 + 1]; + + if x >= min_x && x <= max_x && y >= min_y && y <= max_y { + result.push(self.ids[i]); + } + } + continue; + } + + let m = (left + right) >> 1; + x = self.coords[m * 2]; + y = self.coords[m * 2 + 1]; + + if x >= min_x && x <= max_x && y >= min_y && y <= max_y { + result.push(self.ids[m]); + } + + let next_axis = (axis + 1) % 2; + + if (axis == 0 && min_x <= x) || (axis != 0 && min_y <= y) { + stack.push((next_axis, m - 1, left)); + } + + if (axis == 0 && max_x >= x) || (axis != 0 && max_y >= y) { + stack.push((next_axis, right, m + 1)); + } + } + + result + } + + /// Find all point indices within a given radius from a query point specified by coordinates + pub fn within(&self, qx: f64, qy: f64, radius: f64) -> Vec { + let mut stack = vec![(0, self.ids.len() - 1, 0)]; + let mut result: Vec = Vec::new(); + let r2 = radius * radius; + + while let Some((axis, right, left)) = stack.pop() { + if right - left <= self.node_size { + for i in left..=right { + let x = self.coords[i * 2]; + let y = self.coords[i * 2 + 1]; + let dst = KDBush::sq_dist(x, y, qx, qy); + + if dst <= r2 { + result.push(self.ids[i]); + } + } + + continue; + } + + let m = (left + right) >> 1; + let x = self.coords[m * 2]; + let y = self.coords[m * 2 + 1]; + + if KDBush::sq_dist(x, y, qx, qy) <= r2 { + result.push(self.ids[m]); + } + + let next_axis = (axis + 1) % 2; + + if (axis == 0 && qx - radius <= x) || (axis != 0 && qy - radius <= y) { + stack.push((next_axis, m - 1, left)); + } + + if (axis == 0 && qx + radius >= x) || (axis != 0 && qy + radius >= y) { + stack.push((next_axis, right, m + 1)); + } + } + + result + } + + /// Sort points in the KD-tree along a specified axis + fn sort(&mut self, left: usize, right: usize, axis: usize) { + if right - left <= self.node_size { + return; + } + + let m = (left + right) >> 1; + + self.select(m, left, right, axis); + + self.sort(left, m - 1, 1 - axis); + self.sort(m + 1, right, 1 - axis); + } + + /// Select the k-th element along a specified axis within a range of indices + fn select(&mut self, k: usize, left: usize, right: usize, axis: usize) { + let mut left = left; + let mut right = right; + + while right > left { + if right - left > 600 { + let n = right - left + 1; + let m = k - left + 1; + let z = (n as f64).ln(); + let s = 0.5 * ((2.0 * z) / 3.0).exp(); + let sds = if (m as f64) - (n as f64) / 2.0 < 0.0 { -1.0 } else { 1.0 }; + let n_s = (n as f64) - s; + let sd = 0.5 * ((z * s * n_s) / (n as f64)).sqrt() * sds; + let new_left = KDBush::get_max( + left, + ((k as f64) - ((m as f64) * s) / (n as f64) + sd).floor() as usize + ); + let new_right = KDBush::get_min( + right, + ((k as f64) + (((n - m) as f64) * s) / (n as f64) + sd).floor() as usize + ); + + self.select(k, new_left, new_right, axis); + } + + let t = self.coords[2 * k + axis]; + let mut i = left; + let mut j = right; + + self.swap_item(left, k); + + if self.coords[2 * right + axis] > t { + self.swap_item(left, right); + } + + while i < j { + self.swap_item(i, j); + + i += 1; + j -= 1; + + while self.coords[2 * i + axis] < t { + i += 1; + } + + while self.coords[2 * j + axis] > t { + j -= 1; + } + } + + if self.coords[2 * left + axis] == t { + self.swap_item(left, j); + } else { + j += 1; + self.swap_item(j, right); + } + + if j <= k { + left = j + 1; + } + if k <= j { + right = j - 1; + } + } + } + + /// Return the maximum of two values + fn get_max(a: usize, b: usize) -> usize { + if a > b { a } else { b } + } + + /// Return the minimum of two values + fn get_min(a: usize, b: usize) -> usize { + if a < b { a } else { b } + } + + /// Swap the elements at two specified indices in the KD-tree data structures + fn swap_item(&mut self, i: usize, j: usize) { + self.ids.swap(i, j); + + self.coords.swap(2 * i, 2 * j); + self.coords.swap(2 * i + 1, 2 * j + 1); + } + + /// Compute the square of the Euclidean distance between two points in a 2D space + fn sq_dist(ax: f64, ay: f64, bx: f64, by: f64) -> f64 { + let dx = ax - bx; + let dy = ay - by; + + dx * dx + dy * dy + } +} + +#[cfg(test)] +mod tests { + use super::*; + + pub const POINTS: [[f64; 2]; 100] = [ + [54.0, 1.0], + [97.0, 21.0], + [65.0, 35.0], + [33.0, 54.0], + [95.0, 39.0], + [54.0, 3.0], + [53.0, 54.0], + [84.0, 72.0], + [33.0, 34.0], + [43.0, 15.0], + [52.0, 83.0], + [81.0, 23.0], + [1.0, 61.0], + [38.0, 74.0], + [11.0, 91.0], + [24.0, 56.0], + [90.0, 31.0], + [25.0, 57.0], + [46.0, 61.0], + [29.0, 69.0], + [49.0, 60.0], + [4.0, 98.0], + [71.0, 15.0], + [60.0, 25.0], + [38.0, 84.0], + [52.0, 38.0], + [94.0, 51.0], + [13.0, 25.0], + [77.0, 73.0], + [88.0, 87.0], + [6.0, 27.0], + [58.0, 22.0], + [53.0, 28.0], + [27.0, 91.0], + [96.0, 98.0], + [93.0, 14.0], + [22.0, 93.0], + [45.0, 94.0], + [18.0, 28.0], + [35.0, 15.0], + [19.0, 81.0], + [20.0, 81.0], + [67.0, 53.0], + [43.0, 3.0], + [47.0, 66.0], + [48.0, 34.0], + [46.0, 12.0], + [32.0, 38.0], + [43.0, 12.0], + [39.0, 94.0], + [88.0, 62.0], + [66.0, 14.0], + [84.0, 30.0], + [72.0, 81.0], + [41.0, 92.0], + [26.0, 4.0], + [6.0, 76.0], + [47.0, 21.0], + [57.0, 70.0], + [71.0, 82.0], + [50.0, 68.0], + [96.0, 18.0], + [40.0, 31.0], + [78.0, 53.0], + [71.0, 90.0], + [32.0, 14.0], + [55.0, 6.0], + [32.0, 88.0], + [62.0, 32.0], + [21.0, 67.0], + [73.0, 81.0], + [44.0, 64.0], + [29.0, 50.0], + [70.0, 5.0], + [6.0, 22.0], + [68.0, 3.0], + [11.0, 23.0], + [20.0, 42.0], + [21.0, 73.0], + [63.0, 86.0], + [9.0, 40.0], + [99.0, 2.0], + [99.0, 76.0], + [56.0, 77.0], + [83.0, 6.0], + [21.0, 72.0], + [78.0, 30.0], + [75.0, 53.0], + [41.0, 11.0], + [95.0, 20.0], + [30.0, 38.0], + [96.0, 82.0], + [65.0, 48.0], + [33.0, 18.0], + [87.0, 28.0], + [10.0, 10.0], + [40.0, 34.0], + [10.0, 20.0], + [47.0, 29.0], + [46.0, 78.0], + ]; + + pub const IDS: [usize; 100] = [ + 97, 74, 95, 30, 77, 38, 76, 27, 80, 55, 72, 90, 88, 48, 43, 46, 65, 39, 62, 93, 9, 96, 47, 8, + 3, 12, 15, 14, 21, 41, 36, 40, 69, 56, 85, 78, 17, 71, 44, 19, 18, 13, 99, 24, 67, 33, 37, 49, + 54, 57, 98, 45, 23, 31, 66, 68, 0, 32, 5, 51, 75, 73, 84, 35, 81, 22, 61, 89, 1, 11, 86, 52, + 94, 16, 2, 6, 25, 92, 42, 20, 60, 58, 83, 79, 64, 10, 59, 53, 26, 87, 4, 63, 50, 7, 28, 82, + 70, 29, 34, 91, + ]; + + pub const COORDS: [f64; 200] = [ + 10.0, 20.0, 6.0, 22.0, 10.0, 10.0, 6.0, 27.0, 20.0, 42.0, 18.0, 28.0, 11.0, 23.0, 13.0, 25.0, + 9.0, 40.0, 26.0, 4.0, 29.0, 50.0, 30.0, 38.0, 41.0, 11.0, 43.0, 12.0, 43.0, 3.0, 46.0, 12.0, + 32.0, 14.0, 35.0, 15.0, 40.0, 31.0, 33.0, 18.0, 43.0, 15.0, 40.0, 34.0, 32.0, 38.0, 33.0, 34.0, + 33.0, 54.0, 1.0, 61.0, 24.0, 56.0, 11.0, 91.0, 4.0, 98.0, 20.0, 81.0, 22.0, 93.0, 19.0, 81.0, + 21.0, 67.0, 6.0, 76.0, 21.0, 72.0, 21.0, 73.0, 25.0, 57.0, 44.0, 64.0, 47.0, 66.0, 29.0, 69.0, + 46.0, 61.0, 38.0, 74.0, 46.0, 78.0, 38.0, 84.0, 32.0, 88.0, 27.0, 91.0, 45.0, 94.0, 39.0, 94.0, + 41.0, 92.0, 47.0, 21.0, 47.0, 29.0, 48.0, 34.0, 60.0, 25.0, 58.0, 22.0, 55.0, 6.0, 62.0, 32.0, + 54.0, 1.0, 53.0, 28.0, 54.0, 3.0, 66.0, 14.0, 68.0, 3.0, 70.0, 5.0, 83.0, 6.0, 93.0, 14.0, + 99.0, 2.0, 71.0, 15.0, 96.0, 18.0, 95.0, 20.0, 97.0, 21.0, 81.0, 23.0, 78.0, 30.0, 84.0, 30.0, + 87.0, 28.0, 90.0, 31.0, 65.0, 35.0, 53.0, 54.0, 52.0, 38.0, 65.0, 48.0, 67.0, 53.0, 49.0, 60.0, + 50.0, 68.0, 57.0, 70.0, 56.0, 77.0, 63.0, 86.0, 71.0, 90.0, 52.0, 83.0, 71.0, 82.0, 72.0, 81.0, + 94.0, 51.0, 75.0, 53.0, 95.0, 39.0, 78.0, 53.0, 88.0, 62.0, 84.0, 72.0, 77.0, 73.0, 99.0, 76.0, + 73.0, 81.0, 88.0, 87.0, 96.0, 98.0, 96.0, 82.0, + ]; + + #[test] + fn test_build_index() { + let mut index = KDBush::new(POINTS.len(), 10); + + for point in POINTS.iter() { + index.add_point(point[0], point[1]); + } + + index.build_index(); + + assert_eq!(index.node_size, 10); + assert!(!index.points.is_empty()); + + let expected_ids: Vec = IDS.to_vec(); + let expected_coords: Vec = COORDS.to_vec(); + + assert_eq!(index.ids, expected_ids); + assert_eq!(index.coords, expected_coords) + } + + #[test] + fn test_range() { + let mut index = KDBush::new(POINTS.len(), 10); + + for point in POINTS.iter() { + index.add_point(point[0], point[1]); + } + + index.build_index(); + + let result = index.range(20.0, 30.0, 50.0, 70.0); + let expected_ids = vec![ + 60, + 20, + 45, + 3, + 17, + 71, + 44, + 19, + 18, + 15, + 69, + 90, + 62, + 96, + 47, + 8, + 77, + 72 + ]; + + assert_eq!(result, expected_ids); + + for &i in &result { + let p = POINTS[i]; + + if p[0] < 20.0 || p[0] > 50.0 || p[1] < 30.0 || p[1] > 70.0 { + panic!(); + } + } + + for (i, p) in POINTS.iter().enumerate() { + if !(result.contains(&i) || p[0] < 20.0 || p[0] > 50.0 || p[1] < 30.0 || p[1] > 70.0) { + panic!(); + } + } + } + + #[test] + fn test_within() { + let mut index = KDBush::new(POINTS.len(), 10); + + for point in POINTS.iter() { + index.add_point(point[0], point[1]); + } + + index.build_index(); + + let result = index.within(50.0, 50.0, 20.0); + let expected_ids = vec![60, 6, 25, 92, 42, 20, 45, 3, 71, 44, 18, 96]; + + assert_eq!(result, expected_ids); + + let r2 = 20.0 * 20.0; + + for &i in &result { + let p = POINTS[i]; + + if KDBush::sq_dist(p[0], p[1], 50.0, 50.0) > r2 { + panic!(); + } + } + + for (i, p) in POINTS.iter().enumerate() { + if !result.contains(&i) && KDBush::sq_dist(p[0], p[1], 50.0, 50.0) <= r2 { + panic!(); + } + } + } + + #[test] + fn test_sq_dist() { + let result = KDBush::sq_dist(10.0, 10.0, 5.0, 5.0); + + assert_eq!(result, 50.0); + } + + #[test] + fn test_get_max_a_more_than_b() { + let result = KDBush::get_max(10, 5); + + assert_eq!(result, 10); + } + + #[test] + fn test_get_max_b_more_than_a() { + let result = KDBush::get_max(5, 10); + + assert_eq!(result, 10); + } + + #[test] + fn test_get_min_a_less_than_b() { + let result = KDBush::get_min(5, 10); + + assert_eq!(result, 5); + } + + #[test] + fn test_get_min_b_less_than_a() { + let result = KDBush::get_min(10, 5); + + assert_eq!(result, 5); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..6b34ee1 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,792 @@ +#![forbid(unsafe_code)] + +mod kdbush; + +use std::f64::{ consts::PI, INFINITY }; +use serde::{ Deserialize, Serialize }; +use kdbush::KDBush; + +/// An offset index used to access the zoom level value associated with a cluster in the data arrays +const OFFSET_ZOOM: usize = 2; + +/// An offset index used to access the ID associated with a cluster in the data arrays +const OFFSET_ID: usize = 3; + +/// An offset index used to access the identifier of the parent cluster of a point in the data arrays +const OFFSET_PARENT: usize = 4; + +/// An offset index used to access the number of points contained within a cluster at the given zoom level in the data arrays +const OFFSET_NUM: usize = 5; + +/// An offset index used to access the properties associated with a cluster in the data arrays +const OFFSET_PROP: usize = 6; + +/// Supercluster configuration options +#[derive(Debug)] +pub struct Options { + /// Min zoom level to generate clusters on + pub min_zoom: i32, + + /// Max zoom level to cluster the points on + pub max_zoom: i32, + + /// Minimum points to form a cluster + pub min_points: i32, + + /// Cluster radius in pixels + pub radius: f64, + + /// Tile extent (radius is calculated relative to it) + pub extent: f64, + + /// Size of the KD-tree leaf node, affects performance + pub node_size: usize, +} + +/// GeoJSON Point +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Geometry { + /// Point type + #[serde(rename = "type")] + pub r#type: String, + + /// Array of coordinates with longitude as first value and latitude as second one + pub coordinates: Vec, +} + +/// Feature metadata +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Properties { + /// Feature's name + pub name: Option, + + /// Indicates whether the entity is a cluster + pub cluster: Option, + + /// Cluster's unique identifier + pub cluster_id: Option, + + // Number of points within a cluster + pub point_count: Option, + + /// An abbreviated point count, useful for display + pub point_count_abbreviated: Option, +} + +/// A GeoJSON Feature +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Feature { + /// Feature type + #[serde(rename = "type")] + pub r#type: String, + + /// Feature ID + pub id: Option, + + /// Feature metadata + pub properties: Properties, + + /// Geometry of the feature + pub geometry: Option, +} + +/// Collection of GeoJSON features in a specific tile +#[derive(Debug, Deserialize)] +pub struct Tile { + /// GeoJSON features + pub features: Vec, +} + +#[derive(Debug)] +/// A spatial clustering configuration and data structure +pub struct Supercluster { + /// Configuration settings + options: Options, + + /// Vector of KDBush structures for different zoom levels + trees: Vec, + + /// Stride used for data access within the KD-tree + stride: usize, + + /// Input data points + points: Vec, + + /// Clusters metadata + cluster_props: Vec, +} + +impl Supercluster { + /// Create a new Supercluster instance with the specified configuration settings + pub fn new(options: Options) -> Self { + let capacity = options.max_zoom + 1; + let trees: Vec = (0..capacity + 1) + .map(|_| KDBush::new(0, options.node_size)) + .collect(); + + Supercluster { + trees, + options, + stride: 6, + points: vec![], + cluster_props: vec![], + } + } + + /// Load the input GeoJSON points into the Supercluster instance, performing clustering at various zoom levels + pub fn load(&mut self, points: Vec) -> &mut Self { + let min_zoom = self.options.min_zoom; + let max_zoom = self.options.max_zoom; + + self.points = points; + + // Generate a cluster object for each point and index input points into a KD-tree + let mut data = vec![]; + + for (i, p) in self.points.iter().enumerate() { + if p.geometry.is_none() { + continue; + } + + let coordinates = &p.geometry.as_ref().unwrap().coordinates; + + // Store internal point/cluster data in flat numeric arrays for performance + // Longitude + data.push(lng_x(coordinates[0])); + + // Latitude + data.push(lat_y(coordinates[1])); + + // The last zoom the point was processed at + data.push(INFINITY); + + // Index of the source feature in the original input array + data.push(i as f64); + + // Parent cluster id + data.push(-1.0); + + // Number of points in a cluster + data.push(1.0); + } + + self.trees[(max_zoom as usize) + 1] = self.create_tree(data); + + // Cluster points on max zoom, then cluster the results on previous zoom, etc.; + // Results in a cluster hierarchy across zoom levels + for zoom in (min_zoom..=max_zoom).rev() { + // Create a new set of clusters for the zoom and index them with a KD-tree + let (previous, current) = self.cluster(&self.trees[(zoom as usize) + 1], zoom); + + self.trees[(zoom as usize) + 1].data = previous; + self.trees[zoom as usize] = self.create_tree(current); + } + + self + } + + /// Retrieve clustered features within the specified bounding box and zoom level + pub fn get_clusters(&self, bbox: [f64; 4], zoom: i32) -> Vec { + let mut min_lng = ((((bbox[0] + 180.0) % 360.0) + 360.0) % 360.0) - 180.0; + let min_lat = f64::max(-90.0, f64::min(90.0, bbox[1])); + let mut max_lng = if bbox[2] == 180.0 { + 180.0 + } else { + ((((bbox[2] + 180.0) % 360.0) + 360.0) % 360.0) - 180.0 + }; + let max_lat = f64::max(-90.0, f64::min(90.0, bbox[3])); + + if bbox[2] - bbox[0] >= 360.0 { + min_lng = -180.0; + max_lng = 180.0; + } else if min_lng > max_lng { + let eastern_hem = self.get_clusters([min_lng, min_lat, 180.0, max_lat], zoom); + let western_hem = self.get_clusters([-180.0, min_lat, max_lng, max_lat], zoom); + + return eastern_hem.into_iter().chain(western_hem).collect(); + } + + let tree = &self.trees[self.limit_zoom(zoom)]; + let ids = tree.range(lng_x(min_lng), lat_y(max_lat), lng_x(max_lng), lat_y(min_lat)); + let mut clusters = Vec::new(); + + for id in ids { + let k = self.stride * id; + + clusters.push( + if tree.data[k + OFFSET_NUM] > 1.0 { + get_cluster_json(&tree.data, k, &self.cluster_props) + } else { + self.points[tree.data[k + OFFSET_ID] as usize].clone() + } + ); + } + + clusters + } + + /// Retrieve the cluster features for a specified cluster ID + pub fn get_children(&self, cluster_id: usize) -> Result, &'static str> { + let origin_id = self.get_origin_id(cluster_id); + let origin_zoom = self.get_origin_zoom(cluster_id); + let error_msg = "No cluster with the specified id."; + let tree = self.trees.get(origin_zoom); + + if tree.is_none() { + return Err(error_msg); + } + + let tree = tree.expect("tree is not defined"); + let data = &tree.data; + + if origin_id * self.stride >= data.len() { + return Err(error_msg); + } + + let r = + self.options.radius / + (self.options.extent * f64::powf(2.0, (origin_zoom as f64) - 1.0)); + + let x = data[origin_id * self.stride]; + let y = data[origin_id * self.stride + 1]; + + let ids = tree.within(x, y, r); + + let mut children = Vec::new(); + + for id in ids { + let k = id * self.stride; + + if data[k + OFFSET_PARENT] == (cluster_id as f64) { + if data[k + OFFSET_NUM] > 1.0 { + children.push(get_cluster_json(data, k, &self.cluster_props)); + } else { + let point_id = data[k + OFFSET_ID] as usize; + + children.push(self.points[point_id].clone()); + } + } + } + + if children.is_empty() { + return Err(error_msg); + } + + Ok(children) + } + + /// Retrieve individual leaf features within a cluster + pub fn get_leaves(&self, cluster_id: usize, limit: usize, offset: usize) -> Vec { + let mut leaves = vec![]; + + self.append_leaves(&mut leaves, cluster_id, limit, offset, 0); + + leaves + } + + /// Retrieve a vector of features within a tile at the given zoom level and tile coordinates + pub fn get_tile(&self, z: i32, x: f64, y: f64) -> Option { + let tree = &self.trees[self.limit_zoom(z)]; + let z2: f64 = (2u32).pow(z as u32) as f64; + let p = self.options.radius / self.options.extent; + let top = (y - p) / z2; + let bottom = (y + 1.0 + p) / z2; + + let mut tile = Tile { features: vec![] }; + + let ids = tree.range((x - p) / z2, top, (x + 1.0 + p) / z2, bottom); + + self.add_tile_features(&ids, &tree.data, x, y, z2, &mut tile); + + if x == 0.0 { + let ids = tree.range(1.0 - p / z2, top, 1.0, bottom); + + self.add_tile_features(&ids, &tree.data, z2, y, z2, &mut tile); + } + + if x == z2 - 1.0 { + let ids = tree.range(0.0, top, p / z2, bottom); + + self.add_tile_features(&ids, &tree.data, -1.0, y, z2, &mut tile); + } + + if tile.features.is_empty() { + None + } else { + Some(tile) + } + } + + /// Determine the zoom level at which a specific cluster expands + pub fn get_cluster_expansion_zoom(&self, mut cluster_id: usize) -> usize { + let mut expansion_zoom = self.get_origin_zoom(cluster_id) - 1; + + while expansion_zoom <= (self.options.max_zoom as usize) { + let children = if self.get_children(cluster_id).is_ok() { + self.get_children(cluster_id).unwrap() + } else { + break; + }; + + expansion_zoom += 1; + + if children.len() != 1 { + break; + } + + cluster_id = if children[0].properties.cluster_id.is_some() { + children[0].properties.cluster_id.unwrap() + } else { + break; + }; + } + + expansion_zoom + } + + /// Append cluster leaves or individual points to the result vector based on the specified cluster ID and limits + fn append_leaves( + &self, + result: &mut Vec, + cluster_id: usize, + limit: usize, + offset: usize, + mut skipped: usize + ) -> usize { + let cluster = self.get_children(cluster_id).unwrap(); + + for child in cluster { + if child.properties.cluster.is_some() { + if skipped + child.properties.point_count.unwrap() <= offset { + // Skip the whole cluster + skipped += child.properties.point_count.unwrap(); + } else { + // Enter the cluster + skipped = self.append_leaves( + result, + child.properties.cluster_id.unwrap(), + limit, + offset, + skipped + ); + // Exit the cluster + } + } else if skipped < offset { + // Skip a single point + skipped += 1; + } else { + // Add a single point + result.push(child); + } + + if result.len() == limit { + break; + } + } + + skipped + } + + /// Create a KD-tree using the specified data, which is used for spatial indexing + fn create_tree(&mut self, data: Vec) -> KDBush { + let mut tree = KDBush::new(data.len() / self.stride, self.options.node_size); + + for i in (0..data.len()).step_by(self.stride) { + tree.add_point(data[i], data[i + 1]); + } + + tree.build_index(); + tree.data = data; + + tree + } + + /// Populate a tile with features based on the specified point IDs, data, and tile parameters + fn add_tile_features( + &self, + ids: &Vec, + data: &[f64], + x: f64, + y: f64, + z2: f64, + tile: &mut Tile + ) { + for i in ids { + let k = i * self.stride; + let is_cluster = data[k + OFFSET_NUM] > 1.0; + + let px; + let py; + let properties; + + if is_cluster { + properties = get_cluster_properties(data, k, &self.cluster_props); + + px = data[k]; + py = data[k + 1]; + } else { + let p = &self.points[data[k + OFFSET_ID] as usize]; + properties = p.properties.clone(); + + let coordinates = &p.geometry.as_ref().unwrap().coordinates; + px = lng_x(coordinates[0]); + py = lat_y(coordinates[1]); + } + + let id = if is_cluster { + Some(data[k + OFFSET_ID] as usize) + } else { + self.points[data[k + OFFSET_ID] as usize].id + }; + + tile.features.push(Feature { + id, + properties, + r#type: "Feature".to_string(), + geometry: Some(Geometry { + r#type: "Point".to_string(), + coordinates: vec![ + (self.options.extent * (px * z2 - x)).round(), + (self.options.extent * (py * z2 - y)).round() + ], + }), + }); + } + } + + /// Calculate the effective zoom level that takes into account the configured minimum and maximum zoom levels + fn limit_zoom(&self, zoom: i32) -> usize { + zoom.max(self.options.min_zoom).min(self.options.max_zoom + 1) as usize + } + + /// Cluster points on a given zoom level using a KD-tree and returns updated data arrays + fn cluster(&self, tree: &KDBush, zoom: i32) -> (Vec, Vec) { + let r = self.options.radius / (self.options.extent * (2.0_f64).powi(zoom)); + let mut data = tree.data.clone(); + let mut next_data = Vec::new(); + + // Loop through each point + for i in (0..data.len()).step_by(self.stride) { + // If we've already visited the point at this zoom level, skip it + if data[i + OFFSET_ZOOM] <= (zoom as f64) { + continue; + } + + data[i + OFFSET_ZOOM] = zoom as f64; + + // Find all nearby points + let x = data[i]; + let y = data[i + 1]; + + let neighbor_ids = tree.within(x, y, r); + + let num_points_origin = data[i + OFFSET_NUM]; + let mut num_points = num_points_origin; + + // Count the number of points in a potential cluster + for neighbor_id in &neighbor_ids { + let k = neighbor_id * self.stride; + + // Filter out neighbors that are already processed + if data[k + OFFSET_ZOOM] > (zoom as f64) { + num_points += data[k + OFFSET_NUM]; + } + } + + // If there were neighbors to merge, and there are enough points to form a cluster + if num_points > num_points_origin && num_points >= (self.options.min_points as f64) { + let mut wx = x * num_points_origin; + let mut wy = y * num_points_origin; + + // Encode both zoom and point index on which the cluster originated -- offset by total length of features + let id = ((i / self.stride) << 5) + ((zoom as usize) + 1) + self.points.len(); + + for neighbor_id in neighbor_ids { + let k = neighbor_id * self.stride; + + if data[k + OFFSET_ZOOM] <= (zoom as f64) { + continue; + } + + // Save the zoom (so it doesn't get processed twice) + data[k + OFFSET_ZOOM] = zoom as f64; + + let num_points2 = data[k + OFFSET_NUM]; + + // Accumulate coordinates for calculating weighted center + wx += data[k] * num_points2; + wy += data[k + 1] * num_points2; + + data[k + OFFSET_PARENT] = id as f64; + } + + data[i + OFFSET_PARENT] = id as f64; + + next_data.push(wx / num_points); + next_data.push(wy / num_points); + next_data.push(INFINITY); + next_data.push(id as f64); + next_data.push(-1.0); + next_data.push(num_points); + } else { + // Left points as unclustered + for j in 0..self.stride { + next_data.push(data[i + j]); + } + + if num_points > 1.0 { + for neighbor_id in neighbor_ids { + let k = neighbor_id * self.stride; + + if data[k + OFFSET_ZOOM] <= (zoom as f64) { + continue; + } + + data[k + OFFSET_ZOOM] = zoom as f64; + + for j in 0..self.stride { + next_data.push(data[k + j]); + } + } + } + } + } + + (data, next_data) + } + + /// Get index of the point from which the cluster originated + fn get_origin_id(&self, cluster_id: usize) -> usize { + (cluster_id - self.points.len()) >> 5 + } + + /// Get zoom of the point from which the cluster originated + fn get_origin_zoom(&self, cluster_id: usize) -> usize { + (cluster_id - self.points.len()) % 32 + } +} + +/// Convert clustered point data into a GeoJSON feature representing a cluster +fn get_cluster_json(data: &[f64], i: usize, cluster_props: &[Properties]) -> Feature { + Feature { + r#type: "Feature".to_string(), + id: Some(data[i + OFFSET_ID] as usize), + properties: get_cluster_properties(data, i, cluster_props), + geometry: Some(Geometry { + r#type: "Point".to_string(), + coordinates: vec![x_lng(data[i]), y_lat(data[i + 1])], + }), + } +} + +/// Retrieve properties for a cluster based on clustered point data +fn get_cluster_properties(data: &[f64], i: usize, cluster_props: &[Properties]) -> Properties { + let count = data[i + OFFSET_NUM]; + let abbrev = if count >= 10000.0 { + format!("{}k", count / 1000.0) + } else if count >= 1000.0 { + format!("{:}k", count / 100.0 / 10.0) + } else { + count.to_string() + }; + + let mut properties = if !cluster_props.is_empty() && data.get(i + OFFSET_PROP).is_some() { + cluster_props[data[i + OFFSET_PROP] as usize].clone() + } else { + Properties::default() + }; + + properties.cluster = Some(true); + properties.cluster_id = Some(data[i + OFFSET_ID] as usize); + properties.point_count = Some(count as usize); + properties.point_count_abbreviated = Some(abbrev); + + properties +} + +/// Convert longitude to spherical mercator in [0..1] range +fn lng_x(lng: f64) -> f64 { + lng / 360.0 + 0.5 +} + +/// Convert latitude to spherical mercator in [0..1] range +fn lat_y(lat: f64) -> f64 { + let sin = lat.to_radians().sin(); + let y = 0.5 - (0.25 * ((1.0 + sin) / (1.0 - sin)).ln()) / PI; + + if y < 0.0 { + 0.0 + } else if y > 1.0 { + 1.0 + } else { + y + } +} + +/// Convert spherical mercator to longitude +fn x_lng(x: f64) -> f64 { + (x - 0.5) * 360.0 +} + +/// Convert spherical mercator to latitude +fn y_lat(y: f64) -> f64 { + let y2 = ((180.0 - y * 360.0) * PI) / 180.0; + (360.0 * y2.exp().atan()) / PI - 90.0 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn setup() -> Supercluster { + Supercluster::new(Options { + radius: 40.0, + extent: 512.0, + max_zoom: 16, + min_zoom: 0, + min_points: 2, + node_size: 64, + }) + } + + #[test] + fn test_limit_zoom() { + let supercluster = setup(); + + assert_eq!(supercluster.limit_zoom(5), 5); + } + + #[test] + fn test_get_origin_id() { + let supercluster = setup(); + + assert_eq!(supercluster.get_origin_id(100), 3); + } + + #[test] + fn test_get_origin_zoom() { + let supercluster = setup(); + + assert_eq!(supercluster.get_origin_zoom(100), 4); + } + + #[test] + fn test_get_cluster_json_with_cluster_props() { + let data = [0.0, 0.0, 0.0, 0.0, 0.0, 3.0, 0.0]; + let i = 0; + let cluster_props = vec![Properties { + cluster: Some(false), + cluster_id: Some(0), + point_count: Some(0), + name: Some("name".to_string()), + point_count_abbreviated: Some("0".to_string()), + }]; + + let result = get_cluster_json(&data, i, &cluster_props); + + assert_eq!(result.r#type, "Feature".to_string()); + assert_eq!(result.id, Some(0)); + assert_eq!(result.geometry.as_ref().unwrap().r#type, "Point".to_string()); + assert_eq!(result.geometry.unwrap().coordinates, vec![-180.0, 85.05112877980659]); + + let properties = result.properties; + + assert_eq!(properties.cluster, Some(true)); + assert_eq!(properties.cluster_id, Some(0)); + assert_eq!(properties.point_count, Some(3)); + assert_eq!(properties.name, Some("name".to_string())); + assert_eq!(properties.point_count_abbreviated, Some("3".to_string())); + } + + #[test] + fn test_get_cluster_json_without_cluster_props() { + let data = [0.0, 0.0, 0.0, 0.0, 0.0, 3.0, 0.0]; + let i = 0; + let cluster_props = vec![]; + + let result = get_cluster_json(&data, i, &cluster_props); + + assert_eq!(result.id, Some(0)); + assert_eq!(result.r#type, "Feature".to_string()); + assert_eq!(result.geometry.as_ref().unwrap().r#type, "Point".to_string()); + assert_eq!(result.geometry.unwrap().coordinates, vec![-180.0, 85.05112877980659]); + + let properties = result.properties; + + assert!(properties.name.is_none()); + assert_eq!(properties.cluster, Some(true)); + assert_eq!(properties.cluster_id, Some(0)); + assert_eq!(properties.point_count, Some(3)); + assert_eq!(properties.point_count_abbreviated, Some("3".to_string())); + } + + #[test] + fn test_get_cluster_properties_with_cluster_props() { + let data = [0.0, 0.0, 0.0, 0.0, 0.0, 10000.0, 0.0]; + let i = 0; + let cluster_props = vec![Properties { + cluster: Some(false), + cluster_id: Some(0), + point_count: Some(0), + name: Some("name".to_string()), + point_count_abbreviated: Some("0".to_string()), + }]; + + let result = get_cluster_properties(&data, i, &cluster_props); + + assert_eq!(result.cluster, Some(true)); + assert_eq!(result.cluster_id, Some(0)); + assert_eq!(result.point_count, Some(10000)); + assert_eq!(result.name, Some("name".to_string())); + assert_eq!(result.point_count_abbreviated, Some("10k".to_string())); + } + + #[test] + fn test_get_cluster_properties_without_cluster_props() { + let data = [0.0, 0.0, 0.0, 0.0, 0.0, 1000.0, 0.0]; + let i = 0; + let cluster_props = vec![]; + + let result = get_cluster_properties(&data, i, &cluster_props); + + assert!(result.name.is_none()); + assert_eq!(result.cluster, Some(true)); + assert_eq!(result.cluster_id, Some(0)); + assert_eq!(result.point_count, Some(1000)); + assert_eq!(result.point_count_abbreviated, Some("1k".to_string())); + } + + #[test] + fn test_lng_x() { + assert_eq!(lng_x(0.0), 0.5); + assert_eq!(lng_x(180.0), 1.0); + assert_eq!(lng_x(-180.0), 0.0); + assert_eq!(lng_x(90.0), 0.75); + assert_eq!(lng_x(-90.0), 0.25); + } + + #[test] + fn test_lat_y() { + assert_eq!(lat_y(0.0), 0.5); + assert_eq!(lat_y(90.0), 0.0); + assert_eq!(lat_y(-90.0), 1.0); + assert_eq!(lat_y(45.0), 0.35972503691520497); + assert_eq!(lat_y(-45.0), 0.640274963084795); + } + + #[test] + fn test_x_lng() { + assert_eq!(x_lng(0.5), 0.0); + assert_eq!(x_lng(1.0), 180.0); + assert_eq!(x_lng(0.0), -180.0); + assert_eq!(x_lng(0.75), 90.0); + assert_eq!(x_lng(0.25), -90.0); + } + + #[test] + fn test_y_lat() { + assert_eq!(y_lat(0.5), 0.0); + assert_eq!(y_lat(0.875), -79.17133464081944); + assert_eq!(y_lat(0.125), 79.17133464081945); + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..d9eb4eb --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,40 @@ +use std::{ path::Path, fs }; +use supercluster::{ Tile, Options, Feature }; + +#[allow(dead_code)] +pub fn get_options(radius: f64, extent: f64, min_points: i32, max_zoom: i32) -> Options { + Options { + radius, + extent, + max_zoom, + min_zoom: 0, + min_points, + node_size: 64, + } +} + +#[allow(dead_code)] +pub fn load_places() -> Vec { + let file_path = Path::new("./tests/common/places.json"); + let json_string = fs::read_to_string(file_path).expect("places.json was not found"); + + serde_json::from_str(&json_string).expect("places.json was not parsed") +} + +#[allow(dead_code)] +pub fn load_tile_places() -> Tile { + let file_path = Path::new("./tests/common/places-tile-0-0-0.json"); + let json_string = fs::read_to_string(file_path).expect("places-tile-0-0-0.json was not found"); + + serde_json::from_str(&json_string).expect("places-tile-0-0-0.json was not parsed") +} + +#[allow(dead_code)] +pub fn load_tile_places_with_min_5() -> Tile { + let file_path = Path::new("./tests/common/places-tile-0-0-0-min-5.json"); + let json_string = fs + ::read_to_string(file_path) + .expect("places-tile-0-0-0-min-5.json was not found"); + + serde_json::from_str(&json_string).expect("places-z0-0-0-min5.json was not parsed") +} diff --git a/tests/common/places-tile-0-0-0-min-5.json b/tests/common/places-tile-0-0-0-min-5.json new file mode 100644 index 0000000..bfc249a --- /dev/null +++ b/tests/common/places-tile-0-0-0-min-5.json @@ -0,0 +1,965 @@ +{ + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 151, + 203 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 164, + "point_count": 15, + "point_count_abbreviated": "15" + }, + "id": 164 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 165, + 241 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 196, + "point_count": 20, + "point_count_abbreviated": "20" + }, + "id": 196 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 178, + 305 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 228, + "point_count": 14, + "point_count_abbreviated": "14" + }, + "id": 228 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 329, + 244 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 260, + "point_count": 10, + "point_count_abbreviated": "10" + }, + "id": 260 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 296, + 291 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 356, + "point_count": 11, + "point_count_abbreviated": "11" + }, + "id": 356 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 90, + 416 + ] + }, + "properties": { + "scalerank": 3, + "name": "Wright I.", + "comment": null, + "name_alt": null, + "lat_y": -50.959168, + "long_x": -72.995002, + "region": "Antarctica", + "subregion": null, + "featureclass": "island" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 74, + 419 + ] + }, + "properties": { + "scalerank": 3, + "name": "Dean I.", + "comment": null, + "name_alt": null, + "lat_y": -50.959168, + "long_x": -72.995002, + "region": "Antarctica", + "subregion": null, + "featureclass": "island" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 69, + 418 + ] + }, + "properties": { + "scalerank": 3, + "name": "Grant I.", + "comment": null, + "name_alt": null, + "lat_y": -50.959168, + "long_x": -72.995002, + "region": "Antarctica", + "subregion": null, + "featureclass": "island" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 49, + 425 + ] + }, + "properties": { + "scalerank": 3, + "name": "Newman I.", + "comment": null, + "name_alt": null, + "lat_y": -50.959168, + "long_x": -72.995002, + "region": "Antarctica", + "subregion": null, + "featureclass": "island" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 89, + 209 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 548, + "point_count": 5, + "point_count_abbreviated": "5" + }, + "id": 548 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 106, + 226 + ] + }, + "properties": { + "scalerank": 4, + "name": "Cabo Corrientes", + "comment": null, + "name_alt": null, + "lat_y": 20.409471, + "long_x": -105.683581, + "region": "North America", + "subregion": null, + "featureclass": "cape" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 123, + 152 + ] + }, + "properties": { + "scalerank": 3, + "name": "Cape Churchill", + "comment": null, + "name_alt": null, + "lat_y": 58.752014, + "long_x": -93.182023, + "region": "North America", + "subregion": null, + "featureclass": "cape" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 160, + 352 + ] + }, + "properties": { + "scalerank": 3, + "name": "Cabo de Hornos", + "comment": null, + "name_alt": "Cape Horn", + "lat_y": -55.862824, + "long_x": -67.169425, + "region": "South America", + "subregion": null, + "featureclass": "cape" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 163, + 349 + ] + }, + "properties": { + "scalerank": 5, + "name": "Cabo San Diego", + "comment": null, + "name_alt": null, + "lat_y": -54.6406, + "long_x": -65.21365, + "region": "South America", + "subregion": null, + "featureclass": "cape" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 242, + 237 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 964, + "point_count": 5, + "point_count_abbreviated": "5" + }, + "id": 964 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 259, + 193 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 1092, + "point_count": 6, + "point_count_abbreviated": "6" + }, + "id": 1092 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 80, + 336 + ] + }, + "properties": { + "scalerank": 3, + "name": "Oceanic pole of inaccessibility", + "comment": null, + "name_alt": null, + "lat_y": -48.865032, + "long_x": -123.401986, + "region": "Seven seas (open ocean)", + "subregion": "South Pacific Ocean", + "featureclass": "pole" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 452, + 377 + ] + }, + "properties": { + "scalerank": 3, + "name": "South Magnetic Pole 2005 (est)", + "comment": null, + "name_alt": null, + "lat_y": -48.865032, + "long_x": -123.401986, + "region": "Antarctica", + "subregion": "Southern Ocean", + "featureclass": "pole" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 93, + 32 + ] + }, + "properties": { + "scalerank": 3, + "name": "North Magnetic Pole 2005 (est)", + "comment": null, + "name_alt": null, + "lat_y": -48.865032, + "long_x": -123.401986, + "region": "Seven seas (open ocean)", + "subregion": "Arctic Ocean", + "featureclass": "pole" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 159, + 84 + ] + }, + "properties": { + "scalerank": 4, + "name": "Cape York", + "comment": null, + "name_alt": null, + "lat_y": 76.218919, + "long_x": -68.218612, + "region": "North America", + "subregion": "Greenland", + "featureclass": "cape" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 194, + 149 + ] + }, + "properties": { + "scalerank": 4, + "name": "Nunap Isua", + "comment": null, + "name_alt": "Cape Farewell", + "lat_y": 59.862583, + "long_x": -43.90088, + "region": "North America", + "subregion": "Greenland", + "featureclass": "cape" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 227, + 139 + ] + }, + "properties": { + "scalerank": 5, + "name": "Surtsey", + "comment": null, + "name_alt": null, + "lat_y": 63.217764, + "long_x": -20.434929, + "region": "Europe", + "subregion": "Iceland", + "featureclass": "island" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 27, + 270 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 1444, + "point_count": 6, + "point_count_abbreviated": "6" + }, + "id": 1444 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 100, + 296 + ] + }, + "properties": { + "scalerank": 4, + "name": "I. de Pascua", + "comment": null, + "name_alt": "Easter I.", + "lat_y": -27.102117, + "long_x": -109.367953, + "region": "Oceania", + "subregion": "Polynesia", + "featureclass": "island" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 401, + 226 + ] + }, + "properties": { + "scalerank": 4, + "name": "Plain of Jars", + "comment": null, + "name_alt": null, + "lat_y": 20.550709, + "long_x": 101.890532, + "region": "Asia", + "subregion": null, + "featureclass": "plain" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 371, + 248 + ] + }, + "properties": { + "scalerank": 5, + "name": "Dondra Head", + "comment": null, + "name_alt": null, + "lat_y": 5.947528, + "long_x": 80.616321, + "region": "Asia", + "subregion": null, + "featureclass": "cape" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 19, + 121 + ] + }, + "properties": { + "scalerank": 4, + "name": "Cape Hope", + "comment": null, + "name_alt": null, + "lat_y": 68.35638, + "long_x": -166.815582, + "region": "North America", + "subregion": null, + "featureclass": "cape" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 33, + 109 + ] + }, + "properties": { + "scalerank": 4, + "name": "Point Barrow", + "comment": null, + "name_alt": null, + "lat_y": 71.372637, + "long_x": -156.615894, + "region": "North America", + "subregion": null, + "featureclass": "cape" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 459, + 309 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 1924, + "point_count": 8, + "point_count_abbreviated": "8" + }, + "id": 1924 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 483, + 272 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 2180, + "point_count": 10, + "point_count_abbreviated": "10" + }, + "id": 2180 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 423, + 295 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 2340, + "point_count": 5, + "point_count_abbreviated": "5" + }, + "id": 2340 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 225, + 114 + ] + }, + "properties": { + "scalerank": 5, + "name": "Cape Brewster", + "comment": null, + "name_alt": null, + "lat_y": 70.150754, + "long_x": -22.122616, + "region": "North America", + "subregion": "Greenland", + "featureclass": "cape" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 230, + 127 + ] + }, + "properties": { + "scalerank": 5, + "name": "Grmsey", + "comment": null, + "name_alt": null, + "lat_y": 66.669359, + "long_x": -18.251096, + "region": "Europe", + "subregion": "Iceland", + "featureclass": "island" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 210, + 21 + ] + }, + "properties": { + "scalerank": 5, + "name": "Cape Morris Jesup", + "comment": null, + "name_alt": null, + "lat_y": 83.626331, + "long_x": -32.491541, + "region": "North America", + "subregion": "Greenland", + "featureclass": "cape" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 238, + 154 + ] + }, + "properties": { + "scalerank": 5, + "name": "Rockall", + "comment": null, + "name_alt": null, + "lat_y": 58.163524, + "long_x": -12.408715, + "region": "Seven seas (open ocean)", + "subregion": "North Atlantic Ocean", + "featureclass": "island" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 484, + 235 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 2692, + "point_count": 13, + "point_count_abbreviated": "13" + }, + "id": 2692 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 471, + 167 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 3236, + "point_count": 6, + "point_count_abbreviated": "6" + }, + "id": 3236 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 498, + 149 + ] + }, + "properties": { + "scalerank": 5, + "name": "Cape Olyutorskiy", + "comment": null, + "name_alt": null, + "lat_y": 59.960807, + "long_x": 170.31265, + "region": "Asia", + "subregion": null, + "featureclass": "cape" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 511, + 142 + ] + }, + "properties": { + "scalerank": 5, + "name": "Cape Navarin", + "comment": null, + "name_alt": null, + "lat_y": 62.327239, + "long_x": 179.074225, + "region": "Asia", + "subregion": null, + "featureclass": "cape" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 469, + 106 + ] + }, + "properties": { + "scalerank": 5, + "name": "Cape Lopatka", + "comment": null, + "name_alt": null, + "lat_y": 71.907853, + "long_x": 150.066042, + "region": "Asia", + "subregion": null, + "featureclass": "cape" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 292, + 110 + ] + }, + "properties": { + "scalerank": 5, + "name": "Nordkapp", + "comment": null, + "name_alt": null, + "lat_y": 71.18337, + "long_x": 25.662398, + "region": "Europe", + "subregion": null, + "featureclass": "cape" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 205, + 263 + ] + }, + "properties": { + "scalerank": 5, + "name": "Cabo de São Roque", + "comment": null, + "name_alt": null, + "lat_y": -5.193476, + "long_x": -35.447654, + "region": "South America", + "subregion": null, + "featureclass": "cape" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -29, + 272 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 2180, + "point_count": 10, + "point_count_abbreviated": "10" + }, + "id": 2180 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -28, + 235 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 2692, + "point_count": 13, + "point_count_abbreviated": "13" + }, + "id": 2692 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -14, + 149 + ] + }, + "properties": { + "scalerank": 5, + "name": "Cape Olyutorskiy", + "comment": null, + "name_alt": null, + "lat_y": 59.960807, + "long_x": 170.31265, + "region": "Asia", + "subregion": null, + "featureclass": "cape" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -1, + 142 + ] + }, + "properties": { + "scalerank": 5, + "name": "Cape Navarin", + "comment": null, + "name_alt": null, + "lat_y": 62.327239, + "long_x": 179.074225, + "region": "Asia", + "subregion": null, + "featureclass": "cape" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 539, + 270 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 1444, + "point_count": 6, + "point_count_abbreviated": "6" + }, + "id": 1444 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 531, + 121 + ] + }, + "properties": { + "scalerank": 4, + "name": "Cape Hope", + "comment": null, + "name_alt": null, + "lat_y": 68.35638, + "long_x": -166.815582, + "region": "North America", + "subregion": null, + "featureclass": "cape" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 545, + 109 + ] + }, + "properties": { + "scalerank": 4, + "name": "Point Barrow", + "comment": null, + "name_alt": null, + "lat_y": 71.372637, + "long_x": -156.615894, + "region": "North America", + "subregion": null, + "featureclass": "cape" + } + } + ] +} diff --git a/tests/common/places-tile-0-0-0.json b/tests/common/places-tile-0-0-0.json new file mode 100644 index 0000000..159df49 --- /dev/null +++ b/tests/common/places-tile-0-0-0.json @@ -0,0 +1,725 @@ +{ + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 150, + 205 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 164, + "point_count": 16, + "point_count_abbreviated": "16" + }, + "id": 164 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 165, + 240 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 196, + "point_count": 18, + "point_count_abbreviated": "18" + }, + "id": 196 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 179, + 303 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 228, + "point_count": 13, + "point_count_abbreviated": "13" + }, + "id": 228 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 336, + 234 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 260, + "point_count": 8, + "point_count_abbreviated": "8" + }, + "id": 260 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 299, + 285 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 292, + "point_count": 15, + "point_count_abbreviated": "15" + }, + "id": 292 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 71, + 419 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 324, + "point_count": 4, + "point_count_abbreviated": "4" + }, + "id": 324 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 92, + 212 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 420, + "point_count": 6, + "point_count_abbreviated": "6" + }, + "id": 420 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 123, + 152 + ] + }, + "properties": { + "scalerank": 3, + "name": "Cape Churchill", + "comment": null, + "name_alt": null, + "lat_y": 58.752014, + "long_x": -93.182023, + "region": "North America", + "subregion": null, + "featureclass": "cape" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 162, + 345 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 581, + "point_count": 3, + "point_count_abbreviated": "3" + }, + "id": 581 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 236, + 232 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 580, + "point_count": 4, + "point_count_abbreviated": "4" + }, + "id": 580 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 259, + 193 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 644, + "point_count": 6, + "point_count_abbreviated": "6" + }, + "id": 644 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 80, + 336 + ] + }, + "properties": { + "scalerank": 3, + "name": "Oceanic pole of inaccessibility", + "comment": null, + "name_alt": null, + "lat_y": -48.865032, + "long_x": -123.401986, + "region": "Seven seas (open ocean)", + "subregion": "South Pacific Ocean", + "featureclass": "pole" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 452, + 377 + ] + }, + "properties": { + "scalerank": 3, + "name": "South Magnetic Pole 2005 (est)", + "comment": null, + "name_alt": null, + "lat_y": -48.865032, + "long_x": -123.401986, + "region": "Antarctica", + "subregion": "Southern Ocean", + "featureclass": "pole" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 93, + 32 + ] + }, + "properties": { + "scalerank": 3, + "name": "North Magnetic Pole 2005 (est)", + "comment": null, + "name_alt": null, + "lat_y": -48.865032, + "long_x": -123.401986, + "region": "Seven seas (open ocean)", + "subregion": "Arctic Ocean", + "featureclass": "pole" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 159, + 84 + ] + }, + "properties": { + "scalerank": 4, + "name": "Cape York", + "comment": null, + "name_alt": null, + "lat_y": 76.218919, + "long_x": -68.218612, + "region": "North America", + "subregion": "Greenland", + "featureclass": "cape" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 220, + 147 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 836, + "point_count": 3, + "point_count_abbreviated": "3" + }, + "id": 836 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 27, + 270 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 900, + "point_count": 6, + "point_count_abbreviated": "6" + }, + "id": 900 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 100, + 296 + ] + }, + "properties": { + "scalerank": 4, + "name": "I. de Pascua", + "comment": null, + "name_alt": "Easter I.", + "lat_y": -27.102117, + "long_x": -109.367953, + "region": "Oceania", + "subregion": "Polynesia", + "featureclass": "island" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 401, + 226 + ] + }, + "properties": { + "scalerank": 4, + "name": "Plain of Jars", + "comment": null, + "name_alt": null, + "lat_y": 20.550709, + "long_x": 101.890532, + "region": "Asia", + "subregion": null, + "featureclass": "plain" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 26, + 115 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 1157, + "point_count": 2, + "point_count_abbreviated": "2" + }, + "id": 1157 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 449, + 304 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 1124, + "point_count": 13, + "point_count_abbreviated": "13" + }, + "id": 1124 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 455, + 272 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 1188, + "point_count": 5, + "point_count_abbreviated": "5" + }, + "id": 1188 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 227, + 121 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 1701, + "point_count": 2, + "point_count_abbreviated": "2" + }, + "id": 1701 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 210, + 21 + ] + }, + "properties": { + "scalerank": 5, + "name": "Cape Morris Jesup", + "comment": null, + "name_alt": null, + "lat_y": 83.626331, + "long_x": -32.491541, + "region": "North America", + "subregion": "Greenland", + "featureclass": "cape" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 484, + 235 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 1380, + "point_count": 13, + "point_count_abbreviated": "13" + }, + "id": 1380 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 503, + 260 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 1925, + "point_count": 4, + "point_count_abbreviated": "4" + }, + "id": 1925 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 502, + 308 + ] + }, + "properties": { + "scalerank": 5, + "name": "Cape Reinga", + "comment": null, + "name_alt": null, + "lat_y": -34.432767, + "long_x": 172.7285, + "region": "Oceania", + "subregion": "New Zealand", + "featureclass": "cape" + }, + "id": 737 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 475, + 165 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 1668, + "point_count": 7, + "point_count_abbreviated": "7" + }, + "id": 1668 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 511, + 142 + ] + }, + "properties": { + "scalerank": 5, + "name": "Cape Navarin", + "comment": null, + "name_alt": null, + "lat_y": 62.327239, + "long_x": 179.074225, + "region": "Asia", + "subregion": null, + "featureclass": "cape" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 469, + 106 + ] + }, + "properties": { + "scalerank": 5, + "name": "Cape Lopatka", + "comment": null, + "name_alt": null, + "lat_y": 71.907853, + "long_x": 150.066042, + "region": "Asia", + "subregion": null, + "featureclass": "cape" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 292, + 110 + ] + }, + "properties": { + "scalerank": 5, + "name": "Nordkapp", + "comment": null, + "name_alt": null, + "lat_y": 71.18337, + "long_x": 25.662398, + "region": "Europe", + "subregion": null, + "featureclass": "cape" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 202, + 262 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 4134, + "point_count": 2, + "point_count_abbreviated": "2" + }, + "id": 4134 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -28, + 235 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 1380, + "point_count": 13, + "point_count_abbreviated": "13" + }, + "id": 1380 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -9, + 260 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 1925, + "point_count": 4, + "point_count_abbreviated": "4" + }, + "id": 1925 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -10, + 308 + ] + }, + "properties": { + "scalerank": 5, + "name": "Cape Reinga", + "comment": null, + "name_alt": null, + "lat_y": -34.432767, + "long_x": 172.7285, + "region": "Oceania", + "subregion": "New Zealand", + "featureclass": "cape" + }, + "id": 737 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -37, + 165 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 1668, + "point_count": 7, + "point_count_abbreviated": "7" + }, + "id": 1668 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -1, + 142 + ] + }, + "properties": { + "scalerank": 5, + "name": "Cape Navarin", + "comment": null, + "name_alt": null, + "lat_y": 62.327239, + "long_x": 179.074225, + "region": "Asia", + "subregion": null, + "featureclass": "cape" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 539, + 270 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 900, + "point_count": 6, + "point_count_abbreviated": "6" + }, + "id": 900 + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 538, + 115 + ] + }, + "properties": { + "cluster": true, + "cluster_id": 1157, + "point_count": 2, + "point_count_abbreviated": "2" + }, + "id": 1157 + } + ] +} diff --git a/tests/common/places.json b/tests/common/places.json new file mode 100644 index 0000000..bcd58a5 --- /dev/null +++ b/tests/common/places.json @@ -0,0 +1,3412 @@ +[ + { + "type": "Feature", + "properties": { + "scalerank": 2, + "name": "Niagara Falls", + "comment": null, + "name_alt": null, + "lat_y": 43.087653, + "long_x": -79.044073, + "region": "North America", + "subregion": null, + "featureclass": "waterfall" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -79.04411780507252, + 43.08771393436908 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 2, + "name": "Salto Angel", + "comment": null, + "name_alt": "Angel Falls", + "lat_y": 5.686836, + "long_x": -62.061848, + "region": "South America", + "subregion": null, + "featureclass": "waterfall" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -62.06181800038502, + 5.686896063275327 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 2, + "name": "Iguazu Falls", + "comment": null, + "name_alt": null, + "lat_y": -25.568265, + "long_x": -54.582842, + "region": "South America", + "subregion": null, + "featureclass": "waterfall" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -54.58299719960377, + -25.568291925005923 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 3, + "name": "Gees Gwardafuy", + "comment": null, + "name_alt": null, + "lat_y": 11.812855, + "long_x": 51.235173, + "region": "Africa", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 51.258313644180184, + 11.822028799226407 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 3, + "name": "Victoria Falls", + "comment": null, + "name_alt": null, + "lat_y": -17.77079, + "long_x": 25.460133, + "region": "Africa", + "subregion": null, + "featureclass": "waterfall" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 25.852793816021233, + -17.928033135943423 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 3, + "name": "Wright I.", + "comment": null, + "name_alt": null, + "lat_y": -50.959168, + "long_x": -72.995002, + "region": "Antarctica", + "subregion": null, + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -116.89262854726002, + -74.06670501094342 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 3, + "name": "Grant I.", + "comment": null, + "name_alt": null, + "lat_y": -50.959168, + "long_x": -72.995002, + "region": "Antarctica", + "subregion": null, + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -131.48540198476002, + -74.48272063594342 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 3, + "name": "Newman I.", + "comment": null, + "name_alt": null, + "lat_y": -50.959168, + "long_x": -72.995002, + "region": "Antarctica", + "subregion": null, + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -145.68681800038502, + -75.59185149531842 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 3, + "name": "Dean I.", + "comment": null, + "name_alt": null, + "lat_y": -50.959168, + "long_x": -72.995002, + "region": "Antarctica", + "subregion": null, + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -127.63438880116627, + -74.50066497188092 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 3, + "name": "Cape Canaveral", + "comment": null, + "name_alt": null, + "lat_y": 28.483713, + "long_x": -80.534941, + "region": "North America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -80.53625603636821, + 28.473056814472134 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 3, + "name": "Cape Mendocino", + "comment": null, + "name_alt": null, + "lat_y": 40.350222, + "long_x": -124.323474, + "region": "North America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -124.39201745043425, + 40.44222065537283 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 3, + "name": "Cabo San Lucas", + "comment": null, + "name_alt": null, + "lat_y": 22.887711, + "long_x": -109.969843, + "region": "North America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -109.96983801991627, + 22.887762762494077 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 3, + "name": "Cape Churchill", + "comment": null, + "name_alt": null, + "lat_y": 58.752014, + "long_x": -93.182023, + "region": "North America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -93.18211829335377, + 58.75208161015033 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 3, + "name": "Cape Cod", + "comment": null, + "name_alt": null, + "lat_y": 41.734867, + "long_x": -69.964865, + "region": "North America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -70.03687833567446, + 41.9914589934385 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 3, + "name": "Cape May", + "comment": null, + "name_alt": null, + "lat_y": 37.2017, + "long_x": -75.926791, + "region": "North America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -74.95121933164988, + 38.92969645987068 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 3, + "name": "Cabo de Hornos", + "comment": null, + "name_alt": "Cape Horn", + "lat_y": -55.862824, + "long_x": -67.169425, + "region": "South America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -67.16942298085377, + -55.86284758906842 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 3, + "name": "Cape of Good Hope", + "comment": null, + "name_alt": null, + "lat_y": -34.307311, + "long_x": 18.441206, + "region": "Africa", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 18.441294792583733, + -34.30718352656842 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 3, + "name": "Cape Palmas", + "comment": null, + "name_alt": null, + "lat_y": 4.373924, + "long_x": -7.457356, + "region": "Africa", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -7.457386848041267, + 4.373968817181577 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 3, + "name": "Cape Verde", + "comment": null, + "name_alt": null, + "lat_y": 14.732312, + "long_x": -17.471776, + "region": "Africa", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -17.471730109760017, + 14.732489324994077 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 3, + "name": "Cap Bon", + "comment": null, + "name_alt": null, + "lat_y": 37.073954, + "long_x": 11.024061, + "region": "Africa", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 11.024180534771233, + 37.07398102421283 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 3, + "name": "Oceanic pole of inaccessibility", + "comment": null, + "name_alt": null, + "lat_y": -48.865032, + "long_x": -123.401986, + "region": "Seven seas (open ocean)", + "subregion": "South Pacific Ocean", + "featureclass": "pole" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -123.40202796132252, + -48.86504485469342 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 3, + "name": "South Magnetic Pole 2005 (est)", + "comment": null, + "name_alt": null, + "lat_y": -48.865032, + "long_x": -123.401986, + "region": "Antarctica", + "subregion": "Southern Ocean", + "featureclass": "pole" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 137.85425865977123, + -64.51824309688092 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 3, + "name": "North Magnetic Pole 2005 (est)", + "comment": null, + "name_alt": null, + "lat_y": -48.865032, + "long_x": -123.401986, + "region": "Seven seas (open ocean)", + "subregion": "Arctic Ocean", + "featureclass": "pole" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -114.40569007069752, + 82.71008942265033 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 4, + "name": "Lands End", + "comment": null, + "name_alt": null, + "lat_y": 50.069677, + "long_x": -5.668629, + "region": "Europe", + "subregion": "British Isles", + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -5.668629523822517, + 50.06970856327533 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 4, + "name": "Cape York", + "comment": null, + "name_alt": null, + "lat_y": 76.218919, + "long_x": -68.218612, + "region": "North America", + "subregion": "Greenland", + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -68.21861731679127, + 76.21887848515033 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 4, + "name": "Nunap Isua", + "comment": null, + "name_alt": "Cape Farewell", + "lat_y": 59.862583, + "long_x": -43.90088, + "region": "North America", + "subregion": "Greenland", + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -43.90080725819752, + 59.86267731327533 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 4, + "name": "Cape Vohimena", + "comment": null, + "name_alt": null, + "lat_y": -25.546355, + "long_x": 45.158683, + "region": "Africa", + "subregion": "Indian Ocean", + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 45.15870201914623, + -25.546319268755923 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 4, + "name": "Vavau", + "comment": null, + "name_alt": null, + "lat_y": -18.590062, + "long_x": -173.976769, + "region": "Oceania", + "subregion": "Polynesia", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -173.97673499257252, + -18.590020440630923 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 4, + "name": "I. de Pascua", + "comment": null, + "name_alt": "Easter I.", + "lat_y": -27.102117, + "long_x": -109.367953, + "region": "Oceania", + "subregion": "Polynesia", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -109.36790930897877, + -27.102227471880923 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 4, + "name": "Cape Agulhas", + "comment": null, + "name_alt": null, + "lat_y": -34.801182, + "long_x": 19.993472, + "region": "Africa", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 19.993418816021233, + -34.80108001094342 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 4, + "name": "Plain of Jars", + "comment": null, + "name_alt": null, + "lat_y": 20.550709, + "long_x": 101.890532, + "region": "Asia", + "subregion": null, + "featureclass": "plain" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 101.89063561289623, + 20.550909735150327 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 4, + "name": "Cabo Corrientes", + "comment": null, + "name_alt": null, + "lat_y": 20.409471, + "long_x": -105.683581, + "region": "North America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -105.67795873874799, + 20.420365114940253 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 4, + "name": "Pt. Eugenia", + "comment": null, + "name_alt": null, + "lat_y": 27.861925, + "long_x": -115.07629, + "region": "North America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -115.04623945046137, + 27.842887092585283 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 4, + "name": "Point Conception", + "comment": null, + "name_alt": null, + "lat_y": 34.582313, + "long_x": -120.659016, + "region": "North America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -120.46360036202867, + 34.46027592467621 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 4, + "name": "Cape Hatteras", + "comment": null, + "name_alt": null, + "lat_y": 35.437762, + "long_x": -75.450543, + "region": "North America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -75.54032952413311, + 35.24475263812895 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 4, + "name": "Cape Sable", + "comment": null, + "name_alt": null, + "lat_y": 25.124896, + "long_x": -81.090442, + "region": "North America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -81.09044348866627, + 25.124762274212827 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 4, + "name": "Cape Hope", + "comment": null, + "name_alt": null, + "lat_y": 68.35638, + "long_x": -166.815582, + "region": "North America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -166.81321268769543, + 68.35380207543972 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 4, + "name": "Point Barrow", + "comment": null, + "name_alt": null, + "lat_y": 71.372637, + "long_x": -156.615894, + "region": "North America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -156.4719492091668, + 71.40589128763096 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 4, + "name": "Punta Negra", + "comment": null, + "name_alt": null, + "lat_y": -5.948875, + "long_x": -81.108252, + "region": "South America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -81.10832678944752, + -5.948663018755923 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 4, + "name": "Punta Lavapié", + "comment": null, + "name_alt": null, + "lat_y": -37.262867, + "long_x": -73.606377, + "region": "South America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -73.60304396243782, + -37.17120002933805 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 4, + "name": "Punta Galera", + "comment": null, + "name_alt": null, + "lat_y": 0.731221, + "long_x": -80.062205, + "region": "South America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -80.06212317616627, + 0.731207586712827 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 4, + "name": "Cap Lopez", + "comment": null, + "name_alt": null, + "lat_y": -0.604761, + "long_x": 8.726423, + "region": "Africa", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 8.727299789450319, + -0.615086490513119 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 4, + "name": "Cape Bobaomby", + "comment": null, + "name_alt": null, + "lat_y": -11.966598, + "long_x": 49.262904, + "region": "Africa", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 49.26282799570873, + -11.966485284380923 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 4, + "name": "Cap Blanc", + "comment": null, + "name_alt": null, + "lat_y": 20.822108, + "long_x": -17.052856, + "region": "Africa", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -17.052906867572517, + 20.822088934369077 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "South West Cape", + "comment": null, + "name_alt": null, + "lat_y": -43.510984, + "long_x": 146.054227, + "region": "Oceania", + "subregion": "Australia", + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 146.03379804609568, + -43.5404025683706 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape Howe", + "comment": null, + "name_alt": null, + "lat_y": -37.488775, + "long_x": 149.95853, + "region": "Oceania", + "subregion": "Australia", + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 149.95838463633373, + -37.48894622188092 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape Otway", + "comment": null, + "name_alt": null, + "lat_y": -38.857622, + "long_x": 143.565403, + "region": "Oceania", + "subregion": "Australia", + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 143.537005108191, + -38.85472383068997 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape Jaffa", + "comment": null, + "name_alt": null, + "lat_y": -36.944244, + "long_x": 139.690866, + "region": "Oceania", + "subregion": "Australia", + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 139.68061977964746, + -36.95624316107086 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape Carnot", + "comment": null, + "name_alt": null, + "lat_y": -34.920233, + "long_x": 135.656027, + "region": "Oceania", + "subregion": "Australia", + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 135.65378326897053, + -34.93870859313661 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape Byron", + "comment": null, + "name_alt": null, + "lat_y": -28.658282, + "long_x": 153.632849, + "region": "Oceania", + "subregion": "Australia", + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 153.62799176015545, + -28.66197417050363 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape Manifold", + "comment": null, + "name_alt": null, + "lat_y": -22.702081, + "long_x": 150.811228, + "region": "Oceania", + "subregion": "Australia", + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 150.81116783945873, + -22.702080987505923 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape York", + "comment": null, + "name_alt": null, + "lat_y": -10.710859, + "long_x": 142.522018, + "region": "Oceania", + "subregion": "Australia", + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 142.52173912852123, + -10.710747979693423 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape Melville", + "comment": null, + "name_alt": null, + "lat_y": -14.163629, + "long_x": 144.506417, + "region": "Oceania", + "subregion": "Australia", + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 144.50660240977123, + -14.163506768755923 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape Arnhem", + "comment": null, + "name_alt": null, + "lat_y": -12.337984, + "long_x": 136.952429, + "region": "Oceania", + "subregion": "Australia", + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 136.91481885262823, + -12.295662864626316 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "West Cape Howe", + "comment": null, + "name_alt": null, + "lat_y": -35.104301, + "long_x": 117.597011, + "region": "Oceania", + "subregion": "Australia", + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 117.59693444102123, + -35.10430266719342 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape Leeuwin", + "comment": null, + "name_alt": null, + "lat_y": -34.297841, + "long_x": 115.10633, + "region": "Oceania", + "subregion": "Australia", + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 115.1280088910596, + -34.328007092559645 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape Pasley", + "comment": null, + "name_alt": null, + "lat_y": -33.929054, + "long_x": 123.517283, + "region": "Oceania", + "subregion": "Australia", + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 123.51722252695873, + -33.92888762813092 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape Londonderry", + "comment": null, + "name_alt": null, + "lat_y": -13.713856, + "long_x": 126.964514, + "region": "Oceania", + "subregion": "Australia", + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 126.94130045687105, + -13.74290642667802 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Steep Point", + "comment": null, + "name_alt": null, + "lat_y": -26.16822, + "long_x": 113.169959, + "region": "Oceania", + "subregion": "Australia", + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 113.14519563289093, + -26.157463616878637 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "North West Cape", + "comment": null, + "name_alt": null, + "lat_y": -21.809776, + "long_x": 114.117534, + "region": "Oceania", + "subregion": "Australia", + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 114.16010761213809, + -21.801474697071743 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cabo Gracias a Dios", + "comment": null, + "name_alt": null, + "lat_y": 14.994242, + "long_x": -83.15866, + "region": "North America", + "subregion": "Central America", + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -83.15874182851002, + 14.994208074994077 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape Brewster", + "comment": null, + "name_alt": null, + "lat_y": 70.150754, + "long_x": -22.122616, + "region": "North America", + "subregion": "Greenland", + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -22.122731086322517, + 70.15088532108783 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape Morris Jesup", + "comment": null, + "name_alt": null, + "lat_y": 83.626331, + "long_x": -32.491541, + "region": "North America", + "subregion": "Greenland", + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -32.49150550038502, + 83.62628815311908 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Grmsey", + "comment": null, + "name_alt": null, + "lat_y": 66.669359, + "long_x": -18.251096, + "region": "Europe", + "subregion": "Iceland", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -18.251088019916267, + 66.66937897343158 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Surtsey", + "comment": null, + "name_alt": null, + "lat_y": 63.217764, + "long_x": -20.434929, + "region": "Europe", + "subregion": "Iceland", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -20.434803840228767, + 63.21771881718158 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cap Est", + "comment": null, + "name_alt": null, + "lat_y": -15.274849, + "long_x": 50.499889, + "region": "Africa", + "subregion": "Indian Ocean", + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 50.49976647227123, + -15.274956964068423 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape Cretin", + "comment": null, + "name_alt": null, + "lat_y": -6.637492, + "long_x": 147.852392, + "region": "Oceania", + "subregion": "Melanesia", + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 147.85242760508373, + -6.637261651568423 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Îles Chesterfield", + "comment": null, + "name_alt": null, + "lat_y": -19.20447, + "long_x": 159.95171, + "region": "Oceania", + "subregion": "Melanesia", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 159.95167076914623, + -19.204644464068423 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Pagan", + "comment": null, + "name_alt": null, + "lat_y": 18.119631, + "long_x": 145.785087, + "region": "Oceania", + "subregion": "Micronesia", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 145.78492272227123, + 18.119635321087827 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Wake I.", + "comment": null, + "name_alt": null, + "lat_y": 19.303497, + "long_x": 166.63626, + "region": "Oceania", + "subregion": "Micronesia", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 166.63624108164623, + 19.303595282025327 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Tabiteuea", + "comment": null, + "name_alt": null, + "lat_y": -1.201405, + "long_x": 174.755207, + "region": "Oceania", + "subregion": "Micronesia", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 174.75513756602123, + -1.201348565630923 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Aranuka", + "comment": null, + "name_alt": null, + "lat_y": 0.226766, + "long_x": 173.626286, + "region": "Oceania", + "subregion": "Micronesia", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 173.62623131602123, + 0.226752020306577 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Nauru", + "comment": null, + "name_alt": null, + "lat_y": -0.505856, + "long_x": 166.930778, + "region": "Oceania", + "subregion": "Micronesia", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 166.93067467539623, + -0.505791925005923 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Ebon", + "comment": null, + "name_alt": null, + "lat_y": 4.59977, + "long_x": 168.736432, + "region": "Oceania", + "subregion": "Micronesia", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 168.73633873789623, + 4.599798895306577 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Jaluit", + "comment": null, + "name_alt": null, + "lat_y": 5.964455, + "long_x": 169.682894, + "region": "Oceania", + "subregion": "Micronesia", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 169.68299401133373, + 5.964483953900327 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Mili", + "comment": null, + "name_alt": null, + "lat_y": 6.107334, + "long_x": 171.725875, + "region": "Oceania", + "subregion": "Micronesia", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 171.72584069102123, + 6.107489324994077 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Majuro", + "comment": null, + "name_alt": null, + "lat_y": 7.118009, + "long_x": 171.159743, + "region": "Oceania", + "subregion": "Micronesia", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 171.15980065195873, + 7.117987371869077 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Ailinglapalap", + "comment": null, + "name_alt": null, + "lat_y": 7.276392, + "long_x": 168.596926, + "region": "Oceania", + "subregion": "Micronesia", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 168.59693444102123, + 7.276495672650327 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Kwajalein", + "comment": null, + "name_alt": null, + "lat_y": 8.746619, + "long_x": 167.735072, + "region": "Oceania", + "subregion": "Micronesia", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 167.73511803477123, + 8.746710516400327 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Rongelap", + "comment": null, + "name_alt": null, + "lat_y": 11.164329, + "long_x": 166.869876, + "region": "Oceania", + "subregion": "Micronesia", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 166.86988365977123, + 11.164496160931577 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Bikini", + "comment": null, + "name_alt": null, + "lat_y": 11.639231, + "long_x": 165.550698, + "region": "Oceania", + "subregion": "Micronesia", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 165.55042565195873, + 11.639288641400327 + ] + } + }, + { + "type": "Feature", + "id": 737, + "properties": { + "scalerank": 5, + "name": "Cape Reinga", + "comment": null, + "name_alt": null, + "lat_y": -34.432767, + "long_x": 172.7285, + "region": "Oceania", + "subregion": "New Zealand", + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 172.70558117137455, + -34.42039113947056 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Kanton", + "comment": null, + "name_alt": null, + "lat_y": -2.757106, + "long_x": -171.71703, + "region": "Oceania", + "subregion": "Polynesia", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -171.71703040272877, + -2.757134698443423 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Tabuaeran", + "comment": null, + "name_alt": "Fanning I.", + "lat_y": 3.866545, + "long_x": -159.326781, + "region": "Oceania", + "subregion": "Polynesia", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -159.32683264882252, + 3.866705633587827 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Malden", + "comment": null, + "name_alt": null, + "lat_y": -4.042491, + "long_x": -154.983478, + "region": "Oceania", + "subregion": "Polynesia", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -154.98350989491627, + -4.042657159380923 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Rarotonga", + "comment": null, + "name_alt": null, + "lat_y": -21.201867, + "long_x": -159.797637, + "region": "Oceania", + "subregion": "Polynesia", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -159.79771887929127, + -21.201836846880923 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Rangiroa", + "comment": null, + "name_alt": null, + "lat_y": -15.2046, + "long_x": -147.773967, + "region": "Oceania", + "subregion": "Polynesia", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -147.77403723866627, + -15.204766534380923 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Funafuti", + "comment": null, + "name_alt": null, + "lat_y": -8.491577, + "long_x": 179.19841, + "region": "Oceania", + "subregion": "Polynesia", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 179.19837487070873, + -8.491631768755923 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "St. Croix", + "comment": null, + "name_alt": null, + "lat_y": 17.762944, + "long_x": -64.763088, + "region": "North America", + "subregion": "West Indies", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -64.76317298085377, + 17.763006903119077 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Grand Cayman", + "comment": null, + "name_alt": null, + "lat_y": 19.315829, + "long_x": -81.271416, + "region": "North America", + "subregion": "West Indies", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -81.27159583241627, + 19.315802313275327 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "San Salvador", + "comment": null, + "name_alt": null, + "lat_y": 24.052793, + "long_x": -74.492848, + "region": "North America", + "subregion": "West Indies", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -74.49290930897877, + 24.052801824994077 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Grenada", + "comment": null, + "name_alt": null, + "lat_y": 12.105978, + "long_x": -61.723079, + "region": "North America", + "subregion": "West Indies", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -61.72319495351002, + 12.105963446087827 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Barbuda", + "comment": null, + "name_alt": null, + "lat_y": 17.622525, + "long_x": -61.789243, + "region": "North America", + "subregion": "West Indies", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -61.78929602772877, + 17.622626043744077 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Antigua", + "comment": null, + "name_alt": null, + "lat_y": 17.040441, + "long_x": -61.775982, + "region": "North America", + "subregion": "West Indies", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -61.77592932851002, + 17.040594793744077 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Guadeloupe", + "comment": null, + "name_alt": null, + "lat_y": 16.180583, + "long_x": -61.656947, + "region": "North America", + "subregion": "West Indies", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -61.65703284413502, + 16.180670477337827 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Dominica", + "comment": null, + "name_alt": null, + "lat_y": 15.452943, + "long_x": -61.352652, + "region": "North America", + "subregion": "West Indies", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -61.35271155507252, + 15.452887274212827 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Martinique", + "comment": null, + "name_alt": null, + "lat_y": 14.672462, + "long_x": -61.008715, + "region": "North America", + "subregion": "West Indies", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -61.00883948476002, + 14.672491766400327 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Saint Lucia", + "comment": null, + "name_alt": null, + "lat_y": 13.918332, + "long_x": -60.982225, + "region": "North America", + "subregion": "West Indies", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -60.98222815663502, + 13.918280340619077 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Saint Vincent", + "comment": null, + "name_alt": null, + "lat_y": 13.270131, + "long_x": -61.207143, + "region": "North America", + "subregion": "West Indies", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -61.20720374257252, + 13.270209051556577 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Barbados", + "comment": null, + "name_alt": null, + "lat_y": 13.164326, + "long_x": -59.566742, + "region": "North America", + "subregion": "West Indies", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -59.56682288319752, + 13.164252020306577 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Tobago", + "comment": null, + "name_alt": null, + "lat_y": 11.259334, + "long_x": -60.677992, + "region": "South America", + "subregion": "West Indies", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -60.67808997304127, + 11.259283758587827 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Margarita", + "comment": null, + "name_alt": null, + "lat_y": 10.981467, + "long_x": -64.051401, + "region": "South America", + "subregion": "West Indies", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -64.05144202382252, + 10.981512762494077 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Curaao", + "comment": null, + "name_alt": null, + "lat_y": 12.185355, + "long_x": -68.999109, + "region": "North America", + "subregion": "West Indies", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -68.99919593007252, + 12.185309149212827 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Aruba", + "comment": null, + "name_alt": null, + "lat_y": 12.502849, + "long_x": -69.96488, + "region": "North America", + "subregion": "West Indies", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -69.96501624257252, + 12.502752996869077 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Ra’s Banäs", + "comment": null, + "name_alt": null, + "lat_y": 23.950592, + "long_x": 35.778059, + "region": "Africa", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 35.77808678477123, + 23.950628973431577 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Ponta das Salinas", + "comment": null, + "name_alt": null, + "lat_y": -12.832908, + "long_x": 12.928991, + "region": "Africa", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 12.968705086077254, + -12.855718342716505 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Ponta das Palmeirinhas", + "comment": null, + "name_alt": null, + "lat_y": -9.071387, + "long_x": 12.999549, + "region": "Africa", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 13.033811372274608, + -9.099938228394153 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cabo Bojador", + "comment": null, + "name_alt": null, + "lat_y": 26.157836, + "long_x": -14.473111, + "region": "Africa", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -14.473194953510017, + 26.157965399212827 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape Comorin", + "comment": null, + "name_alt": null, + "lat_y": 8.143554, + "long_x": 77.471497, + "region": "Asia", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 77.51210506924555, + 8.085338515340696 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Dondra Head", + "comment": null, + "name_alt": null, + "lat_y": 5.947528, + "long_x": 80.616321, + "region": "Asia", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 80.59180925571331, + 5.929580617022318 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape Yelizavety", + "comment": null, + "name_alt": null, + "lat_y": 54.416083, + "long_x": 142.720445, + "region": "Asia", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 142.72059166758373, + 54.41620514530658 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Pt. Yuzhnyy", + "comment": null, + "name_alt": null, + "lat_y": 57.733572, + "long_x": 156.796426, + "region": "Asia", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 156.79664147227123, + 57.73346588749408 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape Sata", + "comment": null, + "name_alt": null, + "lat_y": 31.026941, + "long_x": 130.695089, + "region": "Asia", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 130.69520104258373, + 31.026922918744077 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape Aniva", + "comment": null, + "name_alt": null, + "lat_y": 46.081706, + "long_x": 143.43487, + "region": "Asia", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 143.43482506602123, + 46.08179352421283 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape Terpeniya", + "comment": null, + "name_alt": null, + "lat_y": 48.66928, + "long_x": 144.712582, + "region": "Asia", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 144.71253502695873, + 48.66937897343158 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape Lopatka", + "comment": null, + "name_alt": null, + "lat_y": 50.914155, + "long_x": 156.651536, + "region": "Asia", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 156.65162194102123, + 50.91412994999408 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape Ozernoy", + "comment": null, + "name_alt": null, + "lat_y": 57.7708, + "long_x": 163.246685, + "region": "Asia", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 163.24683678477123, + 57.77088043827533 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape Olyutorskiy", + "comment": null, + "name_alt": null, + "lat_y": 59.960807, + "long_x": 170.31265, + "region": "Asia", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 170.31287682383373, + 59.96082184452533 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape Navarin", + "comment": null, + "name_alt": null, + "lat_y": 62.327239, + "long_x": 179.074225, + "region": "Asia", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 179.07422936289623, + 62.32727692265033 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape Lopatka", + "comment": null, + "name_alt": null, + "lat_y": 71.907853, + "long_x": 150.066042, + "region": "Asia", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 150.06592858164623, + 71.90778229374408 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape Ince", + "comment": null, + "name_alt": null, + "lat_y": 42.084312, + "long_x": 34.983358, + "region": "Asia", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 34.98328698008373, + 42.08417389530658 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Ra’s Fartak", + "comment": null, + "name_alt": null, + "lat_y": 15.677412, + "long_x": 52.229105, + "region": "Asia", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 52.2389696999939, + 15.65795249845498 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Ras Sharbatat", + "comment": null, + "name_alt": null, + "lat_y": 18.164534, + "long_x": 56.56827, + "region": "Asia", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 56.558165806017215, + 18.166986171245085 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Ra's al Had", + "comment": null, + "name_alt": null, + "lat_y": 22.530158, + "long_x": 59.849134, + "region": "Asia", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 59.7995168175437, + 22.518675327148298 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Hachijjima", + "comment": null, + "name_alt": null, + "lat_y": 33.109796, + "long_x": 139.804903, + "region": "Asia", + "subregion": null, + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 139.80482018320873, + 33.10980866093158 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Nordkapp", + "comment": null, + "name_alt": null, + "lat_y": 71.18337, + "long_x": 25.662398, + "region": "Europe", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 25.66067519711473, + 71.15450206702127 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cabo de São Vicentete", + "comment": null, + "name_alt": null, + "lat_y": 37.038304, + "long_x": -8.969391, + "region": "Europe", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -8.969410773822517, + 37.03827545780658 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cabo Fisterra", + "comment": null, + "name_alt": null, + "lat_y": 42.952418, + "long_x": -9.267837, + "region": "Europe", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -9.26996282865152, + 42.92873605781255 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape San Blas", + "comment": null, + "name_alt": null, + "lat_y": 29.713967, + "long_x": -85.270961, + "region": "North America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -85.27092444569752, + 29.713995672650327 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape Sable", + "comment": null, + "name_alt": null, + "lat_y": 43.469097, + "long_x": -65.610769, + "region": "North America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -65.61082923085377, + 43.46900055546283 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape Bauld", + "comment": null, + "name_alt": null, + "lat_y": 51.568576, + "long_x": -55.430306, + "region": "North America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -55.43028723866627, + 51.56848786015033 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape Fear", + "comment": null, + "name_alt": null, + "lat_y": 33.867949, + "long_x": -77.990568, + "region": "North America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -77.99058997304127, + 33.86798737186908 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "I. Guadalupe", + "comment": null, + "name_alt": null, + "lat_y": 29.052552, + "long_x": -118.317465, + "region": "Seven seas (open ocean)", + "subregion": "North Pacific Ocean", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -118.31749426991627, + 29.052496649212827 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Miquelon", + "comment": null, + "name_alt": null, + "lat_y": 46.929526, + "long_x": -56.329884, + "region": "North America", + "subregion": null, + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -56.32988440663502, + 46.92938873905658 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "I. Robinson Crusoe", + "comment": null, + "name_alt": null, + "lat_y": -33.589852, + "long_x": -78.872522, + "region": "Seven seas (open ocean)", + "subregion": "South Pacific Ocean", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -78.87254798085377, + -33.58965422969342 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cabo Orange", + "comment": null, + "name_alt": null, + "lat_y": 4.125735, + "long_x": -51.242144, + "region": "South America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -51.26287766987179, + 4.135614177285231 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cabo de Santa Marta Grande", + "comment": null, + "name_alt": null, + "lat_y": -28.558078, + "long_x": -48.735526, + "region": "South America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -48.80338037734664, + -28.57198267323537 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Punta del Este", + "comment": null, + "name_alt": null, + "lat_y": -34.975503, + "long_x": -54.933154, + "region": "South America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -54.94628769070382, + -34.96658679840526 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cabo San Antonio", + "comment": null, + "name_alt": null, + "lat_y": -36.381052, + "long_x": -56.655377, + "region": "South America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -56.716792100626165, + -36.40959917438929 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cabo Corrientes", + "comment": null, + "name_alt": null, + "lat_y": -38.135985, + "long_x": -57.546212, + "region": "South America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -57.56252349612641, + -38.066376942128464 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Punta Rasa", + "comment": null, + "name_alt": null, + "lat_y": -40.834718, + "long_x": -62.282201, + "region": "South America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -62.25911745789756, + -40.72626411656719 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cabo Dos Bahías", + "comment": null, + "name_alt": null, + "lat_y": -44.9887, + "long_x": -65.615952, + "region": "South America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -65.5438334451688, + -44.89439847091873 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cabo Delgado", + "comment": null, + "name_alt": null, + "lat_y": -10.670103, + "long_x": 40.624309, + "region": "Africa", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 40.62440026133373, + -10.670098565630923 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Ponta da Barra", + "comment": null, + "name_alt": null, + "lat_y": -23.829888, + "long_x": 35.515696, + "region": "Africa", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 35.51563561289623, + -23.830010675005923 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Ponta São Sebastio", + "comment": null, + "name_alt": null, + "lat_y": -22.118899, + "long_x": 35.480417, + "region": "Africa", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 35.48023522227123, + -22.118829034380923 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Ras Cantin", + "comment": null, + "name_alt": null, + "lat_y": 32.581636, + "long_x": -9.273918, + "region": "Africa", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -9.273915168353767, + 32.58161041874408 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Ra’s Kasr", + "comment": null, + "name_alt": null, + "lat_y": 18.076817, + "long_x": 38.573746, + "region": "Africa", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 38.58027735871919, + 18.075167704493374 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Ponta de Jericoacoara", + "comment": null, + "name_alt": null, + "lat_y": -2.85044, + "long_x": -40.067208, + "region": "South America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -39.991649927946355, + -2.851822991583529 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cabo de São Roque", + "comment": null, + "name_alt": null, + "lat_y": -5.193476, + "long_x": -35.447654, + "region": "South America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -35.50994900651512, + -5.156866121305913 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Ponta da Baleia", + "comment": null, + "name_alt": null, + "lat_y": -17.710136, + "long_x": -39.157619, + "region": "South America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -39.14557867836578, + -17.678753845220847 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cabo de São Tomé", + "comment": null, + "name_alt": null, + "lat_y": -21.996382, + "long_x": -41.009692, + "region": "South America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -40.98763990313761, + -21.971754611783773 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cabo Frio", + "comment": null, + "name_alt": null, + "lat_y": -22.869501, + "long_x": -41.962188, + "region": "South America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -41.89015627474056, + -22.759730815669258 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cabo San Diego", + "comment": null, + "name_alt": null, + "lat_y": -54.6406, + "long_x": -65.21365, + "region": "South America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -65.21361243397877, + -54.64067962031842 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cabo Tres Puntas", + "comment": null, + "name_alt": null, + "lat_y": -47.237629, + "long_x": -65.774707, + "region": "South America", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -65.74439816328368, + -47.328778975372465 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cap Saint André", + "comment": null, + "name_alt": null, + "lat_y": -16.174457, + "long_x": 44.467405, + "region": "Africa", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 44.46729576914623, + -16.174493096880923 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape St. Lucia", + "comment": null, + "name_alt": null, + "lat_y": -28.552694, + "long_x": 32.367221, + "region": "Africa", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 32.36732018320873, + -28.552666925005923 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Cape St. Francis", + "comment": null, + "name_alt": null, + "lat_y": -34.171766, + "long_x": 24.817688, + "region": "Africa", + "subregion": null, + "featureclass": "cape" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 24.84143613032799, + -34.18861022316314 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Minamitori-shima", + "comment": null, + "name_alt": "Marcus I.", + "lat_y": 24.319813, + "long_x": 153.958899, + "region": "Seven seas (open ocean)", + "subregion": "North Pacific Ocean", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 153.95887291758373, + 24.319769598431577 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Is. Martin Vaz", + "comment": null, + "name_alt": null, + "lat_y": -20.559422, + "long_x": -29.338439, + "region": "Seven seas (open ocean)", + "subregion": "Southern Atlantic Ocean", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -29.338429328510017, + -20.559502862505923 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Rockall", + "comment": null, + "name_alt": null, + "lat_y": 58.163524, + "long_x": -12.408715, + "region": "Seven seas (open ocean)", + "subregion": "North Atlantic Ocean", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -12.408741828510017, + 58.16339752811908 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "I. de Cozumel", + "comment": null, + "name_alt": null, + "lat_y": 20.444687, + "long_x": -86.880555, + "region": "North America", + "subregion": null, + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -86.88060462147877, + 20.444708563275327 + ] + } + }, + { + "type": "Feature", + "properties": { + "scalerank": 5, + "name": "Bermuda Islands", + "comment": null, + "name_alt": null, + "lat_y": 32.317339, + "long_x": -64.742895, + "region": "Seven seas (open ocean)", + "subregion": "North Atlantic Ocean", + "featureclass": "island" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -64.74290930897877, + 32.31726715702533 + ] + } + }, + { + "type": "Feature", + "properties": { + "name": "Null Island" + }, + "geometry": null + } +] diff --git a/tests/supercluster_integration_test.rs b/tests/supercluster_integration_test.rs new file mode 100644 index 0000000..99abbc8 --- /dev/null +++ b/tests/supercluster_integration_test.rs @@ -0,0 +1,163 @@ +mod common; + +use common::{ get_options, load_places, load_tile_places, load_tile_places_with_min_5 }; +use supercluster::{ Supercluster, Feature, Geometry, Properties }; + +#[test] +fn test_generate_clusters() { + let places_tile = load_tile_places(); + + let mut cluster = Supercluster::new(get_options(40.0, 512.0, 2, 16)); + let index = cluster.load(load_places()); + + let tile = index.get_tile(0, 0.0, 0.0).expect("cannot get a tile"); + + assert_eq!(tile.features.len(), places_tile.features.len()); + assert_eq!(tile.features, places_tile.features); +} + +#[test] +fn test_generate_clusters_with_min_points() { + let places_tile = load_tile_places_with_min_5(); + + let mut cluster = Supercluster::new(get_options(40.0, 512.0, 5, 16)); + let index = cluster.load(load_places()); + + let tile = index.get_tile(0, 0.0, 0.0).expect("cannot get a tile"); + + assert_eq!(tile.features.len(), places_tile.features.len()); + assert_eq!(tile.features, places_tile.features); +} + +#[test] +fn test_get_cluster() { + let mut cluster = Supercluster::new(get_options(40.0, 512.0, 2, 16)); + let index = cluster.load(load_places()); + + let cluster_counts: Vec = index + .get_children(164) + .unwrap() + .iter() + .map(|cluster| { cluster.properties.point_count.unwrap_or(1) }) + .collect(); + + // Define the expected cluster counts. + let expected_counts: Vec = vec![6, 7, 2, 1]; + + // Assert that the child counts match the expected counts. + assert_eq!(cluster_counts, expected_counts); +} + +#[test] +fn test_cluster_expansion_zoom() { + let mut cluster = Supercluster::new(get_options(40.0, 512.0, 2, 16)); + let index = cluster.load(load_places()); + + assert_eq!(index.get_cluster_expansion_zoom(164), 1); + assert_eq!(index.get_cluster_expansion_zoom(196), 1); + assert_eq!(index.get_cluster_expansion_zoom(581), 2); + assert_eq!(index.get_cluster_expansion_zoom(1157), 2); + assert_eq!(index.get_cluster_expansion_zoom(4134), 3); +} + +#[test] +fn test_cluster_expansion_zoom_for_max_zoom() { + let mut cluster = Supercluster::new(get_options(60.0, 256.0, 2, 4)); + let index = cluster.load(load_places()); + + assert_eq!(index.get_cluster_expansion_zoom(2504), 5); +} + +#[test] +fn test_get_cluster_leaves() { + let expected_names = vec![ + "Niagara Falls", + "Cape San Blas", + "Cape Sable", + "Cape Canaveral", + "San Salvador", + "Cabo Gracias a Dios", + "I. de Cozumel", + "Grand Cayman", + "Miquelon", + "Cape Bauld" + ]; + + let mut cluster = Supercluster::new(get_options(40.0, 512.0, 2, 16)); + let index = cluster.load(load_places()); + + let leaf_names: Vec = index + .get_leaves(164, 10, 5) + .iter() + .map(|leaf| { leaf.properties.name.clone().unwrap() }) + .collect(); + + assert_eq!(leaf_names.len(), expected_names.len()); + assert_eq!(leaf_names, expected_names); +} + +#[test] +fn test_clusters_when_query_crosses_international_dateline() { + let mut cluster = Supercluster::new(get_options(40.0, 512.0, 2, 16)); + let index = cluster.load( + vec![ + Feature { + id: None, + r#type: "Feature".to_string(), + geometry: Some(Geometry { + r#type: "Point".to_string(), + coordinates: vec![-178.989, 0.0], + }), + properties: Properties::default(), + }, + Feature { + id: None, + r#type: "Feature".to_string(), + geometry: Some(Geometry { + r#type: "Point".to_string(), + coordinates: vec![-178.99, 0.0], + }), + properties: Properties::default(), + }, + Feature { + id: None, + r#type: "Feature".to_string(), + geometry: Some(Geometry { + r#type: "Point".to_string(), + coordinates: vec![-178.991, 0.0], + }), + properties: Properties::default(), + }, + Feature { + id: None, + r#type: "Feature".to_string(), + geometry: Some(Geometry { + r#type: "Point".to_string(), + coordinates: vec![-178.992, 0.0], + }), + properties: Properties::default(), + } + ] + ); + + let non_crossing = index.get_clusters([-179.0, -10.0, -177.0, 10.0], 1); + let crossing = index.get_clusters([179.0, -10.0, -177.0, 10.0], 1); + + assert!(!crossing.is_empty()); + assert!(!non_crossing.is_empty()); + assert_eq!(non_crossing.len(), crossing.len()); +} + +#[test] +fn test_does_not_crash_on_weird_bbox_values() { + let mut cluster = Supercluster::new(get_options(40.0, 512.0, 2, 16)); + let index = cluster.load(load_places()); + + assert_eq!(index.get_clusters([129.42639, -103.720017, -445.930843, 114.518236], 1).len(), 26); + assert_eq!(index.get_clusters([112.207836, -84.578666, -463.149397, 120.169159], 1).len(), 27); + assert_eq!(index.get_clusters([129.886277, -82.33268, -445.470956, 120.39093], 1).len(), 26); + assert_eq!(index.get_clusters([458.220043, -84.239039, -117.13719, 120.206585], 1).len(), 25); + assert_eq!(index.get_clusters([456.713058, -80.354196, -118.644175, 120.539148], 1).len(), 25); + assert_eq!(index.get_clusters([453.105328, -75.857422, -122.251904, 120.73276], 1).len(), 25); + assert_eq!(index.get_clusters([-180.0, -90.0, 180.0, 90.0], 1).len(), 61); +}