From cc47b06b438d0427a775fbbd5f96306fe5bbcb19 Mon Sep 17 00:00:00 2001 From: Joonas Kerttula Date: Tue, 30 Apr 2024 12:13:28 +0300 Subject: [PATCH] [google_maps_flutter_web] Add marker clustering support (#6187) This PR introduces support for marker clustering for Web platform This is prequel PR for: https://github.com/flutter/packages/pull/4319 and sequel PR for: https://github.com/flutter/packages/pull/6158 Containing only changes to `google_maps_flutter_web` package. Follow up PR will hold the app-facing plugin implementation. Linked issue: https://github.com/flutter/flutter/issues/26863 --------- Co-authored-by: David Iglesias Teixeira --- .../google_maps_flutter_web/AUTHORS | 1 + .../google_maps_flutter_web/CHANGELOG.md | 4 + .../google_maps_flutter_web/README.md | 13 ++ .../google_maps_controller_test.mocks.dart | 43 ----- .../google_maps_plugin_test.dart | 4 +- .../google_maps_plugin_test.mocks.dart | 51 +++--- .../marker_clustering_test.dart | 170 ++++++++++++++++++ .../integration_test/markers_test.dart | 8 +- .../example/pubspec.yaml | 2 +- .../example/web/index.html | 1 + .../lib/google_maps_flutter_web.dart | 1 + .../lib/src/convert.dart | 12 +- .../lib/src/google_maps_controller.dart | 46 ++++- .../lib/src/google_maps_flutter_web.dart | 14 ++ .../lib/src/google_maps_inspector_web.dart | 22 ++- .../lib/src/marker.dart | 9 +- .../lib/src/marker_clustering.dart | 139 ++++++++++++++ .../lib/src/marker_clustering_js_interop.dart | 164 +++++++++++++++++ .../lib/src/markers.dart | 56 ++++-- .../google_maps_flutter_web/pubspec.yaml | 4 +- 20 files changed, 657 insertions(+), 107 deletions(-) create mode 100644 packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/marker_clustering_test.dart create mode 100644 packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker_clustering.dart create mode 100644 packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker_clustering_js_interop.dart diff --git a/packages/google_maps_flutter/google_maps_flutter_web/AUTHORS b/packages/google_maps_flutter/google_maps_flutter_web/AUTHORS index b5b0e84d473..906ab219bee 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/AUTHORS +++ b/packages/google_maps_flutter/google_maps_flutter_web/AUTHORS @@ -65,3 +65,4 @@ Anton Borries Alex Li Rahul Raj <64.rahulraj@gmail.com> Justin Baumann +Joonas Kerttula diff --git a/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md index 08d4608228c..77f222b1193 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.5.7 + +* Adds support for marker clustering. + ## 0.5.6+2 * Uses `TrustedTypes` from `web: ^0.5.1`. diff --git a/packages/google_maps_flutter/google_maps_flutter_web/README.md b/packages/google_maps_flutter/google_maps_flutter_web/README.md index b52da95119c..22acc45a807 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/README.md +++ b/packages/google_maps_flutter/google_maps_flutter_web/README.md @@ -50,6 +50,19 @@ To request multiple libraries, separate them with commas: Now you should be able to use the Google Maps plugin normally. +## Marker clustering + +If you need marker clustering support, modify the tag to load the [js-markerclusterer](https://github.com/googlemaps/js-markerclusterer#install) library. Ensure you are using the currently supported version `2.5.3`, like so: + +```html + + + + + + +``` + ## Limitations of the web version The following map options are not available in web, because the map doesn't rotate there: diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.mocks.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.mocks.dart index c85599934ba..ee6ffe02a42 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.mocks.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.mocks.dart @@ -42,7 +42,6 @@ class MockCirclesController extends _i1.Mock implements _i3.CirclesController { returnValue: <_i4.CircleId, _i3.CircleController>{}, returnValueForMissingStub: <_i4.CircleId, _i3.CircleController>{}, ) as Map<_i4.CircleId, _i3.CircleController>); - @override _i2.GMap get googleMap => (super.noSuchMethod( Invocation.getter(#googleMap), @@ -55,7 +54,6 @@ class MockCirclesController extends _i1.Mock implements _i3.CirclesController { Invocation.getter(#googleMap), ), ) as _i2.GMap); - @override set googleMap(_i2.GMap? _googleMap) => super.noSuchMethod( Invocation.setter( @@ -64,14 +62,12 @@ class MockCirclesController extends _i1.Mock implements _i3.CirclesController { ), returnValueForMissingStub: null, ); - @override int get mapId => (super.noSuchMethod( Invocation.getter(#mapId), returnValue: 0, returnValueForMissingStub: 0, ) as int); - @override set mapId(int? _mapId) => super.noSuchMethod( Invocation.setter( @@ -80,7 +76,6 @@ class MockCirclesController extends _i1.Mock implements _i3.CirclesController { ), returnValueForMissingStub: null, ); - @override void addCircles(Set<_i4.Circle>? circlesToAdd) => super.noSuchMethod( Invocation.method( @@ -89,7 +84,6 @@ class MockCirclesController extends _i1.Mock implements _i3.CirclesController { ), returnValueForMissingStub: null, ); - @override void changeCircles(Set<_i4.Circle>? circlesToChange) => super.noSuchMethod( Invocation.method( @@ -98,7 +92,6 @@ class MockCirclesController extends _i1.Mock implements _i3.CirclesController { ), returnValueForMissingStub: null, ); - @override void removeCircles(Set<_i4.CircleId>? circleIdsToRemove) => super.noSuchMethod( @@ -108,7 +101,6 @@ class MockCirclesController extends _i1.Mock implements _i3.CirclesController { ), returnValueForMissingStub: null, ); - @override void bindToMap( int? mapId, @@ -137,7 +129,6 @@ class MockPolygonsController extends _i1.Mock returnValue: <_i4.PolygonId, _i3.PolygonController>{}, returnValueForMissingStub: <_i4.PolygonId, _i3.PolygonController>{}, ) as Map<_i4.PolygonId, _i3.PolygonController>); - @override _i2.GMap get googleMap => (super.noSuchMethod( Invocation.getter(#googleMap), @@ -150,7 +141,6 @@ class MockPolygonsController extends _i1.Mock Invocation.getter(#googleMap), ), ) as _i2.GMap); - @override set googleMap(_i2.GMap? _googleMap) => super.noSuchMethod( Invocation.setter( @@ -159,14 +149,12 @@ class MockPolygonsController extends _i1.Mock ), returnValueForMissingStub: null, ); - @override int get mapId => (super.noSuchMethod( Invocation.getter(#mapId), returnValue: 0, returnValueForMissingStub: 0, ) as int); - @override set mapId(int? _mapId) => super.noSuchMethod( Invocation.setter( @@ -175,7 +163,6 @@ class MockPolygonsController extends _i1.Mock ), returnValueForMissingStub: null, ); - @override void addPolygons(Set<_i4.Polygon>? polygonsToAdd) => super.noSuchMethod( Invocation.method( @@ -184,7 +171,6 @@ class MockPolygonsController extends _i1.Mock ), returnValueForMissingStub: null, ); - @override void changePolygons(Set<_i4.Polygon>? polygonsToChange) => super.noSuchMethod( Invocation.method( @@ -193,7 +179,6 @@ class MockPolygonsController extends _i1.Mock ), returnValueForMissingStub: null, ); - @override void removePolygons(Set<_i4.PolygonId>? polygonIdsToRemove) => super.noSuchMethod( @@ -203,7 +188,6 @@ class MockPolygonsController extends _i1.Mock ), returnValueForMissingStub: null, ); - @override void bindToMap( int? mapId, @@ -232,7 +216,6 @@ class MockPolylinesController extends _i1.Mock returnValue: <_i4.PolylineId, _i3.PolylineController>{}, returnValueForMissingStub: <_i4.PolylineId, _i3.PolylineController>{}, ) as Map<_i4.PolylineId, _i3.PolylineController>); - @override _i2.GMap get googleMap => (super.noSuchMethod( Invocation.getter(#googleMap), @@ -245,7 +228,6 @@ class MockPolylinesController extends _i1.Mock Invocation.getter(#googleMap), ), ) as _i2.GMap); - @override set googleMap(_i2.GMap? _googleMap) => super.noSuchMethod( Invocation.setter( @@ -254,14 +236,12 @@ class MockPolylinesController extends _i1.Mock ), returnValueForMissingStub: null, ); - @override int get mapId => (super.noSuchMethod( Invocation.getter(#mapId), returnValue: 0, returnValueForMissingStub: 0, ) as int); - @override set mapId(int? _mapId) => super.noSuchMethod( Invocation.setter( @@ -270,7 +250,6 @@ class MockPolylinesController extends _i1.Mock ), returnValueForMissingStub: null, ); - @override void addPolylines(Set<_i4.Polyline>? polylinesToAdd) => super.noSuchMethod( Invocation.method( @@ -279,7 +258,6 @@ class MockPolylinesController extends _i1.Mock ), returnValueForMissingStub: null, ); - @override void changePolylines(Set<_i4.Polyline>? polylinesToChange) => super.noSuchMethod( @@ -289,7 +267,6 @@ class MockPolylinesController extends _i1.Mock ), returnValueForMissingStub: null, ); - @override void removePolylines(Set<_i4.PolylineId>? polylineIdsToRemove) => super.noSuchMethod( @@ -299,7 +276,6 @@ class MockPolylinesController extends _i1.Mock ), returnValueForMissingStub: null, ); - @override void bindToMap( int? mapId, @@ -327,7 +303,6 @@ class MockMarkersController extends _i1.Mock implements _i3.MarkersController { returnValue: <_i4.MarkerId, _i3.MarkerController>{}, returnValueForMissingStub: <_i4.MarkerId, _i3.MarkerController>{}, ) as Map<_i4.MarkerId, _i3.MarkerController>); - @override _i2.GMap get googleMap => (super.noSuchMethod( Invocation.getter(#googleMap), @@ -340,7 +315,6 @@ class MockMarkersController extends _i1.Mock implements _i3.MarkersController { Invocation.getter(#googleMap), ), ) as _i2.GMap); - @override set googleMap(_i2.GMap? _googleMap) => super.noSuchMethod( Invocation.setter( @@ -349,14 +323,12 @@ class MockMarkersController extends _i1.Mock implements _i3.MarkersController { ), returnValueForMissingStub: null, ); - @override int get mapId => (super.noSuchMethod( Invocation.getter(#mapId), returnValue: 0, returnValueForMissingStub: 0, ) as int); - @override set mapId(int? _mapId) => super.noSuchMethod( Invocation.setter( @@ -365,7 +337,6 @@ class MockMarkersController extends _i1.Mock implements _i3.MarkersController { ), returnValueForMissingStub: null, ); - @override void addMarkers(Set<_i4.Marker>? markersToAdd) => super.noSuchMethod( Invocation.method( @@ -374,7 +345,6 @@ class MockMarkersController extends _i1.Mock implements _i3.MarkersController { ), returnValueForMissingStub: null, ); - @override void changeMarkers(Set<_i4.Marker>? markersToChange) => super.noSuchMethod( Invocation.method( @@ -383,7 +353,6 @@ class MockMarkersController extends _i1.Mock implements _i3.MarkersController { ), returnValueForMissingStub: null, ); - @override void removeMarkers(Set<_i4.MarkerId>? markerIdsToRemove) => super.noSuchMethod( @@ -393,7 +362,6 @@ class MockMarkersController extends _i1.Mock implements _i3.MarkersController { ), returnValueForMissingStub: null, ); - @override void showMarkerInfoWindow(_i4.MarkerId? markerId) => super.noSuchMethod( Invocation.method( @@ -402,7 +370,6 @@ class MockMarkersController extends _i1.Mock implements _i3.MarkersController { ), returnValueForMissingStub: null, ); - @override void hideMarkerInfoWindow(_i4.MarkerId? markerId) => super.noSuchMethod( Invocation.method( @@ -411,7 +378,6 @@ class MockMarkersController extends _i1.Mock implements _i3.MarkersController { ), returnValueForMissingStub: null, ); - @override bool isInfoWindowShown(_i4.MarkerId? markerId) => (super.noSuchMethod( Invocation.method( @@ -421,7 +387,6 @@ class MockMarkersController extends _i1.Mock implements _i3.MarkersController { returnValue: false, returnValueForMissingStub: false, ) as bool); - @override void bindToMap( int? mapId, @@ -456,7 +421,6 @@ class MockTileOverlaysController extends _i1.Mock Invocation.getter(#googleMap), ), ) as _i2.GMap); - @override set googleMap(_i2.GMap? _googleMap) => super.noSuchMethod( Invocation.setter( @@ -465,14 +429,12 @@ class MockTileOverlaysController extends _i1.Mock ), returnValueForMissingStub: null, ); - @override int get mapId => (super.noSuchMethod( Invocation.getter(#mapId), returnValue: 0, returnValueForMissingStub: 0, ) as int); - @override set mapId(int? _mapId) => super.noSuchMethod( Invocation.setter( @@ -481,7 +443,6 @@ class MockTileOverlaysController extends _i1.Mock ), returnValueForMissingStub: null, ); - @override void addTileOverlays(Set<_i4.TileOverlay>? tileOverlaysToAdd) => super.noSuchMethod( @@ -491,7 +452,6 @@ class MockTileOverlaysController extends _i1.Mock ), returnValueForMissingStub: null, ); - @override void changeTileOverlays(Set<_i4.TileOverlay>? tileOverlays) => super.noSuchMethod( @@ -501,7 +461,6 @@ class MockTileOverlaysController extends _i1.Mock ), returnValueForMissingStub: null, ); - @override void removeTileOverlays(Set<_i4.TileOverlayId>? tileOverlayIds) => super.noSuchMethod( @@ -511,7 +470,6 @@ class MockTileOverlaysController extends _i1.Mock ), returnValueForMissingStub: null, ); - @override void clearTileCache(_i4.TileOverlayId? tileOverlayId) => super.noSuchMethod( Invocation.method( @@ -520,7 +478,6 @@ class MockTileOverlaysController extends _i1.Mock ), returnValueForMissingStub: null, ); - @override void bindToMap( int? mapId, diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.dart index 36b4d11e07d..b84b267c042 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.dart @@ -361,10 +361,10 @@ void main() { }); testWidgets('isMarkerInfoWindowShown', (WidgetTester tester) async { - when(controller.isInfoWindowShown(any)).thenReturn(true); - const MarkerId markerId = MarkerId('testing-123'); + when(controller.isInfoWindowShown(markerId)).thenReturn(true); + await plugin.isMarkerInfoWindowShown(markerId, mapId: mapId); verify(controller.isInfoWindowShown(markerId)); diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.mocks.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.mocks.dart index 3f84b40adc8..582d6ddc1de 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.mocks.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.mocks.dart @@ -9,6 +9,7 @@ import 'package:google_maps/google_maps.dart' as _i5; import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart' as _i2; import 'package:google_maps_flutter_web/google_maps_flutter_web.dart' as _i4; +import 'package:google_maps_flutter_web/src/marker_clustering.dart' as _i6; import 'package:mockito/mockito.dart' as _i1; // ignore_for_file: type=lint @@ -94,42 +95,37 @@ class MockGoogleMapController extends _i1.Mock Invocation.getter(#configuration), ), ) as _i2.MapConfiguration); - @override - _i3.StreamController<_i2.MapEvent> get stream => (super.noSuchMethod( + _i3.StreamController<_i2.MapEvent> get stream => (super.noSuchMethod( Invocation.getter(#stream), - returnValue: _FakeStreamController_1<_i2.MapEvent>( + returnValue: _FakeStreamController_1<_i2.MapEvent>( this, Invocation.getter(#stream), ), returnValueForMissingStub: - _FakeStreamController_1<_i2.MapEvent>( + _FakeStreamController_1<_i2.MapEvent>( this, Invocation.getter(#stream), ), - ) as _i3.StreamController<_i2.MapEvent>); - + ) as _i3.StreamController<_i2.MapEvent>); @override - _i3.Stream<_i2.MapEvent> get events => (super.noSuchMethod( + _i3.Stream<_i2.MapEvent> get events => (super.noSuchMethod( Invocation.getter(#events), - returnValue: _i3.Stream<_i2.MapEvent>.empty(), - returnValueForMissingStub: _i3.Stream<_i2.MapEvent>.empty(), - ) as _i3.Stream<_i2.MapEvent>); - + returnValue: _i3.Stream<_i2.MapEvent>.empty(), + returnValueForMissingStub: _i3.Stream<_i2.MapEvent>.empty(), + ) as _i3.Stream<_i2.MapEvent>); @override bool get isInitialized => (super.noSuchMethod( Invocation.getter(#isInitialized), returnValue: false, returnValueForMissingStub: false, ) as bool); - @override List<_i5.MapTypeStyle> get styles => (super.noSuchMethod( Invocation.getter(#styles), returnValue: <_i5.MapTypeStyle>[], returnValueForMissingStub: <_i5.MapTypeStyle>[], ) as List<_i5.MapTypeStyle>); - @override void debugSetOverrides({ _i4.DebugCreateMapFunction? createMap, @@ -138,6 +134,7 @@ class MockGoogleMapController extends _i1.Mock _i4.CirclesController? circles, _i4.PolygonsController? polygons, _i4.PolylinesController? polylines, + _i6.ClusterManagersController? clusterManagers, _i4.TileOverlaysController? tileOverlays, }) => super.noSuchMethod( @@ -151,12 +148,12 @@ class MockGoogleMapController extends _i1.Mock #circles: circles, #polygons: polygons, #polylines: polylines, + #clusterManagers: clusterManagers, #tileOverlays: tileOverlays, }, ), returnValueForMissingStub: null, ); - @override void init() => super.noSuchMethod( Invocation.method( @@ -165,7 +162,6 @@ class MockGoogleMapController extends _i1.Mock ), returnValueForMissingStub: null, ); - @override void updateMapConfiguration(_i2.MapConfiguration? update) => super.noSuchMethod( @@ -175,7 +171,6 @@ class MockGoogleMapController extends _i1.Mock ), returnValueForMissingStub: null, ); - @override void updateStyles(List<_i5.MapTypeStyle>? styles) => super.noSuchMethod( Invocation.method( @@ -184,7 +179,6 @@ class MockGoogleMapController extends _i1.Mock ), returnValueForMissingStub: null, ); - @override _i3.Future<_i2.LatLngBounds> getVisibleRegion() => (super.noSuchMethod( Invocation.method( @@ -207,7 +201,6 @@ class MockGoogleMapController extends _i1.Mock ), )), ) as _i3.Future<_i2.LatLngBounds>); - @override _i3.Future<_i2.ScreenCoordinate> getScreenCoordinate(_i2.LatLng? latLng) => (super.noSuchMethod( @@ -232,7 +225,6 @@ class MockGoogleMapController extends _i1.Mock ), )), ) as _i3.Future<_i2.ScreenCoordinate>); - @override _i3.Future<_i2.LatLng> getLatLng(_i2.ScreenCoordinate? screenCoordinate) => (super.noSuchMethod( @@ -255,7 +247,6 @@ class MockGoogleMapController extends _i1.Mock ), )), ) as _i3.Future<_i2.LatLng>); - @override _i3.Future moveCamera(_i2.CameraUpdate? cameraUpdate) => (super.noSuchMethod( @@ -266,7 +257,6 @@ class MockGoogleMapController extends _i1.Mock returnValue: _i3.Future.value(), returnValueForMissingStub: _i3.Future.value(), ) as _i3.Future); - @override _i3.Future getZoomLevel() => (super.noSuchMethod( Invocation.method( @@ -276,7 +266,6 @@ class MockGoogleMapController extends _i1.Mock returnValue: _i3.Future.value(0.0), returnValueForMissingStub: _i3.Future.value(0.0), ) as _i3.Future); - @override void updateCircles(_i2.CircleUpdates? updates) => super.noSuchMethod( Invocation.method( @@ -285,7 +274,6 @@ class MockGoogleMapController extends _i1.Mock ), returnValueForMissingStub: null, ); - @override void updatePolygons(_i2.PolygonUpdates? updates) => super.noSuchMethod( Invocation.method( @@ -294,7 +282,6 @@ class MockGoogleMapController extends _i1.Mock ), returnValueForMissingStub: null, ); - @override void updatePolylines(_i2.PolylineUpdates? updates) => super.noSuchMethod( Invocation.method( @@ -303,7 +290,6 @@ class MockGoogleMapController extends _i1.Mock ), returnValueForMissingStub: null, ); - @override void updateMarkers(_i2.MarkerUpdates? updates) => super.noSuchMethod( Invocation.method( @@ -312,7 +298,15 @@ class MockGoogleMapController extends _i1.Mock ), returnValueForMissingStub: null, ); - + @override + void updateClusterManagers(_i2.ClusterManagerUpdates? updates) => + super.noSuchMethod( + Invocation.method( + #updateClusterManagers, + [updates], + ), + returnValueForMissingStub: null, + ); @override void updateTileOverlays(Set<_i2.TileOverlay>? newOverlays) => super.noSuchMethod( @@ -322,7 +316,6 @@ class MockGoogleMapController extends _i1.Mock ), returnValueForMissingStub: null, ); - @override void clearTileCache(_i2.TileOverlayId? id) => super.noSuchMethod( Invocation.method( @@ -331,7 +324,6 @@ class MockGoogleMapController extends _i1.Mock ), returnValueForMissingStub: null, ); - @override void showInfoWindow(_i2.MarkerId? markerId) => super.noSuchMethod( Invocation.method( @@ -340,7 +332,6 @@ class MockGoogleMapController extends _i1.Mock ), returnValueForMissingStub: null, ); - @override void hideInfoWindow(_i2.MarkerId? markerId) => super.noSuchMethod( Invocation.method( @@ -349,7 +340,6 @@ class MockGoogleMapController extends _i1.Mock ), returnValueForMissingStub: null, ); - @override bool isInfoWindowShown(_i2.MarkerId? markerId) => (super.noSuchMethod( Invocation.method( @@ -359,7 +349,6 @@ class MockGoogleMapController extends _i1.Mock returnValue: false, returnValueForMissingStub: false, ) as bool); - @override void dispose() => super.noSuchMethod( Invocation.method( diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/marker_clustering_test.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/marker_clustering_test.dart new file mode 100644 index 00000000000..c34d9c53311 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/marker_clustering_test.dart @@ -0,0 +1,170 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: unnecessary_nullable_for_final_variable_declarations + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; +import 'package:integration_test/integration_test.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + GoogleMapsFlutterPlatform.instance.enableDebugInspection(); + final GoogleMapsFlutterPlatform plugin = GoogleMapsFlutterPlatform.instance; + final GoogleMapsInspectorPlatform inspector = + GoogleMapsInspectorPlatform.instance!; + + const LatLng mapCenter = LatLng(20, 20); + const CameraPosition initialCameraPosition = + CameraPosition(target: mapCenter); + + group('MarkersController', () { + const int testMapId = 33930; + + testWidgets('Marker clustering', (WidgetTester tester) async { + const ClusterManagerId clusterManagerId = ClusterManagerId('cluster 1'); + + final Set clusterManagers = { + const ClusterManager(clusterManagerId: clusterManagerId), + }; + + // Create the marker with clusterManagerId. + final Set initialMarkers = { + const Marker( + markerId: MarkerId('1'), + position: mapCenter, + clusterManagerId: clusterManagerId), + const Marker( + markerId: MarkerId('2'), + position: mapCenter, + clusterManagerId: clusterManagerId), + }; + + final Completer mapIdCompleter = Completer(); + + await _pumpMap( + tester, + plugin.buildViewWithConfiguration( + testMapId, (int id) => mapIdCompleter.complete(id), + widgetConfiguration: const MapWidgetConfiguration( + initialCameraPosition: initialCameraPosition, + textDirection: TextDirection.ltr, + ), + mapObjects: MapObjects( + clusterManagers: clusterManagers, markers: initialMarkers))); + + final int mapId = await mapIdCompleter.future; + expect(mapId, equals(testMapId)); + + addTearDown(() => plugin.dispose(mapId: mapId)); + + final LatLng latlon = await plugin + .getLatLng(const ScreenCoordinate(x: 0, y: 0), mapId: mapId); + debugPrint(latlon.toString()); + + final List clusters = + await waitForValueMatchingPredicate>( + tester, + () async => inspector.getClusters( + mapId: mapId, clusterManagerId: clusterManagerId), + (List clusters) => clusters.isNotEmpty) ?? + []; + + expect(clusters.length, 1); + expect(clusters[0].markerIds.length, 2); + + // Copy only the first marker with null clusterManagerId. + // This means that both markers should be removed from the cluster. + final Set updatedMarkers = { + _copyMarkerWithClusterManagerId(initialMarkers.first, null) + }; + + final MarkerUpdates markerUpdates = + MarkerUpdates.from(initialMarkers, updatedMarkers); + await plugin.updateMarkers(markerUpdates, mapId: mapId); + + final List updatedClusters = + await waitForValueMatchingPredicate>( + tester, + () async => inspector.getClusters( + mapId: mapId, clusterManagerId: clusterManagerId), + (List clusters) => clusters.isNotEmpty) ?? + []; + + expect(updatedClusters.length, 0); + }); + }); +} + +// Repeatedly checks an asynchronous value against a test condition, waiting +// one frame between each check, returing the value if it passes the predicate +// before [maxTries] is reached. +// +// Returns null if the predicate is never satisfied. +// +// This is useful for cases where the Maps SDK has some internally +// asynchronous operation that we don't have visibility into (e.g., native UI +// animations). +Future waitForValueMatchingPredicate(WidgetTester tester, + Future Function() getValue, bool Function(T) predicate, + {int maxTries = 100}) async { + for (int i = 0; i < maxTries; i++) { + final T value = await getValue(); + if (predicate(value)) { + return value; + } + await tester.pump(); + } + return null; +} + +Marker _copyMarkerWithClusterManagerId( + Marker marker, ClusterManagerId? clusterManagerId) { + return Marker( + markerId: marker.markerId, + alpha: marker.alpha, + anchor: marker.anchor, + consumeTapEvents: marker.consumeTapEvents, + draggable: marker.draggable, + flat: marker.flat, + icon: marker.icon, + infoWindow: marker.infoWindow, + position: marker.position, + rotation: marker.rotation, + visible: marker.visible, + zIndex: marker.zIndex, + onTap: marker.onTap, + onDragStart: marker.onDragStart, + onDrag: marker.onDrag, + onDragEnd: marker.onDragEnd, + clusterManagerId: clusterManagerId, + ); +} + +/// Pumps a [map] widget in [tester] of a certain [size], then waits until it settles. +Future _pumpMap(WidgetTester tester, Widget map, + [Size size = const Size.square(200)]) async { + await tester.pumpWidget(_wrapMap(map, size)); + await tester.pumpAndSettle(); +} + +/// Wraps a [map] in a bunch of widgets so it renders in all platforms. +/// +/// An optional [size] can be passed. +Widget _wrapMap(Widget map, [Size size = const Size.square(200)]) { + return MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox.fromSize( + size: size, + child: map, + ), + ), + ), + ); +} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/markers_test.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/markers_test.dart index d965435b3a5..81e1eb26557 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/markers_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/markers_test.dart @@ -11,6 +11,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:google_maps/google_maps.dart' as gmaps; import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; import 'package:google_maps_flutter_web/google_maps_flutter_web.dart'; +import 'package:google_maps_flutter_web/src/marker_clustering.dart'; // ignore: implementation_imports import 'package:google_maps_flutter_web/src/utils.dart'; import 'package:http/http.dart' as http; @@ -25,12 +26,17 @@ void main() { group('MarkersController', () { late StreamController> events; late MarkersController controller; + late ClusterManagersController clusterManagersController; late gmaps.GMap map; setUp(() { events = StreamController>(); - controller = MarkersController(stream: events); + + clusterManagersController = ClusterManagersController(stream: events); + controller = MarkersController( + stream: events, clusterManagersController: clusterManagersController); map = gmaps.GMap(createDivElement()); + clusterManagersController.bindToMap(123, map); controller.bindToMap(123, map); }); diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_web/example/pubspec.yaml index 28381575ea0..a7222dfb12a 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/pubspec.yaml @@ -9,7 +9,7 @@ environment: dependencies: flutter: sdk: flutter - google_maps_flutter_platform_interface: ^2.5.0 + google_maps_flutter_platform_interface: ^2.6.0 google_maps_flutter_web: path: ../ web: ^0.5.0 diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/web/index.html b/packages/google_maps_flutter/google_maps_flutter_web/example/web/index.html index 3121d189b91..9cbd7be791d 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/web/index.html +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/web/index.html @@ -7,6 +7,7 @@ Browser Tests + diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/google_maps_flutter_web.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/google_maps_flutter_web.dart index fe44d41aa48..cda20cf9f52 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/google_maps_flutter_web.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/google_maps_flutter_web.dart @@ -24,6 +24,7 @@ import 'package:web/web.dart'; import 'src/dom_window_extension.dart'; import 'src/google_maps_inspector_web.dart'; import 'src/map_styler.dart'; +import 'src/marker_clustering.dart'; import 'src/third_party/to_screen_location/to_screen_location.dart'; import 'src/types.dart'; import 'src/utils.dart'; diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/convert.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/convert.dart index 0f73a7df044..1822a2fb477 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/convert.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/convert.dart @@ -172,20 +172,22 @@ gmaps.LatLng _latLngToGmLatLng(LatLng latLng) { return gmaps.LatLng(latLng.latitude, latLng.longitude); } -LatLng _gmLatLngToLatLng(gmaps.LatLng latLng) { +/// Converts [gmaps.LatLng] to [LatLng]. +LatLng gmLatLngToLatLng(gmaps.LatLng latLng) { return LatLng(latLng.lat.toDouble(), latLng.lng.toDouble()); } -LatLngBounds _gmLatLngBoundsTolatLngBounds(gmaps.LatLngBounds latLngBounds) { +/// Converts a [gmaps.LatLngBounds] into a [LatLngBounds]. +LatLngBounds gmLatLngBoundsTolatLngBounds(gmaps.LatLngBounds latLngBounds) { return LatLngBounds( - southwest: _gmLatLngToLatLng(latLngBounds.southWest), - northeast: _gmLatLngToLatLng(latLngBounds.northEast), + southwest: gmLatLngToLatLng(latLngBounds.southWest), + northeast: gmLatLngToLatLng(latLngBounds.northEast), ); } CameraPosition _gmViewportToCameraPosition(gmaps.GMap map) { return CameraPosition( - target: _gmLatLngToLatLng(map.center ?? _nullGmapsLatLng), + target: gmLatLngToLatLng(map.center ?? _nullGmapsLatLng), bearing: map.heading?.toDouble() ?? 0, tilt: map.tilt?.toDouble() ?? 0, zoom: map.zoom?.toDouble() ?? 0, diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_controller.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_controller.dart index c60dd92a0ac..c2fe750de89 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_controller.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_controller.dart @@ -29,12 +29,17 @@ class GoogleMapController { _polygons = mapObjects.polygons, _polylines = mapObjects.polylines, _circles = mapObjects.circles, + _clusterManagers = mapObjects.clusterManagers, _tileOverlays = mapObjects.tileOverlays, _lastMapConfiguration = mapConfiguration { _circlesController = CirclesController(stream: _streamController); _polygonsController = PolygonsController(stream: _streamController); _polylinesController = PolylinesController(stream: _streamController); - _markersController = MarkersController(stream: _streamController); + _clusterManagersController = + ClusterManagersController(stream: _streamController); + _markersController = MarkersController( + stream: _streamController, + clusterManagersController: _clusterManagersController!); _tileOverlaysController = TileOverlaysController(); _updateStylesFromConfiguration(mapConfiguration); @@ -60,7 +65,9 @@ class GoogleMapController { final Set _polygons; final Set _polylines; final Set _circles; + final Set _clusterManagers; Set _tileOverlays; + // The configuration passed by the user, before converting to gmaps. // Caching this allows us to re-create the map faithfully when needed. MapConfiguration _lastMapConfiguration = const MapConfiguration(); @@ -118,13 +125,20 @@ class GoogleMapController { PolygonsController? _polygonsController; PolylinesController? _polylinesController; MarkersController? _markersController; + ClusterManagersController? _clusterManagersController; TileOverlaysController? _tileOverlaysController; + // Keeps track if _attachGeometryControllers has been called or not. bool _controllersBoundToMap = false; // Keeps track if the map is moving or not. bool _mapIsMoving = false; + /// The ClusterManagersController of this Map. Only for integration testing. + @visibleForTesting + ClusterManagersController? get clusterManagersController => + _clusterManagersController; + /// Overrides certain properties to install mocks defined during testing. @visibleForTesting void debugSetOverrides({ @@ -134,6 +148,7 @@ class GoogleMapController { CirclesController? circles, PolygonsController? polygons, PolylinesController? polylines, + ClusterManagersController? clusterManagers, TileOverlaysController? tileOverlays, }) { _overrideCreateMap = createMap; @@ -142,6 +157,7 @@ class GoogleMapController { _circlesController = circles ?? _circlesController; _polygonsController = polygons ?? _polygonsController; _polylinesController = polylines ?? _polylinesController; + _clusterManagersController = clusterManagers ?? _clusterManagersController; _tileOverlaysController = tileOverlays ?? _tileOverlaysController; } @@ -197,6 +213,8 @@ class GoogleMapController { _attachMapEvents(map); _attachGeometryControllers(map); + _initClustering(_clusterManagers); + // Now attach the geometry, traffic and any other layers... _renderInitialGeometry(); _setTrafficLayer(map, _lastMapConfiguration.trafficEnabled ?? false); @@ -211,13 +229,13 @@ class GoogleMapController { map.onClick.listen((gmaps.IconMouseEvent event) { assert(event.latLng != null); _streamController.add( - MapTapEvent(_mapId, _gmLatLngToLatLng(event.latLng!)), + MapTapEvent(_mapId, gmLatLngToLatLng(event.latLng!)), ); }); map.onRightclick.listen((gmaps.MapMouseEvent event) { assert(event.latLng != null); _streamController.add( - MapLongPressEvent(_mapId, _gmLatLngToLatLng(event.latLng!)), + MapLongPressEvent(_mapId, gmLatLngToLatLng(event.latLng!)), ); }); map.onBoundsChanged.listen((void _) { @@ -251,6 +269,8 @@ class GoogleMapController { 'Cannot attach a map to a null PolylinesController instance.'); assert(_markersController != null, 'Cannot attach a map to a null MarkersController instance.'); + assert(_clusterManagersController != null, + 'Cannot attach a map to a null ClusterManagersController instance.'); assert(_tileOverlaysController != null, 'Cannot attach a map to a null TileOverlaysController instance.'); @@ -258,11 +278,16 @@ class GoogleMapController { _polygonsController!.bindToMap(_mapId, map); _polylinesController!.bindToMap(_mapId, map); _markersController!.bindToMap(_mapId, map); + _clusterManagersController!.bindToMap(_mapId, map); _tileOverlaysController!.bindToMap(_mapId, map); _controllersBoundToMap = true; } + void _initClustering(Set clusterManagers) { + _clusterManagersController!.addClusterManagers(clusterManagers); + } + // Renders the initial sets of geometry. void _renderInitialGeometry() { assert( @@ -369,7 +394,7 @@ class GoogleMapController { await Future.value(_googleMap!.bounds) ?? _nullGmapsLatLngBounds; - return _gmLatLngBoundsTolatLngBounds(bounds); + return gmLatLngBoundsTolatLngBounds(bounds); } /// Returns the [ScreenCoordinate] for a given viewport [LatLng]. @@ -390,7 +415,7 @@ class GoogleMapController { final gmaps.LatLng latLng = _pixelToLatLng(_googleMap!, screenCoordinate.x, screenCoordinate.y); - return _gmLatLngToLatLng(latLng); + return gmLatLngToLatLng(latLng); } /// Applies a `cameraUpdate` to the current viewport. @@ -447,6 +472,16 @@ class GoogleMapController { _markersController?.removeMarkers(updates.markerIdsToRemove); } + /// Applies [ClusterManagerUpdates] to the currently managed cluster managers. + void updateClusterManagers(ClusterManagerUpdates updates) { + assert(_clusterManagersController != null, + 'Cannot update markers after dispose().'); + _clusterManagersController + ?.addClusterManagers(updates.clusterManagersToAdd); + _clusterManagersController + ?.removeClusterManagers(updates.clusterManagerIdsToRemove); + } + /// Updates the set of [TileOverlay]s. void updateTileOverlays(Set newOverlays) { final MapsObjectUpdates updates = @@ -498,6 +533,7 @@ class GoogleMapController { _polygonsController = null; _polylinesController = null; _markersController = null; + _clusterManagersController = null; _tileOverlaysController = null; _streamController.close(); } diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_flutter_web.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_flutter_web.dart index 805887f0d7f..f71818a4380 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_flutter_web.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_flutter_web.dart @@ -98,6 +98,14 @@ class GoogleMapsPlugin extends GoogleMapsFlutterPlatform { _map(mapId).updateTileOverlays(newTileOverlays); } + @override + Future updateClusterManagers( + ClusterManagerUpdates clusterManagerUpdates, { + required int mapId, + }) async { + _map(mapId).updateClusterManagers(clusterManagerUpdates); + } + @override Future clearTileCache( TileOverlayId tileOverlayId, { @@ -279,6 +287,11 @@ class GoogleMapsPlugin extends GoogleMapsFlutterPlatform { return _events(mapId).whereType(); } + @override + Stream onClusterTap({required int mapId}) { + return _events(mapId).whereType(); + } + @override Future getStyleError({required int mapId}) async { return _map(mapId).lastStyleError; @@ -339,6 +352,7 @@ class GoogleMapsPlugin extends GoogleMapsFlutterPlatform { void enableDebugInspection() { GoogleMapsInspectorPlatform.instance = GoogleMapsInspectorWeb( (int mapId) => _map(mapId).configuration, + (int mapId) => _map(mapId).clusterManagersController, ); } } diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_inspector_web.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_inspector_web.dart index 6d955315239..98b47430958 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_inspector_web.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_inspector_web.dart @@ -3,17 +3,25 @@ // found in the LICENSE file. import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; +import 'marker_clustering.dart'; /// Function that gets the [MapConfiguration] for a given `mapId`. typedef ConfigurationProvider = MapConfiguration Function(int mapId); +/// Function that gets the [ClusterManagersController] for a given `mapId`. +typedef ClusterManagersControllerProvider = ClusterManagersController? Function( + int mapId); + /// This platform implementation allows inspecting the running maps. class GoogleMapsInspectorWeb extends GoogleMapsInspectorPlatform { /// Build an "inspector" that is able to look into maps. - GoogleMapsInspectorWeb(ConfigurationProvider configurationProvider) - : _configurationProvider = configurationProvider; + GoogleMapsInspectorWeb(ConfigurationProvider configurationProvider, + ClusterManagersControllerProvider clusterManagersControllerProvider) + : _configurationProvider = configurationProvider, + _clusterManagersControllerProvider = clusterManagersControllerProvider; final ConfigurationProvider _configurationProvider; + final ClusterManagersControllerProvider _clusterManagersControllerProvider; @override Future areBuildingsEnabled({required int mapId}) async { @@ -85,4 +93,14 @@ class GoogleMapsInspectorWeb extends GoogleMapsInspectorPlatform { Future isTrafficEnabled({required int mapId}) async { return _configurationProvider(mapId).trafficEnabled ?? false; } + + @override + Future> getClusters({ + required int mapId, + required ClusterManagerId clusterManagerId, + }) async { + return _clusterManagersControllerProvider(mapId) + ?.getClusters(clusterManagerId) ?? + []; + } } diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker.dart index c1b0772a139..518dce6de77 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker.dart @@ -15,9 +15,11 @@ class MarkerController { LatLngCallback? onDrag, LatLngCallback? onDragEnd, VoidCallback? onTap, + ClusterManagerId? clusterManagerId, }) : _marker = marker, _infoWindow = infoWindow, - _consumeTapEvents = consumeTapEvents { + _consumeTapEvents = consumeTapEvents, + _clusterManagerId = clusterManagerId { if (onTap != null) { marker.onClick.listen((gmaps.MapMouseEvent event) { onTap.call(); @@ -47,6 +49,8 @@ class MarkerController { final bool _consumeTapEvents; + final ClusterManagerId? _clusterManagerId; + final gmaps.InfoWindow? _infoWindow; bool _infoWindowShown = false; @@ -57,6 +61,9 @@ class MarkerController { /// Returns `true` if the [gmaps.InfoWindow] associated to this marker is being shown. bool get infoWindowShown => _infoWindowShown; + /// Returns [ClusterManagerId] if marker belongs to cluster. + ClusterManagerId? get clusterManagerId => _clusterManagerId; + /// Returns the [gmaps.Marker] associated to this controller. gmaps.Marker? get marker => _marker; diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker_clustering.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker_clustering.dart new file mode 100644 index 00000000000..d4b5ed3bd70 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker_clustering.dart @@ -0,0 +1,139 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:google_maps/google_maps.dart' as gmaps; +import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; + +import '../google_maps_flutter_web.dart'; +import 'marker_clustering_js_interop.dart'; +import 'types.dart'; + +/// A controller class for managing marker clustering. +/// +/// This class maps [ClusterManager] objects to javascript [MarkerClusterer] +/// objects and provides an interface for adding and removing markers from +/// clusters. +class ClusterManagersController extends GeometryController { + /// Creates a new [ClusterManagersController] instance. + /// + /// The [stream] parameter is a required [StreamController] used for + /// emitting map events. + ClusterManagersController( + {required StreamController> stream}) + : _streamController = stream, + _clusterManagerIdToMarkerClusterer = + {}; + + // The stream over which cluster managers broadcast their events + final StreamController> _streamController; + + // A cache of [MarkerClusterer]s indexed by their [ClusterManagerId]. + final Map + _clusterManagerIdToMarkerClusterer; + + /// Adds a set of [ClusterManager] objects to the cache. + void addClusterManagers(Set clusterManagersToAdd) { + clusterManagersToAdd.forEach(_addClusterManager); + } + + void _addClusterManager(ClusterManager clusterManager) { + final MarkerClusterer markerClusterer = createMarkerClusterer( + googleMap, + (gmaps.MapMouseEvent event, MarkerClustererCluster cluster, + gmaps.GMap map) => + _clusterClicked( + clusterManager.clusterManagerId, event, cluster, map)); + + _clusterManagerIdToMarkerClusterer[clusterManager.clusterManagerId] = + markerClusterer; + markerClusterer.onAdd(); + } + + /// Removes a set of [ClusterManagerId]s from the cache. + void removeClusterManagers(Set clusterManagerIdsToRemove) { + clusterManagerIdsToRemove.forEach(_removeClusterManager); + } + + void _removeClusterManager(ClusterManagerId clusterManagerId) { + final MarkerClusterer? markerClusterer = + _clusterManagerIdToMarkerClusterer[clusterManagerId]; + if (markerClusterer != null) { + markerClusterer.clearMarkers(true); + markerClusterer.onRemove(); + } + _clusterManagerIdToMarkerClusterer.remove(clusterManagerId); + } + + /// Adds given [gmaps.Marker] to the [MarkerClusterer] with given + /// [ClusterManagerId]. + void addItem(ClusterManagerId clusterManagerId, gmaps.Marker marker) { + final MarkerClusterer? markerClusterer = + _clusterManagerIdToMarkerClusterer[clusterManagerId]; + if (markerClusterer != null) { + markerClusterer.addMarker(marker, true); + markerClusterer.render(); + } + } + + /// Removes given [gmaps.Marker] from the [MarkerClusterer] with given + /// [ClusterManagerId]. + void removeItem(ClusterManagerId clusterManagerId, gmaps.Marker? marker) { + if (marker != null) { + final MarkerClusterer? markerClusterer = + _clusterManagerIdToMarkerClusterer[clusterManagerId]; + if (markerClusterer != null) { + markerClusterer.removeMarker(marker, true); + markerClusterer.render(); + } + } + } + + /// Returns list of clusters in [MarkerClusterer] with given + /// [ClusterManagerId]. + List getClusters(ClusterManagerId clusterManagerId) { + final MarkerClusterer? markerClusterer = + _clusterManagerIdToMarkerClusterer[clusterManagerId]; + if (markerClusterer != null) { + return markerClusterer.clusters + .map((MarkerClustererCluster cluster) => + _convertCluster(clusterManagerId, cluster)) + .toList(); + } + return []; + } + + void _clusterClicked( + ClusterManagerId clusterManagerId, + gmaps.MapMouseEvent event, + MarkerClustererCluster markerClustererCluster, + gmaps.GMap map) { + if (markerClustererCluster.count > 0 && + markerClustererCluster.bounds != null) { + final Cluster cluster = + _convertCluster(clusterManagerId, markerClustererCluster); + _streamController.add(ClusterTapEvent(mapId, cluster)); + } + } + + /// Converts [MarkerClustererCluster] to [Cluster]. + Cluster _convertCluster(ClusterManagerId clusterManagerId, + MarkerClustererCluster markerClustererCluster) { + final LatLng position = gmLatLngToLatLng(markerClustererCluster.position); + final LatLngBounds bounds = + gmLatLngBoundsTolatLngBounds(markerClustererCluster.bounds!); + + final List markerIds = markerClustererCluster.markers + .map((gmaps.Marker marker) => + MarkerId(marker.get('markerId')! as String)) + .toList(); + return Cluster( + clusterManagerId, + markerIds, + position: position, + bounds: bounds, + ); + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker_clustering_js_interop.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker_clustering_js_interop.dart new file mode 100644 index 00000000000..e3bf42b4e71 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker_clustering_js_interop.dart @@ -0,0 +1,164 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// TODO(srujzs): Needed for https://github.com/dart-lang/sdk/issues/54801. Once +// we publish a version with a min SDK constraint that contains this fix, +// remove. +@JS() +library; + +import 'dart:js_interop'; + +import 'package:google_maps/google_maps.dart' as gmaps; + +/// A typedef representing a callback function for handling cluster tap events. +typedef ClusterClickHandler = void Function( + gmaps.MapMouseEvent, + MarkerClustererCluster, + gmaps.GMap, +); + +/// The [MarkerClustererOptions] object used to initialize [MarkerClusterer]. +/// +/// See: https://googlemaps.github.io/js-markerclusterer/interfaces/MarkerClustererOptions.html +@JS() +@anonymous +extension type MarkerClustererOptions._(JSObject _) implements JSObject { + /// Constructs a new [MarkerClustererOptions] object. + factory MarkerClustererOptions({ + gmaps.GMap? map, + List? markers, + ClusterClickHandler? onClusterClick, + }) => + MarkerClustererOptions._js( + map: map as JSAny?, + markers: markers?.cast().toJS ?? JSArray(), + onClusterClick: onClusterClick != null + ? ((JSAny event, MarkerClustererCluster cluster, JSAny map) => + onClusterClick(event as gmaps.MapMouseEvent, cluster, + map as gmaps.GMap)).toJS + : null, + ); + + external factory MarkerClustererOptions._js({ + JSAny? map, + JSArray markers, + JSFunction? onClusterClick, + }); + + /// Returns the [gmaps.GMap] object. + gmaps.GMap? get map => _map as gmaps.GMap?; + @JS('map') + external JSAny? get _map; + + /// Returns the list of [gmaps.Marker] objects. + List? get markers => _markers?.toDart.cast(); + @JS('markers') + external JSArray? get _markers; + + /// Returns the onClusterClick handler. + ClusterClickHandler? get onClusterClick => + _onClusterClick?.toDart as ClusterClickHandler?; + @JS('onClusterClick') + external JSExportedDartFunction? get _onClusterClick; +} + +/// The cluster object handled by the [MarkerClusterer]. +/// +/// https://googlemaps.github.io/js-markerclusterer/classes/Cluster.html +@JS('markerClusterer.Cluster') +extension type MarkerClustererCluster._(JSObject _) implements JSObject { + /// Getter for the cluster marker. + gmaps.Marker get marker => _marker as gmaps.Marker; + @JS('marker') + external JSAny get _marker; + + /// List of markers in the cluster. + List get markers => _markers.toDart.cast(); + @JS('markers') + external JSArray get _markers; + + /// The bounds of the cluster. + gmaps.LatLngBounds? get bounds => _bounds as gmaps.LatLngBounds?; + @JS('bounds') + external JSAny? get _bounds; + + /// The position of the cluster marker. + gmaps.LatLng get position => _position as gmaps.LatLng; + @JS('position') + external JSAny get _position; + + /// Get the count of **visible** markers. + external int get count; + + /// Deletes the cluster. + external void delete(); + + /// Adds a marker to the cluster. + void push(gmaps.Marker marker) => _push(marker as JSAny); + @JS('push') + external void _push(JSAny marker); +} + +/// The [MarkerClusterer] object used to cluster markers on the map. +/// +/// https://googlemaps.github.io/js-markerclusterer/classes/MarkerClusterer.html +@JS('markerClusterer.MarkerClusterer') +extension type MarkerClusterer._(JSObject _) implements JSObject { + /// Constructs a new [MarkerClusterer] object. + external MarkerClusterer(MarkerClustererOptions options); + + /// Adds a marker to be clustered by the [MarkerClusterer]. + void addMarker(gmaps.Marker marker, bool? noDraw) => + _addMarker(marker as JSAny, noDraw); + @JS('addMarker') + external void _addMarker(JSAny marker, bool? noDraw); + + /// Adds a list of markers to be clustered by the [MarkerClusterer]. + void addMarkers(List? markers, bool? noDraw) => + _addMarkers(markers?.cast().toJS, noDraw); + @JS('addMarkers') + external void _addMarkers(JSArray? markers, bool? noDraw); + + /// Removes a marker from the [MarkerClusterer]. + bool removeMarker(gmaps.Marker marker, bool? noDraw) => + _removeMarker(marker as JSAny, noDraw); + @JS('removeMarker') + external bool _removeMarker(JSAny marker, bool? noDraw); + + /// Removes a list of markers from the [MarkerClusterer]. + bool removeMarkers(List? markers, bool? noDraw) => + _removeMarkers(markers?.cast().toJS, noDraw); + @JS('removeMarkers') + external bool _removeMarkers(JSArray? markers, bool? noDraw); + + /// Clears all the markers from the [MarkerClusterer]. + external void clearMarkers(bool? noDraw); + + /// Called when the [MarkerClusterer] is added to the map. + external void onAdd(); + + /// Called when the [MarkerClusterer] is removed from the map. + external void onRemove(); + + /// Returns the list of clusters. + List get clusters => + _clusters.toDart.cast(); + @JS('clusters') + external JSArray get _clusters; + + /// Recalculates and draws all the marker clusters. + external void render(); +} + +/// Creates [MarkerClusterer] object with given [gmaps.GMap] and +/// [ClusterClickHandler]. +MarkerClusterer createMarkerClusterer( + gmaps.GMap map, ClusterClickHandler onClusterClickHandler) { + final MarkerClustererOptions options = MarkerClustererOptions( + map: map, + onClusterClick: onClusterClickHandler, + ); + return MarkerClusterer(options); +} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/markers.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/markers.dart index 26cb94f9ad2..962422f752a 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/markers.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/markers.dart @@ -9,7 +9,9 @@ class MarkersController extends GeometryController { /// Initialize the cache. The [StreamController] comes from the [GoogleMapController], and is shared with other controllers. MarkersController({ required StreamController> stream, + required ClusterManagersController clusterManagersController, }) : _streamController = stream, + _clusterManagersController = clusterManagersController, _markerIdToController = {}; // A cache of [MarkerController]s indexed by their [MarkerId]. @@ -18,6 +20,8 @@ class MarkersController extends GeometryController { // The stream over which markers broadcast their events final StreamController> _streamController; + final ClusterManagersController _clusterManagersController; + /// Returns the cache of [MarkerController]s. Test only. @visibleForTesting Map get markers => _markerIdToController; @@ -53,9 +57,19 @@ class MarkersController extends GeometryController { final gmaps.MarkerOptions markerOptions = _markerOptionsFromMarker(marker, currentMarker); - final gmaps.Marker gmMarker = gmaps.Marker(markerOptions)..map = googleMap; + + final gmaps.Marker gmMarker = gmaps.Marker(markerOptions); + + gmMarker.set('markerId', marker.markerId.value); + + if (marker.clusterManagerId != null) { + _clusterManagersController.addItem(marker.clusterManagerId!, gmMarker); + } else { + gmMarker.map = googleMap; + } final MarkerController controller = MarkerController( marker: gmMarker, + clusterManagerId: marker.clusterManagerId, infoWindow: gmInfoWindow, consumeTapEvents: marker.consumeTapEvents, onTap: () { @@ -84,16 +98,26 @@ class MarkersController extends GeometryController { final MarkerController? markerController = _markerIdToController[marker.markerId]; if (markerController != null) { - final gmaps.MarkerOptions markerOptions = _markerOptionsFromMarker( - marker, - markerController.marker, - ); - final gmaps.InfoWindowOptions? infoWindow = - _infoWindowOptionsFromMarker(marker); - markerController.update( - markerOptions, - newInfoWindowContent: infoWindow?.content as HTMLElement?, - ); + final ClusterManagerId? oldClusterManagerId = + markerController.clusterManagerId; + final ClusterManagerId? newClusterManagerId = marker.clusterManagerId; + + if (oldClusterManagerId != newClusterManagerId) { + // If clusterManagerId changes. Remove existing marker and create new one. + _removeMarker(marker.markerId); + _addMarker(marker); + } else { + final gmaps.MarkerOptions markerOptions = _markerOptionsFromMarker( + marker, + markerController.marker, + ); + final gmaps.InfoWindowOptions? infoWindow = + _infoWindowOptionsFromMarker(marker); + markerController.update( + markerOptions, + newInfoWindowContent: infoWindow?.content as HTMLElement?, + ); + } } } @@ -104,6 +128,10 @@ class MarkersController extends GeometryController { void _removeMarker(MarkerId markerId) { final MarkerController? markerController = _markerIdToController[markerId]; + if (markerController?.clusterManagerId != null) { + _clusterManagersController.removeItem( + markerController!.clusterManagerId!, markerController.marker); + } markerController?.remove(); _markerIdToController.remove(markerId); } @@ -151,7 +179,7 @@ class MarkersController extends GeometryController { void _onMarkerDragStart(MarkerId markerId, gmaps.LatLng latLng) { _streamController.add(MarkerDragStartEvent( mapId, - _gmLatLngToLatLng(latLng), + gmLatLngToLatLng(latLng), markerId, )); } @@ -159,7 +187,7 @@ class MarkersController extends GeometryController { void _onMarkerDrag(MarkerId markerId, gmaps.LatLng latLng) { _streamController.add(MarkerDragEvent( mapId, - _gmLatLngToLatLng(latLng), + gmLatLngToLatLng(latLng), markerId, )); } @@ -167,7 +195,7 @@ class MarkersController extends GeometryController { void _onMarkerDragEnd(MarkerId markerId, gmaps.LatLng latLng) { _streamController.add(MarkerDragEndEvent( mapId, - _gmLatLngToLatLng(latLng), + gmLatLngToLatLng(latLng), markerId, )); } diff --git a/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml index f5c17935406..3447e8b1026 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml @@ -2,7 +2,7 @@ name: google_maps_flutter_web description: Web platform implementation of google_maps_flutter repository: https://github.com/flutter/packages/tree/main/packages/google_maps_flutter/google_maps_flutter_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 -version: 0.5.6+2 +version: 0.5.7 environment: sdk: ^3.3.0 @@ -23,7 +23,7 @@ dependencies: flutter_web_plugins: sdk: flutter google_maps: ^7.1.0 - google_maps_flutter_platform_interface: ^2.5.0 + google_maps_flutter_platform_interface: ^2.6.0 sanitize_html: ^2.0.0 stream_transform: ^2.0.0 web: ^0.5.1