Skip to content

Both copy text to your clipboard — Build with AI copies a setup prompt to paste into Claude Code, Cursor, Codex or Copilot; Copy page as Markdown copies this page to paste into a chat. How it works

Draw GeoJSON Points in Flutter

Static pins render better with MarkerLayer

This page uses WidgetLayer, which repositions each marker in Dart every frame — it lags a frame while panning, ignores tilt and bearing, and does not scale past a few dozen markers. If your markers are plain pins with no Flutter content or gestures, use MarkerLayer instead; see Markers and Annotations.

This tutorial shows how to render multiple points from GeoJSON data on your MapMetrics Flutter map — efficient for displaying large datasets of locations.

Prerequisites

Before you begin, ensure you have:

How GeoJSON sources and layers work: there's no addGeoJsonSource/addCircleLayer/addSymbolLayer shorthand on the controller. Instead, get a StyleController from onStyleLoaded, add a GeoJsonSource, then add a CircleStyleLayer or SymbolStyleLayer referencing it by sourceId. paint/layout take raw MapLibre style spec maps — the same property names (circle-radius, circle-color, text-field, ...) work as-is.

Basic GeoJSON Points

Render European capital cities as circle markers from a GeoJSON FeatureCollection. GeoJSON coordinates are already [longitude, latitude] per the GeoJSON spec, so they need no reordering — only explicit Position(lng, lat) constructor calls elsewhere in this SDK need longitude and latitude swapped from lat-first order:

dart
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';

class GeoJsonPointsScreen extends StatefulWidget {
  @override
  _GeoJsonPointsScreenState createState() => _GeoJsonPointsScreenState();
}

class _GeoJsonPointsScreenState extends State<GeoJsonPointsScreen> {
  MapController? mapController;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('GeoJSON Points')),
      body: MapMetricsView(
        options: MapOptions(
          initCenter: Position(10.0, 50.0),
          initZoom: 3.0,
          initStyle:
              'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
        ),
        onMapCreated: (MapController controller) {
          mapController = controller;
        },
        onStyleLoaded: (StyleController style) {
          _addGeoJsonPoints(style);
        },
      ),
    );
  }

  Future<void> _addGeoJsonPoints(StyleController style) async {
    final geoJson = {
      'type': 'FeatureCollection',
      'features': [
        {
          'type': 'Feature',
          'properties': {'name': 'Paris', 'population': 2161000},
          'geometry': {
            'type': 'Point',
            'coordinates': [2.349902, 48.852966],
          },
        },
        {
          'type': 'Feature',
          'properties': {'name': 'London', 'population': 8982000},
          'geometry': {
            'type': 'Point',
            'coordinates': [-0.1276, 51.5074],
          },
        },
        {
          'type': 'Feature',
          'properties': {'name': 'Berlin', 'population': 3645000},
          'geometry': {
            'type': 'Point',
            'coordinates': [13.405, 52.52],
          },
        },
        {
          'type': 'Feature',
          'properties': {'name': 'Rome', 'population': 2873000},
          'geometry': {
            'type': 'Point',
            'coordinates': [12.4964, 41.9028],
          },
        },
        {
          'type': 'Feature',
          'properties': {'name': 'Madrid', 'population': 3223000},
          'geometry': {
            'type': 'Point',
            'coordinates': [-3.7038, 40.4168],
          },
        },
        {
          'type': 'Feature',
          'properties': {'name': 'Vienna', 'population': 1897000},
          'geometry': {
            'type': 'Point',
            'coordinates': [16.3738, 48.2082],
          },
        },
        {
          'type': 'Feature',
          'properties': {'name': 'Amsterdam', 'population': 872680},
          'geometry': {
            'type': 'Point',
            'coordinates': [4.9041, 52.3676],
          },
        },
      ],
    };

    // Add the GeoJSON source
    await style.addSource(
      GeoJsonSource(id: 'cities', data: jsonEncode(geoJson)),
    );

    // Add a circle style layer to render the points
    await style.addLayer(
      const CircleStyleLayer(
        id: 'cities-circles',
        sourceId: 'cities',
        paint: {
          'circle-radius': 8.0,
          'circle-color': '#3b82f6',
          'circle-stroke-color': '#ffffff',
          'circle-stroke-width': 2.0,
        },
      ),
    );
  }
}

Points with Labels

Add text labels next to each point with a second SymbolStyleLayer referencing the same source:

dart
Future<void> _addPointsWithLabels(StyleController style) async {
  final geoJson = {
    'type': 'FeatureCollection',
    'features': [
      {
        'type': 'Feature',
        'properties': {'name': 'Paris'},
        'geometry': {
          'type': 'Point',
          'coordinates': [2.349902, 48.852966],
        },
      },
      {
        'type': 'Feature',
        'properties': {'name': 'London'},
        'geometry': {
          'type': 'Point',
          'coordinates': [-0.1276, 51.5074],
        },
      },
      {
        'type': 'Feature',
        'properties': {'name': 'Berlin'},
        'geometry': {
          'type': 'Point',
          'coordinates': [13.405, 52.52],
        },
      },
    ],
  };

  await style.addSource(
    GeoJsonSource(id: 'labeled-cities', data: jsonEncode(geoJson)),
  );

  // Circle layer
  await style.addLayer(
    const CircleStyleLayer(
      id: 'labeled-cities-circles',
      sourceId: 'labeled-cities',
      paint: {
        'circle-radius': 6.0,
        'circle-color': '#ef4444',
        'circle-stroke-color': '#ffffff',
        'circle-stroke-width': 2.0,
      },
    ),
  );

  // Text label layer, referencing the "name" property from each feature
  await style.addLayer(
    const SymbolStyleLayer(
      id: 'labeled-cities-labels',
      sourceId: 'labeled-cities',
      layout: {
        'text-field': ['get', 'name'],
        'text-size': 12.0,
        'text-offset': [0.0, 1.5],
        'text-anchor': 'top',
      },
      paint: {'text-color': '#1f2937'},
    ),
  );
}

