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:
- Completed the Flutter Setup Guide
- A MapMetrics API key and style URL from the MapMetrics Portal
How GeoJSON sources and layers work: there's no
addGeoJsonSource/addCircleLayer/addSymbolLayershorthand on the controller. Instead, get aStyleControllerfromonStyleLoaded, add aGeoJsonSource, then add aCircleStyleLayerorSymbolStyleLayerreferencing it bysourceId.paint/layouttake 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:
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:
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:
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:
| Property | Type | Description |
|---|---|---|
circle-radius | double (or expression) | Radius of the circle in pixels |
circle-color | String (or expression) | Fill color of the circle |
circle-opacity | double | Fill opacity from 0.0 to 1.0 |
circle-stroke-color | String | Border color of the circle |
circle-stroke-width | double | Border width in pixels |
Next Steps
- Add a GeoJSON Line — Draw lines from GeoJSON
- Add a GeoJSON Polygon — Draw filled areas
- Add Clusters — Cluster many points together
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.