mapbox-maps-flutter icon indicating copy to clipboard operation
mapbox-maps-flutter copied to clipboard

How to tap symbols on layer

Open chooyan-eng opened this issue 1 year ago • 12 comments

How can we tap symbols added with SymbolLayer?

I have the source code below to show a symbol, but can't find how to make this tappable.

class _MapScreenState extends State<MapScreen> {
  final position = Position.named(lng: 24.9458, lat: 60.17180);
  MapboxMap? _mapboxMap;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: MapWidget(
        cameraOptions: CameraOptions(zoom: 3.0),
        styleUri: '[my_style_uri]',
        textureView: true,
        onMapCreated: (map) => _mapboxMap = map,
        onStyleLoadedListener: _onStyleLoadedCallback,
      ),
    );
  }

  Future<void> _onStyleLoadedCallback(StyleLoadedEventData event) async {
    final point = Point(coordinates: position);

    await _mapboxMap?.style.addSource(GeoJsonSource(
      id: 'sourceId',
      data: json.encode(point),
    ));

    var modelLayer = SymbolLayer(
      id: 'layerId',
      sourceId: 'sourceId',
      iconImage: '[my_icon_name]',
    );
    _mapboxMap?.style.addLayer(modelLayer);
  }
}

This shows one icon on my map, but that's it.

Do we have any examples or docs for this? Thanks.

chooyan-eng avatar Oct 01 '24 06:10 chooyan-eng

What do you use a style layer with symbols for?

Atm i can only think of a PointAnnotation that can do this.

Future<PointAnnotationManager> createPointManager(
    MapboxMap controller, {
    required void Function(PointAnnotation) callback,
}) async {
    final pointManager = await controller.annotations.createPointAnnotationManager();

    pointManager.addOnPointAnnotationClickListener(
        AnnotationClickListener(callback: callback),
    );

    return pointManager;
}

ThomasAunvik avatar Oct 02 '24 13:10 ThomasAunvik

@ThomasAunvik Thanks for your suggestion!

However, its document says this API is not capable of displaying large amount of annotations.

Use style layers. This will require writing more code but is more flexible and provides better performance for the large amount of annotations (e.g. hundreds and thousands of them)

And my app needs to display hundreds of thousands of symbols at the same time. That's why I'd like to know the usage of layer in detail.

chooyan-eng avatar Oct 07 '24 13:10 chooyan-eng

You can set the GeoJsonSource data with FeatureCollection and set id for point , then listen map click event and use queryRenderedFeatures function to filter id that is point where you want

lantah-1 avatar Oct 08 '24 02:10 lantah-1

@lantah-1 Thanks! I haven't tried queryRenderedFeatures. I'll try.

chooyan-eng avatar Oct 08 '24 02:10 chooyan-eng

This code implements interactive layer tapping:


class _MapScreenState extends State<MapScreen> {
  final position = Position.named(lng: 24.9458, lat: 60.17180);
  MapboxMap? _mapboxMap;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: MapWidget(
        cameraOptions: CameraOptions(zoom: 3.0),
        styleUri: '[my_style_uri]',
        textureView: true,
        onMapCreated: (map) => _mapboxMap = map,
        onStyleLoadedListener: _onStyleLoadedCallback,
        onMapTap: _onMapTap,
      ),
    );
  }

  Future<void> _onStyleLoadedCallback(StyleLoadedEventData event) async {
    final point = Point(coordinates: position);
    final features = [
      {
        "type": "Feature",
        "geometry": {
          "type": "Point",
          "coordinates": [point.coordinates.lng, point.coordinates.lat],
        },
        "properties": {
          'id': 'test-123',
          'anyDataKey': jsonEncode({}) //CUSTOM DATA,
        },
      },
    ];
    await _mapboxMap?.style.addSource(
      GeoJsonSource(
        id: 'sourceId',
        data: json.encode({"type": "FeatureCollection", "features": features}),
      ),
    );
    var modelLayer = SymbolLayer(
      id: 'layerId',
      sourceId: 'sourceId',
      iconImage: '[my_icon_name]',
    );
    _mapboxMap?.style.addLayer(modelLayer);
  }

