Conversation
7441365 to
6d9edd7
Compare
6d9edd7 to
68f0f73
Compare
ece6c6a to
cb6fecb
Compare
cb6fecb to
3847032
Compare
Code Coverage
Files
|
3847032 to
5cc2fa0
Compare
5cc2fa0 to
7905b79
Compare
kikoso
left a comment
There was a problem hiding this comment.
@dkhawk thanks for this, the flat KD-tree approach looks really nice and the invariant test is a good idea.
Main things before merging: CI is red because :clustering:apiCheck fails, so it needs an ./gradlew apiDump with the new public API (and we should look at the dump, since a lot of new public surface lands here). And the renderer change, which I think affects all existing users and not only SuperCluster ones, see inline.
Also ClusterRendererMultipleItems still has the old bucket logic, is it intended to leave it behind?
| protected open fun getDescriptorForCluster(cluster: Cluster<T>): BitmapDescriptor { | ||
| val bucket = getBucket(cluster) | ||
| var descriptor = mIcons[bucket] | ||
| val key = if (showExactCount) { |
There was a problem hiding this comment.
Blocking I think. getDescriptorForCluster doesn't call getBucket() anymore, so nothing in the class reads buckets (the new property) or BUCKETS, and anyone who overrides getBucket() in a subclass gets silently ignored after upgrading.
It also changes the default badges for every existing user, not just SuperCluster ones. With maxNonZeroDigits = 1 a cluster of 350 now shows "300+" where it used to show "200+", and 5,000 shows "5000+" instead of "1000+". Should we keep the old bucket path as the default and only use the significant-digit rounding when one of the new flags is set? Same thing in DefaultAdvancedMarkersClusterRenderer.
| val formatted = if (thousands % 1.0 == 0.0) { | ||
| "${thousands.toInt()}" | ||
| } else { | ||
| String.format(Locale.US, "%.1f", thousands) |
There was a problem hiding this comment.
This can overstate the count. With maxNonZeroDigits = 3, 1,450 rounds down to 1450 but then %.1f rounds half-up to "1.5k+", which reads as "at least 1,500". The + only works if we always truncate, so should we floor here instead of formatting?
Also the compact formatting is basically written three times in this file (here, the millions branch and formatCompactNumber), and then again in DefaultAdvancedMarkersClusterRenderer. Can we pull it into one internal helper with a small unit test, so the two renderers can't drift?
| if (useCompactNumberFormatting) { | ||
| return formatCompactNumber(bucketOrSize) | ||
| } | ||
| return String.format(Locale.US, "%,d", bucketOrSize) |
There was a problem hiding this comment.
Locale.US here means a German user sees "1,234" instead of "1.234". Since this is user visible text on the map, shouldn't we use the default locale (or NumberFormat.getInstance())?
|
|
||
| public companion object { | ||
| private val BUCKETS = intArrayOf(10, 20, 50, 100, 200, 500, 1000) | ||
| public val DEFAULT_BUCKETS: IntArray = intArrayOf(10, 20, 50, 100, 200, 500, 1000) |
There was a problem hiding this comment.
Can this be private, or a List<Int>? A public IntArray in the companion means anyone can do DEFAULT_BUCKETS[0] = 5 and change it for every renderer in the process, and with BCV now it becomes part of the API we have to keep.
| */ | ||
| public fun setCoordinates( | ||
| coords: DoubleArray, | ||
| itemFactory: ((index: Int, position: LatLng) -> T)? = null, |
There was a problem hiding this comment.
If itemFactory is null we hand out DefaultLeafItem(pos) as T, which is unchecked. For SuperClusterAlgorithm<MyItem> that compiles fine and then blows up with a ClassCastException somewhere in the renderer or in a click listener, far away from where the mistake was made.
Should the factory be required? Or we could have a separate non-generic entry point for the raw-coordinates case.
| * Maps to [radius] in pixels to satisfy the [Algorithm] interface contract. | ||
| */ | ||
| public override var maxDistanceBetweenClusteredItems: Int | ||
| get() = mRadius.toInt() |
There was a problem hiding this comment.
Just to double check the units: with extent = 512 the radius ends up as radius / 2 in dp (world is 256 * 2^z dp wide), so maxDistanceBetweenClusteredItems = 100 gives 50dp here, while for NonHierarchicalDistanceBasedAlgorithm it is 100dp. People switching algorithms via ClusterManager would see clusters twice as tight.
Should the getter/setter convert, or at least say it in the KDoc? (The demo comment says "140px" too, which is really 70dp.)
| leafCoords[2 * leafCount + 1] = y | ||
| leafSizes[leafCount] = neighborBuffer.size | ||
| leafIndices[leafCount] = -1 | ||
| leafClusterIds[leafCount] = -(idCounter++) |
There was a problem hiding this comment.
Small one: cluster ids can collide. The first coincident group gets -(0) = 0 if it's item 0, and coincident groups get -1, -2, ... here while nextClusterId below also starts at -1. equals also compares position and size so the sets are fine, but clusterId is public, so anyone keying by it will get clashes. Maybe one shared counter for both?
| val step = maxZoom - z + 1 | ||
| val progress = 0.15f + 0.82f * (step.toFloat() / totalZoomSteps.toFloat()) | ||
| val pct = (progress * 100).toInt() | ||
| onProgressListener?.onClusteringProgress(progress, "Building zoom level $z/$maxZoom ($pct%)") |
There was a problem hiding this comment.
These status strings end up in a public listener, but they're hard-coded English and not localizable, so apps can't really show them to users. Should we drop status from OnClusteringProgressListener (or make it a stage enum) and keep just the progress? Also the KDoc mentions -1.0f for indeterminate but we never send it.
Related, ClusterManager now does is SuperClusterAlgorithm<*> in two places to wire this up. Would be cleaner with a small interface the algorithm implements, so the manager doesn't depend on a concrete algorithm.
| } | ||
| // [END maps_android_utils_benchmark_comparison_runner] | ||
|
|
||
| @Test |
There was a problem hiding this comment.
This one only prints, there's no assertion, and it runs the old algorithms at 100k on every CI run. Can we @Ignore it (or move it to a benchmark task) and keep the numbers in the README? I suspect it's a good part of why the test job takes almost 5 minutes now.
| MyItem(pos.latitude, pos.longitude, "Gremlin #$id", "1M US Supercluster Point") | ||
| } | ||
| } | ||
| val elapsedMs = System.currentTimeMillis() - startBuild |
There was a problem hiding this comment.
setCoordinates / addItems just store data, the pyramid only gets built later inside cluster() on the background thread. So the "Indexed in Xms" toast basically measures the array assignment, not the indexing. Maybe take the time from the progress listener reaching 1.0 instead?
…stering and configurable badge formatting - Implement SuperClusterAlgorithm with a bottom-up hierarchical zoom pyramid powered by FlatKdTree flat contiguous arrays, enabling sub-millisecond viewport queries on 100k+ markers with minimal GC allocation. - Optimize SuperClusterAlgorithm internals with zero-allocation flat primitive ZoomLevels, eliminating LinkedHashSet and LinkedHashMap overhead. - Add raw contiguous buffer ingestion (setCoordinates) for zero-allocation initialization of 1,000,000+ points with lazy item instantiation. - Add OnClusteringProgressListener to ClusterManager and SuperClusterAlgorithm for granular progress reporting during intensive spatial indexing passes. - Support location updates, dynamic item addition/removal, and custom cluster radius configuration. - Add configurable non-zero digit precision (maxNonZeroDigits), compact SI unit formatting (k/m and K/M), and exact count toggling (showExactCount) to DefaultClusterRenderer and DefaultAdvancedMarkersClusterRenderer with automatic icon cache invalidation. - Add SuperCluster100kDemoActivity showcasing 100,000 markers in California and 1,000,000 markers across the United States, unclustered gremlin markers, logarithmic color stops, and an interactive cluster settings dialog. - Add quick dataset switcher with opaque card styling, live LinearProgressIndicator progress feedback, and background coroutine loading. - Include comprehensive performance benchmarks and architecture documentation in README files. - Add full unit test coverage validating FlatKdTree, SuperCluster spatial partitioning, location updates, progress reporting, mathematical invariants, and renderer label formatting.
7905b79 to
d22e4df
Compare
Summary
This PR introduces
SuperClusterAlgorithm, a high-performance hierarchical greedy clustering algorithm designed to handle 100,000 to 1,000,000+ markers on Android with sub-millisecond query performance and minimal garbage collection pressure. It also adds configurable cluster badge precision and compact formatting to bothDefaultClusterRendererandDefaultAdvancedMarkersClusterRenderer, alongside an interactive sample demo.Key Additions & Features
SuperClusterAlgorithm&FlatKdTree:DoubleArray,IntArray).QuadItem,HashSet,HashMap) during runtime pan/zoom, preventing ART GC pauses.ZoomLevelstructures and direct flat KD-tree spatial partitioning for coincident and nearby points.setCoordinates(coords, itemFactory)) enabling instant loading and lazy item instantiation for 1,000,000 markers.OnClusteringProgressListenercallback interface reporting granular progress (0%..100%) during pyramid construction.Cluster Renderer Formatting Enhancements:
maxNonZeroDigits: Int(default:1) for configurable significant digit precision (e.g.,5,10+,50+,100+,1k+).showExactCount: Boolean(default:false) to display exact item counts only when explicitly requested.useCompactNumberFormatting: BooleanandcompactUnitUppercase: Booleanfor SI notation (k/mvsK/M).clearIconCache()andforceReclusterinvalidation on property mutation.SuperCluster100kDemoActivity:LinearProgressIndicatordisplaying live indexing progress.Tests & Documentation:
FlatKdTree,SuperClusterAlgorithmTest,SuperClusterInvariantProofTest,SuperClusterRobolectricTest,DefaultClusterRendererTest) using standard MockK and Robolectric with zero external dependencies.clustering/README.md.