How to use tileOverlays on google_maps_flutter package?

2,111

I managed to make it work.

Context is the following:

I had to show custom tiles created from drone images. The goal was to have a much better resolution on the images between zoom levels 14 and 22.

The API I had to work with didn't have images for the whole map which makes sense because we were only interested in having details for some areas.

However, I could fetch on the API KML layers files which allowed me to know in advance what images I could fetch.

Those KML files define coordinates of the tiles I could download from the API.

Resolution

1)

The first step was to fetch thoses KML files and parse them in order to find which regions were covered by drone images. (Not the point here but I can show you anyway if you wish)

2)

Then I made a custom TileProvider. You basically have to everride a method called getTile.

This method has to return a Tile. A tile in Flutter is basically an object containing a width, a height and and datas as Uint8List.

import 'package:google_maps_flutter/google_maps_flutter.dart';

class TestTileProvider implements TileProvider{
  @override
  Future<Tile> getTile(int x, int y, int zoom) {
    // TODO: implement getTile
    throw UnimplementedError();
  }

}

Those data can be easily created by drawing on a canvas as suggested in the official example.

  @override
  Future<Tile> getTile(int x, int y, int? zoom) async {
    final ui.PictureRecorder recorder = ui.PictureRecorder();
    final Canvas canvas = Canvas(recorder);
    final TextSpan textSpan = TextSpan(
      text: '$x,$y',
      style: textStyle,
    );
    final TextPainter textPainter = TextPainter(
      text: textSpan,
      textDirection: TextDirection.ltr,
    );
    textPainter.layout(
      minWidth: 0.0,
      maxWidth: width.toDouble(),
    );
    final Offset offset = const Offset(0, 0);
    textPainter.paint(canvas, offset);
    canvas.drawRect(
        Rect.fromLTRB(0, 0, width.toDouble(), width.toDouble()), boxPaint);
    final ui.Picture picture = recorder.endRecording();
    final Uint8List byteData = await picture
        .toImage(width, height)
        .then((ui.Image image) =>
            image.toByteData(format: ui.ImageByteFormat.png))
        .then((ByteData? byteData) => byteData!.buffer.asUint8List());
    return Tile(width, height, byteData);
  }

The problem was that it didn't fit my needs as I had to display real pictures and not draw border and Tiles coordinates on Tiles.

I found a way to load network images and convert them into the correct format for the Tile.

final uri = Uri.https(AppUrl.BASE, imageUri);
try {
  final ByteData imageData = await NetworkAssetBundle(uri).load("");
  final Uint8List bytes = imageData.buffer.asUint8List();
  return Tile(TILE_SIZE, TILE_SIZE, bytes);
} catch (e) {}

However, as I don't have images for the whole map, I had to return something when no image was available. I made a transparent tile and returned it when needed. In order to improve performances, I create the transparent Tile when I create the provider and then always return the same one.

class TestTileProvider implements TileProvider {
  static const int TILE_SIZE = 256;
  static final Paint boxPaint = Paint();
  Tile transparentTile;
...
  TestTileProvider() {
    boxPaint.isAntiAlias = true;
    boxPaint.color = Colors.transparent;
    boxPaint.style = PaintingStyle.fill;
    initTransparentTile();
  }
...

  void initTransparentTile() async {
    final ui.PictureRecorder recorder = ui.PictureRecorder();
    final Canvas canvas = Canvas(recorder);
    canvas.drawRect(
        Rect.fromLTRB(0, 0, TILE_SIZE.toDouble(), TILE_SIZE.toDouble()),
        boxPaint);
    final ui.Picture picture = recorder.endRecording();
    final Uint8List byteData = await picture
        .toImage(TILE_SIZE, TILE_SIZE)
        .then((ui.Image image) =>
            image.toByteData(format: ui.ImageByteFormat.png))
        .then((ByteData byteData) => byteData.buffer.asUint8List());
    transparentTile = Tile(TILE_SIZE, TILE_SIZE, byteData);
  }
...

}

The next problem I encountered is that this getTile method only gives you Tile coordinates (x, y and zoom) in the Tile world. The locations defined in KML Layers are defined in Degrees.

<LatLonAltBox>
    <north>47.054785</north>
    <south>47.053557</south>
    <east>6.780674</east>
    <west>6.774709</west>
</LatLonAltBox>

(Those are fake coordinates I can't show you the real one :) )

I really, REALLY, REALLY suggest you to read this in order to understand the differences between all those coordinates types.

I then had to find a way to convert tiles coordinates to degrees.

As I couldn't find anyway in Flutter to retrieve a projection object (In javascript API there's a way to retrieve it). I had to convert those coordinates by myself.