Styled Points with Different Sizes

For per-point interactivity (tap to see details) and Flutter-widget styling — rather than pure style-spec circles — use WidgetLayer with sized Markers instead of GeoJSON layers:

dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';

class StyledPointsScreen extends StatefulWidget {
  @override
  _StyledPointsScreenState createState() => _StyledPointsScreenState();
}

class _StyledPointsScreenState extends State<StyledPointsScreen> {
  MapController? mapController;
  Map<String, dynamic>? selectedCity;

  final List<Map<String, dynamic>> cities = [
    {'name': 'London', 'position': Position(-0.1276, 51.5074), 'pop': 8982000},
    {'name': 'Berlin', 'position': Position(13.405, 52.52), 'pop': 3645000},
    {'name': 'Madrid', 'position': Position(-3.7038, 40.4168), 'pop': 3223000},
    {'name': 'Rome', 'position': Position(12.4964, 41.9028), 'pop': 2873000},
    {'name': 'Paris', 'position': Position(2.3499, 48.853), 'pop': 2161000},
    {'name': 'Vienna', 'position': Position(16.3738, 48.2082), 'pop': 1897000},
  ];

  double _radiusFor(int pop) {
    if (pop > 5000000) return 10;
    if (pop > 2000000) return 8;
    return 6;
  }

  Color _colorFor(int pop) {
    if (pop > 5000000) return Colors.red;
    if (pop > 2000000) return Colors.orange;
    return Colors.blue;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Styled Points')),
      body: Stack(
        children: [
          MapMetricsView(
            options: MapOptions(
              initCenter: Position(8.0, 48.0),
              initZoom: 4.0,
              initStyle:
                  'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
            ),
            onMapCreated: (MapController controller) {
              mapController = controller;
            },
            mapChildren: [
              WidgetLayer(allowInteraction: true, markers: _buildMarkers()),
            ],
          ),
          if (selectedCity != null)
            Positioned(
              top: 16,
              left: 16,
              right: 16,
              child: Card(
                child: ListTile(
                  title: Text(selectedCity!['name'] as String),
                  subtitle: Text(
                    'Pop: ${(selectedCity!['pop'] as int) / 1000000}M',
                  ),
                  trailing: IconButton(
                    icon: Icon(Icons.close),
                    onPressed: () => setState(() => selectedCity = null),
                  ),
                ),
              ),
            ),
          // Legend
          Positioned(
            bottom: 16,
            left: 16,
            child: Container(
              padding: EdgeInsets.all(12),
              decoration: BoxDecoration(
                color: Colors.white,
                borderRadius: BorderRadius.circular(8),
                boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 4)],
              ),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                mainAxisSize: MainAxisSize.min,
                children: [
                  Text('Population', style: TextStyle(fontWeight: FontWeight.bold)),
                  SizedBox(height: 4),
                  Row(children: [
                    Container(width: 10, height: 10, decoration: BoxDecoration(color: Colors.red, shape: BoxShape.circle)),
                    SizedBox(width: 6),
                    Text('> 5M'),
                  ]),
                  Row(children: [
                    Container(width: 8, height: 8, decoration: BoxDecoration(color: Colors.orange, shape: BoxShape.circle)),
                    SizedBox(width: 6),
                    Text('2M - 5M'),
                  ]),
                  Row(children: [
                    Container(width: 6, height: 6, decoration: BoxDecoration(color: Colors.blue, shape: BoxShape.circle)),
                    SizedBox(width: 6),
                    Text('< 2M'),
                  ]),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }

  List<Marker> _buildMarkers() {
    return cities.map((city) {
      final pop = city['pop'] as int;
      final diameter = _radiusFor(pop) * 2;
      return Marker(
        point: city['position'] as Position,
        size: Size.square(diameter),
        alignment: Alignment.center,
        child: GestureDetector(
          onTap: () => setState(() => selectedCity = city),
          child: Container(
            decoration: BoxDecoration(
              color: _colorFor(pop),
              shape: BoxShape.circle,
              border: Border.all(color: Colors.white, width: 1.5),
            ),
          ),
        ),
      );
    }).toList();
  }
}

Style Spec Circle Layer Properties

CircleStyleLayer.paint accepts these (and other) MapLibre circle paint properties:

PropertyTypeDescription
circle-radiusdouble (or expression)Radius of the circle in pixels
circle-colorString (or expression)Fill color of the circle
circle-opacitydoubleFill opacity from 0.0 to 1.0
circle-stroke-colorStringBorder color of the circle
circle-stroke-widthdoubleBorder width in pixels

Next Steps


Tip: GeoJSON style layers (CircleStyleLayer/SymbolStyleLayer) are more efficient than individual WidgetLayer markers when displaying hundreds or thousands of points, since they render natively rather than as Flutter widgets. Use them for large datasets and switch to WidgetLayer markers only when you need custom widgets or per-point tap handling.