diff --git a/example/lib/place.dart b/example/lib/place.dart index 07d4738..5a23b57 100644 --- a/example/lib/place.dart +++ b/example/lib/place.dart @@ -15,4 +15,9 @@ class Place with ClusterItem { @override LatLng get location => latLng; + + @override + set location(LatLng newLocation) { + location = newLocation; + } } diff --git a/lib/src/cluster.dart b/lib/src/cluster.dart index 236fb7f..b7e60fa 100644 --- a/lib/src/cluster.dart +++ b/lib/src/cluster.dart @@ -4,13 +4,31 @@ import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platf class Cluster { final LatLng location; final Iterable items; + bool isOverlapped; - Cluster(this.items) - : this.location = LatLng( + Cluster(this.items, this.location, { this.isOverlapped = false }); + + Cluster.fromItems(Iterable items, { bool isOverlapped = false }) + : this.items = items, + this.location = LatLng( items.fold(0.0, (p, c) => p + c.location.latitude) / items.length, items.fold(0.0, (p, c) => p + c.location.longitude) / - items.length); + items.length), + this.isOverlapped = isOverlapped + ; + + //location becomes weighted avarage lat lon + Cluster.fromClusters(Cluster cluster1, Cluster 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; @@ -20,6 +38,11 @@ class Cluster { /// 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() + @@ -30,4 +53,7 @@ class Cluster { 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; } diff --git a/lib/src/cluster_item.dart b/lib/src/cluster_item.dart index 1c345c6..78efd4a 100644 --- a/lib/src/cluster_item.dart +++ b/lib/src/cluster_item.dart @@ -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; } } diff --git a/lib/src/cluster_manager.dart b/lib/src/cluster_manager.dart index f7b09d9..cf173d6 100644 --- a/lib/src/cluster_manager.dart +++ b/lib/src/cluster_manager.dart @@ -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 { ClusterManager(this._items, this.updateMarkers, {Future Function(Cluster)? 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 Function(Cluster) 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) updateMarkers; @@ -28,12 +60,24 @@ class ClusterManager { /// 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; @@ -68,46 +112,129 @@ class ClusterManager { } /// Update all cluster items - void setItems(List newItems) { + void setItems(List 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>> getMarkers() async { - if (_mapId == null) return List.empty(); - + /// Return the geo-calc inflated bounds + Future 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> buildPlainListWithOverlappingCluster(List 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> _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.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.fromItems([i])).toList(); + // l.forEach((e) { print('@@@@@@ id: ${e.getId()} ${e.location}'); }); + /// Otherwise, simple list + return items.map((i) => Cluster.fromItems([i])).toList(); + } + + /// Retrieve cluster markers + Future>> 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.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 visibleItems = items.where((i) { return inflatedBounds.contains(i.location); }).toList(); - if (stopClusteringZoom != null && _zoom >= stopClusteringZoom!) - return visibleItems.map((i) => Cluster([i])).toList(); - - int level = _findLevel(levels); - List> markers = _computeClusters( - visibleItems, List.empty(growable: true), - level: level); - return markers; + if (clusterAlgorithm == ClusterAlgorithm.GEOHASH || + visibleItems.length >= maxItemsForMaxDistAlgo) { + int level = _findLevel(levels); + List> markers = _computeClusters( + visibleItems, List.empty(growable: true), + level: level); + return markers; + } else { + List> markers = + _computeClustersWithMaxDist(visibleItems, _zoom); + return markers; + } } LatLngBounds _inflateBounds(LatLngBounds bounds) { @@ -146,18 +273,36 @@ class ClusterManager { 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> _computeClustersWithMaxDist( + List inputItems, double zoom) { + MaxDistClustering scanner = MaxDistClustering( + epsilon: maxDistParams?.epsilon ?? 20, + ); + + return scanner.run(inputItems, _getZoomLevel(zoom)); + } + List> _computeClusters( List inputItems, List> markerItems, {int level = 5}) { if (inputItems.isEmpty) return markerItems; - String nextGeohash = inputItems[0].geohash.substring(0, level); List items = inputItems .where((p) => p.geohash.substring(0, level) == nextGeohash) .toList(); - markerItems.add(Cluster(items)); + markerItems.add(Cluster.fromItems(items)); List newInputList = List.from( inputItems.where((i) => i.geohash.substring(0, level) != nextGeohash)); diff --git a/lib/src/common.dart b/lib/src/common.dart new file mode 100644 index 0000000..1db1436 --- /dev/null +++ b/lib/src/common.dart @@ -0,0 +1,184 @@ +import 'dart:math'; +import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; + +const int _thd = 1000; +const double _PI = 3.141592653589793238; +const num _RADIUS = 6371e3; + +class _Tuple { + final LatLng pos1; + final LatLng pos2; + + _Tuple(this.pos1, this.pos2); + + bool operator ==(o) => o is _Tuple && pos1 == o.pos1 && pos2 == o.pos2; + int get hashCode => pos1.hashCode + pos2.hashCode; +} + +class DistUtils { +// Zoom-Level: +// Level # Tiles Tile width +// (° of longitudes) m / pixel +// (on Equator) ~ Scale +// (on screen) Examples of +// areas to represent +// 0 1 360 156 412 1:500 million whole world +// 1 4 180 78 206 1:250 million +// 2 16 90 39 103 1:150 million subcontinental area +// 3 64 45 19 551 1:70 million largest country +// 4 256 22.5 9 776 1:35 million +// 5 1 024 11.25 4 888 1:15 million large African country +// 6 4 096 5.625 2 444 1:10 million large European country +// 7 16 384 2.813 1 222 1:4 million small country, US state +// 8 65 536 1.406 610.984 1:2 million +// 9 262 144 0.703 305.492 1:1 million wide area, large metropolitan area +// 10 1 048 576 0.352 152.746 1:500 thousand metropolitan area +// 11 4 194 304 0.176 76.373 1:250 thousand city +// 12 16 777 216 0.088 38.187 1:150 thousand town, or city district +// 13 67 108 864 0.044 19.093 1:70 thousand village, or suburb +// 14 268 435 456 0.022 9.547 1:35 thousand +// 15 1 073 741 824 0.011 4.773 1:15 thousand small road +// 16 4 294 967 296 0.005 2.387 1:8 thousand street +// 17 17 179 869 184 0.003 1.193 1:4 thousand block, park, addresses +// 18 68 719 476 736 0.001 0.596 1:2 thousand some buildings, trees +// 19 274 877 906 944 0.0005 0.298 1:1 thousand local highway and crossing details +// 20 1 099 511 627 776 0.00025 0.149 1:5 hundred A mid-sized building + final Map<_Tuple, double> distCache = {}; + + double getLatLonDist(LatLng point1, LatLng point2, int zoomLevel) { + if (distCache[_Tuple(point1, point2)] != null) { + return distCache[_Tuple(point1, point2)]!; + } + double meterPerPixel = _getScalingFactor(zoomLevel); + double dist = getDistanceFromLatLonInKm(point1.latitude, point1.longitude, + point2.latitude, point2.longitude) / + (meterPerPixel / _thd); + // print("dist is $x"); + distCache[_Tuple(point1, point2)] = dist; + // print('DISTANCE IS: $dist'); + return dist; + } + + double getDistanceFromLatLonInKm( + double lat1, double lon1, double lat2, double lon2) { + var R = 6371; // Radius of the earth in km + var dLat = _degreeToRadian(lat2 - lat1); + var dLon = _degreeToRadian(lon2 - lon1); + var a = sin(dLat / 2) * sin(dLat / 2) + + cos(_degreeToRadian(lat1)) * + cos(_degreeToRadian(lat2)) * + sin(dLon / 2) * + sin(dLon / 2); + var c = 2 * atan2(sqrt(a), sqrt(1 - a)); + var d = R * c; // Distance in km + return d; + } + + double _degreeToRadian(double degree) { + return degree * _PI / 180; + } + + double _radianToDegree(double rad) { + return rad * (180.0 / _PI); + } + + double _getScalingFactor(int zoomLevel) { + switch (zoomLevel) { + case 0: + return 156412; + case 1: + return 78206; + case 2: + return 39103; + case 3: + return 19551; + case 4: + return 9776; + case 5: + return 4888; + case 6: + return 2444; + case 7: + return 1222; + case 8: + return 610.984; + case 9: + return 305.492; + case 10: + return 152.746; + case 11: + return 76.373; + case 12: + return 38.187; + case 13: + return 19.093; + case 14: + return 9.547; + case 15: + return 4.773; + case 16: + return 2.387; + case 17: + return 1.193; + case 18: + return 0.596; + case 19: + return 0.298; + case 20: + return 0.149; + default: + return 0.149; + } + } + + /// + /// Get distance from point by bearing and km + LatLng getPointAtDistanceFrom(LatLng startPoint, double initialBearingRadians, double distanceKilometres) { + const double radiusEarthKilometres = 6371.01; + var distRatio = distanceKilometres / radiusEarthKilometres; + var distRatioSine = sin(distRatio); + var distRatioCosine = cos(distRatio); + + var startLatRad = _degreeToRadian(startPoint.latitude); + var startLonRad = _degreeToRadian(startPoint.longitude); + + var startLatCos = cos(startLatRad); + var startLatSin = sin(startLatRad); + + var endLatRads = asin((startLatSin * distRatioCosine) + (startLatCos * distRatioSine * cos(initialBearingRadians))); + + var endLonRads = startLonRad + + atan2( + sin(initialBearingRadians) * distRatioSine * startLatCos, + distRatioCosine - startLatSin * sin(endLatRads)); + + return LatLng(_radianToDegree(endLatRads), _radianToDegree(endLonRads)); + } + + /// calculate a destination point given the distance and bearing + LatLng destinationPointByDistanceAndBearing(LatLng l, num distance, num bearing, [num? radius]) { + radius ??= _RADIUS; + + final num angularDistanceRadius = distance / radius; + final num bearingRadians = _degreeToRadian(bearing as double); + + final num latRadians = _degreeToRadian(l.latitude); + final num lngRadians = _degreeToRadian(l.longitude); + + final num sinLatRadians = sin(latRadians); + final num cosLatRadians = cos(latRadians); + final num sinAngularDistanceRadius = sin(angularDistanceRadius); + final num cosAngularDistanceRadius = cos(angularDistanceRadius); + final num sinBearingRadians = sin(bearingRadians); + final num cosBearingRadians = cos(bearingRadians); + + final sinLatRadians2 = sinLatRadians * cosAngularDistanceRadius + + cosLatRadians * sinAngularDistanceRadius * cosBearingRadians; + final num latRadians2 = asin(sinLatRadians2); + final y = sinBearingRadians * sinAngularDistanceRadius * cosLatRadians; + final x = cosAngularDistanceRadius - sinLatRadians * sinLatRadians2; + final num lngRadians2 = lngRadians + atan2(y, x); + return LatLng(_radianToDegree(latRadians2 as double), + (_radianToDegree(lngRadians2 as double) + 540) % 360 - 180); + } +} \ No newline at end of file diff --git a/lib/src/max_dist_clustering.dart b/lib/src/max_dist_clustering.dart new file mode 100644 index 0000000..2fda17f --- /dev/null +++ b/lib/src/max_dist_clustering.dart @@ -0,0 +1,68 @@ +import '../google_maps_cluster_manager.dart'; +import 'common.dart'; + +class _MinDistCluster { + final Cluster cluster; + final double dist; + + _MinDistCluster(this.cluster, this.dist); +} + +class MaxDistClustering { + ///Complete list of points + late List dataset; + + List> _cluster = []; + + ///Threshold distance for two clusters to be considered as one cluster + final double epsilon; + + final DistUtils distUtils = DistUtils(); + + MaxDistClustering({ + this.epsilon = 1, + }); + + ///Run clustering process, add configs in constructor + List> run(List dataset, int zoomLevel) { + this.dataset = dataset; + + //initial variables + List> distMatrix = []; + for (T entry1 in dataset) { + distMatrix.add([]); + _cluster.add(Cluster.fromItems([entry1])); + } + bool changed = true; + while (changed) { + changed = false; + for (Cluster c in _cluster) { + _MinDistCluster? minDistCluster = getClosestCluster(c, zoomLevel); + // print("mindistcluster ${minDistCluster?.dist}"); + if (minDistCluster == null || minDistCluster.dist > epsilon) continue; + _cluster.add(Cluster.fromClusters(minDistCluster.cluster, c)); + _cluster.remove(c); + _cluster.remove(minDistCluster.cluster); + changed = true; + + break; + } + } + return _cluster; + } + + _MinDistCluster? getClosestCluster(Cluster cluster, int zoomLevel) { + double minDist = 1000000000; + Cluster minDistCluster = Cluster.fromItems([]); + for (Cluster c in _cluster) { + if (c.location == cluster.location) continue; + double tmp = + distUtils.getLatLonDist(c.location, cluster.location, zoomLevel); + if (tmp < minDist) { + minDist = tmp; + minDistCluster = Cluster.fromItems(c.items); + } + } + return _MinDistCluster(minDistCluster, minDist); + } +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index c4edf3f..d34e39f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,7 +10,7 @@ dependencies: flutter: sdk: flutter - google_maps_flutter_platform_interface: ^2.1.0 + google_maps_flutter_platform_interface: ^2.1.3 dev_dependencies: flutter_test: