Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ See the [docs](https://gmi-software.github.io/react-native-better-clustering/doc
| New Architecture errors | Confirm New Architecture is enabled and rebuild. |
| Does not work in Expo Go | Use a [development build](https://docs.expo.dev/develop/development-builds/introduction/). |
| `native ClusterEngine module is not available` | Rebuild the native app after installing (`pod install` / `npx expo prebuild --clean`). See [troubleshooting](https://gmi-software.github.io/react-native-better-clustering/docs/troubleshooting#the-native-clusterengine-module-is-not-available). |
| Markers flicker on zoom | Memoize marker components; give each point a stable `id`. |
| Markers flicker on zoom | Give each `Marker` a stable `key` (not the array index); memoize marker components. |


More in [troubleshooting](https://gmi-software.github.io/react-native-better-clustering/docs/troubleshooting).
Expand Down
4 changes: 4 additions & 0 deletions docs/docs/api/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,5 +73,9 @@ import {
} from 'react-native-better-clustering/engine'
```

Each `EngineClusterNode` has `minLeafId`: the smallest point id among its leaves
(its own `id` for a point). A cluster keeps it across zoom levels while it only
gains or loses other leaves, so it can key the cluster's marker.

**Lifecycle:** `setOptions` → `setPoints` (`packPoints()` buffer) → `build()` → query.
Query methods throw when `isBuilt` is `false`; `setPoints` throws on invalid buffers.
10 changes: 8 additions & 2 deletions docs/docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,14 @@ const geoJson = useMemo(

## Markers flicker on zoom

Use `stabilizeClusterFeatures` from `/hooks`, memoize marker components, and
ensure each point has a stable `id`.
Give every `Marker` a stable `key` (its id, not its array index). `MapView`
keeps that key, so adding or removing a marker doesn't remount the others, and
it keys each cluster bubble by its first marker's key, so a cluster that splits
or merges on zoom, or survives a data update, keeps its native marker instead
of being removed and re-added. Memoize custom marker components too.

With `/hooks`, use `stabilizeClusterFeatures` and key your markers by a stable
point `id`.

## Some markers never appear on the map

Expand Down
4 changes: 4 additions & 0 deletions package/cpp/ClusterEngineCore.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ class ClusterEngineCore {
node.pointIndex = _points[i].id;
node.isCluster = false;
node.zoom = _options.maxZoom + 1;
node.minLeafId = node.id;
if (np > 0) {
node.values.assign(np, 0.0);
const auto& v = _points[i].values;
Expand Down Expand Up @@ -296,6 +297,7 @@ class ClusterEngineCore {
cluster.pointIndex = -1;
cluster.isCluster = true;
cluster.zoom = z;
cluster.minLeafId = current[i].minLeafId;
if (np > 0) {
// Seed the accumulator with the origin node's values, then fold.
cluster.values = current[i].values;
Expand All @@ -310,6 +312,7 @@ class ClusterEngineCore {
const int32_t np2 = current[nb].count;
wx += current[nb].x * np2;
wy += current[nb].y * np2;
cluster.minLeafId = std::min(cluster.minLeafId, current[nb].minLeafId);
for (size_t k = 0; k < np; k++) {
const double v = k < current[nb].values.size()
? current[nb].values[k]
Expand Down Expand Up @@ -384,6 +387,7 @@ class ClusterEngineCore {
node.pointIndex = p.id;
node.isCluster = false;
node.zoom = 0;
node.minLeafId = p.id;
node.values = p.values;
result.push_back(std::move(node));
}
Expand Down
41 changes: 41 additions & 0 deletions package/cpp/ClusterEngineCore.test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,46 @@ static void testMemorySizeReflectsNativeAllocations() {
assert(engine.memorySize() > afterPoints);
}

static void testMinLeafIdIsTheSmallestLeafAtEveryZoom() {
ClusterEngineCore engine;
ClusterEngineConfig config;
config.radius = 40.0;
config.minPoints = 2;
config.minZoom = 0;
config.maxZoom = 16;
engine.setOptions(config);

// Pairs that merge into ever larger clusters as the zoom drops.
const int32_t count = 64;
std::vector<int32_t> ids(count);
std::vector<double> lats(count);
std::vector<double> lngs(count);
for (int32_t i = 0; i < count; i++) {
ids[i] = i;
lats[i] = 52.0 + (i % 8) * 0.002 + (i / 8) * 0.00001;
lngs[i] = 21.0 + (i / 8) * 0.003;
}
engine.setPoints(ids.data(), lats.data(), lngs.data(), static_cast<size_t>(count));
engine.build();

int32_t clustersChecked = 0;
for (int32_t z = config.minZoom; z <= config.maxZoom; z++) {
for (const auto& node : engine.getClusters({85.0, -85.0, 180.0, -180.0, static_cast<double>(z)})) {
if (!node.isCluster) {
assert(node.minLeafId == node.id);
continue;
}
int32_t smallest = count;
for (const auto& leaf : engine.getLeaves(node.id, 0, 0)) {
smallest = std::min(smallest, leaf.pointIndex);
}
assert(node.minLeafId == smallest);
clustersChecked++;
}
}
assert(clustersChecked > 10);
}

int main() {
testGetLeavesReturnsAllPointsInCluster();
testGetLeavesPagination();
Expand All @@ -347,6 +387,7 @@ int main() {
testGetLeavesUnlimited();
testInvalidBufferRejected();
testMemorySizeReflectsNativeAllocations();
testMinLeafIdIsTheSmallestLeafAtEveryZoom();
std::printf("ClusterEngineCore tests passed.\n");
return 0;
}
4 changes: 4 additions & 0 deletions package/cpp/GeoUtils.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ struct ClusterNode {
int32_t pointIndex; // -1 for cluster, >= 0 for leaf point index
bool isCluster;
int32_t zoom;
// Smallest point id among the node's leaves (its own id for a point). A
// cluster keeps it while it only gains or loses other leaves, so it
// identifies the cluster across zoom levels and rebuilds.
int32_t minLeafId = -1;
// Aggregated values, one per configured reducer. For a leaf this mirrors the
// point's raw values; for a cluster it is the reduced result over its leaves.
std::vector<double> values;
Expand Down
1 change: 1 addition & 0 deletions package/cpp/HybridClusterEngine.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ EngineClusterNode HybridClusterEngine::toFeature(const ClusterNode& node) const
node.isCluster,
static_cast<double>(node.parentId),
static_cast<double>(node.pointIndex),
static_cast<double>(node.minLeafId),
node.values
);
}
Expand Down
6 changes: 5 additions & 1 deletion package/src/__tests__/fakes/fakeClusterEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ interface PointProps {

interface ClusterProps {
values: number[]
minLeafId: number
}

type Feature = Supercluster.PointFeature<PointProps>
Expand Down Expand Up @@ -134,6 +135,7 @@ function toNode(feature: Result): EngineClusterNode {
isCluster: true,
parentId: -1,
pointIndex: -1,
minLeafId: properties.minLeafId,
values: properties.values ?? [],
}
}
Expand All @@ -146,6 +148,7 @@ function toNode(feature: Result): EngineClusterNode {
isCluster: false,
parentId: -1,
pointIndex: point.index,
minLeafId: point.index,
values: point.values,
}
}
Expand Down Expand Up @@ -193,11 +196,12 @@ export function createFakeClusterEngine(): FakeClusterEngine {
maxZoom: options.maxZoom,
minPoints: options.minPoints,
nodeSize: options.nodeSize,
map: (props) => ({ values: [...props.values] }),
map: (props) => ({ values: [...props.values], minLeafId: props.index }),
reduce: (acc, props) => {
acc.values = acc.values.map((value, k) =>
reduceValue(reducers[k]!, value, props.values[k] ?? 0)
)
acc.minLeafId = Math.min(acc.minLeafId, props.minLeafId)
},
}).load(points)
built = true
Expand Down
74 changes: 58 additions & 16 deletions package/src/__tests__/mapview/MapView.stability.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,64 @@ describe('MapView engine failures (#15)', () => {
})
})

describe('MapView marker keys (#28)', () => {
it('does not remount other markers when one is inserted at the start', async () => {
const { rerender } = render(<Map points={SINGLES} />)
await flush()
const start = mountLog.length

rerender(<Map points={[EXTRA, ...SINGLES]} />)
await flush()

expect(unmountsSince(start)).toEqual([])
// Index keys used to hand the shifted markers' instances to other points.
expect(retargetsSince(start)).toEqual([])
})

const clusterUnmountsSince = (start: number) =>
unmountsSince(start).filter((id) => id.startsWith('cluster@'))

it('keeps a cluster bubble mounted while it splits on zoom', async () => {
// GROUP is one cluster of 5 at camera zoom 17 and loses g4 at 18.
const { container } = render(
<Map points={GROUP} initialRegion={regionAt(GROUP[2]!, 17)} />
)
await flush()
expect(clusterLabels(container)).toEqual(['5'])
const start = mountLog.length

await settleRegion(regionAt(GROUP[2]!, 18))

expect(clusterLabels(container)).toEqual(['4'])
expect(clusterUnmountsSince(start)).toEqual([])
})

it('keeps cluster bubbles mounted when a rebuild inserts a marker', async () => {
const { container, rerender } = render(<Map />)
await flush()
const start = mountLog.length

rerender(<Map points={[EXTRA, ...GROUP, ...SINGLES]} />)
await flush()

expect(fakeClusterEngineStats.created).toBe(2)
expect(clusterLabels(container)).toEqual(['5'])
expect(clusterUnmountsSince(start)).toEqual([])
})

it('unmounts only the marker removed from the start', async () => {
const { rerender } = render(<Map points={[EXTRA, ...SINGLES]} />)
await flush()
const start = mountLog.length

rerender(<Map points={SINGLES} />)
await flush()

expect(unmountsSince(start)).toEqual(['x0'])
expect(retargetsSince(start)).toEqual([])
})
})

describe('Marker cluster prop (#14)', () => {
const clusterable = (points: TestPoint[]) =>
points.map((point) => ({ ...point, cluster: true }))
Expand Down Expand Up @@ -518,20 +576,4 @@ describe('MapView known issues', () => {
expect(clusterLabels(container)).toEqual([])
expect(renderedMarkerIds(container)).toHaveLength(SAME_SPOT.length)
})

it.failing(
'#28: inserting a marker at the start does not remount the others',
async () => {
const { rerender } = render(<Map points={SINGLES} />)
await flush()
const start = mountLog.length

rerender(<Map points={[EXTRA, ...SINGLES]} />)
await flush()

expect(unmountsSince(start)).toEqual([])
// Index keys hand the shifted markers' instances to other points.
expect(retargetsSince(start)).toEqual([])
}
)
})
89 changes: 86 additions & 3 deletions package/src/compat/ClusterMarker.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { describe, expect, it, jest, mock } from 'bun:test'
import { beforeEach, describe, expect, it, jest, mock } from 'bun:test'
import { render } from '@testing-library/react'
import React from 'react'

import type { ClusterFeature } from '../geojson/types'
import type { ClusterMarkerProps } from './ClusterMarker'
Expand All @@ -20,11 +22,19 @@ mock.module('react-native-reanimated', () => ({
withTiming: (value: unknown) => value,
}))

const redraw = jest.fn()

mock.module('react-native-maps', () => ({
Marker: 'Marker',
Marker: class Marker extends React.Component<{ children?: React.ReactNode }> {
redraw = redraw
render() {
return React.createElement('rn-marker', null, this.props.children)
}
},
}))

const { areClusterMarkerPropsEqual } = await import('./ClusterMarker')
const { default: ClusterMarker, areClusterMarkerPropsEqual } =
await import('./ClusterMarker')

const CLUSTER: ClusterFeature = {
type: 'Feature',
Expand Down Expand Up @@ -56,6 +66,79 @@ function baseProps(
}
}

describe('ClusterMarker redraw', () => {
const withCount = (count: number): ClusterFeature => ({
...CLUSTER,
properties: { ...CLUSTER.properties, point_count: count },
})

beforeEach(() => {
redraw.mockClear()
})

it('redraws a mounted bubble when its count or colour changes', () => {
const onPress = jest.fn()
const { rerender } = render(
<ClusterMarker {...baseProps({ onPress, feature: withCount(12) })} />
)
expect(redraw).not.toHaveBeenCalled()

rerender(
<ClusterMarker {...baseProps({ onPress, feature: withCount(7) })} />
)
expect(redraw).toHaveBeenCalledTimes(1)

rerender(
<ClusterMarker
{...baseProps({
onPress,
feature: withCount(7),
clusterColor: '#FF5722',
})}
/>
)
expect(redraw).toHaveBeenCalledTimes(2)
})

it('does not redraw when only the position changes', () => {
const onPress = jest.fn()
const { rerender } = render(<ClusterMarker {...baseProps({ onPress })} />)

rerender(
<ClusterMarker
{...baseProps({
onPress,
feature: {
...CLUSTER,
geometry: { type: 'Point', coordinates: [-122.43, 37.79] },
},
})}
/>
)

expect(redraw).not.toHaveBeenCalled()
})

it('leaves redrawing to the map while tracksViewChanges is on', () => {
const onPress = jest.fn()
const { rerender } = render(
<ClusterMarker {...baseProps({ onPress, tracksViewChanges: true })} />
)

rerender(
<ClusterMarker
{...baseProps({
onPress,
tracksViewChanges: true,
feature: withCount(7),
})}
/>
)

expect(redraw).not.toHaveBeenCalled()
})
})

describe('areClusterMarkerPropsEqual', () => {
it('returns true when feature and stable onPress reference are unchanged', () => {
const onPress = jest.fn()
Expand Down
Loading
Loading