diff --git a/lib/route_map.dart b/lib/route_map.dart index 22ae3b7..d425b80 100644 --- a/lib/route_map.dart +++ b/lib/route_map.dart @@ -12,6 +12,7 @@ export 'package:maplibre_gl/maplibre_gl.dart' export 'src/model/route_map_icon/route_map_icon.dart'; export 'src/model/route_map_icon_anchor.dart'; export 'src/model/route_map_circle/route_map_circle.dart'; +export 'src/model/route_map_area/route_map_area.dart'; export 'src/model/route_map_route/route_map_route.dart'; export 'src/route_map_base.dart'; export 'src/model/service_area_layer.dart'; diff --git a/lib/src/annotation_manager/route_map_area_manager.dart b/lib/src/annotation_manager/route_map_area_manager.dart new file mode 100644 index 0000000..d358c06 --- /dev/null +++ b/lib/src/annotation_manager/route_map_area_manager.dart @@ -0,0 +1,151 @@ +import 'dart:ui'; + +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:route_map/src/annotation_manager/route_map_layer_manager.dart'; +import 'package:route_map/src/model/route_map_area/route_map_area.dart'; +import 'package:route_map/src/route_map_area_geometry.dart'; +import 'package:synchronized/synchronized.dart'; + +class RouteMapAreaManager { + final MapLibreMapController controller; + final RouteMapLayerManager layerManager; + + Brightness _brightness = Brightness.light; + final _areaMap = {}; + + final _lock = Lock(); + + RouteMapAreaManager({required this.controller, required this.layerManager}); + + Future restore(Brightness brightness) { + return _lock.synchronized(() async { + _brightness = brightness; + + for (final area in _areaMap.values.toList()) { + if (controller.isDisposed) return; + await _install(area); + } + }); + } + + Future drawArea(RouteMapArea area) { + return _lock.synchronized(() async { + if (controller.isDisposed) return; + + final isDrawn = _areaMap.containsKey(area.identifier); + _areaMap[area.identifier] = area; + + if (!isDrawn) { + await _install(area); + return; + } + + final ids = _AreaIds(area.identifier); + final theme = _resolveTheme(area); + + await controller.setGeoJsonSource( + ids.source, + createAreaFeatureCollection(area.polygons), + ); + if (controller.isDisposed) return; + await controller.setLayerProperties( + ids.fillLayer, + _fillProperties(theme), + ); + if (controller.isDisposed) return; + await controller.setLayerProperties( + ids.borderLayer, + _borderProperties(theme), + ); + }); + } + + Future removeArea({required String identifier}) { + return _lock.synchronized(() async { + if (_areaMap.remove(identifier) == null) return; + await _uninstall(identifier); + }); + } + + Future removeAreas() { + return _lock.synchronized(() async { + final identifiers = _areaMap.keys.toList(); + _areaMap.clear(); + + for (final identifier in identifiers) { + await _uninstall(identifier); + } + }); + } + + Future _install(RouteMapArea area) async { + final ids = _AreaIds(area.identifier); + final theme = _resolveTheme(area); + + await layerManager.addSource( + ids.source, + GeojsonSourceProperties(data: createAreaFeatureCollection(area.polygons)), + ); + if (controller.isDisposed) return; + + await layerManager.addLayer( + ids.source, + ids.fillLayer, + _fillProperties(theme), + belowLayerId: area.belowLayerId, + enableInteraction: false, + ); + if (controller.isDisposed) return; + + await layerManager.addLayer( + ids.source, + ids.borderLayer, + _borderProperties(theme), + belowLayerId: area.belowLayerId, + enableInteraction: false, + ); + } + + Future _uninstall(String identifier) async { + final ids = _AreaIds(identifier); + + await layerManager.removeLayer(ids.borderLayer); + await layerManager.removeLayer(ids.fillLayer); + await layerManager.removeSource(ids.source); + } + + RouteMapAreaTheme _resolveTheme(RouteMapArea area) { + return switch (_brightness) { + Brightness.dark => area.darkTheme ?? area.theme, + Brightness.light => area.theme, + }; + } + + FillLayerProperties _fillProperties(RouteMapAreaTheme theme) { + return FillLayerProperties( + fillColor: theme.fillColor.toHexStringRGB(), + fillOpacity: theme.fillColor.a, + ); + } + + LineLayerProperties _borderProperties(RouteMapAreaTheme theme) { + return LineLayerProperties( + lineColor: theme.borderColor.toHexStringRGB(), + lineOpacity: theme.borderColor.a, + lineWidth: theme.borderWidth, + ); + } +} + +/// The ids of the style source and layers of an area. +class _AreaIds { + final String identifier; + + const _AreaIds(this.identifier); + + String get source => "route_map_area_source_$identifier"; + + String get fillLayer => "route_map_area_fill_layer_$identifier"; + + String get borderLayer => "route_map_area_border_layer_$identifier"; +} diff --git a/lib/src/annotation_manager/route_map_icon_manager.dart b/lib/src/annotation_manager/route_map_icon_manager.dart index c5dee9a..6e89926 100644 --- a/lib/src/annotation_manager/route_map_icon_manager.dart +++ b/lib/src/annotation_manager/route_map_icon_manager.dart @@ -213,16 +213,7 @@ class RouteMapIconManager { final rawSvg = await rootBundle.loadString(svgIconPath); final tintedSvg = foreground == null ? rawSvg - // ignore: deprecated_member_use - : rawSvg - .replaceAll( - RegExp(r'fill="[^"]*"'), - 'fill="${foreground.toHexStringRGB()}"', - ) - .replaceAll( - RegExp(r'stroke="[^"]*"'), - 'stroke="${foreground.toHexStringRGB()}"', - ); + : tintSvg(rawSvg, color: foreground); final loader = SvgStringLoader(tintedSvg); final pictureInfo = await vg.loadPicture(loader, null); @@ -298,3 +289,16 @@ class RouteMapIconManager { return pngBytes; } } + +/// Replaces every fill and stroke color of the [svg] with the [color]. +/// +/// `none` is kept, otherwise e.g. an outlined icon (`fill="none"` with a +/// stroke) would be filled completely. +@visibleForTesting +String tintSvg(String svg, {required Color color}) { + final hexColor = color.toHexStringRGB(); + + return svg + .replaceAll(RegExp(r'fill="(?!none")[^"]*"'), 'fill="$hexColor"') + .replaceAll(RegExp(r'stroke="(?!none")[^"]*"'), 'stroke="$hexColor"'); +} diff --git a/lib/src/annotation_manager/route_map_layer_manager.dart b/lib/src/annotation_manager/route_map_layer_manager.dart index 5912617..c22c7c0 100644 --- a/lib/src/annotation_manager/route_map_layer_manager.dart +++ b/lib/src/annotation_manager/route_map_layer_manager.dart @@ -93,6 +93,32 @@ class RouteMapLayerManager { _imageIds.add(id); } + /// Removes a single layer added via [addLayer]. Does nothing when the layer + /// isn't attached (anymore), e.g. because a style change removed it. + Future removeLayer(String id) async { + if (!_layerIds.remove(id)) return; + if (controller.isDisposed) return; + + try { + await controller.removeLayer(id); + } catch (_) { + /* ignore, a style change may have already dropped it */ + } + } + + /// Removes a single source added via [addSource]. The layers using the + /// source have to be removed before. + Future removeSource(String id) async { + if (!_sourceIds.remove(id)) return; + if (controller.isDisposed) return; + + try { + await controller.removeSource(id); + } catch (_) { + /* ignore, a style change may have already dropped it */ + } + } + /// Tears down every source & layer this manager has attached to the /// map. Individual failures are swallowed because a style change may /// have already dropped some of them on the native side. diff --git a/lib/src/model/route_map_area/route_map_area.dart b/lib/src/model/route_map_area/route_map_area.dart new file mode 100644 index 0000000..359b9dd --- /dev/null +++ b/lib/src/model/route_map_area/route_map_area.dart @@ -0,0 +1,71 @@ +import 'package:flutter/painting.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:route_map/route_map.dart'; +import 'package:route_map/src/route_map_area_geometry.dart'; + +part 'route_map_area.freezed.dart'; + +/// A filled area with a border on the map, which keeps its geographic size +/// while zooming, e.g. the region reachable from a location. +/// +/// It may consist of several [polygons], e.g. when a radius +/// ([RouteMapAreaPolygon.radius]) is cut by the border of a region. +@freezed +abstract class RouteMapArea with _$RouteMapArea { + const factory RouteMapArea({ + required String identifier, + required List polygons, + required RouteMapAreaTheme theme, + RouteMapAreaTheme? darkTheme, + + /// Preferred style layer to insert the area below, e.g. to keep the labels + /// of the map readable. The area is always placed below drawn routes and + /// icons. + String? belowLayerId, + }) = _RouteMapArea; +} + +@freezed +abstract class RouteMapAreaPolygon with _$RouteMapAreaPolygon { + const RouteMapAreaPolygon._(); + + const factory RouteMapAreaPolygon({ + /// The outline of the polygon. It doesn't need to be closed, the first + /// point is repeated at the end if needed. + required List outline, + + /// Areas cut out of the polygon. + @Default(>[]) List> holes, + }) = _RouteMapAreaPolygon; + + /// A circle with a radius in meters around the [center], approximated by + /// [steps] points on the earth's surface. + /// + /// In contrast to [RouteMapCircle] (whose radius is in screen pixels), it + /// keeps its geographic size while zooming. + factory RouteMapAreaPolygon.radius({ + required LatLng center, + required double radiusInMeters, + int steps = radiusPolygonSteps, + }) { + return RouteMapAreaPolygon( + outline: createRadiusPolygon( + center: center, + radiusInMeters: radiusInMeters, + steps: steps, + ), + ); + } +} + +@freezed +abstract class RouteMapAreaTheme with _$RouteMapAreaTheme { + const factory RouteMapAreaTheme({ + /// The opacity is taken from the alpha of the color. + required Color fillColor, + + /// The opacity is taken from the alpha of the color. + required Color borderColor, + @Default(2) double borderWidth, + }) = _RouteMapAreaTheme; +} diff --git a/lib/src/model/route_map_area/route_map_area.freezed.dart b/lib/src/model/route_map_area/route_map_area.freezed.dart new file mode 100644 index 0000000..22ed733 --- /dev/null +++ b/lib/src/model/route_map_area/route_map_area.freezed.dart @@ -0,0 +1,885 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'route_map_area.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$RouteMapArea { + + String get identifier; List get polygons; RouteMapAreaTheme get theme; RouteMapAreaTheme? get darkTheme;/// Preferred style layer to insert the area below, e.g. to keep the labels +/// of the map readable. The area is always placed below drawn routes and +/// icons. + String? get belowLayerId; +/// Create a copy of RouteMapArea +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$RouteMapAreaCopyWith get copyWith => _$RouteMapAreaCopyWithImpl(this as RouteMapArea, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RouteMapArea&&(identical(other.identifier, identifier) || other.identifier == identifier)&&const DeepCollectionEquality().equals(other.polygons, polygons)&&(identical(other.theme, theme) || other.theme == theme)&&(identical(other.darkTheme, darkTheme) || other.darkTheme == darkTheme)&&(identical(other.belowLayerId, belowLayerId) || other.belowLayerId == belowLayerId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,identifier,const DeepCollectionEquality().hash(polygons),theme,darkTheme,belowLayerId); + +@override +String toString() { + return 'RouteMapArea(identifier: $identifier, polygons: $polygons, theme: $theme, darkTheme: $darkTheme, belowLayerId: $belowLayerId)'; +} + + +} + +/// @nodoc +abstract mixin class $RouteMapAreaCopyWith<$Res> { + factory $RouteMapAreaCopyWith(RouteMapArea value, $Res Function(RouteMapArea) _then) = _$RouteMapAreaCopyWithImpl; +@useResult +$Res call({ + String identifier, List polygons, RouteMapAreaTheme theme, RouteMapAreaTheme? darkTheme, String? belowLayerId +}); + + +$RouteMapAreaThemeCopyWith<$Res> get theme;$RouteMapAreaThemeCopyWith<$Res>? get darkTheme; + +} +/// @nodoc +class _$RouteMapAreaCopyWithImpl<$Res> + implements $RouteMapAreaCopyWith<$Res> { + _$RouteMapAreaCopyWithImpl(this._self, this._then); + + final RouteMapArea _self; + final $Res Function(RouteMapArea) _then; + +/// Create a copy of RouteMapArea +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? identifier = null,Object? polygons = null,Object? theme = null,Object? darkTheme = freezed,Object? belowLayerId = freezed,}) { + return _then(_self.copyWith( +identifier: null == identifier ? _self.identifier : identifier // ignore: cast_nullable_to_non_nullable +as String,polygons: null == polygons ? _self.polygons : polygons // ignore: cast_nullable_to_non_nullable +as List,theme: null == theme ? _self.theme : theme // ignore: cast_nullable_to_non_nullable +as RouteMapAreaTheme,darkTheme: freezed == darkTheme ? _self.darkTheme : darkTheme // ignore: cast_nullable_to_non_nullable +as RouteMapAreaTheme?,belowLayerId: freezed == belowLayerId ? _self.belowLayerId : belowLayerId // ignore: cast_nullable_to_non_nullable +as String?, + )); +} +/// Create a copy of RouteMapArea +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$RouteMapAreaThemeCopyWith<$Res> get theme { + + return $RouteMapAreaThemeCopyWith<$Res>(_self.theme, (value) { + return _then(_self.copyWith(theme: value)); + }); +}/// Create a copy of RouteMapArea +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$RouteMapAreaThemeCopyWith<$Res>? get darkTheme { + if (_self.darkTheme == null) { + return null; + } + + return $RouteMapAreaThemeCopyWith<$Res>(_self.darkTheme!, (value) { + return _then(_self.copyWith(darkTheme: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [RouteMapArea]. +extension RouteMapAreaPatterns on RouteMapArea { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _RouteMapArea value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _RouteMapArea() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _RouteMapArea value) $default,){ +final _that = this; +switch (_that) { +case _RouteMapArea(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _RouteMapArea value)? $default,){ +final _that = this; +switch (_that) { +case _RouteMapArea() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String identifier, List polygons, RouteMapAreaTheme theme, RouteMapAreaTheme? darkTheme, String? belowLayerId)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _RouteMapArea() when $default != null: +return $default(_that.identifier,_that.polygons,_that.theme,_that.darkTheme,_that.belowLayerId);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String identifier, List polygons, RouteMapAreaTheme theme, RouteMapAreaTheme? darkTheme, String? belowLayerId) $default,) {final _that = this; +switch (_that) { +case _RouteMapArea(): +return $default(_that.identifier,_that.polygons,_that.theme,_that.darkTheme,_that.belowLayerId);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String identifier, List polygons, RouteMapAreaTheme theme, RouteMapAreaTheme? darkTheme, String? belowLayerId)? $default,) {final _that = this; +switch (_that) { +case _RouteMapArea() when $default != null: +return $default(_that.identifier,_that.polygons,_that.theme,_that.darkTheme,_that.belowLayerId);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _RouteMapArea implements RouteMapArea { + const _RouteMapArea({required this.identifier, required final List polygons, required this.theme, this.darkTheme, this.belowLayerId}): _polygons = polygons; + + +@override final String identifier; + final List _polygons; +@override List get polygons { + if (_polygons is EqualUnmodifiableListView) return _polygons; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_polygons); +} + +@override final RouteMapAreaTheme theme; +@override final RouteMapAreaTheme? darkTheme; +/// Preferred style layer to insert the area below, e.g. to keep the labels +/// of the map readable. The area is always placed below drawn routes and +/// icons. +@override final String? belowLayerId; + +/// Create a copy of RouteMapArea +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$RouteMapAreaCopyWith<_RouteMapArea> get copyWith => __$RouteMapAreaCopyWithImpl<_RouteMapArea>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _RouteMapArea&&(identical(other.identifier, identifier) || other.identifier == identifier)&&const DeepCollectionEquality().equals(other._polygons, _polygons)&&(identical(other.theme, theme) || other.theme == theme)&&(identical(other.darkTheme, darkTheme) || other.darkTheme == darkTheme)&&(identical(other.belowLayerId, belowLayerId) || other.belowLayerId == belowLayerId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,identifier,const DeepCollectionEquality().hash(_polygons),theme,darkTheme,belowLayerId); + +@override +String toString() { + return 'RouteMapArea(identifier: $identifier, polygons: $polygons, theme: $theme, darkTheme: $darkTheme, belowLayerId: $belowLayerId)'; +} + + +} + +/// @nodoc +abstract mixin class _$RouteMapAreaCopyWith<$Res> implements $RouteMapAreaCopyWith<$Res> { + factory _$RouteMapAreaCopyWith(_RouteMapArea value, $Res Function(_RouteMapArea) _then) = __$RouteMapAreaCopyWithImpl; +@override @useResult +$Res call({ + String identifier, List polygons, RouteMapAreaTheme theme, RouteMapAreaTheme? darkTheme, String? belowLayerId +}); + + +@override $RouteMapAreaThemeCopyWith<$Res> get theme;@override $RouteMapAreaThemeCopyWith<$Res>? get darkTheme; + +} +/// @nodoc +class __$RouteMapAreaCopyWithImpl<$Res> + implements _$RouteMapAreaCopyWith<$Res> { + __$RouteMapAreaCopyWithImpl(this._self, this._then); + + final _RouteMapArea _self; + final $Res Function(_RouteMapArea) _then; + +/// Create a copy of RouteMapArea +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? identifier = null,Object? polygons = null,Object? theme = null,Object? darkTheme = freezed,Object? belowLayerId = freezed,}) { + return _then(_RouteMapArea( +identifier: null == identifier ? _self.identifier : identifier // ignore: cast_nullable_to_non_nullable +as String,polygons: null == polygons ? _self._polygons : polygons // ignore: cast_nullable_to_non_nullable +as List,theme: null == theme ? _self.theme : theme // ignore: cast_nullable_to_non_nullable +as RouteMapAreaTheme,darkTheme: freezed == darkTheme ? _self.darkTheme : darkTheme // ignore: cast_nullable_to_non_nullable +as RouteMapAreaTheme?,belowLayerId: freezed == belowLayerId ? _self.belowLayerId : belowLayerId // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +/// Create a copy of RouteMapArea +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$RouteMapAreaThemeCopyWith<$Res> get theme { + + return $RouteMapAreaThemeCopyWith<$Res>(_self.theme, (value) { + return _then(_self.copyWith(theme: value)); + }); +}/// Create a copy of RouteMapArea +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$RouteMapAreaThemeCopyWith<$Res>? get darkTheme { + if (_self.darkTheme == null) { + return null; + } + + return $RouteMapAreaThemeCopyWith<$Res>(_self.darkTheme!, (value) { + return _then(_self.copyWith(darkTheme: value)); + }); +} +} + +/// @nodoc +mixin _$RouteMapAreaPolygon { + +/// The outline of the polygon. It doesn't need to be closed, the first +/// point is repeated at the end if needed. + List get outline;/// Areas cut out of the polygon. + List> get holes; +/// Create a copy of RouteMapAreaPolygon +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$RouteMapAreaPolygonCopyWith get copyWith => _$RouteMapAreaPolygonCopyWithImpl(this as RouteMapAreaPolygon, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RouteMapAreaPolygon&&const DeepCollectionEquality().equals(other.outline, outline)&&const DeepCollectionEquality().equals(other.holes, holes)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(outline),const DeepCollectionEquality().hash(holes)); + +@override +String toString() { + return 'RouteMapAreaPolygon(outline: $outline, holes: $holes)'; +} + + +} + +/// @nodoc +abstract mixin class $RouteMapAreaPolygonCopyWith<$Res> { + factory $RouteMapAreaPolygonCopyWith(RouteMapAreaPolygon value, $Res Function(RouteMapAreaPolygon) _then) = _$RouteMapAreaPolygonCopyWithImpl; +@useResult +$Res call({ + List outline, List> holes +}); + + + + +} +/// @nodoc +class _$RouteMapAreaPolygonCopyWithImpl<$Res> + implements $RouteMapAreaPolygonCopyWith<$Res> { + _$RouteMapAreaPolygonCopyWithImpl(this._self, this._then); + + final RouteMapAreaPolygon _self; + final $Res Function(RouteMapAreaPolygon) _then; + +/// Create a copy of RouteMapAreaPolygon +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? outline = null,Object? holes = null,}) { + return _then(_self.copyWith( +outline: null == outline ? _self.outline : outline // ignore: cast_nullable_to_non_nullable +as List,holes: null == holes ? _self.holes : holes // ignore: cast_nullable_to_non_nullable +as List>, + )); +} + +} + + +/// Adds pattern-matching-related methods to [RouteMapAreaPolygon]. +extension RouteMapAreaPolygonPatterns on RouteMapAreaPolygon { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _RouteMapAreaPolygon value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _RouteMapAreaPolygon() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _RouteMapAreaPolygon value) $default,){ +final _that = this; +switch (_that) { +case _RouteMapAreaPolygon(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _RouteMapAreaPolygon value)? $default,){ +final _that = this; +switch (_that) { +case _RouteMapAreaPolygon() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( List outline, List> holes)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _RouteMapAreaPolygon() when $default != null: +return $default(_that.outline,_that.holes);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( List outline, List> holes) $default,) {final _that = this; +switch (_that) { +case _RouteMapAreaPolygon(): +return $default(_that.outline,_that.holes);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( List outline, List> holes)? $default,) {final _that = this; +switch (_that) { +case _RouteMapAreaPolygon() when $default != null: +return $default(_that.outline,_that.holes);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _RouteMapAreaPolygon extends RouteMapAreaPolygon { + const _RouteMapAreaPolygon({required final List outline, final List> holes = const >[]}): _outline = outline,_holes = holes,super._(); + + +/// The outline of the polygon. It doesn't need to be closed, the first +/// point is repeated at the end if needed. + final List _outline; +/// The outline of the polygon. It doesn't need to be closed, the first +/// point is repeated at the end if needed. +@override List get outline { + if (_outline is EqualUnmodifiableListView) return _outline; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_outline); +} + +/// Areas cut out of the polygon. + final List> _holes; +/// Areas cut out of the polygon. +@override@JsonKey() List> get holes { + if (_holes is EqualUnmodifiableListView) return _holes; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_holes); +} + + +/// Create a copy of RouteMapAreaPolygon +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$RouteMapAreaPolygonCopyWith<_RouteMapAreaPolygon> get copyWith => __$RouteMapAreaPolygonCopyWithImpl<_RouteMapAreaPolygon>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _RouteMapAreaPolygon&&const DeepCollectionEquality().equals(other._outline, _outline)&&const DeepCollectionEquality().equals(other._holes, _holes)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_outline),const DeepCollectionEquality().hash(_holes)); + +@override +String toString() { + return 'RouteMapAreaPolygon(outline: $outline, holes: $holes)'; +} + + +} + +/// @nodoc +abstract mixin class _$RouteMapAreaPolygonCopyWith<$Res> implements $RouteMapAreaPolygonCopyWith<$Res> { + factory _$RouteMapAreaPolygonCopyWith(_RouteMapAreaPolygon value, $Res Function(_RouteMapAreaPolygon) _then) = __$RouteMapAreaPolygonCopyWithImpl; +@override @useResult +$Res call({ + List outline, List> holes +}); + + + + +} +/// @nodoc +class __$RouteMapAreaPolygonCopyWithImpl<$Res> + implements _$RouteMapAreaPolygonCopyWith<$Res> { + __$RouteMapAreaPolygonCopyWithImpl(this._self, this._then); + + final _RouteMapAreaPolygon _self; + final $Res Function(_RouteMapAreaPolygon) _then; + +/// Create a copy of RouteMapAreaPolygon +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? outline = null,Object? holes = null,}) { + return _then(_RouteMapAreaPolygon( +outline: null == outline ? _self._outline : outline // ignore: cast_nullable_to_non_nullable +as List,holes: null == holes ? _self._holes : holes // ignore: cast_nullable_to_non_nullable +as List>, + )); +} + + +} + +/// @nodoc +mixin _$RouteMapAreaTheme { + +/// The opacity is taken from the alpha of the color. + Color get fillColor;/// The opacity is taken from the alpha of the color. + Color get borderColor; double get borderWidth; +/// Create a copy of RouteMapAreaTheme +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$RouteMapAreaThemeCopyWith get copyWith => _$RouteMapAreaThemeCopyWithImpl(this as RouteMapAreaTheme, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RouteMapAreaTheme&&(identical(other.fillColor, fillColor) || other.fillColor == fillColor)&&(identical(other.borderColor, borderColor) || other.borderColor == borderColor)&&(identical(other.borderWidth, borderWidth) || other.borderWidth == borderWidth)); +} + + +@override +int get hashCode => Object.hash(runtimeType,fillColor,borderColor,borderWidth); + +@override +String toString() { + return 'RouteMapAreaTheme(fillColor: $fillColor, borderColor: $borderColor, borderWidth: $borderWidth)'; +} + + +} + +/// @nodoc +abstract mixin class $RouteMapAreaThemeCopyWith<$Res> { + factory $RouteMapAreaThemeCopyWith(RouteMapAreaTheme value, $Res Function(RouteMapAreaTheme) _then) = _$RouteMapAreaThemeCopyWithImpl; +@useResult +$Res call({ + Color fillColor, Color borderColor, double borderWidth +}); + + + + +} +/// @nodoc +class _$RouteMapAreaThemeCopyWithImpl<$Res> + implements $RouteMapAreaThemeCopyWith<$Res> { + _$RouteMapAreaThemeCopyWithImpl(this._self, this._then); + + final RouteMapAreaTheme _self; + final $Res Function(RouteMapAreaTheme) _then; + +/// Create a copy of RouteMapAreaTheme +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? fillColor = null,Object? borderColor = null,Object? borderWidth = null,}) { + return _then(_self.copyWith( +fillColor: null == fillColor ? _self.fillColor : fillColor // ignore: cast_nullable_to_non_nullable +as Color,borderColor: null == borderColor ? _self.borderColor : borderColor // ignore: cast_nullable_to_non_nullable +as Color,borderWidth: null == borderWidth ? _self.borderWidth : borderWidth // ignore: cast_nullable_to_non_nullable +as double, + )); +} + +} + + +/// Adds pattern-matching-related methods to [RouteMapAreaTheme]. +extension RouteMapAreaThemePatterns on RouteMapAreaTheme { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _RouteMapAreaTheme value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _RouteMapAreaTheme() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _RouteMapAreaTheme value) $default,){ +final _that = this; +switch (_that) { +case _RouteMapAreaTheme(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _RouteMapAreaTheme value)? $default,){ +final _that = this; +switch (_that) { +case _RouteMapAreaTheme() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( Color fillColor, Color borderColor, double borderWidth)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _RouteMapAreaTheme() when $default != null: +return $default(_that.fillColor,_that.borderColor,_that.borderWidth);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( Color fillColor, Color borderColor, double borderWidth) $default,) {final _that = this; +switch (_that) { +case _RouteMapAreaTheme(): +return $default(_that.fillColor,_that.borderColor,_that.borderWidth);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( Color fillColor, Color borderColor, double borderWidth)? $default,) {final _that = this; +switch (_that) { +case _RouteMapAreaTheme() when $default != null: +return $default(_that.fillColor,_that.borderColor,_that.borderWidth);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _RouteMapAreaTheme implements RouteMapAreaTheme { + const _RouteMapAreaTheme({required this.fillColor, required this.borderColor, this.borderWidth = 2}); + + +/// The opacity is taken from the alpha of the color. +@override final Color fillColor; +/// The opacity is taken from the alpha of the color. +@override final Color borderColor; +@override@JsonKey() final double borderWidth; + +/// Create a copy of RouteMapAreaTheme +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$RouteMapAreaThemeCopyWith<_RouteMapAreaTheme> get copyWith => __$RouteMapAreaThemeCopyWithImpl<_RouteMapAreaTheme>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _RouteMapAreaTheme&&(identical(other.fillColor, fillColor) || other.fillColor == fillColor)&&(identical(other.borderColor, borderColor) || other.borderColor == borderColor)&&(identical(other.borderWidth, borderWidth) || other.borderWidth == borderWidth)); +} + + +@override +int get hashCode => Object.hash(runtimeType,fillColor,borderColor,borderWidth); + +@override +String toString() { + return 'RouteMapAreaTheme(fillColor: $fillColor, borderColor: $borderColor, borderWidth: $borderWidth)'; +} + + +} + +/// @nodoc +abstract mixin class _$RouteMapAreaThemeCopyWith<$Res> implements $RouteMapAreaThemeCopyWith<$Res> { + factory _$RouteMapAreaThemeCopyWith(_RouteMapAreaTheme value, $Res Function(_RouteMapAreaTheme) _then) = __$RouteMapAreaThemeCopyWithImpl; +@override @useResult +$Res call({ + Color fillColor, Color borderColor, double borderWidth +}); + + + + +} +/// @nodoc +class __$RouteMapAreaThemeCopyWithImpl<$Res> + implements _$RouteMapAreaThemeCopyWith<$Res> { + __$RouteMapAreaThemeCopyWithImpl(this._self, this._then); + + final _RouteMapAreaTheme _self; + final $Res Function(_RouteMapAreaTheme) _then; + +/// Create a copy of RouteMapAreaTheme +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? fillColor = null,Object? borderColor = null,Object? borderWidth = null,}) { + return _then(_RouteMapAreaTheme( +fillColor: null == fillColor ? _self.fillColor : fillColor // ignore: cast_nullable_to_non_nullable +as Color,borderColor: null == borderColor ? _self.borderColor : borderColor // ignore: cast_nullable_to_non_nullable +as Color,borderWidth: null == borderWidth ? _self.borderWidth : borderWidth // ignore: cast_nullable_to_non_nullable +as double, + )); +} + + +} + +// dart format on diff --git a/lib/src/route_map_area_geometry.dart b/lib/src/route_map_area_geometry.dart new file mode 100644 index 0000000..a4d4a7b --- /dev/null +++ b/lib/src/route_map_area_geometry.dart @@ -0,0 +1,112 @@ +import 'dart:math'; + +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:route_map/src/model/route_map_area/route_map_area.dart'; + +/// Mean radius of the earth in meters. +const _earthRadiusInMeters = 6371008.8; + +/// Amount of points of the polygon approximating a radius. With 256 points the +/// polygon deviates less than 0.008 % of the radius from a real circle +/// (e.g. 2.3 m for 30 km), which isn't visible on any zoom level. +const radiusPolygonSteps = 256; + +/// The points on the earth's surface which are [radiusInMeters] away from the +/// [center], as closed ring (the first point is repeated at the end). +List createRadiusPolygon({ + required LatLng center, + required double radiusInMeters, + int steps = radiusPolygonSteps, +}) { + assert(radiusInMeters > 0, "The radius has to be positive"); + assert(steps >= 3, "A polygon needs at least 3 points"); + + final latitude = _toRadians(center.latitude); + final longitude = _toRadians(center.longitude); + final angularDistance = radiusInMeters / _earthRadiusInMeters; + + final points = List.generate(steps, (index) { + final bearing = 2 * pi * index / steps; + + final pointLatitude = asin( + sin(latitude) * cos(angularDistance) + + cos(latitude) * sin(angularDistance) * cos(bearing), + ); + + final pointLongitude = + longitude + + atan2( + sin(bearing) * sin(angularDistance) * cos(latitude), + cos(angularDistance) - sin(latitude) * sin(pointLatitude), + ); + + return LatLng( + _toDegrees(pointLatitude), + _normalizeLongitude(pointLongitude), + ); + }); + + return [...points, points.first]; +} + +/// The GeoJSON feature collection of the [polygons] as one MultiPolygon, as +/// expected by [GeojsonSourceProperties.data] and +/// [MapLibreMapController.setGeoJsonSource]. +Map createAreaFeatureCollection( + List polygons, +) { + return { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + for (final polygon in polygons) + [ + _toGeoJsonRing(polygon.outline), + for (final hole in polygon.holes) _toGeoJsonRing(hole), + ], + ], + }, + }, + ], + }; +} + +/// GeoJSON positions are [longitude, latitude] and rings have to be closed. +List> _toGeoJsonRing(List ring) { + final isClosed = ring.isNotEmpty && ring.first == ring.last; + + return [ + for (final point in ring) [point.longitude, point.latitude], + if (!isClosed && ring.isNotEmpty) + [ring.first.longitude, ring.first.latitude], + ]; +} + +/// The great-circle distance between [a] and [b] in meters (haversine). +double distanceInMeters(LatLng a, LatLng b) { + final latitudeA = _toRadians(a.latitude); + final latitudeB = _toRadians(b.latitude); + final deltaLatitude = latitudeB - latitudeA; + final deltaLongitude = _toRadians(b.longitude - a.longitude); + + final h = + pow(sin(deltaLatitude / 2), 2) + + cos(latitudeA) * cos(latitudeB) * pow(sin(deltaLongitude / 2), 2); + + return 2 * _earthRadiusInMeters * asin(sqrt(h)); +} + +double _toRadians(double degrees) => degrees * pi / 180; + +double _toDegrees(double radians) => radians * 180 / pi; + +/// Keeps the longitude within -180° and 180°. +double _normalizeLongitude(double longitudeInRadians) { + final degrees = _toDegrees(longitudeInRadians); + return (degrees + 540) % 360 - 180; +} diff --git a/lib/src/route_map_base.dart b/lib/src/route_map_base.dart index 063e530..39b4de3 100644 --- a/lib/src/route_map_base.dart +++ b/lib/src/route_map_base.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:route_map/route_map.dart'; @@ -11,6 +12,7 @@ import 'package:route_map/src/annotation_manager/route_map_circle_manager.dart'; import 'package:route_map/src/annotation_manager/route_map_icon_manager.dart'; import 'package:route_map/src/annotation_manager/route_map_layer_manager.dart'; import 'package:route_map/src/annotation_manager/route_map_line_manager.dart'; +import 'package:route_map/src/annotation_manager/route_map_area_manager.dart'; import 'package:route_map/src/route_map_camera_update.dart'; import 'package:route_map/src/route_map_geometry_extension.dart'; import 'package:route_map/src/utils/coalescing_runner.dart'; @@ -64,6 +66,17 @@ class RouteMap extends StatefulWidget { /// [RouteMapPoiCategory] (see [RouteMapPoiCategory.interactive]). final void Function(RouteMapPoiTappedEvent event)? onPoiTapped; + /// Which gestures should be consumed by the map (iOS / Android). + /// + /// Needed when the map is inside another widget handling gestures, e.g. a + /// scrollable page would take the vertical drags away from the map. Pass + /// `{Factory(() => EagerGestureRecognizer())}` to let the map handle all + /// gestures. Without recognizers, the map only gets the gestures nobody + /// else claims. + /// + /// It has no effect on web, where the map receives the events directly. + final Set>? gestureRecognizers; + const RouteMap({ super.key, required this.initialCameraPosition, @@ -84,6 +97,7 @@ class RouteMap extends StatefulWidget { this.onPoiTapped, this.allowIconsOverlap = false, this.ignoreIconsPlacement = false, + this.gestureRecognizers, }); @override @@ -120,6 +134,8 @@ class _RouteMapState extends State { late final RouteMapLayerManager _layerManagerInstance; + late final RouteMapAreaManager _areaManagerInstance; + late final RouteMapLocationIndicatorCoordinator _locationIndicatorCoordinatorInstance; @@ -144,6 +160,11 @@ class _RouteMapState extends State { return _circleManagerInstance; } + Future get _areaManager async { + await _afterStyleReady(); + return _areaManagerInstance; + } + Future get _locationIndicatorCoordinator async { await _afterStyleReady(); @@ -267,6 +288,7 @@ class _RouteMapState extends State { // support that feature in 3d. // https://www.nextpit.com/forum/561686/how-to-use-google-maps-secret-gestures tiltGesturesEnabled: false, + gestureRecognizers: widget.gestureRecognizers, onMapClick: widget.onMapClicked, onCameraIdle: widget.onCameraIdle, onMapCreated: (controller) { @@ -276,6 +298,10 @@ class _RouteMapState extends State { controller: controller, ); _layerManagerInstance = RouteMapLayerManager(controller: controller); + _areaManagerInstance = RouteMapAreaManager( + controller: controller, + layerManager: _layerManagerInstance, + ); _locationIndicatorCoordinatorInstance = RouteMapLocationIndicatorCoordinator( circleManager: _circleManagerInstance, @@ -351,6 +377,14 @@ class _RouteMapState extends State { final circleManager = _circleManagerInstance; final brightness = MediaQuery.platformBrightnessOf(context); + + // The areas are style layers, which were removed together with the + // other style content, so they have to be added again. This isn't done in + // parallel with the annotations, since the layers are inserted relative to + // the annotation layers. + await _areaManagerInstance.restore(brightness); + if (!mounted) return; + await Future.wait([ lineManager.restore(brightness), symbolManager.restore(brightness), diff --git a/lib/src/route_map_controller.dart b/lib/src/route_map_controller.dart index 423e592..2025d86 100644 --- a/lib/src/route_map_controller.dart +++ b/lib/src/route_map_controller.dart @@ -22,6 +22,9 @@ class RouteMapController { Future get _iconManager async => (await _state)._iconManager; + Future get _areaManager async => + (await _state)._areaManager; + Future get _locationIndicatorManager async => (await _state)._locationIndicatorCoordinator; @@ -111,6 +114,31 @@ class RouteMapController { await circleManager.removeCircle(identifier: identifier); } + Future drawArea(RouteMapArea area, {bool animateCamera = false}) async { + final areaManager = await _areaManager; + if (!await _mounted) return; + await areaManager.drawArea(area); + if (!await _mounted) return; + + if (animateCamera) { + await animateCameraTo( + points: [for (final polygon in area.polygons) ...polygon.outline], + ); + } + } + + Future removeArea(String identifier) async { + final areaManager = await _areaManager; + if (!await _mounted) return; + await areaManager.removeArea(identifier: identifier); + } + + Future removeAreas() async { + final areaManager = await _areaManager; + if (!await _mounted) return; + await areaManager.removeAreas(); + } + Future drawUserLocationIndicator( RouteMapUserLocationIndicator indicator, ) async { diff --git a/test/route_map_area_test.dart b/test/route_map_area_test.dart new file mode 100644 index 0000000..7c4acbc --- /dev/null +++ b/test/route_map_area_test.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:route_map/route_map.dart'; +import 'package:route_map/src/route_map_area_geometry.dart'; + +void main() { + const strasbourg = LatLng(48.5796, 7.7353); + + group('createRadiusPolygon', () { + test('every point has the radius as distance to the center', () { + for (final radius in [50.0, 30000.0, 150000.0]) { + final polygon = createRadiusPolygon( + center: strasbourg, + radiusInMeters: radius, + ); + + for (final point in polygon) { + expect( + distanceInMeters(strasbourg, point), + closeTo(radius, radius * 0.0001), + ); + } + } + }); + + test('is a closed ring with the amount of steps', () { + final polygon = createRadiusPolygon( + center: strasbourg, + radiusInMeters: 30000, + steps: 64, + ); + + expect(polygon, hasLength(65)); + expect(polygon.first, polygon.last); + }); + + test('starts in the north and goes clockwise', () { + final polygon = createRadiusPolygon( + center: strasbourg, + radiusInMeters: 30000, + steps: 4, + ); + + final [north, east, south, west, _] = polygon; + + expect(north.latitude, greaterThan(strasbourg.latitude)); + expect(north.longitude, closeTo(strasbourg.longitude, 0.000001)); + expect(east.longitude, greaterThan(strasbourg.longitude)); + expect(south.latitude, lessThan(strasbourg.latitude)); + expect(west.longitude, lessThan(strasbourg.longitude)); + }); + + test('keeps the longitude within -180 and 180 at the antimeridian', () { + final polygon = createRadiusPolygon( + center: const LatLng(0, 179.9), + radiusInMeters: 50000, + ); + + for (final point in polygon) { + expect(point.longitude, inInclusiveRange(-180, 180)); + } + }); + }); + + group('createAreaFeatureCollection', () { + test('creates a MultiPolygon with [longitude, latitude] positions', () { + final featureCollection = createAreaFeatureCollection(const [ + RouteMapAreaPolygon( + outline: [LatLng(1, 2), LatLng(3, 4), LatLng(5, 6), LatLng(1, 2)], + holes: [ + [LatLng(2, 3), LatLng(3, 3.5), LatLng(4, 4.5), LatLng(2, 3)], + ], + ), + RouteMapAreaPolygon( + outline: [LatLng(10, 20), LatLng(30, 40), LatLng(50, 60)], + ), + ]); + + final features = featureCollection["features"] as List; + final geometry = (features.single as Map)["geometry"] as Map; + + expect(featureCollection["type"], "FeatureCollection"); + expect(geometry["type"], "MultiPolygon"); + expect(geometry["coordinates"], [ + [ + [ + [2, 1], + [4, 3], + [6, 5], + [2, 1], + ], + [ + [3, 2], + [3.5, 3], + [4.5, 4], + [3, 2], + ], + ], + // the open outline is closed + [ + [ + [20, 10], + [40, 30], + [60, 50], + [20, 10], + ], + ], + ]); + }); + }); + + test('distanceInMeters between Strasbourg and Paris', () { + const paris = LatLng(48.8566, 2.3522); + + // ~397 km great-circle distance + expect(distanceInMeters(strasbourg, paris), closeTo(397000, 2000)); + }); + + group('RouteMapAreaPolygon.radius', () { + test('has the radius as outline and no holes', () { + final polygon = RouteMapAreaPolygon.radius( + center: strasbourg, + radiusInMeters: 30000, + ); + + expect(polygon.holes, isEmpty); + expect(polygon.outline, hasLength(radiusPolygonSteps + 1)); + + for (final point in polygon.outline) { + expect(distanceInMeters(strasbourg, point), closeTo(30000, 3)); + } + }); + }); + + test('area keeps the optional fields empty by default', () { + final area = RouteMapArea( + identifier: 'area-id', + polygons: [ + RouteMapAreaPolygon.radius(center: strasbourg, radiusInMeters: 30000), + ], + theme: const RouteMapAreaTheme( + fillColor: Color(0x330000FF), + borderColor: Color(0xFF0000FF), + ), + ); + + expect(area.darkTheme, isNull); + expect(area.belowLayerId, isNull); + expect(area.theme.borderWidth, 2); + }); +} diff --git a/test/route_map_icon_manager_test.dart b/test/route_map_icon_manager_test.dart new file mode 100644 index 0000000..f4cdb7d --- /dev/null +++ b/test/route_map_icon_manager_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:route_map/src/annotation_manager/route_map_icon_manager.dart'; + +void main() { + group('tintSvg', () { + test('keeps fill="none" of an outlined icon', () { + const svg = + ''; + + final tinted = tintSvg(svg, color: const Color(0xFFFFFFFF)); + + expect( + tinted, + '', + ); + }); + + test('replaces the fill of a filled icon', () { + const svg = ''; + + final tinted = tintSvg(svg, color: const Color(0xFFFF0000)); + + expect(tinted, ''); + }); + + test('keeps stroke="none"', () { + const svg = ''; + + final tinted = tintSvg(svg, color: const Color(0xFF00FF00)); + + expect(tinted, ''); + }); + }); +}