Future<void> _onMapTap(
  MapContentGestureContext mapContentGestureContext,
) async {
  if (_mapboxMap == null) return;
  final List<QueriedRenderedFeature?> features = await _mapboxMap!.queryRenderedFeatures(
    RenderedQueryGeometry.fromScreenCoordinate(mapContentGestureContext.touchPosition),
    RenderedQueryOptions(
      layerIds: [
        ///LAYERS IDS YOU WANT TO LISTEN ...[]
        'layerId'
      ],
    ),
  );
  final queriedRenderedFeature = features.firstOrNull;
  if (queriedRenderedFeature == null || !mounted) return;
  _onFeatureTapped(queriedRenderedFeature);
}

  void _onFeatureTapped(
    QueriedRenderedFeature queriedRenderedFeature,
  ) {
    Point? point;
    Map? geometry = queriedRenderedFeature.queriedFeature.feature["geometry"] as Map?;
    if (geometry != null && _mapboxMap != null) {
      final lon = geometry["coordinates"][0];
      final lat = geometry["coordinates"][1];
      ///YOU CAN EASE TO
      ///THE POINT CAMERA POSITION
      //fitCameraToPosition(lat: lat, lon: lon);
      point = Point(coordinates: Position(lon, lat));
    }

    ///GET THE SOURCE PROPERITIES WHERE DEFINED Ids
    ///OR CUSTOM DATA YOU SET.
    final properties = queriedRenderedFeature.queriedFeature.feature["properties"] as Map?;
    final id = properties?['id'] as String?;
    if (id == null) return;
    if (id.startsWith('test-')) {
      ///CALLBACK CLICK
      ///YOU HAVE [POINT GEMOETRY,PROPERITIES OF THE POINT],
      /// final anyData = jsonDecode(properties?['anyDataKey'] ?? '{}');
    }
  }
}

abdelrahmanmostafa21 avatar Oct 08 '24 20:10 abdelrahmanmostafa21

would be great if one could just have direct access to subscribe to tap events on features.

map.registerOnFeatureTapCallback(callback, layerIds: ["layerId"], queryGeometrySize: 10)

My experience with the old mapbox library is that any roundtrip to the native side should be avoided as this might cause the ui to wait for multiple frames before reposing to user input. Epscially because setting the changes to the feature in the given layer requires another round trip.

Would also be great if we could also get drag listerners.

map.registerOnFeatureDragCallback(callback, layerIds: ["layerId"], queryGeometrySize: 10)

felix-ht avatar Dec 16 '24 14:12 felix-ht

This code implements interactive layer tapping:

