| // 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. |
| |
| part of google_maps_flutter_web; |
| |
| // Default values for when the gmaps objects return null/undefined values. |
| final gmaps.LatLng _nullGmapsLatLng = gmaps.LatLng(0, 0); |
| final gmaps.LatLngBounds _nullGmapsLatLngBounds = |
| gmaps.LatLngBounds(_nullGmapsLatLng, _nullGmapsLatLng); |
| |
| // Defaults taken from the Google Maps Platform SDK documentation. |
| const String _defaultCssColor = '#000000'; |
| const double _defaultCssOpacity = 0.0; |
| |
| // Converts a [Color] into a valid CSS value #RRGGBB. |
| String _getCssColor(Color color) { |
| if (color == null) { |
| return _defaultCssColor; |
| } |
| return '#${color.value.toRadixString(16).padLeft(8, '0').substring(2)}'; |
| } |
| |
| // Extracts the opacity from a [Color]. |
| double _getCssOpacity(Color color) { |
| if (color == null) { |
| return _defaultCssOpacity; |
| } |
| return color.opacity; |
| } |
| |
| // Converts options from the plugin into gmaps.MapOptions that can be used by the JS SDK. |
| // The following options are not handled here, for various reasons: |
| // The following are not available in web, because the map doesn't rotate there: |
| // compassEnabled |
| // rotateGesturesEnabled |
| // tiltGesturesEnabled |
| // mapToolbarEnabled is unused in web, there's no "map toolbar" |
| // myLocationButtonEnabled Widget not available in web yet, it needs to be built on top of the maps widget |
| // See: https://developers.google.com/maps/documentation/javascript/examples/control-custom |
| // myLocationEnabled needs to be built through dart:html navigator.geolocation |
| // See: https://api.dart.dev/stable/2.8.4/dart-html/Geolocation-class.html |
| // trafficEnabled is handled when creating the GMap object, since it needs to be added as a layer. |
| // trackCameraPosition is just a boolan value that indicates if the map has an onCameraMove handler. |
| // indoorViewEnabled seems to not have an equivalent in web |
| // buildingsEnabled seems to not have an equivalent in web |
| // padding seems to behave differently in web than mobile. You can't move UI elements in web. |
| gmaps.MapOptions _configurationAndStyleToGmapsOptions( |
| MapConfiguration configuration, List<gmaps.MapTypeStyle> styles) { |
| final gmaps.MapOptions options = gmaps.MapOptions(); |
| |
| if (configuration.mapType != null) { |
| options.mapTypeId = _gmapTypeIDForPluginType(configuration.mapType!); |
| } |
| |
| final MinMaxZoomPreference? zoomPreference = |
| configuration.minMaxZoomPreference; |
| if (zoomPreference != null) { |
| options |
| ..minZoom = zoomPreference.minZoom |
| ..maxZoom = zoomPreference.maxZoom; |
| } |
| |
| if (configuration.cameraTargetBounds != null) { |
| // Needs gmaps.MapOptions.restriction and gmaps.MapRestriction |
| // see: https://developers.google.com/maps/documentation/javascript/reference/map#MapOptions.restriction |
| } |
| |
| if (configuration.zoomControlsEnabled != null) { |
| options.zoomControl = configuration.zoomControlsEnabled; |
| } |
| |
| if (configuration.scrollGesturesEnabled == false || |
| configuration.zoomGesturesEnabled == false) { |
| options.gestureHandling = 'none'; |
| } else { |
| options.gestureHandling = 'auto'; |
| } |
| |
| // These don't have any configuration entries, but they seem to be off in the |
| // native maps. |
| options.mapTypeControl = false; |
| options.fullscreenControl = false; |
| options.streetViewControl = false; |
| |
| options.styles = styles; |
| |
| return options; |
| } |
| |
| gmaps.MapTypeId _gmapTypeIDForPluginType(MapType type) { |
| switch (type) { |
| case MapType.satellite: |
| return gmaps.MapTypeId.SATELLITE; |
| case MapType.terrain: |
| return gmaps.MapTypeId.TERRAIN; |
| case MapType.hybrid: |
| return gmaps.MapTypeId.HYBRID; |
| case MapType.normal: |
| case MapType.none: |
| return gmaps.MapTypeId.ROADMAP; |
| } |
| // The enum comes from a different package, which could get a new value at |
| // any time, so provide a fallback that ensures this won't break when used |
| // with a version that contains new values. This is deliberately outside |
| // the switch rather than a `default` so that the linter will flag the |
| // switch as needing an update. |
| // ignore: dead_code |
| return gmaps.MapTypeId.ROADMAP; |
| } |
| |
| gmaps.MapOptions _applyInitialPosition( |
| CameraPosition initialPosition, |
| gmaps.MapOptions options, |
| ) { |
| // Adjust the initial position, if passed... |
| if (initialPosition != null) { |
| options.zoom = initialPosition.zoom; |
| options.center = gmaps.LatLng( |
| initialPosition.target.latitude, initialPosition.target.longitude); |
| } |
| return options; |
| } |
| |
| // The keys we'd expect to see in a serialized MapTypeStyle JSON object. |
| final Set<String> _mapStyleKeys = <String>{ |
| 'elementType', |
| 'featureType', |
| 'stylers', |
| }; |
| |
| // Checks if the passed in Map contains some of the _mapStyleKeys. |
| bool _isJsonMapStyle(Map<String, Object?> value) { |
| return _mapStyleKeys.intersection(value.keys.toSet()).isNotEmpty; |
| } |
| |
| // Converts an incoming JSON-encoded Style info, into the correct gmaps array. |
| List<gmaps.MapTypeStyle> _mapStyles(String? mapStyleJson) { |
| List<gmaps.MapTypeStyle> styles = <gmaps.MapTypeStyle>[]; |
| if (mapStyleJson != null) { |
| styles = (json.decode(mapStyleJson, reviver: (Object? key, Object? value) { |
| if (value is Map && _isJsonMapStyle(value as Map<String, Object?>)) { |
| List<Object?> stylers = <Object?>[]; |
| if (value['stylers'] != null) { |
| stylers = (value['stylers']! as List<Object?>) |
| .map<Object?>((Object? e) => e != null ? jsify(e) : null) |
| .toList(); |
| } |
| return gmaps.MapTypeStyle() |
| ..elementType = value['elementType'] as String? |
| ..featureType = value['featureType'] as String? |
| ..stylers = stylers; |
| } |
| return value; |
| }) as List<Object?>) |
| .where((Object? element) => element != null) |
| .cast<gmaps.MapTypeStyle>() |
| .toList(); |
| // .toList calls are required so the JS API understands the underlying data structure. |
| } |
| return styles; |
| } |
| |
| gmaps.LatLng _latLngToGmLatLng(LatLng latLng) { |
| return gmaps.LatLng(latLng.latitude, latLng.longitude); |
| } |
| |
| LatLng _gmLatLngToLatLng(gmaps.LatLng latLng) { |
| return LatLng(latLng.lat.toDouble(), latLng.lng.toDouble()); |
| } |
| |
| LatLngBounds _gmLatLngBoundsTolatLngBounds(gmaps.LatLngBounds latLngBounds) { |
| return LatLngBounds( |
| southwest: _gmLatLngToLatLng(latLngBounds.southWest), |
| northeast: _gmLatLngToLatLng(latLngBounds.northEast), |
| ); |
| } |
| |
| CameraPosition _gmViewportToCameraPosition(gmaps.GMap map) { |
| return CameraPosition( |
| target: _gmLatLngToLatLng(map.center ?? _nullGmapsLatLng), |
| bearing: map.heading?.toDouble() ?? 0, |
| tilt: map.tilt?.toDouble() ?? 0, |
| zoom: map.zoom?.toDouble() ?? 0, |
| ); |
| } |
| |
| // Convert plugin objects to gmaps.Options objects |
| // TODO(ditman): Move to their appropriate objects, maybe make them copy constructors? |
| // Marker.fromMarker(anotherMarker, moreOptions); |
| |
| gmaps.InfoWindowOptions? _infoWindowOptionsFromMarker(Marker marker) { |
| final String markerTitle = marker.infoWindow.title ?? ''; |
| final String markerSnippet = marker.infoWindow.snippet ?? ''; |
| |
| // If both the title and snippet of an infowindow are empty, we don't really |
| // want an infowindow... |
| if ((markerTitle.isEmpty) && (markerSnippet.isEmpty)) { |
| return null; |
| } |
| |
| // Add an outer wrapper to the contents of the infowindow, we need it to listen |
| // to click events... |
| final HtmlElement container = DivElement() |
| ..id = 'gmaps-marker-${marker.markerId.value}-infowindow'; |
| |
| if (markerTitle.isNotEmpty) { |
| final HtmlElement title = HeadingElement.h3() |
| ..className = 'infowindow-title' |
| ..innerText = markerTitle; |
| container.children.add(title); |
| } |
| if (markerSnippet.isNotEmpty) { |
| final HtmlElement snippet = DivElement() |
| ..className = 'infowindow-snippet' |
| // `sanitizeHtml` is used to clean the (potential) user input from (potential) |
| // XSS attacks through the contents of the marker InfoWindow. |
| // See: https://pub.dev/documentation/sanitize_html/latest/sanitize_html/sanitizeHtml.html |
| // See: b/159137885, b/159598165 |
| // The NodeTreeSanitizer.trusted just tells setInnerHtml to leave the output |
| // of `sanitizeHtml` untouched. |
| // ignore: unsafe_html |
| ..setInnerHtml( |
| sanitizeHtml(markerSnippet), |
| treeSanitizer: NodeTreeSanitizer.trusted, |
| ); |
| container.children.add(snippet); |
| } |
| |
| return gmaps.InfoWindowOptions() |
| ..content = container |
| ..zIndex = marker.zIndex; |
| // TODO(ditman): Compute the pixelOffset of the infoWindow, from the size of the Marker, |
| // and the marker.infoWindow.anchor property. |
| } |
| |
| // Attempts to extract a [gmaps.Size] from `iconConfig[sizeIndex]`. |
| gmaps.Size? _gmSizeFromIconConfig(List<Object?> iconConfig, int sizeIndex) { |
| gmaps.Size? size; |
| if (iconConfig.length >= sizeIndex + 1) { |
| final List<Object?>? rawIconSize = iconConfig[sizeIndex] as List<Object?>?; |
| if (rawIconSize != null) { |
| size = gmaps.Size( |
| rawIconSize[0] as num?, |
| rawIconSize[1] as num?, |
| ); |
| } |
| } |
| return size; |
| } |
| |
| // Converts a [BitmapDescriptor] into a [gmaps.Icon] that can be used in Markers. |
| gmaps.Icon? _gmIconFromBitmapDescriptor(BitmapDescriptor bitmapDescriptor) { |
| final List<Object?> iconConfig = bitmapDescriptor.toJson() as List<Object?>; |
| |
| gmaps.Icon? icon; |
| |
| if (iconConfig != null) { |
| if (iconConfig[0] == 'fromAssetImage') { |
| assert(iconConfig.length >= 2); |
| // iconConfig[2] contains the DPIs of the screen, but that information is |
| // already encoded in the iconConfig[1] |
| icon = gmaps.Icon() |
| ..url = ui.webOnlyAssetManager.getAssetUrl(iconConfig[1]! as String); |
| |
| final gmaps.Size? size = _gmSizeFromIconConfig(iconConfig, 3); |
| if (size != null) { |
| icon |
| ..size = size |
| ..scaledSize = size; |
| } |
| } else if (iconConfig[0] == 'fromBytes') { |
| // Grab the bytes, and put them into a blob |
| final List<int> bytes = iconConfig[1]! as List<int>; |
| // Create a Blob from bytes, but let the browser figure out the encoding |
| final Blob blob = Blob(<dynamic>[bytes]); |
| icon = gmaps.Icon()..url = Url.createObjectUrlFromBlob(blob); |
| |
| final gmaps.Size? size = _gmSizeFromIconConfig(iconConfig, 2); |
| if (size != null) { |
| icon |
| ..size = size |
| ..scaledSize = size; |
| } |
| } |
| } |
| |
| return icon; |
| } |
| |
| // Computes the options for a new [gmaps.Marker] from an incoming set of options |
| // [marker], and the existing marker registered with the map: [currentMarker]. |
| gmaps.MarkerOptions _markerOptionsFromMarker( |
| Marker marker, |
| gmaps.Marker? currentMarker, |
| ) { |
| return gmaps.MarkerOptions() |
| ..position = gmaps.LatLng( |
| marker.position.latitude, |
| marker.position.longitude, |
| ) |
| ..title = sanitizeHtml(marker.infoWindow.title ?? '') |
| ..zIndex = marker.zIndex |
| ..visible = marker.visible |
| ..opacity = marker.alpha |
| ..draggable = marker.draggable |
| ..icon = _gmIconFromBitmapDescriptor(marker.icon); |
| // TODO(ditman): Compute anchor properly, otherwise infowindows attach to the wrong spot. |
| // Flat and Rotation are not supported directly on the web. |
| } |
| |
| gmaps.CircleOptions _circleOptionsFromCircle(Circle circle) { |
| final gmaps.CircleOptions circleOptions = gmaps.CircleOptions() |
| ..strokeColor = _getCssColor(circle.strokeColor) |
| ..strokeOpacity = _getCssOpacity(circle.strokeColor) |
| ..strokeWeight = circle.strokeWidth |
| ..fillColor = _getCssColor(circle.fillColor) |
| ..fillOpacity = _getCssOpacity(circle.fillColor) |
| ..center = gmaps.LatLng(circle.center.latitude, circle.center.longitude) |
| ..radius = circle.radius |
| ..visible = circle.visible |
| ..zIndex = circle.zIndex; |
| return circleOptions; |
| } |
| |
| gmaps.PolygonOptions _polygonOptionsFromPolygon( |
| gmaps.GMap googleMap, Polygon polygon) { |
| // Convert all points to GmLatLng |
| final List<gmaps.LatLng> path = |
| polygon.points.map(_latLngToGmLatLng).toList(); |
| |
| final bool isClockwisePolygon = _isPolygonClockwise(path); |
| |
| final List<List<gmaps.LatLng>> paths = <List<gmaps.LatLng>>[path]; |
| |
| for (int i = 0; i < polygon.holes.length; i++) { |
| final List<LatLng> hole = polygon.holes[i]; |
| final List<gmaps.LatLng> correctHole = _ensureHoleHasReverseWinding( |
| hole, |
| isClockwisePolygon, |
| holeId: i, |
| polygonId: polygon.polygonId, |
| ); |
| paths.add(correctHole); |
| } |
| |
| return gmaps.PolygonOptions() |
| ..paths = paths |
| ..strokeColor = _getCssColor(polygon.strokeColor) |
| ..strokeOpacity = _getCssOpacity(polygon.strokeColor) |
| ..strokeWeight = polygon.strokeWidth |
| ..fillColor = _getCssColor(polygon.fillColor) |
| ..fillOpacity = _getCssOpacity(polygon.fillColor) |
| ..visible = polygon.visible |
| ..zIndex = polygon.zIndex |
| ..geodesic = polygon.geodesic; |
| } |
| |
| List<gmaps.LatLng> _ensureHoleHasReverseWinding( |
| List<LatLng> hole, |
| bool polyIsClockwise, { |
| required int holeId, |
| required PolygonId polygonId, |
| }) { |
| List<gmaps.LatLng> holePath = hole.map(_latLngToGmLatLng).toList(); |
| final bool holeIsClockwise = _isPolygonClockwise(holePath); |
| |
| if (holeIsClockwise == polyIsClockwise) { |
| holePath = holePath.reversed.toList(); |
| if (kDebugMode) { |
| print('Hole [$holeId] in Polygon [${polygonId.value}] has been reversed.' |
| ' Ensure holes in polygons are "wound in the opposite direction to the outer path."' |
| ' More info: https://github.com/flutter/flutter/issues/74096'); |
| } |
| } |
| |
| return holePath; |
| } |
| |
| /// Calculates the direction of a given Polygon |
| /// based on: https://stackoverflow.com/a/1165943 |
| /// |
| /// returns [true] if clockwise [false] if counterclockwise |
| /// |
| /// This method expects that the incoming [path] is a `List` of well-formed, |
| /// non-null [gmaps.LatLng] objects. |
| /// |
| /// Currently, this method is only called from [_polygonOptionsFromPolygon], and |
| /// the `path` is a transformed version of [Polygon.points] or each of the |
| /// [Polygon.holes], guaranteeing that `lat` and `lng` can be accessed with `!`. |
| bool _isPolygonClockwise(List<gmaps.LatLng> path) { |
| double direction = 0.0; |
| for (int i = 0; i < path.length; i++) { |
| direction = direction + |
| ((path[(i + 1) % path.length].lat - path[i].lat) * |
| (path[(i + 1) % path.length].lng + path[i].lng)); |
| } |
| return direction >= 0; |
| } |
| |
| gmaps.PolylineOptions _polylineOptionsFromPolyline( |
| gmaps.GMap googleMap, Polyline polyline) { |
| final List<gmaps.LatLng> paths = |
| polyline.points.map(_latLngToGmLatLng).toList(); |
| |
| return gmaps.PolylineOptions() |
| ..path = paths |
| ..strokeWeight = polyline.width |
| ..strokeColor = _getCssColor(polyline.color) |
| ..strokeOpacity = _getCssOpacity(polyline.color) |
| ..visible = polyline.visible |
| ..zIndex = polyline.zIndex |
| ..geodesic = polyline.geodesic; |
| // this.endCap = Cap.buttCap, |
| // this.jointType = JointType.mitered, |
| // this.patterns = const <PatternItem>[], |
| // this.startCap = Cap.buttCap, |
| // this.width = 10, |
| } |
| |
| // Translates a [CameraUpdate] into operations on a [gmaps.GMap]. |
| void _applyCameraUpdate(gmaps.GMap map, CameraUpdate update) { |
| // Casts [value] to a JSON dictionary (string -> nullable object). [value] |
| // must be a non-null JSON dictionary. |
| Map<String, Object?> asJsonObject(dynamic value) { |
| return (value as Map<Object?, Object?>).cast<String, Object?>(); |
| } |
| |
| // Casts [value] to a JSON list. [value] must be a non-null JSON list. |
| List<Object?> asJsonList(dynamic value) { |
| return value as List<Object?>; |
| } |
| |
| final List<dynamic> json = update.toJson() as List<dynamic>; |
| switch (json[0]) { |
| case 'newCameraPosition': |
| final Map<String, Object?> position = asJsonObject(json[1]); |
| final List<Object?> latLng = asJsonList(position['target']); |
| map.heading = position['bearing'] as num?; |
| map.zoom = position['zoom'] as num?; |
| map.panTo( |
| gmaps.LatLng(latLng[0] as num?, latLng[1] as num?), |
| ); |
| map.tilt = position['tilt'] as num?; |
| break; |
| case 'newLatLng': |
| final List<Object?> latLng = asJsonList(json[1]); |
| map.panTo(gmaps.LatLng(latLng[0] as num?, latLng[1] as num?)); |
| break; |
| case 'newLatLngZoom': |
| final List<Object?> latLng = asJsonList(json[1]); |
| map.zoom = json[2] as num?; |
| map.panTo(gmaps.LatLng(latLng[0] as num?, latLng[1] as num?)); |
| break; |
| case 'newLatLngBounds': |
| final List<Object?> latLngPair = asJsonList(json[1]); |
| final List<Object?> latLng1 = asJsonList(latLngPair[0]); |
| final List<Object?> latLng2 = asJsonList(latLngPair[1]); |
| map.fitBounds( |
| gmaps.LatLngBounds( |
| gmaps.LatLng(latLng1[0] as num?, latLng1[1] as num?), |
| gmaps.LatLng(latLng2[0] as num?, latLng2[1] as num?), |
| ), |
| ); |
| // padding = json[2]; |
| // Needs package:google_maps ^4.0.0 to adjust the padding in fitBounds |
| break; |
| case 'scrollBy': |
| map.panBy(json[1] as num?, json[2] as num?); |
| break; |
| case 'zoomBy': |
| gmaps.LatLng? focusLatLng; |
| final double zoomDelta = json[1] as double? ?? 0; |
| // Web only supports integer changes... |
| final int newZoomDelta = |
| zoomDelta < 0 ? zoomDelta.floor() : zoomDelta.ceil(); |
| if (json.length == 3) { |
| final List<Object?> latLng = asJsonList(json[2]); |
| // With focus |
| try { |
| focusLatLng = |
| _pixelToLatLng(map, latLng[0]! as int, latLng[1]! as int); |
| } catch (e) { |
| // https://github.com/a14n/dart-google-maps/issues/87 |
| // print('Error computing new focus LatLng. JS Error: ' + e.toString()); |
| } |
| } |
| map.zoom = (map.zoom ?? 0) + newZoomDelta; |
| if (focusLatLng != null) { |
| map.panTo(focusLatLng); |
| } |
| break; |
| case 'zoomIn': |
| map.zoom = (map.zoom ?? 0) + 1; |
| break; |
| case 'zoomOut': |
| map.zoom = (map.zoom ?? 0) - 1; |
| break; |
| case 'zoomTo': |
| map.zoom = json[1] as num?; |
| break; |
| default: |
| throw UnimplementedError('Unimplemented CameraMove: ${json[0]}.'); |
| } |
| } |
| |
| // original JS by: Byron Singh (https://stackoverflow.com/a/30541162) |
| gmaps.LatLng _pixelToLatLng(gmaps.GMap map, int x, int y) { |
| final gmaps.LatLngBounds? bounds = map.bounds; |
| final gmaps.Projection? projection = map.projection; |
| final num? zoom = map.zoom; |
| |
| assert( |
| bounds != null, 'Map Bounds required to compute LatLng of screen x/y.'); |
| assert(projection != null, |
| 'Map Projection required to compute LatLng of screen x/y'); |
| assert(zoom != null, |
| 'Current map zoom level required to compute LatLng of screen x/y'); |
| |
| final gmaps.LatLng ne = bounds!.northEast; |
| final gmaps.LatLng sw = bounds.southWest; |
| |
| final gmaps.Point topRight = projection!.fromLatLngToPoint!(ne)!; |
| final gmaps.Point bottomLeft = projection.fromLatLngToPoint!(sw)!; |
| |
| final int scale = 1 << (zoom!.toInt()); // 2 ^ zoom |
| |
| final gmaps.Point point = |
| gmaps.Point((x / scale) + bottomLeft.x!, (y / scale) + topRight.y!); |
| |
| return projection.fromPointToLatLng!(point)!; |
| } |