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
5 changes: 5 additions & 0 deletions example/lib/place.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,9 @@ class Place with ClusterItem {

@override
LatLng get location => latLng;

@override
set location(LatLng newLocation) {
location = newLocation;
}
}
32 changes: 29 additions & 3 deletions lib/src/cluster.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,31 @@ import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platf
class Cluster<T extends ClusterItem> {
final LatLng location;
final Iterable<T> items;
bool isOverlapped;

Cluster(this.items)
: this.location = LatLng(
Cluster(this.items, this.location, { this.isOverlapped = false });

Cluster.fromItems(Iterable<T> items, { bool isOverlapped = false })
: this.items = items,
this.location = LatLng(
items.fold<double>(0.0, (p, c) => p + c.location.latitude) /
items.length,
items.fold<double>(0.0, (p, c) => p + c.location.longitude) /
items.length);
items.length),
this.isOverlapped = isOverlapped
;

//location becomes weighted avarage lat lon
Cluster.fromClusters(Cluster<T> cluster1, Cluster<T> cluster2)
: this.items = cluster1.items.toSet()..addAll(cluster2.items.toSet()),
this.isOverlapped = false,
this.location = LatLng(
(cluster1.location.latitude * cluster1.count +
cluster2.location.latitude * cluster2.count) /
(cluster1.count + cluster2.count),
(cluster1.location.longitude * cluster1.count +
cluster2.location.longitude * cluster2.count) /
(cluster1.count + cluster2.count));

/// Get number of clustered items
int get count => items.length;
Expand All @@ -20,6 +38,11 @@ class Cluster<T extends ClusterItem> {

/// Basic cluster marker id
String getId() {
final idList = items.where((e) => e.getId() != null).map((e) => e.getId()).toList();
if (idList.isNotEmpty) {
return idList.join(':');
}

return location.latitude.toString() +
"_" +
location.longitude.toString() +
Expand All @@ -30,4 +53,7 @@ class Cluster<T extends ClusterItem> {
String toString() {
return 'Cluster of $count $T (${location.latitude}, ${location.longitude})';
}

bool operator ==(o) => o is Cluster && items == o.items;
int get hashCode => items.hashCode;
}
7 changes: 7 additions & 0 deletions lib/src/cluster_item.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@ import 'package:google_maps_cluster_manager/google_maps_cluster_manager.dart';
import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart';

abstract class ClusterItem {
/// Getter for location
LatLng get location;
/// Setter for location.
set location(LatLng newLocation);

String? _geohash;
String get geohash => _geohash ??=
Geohash.encode(location, codeLength: ClusterManager.precision);

/// base getId.
/// If you override it, it uses it's value
String? getId() { return null; }
}
191 changes: 168 additions & 23 deletions lib/src/cluster_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,52 @@ import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:google_maps_cluster_manager/google_maps_cluster_manager.dart';
import 'package:google_maps_cluster_manager/src/common.dart';
import 'package:google_maps_cluster_manager/src/max_dist_clustering.dart';
import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart';

enum ClusterAlgorithm { GEOHASH, MAX_DIST }
enum ClusterOverlapping { NONE, OVERLAP, DISTRIBUTE }

class MaxDistParams {
final double epsilon;

MaxDistParams(this.epsilon);
}

class ClusterOverlappingParams {
final double bearing;
final double distance;
final double overlappingDistanceLimitInMeters;

ClusterOverlappingParams({
this.bearing = 0.3,
this.distance = 0.4,
this.overlappingDistanceLimitInMeters = 20,
});
}

class ClusterManager<T extends ClusterItem> {
ClusterManager(this._items, this.updateMarkers,
{Future<Marker> Function(Cluster<T>)? markerBuilder,
this.levels = const [1, 4.25, 6.75, 8.25, 11.5, 14.5, 16.0, 16.5, 20.0],
this.extraPercent = 0.5,
this.stopClusteringZoom})
this.levels = const [1, 4.25, 6.75, 8.25, 11.5, 14.5, 16.0, 16.5, 20.0],
this.extraPercent = 0.5,
this.maxItemsForMaxDistAlgo = 200,
this.clusterAlgorithm = ClusterAlgorithm.GEOHASH,
this.maxDistParams,
this.stopClusteringZoom,
this.clusterOverlapping = ClusterOverlapping.NONE,
this.clusterOverlappingParams,
})
: this.markerBuilder = markerBuilder ?? _basicMarkerBuilder,
assert(levels.length <= precision);

/// Method to build markers
final Future<Marker> Function(Cluster<T>) markerBuilder;

/// Num of Items to switch from MAX_DIST algo to GEOHASH
final int maxItemsForMaxDistAlgo;

/// Function to update Markers on Google Map
final void Function(Set<Marker>) updateMarkers;

Expand All @@ -28,12 +60,24 @@ class ClusterManager<T extends ClusterItem> {
/// Extra percent of markers to be loaded (ex : 0.2 for 20%)
final double extraPercent;

/// Clusteringalgorithm
final ClusterAlgorithm clusterAlgorithm;

/// Max dists params
final MaxDistParams? maxDistParams;

/// Zoom level to stop cluster rendering
final double? stopClusteringZoom;

/// Precision of the geohash
static final int precision = kIsWeb ? 12 : 20;

/// Overlapping option
final ClusterOverlapping clusterOverlapping;

/// Overlapping distance limit
ClusterOverlappingParams? clusterOverlappingParams;

/// Google Maps map id
int? _mapId;

Expand Down Expand Up @@ -68,46 +112,129 @@ class ClusterManager<T extends ClusterItem> {
}

/// Update all cluster items
void setItems(List<T> newItems) {
void setItems(List<T> newItems, { bool update = true }) {
_items = newItems;
updateMap();
if (update) {
updateMap();
}
}

/// Add on cluster item
void addItem(ClusterItem newItem) {
void addItem(ClusterItem newItem, { bool update = true }) {
_items = List.from([...items, newItem]);
updateMap();
if (update) {
updateMap();
}
}

/// Method called on camera move
void onCameraMove(CameraPosition position, {forceUpdate = false}) {
void onCameraMove(CameraPosition position, { bool forceUpdate = false }) {
_zoom = position.zoom;
if (forceUpdate) {
updateMap();
}
}

/// Retrieve cluster markers
Future<List<Cluster<T>>> getMarkers() async {
if (_mapId == null) return List.empty();

/// Return the geo-calc inflated bounds
Future<LatLngBounds?> getInflatedBounds() async {
if (_mapId == null) return null;
final LatLngBounds mapBounds = await GoogleMapsFlutterPlatform.instance
.getVisibleRegion(mapId: _mapId!);

final LatLngBounds inflatedBounds = _inflateBounds(mapBounds);
late LatLngBounds inflatedBounds;
if (clusterAlgorithm == ClusterAlgorithm.GEOHASH) {
inflatedBounds = _inflateBounds(mapBounds);
} else {
inflatedBounds = mapBounds;
}
return inflatedBounds;
}

/// Build cluster items in case of overlap
List<Cluster<T>> buildPlainListWithOverlappingCluster(List<T> items) {
final DistUtils distUtils = DistUtils();
// items.forEach((e) { print('***** id: ${e.getId()} ${e.location}'); });

clusterOverlappingParams ??= ClusterOverlappingParams();
// print('BUILD OVERLAPPED WITH $clusterOverlapping');
/// Overlapping: if the points are in the same place, create fixed cluster
if (clusterOverlapping == ClusterOverlapping.OVERLAP) {
Map<LatLng, List<T>> _map = {};
items.forEach((e) {
final key = _map.keys.firstWhere(
(k) => distUtils.getLatLonDist(k, e.location, _getZoomLevel(_zoom)) <= (clusterOverlappingParams!.overlappingDistanceLimitInMeters), orElse: () => LatLng(0, 0)
);
if (key.longitude != 0) {
_map[key]?.add(e);
} else {
_map[e.location] = [e];
}
});
return _map.values.map((i) =>
Cluster<T>.fromItems(i, isOverlapped: i.length > 1)).toList();
}

/// Distribute: if the points are in the same place, put aside
if (clusterOverlapping == ClusterOverlapping.DISTRIBUTE) {
var bearing = clusterOverlappingParams!.bearing;
for(var i = 0; i < items.length; i++) {
for(var j = i + 1; j < items.length; j++) {
final dist = distUtils.getLatLonDist(
items[i].location, items[j].location, _getZoomLevel(_zoom));

// print('parking id: ${items[i].getId()} - ${items[j].getId()} = $dist (of ${clusterOverlappingParams!.overlappingDistanceLimitInMeters})');
if (dist < (clusterOverlappingParams!.overlappingDistanceLimitInMeters)) {
items[i].location = distUtils.getPointAtDistanceFrom(
items[i].location,
bearing,
clusterOverlappingParams!.distance
);
bearing += clusterOverlappingParams!.bearing;
}
}
}
}

// final l = items.map((i) => Cluster<T>.fromItems([i])).toList();
// l.forEach((e) { print('@@@@@@ id: ${e.getId()} ${e.location}'); });

/// Otherwise, simple list
return items.map((i) => Cluster<T>.fromItems([i])).toList();
}

/// Retrieve cluster markers
Future<List<Cluster<T>>> getMarkers() async {
final inflatedBounds = await getInflatedBounds();
if (inflatedBounds == null) return List.empty();

print('STOP $stopClusteringZoom AT ZOOM $_zoom');
// in case of stopping zoom clustering and custom overlapping conf,
// clear visible point in bounds after change point positions
if (stopClusteringZoom != null && _zoom >= stopClusteringZoom!) {
// return visibleItems.map((i) => Cluster<T>.fromItems([i])).toList();
final l = buildPlainListWithOverlappingCluster(items.toList()); // visibleItems);
return l.where((i) {
return inflatedBounds.contains(i.location);
}).toList();
}

// otherwise go ahead with simple standard clustering logic
List<T> visibleItems = items.where((i) {
return inflatedBounds.contains(i.location);
}).toList();

if (stopClusteringZoom != null && _zoom >= stopClusteringZoom!)
return visibleItems.map((i) => Cluster<T>([i])).toList();

int level = _findLevel(levels);
List<Cluster<T>> markers = _computeClusters(
visibleItems, List.empty(growable: true),
level: level);
return markers;
if (clusterAlgorithm == ClusterAlgorithm.GEOHASH ||
visibleItems.length >= maxItemsForMaxDistAlgo) {
int level = _findLevel(levels);
List<Cluster<T>> markers = _computeClusters(
visibleItems, List.empty(growable: true),
level: level);
return markers;
} else {
List<Cluster<T>> markers =
_computeClustersWithMaxDist(visibleItems, _zoom);
return markers;
}
}

LatLngBounds _inflateBounds(LatLngBounds bounds) {
Expand Down Expand Up @@ -146,18 +273,36 @@ class ClusterManager<T extends ClusterItem> {
return 1;
}

int _getZoomLevel(double zoom) {
for (int i = levels.length - 1; i >= 0; i--) {
if (levels[i] <= zoom) {
return levels[i].toInt();
}
}

return 1;
}

List<Cluster<T>> _computeClustersWithMaxDist(
List<T> inputItems, double zoom) {
MaxDistClustering<T> scanner = MaxDistClustering(
epsilon: maxDistParams?.epsilon ?? 20,
);

return scanner.run(inputItems, _getZoomLevel(zoom));
}

List<Cluster<T>> _computeClusters(
List<T> inputItems, List<Cluster<T>> markerItems,
{int level = 5}) {
if (inputItems.isEmpty) return markerItems;

String nextGeohash = inputItems[0].geohash.substring(0, level);

List<T> items = inputItems
.where((p) => p.geohash.substring(0, level) == nextGeohash)
.toList();

markerItems.add(Cluster<T>(items));
markerItems.add(Cluster<T>.fromItems(items));

List<T> newInputList = List.from(
inputItems.where((i) => i.geohash.substring(0, level) != nextGeohash));
Expand Down
Loading