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

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:

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:

dart
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:

dart
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


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.