diff --git a/CodenameOne/src/com/codename1/maps/CameraChangeListener.java b/CodenameOne/src/com/codename1/maps/CameraChangeListener.java new file mode 100644 index 0000000000..7475148b4b --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/CameraChangeListener.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps; + +/// Receives camera movement notifications from a [MapSurface]. Register with +/// [MapSurface#addCameraChangeListener]. +public interface CameraChangeListener { + + /// Invoked on the EDT after the camera settles at a new position, whether + /// from a programmatic move or a user pan/zoom gesture. + /// + /// #### Parameters + /// + /// - `map`: the surface whose camera moved + /// + /// - `position`: the new camera position + void cameraChanged(MapSurface map, CameraPosition position); +} diff --git a/CodenameOne/src/com/codename1/maps/CameraPosition.java b/CodenameOne/src/com/codename1/maps/CameraPosition.java new file mode 100644 index 0000000000..181ec12a86 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/CameraPosition.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps; + +/// An immutable description of the map camera: where it looks ([#getTarget]), +/// how far it is zoomed in ([#getZoom]), the compass bearing in degrees +/// ([#getBearing]) and the tilt away from nadir ([#getTilt]). +/// +/// Zoom uses the standard slippy-map scale where each whole increment +/// doubles the scale (zoom 0 shows the whole world in a single 256px tile). +/// Fractional zoom is supported by the vector engine; native providers may +/// round it. Bearing and tilt are honored by native providers that support +/// them and ignored by the pure-vector [MapView]. +public final class CameraPosition { + + private final LatLng target; + private final double zoom; + private final double bearing; + private final double tilt; + + /// Creates a camera position that looks straight down (no bearing/tilt). + public CameraPosition(LatLng target, double zoom) { + this(target, zoom, 0, 0); + } + + /// Creates a fully specified camera position. + /// + /// #### Parameters + /// + /// - `target`: the geographic point at the center of the viewport + /// + /// - `zoom`: the slippy-map zoom level + /// + /// - `bearing`: the compass bearing in degrees (0 = north up) + /// + /// - `tilt`: the viewing angle away from straight-down, in degrees + public CameraPosition(LatLng target, double zoom, double bearing, double tilt) { + this.target = target; + this.zoom = zoom; + this.bearing = bearing; + this.tilt = tilt; + } + + /// The geographic point at the center of the viewport. + public LatLng getTarget() { + return target; + } + + /// The slippy-map zoom level. + public double getZoom() { + return zoom; + } + + /// The compass bearing in degrees (0 = north up). + public double getBearing() { + return bearing; + } + + /// The viewing tilt in degrees (0 = straight down). + public double getTilt() { + return tilt; + } + + /// Returns a copy of this position with a different target. + public CameraPosition withTarget(LatLng newTarget) { + return new CameraPosition(newTarget, zoom, bearing, tilt); + } + + /// Returns a copy of this position with a different zoom level. + public CameraPosition withZoom(double newZoom) { + return new CameraPosition(target, newZoom, bearing, tilt); + } + + /// {@inheritDoc} + @Override + public String toString() { + return "CameraPosition{target=" + target + ", zoom=" + zoom + + ", bearing=" + bearing + ", tilt=" + tilt + "}"; + } +} diff --git a/CodenameOne/src/com/codename1/maps/Circle.java b/CodenameOne/src/com/codename1/maps/Circle.java new file mode 100644 index 0000000000..1ef028a5a5 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/Circle.java @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps; + +/// A geodesic circle drawn on a map, described by a center and a radius in +/// meters. Add one through [MapSurface#addCircle(Circle)]. +public final class Circle extends MapObject { + + private LatLng center; + private double radiusMeters; + private int fillColor = 0x402196f3; + private int strokeColor = 0x2196f3; + private int strokeWidth = 2; + private boolean visible = true; + + /// Creates a circle. + /// + /// #### Parameters + /// + /// - `center`: the geographic center + /// + /// - `radiusMeters`: the radius in meters + public Circle(LatLng center, double radiusMeters) { + this.center = center; + this.radiusMeters = radiusMeters; + } + + /// The circle center. + public LatLng getCenter() { + return center; + } + + /// Moves the circle center. + public Circle setCenter(LatLng center) { + this.center = center; + return this; + } + + /// The radius in meters. + public double getRadiusMeters() { + return radiusMeters; + } + + /// Sets the radius in meters. + public Circle setRadiusMeters(double radiusMeters) { + this.radiusMeters = radiusMeters; + return this; + } + + /// The fill color as 0xAARRGGBB (alpha in the high byte). + public int getFillColor() { + return fillColor; + } + + /// Sets the fill color as 0xAARRGGBB (alpha in the high byte). + public Circle setFillColor(int fillColor) { + this.fillColor = fillColor; + return this; + } + + /// The stroke color as 0xRRGGBB. + public int getStrokeColor() { + return strokeColor; + } + + /// Sets the stroke color as 0xRRGGBB. + public Circle setStrokeColor(int strokeColor) { + this.strokeColor = strokeColor; + return this; + } + + /// The stroke width in pixels (0 hides the outline). + public int getStrokeWidth() { + return strokeWidth; + } + + /// Sets the stroke width in pixels (0 hides the outline). + public Circle setStrokeWidth(int strokeWidth) { + this.strokeWidth = strokeWidth; + return this; + } + + /// Whether the circle is rendered. + public boolean isVisible() { + return visible; + } + + /// Shows or hides the circle. + public Circle setVisible(boolean visible) { + this.visible = visible; + return this; + } +} diff --git a/CodenameOne/src/com/codename1/maps/LatLng.java b/CodenameOne/src/com/codename1/maps/LatLng.java new file mode 100644 index 0000000000..95b2598eb9 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/LatLng.java @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps; + +import com.codename1.util.MathUtil; + +/// An immutable WGS84 geographic coordinate (latitude/longitude in degrees). +/// +/// Unlike the legacy [Coord], `LatLng` is always unprojected (plain +/// lat/lon) and immutable, which makes it safe to share between the map +/// components, the vector engine and native providers. It is the value +/// type used throughout the modern maps API ([MapView], [NativeMap] and +/// [com.codename1.maps.spi.MapProvider]). +public final class LatLng { + + private static final double EARTH_RADIUS_METERS = 6378137.0; + private static final double DELTA = 0.0000001; + + private final double latitude; + private final double longitude; + + /// Creates a coordinate from a latitude/longitude pair in degrees. + /// + /// #### Parameters + /// + /// - `latitude`: the latitude in degrees, clamped to the valid range + /// + /// - `longitude`: the longitude in degrees, normalized to [-180, 180] + public LatLng(double latitude, double longitude) { + if (latitude > 90) { + latitude = 90; + } else if (latitude < -90) { + latitude = -90; + } + if (longitude > 180 || longitude < -180) { + longitude = ((longitude + 180) % 360 + 360) % 360 - 180; + } + this.latitude = latitude; + this.longitude = longitude; + } + + /// Factory method mirroring the constructor for fluent call sites. + public static LatLng create(double latitude, double longitude) { + return new LatLng(latitude, longitude); + } + + /// Converts a legacy [Coord] (assumed WGS84) into a `LatLng`. + public static LatLng fromCoord(Coord c) { + return new LatLng(c.getLatitude(), c.getLongitude()); + } + + /// The latitude in degrees in the range [-90, 90]. + public double getLatitude() { + return latitude; + } + + /// The longitude in degrees in the range [-180, 180]. + public double getLongitude() { + return longitude; + } + + /// Converts this coordinate into a legacy WGS84 [Coord]. + public Coord toCoord() { + return new Coord(latitude, longitude, false); + } + + /// The great-circle distance in meters between this coordinate and + /// `other`, computed with the haversine formula. + public double distanceTo(LatLng other) { + double dLat = Math.toRadians(other.latitude - latitude); + double dLon = Math.toRadians(other.longitude - longitude); + double lat1 = Math.toRadians(latitude); + double lat2 = Math.toRadians(other.latitude); + double sinLat = Math.sin(dLat / 2); + double sinLon = Math.sin(dLon / 2); + double a = sinLat * sinLat + Math.cos(lat1) * Math.cos(lat2) * sinLon * sinLon; + double c = 2 * MathUtil.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return EARTH_RADIUS_METERS * c; + } + + /// {@inheritDoc} + @Override + public boolean equals(Object o) { + if (!(o instanceof LatLng)) { + return false; + } + LatLng l = (LatLng) o; + return Math.abs(latitude - l.latitude) < DELTA + && Math.abs(longitude - l.longitude) < DELTA; + } + + /// {@inheritDoc} + @Override + public int hashCode() { + long lat = Double.doubleToLongBits(latitude); + long lon = Double.doubleToLongBits(longitude); + int hash = 7; + hash = 31 * hash + (int) (lat ^ (lat >>> 32)); + hash = 31 * hash + (int) (lon ^ (lon >>> 32)); + return hash; + } + + /// {@inheritDoc} + @Override + public String toString() { + return "LatLng{" + latitude + ", " + longitude + "}"; + } +} diff --git a/CodenameOne/src/com/codename1/maps/MapBounds.java b/CodenameOne/src/com/codename1/maps/MapBounds.java new file mode 100644 index 0000000000..0d0215e004 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/MapBounds.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps; + +import java.util.List; + +/// An immutable axis-aligned latitude/longitude rectangle delimited by its +/// south-west and north-east corners. +/// +/// Replaces the legacy [BoundingBox] for the modern API, fixing the null +/// bounding-box issues of the old point layers and always operating in +/// WGS84 ([LatLng]) coordinates. +public final class MapBounds { + + private final LatLng southWest; + private final LatLng northEast; + + /// Creates a bounding box from two opposing corners. The corners are + /// normalized so that `southWest` always holds the minimum latitude and + /// longitude and `northEast` the maximum. + public MapBounds(LatLng southWest, LatLng northEast) { + double minLat = Math.min(southWest.getLatitude(), northEast.getLatitude()); + double maxLat = Math.max(southWest.getLatitude(), northEast.getLatitude()); + double minLon = Math.min(southWest.getLongitude(), northEast.getLongitude()); + double maxLon = Math.max(southWest.getLongitude(), northEast.getLongitude()); + this.southWest = new LatLng(minLat, minLon); + this.northEast = new LatLng(maxLat, maxLon); + } + + /// Builds the smallest bounding box that contains every coordinate in + /// `coords`. Returns `null` when the list is empty. + public static MapBounds fromCoordinates(List coords) { + if (coords == null || coords.isEmpty()) { + return null; + } + double minLat = Double.MAX_VALUE; + double maxLat = -Double.MAX_VALUE; + double minLon = Double.MAX_VALUE; + double maxLon = -Double.MAX_VALUE; + int size = coords.size(); + for (int i = 0; i < size; i++) { + LatLng c = (LatLng) coords.get(i); + minLat = Math.min(minLat, c.getLatitude()); + maxLat = Math.max(maxLat, c.getLatitude()); + minLon = Math.min(minLon, c.getLongitude()); + maxLon = Math.max(maxLon, c.getLongitude()); + } + return new MapBounds(new LatLng(minLat, minLon), new LatLng(maxLat, maxLon)); + } + + /// The south-west (minimum latitude/longitude) corner. + public LatLng getSouthWest() { + return southWest; + } + + /// The north-east (maximum latitude/longitude) corner. + public LatLng getNorthEast() { + return northEast; + } + + /// The geometric center of this box. + public LatLng getCenter() { + return new LatLng((southWest.getLatitude() + northEast.getLatitude()) / 2, + (southWest.getLongitude() + northEast.getLongitude()) / 2); + } + + /// Returns true if `point` lies inside this box (inclusive). + public boolean contains(LatLng point) { + return point.getLatitude() >= southWest.getLatitude() + && point.getLatitude() <= northEast.getLatitude() + && point.getLongitude() >= southWest.getLongitude() + && point.getLongitude() <= northEast.getLongitude(); + } + + /// Returns a new box that contains both this box and `point`. + public MapBounds extend(LatLng point) { + return new MapBounds( + new LatLng(Math.min(southWest.getLatitude(), point.getLatitude()), + Math.min(southWest.getLongitude(), point.getLongitude())), + new LatLng(Math.max(northEast.getLatitude(), point.getLatitude()), + Math.max(northEast.getLongitude(), point.getLongitude()))); + } + + /// The span between the north and south edges in degrees. + public double getLatitudeSpan() { + return northEast.getLatitude() - southWest.getLatitude(); + } + + /// The span between the east and west edges in degrees. + public double getLongitudeSpan() { + return northEast.getLongitude() - southWest.getLongitude(); + } + + /// {@inheritDoc} + @Override + public String toString() { + return "MapBounds{" + southWest + " -> " + northEast + "}"; + } +} diff --git a/CodenameOne/src/com/codename1/maps/MapComponent.java b/CodenameOne/src/com/codename1/maps/MapComponent.java index 61e7921332..add7e954c4 100644 --- a/CodenameOne/src/com/codename1/maps/MapComponent.java +++ b/CodenameOne/src/com/codename1/maps/MapComponent.java @@ -55,7 +55,10 @@ /// /// #### Deprecated /// -/// we highly recommend migrating to the native maps cn1lib +/// Use the modern [MapView] (pure-vector) or [NativeMap] (native provider +/// with vector fallback) instead. This tile-based component is retained only +/// for backward compatibility. +@Deprecated public class MapComponent extends Container { private static final Font attributionFont = Font.createSystemFont(Font.FACE_PROPORTIONAL, Font.STYLE_ITALIC, Font.SIZE_SMALL); diff --git a/CodenameOne/src/com/codename1/maps/MapObject.java b/CodenameOne/src/com/codename1/maps/MapObject.java new file mode 100644 index 0000000000..2e821d9915 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/MapObject.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps; + +/// Common base for objects placed on a map ([Marker], [Polyline], +/// [Polygon], [Circle]). Holds the bookkeeping the map surface and the +/// native providers need to track the object across the Java/native +/// boundary without the public API exposing it. +public abstract class MapObject { + + private static int idCounter = 1; + + private final int id; + + /// Opaque handle owned by whichever backend (vector engine or native + /// provider) currently renders this object. For native providers it + /// typically holds the `long` element key; for the vector engine it is + /// unused. Package visible by design. + Object providerKey; + + /// True once the object has been removed from its surface. + boolean removed; + + MapObject() { + synchronized (MapObject.class) { + id = idCounter++; + } + } + + /// A process-unique identifier for this object. + public int getId() { + return id; + } + + /// {@inheritDoc} + @Override + public int hashCode() { + return id; + } + + /// {@inheritDoc} + @Override + public boolean equals(Object o) { + return o instanceof MapObject && ((MapObject) o).id == id; + } +} diff --git a/CodenameOne/src/com/codename1/maps/MapSurface.java b/CodenameOne/src/com/codename1/maps/MapSurface.java new file mode 100644 index 0000000000..e6cfad2100 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/MapSurface.java @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps; + +import com.codename1.ui.Component; +import com.codename1.ui.geom.Point; + +/// The provider-agnostic map API shared by the pure-vector [MapView] and the +/// native-peer [NativeMap]. +/// +/// Application code should program against `MapSurface` so it does not need +/// to know whether the map is drawn by the built-in vector engine or by a +/// native provider (Apple MapKit, Google Maps, ...). The two concrete +/// components differ only in how they render; their behavior through this +/// interface is identical, and a [NativeMap] with no native provider wired +/// in transparently delegates to an embedded [MapView]. +/// +/// Named `MapSurface` rather than `Map` to avoid clashing with +/// `java.util.Map`. +public interface MapSurface { + + // ---- Camera ----------------------------------------------------------- + + /// The current camera position (target, zoom, bearing, tilt). + CameraPosition getCameraPosition(); + + /// Moves the camera to `position`, animating where the backend supports it. + void setCameraPosition(CameraPosition position); + + /// Convenience to recenter at `target` and set `zoom` in one call. + void moveCamera(LatLng target, double zoom); + + /// The current zoom level. + double getZoom(); + + /// Sets the zoom level, keeping the current center. + void setZoom(double zoom); + + /// The smallest zoom level the backend permits. + double getMinZoom(); + + /// The largest zoom level the backend permits. + double getMaxZoom(); + + /// The geographic coordinate at the center of the viewport. + LatLng getCenter(); + + /// Recenters the viewport at `center`, keeping the current zoom. + void setCenter(LatLng center); + + // ---- Bounds ----------------------------------------------------------- + + /// The geographic bounds currently visible, or `null` before layout. + /// (Named `getVisibleRegion` to avoid clashing with + /// `Component.getVisibleBounds()`, which returns a pixel rectangle.) + MapBounds getVisibleRegion(); + + /// Moves and zooms the camera so `bounds` fits within the viewport, + /// inset by `paddingPixels` on every edge. + void fitBounds(MapBounds bounds, int paddingPixels); + + // ---- Map objects ------------------------------------------------------ + + /// Adds a marker described by `options` and returns its live handle. + Marker addMarker(MarkerOptions options); + + /// Removes a previously added marker. + void removeMarker(Marker marker); + + /// Adds a polyline and returns it for chaining. + Polyline addPolyline(Polyline polyline); + + /// Removes a previously added polyline. + void removePolyline(Polyline polyline); + + /// Adds a polygon and returns it for chaining. + Polygon addPolygon(Polygon polygon); + + /// Removes a previously added polygon. + void removePolygon(Polygon polygon); + + /// Adds a circle and returns it for chaining. + Circle addCircle(Circle circle); + + /// Removes a previously added circle. + void removeCircle(Circle circle); + + /// Removes every marker, polyline, polygon and circle. + void clearMapObjects(); + + // ---- Coordinate conversion ------------------------------------------- + + /// Converts a geographic coordinate to a pixel relative to this component. + Point latLngToScreen(LatLng coord); + + /// Converts a pixel relative to this component to a geographic coordinate. + LatLng screenToLatLng(int x, int y); + + // ---- Listeners -------------------------------------------------------- + + /// Registers a tap listener. + void addTapListener(MapTapListener l); + + /// Unregisters a tap listener. + void removeTapListener(MapTapListener l); + + /// Registers a long-press listener. + void addLongPressListener(MapTapListener l); + + /// Unregisters a long-press listener. + void removeLongPressListener(MapTapListener l); + + /// Registers a camera-change listener. + void addCameraChangeListener(CameraChangeListener l); + + /// Unregisters a camera-change listener. + void removeCameraChangeListener(CameraChangeListener l); + + // ---- Backend introspection ------------------------------------------- + + /// True when a native provider currently backs this surface; false for a + /// pure-vector map or a [NativeMap] that fell back to the vector engine. + boolean isNativeMap(); + + /// This surface as a Codename One [Component] for layout purposes. + Component asComponent(); +} diff --git a/CodenameOne/src/com/codename1/maps/MapTapListener.java b/CodenameOne/src/com/codename1/maps/MapTapListener.java new file mode 100644 index 0000000000..0b61a33c1b --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/MapTapListener.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps; + +/// Receives tap and long-press notifications from a [MapSurface]. Register +/// with [MapSurface#addTapListener] or [MapSurface#addLongPressListener]. +public interface MapTapListener { + + /// Invoked on the EDT when the user taps (or long-presses) the map. + /// + /// #### Parameters + /// + /// - `map`: the surface that was tapped + /// + /// - `location`: the geographic coordinate under the touch point + /// + /// - `x`: the x pixel relative to the map component + /// + /// - `y`: the y pixel relative to the map component + void mapTapped(MapSurface map, LatLng location, int x, int y); +} diff --git a/CodenameOne/src/com/codename1/maps/MapView.java b/CodenameOne/src/com/codename1/maps/MapView.java new file mode 100644 index 0000000000..be0f1f0420 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/MapView.java @@ -0,0 +1,603 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps; + +import com.codename1.maps.vector.MapStyle; +import com.codename1.maps.vector.MvtTileSource; +import com.codename1.maps.vector.TileSource; +import com.codename1.maps.vector.VectorMapEngine; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.ui.EncodedImage; +import com.codename1.ui.Font; +import com.codename1.ui.FontImage; +import com.codename1.ui.Graphics; +import com.codename1.ui.Stroke; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.geom.GeneralPath; +import com.codename1.ui.geom.Point; +import com.codename1.util.MathUtil; + +import java.util.ArrayList; +import java.util.List; + +/// A pure-vector map component: it renders entirely through the Codename One +/// [Graphics] API (the built-in [VectorMapEngine]) and never embeds a native +/// peer, so it composes cleanly with the rest of the UI -- dialogs, lists and +/// overlays draw over it without the clipping limitations of a native view. +/// +/// `MapView` works identically on every platform including the simulator and +/// the web. By default it shows the free, keyless **OpenFreeMap** vector +/// basemap (real OpenStreetMap data) so it renders real maps with zero +/// configuration and no API key; point it at any other +/// [com.codename1.maps.vector.TileSource] (a keyed MVT endpoint, a raster +/// source such as [com.codename1.maps.vector.RasterTileSource#openStreetMap()], +/// or a bundled offline tileset) as needed. For a native-rendered map (Apple +/// MapKit, Google Maps, ...) use [NativeMap], which falls back to this +/// component when no native provider is wired in. +public class MapView extends Container implements MapSurface { + + private final VectorMapEngine engine; + private Font markerFont; + + private final List markers = new ArrayList(); + private final List polylines = new ArrayList(); + private final List polygons = new ArrayList(); + private final List circles = new ArrayList(); + + private final List tapListeners = new ArrayList(); + private final List longPressListeners = new ArrayList(); + private final List cameraListeners = new ArrayList(); + + private int lastX; + private int lastY; + private int dragDistance; + private boolean pinching; + private double pinchStartZoom; + private long lastTapTime; + private int lastTapX; + private int lastTapY; + + /// Creates a map showing the free, keyless OpenFreeMap vector basemap (real + /// OpenStreetMap data) centered on the equator at a low zoom. + public MapView() { + this(MvtTileSource.openFreeMap(), MapStyle.light()); + } + + /// Creates a map backed by `source` with the default light style. + public MapView(TileSource source) { + this(source, MapStyle.light()); + } + + /// Creates a map backed by `source` and styled by `style` (the style is + /// only consulted for vector sources). + public MapView(TileSource source, MapStyle style) { + engine = new VectorMapEngine(source, style); + engine.setCenter(new LatLng(0, 0)); + engine.setZoom(2); + engine.setRepaintCallback(new Runnable() { + @Override + public void run() { + repaint(); + } + }); + setFocusable(true); + getAllStyles().setBgTransparency(255); + } + + /// The underlying vector engine, for advanced configuration (tile cache, + /// source and style swapping). + public VectorMapEngine getEngine() { + return engine; + } + + /// Replaces the tile source. + public MapView setTileSource(TileSource source) { + engine.setSource(source); + repaint(); + return this; + } + + /// Replaces the style. + public MapView setStyle(MapStyle style) { + engine.setStyle(style); + repaint(); + return this; + } + + // ---- MapSurface: camera ---------------------------------------------- + + /// {@inheritDoc} + @Override + public CameraPosition getCameraPosition() { + return new CameraPosition(engine.getCenter(), engine.getZoom()); + } + + /// {@inheritDoc} + @Override + public void setCameraPosition(CameraPosition position) { + engine.setCenter(position.getTarget()); + engine.setZoom(position.getZoom()); + repaint(); + fireCameraChanged(); + } + + /// {@inheritDoc} + @Override + public void moveCamera(LatLng target, double zoom) { + engine.setCenter(target); + engine.setZoom(zoom); + repaint(); + fireCameraChanged(); + } + + /// {@inheritDoc} + @Override + public double getZoom() { + return engine.getZoom(); + } + + /// {@inheritDoc} + @Override + public void setZoom(double zoom) { + engine.setZoom(zoom); + repaint(); + fireCameraChanged(); + } + + /// {@inheritDoc} + @Override + public double getMinZoom() { + return engine.getMinZoom(); + } + + /// {@inheritDoc} + @Override + public double getMaxZoom() { + return engine.getMaxZoom(); + } + + /// {@inheritDoc} + @Override + public LatLng getCenter() { + return engine.getCenter(); + } + + /// {@inheritDoc} + @Override + public void setCenter(LatLng center) { + engine.setCenter(center); + repaint(); + fireCameraChanged(); + } + + /// {@inheritDoc} + @Override + public MapBounds getVisibleRegion() { + return engine.getVisibleBounds(); + } + + /// {@inheritDoc} + @Override + public void fitBounds(MapBounds bounds, int paddingPixels) { + engine.setViewport(getWidth(), getHeight()); + engine.fitBounds(bounds, paddingPixels); + repaint(); + fireCameraChanged(); + } + + // ---- MapSurface: map objects ----------------------------------------- + + /// {@inheritDoc} + @Override + public Marker addMarker(MarkerOptions options) { + Marker m = options.build(); + markers.add(m); + repaint(); + return m; + } + + /// {@inheritDoc} + @Override + public void removeMarker(Marker marker) { + markers.remove(marker); + repaint(); + } + + /// {@inheritDoc} + @Override + public Polyline addPolyline(Polyline polyline) { + polylines.add(polyline); + repaint(); + return polyline; + } + + /// {@inheritDoc} + @Override + public void removePolyline(Polyline polyline) { + polylines.remove(polyline); + repaint(); + } + + /// {@inheritDoc} + @Override + public Polygon addPolygon(Polygon polygon) { + polygons.add(polygon); + repaint(); + return polygon; + } + + /// {@inheritDoc} + @Override + public void removePolygon(Polygon polygon) { + polygons.remove(polygon); + repaint(); + } + + /// {@inheritDoc} + @Override + public Circle addCircle(Circle circle) { + circles.add(circle); + repaint(); + return circle; + } + + /// {@inheritDoc} + @Override + public void removeCircle(Circle circle) { + circles.remove(circle); + repaint(); + } + + /// {@inheritDoc} + @Override + public void clearMapObjects() { + markers.clear(); + polylines.clear(); + polygons.clear(); + circles.clear(); + repaint(); + } + + // ---- MapSurface: conversion + listeners ------------------------------ + + /// {@inheritDoc} + @Override + public Point latLngToScreen(LatLng coord) { + engine.setViewport(getWidth(), getHeight()); + return engine.latLngToScreen(coord); + } + + /// {@inheritDoc} + @Override + public LatLng screenToLatLng(int x, int y) { + engine.setViewport(getWidth(), getHeight()); + return engine.screenToLatLng(x, y); + } + + /// {@inheritDoc} + @Override + public void addTapListener(MapTapListener l) { + tapListeners.add(l); + } + + /// {@inheritDoc} + @Override + public void removeTapListener(MapTapListener l) { + tapListeners.remove(l); + } + + /// {@inheritDoc} + @Override + public void addLongPressListener(MapTapListener l) { + longPressListeners.add(l); + } + + /// {@inheritDoc} + @Override + public void removeLongPressListener(MapTapListener l) { + longPressListeners.remove(l); + } + + /// {@inheritDoc} + @Override + public void addCameraChangeListener(CameraChangeListener l) { + cameraListeners.add(l); + } + + /// {@inheritDoc} + @Override + public void removeCameraChangeListener(CameraChangeListener l) { + cameraListeners.remove(l); + } + + /// {@inheritDoc} + @Override + public boolean isNativeMap() { + return false; + } + + /// {@inheritDoc} + @Override + public Component asComponent() { + return this; + } + + // ---- Painting -------------------------------------------------------- + + @Override + protected void paintBackground(Graphics g) { + engine.setViewport(getWidth(), getHeight()); + g.translate(getX(), getY()); + engine.paint(g, 0, 0, getWidth(), getHeight()); + drawOverlays(g); + g.translate(-getX(), -getY()); + } + + private void drawOverlays(Graphics g) { + g.setAntiAliased(true); + for (Object polygonObj : polygons) { + drawPolygon(g, (Polygon) polygonObj); + } + for (Object circleObj : circles) { + drawCircle(g, (Circle) circleObj); + } + for (Object polylineObj : polylines) { + drawPolyline(g, (Polyline) polylineObj); + } + for (Object markerObj : markers) { + drawMarker(g, (Marker) markerObj); + } + } + + private void drawPolyline(Graphics g, Polyline pl) { + if (!pl.isVisible() || pl.getPoints().size() < 2) { + return; + } + GeneralPath path = buildPath(pl.getPoints(), false); + g.setColor(pl.getStrokeColor()); + g.setAlpha(pl.getStrokeAlpha()); + g.drawShape(path, new Stroke(pl.getStrokeWidth(), Stroke.CAP_ROUND, Stroke.JOIN_ROUND, 4f)); + g.setAlpha(255); + } + + private void drawPolygon(Graphics g, Polygon pg) { + if (!pg.isVisible() || pg.getPoints().size() < 3) { + return; + } + GeneralPath path = buildPath(pg.getPoints(), true); + int fill = pg.getFillColor(); + int fa = (fill >>> 24) & 0xff; + g.setColor(fill & 0xffffff); + g.setAlpha(fa == 0 ? 255 : fa); + g.fillShape(path); + if (pg.getStrokeWidth() > 0) { + g.setColor(pg.getStrokeColor()); + g.setAlpha(255); + g.drawShape(path, new Stroke(pg.getStrokeWidth(), Stroke.CAP_ROUND, Stroke.JOIN_ROUND, 4f)); + } + g.setAlpha(255); + } + + private void drawCircle(Graphics g, Circle c) { + if (!c.isVisible()) { + return; + } + Point center = engine.latLngToScreen(c.getCenter()); + LatLng north = new LatLng(c.getCenter().getLatitude() + c.getRadiusMeters() / 111320.0, + c.getCenter().getLongitude()); + Point np = engine.latLngToScreen(north); + int r = (int) Math.abs(center.getY() - np.getY()); + if (r < 1) { + r = 1; + } + int fill = c.getFillColor(); + int fa = (fill >>> 24) & 0xff; + g.setColor(fill & 0xffffff); + g.setAlpha(fa == 0 ? 255 : fa); + g.fillArc(center.getX() - r, center.getY() - r, r * 2, r * 2, 0, 360); + if (c.getStrokeWidth() > 0) { + g.setColor(c.getStrokeColor()); + g.setAlpha(255); + g.drawArc(center.getX() - r, center.getY() - r, r * 2, r * 2, 0, 360); + } + g.setAlpha(255); + } + + private void drawMarker(Graphics g, Marker m) { + if (!m.isVisible()) { + return; + } + Point p = engine.latLngToScreen(m.getPosition()); + EncodedImage icon = m.getIcon(); + if (icon != null) { + int w = icon.getWidth(); + int h = icon.getHeight(); + int dx = p.getX() - (int) (w * m.getAnchorU()); + int dy = p.getY() - (int) (h * m.getAnchorV()); + g.drawImage(icon, dx, dy); + return; + } + // Default marker: the standard Material Design map pin glyph, anchored + // at the marker's tip (anchor defaults to 0.5, 1.0 -- bottom center). + Font pin = markerFont(); + String glyph = String.valueOf(FontImage.MATERIAL_PLACE); + int gw = pin.stringWidth(glyph); + int gh = pin.getHeight(); + int gx = p.getX() - (int) (gw * m.getAnchorU()); + int gy = p.getY() - (int) (gh * m.getAnchorV()); + int prevAlpha = g.getAlpha(); + g.setFont(pin); + g.setAlpha(255); + g.setColor(0x8e1c16); + g.drawString(glyph, gx - 1, gy); + g.drawString(glyph, gx + 1, gy); + g.drawString(glyph, gx, gy - 1); + g.drawString(glyph, gx, gy + 1); + g.setColor(0xe53935); + g.drawString(glyph, gx, gy); + g.setAlpha(prevAlpha); + } + + private Font markerFont() { + if (markerFont == null) { + float size = CN.convertToPixels(7f); + if (size < 24) { + size = 24; + } + markerFont = FontImage.getMaterialDesignFont().derive(size, Font.STYLE_PLAIN); + } + return markerFont; + } + + private GeneralPath buildPath(List points, boolean close) { + GeneralPath path = new GeneralPath(); + for (int i = 0; i < points.size(); i++) { + Point sp = engine.latLngToScreen((LatLng) points.get(i)); + if (i == 0) { + path.moveTo(sp.getX(), sp.getY()); + } else { + path.lineTo(sp.getX(), sp.getY()); + } + } + if (close) { + path.closePath(); + } + return path; + } + + // ---- Gestures -------------------------------------------------------- + + /// {@inheritDoc} + @Override + public void pointerPressed(int x, int y) { + lastX = x; + lastY = y; + dragDistance = 0; + } + + /// {@inheritDoc} + @Override + public void pointerDragged(int x, int y) { + int dx = x - lastX; + int dy = y - lastY; + lastX = x; + lastY = y; + dragDistance += Math.abs(dx) + Math.abs(dy); + engine.panPixels(dx, dy); + repaint(); + } + + /// {@inheritDoc} + @Override + public void pointerReleased(int x, int y) { + if (pinching) { + pinching = false; + fireCameraChanged(); + return; + } + if (dragDistance < 10) { + int lx = x - getAbsoluteX(); + int ly = y - getAbsoluteY(); + long now = System.currentTimeMillis(); + if (now - lastTapTime < 300 && Math.abs(x - lastTapX) < 30 && Math.abs(y - lastTapY) < 30) { + lastTapTime = 0; + engine.zoomAround(engine.getZoom() + 1, lx, ly); + repaint(); + fireCameraChanged(); + } else { + lastTapTime = now; + lastTapX = x; + lastTapY = y; + handleTap(lx, ly); + } + } else { + fireCameraChanged(); + } + } + + /// {@inheritDoc} + @Override + public void longPointerPress(int x, int y) { + int lx = x - getAbsoluteX(); + int ly = y - getAbsoluteY(); + LatLng geo = engine.screenToLatLng(lx, ly); + for (Object lpListener : longPressListeners) { + ((MapTapListener) lpListener).mapTapped(this, geo, lx, ly); + } + } + + @Override + protected boolean pinch(float scale) { + if (!pinching) { + pinching = true; + pinchStartZoom = engine.getZoom(); + } + double nz = pinchStartZoom + MathUtil.log(scale) / MathUtil.log(2); + engine.zoomAround(nz, getWidth() / 2, getHeight() / 2); + repaint(); + return true; + } + + private void handleTap(int lx, int ly) { + // Hit-test markers first (top-most wins). + for (int i = markers.size() - 1; i >= 0; i--) { + Marker m = (Marker) markers.get(i); + if (!m.isVisible() || m.getOnClick() == null) { + continue; + } + Point p = engine.latLngToScreen(m.getPosition()); + int w = m.getIcon() != null ? m.getIcon().getWidth() : 16; + int h = m.getIcon() != null ? m.getIcon().getHeight() : 16; + int left = p.getX() - (int) (w * m.getAnchorU()); + int top = p.getY() - (int) (h * m.getAnchorV()); + if (lx >= left && lx <= left + w && ly >= top && ly <= top + h) { + m.getOnClick().actionPerformed(new ActionEvent(m, lx, ly)); + return; + } + } + LatLng geo = engine.screenToLatLng(lx, ly); + for (Object tapListener : tapListeners) { + ((MapTapListener) tapListener).mapTapped(this, geo, lx, ly); + } + } + + private void fireCameraChanged() { + if (cameraListeners.isEmpty()) { + return; + } + CameraPosition pos = getCameraPosition(); + for (Object camListener : cameraListeners) { + ((CameraChangeListener) camListener).cameraChanged(this, pos); + } + } + + @Override + protected com.codename1.ui.geom.Dimension calcPreferredSize() { + int w = Display.getInstance().getDisplayWidth(); + int h = Display.getInstance().getDisplayHeight(); + return new com.codename1.ui.geom.Dimension(w, h); + } +} diff --git a/CodenameOne/src/com/codename1/maps/Marker.java b/CodenameOne/src/com/codename1/maps/Marker.java new file mode 100644 index 0000000000..afe635f9e6 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/Marker.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps; + +import com.codename1.ui.EncodedImage; +import com.codename1.ui.events.ActionListener; + +/// A marker pinned to a geographic location on a map. Create one from a +/// [MarkerOptions] via [MapSurface#addMarker(MarkerOptions)]; the returned +/// instance is a live handle whose mutators ([#setPosition], [#setVisible]) +/// update the rendered marker on the next repaint. +public final class Marker extends MapObject { + + private LatLng position; + private final EncodedImage icon; + private final String title; + private final String snippet; + private final float anchorU; + private final float anchorV; + private final boolean draggable; + private boolean visible; + private final ActionListener onClick; + + Marker(MarkerOptions options) { + this.position = options.getPosition(); + this.icon = options.getIcon(); + this.title = options.getTitle(); + this.snippet = options.getSnippet(); + this.anchorU = options.getAnchorU(); + this.anchorV = options.getAnchorV(); + this.draggable = options.isDraggable(); + this.visible = true; + this.onClick = options.getOnClick(); + } + + /// The marker location. + public LatLng getPosition() { + return position; + } + + /// Moves the marker to a new location. + public void setPosition(LatLng position) { + this.position = position; + } + + /// The marker icon, or `null` to use the surface's default pin. + public EncodedImage getIcon() { + return icon; + } + + /// The marker title shown in an info window (provider dependent). + public String getTitle() { + return title; + } + + /// The secondary text shown beneath the title (provider dependent). + public String getSnippet() { + return snippet; + } + + /// The horizontal icon anchor in normalized [0,1] image space + /// (0 = left, 1 = right). Defaults to 0.5 (centered). + public float getAnchorU() { + return anchorU; + } + + /// The vertical icon anchor in normalized [0,1] image space + /// (0 = top, 1 = bottom). Defaults to 1 (pin tip at the location). + public float getAnchorV() { + return anchorV; + } + + /// Whether the user may drag this marker (native providers only). + public boolean isDraggable() { + return draggable; + } + + /// Whether the marker is currently rendered. + public boolean isVisible() { + return visible; + } + + /// Shows or hides the marker. + public void setVisible(boolean visible) { + this.visible = visible; + } + + /// The listener invoked when the marker is tapped, or `null`. + public ActionListener getOnClick() { + return onClick; + } +} diff --git a/CodenameOne/src/com/codename1/maps/MarkerOptions.java b/CodenameOne/src/com/codename1/maps/MarkerOptions.java new file mode 100644 index 0000000000..8598ee4d9b --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/MarkerOptions.java @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps; + +import com.codename1.ui.EncodedImage; +import com.codename1.ui.events.ActionListener; + +/// A fluent builder describing a [Marker] before it is added to a +/// [MapSurface]. Only the position is required; every other property has a +/// sensible default. +/// +/// ```java +/// map.addMarker(new MarkerOptions(new LatLng(37.78, -122.40)) +/// .icon(pin) +/// .title("Union Square") +/// .anchor(0.5f, 1.0f) +/// .onClick(e -> showDetails())); +/// ``` +public final class MarkerOptions { + + private LatLng position; + private EncodedImage icon; + private String title; + private String snippet; + private float anchorU = 0.5f; + private float anchorV = 1.0f; + private boolean draggable; + private ActionListener onClick; + + /// Starts a builder for a marker at `position`. + public MarkerOptions(LatLng position) { + this.position = position; + } + + /// Starts a builder with the location supplied later via [#position]. + public MarkerOptions() { + } + + /// Sets the marker location. + public MarkerOptions position(LatLng position) { + this.position = position; + return this; + } + + /// Sets the marker icon. When `null` the surface renders its default pin. + public MarkerOptions icon(EncodedImage icon) { + this.icon = icon; + return this; + } + + /// Sets the info-window title. + public MarkerOptions title(String title) { + this.title = title; + return this; + } + + /// Sets the info-window secondary text. + public MarkerOptions snippet(String snippet) { + this.snippet = snippet; + return this; + } + + /// Sets the icon anchor in normalized [0,1] image space. + public MarkerOptions anchor(float u, float v) { + this.anchorU = u; + this.anchorV = v; + return this; + } + + /// Makes the marker draggable (native providers only). + public MarkerOptions draggable(boolean draggable) { + this.draggable = draggable; + return this; + } + + /// Sets the tap listener. + public MarkerOptions onClick(ActionListener onClick) { + this.onClick = onClick; + return this; + } + + /// Builds an immutable-by-convention [Marker] from this builder. + public Marker build() { + return new Marker(this); + } + + LatLng getPosition() { + return position; + } + + EncodedImage getIcon() { + return icon; + } + + String getTitle() { + return title; + } + + String getSnippet() { + return snippet; + } + + float getAnchorU() { + return anchorU; + } + + float getAnchorV() { + return anchorV; + } + + boolean isDraggable() { + return draggable; + } + + ActionListener getOnClick() { + return onClick; + } +} diff --git a/CodenameOne/src/com/codename1/maps/NativeMap.java b/CodenameOne/src/com/codename1/maps/NativeMap.java new file mode 100644 index 0000000000..49da4d224c --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/NativeMap.java @@ -0,0 +1,604 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps; + +import com.codename1.maps.spi.MapProvider; +import com.codename1.maps.spi.MapProviderRegistry; +import com.codename1.maps.vector.MapStyle; +import com.codename1.maps.vector.TileSource; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.EncodedImage; +import com.codename1.ui.Form; +import com.codename1.ui.PeerComponent; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.geom.Point; +import com.codename1.ui.layouts.BorderLayout; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// A native-rendered map. When the build wired in a native provider (Apple +/// MapKit, Google Maps, Bing, Huawei, ... selected via the `maps.provider` +/// build hint) and it is available on the device, `NativeMap` embeds that +/// provider's native view as a [PeerComponent]. Otherwise -- on the simulator, +/// on devices without the selected provider, or when no provider was wired in +/// at all -- it transparently falls back to an embedded pure-vector +/// [MapView]. Either way it exposes the same [MapSurface] API, so application +/// code is identical. +/// +/// The public API never names a provider; which one (if any) backs a given +/// build is decided entirely by build hints through [MapProviderRegistry]. +public class NativeMap extends Container implements MapSurface { + + private static final Map INSTANCES = new HashMap(); + private static int idCounter = 1; + + private final int mapId; + private MapProvider provider; + private MapView fallback; + private boolean peerInitialized; + + private LatLng initialCenter = new LatLng(0, 0); + private double initialZoom = 2; + private TileSource fallbackSource; + private MapStyle fallbackStyle; + + private final List markers = new ArrayList(); + private final List tapListeners = new ArrayList(); + private final List longPressListeners = new ArrayList(); + private final List cameraListeners = new ArrayList(); + + /// Creates a native map centered on the equator at a low zoom. + public NativeMap() { + this(new LatLng(0, 0), 2); + } + + /// Creates a native map at the given initial camera. + public NativeMap(LatLng center, double zoom) { + this(center, zoom, null, null); + } + + /// Creates a native map at the given initial camera, specifying the tile + /// source and style used by the pure-vector [MapView] when no native + /// provider is available. Useful for an offline or branded fallback + /// basemap (and for deterministic tests). + public NativeMap(LatLng center, double zoom, TileSource fallbackSource, MapStyle fallbackStyle) { + synchronized (NativeMap.class) { + mapId = idCounter++; + } + INSTANCES.put(Integer.valueOf(mapId), this); + this.initialCenter = center; + this.initialZoom = zoom; + this.fallbackSource = fallbackSource; + this.fallbackStyle = fallbackStyle; + setLayout(new BorderLayout()); + provider = MapProviderRegistry.getProvider(); + if (provider != null && !safeAvailable(provider)) { + provider = null; + } + if (provider == null) { + // No native provider wired in (or unavailable at runtime) -> behave + // as a pure-vector MapView immediately so the API works before the + // component is ever shown. + createFallback(); + } + // When a provider IS present the native peer is created lazily in + // initComponent() -- the standard Codename One peer lifecycle. Building + // the peer here (detached from any form) and laying it out on show is + // what crashed UIKit/MapKit, so we defer it until we are attached. + } + + /// {@inheritDoc} + @Override + protected void initComponent() { + super.initComponent(); + if (peerInitialized || provider == null) { + return; + } + peerInitialized = true; + // Create the native peer once the form is fully shown, not inline here: + // building/laying it out during initComponent re-enters layout before the + // component is wired to its form, which crashed the native peer on iOS. + CN.callSerially(new Runnable() { + @Override + public void run() { + installPeer(); + } + }); + } + + private void installPeer() { + PeerComponent peer = null; + try { + peer = provider.createPeer(this, mapId); + } catch (Throwable t) { + peer = null; + } + if (peer == null) { + // The provider could not create a peer at runtime -> vector fallback. + provider = null; + createFallback(); + revalidateForm(); + return; + } + addComponent(BorderLayout.CENTER, peer); + // Lay the peer out (give it a non-zero frame) so the native view is on + // screen. The provider positions the camera at creation from the + // initial center/zoom (see getInitialCenter/getInitialZoom). + revalidateForm(); + replayMarkers(); + } + + /// The initial camera center. Package-private: build-injected providers + /// read it to position the native map when its peer is created. + LatLng getInitialCenter() { + return initialCenter; + } + + /// The initial camera zoom. Package-private (see [#getInitialCenter()]). + double getInitialZoom() { + return initialZoom; + } + + private void revalidateForm() { + Form form = getComponentForm(); + if (form != null) { + form.revalidate(); + } + } + + private void createFallback() { + if (fallbackSource != null) { + fallback = new MapView(fallbackSource, fallbackStyle == null ? MapStyle.light() : fallbackStyle); + } else { + fallback = new MapView(); + } + fallback.setCenter(initialCenter); + fallback.setZoom(initialZoom); + addComponent(BorderLayout.CENTER, fallback); + } + + /// Re-issues markers that were added before the native peer existed so they + /// appear once the peer is created on attach. + private void replayMarkers() { + for (Object markerObj : markers) { + Marker m = (Marker) markerObj; + byte[] iconData = null; + EncodedImage icon = m.getIcon(); + if (icon != null) { + iconData = icon.getImageData(); + } + long key = provider.addMarker(mapId, iconData, m.getPosition().getLatitude(), + m.getPosition().getLongitude(), m.getTitle(), m.getSnippet(), + m.getAnchorU(), m.getAnchorV()); + m.providerKey = Long.valueOf(key); + } + } + + private static boolean safeAvailable(MapProvider p) { + try { + return p.isAvailable(); + } catch (Throwable t) { + return false; + } + } + + private boolean isFallback() { + return fallback != null; + } + + // ---- MapSurface: camera ---------------------------------------------- + + /// {@inheritDoc} + @Override + public CameraPosition getCameraPosition() { + if (isFallback()) { + return fallback.getCameraPosition(); + } + return new CameraPosition(getCenter(), getZoom()); + } + + /// {@inheritDoc} + @Override + public void setCameraPosition(CameraPosition position) { + if (isFallback()) { + fallback.setCameraPosition(position); + return; + } + provider.setCamera(mapId, position.getTarget().getLatitude(), + position.getTarget().getLongitude(), (float) position.getZoom(), + (float) position.getBearing(), (float) position.getTilt()); + } + + /// {@inheritDoc} + @Override + public void moveCamera(LatLng target, double zoom) { + if (isFallback()) { + fallback.moveCamera(target, zoom); + return; + } + provider.setCamera(mapId, target.getLatitude(), target.getLongitude(), (float) zoom, 0, 0); + } + + /// {@inheritDoc} + @Override + public double getZoom() { + return isFallback() ? fallback.getZoom() : provider.getZoom(mapId); + } + + /// {@inheritDoc} + @Override + public void setZoom(double zoom) { + if (isFallback()) { + fallback.setZoom(zoom); + return; + } + provider.setCamera(mapId, provider.getLatitude(mapId), provider.getLongitude(mapId), + (float) zoom, 0, 0); + } + + /// {@inheritDoc} + @Override + public double getMinZoom() { + return isFallback() ? fallback.getMinZoom() : provider.getMinZoom(mapId); + } + + /// {@inheritDoc} + @Override + public double getMaxZoom() { + return isFallback() ? fallback.getMaxZoom() : provider.getMaxZoom(mapId); + } + + /// {@inheritDoc} + @Override + public LatLng getCenter() { + if (isFallback()) { + return fallback.getCenter(); + } + return new LatLng(provider.getLatitude(mapId), provider.getLongitude(mapId)); + } + + /// {@inheritDoc} + @Override + public void setCenter(LatLng center) { + if (isFallback()) { + fallback.setCenter(center); + return; + } + provider.setCamera(mapId, center.getLatitude(), center.getLongitude(), + provider.getZoom(mapId), 0, 0); + } + + /// {@inheritDoc} + @Override + public MapBounds getVisibleRegion() { + if (isFallback()) { + return fallback.getVisibleRegion(); + } + LatLng nw = screenToLatLng(0, 0); + LatLng se = screenToLatLng(getWidth(), getHeight()); + return new MapBounds(new LatLng(se.getLatitude(), nw.getLongitude()), + new LatLng(nw.getLatitude(), se.getLongitude())); + } + + /// {@inheritDoc} + @Override + public void fitBounds(MapBounds bounds, int paddingPixels) { + if (isFallback()) { + fallback.fitBounds(bounds, paddingPixels); + return; + } + // Native providers center on the bounds; precise fit is provider work. + provider.setCamera(mapId, bounds.getCenter().getLatitude(), + bounds.getCenter().getLongitude(), provider.getZoom(mapId), 0, 0); + } + + // ---- MapSurface: map objects ----------------------------------------- + + /// {@inheritDoc} + @Override + public Marker addMarker(MarkerOptions options) { + Marker m = options.build(); + if (isFallback()) { + return fallback.addMarker(options); + } + byte[] iconData = null; + EncodedImage icon = m.getIcon(); + if (icon != null) { + iconData = icon.getImageData(); + } + long key = provider.addMarker(mapId, iconData, m.getPosition().getLatitude(), + m.getPosition().getLongitude(), m.getTitle(), m.getSnippet(), + m.getAnchorU(), m.getAnchorV()); + m.providerKey = Long.valueOf(key); + markers.add(m); + return m; + } + + /// {@inheritDoc} + @Override + public void removeMarker(Marker marker) { + if (isFallback()) { + fallback.removeMarker(marker); + return; + } + if (marker.providerKey instanceof Long) { + provider.removeElement(mapId, ((Long) marker.providerKey).longValue()); + } + markers.remove(marker); + } + + /// {@inheritDoc} + @Override + public Polyline addPolyline(Polyline polyline) { + if (isFallback()) { + return fallback.addPolyline(polyline); + } + long pathId = provider.beginPath(mapId); + List pts = polyline.getPoints(); + for (Object ptObj : pts) { + LatLng p = (LatLng) ptObj; + provider.addToPath(mapId, pathId, p.getLatitude(), p.getLongitude()); + } + long key = provider.finishPolyline(mapId, pathId, polyline.getStrokeColor(), + polyline.getStrokeWidth()); + polyline.providerKey = Long.valueOf(key); + return polyline; + } + + /// {@inheritDoc} + @Override + public void removePolyline(Polyline polyline) { + if (isFallback()) { + fallback.removePolyline(polyline); + return; + } + removeElement(polyline.providerKey); + } + + /// {@inheritDoc} + @Override + public Polygon addPolygon(Polygon polygon) { + if (isFallback()) { + return fallback.addPolygon(polygon); + } + long pathId = provider.beginPath(mapId); + List pts = polygon.getPoints(); + for (Object ptObj : pts) { + LatLng p = (LatLng) ptObj; + provider.addToPath(mapId, pathId, p.getLatitude(), p.getLongitude()); + } + long key = provider.finishPolygon(mapId, pathId, polygon.getFillColor(), + polygon.getStrokeColor(), polygon.getStrokeWidth()); + polygon.providerKey = Long.valueOf(key); + return polygon; + } + + /// {@inheritDoc} + @Override + public void removePolygon(Polygon polygon) { + if (isFallback()) { + fallback.removePolygon(polygon); + return; + } + removeElement(polygon.providerKey); + } + + /// {@inheritDoc} + @Override + public Circle addCircle(Circle circle) { + if (isFallback()) { + return fallback.addCircle(circle); + } + long key = provider.addCircle(mapId, circle.getCenter().getLatitude(), + circle.getCenter().getLongitude(), circle.getRadiusMeters(), + circle.getFillColor(), circle.getStrokeColor(), circle.getStrokeWidth()); + circle.providerKey = Long.valueOf(key); + return circle; + } + + /// {@inheritDoc} + @Override + public void removeCircle(Circle circle) { + if (isFallback()) { + fallback.removeCircle(circle); + return; + } + removeElement(circle.providerKey); + } + + /// {@inheritDoc} + @Override + public void clearMapObjects() { + if (isFallback()) { + fallback.clearMapObjects(); + return; + } + provider.removeAllElements(mapId); + markers.clear(); + } + + private void removeElement(Object providerKey) { + if (providerKey instanceof Long) { + provider.removeElement(mapId, ((Long) providerKey).longValue()); + } + } + + // ---- MapSurface: conversion + listeners ------------------------------ + + /// {@inheritDoc} + @Override + public Point latLngToScreen(LatLng coord) { + if (isFallback()) { + return fallback.latLngToScreen(coord); + } + provider.calcScreenPosition(mapId, coord.getLatitude(), coord.getLongitude()); + return new Point(provider.getScreenX(mapId), provider.getScreenY(mapId)); + } + + /// {@inheritDoc} + @Override + public LatLng screenToLatLng(int x, int y) { + if (isFallback()) { + return fallback.screenToLatLng(x, y); + } + provider.calcLatLongPosition(mapId, x, y); + return new LatLng(provider.getScreenLat(mapId), provider.getScreenLon(mapId)); + } + + /// {@inheritDoc} + @Override + public void addTapListener(MapTapListener l) { + if (isFallback()) { + fallback.addTapListener(l); + return; + } + tapListeners.add(l); + } + + /// {@inheritDoc} + @Override + public void removeTapListener(MapTapListener l) { + if (isFallback()) { + fallback.removeTapListener(l); + return; + } + tapListeners.remove(l); + } + + /// {@inheritDoc} + @Override + public void addLongPressListener(MapTapListener l) { + if (isFallback()) { + fallback.addLongPressListener(l); + return; + } + longPressListeners.add(l); + } + + /// {@inheritDoc} + @Override + public void removeLongPressListener(MapTapListener l) { + if (isFallback()) { + fallback.removeLongPressListener(l); + return; + } + longPressListeners.remove(l); + } + + /// {@inheritDoc} + @Override + public void addCameraChangeListener(CameraChangeListener l) { + if (isFallback()) { + fallback.addCameraChangeListener(l); + return; + } + cameraListeners.add(l); + } + + /// {@inheritDoc} + @Override + public void removeCameraChangeListener(CameraChangeListener l) { + if (isFallback()) { + fallback.removeCameraChangeListener(l); + return; + } + cameraListeners.remove(l); + } + + /// {@inheritDoc} + @Override + public boolean isNativeMap() { + return !isFallback(); + } + + /// {@inheritDoc} + @Override + public Component asComponent() { + return this; + } + + // ---- Native callbacks (invoked by build-injected provider code) ------ + + /// Invoked from native code when the map is tapped. + public static void fireTap(int mapId, int x, int y) { + NativeMap map = lookup(mapId); + if (map == null) { + return; + } + LatLng geo = map.screenToLatLng(x, y); + for (int i = 0; i < map.tapListeners.size(); i++) { + ((MapTapListener) map.tapListeners.get(i)).mapTapped(map, geo, x, y); + } + } + + /// Invoked from native code when the map is long-pressed. + public static void fireLongPress(int mapId, int x, int y) { + NativeMap map = lookup(mapId); + if (map == null) { + return; + } + LatLng geo = map.screenToLatLng(x, y); + for (int i = 0; i < map.longPressListeners.size(); i++) { + ((MapTapListener) map.longPressListeners.get(i)).mapTapped(map, geo, x, y); + } + } + + /// Invoked from native code when a marker is tapped (`markerKey` is the + /// value returned by [MapProvider#addMarker]). + public static void fireMarkerClick(int mapId, long markerKey) { + NativeMap map = lookup(mapId); + if (map == null) { + return; + } + for (int i = 0; i < map.markers.size(); i++) { + Marker m = (Marker) map.markers.get(i); + if (m.providerKey instanceof Long && ((Long) m.providerKey).longValue() == markerKey) { + if (m.getOnClick() != null) { + m.getOnClick().actionPerformed(new ActionEvent(m)); + } + return; + } + } + } + + /// Invoked from native code when the camera settles after movement. + public static void fireCameraChange(int mapId) { + NativeMap map = lookup(mapId); + if (map == null || map.cameraListeners.isEmpty()) { + return; + } + CameraPosition pos = map.getCameraPosition(); + for (int i = 0; i < map.cameraListeners.size(); i++) { + ((CameraChangeListener) map.cameraListeners.get(i)).cameraChanged(map, pos); + } + } + + private static NativeMap lookup(int mapId) { + return (NativeMap) INSTANCES.get(Integer.valueOf(mapId)); + } +} diff --git a/CodenameOne/src/com/codename1/maps/Polygon.java b/CodenameOne/src/com/codename1/maps/Polygon.java new file mode 100644 index 0000000000..00f8d7fd6b --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/Polygon.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps; + +import java.util.ArrayList; +import java.util.List; + +/// A filled, optionally stroked polygon drawn on a map. The vertices +/// describe the outer ring; the ring is implicitly closed. Add one through +/// [MapSurface#addPolygon(Polygon)]. +public final class Polygon extends MapObject { + + private final List points; + private int fillColor = 0x402196f3; + private int strokeColor = 0x2196f3; + private int strokeWidth = 2; + private boolean visible = true; + + /// Creates an empty polygon; append outer-ring vertices with + /// [#addPoint(LatLng)]. + public Polygon() { + points = new ArrayList(); + } + + /// Creates a polygon with the supplied outer-ring vertices. + public Polygon(LatLng[] pts) { + points = new ArrayList(); + if (pts != null) { + for (LatLng pt : pts) { + points.add(pt); + } + } + } + + /// Appends an outer-ring vertex. + public Polygon addPoint(LatLng point) { + points.add(point); + return this; + } + + /// The live list of outer-ring vertices ([LatLng]). + public List getPoints() { + return points; + } + + /// The fill color as 0xAARRGGBB (alpha in the high byte). + public int getFillColor() { + return fillColor; + } + + /// Sets the fill color as 0xAARRGGBB (alpha in the high byte). + public Polygon setFillColor(int fillColor) { + this.fillColor = fillColor; + return this; + } + + /// The stroke color as 0xRRGGBB. + public int getStrokeColor() { + return strokeColor; + } + + /// Sets the stroke color as 0xRRGGBB. + public Polygon setStrokeColor(int strokeColor) { + this.strokeColor = strokeColor; + return this; + } + + /// The stroke width in pixels (0 hides the outline). + public int getStrokeWidth() { + return strokeWidth; + } + + /// Sets the stroke width in pixels (0 hides the outline). + public Polygon setStrokeWidth(int strokeWidth) { + this.strokeWidth = strokeWidth; + return this; + } + + /// Whether the polygon is rendered. + public boolean isVisible() { + return visible; + } + + /// Shows or hides the polygon. + public Polygon setVisible(boolean visible) { + this.visible = visible; + return this; + } +} diff --git a/CodenameOne/src/com/codename1/maps/Polyline.java b/CodenameOne/src/com/codename1/maps/Polyline.java new file mode 100644 index 0000000000..2f52610a54 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/Polyline.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps; + +import java.util.ArrayList; +import java.util.List; + +/// A connected sequence of line segments drawn on a map. Add one through +/// [MapSurface#addPolyline(Polyline)]. +public final class Polyline extends MapObject { + + private final List points; + private int strokeColor = 0x2196f3; + private int strokeWidth = 4; + private int strokeAlpha = 255; + private boolean visible = true; + + /// Creates an empty polyline; append vertices with [#addPoint(LatLng)]. + public Polyline() { + points = new ArrayList(); + } + + /// Creates a polyline through the supplied vertices (defensively copied). + public Polyline(LatLng[] pts) { + points = new ArrayList(); + if (pts != null) { + for (LatLng pt : pts) { + points.add(pt); + } + } + } + + /// Appends a vertex. + public Polyline addPoint(LatLng point) { + points.add(point); + return this; + } + + /// The live list of vertices ([LatLng]). + public List getPoints() { + return points; + } + + /// The stroke color as 0xRRGGBB. + public int getStrokeColor() { + return strokeColor; + } + + /// Sets the stroke color as 0xRRGGBB. + public Polyline setStrokeColor(int strokeColor) { + this.strokeColor = strokeColor; + return this; + } + + /// The stroke width in pixels. + public int getStrokeWidth() { + return strokeWidth; + } + + /// Sets the stroke width in pixels. + public Polyline setStrokeWidth(int strokeWidth) { + this.strokeWidth = strokeWidth; + return this; + } + + /// The stroke opacity in [0,255]. + public int getStrokeAlpha() { + return strokeAlpha; + } + + /// Sets the stroke opacity in [0,255]. + public Polyline setStrokeAlpha(int strokeAlpha) { + this.strokeAlpha = strokeAlpha; + return this; + } + + /// Whether the polyline is rendered. + public boolean isVisible() { + return visible; + } + + /// Shows or hides the polyline. + public Polyline setVisible(boolean visible) { + this.visible = visible; + return this; + } +} diff --git a/CodenameOne/src/com/codename1/maps/spi/MapProvider.java b/CodenameOne/src/com/codename1/maps/spi/MapProvider.java new file mode 100644 index 0000000000..85b429d60b --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/spi/MapProvider.java @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.spi; + +import com.codename1.maps.NativeMap; +import com.codename1.ui.PeerComponent; + +/// The service-provider interface a native map backend implements so the +/// core [NativeMap] component can drive it without knowing which provider +/// (Apple MapKit, Google Maps, Bing, Huawei, ...) is in use. +/// +/// This is a plain interface, deliberately **not** a +/// `com.codename1.system.NativeInterface`: core neither ships nor references +/// any concrete implementation. When the developer selects a provider with +/// the `maps.provider` build hint, the build pushes a provider implementation +/// (carrying the platform-native methods) into the `com.codename1.maps` +/// package of the app and weaves in a call to +/// [MapProviderRegistry#register(MapProvider)]. Absent that injection the +/// registry stays empty and `NativeMap` falls back to the pure-vector +/// `MapView`. +/// +/// Every method is keyed by a `mapId` so a single provider implementation can +/// serve multiple `NativeMap` instances. Coordinate conversion uses a stateful +/// "calculate then read" idiom ([#calcScreenPosition] then [#getScreenX] / +/// [#getScreenY]) to avoid returning multiple values across the native +/// boundary. Native code reports user interaction back through the static +/// callbacks on [NativeMap] (`fireTap`, `fireLongPress`, `fireMarkerClick`, +/// `fireCameraChange`, `fireMapReady`). +public interface MapProvider { + + /// Map type: standard street map. + int MAP_TYPE_STANDARD = 0; + /// Map type: satellite imagery. + int MAP_TYPE_SATELLITE = 1; + /// Map type: hybrid imagery with labels. + int MAP_TYPE_HYBRID = 2; + /// Map type: terrain relief. + int MAP_TYPE_TERRAIN = 3; + + /// A stable identifier for this provider, e.g. `"apple"` or `"google"`. + String getId(); + + /// Whether this provider can render on the current device right now + /// (e.g. Google checks that Play Services is present). When this returns + /// false `NativeMap` falls back to the vector engine. + boolean isAvailable(); + + /// Creates the native peer view for the map identified by `mapId` and + /// returns it wrapped as a Codename One [PeerComponent]. Returning `null` + /// triggers the vector fallback. + PeerComponent createPeer(NativeMap host, int mapId); + + /// Releases native resources for `mapId` when the map is no longer used. + void deinitialize(int mapId); + + // ---- Camera ----------------------------------------------------------- + + /// Moves the camera. `bearing` and `tilt` are in degrees; providers that + /// do not support them ignore those arguments. + void setCamera(int mapId, double lat, double lon, float zoom, float bearing, float tilt); + + double getLatitude(int mapId); + + double getLongitude(int mapId); + + float getZoom(int mapId); + + float getMaxZoom(int mapId); + + float getMinZoom(int mapId); + + // ---- Markers and shapes ---------------------------------------------- + + /// Adds a marker. `icon` is PNG bytes (or `null` for the default pin); + /// returns an opaque element key. `anchorU`/`anchorV` are normalized. + long addMarker(int mapId, byte[] icon, double lat, double lon, + String title, String snippet, float anchorU, float anchorV); + + /// Starts accumulating a path; feed it with [#addToPath]. + long beginPath(int mapId); + + void addToPath(int mapId, long pathId, double lat, double lon); + + /// Finishes the path as a stroked polyline and returns its element key. + long finishPolyline(int mapId, long pathId, int strokeColor, int strokeWidth); + + /// Finishes the path as a filled polygon and returns its element key. + long finishPolygon(int mapId, long pathId, int fillColor, int strokeColor, int strokeWidth); + + /// Adds a geodesic circle and returns its element key. + long addCircle(int mapId, double lat, double lon, double radiusMeters, + int fillColor, int strokeColor, int strokeWidth); + + void removeElement(int mapId, long elementId); + + void removeAllElements(int mapId); + + // ---- Coordinate conversion (stateful) -------------------------------- + + void calcScreenPosition(int mapId, double lat, double lon); + + int getScreenX(int mapId); + + int getScreenY(int mapId); + + void calcLatLongPosition(int mapId, int x, int y); + + double getScreenLat(int mapId); + + double getScreenLon(int mapId); + + // ---- Gestures and features ------------------------------------------- + + void setShowMyLocation(int mapId, boolean show); + + void setRotateGestureEnabled(int mapId, boolean enabled); + + void setMapType(int mapId, int type); +} diff --git a/CodenameOne/src/com/codename1/maps/spi/MapProviderRegistry.java b/CodenameOne/src/com/codename1/maps/spi/MapProviderRegistry.java new file mode 100644 index 0000000000..fee2c71ac7 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/spi/MapProviderRegistry.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.spi; + +import java.util.ArrayList; +import java.util.List; + +/// The registry through which build-injected [MapProvider] implementations +/// advertise themselves to the core [com.codename1.maps.NativeMap] component. +/// +/// Core never registers a provider itself. The build, when a `maps.provider` +/// hint selects one, injects the provider implementation into the app and +/// weaves a `register(...)` call into the generated startup code (the same way +/// optional features such as push messaging are wired in). With no provider +/// injected the registry is empty and `NativeMap` renders the vector fallback. +public final class MapProviderRegistry { + + private static final List PROVIDERS = new ArrayList(); + private static String preferredId; + + private MapProviderRegistry() { + } + + /// Registers a provider. Called by build-injected startup code. Repeated + /// registration of the same provider id replaces the earlier instance. + public static synchronized void register(MapProvider provider) { + if (provider == null) { + return; + } + for (int i = 0; i < PROVIDERS.size(); i++) { + MapProvider existing = (MapProvider) PROVIDERS.get(i); + if (existing.getId() != null && existing.getId().equals(provider.getId())) { + PROVIDERS.set(i, provider); + return; + } + } + PROVIDERS.add(provider); + } + + /// Hints which provider id to prefer when several are registered. May be + /// set from a display/build property; ignored if that provider is absent. + public static synchronized void setPreferredProvider(String id) { + preferredId = id; + } + + /// Returns the provider that should back a new native map: the preferred + /// one if registered and available, otherwise the first available + /// provider, or `null` when none can render right now. + public static synchronized MapProvider getProvider() { + if (preferredId != null) { + for (Object prov : PROVIDERS) { + MapProvider p = (MapProvider) prov; + if (preferredId.equals(p.getId()) && safeAvailable(p)) { + return p; + } + } + } + for (Object prov : PROVIDERS) { + MapProvider p = (MapProvider) prov; + if (safeAvailable(p)) { + return p; + } + } + return null; + } + + /// Whether any registered provider can render on this device right now. + public static synchronized boolean hasProvider() { + return getProvider() != null; + } + + private static boolean safeAvailable(MapProvider p) { + try { + return p.isAvailable(); + } catch (Throwable t) { + // A provider whose native side failed to initialize (e.g. missing + // Play Services) must not break map creation -- treat as absent. + return false; + } + } +} diff --git a/CodenameOne/src/com/codename1/maps/spi/package-info.java b/CodenameOne/src/com/codename1/maps/spi/package-info.java new file mode 100644 index 0000000000..384a898006 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/spi/package-info.java @@ -0,0 +1,12 @@ +/// The maps service-provider interface (SPI). +/// +/// This package defines the contract that native map providers (Apple MapKit, +/// Google Maps, Bing, Huawei, ...) implement so that +/// [com.codename1.maps.NativeMap] can drive a native map peer without the core +/// framework depending on any provider SDK. Provider implementations are not +/// part of the core: they are injected into the app's +/// `com.codename1.maps` package by the build tooling when a `maps.provider` +/// build hint selects one, and they register themselves with +/// [com.codename1.maps.spi.MapProviderRegistry]. When no provider is present +/// `NativeMap` falls back to the pure-vector [com.codename1.maps.MapView]. +package com.codename1.maps.spi; diff --git a/CodenameOne/src/com/codename1/maps/vector/BundledTileSource.java b/CodenameOne/src/com/codename1/maps/vector/BundledTileSource.java new file mode 100644 index 0000000000..9baa60828f --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/vector/BundledTileSource.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.vector; + +import com.codename1.io.Util; +import com.codename1.ui.CN; +import com.codename1.ui.Display; + +import java.io.InputStream; + +/// A [TileSource] that loads tiles from application resources bundled into the +/// app (the classpath), with no network access. It powers offline maps and, +/// crucially, the deterministic map screenshot tests: a small fixture tileset +/// is shipped as a resource and rendered identically on every run. +/// +/// The resource path is a template containing the literal tokens `{z}`, `{x}` +/// and `{y}` (for example `/maptiles/{z}/{x}/{y}.mvt`). +public final class BundledTileSource implements TileSource { + + private final String pathTemplate; + private final boolean vector; + private final int minZoom; + private final int maxZoom; + private String attribution = ""; + + /// Creates a bundled source. + /// + /// #### Parameters + /// + /// - `pathTemplate`: a resource path containing `{z}`/`{x}`/`{y}` tokens + /// + /// - `vector`: true for MVT tiles, false for raster image tiles + /// + /// - `minZoom`: the smallest available zoom + /// + /// - `maxZoom`: the largest available zoom + public BundledTileSource(String pathTemplate, boolean vector, int minZoom, int maxZoom) { + this.pathTemplate = pathTemplate; + this.vector = vector; + this.minZoom = minZoom; + this.maxZoom = maxZoom; + } + + /// Sets the attribution string shown over the map. + public BundledTileSource setAttribution(String attribution) { + this.attribution = attribution; + return this; + } + + /// {@inheritDoc} + @Override + public boolean isVector() { + return vector; + } + + /// {@inheritDoc} + @Override + public int getTileSize() { + return WebMercator.TILE_SIZE; + } + + /// {@inheritDoc} + @Override + public int getMinZoom() { + return minZoom; + } + + /// {@inheritDoc} + @Override + public int getMaxZoom() { + return maxZoom; + } + + /// {@inheritDoc} + @Override + public String getAttribution() { + return attribution; + } + + /// {@inheritDoc} + @Override + public void fetchTile(final int z, final int x, final int y, final TileCallback callback) { + final String path = resolve(z, x, y); + CN.callSerially(new Runnable() { + @Override + public void run() { + byte[] data = null; + try { + InputStream is = Display.getInstance().getResourceAsStream( + BundledTileSource.this.getClass(), path); + if (is != null) { + try { + data = TileUtil.maybeGunzip(Util.readInputStream(is)); + } finally { + is.close(); + } + } + } catch (Throwable t) { + data = null; + } + if (data != null) { + callback.tileLoaded(z, x, y, data); + } else { + callback.tileFailed(z, x, y); + } + } + }); + } + + private String resolve(int z, int x, int y) { + String s = pathTemplate; + s = replace(s, "{z}", Integer.toString(z)); + s = replace(s, "{x}", Integer.toString(x)); + s = replace(s, "{y}", Integer.toString(y)); + return s; + } + + private static String replace(String src, String token, String value) { + int idx = src.indexOf(token); + if (idx < 0) { + return src; + } + return src.substring(0, idx) + value + src.substring(idx + token.length()); + } +} diff --git a/CodenameOne/src/com/codename1/maps/vector/ColorParser.java b/CodenameOne/src/com/codename1/maps/vector/ColorParser.java new file mode 100644 index 0000000000..3730834961 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/vector/ColorParser.java @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.vector; + +/// Parses the CSS color forms found in MapLibre style sheets into a packed +/// `0xAARRGGBB` integer: `#rgb`, `#rrggbb`, `#rrggbbaa`, `rgb(r,g,b)` and +/// `rgba(r,g,b,a)`. Unsupported forms (named colors, `hsl(...)`) return the +/// supplied default. +final class ColorParser { + + private ColorParser() { + } + + static int parse(String value, int def) { + if (value == null) { + return def; + } + String s = value.trim(); + try { + if (s.length() > 0 && s.charAt(0) == '#') { + return parseHex(s.substring(1), def); + } + if (s.startsWith("rgba(") || s.startsWith("rgb(")) { + return parseRgb(s, def); + } + } catch (Throwable t) { + return def; + } + return def; + } + + private static int parseHex(String hex, int def) { + int r; + int g; + int b; + int a = 255; + if (hex.length() == 3) { + r = hexPair("" + hex.charAt(0) + hex.charAt(0)); + g = hexPair("" + hex.charAt(1) + hex.charAt(1)); + b = hexPair("" + hex.charAt(2) + hex.charAt(2)); + } else if (hex.length() == 6) { + r = hexPair(hex.substring(0, 2)); + g = hexPair(hex.substring(2, 4)); + b = hexPair(hex.substring(4, 6)); + } else if (hex.length() == 8) { + r = hexPair(hex.substring(0, 2)); + g = hexPair(hex.substring(2, 4)); + b = hexPair(hex.substring(4, 6)); + a = hexPair(hex.substring(6, 8)); + } else { + return def; + } + return (a << 24) | (r << 16) | (g << 8) | b; + } + + private static int parseRgb(String s, int def) { + int open = s.indexOf('('); + int close = s.indexOf(')'); + if (open < 0 || close < 0 || close < open) { + return def; + } + String inner = s.substring(open + 1, close); + String[] parts = split(inner, ','); + if (parts.length < 3) { + return def; + } + int r = clamp(parseIntSafe(parts[0])); + int g = clamp(parseIntSafe(parts[1])); + int b = clamp(parseIntSafe(parts[2])); + int a = 255; + if (parts.length >= 4) { + double af = parseDoubleSafe(parts[3]); + a = clamp((int) (af * 255 + 0.5)); + } + return (a << 24) | (r << 16) | (g << 8) | b; + } + + private static int hexPair(String pair) { + return Integer.parseInt(pair, 16); + } + + private static int parseIntSafe(String s) { + return (int) parseDoubleSafe(s); + } + + private static double parseDoubleSafe(String s) { + try { + return Double.parseDouble(s.trim()); + } catch (NumberFormatException nfe) { + return 0; + } + } + + private static int clamp(int v) { + if (v < 0) { + return 0; + } + if (v > 255) { + return 255; + } + return v; + } + + private static String[] split(String s, char sep) { + java.util.List parts = new java.util.ArrayList(); + int start = 0; + for (int i = 0; i < s.length(); i++) { + if (s.charAt(i) == sep) { + parts.add(s.substring(start, i)); + start = i + 1; + } + } + parts.add(s.substring(start)); + String[] out = new String[parts.size()]; + for (int i = 0; i < out.length; i++) { + out[i] = (String) parts.get(i); + } + return out; + } +} diff --git a/CodenameOne/src/com/codename1/maps/vector/DemoTileSource.java b/CodenameOne/src/com/codename1/maps/vector/DemoTileSource.java new file mode 100644 index 0000000000..a5e6d4ccb9 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/vector/DemoTileSource.java @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.vector; + +import com.codename1.io.grpc.ProtoWriter; +import com.codename1.ui.CN; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// A self-contained [TileSource] that synthesizes a deterministic Mapbox +/// Vector Tile in memory for every address, with no network or bundled +/// assets. Every tile carries the same recognizable content -- a water body, +/// a landuse area, two roads, a building and a labeled place -- which makes it +/// ideal for offline demos and for reproducible map screenshot tests. +public final class DemoTileSource implements TileSource { + + private static final int EXTENT = 4096; + private byte[] cachedTile; + + /// {@inheritDoc} + @Override + public boolean isVector() { + return true; + } + + /// {@inheritDoc} + @Override + public int getTileSize() { + return WebMercator.TILE_SIZE; + } + + /// {@inheritDoc} + @Override + public int getMinZoom() { + return 0; + } + + /// {@inheritDoc} + @Override + public int getMaxZoom() { + return 18; + } + + /// {@inheritDoc} + @Override + public String getAttribution() { + return "Codename One demo tiles"; + } + + /// {@inheritDoc} + @Override + public void fetchTile(final int z, final int x, final int y, final TileCallback callback) { + CN.callSerially(new Runnable() { + @Override + public void run() { + try { + callback.tileLoaded(z, x, y, tileBytes()); + } catch (Throwable t) { + callback.tileFailed(z, x, y); + } + } + }); + } + + private synchronized byte[] tileBytes() throws IOException { + if (cachedTile == null) { + cachedTile = buildTile(); + } + return cachedTile; + } + + /// Builds the synthetic MVT payload. Public and static so unit tests and + /// the demo can reuse exactly the same bytes. + public static byte[] buildTile() throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ProtoWriter tile = new ProtoWriter(out); + + // water: left third of the tile. + tile.writeBytes(3, polygonLayer("water", + new int[]{0, 1500, 1500, 0}, new int[]{0, 0, EXTENT, EXTENT})); + // landuse: top-right region. + tile.writeBytes(3, polygonLayer("landuse", + new int[]{1500, EXTENT, EXTENT, 1500}, new int[]{0, 0, 2200, 2200})); + // road: two crossing lines. + ByteArrayOutputStream roads = new ByteArrayOutputStream(); + ProtoWriter rl = new ProtoWriter(roads); + rl.writeString(1, "road"); + rl.writeBytes(2, lineFeature(new int[]{0, EXTENT}, new int[]{EXTENT, 0})); + rl.writeBytes(2, lineFeature(new int[]{0, EXTENT}, new int[]{2048, 2048})); + rl.writeInt32(5, EXTENT); + tile.writeBytes(3, roads.toByteArray()); + // building: small square. + tile.writeBytes(3, polygonLayer("building", + new int[]{2750, 3200, 3200, 2750}, new int[]{2650, 2650, 3050, 3050})); + // place: a labeled point in the center. + tile.writeBytes(3, placeLayer("place", "CN1 City", 2048, 2048)); + + return out.toByteArray(); + } + + private static byte[] polygonLayer(String name, int[] xs, int[] ys) throws IOException { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + ProtoWriter layer = new ProtoWriter(buf); + layer.writeString(1, name); + layer.writeBytes(2, polygonFeature(xs, ys)); + layer.writeInt32(5, EXTENT); + return buf.toByteArray(); + } + + private static byte[] placeLayer(String name, String label, int x, int y) throws IOException { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + ProtoWriter layer = new ProtoWriter(buf); + layer.writeString(1, name); + // feature with one tag: name -> label. + ByteArrayOutputStream fb = new ByteArrayOutputStream(); + ProtoWriter f = new ProtoWriter(fb); + List tags = new ArrayList(); + tags.add(Integer.valueOf(0)); + tags.add(Integer.valueOf(0)); + f.writePackedInt32(2, tags); + f.writeInt32(3, VectorFeature.GEOM_POINT); + f.writePackedInt32(4, pointGeometry(x, y)); + layer.writeBytes(2, fb.toByteArray()); + // keys[0] = "name" + layer.writeString(3, "name"); + // values[0] = string label + ByteArrayOutputStream vb = new ByteArrayOutputStream(); + ProtoWriter v = new ProtoWriter(vb); + v.writeString(1, label); + layer.writeBytes(4, vb.toByteArray()); + layer.writeInt32(5, EXTENT); + return buf.toByteArray(); + } + + private static byte[] polygonFeature(int[] xs, int[] ys) throws IOException { + ByteArrayOutputStream fb = new ByteArrayOutputStream(); + ProtoWriter f = new ProtoWriter(fb); + f.writeInt32(3, VectorFeature.GEOM_POLYGON); + f.writePackedInt32(4, ringGeometry(xs, ys, true)); + return fb.toByteArray(); + } + + private static byte[] lineFeature(int[] xs, int[] ys) throws IOException { + ByteArrayOutputStream fb = new ByteArrayOutputStream(); + ProtoWriter f = new ProtoWriter(fb); + f.writeInt32(3, VectorFeature.GEOM_LINESTRING); + f.writePackedInt32(4, ringGeometry(xs, ys, false)); + return fb.toByteArray(); + } + + private static List ringGeometry(int[] xs, int[] ys, boolean close) { + List g = new ArrayList(); + int cx = 0; + int cy = 0; + // MoveTo first point. + g.add(Integer.valueOf(command(1, 1))); + g.add(Integer.valueOf(ProtoWriter.zigZag32(xs[0] - cx))); + g.add(Integer.valueOf(ProtoWriter.zigZag32(ys[0] - cy))); + cx = xs[0]; + cy = ys[0]; + // LineTo remaining points. + int n = xs.length - 1; + if (n > 0) { + g.add(Integer.valueOf(command(2, n))); + for (int i = 1; i < xs.length; i++) { + g.add(Integer.valueOf(ProtoWriter.zigZag32(xs[i] - cx))); + g.add(Integer.valueOf(ProtoWriter.zigZag32(ys[i] - cy))); + cx = xs[i]; + cy = ys[i]; + } + } + if (close) { + g.add(Integer.valueOf(command(7, 1))); + } + return g; + } + + private static List pointGeometry(int x, int y) { + List g = new ArrayList(); + g.add(Integer.valueOf(command(1, 1))); + g.add(Integer.valueOf(ProtoWriter.zigZag32(x))); + g.add(Integer.valueOf(ProtoWriter.zigZag32(y))); + return g; + } + + private static int command(int id, int count) { + return (id & 0x7) | (count << 3); + } +} diff --git a/CodenameOne/src/com/codename1/maps/vector/HttpTileSource.java b/CodenameOne/src/com/codename1/maps/vector/HttpTileSource.java new file mode 100644 index 0000000000..04f501cb72 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/vector/HttpTileSource.java @@ -0,0 +1,297 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.vector; + +import com.codename1.io.CharArrayReader; +import com.codename1.io.ConnectionRequest; +import com.codename1.io.JSONParser; +import com.codename1.io.NetworkManager; +import com.codename1.io.Util; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/// A [TileSource] that fetches tiles over HTTPS from a slippy-map URL +/// template. The template contains `{z}`/`{x}`/`{y}` tokens and, optionally, +/// a `{key}` token substituted with the configured API key. Downloads run on +/// the Codename One network thread and deliver results on the EDT, with +/// transparent gunzip for vector payloads. +/// +/// When the URL has no `{z}` token it is treated as a *TileJSON* endpoint: on +/// first use the source fetches that document, reads its `tiles` template and +/// then serves tiles from it. This is how the keyless OpenFreeMap basemap +/// (whose tile URLs are versioned) is supported -- see +/// [MvtTileSource#openFreeMap()]. +/// +/// This is the shared base for [MvtTileSource] (vector) and +/// [RasterTileSource] (raster). +public class HttpTileSource implements TileSource { + + private final String urlTemplate; + private final boolean vector; + private final int minZoom; + private final int maxZoom; + private String apiKey = ""; + private String attribution = ""; + + // TileJSON resolution: when urlTemplate carries no {z} token it is a + // TileJSON document URL whose `tiles` template we resolve once, queueing + // any tile requests that arrive while resolution is in flight. + private String resolvedTemplate; + private boolean resolving; + private final List pendingRequests = new ArrayList(); + + /// Creates an HTTP tile source. + /// + /// #### Parameters + /// + /// - `urlTemplate`: a URL with `{z}`/`{x}`/`{y}` (and optional `{key}`) tokens + /// + /// - `vector`: true for MVT tiles, false for raster image tiles + /// + /// - `minZoom`: the smallest available zoom + /// + /// - `maxZoom`: the largest available zoom + public HttpTileSource(String urlTemplate, boolean vector, int minZoom, int maxZoom) { + this.urlTemplate = urlTemplate; + this.vector = vector; + this.minZoom = minZoom; + this.maxZoom = maxZoom; + } + + /// Sets the API key substituted into the `{key}` token of the template. + public HttpTileSource setApiKey(String apiKey) { + this.apiKey = apiKey == null ? "" : apiKey; + return this; + } + + /// Sets the attribution string shown over the map. + public HttpTileSource setAttribution(String attribution) { + this.attribution = attribution; + return this; + } + + /// {@inheritDoc} + @Override + public boolean isVector() { + return vector; + } + + /// {@inheritDoc} + @Override + public int getTileSize() { + return WebMercator.TILE_SIZE; + } + + /// {@inheritDoc} + @Override + public int getMinZoom() { + return minZoom; + } + + /// {@inheritDoc} + @Override + public int getMaxZoom() { + return maxZoom; + } + + /// {@inheritDoc} + @Override + public String getAttribution() { + return attribution; + } + + /// {@inheritDoc} + @Override + public void fetchTile(int z, int x, int y, TileCallback callback) { + if (needsTileJson()) { + synchronized (this) { + if (resolvedTemplate == null) { + pendingRequests.add(new Object[]{Integer.valueOf(z), Integer.valueOf(x), + Integer.valueOf(y), callback}); + if (!resolving) { + resolving = true; + resolveTileJson(); + } + return; + } + } + } + doFetch(z, x, y, callback); + } + + private boolean needsTileJson() { + return urlTemplate.indexOf("{z}") < 0; + } + + private void doFetch(int z, int x, int y, TileCallback callback) { + TileRequest req = new TileRequest(z, x, y, callback); + req.setUrl(resolve(z, x, y)); + req.setPost(false); + req.setFailSilently(true); + NetworkManager.getInstance().addToQueue(req); + } + + private String resolve(int z, int x, int y) { + String resolved; + synchronized (this) { + resolved = resolvedTemplate; + } + String s = resolved != null ? resolved : urlTemplate; + s = replace(s, "{z}", Integer.toString(z)); + s = replace(s, "{x}", Integer.toString(x)); + s = replace(s, "{y}", Integer.toString(y)); + s = replace(s, "{key}", apiKey); + return s; + } + + private void resolveTileJson() { + ConnectionRequest req = new ConnectionRequest() { + private byte[] body; + + @Override + protected void readResponse(InputStream input) throws IOException { + body = Util.readInputStream(input); + } + + @Override + protected void postResponse() { + String tiles = body == null ? null : parseTileJsonTemplate(body); + List drain; + synchronized (HttpTileSource.this) { + resolvedTemplate = tiles; + resolving = false; + drain = new ArrayList(pendingRequests); + pendingRequests.clear(); + } + for (Object drainItem : drain) { + Object[] r = (Object[]) drainItem; + TileCallback cb = (TileCallback) r[3]; + if (tiles == null) { + cb.tileFailed(((Integer) r[0]).intValue(), + ((Integer) r[1]).intValue(), ((Integer) r[2]).intValue()); + } else { + doFetch(((Integer) r[0]).intValue(), ((Integer) r[1]).intValue(), + ((Integer) r[2]).intValue(), cb); + } + } + } + + @Override + protected void handleException(Exception err) { + failAllPending(); + } + + @Override + protected void handleErrorResponseCode(int code, String message) { + failAllPending(); + } + }; + req.setUrl(replace(urlTemplate, "{key}", apiKey)); + req.setPost(false); + req.setFailSilently(true); + NetworkManager.getInstance().addToQueue(req); + } + + private void failAllPending() { + List drain; + synchronized (this) { + resolving = false; + drain = new ArrayList(pendingRequests); + pendingRequests.clear(); + } + for (Object drainItem : drain) { + Object[] r = (Object[]) drainItem; + ((TileCallback) r[3]).tileFailed(((Integer) r[0]).intValue(), + ((Integer) r[1]).intValue(), ((Integer) r[2]).intValue()); + } + } + + private static String parseTileJsonTemplate(byte[] json) { + try { + Map root = new JSONParser().parseJSON(new CharArrayReader(new String(json, "UTF-8").toCharArray())); + Object tiles = root.get("tiles"); + if (tiles instanceof List && !((List) tiles).isEmpty()) { + return String.valueOf(((List) tiles).get(0)); + } + } catch (Throwable t) { + // Malformed TileJSON -> treat as unresolved. + return null; + } + return null; + } + + private static String replace(String src, String token, String value) { + int idx; + while ((idx = src.indexOf(token)) >= 0) { + src = src.substring(0, idx) + value + src.substring(idx + token.length()); + } + return src; + } + + private final class TileRequest extends ConnectionRequest { + private final int z; + private final int x; + private final int y; + private final TileCallback callback; + private byte[] result; + + TileRequest(int z, int x, int y, TileCallback callback) { + this.z = z; + this.x = x; + this.y = y; + this.callback = callback; + } + + @Override + protected void readResponse(InputStream input) throws IOException { + result = Util.readInputStream(input); + } + + @Override + protected void postResponse() { + if (result == null || result.length == 0) { + callback.tileFailed(z, x, y); + return; + } + try { + callback.tileLoaded(z, x, y, vector ? TileUtil.maybeGunzip(result) : result); + } catch (Throwable t) { + callback.tileFailed(z, x, y); + } + } + + @Override + protected void handleException(Exception err) { + callback.tileFailed(z, x, y); + } + + @Override + protected void handleErrorResponseCode(int code, String message) { + callback.tileFailed(z, x, y); + } + } +} diff --git a/CodenameOne/src/com/codename1/maps/vector/IntArray.java b/CodenameOne/src/com/codename1/maps/vector/IntArray.java new file mode 100644 index 0000000000..9a5b8948ae --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/vector/IntArray.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.vector; + +/// A minimal growable `int` buffer used while decoding vector-tile geometry. +/// +/// Tile geometry is a long run of packed integers; collecting it into a +/// `java.util.List` would box every value. This primitive buffer +/// avoids that overhead, which matters when a single tile holds tens of +/// thousands of coordinates. +final class IntArray { + + private int[] data; + private int size; + + IntArray() { + this(16); + } + + IntArray(int initialCapacity) { + data = new int[initialCapacity < 4 ? 4 : initialCapacity]; + } + + void add(int value) { + if (size == data.length) { + int[] grown = new int[data.length * 2]; + System.arraycopy(data, 0, grown, 0, size); + data = grown; + } + data[size++] = value; + } + + int get(int index) { + return data[index]; + } + + int size() { + return size; + } + + void clear() { + size = 0; + } + + /// A trimmed copy holding exactly [#size] elements. + int[] toArray() { + int[] out = new int[size]; + System.arraycopy(data, 0, out, 0, size); + return out; + } +} diff --git a/CodenameOne/src/com/codename1/maps/vector/LabelCandidate.java b/CodenameOne/src/com/codename1/maps/vector/LabelCandidate.java new file mode 100644 index 0000000000..e3d30088c3 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/vector/LabelCandidate.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.vector; + +/// A single label to draw, captured at tile-decode time. Its anchor is stored +/// in integer-zoom world pixels (256px tiles) so the engine can convert it to +/// the screen at any fractional camera zoom without re-walking the tile. +final class LabelCandidate { + + final String text; + final double worldX; + final double worldY; + final int tileZoom; + final int textColor; + final int haloColor; + final double sizePx; + + LabelCandidate(String text, double worldX, double worldY, int tileZoom, + int textColor, int haloColor, double sizePx) { + this.text = text; + this.worldX = worldX; + this.worldY = worldY; + this.tileZoom = tileZoom; + this.textColor = textColor; + this.haloColor = haloColor; + this.sizePx = sizePx; + } +} diff --git a/CodenameOne/src/com/codename1/maps/vector/LabelEngine.java b/CodenameOne/src/com/codename1/maps/vector/LabelEngine.java new file mode 100644 index 0000000000..097668adb2 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/vector/LabelEngine.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.vector; + +import com.codename1.ui.Font; +import com.codename1.ui.Graphics; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Places and draws text labels with greedy collision avoidance: a label is +/// only drawn when its bounding box does not overlap one already placed this +/// frame. Each label is rendered with a one-pixel halo for legibility over +/// busy map content. This is a lightweight stand-in for a full label engine, +/// adequate for place names on a basemap. +final class LabelEngine { + + private final List occupied = new ArrayList(); + private final Map fontCache = new HashMap(); + + /// Clears placements at the start of a frame. + void reset() { + occupied.clear(); + } + + /// Attempts to draw `text` centered at `cx,cy`. Returns false (drawing + /// nothing) when it would collide with an already placed label. + boolean place(Graphics g, String text, double sizePx, int textColor, int haloColor, int cx, int cy) { + if (text == null || text.length() == 0) { + return false; + } + Font font = fontFor(sizePx); + int w = font.stringWidth(text); + int h = font.getHeight(); + int x = cx - w / 2; + int y = cy - h / 2; + int bx = x - 2; + int by = y - 2; + int bw = w + 4; + int bh = h + 4; + for (Object occItem : occupied) { + int[] o = (int[]) occItem; + if (intersects(bx, by, bw, bh, o[0], o[1], o[2], o[3])) { + return false; + } + } + occupied.add(new int[]{bx, by, bw, bh}); + draw(g, text, font, textColor, haloColor, x, y); + return true; + } + + private void draw(Graphics g, String text, Font font, int textColor, int haloColor, int x, int y) { + g.setFont(font); + int prevAlpha = g.getAlpha(); + int haloA = (haloColor >>> 24) & 0xff; + if (haloA > 0) { + g.setAlpha(haloA); + g.setColor(haloColor & 0xffffff); + g.drawString(text, x - 1, y); + g.drawString(text, x + 1, y); + g.drawString(text, x, y - 1); + g.drawString(text, x, y + 1); + } + int textA = (textColor >>> 24) & 0xff; + if (textA == 0) { + textA = 255; + } + g.setAlpha(textA); + g.setColor(textColor & 0xffffff); + g.drawString(text, x, y); + g.setAlpha(prevAlpha); + } + + private Font fontFor(double sizePx) { + int bucket; + int sizeConst; + if (sizePx <= 12) { + bucket = 0; + sizeConst = Font.SIZE_SMALL; + } else if (sizePx <= 17) { + bucket = 1; + sizeConst = Font.SIZE_MEDIUM; + } else { + bucket = 2; + sizeConst = Font.SIZE_LARGE; + } + Integer k = Integer.valueOf(bucket); + Font f = (Font) fontCache.get(k); + if (f == null) { + f = Font.createSystemFont(Font.FACE_PROPORTIONAL, Font.STYLE_PLAIN, sizeConst); + fontCache.put(k, f); + } + return f; + } + + private static boolean intersects(int ax, int ay, int aw, int ah, int bx, int by, int bw, int bh) { + return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by; + } +} diff --git a/CodenameOne/src/com/codename1/maps/vector/MapStyle.java b/CodenameOne/src/com/codename1/maps/vector/MapStyle.java new file mode 100644 index 0000000000..891542283f --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/vector/MapStyle.java @@ -0,0 +1,246 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.vector; + +import com.codename1.io.CharArrayReader; +import com.codename1.io.JSONParser; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/// An ordered list of [StyleLayer] rules plus a background color, describing +/// how a vector tile is painted. The built-in [#light] and [#dark] styles +/// target the common OpenMapTiles/Protomaps source-layer names and also the +/// simplified names used by the bundled screenshot fixtures, so they render +/// real basemaps and the offline fixtures alike. +public final class MapStyle { + + private final String name; + private int backgroundColor; + private final List layers = new ArrayList(); + + /// Creates an empty style with the given background color (0xAARRGGBB). + public MapStyle(String name, int backgroundColor) { + this.name = name; + this.backgroundColor = backgroundColor; + } + + /// The style name. + public String getName() { + return name; + } + + /// The viewport background color as 0xAARRGGBB. + public int getBackgroundColor() { + return backgroundColor; + } + + /// Appends a layer rule (rendered in insertion order, bottom to top). + public MapStyle add(StyleLayer layer) { + layers.add(layer); + return this; + } + + List getLayers() { + return layers; + } + + // ---- Built-in styles -------------------------------------------------- + + /// A clean light basemap (sensible default for most apps). + public static MapStyle light() { + MapStyle s = new MapStyle("light", 0xfff2efe9); + addPolygonRule(s, "water", 0xffa0c8f0); + addPolygonRule(s, "ocean", 0xffa0c8f0); + addPolygonRule(s, "landcover", 0xffd8e8c8); + addPolygonRule(s, "landuse", 0xffe8f0d8); + addPolygonRule(s, "park", 0xffc8e0b0); + addLineRule(s, "waterway", 0xffa0c8f0, 6, 1.0, 16, 4.0); + addLineRule(s, "road", 0xffffffff, 6, 1.0, 18, 8.0); + addLineRule(s, "transportation", 0xffffffff, 6, 1.0, 18, 8.0).excludeFilter("class", "ferry"); + addPolygonRule(s, "building", 0xffd9d0c9); + addPolygonRule(s, "buildings", 0xffd9d0c9); + addSymbolRule(s, "place", "name", 0xff333333, 0xffffffff); + addSymbolRule(s, "place_label", "name", 0xff333333, 0xffffffff); + return s; + } + + /// A dark basemap suited to night mode. + public static MapStyle dark() { + MapStyle s = new MapStyle("dark", 0xff121417); + addPolygonRule(s, "water", 0xff1b2733); + addPolygonRule(s, "ocean", 0xff1b2733); + addPolygonRule(s, "landcover", 0xff1a1d20); + addPolygonRule(s, "landuse", 0xff1d2024); + addPolygonRule(s, "park", 0xff17251a); + addLineRule(s, "waterway", 0xff1b2733, 6, 1.0, 16, 4.0); + addLineRule(s, "road", 0xff3a4048, 6, 1.0, 18, 8.0); + addLineRule(s, "transportation", 0xff3a4048, 6, 1.0, 18, 8.0).excludeFilter("class", "ferry"); + addPolygonRule(s, "building", 0xff20242a); + addPolygonRule(s, "buildings", 0xff20242a); + addSymbolRule(s, "place", "name", 0xffe8e8e8, 0xff000000); + addSymbolRule(s, "place_label", "name", 0xffe8e8e8, 0xff000000); + return s; + } + + private static void addPolygonRule(MapStyle s, String sourceLayer, int color) { + s.add(new StyleLayer(StyleLayer.TYPE_FILL).sourceLayer(sourceLayer).fillColor(color)); + } + + private static StyleLayer addLineRule(MapStyle s, String sourceLayer, int color, + double z0, double w0, double z1, double w1) { + StyleLayer sl = new StyleLayer(StyleLayer.TYPE_LINE).sourceLayer(sourceLayer).lineColor(color) + .lineWidth(ZoomValue.stops(new double[]{z0, z1}, new double[]{w0, w1})); + s.add(sl); + return sl; + } + + private static void addSymbolRule(MapStyle s, String sourceLayer, String field, + int textColor, int haloColor) { + s.add(new StyleLayer(StyleLayer.TYPE_SYMBOL).sourceLayer(sourceLayer).textField(field) + .textColor(textColor).textHaloColor(haloColor) + .textSize(ZoomValue.constant(13))); + } + + // ---- JSON loading ----------------------------------------------------- + + /// Parses a (subset of a) MapLibre GL style JSON document. Recognized: + /// the top-level `layers` array with `type` of `background`/`fill`/ + /// `line`/`symbol`, each layer's `source-layer`, `minzoom`/`maxzoom`, + /// a simple `["==", key, value]` filter, and the common paint/layout + /// properties (`background-color`, `fill-color`, `line-color`, + /// `line-width`, `text-field`, `text-color`, `text-size`). Unsupported + /// constructs are ignored rather than failing. + public static MapStyle fromJson(String json) { + MapStyle style = new MapStyle("custom", 0xfff2efe9); + try { + Map root = new JSONParser().parseJSON(new CharArrayReader(json.toCharArray())); + Object layersObj = root.get("layers"); + if (!(layersObj instanceof List)) { + return style; + } + List layers = (List) layersObj; + for (Object lo : layers) { + if (!(lo instanceof Map)) { + continue; + } + StyleLayer parsed = parseLayer(style, (Map) lo); + if (parsed != null) { + style.add(parsed); + } + } + } catch (Throwable t) { + // Malformed style: fall back to whatever parsed so far. + return style; + } + return style; + } + + private static StyleLayer parseLayer(MapStyle style, Map layer) { + String type = str(layer.get("type"), ""); + Map paint = layer.get("paint") instanceof Map ? (Map) layer.get("paint") : null; + Map layout = layer.get("layout") instanceof Map ? (Map) layer.get("layout") : null; + if ("background".equals(type)) { + if (paint != null) { + style.backgroundColor = color(paint.get("background-color"), style.backgroundColor); + } + return null; + } + StyleLayer sl; + if ("fill".equals(type)) { + sl = new StyleLayer(StyleLayer.TYPE_FILL); + if (paint != null) { + sl.fillColor(color(paint.get("fill-color"), 0xff808080)); + } + } else if ("line".equals(type)) { + sl = new StyleLayer(StyleLayer.TYPE_LINE); + if (paint != null) { + sl.lineColor(color(paint.get("line-color"), 0xff808080)); + sl.lineWidth(ZoomValue.constant(number(paint.get("line-width"), 1))); + } + } else if ("symbol".equals(type)) { + sl = new StyleLayer(StyleLayer.TYPE_SYMBOL); + if (layout != null) { + sl.textField(fieldName(str(layout.get("text-field"), "name"))); + sl.textSize(ZoomValue.constant(number(layout.get("text-size"), 13))); + } + if (paint != null) { + sl.textColor(color(paint.get("text-color"), 0xff333333)); + sl.textHaloColor(color(paint.get("text-halo-color"), 0x00000000)); + } + } else { + return null; + } + sl.sourceLayer(str(layer.get("source-layer"), null)); + sl.zoomRange(number(layer.get("minzoom"), 0), number(layer.get("maxzoom"), 24)); + applyFilter(sl, layer.get("filter")); + return sl; + } + + private static void applyFilter(StyleLayer sl, Object filter) { + if (filter instanceof List) { + List f = (List) filter; + if (f.size() == 3 && "==".equals(String.valueOf(f.get(0)))) { + sl.filter(String.valueOf(f.get(1)), String.valueOf(f.get(2))); + } + } + } + + private static String fieldName(String textField) { + // MapLibre text-field is often "{name}"; strip the braces. + if (textField == null) { + return "name"; + } + String s = textField; + if (s.startsWith("{") && s.endsWith("}")) { + s = s.substring(1, s.length() - 1); + } + return s; + } + + private static String str(Object o, String def) { + return o == null ? def : o.toString(); + } + + private static double number(Object o, double def) { + if (o instanceof Number) { + return ((Number) o).doubleValue(); + } + if (o instanceof String) { + try { + return Double.parseDouble((String) o); + } catch (NumberFormatException nfe) { + return def; + } + } + return def; + } + + private static int color(Object o, int def) { + if (!(o instanceof String)) { + return def; + } + return ColorParser.parse((String) o, def); + } +} diff --git a/CodenameOne/src/com/codename1/maps/vector/MvtDecoder.java b/CodenameOne/src/com/codename1/maps/vector/MvtDecoder.java new file mode 100644 index 0000000000..5fe4c9367c --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/vector/MvtDecoder.java @@ -0,0 +1,255 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.vector; + +import com.codename1.io.grpc.ProtoReader; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Decodes a Mapbox Vector Tile (MVT 2.1, `application/x-protobuf`) into the +/// in-memory [VectorTile] model using the framework's protobuf reader +/// ([ProtoReader]). +/// +/// The geometry command stream is decoded per the MVT spec: integers are +/// either `(command | count << 3)` headers or zig-zag delta-encoded +/// parameters; `MoveTo` (1) starts a part, `LineTo` (2) extends it and +/// `ClosePath` (7) ends a polygon ring. Coordinates are kept tile-local +/// (0..extent); projection to the screen happens in [TileRenderer]. +public final class MvtDecoder { + + // Tile-level field numbers. + private static final int TILE_LAYERS = 3; + + // Layer-level field numbers. + private static final int LAYER_NAME = 1; + private static final int LAYER_FEATURES = 2; + private static final int LAYER_KEYS = 3; + private static final int LAYER_VALUES = 4; + private static final int LAYER_EXTENT = 5; + + // Feature-level field numbers. + private static final int FEATURE_ID = 1; + private static final int FEATURE_TAGS = 2; + private static final int FEATURE_TYPE = 3; + private static final int FEATURE_GEOMETRY = 4; + + // Geometry command ids. + private static final int CMD_MOVE_TO = 1; + private static final int CMD_LINE_TO = 2; + private static final int CMD_CLOSE_PATH = 7; + + private static final int WIRE_LEN = 2; + + private MvtDecoder() { + } + + /// Decodes raw tile bytes into a [VectorTile]. The input must already be + /// decompressed (the tile sources gunzip transparently). + public static VectorTile decode(byte[] data) throws IOException { + ProtoReader in = new ProtoReader(data); + List layers = new ArrayList(); + int tag; + while ((tag = in.readTag()) != 0) { + if ((tag >>> 3) == TILE_LAYERS) { + layers.add(decodeLayer(in.readBytes())); + } else { + in.skipField(tag); + } + } + return new VectorTile(layers); + } + + private static VectorLayer decodeLayer(byte[] body) throws IOException { + ProtoReader in = new ProtoReader(body); + String name = ""; + int extent = 4096; + List rawFeatures = new ArrayList(); + List keys = new ArrayList(); + List values = new ArrayList(); + int tag; + while ((tag = in.readTag()) != 0) { + int field = tag >>> 3; + switch (field) { + case LAYER_NAME: + name = in.readString(); + break; + case LAYER_FEATURES: + rawFeatures.add(in.readBytes()); + break; + case LAYER_KEYS: + keys.add(in.readString()); + break; + case LAYER_VALUES: + values.add(decodeValue(in.readBytes())); + break; + case LAYER_EXTENT: + extent = in.readVarint32(); + break; + default: + in.skipField(tag); + } + } + List features = new ArrayList(rawFeatures.size()); + for (Object rf : rawFeatures) { + features.add(decodeFeature((byte[]) rf, keys, values)); + } + return new VectorLayer(name, extent, features); + } + + private static Object decodeValue(byte[] body) throws IOException { + ProtoReader in = new ProtoReader(body); + Object result = null; + int tag; + while ((tag = in.readTag()) != 0) { + switch (tag >>> 3) { + case 1: + result = in.readString(); + break; + case 2: + result = Float.valueOf(in.readFloat()); + break; + case 3: + result = Double.valueOf(in.readDouble()); + break; + case 4: + case 5: + result = Long.valueOf(in.readVarint64()); + break; + case 6: + result = Long.valueOf(in.readSInt64()); + break; + case 7: + result = in.readBool() ? Boolean.TRUE : Boolean.FALSE; + break; + default: + in.skipField(tag); + } + } + return result; + } + + private static VectorFeature decodeFeature(byte[] body, List keys, List values) throws IOException { + ProtoReader in = new ProtoReader(body); + long id = 0; + int type = VectorFeature.GEOM_UNKNOWN; + IntArray tags = new IntArray(); + IntArray geometry = new IntArray(); + int tag; + while ((tag = in.readTag()) != 0) { + int field = tag >>> 3; + int wire = tag & 0x7; + switch (field) { + case FEATURE_ID: + id = in.readVarint64(); + break; + case FEATURE_TAGS: + readPackedUint32(in, wire, tags); + break; + case FEATURE_TYPE: + type = in.readVarint32(); + break; + case FEATURE_GEOMETRY: + readPackedUint32(in, wire, geometry); + break; + default: + in.skipField(tag); + } + } + Map attributes = new HashMap(); + for (int i = 0; i + 1 < tags.size(); i += 2) { + int keyIndex = tags.get(i); + int valIndex = tags.get(i + 1); + if (keyIndex >= 0 && keyIndex < keys.size() + && valIndex >= 0 && valIndex < values.size()) { + attributes.put(keys.get(keyIndex), values.get(valIndex)); + } + } + return new VectorFeature(id, type, attributes, decodeGeometry(geometry)); + } + + private static void readPackedUint32(ProtoReader in, int wire, IntArray target) throws IOException { + if (wire == WIRE_LEN) { + ProtoReader sub = new ProtoReader(in.readBytes()); + while (!sub.isAtEnd()) { + target.add(sub.readVarint32()); + } + } else { + // Non-packed fallback: a single scalar element. + target.add(in.readVarint32()); + } + } + + private static List decodeGeometry(IntArray geom) { + List parts = new ArrayList(); + int i = 0; + int cx = 0; + int cy = 0; + int command = 0; + int length = 0; + IntArray current = null; + int n = geom.size(); + while (i < n) { + if (length == 0) { + int cmdInt = geom.get(i++); + command = cmdInt & 0x7; + length = cmdInt >>> 3; + if (command == CMD_MOVE_TO) { + current = new IntArray(); + parts.add(current); + } + if (command == CMD_CLOSE_PATH) { + // ClosePath carries no parameters; consume its count and + // end the current ring so the next MoveTo starts a new one. + length = 0; + current = null; + continue; + } + } + if (command == CMD_MOVE_TO || command == CMD_LINE_TO) { + if (i + 1 >= n) { + break; + } + cx += ProtoReader.zagZig32(geom.get(i++)); + cy += ProtoReader.zagZig32(geom.get(i++)); + if (current == null) { + current = new IntArray(); + parts.add(current); + } + current.add(cx); + current.add(cy); + length--; + } else { + break; + } + } + List out = new ArrayList(parts.size()); + for (Object part : parts) { + out.add(((IntArray) part).toArray()); + } + return out; + } +} diff --git a/CodenameOne/src/com/codename1/maps/vector/MvtTileSource.java b/CodenameOne/src/com/codename1/maps/vector/MvtTileSource.java new file mode 100644 index 0000000000..c455036054 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/vector/MvtTileSource.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.vector; + +/// A networked MVT (Mapbox Vector Tile) source. Point it at any +/// `{z}/{x}/{y}.pbf`/`.mvt` endpoint (MapLibre/OpenMapTiles, Protomaps, +/// MapTiler, ...). Most hosted vector basemaps require an API key, supplied +/// through [HttpTileSource#setApiKey] and referenced as `{key}` in the URL. +/// +/// For a keyless, self-hostable basemap, Protomaps `.pmtiles` served behind a +/// `z/x/y` proxy works well; for fully offline maps use [BundledTileSource]. +public final class MvtTileSource extends HttpTileSource { + + /// Creates a vector source from a `{z}/{x}/{y}` URL template. + public MvtTileSource(String urlTemplate, int minZoom, int maxZoom) { + super(urlTemplate, true, minZoom, maxZoom); + } + + /// The free, keyless [OpenFreeMap](https://openfreemap.org) vector basemap, + /// built from OpenStreetMap data. No API key or sign-up is required. Its + /// tile URLs are versioned, so the source is given OpenFreeMap's TileJSON + /// URL (no `{z}` token) and resolves the current tile template from it on + /// first use. Works out of the box with [MapStyle#light()] / [MapStyle#dark()]. + public static MvtTileSource openFreeMap() { + MvtTileSource s = new MvtTileSource("https://tiles.openfreemap.org/planet", 0, 14); + s.setAttribution("(c) OpenStreetMap contributors, (c) OpenFreeMap"); + return s; + } +} + diff --git a/CodenameOne/src/com/codename1/maps/vector/RasterTileSource.java b/CodenameOne/src/com/codename1/maps/vector/RasterTileSource.java new file mode 100644 index 0000000000..c1ca339348 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/vector/RasterTileSource.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.vector; + +/// A networked raster (image) XYZ tile source. Use for keyless, zero-config +/// basemaps such as OpenStreetMap; the engine simply decodes and blits the +/// returned image rather than styling vector geometry. +public final class RasterTileSource extends HttpTileSource { + + /// Creates a raster source from a `{z}/{x}/{y}` image URL template. + public RasterTileSource(String urlTemplate, int minZoom, int maxZoom) { + super(urlTemplate, false, minZoom, maxZoom); + } + + /// The standard OpenStreetMap raster basemap (HTTPS, keyless). Subject to + /// the OSM tile usage policy; supply a real tileset for production traffic. + public static RasterTileSource openStreetMap() { + RasterTileSource s = new RasterTileSource( + "https://tile.openstreetmap.org/{z}/{x}/{y}.png", 0, 19); + s.setAttribution("(c) OpenStreetMap contributors"); + return s; + } +} diff --git a/CodenameOne/src/com/codename1/maps/vector/StyleLayer.java b/CodenameOne/src/com/codename1/maps/vector/StyleLayer.java new file mode 100644 index 0000000000..8c31f40dcd --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/vector/StyleLayer.java @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.vector; + +/// One rule of a [MapStyle]: it selects features from a named vector-tile +/// source layer (optionally narrowed by a single attribute equality filter) +/// within a zoom range, and describes how to paint them. +/// +/// Supports the four layer types that cover a usable basemap: a single +/// [#TYPE_BACKGROUND] fill, [#TYPE_FILL] polygons, [#TYPE_LINE] strokes and +/// [#TYPE_SYMBOL] text labels. This is intentionally a pragmatic subset of the +/// MapLibre GL style spec rather than a full implementation. +public final class StyleLayer { + + /// A full-viewport background fill. + public static final int TYPE_BACKGROUND = 0; + /// Filled (and optionally stroked) polygons. + public static final int TYPE_FILL = 1; + /// Stroked lines. + public static final int TYPE_LINE = 2; + /// Text labels placed at point/centroid positions. + public static final int TYPE_SYMBOL = 3; + + private final int type; + private String sourceLayer; + private double minZoom = 0; + private double maxZoom = 24; + + private int fillColor = 0xff000000; + private int lineColor = 0xff000000; + private ZoomValue lineWidth = ZoomValue.constant(1); + private int textColor = 0xff000000; + private int textHaloColor = 0x00000000; + private ZoomValue textSize = ZoomValue.constant(12); + private String textField; + + private String filterKey; + private String filterValue; + private String excludeKey; + private String excludeValue; + + StyleLayer(int type) { + this.type = type; + } + + int getType() { + return type; + } + + String getSourceLayer() { + return sourceLayer; + } + + StyleLayer sourceLayer(String sourceLayer) { + this.sourceLayer = sourceLayer; + return this; + } + + StyleLayer zoomRange(double minZoom, double maxZoom) { + this.minZoom = minZoom; + this.maxZoom = maxZoom; + return this; + } + + boolean visibleAt(double zoom) { + return zoom >= minZoom && zoom <= maxZoom; + } + + StyleLayer fillColor(int argb) { + this.fillColor = argb; + return this; + } + + int getFillColor() { + return fillColor; + } + + StyleLayer lineColor(int argb) { + this.lineColor = argb; + return this; + } + + int getLineColor() { + return lineColor; + } + + StyleLayer lineWidth(ZoomValue width) { + this.lineWidth = width; + return this; + } + + double lineWidthAt(double zoom) { + return lineWidth.eval(zoom); + } + + StyleLayer textColor(int argb) { + this.textColor = argb; + return this; + } + + int getTextColor() { + return textColor; + } + + StyleLayer textHaloColor(int argb) { + this.textHaloColor = argb; + return this; + } + + int getTextHaloColor() { + return textHaloColor; + } + + StyleLayer textSize(ZoomValue size) { + this.textSize = size; + return this; + } + + double textSizeAt(double zoom) { + return textSize.eval(zoom); + } + + StyleLayer textField(String field) { + this.textField = field; + return this; + } + + String getTextField() { + return textField; + } + + StyleLayer filter(String key, String value) { + this.filterKey = key; + this.filterValue = value; + return this; + } + + /// Drops features whose `key` attribute equals `value` (e.g. excluding + /// `class=ferry` so ferry routes are not drawn as roads across the water). + StyleLayer excludeFilter(String key, String value) { + this.excludeKey = key; + this.excludeValue = value; + return this; + } + + /// Whether `feature` passes this layer's optional equality / exclusion filters. + boolean accepts(VectorFeature feature) { + if (excludeKey != null) { + Object ev = feature.getAttribute(excludeKey); + if (ev != null && excludeValue != null && excludeValue.equals(String.valueOf(ev))) { + return false; + } + } + if (filterKey == null) { + return true; + } + Object v = feature.getAttribute(filterKey); + return v != null && filterValue != null && filterValue.equals(String.valueOf(v)); + } +} diff --git a/CodenameOne/src/com/codename1/maps/vector/TileCache.java b/CodenameOne/src/com/codename1/maps/vector/TileCache.java new file mode 100644 index 0000000000..51165173d5 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/vector/TileCache.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.vector; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// A bounded least-recently-used cache of rendered tiles keyed by their +/// `z/x/y` address. Bounding the in-memory tile set is the eviction the +/// legacy `WeakHashMap`-based tile cache lacked, keeping memory predictable +/// while panning across a large area. +final class TileCache { + + private final int maxEntries; + private final Map map = new HashMap(); + private final List order = new ArrayList(); + + TileCache(int maxEntries) { + this.maxEntries = maxEntries < 1 ? 1 : maxEntries; + } + + synchronized Object get(String key) { + Object v = map.get(key); + if (v != null) { + order.remove(key); + order.add(key); + } + return v; + } + + synchronized boolean contains(String key) { + return map.containsKey(key); + } + + synchronized void put(String key, Object value) { + if (map.containsKey(key)) { + map.put(key, value); + order.remove(key); + order.add(key); + return; + } + map.put(key, value); + order.add(key); + while (order.size() > maxEntries) { + Object evicted = order.remove(0); + map.remove(evicted); + } + } + + synchronized void clear() { + map.clear(); + order.clear(); + } + + synchronized int size() { + return map.size(); + } +} diff --git a/CodenameOne/src/com/codename1/maps/vector/TileCallback.java b/CodenameOne/src/com/codename1/maps/vector/TileCallback.java new file mode 100644 index 0000000000..ce572ff856 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/vector/TileCallback.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.vector; + +/// Asynchronous result callback for [TileSource#fetchTile]. Implementations +/// are invoked on the Codename One event dispatch thread. +public interface TileCallback { + + /// Delivers the decompressed tile payload (MVT protobuf or encoded image + /// bytes depending on the source). + void tileLoaded(int z, int x, int y, byte[] data); + + /// Reports that the tile could not be retrieved. + void tileFailed(int z, int x, int y); +} diff --git a/CodenameOne/src/com/codename1/maps/vector/TileRenderer.java b/CodenameOne/src/com/codename1/maps/vector/TileRenderer.java new file mode 100644 index 0000000000..e797223e07 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/vector/TileRenderer.java @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.vector; + +import com.codename1.ui.Graphics; +import com.codename1.ui.Stroke; +import com.codename1.ui.geom.GeneralPath; + +import java.util.ArrayList; +import java.util.List; + +/// Rasterizes a decoded [VectorTile] into a tile-sized buffer according to a +/// [MapStyle], and extracts the text labels for the engine to place globally. +/// +/// Fills and lines are drawn into the per-tile buffer with [GeneralPath] + +/// [Stroke]; symbol layers are not drawn here -- their labels are collected by +/// [#extractLabels] and placed across tile boundaries by the [LabelEngine] so +/// they neither clip nor duplicate at tile seams. +final class TileRenderer { + + private TileRenderer() { + } + + /// Draws the fill and line layers of `tile` into `g` (a buffer of + /// `tileSize` pixels), honoring the rules in `style` at integer `zoom`. + static void renderTile(Graphics g, VectorTile tile, MapStyle style, int zoom, int tileSize) { + g.setAntiAliased(true); + List styleLayers = style.getLayers(); + for (Object slObj : styleLayers) { + StyleLayer sl = (StyleLayer) slObj; + if (sl.getType() == StyleLayer.TYPE_SYMBOL || sl.getType() == StyleLayer.TYPE_BACKGROUND) { + continue; + } + if (!sl.visibleAt(zoom) || sl.getSourceLayer() == null) { + continue; + } + VectorLayer vl = tile.getLayer(sl.getSourceLayer()); + if (vl == null) { + continue; + } + double scale = (double) tileSize / vl.getExtent(); + List features = vl.getFeatures(); + if (sl.getType() == StyleLayer.TYPE_FILL) { + renderFills(g, features, sl, scale); + } else { + renderLines(g, features, sl, scale, zoom); + } + } + } + + private static void renderFills(Graphics g, List features, StyleLayer sl, double scale) { + int argb = sl.getFillColor(); + applyColor(g, argb); + for (Object featureObj : features) { + VectorFeature f = (VectorFeature) featureObj; + if (f.getGeometryType() != VectorFeature.GEOM_POLYGON || !sl.accepts(f)) { + continue; + } + List parts = f.getParts(); + if (parts.isEmpty()) { + continue; + } + GeneralPath path = new GeneralPath(); + for (Object partObj : parts) { + int[] ring = (int[]) partObj; + appendRing(path, ring, scale, true); + } + g.fillShape(path); + } + } + + private static void renderLines(Graphics g, List features, StyleLayer sl, double scale, int zoom) { + applyColor(g, sl.getLineColor()); + float width = (float) sl.lineWidthAt(zoom); + if (width < 0.5f) { + width = 0.5f; + } + Stroke stroke = new Stroke(width, Stroke.CAP_ROUND, Stroke.JOIN_ROUND, 4f); + for (Object featureObj : features) { + VectorFeature f = (VectorFeature) featureObj; + int gt = f.getGeometryType(); + if ((gt != VectorFeature.GEOM_LINESTRING && gt != VectorFeature.GEOM_POLYGON) || !sl.accepts(f)) { + continue; + } + List parts = f.getParts(); + for (Object partObj : parts) { + int[] line = (int[]) partObj; + GeneralPath path = new GeneralPath(); + appendRing(path, line, scale, false); + g.drawShape(path, stroke); + } + } + } + + private static void appendRing(GeneralPath path, int[] coords, double scale, boolean close) { + if (coords.length < 2) { + return; + } + path.moveTo((float) (coords[0] * scale), (float) (coords[1] * scale)); + for (int i = 2; i + 1 < coords.length; i += 2) { + path.lineTo((float) (coords[i] * scale), (float) (coords[i + 1] * scale)); + } + if (close) { + path.closePath(); + } + } + + private static void applyColor(Graphics g, int argb) { + int a = (argb >>> 24) & 0xff; + if (a == 0) { + a = 255; + } + g.setAlpha(a); + g.setColor(argb & 0xffffff); + } + + /// Collects the labels declared by the style's symbol layers for one tile. + /// `tileX`/`tileY` are the tile's slippy coordinates at integer `zoom`. + static List extractLabels(VectorTile tile, MapStyle style, int zoom, + int tileX, int tileY, int tileSize) { + List out = new ArrayList(); + List styleLayers = style.getLayers(); + for (Object slObj : styleLayers) { + StyleLayer sl = (StyleLayer) slObj; + if (sl.getType() != StyleLayer.TYPE_SYMBOL || sl.getSourceLayer() == null) { + continue; + } + if (!sl.visibleAt(zoom)) { + continue; + } + VectorLayer vl = tile.getLayer(sl.getSourceLayer()); + if (vl == null) { + continue; + } + int extent = vl.getExtent(); + double scale = (double) tileSize / extent; + double originX = (double) tileX * tileSize; + double originY = (double) tileY * tileSize; + List features = vl.getFeatures(); + for (Object featureObj : features) { + VectorFeature f = (VectorFeature) featureObj; + if (!sl.accepts(f)) { + continue; + } + Object value = sl.getTextField() == null ? null : f.getAttribute(sl.getTextField()); + if (value == null) { + continue; + } + double[] anchor = anchorOf(f); + if (anchor == null) { + continue; + } + // Drop labels whose anchor falls in the tile's buffer (outside + // 0..extent): those belong to a neighbouring tile and would + // otherwise float in empty space past the loaded coverage. + if (anchor[0] < 0 || anchor[0] > extent || anchor[1] < 0 || anchor[1] > extent) { + continue; + } + double worldX = originX + anchor[0] * scale; + double worldY = originY + anchor[1] * scale; + out.add(new LabelCandidate(String.valueOf(value), worldX, worldY, zoom, + sl.getTextColor(), sl.getTextHaloColor(), sl.textSizeAt(zoom))); + } + } + return out; + } + + private static double[] anchorOf(VectorFeature f) { + List parts = f.getParts(); + if (parts.isEmpty()) { + return null; + } + int[] first = (int[]) parts.get(0); + if (first.length < 2) { + return null; + } + if (f.getGeometryType() == VectorFeature.GEOM_POINT) { + return new double[]{first[0], first[1]}; + } + // Centroid of the first ring/line as the label anchor. + double sx = 0; + double sy = 0; + int n = 0; + for (int i = 0; i + 1 < first.length; i += 2) { + sx += first[i]; + sy += first[i + 1]; + n++; + } + if (n == 0) { + return null; + } + return new double[]{sx / n, sy / n}; + } +} diff --git a/CodenameOne/src/com/codename1/maps/vector/TileSource.java b/CodenameOne/src/com/codename1/maps/vector/TileSource.java new file mode 100644 index 0000000000..e79c505051 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/vector/TileSource.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.vector; + +/// Supplies tile payloads to the [VectorMapEngine] given a slippy-map +/// `z/x/y` address. Two flavors exist: vector sources ([#isVector] true) +/// returning MVT protobuf bytes that the engine decodes and styles, and +/// raster sources returning encoded-image bytes that the engine simply +/// blits. The bundled [BundledTileSource], the networked [MvtTileSource] and +/// the keyless [RasterTileSource] cover the universal, vector and zero-config +/// cases respectively. +public interface TileSource { + + /// True for MVT vector tiles, false for raster image tiles. + boolean isVector(); + + /// The tile edge in pixels (almost always 256). + int getTileSize(); + + /// The smallest zoom level this source serves. + int getMinZoom(); + + /// The largest zoom level this source serves. + int getMaxZoom(); + + /// Attribution text that must be displayed over the map. + String getAttribution(); + + /// Requests the tile at `z/x/y`, delivering the result to `callback` on + /// the event dispatch thread. + void fetchTile(int z, int x, int y, TileCallback callback); +} diff --git a/CodenameOne/src/com/codename1/maps/vector/TileUtil.java b/CodenameOne/src/com/codename1/maps/vector/TileUtil.java new file mode 100644 index 0000000000..567e5a10c2 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/vector/TileUtil.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.vector; + +import com.codename1.io.Util; +import com.codename1.io.gzip.GZIPInputStream; + +import java.io.ByteArrayInputStream; +import java.io.IOException; + +/// Small helpers shared by the tile sources and cache. +final class TileUtil { + + private TileUtil() { + } + + /// A canonical cache/identity key for a slippy-map tile address. + static String key(int z, int x, int y) { + return z + "/" + x + "/" + y; + } + + /// Transparently gunzips `data` when it carries the gzip magic header, + /// otherwise returns it unchanged. Vector tiles are frequently served + /// gzip-compressed regardless of the `Content-Encoding` the HTTP stack + /// reports. + static byte[] maybeGunzip(byte[] data) throws IOException { + if (data == null || data.length < 2) { + return data; + } + if ((data[0] & 0xFF) == 0x1F && (data[1] & 0xFF) == 0x8B) { + GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(data)); + try { + return Util.readInputStream(in); + } finally { + in.close(); + } + } + return data; + } +} diff --git a/CodenameOne/src/com/codename1/maps/vector/VectorFeature.java b/CodenameOne/src/com/codename1/maps/vector/VectorFeature.java new file mode 100644 index 0000000000..9fe49e0ec9 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/vector/VectorFeature.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.vector; + +import java.util.List; +import java.util.Map; + +/// A single decoded feature of a Mapbox Vector Tile: its geometry type, its +/// attribute map, and its geometry as one or more parts in tile-local +/// coordinates (0..[VectorLayer#getExtent], origin top-left). +/// +/// For [#GEOM_POINT] each part is a flat run of `x,y` points; for +/// [#GEOM_LINESTRING] each part is a polyline; for [#GEOM_POLYGON] each part +/// is a closed ring (exterior rings wind one way, holes the other, per the +/// MVT spec). +public final class VectorFeature { + + /// Unknown / empty geometry. + public static final int GEOM_UNKNOWN = 0; + /// One or more points. + public static final int GEOM_POINT = 1; + /// One or more polylines. + public static final int GEOM_LINESTRING = 2; + /// One or more polygon rings. + public static final int GEOM_POLYGON = 3; + + private final long id; + private final int geometryType; + private final Map attributes; + private final List parts; + + VectorFeature(long id, int geometryType, Map attributes, List parts) { + this.id = id; + this.geometryType = geometryType; + this.attributes = attributes; + this.parts = parts; + } + + /// The feature id (0 when absent). + public long getId() { + return id; + } + + /// One of the `GEOM_*` constants. + public int getGeometryType() { + return geometryType; + } + + /// The feature attributes as a `Map` (string/number/bool). + public Map getAttributes() { + return attributes; + } + + /// Convenience accessor for a single attribute, or `null`. + public Object getAttribute(String key) { + return attributes == null ? null : attributes.get(key); + } + + /// The geometry parts, each an `int[]` of interleaved `x,y` tile-local + /// coordinates. + public List getParts() { + return parts; + } +} diff --git a/CodenameOne/src/com/codename1/maps/vector/VectorLayer.java b/CodenameOne/src/com/codename1/maps/vector/VectorLayer.java new file mode 100644 index 0000000000..dda44df3fe --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/vector/VectorLayer.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.vector; + +import java.util.List; + +/// A named layer of a decoded vector tile (for example `water`, `road`, +/// `building`, `place`) holding its features and the integer extent of its +/// local coordinate grid (4096 by default). +public final class VectorLayer { + + private final String name; + private final int extent; + private final List features; + + VectorLayer(String name, int extent, List features) { + this.name = name; + this.extent = extent; + this.features = features; + } + + /// The layer name as authored in the tileset (used to match style rules). + public String getName() { + return name; + } + + /// The tile-local coordinate extent; geometry ranges over `0..extent`. + public int getExtent() { + return extent; + } + + /// The features in this layer ([VectorFeature]). + public List getFeatures() { + return features; + } +} diff --git a/CodenameOne/src/com/codename1/maps/vector/VectorMapEngine.java b/CodenameOne/src/com/codename1/maps/vector/VectorMapEngine.java new file mode 100644 index 0000000000..a4e585b113 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/vector/VectorMapEngine.java @@ -0,0 +1,407 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.vector; + +import com.codename1.maps.LatLng; +import com.codename1.maps.MapBounds; +import com.codename1.ui.Graphics; +import com.codename1.ui.Image; +import com.codename1.ui.geom.Point; +import com.codename1.util.MathUtil; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The pure-Codename One map renderer behind [com.codename1.maps.MapView] (and +/// the [com.codename1.maps.NativeMap] fallback). +/// +/// It maintains the camera (center + fractional zoom), pulls tiles from a +/// [TileSource], rasterizes vector tiles once into 256px buffers (or decodes +/// raster tiles), caches them in an LRU [TileCache], and on each paint blits +/// the visible buffers scaled to the fractional zoom and places labels with +/// the [LabelEngine]. All drawing uses the framework [Graphics] API -- there +/// is no native peer. +public final class VectorMapEngine { + + private static final int tileSize = WebMercator.TILE_SIZE; + private TileSource source; + private MapStyle style; + + private double centerLat; + private double centerLon; + private double zoom = 2; + + private int viewWidth; + private int viewHeight; + + private final TileCache rendered; + private final Map labels = new HashMap(); + private final Map pending = new HashMap(); + private final Map failed = new HashMap(); + private final LabelEngine labelEngine = new LabelEngine(); + + private Runnable repaintCallback; + + /// Creates an engine over `source`, styled by `style`. + public VectorMapEngine(TileSource source, MapStyle style) { + this.source = source; + this.style = style == null ? MapStyle.light() : style; + this.rendered = new TileCache(256); + } + + /// Sets the callback invoked (on the EDT) whenever a tile finishes loading + /// and the map needs to repaint. + public void setRepaintCallback(Runnable r) { + this.repaintCallback = r; + } + + /// Replaces the tile source, clearing cached tiles. + public void setSource(TileSource source) { + this.source = source; + rendered.clear(); + labels.clear(); + pending.clear(); + failed.clear(); + } + + /// The active tile source. + public TileSource getSource() { + return source; + } + + /// Replaces the style, clearing rendered tiles so they redraw. + public void setStyle(MapStyle style) { + this.style = style; + rendered.clear(); + labels.clear(); + } + + /// The active style. + public MapStyle getStyle() { + return style; + } + + // ---- Camera ----------------------------------------------------------- + + /// Recenters the camera at `center`, keeping the current zoom. + public void setCenter(LatLng center) { + this.centerLat = center.getLatitude(); + this.centerLon = center.getLongitude(); + } + + /// The geographic coordinate at the center of the viewport. + public LatLng getCenter() { + return new LatLng(centerLat, centerLon); + } + + /// Sets the zoom level, clamped to the source's min/max. + public void setZoom(double zoom) { + this.zoom = clampZoom(zoom); + } + + /// The current fractional zoom level. + public double getZoom() { + return zoom; + } + + /// The smallest zoom level the tile source serves. + public double getMinZoom() { + return source.getMinZoom(); + } + + /// The largest zoom level the tile source serves. + public double getMaxZoom() { + return source.getMaxZoom(); + } + + /// Sets the pixel size of the viewport (called by the host component on + /// layout before painting and coordinate conversion). + public void setViewport(int width, int height) { + this.viewWidth = width; + this.viewHeight = height; + } + + private double clampZoom(double z) { + double min = source.getMinZoom(); + double max = source.getMaxZoom(); + if (z < min) { + return min; + } + if (z > max) { + return max; + } + return z; + } + + // ---- Coordinate conversion ------------------------------------------- + + /// Geographic to component-relative pixel. + public Point latLngToScreen(LatLng coord) { + double cwx = WebMercator.lonToWorldX(centerLon, zoom); + double cwy = WebMercator.latToWorldY(centerLat, zoom); + double wx = WebMercator.lonToWorldX(coord.getLongitude(), zoom); + double wy = WebMercator.latToWorldY(coord.getLatitude(), zoom); + int sx = (int) Math.floor(wx - cwx + viewWidth / 2.0 + 0.5); + int sy = (int) Math.floor(wy - cwy + viewHeight / 2.0 + 0.5); + return new Point(sx, sy); + } + + /// Pans the camera by a pixel delta (used for drag gestures). A positive + /// `dx` moves the map content to the right. + public void panPixels(double dx, double dy) { + double cwx = WebMercator.lonToWorldX(centerLon, zoom) - dx; + double cwy = WebMercator.latToWorldY(centerLat, zoom) - dy; + centerLon = WebMercator.worldXToLon(cwx, zoom); + double lat = WebMercator.worldYToLat(cwy, zoom); + if (lat > 85.05112878) { + lat = 85.05112878; + } else if (lat < -85.05112878) { + lat = -85.05112878; + } + centerLat = lat; + } + + /// Zooms to `newZoom` while keeping the geographic point currently under + /// the component pixel `sx,sy` fixed (used for pinch and double-tap). + public void zoomAround(double newZoom, int sx, int sy) { + LatLng anchor = screenToLatLng(sx, sy); + setZoom(newZoom); + Point p = latLngToScreen(anchor); + panPixels(sx - p.getX(), sy - p.getY()); + } + + /// Component-relative pixel to geographic. + public LatLng screenToLatLng(int x, int y) { + double cwx = WebMercator.lonToWorldX(centerLon, zoom); + double cwy = WebMercator.latToWorldY(centerLat, zoom); + double wx = x - viewWidth / 2.0 + cwx; + double wy = y - viewHeight / 2.0 + cwy; + return new LatLng(WebMercator.worldYToLat(wy, zoom), WebMercator.worldXToLon(wx, zoom)); + } + + /// The geographic bounds currently visible, or null before layout. + public MapBounds getVisibleBounds() { + if (viewWidth <= 0 || viewHeight <= 0) { + return null; + } + LatLng nw = screenToLatLng(0, 0); + LatLng se = screenToLatLng(viewWidth, viewHeight); + return new MapBounds(new LatLng(se.getLatitude(), nw.getLongitude()), + new LatLng(nw.getLatitude(), se.getLongitude())); + } + + /// Moves the camera so `bounds` fits the viewport inset by `padding`. + public void fitBounds(MapBounds bounds, int padding) { + if (bounds == null || viewWidth <= 0 || viewHeight <= 0) { + return; + } + double usableW = Math.max(1, viewWidth - 2 * padding); + double usableH = Math.max(1, viewHeight - 2 * padding); + double worldW = worldSpanX(bounds); + double worldH = worldSpanY(bounds); + double zx = worldW <= 0 ? getMaxZoom() : log2(usableW / worldW); + double zy = worldH <= 0 ? getMaxZoom() : log2(usableH / worldH); + setZoom(Math.min(zx, zy)); + setCenter(bounds.getCenter()); + } + + private double worldSpanX(MapBounds b) { + double x0 = WebMercator.lonToWorldX(b.getSouthWest().getLongitude(), 0); + double x1 = WebMercator.lonToWorldX(b.getNorthEast().getLongitude(), 0); + return Math.abs(x1 - x0); + } + + private double worldSpanY(MapBounds b) { + double y0 = WebMercator.latToWorldY(b.getSouthWest().getLatitude(), 0); + double y1 = WebMercator.latToWorldY(b.getNorthEast().getLatitude(), 0); + return Math.abs(y1 - y0); + } + + private static double log2(double v) { + return MathUtil.log(v) / MathUtil.log(2); + } + + // ---- Painting --------------------------------------------------------- + + /// Paints the basemap and labels into `g`, offset by `originX,originY`. + /// The caller is responsible for clipping to the component bounds and for + /// drawing overlays (markers/shapes) afterwards. + public void paint(Graphics g, int originX, int originY, int width, int height) { + viewWidth = width; + viewHeight = height; + + int bg = style.getBackgroundColor(); + g.setAlpha(255); + g.setColor(bg & 0xffffff); + g.fillRect(originX, originY, width, height); + + int z = integerZoom(); + double s = MathUtil.pow(2, zoom - z); + double cwx = WebMercator.lonToWorldX(centerLon, zoom); + double cwy = WebMercator.latToWorldY(centerLat, zoom); + + int tiles = 1 << z; + double wzLeft = (cwx - width / 2.0) / s; + double wzRight = (cwx + width / 2.0) / s; + double wzTop = (cwy - height / 2.0) / s; + double wzBottom = (cwy + height / 2.0) / s; + + int txMin = floorDiv((int) Math.floor(wzLeft), tileSize); + int txMax = floorDiv((int) Math.floor(wzRight), tileSize); + int tyMin = floorDiv((int) Math.floor(wzTop), tileSize); + int tyMax = floorDiv((int) Math.floor(wzBottom), tileSize); + + List visibleLabels = new ArrayList(); + + for (int tx = txMin; tx <= txMax; tx++) { + for (int ty = tyMin; ty <= tyMax; ty++) { + if (ty < 0 || ty >= tiles) { + continue; + } + int wrappedTx = ((tx % tiles) + tiles) % tiles; + String key = TileUtil.key(z, wrappedTx, ty); + Image img = (Image) rendered.get(key); + if (img == null) { + requestTile(z, wrappedTx, ty); + } else { + int left = screenX(tx * (double) tileSize, s, cwx, originX, width); + int top = screenY(ty * (double) tileSize, s, cwy, originY, height); + int right = screenX((tx + 1) * (double) tileSize, s, cwx, originX, width); + int bottom = screenY((ty + 1) * (double) tileSize, s, cwy, originY, height); + g.drawImage(img, left, top, right - left, bottom - top); + } + List tileLabels = (List) labels.get(key); + if (tileLabels != null) { + visibleLabels.addAll(tileLabels); + } + } + } + + drawLabels(g, visibleLabels, s, cwx, cwy, originX, originY, width, height, z); + } + + private void drawLabels(Graphics g, List candidates, double s, double cwx, double cwy, + int originX, int originY, int width, int height, int z) { + labelEngine.reset(); + for (Object cand : candidates) { + LabelCandidate c = (LabelCandidate) cand; + // Candidate world coords are at its own tile zoom; rescale to this z. + double factor = MathUtil.pow(2, z - c.tileZoom); + double wzx = c.worldX * factor; + double wzy = c.worldY * factor; + int sx = (int) Math.floor(originX + wzx * s - cwx + width / 2.0 + 0.5); + int sy = (int) Math.floor(originY + wzy * s - cwy + height / 2.0 + 0.5); + if (sx < originX - 64 || sx > originX + width + 64 + || sy < originY - 32 || sy > originY + height + 32) { + continue; + } + labelEngine.place(g, c.text, c.sizePx, c.textColor, c.haloColor, sx, sy); + } + } + + private int screenX(double worldZ, double s, double cwx, int originX, int width) { + return (int) Math.floor(originX + worldZ * s - cwx + width / 2.0 + 0.5); + } + + private int screenY(double worldZ, double s, double cwy, int originY, int height) { + return (int) Math.floor(originY + worldZ * s - cwy + height / 2.0 + 0.5); + } + + private int integerZoom() { + int z = (int) Math.floor(zoom + 0.5); + if (z < source.getMinZoom()) { + z = source.getMinZoom(); + } + if (z > source.getMaxZoom()) { + z = source.getMaxZoom(); + } + if (z < 0) { + z = 0; + } + return z; + } + + private static int floorDiv(int a, int b) { + int q = a / b; + if ((a % b != 0) && ((a < 0) != (b < 0))) { + q--; + } + return q; + } + + // ---- Tile loading ----------------------------------------------------- + + private void requestTile(final int z, final int x, final int y) { + final String key = TileUtil.key(z, x, y); + if (pending.containsKey(key) || failed.containsKey(key)) { + return; + } + pending.put(key, Boolean.TRUE); + source.fetchTile(z, x, y, new TileCallback() { + @Override + public void tileLoaded(int tz, int tx, int ty, byte[] data) { + pending.remove(key); + try { + if (source.isVector()) { + VectorTile tile = MvtDecoder.decode(data); + rendered.put(key, rasterize(tile, tz)); + labels.put(key, TileRenderer.extractLabels(tile, style, tz, tx, ty, tileSize)); + } else { + rendered.put(key, Image.createImage(data, 0, data.length)); + } + repaint(); + } catch (Throwable t) { + failed.put(key, Boolean.TRUE); + } + } + + @Override + public void tileFailed(int tz, int tx, int ty) { + pending.remove(key); + failed.put(key, Boolean.TRUE); + } + }); + } + + private Image rasterize(VectorTile tile, int z) { + Image buffer = Image.createImage(tileSize, tileSize, 0); + Graphics g = buffer.getGraphics(); + TileRenderer.renderTile(g, tile, style, z, tileSize); + return buffer; + } + + private void repaint() { + if (repaintCallback != null) { + repaintCallback.run(); + } + } + + /// Drops all cached tiles (e.g. on low memory). + public void clearCache() { + rendered.clear(); + labels.clear(); + failed.clear(); + } +} diff --git a/CodenameOne/src/com/codename1/maps/vector/VectorTile.java b/CodenameOne/src/com/codename1/maps/vector/VectorTile.java new file mode 100644 index 0000000000..d431721be0 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/vector/VectorTile.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.vector; + +import java.util.List; + +/// A decoded Mapbox Vector Tile: an ordered collection of named +/// [VectorLayer]s. Produced by [MvtDecoder] and consumed by [TileRenderer]. +public final class VectorTile { + + private final List layers; + + VectorTile(List layers) { + this.layers = layers; + } + + /// All layers in declaration order. + public List getLayers() { + return layers; + } + + /// The layer with the given name, or `null` if the tile has none. + public VectorLayer getLayer(String name) { + for (Object layerObj : layers) { + VectorLayer l = (VectorLayer) layerObj; + if (l.getName().equals(name)) { + return l; + } + } + return null; + } +} diff --git a/CodenameOne/src/com/codename1/maps/vector/WebMercator.java b/CodenameOne/src/com/codename1/maps/vector/WebMercator.java new file mode 100644 index 0000000000..35f7d08ffc --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/vector/WebMercator.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.vector; + +import com.codename1.util.MathUtil; + +/// Spherical Web Mercator (EPSG:3857) helpers expressed in "world pixels" -- +/// the slippy-map coordinate space where the whole world spans +/// `tileSize * 2^zoom` pixels. All of the vector engine's panning and tile +/// math is done in this space. +/// +/// These are the standard OSM tiling formulas; kept here (rather than reusing +/// the projected [com.codename1.maps.Mercator]) so the renderer can work in +/// fractional pixels at fractional zoom without round-tripping through the +/// legacy `Coord` projection flag. +public final class WebMercator { + + /// The canonical tile edge length in pixels. + public static final int TILE_SIZE = 256; + + private static final double PI = Math.PI; + + private WebMercator() { + } + + /// The world width/height in pixels at `zoom` (may be fractional). + public static double worldSize(double zoom) { + return TILE_SIZE * MathUtil.pow(2, zoom); + } + + /// Longitude in degrees to an absolute world-pixel x at `zoom`. + public static double lonToWorldX(double lon, double zoom) { + return (lon + 180.0) / 360.0 * worldSize(zoom); + } + + /// Latitude in degrees to an absolute world-pixel y at `zoom`. + public static double latToWorldY(double lat, double zoom) { + double latRad = lat * PI / 180.0; + double y = MathUtil.log(Math.tan(latRad) + 1.0 / Math.cos(latRad)); + return (1.0 - y / PI) / 2.0 * worldSize(zoom); + } + + /// World-pixel x at `zoom` back to longitude in degrees. + public static double worldXToLon(double worldX, double zoom) { + return worldX / worldSize(zoom) * 360.0 - 180.0; + } + + /// World-pixel y at `zoom` back to latitude in degrees. + public static double worldYToLat(double worldY, double zoom) { + double n = PI * (1.0 - 2.0 * worldY / worldSize(zoom)); + double latRad = MathUtil.atan(sinh(n)); + return latRad * 180.0 / PI; + } + + /// Hyperbolic sine, absent from the minimal device `Math`. + public static double sinh(double x) { + return (MathUtil.exp(x) - MathUtil.exp(-x)) / 2.0; + } +} diff --git a/CodenameOne/src/com/codename1/maps/vector/ZoomValue.java b/CodenameOne/src/com/codename1/maps/vector/ZoomValue.java new file mode 100644 index 0000000000..5b2be2e2fe --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/vector/ZoomValue.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maps.vector; + +/// A numeric style property that may vary with zoom. It is either a constant +/// or a set of `(zoom, value)` stops linearly interpolated between, mirroring +/// the most common form of a MapLibre GL "interpolate" expression. Used for +/// line widths and text sizes. +final class ZoomValue { + + private final double base; + private final double[] stopZooms; + private final double[] stopValues; + + private ZoomValue(double base, double[] zooms, double[] values) { + this.base = base; + this.stopZooms = zooms; + this.stopValues = values; + } + + /// A value that does not change with zoom. + static ZoomValue constant(double value) { + return new ZoomValue(value, null, null); + } + + /// A value interpolated between the supplied ascending zoom stops. + static ZoomValue stops(double[] zooms, double[] values) { + if (zooms == null || values == null || zooms.length != values.length || zooms.length == 0) { + return constant(0); + } + return new ZoomValue(values[0], zooms, values); + } + + /// Evaluates the property at `zoom`, clamping outside the stop range. + double eval(double zoom) { + if (stopZooms == null) { + return base; + } + if (zoom <= stopZooms[0]) { + return stopValues[0]; + } + int last = stopZooms.length - 1; + if (zoom >= stopZooms[last]) { + return stopValues[last]; + } + for (int i = 0; i < last; i++) { + double z0 = stopZooms[i]; + double z1 = stopZooms[i + 1]; + if (zoom >= z0 && zoom <= z1) { + double t = (zoom - z0) / (z1 - z0); + return stopValues[i] + t * (stopValues[i + 1] - stopValues[i]); + } + } + return base; + } +} diff --git a/CodenameOne/src/com/codename1/maps/vector/package-info.java b/CodenameOne/src/com/codename1/maps/vector/package-info.java new file mode 100644 index 0000000000..f7aff64df8 --- /dev/null +++ b/CodenameOne/src/com/codename1/maps/vector/package-info.java @@ -0,0 +1,15 @@ +/// The pure-vector map rendering engine that backs +/// [com.codename1.maps.MapView]. +/// +/// The engine decodes Mapbox Vector Tiles (MVT) with the framework protobuf +/// reader and draws them entirely through the Codename One [com.codename1.ui.Graphics] +/// API -- there is no native peer, so a [com.codename1.maps.MapView] composes +/// with regular lightweight UI on every platform including the simulator and +/// the browser. Tiles are supplied by a pluggable +/// [com.codename1.maps.vector.TileSource] (network MVT or raster, bundled +/// fixtures, or the keyless OpenFreeMap basemap) and styled with a +/// [com.codename1.maps.vector.MapStyle] (a subset of the MapLibre GL style +/// specification). The classes in this package are internal building blocks of +/// the engine; application code targets [com.codename1.maps.MapView] and the +/// public tile-source and style types. +package com.codename1.maps.vector; diff --git a/CodenameOne/src/com/codename1/ui/PeerComponent.java b/CodenameOne/src/com/codename1/ui/PeerComponent.java index 9b43b3dcd1..3758e4a6cb 100644 --- a/CodenameOne/src/com/codename1/ui/PeerComponent.java +++ b/CodenameOne/src/com/codename1/ui/PeerComponent.java @@ -274,7 +274,10 @@ public void pointerReleased(int x, int y) { /// Updates the size of the component from the native widget public void invalidate() { setShouldCalcPreferredSize(true); - getComponentForm().revalidate(); + Form parentForm = getComponentForm(); + if (parentForm != null) { + parentForm.revalidate(); + } } /// Callback useful for sublclasses that need to track the change in size/position diff --git a/docs/developer-guide/Maps.asciidoc b/docs/developer-guide/Maps.asciidoc new file mode 100644 index 0000000000..8180e706c9 --- /dev/null +++ b/docs/developer-guide/Maps.asciidoc @@ -0,0 +1,136 @@ +== Maps + +Codename One ships a modern mapping API in the `com.codename1.maps` package built around two components: + +* `MapView` -- a *pure-vector* map drawn entirely through the Codename One `Graphics` pipeline. It never embeds a native peer, so it composes cleanly with the rest of your UI (dialogs, lists and overlays draw over it) and behaves identically on every platform, including the simulator and the web. +* `NativeMap` -- a map backed by a *native provider* (Apple MapKit, Google Maps, ...) when one is wired into the build, and which transparently *falls back to a `MapView`* when no provider is available (the simulator, devices without the selected provider, or builds that didn't opt in). + +Both components implement the same `MapSurface` interface, so application code is identical regardless of which one -- or which provider -- is backing the map. + +NOTE: The legacy tile-based `MapComponent` and the external `codenameone-google-maps` cn1lib are deprecated in favor of this API. + +=== A first map + +The pure-vector `MapView` renders real maps with zero configuration and no API key -- by default it shows the free, keyless https://openfreemap.org[OpenFreeMap] vector basemap (real OpenStreetMap data): + +[source,java] +---- +MapView map = new MapView(); +map.moveCamera(new LatLng(37.7749, -122.4194), 12); +form.add(BorderLayout.CENTER, map); +---- + +`LatLng` is the immutable WGS84 coordinate value type used throughout the API. The camera is described by a center `LatLng` and a fractional zoom level (the standard slippy-map scale where each whole increment doubles the scale). + +image::img/maps-vector.png[The pure-vector MapView rendering OpenStreetMap data,scaledwidth=40%] + +=== The MapSurface API + +Every map -- vector or native -- exposes the same operations through `MapSurface`: + +[source,java] +---- +// Camera +map.setCameraPosition(new CameraPosition(new LatLng(48.8566, 2.3522), 11)); +map.moveCamera(new LatLng(48.8566, 2.3522), 11); +map.setZoom(13); +map.fitBounds(new MapBounds(new LatLng(48.8, 2.2), new LatLng(48.9, 2.4)), 24); + +// Markers +Marker m = map.addMarker(new MarkerOptions(new LatLng(48.8584, 2.2945)) + .icon(pinImage) + .title("Eiffel Tower") + .anchor(0.5f, 1.0f) + .onClick(e -> showDetails())); +map.removeMarker(m); + +// Shapes +map.addPolyline(new Polyline(routePoints).setStrokeColor(0xff5722).setStrokeWidth(6)); +map.addPolygon(new Polygon(areaPoints).setFillColor(0x803f51b5).setStrokeColor(0x3f51b5)); +map.addCircle(new Circle(new LatLng(48.85, 2.35), 500).setFillColor(0x804caf50)); +map.clearMapObjects(); + +// Coordinate conversion and bounds +Point pixel = map.latLngToScreen(new LatLng(48.85, 2.35)); +LatLng coord = map.screenToLatLng(120, 240); +MapBounds visible = map.getVisibleRegion(); + +// Events +map.addTapListener((surface, location, x, y) -> placeMarker(location)); +map.addLongPressListener((surface, location, x, y) -> contextMenu(location)); +map.addCameraChangeListener((surface, camera) -> persist(camera)); +---- + +Marker icons are supplied as `EncodedImage` and anchored in normalized `(u, v)` image space (`0.5, 1.0` puts the pin tip on the location). When no icon is given, a marker draws the standard Material Design map pin. Polygon and circle fills accept an `0xAARRGGBB` color so you can make them translucent. + +image::img/maps-markers.png[Markers drawn with the default Material map pin,scaledwidth=40%] + +=== Tile sources and styles (MapView) + +`MapView` pulls its tiles from a pluggable `com.codename1.maps.vector.TileSource`: + +* `MvtTileSource` -- networked Mapbox Vector Tiles (`.pbf`/`.mvt`). `MvtTileSource.openFreeMap()` is the free, keyless OpenStreetMap-based default (its tile URLs are versioned, so it resolves them from OpenFreeMap's TileJSON automatically). Most other hosted vector basemaps require an API key supplied via `setApiKey(...)` and referenced as `{key}` in the URL template; a TileJSON endpoint (a URL with no `{z}` token) is resolved automatically. +* `RasterTileSource` -- networked XYZ image tiles. `RasterTileSource.openStreetMap()` is a keyless raster alternative. +* `BundledTileSource` -- tiles bundled into the app as resources, for fully offline maps. +* `DemoTileSource` -- a self-contained synthetic tile set (no network), handy for demos and deterministic tests. + +[source,java] +---- +MapView vector = new MapView( + new MvtTileSource("https://tiles.example.com/{z}/{x}/{y}.pbf?key={key}", 0, 16) + .setApiKey(apiKey), + MapStyle.dark()); +---- + +Vector tiles are painted according to a `MapStyle`. The built-in `MapStyle.light()` and `MapStyle.dark()` cover a usable basemap; `MapStyle.fromJson(json)` parses a subset of the MapLibre GL style specification (`background`/`fill`/`line`/`symbol` layers, zoom-stop interpolation, simple `["==", key, value]` filters). + +image::img/maps-dark.png[The same vector data rendered with the built-in dark style,scaledwidth=40%] + +=== Native maps and providers + +`NativeMap` renders through a native map SDK when the build wires one in: + +[source,java] +---- +NativeMap map = new NativeMap(new LatLng(37.7749, -122.4194), 12); +map.addMarker(new MarkerOptions(new LatLng(37.7749, -122.4194)).title("San Francisco")); +form.add(BorderLayout.CENTER, map); + +if (!map.isNativeMap()) { + // Running on the simulator (or a build without a provider) -> vector fallback. +} +---- + +image::img/maps-native.png[NativeMap backed by Apple MapKit (ios.maps.provider=apple),scaledwidth=40%] + +Which provider (if any) backs the map is decided entirely by *build hints* -- the public API never names a provider, so unused providers add nothing to your app's size. Select one with the `maps.provider` build hint (or the per-platform `ios.maps.provider` / `android.maps.provider`): + +[source] +---- +# iOS uses Apple MapKit (a free system framework, no API key, no pod): +codename1.arg.ios.maps.provider=apple + +# Android uses Google Maps (pulls Google Play Services Maps): +codename1.arg.android.maps.provider=google +---- + +When a provider is selected the build server injects that provider's implementation into your app and wires it in; with no provider selected (or when the provider is unavailable at runtime, for example when Google Play Services is missing) `NativeMap` simply renders the vector `MapView` fallback. You can configure the fallback basemap explicitly: + +[source,java] +---- +NativeMap map = new NativeMap(new LatLng(0, 0), 4, fallbackTileSource, MapStyle.light()); +---- + +==== Provider API keys + +Apple MapKit needs no key. Google Maps requires the usual keys, supplied as build hints: + +[source] +---- +codename1.arg.android.xapplication= +codename1.arg.ios.afterFinishLaunching=[GMSServices provideAPIKey:@"YOUR_IOS_API_KEY"]; +---- + +=== Choosing between MapView and NativeMap + +Use `MapView` when you want a lightweight, dependency-free map that looks the same everywhere and composes with CN1 components. Use `NativeMap` when you want the platform's native map look, gestures and performance, and are willing to opt a provider in through build hints -- with the guarantee that it still works (as a vector map) where the provider is unavailable. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index b6d7acf2d5..0b1f1cbc3a 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -81,6 +81,8 @@ include::Push-Notifications.asciidoc[] include::Notifications-And-Background-Execution.asciidoc[] +include::Maps.asciidoc[] + include::Miscellaneous-Features.asciidoc[] include::performance.asciidoc[] diff --git a/docs/developer-guide/img/maps-dark.png b/docs/developer-guide/img/maps-dark.png new file mode 100644 index 0000000000..1cf3aea78a Binary files /dev/null and b/docs/developer-guide/img/maps-dark.png differ diff --git a/docs/developer-guide/img/maps-markers.png b/docs/developer-guide/img/maps-markers.png new file mode 100644 index 0000000000..ef8b16e550 Binary files /dev/null and b/docs/developer-guide/img/maps-markers.png differ diff --git a/docs/developer-guide/img/maps-native.png b/docs/developer-guide/img/maps-native.png new file mode 100644 index 0000000000..3290cf78d6 Binary files /dev/null and b/docs/developer-guide/img/maps-native.png differ diff --git a/docs/developer-guide/img/maps-vector.png b/docs/developer-guide/img/maps-vector.png new file mode 100644 index 0000000000..24e76e02d1 Binary files /dev/null and b/docs/developer-guide/img/maps-vector.png differ diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index 42844bbd26..7821803c3f 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -622,3 +622,9 @@ transcoder # in the crash-reporting space talks. Shows up in CrashReportPayload # field descriptions. dedup + +# Maps chapter: vector-map terminology. "basemap"/"basemaps" is the standard +# one-word cartography term for the background reference map, and "Mapbox" is +# the vendor whose Mapbox Vector Tiles (MVT) format the engine decodes. +[Bb]asemaps? +Mapbox diff --git a/docs/maps-provider-ci-setup.md b/docs/maps-provider-ci-setup.md new file mode 100644 index 0000000000..560e6a01c6 --- /dev/null +++ b/docs/maps-provider-ci-setup.md @@ -0,0 +1,189 @@ +# Testing native map providers in CI (keys & secrets) + +This is a contributor/CI guide for exercising the `NativeMap` providers +(`maps.provider=apple` / `google`) in automated builds. End-user app setup +(adding keys to your own app) is in the developer guide's *Maps* chapter. + +## Why provider tests are different + +The pure-vector `MapView` is fully covered by deterministic JVM unit tests and +offline screenshot tests (it renders the same everywhere). Native providers are +not: + +1. **They only render on a real device/simulator.** In the JavaSE simulator + `NativeMap` falls back to the vector `MapView`, so a native provider is only + exercised by the **iOS / Android device-runner** jobs + (`scripts/run-ios-ui-tests.sh`, `scripts/run-android-instrumentation-tests.sh`). +2. **A live native map is non-deterministic.** Map tiles, labels and styling + come from the provider's servers and load asynchronously, so a pixel-exact + screenshot baseline is not reliable. Provider coverage therefore comes in two + forms: + - **Smoke build tests (deterministic, recommended):** build the app with the + provider selected and assert the build *succeeds* and the app launches and + renders a map without crashing. This is exactly what catches regressions in + the provider injection (the bytecode→native bridge, frameworks, gradle deps, + `register()` wiring). Apple needs no key, so this runs with no secrets. + - **Loose-tolerance screenshots (optional):** capture the live map with a high + pixel-mismatch tolerance (`.tolerance` file next to the baseline). Use + sparingly; prefer the smoke build test. + +## The native-map screenshot test + +`NativeMapProviderScreenshotTest` (in the hellocodenameone suite) is the visual +guard. It only emits a screenshot when a native provider is actually active +(`NativeMap.isNativeMap()`); otherwise it skips, and the vector-fallback path is +covered by `NativeMapFallbackScreenshotTest` (the two are complements -- exactly +one runs per platform/build). Because the test app sets +`codename1.arg.ios.maps.provider=apple`, the **iOS device-runner produces a real +MapKit baseline with no secret**; Android produces a Google baseline only when +the keys below are provided. + +Variance is controlled deliberately so the baseline is stable yet a blocked map +still fails: + +- **Low-variance scene:** a regional view of the Italian peninsula + the + Mediterranean. Natural geography does not change, there is strong land/water + contrast, and at this zoom there is no traffic layer, no street-level churn and + minimal label movement. (Default standard map type, user-location dot off.) +- **Lenient comparison:** each baseline has a `.tolerance` file + (`maxChannelDelta=20`, `maxMismatchPercent=12.0`) so day-to-day tile/label + noise does not fail CI -- while a blank/blocked map, which differs from a real + render across essentially the whole frame, still does. + +This is what catches the failure mode you actually care about: a provider that +"builds and launches" but renders nothing (bad key, missing framework, Play +Services unavailable, a broken bridge) -- invisible to a smoke build, obvious in +the screenshot. + +## Apple MapKit -- no key required + +MapKit is a free iOS system framework. To exercise it, build hellocodenameone +for iOS with the hint set: + +``` +codename1.arg.ios.maps.provider=apple +``` + +The build injects the MapKit provider, adds the `MapKit`/`CoreLocation` +frameworks and the `NSLocationWhenInUseUsageDescription` plist string, and the +app renders an `MKMapView` through `NativeMap`. No secret is needed. + +## Google Maps -- API keys + GitHub secrets + +Google Maps needs an Android key and an iOS key. + +### 1. Create the keys in Google Cloud + +1. Go to , create (or pick) a project. +2. **APIs & Services -> Library**, enable **Maps SDK for Android** and **Maps + SDK for iOS**. +3. **APIs & Services -> Credentials -> Create credentials -> API key**. Create + two keys (one Android, one iOS) so you can restrict each. +4. **Restrict each key** (Credentials -> the key -> *Edit*): + - *Android key:* Application restrictions -> **Android apps**; add your + package name (`com.codenameone.examples.hellocodenameone` for the test app) + and the debug-keystore SHA-1 fingerprint + (`keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android`). + API restrictions -> **Maps SDK for Android**. + - *iOS key:* Application restrictions -> **iOS apps**; add the bundle id + (`com.codenameone.examples.hellocodenameone`). API restrictions -> **Maps + SDK for iOS**. + +> The CI test app's package/bundle id is +> `com.codenameone.examples.hellocodenameone`. For ad-hoc local testing you may +> leave a key unrestricted, but always restrict keys used in CI. + +### 2. Add the keys as GitHub Actions secrets + +In the repository: **Settings -> Secrets and variables -> Actions -> New +repository secret**. Add: + +| Secret name | Value | +|---------------------------------|--------------------------------| +| `GOOGLE_MAPS_ANDROID_API_KEY` | the restricted Android key | +| `GOOGLE_MAPS_IOS_API_KEY` | the restricted iOS key | + +(Use organization-level secrets if you want them shared across repos.) + +### 3. How the workflow consumes them + +The provider build maps the secrets to build hints before building. Android key +goes into the manifest meta-data; iOS key into the app-delegate launch call: + +```yaml +env: + GOOGLE_MAPS_ANDROID_API_KEY: ${{ secrets.GOOGLE_MAPS_ANDROID_API_KEY }} + GOOGLE_MAPS_IOS_API_KEY: ${{ secrets.GOOGLE_MAPS_IOS_API_KEY }} +steps: + - name: Select Google provider + inject keys (skips if no secret) + if: env.GOOGLE_MAPS_ANDROID_API_KEY != '' + run: | + SETTINGS=scripts/hellocodenameone/common/codenameone_settings.properties + { + echo "codename1.arg.maps.provider=google" + echo "codename1.arg.android.xapplication=" + echo "codename1.arg.ios.afterFinishLaunching=[GMSServices provideAPIKey:@\"$GOOGLE_MAPS_IOS_API_KEY\"];" + } >> "$SETTINGS" + - run: ./scripts/run-ios-ui-tests.sh # or run-android-instrumentation-tests.sh +``` + +The `if: env.… != ''` guard means forks and PRs without the secret simply skip +the Google job rather than failing -- the Apple job (no key) always runs. + +## Huawei Map Kit (HMS) -- key + agconnect config + +`maps.provider=huawei` targets Huawei devices that ship HMS Core instead of +Google Play Services. + +### Create the credentials + +1. Register at (a *verified* Huawei + developer account is required; verification can take a couple of days). +2. In **AppGallery Connect** () + create a **project** and an **app** (platform: Android), using the same + package name as your build. +3. **Project settings -> Manage APIs**: enable **Map Kit**. +4. **Project settings -> General information**: copy the **App ID** and the + **API key** ("Client -> API key"). Download **`agconnect-services.json`**. +5. Add the SHA-256 fingerprint of your signing certificate under + *Project settings -> General -> SHA-256 certificate fingerprint*. + +### Add as secrets + +| Secret name | Value | +|-----------------------------|----------------------------------------| +| `HUAWEI_MAPS_API_KEY` | the AppGallery Connect API key | +| `HUAWEI_AGCONNECT_JSON` | the contents of `agconnect-services.json` (base64 or raw) | + +Wire them into the build the same way (provider hint + key/JSON build hints). + +> **CI testability:** HMS maps render only on a device/emulator that has **HMS +> Core** installed. The standard Google Android emulators used in CI do not, so +> the Huawei provider cannot be screenshot-tested on ordinary CI runners -- the +> map would (correctly) fall back to vector. Cover Huawei with the smoke build +> (it injects + compiles against the HMS SDK) and validate the render on a real +> Huawei device. The keys above are still needed for that manual/device run. + +## Bing Maps (Windows / UWP) -- key + +The Windows native provider uses Bing Maps. + +1. Sign in at the **Bing Maps Dev Center** (). +2. **My account -> My keys -> Create a new key** (key type: *Basic* or + *Enterprise*; application type: as appropriate). +3. Provide the token to the app at runtime: + `Display.setProperty("windows.bingmaps.token", "YOUR_BING_KEY");` (or as a + build hint). As a secret: `BING_MAPS_TOKEN`. + +> **CI testability:** only relevant to Windows/UWP builds; not part of the +> iOS/Android device-runner screenshot suite. + +## Summary + +| Provider | Key? | Secret(s) | What's tested in CI | +|----------|------|-----------|---------------------| +| Vector `MapView` (OSM) | no | -- | JVM unit tests + offline + real-OSM screenshot | +| Apple MapKit | no | -- | iOS smoke build **+ live MapKit screenshot** (`NativeMapProvider`), no secret | +| Google Maps | yes | `GOOGLE_MAPS_ANDROID_API_KEY`, `GOOGLE_MAPS_IOS_API_KEY` | iOS + Android smoke build + screenshot, gated on secrets | +| Huawei Map Kit | yes | `HUAWEI_MAPS_API_KEY`, `HUAWEI_AGCONNECT_JSON` | smoke build only (render needs an HMS device, not CI emulators) | +| Bing Maps (Windows) | yes | `BING_MAPS_TOKEN` | Windows build only (not in the device-runner suite) | diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 9d59735734..dc76f891a3 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -1061,6 +1061,11 @@ public boolean build(File sourceZip, final BuildRequest request) throws BuildExc File srcDir = new File(projectDir, "src/main/java"); srcDir.mkdirs(); + // Native map provider injection (no-op unless maps.provider is set): + // pushes the selected provider's implementation into the app's + // com.codename1.maps package and returns the onCreate snippet that + // registers it. Keeps the core framework free of any map SDK. + String mapsProviderSupport = MapsProviderInjector.injectAndroid(this, request, srcDir); File dummyClassesDir = new File(tmpFile, "Classes"); dummyClassesDir.mkdirs(); File libsDir = new File(projectDir, "libs"); @@ -3236,6 +3241,7 @@ public void usesClassMethod(String cls, String method) { + rootCheckCall + facebookHashCode + facebookSupport + + mapsProviderSupport + streamMode + registerNativeImplementationsAndCreateStubs( new URLClassLoader( diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index ee76f884d4..7d8f4603b2 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -1266,6 +1266,27 @@ public void usesClassMethod(String cls, String method) { File stubSource = new File(tmpFile, "stub"); stubSource.mkdirs(); + // Native map provider injection (no-op unless maps.provider=apple): + // writes the MapKit provider's Java into the stub source (compiled by + // javac + translated by ParparVM) and its Objective-C into the native + // sources, returning the startup snippet that registers it. Keeps the + // core framework free of any map SDK. + String integrateMaps = MapsProviderInjector.injectIos(this, request, stubSource, buildinRes); + if (integrateMaps.length() > 0) { + StringBuilder libs = new StringBuilder(request.getArg("ios.add_libs", "")); + String[] fw = MapsProviderInjector.iosFrameworks(request); + for (int fwi = 0; fwi < fw.length; fwi++) { + if (libs.length() > 0) { + libs.append(';'); + } + libs.append(fw[fwi]); + } + request.putArgument("ios.add_libs", libs.toString()); + if (request.getArg("ios.NSLocationWhenInUseUsageDescription", null) == null) { + request.putArgument("ios.NSLocationWhenInUseUsageDescription", + "Shows your location on the map."); + } + } try { generateUnitTestFiles(request, stubSource); } catch (Exception ex) { @@ -1334,6 +1355,7 @@ public void usesClassMethod(String cls, String method) { + integrateOidcBrowser + integrateAppleSignIn + integrateWebauthn + + integrateMaps + " if(!initialized) {\n" + " initialized = true;\n" diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MapsProviderInjector.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MapsProviderInjector.java new file mode 100644 index 0000000000..9822c11eaa --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MapsProviderInjector.java @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; + +/** + * Injects a native map provider into an app build when the {@code maps.provider} + * build hint selects one. The public maps API ({@code com.codename1.maps.MapView} + * / {@code NativeMap}) never names a provider; this is the single point where a + * concrete provider (its native-method-bearing implementation) is pushed into + * the {@code com.codename1.maps} package of the app and wired in, keeping the + * core framework free of any heavyweight provider SDK. + * + *

