Hexagon Layer — Data Aggregation in Flutter
This tutorial shows how to create a hexagonal grid to aggregate and visualize point data — useful for density maps, analytics dashboards, and spatial analysis.
Prerequisites
Before you begin, ensure you have:
- Completed the Flutter Setup Guide
- A MapMetrics API key and style URL from the MapMetrics Portal
Basic Hexagon Grid
Create a hexagonal grid overlay and color cells by point count. The hex math itself doesn't depend on the map SDK, so it's unchanged — what changes is how the resulting shapes get onto the map: each colored hexagon becomes its own PolygonLayer (since PolygonLayer.color applies to every polygon it holds, and each hexagon here needs a different color), and the raw data points render as a CircleLayer:
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class HexagonLayerScreen extends StatefulWidget {
@override
_HexagonLayerScreenState createState() => _HexagonLayerScreenState();
}
class _HexagonLayerScreenState extends State<HexagonLayerScreen> {
MapController? mapController;
// Sample data points (e.g., reported incidents, sightings) — lng, lat
final List<Position> dataPoints = [
Position(2.340, 48.860), Position(2.342, 48.861), Position(2.341, 48.859),
Position(2.343, 48.862), Position(2.339, 48.858), Position(2.341, 48.860),
Position(2.350, 48.855), Position(2.352, 48.856), Position(2.349, 48.854),
Position(2.330, 48.870), Position(2.331, 48.871),
Position(2.360, 48.845), Position(2.361, 48.846), Position(2.359, 48.847),
Position(2.362, 48.844), Position(2.358, 48.845), Position(2.360, 48.846),
Position(2.363, 48.846), Position(2.361, 48.843),
Position(2.320, 48.865),
Position(2.310, 48.850), Position(2.311, 48.851),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Hexagon Layer')),
body: Stack(
children: [
MapMetricsView(
options: MapOptions(
initCenter: Position(2.340, 48.855),
initZoom: 13,
initStyle:
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
),
onMapCreated: (MapController controller) {
mapController = controller;
},
layers: [
..._buildHexagonLayers(),
// Show original data points as small markers
CircleLayer(
points: dataPoints
.map((p) => Point(coordinates: p))
.toList(),
radius: 4,
color: Colors.black.withValues(alpha: 0.5),
strokeWidth: 0,
),
],
),
// Legend
Positioned(
bottom: 16,
left: 16,
child: Card(
child: Padding(
padding: EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Density',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)),
SizedBox(height: 4),
_legendRow(Colors.green.withValues(alpha: 0.3), '1-2 points'),
_legendRow(Colors.yellow.withValues(alpha: 0.4), '3-4 points'),
_legendRow(Colors.orange.withValues(alpha: 0.5), '5-6 points'),
_legendRow(Colors.red.withValues(alpha: 0.6), '7+ points'),
],
),
),
),
),
],
),
);
}
Widget _legendRow(Color color, String label) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 1),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 16, height: 16, color: color),
SizedBox(width: 6),
Text(label, style: TextStyle(fontSize: 11)),
],
),
);
}
/// Build one PolygonLayer per non-empty hexagon, colored by data density
List<Layer> _buildHexagonLayers() {
final hexSize = 0.005; // Size of hexagon in degrees
final minLat = 48.840;
final maxLat = 48.875;
final minLng = 2.300;
final maxLng = 2.370;
final layers = <Layer>[];
for (double lat = minLat; lat < maxLat; lat += hexSize * 1.5) {
for (double lng = minLng; lng < maxLng; lng += hexSize * 1.732) {
// Offset every other row
final rowOffset =
((lat - minLat) / (hexSize * 1.5)).round() % 2 == 1
? hexSize * 0.866
: 0.0;
final centerLat = lat;
final centerLng = lng + rowOffset;
// Count points in this hexagon
final count = _countPointsInHex(centerLat, centerLng, hexSize);
if (count == 0) continue;
// Generate hexagon vertices as a single-ring Polygon
final ring = _hexagonRing(centerLat, centerLng, hexSize);
final color = _densityColor(count);
layers.add(
PolygonLayer(
polygons: [Polygon(coordinates: [ring])],
color: color,
outlineColor: color.withValues(alpha: 0.8),
),
);
}
}
return layers;
}
/// A closed ring of Position vertices (GeoJSON order: lng, lat)
List<Position> _hexagonRing(double lat, double lng, double size) {
final vertices = <Position>[];
for (int i = 0; i < 6; i++) {
final angle = (60 * i - 30) * pi / 180;
vertices.add(Position(
lng + size * cos(angle),
lat + size * sin(angle),
));
}
vertices.add(vertices.first); // Close the polygon
return vertices;
}
int _countPointsInHex(double lat, double lng, double size) {
int count = 0;
for (final point in dataPoints) {
final dist = sqrt(
pow(point.lat - lat, 2) + pow(point.lng - lng, 2),
);
if (dist < size) count++;
}
return count;
}
Color _densityColor(int count) {
if (count <= 2) return Colors.green.withValues(alpha: 0.3);
if (count <= 4) return Colors.yellow.withValues(alpha: 0.4);
if (count <= 6) return Colors.orange.withValues(alpha: 0.5);
return Colors.red.withValues(alpha: 0.6);
}
}Interactive Hexagon with Tap Details
There is no per-polygon onTap in the real PolygonLayer — it's a declarative list of shapes with one shared style, not a set of individually-tappable objects. To detect which hexagon was tapped, listen for MapEventClick on onEvent and reuse the same point-in-hexagon math used to build the grid:
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class InteractiveHexScreen extends StatefulWidget {
@override
_InteractiveHexScreenState createState() => _InteractiveHexScreenState();
}
class _InteractiveHexScreenState extends State<InteractiveHexScreen> {
MapController? mapController;
String? selectedHexId;
int selectedCount = 0;
final List<Position> dataPoints = List.generate(50, (i) {
final random = Random(i);
return Position(
2.300 + random.nextDouble() * 0.070,
48.840 + random.nextDouble() * 0.035,
);
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Interactive Hexagons')),
body: Stack(
children: [
MapMetricsView(
options: MapOptions(
initCenter: Position(2.340, 48.855),
initZoom: 13,
initStyle:
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
),
onMapCreated: (MapController controller) {
mapController = controller;
},
onEvent: (event) {
if (event case MapEventClick()) {
_handleTap(event.point);
}
},
layers: _buildInteractiveHexagonLayers(),
),
// Selected hex info
if (selectedHexId != null)
Positioned(
top: 16,
left: 16,
right: 16,
child: Card(
child: Padding(
padding: EdgeInsets.all(12),
child: Text(
'Hexagon contains $selectedCount data points',
style: TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
),
),
),
],
),
);
}
void _handleTap(Position tapped) {
final hexSize = 0.005;
for (double lat = 48.840; lat < 48.875; lat += hexSize * 1.5) {
for (double lng = 2.300; lng < 2.370; lng += hexSize * 1.732) {
final rowOff =
((lat - 48.840) / (hexSize * 1.5)).round() % 2 == 1
? hexSize * 0.866
: 0.0;
final cLat = lat;
final cLng = lng + rowOff;
final dist = sqrt(
pow(tapped.lng - cLng, 2) + pow(tapped.lat - cLat, 2),
);
if (dist < hexSize) {
final count = _countPointsInHex(cLat, cLng, hexSize);
if (count > 0) {
setState(() {
selectedHexId = 'hex_${cLat}_$cLng';
selectedCount = count;
});
}
return;
}
}
}
}
List<Layer> _buildInteractiveHexagonLayers() {
final hexSize = 0.005;
final layers = <Layer>[];
for (double lat = 48.840; lat < 48.875; lat += hexSize * 1.5) {
for (double lng = 2.300; lng < 2.370; lng += hexSize * 1.732) {
final rowOff =
((lat - 48.840) / (hexSize * 1.5)).round() % 2 == 1
? hexSize * 0.866
: 0.0;
final cLat = lat;
final cLng = lng + rowOff;
final count = _countPointsInHex(cLat, cLng, hexSize);
if (count == 0) continue;
final hexId = 'hex_${cLat}_$cLng';
final isSelected = hexId == selectedHexId;
final ring = <Position>[];
for (int i = 0; i < 6; i++) {
final a = (60 * i - 30) * pi / 180;
ring.add(Position(cLng + hexSize * cos(a), cLat + hexSize * sin(a)));
}
ring.add(ring.first);
final color = isSelected
? Colors.blue.withValues(alpha: 0.6)
: _densityColor(count);
layers.add(
PolygonLayer(
polygons: [Polygon(coordinates: [ring])],
color: color,
outlineColor: isSelected ? Colors.blue : Colors.grey,
),
);
}
}
return layers;
}
int _countPointsInHex(double lat, double lng, double size) {
int count = 0;
for (final p in dataPoints) {
if (sqrt(pow(p.lng - lng, 2) + pow(p.lat - lat, 2)) < size) count++;
}
return count;
}
Color _densityColor(int count) {
if (count <= 2) return Colors.green.withValues(alpha: 0.3);
if (count <= 5) return Colors.yellow.withValues(alpha: 0.4);
return Colors.red.withValues(alpha: 0.5);
}
}Next Steps
- Markers and Annotations — Point rendering and clustering
- Map Interactions — Handle taps and gestures
- Draw a Gradient Line — Color-coded route rendering
Tip: Hexagons are better than squares for spatial aggregation because they have uniform neighbor distances and avoid the visual bias of grid alignment. Adjust hexSize based on your zoom level and data density. Each colored hexagon costs one native Layer, so for very dense grids (thousands of cells) consider building the hexagons as a single GeoJsonSource + FillStyleLayer with a fill-color data expression via StyleController instead of one PolygonLayer per cell.