From 9207b2e7f85fd403c2e1542721e579fdc28afd4e Mon Sep 17 00:00:00 2001 From: Vladimir Agafonkin Date: Mon, 10 Aug 2026 12:31:46 +0300 Subject: [PATCH 1/2] add support for multipoint features --- README.md | 2 +- index.js | 64 ++++++++++++++++++++++++++++++++-------------------- test/test.js | 23 +++++++++++++++++++ 3 files changed, 63 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 8b6db498..2e9a0d76 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Or use it with an ordinary script tag in the browser: #### `load(points)` -Loads an array of [GeoJSON Feature](https://tools.ietf.org/html/rfc7946#section-3.2) objects. Each feature's `geometry` must be a [GeoJSON Point](https://tools.ietf.org/html/rfc7946#section-3.1.2). Once loaded, index is immutable. +Loads an array of [GeoJSON Feature](https://tools.ietf.org/html/rfc7946#section-3.2) objects. Each feature's `geometry` must be a [GeoJSON Point](https://tools.ietf.org/html/rfc7946#section-3.1.2) or [MultiPoint](https://tools.ietf.org/html/rfc7946#section-3.1.3) — a MultiPoint is clustered as an individual point per coordinate, each inheriting the feature's `properties` and `id`. Once loaded, index is immutable. #### `getClusters(bbox, zoom)` diff --git a/index.js b/index.js index d438b0d0..0b682ef7 100644 --- a/index.js +++ b/index.js @@ -52,40 +52,54 @@ export default class Supercluster { const timerId = `z${notProcessed}: ${points.length} points`; if (log) console.time(timerId); - this.numPoints = points.length; const stride = this.stride; + // MultiPoint features expand into one internal point per coordinate, so count first + let numPoints = points.length; + for (const p of points) { + const g = p.geometry; + if (g && g.type === 'MultiPoint') numPoints += g.coordinates.length - 1; + } + this.numPoints = numPoints; + // retain only per-point fields used by output paths; drop the GeoJSON wrappers - const props = this.props = new Array(points.length); + const props = this.props = new Array(numPoints); // original Float64 mercator coords for drift-free single-point output - const coords = this.coords = new Float64Array(points.length * 2); + const coords = this.coords = new Float64Array(numPoints * 2); let ids = null; // generate a cluster object for each point and index input points into a KD-tree - const data = new Int32Array(points.length * stride); + const data = new Int32Array(numPoints * stride); let w = 0; - for (let i = 0; i < points.length; i++) { - const p = points[i]; - if (!p.geometry) continue; - - const [lng, lat] = p.geometry.coordinates; - const px = lngX(lng); - const py = latY(lat); - coords[2 * i] = px; - coords[2 * i + 1] = py; - // store internal point/cluster data in flat typed arrays for performance - data[w] = encode(px); - data[w + 1] = encode(py); - data[w + OFFSET_ZOOM] = notProcessed; - data[w + OFFSET_ID] = i; - data[w + OFFSET_PARENT] = -1; - data[w + OFFSET_NUM] = 1; - props[i] = p.properties; - if (p.id !== undefined) { - if (!ids) ids = new Array(points.length); - ids[i] = p.id; + let i = 0; // index of the individual point (multiple per MultiPoint feature) + for (const p of points) { + const g = p.geometry; + if (!g) { // keep the slot so point indices stay aligned with input features + i++; + continue; + } + const multi = g.type === 'MultiPoint'; + + for (let c = 0, n = multi ? g.coordinates.length : 1; c < n; c++, i++) { + const [lng, lat] = multi ? g.coordinates[c] : g.coordinates; + const px = lngX(lng); + const py = latY(lat); + coords[2 * i] = px; + coords[2 * i + 1] = py; + // store internal point/cluster data in flat typed arrays for performance + data[w] = encode(px); + data[w + 1] = encode(py); + data[w + OFFSET_ZOOM] = notProcessed; + data[w + OFFSET_ID] = i; + data[w + OFFSET_PARENT] = -1; + data[w + OFFSET_NUM] = 1; + props[i] = p.properties; + if (p.id !== undefined) { + if (!ids) ids = new Array(numPoints); + ids[i] = p.id; + } + w += stride; } - w += stride; } this.ids = ids; let prev = w === data.length ? data : data.subarray(0, w); diff --git a/test/test.js b/test/test.js index 1492dec4..f71af0f1 100644 --- a/test/test.js +++ b/test/test.js @@ -199,6 +199,29 @@ test('preserves single-point tile coords at GL JS params (extent 8192, z18)', () assert.ok(checked > 50, `expected to check >50 points, got ${checked}`); }); +test('expands MultiPoint features into individual points', () => { + const index = new Supercluster().load([{ + type: 'Feature', + id: 'mp', + properties: {a: 1}, + geometry: {type: 'MultiPoint', coordinates: [[0, 0], [0.001, 0.001], [50, 50]]} + }, { + type: 'Feature', + properties: {b: 2}, + geometry: {type: 'Point', coordinates: [0, 0]} + }]); + + const clusters = index.getClusters([-180, -85, 180, 85], 5); + assert.equal(clusters.length, 2); + assert.equal(clusters[0].properties.point_count, 3); + assert.deepEqual(clusters[1].geometry.coordinates, [49.999999999999986, 50]); + assert.deepEqual(clusters[1].properties, {a: 1}); + assert.equal(clusters[1].id, 'mp'); + + const leaves = index.getLeaves(clusters[0].properties.cluster_id); + assert.deepEqual(leaves.map(l => l.properties), [{a: 1}, {b: 2}, {a: 1}]); +}); + test('does not throw on zero items', () => { assert.doesNotThrow(() => { const index = new Supercluster().load([]); From b8cf6d321363cdacc84fca7800f9e2f08494844d Mon Sep 17 00:00:00 2001 From: Vladimir Agafonkin Date: Mon, 10 Aug 2026 12:34:51 +0300 Subject: [PATCH 2/2] small cleanup --- index.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index 0b682ef7..951db112 100644 --- a/index.js +++ b/index.js @@ -78,10 +78,11 @@ export default class Supercluster { i++; continue; } + const cs = g.coordinates; const multi = g.type === 'MultiPoint'; - for (let c = 0, n = multi ? g.coordinates.length : 1; c < n; c++, i++) { - const [lng, lat] = multi ? g.coordinates[c] : g.coordinates; + for (let c = 0, n = multi ? cs.length : 1; c < n; c++, i++) { + const [lng, lat] = multi ? cs[c] : cs; const px = lngX(lng); const py = latY(lat); coords[2 * i] = px;