Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class GlobalMethodHandler implements MethodChannel.MethodCallHandler {
@NonNull private final BinaryMessenger messenger;
@Nullable private FlutterPlugin.FlutterAssets flutterAssets;
@Nullable private OfflineChannelHandlerImpl downloadOfflineRegionChannelHandler;
@Nullable private MapSnapshotWrapper mapSnapshotter;


GlobalMethodHandler(@NonNull FlutterPlugin.FlutterPluginBinding binding) {
Expand Down Expand Up @@ -125,6 +126,13 @@ public void onMethodCall(MethodCall methodCall, MethodChannel.Result result) {
OfflineManagerUtils.deleteRegion(
result, context, methodCall.<Number>argument("id").longValue());
break;
case "startMapSnapshot":
Map<String, Object> snapshotArgs = (Map<String, Object>) methodCall.arguments;
if (mapSnapshotter == null) {
mapSnapshotter = new MapSnapshotWrapper(new MethodChannel(messenger, "plugins.flutter.io/maplibre_gl"), context);
}
mapSnapshotter.startSnapshot(snapshotArgs, result);
break;
default:
result.notImplemented();
break;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package org.maplibre.maplibregl;

import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.PointF;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import io.flutter.plugin.common.MethodChannel;
import org.maplibre.android.snapshotter.MapSnapshot;
import org.maplibre.android.snapshotter.MapSnapshotter;
import org.maplibre.android.camera.CameraPosition;
import org.maplibre.android.geometry.LatLng;
import org.maplibre.android.maps.MapLibreMap;
import org.maplibre.android.maps.Style;
import org.maplibre.android.style.layers.PropertyValue;

import java.io.ByteArrayOutputStream;
import java.util.List;
import java.util.Map;

public class MapSnapshotWrapper {
private static final String TAG = MapSnapshotWrapper.class.getSimpleName();

private final MethodChannel channel;
private final android.content.Context context;
private MapSnapshotter snapshotter;
private MethodChannel.Result result;

public MapSnapshotWrapper(MethodChannel channel, android.content.Context context) {
this.channel = channel;
this.context = context;
}

public void startSnapshot(Map<String, Object> arguments, MethodChannel.Result result) {
this.result = result;

try {
// Parse arguments
Integer width = (Integer) arguments.get("width");
Integer height = (Integer) arguments.get("height");
String styleUrl = (String) arguments.get("styleUrl");
Map<String, Object> cameraPositionMap = (Map<String, Object>) arguments.get("cameraPosition");

if (width == null || height == null || styleUrl == null || cameraPositionMap == null) {
result.error("INVALID_ARGUMENTS", "Missing required arguments", null);
return;
}
Comment on lines +48 to +51
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also check if styleUrl is empty and, if so, return an error

Suggested change
if (width == null || height == null || styleUrl == null || cameraPositionMap == null) {
result.error("INVALID_ARGUMENTS", "Missing required arguments", null);
return;
}
if (width == null || height == null || styleUrl == null || cameraPositionMap == null) {
result.error("INVALID_ARGUMENTS", "Missing required arguments", null);
return;
}
if (styleUrl.isEmpty()) {
result.error("INVALID_ARGUMENTS", "Style URL cannot be empty", null);
return;
}


// Create snapshot options
MapSnapshotter.Options options = new MapSnapshotter.Options(width, height)
.withStyle(styleUrl);

// Configure camera
if (cameraPositionMap.containsKey("target")) {
Map<String, Object> target = (Map<String, Object>) cameraPositionMap.get("target");
if (target != null) {
Double lat = (Double) target.get("latitude");
Double lng = (Double) target.get("longitude");
if (lat != null && lng != null) {
options.withCameraPosition(new CameraPosition.Builder()
.target(new LatLng(lat, lng))
.build());
}
}
}

if (cameraPositionMap.containsKey("zoom")) {
Double zoom = (Double) cameraPositionMap.get("zoom");
if (zoom != null) {
CameraPosition.Builder cameraBuilder = new CameraPosition.Builder();
if (options.getCameraPosition() != null) {
cameraBuilder.target(options.getCameraPosition().target);
}
CameraPosition cameraPosition = cameraBuilder.zoom(zoom).build();
Log.d(TAG, "Setting camera zoom to: " + zoom);
options.withCameraPosition(cameraPosition);
}
}

// Create snapshotter
snapshotter = new MapSnapshotter(context, options);

// Set up snapshot listener
snapshotter.start(new MapSnapshotter.SnapshotReadyCallback() {
@Override
public void onSnapshotReady(@NonNull MapSnapshot snapshot) {
try {
// Get the bitmap
Bitmap bitmap = snapshot.getBitmap();

// Add markers if provided
List<Map<String, Object>> markers = (List<Map<String, Object>>) arguments.get("markers");
if (markers != null && !markers.isEmpty()) {
bitmap = addMarkersToBitmap(bitmap, markers, snapshot);
}

// Convert to PNG byte array
ByteArrayOutputStream stream = new ByteArrayOutputStream();
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream);
byte[] imageData = stream.toByteArray();

// Return success result
result.success(imageData);
} catch (Exception e) {
Log.e(TAG, "Error processing snapshot", e);
result.error("SNAPSHOT_ERROR", "Failed to process snapshot: " + e.getMessage(), null);
}
}
});

} catch (Exception e) {
Log.e(TAG, "Error starting snapshot", e);
result.error("SNAPSHOT_ERROR", "Failed to start snapshot: " + e.getMessage(), null);
}
}

private Bitmap addMarkersToBitmap(Bitmap originalBitmap, List<Map<String, Object>> markers, MapSnapshot snapshot) {
// Create a mutable copy of the original bitmap
Bitmap.Config config = originalBitmap.getConfig();
if (config == null) {
config = Bitmap.Config.ARGB_8888;
}
Bitmap resultBitmap = originalBitmap.copy(config, true);

Canvas canvas = new Canvas(resultBitmap);
Paint paint = new Paint();

for (Map<String, Object> marker : markers) {
try {
// Parse marker data
Map<String, Object> position = (Map<String, Object>) marker.get("position");
Double lat = (Double) position.get("latitude");
Double lng = (Double) position.get("longitude");
byte[] iconData = (byte[]) marker.get("iconData");
Double iconSize = (Double) marker.get("iconSize");

if (lat == null || lng == null || iconData == null || iconSize == null) {
continue;
}

// Convert coordinates to screen point
LatLng markerLatLng = new LatLng(lat, lng);
PointF point = snapshot.pixelForLatLng(markerLatLng);

// Create icon bitmap
BitmapFactory.Options options = new BitmapFactory.Options();
options.inPreferredConfig = Bitmap.Config.ARGB_8888;
Bitmap iconBitmap = BitmapFactory.decodeByteArray(iconData, 0, iconData.length, options);

if (iconBitmap != null) {
Log.d(TAG, "Android bitmap size: " + iconBitmap.getWidth() + "x" + iconBitmap.getHeight());
// Use original bitmap size (already includes DPR from Flutter)
int scaledWidth = iconBitmap.getWidth();
int scaledHeight = iconBitmap.getHeight();
Log.d(TAG, "Android marker - using original bitmap size: " + scaledWidth + "x" + scaledHeight);
Bitmap scaledIcon = iconBitmap; // Use original bitmap directly

// Draw icon centered at the point
float left = point.x - scaledWidth / 2;
float top = point.y - scaledHeight;
canvas.drawBitmap(scaledIcon, left, top, paint);

// Only recycle scaledIcon if it's a different bitmap
if (scaledIcon != iconBitmap) {
scaledIcon.recycle();
}
iconBitmap.recycle();
}

} catch (Exception e) {
Log.e(TAG, "Error drawing marker", e);
}
}

return resultBitmap;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import UIKit

public class MapLibreMapsPlugin: NSObject, FlutterPlugin {
static var downloadOfflineRegionChannelHandler: OfflineChannelHandler? = nil
static var mapSnapshotter: MapSnapshotter? = nil

public static func register(with registrar: FlutterPluginRegistrar) {
let instance = MapLibreMapFactory(withRegistrar: registrar)
Expand Down Expand Up @@ -109,6 +110,20 @@ public class MapLibreMapsPlugin: NSObject, FlutterPlugin {
return
}
OfflineManagerUtils.deleteRegion(result: result, id: id)
case "startMapSnapshot":
guard let args = methodCall.arguments as? [String: Any] else {
result(FlutterError(
code: "INVALID_ARGUMENTS",
message: "Invalid arguments for startMapSnapshot",
details: nil
))
return
}

if mapSnapshotter == nil {
mapSnapshotter = MapSnapshotter(channel: channel)
}
mapSnapshotter?.startSnapshot(arguments: args, result: result)
default:
result(FlutterMethodNotImplemented)
}
Expand Down
136 changes: 136 additions & 0 deletions maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapSnapshotter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import Flutter
import Foundation
import MapLibre
import UIKit

public class MapSnapshotter: NSObject {
private var snapshotter: MLNMapSnapshotter?
private var result: FlutterResult?
private var channel: FlutterMethodChannel?

public init(channel: FlutterMethodChannel) {
self.channel = channel
super.init()
}

public func startSnapshot(
arguments: [String: Any],
result: @escaping FlutterResult
) {
self.result = result

guard let width = arguments["width"] as? Int,
let height = arguments["height"] as? Int,
let styleUrl = arguments["styleUrl"] as? String,
let cameraPosition = arguments["cameraPosition"] as? [String: Any]
else {
result(FlutterError(
code: "INVALID_ARGUMENTS",
message: "Missing required arguments",
details: nil
))
return
}

let size = CGSize(width: width, height: height)
let camera = MLNMapCamera(lookingAtCenter: CLLocationCoordinate2D(latitude: 0, longitude: 0), fromDistance: 1000, pitch: 0, heading: 0)
let options = MLNMapSnapshotOptions(styleURL: URL(string: styleUrl)!, camera: camera, size: size)

// Configure camera
if let zoom = cameraPosition["zoom"] as? Double {
print("Setting zoom level to: \(zoom)")
options.zoomLevel = zoom
}

if let center = cameraPosition["target"] as? [String: Double],
let lat = center["latitude"],
let lng = center["longitude"] {
options.camera.centerCoordinate = CLLocationCoordinate2D(latitude: lat, longitude: lng)
}

// Create snapshotter
snapshotter = MLNMapSnapshotter(options: options)

// Start snapshot
snapshotter?.start { (snapshot, error) in
if let error = error {
self.result?(FlutterError(
code: "SNAPSHOT_ERROR",
message: error.localizedDescription,
details: nil
))
return
}

guard let snapshot = snapshot else {
self.result?(FlutterError(
code: "SNAPSHOT_ERROR",
message: "Failed to generate snapshot",
details: nil
))
return
}

// Convert snapshot to image data
let image = snapshot.image

// Add markers if provided
var finalImage = image
if let markers = arguments["markers"] as? [[String: Any]] {
finalImage = self.addMarkers(to: image, markers: markers, snapshot: snapshot)
}

// Convert to PNG data
guard let imageData = finalImage.pngData() else {
self.result?(FlutterError(
code: "SNAPSHOT_ERROR",
message: "Failed to convert image to PNG",
details: nil
))
return
}

// Return image data to Flutter
self.result?(FlutterStandardTypedData(bytes: imageData))
}
}

private func addMarkers(to image: UIImage, markers: [[String: Any]], snapshot: MLNMapSnapshot) -> UIImage {
print("iOS image size: \(image.size), scale: \(image.scale)")
UIGraphicsBeginImageContextWithOptions(image.size, false, image.scale)
image.draw(at: CGPoint.zero)

let context = UIGraphicsGetCurrentContext()

for marker in markers {
guard let position = marker["position"] as? [String: Double],
let lat = position["latitude"],
let lng = position["longitude"],
let iconData = marker["iconData"] as? FlutterStandardTypedData,
let iconSize = marker["iconSize"] as? Double else {
continue
}

// Convert marker coordinates to point on image
let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lng)
let point = snapshot.point(for: coordinate)

// Create icon from data
guard let iconImage = UIImage(data: iconData.data) else { continue }
print("iOS iconImage size: \(iconImage.size), scale: \(iconImage.scale)")

// Use original icon size (already includes DPR from Flutter)
let size = iconImage.size
print("iOS marker - using original icon size: \(size.width)x\(size.height)")
let origin = CGPoint(x: point.x - size.width / 2, y: point.y - size.height)

// Draw icon
iconImage.draw(in: CGRect(origin: origin, size: size))
}

let resultImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()

return resultImage ?? image
}
}
Loading