class _MapScreenState extends State<MapScreen> { final position = Position.named(lng: 24.9458, lat: 60.17180); MapboxMap? _mapboxMap;

@override Widget build(BuildContext context) { return Scaffold( body: MapWidget( cameraOptions: CameraOptions(zoom: 3.0), styleUri: '[my_style_uri]', textureView: true, onMapCreated: (map) => _mapboxMap = map, onStyleLoadedListener: _onStyleLoadedCallback, onMapTap: _onMapTap, ), ); }

Future _onStyleLoadedCallback(StyleLoadedEventData event) async { final point = Point(coordinates: position); final features = [ { "type": "Feature", "geometry": { "type": "Point", "coordinates": [point.coordinates.lng, point.coordinates.lat], }, "properties": { 'id': 'test-123', 'anyDataKey': jsonEncode({}) //CUSTOM DATA, }, }, ]; await _mapboxMap?.style.addSource( GeoJsonSource( id: 'sourceId', data: json.encode({"type": "FeatureCollection", "features": features}), ), ); var modelLayer = SymbolLayer( id: 'layerId', sourceId: 'sourceId', iconImage: '[my_icon_name]', ); _mapboxMap?.style.addLayer(modelLayer); }

Future _onMapTap( MapContentGestureContext mapContentGestureContext, ) async { if (_mapboxMap == null) return; final List<QueriedRenderedFeature?> features = await _mapboxMap!.queryRenderedFeatures( RenderedQueryGeometry.fromScreenCoordinate(mapContentGestureContext.touchPosition), RenderedQueryOptions( layerIds: [ ///LAYERS IDS YOU WANT TO LISTEN ...[] 'layerId' ], ), ); final queriedRenderedFeature = features.firstOrNull; if (queriedRenderedFeature == null || !mounted) return; _onFeatureTapped(queriedRenderedFeature); }

void _onFeatureTapped( QueriedRenderedFeature queriedRenderedFeature, ) { Point? point; Map? geometry = queriedRenderedFeature.queriedFeature.feature["geometry"] as Map?; if (geometry != null && _mapboxMap != null) { final lon = geometry["coordinates"][0]; final lat = geometry["coordinates"][1]; ///YOU CAN EASE TO ///THE POINT CAMERA POSITION //fitCameraToPosition(lat: lat, lon: lon); point = Point(coordinates: Position(lon, lat)); }

///GET THE SOURCE PROPERITIES WHERE DEFINED Ids
///OR CUSTOM DATA YOU SET.
final properties = queriedRenderedFeature.queriedFeature.feature["properties"] as Map?;
final id = properties?['id'] as String?;
if (id == null) return;
if (id.startsWith('test-')) {
  ///CALLBACK CLICK
  ///YOU HAVE [POINT GEMOETRY,PROPERITIES OF THE POINT],
  /// final anyData = jsonDecode(properties?['anyDataKey'] ?? '{}');
}

} }

would this work if a layer has multiple features in it. I want to get a particular feature only

Brez18 avatar Mar 21 '25 08:03 Brez18

This code implements interactive layer tapping: class _MapScreenState extends State { final position = Position.named(lng: 24.9458, lat: 60.17180); MapboxMap? _mapboxMap; @OverRide Widget build(BuildContext context) { return Scaffold( body: MapWidget( cameraOptions: CameraOptions(zoom: 3.0), styleUri: '[my_style_uri]', textureView: true, onMapCreated: (map) => _mapboxMap = map, onStyleLoadedListener: _onStyleLoadedCallback, onMapTap: _onMapTap, ), ); } Future _onStyleLoadedCallback(StyleLoadedEventData event) async { final point = Point(coordinates: position); final features = [ { "type": "Feature", "geometry": { "type": "Point", "coordinates": [point.coordinates.lng, point.coordinates.lat], }, "properties": { 'id': 'test-123', 'anyDataKey': jsonEncode({}) //CUSTOM DATA, }, }, ]; await _mapboxMap?.style.addSource( GeoJsonSource( id: 'sourceId', data: json.encode({"type": "FeatureCollection", "features": features}), ), ); var modelLayer = SymbolLayer( id: 'layerId', sourceId: 'sourceId', iconImage: '[my_icon_name]', ); _mapboxMap?.style.addLayer(modelLayer); } Future _onMapTap( MapContentGestureContext mapContentGestureContext, ) async { if (_mapboxMap == null) return; final List<QueriedRenderedFeature?> features = await _mapboxMap!.queryRenderedFeatures( RenderedQueryGeometry.fromScreenCoordinate(mapContentGestureContext.touchPosition), RenderedQueryOptions( layerIds: [ ///LAYERS IDS YOU WANT TO LISTEN ...[] 'layerId' ], ), ); final queriedRenderedFeature = features.firstOrNull; if (queriedRenderedFeature == null || !mounted) return; _onFeatureTapped(queriedRenderedFeature); } void _onFeatureTapped( QueriedRenderedFeature queriedRenderedFeature, ) { Point? point; Map? geometry = queriedRenderedFeature.queriedFeature.feature["geometry"] as Map?; if (geometry != null && _mapboxMap != null) { final lon = geometry["coordinates"][0]; final lat = geometry["coordinates"][1]; ///YOU CAN EASE TO ///THE POINT CAMERA POSITION //fitCameraToPosition(lat: lat, lon: lon); point = Point(coordinates: Position(lon, lat)); }

///GET THE SOURCE PROPERITIES WHERE DEFINED Ids
///OR CUSTOM DATA YOU SET.
final properties = queriedRenderedFeature.queriedFeature.feature["properties"] as Map?;
final id = properties?['id'] as String?;
if (id == null) return;
if (id.startsWith('test-')) {
  ///CALLBACK CLICK
  ///YOU HAVE [POINT GEMOETRY,PROPERITIES OF THE POINT],
  /// final anyData = jsonDecode(properties?['anyDataKey'] ?? '{}');
}

} }

would this work if a layer has multiple features in it. I want to get a particular feature only

Filtering for a Specific Feature:

In the _onMapTap method, after querying the rendered features, you can iterate through the features list and filter based on the properties of each feature. For example, if you want to find a feature with a specific id, you can check the properties map of each feature.

  // Use firstWhereOrNull to find the specific feature
  final queriedFeature = features.firstWhereOrNull(
    (feature) {
      if (feature == null || !mounted) return false;

      // Access the properties of the feature
      final properties = feature.queriedFeature.feature["properties"] as Map?;
      final id = properties?['id'] as String?;

      // Check if this is the feature you're looking for
      return id == 'test-123'; // Replace with your specific condition
    },
  );

but querying all features and filtering them in memory using queryRenderedFeatures may not be the most performant approach. Instead, you can optimize this process by leveraging mapbox vector tile querying capabilities or pre-filtering data on the server side.

  // Query features directly from the source using a filter
  final sourceFeatures = await _mapboxMap!.querySourceFeatures(
    'sourceId', // The ID of your GeoJSON source
    SourceQueryOptions(
      sourceLayerIds: ['LAYER_ID'],
      filter: [
        '==', // Filter condition
        ['get', 'id'], // Property to filter by
        'test-123', // Value to match
      ],
    ),
  );

  // Check if any features match the filter
  if (sourceFeatures.isNotEmpty) {
    final queriedFeature = sourceFeatures.first.queriedFeature;
    // ACCESS THE FEATURE HERE
  }

but If you need to frequently query features by a specific property (e.g., id), you can create an index (e.g., a Map<String, Feature>) to quickly look up features by their id.

Using Feature Indexing:

// while you add the source after the style loaded 
final Map<String, QueriedRenderedFeature> featureIndex = {}; 
Future<void> _onStyleLoadedCallback(StyleLoadedEventData event) async {
  // Add features to the index when the style is loaded
  final features = [
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [24.9458, 60.1718],
      },
      "properties": {
        'id': 'test-123',
        'anyDataKey': jsonEncode({}),
      },
    },
    // Add more features...
  ];

  for (final feature in features) {
    final id = feature['properties']['id'] as String;
    featureIndex[id] = feature;
  }
}

Future<void> _onMapTap(
  MapContentGestureContext mapContentGestureContext,
) async {
  // Look up the feature by ID in the index
  final queriedFeature = featureIndex['test-123'];
  if (queriedFeature != null) {
   // ACCESS THE FEATURE HERE
  }
}

This faster cause: -O(1) Lookup Time, using a Map allows you to retrieve features by their id in constant time. -No Query Overhead, you avoid querying the map or source entirely.

abdelrahmanmostafa21 avatar Mar 21 '25 22:03 abdelrahmanmostafa21

@abdelrahmanmostafa21 i will have to use the queriedFeatures approach only imo cause i need to find the feature that is being clicked on which as far as i am seeing only the queiredFeatures approach allows to query based on the touched coordinates.

Image

Though i have question as to why is the sourceLayer here being shown as null. I can see the source properly but not the sourceLayer any idea why this is happening and how to rectify this.

Regardless thank you for your suggestions as i was not really aware of these things. I do have some more questions to ask tho not related to this issue here. Its more related to transitions of the layer will you open to discuss this matter?

Brez18 avatar Mar 23 '25 09:03 Brez18

@Brez18 I agree that the queriedFeatures approach is the most suitable for identifying the feature being clicked on as it allows querying based on the touched coordinates.

Regarding the sourceLayer being null this typically happens when the source is not a vector tile source GeoJSON sources not have layers. If your source is a vector tile source ensure that the sourceLayer property is correctly defined in your layer configuration. If it’s a GeoJSON source, this behavior is expected, and you can safely ignore it.

About layer transitions you can contact me or open a new issue and continue discuss it there and I’ll do my best to assist

abdelrahmanmostafa21 avatar Mar 23 '25 17:03 abdelrahmanmostafa21

@abdelrahmanmostafa21 this is the issue https://github.com/mapbox/mapbox-maps-flutter/issues/903

Brez18 avatar Mar 24 '25 09:03 Brez18

Hi @chooyan-eng, I am glad to tell that we have introduced Interactive Map Element (IME) in our v2.9.0 This allows you to add an interaction (a tap or a long press) to a featureset, in your case it's gonna be the SymbolLayer as shown below

final tapInteraction = TapInteraction(
  FeaturesetDescriptor(layerId: layerId),
  (feature, context) {
    print("Tapped on feature with id: ${feature.id}");
  },
  stopPropagation: true, // don't propagate to layer or feature below
);

mapboxMap.addInteraction(
  tapInteraction,
  interactionID: "tap_interaction_symbol_layer",
);

Could you checkout version v2.9.0 or latest and try this feature? Thank you

maios avatar Jun 25 '25 11:06 maios