diff --git a/README.md b/README.md index 44f8b720..9370fd35 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,15 @@ # mutable-supercluster [![Test](https://github.com/SegmentationFaults0/mutable_supercluster/actions/workflows/test.yml/badge.svg)](https://github.com/SegmentationFaults0/mutable_supercluster/actions/workflows/test.yml) ![NPM Version](https://img.shields.io/npm/v/mutable-supercluster) -_This repository is a fork from [mapbox/supercluster](https://github.com/mapbox/supercluster)._ +_This repository is a fork from [mapbox/supercluster](https://github.com/mapbox/supercluster), further inspired by [rorystephenson/supercluster_dart](https://github.com/rorystephenson/supercluster_dart.git)._ A Node.js library for fast and mutable geospatial point clustering. ```js -const index = new Supercluster({ radius: 40, maxZoom: 16 }); +const index = new Supercluster({ + radius: 40, + maxZoom: 16, + getId: (point) => point.id, +}); index.load(points); const clusters = index.getClusters([-180, -85, 180, 85], 2); @@ -58,6 +62,14 @@ Returns the zoom on which the cluster expands into several children (useful for Updates the point in the cluster with the same `id` (according to `getId`), with the given `properties`. If the given `id` is not present in the cluster, this method will throw an error. The location of the point (`point.geometry.coordinates`) can not be updated through this method. +#### `addPoint(point)` + +Adds the given `point` to the cluster. Just like `load()`, the given point must be a [GeoJSON Feature](https://tools.ietf.org/html/rfc7946#section-3.2) with its `geometry` a [GeoJSON Point](https://tools.ietf.org/html/rfc7946#section-3.1.2). + +#### `removePoint(id)` + +Removes the point with the same `id` (according to `getId`) from the cluster. + ## Options | Option | Default | Description | diff --git a/demo/index.html b/demo/index.html index f8f3908a..f77cf940 100644 --- a/demo/index.html +++ b/demo/index.html @@ -22,13 +22,15 @@ html, body, #map { - height: 100%; + height: 95%; margin: 0; }
+ + diff --git a/demo/index.js b/demo/index.js index 46170705..2fa50efd 100644 --- a/demo/index.js +++ b/demo/index.js @@ -64,3 +64,11 @@ markers.on("click", (e) => { }); } }); + +function addPoint() { + worker.postMessage({ addPoint: true }); +} + +function removePoint() { + worker.postMessage({ removePoint: true }); +} diff --git a/demo/worker.js b/demo/worker.js index 035baf72..d4e5467d 100644 --- a/demo/worker.js +++ b/demo/worker.js @@ -1,22 +1,23 @@ /*global importScripts Supercluster */ -importScripts("../dist/supercluster.js"); +importScripts("../dist/mutable-supercluster.js"); const now = Date.now(); +let geojson; let index; +let addCounter = 10; +let removeCounter = 0; -getJSON("../test/fixtures/places.json", (geojson) => { +getJSON("../test/fixtures/places.json", () => { console.log( `loaded ${geojson.features.length} points JSON in ${(Date.now() - now) / 1000}s`, ); index = new Supercluster({ log: true, - radius: 60, - extent: 256, - maxZoom: 17, - }).load(geojson.features); + getId: (point) => point.pointId, + }).load(geojson.features.slice(0, 10)); console.log(index.getTile(0, 0, 0)); @@ -31,6 +32,18 @@ self.onmessage = function (e) { ), center: e.data.center, }); + } else if (e.data.addPoint) { + if (addCounter < geojson.features.length) { + index.addPoint(geojson.features[addCounter]); + addCounter++; + } + postMessage({ ready: true }); + } else if (e.data.removePoint) { + if (removeCounter < addCounter) { + index.removePoint(removeCounter); + removeCounter++; + } + postMessage({ ready: true }); } else if (e.data) { postMessage(index.getClusters(e.data.bbox, e.data.zoom)); } @@ -48,7 +61,8 @@ function getJSON(url, callback) { xhr.status < 300 && xhr.response ) { - callback(xhr.response); + geojson = xhr.response; + callback(); } }; xhr.send(); diff --git a/index.js b/index.js index 3fd1cfcc..87759212 100644 --- a/index.js +++ b/index.js @@ -54,8 +54,6 @@ export default class Supercluster { this.options = Object.assign(Object.create(defaultOptions), options); if (!this.options.getId) throw new Error("The Id access function (options.getId) can not be null"); - this.trees = new Array(this.options.maxZoom + 1); - this.clusterData = new Array(this.options.maxZoom + 1); this.stride = this.options.reduce ? 7 : 6; this.clusterProps = []; this.getId = this.options.getId; @@ -69,11 +67,14 @@ export default class Supercluster { const timerId = `prepare ${points.length} points`; if (log) console.time(timerId); + this.trees = new Array(maxZoom + 2); + this.clusterData = Array.from({ length: maxZoom + 2 }, () => new Array()); + this.emptyIndices = Array.from({ length: maxZoom + 2 }, () => new Array()); this.points = structuredClone(points); + this.emptyPointIndices = new Array(); points.length = 0; // generate a cluster object for each point and index input points into a R-tree - const currentClusterData = []; const currentIndexData = []; for (let i = 0; i < this.points.length; i++) { @@ -84,22 +85,22 @@ export default class Supercluster { const x = fround(lngX(lng)); const y = fround(latY(lat)); // store internal point/cluster data in flat numeric arrays for performance - currentClusterData.push( + const currentNodeData = [ x, y, // projected point coordinates Infinity, // the last zoom the point was processed at i, // index of the source feature in the original input array - -1, // parent cluster id + null, // parent cluster id 1, // number of points in a cluster - ); - if (this.options.reduce) currentClusterData.push(0); // noop + ]; + if (this.options.reduce) currentNodeData.push(0); // noop + const idx = this._addNodeToTree(maxZoom + 1, currentNodeData); // populate indexData-array because R-Tree needs an array of separate items. // TODO: possible optimization is forking RBush repo and change this to be more like KDBush? - currentIndexData.push([x, y, i]); + currentIndexData.push([x, y, idx]); } this.trees[maxZoom + 1] = this._createTree(currentIndexData); - this.clusterData[maxZoom + 1] = currentClusterData; if (log) console.timeEnd(timerId); @@ -164,8 +165,8 @@ export default class Supercluster { } getChildren(clusterId) { - const originId = this._getOriginId(clusterId); - const originZoom = this._getOriginZoom(clusterId); + const originId = getOriginIdx(clusterId); + const originZoom = getOriginZoom(clusterId); const errorMsg = "No cluster with the specified id."; if (!this.trees[originZoom]) throw new Error(errorMsg); @@ -173,9 +174,7 @@ export default class Supercluster { const data = this.clusterData[originZoom]; if (originId * this.stride >= data.length) throw new Error(errorMsg); - const r = - this.options.radius / - (this.options.extent * Math.pow(this.options.zoomFactor, originZoom - 1)); + const r = this._calculateRadius(originZoom - 1); const x = data[originId * this.stride]; const y = data[originId * this.stride + 1]; const ids = this._rbushWithin(x, y, originZoom, r); @@ -252,7 +251,7 @@ export default class Supercluster { } getClusterExpansionZoom(clusterId) { - let expansionZoom = this._getOriginZoom(clusterId) - 1; + let expansionZoom = getOriginZoom(clusterId) - 1; while (expansionZoom <= this.options.maxZoom) { const children = this.getChildren(clusterId); expansionZoom++; @@ -264,12 +263,77 @@ export default class Supercluster { updatePointProperties(id, properties) { const idx = this._linearSearchInPoints(id); - if (!idx) throw new Error("No point with the given id could be found."); + if (idx === null) + throw new Error("No point with the given id could be found."); + const clonedProperties = structuredClone(properties); delete clonedProperties.geometry?.coordinates; lodashMerge(this.points[idx], clonedProperties); } + addPoint(point) { + const { minZoom, maxZoom, reduce, minPoints } = this.options; + const p = structuredClone(point); + const pointIdx = this._addPointToList(p); + if (!p.geometry) return; + const [lng, lat] = p.geometry.coordinates; + const x = fround(lngX(lng)); + const y = fround(latY(lat)); + const newNodeData = [ + x, + y, // projected point coordinates + Infinity, // the last zoom the point was processed at + pointIdx, // index of the source feature in the original input array + null, // parent cluster id + 1, // number of points in a cluster + ]; + if (reduce) newNodeData.push(0); + let idx = this._addNodeToTree(maxZoom + 1, newNodeData); + this.trees[maxZoom + 1].insert([x, y, idx]); + + for (let z = maxZoom; z >= minZoom; z--) { + const neighborIdxs = this._rbushWithin( + x, + y, + z + 1, + this._calculateRadius(z), + ); + if (neighborIdxs.length >= minPoints) { + this._recluster(z, neighborIdxs); + return; + } + this.clusterData[z + 1][idx * this.stride + OFFSET_ZOOM] = z; + idx = this._addNodeToTree(z, newNodeData); + this.trees[z].insert([x, y, idx]); + } + } + + removePoint(id) { + const { maxZoom } = this.options; + const stride = this.stride; + const pointIdx = this._linearSearchInPoints(id); + if (pointIdx === null) return; + this._removePointFromList(pointIdx); + const removedNode = this.clusterData[maxZoom + 1].slice( + pointIdx * stride, + (pointIdx + 1) * stride, + ); + this._removeNodeFromTree(maxZoom + 1, pointIdx); + + const ancestorRemovals = Array.from( + { length: maxZoom + 2 }, + () => new Array(), + ); + ancestorRemovals[maxZoom + 1].push(removedNode); + this._removeAncestors( + maxZoom, + ancestorRemovals[maxZoom + 1], + ancestorRemovals, + ); + + this._recluster(maxZoom, [], ancestorRemovals); + } + _appendLeaves(result, clusterId, limit, offset, skipped) { const children = this.getChildren(clusterId); @@ -362,16 +426,18 @@ export default class Supercluster { ); } - _cluster(zoom) { - const { radius, extent, reduce, minPoints, zoomFactor } = this.options; - const r = radius / (extent * Math.pow(zoomFactor, zoom)); + _cluster(zoom, childIdxs) { + const { reduce, minPoints } = this.options; + const r = this._calculateRadius(zoom); const data = this.clusterData[zoom + 1]; - const nextClusterData = []; const nextIndexData = []; const stride = this.stride; + childIdxs ??= [...Array(data.length / stride).keys()]; + // loop through each point - for (let i = 0; i < data.length; i += stride) { + for (const childIdx of childIdxs) { + const i = childIdx * stride; // if we've already visited the point at this zoom level, skip it if (data[i + OFFSET_ZOOM] <= zoom) continue; data[i + OFFSET_ZOOM] = zoom; @@ -399,8 +465,9 @@ export default class Supercluster { let clusterProperties; let clusterPropIndex = -1; - // encode both zoom and point index on which the cluster originated -- offset by total length of features - const id = (((i / stride) | 0) << 5) + (zoom + 1) + this.points.length; + // encode both zoom and point index on which the cluster originated + // we use negative ids for clusters because we don't want id collisions between points and clusters + const id = -((((i / stride) | 0) << 5) + (zoom + 1)); for (const neighborId of neighborIds) { const k = neighborId * stride; @@ -425,48 +492,82 @@ export default class Supercluster { } data[i + OFFSET_PARENT] = id; - nextIndexData.push([ - wx / numPoints, - wy / numPoints, - nextIndexData.length, - ]); - nextClusterData.push( + const currentNodeData = [ wx / numPoints, wy / numPoints, Infinity, id, - -1, + null, numPoints, - ); - if (reduce) nextClusterData.push(clusterPropIndex); + ]; + if (reduce) currentNodeData.push(clusterPropIndex); + nextIndexData.push([ + wx / numPoints, + wy / numPoints, + this._addNodeToTree(zoom, currentNodeData), + ]); } else { // left points as unclustered - nextIndexData.push([data[i], data[i + 1], nextIndexData.length]); - for (let j = 0; j < stride; j++) nextClusterData.push(data[i + j]); + + nextIndexData.push([ + data[i], + data[i + 1], + this._addNodeToTree(zoom, data.slice(i, i + stride)), + ]); if (numPoints > 1) { for (const neighborId of neighborIds) { const k = neighborId * stride; if (data[k + OFFSET_ZOOM] <= zoom) continue; data[k + OFFSET_ZOOM] = zoom; - nextIndexData.push([data[k], data[k + 1], nextIndexData.length]); - for (let j = 0; j < stride; j++) nextClusterData.push(data[k + j]); + nextIndexData.push([ + data[k], + data[k + 1], + this._addNodeToTree(zoom, data.slice(k, k + stride)), + ]); } } } } - this.clusterData[zoom] = nextClusterData; return nextIndexData; } - // get index of the point from which the cluster originated - _getOriginId(clusterId) { - return (clusterId - this.points.length) >> 5; - } + _recluster(firstClusteringZoom, childLayerElements, ancestorRemovals) { + const { minZoom, maxZoom } = this.options; + ancestorRemovals ??= Array.from({ length: maxZoom + 2 }, () => new Array()); - // get zoom of the point from which the cluster originated - _getOriginZoom(clusterId) { - return (clusterId - this.points.length) % 32; + let contiguousChildIdxs = this._visitContiguous( + firstClusteringZoom + 1, + this._calculateRadius(firstClusteringZoom), + childLayerElements, + ancestorRemovals[firstClusteringZoom + 1], + ); + + for (let zoom = firstClusteringZoom; zoom >= minZoom; zoom--) { + const removed = this._removeParentsOfChildren( + zoom, + contiguousChildIdxs.map((idx) => + this.clusterData[zoom + 1].slice( + idx * this.stride, + (idx + 1) * this.stride, + ), + ), + ); + + this._removeAncestors(zoom - 1, removed, ancestorRemovals); + + const newIndexData = this._cluster(zoom, contiguousChildIdxs); + this.trees[zoom].load(newIndexData); + + if (zoom > minZoom) { + contiguousChildIdxs = this._visitContiguous( + zoom, + this._calculateRadius(zoom - 1), + newIndexData.map((idxData) => idxData[2]), + [...ancestorRemovals[zoom], ...removed], + ); + } + } } _map(data, i, clone) { @@ -479,6 +580,123 @@ export default class Supercluster { return clone && result === original ? Object.assign({}, result) : result; } + _addNodeToTree(zoom, nodeData) { + let idx = this.emptyIndices[zoom].pop(); + if (idx !== undefined) { + for (let i = 0; i < this.stride; i++) { + this.clusterData[zoom][idx * this.stride + i] = nodeData[i]; + } + return idx; + } + idx = this.clusterData[zoom].length / this.stride; + this.clusterData[zoom].push(...nodeData.slice(0, this.stride)); + return idx; + } + + _removeNodeFromTree(zoom, idx) { + const stride = this.stride; + this.trees[zoom].remove( + [ + this.clusterData[zoom][idx * stride], + this.clusterData[zoom][idx * stride + 1], + idx, + ], + (a, b) => a[2] === b[2], + ); + this.clusterData[zoom].fill(null, idx * stride, (idx + 1) * stride); + this.emptyIndices[zoom].push(idx); + } + + _addPointToList(point) { + let idx = this.emptyPointIndices.pop(); + if (idx !== undefined) { + this.points[idx] = point; + return idx; + } + idx = this.points.length; + this.points.push(point); + return idx; + } + + _removePointFromList(idx) { + this.points[idx] = null; + this.emptyPointIndices.push(idx); + } + + _visitContiguous(zoom, searchRadius, elementIdxs, nonIndexedNodes) { + const stride = this.stride; + const result = new Set(); + const notVisited = [...elementIdxs]; + + const visitPosition = (x, y) => { + for (const neighborIdx of this._rbushWithin(x, y, zoom, searchRadius)) { + if (!result.has(neighborIdx)) { + this.clusterData[zoom][neighborIdx * stride + OFFSET_ZOOM] = Math.max( + zoom, + this.clusterData[zoom][neighborIdx * stride + OFFSET_ZOOM], + ); + result.add(neighborIdx); + notVisited.push(neighborIdx); + } + } + }; + + if (nonIndexedNodes) { + for (const node of nonIndexedNodes) { + visitPosition(node[0], node[1]); + } + } + while (notVisited.length > 0) { + const nodeIdx = notVisited.pop(); + visitPosition( + this.clusterData[zoom][nodeIdx * stride], + this.clusterData[zoom][nodeIdx * stride + 1], + ); + } + return [...result]; + } + + _removeParentsOfChildren(zoom, childNodes) { + const stride = this.stride; + const removedParentIds = new Set(); + const removedParentNodes = new Array(); + const r = this._calculateRadius(zoom); + + for (const node of childNodes) { + const parentIdxs = this._rbushWithin(node[0], node[1], zoom, r).filter( + (idx) => + !removedParentIds.has( + this.clusterData[zoom][idx * stride + OFFSET_ID], + ) && + (node[OFFSET_ID] === + this.clusterData[zoom][idx * stride + OFFSET_ID] || + node[OFFSET_PARENT] === + this.clusterData[zoom][idx * stride + OFFSET_ID]), + ); + + for (const idx of parentIdxs) { + removedParentIds.add(this.clusterData[zoom][idx * stride + OFFSET_ID]); + removedParentNodes.push( + this.clusterData[zoom].slice(idx * stride, (idx + 1) * stride), + ); + this._removeNodeFromTree(zoom, idx); + } + } + return removedParentNodes; + } + + _removeAncestors(firstZoom, descendantNodes, removals) { + const { minZoom } = this.options; + for ( + let zoom = firstZoom; + zoom >= minZoom && descendantNodes.length !== 0; + zoom-- + ) { + descendantNodes = this._removeParentsOfChildren(zoom, descendantNodes); + removals[zoom].push(...descendantNodes); + } + } + _rbushWithin(ax, ay, zoom, radius) { const r2 = radius * radius; const pointsInSquare = this.trees[zoom].search({ @@ -500,9 +718,42 @@ export default class Supercluster { } _linearSearchInPoints(id) { - const index = this.points.findIndex((p) => this.getId(p) === id); + const index = this.points.findIndex((p) => p && this.getId(p) === id); return index !== -1 ? index : null; } + + _calculateRadius(zoom) { + const { radius, extent, zoomFactor } = this.options; + return radius / (extent * Math.pow(zoomFactor, zoom)); + } + + _printClusterData() { + const { maxZoom, minZoom } = this.options; + for (let zoom = maxZoom + 1; zoom >= minZoom; zoom--) { + const data = this.clusterData[zoom]; + console.log(`Zoom ${zoom} (${data.length / this.stride}):`); + for (let i = 0; i < data.length; i += this.stride) { + console.log(` Position: (${data[i]}, ${data[i + 1]})`); + console.log(` ID: ${data[i + OFFSET_ID]}`); + console.log(` Parent: ${data[i + OFFSET_PARENT]}`); + console.log(` NumPoints: ${data[i + OFFSET_NUM]}`); + console.log(` LastZoom: ${data[i + OFFSET_ZOOM]}`); + console.log(""); + } + } + } +} + +// get index of the point from which the cluster originated +function getOriginIdx(clusterId) { + if (clusterId >= 0) throw new Error("A cluster id should be negative"); + return -clusterId >> 5; +} + +// get zoom of the point from which the cluster originated +function getOriginZoom(clusterId) { + if (clusterId >= 0) throw new Error("A cluster id should be negative"); + return -clusterId % 32; } function getClusterJSON(data, i, clusterProps) { diff --git a/package-lock.json b/package-lock.json index 122b2fef..258d34ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mutable-supercluster", - "version": "1.0.4", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mutable-supercluster", - "version": "1.0.4", + "version": "1.1.0", "license": "ISC", "dependencies": { "lodash-es": "^4.17.21", @@ -395,11 +395,10 @@ "license": "MIT" }, "node_modules/acorn": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz", - "integrity": "sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, - "license": "MIT", "bin": { "acorn": "bin/acorn" }, diff --git a/package.json b/package.json index ee914990..729c9a57 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mutable-supercluster", - "version": "1.0.4", + "version": "1.1.0", "description": "A library for fast and mutable geospatial point clustering.", "main": "dist/mutable-supercluster.js", "type": "module", diff --git a/rollup.config.js b/rollup.config.js index db1169b9..fbaacb4a 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -4,7 +4,7 @@ import resolve from "@rollup/plugin-node-resolve"; const config = (file, plugins) => ({ input: "index.js", output: { - name: "mutable-supercluster", + name: "Supercluster", format: "umd", indent: false, file, diff --git a/test/fixtures/places-z0-0-0-min5.json b/test/fixtures/places-z0-0-0-min5.json index 8c6ec515..1a5502c8 100644 --- a/test/fixtures/places-z0-0-0-min5.json +++ b/test/fixtures/places-z0-0-0-min5.json @@ -5,55 +5,55 @@ "geometry": [[151, 203]], "tags": { "cluster": true, - "cluster_id": 164, + "cluster_id": -1, "point_count": 15, "point_count_abbreviated": 15 }, - "id": 164 + "id": -1 }, { "type": 1, "geometry": [[165, 241]], "tags": { "cluster": true, - "cluster_id": 196, + "cluster_id": -33, "point_count": 20, "point_count_abbreviated": 20 }, - "id": 196 + "id": -33 }, { "type": 1, "geometry": [[178, 305]], "tags": { "cluster": true, - "cluster_id": 228, + "cluster_id": -65, "point_count": 14, "point_count_abbreviated": 14 }, - "id": 228 + "id": -65 }, { "type": 1, "geometry": [[329, 244]], "tags": { "cluster": true, - "cluster_id": 260, + "cluster_id": -97, "point_count": 10, "point_count_abbreviated": 10 }, - "id": 260 + "id": -97 }, { "type": 1, "geometry": [[296, 291]], "tags": { "cluster": true, - "cluster_id": 356, + "cluster_id": -193, "point_count": 11, "point_count_abbreviated": 11 }, - "id": 356 + "id": -193 }, { "type": 1, @@ -120,11 +120,11 @@ "geometry": [[89, 209]], "tags": { "cluster": true, - "cluster_id": 548, + "cluster_id": -385, "point_count": 5, "point_count_abbreviated": 5 }, - "id": 548 + "id": -385 }, { "type": 1, @@ -191,22 +191,22 @@ "geometry": [[242, 237]], "tags": { "cluster": true, - "cluster_id": 964, + "cluster_id": -801, "point_count": 5, "point_count_abbreviated": 5 }, - "id": 964 + "id": -801 }, { "type": 1, "geometry": [[259, 193]], "tags": { "cluster": true, - "cluster_id": 1092, + "cluster_id": -929, "point_count": 6, "point_count_abbreviated": 6 }, - "id": 1092 + "id": -929 }, { "type": 1, @@ -303,11 +303,11 @@ "geometry": [[27, 270]], "tags": { "cluster": true, - "cluster_id": 1444, + "cluster_id": -1281, "point_count": 6, "point_count_abbreviated": 6 }, - "id": 1444 + "id": -1281 }, { "type": 1, @@ -389,33 +389,33 @@ "geometry": [[459, 309]], "tags": { "cluster": true, - "cluster_id": 1924, + "cluster_id": -1761, "point_count": 8, "point_count_abbreviated": 8 }, - "id": 1924 + "id": -1761 }, { "type": 1, "geometry": [[483, 272]], "tags": { "cluster": true, - "cluster_id": 2180, + "cluster_id": -2017, "point_count": 10, "point_count_abbreviated": 10 }, - "id": 2180 + "id": -2017 }, { "type": 1, "geometry": [[423, 295]], "tags": { "cluster": true, - "cluster_id": 2340, + "cluster_id": -2177, "point_count": 5, "point_count_abbreviated": 5 }, - "id": 2340 + "id": -2177 }, { "type": 1, @@ -482,22 +482,22 @@ "geometry": [[484, 235]], "tags": { "cluster": true, - "cluster_id": 2692, + "cluster_id": -2529, "point_count": 13, "point_count_abbreviated": 13 }, - "id": 2692 + "id": -2529 }, { "type": 1, "geometry": [[471, 167]], "tags": { "cluster": true, - "cluster_id": 3236, + "cluster_id": -3073, "point_count": 6, "point_count_abbreviated": 6 }, - "id": 3236 + "id": -3073 }, { "type": 1, @@ -579,22 +579,22 @@ "geometry": [[-29, 272]], "tags": { "cluster": true, - "cluster_id": 2180, + "cluster_id": -2017, "point_count": 10, "point_count_abbreviated": 10 }, - "id": 2180 + "id": -2017 }, { "type": 1, "geometry": [[-28, 235]], "tags": { "cluster": true, - "cluster_id": 2692, + "cluster_id": -2529, "point_count": 13, "point_count_abbreviated": 13 }, - "id": 2692 + "id": -2529 }, { "type": 1, @@ -631,11 +631,11 @@ "geometry": [[539, 270]], "tags": { "cluster": true, - "cluster_id": 1444, + "cluster_id": -1281, "point_count": 6, "point_count_abbreviated": 6 }, - "id": 1444 + "id": -1281 }, { "type": 1, diff --git a/test/fixtures/places-z0-0-0.json b/test/fixtures/places-z0-0-0.json index c7278d87..6f6a3eb6 100644 --- a/test/fixtures/places-z0-0-0.json +++ b/test/fixtures/places-z0-0-0.json @@ -5,77 +5,77 @@ "geometry": [[150, 205]], "tags": { "cluster": true, - "cluster_id": 164, + "cluster_id": -1, "point_count": 16, "point_count_abbreviated": 16 }, - "id": 164 + "id": -1 }, { "type": 1, "geometry": [[165, 240]], "tags": { "cluster": true, - "cluster_id": 196, + "cluster_id": -33, "point_count": 18, "point_count_abbreviated": 18 }, - "id": 196 + "id": -33 }, { "type": 1, "geometry": [[179, 303]], "tags": { "cluster": true, - "cluster_id": 228, + "cluster_id": -65, "point_count": 13, "point_count_abbreviated": 13 }, - "id": 228 + "id": -65 }, { "type": 1, "geometry": [[336, 234]], "tags": { "cluster": true, - "cluster_id": 260, + "cluster_id": -97, "point_count": 8, "point_count_abbreviated": 8 }, - "id": 260 + "id": -97 }, { "type": 1, "geometry": [[299, 285]], "tags": { "cluster": true, - "cluster_id": 292, + "cluster_id": -129, "point_count": 15, "point_count_abbreviated": 15 }, - "id": 292 + "id": -129 }, { "type": 1, "geometry": [[71, 419]], "tags": { "cluster": true, - "cluster_id": 324, + "cluster_id": -161, "point_count": 4, "point_count_abbreviated": 4 }, - "id": 324 + "id": -161 }, { "type": 1, "geometry": [[92, 212]], "tags": { "cluster": true, - "cluster_id": 420, + "cluster_id": -257, "point_count": 6, "point_count_abbreviated": 6 }, - "id": 420 + "id": -257 }, { "type": 1, @@ -97,33 +97,33 @@ "geometry": [[162, 345]], "tags": { "cluster": true, - "cluster_id": 581, + "cluster_id": -418, "point_count": 3, "point_count_abbreviated": 3 }, - "id": 581 + "id": -418 }, { "type": 1, "geometry": [[236, 232]], "tags": { "cluster": true, - "cluster_id": 580, + "cluster_id": -417, "point_count": 4, "point_count_abbreviated": 4 }, - "id": 580 + "id": -417 }, { "type": 1, "geometry": [[259, 193]], "tags": { "cluster": true, - "cluster_id": 644, + "cluster_id": -481, "point_count": 6, "point_count_abbreviated": 6 }, - "id": 644 + "id": -481 }, { "type": 1, @@ -190,22 +190,22 @@ "geometry": [[220, 147]], "tags": { "cluster": true, - "cluster_id": 836, + "cluster_id": -673, "point_count": 3, "point_count_abbreviated": 3 }, - "id": 836 + "id": -673 }, { "type": 1, "geometry": [[27, 270]], "tags": { "cluster": true, - "cluster_id": 900, + "cluster_id": -737, "point_count": 6, "point_count_abbreviated": 6 }, - "id": 900 + "id": -737 }, { "type": 1, @@ -242,44 +242,44 @@ "geometry": [[26, 115]], "tags": { "cluster": true, - "cluster_id": 1157, + "cluster_id": -994, "point_count": 2, "point_count_abbreviated": 2 }, - "id": 1157 + "id": -994 }, { "type": 1, "geometry": [[449, 304]], "tags": { "cluster": true, - "cluster_id": 1124, + "cluster_id": -961, "point_count": 13, "point_count_abbreviated": 13 }, - "id": 1124 + "id": -961 }, { "type": 1, "geometry": [[455, 272]], "tags": { "cluster": true, - "cluster_id": 1188, + "cluster_id": -1025, "point_count": 5, "point_count_abbreviated": 5 }, - "id": 1188 + "id": -1025 }, { "type": 1, "geometry": [[227, 121]], "tags": { "cluster": true, - "cluster_id": 1701, + "cluster_id": -1538, "point_count": 2, "point_count_abbreviated": 2 }, - "id": 1701 + "id": -1538 }, { "type": 1, @@ -301,22 +301,22 @@ "geometry": [[484, 235]], "tags": { "cluster": true, - "cluster_id": 1380, + "cluster_id": -1217, "point_count": 13, "point_count_abbreviated": 13 }, - "id": 1380 + "id": -1217 }, { "type": 1, "geometry": [[503, 260]], "tags": { "cluster": true, - "cluster_id": 1925, + "cluster_id": -1762, "point_count": 4, "point_count_abbreviated": 4 }, - "id": 1925 + "id": -1762 }, { "type": 1, @@ -339,11 +339,11 @@ "geometry": [[475, 165]], "tags": { "cluster": true, - "cluster_id": 1668, + "cluster_id": -1505, "point_count": 7, "point_count_abbreviated": 7 }, - "id": 1668 + "id": -1505 }, { "type": 1, @@ -395,33 +395,33 @@ "geometry": [[202, 262]], "tags": { "cluster": true, - "cluster_id": 4134, + "cluster_id": -3971, "point_count": 2, "point_count_abbreviated": 2 }, - "id": 4134 + "id": -3971 }, { "type": 1, "geometry": [[-28, 235]], "tags": { "cluster": true, - "cluster_id": 1380, + "cluster_id": -1217, "point_count": 13, "point_count_abbreviated": 13 }, - "id": 1380 + "id": -1217 }, { "type": 1, "geometry": [[-9, 260]], "tags": { "cluster": true, - "cluster_id": 1925, + "cluster_id": -1762, "point_count": 4, "point_count_abbreviated": 4 }, - "id": 1925 + "id": -1762 }, { "type": 1, @@ -444,11 +444,11 @@ "geometry": [[-37, 165]], "tags": { "cluster": true, - "cluster_id": 1668, + "cluster_id": -1505, "point_count": 7, "point_count_abbreviated": 7 }, - "id": 1668 + "id": -1505 }, { "type": 1, @@ -470,22 +470,22 @@ "geometry": [[539, 270]], "tags": { "cluster": true, - "cluster_id": 900, + "cluster_id": -737, "point_count": 6, "point_count_abbreviated": 6 }, - "id": 900 + "id": -737 }, { "type": 1, "geometry": [[538, 115]], "tags": { "cluster": true, - "cluster_id": 1157, + "cluster_id": -994, "point_count": 2, "point_count_abbreviated": 2 }, - "id": 1157 + "id": -994 } ] } diff --git a/test/test.js b/test/test.js index 24c51fd3..12de5602 100644 --- a/test/test.js +++ b/test/test.js @@ -47,7 +47,7 @@ test("returns children of a cluster", () => { structuredClone(places.features), ); const childCounts = index - .getChildren(164) + .getChildren(-1) .map((p) => p.properties.point_count || 1); assert.deepEqual(childCounts, [1, 7, 2, 6]); }); @@ -56,7 +56,7 @@ test("returns leaves of a cluster", () => { const index = new Supercluster({ getId }).load( structuredClone(places.features), ); - const leafNames = index.getLeaves(164, 10, 5).map((p) => p.properties.name); + const leafNames = index.getLeaves(-1, 10, 5).map((p) => p.properties.name); assert.deepEqual(leafNames, [ "I. de Cozumel", "Cabo Gracias a Dios", @@ -98,7 +98,7 @@ test("getLeaves handles null-property features", () => { }, ]), ); - const leaves = index.getLeaves(165, 1, 12); + const leaves = index.getLeaves(-1, 1, 12); assert.equal(leaves[0].properties, null); }); @@ -106,11 +106,11 @@ test("returns cluster expansion zoom", () => { const index = new Supercluster({ getId }).load( structuredClone(places.features), ); - assert.deepEqual(index.getClusterExpansionZoom(164), 1); - assert.deepEqual(index.getClusterExpansionZoom(196), 1); - assert.deepEqual(index.getClusterExpansionZoom(581), 2); - assert.deepEqual(index.getClusterExpansionZoom(1157), 2); - assert.deepEqual(index.getClusterExpansionZoom(4134), 3); + assert.deepEqual(index.getClusterExpansionZoom(-1), 1); + assert.deepEqual(index.getClusterExpansionZoom(-33), 1); + assert.deepEqual(index.getClusterExpansionZoom(-418), 2); + assert.deepEqual(index.getClusterExpansionZoom(-994), 2); + assert.deepEqual(index.getClusterExpansionZoom(-3971), 3); }); test("returns cluster expansion zoom for maxZoom", () => { @@ -121,7 +121,7 @@ test("returns cluster expansion zoom for maxZoom", () => { getId, }).load(structuredClone(places.features)); - assert.deepEqual(index.getClusterExpansionZoom(2504), 5); + assert.deepEqual(index.getClusterExpansionZoom(-2341), 5); }); test("aggregates cluster properties with reduce", () => { @@ -286,7 +286,7 @@ test("update properties succeeds", () => { const index = new Supercluster({ getId }).load( structuredClone(places.features), ); - const leafNames = index.getLeaves(164, 3, 5).map((p) => p.properties.name); + const leafNames = index.getLeaves(-1, 3, 5).map((p) => p.properties.name); assert.deepEqual(leafNames, [ "I. de Cozumel", "Cabo Gracias a Dios", @@ -294,7 +294,7 @@ test("update properties succeeds", () => { ]); // Update name of point 160, currently named I. de Cozumel. index.updatePointProperties(160, { properties: { name: "New York" } }); - const newLeafNames = index.getLeaves(164, 3, 5).map((p) => p.properties.name); + const newLeafNames = index.getLeaves(-1, 3, 5).map((p) => p.properties.name); assert.deepEqual(newLeafNames, [ "New York", "Cabo Gracias a Dios", @@ -309,7 +309,7 @@ test("update properties with different location fails", () => { // Change location of point 160 and try to update. index.updatePointProperties(160, { geometry: { coordinates: [0, 0] } }); // Result should not have changed. - const leafNames = index.getLeaves(164, 3, 5).map((p) => p.properties.name); + const leafNames = index.getLeaves(-1, 3, 5).map((p) => p.properties.name); assert.deepEqual(leafNames, [ "I. de Cozumel", "Cabo Gracias a Dios",