Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/route_map.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
151 changes: 151 additions & 0 deletions lib/src/annotation_manager/route_map_area_manager.dart
Original file line number Diff line number Diff line change
@@ -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 = <String, RouteMapArea>{};

final _lock = Lock();

RouteMapAreaManager({required this.controller, required this.layerManager});

Future<void> restore(Brightness brightness) {
return _lock.synchronized(() async {
_brightness = brightness;

for (final area in _areaMap.values.toList()) {
if (controller.isDisposed) return;
await _install(area);
}
});
}

Future<void> 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<void> removeArea({required String identifier}) {
return _lock.synchronized(() async {
if (_areaMap.remove(identifier) == null) return;
await _uninstall(identifier);
});
}

Future<void> removeAreas() {
return _lock.synchronized(() async {
final identifiers = _areaMap.keys.toList();
_areaMap.clear();

for (final identifier in identifiers) {
await _uninstall(identifier);
}
});
}

Future<void> _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<void> _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";
}
24 changes: 14 additions & 10 deletions lib/src/annotation_manager/route_map_icon_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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"');
}
26 changes: 26 additions & 0 deletions lib/src/annotation_manager/route_map_layer_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> 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<void> 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.
Expand Down
71 changes: 71 additions & 0 deletions lib/src/model/route_map_area/route_map_area.dart
Original file line number Diff line number Diff line change
@@ -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<RouteMapAreaPolygon> 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<LatLng> outline,

/// Areas cut out of the polygon.
@Default(<List<LatLng>>[]) List<List<LatLng>> 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;
}
Loading