From 94654f1c8b940cb7225887e83f1cd658f62af10f Mon Sep 17 00:00:00 2001 From: Yarchik Date: Thu, 23 Jul 2026 18:13:52 +0100 Subject: [PATCH] Support maxZoom >= 31 without corrupting cluster ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cluster id packs the origin zoom into the low 5 bits (`<< 5`, `>> 5`, `% 32`), which can only hold `zoom + 1` up to 31 — i.e. maxZoom <= 30. Two coincident (or Float32-coincident) points always cluster at zoom-pass = maxZoom, so with `maxZoom >= 31` the `zoom + 1` value (>= 32) overflows the zoom field into the origin-index bits. Decoding then yields the wrong origin id and zoom, and `getChildren` / `getLeaves` / `getClusterExpansionZoom` throw "No cluster with the specified id." on input the library silently accepts (there is no documented upper bound on maxZoom). Size the zoom field to the configured maxZoom (`zoomBase`, a power of two >= 32) and pack/unpack with `*` / `/` / `%` instead of the fixed 5-bit shifts. `zoomBase === 32` for every maxZoom <= 30, so existing cluster ids are unchanged; using arithmetic instead of the 32-bit bitwise operators also removes a latent overflow for very large point counts. Fixes #221, #243. --- index.js | 9 ++++++--- test/test.js | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/index.js b/index.js index 4dce8d36..b16b1455 100644 --- a/index.js +++ b/index.js @@ -34,6 +34,9 @@ export default class Supercluster { this.trees = new Array(this.options.maxZoom + 1); this.stride = this.options.reduce ? 7 : 6; this.clusterProps = []; + // Reserve enough bits of the cluster id for the origin zoom so that a + // maxZoom of 31 or more does not overflow into the origin-index bits. + this.zoomBase = 2 ** Math.max(5, Math.ceil(Math.log2(this.options.maxZoom + 2))); } load(points) { @@ -311,7 +314,7 @@ export default class Supercluster { 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; + const id = (i / stride | 0) * this.zoomBase + (zoom + 1) + this.points.length; for (const neighborId of neighborIds) { const k = neighborId * stride; @@ -358,12 +361,12 @@ export default class Supercluster { // get index of the point from which the cluster originated _getOriginId(clusterId) { - return (clusterId - this.points.length) >> 5; + return Math.floor((clusterId - this.points.length) / this.zoomBase); } // get zoom of the point from which the cluster originated _getOriginZoom(clusterId) { - return (clusterId - this.points.length) % 32; + return (clusterId - this.points.length) % this.zoomBase; } _map(data, i, clone) { diff --git a/test/test.js b/test/test.js index 5fb05107..ea4940c1 100644 --- a/test/test.js +++ b/test/test.js @@ -82,6 +82,24 @@ test('returns cluster expansion zoom for maxZoom', () => { assert.deepEqual(index.getClusterExpansionZoom(2504), 5); }); +test('handles a maxZoom of 31 or more without corrupting cluster ids', () => { + const point = (lng, lat, name) => ({ + type: 'Feature', + properties: {name}, + geometry: {type: 'Point', coordinates: [lng, lat]}, + }); + + for (const maxZoom of [31, 32, 40]) { + const index = new Supercluster({maxZoom}).load([point(10, 50, 'a'), point(10, 50, 'b')]); + const clusterId = index.getClusters([-180, -90, 180, 90], 0) + .find(f => f.properties.cluster).properties.cluster_id; + + assert.deepEqual(index.getLeaves(clusterId).map(f => f.properties.name).sort(), ['a', 'b']); + assert.deepEqual(index.getChildren(clusterId).map(f => f.properties.name).sort(), ['a', 'b']); + assert.equal(index.getClusterExpansionZoom(clusterId), maxZoom + 1); + } +}); + test('aggregates cluster properties with reduce', () => { const index = new Supercluster({ map: props => ({sum: props.scalerank}),