First I had to understand how they are converted from degrees to tile coordinates (This uses a Mercator projection and some math).

Hopefully I could find a JS implementation here in the project method.

The "only" thing I had to do was to invert this and I would be able to obtain degrees from tile coordinates.

I first rewrote the project method and then inverted it:

// The mapping between latitude, longitude and pixels is defined by the web
  // mercator projection.
  // Source: https://developers.google.com/maps/documentation/javascript/examples/map-coordinates?csw=1
  math.Point project(LatLng latLng) {
    double siny = math.sin((latLng.latitude * math.pi) / 180);

    // Truncating to 0.9999 effectively limits latitude to 89.189. This is
    // about a third of a tile past the edge of the world tile.
    siny = math.min(math.max(siny, -0.9999), 0.9999);

    return new math.Point(
      TILE_SIZE * (0.5 + latLng.longitude / 360),
      TILE_SIZE * (0.5 - math.log((1 + siny) / (1 - siny)) / (4 * math.pi)),
    );
  }

  LatLng reverseMercatorFromTileCoordinates(int x, int y, int zoom) {
    // Reverse mercator projection.
    // Reverse of above function (kept for readibility)
    //
    // 1) Compute longitude
    //
    // TILE_SIZE * (0.5 + latLng.longitude / 360) = x
    //0.5 + latLng.longitude / 360 = x / TILE_SIZE
    // latLng.longitude / 360 = x / TILE_SIZE - 0.5
    // latLng.longitude = (x / TILE_SIZE - 0.5) *360

    int pixelCoordinateX = x * TILE_SIZE;
    int pixelCoordinateY = y * TILE_SIZE;

    double worldCoordinateX = pixelCoordinateX / math.pow(2, zoom);
    double worldCoordinateY = pixelCoordinateY / math.pow(2, zoom);

    double long = (worldCoordinateX / TILE_SIZE - 0.5) * 360;

    //
    // 2) compute sin(y)
    //
    // TILE_SIZE * (0.5 - math.log((1 + siny) / (1 - siny)) / (4 * math.pi)) = y
    // 0.5 - math.log((1 + siny) / (1 - siny)) / (4 * math.pi) = y / TILE_SIZE
    // math.log((1 + siny) / (1 - siny)) / (4 * math.pi) = -(y / TILE_SIZE) + 0.5
    // math.log((1 + siny) / (1 - siny)) = (-(y / TILE_SIZE) + 0.5)(4 * math.pi)
    // (1 + siny) / (1 - siny) = math.pow(math.e, (-(y / TILE_SIZE) + 0.5)(4 * math.pi))
    // siny = (math.pow(math.e, (-(y / TILE_SIZE) + 0.5)(4 * math.pi)) - 1) / (1+math.pow(math.e, (-(y / TILE_SIZE) + 0.5)(4 * math.pi)));
    double part = math.pow(
        math.e, ((-(worldCoordinateY / TILE_SIZE) + 0.5) * (4 * math.pi)));
    double siny = (part - 1) / (1 + part);
    //
    // 3) Compute latitude
    //
    // siny = math.sin((latLng.latitude * math.pi) / 180)
    // math.asin(siny) = (latLng.latitude * math.pi) / 180
    // math.asin(siny) * 180 = (latLng.latitude * math.pi)
    // (math.asin(siny) * 180) / math.pi = latLng.latitude
    double lat = (math.asin(siny) * 180) / math.pi;
    return LatLng(lat, long);
  }

I was now able to check if the Tile requested from the Provider was in the areas covered by drone images!

Here's the full getTile implementation:

  @override
  Future<Tile> getTile(int x, int y, int zoom) async {

    // Reverse tile coordinates to degreees
    LatLng tileNorthWestCornerCoordinates =
        reverseMercatorFromTileCoordinates(x, y, zoom);

    // `domain` is an object in which are stored the kml datas fetched before
    KMLLayer kmlLayer =
        domain.getKMLLayerforPoint(tileNorthWestCornerCoordinates);

    
    if (kmlLayer != null &&
        zoom >= kmlLayer.minZoom &&
        zoom <= kmlLayer.maxZoom) {
      final String imageUri = domain.getImageUri(kmlLayer, x, y, zoom);

      final uri = Uri.https(AppUrl.BASE, imageUri);
      try {
        final ByteData imageData = await NetworkAssetBundle(uri).load("");
        final Uint8List bytes = imageData.buffer.asUint8List();
        return Tile(TILE_SIZE, TILE_SIZE, bytes);
      } catch (e) {}
    }
    // return transparent tile
    return transparentTile;
  }

Anddddddddd the full TileProvider example:

