diff --git a/lib/route_map.dart b/lib/route_map.dart index 371f158..2d3b3fe 100644 --- a/lib/route_map.dart +++ b/lib/route_map.dart @@ -15,4 +15,5 @@ export 'src/model/route_map_circle/route_map_circle.dart'; export 'src/model/route_map_route/route_map_route.dart'; export 'src/route_map_base.dart'; export 'src/model/no_service_area_layer.dart'; +export 'src/model/route_map_poi_layer.dart'; export 'src/model/route_map_user_location_indicator/route_map_user_location_indicator.dart'; diff --git a/lib/src/annotation_manager/route_map_icon_manager.dart b/lib/src/annotation_manager/route_map_icon_manager.dart index dd51ba8..2667a19 100644 --- a/lib/src/annotation_manager/route_map_icon_manager.dart +++ b/lib/src/annotation_manager/route_map_icon_manager.dart @@ -153,8 +153,13 @@ class RouteMapIconManager { final drawCircleAroundIcon = theme.drawCircleAroundIcon; final svgIconPath = mapIcon.svgIconPath; final text = mapIcon.text; + final foreground = theme.foreground; + + assert( + text == null || foreground != null, + "RouteMapIconTheme.foreground must be non-null when rendering text.", + ); - final colorHex = theme.foreground.toHexStringRGB(); final circleRadius = sizeBeforeStroke.width / 2 - padding; final circleOffset = padding + circleRadius; @@ -189,12 +194,23 @@ class RouteMapIconManager { } if (svgIconPath != null) { - // Load SVG and override color - final rawSvg = (await rootBundle.loadString(svgIconPath)) - .replaceAll(RegExp(r'fill="[^"]*"'), 'fill="$colorHex"') - .replaceAll(RegExp(r'stroke="[^"]*"'), 'stroke="$colorHex"'); - - final loader = SvgStringLoader(rawSvg); + // Load SVG and (optionally) override its fill / stroke. When + // [foreground] is null, the original SVG colors are preserved. + 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()}"', + ); + + final loader = SvgStringLoader(tintedSvg); final pictureInfo = await vg.loadPicture(loader, null); // Draw the SVG icon @@ -231,7 +247,8 @@ class RouteMapIconManager { style: TextStyle( fontSize: 24, fontWeight: FontWeight.bold, - color: theme.foreground, + // Asserted to be non-null above. + color: foreground, ), ), textDirection: TextDirection.ltr, diff --git a/lib/src/model/route_map_icon/route_map_icon.dart b/lib/src/model/route_map_icon/route_map_icon.dart index df6a48a..0be8766 100644 --- a/lib/src/model/route_map_icon/route_map_icon.dart +++ b/lib/src/model/route_map_icon/route_map_icon.dart @@ -29,7 +29,15 @@ abstract class RouteMapIcon with _$RouteMapIcon { abstract class RouteMapIconTheme with _$RouteMapIconTheme { const factory RouteMapIconTheme({ required Color background, - required Color foreground, + + /// Foreground color used to tint the SVG icon (or render the text). + /// + /// When `null`, the original `fill="…"` / `stroke="…"` attributes of + /// the SVG are preserved instead of being overridden. Useful for + /// multi-color brand icons where the source colors should win. + /// + /// Must be non-null when `RouteMapIcon.text` is used. + required Color? foreground, @Default(false) bool drawCircleAroundIcon, @Default(0) double strokeWidth, @Default(10) double padding, diff --git a/lib/src/model/route_map_icon/route_map_icon.freezed.dart b/lib/src/model/route_map_icon/route_map_icon.freezed.dart index 376aa9a..bb2622d 100644 --- a/lib/src/model/route_map_icon/route_map_icon.freezed.dart +++ b/lib/src/model/route_map_icon/route_map_icon.freezed.dart @@ -343,7 +343,14 @@ $RouteMapIconThemeCopyWith<$Res>? get darkTheme { /// @nodoc mixin _$RouteMapIconTheme { - Color get background; Color get foreground; bool get drawCircleAroundIcon; double get strokeWidth; double get padding; + Color get background;/// Foreground color used to tint the SVG icon (or render the text). +/// +/// When `null`, the original `fill="…"` / `stroke="…"` attributes of +/// the SVG are preserved instead of being overridden. Useful for +/// multi-color brand icons where the source colors should win. +/// +/// Must be non-null when `RouteMapIcon.text` is used. + Color? get foreground; bool get drawCircleAroundIcon; double get strokeWidth; double get padding; /// Create a copy of RouteMapIconTheme /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -374,7 +381,7 @@ abstract mixin class $RouteMapIconThemeCopyWith<$Res> { factory $RouteMapIconThemeCopyWith(RouteMapIconTheme value, $Res Function(RouteMapIconTheme) _then) = _$RouteMapIconThemeCopyWithImpl; @useResult $Res call({ - Color background, Color foreground, bool drawCircleAroundIcon, double strokeWidth, double padding + Color background, Color? foreground, bool drawCircleAroundIcon, double strokeWidth, double padding }); @@ -391,11 +398,11 @@ class _$RouteMapIconThemeCopyWithImpl<$Res> /// Create a copy of RouteMapIconTheme /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? background = null,Object? foreground = null,Object? drawCircleAroundIcon = null,Object? strokeWidth = null,Object? padding = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? background = null,Object? foreground = freezed,Object? drawCircleAroundIcon = null,Object? strokeWidth = null,Object? padding = null,}) { return _then(_self.copyWith( background: null == background ? _self.background : background // ignore: cast_nullable_to_non_nullable -as Color,foreground: null == foreground ? _self.foreground : foreground // ignore: cast_nullable_to_non_nullable -as Color,drawCircleAroundIcon: null == drawCircleAroundIcon ? _self.drawCircleAroundIcon : drawCircleAroundIcon // ignore: cast_nullable_to_non_nullable +as Color,foreground: freezed == foreground ? _self.foreground : foreground // ignore: cast_nullable_to_non_nullable +as Color?,drawCircleAroundIcon: null == drawCircleAroundIcon ? _self.drawCircleAroundIcon : drawCircleAroundIcon // ignore: cast_nullable_to_non_nullable as bool,strokeWidth: null == strokeWidth ? _self.strokeWidth : strokeWidth // ignore: cast_nullable_to_non_nullable as double,padding: null == padding ? _self.padding : padding // ignore: cast_nullable_to_non_nullable as double, @@ -483,7 +490,7 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( Color background, Color foreground, bool drawCircleAroundIcon, double strokeWidth, double padding)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( Color background, Color? foreground, bool drawCircleAroundIcon, double strokeWidth, double padding)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _RouteMapIconTheme() when $default != null: return $default(_that.background,_that.foreground,_that.drawCircleAroundIcon,_that.strokeWidth,_that.padding);case _: @@ -504,7 +511,7 @@ return $default(_that.background,_that.foreground,_that.drawCircleAroundIcon,_th /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( Color background, Color foreground, bool drawCircleAroundIcon, double strokeWidth, double padding) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( Color background, Color? foreground, bool drawCircleAroundIcon, double strokeWidth, double padding) $default,) {final _that = this; switch (_that) { case _RouteMapIconTheme(): return $default(_that.background,_that.foreground,_that.drawCircleAroundIcon,_that.strokeWidth,_that.padding);case _: @@ -524,7 +531,7 @@ return $default(_that.background,_that.foreground,_that.drawCircleAroundIcon,_th /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( Color background, Color foreground, bool drawCircleAroundIcon, double strokeWidth, double padding)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( Color background, Color? foreground, bool drawCircleAroundIcon, double strokeWidth, double padding)? $default,) {final _that = this; switch (_that) { case _RouteMapIconTheme() when $default != null: return $default(_that.background,_that.foreground,_that.drawCircleAroundIcon,_that.strokeWidth,_that.padding);case _: @@ -543,7 +550,14 @@ class _RouteMapIconTheme implements RouteMapIconTheme { @override final Color background; -@override final Color foreground; +/// Foreground color used to tint the SVG icon (or render the text). +/// +/// When `null`, the original `fill="…"` / `stroke="…"` attributes of +/// the SVG are preserved instead of being overridden. Useful for +/// multi-color brand icons where the source colors should win. +/// +/// Must be non-null when `RouteMapIcon.text` is used. +@override final Color? foreground; @override@JsonKey() final bool drawCircleAroundIcon; @override@JsonKey() final double strokeWidth; @override@JsonKey() final double padding; @@ -578,7 +592,7 @@ abstract mixin class _$RouteMapIconThemeCopyWith<$Res> implements $RouteMapIconT factory _$RouteMapIconThemeCopyWith(_RouteMapIconTheme value, $Res Function(_RouteMapIconTheme) _then) = __$RouteMapIconThemeCopyWithImpl; @override @useResult $Res call({ - Color background, Color foreground, bool drawCircleAroundIcon, double strokeWidth, double padding + Color background, Color? foreground, bool drawCircleAroundIcon, double strokeWidth, double padding }); @@ -595,11 +609,11 @@ class __$RouteMapIconThemeCopyWithImpl<$Res> /// Create a copy of RouteMapIconTheme /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? background = null,Object? foreground = null,Object? drawCircleAroundIcon = null,Object? strokeWidth = null,Object? padding = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? background = null,Object? foreground = freezed,Object? drawCircleAroundIcon = null,Object? strokeWidth = null,Object? padding = null,}) { return _then(_RouteMapIconTheme( background: null == background ? _self.background : background // ignore: cast_nullable_to_non_nullable -as Color,foreground: null == foreground ? _self.foreground : foreground // ignore: cast_nullable_to_non_nullable -as Color,drawCircleAroundIcon: null == drawCircleAroundIcon ? _self.drawCircleAroundIcon : drawCircleAroundIcon // ignore: cast_nullable_to_non_nullable +as Color,foreground: freezed == foreground ? _self.foreground : foreground // ignore: cast_nullable_to_non_nullable +as Color?,drawCircleAroundIcon: null == drawCircleAroundIcon ? _self.drawCircleAroundIcon : drawCircleAroundIcon // ignore: cast_nullable_to_non_nullable as bool,strokeWidth: null == strokeWidth ? _self.strokeWidth : strokeWidth // ignore: cast_nullable_to_non_nullable as double,padding: null == padding ? _self.padding : padding // ignore: cast_nullable_to_non_nullable as double, diff --git a/lib/src/model/route_map_poi_layer.dart b/lib/src/model/route_map_poi_layer.dart new file mode 100644 index 0000000..e7dd552 --- /dev/null +++ b/lib/src/model/route_map_poi_layer.dart @@ -0,0 +1,211 @@ +import 'dart:ui'; + +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:route_map/src/model/route_map_icon/route_map_icon.dart'; +import 'package:route_map/src/model/route_map_icon_anchor.dart'; + +/// Describes a clustered, optionally interactive point-of-interest overlay. +/// +/// A [RouteMapPoiLayer] is a thin declarative wrapper around a MapLibre +/// GeoJSON source plus one or more symbol/circle layers. +/// +/// The actual GeoJSON data is loaded lazily via [createSource] so that +/// callers can fetch it from disk, network or any other async source. +class RouteMapPoiLayer { + /// Unique identifier — used for visibility toggling and removal. + final String identifier; + + /// Async source loader. May be invoked more than once if the layer needs + /// to be re-created (e.g. after a style change). + final Future Function() createSource; + + /// One or more categories that filter the source's features and render + /// them with different icons / labels. + final List categories; + + /// When non-null, clustering is rendered using these visual properties. + /// Note: the underlying [GeojsonSourceProperties] returned from + /// [createSource] must also be configured with `cluster: true` for this + /// to take effect. + final RouteMapPoiClusterTheme? clusterTheme; + + /// Initial visibility — defaults to `true`. + final bool initiallyVisible; + + /// Optional layer-id from the underlying style below which all of this + /// POI layer's sub-layers are inserted. Useful to keep POIs underneath + /// other top-level annotations. + final String? belowLayerId; + + const RouteMapPoiLayer({ + required this.identifier, + required this.createSource, + required this.categories, + this.clusterTheme, + this.initiallyVisible = true, + this.belowLayerId, + }); +} + +/// One filtered "sub-layer" within a [RouteMapPoiLayer]. +/// +/// Visually identical to [RouteMapIcon] — every feature is rendered as a +/// pin-shaped marker (the [markerPath] filled with [theme.background]) +/// containing the supplied SVG icon. +class RouteMapPoiCategory { + /// Stable identifier — forwarded back to the caller via + /// [RouteMapPoiTappedEvent.categoryIdentifier]. + final String identifier; + + /// GeoJSON filter expression applied to the source's features. + /// Example: `['==', ['get', 'type'], 'service']`. + final List filter; + + /// Outline of the pin background, identical to [RouteMapIcon.markerPath]. + /// Pass the same path you use for the regular icons so the POI markers + /// match visually. + final Path markerPath; + + /// SVG asset path of the icon drawn inside the pin. All `fill="…"` / + /// `stroke="…"` attributes are overridden with [theme.foreground]. + final String svgIconPath; + + /// Pin background + foreground theme (light mode). + final RouteMapIconTheme theme; + + /// Optional dark-mode theme. Defaults to [theme]. + final RouteMapIconTheme? darkTheme; + + /// Whether tapping a feature of this category should trigger + /// `onPoiTapped`. + final bool interactive; + + /// Optional label rendered next to the icon (typically below it). + final RouteMapPoiCategoryLabel? label; + + /// Anchor of the rendered icon image. + final RouteMapIconAnchor anchor; + + const RouteMapPoiCategory({ + required this.identifier, + required this.filter, + required this.markerPath, + required this.svgIconPath, + required this.theme, + this.darkTheme, + this.interactive = false, + this.label, + this.anchor = RouteMapIconAnchor.bottom, + }); +} + +/// Optional text label rendered for the features of a [RouteMapPoiCategory]. +class RouteMapPoiCategoryLabel { + /// GeoJSON expression returning the label text. + /// Example: `['concat', ['literal', 'Service '], ['get', 'master-id']]`. + final List textExpression; + + /// Foreground color of the rendered text in light mode. + final Color color; + + /// Optional dark-mode color (defaults to [color]). + final Color? darkColor; + + /// Color of the outline drawn around the text (used to keep it legible + /// on busy map backgrounds). + final Color haloColor; + + /// Optional dark-mode halo color (defaults to [haloColor]). + final Color? darkHaloColor; + + /// Width of the halo outline. + final double haloWidth; + + /// Font size of the label. + final double textSize; + + /// Anchor of the label relative to the icon. + final RouteMapIconAnchor anchor; + + const RouteMapPoiCategoryLabel({ + required this.textExpression, + required this.color, + required this.haloColor, + this.darkColor, + this.darkHaloColor, + this.haloWidth = 3, + this.textSize = 14, + this.anchor = RouteMapIconAnchor.top, + }); +} + +/// Theme applied to clustered POI features. +/// +/// MapLibre's clustering produces synthetic features with a `point_count` +/// property — when [RouteMapPoiLayer.clusterTheme] is non-null, route_map +/// renders a circle background + the point count on top of every clustered +/// feature. +class RouteMapPoiClusterTheme { + /// Background color of the cluster circle (light mode). + final Color circleColor; + + /// Optional dark-mode background color. + final Color? darkCircleColor; + + /// Color of the outline of the cluster circle (light mode). + final Color circleStrokeColor; + + /// Optional dark-mode outline color. + final Color? darkCircleStrokeColor; + + /// Color of the count text rendered on top of the circle (light mode). + final Color textColor; + + /// Optional dark-mode count text color. + final Color? darkTextColor; + + /// Radius of the cluster circle. + final double circleRadius; + + /// Outline width of the cluster circle. + final double circleStrokeWidth; + + /// Font size of the count text. + final double textSize; + + const RouteMapPoiClusterTheme({ + required this.circleColor, + required this.circleStrokeColor, + required this.textColor, + this.darkCircleColor, + this.darkCircleStrokeColor, + this.darkTextColor, + this.circleRadius = 12, + this.circleStrokeWidth = 1, + this.textSize = 16, + }); +} + +/// Payload of the `onPoiTapped` callback. +class RouteMapPoiTappedEvent { + /// Identifier of the [RouteMapPoiLayer] containing the tapped feature. + final String layerIdentifier; + + /// Identifier of the [RouteMapPoiCategory] the feature belongs to. + final String categoryIdentifier; + + /// Raw feature id as reported by MapLibre. For features that have an + /// explicit `id` set in the source GeoJSON this will be that id (as a + /// stringified value); otherwise an auto-generated id. + final String featureId; + + /// Geographic position of the tap. + final LatLng latLng; + + const RouteMapPoiTappedEvent({ + required this.layerIdentifier, + required this.categoryIdentifier, + required this.featureId, + required this.latLng, + }); +} diff --git a/lib/src/poi_layer_extension.dart b/lib/src/poi_layer_extension.dart new file mode 100644 index 0000000..08d1132 --- /dev/null +++ b/lib/src/poi_layer_extension.dart @@ -0,0 +1,307 @@ +part of 'route_map_base.dart'; + +/// Internal book-keeping for a single [RouteMapPoiLayer] that has been +/// materialised on the map. +class _PoiLayerEntry { + final RouteMapPoiLayer layer; + final String sourceId; + final List categoryLayerIds; + final List clusterLayerIds; + final Map categoryById; + bool isVisible; + + _PoiLayerEntry({ + required this.layer, + required this.sourceId, + required this.categoryLayerIds, + required this.clusterLayerIds, + required this.categoryById, + required this.isVisible, + }); + + List get allLayerIds => [...categoryLayerIds, ...clusterLayerIds]; +} + +extension _RouteMapPoiLayerState on _RouteMapState { + /// Wires up every declared [RouteMapPoiLayer] on the freshly-loaded + /// style. Must be called from `onStyleLoadedCallback`. + Future _addPoiLayers() async { + final poiLayers = widget.poiLayers; + if (poiLayers.isEmpty) return; + final controller = await _controller; + if (!mounted) return; + + for (final layer in poiLayers) { + await _addPoiLayer(controller: controller, layer: layer); + if (!mounted) return; + } + } + + /// Removes all currently-installed POI layers. Used both for explicit + /// removal and on style change to clean up before re-installing. + Future _removeAllPoiLayers() async { + if (_poiLayers.isEmpty) return; + final controller = await _controller; + if (!mounted) return; + + for (final entry in _poiLayers.values.toList()) { + await _removePoiLayerEntry(controller: controller, entry: entry); + if (!mounted) return; + } + } + + Future _addPoiLayer({ + required MapLibreMapController controller, + required RouteMapPoiLayer layer, + }) async { + // Resolve the layer-id below which we insert all POI sub-layers. Mirrors + // the logic of `_addNoServiceAreaLayer` to keep symbol manager layers + // above the inserted POI layers. + final belowLayerId = layer.belowLayerId; + + final topLayers = [ + ...controller.lineManager!.layerIds, + ...controller.symbolManager!.layerIds, + ...controller.circleManager!.layerIds, + ...controller.fillManager!.layerIds, + ?belowLayerId, + ]; + final allLayerIds = await controller.getLayerIds(); + final insertBelowLayer = allLayerIds + .map((layerId) => layerId.toString()) + .firstWhere((layerId) => topLayers.contains(layerId)); + + if (!mounted) return; + + // Load the GeoJSON source. + final source = await layer.createSource(); + if (!mounted) return; + + final sourceId = "route_map_poi_source_${layer.identifier}"; + await controller.addSource(sourceId, source); + if (!mounted) return; + + // Register icon images for every category via the shared icon manager + // so POI markers go through the exact same rasterization pipeline as + // regular [RouteMapIcon]s. A synthetic [RouteMapIcon] template is used + // — only the markerPath, theme and svgIconPath are read by the + // rasterizer; latLng is ignored. + for (final category in layer.categories) { + final imageId = _poiCategoryImageId(layer: layer, category: category); + final template = RouteMapIcon( + identifier: imageId, + latLng: const LatLng(0, 0), + markerPath: category.markerPath, + theme: category.theme, + darkTheme: category.darkTheme, + svgIconPath: category.svgIconPath, + anchor: category.anchor, + ); + await _iconManagerInstance.addImageToCacheIfNeeded( + controller, + mapIcon: template, + ); + if (!mounted) return; + } + + // Add a SymbolLayer per category. + final categoryLayerIds = []; + final categoryById = {}; + + final brightness = MediaQuery.platformBrightnessOf(context); + + for (final category in layer.categories) { + final imageId = _poiCategoryImageId(layer: layer, category: category); + final layerId = _poiCategoryLayerId(layer: layer, category: category); + + // Combine the user-provided filter with a "not clustered" check so + // clustered features don't render individually. + final filter = [ + 'all', + [ + '!', + ['has', 'point_count'], + ], + category.filter, + ]; + + final labelDef = category.label; + final hasLabel = labelDef != null; + + // Resolve theme-aware label colors. + final labelColor = labelDef == null + ? null + : (brightness == Brightness.dark + ? (labelDef.darkColor ?? labelDef.color) + : labelDef.color); + final labelHaloColor = labelDef == null + ? null + : (brightness == Brightness.dark + ? (labelDef.darkHaloColor ?? labelDef.haloColor) + : labelDef.haloColor); + + await controller.addLayer( + sourceId, + layerId, + SymbolLayerProperties( + iconImage: imageId, + iconAnchor: category.anchor.mglIconValue, + // Match the [RouteMapIconManager.iconScale] used for regular + // icons so POI pins are rendered at the same size. + iconSize: _iconManagerInstance.iconScale, + iconAllowOverlap: widget.allowIconsOverlap, + iconIgnorePlacement: widget.ignoreIconsPlacement, + textField: hasLabel ? labelDef.textExpression : null, + textAnchor: hasLabel ? labelDef.anchor.mglIconValue : null, + textSize: hasLabel ? labelDef.textSize : null, + // ignore: deprecated_member_use + textColor: labelColor?.toHexStringRGB(), + // ignore: deprecated_member_use + textHaloColor: labelHaloColor?.toHexStringRGB(), + textHaloWidth: hasLabel ? labelDef.haloWidth : null, + ), + belowLayerId: insertBelowLayer, + enableInteraction: category.interactive, + filter: filter, + ); + if (!mounted) return; + + categoryLayerIds.add(layerId); + categoryById[layerId] = category; + } + + // Cluster appearance — single circle layer + count text layer, inserted + // *above* the categories so they always paint on top of individual + // features (mirroring the maplibre clustering example). + final clusterLayerIds = []; + final clusterTheme = layer.clusterTheme; + if (clusterTheme != null) { + final circleId = _poiClusterCircleLayerId(layer: layer); + final textId = _poiClusterTextLayerId(layer: layer); + + final circleColor = brightness == Brightness.dark + ? (clusterTheme.darkCircleColor ?? clusterTheme.circleColor) + : clusterTheme.circleColor; + final circleStrokeColor = brightness == Brightness.dark + ? (clusterTheme.darkCircleStrokeColor ?? + clusterTheme.circleStrokeColor) + : clusterTheme.circleStrokeColor; + final textColor = brightness == Brightness.dark + ? (clusterTheme.darkTextColor ?? clusterTheme.textColor) + : clusterTheme.textColor; + + await controller.addLayer( + sourceId, + circleId, + CircleLayerProperties( + // ignore: deprecated_member_use + circleColor: circleColor.toHexStringRGB(), + circleRadius: clusterTheme.circleRadius, + // ignore: deprecated_member_use + circleStrokeColor: circleStrokeColor.toHexStringRGB(), + circleStrokeWidth: clusterTheme.circleStrokeWidth, + ), + belowLayerId: insertBelowLayer, + filter: ['has', 'point_count'], + ); + if (!mounted) return; + + await controller.addLayer( + sourceId, + textId, + SymbolLayerProperties( + textField: [Expressions.get, 'point_count'], + textSize: clusterTheme.textSize, + // ignore: deprecated_member_use + textColor: textColor.toHexStringRGB(), + ), + belowLayerId: insertBelowLayer, + filter: ['has', 'point_count'], + ); + if (!mounted) return; + + clusterLayerIds.addAll([circleId, textId]); + } + + final entry = _PoiLayerEntry( + layer: layer, + sourceId: sourceId, + categoryLayerIds: categoryLayerIds, + clusterLayerIds: clusterLayerIds, + categoryById: categoryById, + isVisible: layer.initiallyVisible, + ); + _poiLayers[layer.identifier] = entry; + + if (!layer.initiallyVisible) { + await _applyPoiLayerVisibility( + controller: controller, + entry: entry, + isVisible: false, + ); + } + } + + Future _removePoiLayerEntry({ + required MapLibreMapController controller, + required _PoiLayerEntry entry, + }) async { + for (final layerId in entry.allLayerIds) { + try { + await controller.removeLayer(layerId); + } catch (_) { + // The layer might already be gone after a style change — ignore. + } + if (!mounted) return; + } + try { + await controller.removeSource(entry.sourceId); + } catch (_) { + // Same here. + } + _poiLayers.remove(entry.layer.identifier); + } + + Future _applyPoiLayerVisibility({ + required MapLibreMapController controller, + required _PoiLayerEntry entry, + required bool isVisible, + }) async { + final layerIds = await controller.getLayerIds(); + for (final layerId in entry.allLayerIds) { + if (!layerIds.contains(layerId)) continue; + await controller.setLayerVisibility(layerId, isVisible); + if (!mounted) return; + } + entry.isVisible = isVisible; + } + + /// Looks up the [RouteMapPoiCategory] / [RouteMapPoiLayer] pair of a + /// tapped feature, identified by the layer-id reported by maplibre. + ({RouteMapPoiLayer layer, RouteMapPoiCategory category})? + _findPoiCategoryByLayerId(String layerId) { + for (final entry in _poiLayers.values) { + final category = entry.categoryById[layerId]; + if (category != null) { + return (layer: entry.layer, category: category); + } + } + return null; + } +} + +String _poiCategoryImageId({ + required RouteMapPoiLayer layer, + required RouteMapPoiCategory category, +}) => "route_map_poi_icon_${layer.identifier}_${category.identifier}"; + +String _poiCategoryLayerId({ + required RouteMapPoiLayer layer, + required RouteMapPoiCategory category, +}) => "route_map_poi_layer_${layer.identifier}_${category.identifier}"; + +String _poiClusterCircleLayerId({required RouteMapPoiLayer layer}) => + "route_map_poi_cluster_circle_${layer.identifier}"; + +String _poiClusterTextLayerId({required RouteMapPoiLayer layer}) => + "route_map_poi_cluster_text_${layer.identifier}"; diff --git a/lib/src/route_map_base.dart b/lib/src/route_map_base.dart index 6f37c84..faaa8f5 100644 --- a/lib/src/route_map_base.dart +++ b/lib/src/route_map_base.dart @@ -15,6 +15,7 @@ import 'package:route_map/src/route_map_geometry_extension.dart'; part 'route_map_controller.dart'; part 'route_map_no_service_area_layer.dart'; +part 'poi_layer_extension.dart'; class RouteMap extends StatefulWidget { final CameraPosition initialCameraPosition; @@ -49,6 +50,15 @@ class RouteMap extends StatefulWidget { )? onFeatureHover; + /// Optional clustered POI overlays (e.g. service points, border crossings). + /// Each entry is materialised when the map's style is loaded; visibility + /// can later be toggled through [RouteMapController.setPoiLayerVisibility]. + final List poiLayers; + + /// Called when the user taps a feature of an interactive + /// [RouteMapPoiCategory] (see [RouteMapPoiCategory.interactive]). + final void Function(RouteMapPoiTappedEvent event)? onPoiTapped; + const RouteMap({ super.key, required this.initialCameraPosition, @@ -60,11 +70,13 @@ class RouteMap extends StatefulWidget { this.cameraTargetBounds, this.minMaxZoomPreference, this.noServiceAreaLayers = const [], + this.poiLayers = const [], this.trackCameraPosition = false, this.onCameraMoveStarted, this.onCameraIdle, this.onFeatureDrag, this.onFeatureHover, + this.onPoiTapped, this.allowIconsOverlap = false, this.ignoreIconsPlacement = false, }); @@ -88,6 +100,9 @@ class _RouteMapState extends State { late final RouteMapLocationIndicatorCoordinator _locationIndicatorCoordinatorInstance; + /// Currently materialised POI layers (keyed by layer identifier). + final Map _poiLayers = {}; + Future get _controller => _controllerCompleter.future; Future get _iconManager async { @@ -136,6 +151,7 @@ class _RouteMapState extends State { if (!_controllerCompleter.isCompleted) return; final controller = await _controllerCompleter.future; controller.onFeatureDrag.remove(_onFeatureDrag); + controller.onFeatureTapped.remove(_onFeatureTapped); if (kIsWeb) { controller.onFeatureHover.remove(_onFeatureHover); } @@ -183,6 +199,32 @@ class _RouteMapState extends State { widget.onFeatureHover?.call(id, latLng, eventType); } + /// Looks up the [RouteMapPoiCategory] (if any) for the layer-id reported + /// by maplibre's tap event and forwards the event to the caller via + /// [RouteMap.onPoiTapped]. + void _onFeatureTapped( + Point point, + LatLng latLng, + String featureId, + String layerId, + Annotation? annotation, + ) { + final onPoiTapped = widget.onPoiTapped; + if (onPoiTapped == null) return; + + final match = _findPoiCategoryByLayerId(layerId); + if (match == null) return; + + onPoiTapped( + RouteMapPoiTappedEvent( + layerIdentifier: match.layer.identifier, + categoryIdentifier: match.category.identifier, + featureId: featureId, + latLng: latLng, + ), + ); + } + @override Widget build(BuildContext context) { // the [PlatformView] could get the focus, but it doesn't make any sense @@ -226,13 +268,20 @@ class _RouteMapState extends State { controller.addListener(_cameraStateListener!); controller.onFeatureDrag.add(_onFeatureDrag); + controller.onFeatureTapped.add(_onFeatureTapped); if (kIsWeb) { controller.onFeatureHover.add(_onFeatureHover); } }, onStyleLoadedCallback: () async { + // Drop any POI layers we set up for the previous style so the + // following `_addPoiLayers` rebuild can re-use the same identifiers. + await _removeAllPoiLayers(); + await _addNoServiceAreaLayers(); + await _addPoiLayers(); + await _setMapLanguage(); unawaited(_setOverlap()); diff --git a/lib/src/route_map_controller.dart b/lib/src/route_map_controller.dart index 467f995..423e592 100644 --- a/lib/src/route_map_controller.dart +++ b/lib/src/route_map_controller.dart @@ -163,4 +163,27 @@ class RouteMapController { duration: const Duration(milliseconds: 1500), ); } + + /// Toggles the visibility of a previously-installed POI layer. + /// + /// Identifier must match [RouteMapPoiLayer.identifier]. Silently does + /// nothing when no layer with that identifier is installed. + Future setPoiLayerVisibility({ + required String identifier, + required bool isVisible, + }) async { + final state = await _state; + final controller = await _controller; + if (!await _mounted) return; + + final entry = state._poiLayers[identifier]; + if (entry == null) return; + if (entry.isVisible == isVisible) return; + + await state._applyPoiLayerVisibility( + controller: controller, + entry: entry, + isVisible: isVisible, + ); + } }