The hint is resolved per platform: {@code android.maps.provider} / + * {@code ios.maps.provider} override the generic {@code maps.provider}. When no + * hint is set the methods are inert, so default builds are completely + * unaffected.

+ * + *

Android has an app-source compile step, so the provider's Java + * implementation is injected as source ({@code MapProviderImpl.java}) and + * compiled against the build-injected Google Play Services dependency. iOS + * translates compiled bytecode (no app javac), so only the Objective-C + * MapKit implementation is injected here; the Java side of the iOS provider is + * supplied precompiled (port/library) and wired by the native-interface + * bridge.

+ */ +public final class MapsProviderInjector { + + private static final String REGISTER_CALL = + " com.codename1.maps.MapProviderImpl.register();\n"; + + private MapsProviderInjector() { + } + + /** + * Resolves the selected provider id for {@code platform} ("android"/"ios"), + * or {@code null} when no provider hint is set. + */ + public static String resolveProvider(BuildRequest request, String platform) { + String p = request.getArg(platform + ".maps.provider", request.getArg("maps.provider", "")); + if (p == null) { + return null; + } + p = p.trim().toLowerCase(); + return p.length() == 0 ? null : p; + } + + /** + * Injects the Android provider implementation and dependencies. Returns the + * startup snippet to splice into the activity's {@code onCreate}, or an + * empty string when no provider is selected. + * + * @param exec the running builder (for resource access / file copy) + * @param request the build request carrying the hints + * @param srcDir the generated project's {@code src/main/java} root + */ + public static String injectAndroid(Executor exec, BuildRequest request, File srcDir) { + String provider = resolveProvider(request, "android"); + String template = androidTemplate(provider); + if (template == null) { + return ""; + } + try { + File pkgDir = new File(srcDir, "com" + File.separator + "codename1" + File.separator + "maps"); + pkgDir.mkdirs(); + File out = new File(pkgDir, "MapProviderImpl.java"); + copyResource(exec, template, out); + } catch (Exception ex) { + throw new RuntimeException("Failed to inject map provider '" + provider + "'", ex); + } + if ("google".equals(provider)) { + addGradleDependency(request, "com.google.android.gms:play-services-maps:18.2.0"); + } else if ("huawei".equals(provider)) { + addGradleDependency(request, "com.huawei.hms:maps:6.11.0.300"); + } + return REGISTER_CALL; + } + + /** + * Injects the iOS provider when one is selected. The provider's Java + * implementation is written into the stub source dir as + * {@code MapProviderImpl.java} (the build's javac compiles it and ParparVM + * translates it to C, where its native methods bind to the injected + * Objective-C), and the Objective-C is written into the native sources + * dir. Returns the {@code onCreate}/startup snippet that registers the + * provider, or an empty string when no provider is selected. + * + * @param exec the running builder + * @param request the build request + * @param stubSrc the generated stub source dir (compiled by javac) + * @param nativeDir the generated Xcode project's native-sources directory + */ + public static String injectIos(Executor exec, BuildRequest request, File stubSrc, File nativeDir) { + String provider = resolveProvider(request, "ios"); + if (!"apple".equals(provider)) { + return ""; + } + try { + File pkgDir = new File(stubSrc, "com" + File.separator + "codename1" + File.separator + "maps"); + pkgDir.mkdirs(); + copyResource(exec, "maps/AppleMapProvider.javas", new File(pkgDir, "MapProviderImpl.java")); + // Use a non-colliding file name: ParparVM generates its own + // com_codename1_maps_MapProviderImpl.m (the Java translation), so + // our native implementation must live in a different file. The + // C symbol names inside resolve the externs regardless of filename. + copyResource(exec, "maps/AppleMapProvider.m", + new File(nativeDir, "CN1AppleMapKit.m")); + } catch (Exception ex) { + throw new RuntimeException("Failed to inject Apple map provider", ex); + } + return REGISTER_CALL; + } + + /** + * The system frameworks the selected iOS provider requires, or an empty + * array when no provider is selected. + */ + public static String[] iosFrameworks(BuildRequest request) { + if ("apple".equals(resolveProvider(request, "ios"))) { + return new String[]{"MapKit.framework", "CoreLocation.framework"}; + } + return new String[0]; + } + + private static String androidTemplate(String provider) { + if (provider == null || provider.length() == 0) { + return null; + } + if ("apple".equals(provider)) { + // Apple MapKit is iOS-only; on Android this hint means "no native + // provider", so NativeMap falls back to the vector MapView. + return null; + } + // Convention: maps/MapProvider.javas. Adding a new + // provider (Bing, Huawei, ...) is just dropping a template with the + // matching name; only Google is bundled by default. + return "maps/" + Character.toUpperCase(provider.charAt(0)) + + provider.substring(1) + "MapProvider.javas"; + } + + private static void addGradleDependency(BuildRequest request, String gav) { + String existing = request.getArg("gradleDependencies", ""); + if (existing.contains(gav)) { + return; + } + request.putArgument("gradleDependencies", + existing + "\n implementation '" + gav + "'\n"); + } + + private static void copyResource(Executor exec, String resource, File out) throws Exception { + InputStream is = exec.getResourceAsStream(resource); + if (is == null) { + throw new IllegalStateException("Missing map provider template resource: " + resource); + } + FileOutputStream os = new FileOutputStream(out); + try { + Executor.copy(is, os); + } finally { + os.close(); + is.close(); + } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/maps/AppleMapProvider.javas b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/maps/AppleMapProvider.javas new file mode 100644 index 0000000000..6b8eec9f00 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/maps/AppleMapProvider.javas @@ -0,0 +1,210 @@ +/* + * Codename One maps provider -- Apple MapKit (iOS). + * + * BUILD TEMPLATE. When the build hint maps.provider=apple (the iOS default) + * is set, the iOS builder copies this file into the generated stub source dir + * as com/codename1/maps/MapProviderImpl.java. It is compiled by the build's + * javac (alongside the app stub) and translated to C by ParparVM, so its + * native methods bind to the hand-written Objective-C in + * com_codename1_maps_MapProviderImpl.m. The builder also weaves a call to + * MapProviderImpl.register() into the app startup. No NativeInterface and no + * implementation-layer hooks are involved; MapKit is a free system framework. + */ +package com.codename1.maps; + +import com.codename1.maps.spi.MapProvider; +import com.codename1.maps.spi.MapProviderRegistry; +import com.codename1.ui.PeerComponent; + +import java.util.ArrayList; +import java.util.List; + +public class MapProviderImpl implements MapProvider { + + private double calcLat; + private double calcLon; + private List pathPoints; + + /** Invoked by build-injected startup code. */ + public static void register() { + MapProviderRegistry.register(new MapProviderImpl()); + MapProviderRegistry.setPreferredProvider("apple"); + } + + public String getId() { + return "apple"; + } + + public boolean isAvailable() { + return true; + } + + public PeerComponent createPeer(NativeMap host, int mapId) { + LatLng center = host.getInitialCenter(); + long peer = nativeCreate(mapId, center.getLatitude(), center.getLongitude(), + (float) host.getInitialZoom()); + if (peer == 0) { + return null; + } + return PeerComponent.create(new long[]{peer}); + } + + public void deinitialize(int mapId) { + nativeDeinit(mapId); + } + + public void setCamera(int mapId, double lat, double lon, float zoom, float bearing, float tilt) { + nativeSetCamera(mapId, lat, lon, zoom); + } + + public double getLatitude(int mapId) { + return nativeGetLat(mapId); + } + + public double getLongitude(int mapId) { + return nativeGetLon(mapId); + } + + public float getZoom(int mapId) { + return nativeGetZoom(mapId); + } + + public float getMaxZoom(int mapId) { + return 20; + } + + public float getMinZoom(int mapId) { + return 0; + } + + public long addMarker(int mapId, byte[] icon, double lat, double lon, + String title, String snippet, float anchorU, float anchorV) { + return nativeAddMarker(mapId, lat, lon, title == null ? "" : title); + } + + public long beginPath(int mapId) { + pathPoints = new ArrayList(); + return 0; + } + + public void addToPath(int mapId, long pathId, double lat, double lon) { + if (pathPoints != null) { + pathPoints.add(new double[]{lat, lon}); + } + } + + public long finishPolyline(int mapId, long pathId, int strokeColor, int strokeWidth) { + double[][] arr = drainPath(); + return nativeAddPolyline(mapId, arr[0], arr[1], strokeColor, strokeWidth); + } + + public long finishPolygon(int mapId, long pathId, int fillColor, int strokeColor, int strokeWidth) { + double[][] arr = drainPath(); + return nativeAddPolygon(mapId, arr[0], arr[1], fillColor, strokeColor, strokeWidth); + } + + private double[][] drainPath() { + List pts = pathPoints; + pathPoints = null; + int n = pts == null ? 0 : pts.size(); + double[] lats = new double[n]; + double[] lons = new double[n]; + for (int i = 0; i < n; i++) { + double[] p = pts.get(i); + lats[i] = p[0]; + lons[i] = p[1]; + } + return new double[][]{lats, lons}; + } + + public long addCircle(int mapId, double lat, double lon, double radiusMeters, + int fillColor, int strokeColor, int strokeWidth) { + return nativeAddCircle(mapId, lat, lon, radiusMeters, fillColor, strokeColor, strokeWidth); + } + + public void removeElement(int mapId, long elementId) { + nativeRemove(mapId, elementId); + } + + public void removeAllElements(int mapId) { + nativeRemoveAll(mapId); + } + + public void calcScreenPosition(int mapId, double lat, double lon) { + calcLat = lat; + calcLon = lon; + } + + public int getScreenX(int mapId) { + return nativeScreenX(mapId, calcLat, calcLon); + } + + public int getScreenY(int mapId) { + return nativeScreenY(mapId, calcLat, calcLon); + } + + public void calcLatLongPosition(int mapId, int x, int y) { + calcLat = nativeLat(mapId, x, y); + calcLon = nativeLon(mapId, x, y); + } + + public double getScreenLat(int mapId) { + return calcLat; + } + + public double getScreenLon(int mapId) { + return calcLon; + } + + public void setShowMyLocation(int mapId, boolean show) { + nativeSetShowMyLocation(mapId, show); + } + + public void setRotateGestureEnabled(int mapId, boolean enabled) { + nativeSetRotateEnabled(mapId, enabled); + } + + public void setMapType(int mapId, int type) { + nativeSetMapType(mapId, type); + } + + // ---- Native bridge (implemented in com_codename1_maps_MapProviderImpl.m) + + private native long nativeCreate(int mapId, double lat, double lon, float zoom); + + private native void nativeDeinit(int mapId); + + private native void nativeSetCamera(int mapId, double lat, double lon, float zoom); + + private native double nativeGetLat(int mapId); + + private native double nativeGetLon(int mapId); + + private native float nativeGetZoom(int mapId); + + private native long nativeAddMarker(int mapId, double lat, double lon, String title); + + private native long nativeAddPolyline(int mapId, double[] lats, double[] lons, int color, int width); + + private native long nativeAddPolygon(int mapId, double[] lats, double[] lons, int fill, int stroke, int width); + + private native long nativeAddCircle(int mapId, double lat, double lon, double radius, int fill, int stroke, int width); + + private native void nativeRemove(int mapId, long elementId); + + private native void nativeRemoveAll(int mapId); + + private native int nativeScreenX(int mapId, double lat, double lon); + + private native int nativeScreenY(int mapId, double lat, double lon); + + private native double nativeLat(int mapId, int x, int y); + + private native double nativeLon(int mapId, int x, int y); + + private native void nativeSetShowMyLocation(int mapId, boolean show); + + private native void nativeSetRotateEnabled(int mapId, boolean enabled); + + private native void nativeSetMapType(int mapId, int type); +} diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/maps/AppleMapProvider.m b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/maps/AppleMapProvider.m new file mode 100644 index 0000000000..3e3f11aa70 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/maps/AppleMapProvider.m @@ -0,0 +1,345 @@ +/* + * Codename One maps provider -- Apple MapKit (iOS). + * + * BUILD TEMPLATE. Copied into the generated Xcode project's native sources as + * CN1AppleMapKit.m when maps.provider=apple. Implements the native methods + * declared by the injected com.codename1.maps.MapProviderImpl (ParparVM binds + * them by the symbol names below) and forwards taps, long-presses and camera + * changes back into Java via the static callbacks on + * com.codename1.maps.NativeMap. MapKit is a free iOS system framework. + * + * watchOS note: MKMapView and the overlay renderers are unavailable on + * watchOS, so on that platform we compile a set of no-op stubs that keep the + * translated provider linkable. nativeCreate returns 0 there, which makes + * MapProviderImpl.createPeer return null and NativeMap fall back to the + * pure-vector MapView. + */ +#import + +#ifndef BRIDGE_CAST +#if __has_feature(objc_arc) +#define BRIDGE_CAST __bridge +#else +#define BRIDGE_CAST +#endif +#endif + +#if TARGET_OS_WATCH + +// ---- watchOS stubs --------------------------------------------------------- +// MapKit map views are unavailable on watchOS. These keep the symbols the +// translated MapProviderImpl references resolvable; the map degrades to the +// vector MapView at runtime because nativeCreate returns 0. + +JAVA_LONG com_codename1_maps_MapProviderImpl_nativeCreate___int_double_double_float_R_long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_DOUBLE lat, JAVA_DOUBLE lon, JAVA_FLOAT zoom) { return 0; } +void com_codename1_maps_MapProviderImpl_nativeDeinit___int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId) {} +void com_codename1_maps_MapProviderImpl_nativeSetCamera___int_double_double_float(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_DOUBLE lat, JAVA_DOUBLE lon, JAVA_FLOAT zoom) {} +JAVA_DOUBLE com_codename1_maps_MapProviderImpl_nativeGetLat___int_R_double(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId) { return 0; } +JAVA_DOUBLE com_codename1_maps_MapProviderImpl_nativeGetLon___int_R_double(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId) { return 0; } +JAVA_FLOAT com_codename1_maps_MapProviderImpl_nativeGetZoom___int_R_float(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId) { return 0; } +JAVA_LONG com_codename1_maps_MapProviderImpl_nativeAddMarker___int_double_double_java_lang_String_R_long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_DOUBLE lat, JAVA_DOUBLE lon, JAVA_OBJECT title) { return 0; } +JAVA_LONG com_codename1_maps_MapProviderImpl_nativeAddPolyline___int_double_1ARRAY_double_1ARRAY_int_int_R_long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_OBJECT lats, JAVA_OBJECT lons, JAVA_INT color, JAVA_INT width) { return 0; } +JAVA_LONG com_codename1_maps_MapProviderImpl_nativeAddPolygon___int_double_1ARRAY_double_1ARRAY_int_int_int_R_long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_OBJECT lats, JAVA_OBJECT lons, JAVA_INT fill, JAVA_INT stroke, JAVA_INT width) { return 0; } +JAVA_LONG com_codename1_maps_MapProviderImpl_nativeAddCircle___int_double_double_double_int_int_int_R_long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_DOUBLE lat, JAVA_DOUBLE lon, JAVA_DOUBLE radius, JAVA_INT fill, JAVA_INT stroke, JAVA_INT width) { return 0; } +void com_codename1_maps_MapProviderImpl_nativeRemove___int_long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_LONG elementId) {} +void com_codename1_maps_MapProviderImpl_nativeRemoveAll___int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId) {} +JAVA_INT com_codename1_maps_MapProviderImpl_nativeScreenX___int_double_double_R_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_DOUBLE lat, JAVA_DOUBLE lon) { return 0; } +JAVA_INT com_codename1_maps_MapProviderImpl_nativeScreenY___int_double_double_R_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_DOUBLE lat, JAVA_DOUBLE lon) { return 0; } +JAVA_DOUBLE com_codename1_maps_MapProviderImpl_nativeLat___int_int_int_R_double(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_INT x, JAVA_INT y) { return 0; } +JAVA_DOUBLE com_codename1_maps_MapProviderImpl_nativeLon___int_int_int_R_double(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_INT x, JAVA_INT y) { return 0; } +void com_codename1_maps_MapProviderImpl_nativeSetShowMyLocation___int_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_BOOLEAN show) {} +void com_codename1_maps_MapProviderImpl_nativeSetRotateEnabled___int_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_BOOLEAN enabled) {} +void com_codename1_maps_MapProviderImpl_nativeSetMapType___int_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_INT type) {} + +#else + +#import +#import + +extern NSString* toNSString(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT str); + +extern void com_codename1_maps_NativeMap_fireTap___int_int_int(CN1_THREAD_STATE_MULTI_ARG JAVA_INT mapId, JAVA_INT x, JAVA_INT y); +extern void com_codename1_maps_NativeMap_fireLongPress___int_int_int(CN1_THREAD_STATE_MULTI_ARG JAVA_INT mapId, JAVA_INT x, JAVA_INT y); +extern void com_codename1_maps_NativeMap_fireCameraChange___int(CN1_THREAD_STATE_MULTI_ARG JAVA_INT mapId); + +@interface CN1AppleMap : NSObject +@property (nonatomic, assign) int mapId; +@property (nonatomic, strong) MKMapView *mapView; +@property (nonatomic, strong) NSMutableDictionary *elements; +@property (nonatomic, assign) long nextId; +@end + +@implementation CN1AppleMap + +- (instancetype)initWithMapId:(int)mapId { + self = [super init]; + if (self) { + _mapId = mapId; + _nextId = 1; + _elements = [NSMutableDictionary dictionary]; + _mapView = [[MKMapView alloc] initWithFrame:CGRectMake(0, 0, 320, 480)]; + _mapView.delegate = self; + UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] + initWithTarget:self action:@selector(onTap:)]; + [_mapView addGestureRecognizer:tap]; + UILongPressGestureRecognizer *lp = [[UILongPressGestureRecognizer alloc] + initWithTarget:self action:@selector(onLongPress:)]; + [_mapView addGestureRecognizer:lp]; + } + return self; +} + +- (void)onTap:(UITapGestureRecognizer *)g { + CGPoint p = [g locationInView:self.mapView]; + com_codename1_maps_NativeMap_fireTap___int_int_int(getThreadLocalData(), self.mapId, (int)p.x, (int)p.y); +} + +- (void)onLongPress:(UILongPressGestureRecognizer *)g { + if (g.state != UIGestureRecognizerStateBegan) { + return; + } + CGPoint p = [g locationInView:self.mapView]; + com_codename1_maps_NativeMap_fireLongPress___int_int_int(getThreadLocalData(), self.mapId, (int)p.x, (int)p.y); +} + +- (void)mapView:(MKMapView *)mapView regionDidChangeAnimated:(BOOL)animated { + com_codename1_maps_NativeMap_fireCameraChange___int(getThreadLocalData(), self.mapId); +} + +- (MKOverlayRenderer *)mapView:(MKMapView *)mapView rendererForOverlay:(id)overlay { + if ([overlay isKindOfClass:[MKPolyline class]]) { + MKPolylineRenderer *r = [[MKPolylineRenderer alloc] initWithPolyline:overlay]; + r.strokeColor = [UIColor blueColor]; + r.lineWidth = 4; + return r; + } + if ([overlay isKindOfClass:[MKPolygon class]]) { + MKPolygonRenderer *r = [[MKPolygonRenderer alloc] initWithPolygon:overlay]; + r.fillColor = [[UIColor blueColor] colorWithAlphaComponent:0.25]; + r.strokeColor = [UIColor blueColor]; + r.lineWidth = 2; + return r; + } + if ([overlay isKindOfClass:[MKCircle class]]) { + MKCircleRenderer *r = [[MKCircleRenderer alloc] initWithCircle:overlay]; + r.fillColor = [[UIColor greenColor] colorWithAlphaComponent:0.25]; + r.strokeColor = [UIColor greenColor]; + r.lineWidth = 2; + return r; + } + return nil; +} + +@end + +static NSMutableDictionary *cn1AppleMaps() { + static NSMutableDictionary *maps = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ maps = [NSMutableDictionary dictionary]; }); + return maps; +} + +static CN1AppleMap *cn1MapFor(int mapId) { + return [cn1AppleMaps() objectForKey:[NSNumber numberWithInt:mapId]]; +} + +static float spanToZoom(double lonDelta) { + if (lonDelta <= 0) { + return 0; + } + return (float)(log(360.0 / lonDelta) / log(2.0)); +} + +static double zoomToSpan(float zoom) { + return 360.0 / pow(2.0, zoom); +} + +JAVA_LONG com_codename1_maps_MapProviderImpl_nativeCreate___int_double_double_float_R_long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_DOUBLE lat, JAVA_DOUBLE lon, JAVA_FLOAT zoom) { + __block CN1AppleMap *m = nil; + double span = zoomToSpan((float)zoom); + void (^createBlock)(void) = ^{ + m = [[CN1AppleMap alloc] initWithMapId:(int)mapId]; + MKCoordinateRegion region = MKCoordinateRegionMake( + CLLocationCoordinate2DMake(lat, lon), MKCoordinateSpanMake(span, span)); + [m.mapView setRegion:region animated:NO]; + [cn1AppleMaps() setObject:m forKey:[NSNumber numberWithInt:(int)mapId]]; + }; + // The Codename One iOS EDT runs on the main thread, so a dispatch_sync to + // the main queue from here would deadlock. Create inline when already on + // the main thread; only marshal when invoked from a background thread. + if ([NSThread isMainThread]) { + createBlock(); + } else { + dispatch_sync(dispatch_get_main_queue(), createBlock); + } + return (JAVA_LONG)((BRIDGE_CAST void*)m.mapView); +} + +void com_codename1_maps_MapProviderImpl_nativeDeinit___int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId) { + [cn1AppleMaps() removeObjectForKey:[NSNumber numberWithInt:(int)mapId]]; +} + +void com_codename1_maps_MapProviderImpl_nativeSetCamera___int_double_double_float(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_DOUBLE lat, JAVA_DOUBLE lon, JAVA_FLOAT zoom) { + CN1AppleMap *m = cn1MapFor((int)mapId); + if (!m) { return; } + double span = zoomToSpan((float)zoom); + dispatch_async(dispatch_get_main_queue(), ^{ + MKCoordinateRegion region = MKCoordinateRegionMake( + CLLocationCoordinate2DMake(lat, lon), MKCoordinateSpanMake(span, span)); + [m.mapView setRegion:region animated:YES]; + }); +} + +JAVA_DOUBLE com_codename1_maps_MapProviderImpl_nativeGetLat___int_R_double(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId) { + CN1AppleMap *m = cn1MapFor((int)mapId); + return m ? m.mapView.centerCoordinate.latitude : 0; +} + +JAVA_DOUBLE com_codename1_maps_MapProviderImpl_nativeGetLon___int_R_double(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId) { + CN1AppleMap *m = cn1MapFor((int)mapId); + return m ? m.mapView.centerCoordinate.longitude : 0; +} + +JAVA_FLOAT com_codename1_maps_MapProviderImpl_nativeGetZoom___int_R_float(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId) { + CN1AppleMap *m = cn1MapFor((int)mapId); + return m ? spanToZoom(m.mapView.region.span.longitudeDelta) : 0; +} + +JAVA_LONG com_codename1_maps_MapProviderImpl_nativeAddMarker___int_double_double_java_lang_String_R_long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_DOUBLE lat, JAVA_DOUBLE lon, JAVA_OBJECT title) { + CN1AppleMap *m = cn1MapFor((int)mapId); + if (!m) { return 0; } + NSString *t = title ? toNSString(threadStateData, title) : nil; + MKPointAnnotation *a = [[MKPointAnnotation alloc] init]; + a.coordinate = CLLocationCoordinate2DMake(lat, lon); + a.title = t; + long eid = m.nextId++; + [m.elements setObject:a forKey:[NSNumber numberWithLong:eid]]; + dispatch_async(dispatch_get_main_queue(), ^{ [m.mapView addAnnotation:a]; }); + return (JAVA_LONG)eid; +} + +static void cn1Coords(JAVA_OBJECT lats, JAVA_OBJECT lons, CLLocationCoordinate2D **out, int *count) { + int n = (int)((JAVA_ARRAY)lats)->length; + JAVA_ARRAY_DOUBLE *la = (JAVA_ARRAY_DOUBLE*)((JAVA_ARRAY)lats)->data; + JAVA_ARRAY_DOUBLE *lo = (JAVA_ARRAY_DOUBLE*)((JAVA_ARRAY)lons)->data; + CLLocationCoordinate2D *c = malloc(sizeof(CLLocationCoordinate2D) * (n > 0 ? n : 1)); + for (int i = 0; i < n; i++) { + c[i] = CLLocationCoordinate2DMake(la[i], lo[i]); + } + *out = c; + *count = n; +} + +JAVA_LONG com_codename1_maps_MapProviderImpl_nativeAddPolyline___int_double_1ARRAY_double_1ARRAY_int_int_R_long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_OBJECT lats, JAVA_OBJECT lons, JAVA_INT color, JAVA_INT width) { + CN1AppleMap *m = cn1MapFor((int)mapId); + if (!m) { return 0; } + CLLocationCoordinate2D *c; int n; + cn1Coords(lats, lons, &c, &n); + MKPolyline *line = [MKPolyline polylineWithCoordinates:c count:n]; + free(c); + long eid = m.nextId++; + [m.elements setObject:line forKey:[NSNumber numberWithLong:eid]]; + dispatch_async(dispatch_get_main_queue(), ^{ [m.mapView addOverlay:line]; }); + return (JAVA_LONG)eid; +} + +JAVA_LONG com_codename1_maps_MapProviderImpl_nativeAddPolygon___int_double_1ARRAY_double_1ARRAY_int_int_int_R_long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_OBJECT lats, JAVA_OBJECT lons, JAVA_INT fill, JAVA_INT stroke, JAVA_INT width) { + CN1AppleMap *m = cn1MapFor((int)mapId); + if (!m) { return 0; } + CLLocationCoordinate2D *c; int n; + cn1Coords(lats, lons, &c, &n); + MKPolygon *poly = [MKPolygon polygonWithCoordinates:c count:n]; + free(c); + long eid = m.nextId++; + [m.elements setObject:poly forKey:[NSNumber numberWithLong:eid]]; + dispatch_async(dispatch_get_main_queue(), ^{ [m.mapView addOverlay:poly]; }); + return (JAVA_LONG)eid; +} + +JAVA_LONG com_codename1_maps_MapProviderImpl_nativeAddCircle___int_double_double_double_int_int_int_R_long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_DOUBLE lat, JAVA_DOUBLE lon, JAVA_DOUBLE radius, JAVA_INT fill, JAVA_INT stroke, JAVA_INT width) { + CN1AppleMap *m = cn1MapFor((int)mapId); + if (!m) { return 0; } + MKCircle *circle = [MKCircle circleWithCenterCoordinate:CLLocationCoordinate2DMake(lat, lon) radius:radius]; + long eid = m.nextId++; + [m.elements setObject:circle forKey:[NSNumber numberWithLong:eid]]; + dispatch_async(dispatch_get_main_queue(), ^{ [m.mapView addOverlay:circle]; }); + return (JAVA_LONG)eid; +} + +void com_codename1_maps_MapProviderImpl_nativeRemove___int_long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_LONG elementId) { + CN1AppleMap *m = cn1MapFor((int)mapId); + if (!m) { return; } + id e = [m.elements objectForKey:[NSNumber numberWithLong:(long)elementId]]; + if (!e) { return; } + [m.elements removeObjectForKey:[NSNumber numberWithLong:(long)elementId]]; + dispatch_async(dispatch_get_main_queue(), ^{ + if ([e isKindOfClass:[MKPointAnnotation class]]) { + [m.mapView removeAnnotation:e]; + } else if ([e conformsToProtocol:@protocol(MKOverlay)]) { + [m.mapView removeOverlay:e]; + } + }); +} + +void com_codename1_maps_MapProviderImpl_nativeRemoveAll___int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId) { + CN1AppleMap *m = cn1MapFor((int)mapId); + if (!m) { return; } + [m.elements removeAllObjects]; + dispatch_async(dispatch_get_main_queue(), ^{ + [m.mapView removeAnnotations:m.mapView.annotations]; + [m.mapView removeOverlays:m.mapView.overlays]; + }); +} + +JAVA_INT com_codename1_maps_MapProviderImpl_nativeScreenX___int_double_double_R_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_DOUBLE lat, JAVA_DOUBLE lon) { + CN1AppleMap *m = cn1MapFor((int)mapId); + if (!m) { return 0; } + CGPoint p = [m.mapView convertCoordinate:CLLocationCoordinate2DMake(lat, lon) toPointToView:m.mapView]; + return (JAVA_INT)p.x; +} + +JAVA_INT com_codename1_maps_MapProviderImpl_nativeScreenY___int_double_double_R_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_DOUBLE lat, JAVA_DOUBLE lon) { + CN1AppleMap *m = cn1MapFor((int)mapId); + if (!m) { return 0; } + CGPoint p = [m.mapView convertCoordinate:CLLocationCoordinate2DMake(lat, lon) toPointToView:m.mapView]; + return (JAVA_INT)p.y; +} + +JAVA_DOUBLE com_codename1_maps_MapProviderImpl_nativeLat___int_int_int_R_double(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_INT x, JAVA_INT y) { + CN1AppleMap *m = cn1MapFor((int)mapId); + if (!m) { return 0; } + CLLocationCoordinate2D c = [m.mapView convertPoint:CGPointMake(x, y) toCoordinateFromView:m.mapView]; + return c.latitude; +} + +JAVA_DOUBLE com_codename1_maps_MapProviderImpl_nativeLon___int_int_int_R_double(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_INT x, JAVA_INT y) { + CN1AppleMap *m = cn1MapFor((int)mapId); + if (!m) { return 0; } + CLLocationCoordinate2D c = [m.mapView convertPoint:CGPointMake(x, y) toCoordinateFromView:m.mapView]; + return c.longitude; +} + +void com_codename1_maps_MapProviderImpl_nativeSetShowMyLocation___int_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_BOOLEAN show) { + CN1AppleMap *m = cn1MapFor((int)mapId); + if (m) { + dispatch_async(dispatch_get_main_queue(), ^{ m.mapView.showsUserLocation = show ? YES : NO; }); + } +} + +void com_codename1_maps_MapProviderImpl_nativeSetRotateEnabled___int_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_BOOLEAN enabled) { + CN1AppleMap *m = cn1MapFor((int)mapId); + if (m) { + dispatch_async(dispatch_get_main_queue(), ^{ m.mapView.rotateEnabled = enabled ? YES : NO; }); + } +} + +void com_codename1_maps_MapProviderImpl_nativeSetMapType___int_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT mapId, JAVA_INT type) { + CN1AppleMap *m = cn1MapFor((int)mapId); + if (!m) { return; } + MKMapType t = MKMapTypeStandard; + if (type == 1) { t = MKMapTypeSatellite; } + else if (type == 2) { t = MKMapTypeHybrid; } + dispatch_async(dispatch_get_main_queue(), ^{ m.mapView.mapType = t; }); +} + +#endif diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/maps/GoogleMapProvider.javas b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/maps/GoogleMapProvider.javas new file mode 100644 index 0000000000..b6e9a0e35f --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/maps/GoogleMapProvider.javas @@ -0,0 +1,359 @@ +/* + * Codename One maps provider -- Google Maps (Android). + * + * This file is a BUILD TEMPLATE. The Codename One build server copies it into + * the generated Android project's com/codename1/maps package as + * MapProviderImpl.java when the build hint maps.provider=google (or + * android.maps.provider=google) is set, then weaves a call to + * MapProviderImpl.register() into the app's onCreate. It is compiled by the + * Android build against the injected Google Play Services Maps dependency, so + * the core framework never carries the Google SDK. + * + * Because it is injected (not part of core), it may reference the Android + * Google Maps SDK directly. It implements the provider-agnostic SPI + * com.codename1.maps.spi.MapProvider and reports user interaction back through + * the static callbacks on com.codename1.maps.NativeMap. + */ +package com.codename1.maps; + +import android.os.Bundle; +import com.codename1.impl.android.AndroidImplementation; +import com.codename1.impl.android.AndroidNativeUtil; +import com.codename1.maps.spi.MapProvider; +import com.codename1.maps.spi.MapProviderRegistry; +import com.codename1.ui.Display; +import com.codename1.ui.PeerComponent; +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GoogleApiAvailability; +import com.google.android.gms.maps.CameraUpdateFactory; +import com.google.android.gms.maps.GoogleMap; +import com.google.android.gms.maps.MapView; +import com.google.android.gms.maps.MapsInitializer; +import com.google.android.gms.maps.Projection; +import com.google.android.gms.maps.model.CameraPosition; +import com.google.android.gms.maps.model.Circle; +import com.google.android.gms.maps.model.CircleOptions; +import com.google.android.gms.maps.model.LatLng; +import com.google.android.gms.maps.model.Marker; +import com.google.android.gms.maps.model.MarkerOptions; +import com.google.android.gms.maps.model.Polygon; +import com.google.android.gms.maps.model.PolygonOptions; +import com.google.android.gms.maps.model.Polyline; +import com.google.android.gms.maps.model.PolylineOptions; +import android.graphics.Point; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class MapProviderImpl implements MapProvider { + + private final Map views = new HashMap(); + private final Map maps = new HashMap(); + private final Map elements = new HashMap(); + private long nextElementId = 1; + private int lastScreenX; + private int lastScreenY; + private double lastLat; + private double lastLon; + private List pendingPath; + + /** Invoked by build-injected startup code. */ + public static void register() { + MapProviderRegistry.register(new MapProviderImpl()); + MapProviderRegistry.setPreferredProvider("google"); + } + + public String getId() { + return "google"; + } + + public boolean isAvailable() { + try { + int status = GoogleApiAvailability.getInstance() + .isGooglePlayServicesAvailable(AndroidImplementation.getContext()); + return status == ConnectionResult.SUCCESS; + } catch (Throwable t) { + return false; + } + } + + public PeerComponent createPeer(final NativeMap host, final int mapId) { + final MapView[] holder = new MapView[1]; + final Object lock = new Object(); + AndroidImplementation.runOnAndroidUiThreadAndWait(new Runnable() { + public void run() { + try { + MapsInitializer.initialize(AndroidImplementation.getContext()); + final MapView mv = new MapView(AndroidImplementation.getActivity()); + mv.onCreate(null); + mv.onResume(); + views.put(new Integer(mapId), mv); + mv.getMapAsync(new com.google.android.gms.maps.OnMapReadyCallback() { + public void onMapReady(GoogleMap map) { + maps.put(new Integer(mapId), map); + attachListeners(host, mapId, map); + NativeMap.fireCameraChange(mapId); + } + }); + holder[0] = mv; + } catch (Throwable t) { + holder[0] = null; + } + } + }); + if (holder[0] == null) { + return null; + } + return PeerComponent.create(holder[0]); + } + + private void attachListeners(final NativeMap host, final int mapId, GoogleMap map) { + map.setOnMapClickListener(new GoogleMap.OnMapClickListener() { + public void onMapClick(LatLng p) { + calcScreenPosition(mapId, p.latitude, p.longitude); + NativeMap.fireTap(mapId, lastScreenX, lastScreenY); + } + }); + map.setOnMapLongClickListener(new GoogleMap.OnMapLongClickListener() { + public void onMapLongClick(LatLng p) { + calcScreenPosition(mapId, p.latitude, p.longitude); + NativeMap.fireLongPress(mapId, lastScreenX, lastScreenY); + } + }); + map.setOnMarkerClickListener(new GoogleMap.OnMarkerClickListener() { + public boolean onMarkerClick(Marker marker) { + Object tag = marker.getTag(); + if (tag instanceof Long) { + NativeMap.fireMarkerClick(mapId, ((Long) tag).longValue()); + } + return false; + } + }); + map.setOnCameraIdleListener(new GoogleMap.OnCameraIdleListener() { + public void onCameraIdle() { + NativeMap.fireCameraChange(mapId); + } + }); + } + + public void deinitialize(int mapId) { + MapView mv = views.remove(new Integer(mapId)); + maps.remove(new Integer(mapId)); + if (mv != null) { + mv.onPause(); + mv.onDestroy(); + } + } + + private GoogleMap map(int mapId) { + return maps.get(new Integer(mapId)); + } + + public void setCamera(int mapId, double lat, double lon, float zoom, float bearing, float tilt) { + GoogleMap m = map(mapId); + if (m == null) { + return; + } + CameraPosition pos = new CameraPosition.Builder() + .target(new LatLng(lat, lon)).zoom(zoom).bearing(bearing).tilt(tilt).build(); + m.moveCamera(CameraUpdateFactory.newCameraPosition(pos)); + } + + public double getLatitude(int mapId) { + GoogleMap m = map(mapId); + return m == null ? 0 : m.getCameraPosition().target.latitude; + } + + public double getLongitude(int mapId) { + GoogleMap m = map(mapId); + return m == null ? 0 : m.getCameraPosition().target.longitude; + } + + public float getZoom(int mapId) { + GoogleMap m = map(mapId); + return m == null ? 0 : m.getCameraPosition().zoom; + } + + public float getMaxZoom(int mapId) { + GoogleMap m = map(mapId); + return m == null ? 21 : m.getMaxZoomLevel(); + } + + public float getMinZoom(int mapId) { + GoogleMap m = map(mapId); + return m == null ? 0 : m.getMinZoomLevel(); + } + + public long addMarker(int mapId, byte[] icon, double lat, double lon, + String title, String snippet, float anchorU, float anchorV) { + GoogleMap m = map(mapId); + if (m == null) { + return 0; + } + MarkerOptions opts = new MarkerOptions().position(new LatLng(lat, lon)).anchor(anchorU, anchorV); + if (title != null) { + opts.title(title); + } + if (snippet != null) { + opts.snippet(snippet); + } + if (icon != null) { + android.graphics.Bitmap bmp = android.graphics.BitmapFactory.decodeByteArray(icon, 0, icon.length); + if (bmp != null) { + opts.icon(com.google.android.gms.maps.model.BitmapDescriptorFactory.fromBitmap(bmp)); + } + } + Marker marker = m.addMarker(opts); + long id = nextElementId++; + marker.setTag(new Long(id)); + elements.put(new Long(id), marker); + return id; + } + + public long beginPath(int mapId) { + pendingPath = new ArrayList(); + return 0; + } + + public void addToPath(int mapId, long pathId, double lat, double lon) { + if (pendingPath != null) { + pendingPath.add(new LatLng(lat, lon)); + } + } + + public long finishPolyline(int mapId, long pathId, int strokeColor, int strokeWidth) { + GoogleMap m = map(mapId); + if (m == null || pendingPath == null) { + return 0; + } + Polyline p = m.addPolyline(new PolylineOptions().addAll(pendingPath) + .color(0xff000000 | strokeColor).width(strokeWidth)); + pendingPath = null; + long id = nextElementId++; + elements.put(new Long(id), p); + return id; + } + + public long finishPolygon(int mapId, long pathId, int fillColor, int strokeColor, int strokeWidth) { + GoogleMap m = map(mapId); + if (m == null || pendingPath == null) { + return 0; + } + Polygon p = m.addPolygon(new PolygonOptions().addAll(pendingPath) + .fillColor(fillColor).strokeColor(0xff000000 | strokeColor).strokeWidth(strokeWidth)); + pendingPath = null; + long id = nextElementId++; + elements.put(new Long(id), p); + return id; + } + + public long addCircle(int mapId, double lat, double lon, double radiusMeters, + int fillColor, int strokeColor, int strokeWidth) { + GoogleMap m = map(mapId); + if (m == null) { + return 0; + } + Circle c = m.addCircle(new CircleOptions().center(new LatLng(lat, lon)).radius(radiusMeters) + .fillColor(fillColor).strokeColor(0xff000000 | strokeColor).strokeWidth(strokeWidth)); + long id = nextElementId++; + elements.put(new Long(id), c); + return id; + } + + public void removeElement(int mapId, long elementId) { + Object e = elements.remove(new Long(elementId)); + if (e instanceof Marker) { + ((Marker) e).remove(); + } else if (e instanceof Polyline) { + ((Polyline) e).remove(); + } else if (e instanceof Polygon) { + ((Polygon) e).remove(); + } else if (e instanceof Circle) { + ((Circle) e).remove(); + } + } + + public void removeAllElements(int mapId) { + GoogleMap m = map(mapId); + if (m != null) { + m.clear(); + } + elements.clear(); + } + + public void calcScreenPosition(int mapId, double lat, double lon) { + GoogleMap m = map(mapId); + if (m == null) { + return; + } + Projection proj = m.getProjection(); + Point p = proj.toScreenLocation(new LatLng(lat, lon)); + lastScreenX = p.x; + lastScreenY = p.y; + } + + public int getScreenX(int mapId) { + return lastScreenX; + } + + public int getScreenY(int mapId) { + return lastScreenY; + } + + public void calcLatLongPosition(int mapId, int x, int y) { + GoogleMap m = map(mapId); + if (m == null) { + return; + } + LatLng ll = m.getProjection().fromScreenLocation(new Point(x, y)); + lastLat = ll.latitude; + lastLon = ll.longitude; + } + + public double getScreenLat(int mapId) { + return lastLat; + } + + public double getScreenLon(int mapId) { + return lastLon; + } + + public void setShowMyLocation(int mapId, boolean show) { + GoogleMap m = map(mapId); + if (m != null) { + try { + m.setMyLocationEnabled(show); + } catch (SecurityException se) { + // Location permission not granted; ignore. + } + } + } + + public void setRotateGestureEnabled(int mapId, boolean enabled) { + GoogleMap m = map(mapId); + if (m != null) { + m.getUiSettings().setRotateGesturesEnabled(enabled); + } + } + + public void setMapType(int mapId, int type) { + GoogleMap m = map(mapId); + if (m == null) { + return; + } + switch (type) { + case MAP_TYPE_SATELLITE: + m.setMapType(GoogleMap.MAP_TYPE_SATELLITE); + break; + case MAP_TYPE_HYBRID: + m.setMapType(GoogleMap.MAP_TYPE_HYBRID); + break; + case MAP_TYPE_TERRAIN: + m.setMapType(GoogleMap.MAP_TYPE_TERRAIN); + break; + default: + m.setMapType(GoogleMap.MAP_TYPE_NORMAL); + } + } +} diff --git a/maven/core-unittests/spotbugs-exclude.xml b/maven/core-unittests/spotbugs-exclude.xml index c68d65f7b9..720bb1c913 100644 --- a/maven/core-unittests/spotbugs-exclude.xml +++ b/maven/core-unittests/spotbugs-exclude.xml @@ -268,6 +268,18 @@ + + + + + + diff --git a/maven/core-unittests/src/test/java/com/codename1/maps/MapsModelTest.java b/maven/core-unittests/src/test/java/com/codename1/maps/MapsModelTest.java new file mode 100644 index 0000000000..289c033204 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/maps/MapsModelTest.java @@ -0,0 +1,346 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.maps; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.codename1.maps.spi.MapProvider; +import com.codename1.maps.spi.MapProviderRegistry; +import com.codename1.ui.PeerComponent; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Unit tests for the modern maps value types, object model and provider SPI. */ +class MapsModelTest { + + // ---- LatLng ----------------------------------------------------------- + + @Test + void latLngClampsLatitudeAndWrapsLongitude() { + assertEquals(90.0, new LatLng(120, 0).getLatitude(), 1e-9); + assertEquals(-90.0, new LatLng(-120, 0).getLatitude(), 1e-9); + assertEquals(-170.0, new LatLng(0, 190).getLongitude(), 1e-9); + assertEquals(170.0, new LatLng(0, -190).getLongitude(), 1e-9); + assertEquals(0.0, new LatLng(0, 360).getLongitude(), 1e-9); + } + + @Test + void latLngEqualsAndHashCode() { + LatLng a = new LatLng(37.7749, -122.4194); + LatLng b = new LatLng(37.7749, -122.4194); + LatLng c = new LatLng(40.0, -122.4194); + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + assertNotEquals(a, c); + assertNotEquals(a, "not a latlng"); + } + + @Test + void latLngHaversineDistance() { + // San Francisco to New York is ~4130 km. + double d = new LatLng(37.7749, -122.4194).distanceTo(new LatLng(40.7128, -74.0060)); + assertTrue(d > 4_100_000 && d < 4_200_000, "distance was " + d); + assertEquals(0.0, new LatLng(10, 20).distanceTo(new LatLng(10, 20)), 1e-3); + } + + @Test + void latLngCoordRoundTrip() { + LatLng a = new LatLng(51.5074, -0.1278); + Coord c = a.toCoord(); + assertFalse(c.isProjected()); + assertEquals(a, LatLng.fromCoord(c)); + assertEquals(a, LatLng.create(51.5074, -0.1278)); + } + + // ---- MapBounds -------------------------------------------------------- + + @Test + void mapBoundsNormalizesCornersAndContains() { + MapBounds b = new MapBounds(new LatLng(40, 10), new LatLng(30, -10)); + assertEquals(30.0, b.getSouthWest().getLatitude(), 1e-9); + assertEquals(-10.0, b.getSouthWest().getLongitude(), 1e-9); + assertEquals(40.0, b.getNorthEast().getLatitude(), 1e-9); + assertEquals(10.0, b.getNorthEast().getLongitude(), 1e-9); + assertTrue(b.contains(new LatLng(35, 0))); + assertFalse(b.contains(new LatLng(50, 0))); + assertEquals(35.0, b.getCenter().getLatitude(), 1e-9); + assertEquals(10.0, b.getLatitudeSpan(), 1e-9); + assertEquals(20.0, b.getLongitudeSpan(), 1e-9); + } + + @Test + void mapBoundsFromCoordinatesAndExtend() { + assertNull(MapBounds.fromCoordinates(new ArrayList())); + List pts = new ArrayList(); + pts.add(new LatLng(10, 10)); + pts.add(new LatLng(-5, 20)); + pts.add(new LatLng(3, -8)); + MapBounds b = MapBounds.fromCoordinates(pts); + assertEquals(-5.0, b.getSouthWest().getLatitude(), 1e-9); + assertEquals(-8.0, b.getSouthWest().getLongitude(), 1e-9); + assertEquals(10.0, b.getNorthEast().getLatitude(), 1e-9); + assertEquals(20.0, b.getNorthEast().getLongitude(), 1e-9); + MapBounds extended = b.extend(new LatLng(40, 40)); + assertTrue(extended.contains(new LatLng(40, 40))); + assertTrue(extended.contains(new LatLng(-5, -8))); + } + + // ---- CameraPosition --------------------------------------------------- + + @Test + void cameraPositionAccessorsAndWithers() { + LatLng t = new LatLng(1, 2); + CameraPosition p = new CameraPosition(t, 5); + assertSame(t, p.getTarget()); + assertEquals(5.0, p.getZoom(), 1e-9); + assertEquals(0.0, p.getBearing(), 1e-9); + assertEquals(0.0, p.getTilt(), 1e-9); + CameraPosition p2 = new CameraPosition(t, 5, 90, 30); + assertEquals(90.0, p2.getBearing(), 1e-9); + assertEquals(30.0, p2.getTilt(), 1e-9); + assertEquals(8.0, p.withZoom(8).getZoom(), 1e-9); + LatLng t2 = new LatLng(3, 4); + assertSame(t2, p.withTarget(t2).getTarget()); + } + + // ---- Object model ----------------------------------------------------- + + @Test + void markerOptionsBuildsMarkerWithDefaults() { + Marker m = new MarkerOptions(new LatLng(1, 2)).title("t").snippet("s").build(); + assertEquals(new LatLng(1, 2), m.getPosition()); + assertEquals("t", m.getTitle()); + assertEquals("s", m.getSnippet()); + assertEquals(0.5f, m.getAnchorU(), 1e-6); + assertEquals(1.0f, m.getAnchorV(), 1e-6); + assertFalse(m.isDraggable()); + assertTrue(m.isVisible()); + assertNull(m.getIcon()); + m.setVisible(false); + assertFalse(m.isVisible()); + m.setPosition(new LatLng(3, 4)); + assertEquals(new LatLng(3, 4), m.getPosition()); + } + + @Test + void markerOptionsAnchorAndDraggable() { + Marker m = new MarkerOptions().position(new LatLng(0, 0)).anchor(0.25f, 0.75f).draggable(true).build(); + assertEquals(0.25f, m.getAnchorU(), 1e-6); + assertEquals(0.75f, m.getAnchorV(), 1e-6); + assertTrue(m.isDraggable()); + } + + @Test + void polylineAccessors() { + Polyline pl = new Polyline().addPoint(new LatLng(0, 0)).addPoint(new LatLng(1, 1)); + assertEquals(2, pl.getPoints().size()); + pl.setStrokeColor(0x123456).setStrokeWidth(7).setStrokeAlpha(128).setVisible(false); + assertEquals(0x123456, pl.getStrokeColor()); + assertEquals(7, pl.getStrokeWidth()); + assertEquals(128, pl.getStrokeAlpha()); + assertFalse(pl.isVisible()); + Polyline fromArray = new Polyline(new LatLng[]{new LatLng(0, 0), new LatLng(2, 2), new LatLng(3, 3)}); + assertEquals(3, fromArray.getPoints().size()); + } + + @Test + void polygonAccessors() { + Polygon pg = new Polygon(new LatLng[]{new LatLng(0, 0), new LatLng(0, 1), new LatLng(1, 1)}); + assertEquals(3, pg.getPoints().size()); + pg.setFillColor(0x80ff0000).setStrokeColor(0x00ff00).setStrokeWidth(4).setVisible(false); + assertEquals(0x80ff0000, pg.getFillColor()); + assertEquals(0x00ff00, pg.getStrokeColor()); + assertEquals(4, pg.getStrokeWidth()); + assertFalse(pg.isVisible()); + } + + @Test + void circleAccessors() { + Circle c = new Circle(new LatLng(5, 6), 1000); + assertEquals(new LatLng(5, 6), c.getCenter()); + assertEquals(1000.0, c.getRadiusMeters(), 1e-9); + c.setCenter(new LatLng(7, 8)).setRadiusMeters(2000).setFillColor(0x11223344) + .setStrokeColor(0x556677).setStrokeWidth(3).setVisible(false); + assertEquals(new LatLng(7, 8), c.getCenter()); + assertEquals(2000.0, c.getRadiusMeters(), 1e-9); + assertEquals(0x11223344, c.getFillColor()); + assertEquals(0x556677, c.getStrokeColor()); + assertEquals(3, c.getStrokeWidth()); + assertFalse(c.isVisible()); + } + + @Test + void mapObjectIdsAreUniqueAndEqualityById() { + Marker a = new MarkerOptions(new LatLng(0, 0)).build(); + Marker b = new MarkerOptions(new LatLng(0, 0)).build(); + assertNotEquals(a.getId(), b.getId()); + assertNotEquals(a, b); + assertEquals(a, a); + assertEquals(a.getId(), a.hashCode()); + } + + // ---- Provider SPI registry ------------------------------------------- + + @Test + void mapProviderRegistrySelectionRules() { + StubProvider unavailable = new StubProvider("test-unavailable", false); + StubProvider available = new StubProvider("test-google", true); + StubProvider availableApple = new StubProvider("test-apple", true); + + MapProviderRegistry.register(unavailable); + assertNull(MapProviderRegistry.getProvider(), "only an unavailable provider is registered"); + assertFalse(MapProviderRegistry.hasProvider()); + + MapProviderRegistry.register(available); + assertSame(available, MapProviderRegistry.getProvider()); + assertTrue(MapProviderRegistry.hasProvider()); + + MapProviderRegistry.register(availableApple); + MapProviderRegistry.setPreferredProvider("test-apple"); + assertSame(availableApple, MapProviderRegistry.getProvider()); + + // Re-registering the same id replaces the instance. + StubProvider replacement = new StubProvider("test-apple", true); + MapProviderRegistry.register(replacement); + assertSame(replacement, MapProviderRegistry.getProvider()); + + // Preferring an unknown id falls back to the first available provider. + MapProviderRegistry.setPreferredProvider("does-not-exist"); + assertNotNull(MapProviderRegistry.getProvider()); + assertTrue(MapProviderRegistry.getProvider().isAvailable()); + + // A provider whose isAvailable throws is treated as absent, not fatal. + MapProviderRegistry.setPreferredProvider("test-throws"); + MapProviderRegistry.register(new StubProvider("test-throws", true) { + public boolean isAvailable() { + throw new RuntimeException("native init failed"); + } + }); + assertNotNull(MapProviderRegistry.getProvider()); + } + + /** A no-op MapProvider used to exercise the registry without any native peer. */ + private static class StubProvider implements MapProvider { + private final String id; + private final boolean available; + + StubProvider(String id, boolean available) { + this.id = id; + this.available = available; + } + + public String getId() { + return id; + } + + public boolean isAvailable() { + return available; + } + + public PeerComponent createPeer(NativeMap host, int mapId) { + return null; + } + + public void deinitialize(int mapId) { + } + + public void setCamera(int mapId, double lat, double lon, float zoom, float bearing, float tilt) { + } + + public double getLatitude(int mapId) { + return 0; + } + + public double getLongitude(int mapId) { + return 0; + } + + public float getZoom(int mapId) { + return 0; + } + + public float getMaxZoom(int mapId) { + return 0; + } + + public float getMinZoom(int mapId) { + return 0; + } + + public long addMarker(int mapId, byte[] icon, double lat, double lon, String title, + String snippet, float anchorU, float anchorV) { + return 0; + } + + public long beginPath(int mapId) { + return 0; + } + + public void addToPath(int mapId, long pathId, double lat, double lon) { + } + + public long finishPolyline(int mapId, long pathId, int strokeColor, int strokeWidth) { + return 0; + } + + public long finishPolygon(int mapId, long pathId, int fillColor, int strokeColor, int strokeWidth) { + return 0; + } + + public long addCircle(int mapId, double lat, double lon, double radiusMeters, int fillColor, + int strokeColor, int strokeWidth) { + return 0; + } + + public void removeElement(int mapId, long elementId) { + } + + public void removeAllElements(int mapId) { + } + + public void calcScreenPosition(int mapId, double lat, double lon) { + } + + public int getScreenX(int mapId) { + return 0; + } + + public int getScreenY(int mapId) { + return 0; + } + + public void calcLatLongPosition(int mapId, int x, int y) { + } + + public double getScreenLat(int mapId) { + return 0; + } + + public double getScreenLon(int mapId) { + return 0; + } + + public void setShowMyLocation(int mapId, boolean show) { + } + + public void setRotateGestureEnabled(int mapId, boolean enabled) { + } + + public void setMapType(int mapId, int type) { + } + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/maps/vector/MapsVectorInternalsTest.java b/maven/core-unittests/src/test/java/com/codename1/maps/vector/MapsVectorInternalsTest.java new file mode 100644 index 0000000000..96f531d8f1 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/maps/vector/MapsVectorInternalsTest.java @@ -0,0 +1,292 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.maps.vector; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.codename1.io.grpc.ProtoWriter; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** Unit tests for the internal pieces of the pure-Java vector map engine. */ +class MapsVectorInternalsTest { + + // ---- ZoomValue -------------------------------------------------------- + + @Test + void zoomValueConstantAndStops() { + assertEquals(3.0, ZoomValue.constant(3).eval(0), 1e-9); + assertEquals(3.0, ZoomValue.constant(3).eval(20), 1e-9); + + ZoomValue z = ZoomValue.stops(new double[]{4, 16}, new double[]{1, 9}); + assertEquals(1.0, z.eval(2), 1e-9, "below first stop clamps"); + assertEquals(9.0, z.eval(18), 1e-9, "above last stop clamps"); + assertEquals(1.0, z.eval(4), 1e-9); + assertEquals(9.0, z.eval(16), 1e-9); + assertEquals(5.0, z.eval(10), 1e-9, "linear interpolation at midpoint"); + + // Malformed stops fall back to a constant 0. + assertEquals(0.0, ZoomValue.stops(new double[]{1}, new double[]{1, 2}).eval(5), 1e-9); + assertEquals(0.0, ZoomValue.stops(new double[0], new double[0]).eval(5), 1e-9); + } + + // ---- StyleLayer ------------------------------------------------------- + + @Test + void styleLayerZoomRangeAndFilter() { + StyleLayer sl = new StyleLayer(StyleLayer.TYPE_FILL) + .sourceLayer("water") + .zoomRange(5, 12) + .fillColor(0xff0000ff) + .filter("class", "lake"); + assertEquals(StyleLayer.TYPE_FILL, sl.getType()); + assertEquals("water", sl.getSourceLayer()); + assertEquals(0xff0000ff, sl.getFillColor()); + assertFalse(sl.visibleAt(4)); + assertTrue(sl.visibleAt(8)); + assertFalse(sl.visibleAt(13)); + + assertTrue(sl.accepts(feature("class", "lake"))); + assertFalse(sl.accepts(feature("class", "river"))); + assertFalse(sl.accepts(feature("other", "lake"))); + + StyleLayer noFilter = new StyleLayer(StyleLayer.TYPE_LINE).sourceLayer("road"); + assertTrue(noFilter.accepts(feature("anything", "goes"))); + } + + @Test + void styleLayerZoomDependentWidthAndSize() { + StyleLayer line = new StyleLayer(StyleLayer.TYPE_LINE) + .lineColor(0xffffffff) + .lineWidth(ZoomValue.stops(new double[]{6, 18}, new double[]{1, 7})); + assertEquals(1.0, line.lineWidthAt(6), 1e-9); + assertEquals(7.0, line.lineWidthAt(18), 1e-9); + assertEquals(4.0, line.lineWidthAt(12), 1e-9); + + StyleLayer sym = new StyleLayer(StyleLayer.TYPE_SYMBOL) + .textField("name").textColor(0xff112233).textHaloColor(0xffffffff) + .textSize(ZoomValue.constant(14)); + assertEquals("name", sym.getTextField()); + assertEquals(0xff112233, sym.getTextColor()); + assertEquals(0xffffffff, sym.getTextHaloColor()); + assertEquals(14.0, sym.textSizeAt(10), 1e-9); + } + + private static VectorFeature feature(String key, String value) { + java.util.HashMap attrs = new java.util.HashMap(); + attrs.put(key, value); + return new VectorFeature(0, VectorFeature.GEOM_POLYGON, attrs, new ArrayList()); + } + + // ---- IntArray --------------------------------------------------------- + + @Test + void intArrayGrowsAndTrims() { + IntArray a = new IntArray(2); + for (int i = 0; i < 50; i++) { + a.add(i * 3); + } + assertEquals(50, a.size()); + assertEquals(0, a.get(0)); + assertEquals(147, a.get(49)); + int[] arr = a.toArray(); + assertEquals(50, arr.length); + assertEquals(147, arr[49]); + a.clear(); + assertEquals(0, a.size()); + } + + // ---- MapStyle built-ins ---------------------------------------------- + + @Test + void builtInStylesAreNonEmptyAndDistinct() { + MapStyle light = MapStyle.light(); + MapStyle dark = MapStyle.dark(); + assertEquals("light", light.getName()); + assertEquals("dark", dark.getName()); + assertTrue(light.getLayers().size() > 3); + assertTrue(dark.getLayers().size() > 3); + assertNotNull(light.getLayers()); + // Backgrounds differ between light and dark. + assertTrue(light.getBackgroundColor() != dark.getBackgroundColor()); + // Both target a water source-layer. + assertTrue(hasSourceLayer(light, "water")); + assertTrue(hasSourceLayer(dark, "water")); + } + + private static boolean hasSourceLayer(MapStyle style, String name) { + List layers = style.getLayers(); + for (int i = 0; i < layers.size(); i++) { + StyleLayer sl = (StyleLayer) layers.get(i); + if (name.equals(sl.getSourceLayer())) { + return true; + } + } + return false; + } + + // ---- ColorParser ------------------------------------------------------ + + @Test + void colorParserForms() { + assertEquals(0xffaabbcc, ColorParser.parse("#aabbcc", 0)); + assertEquals(0xffaabbcc, ColorParser.parse("#abc", 0)); + assertEquals(0x80ffffff, ColorParser.parse("#ffffff80", 0)); + assertEquals(0xff010203, ColorParser.parse("rgb(1,2,3)", 0)); + assertEquals(0xff0a0b0c, ColorParser.parse(" #0A0B0C ", 0)); + assertEquals(123, ColorParser.parse("not-a-color", 123)); + assertEquals(123, ColorParser.parse(null, 123)); + assertEquals(123, ColorParser.parse("hsl(1,2,3)", 123)); + } + + // ---- Tile sources ----------------------------------------------------- + + @Test + void openFreeMapSourceIsKeylessVector() { + MvtTileSource s = MvtTileSource.openFreeMap(); + assertTrue(s.isVector()); + assertEquals(0, s.getMinZoom()); + assertEquals(14, s.getMaxZoom()); + assertEquals(WebMercator.TILE_SIZE, s.getTileSize()); + assertTrue(s.getAttribution().contains("OpenStreetMap")); + } + + @Test + void rasterOpenStreetMapSourceIsKeylessRaster() { + RasterTileSource s = RasterTileSource.openStreetMap(); + assertFalse(s.isVector()); + assertTrue(s.getMaxZoom() >= 18); + assertTrue(s.getAttribution().contains("OpenStreetMap")); + } + + // ---- WebMercator ------------------------------------------------------ + + @Test + void webMercatorWorldSizeDoublesPerZoom() { + assertEquals(256.0, WebMercator.worldSize(0), 1e-6); + assertEquals(512.0, WebMercator.worldSize(1), 1e-6); + assertEquals(256.0 * 1024, WebMercator.worldSize(10), 1e-3); + // Equator projects to the vertical center at any zoom. + assertEquals(WebMercator.worldSize(5) / 2, WebMercator.latToWorldY(0, 5), 1e-6); + // Prime meridian projects to the horizontal center. + assertEquals(WebMercator.worldSize(5) / 2, WebMercator.lonToWorldX(0, 5), 1e-6); + } + + // ---- MvtDecoder value types ------------------------------------------ + + @Test + void mvtDecoderReadsAllValueTypes() throws Exception { + VectorTile tile = MvtDecoder.decode(buildValueTile()); + VectorLayer layer = tile.getLayer("test"); + assertNotNull(layer); + VectorFeature f = (VectorFeature) layer.getFeatures().get(0); + assertEquals(VectorFeature.GEOM_POINT, f.getGeometryType()); + Map attrs = f.getAttributes(); + assertEquals("hi", attrs.get("s")); + assertEquals(new Float(1.5f), attrs.get("f")); + assertEquals(new Double(2.5), attrs.get("d")); + assertEquals(new Long(7), attrs.get("i")); + assertEquals(new Long(8), attrs.get("u")); + assertEquals(Boolean.TRUE, attrs.get("b")); + int[] geom = (int[]) f.getParts().get(0); + assertEquals(10, geom[0]); + assertEquals(20, geom[1]); + } + + @Test + void mvtDecoderHandlesEmptyTile() throws Exception { + VectorTile tile = MvtDecoder.decode(new byte[0]); + assertEquals(0, tile.getLayers().size()); + } + + private static byte[] buildValueTile() throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ProtoWriter tile = new ProtoWriter(out); + + ByteArrayOutputStream lbuf = new ByteArrayOutputStream(); + ProtoWriter layer = new ProtoWriter(lbuf); + layer.writeString(1, "test"); + + // one POINT feature at (10,20) tagging each key to each value index. + ByteArrayOutputStream fbuf = new ByteArrayOutputStream(); + ProtoWriter f = new ProtoWriter(fbuf); + List tags = new ArrayList(); + for (int i = 0; i < 6; i++) { + tags.add(new Integer(i)); + tags.add(new Integer(i)); + } + f.writePackedInt32(2, tags); + f.writeInt32(3, VectorFeature.GEOM_POINT); + List geom = new ArrayList(); + geom.add(new Integer((1 & 0x7) | (1 << 3))); + geom.add(new Integer(ProtoWriter.zigZag32(10))); + geom.add(new Integer(ProtoWriter.zigZag32(20))); + f.writePackedInt32(4, geom); + layer.writeBytes(2, fbuf.toByteArray()); + + String[] keys = {"s", "f", "d", "i", "u", "b"}; + for (int i = 0; i < keys.length; i++) { + layer.writeString(3, keys[i]); + } + layer.writeBytes(4, value(1, "hi")); + layer.writeBytes(4, valueFloat(1.5f)); + layer.writeBytes(4, valueDouble(2.5)); + layer.writeBytes(4, valueInt64(7)); + layer.writeBytes(4, valueUInt64(8)); + layer.writeBytes(4, valueBool(true)); + layer.writeInt32(5, 4096); + + tile.writeBytes(3, lbuf.toByteArray()); + return out.toByteArray(); + } + + private static byte[] value(int field, String s) throws IOException { + ByteArrayOutputStream b = new ByteArrayOutputStream(); + new ProtoWriter(b).writeString(field, s); + return b.toByteArray(); + } + + private static byte[] valueFloat(float v) throws IOException { + ByteArrayOutputStream b = new ByteArrayOutputStream(); + new ProtoWriter(b).writeFloat(2, v); + return b.toByteArray(); + } + + private static byte[] valueDouble(double v) throws IOException { + ByteArrayOutputStream b = new ByteArrayOutputStream(); + new ProtoWriter(b).writeDouble(3, v); + return b.toByteArray(); + } + + private static byte[] valueInt64(long v) throws IOException { + ByteArrayOutputStream b = new ByteArrayOutputStream(); + new ProtoWriter(b).writeInt64(4, v); + return b.toByteArray(); + } + + private static byte[] valueUInt64(long v) throws IOException { + ByteArrayOutputStream b = new ByteArrayOutputStream(); + new ProtoWriter(b).writeUInt64(5, v); + return b.toByteArray(); + } + + private static byte[] valueBool(boolean v) throws IOException { + ByteArrayOutputStream b = new ByteArrayOutputStream(); + new ProtoWriter(b).writeBool(7, v); + return b.toByteArray(); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/maps/vector/MapsVectorTest.java b/maven/core-unittests/src/test/java/com/codename1/maps/vector/MapsVectorTest.java new file mode 100644 index 0000000000..deb49bea3b --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/maps/vector/MapsVectorTest.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Codename One in the LICENSE file that accompanied this code. + */ +package com.codename1.maps.vector; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Unit tests for the pure-Java vector map engine internals. */ +class MapsVectorTest { + + @Test + void demoTileEncodesAndDecodes() throws Exception { + byte[] bytes = DemoTileSource.buildTile(); + assertTrue(bytes.length > 0); + VectorTile tile = MvtDecoder.decode(bytes); + assertEquals(5, tile.getLayers().size()); + + VectorLayer water = tile.getLayer("water"); + assertNotNull(water); + assertEquals(4096, water.getExtent()); + assertEquals(1, water.getFeatures().size()); + VectorFeature waterFeature = (VectorFeature) water.getFeatures().get(0); + assertEquals(VectorFeature.GEOM_POLYGON, waterFeature.getGeometryType()); + + VectorLayer road = tile.getLayer("road"); + assertNotNull(road); + assertEquals(2, road.getFeatures().size()); + assertEquals( + VectorFeature.GEOM_LINESTRING, + ((VectorFeature) road.getFeatures().get(0)).getGeometryType()); + + VectorLayer place = tile.getLayer("place"); + assertNotNull(place); + VectorFeature placeFeature = (VectorFeature) place.getFeatures().get(0); + assertEquals(VectorFeature.GEOM_POINT, placeFeature.getGeometryType()); + assertEquals("CN1 City", placeFeature.getAttribute("name")); + } + + @Test + void polygonGeometryDecodesToExpectedCoordinates() throws Exception { + VectorTile tile = MvtDecoder.decode(DemoTileSource.buildTile()); + VectorFeature water = (VectorFeature) tile.getLayer("water").getFeatures().get(0); + int[] ring = (int[]) water.getParts().get(0); + // Encoded ring: (0,0)(1500,0)(1500,4096)(0,4096). + assertEquals(0, ring[0]); + assertEquals(0, ring[1]); + assertEquals(1500, ring[2]); + assertEquals(0, ring[3]); + assertEquals(1500, ring[4]); + assertEquals(4096, ring[5]); + } + + @Test + void webMercatorRoundTrips() { + double[] lats = {0, 37.7793, -33.8688, 51.5072, 85.0}; + double[] lons = {0, -122.4193, 151.2093, -0.1276, 179.0}; + for (double zoom = 1; zoom <= 18; zoom += 3) { + for (int i = 0; i < lats.length; i++) { + double wx = WebMercator.lonToWorldX(lons[i], zoom); + double wy = WebMercator.latToWorldY(lats[i], zoom); + double lon = WebMercator.worldXToLon(wx, zoom); + double lat = WebMercator.worldYToLat(wy, zoom); + assertEquals(lons[i], lon, 1e-6, "lon at zoom " + zoom); + assertEquals(lats[i], lat, 1e-6, "lat at zoom " + zoom); + } + } + } + + @Test + void tileCacheEvictsLeastRecentlyUsed() { + TileCache cache = new TileCache(2); + cache.put("a", "A"); + cache.put("b", "B"); + // Touch "a" so "b" becomes the eviction candidate. + assertEquals("A", cache.get("a")); + cache.put("c", "C"); + assertEquals(2, cache.size()); + assertNull(cache.get("b")); + assertEquals("A", cache.get("a")); + assertEquals("C", cache.get("c")); + } + + @Test + void mapStyleParsesJsonSubset() { + String json = + "{\"layers\":[" + + "{\"type\":\"background\",\"paint\":{\"background-color\":\"#102030\"}}," + + "{\"type\":\"fill\",\"source-layer\":\"water\"," + + "\"paint\":{\"fill-color\":\"#0000ff\"}}," + + "{\"type\":\"line\",\"source-layer\":\"road\"," + + "\"paint\":{\"line-color\":\"#ffffff\",\"line-width\":2}}" + + "]}"; + MapStyle style = MapStyle.fromJson(json); + assertEquals(0xff102030, style.getBackgroundColor()); + List layers = style.getLayers(); + assertEquals(2, layers.size()); + assertEquals(StyleLayer.TYPE_FILL, ((StyleLayer) layers.get(0)).getType()); + assertEquals(StyleLayer.TYPE_LINE, ((StyleLayer) layers.get(1)).getType()); + assertEquals(0xff0000ff, ((StyleLayer) layers.get(0)).getFillColor()); + } + + @Test + void colorParserHandlesCommonForms() { + assertEquals(0xff0000ff, ColorParser.parse("#0000ff", 0)); + assertEquals(0xffffffff, ColorParser.parse("#fff", 0)); + assertEquals(0x80ff0000, ColorParser.parse("rgba(255,0,0,0.5)", 0)); + assertEquals(0xff112233, ColorParser.parse("#112233", 0)); + } +} diff --git a/scripts/android/screenshots/NativeMapProvider.tolerance b/scripts/android/screenshots/NativeMapProvider.tolerance new file mode 100644 index 0000000000..9e151090e8 --- /dev/null +++ b/scripts/android/screenshots/NativeMapProvider.tolerance @@ -0,0 +1,6 @@ +# Live native map (Apple MapKit / Google): tiles, labels and shading load +# from the provider servers and vary run-to-run, so the tolerance is wide. It +# still fails loudly on a blank / blocked map (which differs across essentially +# the whole frame) -- the regression this test guards against. +maxChannelDelta=40 +maxMismatchPercent=30.0 diff --git a/scripts/hellocodenameone/common/codenameone_settings.properties b/scripts/hellocodenameone/common/codenameone_settings.properties index 96a2035069..3946b8efe5 100644 --- a/scripts/hellocodenameone/common/codenameone_settings.properties +++ b/scripts/hellocodenameone/common/codenameone_settings.properties @@ -32,3 +32,5 @@ codename1.rim.signtoolDb= codename1.secondaryTitle=Hello World codename1.vendor=CodenameOne codename1.version=1.0 + +codename1.arg.ios.maps.provider=apple diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index 862bc643e3..ff163845d5 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -237,6 +237,16 @@ private static int testTimeoutMs(BaseTest testClass) { new PaletteOverrideThemeScreenshotTest(), new CssGradientsScreenshotTest(), new CssFilterBlurScreenshotTest(), + // Modern maps API: the pure-vector MapView (real OSM basemap, + // light/dark styles, marker + shape overlays) and the NativeMap + // vector fallback, all rendered against the bundled real San + // Francisco tiles so the baselines are network-free and reproducible. + new RealOsmVectorScreenshotTest(), + new VectorMapDarkStyleScreenshotTest(), + new VectorMapMarkersScreenshotTest(), + new VectorMapShapesScreenshotTest(), + new NativeMapFallbackScreenshotTest(), + new NativeMapProviderScreenshotTest(), // Build-time SVG transcoder coverage: the static test renders // shapes / gradients / paths, the animated test pins // AnimationTime so the captured frame is deterministic. diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/NativeMapFallbackScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/NativeMapFallbackScreenshotTest.java new file mode 100644 index 0000000000..ac34c3a456 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/NativeMapFallbackScreenshotTest.java @@ -0,0 +1,48 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.maps.LatLng; +import com.codename1.maps.MarkerOptions; +import com.codename1.maps.NativeMap; +import com.codename1.maps.vector.BundledTileSource; +import com.codename1.maps.vector.MapStyle; +import com.codename1.ui.CN; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; + +/// Verifies that {@link NativeMap} transparently falls back to the vector +/// {@link com.codename1.maps.MapView} when no native provider is wired in. The +/// fallback is configured with the bundled real San Francisco tiles so the +/// capture is deterministic, and a marker is added through the +/// {@code MapSurface} API to prove it routes to the fallback. +/// +/// This is the complement of {@link NativeMapProviderScreenshotTest}: when a +/// native provider *is* active (e.g. an iOS build with +/// `ios.maps.provider=apple`) the fallback path is not exercised, so this test +/// skips and the provider test captures the native render instead. +public class NativeMapFallbackScreenshotTest extends BaseTest { + + @Override + public boolean runTest() { + if (CN.isWatch()) { + // No committed watch golden; phone/tablet form factors cover this. + System.out.println( + "CN1SS:INFO:test=NativeMapFallback status=SKIPPED reason=watch-form-factor"); + done(); + return true; + } + NativeMap map = new NativeMap(new LatLng(37.808, -122.412), 13, + new BundledTileSource("/maptiles/mt_{z}_{x}_{y}.mvt", true, 13, 13).setAttribution("(c) OSM"), + MapStyle.light()); + if (map.isNativeMap()) { + System.out.println( + "CN1SS:INFO:test=NativeMapFallback status=SKIPPED reason=native-provider-active"); + done(); + return true; + } + Form form = createForm("Native Map Fallback", new BorderLayout(), "NativeMapFallback"); + map.addMarker(new MarkerOptions(new LatLng(37.8087, -122.4098)).title("Pier 39")); + form.add(BorderLayout.CENTER, map); + form.show(); + return true; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/NativeMapProviderScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/NativeMapProviderScreenshotTest.java new file mode 100644 index 0000000000..dff7e0677e --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/NativeMapProviderScreenshotTest.java @@ -0,0 +1,56 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.maps.LatLng; +import com.codename1.maps.NativeMap; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; + +/// Visual confirmation that a native map provider (Apple MapKit on iOS, Google +/// Maps on Android when wired) actually renders -- the kind of "silently blank" +/// failure a smoke build cannot catch. +/// +/// To keep the baseline stable it views a deliberately low-variance scene: the +/// Italian peninsula and the Mediterranean at a regional zoom. The geography +/// does not change, there is strong land/water contrast (so a blank/grey tile +/// differs hugely from a real render), and at this zoom there is no traffic, +/// no street-level churn and minimal label movement. The map is left in its +/// default standard type with the user-location dot off, and the comparison +/// uses a lenient `.tolerance` so day-to-day tile/label noise does not fail CI +/// while a genuinely blocked map still does. +/// +/// The test only emits a screenshot when a native provider is actually active +/// (`isNativeMap()`); on the simulator / builds with no `maps.provider` wired +/// in (or a missing key) it skips rather than baseline the vector fallback -- +/// that path is covered by {@link NativeMapFallbackScreenshotTest}. +public class NativeMapProviderScreenshotTest extends BaseTest { + + @Override + public boolean runTest() { + if (com.codename1.ui.CN.isWatch()) { + // No committed watch golden, and the watch has no native map (the + // provider falls back to vector there). Phone/tablet cover this. + System.out.println( + "CN1SS:INFO:test=NativeMapProvider status=SKIPPED reason=watch-form-factor"); + done(); + return true; + } + NativeMap map = new NativeMap(new LatLng(41.0, 13.0), 5); + if (!map.isNativeMap()) { + System.out.println( + "CN1SS:INFO:test=NativeMapProvider status=SKIPPED reason=no-native-provider"); + done(); + return true; + } + Form form = createForm("Native Map Provider", new BorderLayout(), "NativeMapProvider"); + form.add(BorderLayout.CENTER, map); + form.show(); + return true; + } + + @Override + protected void registerReadyCallback(Form parent, Runnable run) { + // A live native map fetches its tiles asynchronously; give it longer than + // the default settle so the imagery is present before the capture. + com.codename1.ui.util.UITimer.timer(4000, false, parent, run); + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/RealOsmVectorScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/RealOsmVectorScreenshotTest.java new file mode 100644 index 0000000000..742ab0c248 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/RealOsmVectorScreenshotTest.java @@ -0,0 +1,36 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.maps.LatLng; +import com.codename1.maps.MapView; +import com.codename1.maps.vector.BundledTileSource; +import com.codename1.maps.vector.MapStyle; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; + +/// Renders the pure-vector {@link MapView} against *real* OpenStreetMap vector +/// tiles (a bundled San Francisco fixture downloaded from the keyless +/// OpenFreeMap basemap), proving the engine maps real OSM data -- streets, +/// water, parks, buildings and place labels. Deterministic and offline (the +/// tiles are shipped as resources), so it produces a stable screenshot baseline. +public class RealOsmVectorScreenshotTest extends BaseTest { + + @Override + public boolean runTest() { + if (com.codename1.ui.CN.isWatch()) { + // The watch form factor has no committed map goldens; the map + // coverage runs on phone/tablet form factors instead. + System.out.println( + "CN1SS:INFO:test=RealOsmVector status=SKIPPED reason=watch-form-factor"); + done(); + return true; + } + Form form = createForm("Real OSM Vector", new BorderLayout(), "RealOsmVector"); + MapView map = new MapView( + new BundledTileSource("/maptiles/mt_{z}_{x}_{y}.mvt", true, 13, 13).setAttribution("(c) OSM"), + MapStyle.light()); + map.moveCamera(new LatLng(37.814, -122.413), 13); + form.add(BorderLayout.CENTER, map); + form.show(); + return true; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VectorMapDarkStyleScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VectorMapDarkStyleScreenshotTest.java new file mode 100644 index 0000000000..677dc17e2e --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VectorMapDarkStyleScreenshotTest.java @@ -0,0 +1,34 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.maps.LatLng; +import com.codename1.maps.MapView; +import com.codename1.maps.vector.BundledTileSource; +import com.codename1.maps.vector.MapStyle; +import com.codename1.ui.CN; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; + +/// Renders the bundled real San Francisco OSM tiles with the built-in dark +/// style, exercising the style engine (background + per-layer colors) on real +/// data so it is distinct from the light basemap. +public class VectorMapDarkStyleScreenshotTest extends BaseTest { + + @Override + public boolean runTest() { + if (CN.isWatch()) { + // No committed watch golden; phone/tablet form factors cover this. + System.out.println( + "CN1SS:INFO:test=VectorMapDarkStyle status=SKIPPED reason=watch-form-factor"); + done(); + return true; + } + Form form = createForm("Vector Map Dark", new BorderLayout(), "VectorMapDarkStyle"); + MapView map = new MapView( + new BundledTileSource("/maptiles/mt_{z}_{x}_{y}.mvt", true, 13, 13).setAttribution("(c) OSM"), + MapStyle.dark()); + map.moveCamera(new LatLng(37.808, -122.412), 13); + form.add(BorderLayout.CENTER, map); + form.show(); + return true; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VectorMapMarkersScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VectorMapMarkersScreenshotTest.java new file mode 100644 index 0000000000..13c5efd732 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VectorMapMarkersScreenshotTest.java @@ -0,0 +1,37 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.maps.LatLng; +import com.codename1.maps.MapView; +import com.codename1.maps.MarkerOptions; +import com.codename1.maps.vector.BundledTileSource; +import com.codename1.maps.vector.MapStyle; +import com.codename1.ui.CN; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; + +/// Exercises marker overlays (the default Material map-pin rendering) on the +/// real San Francisco basemap at a few well-known waterfront landmarks. +public class VectorMapMarkersScreenshotTest extends BaseTest { + + @Override + public boolean runTest() { + if (CN.isWatch()) { + // No committed watch golden; phone/tablet form factors cover this. + System.out.println( + "CN1SS:INFO:test=VectorMapMarkers status=SKIPPED reason=watch-form-factor"); + done(); + return true; + } + Form form = createForm("Vector Map Markers", new BorderLayout(), "VectorMapMarkers"); + MapView map = new MapView( + new BundledTileSource("/maptiles/mt_{z}_{x}_{y}.mvt", true, 13, 13).setAttribution("(c) OSM"), + MapStyle.light()); + map.moveCamera(new LatLng(37.808, -122.412), 13); + map.addMarker(new MarkerOptions(new LatLng(37.8087, -122.4098)).title("Pier 39")); + map.addMarker(new MarkerOptions(new LatLng(37.8083, -122.4156)).title("Fisherman's Wharf")); + map.addMarker(new MarkerOptions(new LatLng(37.8024, -122.4058)).title("Coit Tower")); + form.add(BorderLayout.CENTER, map); + form.show(); + return true; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VectorMapShapesScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VectorMapShapesScreenshotTest.java new file mode 100644 index 0000000000..e40ba019bf --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/VectorMapShapesScreenshotTest.java @@ -0,0 +1,57 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.maps.Circle; +import com.codename1.maps.LatLng; +import com.codename1.maps.MapView; +import com.codename1.maps.Polygon; +import com.codename1.maps.Polyline; +import com.codename1.maps.vector.BundledTileSource; +import com.codename1.maps.vector.MapStyle; +import com.codename1.ui.CN; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; + +/// Exercises polyline, polygon and circle overlays on the real San Francisco +/// basemap: a walking route along the waterfront, a highlighted area and a +/// radius circle around a point of interest. +public class VectorMapShapesScreenshotTest extends BaseTest { + + @Override + public boolean runTest() { + if (CN.isWatch()) { + // No committed watch golden; phone/tablet form factors cover this. + System.out.println( + "CN1SS:INFO:test=VectorMapShapes status=SKIPPED reason=watch-form-factor"); + done(); + return true; + } + Form form = createForm("Vector Map Shapes", new BorderLayout(), "VectorMapShapes"); + MapView map = new MapView( + new BundledTileSource("/maptiles/mt_{z}_{x}_{y}.mvt", true, 13, 13).setAttribution("(c) OSM"), + MapStyle.light()); + map.moveCamera(new LatLng(37.806, -122.412), 13); + + Polyline route = new Polyline(); + route.addPoint(new LatLng(37.8087, -122.4098)) + .addPoint(new LatLng(37.8083, -122.4156)) + .addPoint(new LatLng(37.8066, -122.4230)); + route.setStrokeColor(0x1976d2).setStrokeWidth(6); + map.addPolyline(route); + + Polygon area = new Polygon(); + area.addPoint(new LatLng(37.805, -122.410)) + .addPoint(new LatLng(37.805, -122.404)) + .addPoint(new LatLng(37.801, -122.404)) + .addPoint(new LatLng(37.801, -122.410)); + area.setFillColor(0x331976d2).setStrokeColor(0x1976d2).setStrokeWidth(2); + map.addPolygon(area); + + Circle circle = new Circle(new LatLng(37.8087, -122.4098), 300); + circle.setFillColor(0x332e7d32).setStrokeColor(0x2e7d32).setStrokeWidth(2); + map.addCircle(circle); + + form.add(BorderLayout.CENTER, map); + form.show(); + return true; + } +} diff --git a/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1309_3163.mvt b/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1309_3163.mvt new file mode 100644 index 0000000000..7785b8fba1 Binary files /dev/null and b/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1309_3163.mvt differ diff --git a/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1309_3164.mvt b/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1309_3164.mvt new file mode 100644 index 0000000000..11e89b4277 Binary files /dev/null and b/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1309_3164.mvt differ diff --git a/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1309_3165.mvt b/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1309_3165.mvt new file mode 100644 index 0000000000..93bf56093f Binary files /dev/null and b/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1309_3165.mvt differ diff --git a/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1309_3166.mvt b/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1309_3166.mvt new file mode 100644 index 0000000000..8fa95c6437 Binary files /dev/null and b/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1309_3166.mvt differ diff --git a/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1310_3163.mvt b/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1310_3163.mvt new file mode 100644 index 0000000000..982d384b14 Binary files /dev/null and b/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1310_3163.mvt differ diff --git a/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1310_3164.mvt b/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1310_3164.mvt new file mode 100644 index 0000000000..667a550199 Binary files /dev/null and b/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1310_3164.mvt differ diff --git a/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1310_3165.mvt b/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1310_3165.mvt new file mode 100644 index 0000000000..3cab7e8e78 Binary files /dev/null and b/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1310_3165.mvt differ diff --git a/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1310_3166.mvt b/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1310_3166.mvt new file mode 100644 index 0000000000..40dad2a995 Binary files /dev/null and b/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1310_3166.mvt differ diff --git a/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1311_3163.mvt b/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1311_3163.mvt new file mode 100644 index 0000000000..8f6e283a4b Binary files /dev/null and b/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1311_3163.mvt differ diff --git a/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1311_3164.mvt b/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1311_3164.mvt new file mode 100644 index 0000000000..7dcab4a50e Binary files /dev/null and b/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1311_3164.mvt differ diff --git a/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1311_3165.mvt b/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1311_3165.mvt new file mode 100644 index 0000000000..3188163f18 Binary files /dev/null and b/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1311_3165.mvt differ diff --git a/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1311_3166.mvt b/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1311_3166.mvt new file mode 100644 index 0000000000..9bab08113c Binary files /dev/null and b/scripts/hellocodenameone/common/src/main/resources/maptiles/mt_13_1311_3166.mvt differ diff --git a/scripts/ios/screenshots-metal/NativeMapProvider.tolerance b/scripts/ios/screenshots-metal/NativeMapProvider.tolerance new file mode 100644 index 0000000000..9e151090e8 --- /dev/null +++ b/scripts/ios/screenshots-metal/NativeMapProvider.tolerance @@ -0,0 +1,6 @@ +# Live native map (Apple MapKit / Google): tiles, labels and shading load +# from the provider servers and vary run-to-run, so the tolerance is wide. It +# still fails loudly on a blank / blocked map (which differs across essentially +# the whole frame) -- the regression this test guards against. +maxChannelDelta=40 +maxMismatchPercent=30.0 diff --git a/scripts/ios/screenshots/NativeMapProvider.tolerance b/scripts/ios/screenshots/NativeMapProvider.tolerance new file mode 100644 index 0000000000..9e151090e8 --- /dev/null +++ b/scripts/ios/screenshots/NativeMapProvider.tolerance @@ -0,0 +1,6 @@ +# Live native map (Apple MapKit / Google): tiles, labels and shading load +# from the provider servers and vary run-to-run, so the tolerance is wide. It +# still fails loudly on a blank / blocked map (which differs across essentially +# the whole frame) -- the regression this test guards against. +maxChannelDelta=40 +maxMismatchPercent=30.0 diff --git a/scripts/ios/screenshots/RealOsmVector.png b/scripts/ios/screenshots/RealOsmVector.png new file mode 100644 index 0000000000..248a6d9970 Binary files /dev/null and b/scripts/ios/screenshots/RealOsmVector.png differ diff --git a/scripts/ios/screenshots/RealOsmVector.tolerance b/scripts/ios/screenshots/RealOsmVector.tolerance new file mode 100644 index 0000000000..152e9c83cc --- /dev/null +++ b/scripts/ios/screenshots/RealOsmVector.tolerance @@ -0,0 +1,3 @@ +# Deterministic vector tiles; small tolerance for backend AA/font variance. +maxChannelDelta=24 +maxMismatchPercent=5.0 diff --git a/scripts/ios/screenshots/VectorMapDarkStyle.png b/scripts/ios/screenshots/VectorMapDarkStyle.png new file mode 100644 index 0000000000..f3dbb2c827 Binary files /dev/null and b/scripts/ios/screenshots/VectorMapDarkStyle.png differ diff --git a/scripts/ios/screenshots/VectorMapDarkStyle.tolerance b/scripts/ios/screenshots/VectorMapDarkStyle.tolerance new file mode 100644 index 0000000000..152e9c83cc --- /dev/null +++ b/scripts/ios/screenshots/VectorMapDarkStyle.tolerance @@ -0,0 +1,3 @@ +# Deterministic vector tiles; small tolerance for backend AA/font variance. +maxChannelDelta=24 +maxMismatchPercent=5.0 diff --git a/scripts/ios/screenshots/VectorMapMarkers.png b/scripts/ios/screenshots/VectorMapMarkers.png new file mode 100644 index 0000000000..317f682370 Binary files /dev/null and b/scripts/ios/screenshots/VectorMapMarkers.png differ diff --git a/scripts/ios/screenshots/VectorMapMarkers.tolerance b/scripts/ios/screenshots/VectorMapMarkers.tolerance new file mode 100644 index 0000000000..152e9c83cc --- /dev/null +++ b/scripts/ios/screenshots/VectorMapMarkers.tolerance @@ -0,0 +1,3 @@ +# Deterministic vector tiles; small tolerance for backend AA/font variance. +maxChannelDelta=24 +maxMismatchPercent=5.0 diff --git a/scripts/ios/screenshots/VectorMapShapes.png b/scripts/ios/screenshots/VectorMapShapes.png new file mode 100644 index 0000000000..b40a8bf3f6 Binary files /dev/null and b/scripts/ios/screenshots/VectorMapShapes.png differ diff --git a/scripts/ios/screenshots/VectorMapShapes.tolerance b/scripts/ios/screenshots/VectorMapShapes.tolerance new file mode 100644 index 0000000000..152e9c83cc --- /dev/null +++ b/scripts/ios/screenshots/VectorMapShapes.tolerance @@ -0,0 +1,3 @@ +# Deterministic vector tiles; small tolerance for backend AA/font variance. +maxChannelDelta=24 +maxMismatchPercent=5.0 diff --git a/scripts/mac-native/screenshots/NativeMapProvider.tolerance b/scripts/mac-native/screenshots/NativeMapProvider.tolerance new file mode 100644 index 0000000000..9e151090e8 --- /dev/null +++ b/scripts/mac-native/screenshots/NativeMapProvider.tolerance @@ -0,0 +1,6 @@ +# Live native map (Apple MapKit / Google): tiles, labels and shading load +# from the provider servers and vary run-to-run, so the tolerance is wide. It +# still fails loudly on a blank / blocked map (which differs across essentially +# the whole frame) -- the regression this test guards against. +maxChannelDelta=40 +maxMismatchPercent=30.0