import 'dart:math' as math;
import 'dart:typed_data';
import 'dart:ui' as ui;

import 'package:app_digivitis/core/constants/app_url.dart';
import 'package:app_digivitis/core/models/domain.dart';
import 'package:app_digivitis/core/models/kml_layer.dart';
import 'package:app_digivitis/core/viewmodels/screens/user_view_model.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:provider/provider.dart';

class TestTileProvider implements TileProvider {
  static const int TILE_SIZE = 256;
  static final Paint boxPaint = Paint();
  BuildContext context;
  Tile transparentTile;
  Domain domain;

  TestTileProvider() {
    boxPaint.isAntiAlias = true;
    boxPaint.color = Colors.transparent;
    boxPaint.style = PaintingStyle.fill;
    initTransparentTile();
  }

  @override
  Future<Tile> getTile(int x, int y, int zoom) async {
    // Reverse tile coordinates to degreees
    LatLng tileNorthWestCornerCoordinates =
        reverseMercatorFromTileCoordinates(x, y, zoom);

    // `domain` is an object in which are stored the kml datas fetched before
    KMLLayer kmlLayer =
        domain.getKMLLayerforPoint(tileNorthWestCornerCoordinates);

    if (kmlLayer != null &&
        zoom >= kmlLayer.minZoom &&
        zoom <= kmlLayer.maxZoom) {
      final String imageUri = domain.getImageUri(kmlLayer, x, y, zoom);

      final uri = Uri.https(AppUrl.BASE, imageUri);
      try {
        final ByteData imageData = await NetworkAssetBundle(uri).load("");
        final Uint8List bytes = imageData.buffer.asUint8List();
        return Tile(TILE_SIZE, TILE_SIZE, bytes);
      } catch (e) {}
    }
    // return transparent tile
    return transparentTile;
  }

  void initContext(BuildContext context) {
    context = context;
    final userViewModel = Provider.of<UserViewModel>(context, listen: false);
    domain = userViewModel.domain;
  }

  void initTransparentTile() async {
    final ui.PictureRecorder recorder = ui.PictureRecorder();
    final Canvas canvas = Canvas(recorder);
    canvas.drawRect(
        Rect.fromLTRB(0, 0, TILE_SIZE.toDouble(), TILE_SIZE.toDouble()),
        boxPaint);
    final ui.Picture picture = recorder.endRecording();
    final Uint8List byteData = await picture
        .toImage(TILE_SIZE, TILE_SIZE)
        .then((ui.Image image) =>
            image.toByteData(format: ui.ImageByteFormat.png))
        .then((ByteData byteData) => byteData.buffer.asUint8List());
    transparentTile = Tile(TILE_SIZE, TILE_SIZE, byteData);
  }

  // The mapping between latitude, longitude and pixels is defined by the web
  // mercator projection.
  // Source: https://developers.google.com/maps/documentation/javascript/examples/map-coordinates?csw=1
  math.Point project(LatLng latLng) {
    double siny = math.sin((latLng.latitude * math.pi) / 180);

    // Truncating to 0.9999 effectively limits latitude to 89.189. This is
    // about a third of a tile past the edge of the world tile.
    siny = math.min(math.max(siny, -0.9999), 0.9999);

    return new math.Point(
      TILE_SIZE * (0.5 + latLng.longitude / 360),
      TILE_SIZE * (0.5 - math.log((1 + siny) / (1 - siny)) / (4 * math.pi)),
    );
  }

