blob: dcce8d35699ec24a1b6eff61e86c1e5f9a48d658 [file] [log] [blame]
// 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)!;
}