Skip to content
Open
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
1 change: 1 addition & 0 deletions CCHMapClusterController/CCHMapClusterController.m
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ - (void)updateAnnotationsWithCompletionHandler:(void (^)())completionHandler
operation.animator = self.animator;
operation.clusterControllerDelegate = self.delegate;
operation.clusterController = self;
operation.clusterMethod = ClusterMethodDistanceBased;

if (completionHandler) {
operation.completionBlock = ^{
Expand Down
8 changes: 8 additions & 0 deletions CCHMapClusterController/CCHMapClusterOperation.h
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@
@protocol CCHMapAnimator;
@protocol CCHMapClusterControllerDelegate;


typedef enum {
ClusterMethodGridBased,
ClusterMethodDistanceBased
} CCHClusterMethod;


@interface CCHMapClusterOperation : NSOperation

@property (nonatomic) CCHMapTree *allAnnotationsMapTree;
Expand All @@ -41,6 +48,7 @@
@property (nonatomic) id<CCHMapAnimator> animator;
@property (nonatomic, weak) id<CCHMapClusterControllerDelegate> clusterControllerDelegate;
@property (nonatomic, weak) CCHMapClusterController *clusterController;
@property (nonatomic) CCHClusterMethod clusterMethod;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of having this type property I would rather create two classed called CCHMapClusterOperationDistanceBased and CCHMapClusterOperationGridBased


- (instancetype)initWithMapView:(MKMapView *)mapView cellSize:(double)cellSize marginFactor:(double)marginFactor reuseExistingClusterAnnotations:(BOOL)reuseExistingClusterAnnotation maxZoomLevelForClustering:(double)maxZoomLevelForClustering minUniqueLocationsForClustering:(NSUInteger)minUniqueLocationsForClustering;

Expand Down
232 changes: 227 additions & 5 deletions CCHMapClusterController/CCHMapClusterOperation.m
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
@interface CCHMapClusterOperation()

@property (nonatomic) MKMapView *mapView;
@property (nonatomic) double cellSize;
@property (nonatomic) double cellMapSize;
@property (nonatomic) double marginFactor;
@property (nonatomic) MKMapRect mapViewVisibleMapRect;
Expand All @@ -56,12 +57,15 @@ @implementation CCHMapClusterOperation

@synthesize executing = _executing;
@synthesize finished = _finished;
@synthesize clusterMethod;


- (instancetype)initWithMapView:(MKMapView *)mapView cellSize:(double)cellSize marginFactor:(double)marginFactor reuseExistingClusterAnnotations:(BOOL)reuseExistingClusterAnnotation maxZoomLevelForClustering:(double)maxZoomLevelForClustering minUniqueLocationsForClustering:(NSUInteger)minUniqueLocationsForClustering
{
self = [super init];
if (self) {
_mapView = mapView;
_cellSize = cellSize;
_cellMapSize = [self.class cellMapSizeForCellSize:cellSize withMapView:mapView];
_marginFactor = marginFactor;
_mapViewVisibleMapRect = mapView.visibleMapRect;
Expand Down Expand Up @@ -97,20 +101,237 @@ + (MKMapRect)gridMapRectForMapRect:(MKMapRect)mapRect withCellMapSize:(double)ce
return gridMapRect;
}


- (MKZoomScale) currentZoomScale
{
CGSize screenSize = self.mapView.bounds.size;
MKMapRect mapRect = self.mapView.visibleMapRect;
MKZoomScale zoomScale = screenSize.width / mapRect.size.width;
return zoomScale;
}


+ (double) distanceSquared:(MKMapPoint)a b:(MKMapPoint)b {
return (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y);
}


+ (MKMapRect) createBoundsFromSpan:(MKMapPoint)p span:(double)span {
double halfSpan = span / 2;
return MKMapRectMake(p.x - halfSpan, p.y - halfSpan, span, span);
}



- (void)start
{
switch (self.clusterMethod) {
case ClusterMethodGridBased:
[self start_grid];
break;
case ClusterMethodDistanceBased:
[self start_distance];
break;
};
}



// A simple clustering algorithm with O(nlog n) performance. Resulting clusters are not
// hierarchical. Contributed by GitHub user __amishjake__.
// Steps:
// 1. Iterate over candidate annotations.
// 2. Create/re-use a CCHMapClusterAnnotation with the center of the annotation.
// 3. Add all annotations that are within a certain distance to the cluster.
// 4. Move any annotations out of an existing cluster if they are closer to another cluster.
// 5. Remove those items from the list of candidate clusters.
// 6. Add/remove items on the actual map as in the grid algorithm.
//
- (void) start_distance
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess in objective c we follow the naming convention to use camel case or method names? startDistance

{
self.executing = YES;

BOOL respondsToSelector = [_clusterControllerDelegate respondsToSelector:@selector(mapClusterController:willReuseMapClusterAnnotation:)];

double zoomLevel = CCHMapClusterControllerZoomLevelForRegion(self.mapViewRegion.center.longitude, self.mapViewRegion.span.longitudeDelta, self.mapViewWidth);
BOOL disableClustering = (zoomLevel > self.maxZoomLevelForClustering);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is never read in your code. We should not ignore this property.


// Zoom scale * MK distance = screen points
MKZoomScale zoomScale = [self currentZoomScale];

// The width and height of the square around a point that we'll consider later
double zoomSpecificSpan = _cellSize / zoomScale;

// Annotations we've already looked at for a starting point for a cluster
NSMutableSet *visitedCandidates = [[NSMutableSet alloc] init];

// The MKAnnotations (single POIs and clusters alike) we want on display
NSMutableSet *results = [[NSMutableSet alloc] init];

// Map a single POI MKAnnotation to its distance (NSNumber*) from its cluster (if added to one yet)
NSMapTable *distanceToCluster = [NSMapTable strongToStrongObjectsMapTable];

// Map a single POI MKAnnotation to its cluster annotation (if added to one yet)
NSMapTable *itemToCluster = [NSMapTable strongToStrongObjectsMapTable];

// Existing annotations to re-use
NSMutableSet *annotationsToReuse = [NSMutableSet setWithSet:[_visibleAnnotationsMapTree annotationsInMapRect:self.mapView.visibleMapRect]];
//NSMutableSet *annotationsToReuse = [NSMutableSet setWithSet:[_visibleAnnotationsMapTree annotations]];

// The CCHMapClusterAnnotations that are being reused
NSMutableSet *reusedAnnotations = [NSMutableSet setWithSet:[_visibleAnnotationsMapTree annotations]];

@synchronized(_allAnnotationsMapTree) {

// Iterate over all POIs we know about
for (id<MKAnnotation> candidate in _allAnnotationsMapTree.annotations) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might not be the most performant way. Imagine we have tons of annotations placed over the whole globe. We'd iterate over all of them when ever we change the visible map rect for a small area. In the grid based algorithm we only consider annotations in a specific area around the visible map rect to speed up performance. I guess here we could do the same?


if ([visitedCandidates containsObject:candidate]) {
// Candidate is already part of another cluster.
continue;
}

// searchBounds is a rectangle of size zoomSpecificSpan (map x and y,
// not LatLng), centered on the candidate item's point
MKMapPoint point = MKMapPointForCoordinate(candidate.coordinate);
MKMapRect searchBounds = [self.class createBoundsFromSpan:point span:zoomSpecificSpan];

// Get list of MKAnnotations in that bounds
NSSet *clusterItems = [_allAnnotationsMapTree annotationsInMapRect:searchBounds];

CCHMapClusterAnnotation *cluster;

if (_reuseExistingClusterAnnotations) {
// Check if an existing cluster annotation can be reused
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not quite sure if this works as expected. I'd start simple and first make the algorithm work without reusing annotations. Then in the next iteration we can check how to adapt the old re-use logic to the new algo

cluster = CCHMapClusterControllerFindVisibleAnnotation(clusterItems, annotationsToReuse);

// For unique locations, coordinate has to match as well
//if (annotationForCell && annotationSetsAreUniqueLocations) {
// BOOL coordinateMatches = fequal(coordinate.latitude, annotationForCell.coordinate.latitude) && fequal(coordinate.longitude, annotationForCell.coordinate.longitude);
// annotationForCell = coordinateMatches ? annotationForCell : nil;
//}
}

if (cluster == nil) {
// Create new cluster annotation
cluster = [[CCHMapClusterAnnotation alloc] init];
cluster.mapClusterController = _clusterController;
cluster.delegate = _clusterControllerDelegate;
}
else {
[reusedAnnotations addObject:cluster];
[annotationsToReuse removeObject:cluster];
}

// Cluster takes coordinate of first found annotation
cluster.coordinate = candidate.coordinate;
cluster.annotations = [[NSSet alloc] init];

if (clusterItems.count == 1) {
// Only the current marker is in range. Just add the single item to the results.
cluster.annotations = [cluster.annotations setByAddingObject:candidate];
[results addObject:cluster];
[visitedCandidates addObject:candidate];
[distanceToCluster setObject:[NSNumber numberWithDouble:0.0] forKey:candidate];
continue;
}

// Add the cluster of several annotations to the results
[results addObject:cluster];

// Iterate items in the bounds box
for (id<MKAnnotation> clusterItem in clusterItems) {

// This item may already be associated with another cluster,
// in which case we can know its distance from that cluster
NSNumber *existingDistance = [distanceToCluster objectForKey:clusterItem];

// Get distance from the new cluster location we're working on
double distance = [self.class distanceSquared:MKMapPointForCoordinate(clusterItem.coordinate) b:MKMapPointForCoordinate(candidate.coordinate)];

if (existingDistance != nil) {
// Item already belongs to another cluster. Check if it's closer to this cluster.
if ([existingDistance doubleValue] <= distance) {
// next clusterItem
continue;
}
// Remove from previous cluster.
CCHMapClusterAnnotation *prevCluster = [itemToCluster objectForKey:clusterItem];
if (prevCluster != nil) {
NSMutableSet *set = [NSMutableSet setWithSet:prevCluster.annotations];
[set minusSet:[NSSet setWithObject:clusterItem]];
prevCluster.annotations = set;
}

}

// Record new distance
[distanceToCluster setObject:[NSNumber numberWithDouble:distance] forKey:clusterItem];

// Add item to the cluster we're working on
cluster.annotations = [cluster.annotations setByAddingObject:clusterItem];

// Update mapping in our item-to-cluster map.
[itemToCluster setObject:cluster forKey:clusterItem];
}

// Mark all of them visited so we don't start considering them again
[visitedCandidates addObjectsFromArray:[clusterItems allObjects]];
}
}

// Tell client to reconfigure all annotations that we are re-using
for (CCHMapClusterAnnotation* anno in reusedAnnotations) {
dispatch_async(dispatch_get_main_queue(), ^{
anno.title = nil;
anno.subtitle = nil;
if (respondsToSelector) {
[_clusterControllerDelegate mapClusterController:_clusterController willReuseMapClusterAnnotation:anno];
}
});
}

// Figure out difference between new and old clusters
NSSet *annotationsBeforeAsSet = CCHMapClusterControllerClusterAnnotationsForAnnotations(self.mapViewAnnotations, self.clusterController);
NSMutableSet *annotationsToKeep = [NSMutableSet setWithSet:annotationsBeforeAsSet];
[annotationsToKeep intersectSet:results];
NSMutableSet *annotationsToAddAsSet = [NSMutableSet setWithSet:results];
[annotationsToAddAsSet minusSet:annotationsToKeep];
NSArray *annotationsToAdd = [annotationsToAddAsSet allObjects];
NSMutableSet *annotationsToRemoveAsSet = [NSMutableSet setWithSet:annotationsBeforeAsSet];
[annotationsToRemoveAsSet minusSet:results];
NSArray *annotationsToRemove = [annotationsToRemoveAsSet allObjects];

// Show cluster annotations on map
[_visibleAnnotationsMapTree removeAnnotations:annotationsToRemove];
[_visibleAnnotationsMapTree addAnnotations:annotationsToAdd];
dispatch_async(dispatch_get_main_queue(), ^{
[self.mapView addAnnotations:annotationsToAdd];
[self.animator mapClusterController:self.clusterController willRemoveAnnotations:annotationsToRemove withCompletionHandler:^{
[self.mapView removeAnnotations:annotationsToRemove];

self.executing = NO;
self.finished = YES;
}];
});
}


// Original Grid-based algorithm
- (void)start_grid
{
self.executing = YES;
double zoomLevel = CCHMapClusterControllerZoomLevelForRegion(self.mapViewRegion.center.longitude, self.mapViewRegion.span.longitudeDelta, self.mapViewWidth);
BOOL disableClustering = (zoomLevel > self.maxZoomLevelForClustering);
BOOL respondsToSelector = [_clusterControllerDelegate respondsToSelector:@selector(mapClusterController:willReuseMapClusterAnnotation:)];
// For each cell in the grid, pick one cluster annotation to show
MKMapRect gridMapRect = [self.class gridMapRectForMapRect:self.mapViewVisibleMapRect withCellMapSize:self.cellMapSize marginFactor:self.marginFactor];
NSMutableSet *clusters = [NSMutableSet set];
CCHMapClusterControllerEnumerateCells(gridMapRect, _cellMapSize, ^(MKMapRect cellMapRect) {
NSSet *allAnnotationsInCell = [_allAnnotationsMapTree annotationsInMapRect:cellMapRect];
if (allAnnotationsInCell.count > 0) {
BOOL annotationSetsAreUniqueLocations;
NSArray *annotationSets;
Expand Down Expand Up @@ -164,9 +385,10 @@ - (void)start
[visibleAnnotationsInCell removeObject:annotationForCell];
annotationForCell.annotations = annotationSet;
dispatch_async(dispatch_get_main_queue(), ^{
if (annotationSetsAreUniqueLocations) {
// 3/24/15
//if (annotationSetsAreUniqueLocations) {
annotationForCell.coordinate = coordinate;
}
//}
annotationForCell.title = nil;
annotationForCell.subtitle = nil;
if (respondsToSelector) {
Expand Down