  LatLng reverseMercatorFromTileCoordinates(int x, int y, int zoom) {
    // Reverse mercator projection.
    // Reverse of above function (kept for readibility)
    //
    // 1) Compute longitude
    //
    // TILE_SIZE * (0.5 + latLng.longitude / 360) = x
    //0.5 + latLng.longitude / 360 = x / TILE_SIZE
    // latLng.longitude / 360 = x / TILE_SIZE - 0.5
    // latLng.longitude = (x / TILE_SIZE - 0.5) *360

    int pixelCoordinateX = x * TILE_SIZE;
    int pixelCoordinateY = y * TILE_SIZE;

    double worldCoordinateX = pixelCoordinateX / math.pow(2, zoom);
    double worldCoordinateY = pixelCoordinateY / math.pow(2, zoom);

    double long = (worldCoordinateX / TILE_SIZE - 0.5) * 360;

    //
    // 2) compute sin(y)
    //
    // TILE_SIZE * (0.5 - math.log((1 + siny) / (1 - siny)) / (4 * math.pi)) = y
    // 0.5 - math.log((1 + siny) / (1 - siny)) / (4 * math.pi) = y / TILE_SIZE
    // math.log((1 + siny) / (1 - siny)) / (4 * math.pi) = -(y / TILE_SIZE) + 0.5
    // math.log((1 + siny) / (1 - siny)) = (-(y / TILE_SIZE) + 0.5)(4 * math.pi)
    // (1 + siny) / (1 - siny) = math.pow(math.e, (-(y / TILE_SIZE) + 0.5)(4 * math.pi))
    // siny = (math.pow(math.e, (-(y / TILE_SIZE) + 0.5)(4 * math.pi)) - 1) / (1+math.pow(math.e, (-(y / TILE_SIZE) + 0.5)(4 * math.pi)));
    double part = math.pow(
        math.e, ((-(worldCoordinateY / TILE_SIZE) + 0.5) * (4 * math.pi)));
    double siny = (part - 1) / (1 + part);
    //
    // 3) Compute latitude
    //
    // siny = math.sin((latLng.latitude * math.pi) / 180)
    // math.asin(siny) = (latLng.latitude * math.pi) / 180
    // math.asin(siny) * 180 = (latLng.latitude * math.pi)
    // (math.asin(siny) * 180) / math.pi = latLng.latitude
    double lat = (math.asin(siny) * 180) / math.pi;
    return LatLng(lat, long);
  }
}

Don't pay much attention to context as I needed it in order to use the provider to retrieve my domain instance.

3)

Use this provider!

Full widget with map example:

import 'package:app_digivitis/core/providers/test_provider.dart';
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:location/location.dart';

class MarksMapTest extends StatefulWidget {
  @override
  _MarksMapTestState createState() => _MarksMapTestState();
}

class _MarksMapTestState extends State<MarksMapTest>
    with SingleTickerProviderStateMixin {
  GoogleMapController mapController;
  Location _location = Location();
  LocationData _center =
      LocationData.fromMap({'latitude': 51.512153, 'longitude': -0.111019});
  TileOverlay _tileOverlay;
  TestTileProvider testTileProvider = TestTileProvider();

  @override
  void initState() {
    super.initState();
    createTileOverlay();
    _location.getLocation().then((value) {
      _center = value;
    });
  }

  void createTileOverlay() {
    if (_tileOverlay == null) {
      final TileOverlay tileOverlay = TileOverlay(
        tileOverlayId: TileOverlayId('tile_overlay_1'),
        tileProvider: testTileProvider,
      );
      _tileOverlay = tileOverlay;
    }
  }

  void _onMapCreated(GoogleMapController controller) {
    mapController = controller;
    centerMap(location: _center);
  }

  void centerMap({LocationData location}) async {
    if (location == null) location = await _location.getLocation();
    mapController.animateCamera(
      CameraUpdate.newCameraPosition(CameraPosition(
          target: LatLng(location.latitude, location.longitude), zoom: 14)),
    );
  }

  @override
  Widget build(BuildContext context) {
    testTileProvider.initContext(context);
    Set<TileOverlay> overlays = <TileOverlay>{
      if (_tileOverlay != null) _tileOverlay,
    };
    return Scaffold(
      body: GoogleMap(
        onMapCreated: (controller) => _onMapCreated(controller),
        initialCameraPosition: CameraPosition(
          target: LatLng(_center.latitude, _center.longitude),
          zoom: 14.0,
        ),
        tileOverlays: overlays,
        mapType: MapType.hybrid,
        myLocationButtonEnabled: false,
        myLocationEnabled: true,
        zoomControlsEnabled: false,
        mapToolbarEnabled: false,
      ),
    );
  }
}

Share:
2,111
ktary
Author by

ktary

Updated on December 28, 2022

Comments

  • ktary
    ktary over 1 year

    With java, we can put WMS tiles on top of Google Base Map by making use of tile overlays. In flutter i found google_maps_flutter has tileOverlays property on its constructor but it very hard to find a working example. Anyone here successfully overlay WMS tiles on top of google maps in flutter?

  • زياد
    زياد over 2 years
    Thanks Maël, but how can I add a custom tile URL?
  • Maël Pedretti
    Maël Pedretti over 2 years
    what do you mean? @زياد
  • زياد
    زياد over 2 years
    Thanks for your help. I got it.
  • Maël Pedretti
    Maël Pedretti over 2 years
    Could you accept my answer? It would give more visibility to other users who may need an answer on this topic too. @زياد
  • Tim Mwaura
    Tim Mwaura about 2 years
    @MaëlPedretti Hello, could you share this entire code please?
  • Maël Pedretti
    Maël Pedretti about 2 years
    Hey @TimMwaura, this project is an entreprise project so I am afraid I won't be able to do it but I could make another example if you find an open databank of drone images