From 92e0a336a9873ff993f7c6780e823856c1d7d017 Mon Sep 17 00:00:00 2001 From: Sander Verwimp <91965164+sanDer153@users.noreply.github.com> Date: Sat, 23 Aug 2025 13:07:30 +0100 Subject: [PATCH 01/10] make cluster ids negative --- index.js | 74 +++-- package-lock.json | 446 ++++++++++---------------- package.json | 4 +- test/fixtures/places-z0-0-0-min5.json | 68 ++-- test/fixtures/places-z0-0-0.json | 100 +++--- test/test.js | 25 +- 6 files changed, 316 insertions(+), 401 deletions(-) diff --git a/index.js b/index.js index acd7d2a5..c6521fb4 100644 --- a/index.js +++ b/index.js @@ -42,6 +42,17 @@ const OFFSET_PARENT = 4; const OFFSET_NUM = 5; const OFFSET_PROP = 6; +function pushClusterData(clusterData, reduce, x, y, zoom, id, parent, numPoints, properties) { + clusterData.push( + x, y, // projected point coordinates + zoom, // the last zoom the point was processed at + id, // index of the source feature in the original input array + parent, // parent cluster id + numPoints // number of points in a cluster + ); + if (reduce) clusterData.push(properties); // noop +} + export default class Supercluster { constructor(options) { this.options = Object.assign(Object.create(defaultOptions), options); @@ -75,14 +86,8 @@ 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( - 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 - 1 // number of points in a cluster - ); - if (this.options.reduce) currentClusterData.push(0); // noop + pushClusterData(currentClusterData, this.options.reduce, x, y, Infinity, i, -1, 1, 0); + // 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? @@ -137,8 +142,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); @@ -204,7 +209,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++; @@ -223,6 +228,27 @@ export default class Supercluster { lodashMerge(this.points[idx], clonedProperties); } + addPoint(point) { + const {maxZoom, reduce} = this.options; + const p = structuredClone(point); + this.points.push(p); + if (!p.geometry) return; + const [lng, lat] = p.geometry.coordinates; + const x = fround(lngX(lng)); + const y = fround(latY(lat)); + this.clusterData[maxZoom + 1].push( + x, y, // projected point coordinates + Infinity, // the last zoom the point was processed at + this.points.length - 1, // index of the source feature in the original input array + -1, // parent cluster id + 1 // number of points in a cluster + ); + if (reduce) this.clusterData[maxZoom + 1].push(0); + this.trees[maxZoom + 1].insert([x, y, this.points.length - 1]); + + // for (let z = maxZoom; z >= minZoom; z--) {} + } + _appendLeaves(result, clusterId, limit, offset, skipped) { const children = this.getChildren(clusterId); @@ -341,8 +367,8 @@ 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 + const id = -(((i / stride | 0) << 5) + (zoom + 1)); for (const neighborId of neighborIds) { const k = neighborId * stride; @@ -390,16 +416,6 @@ export default class Supercluster { return nextIndexData; } - // get index of the point from which the cluster originated - _getOriginId(clusterId) { - return (clusterId - this.points.length) >> 5; - } - - // get zoom of the point from which the cluster originated - _getOriginZoom(clusterId) { - return (clusterId - this.points.length) % 32; - } - _map(data, i, clone) { if (data[i + OFFSET_NUM] > 1) { const props = this.clusterProps[data[i + OFFSET_PROP]]; @@ -429,6 +445,18 @@ export default class Supercluster { } } +// 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) { return { type: 'Feature', diff --git a/package-lock.json b/package-lock.json index 1cbfc823..78e5f983 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mutable-supercluster", - "version": "1.0.3", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mutable-supercluster", - "version": "1.0.3", + "version": "1.1.0", "license": "ISC", "dependencies": { "lodash-es": "^4.17.21", @@ -15,7 +15,7 @@ "devDependencies": { "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-terser": "^0.4.4", - "eslint": "^9.5.0", + "eslint": "^9.33.0", "eslint-config-mourner": "^4.0.0", "mkdirp": "^3.0.1", "rollup": "^4.18.0" @@ -51,54 +51,54 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.1.tgz", - "integrity": "sha512-Zm2NGpWELsQAD1xsJzGQpYfvICSsFkEpU0jxBjfdC6uNEWXcHnfs9hScFWtXVDVl+rBQJGrl4g1vcKIejpH9dA==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", "dev": true, - "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, "node_modules/@eslint/config-array": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.16.0.tgz", - "integrity": "sha512-/jmuSd74i4Czf1XXn7wGRWZCuyaUZ330NH1Bek0Pplatt4Sy1S5haN21SCLLdbeKslQ+S0wEJ+++v5YibSi+Lg==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "dev": true, - "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.4", + "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", - "minimatch": "^3.0.5" + "minimatch": "^3.1.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/config-array/node_modules/debug": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", - "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "node_modules/@eslint/config-helpers": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", "dev": true, - "license": "MIT", "dependencies": { - "ms": "2.1.2" + "@types/json-schema": "^7.0.15" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/eslintrc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", - "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", "dev": true, - "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -118,25 +118,74 @@ } }, "node_modules/@eslint/js": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.5.0.tgz", - "integrity": "sha512-A7+AOT2ICkodvtsWnxZP4Xxk3NbZ3VMHd8oihydLRGrJgqqdEz1qSeEgXYyT/Cu8h1TWWsQRejIx48mtjZ5y1w==", + "version": "9.33.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.33.0.tgz", + "integrity": "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz", - "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.15.2", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -152,11 +201,10 @@ } }, "node_modules/@humanwhocodes/retry": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.0.tgz", - "integrity": "sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">=18.18" }, @@ -229,44 +277,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@rollup/plugin-node-resolve": { "version": "15.2.3", "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.3.tgz", @@ -615,11 +625,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" }, @@ -642,7 +651,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -654,16 +662,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -684,22 +682,19 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" + "dev": true }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -730,7 +725,6 @@ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" } @@ -783,15 +777,13 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, - "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -802,13 +794,12 @@ } }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "dev": true, - "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -850,28 +841,32 @@ } }, "node_modules/eslint": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.5.0.tgz", - "integrity": "sha512-+NAOZFrW/jFTS3dASCGBxX1pkFD0/fsO+hfAkJ4TyYKwgsXZbqzrw+seCYFCcPCYXvnD67tAnglU7GQTz6kcVw==", + "version": "9.33.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.33.0.tgz", + "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/config-array": "^0.16.0", - "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "9.5.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.33.0", + "@eslint/plugin-kit": "^0.3.5", + "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.3.0", - "@nodelib/fs.walk": "^1.2.8", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", + "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.0.1", - "eslint-visitor-keys": "^4.0.0", - "espree": "^10.0.1", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -881,15 +876,11 @@ "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" + "optionator": "^0.9.3" }, "bin": { "eslint": "bin/eslint.js" @@ -899,6 +890,14 @@ }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, "node_modules/eslint-config-mourner": { @@ -913,11 +912,10 @@ } }, "node_modules/eslint-scope": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.0.1.tgz", - "integrity": "sha512-pL8XjgP4ZOmmwfFE8mEhSxA7ZY4C+LWyqjQ3o4yWkkmD0qcMT9kkW3zWHOczhWcjTSgqycYAgwSlXvZltv65og==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -930,11 +928,10 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", - "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -942,16 +939,21 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, "node_modules/espree": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.1.0.tgz", - "integrity": "sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.12.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.0.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -978,7 +980,6 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -1017,15 +1018,13 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/fast-levenshtein": { "version": "2.0.6", @@ -1034,16 +1033,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fastq": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", - "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -1134,7 +1123,6 @@ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=18" }, @@ -1166,21 +1154,19 @@ } }, "node_modules/ignore": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", - "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, - "license": "MIT", "engines": { "node": ">= 4" } }, "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, - "license": "MIT", "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -1261,29 +1247,17 @@ "dev": true, "license": "MIT" }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" + "dev": true }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, - "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -1302,8 +1276,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -1369,7 +1342,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -1394,11 +1366,10 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true, - "license": "MIT" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true }, "node_modules/natural-compare": { "version": "1.4.0", @@ -1462,7 +1433,6 @@ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, - "license": "MIT", "dependencies": { "callsites": "^3.0.0" }, @@ -1485,7 +1455,6 @@ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -1521,36 +1490,14 @@ } }, "node_modules/punycode": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", - "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/quickselect": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", @@ -1597,22 +1544,10 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, - "license": "MIT", "engines": { "node": ">=4" } }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rollup": { "version": "4.18.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.18.0.tgz", @@ -1649,30 +1584,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1709,7 +1620,6 @@ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, - "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" }, @@ -1722,7 +1632,6 @@ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -1755,25 +1664,11 @@ "source-map": "^0.6.0" } }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" }, @@ -1826,13 +1721,6 @@ "node": ">=10" } }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true, - "license": "MIT" - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -1851,7 +1739,6 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } @@ -1861,7 +1748,6 @@ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, - "license": "ISC", "dependencies": { "isexe": "^2.0.0" }, diff --git a/package.json b/package.json index 85646d7b..f3c05221 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mutable-supercluster", - "version": "1.0.3", + "version": "1.1.0", "description": "A library for fast and mutable geospatial point clustering.", "main": "dist/mutable-supercluster.js", "type": "module", @@ -38,7 +38,7 @@ "devDependencies": { "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-terser": "^0.4.4", - "eslint": "^9.5.0", + "eslint": "^9.33.0", "eslint-config-mourner": "^4.0.0", "mkdirp": "^3.0.1", "rollup": "^4.18.0" 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 28eb737d..7361785b 100644 --- a/test/test.js +++ b/test/test.js @@ -34,13 +34,13 @@ test('supports minPoints option', () => { test('returns children of a cluster', () => { const index = new Supercluster({getId}).load(places.features); - const childCounts = index.getChildren(164).map(p => p.properties.point_count || 1); + const childCounts = index.getChildren(-1).map(p => p.properties.point_count || 1); assert.deepEqual(childCounts, [1, 7, 2, 6]); }); test('returns leaves of a cluster', () => { const index = new Supercluster({getId}).load(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', @@ -70,17 +70,18 @@ test('getLeaves handles null-property features', () => { coordinates: [-79.04411780507252, 43.08771393436908] } }])); - const leaves = index.getLeaves(165, 1, 12); + const leaves = index.getLeaves(-1, 1, 12); + console.log(leaves); assert.equal(leaves[0].properties, null); }); test('returns cluster expansion zoom', () => { const index = new Supercluster({getId}).load(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', () => { @@ -91,7 +92,7 @@ test('returns cluster expansion zoom for maxZoom', () => { getId }).load(places.features); - assert.deepEqual(index.getClusterExpansionZoom(2504), 5); + assert.deepEqual(index.getClusterExpansionZoom(-2341), 5); }); test('aggregates cluster properties with reduce', () => { @@ -196,7 +197,7 @@ test('does not throw on zero items', () => { test('update properties succeeds', () => { const index = new Supercluster({getId}).load(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', @@ -204,7 +205,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', @@ -217,7 +218,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', From c704cc1ed7600dd02d3343f366c4e521d05f7c5d Mon Sep 17 00:00:00 2001 From: Sander Verwimp <91965164+sanDer153@users.noreply.github.com> Date: Sat, 23 Aug 2025 13:43:39 +0100 Subject: [PATCH 02/10] remove separate clusterData function --- index.js | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/index.js b/index.js index 98758ec7..3ba009fc 100644 --- a/index.js +++ b/index.js @@ -42,17 +42,6 @@ const OFFSET_PARENT = 4; const OFFSET_NUM = 5; const OFFSET_PROP = 6; -function pushClusterData(clusterData, reduce, x, y, zoom, id, parent, numPoints, properties) { - clusterData.push( - x, y, // projected point coordinates - zoom, // the last zoom the point was processed at - id, // index of the source feature in the original input array - parent, // parent cluster id - numPoints // number of points in a cluster - ); - if (reduce) clusterData.push(properties); // noop -} - export default class Supercluster { constructor(options) { this.options = Object.assign(Object.create(defaultOptions), options); @@ -87,8 +76,14 @@ 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 - pushClusterData(currentClusterData, this.options.reduce, x, y, Infinity, i, -1, 1, 0); - + currentClusterData.push( + 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 + 1 // number of points in a cluster + ); + if (this.options.reduce) currentClusterData.push(0); // noop // 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? From 72c9ce0612a948791dfd1c807038036f7c2bb1c5 Mon Sep 17 00:00:00 2001 From: Sander Verwimp <91965164+sanDer153@users.noreply.github.com> Date: Tue, 26 Aug 2025 13:13:47 +0200 Subject: [PATCH 03/10] chore: run prettier --- index.js | 115 +++++++++++++++++++++---------------------- test/test.js | 135 ++++++++++++++++++++++++++++----------------------- 2 files changed, 132 insertions(+), 118 deletions(-) diff --git a/index.js b/index.js index c765fc0f..efc8f562 100644 --- a/index.js +++ b/index.js @@ -163,10 +163,10 @@ export default class Supercluster { return clusters; } - getChildren(clusterId) { - const originId = getOriginIdx(clusterId); - const originZoom = getOriginZoom(clusterId); - const errorMsg = 'No cluster with the specified id.'; + getChildren(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); @@ -251,46 +251,47 @@ export default class Supercluster { return tile.features.length ? tile : null; } - getClusterExpansionZoom(clusterId) { - let expansionZoom = getOriginZoom(clusterId) - 1; - while (expansionZoom <= this.options.maxZoom) { - const children = this.getChildren(clusterId); - expansionZoom++; - if (children.length !== 1) break; - clusterId = children[0].properties.cluster_id; - } - return expansionZoom; + getClusterExpansionZoom(clusterId) { + let expansionZoom = getOriginZoom(clusterId) - 1; + while (expansionZoom <= this.options.maxZoom) { + const children = this.getChildren(clusterId); + expansionZoom++; + if (children.length !== 1) break; + clusterId = children[0].properties.cluster_id; } + return expansionZoom; + } - updatePointProperties(id, properties) { - const idx = this._linearSearchInPoints(id); - if (!idx) throw new Error('No point with the given id could be found.'); + updatePointProperties(id, properties) { + const idx = this._linearSearchInPoints(id); + if (!idx) 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); - } + const clonedProperties = structuredClone(properties); + delete clonedProperties.geometry?.coordinates; + lodashMerge(this.points[idx], clonedProperties); + } - addPoint(point) { - const {maxZoom, reduce} = this.options; - const p = structuredClone(point); - this.points.push(p); - if (!p.geometry) return; - const [lng, lat] = p.geometry.coordinates; - const x = fround(lngX(lng)); - const y = fround(latY(lat)); - this.clusterData[maxZoom + 1].push( - x, y, // projected point coordinates - Infinity, // the last zoom the point was processed at - this.points.length - 1, // index of the source feature in the original input array - -1, // parent cluster id - 1 // number of points in a cluster - ); - if (reduce) this.clusterData[maxZoom + 1].push(0); - this.trees[maxZoom + 1].insert([x, y, this.points.length - 1]); + addPoint(point) { + const { maxZoom, reduce } = this.options; + const p = structuredClone(point); + this.points.push(p); + if (!p.geometry) return; + const [lng, lat] = p.geometry.coordinates; + const x = fround(lngX(lng)); + const y = fround(latY(lat)); + this.clusterData[maxZoom + 1].push( + x, + y, // projected point coordinates + Infinity, // the last zoom the point was processed at + this.points.length - 1, // index of the source feature in the original input array + -1, // parent cluster id + 1, // number of points in a cluster + ); + if (reduce) this.clusterData[maxZoom + 1].push(0); + this.trees[maxZoom + 1].insert([x, y, this.points.length - 1]); - // for (let z = maxZoom; z >= minZoom; z--) {} - } + // for (let z = maxZoom; z >= minZoom; z--) {} + } _appendLeaves(result, clusterId, limit, offset, skipped) { const children = this.getChildren(clusterId); @@ -421,8 +422,8 @@ export default class Supercluster { let clusterProperties; let clusterPropIndex = -1; - // encode both zoom and point index on which the cluster originated - const id = -(((i / stride | 0) << 5) + (zoom + 1)); + // encode both zoom and point index on which the cluster originated + const id = -((((i / stride) | 0) << 5) + (zoom + 1)); for (const neighborId of neighborIds) { const k = neighborId * stride; @@ -466,20 +467,20 @@ export default class Supercluster { nextIndexData.push([data[i], data[i + 1], nextIndexData.length]); for (let j = 0; j < stride; j++) nextClusterData.push(data[i + j]); - 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]); - } - } - } + 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]); + } } - this.clusterData[zoom] = nextClusterData; - return nextIndexData; + } } + this.clusterData[zoom] = nextClusterData; + return nextIndexData; + } _map(data, i, clone) { if (data[i + OFFSET_NUM] > 1) { @@ -519,14 +520,14 @@ export default class Supercluster { // 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; + 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; + if (clusterId >= 0) throw new Error("A cluster id should be negative"); + return -clusterId % 32; } function getClusterJSON(data, i, clusterProps) { diff --git a/test/test.js b/test/test.js index 42e60cb4..12de5602 100644 --- a/test/test.js +++ b/test/test.js @@ -42,27 +42,33 @@ test("supports minPoints option", () => { assert.deepEqual(tile.features, sortedTileMin5Features); }); -test('returns children of a cluster', () => { - const index = new Supercluster({getId}).load(structuredClone(places.features)); - const childCounts = index.getChildren(-1).map(p => p.properties.point_count || 1); - assert.deepEqual(childCounts, [1, 7, 2, 6]); +test("returns children of a cluster", () => { + const index = new Supercluster({ getId }).load( + structuredClone(places.features), + ); + const childCounts = index + .getChildren(-1) + .map((p) => p.properties.point_count || 1); + assert.deepEqual(childCounts, [1, 7, 2, 6]); }); -test('returns leaves of a cluster', () => { - const index = new Supercluster({getId}).load(structuredClone(places.features)); - const leafNames = index.getLeaves(-1, 10, 5).map(p => p.properties.name); - assert.deepEqual(leafNames, [ - 'I. de Cozumel', - 'Cabo Gracias a Dios', - 'Grand Cayman', - 'Cape Bauld', - 'Miquelon', - 'Cape May', - 'Niagara Falls', - 'Cape Hatteras', - 'Cape Fear', - 'Cape Sable', - ]); +test("returns leaves of a cluster", () => { + const index = new Supercluster({ getId }).load( + structuredClone(places.features), + ); + const leafNames = index.getLeaves(-1, 10, 5).map((p) => p.properties.name); + assert.deepEqual(leafNames, [ + "I. de Cozumel", + "Cabo Gracias a Dios", + "Grand Cayman", + "Cape Bauld", + "Miquelon", + "Cape May", + "Niagara Falls", + "Cape Hatteras", + "Cape Fear", + "Cape Sable", + ]); }); test("generates unique ids with generateId option", () => { @@ -86,22 +92,25 @@ test("getLeaves handles null-property features", () => { type: "Feature", properties: null, geometry: { - type: 'Point', - coordinates: [-79.04411780507252, 43.08771393436908] - } - }])); - const leaves = index.getLeaves(-1, 1, 12); - console.log(leaves); - assert.equal(leaves[0].properties, null); + type: "Point", + coordinates: [-79.04411780507252, 43.08771393436908], + }, + }, + ]), + ); + const leaves = index.getLeaves(-1, 1, 12); + assert.equal(leaves[0].properties, null); }); -test('returns cluster expansion zoom', () => { - const index = new Supercluster({getId}).load(structuredClone(places.features)); - 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", () => { + const index = new Supercluster({ getId }).load( + structuredClone(places.features), + ); + 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", () => { @@ -112,7 +121,7 @@ test("returns cluster expansion zoom for maxZoom", () => { getId, }).load(structuredClone(places.features)); - assert.deepEqual(index.getClusterExpansionZoom(-2341), 5); + assert.deepEqual(index.getClusterExpansionZoom(-2341), 5); }); test("aggregates cluster properties with reduce", () => { @@ -273,33 +282,37 @@ test("does not throw on zero items", () => { }); }); -test('update properties succeeds', () => { - const index = new Supercluster({getId}).load(structuredClone(places.features)); - const leafNames = index.getLeaves(-1, 3, 5).map(p => p.properties.name); - assert.deepEqual(leafNames, [ - 'I. de Cozumel', - 'Cabo Gracias a Dios', - 'Grand Cayman', - ]); - // Update name of point 160, currently named I. de Cozumel. - index.updatePointProperties(160, {properties: {name: 'New York'}}); - const newLeafNames = index.getLeaves(-1, 3, 5).map(p => p.properties.name); - assert.deepEqual(newLeafNames, [ - 'New York', - 'Cabo Gracias a Dios', - 'Grand Cayman', - ]); +test("update properties succeeds", () => { + const index = new Supercluster({ getId }).load( + structuredClone(places.features), + ); + const leafNames = index.getLeaves(-1, 3, 5).map((p) => p.properties.name); + assert.deepEqual(leafNames, [ + "I. de Cozumel", + "Cabo Gracias a Dios", + "Grand Cayman", + ]); + // Update name of point 160, currently named I. de Cozumel. + index.updatePointProperties(160, { properties: { name: "New York" } }); + const newLeafNames = index.getLeaves(-1, 3, 5).map((p) => p.properties.name); + assert.deepEqual(newLeafNames, [ + "New York", + "Cabo Gracias a Dios", + "Grand Cayman", + ]); }); -test('update properties with different location fails', () => { - const index = new Supercluster({getId}).load(structuredClone(places.features)); - // 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(-1, 3, 5).map(p => p.properties.name); - assert.deepEqual(leafNames, [ - 'I. de Cozumel', - 'Cabo Gracias a Dios', - 'Grand Cayman', - ]); +test("update properties with different location fails", () => { + const index = new Supercluster({ getId }).load( + structuredClone(places.features), + ); + // 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(-1, 3, 5).map((p) => p.properties.name); + assert.deepEqual(leafNames, [ + "I. de Cozumel", + "Cabo Gracias a Dios", + "Grand Cayman", + ]); }); From d4d209a37ee521008f9cb66ad3d90b2929908084 Mon Sep 17 00:00:00 2001 From: Sander Verwimp <91965164+sanDer153@users.noreply.github.com> Date: Sat, 30 Aug 2025 00:06:16 +0200 Subject: [PATCH 04/10] implement _recluster --- index.js | 253 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 202 insertions(+), 51 deletions(-) diff --git a/index.js b/index.js index efc8f562..180e6291 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,5 @@ import RBush from "rbush"; -import { merge as lodashMerge } from "lodash-es"; +import { merge as lodashMerge, min } from "lodash-es"; class MyRBush extends RBush { toBBox([x, y]) { @@ -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 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); @@ -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); @@ -271,27 +270,29 @@ export default class Supercluster { lodashMerge(this.points[idx], clonedProperties); } - addPoint(point) { - const { maxZoom, reduce } = this.options; - const p = structuredClone(point); - this.points.push(p); - if (!p.geometry) return; - const [lng, lat] = p.geometry.coordinates; - const x = fround(lngX(lng)); - const y = fround(latY(lat)); - this.clusterData[maxZoom + 1].push( - x, - y, // projected point coordinates - Infinity, // the last zoom the point was processed at - this.points.length - 1, // index of the source feature in the original input array - -1, // parent cluster id - 1, // number of points in a cluster - ); - if (reduce) this.clusterData[maxZoom + 1].push(0); - this.trees[maxZoom + 1].insert([x, y, this.points.length - 1]); - - // for (let z = maxZoom; z >= minZoom; z--) {} - } + // addPoint(point) { + // const { minZoom, maxZoom, reduce } = this.options; + // const p = structuredClone(point); + // this.points.push(p); + // if (!p.geometry) return; + // const [lng, lat] = p.geometry.coordinates; + // const x = fround(lngX(lng)); + // const y = fround(latY(lat)); + // this.clusterData[maxZoom + 1].push( + // x, + // y, // projected point coordinates + // Infinity, // the last zoom the point was processed at + // this.points.length - 1, // index of the source feature in the original input array + // -1, // parent cluster id + // 1, // number of points in a cluster + // ); + // if (reduce) this.clusterData[maxZoom + 1].push(0); + // this.trees[maxZoom + 1].insert([x, y, this.points.length - 1]); + + // for (let z = maxZoom; z >= minZoom; z--) { + + // } + // } _appendLeaves(result, clusterId, limit, offset, skipped) { const children = this.getChildren(clusterId); @@ -385,16 +386,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; @@ -448,40 +451,77 @@ 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, numPoints, - ); - if (reduce) nextClusterData.push(clusterPropIndex); + ]; + if (reduce) currentNodeData.push(clusterPropIndex); + const idx = this._addNodeToTree(zoom, currentNodeData); + nextIndexData.push([wx / numPoints, wy / numPoints, idx]); } 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; } + _recluster(firstClusteringZoom, childLayerElements, ancestorRemovals) { + const { minZoom, maxZoom } = this.options; + ancestorRemovals ??= Array.from({ length: maxZoom + 1 }, () => new Array()); + + let contiguousChildIdxs = this._visitContiguous( + firstClusteringZoom + 1, + this._calculateRadius(firstClusteringZoom), + childLayerElements, + ); + + for (let zoom = firstClusteringZoom; zoom >= minZoom; zoom--) { + const removed = this._removeParentsOfChildren( + zoom, + contiguousChildIdxs.map((idx) => + this.clusterData[zoom + 1].slice(idx * stride, (idx + 1) * stride), + ), + ); + + this._removeAncestors(zoom - 1, removed); + + 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[z], ...removed], + ); + } + } + } + _map(data, i, clone) { if (data[i + OFFSET_NUM] > 1) { const props = this.clusterProps[data[i + OFFSET_PROP]]; @@ -492,6 +532,112 @@ export default class Supercluster { return clone && result === original ? Object.assign({}, result) : result; } + _addNodeToTree(zoom, nodeData) { + let idx = -1; + if (this.emptyIndices[zoom].length > 0) { + idx = this.emptyIndices[zoom].pop(); + 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) { + this.trees[zoom].remove([null, null, idx], (a, b) => a[2] === b[2]); + for (let i = idx * this.stride; i < (idx + 1) * this.stride; i++) { + this.clusterData[zoom][i] = null; + } + this.emptyIndices[zoom].push(idx); + } + + _visitContiguous(zoom, searchRadius, elementIdxs, nonIndexedNodes) { + const { stride } = this.options; + const result = new Set(); + const notVisited = [...elementIdxs]; + + if (nonIndexedNodes) { + for (const node of nonIndexedNodes) { + for (const neighborIdx of this._rbushWithin( + node[0], + node[1], + zoom, + searchRadius, + )) { + if (!result.has(neighborIdx)) { + this.clusterData[zoom][neighborIdx * stride + OFFSET_ZOOM] = max( + zoom, + this.clusterData[zoom][neighborIdx * stride + OFFSET_ZOOM], + ); + result.add(neighborIdx); + notVisited.push(neighborIdx); + } + } + } + } + + while (notVisited.length > 0) { + const nodeIdx = notVisited.pop(); + for (const neighborIdx of this._rbushWithin( + this.clusterData[zoom][nodeIdx * stride], + this.clusterData[zoom][nodeIdx * stride + 1], + zoom, + searchRadius, + )) { + if (!result.has(neighborIdx)) { + this.clusterData[zoom][neighborIdx * stride + OFFSET_ZOOM] = max( + zoom, + this.clusterData[zoom][neighborIdx * stride + OFFSET_ZOOM], + ); + result.add(neighborIdx); + notVisited.push(neighborIdx); + } + } + } + return [...result]; + } + + _removeParentsOfChildren(zoom, childNodes) { + const { stride } = this.options; + 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; zoom--) { + if (descendantNodes.length === 0) break; + descendantNodes = this._removeParentsOfChildren(zoom, descendantNodes); + removals[zoom].push(...descendantNodes); + } + } + _rbushWithin(ax, ay, zoom, radius) { const r2 = radius * radius; const pointsInSquare = this.trees[zoom].search({ @@ -516,6 +662,11 @@ export default class Supercluster { const index = this.points.findIndex((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)); + } } // get index of the point from which the cluster originated From 532dd35ddc20f6c15db8008696d0f491006664a1 Mon Sep 17 00:00:00 2001 From: Sander Verwimp <91965164+sanDer153@users.noreply.github.com> Date: Sat, 30 Aug 2025 15:24:41 +0200 Subject: [PATCH 05/10] add semi works --- add_test.js | 15 ++++++++ demo/index.html | 3 +- demo/index.js | 5 +++ demo/worker.js | 20 +++++++---- index.js | 89 ++++++++++++++++++++++++++++-------------------- rollup.config.js | 2 +- 6 files changed, 89 insertions(+), 45 deletions(-) create mode 100644 add_test.js diff --git a/add_test.js b/add_test.js new file mode 100644 index 00000000..039c3c04 --- /dev/null +++ b/add_test.js @@ -0,0 +1,15 @@ +import { readFileSync } from "fs"; +import Supercluster from "./index.js"; + +const getId = (point) => point.pointId; +const places = JSON.parse( + readFileSync(new URL("./test/fixtures/places.json", import.meta.url)), +); + +const index = new Supercluster({ getId, log: true, maxZoom: 4 }).load( + structuredClone(places.features).slice(10, 14), +); + +index.addPoint(places.features[14]); + +console.log("done"); diff --git a/demo/index.html b/demo/index.html index f8f3908a..9ec6cbfd 100644 --- a/demo/index.html +++ b/demo/index.html @@ -22,13 +22,14 @@ html, body, #map { - height: 100%; + height: 95%; margin: 0; }
+ diff --git a/demo/index.js b/demo/index.js index 46170705..cd283482 100644 --- a/demo/index.js +++ b/demo/index.js @@ -64,3 +64,8 @@ markers.on("click", (e) => { }); } }); + +function addPoint() { + worker.postMessage({ addPoint: true }); + update(); +} diff --git a/demo/worker.js b/demo/worker.js index 035baf72..b10e3323 100644 --- a/demo/worker.js +++ b/demo/worker.js @@ -1,22 +1,22 @@ /*global importScripts Supercluster */ -importScripts("../dist/supercluster.js"); +importScripts("../dist/mutable-supercluster.js"); const now = Date.now(); +let geojson; let index; +let counter = 10; -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 +31,11 @@ self.onmessage = function (e) { ), center: e.data.center, }); + } else if (e.data.addPoint) { + if (counter < geojson.features.length) { + index.addPoint(geojson.features[counter]); + counter++; + } } else if (e.data) { postMessage(index.getClusters(e.data.bbox, e.data.zoom)); } @@ -48,7 +53,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 180e6291..43d832ad 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,5 @@ import RBush from "rbush"; -import { merge as lodashMerge, min } from "lodash-es"; +import { merge as lodashMerge } from "lodash-es"; class MyRBush extends RBush { toBBox([x, y]) { @@ -90,7 +90,7 @@ export default class Supercluster { 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) currentNodeData.push(0); // noop @@ -270,29 +270,42 @@ export default class Supercluster { lodashMerge(this.points[idx], clonedProperties); } - // addPoint(point) { - // const { minZoom, maxZoom, reduce } = this.options; - // const p = structuredClone(point); - // this.points.push(p); - // if (!p.geometry) return; - // const [lng, lat] = p.geometry.coordinates; - // const x = fround(lngX(lng)); - // const y = fround(latY(lat)); - // this.clusterData[maxZoom + 1].push( - // x, - // y, // projected point coordinates - // Infinity, // the last zoom the point was processed at - // this.points.length - 1, // index of the source feature in the original input array - // -1, // parent cluster id - // 1, // number of points in a cluster - // ); - // if (reduce) this.clusterData[maxZoom + 1].push(0); - // this.trees[maxZoom + 1].insert([x, y, this.points.length - 1]); - - // for (let z = maxZoom; z >= minZoom; z--) { - - // } - // } + addPoint(point) { + const { minZoom, maxZoom, reduce, minPoints } = this.options; + const p = structuredClone(point); + this.points.push(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 + this.points.length - 1, // 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]); + } + } _appendLeaves(result, clusterId, limit, offset, skipped) { const children = this.getChildren(clusterId); @@ -456,7 +469,7 @@ export default class Supercluster { wy / numPoints, Infinity, id, - -1, + null, numPoints, ]; if (reduce) currentNodeData.push(clusterPropIndex); @@ -502,11 +515,14 @@ export default class Supercluster { const removed = this._removeParentsOfChildren( zoom, contiguousChildIdxs.map((idx) => - this.clusterData[zoom + 1].slice(idx * stride, (idx + 1) * stride), + this.clusterData[zoom + 1].slice( + idx * this.stride, + (idx + 1) * this.stride, + ), ), ); - this._removeAncestors(zoom - 1, removed); + this._removeAncestors(zoom - 1, removed, ancestorRemovals); const newIndexData = this._cluster(zoom, contiguousChildIdxs); this.trees[zoom].load(newIndexData); @@ -516,7 +532,7 @@ export default class Supercluster { zoom, this._calculateRadius(zoom - 1), newIndexData.map((idxData) => idxData[2]), - [...ancestorRemovals[z], ...removed], + [...ancestorRemovals[zoom], ...removed], ); } } @@ -555,7 +571,7 @@ export default class Supercluster { } _visitContiguous(zoom, searchRadius, elementIdxs, nonIndexedNodes) { - const { stride } = this.options; + const stride = this.stride; const result = new Set(); const notVisited = [...elementIdxs]; @@ -568,10 +584,11 @@ export default class Supercluster { searchRadius, )) { if (!result.has(neighborIdx)) { - this.clusterData[zoom][neighborIdx * stride + OFFSET_ZOOM] = max( - zoom, - this.clusterData[zoom][neighborIdx * stride + OFFSET_ZOOM], - ); + this.clusterData[zoom][neighborIdx * stride + OFFSET_ZOOM] = + Math.max( + zoom, + this.clusterData[zoom][neighborIdx * stride + OFFSET_ZOOM], + ); result.add(neighborIdx); notVisited.push(neighborIdx); } @@ -588,7 +605,7 @@ export default class Supercluster { searchRadius, )) { if (!result.has(neighborIdx)) { - this.clusterData[zoom][neighborIdx * stride + OFFSET_ZOOM] = max( + this.clusterData[zoom][neighborIdx * stride + OFFSET_ZOOM] = Math.max( zoom, this.clusterData[zoom][neighborIdx * stride + OFFSET_ZOOM], ); @@ -601,7 +618,7 @@ export default class Supercluster { } _removeParentsOfChildren(zoom, childNodes) { - const { stride } = this.options; + const stride = this.stride; const removedParentIds = new Set(); const removedParentNodes = new Array(); const r = this._calculateRadius(zoom); 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, From 5975ee7d0c6b057216abb16899cfdc119cd7e776 Mon Sep 17 00:00:00 2001 From: Sander Verwimp <91965164+sanDer153@users.noreply.github.com> Date: Sat, 30 Aug 2025 20:05:37 +0200 Subject: [PATCH 06/10] addPoint implemented --- add_test.js | 19 +++++++++++++++++-- index.js | 47 +++++++++++++++++++++++++++++++++++++---------- 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/add_test.js b/add_test.js index 039c3c04..7c26b0b1 100644 --- a/add_test.js +++ b/add_test.js @@ -7,9 +7,24 @@ const places = JSON.parse( ); const index = new Supercluster({ getId, log: true, maxZoom: 4 }).load( - structuredClone(places.features).slice(10, 14), + structuredClone(places.features).slice(0, 10), ); +index.printClusterData(); -index.addPoint(places.features[14]); +console.log("-----\nADD 1\n-----"); +index.addPoint(places.features[10]); +index.printClusterData(); + +console.log("-----\nADD 2\n-----"); +index.addPoint(places.features[11]); +index.printClusterData(); + +console.log("-----\nADD 3\n-----"); +index.addPoint(places.features[12]); +index.printClusterData(); + +console.log("-----\nADD 4\n-----"); +index.addPoint(places.features[13]); +index.printClusterData(); console.log("done"); diff --git a/index.js b/index.js index 43d832ad..bfe64a89 100644 --- a/index.js +++ b/index.js @@ -261,6 +261,22 @@ export default class Supercluster { return expansionZoom; } + 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(""); + } + } + } + updatePointProperties(id, properties) { const idx = this._linearSearchInPoints(id); if (!idx) throw new Error("No point with the given id could be found."); @@ -473,8 +489,11 @@ export default class Supercluster { numPoints, ]; if (reduce) currentNodeData.push(clusterPropIndex); - const idx = this._addNodeToTree(zoom, currentNodeData); - nextIndexData.push([wx / numPoints, wy / numPoints, idx]); + nextIndexData.push([ + wx / numPoints, + wy / numPoints, + this._addNodeToTree(zoom, currentNodeData), + ]); } else { // left points as unclustered @@ -563,8 +582,16 @@ export default class Supercluster { } _removeNodeFromTree(zoom, idx) { - this.trees[zoom].remove([null, null, idx], (a, b) => a[2] === b[2]); - for (let i = idx * this.stride; i < (idx + 1) * this.stride; i++) { + 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], + ); + for (let i = idx * stride; i < (idx + 1) * stride; i++) { this.clusterData[zoom][i] = null; } this.emptyIndices[zoom].push(idx); @@ -627,12 +654,12 @@ export default class Supercluster { 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]), - ), + 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) { From 34b46440a9030368b5743dacb6b432e6c2c6616d Mon Sep 17 00:00:00 2001 From: Sander Verwimp <91965164+sanDer153@users.noreply.github.com> Date: Sun, 31 Aug 2025 16:09:27 +0200 Subject: [PATCH 07/10] implement remove point --- .DS_Store | Bin 0 -> 6148 bytes README.md | 11 +++++++-- add_test.js | 30 ------------------------ demo/index.html | 1 + demo/index.js | 5 +++- demo/worker.js | 16 +++++++++---- index.js | 61 +++++++++++++++++++++++++++++++++++++++++------- 7 files changed, 79 insertions(+), 45 deletions(-) create mode 100644 .DS_Store delete mode 100644 add_test.js diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..ab713c6b664a45f4a405326f97b9dd4351470abf GIT binary patch literal 6148 zcmeHK%}T>S5Z<-XrW7Fug&r5Y7Od4;!Apqs1&ruHr6we3FwK@GwTDv3SzpK}@p+ut z-H63{6|pn0`_1oe_JiyXV~l&#=zuYYF=j(UKzceI2kHc4O$Y=JVdkVOw;2-9=l>`$xxZ z(d+jYi-x_sw|{yud`d@|e9?q*;9JSI!3thM`CQMlKgm*=J%F#uukr|q0b+m{AO_Z# z0dpqU&Gn^#R!$5M13xf;`-6an=o&0Fs;vV$ygp;xLPP-_-x7$zplh(y2oVsjO96E$ zH%|<%%fT;9o@=nwsLL5wGs8G$=IZgn)$HIGDxGmxBlW}pF|f=)O`8s$|L5?_tbF7z zmyksa5Ci{=0d5Wafd`8+XY04+;aMx7-9tmcyb=`<(3dU&VBkK|Qb8RTs6(D>u+)g7 TpkI{((nUZKLLD*i3k-Y!2SrL% literal 0 HcmV?d00001 diff --git a/README.md b/README.md index 44f8b720..1c17ac63 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # 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 +58,13 @@ 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/add_test.js b/add_test.js deleted file mode 100644 index 7c26b0b1..00000000 --- a/add_test.js +++ /dev/null @@ -1,30 +0,0 @@ -import { readFileSync } from "fs"; -import Supercluster from "./index.js"; - -const getId = (point) => point.pointId; -const places = JSON.parse( - readFileSync(new URL("./test/fixtures/places.json", import.meta.url)), -); - -const index = new Supercluster({ getId, log: true, maxZoom: 4 }).load( - structuredClone(places.features).slice(0, 10), -); -index.printClusterData(); - -console.log("-----\nADD 1\n-----"); -index.addPoint(places.features[10]); -index.printClusterData(); - -console.log("-----\nADD 2\n-----"); -index.addPoint(places.features[11]); -index.printClusterData(); - -console.log("-----\nADD 3\n-----"); -index.addPoint(places.features[12]); -index.printClusterData(); - -console.log("-----\nADD 4\n-----"); -index.addPoint(places.features[13]); -index.printClusterData(); - -console.log("done"); diff --git a/demo/index.html b/demo/index.html index 9ec6cbfd..f77cf940 100644 --- a/demo/index.html +++ b/demo/index.html @@ -30,6 +30,7 @@
+ diff --git a/demo/index.js b/demo/index.js index cd283482..2fa50efd 100644 --- a/demo/index.js +++ b/demo/index.js @@ -67,5 +67,8 @@ markers.on("click", (e) => { function addPoint() { worker.postMessage({ addPoint: true }); - update(); +} + +function removePoint() { + worker.postMessage({ removePoint: true }); } diff --git a/demo/worker.js b/demo/worker.js index b10e3323..d4e5467d 100644 --- a/demo/worker.js +++ b/demo/worker.js @@ -6,7 +6,8 @@ const now = Date.now(); let geojson; let index; -let counter = 10; +let addCounter = 10; +let removeCounter = 0; getJSON("../test/fixtures/places.json", () => { console.log( @@ -32,10 +33,17 @@ self.onmessage = function (e) { center: e.data.center, }); } else if (e.data.addPoint) { - if (counter < geojson.features.length) { - index.addPoint(geojson.features[counter]); - counter++; + 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)); } diff --git a/index.js b/index.js index bfe64a89..dca944c7 100644 --- a/index.js +++ b/index.js @@ -279,7 +279,8 @@ 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; @@ -289,7 +290,7 @@ export default class Supercluster { addPoint(point) { const { minZoom, maxZoom, reduce, minPoints } = this.options; const p = structuredClone(point); - this.points.push(p); + const pointIdx = this._addPointToList(p); if (!p.geometry) return; const [lng, lat] = p.geometry.coordinates; const x = fround(lngX(lng)); @@ -298,7 +299,7 @@ export default class Supercluster { x, y, // projected point coordinates Infinity, // the last zoom the point was processed at - this.points.length - 1, // index of the source feature in the original input array + pointIdx, // index of the source feature in the original input array null, // parent cluster id 1, // number of points in a cluster ]; @@ -323,6 +324,32 @@ export default class Supercluster { } } + 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); @@ -522,12 +549,13 @@ export default class Supercluster { _recluster(firstClusteringZoom, childLayerElements, ancestorRemovals) { const { minZoom, maxZoom } = this.options; - ancestorRemovals ??= Array.from({ length: maxZoom + 1 }, () => new Array()); + ancestorRemovals ??= Array.from({ length: maxZoom + 2 }, () => new Array()); let contiguousChildIdxs = this._visitContiguous( firstClusteringZoom + 1, this._calculateRadius(firstClusteringZoom), childLayerElements, + ancestorRemovals[firstClusteringZoom + 1], ); for (let zoom = firstClusteringZoom; zoom >= minZoom; zoom--) { @@ -568,9 +596,8 @@ export default class Supercluster { } _addNodeToTree(zoom, nodeData) { - let idx = -1; - if (this.emptyIndices[zoom].length > 0) { - idx = this.emptyIndices[zoom].pop(); + 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]; } @@ -597,6 +624,22 @@ export default class Supercluster { 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(); @@ -703,7 +746,9 @@ 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 : false, + ); return index !== -1 ? index : null; } From 2860570166ffb78b192076e85ad658e9eb436e4e Mon Sep 17 00:00:00 2001 From: Sander Verwimp <91965164+sanDer153@users.noreply.github.com> Date: Sun, 31 Aug 2025 16:10:58 +0200 Subject: [PATCH 08/10] remove DS_store --- .DS_Store | Bin 6148 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index ab713c6b664a45f4a405326f97b9dd4351470abf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK%}T>S5Z<-XrW7Fug&r5Y7Od4;!Apqs1&ruHr6we3FwK@GwTDv3SzpK}@p+ut z-H63{6|pn0`_1oe_JiyXV~l&#=zuYYF=j(UKzceI2kHc4O$Y=JVdkVOw;2-9=l>`$xxZ z(d+jYi-x_sw|{yud`d@|e9?q*;9JSI!3thM`CQMlKgm*=J%F#uukr|q0b+m{AO_Z# z0dpqU&Gn^#R!$5M13xf;`-6an=o&0Fs;vV$ygp;xLPP-_-x7$zplh(y2oVsjO96E$ zH%|<%%fT;9o@=nwsLL5wGs8G$=IZgn)$HIGDxGmxBlW}pF|f=)O`8s$|L5?_tbF7z zmyksa5Ci{=0d5Wafd`8+XY04+;aMx7-9tmcyb=`<(3dU&VBkK|Qb8RTs6(D>u+)g7 TpkI{((nUZKLLD*i3k-Y!2SrL% From 31e9b821872527843381ba4a2760f2dca18c9f02 Mon Sep 17 00:00:00 2001 From: Sander Verwimp <91965164+sanDer153@users.noreply.github.com> Date: Sun, 31 Aug 2025 16:24:21 +0200 Subject: [PATCH 09/10] fix formatting --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1c17ac63..9370fd35 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,11 @@ _This repository is a fork from [mapbox/supercluster](https://github.com/mapbox/ A Node.js library for fast and mutable geospatial point clustering. ```js -const index = new Supercluster({ radius: 40, maxZoom: 16 , getId: (point) => point.id}); +const index = new Supercluster({ + radius: 40, + maxZoom: 16, + getId: (point) => point.id, +}); index.load(points); const clusters = index.getClusters([-180, -85, 180, 85], 2); @@ -63,6 +67,7 @@ Updates the point in the cluster with the same `id` (according to `getId`), with 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 From 77ae5dee1b2f7df34dee88610c65274f7c5ece01 Mon Sep 17 00:00:00 2001 From: Sander Verwimp <91965164+sanDer153@users.noreply.github.com> Date: Sun, 31 Aug 2025 21:01:19 +0200 Subject: [PATCH 10/10] address PR comments --- index.js | 92 ++++++++++++++++++++++++-------------------------------- 1 file changed, 39 insertions(+), 53 deletions(-) diff --git a/index.js b/index.js index dca944c7..87759212 100644 --- a/index.js +++ b/index.js @@ -261,22 +261,6 @@ export default class Supercluster { return expansionZoom; } - 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(""); - } - } - } - updatePointProperties(id, properties) { const idx = this._linearSearchInPoints(id); if (idx === null) @@ -482,6 +466,7 @@ export default class Supercluster { let clusterPropIndex = -1; // 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) { @@ -618,9 +603,7 @@ export default class Supercluster { ], (a, b) => a[2] === b[2], ); - for (let i = idx * stride; i < (idx + 1) * stride; i++) { - this.clusterData[zoom][i] = null; - } + this.clusterData[zoom].fill(null, idx * stride, (idx + 1) * stride); this.emptyIndices[zoom].push(idx); } @@ -645,35 +628,8 @@ export default class Supercluster { const result = new Set(); const notVisited = [...elementIdxs]; - if (nonIndexedNodes) { - for (const node of nonIndexedNodes) { - for (const neighborIdx of this._rbushWithin( - node[0], - node[1], - 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); - } - } - } - } - - while (notVisited.length > 0) { - const nodeIdx = notVisited.pop(); - for (const neighborIdx of this._rbushWithin( - this.clusterData[zoom][nodeIdx * stride], - this.clusterData[zoom][nodeIdx * stride + 1], - zoom, - searchRadius, - )) { + 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, @@ -683,6 +639,19 @@ export default class Supercluster { 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]; } @@ -718,8 +687,11 @@ export default class Supercluster { _removeAncestors(firstZoom, descendantNodes, removals) { const { minZoom } = this.options; - for (let zoom = firstZoom; zoom >= minZoom; zoom--) { - if (descendantNodes.length === 0) break; + for ( + let zoom = firstZoom; + zoom >= minZoom && descendantNodes.length !== 0; + zoom-- + ) { descendantNodes = this._removeParentsOfChildren(zoom, descendantNodes); removals[zoom].push(...descendantNodes); } @@ -746,9 +718,7 @@ export default class Supercluster { } _linearSearchInPoints(id) { - const index = this.points.findIndex((p) => - p ? this.getId(p) === id : false, - ); + const index = this.points.findIndex((p) => p && this.getId(p) === id); return index !== -1 ? index : null; } @@ -756,6 +726,22 @@ export default class Supercluster { 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