From 22199943e1b46e7ce62c4154d73638dd4201c53e Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Fri, 26 Jun 2026 11:16:41 +0200 Subject: [PATCH 1/9] feat: add clustered POI layer support --- lib/route_map.dart | 2 + lib/src/model/route_map_poi_layer.dart | 205 +++++++++++++++++ lib/src/route_map_base.dart | 49 ++++ lib/src/route_map_controller.dart | 44 ++++ lib/src/route_map_poi_layer.dart | 295 +++++++++++++++++++++++++ lib/src/util/svg_rasterizer.dart | 52 +++++ 6 files changed, 647 insertions(+) create mode 100644 lib/src/model/route_map_poi_layer.dart create mode 100644 lib/src/route_map_poi_layer.dart create mode 100644 lib/src/util/svg_rasterizer.dart diff --git a/lib/route_map.dart b/lib/route_map.dart index 371f158..a0bc612 100644 --- a/lib/route_map.dart +++ b/lib/route_map.dart @@ -15,4 +15,6 @@ 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'; +export 'src/util/svg_rasterizer.dart' show rasterizeSvgAsset; 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..40354dc --- /dev/null +++ b/lib/src/model/route_map_poi_layer.dart @@ -0,0 +1,205 @@ +import 'dart:ui'; + +import 'package:maplibre_gl/maplibre_gl.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]. +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; + + /// SVG asset path. The icon is rasterized at runtime — all `fill="…"` + /// and `stroke="…"` attributes are overridden with [iconColor]. + final String svgIconPath; + + /// Color the SVG fill / stroke is overridden with for the light theme. + final Color iconColor; + + /// Optional color used when the device is in dark mode. Defaults to + /// [iconColor]. + final Color? darkIconColor; + + /// Logical pixel size used for SVG rasterization. + final double iconSize; + + /// 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.svgIconPath, + required this.iconColor, + this.darkIconColor, + this.iconSize = 32, + 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/route_map_base.dart b/lib/src/route_map_base.dart index 6f37c84..d36212e 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 'route_map_poi_layer.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..16dc2a7 100644 --- a/lib/src/route_map_controller.dart +++ b/lib/src/route_map_controller.dart @@ -163,4 +163,48 @@ 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, + ); + } + + /// Whether a given POI layer is currently visible. Returns `null` when + /// no layer with the supplied [identifier] is installed. + Future isPoiLayerVisible(String identifier) async { + final state = await _state; + return state._poiLayers[identifier]?.isVisible; + } + + /// Removes a previously-installed POI layer entirely (including its + /// GeoJSON source). Silently does nothing when no layer with that + /// identifier is installed. + Future removePoiLayer(String identifier) async { + final state = await _state; + final controller = await _controller; + if (!await _mounted) return; + + final entry = state._poiLayers[identifier]; + if (entry == null) return; + + await state._removePoiLayerEntry(controller: controller, entry: entry); + } } diff --git a/lib/src/route_map_poi_layer.dart b/lib/src/route_map_poi_layer.dart new file mode 100644 index 0000000..650570b --- /dev/null +++ b/lib/src/route_map_poi_layer.dart @@ -0,0 +1,295 @@ +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. SVGs are rasterized to PNG + // bytes once per (category, brightness) tuple. + final brightness = MediaQuery.platformBrightnessOf(context); + for (final category in layer.categories) { + final imageId = _poiCategoryImageId(layer: layer, category: category); + final color = brightness == Brightness.dark + ? (category.darkIconColor ?? category.iconColor) + : category.iconColor; + final iconBytes = await rasterizeSvgAsset( + assetPath: category.svgIconPath, + color: color, + size: category.iconSize, + ); + if (!mounted) return; + + await controller.addImage(imageId, iconBytes); + if (!mounted) return; + } + + // Add a SymbolLayer per category. + final categoryLayerIds = []; + final categoryById = {}; + + 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, + 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/util/svg_rasterizer.dart b/lib/src/util/svg_rasterizer.dart new file mode 100644 index 0000000..5cbd731 --- /dev/null +++ b/lib/src/util/svg_rasterizer.dart @@ -0,0 +1,52 @@ +import 'dart:ui'; + +import 'package:flutter/services.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +/// Rasterizes an SVG asset to a PNG byte buffer suitable for +/// `MapLibreMapController.addImage`. +/// +/// All `fill="…"` and `stroke="…"` attributes in the source SVG are +/// overridden with [color]. The result has square dimensions of [size] +/// logical pixels (multiplied by [devicePixelRatio] internally). +Future rasterizeSvgAsset({ + required String assetPath, + required Color color, + required double size, + double devicePixelRatio = 2, +}) async { + final colorHex = + '#' + // ignore: deprecated_member_use + '${color.value.toRadixString(16).padLeft(8, '0').substring(2)}'; + + final rawSvg = (await rootBundle.loadString(assetPath)) + .replaceAll(RegExp(r'fill="[^"]*"'), 'fill="$colorHex"') + .replaceAll(RegExp(r'stroke="[^"]*"'), 'stroke="$colorHex"'); + + final loader = SvgStringLoader(rawSvg); + final pictureInfo = await vg.loadPicture(loader, null); + + final imageSize = (size * devicePixelRatio).toInt(); + final scaleX = imageSize / pictureInfo.size.width; + final scaleY = imageSize / pictureInfo.size.height; + final scale = scaleX < scaleY ? scaleX : scaleY; + + final recorder = PictureRecorder(); + final canvas = Canvas(recorder); + canvas.scale(scale, scale); + canvas.drawPicture(pictureInfo.picture); + pictureInfo.picture.dispose(); + + final picture = recorder.endRecording(); + final image = await picture.toImage(imageSize, imageSize); + final byteData = await image.toByteData(format: ImageByteFormat.png); + picture.dispose(); + image.dispose(); + + if (byteData == null) { + throw StateError('Failed to rasterize SVG asset "$assetPath" to PNG.'); + } + + return byteData.buffer.asUint8List(); +} From c30977b36766f243426da77f3dcfc90bba964f19 Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Fri, 26 Jun 2026 11:50:38 +0200 Subject: [PATCH 2/9] add draft --- lib/route_map.dart | 2 +- .../route_map_icon_manager.dart | 133 +---------------- lib/src/model/route_map_poi_layer.dart | 32 ++-- lib/src/route_map_poi_layer.dart | 21 ++- lib/src/util/pin_marker_rasterizer.dart | 138 ++++++++++++++++++ lib/src/util/svg_rasterizer.dart | 52 ------- 6 files changed, 176 insertions(+), 202 deletions(-) create mode 100644 lib/src/util/pin_marker_rasterizer.dart delete mode 100644 lib/src/util/svg_rasterizer.dart diff --git a/lib/route_map.dart b/lib/route_map.dart index a0bc612..9f925bd 100644 --- a/lib/route_map.dart +++ b/lib/route_map.dart @@ -17,4 +17,4 @@ 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'; -export 'src/util/svg_rasterizer.dart' show rasterizeSvgAsset; +export 'src/util/pin_marker_rasterizer.dart' show rasterizePinMarker; diff --git a/lib/src/annotation_manager/route_map_icon_manager.dart b/lib/src/annotation_manager/route_map_icon_manager.dart index dd51ba8..e066f64 100644 --- a/lib/src/annotation_manager/route_map_icon_manager.dart +++ b/lib/src/annotation_manager/route_map_icon_manager.dart @@ -1,10 +1,5 @@ -import 'dart:ui'; - import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_svg/flutter_svg.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:route_map/route_map.dart'; @@ -141,129 +136,11 @@ class RouteMapIconManager { Brightness.dark => mapIcon.darkTheme ?? mapIcon.theme, Brightness.light => mapIcon.theme, }; - - final markerPath = mapIcon.markerPath; - final sizeBeforeStroke = markerPath.getBounds().size; - final sizeWithStroke = Size( - sizeBeforeStroke.width + theme.strokeWidth, - sizeBeforeStroke.height + theme.strokeWidth, - ); - final padding = theme.padding; - final strokeWidth = theme.strokeWidth; - final drawCircleAroundIcon = theme.drawCircleAroundIcon; - final svgIconPath = mapIcon.svgIconPath; - final text = mapIcon.text; - - final colorHex = theme.foreground.toHexStringRGB(); - final circleRadius = sizeBeforeStroke.width / 2 - padding; - final circleOffset = padding + circleRadius; - - // Create a canvas to draw on - final recorder = PictureRecorder(); - final canvas = Canvas(recorder); - final paint = Paint()..style = PaintingStyle.fill; - - // Draw Marker - // Half of stroke is outer and the other half is inner, tranlsate only half of stroke width - canvas.translate(strokeWidth / 2, strokeWidth / 2); - paint.color = theme.background; - canvas.drawPath(markerPath, paint); - // Draw Stroke - if (strokeWidth != 0) { - canvas.drawPath( - markerPath, - Paint() - ..color = const Color(0xFF7A7A7A) - ..style = PaintingStyle.stroke - ..strokeWidth = strokeWidth, - ); - } - // Draw white circle - if (drawCircleAroundIcon) { - paint.color = const Color(0xffffffff); - canvas.drawCircle( - Offset(circleOffset, circleOffset), - circleRadius, - paint, - ); - } - - 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); - final pictureInfo = await vg.loadPicture(loader, null); - - // Draw the SVG icon - final iconWidth = drawCircleAroundIcon - ? circleRadius * 1.3 - : sizeBeforeStroke.width - padding * 2; - final iconHeight = drawCircleAroundIcon - ? circleRadius * 1.3 - : sizeBeforeStroke.height - padding * 2; - // Compute scaling factors - final iconScaleX = iconWidth / pictureInfo.size.width; - final iconScaleY = iconHeight / pictureInfo.size.height; - - // Use the smaller scale to contain - final iconScale = iconScaleX < iconScaleY ? iconScaleX : iconScaleY; - - // Center the scaled picture - final dx = circleOffset - (pictureInfo.size.width * iconScale / 2); - final dy = circleOffset - (pictureInfo.size.height * iconScale / 2); - - canvas.save(); - canvas.translate(dx, dy); - canvas.scale(iconScale, iconScale); - canvas.drawPicture(pictureInfo.picture); - canvas.restore(); - pictureInfo.picture.dispose(); - } - - if (text != null) { - // Draw the number in the center of th circle - final textPainter = TextPainter( - text: TextSpan( - text: text, - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: theme.foreground, - ), - ), - textDirection: TextDirection.ltr, - maxLines: 1, - ); - textPainter.layout(); - final offset = Offset( - circleOffset - textPainter.width / 2, - circleOffset - textPainter.height / 2, - ); - textPainter.paint(canvas, offset); - } - - // End recording and convert to image - return _recorderToPng(recorder, sizeWithStroke); - } - - Future _recorderToPng( - PictureRecorder recorder, - Size imageSize, - ) async { - // End recording and convert to image - final picture = recorder.endRecording(); - final img = await picture.toImage( - imageSize.width.toInt(), - imageSize.height.toInt(), + return rasterizePinMarker( + markerPath: mapIcon.markerPath, + theme: theme, + svgIconPath: mapIcon.svgIconPath, + text: mapIcon.text, ); - // Convert image to PNG byte data - final byteData = await img.toByteData(format: ImageByteFormat.png); - final pngBytes = byteData!.buffer.asUint8List(); - picture.dispose(); - img.dispose(); - return pngBytes; } } diff --git a/lib/src/model/route_map_poi_layer.dart b/lib/src/model/route_map_poi_layer.dart index 40354dc..e7dd552 100644 --- a/lib/src/model/route_map_poi_layer.dart +++ b/lib/src/model/route_map_poi_layer.dart @@ -1,6 +1,7 @@ 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. @@ -47,6 +48,10 @@ class RouteMapPoiLayer { } /// 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]. @@ -56,19 +61,20 @@ class RouteMapPoiCategory { /// Example: `['==', ['get', 'type'], 'service']`. final List filter; - /// SVG asset path. The icon is rasterized at runtime — all `fill="…"` - /// and `stroke="…"` attributes are overridden with [iconColor]. - final String svgIconPath; + /// 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; - /// Color the SVG fill / stroke is overridden with for the light theme. - final Color iconColor; + /// SVG asset path of the icon drawn inside the pin. All `fill="…"` / + /// `stroke="…"` attributes are overridden with [theme.foreground]. + final String svgIconPath; - /// Optional color used when the device is in dark mode. Defaults to - /// [iconColor]. - final Color? darkIconColor; + /// Pin background + foreground theme (light mode). + final RouteMapIconTheme theme; - /// Logical pixel size used for SVG rasterization. - final double iconSize; + /// Optional dark-mode theme. Defaults to [theme]. + final RouteMapIconTheme? darkTheme; /// Whether tapping a feature of this category should trigger /// `onPoiTapped`. @@ -83,10 +89,10 @@ class RouteMapPoiCategory { const RouteMapPoiCategory({ required this.identifier, required this.filter, + required this.markerPath, required this.svgIconPath, - required this.iconColor, - this.darkIconColor, - this.iconSize = 32, + required this.theme, + this.darkTheme, this.interactive = false, this.label, this.anchor = RouteMapIconAnchor.bottom, diff --git a/lib/src/route_map_poi_layer.dart b/lib/src/route_map_poi_layer.dart index 650570b..57d17e9 100644 --- a/lib/src/route_map_poi_layer.dart +++ b/lib/src/route_map_poi_layer.dart @@ -82,17 +82,19 @@ extension _RouteMapPoiLayerState on _RouteMapState { if (!mounted) return; // Register icon images for every category. SVGs are rasterized to PNG - // bytes once per (category, brightness) tuple. + // bytes once per (category, brightness) tuple — using the same pin + // marker renderer that backs [RouteMapIcon] so POI markers match the + // visual style of regular icons. final brightness = MediaQuery.platformBrightnessOf(context); for (final category in layer.categories) { final imageId = _poiCategoryImageId(layer: layer, category: category); - final color = brightness == Brightness.dark - ? (category.darkIconColor ?? category.iconColor) - : category.iconColor; - final iconBytes = await rasterizeSvgAsset( - assetPath: category.svgIconPath, - color: color, - size: category.iconSize, + final theme = brightness == Brightness.dark + ? (category.darkTheme ?? category.theme) + : category.theme; + final iconBytes = await rasterizePinMarker( + markerPath: category.markerPath, + theme: theme, + svgIconPath: category.svgIconPath, ); if (!mounted) return; @@ -137,6 +139,9 @@ extension _RouteMapPoiLayerState on _RouteMapState { 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: kIsWeb ? 0.5 : 1.5, iconAllowOverlap: widget.allowIconsOverlap, iconIgnorePlacement: widget.ignoreIconsPlacement, textField: hasLabel ? labelDef.textExpression : null, diff --git a/lib/src/util/pin_marker_rasterizer.dart b/lib/src/util/pin_marker_rasterizer.dart new file mode 100644 index 0000000..86536f1 --- /dev/null +++ b/lib/src/util/pin_marker_rasterizer.dart @@ -0,0 +1,138 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:route_map/src/model/route_map_icon/route_map_icon.dart'; + +/// Rasterizes a pin-shaped marker (the same kind that backs [RouteMapIcon]) +/// to a PNG byte buffer suitable for `MapLibreMapController.addImage`. +/// +/// The rendering matches the style used internally by `RouteMapIconManager`: +/// +/// 1. Draws [markerPath] filled with [RouteMapIconTheme.background]. +/// 2. Optionally draws a stroke around the marker. +/// 3. Optionally draws a white circle in the marker's "icon slot". +/// 4. Centers and tints [svgIconPath] (if supplied) using +/// [RouteMapIconTheme.foreground]; otherwise centers [text]. +/// +/// Exactly one of [svgIconPath] / [text] may be non-null. +Future rasterizePinMarker({ + required Path markerPath, + required RouteMapIconTheme theme, + String? svgIconPath, + String? text, +}) async { + assert( + svgIconPath == null || text == null, + "Provide either svgIconPath or text, not both.", + ); + + final sizeBeforeStroke = markerPath.getBounds().size; + final sizeWithStroke = Size( + sizeBeforeStroke.width + theme.strokeWidth, + sizeBeforeStroke.height + theme.strokeWidth, + ); + final padding = theme.padding; + final strokeWidth = theme.strokeWidth; + final drawCircleAroundIcon = theme.drawCircleAroundIcon; + + // ignore: deprecated_member_use + final colorHex = theme.foreground.toHexStringRGB(); + final circleRadius = sizeBeforeStroke.width / 2 - padding; + final circleOffset = padding + circleRadius; + + final recorder = PictureRecorder(); + final canvas = Canvas(recorder); + final paint = Paint()..style = PaintingStyle.fill; + + // Half of stroke is outer / half is inner — translate only half so the + // path stays centered within [sizeWithStroke]. + canvas.translate(strokeWidth / 2, strokeWidth / 2); + paint.color = theme.background; + canvas.drawPath(markerPath, paint); + + if (strokeWidth != 0) { + canvas.drawPath( + markerPath, + Paint() + ..color = const Color(0xFF7A7A7A) + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth, + ); + } + + if (drawCircleAroundIcon) { + paint.color = const Color(0xffffffff); + canvas.drawCircle( + Offset(circleOffset, circleOffset), + circleRadius, + paint, + ); + } + + if (svgIconPath != null) { + final rawSvg = (await rootBundle.loadString(svgIconPath)) + .replaceAll(RegExp(r'fill="[^"]*"'), 'fill="$colorHex"') + .replaceAll(RegExp(r'stroke="[^"]*"'), 'stroke="$colorHex"'); + + final loader = SvgStringLoader(rawSvg); + final pictureInfo = await vg.loadPicture(loader, null); + + final iconWidth = drawCircleAroundIcon + ? circleRadius * 1.3 + : sizeBeforeStroke.width - padding * 2; + final iconHeight = drawCircleAroundIcon + ? circleRadius * 1.3 + : sizeBeforeStroke.height - padding * 2; + final iconScaleX = iconWidth / pictureInfo.size.width; + final iconScaleY = iconHeight / pictureInfo.size.height; + + final iconScale = iconScaleX < iconScaleY ? iconScaleX : iconScaleY; + + final dx = circleOffset - (pictureInfo.size.width * iconScale / 2); + final dy = circleOffset - (pictureInfo.size.height * iconScale / 2); + + canvas.save(); + canvas.translate(dx, dy); + canvas.scale(iconScale, iconScale); + canvas.drawPicture(pictureInfo.picture); + canvas.restore(); + pictureInfo.picture.dispose(); + } else if (text != null) { + final textPainter = TextPainter( + text: TextSpan( + text: text, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: theme.foreground, + ), + ), + textDirection: TextDirection.ltr, + maxLines: 1, + ); + textPainter.layout(); + final offset = Offset( + circleOffset - textPainter.width / 2, + circleOffset - textPainter.height / 2, + ); + textPainter.paint(canvas, offset); + } + + final picture = recorder.endRecording(); + final image = await picture.toImage( + sizeWithStroke.width.toInt(), + sizeWithStroke.height.toInt(), + ); + final byteData = await image.toByteData(format: ImageByteFormat.png); + picture.dispose(); + image.dispose(); + + if (byteData == null) { + throw StateError('Failed to rasterize pin marker.'); + } + + return byteData.buffer.asUint8List(); +} diff --git a/lib/src/util/svg_rasterizer.dart b/lib/src/util/svg_rasterizer.dart deleted file mode 100644 index 5cbd731..0000000 --- a/lib/src/util/svg_rasterizer.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/services.dart'; -import 'package:flutter_svg/flutter_svg.dart'; - -/// Rasterizes an SVG asset to a PNG byte buffer suitable for -/// `MapLibreMapController.addImage`. -/// -/// All `fill="…"` and `stroke="…"` attributes in the source SVG are -/// overridden with [color]. The result has square dimensions of [size] -/// logical pixels (multiplied by [devicePixelRatio] internally). -Future rasterizeSvgAsset({ - required String assetPath, - required Color color, - required double size, - double devicePixelRatio = 2, -}) async { - final colorHex = - '#' - // ignore: deprecated_member_use - '${color.value.toRadixString(16).padLeft(8, '0').substring(2)}'; - - final rawSvg = (await rootBundle.loadString(assetPath)) - .replaceAll(RegExp(r'fill="[^"]*"'), 'fill="$colorHex"') - .replaceAll(RegExp(r'stroke="[^"]*"'), 'stroke="$colorHex"'); - - final loader = SvgStringLoader(rawSvg); - final pictureInfo = await vg.loadPicture(loader, null); - - final imageSize = (size * devicePixelRatio).toInt(); - final scaleX = imageSize / pictureInfo.size.width; - final scaleY = imageSize / pictureInfo.size.height; - final scale = scaleX < scaleY ? scaleX : scaleY; - - final recorder = PictureRecorder(); - final canvas = Canvas(recorder); - canvas.scale(scale, scale); - canvas.drawPicture(pictureInfo.picture); - pictureInfo.picture.dispose(); - - final picture = recorder.endRecording(); - final image = await picture.toImage(imageSize, imageSize); - final byteData = await image.toByteData(format: ImageByteFormat.png); - picture.dispose(); - image.dispose(); - - if (byteData == null) { - throw StateError('Failed to rasterize SVG asset "$assetPath" to PNG.'); - } - - return byteData.buffer.asUint8List(); -} From 1771ee1e6afc384f37747df370a0d50d56df8a4d Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Fri, 26 Jun 2026 12:42:44 +0200 Subject: [PATCH 3/9] add draft --- .../model/route_map_icon/route_map_icon.dart | 10 ++++- .../route_map_icon.freezed.dart | 40 +++++++++++++------ lib/src/util/pin_marker_rasterizer.dart | 36 ++++++++++++----- 3 files changed, 62 insertions(+), 24 deletions(-) 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/util/pin_marker_rasterizer.dart b/lib/src/util/pin_marker_rasterizer.dart index 86536f1..7ba21d7 100644 --- a/lib/src/util/pin_marker_rasterizer.dart +++ b/lib/src/util/pin_marker_rasterizer.dart @@ -17,6 +17,10 @@ import 'package:route_map/src/model/route_map_icon/route_map_icon.dart'; /// 4. Centers and tints [svgIconPath] (if supplied) using /// [RouteMapIconTheme.foreground]; otherwise centers [text]. /// +/// When [RouteMapIconTheme.foreground] is `null`, the original `fill="…"` / +/// `stroke="…"` attributes of the SVG are preserved instead of being +/// overridden. In that case [text] must not be used. +/// /// Exactly one of [svgIconPath] / [text] may be non-null. Future rasterizePinMarker({ required Path markerPath, @@ -28,7 +32,12 @@ Future rasterizePinMarker({ svgIconPath == null || text == null, "Provide either svgIconPath or text, not both.", ); + assert( + text == null || theme.foreground != null, + "RouteMapIconTheme.foreground must be non-null when rendering text.", + ); + final foreground = theme.foreground; final sizeBeforeStroke = markerPath.getBounds().size; final sizeWithStroke = Size( sizeBeforeStroke.width + theme.strokeWidth, @@ -38,8 +47,6 @@ Future rasterizePinMarker({ final strokeWidth = theme.strokeWidth; final drawCircleAroundIcon = theme.drawCircleAroundIcon; - // ignore: deprecated_member_use - final colorHex = theme.foreground.toHexStringRGB(); final circleRadius = sizeBeforeStroke.width / 2 - padding; final circleOffset = padding + circleRadius; @@ -47,8 +54,6 @@ Future rasterizePinMarker({ final canvas = Canvas(recorder); final paint = Paint()..style = PaintingStyle.fill; - // Half of stroke is outer / half is inner — translate only half so the - // path stays centered within [sizeWithStroke]. canvas.translate(strokeWidth / 2, strokeWidth / 2); paint.color = theme.background; canvas.drawPath(markerPath, paint); @@ -73,11 +78,21 @@ Future rasterizePinMarker({ } if (svgIconPath != null) { - final rawSvg = (await rootBundle.loadString(svgIconPath)) - .replaceAll(RegExp(r'fill="[^"]*"'), 'fill="$colorHex"') - .replaceAll(RegExp(r'stroke="[^"]*"'), 'stroke="$colorHex"'); - - final loader = SvgStringLoader(rawSvg); + 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); final iconWidth = drawCircleAroundIcon @@ -107,7 +122,8 @@ Future rasterizePinMarker({ style: TextStyle( fontSize: 24, fontWeight: FontWeight.bold, - color: theme.foreground, + // Asserted to be non-null above. + color: foreground, ), ), textDirection: TextDirection.ltr, From 229e36296b64103ca55ac1565d88051792469949 Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Fri, 26 Jun 2026 11:16:41 +0200 Subject: [PATCH 4/9] feat: add clustered POI layer support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds RouteMapPoiLayer + RouteMapPoiCategory for clustered, optionally interactive point-of-interest overlays. Features: * Declarative API mirroring NoServiceAreaLayer — pass a list of POI layers to RouteMap and visibility can be toggled via the controller. * Categories render as pin-shaped markers matching the visual style of regular RouteMapIcons (same markerPath + RouteMapIconTheme). * Optional clustering with configurable circle + count-text appearance. * GeoJSON-expression labels with light/dark mode colors. * Per-feature tap events forwarded via onPoiTapped. Extracts the pin-marker rasterization logic from RouteMapIconManager into the reusable rasterizePinMarker helper so both regular icons and POI categories share the exact same rendering. --- lib/route_map.dart | 2 + .../route_map_icon_manager.dart | 133 +------- lib/src/model/route_map_poi_layer.dart | 211 ++++++++++++ lib/src/route_map_base.dart | 49 +++ lib/src/route_map_controller.dart | 44 +++ lib/src/route_map_poi_layer.dart | 300 ++++++++++++++++++ lib/src/util/pin_marker_rasterizer.dart | 138 ++++++++ 7 files changed, 749 insertions(+), 128 deletions(-) create mode 100644 lib/src/model/route_map_poi_layer.dart create mode 100644 lib/src/route_map_poi_layer.dart create mode 100644 lib/src/util/pin_marker_rasterizer.dart diff --git a/lib/route_map.dart b/lib/route_map.dart index 371f158..9f925bd 100644 --- a/lib/route_map.dart +++ b/lib/route_map.dart @@ -15,4 +15,6 @@ 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'; +export 'src/util/pin_marker_rasterizer.dart' show rasterizePinMarker; diff --git a/lib/src/annotation_manager/route_map_icon_manager.dart b/lib/src/annotation_manager/route_map_icon_manager.dart index dd51ba8..e066f64 100644 --- a/lib/src/annotation_manager/route_map_icon_manager.dart +++ b/lib/src/annotation_manager/route_map_icon_manager.dart @@ -1,10 +1,5 @@ -import 'dart:ui'; - import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_svg/flutter_svg.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:route_map/route_map.dart'; @@ -141,129 +136,11 @@ class RouteMapIconManager { Brightness.dark => mapIcon.darkTheme ?? mapIcon.theme, Brightness.light => mapIcon.theme, }; - - final markerPath = mapIcon.markerPath; - final sizeBeforeStroke = markerPath.getBounds().size; - final sizeWithStroke = Size( - sizeBeforeStroke.width + theme.strokeWidth, - sizeBeforeStroke.height + theme.strokeWidth, - ); - final padding = theme.padding; - final strokeWidth = theme.strokeWidth; - final drawCircleAroundIcon = theme.drawCircleAroundIcon; - final svgIconPath = mapIcon.svgIconPath; - final text = mapIcon.text; - - final colorHex = theme.foreground.toHexStringRGB(); - final circleRadius = sizeBeforeStroke.width / 2 - padding; - final circleOffset = padding + circleRadius; - - // Create a canvas to draw on - final recorder = PictureRecorder(); - final canvas = Canvas(recorder); - final paint = Paint()..style = PaintingStyle.fill; - - // Draw Marker - // Half of stroke is outer and the other half is inner, tranlsate only half of stroke width - canvas.translate(strokeWidth / 2, strokeWidth / 2); - paint.color = theme.background; - canvas.drawPath(markerPath, paint); - // Draw Stroke - if (strokeWidth != 0) { - canvas.drawPath( - markerPath, - Paint() - ..color = const Color(0xFF7A7A7A) - ..style = PaintingStyle.stroke - ..strokeWidth = strokeWidth, - ); - } - // Draw white circle - if (drawCircleAroundIcon) { - paint.color = const Color(0xffffffff); - canvas.drawCircle( - Offset(circleOffset, circleOffset), - circleRadius, - paint, - ); - } - - 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); - final pictureInfo = await vg.loadPicture(loader, null); - - // Draw the SVG icon - final iconWidth = drawCircleAroundIcon - ? circleRadius * 1.3 - : sizeBeforeStroke.width - padding * 2; - final iconHeight = drawCircleAroundIcon - ? circleRadius * 1.3 - : sizeBeforeStroke.height - padding * 2; - // Compute scaling factors - final iconScaleX = iconWidth / pictureInfo.size.width; - final iconScaleY = iconHeight / pictureInfo.size.height; - - // Use the smaller scale to contain - final iconScale = iconScaleX < iconScaleY ? iconScaleX : iconScaleY; - - // Center the scaled picture - final dx = circleOffset - (pictureInfo.size.width * iconScale / 2); - final dy = circleOffset - (pictureInfo.size.height * iconScale / 2); - - canvas.save(); - canvas.translate(dx, dy); - canvas.scale(iconScale, iconScale); - canvas.drawPicture(pictureInfo.picture); - canvas.restore(); - pictureInfo.picture.dispose(); - } - - if (text != null) { - // Draw the number in the center of th circle - final textPainter = TextPainter( - text: TextSpan( - text: text, - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: theme.foreground, - ), - ), - textDirection: TextDirection.ltr, - maxLines: 1, - ); - textPainter.layout(); - final offset = Offset( - circleOffset - textPainter.width / 2, - circleOffset - textPainter.height / 2, - ); - textPainter.paint(canvas, offset); - } - - // End recording and convert to image - return _recorderToPng(recorder, sizeWithStroke); - } - - Future _recorderToPng( - PictureRecorder recorder, - Size imageSize, - ) async { - // End recording and convert to image - final picture = recorder.endRecording(); - final img = await picture.toImage( - imageSize.width.toInt(), - imageSize.height.toInt(), + return rasterizePinMarker( + markerPath: mapIcon.markerPath, + theme: theme, + svgIconPath: mapIcon.svgIconPath, + text: mapIcon.text, ); - // Convert image to PNG byte data - final byteData = await img.toByteData(format: ImageByteFormat.png); - final pngBytes = byteData!.buffer.asUint8List(); - picture.dispose(); - img.dispose(); - return pngBytes; } } 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/route_map_base.dart b/lib/src/route_map_base.dart index 6f37c84..d36212e 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 'route_map_poi_layer.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..16dc2a7 100644 --- a/lib/src/route_map_controller.dart +++ b/lib/src/route_map_controller.dart @@ -163,4 +163,48 @@ 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, + ); + } + + /// Whether a given POI layer is currently visible. Returns `null` when + /// no layer with the supplied [identifier] is installed. + Future isPoiLayerVisible(String identifier) async { + final state = await _state; + return state._poiLayers[identifier]?.isVisible; + } + + /// Removes a previously-installed POI layer entirely (including its + /// GeoJSON source). Silently does nothing when no layer with that + /// identifier is installed. + Future removePoiLayer(String identifier) async { + final state = await _state; + final controller = await _controller; + if (!await _mounted) return; + + final entry = state._poiLayers[identifier]; + if (entry == null) return; + + await state._removePoiLayerEntry(controller: controller, entry: entry); + } } diff --git a/lib/src/route_map_poi_layer.dart b/lib/src/route_map_poi_layer.dart new file mode 100644 index 0000000..57d17e9 --- /dev/null +++ b/lib/src/route_map_poi_layer.dart @@ -0,0 +1,300 @@ +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. SVGs are rasterized to PNG + // bytes once per (category, brightness) tuple — using the same pin + // marker renderer that backs [RouteMapIcon] so POI markers match the + // visual style of regular icons. + final brightness = MediaQuery.platformBrightnessOf(context); + for (final category in layer.categories) { + final imageId = _poiCategoryImageId(layer: layer, category: category); + final theme = brightness == Brightness.dark + ? (category.darkTheme ?? category.theme) + : category.theme; + final iconBytes = await rasterizePinMarker( + markerPath: category.markerPath, + theme: theme, + svgIconPath: category.svgIconPath, + ); + if (!mounted) return; + + await controller.addImage(imageId, iconBytes); + if (!mounted) return; + } + + // Add a SymbolLayer per category. + final categoryLayerIds = []; + final categoryById = {}; + + 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: kIsWeb ? 0.5 : 1.5, + 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/util/pin_marker_rasterizer.dart b/lib/src/util/pin_marker_rasterizer.dart new file mode 100644 index 0000000..86536f1 --- /dev/null +++ b/lib/src/util/pin_marker_rasterizer.dart @@ -0,0 +1,138 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:route_map/src/model/route_map_icon/route_map_icon.dart'; + +/// Rasterizes a pin-shaped marker (the same kind that backs [RouteMapIcon]) +/// to a PNG byte buffer suitable for `MapLibreMapController.addImage`. +/// +/// The rendering matches the style used internally by `RouteMapIconManager`: +/// +/// 1. Draws [markerPath] filled with [RouteMapIconTheme.background]. +/// 2. Optionally draws a stroke around the marker. +/// 3. Optionally draws a white circle in the marker's "icon slot". +/// 4. Centers and tints [svgIconPath] (if supplied) using +/// [RouteMapIconTheme.foreground]; otherwise centers [text]. +/// +/// Exactly one of [svgIconPath] / [text] may be non-null. +Future rasterizePinMarker({ + required Path markerPath, + required RouteMapIconTheme theme, + String? svgIconPath, + String? text, +}) async { + assert( + svgIconPath == null || text == null, + "Provide either svgIconPath or text, not both.", + ); + + final sizeBeforeStroke = markerPath.getBounds().size; + final sizeWithStroke = Size( + sizeBeforeStroke.width + theme.strokeWidth, + sizeBeforeStroke.height + theme.strokeWidth, + ); + final padding = theme.padding; + final strokeWidth = theme.strokeWidth; + final drawCircleAroundIcon = theme.drawCircleAroundIcon; + + // ignore: deprecated_member_use + final colorHex = theme.foreground.toHexStringRGB(); + final circleRadius = sizeBeforeStroke.width / 2 - padding; + final circleOffset = padding + circleRadius; + + final recorder = PictureRecorder(); + final canvas = Canvas(recorder); + final paint = Paint()..style = PaintingStyle.fill; + + // Half of stroke is outer / half is inner — translate only half so the + // path stays centered within [sizeWithStroke]. + canvas.translate(strokeWidth / 2, strokeWidth / 2); + paint.color = theme.background; + canvas.drawPath(markerPath, paint); + + if (strokeWidth != 0) { + canvas.drawPath( + markerPath, + Paint() + ..color = const Color(0xFF7A7A7A) + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth, + ); + } + + if (drawCircleAroundIcon) { + paint.color = const Color(0xffffffff); + canvas.drawCircle( + Offset(circleOffset, circleOffset), + circleRadius, + paint, + ); + } + + if (svgIconPath != null) { + final rawSvg = (await rootBundle.loadString(svgIconPath)) + .replaceAll(RegExp(r'fill="[^"]*"'), 'fill="$colorHex"') + .replaceAll(RegExp(r'stroke="[^"]*"'), 'stroke="$colorHex"'); + + final loader = SvgStringLoader(rawSvg); + final pictureInfo = await vg.loadPicture(loader, null); + + final iconWidth = drawCircleAroundIcon + ? circleRadius * 1.3 + : sizeBeforeStroke.width - padding * 2; + final iconHeight = drawCircleAroundIcon + ? circleRadius * 1.3 + : sizeBeforeStroke.height - padding * 2; + final iconScaleX = iconWidth / pictureInfo.size.width; + final iconScaleY = iconHeight / pictureInfo.size.height; + + final iconScale = iconScaleX < iconScaleY ? iconScaleX : iconScaleY; + + final dx = circleOffset - (pictureInfo.size.width * iconScale / 2); + final dy = circleOffset - (pictureInfo.size.height * iconScale / 2); + + canvas.save(); + canvas.translate(dx, dy); + canvas.scale(iconScale, iconScale); + canvas.drawPicture(pictureInfo.picture); + canvas.restore(); + pictureInfo.picture.dispose(); + } else if (text != null) { + final textPainter = TextPainter( + text: TextSpan( + text: text, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: theme.foreground, + ), + ), + textDirection: TextDirection.ltr, + maxLines: 1, + ); + textPainter.layout(); + final offset = Offset( + circleOffset - textPainter.width / 2, + circleOffset - textPainter.height / 2, + ); + textPainter.paint(canvas, offset); + } + + final picture = recorder.endRecording(); + final image = await picture.toImage( + sizeWithStroke.width.toInt(), + sizeWithStroke.height.toInt(), + ); + final byteData = await image.toByteData(format: ImageByteFormat.png); + picture.dispose(); + image.dispose(); + + if (byteData == null) { + throw StateError('Failed to rasterize pin marker.'); + } + + return byteData.buffer.asUint8List(); +} From a58b92ace3c925a9135cb027fa4c40acc9613069 Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Fri, 26 Jun 2026 12:42:44 +0200 Subject: [PATCH 5/9] feat: preserve SVG source colors via nullable foreground MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make RouteMapIconTheme.foreground nullable. When null, rasterizePinMarker keeps the original fill/stroke attributes of the SVG instead of overriding them — useful for multi-color brand icons. Asserts that foreground is non-null whenever a RouteMapIcon renders text. --- .../model/route_map_icon/route_map_icon.dart | 10 ++++- .../route_map_icon.freezed.dart | 40 +++++++++++++------ lib/src/util/pin_marker_rasterizer.dart | 36 ++++++++++++----- 3 files changed, 62 insertions(+), 24 deletions(-) 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/util/pin_marker_rasterizer.dart b/lib/src/util/pin_marker_rasterizer.dart index 86536f1..7ba21d7 100644 --- a/lib/src/util/pin_marker_rasterizer.dart +++ b/lib/src/util/pin_marker_rasterizer.dart @@ -17,6 +17,10 @@ import 'package:route_map/src/model/route_map_icon/route_map_icon.dart'; /// 4. Centers and tints [svgIconPath] (if supplied) using /// [RouteMapIconTheme.foreground]; otherwise centers [text]. /// +/// When [RouteMapIconTheme.foreground] is `null`, the original `fill="…"` / +/// `stroke="…"` attributes of the SVG are preserved instead of being +/// overridden. In that case [text] must not be used. +/// /// Exactly one of [svgIconPath] / [text] may be non-null. Future rasterizePinMarker({ required Path markerPath, @@ -28,7 +32,12 @@ Future rasterizePinMarker({ svgIconPath == null || text == null, "Provide either svgIconPath or text, not both.", ); + assert( + text == null || theme.foreground != null, + "RouteMapIconTheme.foreground must be non-null when rendering text.", + ); + final foreground = theme.foreground; final sizeBeforeStroke = markerPath.getBounds().size; final sizeWithStroke = Size( sizeBeforeStroke.width + theme.strokeWidth, @@ -38,8 +47,6 @@ Future rasterizePinMarker({ final strokeWidth = theme.strokeWidth; final drawCircleAroundIcon = theme.drawCircleAroundIcon; - // ignore: deprecated_member_use - final colorHex = theme.foreground.toHexStringRGB(); final circleRadius = sizeBeforeStroke.width / 2 - padding; final circleOffset = padding + circleRadius; @@ -47,8 +54,6 @@ Future rasterizePinMarker({ final canvas = Canvas(recorder); final paint = Paint()..style = PaintingStyle.fill; - // Half of stroke is outer / half is inner — translate only half so the - // path stays centered within [sizeWithStroke]. canvas.translate(strokeWidth / 2, strokeWidth / 2); paint.color = theme.background; canvas.drawPath(markerPath, paint); @@ -73,11 +78,21 @@ Future rasterizePinMarker({ } if (svgIconPath != null) { - final rawSvg = (await rootBundle.loadString(svgIconPath)) - .replaceAll(RegExp(r'fill="[^"]*"'), 'fill="$colorHex"') - .replaceAll(RegExp(r'stroke="[^"]*"'), 'stroke="$colorHex"'); - - final loader = SvgStringLoader(rawSvg); + 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); final iconWidth = drawCircleAroundIcon @@ -107,7 +122,8 @@ Future rasterizePinMarker({ style: TextStyle( fontSize: 24, fontWeight: FontWeight.bold, - color: theme.foreground, + // Asserted to be non-null above. + color: foreground, ), ), textDirection: TextDirection.ltr, From 11eaf3c22c9c0c552ebd183785bccf7ea1372e72 Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Fri, 26 Jun 2026 13:30:06 +0200 Subject: [PATCH 6/9] fix feedback on pr --- lib/route_map.dart | 1 - .../route_map_icon_manager.dart | 150 ++++++++++++++++- lib/src/route_map_poi_layer.dart | 32 ++-- lib/src/util/pin_marker_rasterizer.dart | 154 ------------------ 4 files changed, 163 insertions(+), 174 deletions(-) delete mode 100644 lib/src/util/pin_marker_rasterizer.dart diff --git a/lib/route_map.dart b/lib/route_map.dart index 9f925bd..2d3b3fe 100644 --- a/lib/route_map.dart +++ b/lib/route_map.dart @@ -17,4 +17,3 @@ 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'; -export 'src/util/pin_marker_rasterizer.dart' show rasterizePinMarker; diff --git a/lib/src/annotation_manager/route_map_icon_manager.dart b/lib/src/annotation_manager/route_map_icon_manager.dart index e066f64..2667a19 100644 --- a/lib/src/annotation_manager/route_map_icon_manager.dart +++ b/lib/src/annotation_manager/route_map_icon_manager.dart @@ -1,5 +1,10 @@ +import 'dart:ui'; + import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:route_map/route_map.dart'; @@ -136,11 +141,146 @@ class RouteMapIconManager { Brightness.dark => mapIcon.darkTheme ?? mapIcon.theme, Brightness.light => mapIcon.theme, }; - return rasterizePinMarker( - markerPath: mapIcon.markerPath, - theme: theme, - svgIconPath: mapIcon.svgIconPath, - text: mapIcon.text, + + final markerPath = mapIcon.markerPath; + final sizeBeforeStroke = markerPath.getBounds().size; + final sizeWithStroke = Size( + sizeBeforeStroke.width + theme.strokeWidth, + sizeBeforeStroke.height + theme.strokeWidth, + ); + final padding = theme.padding; + final strokeWidth = theme.strokeWidth; + 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 circleRadius = sizeBeforeStroke.width / 2 - padding; + final circleOffset = padding + circleRadius; + + // Create a canvas to draw on + final recorder = PictureRecorder(); + final canvas = Canvas(recorder); + final paint = Paint()..style = PaintingStyle.fill; + + // Draw Marker + // Half of stroke is outer and the other half is inner, tranlsate only half of stroke width + canvas.translate(strokeWidth / 2, strokeWidth / 2); + paint.color = theme.background; + canvas.drawPath(markerPath, paint); + // Draw Stroke + if (strokeWidth != 0) { + canvas.drawPath( + markerPath, + Paint() + ..color = const Color(0xFF7A7A7A) + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth, + ); + } + // Draw white circle + if (drawCircleAroundIcon) { + paint.color = const Color(0xffffffff); + canvas.drawCircle( + Offset(circleOffset, circleOffset), + circleRadius, + paint, + ); + } + + if (svgIconPath != null) { + // 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 + final iconWidth = drawCircleAroundIcon + ? circleRadius * 1.3 + : sizeBeforeStroke.width - padding * 2; + final iconHeight = drawCircleAroundIcon + ? circleRadius * 1.3 + : sizeBeforeStroke.height - padding * 2; + // Compute scaling factors + final iconScaleX = iconWidth / pictureInfo.size.width; + final iconScaleY = iconHeight / pictureInfo.size.height; + + // Use the smaller scale to contain + final iconScale = iconScaleX < iconScaleY ? iconScaleX : iconScaleY; + + // Center the scaled picture + final dx = circleOffset - (pictureInfo.size.width * iconScale / 2); + final dy = circleOffset - (pictureInfo.size.height * iconScale / 2); + + canvas.save(); + canvas.translate(dx, dy); + canvas.scale(iconScale, iconScale); + canvas.drawPicture(pictureInfo.picture); + canvas.restore(); + pictureInfo.picture.dispose(); + } + + if (text != null) { + // Draw the number in the center of th circle + final textPainter = TextPainter( + text: TextSpan( + text: text, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + // Asserted to be non-null above. + color: foreground, + ), + ), + textDirection: TextDirection.ltr, + maxLines: 1, + ); + textPainter.layout(); + final offset = Offset( + circleOffset - textPainter.width / 2, + circleOffset - textPainter.height / 2, + ); + textPainter.paint(canvas, offset); + } + + // End recording and convert to image + return _recorderToPng(recorder, sizeWithStroke); + } + + Future _recorderToPng( + PictureRecorder recorder, + Size imageSize, + ) async { + // End recording and convert to image + final picture = recorder.endRecording(); + final img = await picture.toImage( + imageSize.width.toInt(), + imageSize.height.toInt(), ); + // Convert image to PNG byte data + final byteData = await img.toByteData(format: ImageByteFormat.png); + final pngBytes = byteData!.buffer.asUint8List(); + picture.dispose(); + img.dispose(); + return pngBytes; } } diff --git a/lib/src/route_map_poi_layer.dart b/lib/src/route_map_poi_layer.dart index 57d17e9..63565d7 100644 --- a/lib/src/route_map_poi_layer.dart +++ b/lib/src/route_map_poi_layer.dart @@ -81,24 +81,26 @@ extension _RouteMapPoiLayerState on _RouteMapState { await controller.addSource(sourceId, source); if (!mounted) return; - // Register icon images for every category. SVGs are rasterized to PNG - // bytes once per (category, brightness) tuple — using the same pin - // marker renderer that backs [RouteMapIcon] so POI markers match the - // visual style of regular icons. - final brightness = MediaQuery.platformBrightnessOf(context); + // 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 theme = brightness == Brightness.dark - ? (category.darkTheme ?? category.theme) - : category.theme; - final iconBytes = await rasterizePinMarker( + final template = RouteMapIcon( + identifier: imageId, + latLng: const LatLng(0, 0), markerPath: category.markerPath, - theme: theme, + theme: category.theme, + darkTheme: category.darkTheme, svgIconPath: category.svgIconPath, + anchor: category.anchor, + ); + await _iconManagerInstance.addImageToCacheIfNeeded( + controller, + mapIcon: template, ); - if (!mounted) return; - - await controller.addImage(imageId, iconBytes); if (!mounted) return; } @@ -106,6 +108,8 @@ extension _RouteMapPoiLayerState on _RouteMapState { 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); @@ -141,7 +145,7 @@ extension _RouteMapPoiLayerState on _RouteMapState { iconAnchor: category.anchor.mglIconValue, // Match the [RouteMapIconManager.iconScale] used for regular // icons so POI pins are rendered at the same size. - iconSize: kIsWeb ? 0.5 : 1.5, + iconSize: _iconManagerInstance.iconScale, iconAllowOverlap: widget.allowIconsOverlap, iconIgnorePlacement: widget.ignoreIconsPlacement, textField: hasLabel ? labelDef.textExpression : null, diff --git a/lib/src/util/pin_marker_rasterizer.dart b/lib/src/util/pin_marker_rasterizer.dart deleted file mode 100644 index 7ba21d7..0000000 --- a/lib/src/util/pin_marker_rasterizer.dart +++ /dev/null @@ -1,154 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; -import 'package:route_map/src/model/route_map_icon/route_map_icon.dart'; - -/// Rasterizes a pin-shaped marker (the same kind that backs [RouteMapIcon]) -/// to a PNG byte buffer suitable for `MapLibreMapController.addImage`. -/// -/// The rendering matches the style used internally by `RouteMapIconManager`: -/// -/// 1. Draws [markerPath] filled with [RouteMapIconTheme.background]. -/// 2. Optionally draws a stroke around the marker. -/// 3. Optionally draws a white circle in the marker's "icon slot". -/// 4. Centers and tints [svgIconPath] (if supplied) using -/// [RouteMapIconTheme.foreground]; otherwise centers [text]. -/// -/// When [RouteMapIconTheme.foreground] is `null`, the original `fill="…"` / -/// `stroke="…"` attributes of the SVG are preserved instead of being -/// overridden. In that case [text] must not be used. -/// -/// Exactly one of [svgIconPath] / [text] may be non-null. -Future rasterizePinMarker({ - required Path markerPath, - required RouteMapIconTheme theme, - String? svgIconPath, - String? text, -}) async { - assert( - svgIconPath == null || text == null, - "Provide either svgIconPath or text, not both.", - ); - assert( - text == null || theme.foreground != null, - "RouteMapIconTheme.foreground must be non-null when rendering text.", - ); - - final foreground = theme.foreground; - final sizeBeforeStroke = markerPath.getBounds().size; - final sizeWithStroke = Size( - sizeBeforeStroke.width + theme.strokeWidth, - sizeBeforeStroke.height + theme.strokeWidth, - ); - final padding = theme.padding; - final strokeWidth = theme.strokeWidth; - final drawCircleAroundIcon = theme.drawCircleAroundIcon; - - final circleRadius = sizeBeforeStroke.width / 2 - padding; - final circleOffset = padding + circleRadius; - - final recorder = PictureRecorder(); - final canvas = Canvas(recorder); - final paint = Paint()..style = PaintingStyle.fill; - - canvas.translate(strokeWidth / 2, strokeWidth / 2); - paint.color = theme.background; - canvas.drawPath(markerPath, paint); - - if (strokeWidth != 0) { - canvas.drawPath( - markerPath, - Paint() - ..color = const Color(0xFF7A7A7A) - ..style = PaintingStyle.stroke - ..strokeWidth = strokeWidth, - ); - } - - if (drawCircleAroundIcon) { - paint.color = const Color(0xffffffff); - canvas.drawCircle( - Offset(circleOffset, circleOffset), - circleRadius, - paint, - ); - } - - if (svgIconPath != null) { - 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); - - final iconWidth = drawCircleAroundIcon - ? circleRadius * 1.3 - : sizeBeforeStroke.width - padding * 2; - final iconHeight = drawCircleAroundIcon - ? circleRadius * 1.3 - : sizeBeforeStroke.height - padding * 2; - final iconScaleX = iconWidth / pictureInfo.size.width; - final iconScaleY = iconHeight / pictureInfo.size.height; - - final iconScale = iconScaleX < iconScaleY ? iconScaleX : iconScaleY; - - final dx = circleOffset - (pictureInfo.size.width * iconScale / 2); - final dy = circleOffset - (pictureInfo.size.height * iconScale / 2); - - canvas.save(); - canvas.translate(dx, dy); - canvas.scale(iconScale, iconScale); - canvas.drawPicture(pictureInfo.picture); - canvas.restore(); - pictureInfo.picture.dispose(); - } else if (text != null) { - final textPainter = TextPainter( - text: TextSpan( - text: text, - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - // Asserted to be non-null above. - color: foreground, - ), - ), - textDirection: TextDirection.ltr, - maxLines: 1, - ); - textPainter.layout(); - final offset = Offset( - circleOffset - textPainter.width / 2, - circleOffset - textPainter.height / 2, - ); - textPainter.paint(canvas, offset); - } - - final picture = recorder.endRecording(); - final image = await picture.toImage( - sizeWithStroke.width.toInt(), - sizeWithStroke.height.toInt(), - ); - final byteData = await image.toByteData(format: ImageByteFormat.png); - picture.dispose(); - image.dispose(); - - if (byteData == null) { - throw StateError('Failed to rasterize pin marker.'); - } - - return byteData.buffer.asUint8List(); -} From 25f37531d2b6f64fe25930d52a474eb6c4f20c9f Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Fri, 26 Jun 2026 13:35:26 +0200 Subject: [PATCH 7/9] remove not needed code --- lib/src/route_map_controller.dart | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/lib/src/route_map_controller.dart b/lib/src/route_map_controller.dart index 16dc2a7..423e592 100644 --- a/lib/src/route_map_controller.dart +++ b/lib/src/route_map_controller.dart @@ -186,25 +186,4 @@ class RouteMapController { isVisible: isVisible, ); } - - /// Whether a given POI layer is currently visible. Returns `null` when - /// no layer with the supplied [identifier] is installed. - Future isPoiLayerVisible(String identifier) async { - final state = await _state; - return state._poiLayers[identifier]?.isVisible; - } - - /// Removes a previously-installed POI layer entirely (including its - /// GeoJSON source). Silently does nothing when no layer with that - /// identifier is installed. - Future removePoiLayer(String identifier) async { - final state = await _state; - final controller = await _controller; - if (!await _mounted) return; - - final entry = state._poiLayers[identifier]; - if (entry == null) return; - - await state._removePoiLayerEntry(controller: controller, entry: entry); - } } From 1eac897a3894c3cc920e30db4e18f5f270012d6e Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Fri, 26 Jun 2026 13:41:34 +0200 Subject: [PATCH 8/9] remove not needed code --- lib/src/route_map_poi_layer.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/src/route_map_poi_layer.dart b/lib/src/route_map_poi_layer.dart index 63565d7..08d1132 100644 --- a/lib/src/route_map_poi_layer.dart +++ b/lib/src/route_map_poi_layer.dart @@ -118,7 +118,10 @@ extension _RouteMapPoiLayerState on _RouteMapState { // clustered features don't render individually. final filter = [ 'all', - ['!', ['has', 'point_count']], + [ + '!', + ['has', 'point_count'], + ], category.filter, ]; From facb589f5217b83f5c597b6b74c1151f32ee6f1b Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Mon, 29 Jun 2026 12:02:29 +0200 Subject: [PATCH 9/9] fix name of extension --- lib/src/{route_map_poi_layer.dart => poi_layer_extension.dart} | 0 lib/src/route_map_base.dart | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename lib/src/{route_map_poi_layer.dart => poi_layer_extension.dart} (100%) diff --git a/lib/src/route_map_poi_layer.dart b/lib/src/poi_layer_extension.dart similarity index 100% rename from lib/src/route_map_poi_layer.dart rename to lib/src/poi_layer_extension.dart diff --git a/lib/src/route_map_base.dart b/lib/src/route_map_base.dart index d36212e..faaa8f5 100644 --- a/lib/src/route_map_base.dart +++ b/lib/src/route_map_base.dart @@ -15,7 +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 'route_map_poi_layer.dart'; +part 'poi_layer_extension.dart'; class RouteMap extends StatefulWidget { final CameraPosition initialCameraPosition;