From fb57c0ad79a2e600d2a42f366b8fdeb72982eb9e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 17 Sep 2026 05:31:44 +0300 Subject: [PATCH 1/3] maps: add basemap names and route-stop labels --- .../src/com/codename1/maps/MapView.java | 91 ++++++++ .../src/com/codename1/maps/Marker.java | 8 + .../src/com/codename1/maps/MarkerOptions.java | 15 ++ .../codename1/maps/routing/RouteRequest.java | 69 +++++- .../com/codename1/maps/routing/Routing.java | 53 ++++- .../com/codename1/maps/vector/MapStyle.java | 30 ++- .../com/codename1/maps/vector/StyleLayer.java | 2 +- .../codename1/maps/vector/TileRenderer.java | 47 ++++- .../maps/MapViewMarkerLabelTest.java | 161 ++++++++++++++ .../com/codename1/maps/MapsModelTest.java | 2 + .../maps/routing/RouteLabelsTest.java | 199 ++++++++++++++++++ .../codename1/maps/vector/MapLabelsTest.java | 124 +++++++++++ 12 files changed, 785 insertions(+), 16 deletions(-) create mode 100644 maven/core-unittests/src/test/java/com/codename1/maps/MapViewMarkerLabelTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/maps/routing/RouteLabelsTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/maps/vector/MapLabelsTest.java diff --git a/CodenameOne/src/com/codename1/maps/MapView.java b/CodenameOne/src/com/codename1/maps/MapView.java index d0a71e4907b..a4c447f4a0b 100644 --- a/CodenameOne/src/com/codename1/maps/MapView.java +++ b/CodenameOne/src/com/codename1/maps/MapView.java @@ -411,6 +411,11 @@ private void drawOverlays(Graphics g) { for (Object markerObj : markers) { drawMarker(g, (Marker) markerObj); } + // Draw labels last so routes and later pins cannot paint over them. + List labelBounds = new ArrayList(); + for (Object markerObj : markers) { + drawMarkerLabel(g, (Marker) markerObj, labelBounds); + } } private void drawPolyline(Graphics g, Polyline pl) { @@ -502,6 +507,92 @@ private void drawMarker(Graphics g, Marker m) { g.setAlpha(prevAlpha); } + private void drawMarkerLabel(Graphics g, Marker marker, List occupied) { + String label = marker.getLabel(); + if (!marker.isVisible() || label == null || label.trim().length() == 0) { + return; + } + Point point = engine.latLngToScreen(marker.getPosition()); + int px = point.getX(); + int py = point.getY(); + if (px < 0 || py < 0 || px > getWidth() || py > getHeight()) { + return; + } + Font font = Font.getDefaultFont(); + int padding = Math.max(2, CN.convertToPixels(1)); + int available = getWidth() - padding * 4; + if (available <= 0 || getHeight() < font.getHeight() + padding * 2) { + return; + } + // Keep long place names inside the viewport without changing the model. + if (font.stringWidth(label) > available) { + String ellipsis = "..."; + int end = label.length(); + while (end > 0 && font.stringWidth(label.substring(0, end) + ellipsis) > available) { + end--; + } + if (end == 0) { + return; + } + label = label.substring(0, end) + ellipsis; + } + int width = font.stringWidth(label) + padding * 2; + int height = font.getHeight() + padding * 2; + int x = Math.max(0, Math.min(px - width / 2, getWidth() - width)); + // Prefer below the pin's tip, away from its icon; flip at the bottom edge. + int y = py + padding; + if (y + height > getHeight()) { + int iconHeight = marker.getIcon() == null ? markerFont().getHeight() : marker.getIcon().getHeight(); + y = Math.max(0, py - (int) (iconHeight * marker.getAnchorV()) - height - padding); + } + // Nearby stops (including a round trip's start and destination) must + // not paint their names on top of each other. Try rows below/above. + int preferredY = y; + boolean placed = false; + for (int offset = 0; offset < getHeight(); offset += height + padding) { + if (markerLabelFits(x, preferredY + offset, width, height, occupied)) { + y = preferredY + offset; + placed = true; + break; + } + if (offset > 0 && markerLabelFits(x, preferredY - offset, width, height, occupied)) { + y = preferredY - offset; + placed = true; + break; + } + } + if (!placed) { + return; + } + occupied.add(new int[]{x, y, width, height}); + Font previousFont = g.getFont(); + int previousAlpha = g.getAlpha(); + int previousColor = g.getColor(); + g.setAlpha(255); + g.setColor(0xffffff); + g.fillRoundRect(x, y, width, height, padding * 2, padding * 2); + g.setFont(font); + g.setColor(0x222222); + g.drawString(label, x + padding, y + padding); + g.setFont(previousFont); + g.setAlpha(previousAlpha); + g.setColor(previousColor); + } + + private boolean markerLabelFits(int x, int y, int width, int height, List occupied) { + if (y < 0 || y + height > getHeight()) { + return false; + } + for (Object item : occupied) { + int[] box = (int[]) item; + if (x < box[0] + box[2] && x + width > box[0] + && y < box[1] + box[3] && y + height > box[1]) { + return false; + } + } + return true; + } + private Font markerFont() { if (markerFont == null) { float size = CN.convertToPixels(7f); diff --git a/CodenameOne/src/com/codename1/maps/Marker.java b/CodenameOne/src/com/codename1/maps/Marker.java index afe635f9e64..e2239005037 100644 --- a/CodenameOne/src/com/codename1/maps/Marker.java +++ b/CodenameOne/src/com/codename1/maps/Marker.java @@ -34,6 +34,7 @@ public final class Marker extends MapObject { private LatLng position; private final EncodedImage icon; private final String title; + private final String label; private final String snippet; private final float anchorU; private final float anchorV; @@ -45,6 +46,7 @@ public final class Marker extends MapObject { this.position = options.getPosition(); this.icon = options.getIcon(); this.title = options.getTitle(); + this.label = options.getLabel(); this.snippet = options.getSnippet(); this.anchorU = options.getAnchorU(); this.anchorV = options.getAnchorV(); @@ -73,6 +75,12 @@ public String getTitle() { return title; } + /// The persistent vector-map label, or `null` when none was supplied. + /// See [MarkerOptions#label(String)]. + public String getLabel() { + return label; + } + /// The secondary text shown beneath the title (provider dependent). public String getSnippet() { return snippet; diff --git a/CodenameOne/src/com/codename1/maps/MarkerOptions.java b/CodenameOne/src/com/codename1/maps/MarkerOptions.java index 8598ee4d9b6..c0b21c17dbf 100644 --- a/CodenameOne/src/com/codename1/maps/MarkerOptions.java +++ b/CodenameOne/src/com/codename1/maps/MarkerOptions.java @@ -41,6 +41,7 @@ public final class MarkerOptions { private LatLng position; private EncodedImage icon; private String title; + private String label; private String snippet; private float anchorU = 0.5f; private float anchorV = 1.0f; @@ -74,6 +75,16 @@ public MarkerOptions title(String title) { return this; } + /// Sets a persistent label beside the pin on [MapView] (including a + /// [NativeMap] using its vector fallback). Unlike [#title], this text is + /// visible without tapping. Native providers still use the info-window + /// title, so set both when the marker should work on either surface. + /// `null` or blank text leaves the pin unlabeled. + public MarkerOptions label(String label) { + this.label = label; + return this; + } + /// Sets the info-window secondary text. public MarkerOptions snippet(String snippet) { this.snippet = snippet; @@ -116,6 +127,10 @@ String getTitle() { return title; } + String getLabel() { + return label; + } + String getSnippet() { return snippet; } diff --git a/CodenameOne/src/com/codename1/maps/routing/RouteRequest.java b/CodenameOne/src/com/codename1/maps/routing/RouteRequest.java index d3d6be46e9c..f6ba8c25a99 100644 --- a/CodenameOne/src/com/codename1/maps/routing/RouteRequest.java +++ b/CodenameOne/src/com/codename1/maps/routing/RouteRequest.java @@ -35,7 +35,8 @@ /// /// ```java /// RouteRequest req = new RouteRequest(home, office) -/// .addWaypoint(daycare) +/// .setOriginLabel("Home").setDestinationLabel("Office") +/// .addWaypoint(daycare, "Daycare") /// .setTravelMode(TravelMode.DRIVING) /// .setAlternatives(true); /// ``` @@ -44,6 +45,10 @@ public final class RouteRequest { private final LatLng origin; private final LatLng destination; private final List waypoints = new ArrayList(); + private final List waypointLabels = new ArrayList(); + private String originLabel; + private String destinationLabel; + private boolean showStopLabels = true; private TravelMode travelMode = TravelMode.DRIVING; private boolean alternatives; private boolean steps = true; @@ -69,8 +74,15 @@ public LatLng getDestination() { /// order. Backends route through waypoints in the order added; none of /// them reorder to optimize the trip. public RouteRequest addWaypoint(LatLng waypoint) { + return addWaypoint(waypoint, null); + } + + /// Adds a stop with a display name for [Routing#showRoute]. A `null` + /// waypoint is ignored; a blank name uses the numbered default label. + public RouteRequest addWaypoint(LatLng waypoint, String label) { if (waypoint != null) { waypoints.add(waypoint); + waypointLabels.add(label); } return this; } @@ -83,6 +95,61 @@ public List getWaypoints() { return Collections.unmodifiableList(waypoints); } + /// Optional display names in the same order as [#getWaypoints()]. Entries + /// may be `null`; [Routing#showRoute] then uses "Stop 1", "Stop 2", etc. + public List getWaypointLabels() { + return Collections.unmodifiableList(waypointLabels); + } + + /// The optional name displayed at the journey's start. + public String getOriginLabel() { + return originLabel; + } + + /// Names the start marker. Blank or `null` uses "Start". This is display + /// text only: it does not change the coordinates sent to the service. + public RouteRequest setOriginLabel(String label) { + originLabel = label; + return this; + } + + /// The optional name displayed at the destination. + public String getDestinationLabel() { + return destinationLabel; + } + + /// Names the destination marker. Blank or `null` uses "Destination". + public RouteRequest setDestinationLabel(String label) { + destinationLabel = label; + return this; + } + + /// Whether [Routing#showRoute] adds labeled start, stop and destination + /// markers. True by default. [Routing#findRoute] only returns route data. + public boolean isShowStopLabels() { + return showStopLabels; + } + + /// Enables or disables the labeled markers added by [Routing#showRoute]. + public RouteRequest setShowStopLabels(boolean show) { + showStopLabels = show; + return this; + } + + // Freeze geometry and display metadata together before asynchronous routing. + RouteRequest snapshot() { + RouteRequest copy = new RouteRequest(origin, destination); + copy.waypoints.addAll(waypoints); + copy.waypointLabels.addAll(waypointLabels); + copy.originLabel = originLabel; + copy.destinationLabel = destinationLabel; + copy.showStopLabels = showStopLabels; + copy.travelMode = travelMode; + copy.alternatives = alternatives; + copy.steps = steps; + return copy; + } + /// How the traveller moves; [TravelMode#DRIVING] unless changed. public TravelMode getTravelMode() { return travelMode; diff --git a/CodenameOne/src/com/codename1/maps/routing/Routing.java b/CodenameOne/src/com/codename1/maps/routing/Routing.java index 29ea202a9a1..b4d9042fbc7 100644 --- a/CodenameOne/src/com/codename1/maps/routing/Routing.java +++ b/CodenameOne/src/com/codename1/maps/routing/Routing.java @@ -24,6 +24,9 @@ import com.codename1.maps.LatLng; import com.codename1.maps.MapSurface; +import com.codename1.maps.MapBounds; +import com.codename1.maps.MarkerOptions; +import com.codename1.ui.plaf.UIManager; import com.codename1.ui.CN; import java.util.List; @@ -225,7 +228,16 @@ public static void showRoute(MapSurface map, LatLng origin, LatLng destination) /// Draws the best route for `request` on `map`, frames it, and forwards /// the outcome to `callback`. /// - /// The polyline is added and the camera moved *before* `callback` runs, so + /// Adds labeled markers at the requested start, intermediate stops and + /// destination. Supply place names through [RouteRequest#setOriginLabel], + /// [RouteRequest#setDestinationLabel] and [RouteRequest#addWaypoint(LatLng, String)]; + /// otherwise the labels are "Start", "Stop 1", etc. and "Destination" + /// (localized through the UI manager). No reverse geocoding is performed. + /// Labels stay visible on vector maps; native providers show the same + /// names as marker titles. Disable these markers with + /// [RouteRequest#setShowStopLabels]. The request is snapshotted when called. + /// + /// The polyline and markers are added and the camera moved *before* `callback` runs, so /// the callback can read the route's distance and duration to update the /// UI. It cannot restyle the line that was drawn -- that polyline is not /// exposed, and [Route#toPolyline()] hands back a fresh one every call. To @@ -242,7 +254,8 @@ public static void showRoute(MapSurface map, LatLng origin, LatLng destination) /// - `callback`: notified of the outcome, or `null` to just draw the route public static void showRoute(final MapSurface map, RouteRequest request, final RouteCallback callback) { - findRoute(request, new RouteCallback() { + final RouteRequest submitted = request == null ? null : request.snapshot(); + findRoute(submitted, new RouteCallback() { @Override public void routesFound(List routes) { if (routes == null || routes.isEmpty()) { @@ -261,8 +274,25 @@ public void routesFound(List routes) { } Route best = (Route) first; map.addPolyline(best.toPolyline()); - if (best.getBounds() != null) { - map.fitBounds(best.getBounds(), CN.convertToPixels(4)); + MapBounds bounds = best.getBounds(); + boolean labels = submitted != null && submitted.isShowStopLabels(); + if (labels) { + bounds = addStopMarker(map, submitted.getOrigin(), + stopLabel(submitted.getOriginLabel(), "Start"), bounds); + List stops = submitted.getWaypoints(); + List names = submitted.getWaypointLabels(); + for (int i = 0; i < stops.size(); i++) { + String name = (String) names.get(i); + if (name == null || name.trim().length() == 0) { + name = UIManager.getInstance().localize("Stop", "Stop") + " " + (i + 1); + } + bounds = addStopMarker(map, (LatLng) stops.get(i), name, bounds); + } + bounds = addStopMarker(map, submitted.getDestination(), + stopLabel(submitted.getDestinationLabel(), "Destination"), bounds); + } + if (bounds != null) { + map.fitBounds(bounds, CN.convertToPixels(labels ? 10 : 4)); } if (callback != null) { callback.routesFound(routes); @@ -277,4 +307,19 @@ public void routeFailed(String message, Throwable error) { } }); } + + private static String stopLabel(String name, String fallback) { + return name == null || name.trim().length() == 0 + ? UIManager.getInstance().localize(fallback, fallback) : name; + } + + private static MapBounds addStopMarker(MapSurface map, LatLng position, + String label, MapBounds bounds) { + if (position == null) { + return bounds; + } + map.addMarker(new MarkerOptions(position).title(label).label(label)); + return bounds == null ? new MapBounds(position, position) : bounds.extend(position); + } + } diff --git a/CodenameOne/src/com/codename1/maps/vector/MapStyle.java b/CodenameOne/src/com/codename1/maps/vector/MapStyle.java index 0fbe055d3f5..b082ce44a9c 100644 --- a/CodenameOne/src/com/codename1/maps/vector/MapStyle.java +++ b/CodenameOne/src/com/codename1/maps/vector/MapStyle.java @@ -70,7 +70,9 @@ List getLayers() { // ---- Built-in styles -------------------------------------------------- - /// A clean light basemap (sensible default for most apps). + /// A clean light basemap (sensible default for most apps). Settlement + /// labels are joined by street and park names from zoom 12 and points of + /// interest from zoom 14, when the tile source supplies those names. /// /// Every colour falls back to the value baked in here but is overridable /// through a theme constant (a CSS color string) so an app can recolour the @@ -96,8 +98,7 @@ public static MapStyle light() { addPolygonRule(s, "buildings", building); int label = themeColor("mapLightLabelColor", 0xff333333); int halo = themeColor("mapLightLabelHaloColor", 0xffffffff); - addSymbolRule(s, "place", "name", label, halo); - addSymbolRule(s, "place_label", "name", label, halo); + addBasemapLabels(s, label, halo); return s; } @@ -123,8 +124,7 @@ public static MapStyle dark() { addPolygonRule(s, "buildings", building); int label = themeColor("mapDarkLabelColor", 0xffe8e8e8); int halo = themeColor("mapDarkLabelHaloColor", 0xff000000); - addSymbolRule(s, "place", "name", label, halo); - addSymbolRule(s, "place_label", "name", label, halo); + addBasemapLabels(s, label, halo); return s; } @@ -155,11 +155,25 @@ private static StyleLayer addLineRule(MapStyle s, String sourceLayer, int color, return sl; } - private static void addSymbolRule(MapStyle s, String sourceLayer, String field, + // Within each tile, consider settlement names before streets and landmarks. + // Detail labels only appear at neighbourhood/street zooms. + private static void addBasemapLabels(MapStyle s, int label, int halo) { + addSymbolRule(s, "place", "name", label, halo); + addSymbolRule(s, "place_label", "name", label, halo); + addSymbolRule(s, "transportation_name", "name", label, halo).zoomRange(12, 24); + addSymbolRule(s, "road", "name", label, halo).zoomRange(12, 24); + addSymbolRule(s, "road_label", "name", label, halo).zoomRange(12, 24); + addSymbolRule(s, "park", "name", label, halo).zoomRange(12, 24); + addSymbolRule(s, "poi", "name", label, halo).zoomRange(14, 24); + } + + private static StyleLayer addSymbolRule(MapStyle s, String sourceLayer, String field, int textColor, int haloColor) { - s.add(new StyleLayer(StyleLayer.TYPE_SYMBOL).sourceLayer(sourceLayer).textField(field) + StyleLayer layer = new StyleLayer(StyleLayer.TYPE_SYMBOL).sourceLayer(sourceLayer).textField(field) .textColor(textColor).textHaloColor(haloColor) - .textSize(ZoomValue.constant(13))); + .textSize(ZoomValue.constant(13)); + s.add(layer); + return layer; } // ---- JSON loading ----------------------------------------------------- diff --git a/CodenameOne/src/com/codename1/maps/vector/StyleLayer.java b/CodenameOne/src/com/codename1/maps/vector/StyleLayer.java index 8c31f40dcd1..39100903016 100644 --- a/CodenameOne/src/com/codename1/maps/vector/StyleLayer.java +++ b/CodenameOne/src/com/codename1/maps/vector/StyleLayer.java @@ -38,7 +38,7 @@ public final class StyleLayer { public static final int TYPE_FILL = 1; /// Stroked lines. public static final int TYPE_LINE = 2; - /// Text labels placed at point/centroid positions. + /// Text labels placed at points, line midpoints or polygon centers. public static final int TYPE_SYMBOL = 3; private final int type; diff --git a/CodenameOne/src/com/codename1/maps/vector/TileRenderer.java b/CodenameOne/src/com/codename1/maps/vector/TileRenderer.java index e797223e07e..bd84e9f8159 100644 --- a/CodenameOne/src/com/codename1/maps/vector/TileRenderer.java +++ b/CodenameOne/src/com/codename1/maps/vector/TileRenderer.java @@ -163,7 +163,7 @@ static List extractLabels(VectorTile tile, MapStyle style, int zoom, continue; } Object value = sl.getTextField() == null ? null : f.getAttribute(sl.getTextField()); - if (value == null) { + if (value == null || String.valueOf(value).trim().length() == 0) { continue; } double[] anchor = anchorOf(f); @@ -185,11 +185,54 @@ static List extractLabels(VectorTile tile, MapStyle style, int zoom, return out; } + // Put road names halfway along the longest line part. Averaging vertices + // can put a label far from a curved road, and biases it toward dense bends. + private static double[] lineAnchor(List parts) { + int[] longest = null; + double longestLength = 0; + for (Object part : parts) { + int[] line = (int[]) part; + double length = 0; + for (int i = 2; i + 1 < line.length; i += 2) { + length += segmentLength(line, i); + } + if (length > longestLength) { + longest = line; + longestLength = length; + } + } + if (longest == null) { + return null; + } + double remaining = longestLength / 2; + for (int i = 2; i + 1 < longest.length; i += 2) { + double length = segmentLength(longest, i); + if (length > 0 && remaining <= length) { + double fraction = remaining / length; + return new double[]{ + longest[i - 2] + fraction * ((double) longest[i] - longest[i - 2]), + longest[i - 1] + fraction * ((double) longest[i + 1] - longest[i - 1]) + }; + } + remaining -= length; + } + return null; + } + + private static double segmentLength(int[] line, int end) { + double dx = (double) line[end] - line[end - 2]; + double dy = (double) line[end + 1] - line[end - 1]; + return Math.sqrt(dx * dx + dy * dy); + } + private static double[] anchorOf(VectorFeature f) { List parts = f.getParts(); if (parts.isEmpty()) { return null; } + if (f.getGeometryType() == VectorFeature.GEOM_LINESTRING) { + return lineAnchor(parts); + } int[] first = (int[]) parts.get(0); if (first.length < 2) { return null; @@ -197,7 +240,7 @@ private static double[] anchorOf(VectorFeature f) { if (f.getGeometryType() == VectorFeature.GEOM_POINT) { return new double[]{first[0], first[1]}; } - // Centroid of the first ring/line as the label anchor. + // Average of the first polygon ring as the label anchor. double sx = 0; double sy = 0; int n = 0; diff --git a/maven/core-unittests/src/test/java/com/codename1/maps/MapViewMarkerLabelTest.java b/maven/core-unittests/src/test/java/com/codename1/maps/MapViewMarkerLabelTest.java new file mode 100644 index 00000000000..bba3aa494ca --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/maps/MapViewMarkerLabelTest.java @@ -0,0 +1,161 @@ +/* + * 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 com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.maps.vector.MapStyle; +import com.codename1.maps.vector.TileCallback; +import com.codename1.maps.vector.TileSource; +import com.codename1.ui.EncodedImage; +import com.codename1.ui.Font; +import com.codename1.ui.Graphics; +import com.codename1.ui.Image; +import com.codename1.testing.TestCodenameOneImplementation; +import com.codename1.ui.Stroke; +import com.codename1.ui.geom.Shape; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class MapViewMarkerLabelTest extends UITestBase { + private TestCodenameOneImplementation drawing; + @FormTest + void persistentLabelsAreDrawnAfterTheRouteAndCustomPins() { + MapView map = map(); + EncodedImage icon = icon(); + map.addPolyline(new Polyline().addPoint(new LatLng(0, -1)).addPoint(new LatLng(0, 1))); + map.addMarker(new MarkerOptions(new LatLng(0, 0)).icon(icon).label("Museum")); + Graphics graphics = graphics(); + map.paintBackground(graphics); + InOrder order = inOrder(drawing); + order.verify(drawing).drawShape(any(), any(Shape.class), any(Stroke.class)); + order.verify(drawing).drawString(any(), eq("Museum"), anyInt(), anyInt()); + } + + @FormTest + void hidingRemovingAndPanningAwayAlsoHideTheLabel() { + MapView map = map(); + Marker marker = map.addMarker(new MarkerOptions(new LatLng(0, 0)).icon(icon()).label("Home")); + marker.setVisible(false); + Graphics graphics = graphics(); + map.paintBackground(graphics); + verify(drawing, never()).drawString(any(), eq("Home"), anyInt(), anyInt()); + marker.setVisible(true); + map.moveCamera(new LatLng(0, 150), 10); + graphics = graphics(); + map.paintBackground(graphics); + verify(drawing, never()).drawString(any(), eq("Home"), anyInt(), anyInt()); + map.moveCamera(new LatLng(0, 0), 10); + graphics = graphics(); + map.paintBackground(graphics); + verify(drawing).drawString(any(), eq("Home"), anyInt(), anyInt()); + map.removeMarker(marker); + graphics = graphics(); + map.paintBackground(graphics); + verify(drawing, never()).drawString(any(), eq("Home"), anyInt(), anyInt()); + } + + @FormTest + void longLabelsStayInsideTheViewportWithoutChangingTheName() { + MapView map = map(); + String name = "A very long place name that cannot fit inside this small map viewport"; + Marker marker = map.addMarker(new MarkerOptions(new LatLng(0, 0)).icon(icon()).label(name)); + Graphics graphics = graphics(); + map.paintBackground(graphics); + ArgumentCaptor text = ArgumentCaptor.forClass(String.class); + ArgumentCaptor x = ArgumentCaptor.forClass(Integer.class); + ArgumentCaptor y = ArgumentCaptor.forClass(Integer.class); + verify(drawing).drawString(any(), text.capture(), x.capture(), y.capture()); + assertTrue(text.getValue().endsWith("...")); + assertTrue(x.getValue() >= 0); + assertTrue(x.getValue() + Font.getDefaultFont().stringWidth(text.getValue()) <= map.getWidth()); + assertTrue(y.getValue() + Font.getDefaultFont().getHeight() <= map.getHeight()); + assertEquals(name, marker.getLabel()); + } + + @FormTest + void roundTripEndpointsDoNotPaintTheirLabelsOnTopOfEachOther() { + MapView map = map(); + map.addMarker(new MarkerOptions(new LatLng(0, 0)).icon(icon()).label("Start")); + map.addMarker(new MarkerOptions(new LatLng(0, 0)).icon(icon()).label("Destination")); + map.paintBackground(graphics()); + ArgumentCaptor startY = ArgumentCaptor.forClass(Integer.class); + ArgumentCaptor destinationY = ArgumentCaptor.forClass(Integer.class); + verify(drawing).drawString(any(), eq("Start"), anyInt(), startY.capture()); + verify(drawing).drawString(any(), eq("Destination"), anyInt(), destinationY.capture()); + assertTrue(Math.abs(startY.getValue() - destinationY.getValue()) >= Font.getDefaultFont().getHeight()); + } + + @FormTest + void defaultPinLabelFlipsAboveTheBottomEdge() { + MapView map = map(); + map.addMarker(new MarkerOptions(map.screenToLatLng(190, 155)).label("Destination")); + Graphics graphics = graphics(); + map.paintBackground(graphics); + ArgumentCaptor x = ArgumentCaptor.forClass(Integer.class); + ArgumentCaptor y = ArgumentCaptor.forClass(Integer.class); + verify(drawing).drawString(any(), eq("Destination"), x.capture(), y.capture()); + assertTrue(x.getValue() >= 0); + assertTrue(x.getValue() + Font.getDefaultFont().stringWidth("Destination") <= map.getWidth()); + assertTrue(y.getValue() >= 0); + assertTrue(y.getValue() + Font.getDefaultFont().getHeight() < 155); + } + + @FormTest + void infoWindowTitlesAndBlankLabelsDoNotBecomePersistentLabels() { + MapView map = map(); + map.addMarker(new MarkerOptions(new LatLng(0, 0)).icon(icon()).title("Title only")); + map.addMarker(new MarkerOptions(new LatLng(0, 1)).icon(icon()).label(" ")); + Graphics graphics = graphics(); + map.paintBackground(graphics); + verify(drawing, never()).drawString(any(), anyString(), anyInt(), anyInt()); + } + + private Graphics graphics() { + Graphics graphics = Image.createImage(200, 160).getGraphics(); + drawing = spy(implementation); + drawing.setShapeSupported(true); + try { + java.lang.reflect.Field field = Graphics.class.getDeclaredField("impl"); + field.setAccessible(true); + field.set(graphics, drawing); + } catch (Exception e) { + throw new AssertionError(e); + } + return graphics; + } + + private EncodedImage icon() { + return EncodedImage.create(new byte[]{1, 2, 3}, 12, 16, true); + } + + private MapView map() { + TileSource source = new TileSource() { + public boolean isVector() { return true; } + public int getTileSize() { return 256; } + public int getMinZoom() { return 0; } + public int getMaxZoom() { return 18; } + public String getAttribution() { return ""; } + public void fetchTile(int z, int x, int y, TileCallback callback) { + callback.tileFailed(z, x, y); + } + }; + MapView map = new MapView(source, new MapStyle("empty", 0xffeeeeee)); + map.setWidth(200); + map.setHeight(160); + map.moveCamera(new LatLng(0, 0), 5); + return map; + } +} 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 index 77b81466070..ac538abe352 100644 --- a/maven/core-unittests/src/test/java/com/codename1/maps/MapsModelTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/maps/MapsModelTest.java @@ -138,6 +138,8 @@ 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()); + assertNull(m.getLabel(), "info-window titles do not opt into persistent labels"); + assertEquals("Home", new MarkerOptions(new LatLng(1, 2)).label("Home").build().getLabel()); assertEquals("s", m.getSnippet()); assertEquals(0.5f, m.getAnchorU(), 1e-6); assertEquals(1.0f, m.getAnchorV(), 1e-6); diff --git a/maven/core-unittests/src/test/java/com/codename1/maps/routing/RouteLabelsTest.java b/maven/core-unittests/src/test/java/com/codename1/maps/routing/RouteLabelsTest.java new file mode 100644 index 00000000000..e49a37589d9 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/maps/routing/RouteLabelsTest.java @@ -0,0 +1,199 @@ +/* + * 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.routing; + +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.maps.LatLng; +import com.codename1.maps.MapBounds; +import com.codename1.maps.MapView; +import com.codename1.maps.Marker; +import com.codename1.maps.MarkerOptions; +import com.codename1.maps.Polyline; +import com.codename1.maps.vector.DemoTileSource; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import static org.junit.jupiter.api.Assertions.*; + +class RouteLabelsTest extends UITestBase { + @AfterEach + void restoreRouteService() { + Routing.setService(null); + } + + @FormTest + void showRouteAddsDefaultStopLabelsBeforeReportingSuccess() { + RouteRequest request = new RouteRequest(new LatLng(1, 2), new LatLng(3, 4)) + .addWaypoint(new LatLng(2, 3)).addWaypoint(new LatLng(2.5, 3.5)); + Routing.setService(successfulService()); + final RecordingMap map = new RecordingMap(); + Routing.showRoute(map, request, new RouteCallback() { + @Override + public void routesFound(List routes) { + assertEquals(1, map.polylines); + assertEquals(1, map.fits); + assertEquals(4, map.markers.size()); + } + + @Override + public void routeFailed(String message, Throwable error) { + throw new AssertionError(message, error); + } + }); + flushSerialCalls(); + assertEquals("Start", map.markers.get(0).getLabel()); + assertEquals("Stop 1", map.markers.get(1).getLabel()); + assertEquals("Stop 2", map.markers.get(2).getLabel()); + assertEquals("Destination", map.markers.get(3).getLabel()); + assertEquals(request.getOrigin(), map.markers.get(0).getPosition()); + assertEquals(request.getWaypoints().get(0), map.markers.get(1).getPosition()); + assertEquals(request.getDestination(), map.markers.get(3).getPosition()); + for (Marker marker : map.markers) { + assertEquals(marker.getLabel(), marker.getTitle(), "native providers retain the name as a title"); + assertTrue(map.bounds.contains(marker.getPosition()), "requested stops may be off the snapped route"); + } + } + + @FormTest + void customNamesAndNullWaypointsStayAligned() { + RouteRequest request = new RouteRequest(new LatLng(1, 2), new LatLng(3, 4)) + .setOriginLabel("Home").setDestinationLabel("Museum") + .addWaypoint(null, "Ignored").addWaypoint(new LatLng(2, 3), "Cafe") + .addWaypoint(new LatLng(2.5, 3.5), " "); + assertEquals(2, request.getWaypointLabels().size()); + assertThrows(UnsupportedOperationException.class, () -> request.getWaypointLabels().add("bad")); + Routing.setService(successfulService()); + RecordingMap map = new RecordingMap(); + Routing.showRoute(map, request, null); + flushSerialCalls(); + assertEquals("Home", map.markers.get(0).getLabel()); + assertEquals("Cafe", map.markers.get(1).getLabel()); + assertEquals("Stop 2", map.markers.get(2).getLabel()); + assertEquals("Museum", map.markers.get(3).getLabel()); + } + + @FormTest + void labelsCanBeDisabledAndFailuresAddNoMarkers() { + Routing.setService(successfulService()); + RecordingMap map = new RecordingMap(); + Routing.showRoute(map, new RouteRequest(new LatLng(1, 2), new LatLng(3, 4)) + .setShowStopLabels(false), null); + flushSerialCalls(); + assertEquals(1, map.polylines); + assertTrue(map.markers.isEmpty()); + Routing.setService(failingService()); + Routing.showRoute(map, new RouteRequest(new LatLng(1, 2), new LatLng(3, 4)), null); + flushSerialCalls(); + assertTrue(map.markers.isEmpty()); + assertEquals(1, map.polylines); + } + + @FormTest + void asynchronousRouteUsesTheSubmittedNamesAndStops() { + final RouteRequest[] received = new RouteRequest[1]; + final RouteCallback[] pending = new RouteCallback[1]; + Routing.setService(new RouteService() { + public String getId() { return "deferred"; } + public boolean isAvailable() { return true; } + public void findRoutes(RouteRequest request, RouteCallback callback) { + received[0] = request; + pending[0] = callback; + } + }); + RouteRequest request = new RouteRequest(new LatLng(1, 2), new LatLng(3, 4)) + .setOriginLabel("Home").addWaypoint(new LatLng(2, 3), "Cafe") + .setTravelMode(TravelMode.CYCLING).setAlternatives(true).setSteps(false); + RecordingMap map = new RecordingMap(); + Routing.showRoute(map, request, null); + request.setOriginLabel("Changed").addWaypoint(new LatLng(2.5, 3.5), "Later") + .setShowStopLabels(false); + assertNotSame(request, received[0]); + assertEquals(TravelMode.CYCLING, received[0].getTravelMode()); + assertTrue(received[0].isAlternatives()); + assertFalse(received[0].isSteps()); + assertEquals(1, received[0].getWaypoints().size()); + pending[0].routesFound(java.util.Arrays.asList(sampleRoute())); + flushSerialCalls(); + assertEquals(3, map.markers.size()); + assertEquals("Home", map.markers.get(0).getLabel()); + assertEquals("Cafe", map.markers.get(1).getLabel()); + } + + private static Route sampleRoute() { + return new Route(java.util.Arrays.asList(new LatLng(1.1, 2.1), new LatLng(2.9, 3.9)), + null, 1000, 100, "Main Street"); + } + + + private RouteService successfulService() { + return new RouteService() { + public String getId() { return "success"; } + public boolean isAvailable() { return true; } + public void findRoutes(RouteRequest request, RouteCallback callback) { + callback.routesFound(java.util.Arrays.asList(sampleRoute())); + } + }; + } + + private RouteService failingService() { + return new RouteService() { + public String getId() { return "failure"; } + public boolean isAvailable() { return true; } + public void findRoutes(RouteRequest request, RouteCallback callback) { + callback.routeFailed("No route", null); + } + }; + } + + private static class RecordingMap extends MapView { + private int polylines; + private int fits; + private MapBounds bounds; + private final List markers = new ArrayList(); + + RecordingMap() { + super(new DemoTileSource()); + } + + @Override + public Polyline addPolyline(Polyline polyline) { + polylines++; + return super.addPolyline(polyline); + } + + @Override + public Marker addMarker(MarkerOptions options) { + Marker marker = super.addMarker(options); + markers.add(marker); + return marker; + } + + @Override + public void fitBounds(MapBounds bounds, int padding) { + fits++; + this.bounds = bounds; + super.fitBounds(bounds, padding); + } + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/maps/vector/MapLabelsTest.java b/maven/core-unittests/src/test/java/com/codename1/maps/vector/MapLabelsTest.java new file mode 100644 index 00000000000..4c815b0cd95 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/maps/vector/MapLabelsTest.java @@ -0,0 +1,124 @@ +/* + * 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 java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MapLabelsTest { + @Test + void builtInStylesIncludeStreetParkAndLandmarkNamesAtDetailZooms() { + VectorTile tile = new VectorTile(Arrays.asList( + layer("place", named("Town", VectorFeature.GEOM_POINT, new int[]{100, 100})), + layer("transportation_name", named("Main Street", VectorFeature.GEOM_LINESTRING, + new int[]{100, 200, 300, 200})), + layer("park", named("City Park", VectorFeature.GEOM_POINT, new int[]{800, 800})), + layer("poi", named("Museum", VectorFeature.GEOM_POINT, new int[]{1500, 1500})))); + for (MapStyle style : new MapStyle[]{MapStyle.light(), MapStyle.dark()}) { + assertEquals(Arrays.asList("Town"), texts(labels(tile, style, 11))); + assertEquals(Arrays.asList("Town", "Main Street", "City Park"), texts(labels(tile, style, 12))); + List detail = labels(tile, style, 14); + assertEquals(Arrays.asList("Town", "Main Street", "City Park", "Museum"), texts(detail)); + for (LabelCandidate label : detail) { + assertEquals(detail.get(0).textColor, label.textColor); + assertEquals(detail.get(0).haloColor, label.haloColor); + } + } + } + + @Test + void alternateRoadLayersSupplyNames() { + for (String source : new String[]{"road", "road_label"}) { + VectorTile tile = new VectorTile(Arrays.asList(layer(source, + named("High Street", VectorFeature.GEOM_LINESTRING, new int[]{0, 100, 4096, 100})))); + assertEquals(Arrays.asList("High Street"), texts(labels(tile, MapStyle.light(), 13))); + } + } + + @Test + void curvedRoadLabelStaysOnTheRoadAndUsesWorldCoordinates() { + // Dense vertices near the start must not pull the label off the bend. + VectorTile tile = new VectorTile(Arrays.asList(layer("transportation_name", + named("Bent Street", VectorFeature.GEOM_LINESTRING, + new int[]{0, 0, 100, 0, 200, 0, 1000, 0, 1000, 3000})))); + LabelCandidate label = labels(tile, MapStyle.light(), 13).get(0); + assertEquals(2 * 256 + 1000 / 16.0, label.worldX, 1e-9); + assertEquals(3 * 256 + 1000 / 16.0, label.worldY, 1e-9); + assertEquals(13, label.tileZoom); + } + + @Test + void multipartRoadUsesLongestPartAndSkipsZeroLengthSegments() { + VectorTile tile = new VectorTile(Arrays.asList(layer("transportation_name", + named("Long Street", VectorFeature.GEOM_LINESTRING, + new int[]{0, 0, 20, 0}, new int[]{100, 200, 100, 200, 1100, 200})))); + LabelCandidate label = labels(tile, MapStyle.light(), 13).get(0); + assertEquals(2 * 256 + 600 / 16.0, label.worldX, 1e-9); + assertEquals(3 * 256 + 200 / 16.0, label.worldY, 1e-9); + } + + @Test + void absentNamesDegenerateRoadsAndBufferedAnchorsProduceNoLabels() { + VectorTile tile = new VectorTile(Arrays.asList(layer("transportation_name", + named(null, VectorFeature.GEOM_LINESTRING, new int[]{0, 0, 100, 0}), + named(" ", VectorFeature.GEOM_LINESTRING, new int[]{0, 0, 100, 0}), + named("Empty", VectorFeature.GEOM_LINESTRING, new int[0]), + named("Zero", VectorFeature.GEOM_LINESTRING, new int[]{100, 100, 100, 100}), + named("Outside", VectorFeature.GEOM_LINESTRING, new int[]{-300, 100, -100, 100})))); + assertTrue(labels(tile, MapStyle.light(), 13).isEmpty()); + } + + @Test + void customStyleKeepsControlOfLabelSelection() { + MapStyle style = MapStyle.fromJson("{\"layers\":[{\"type\":\"symbol\"," + + "\"source-layer\":\"transportation_name\",\"minzoom\":13," + + "\"filter\":[\"==\",\"class\",\"primary\"]," + + "\"layout\":{\"text-field\":\"{ref}\"}}]}"); + VectorFeature road = named("Main Street", VectorFeature.GEOM_LINESTRING, + new int[]{0, 100, 1000, 100}); + road.getAttributes().put("ref", "A1"); + road.getAttributes().put("class", "primary"); + VectorTile tile = new VectorTile(Arrays.asList(layer("transportation_name", road))); + assertTrue(labels(tile, style, 12).isEmpty()); + assertEquals(Arrays.asList("A1"), texts(labels(tile, style, 13))); + road.getAttributes().put("class", "secondary"); + assertTrue(labels(tile, style, 13).isEmpty()); + } + + private static VectorFeature named(String name, int geometry, int[]... parts) { + Map attributes = new HashMap(); + if (name != null) { + attributes.put("name", name); + } + return new VectorFeature(0, geometry, attributes, Arrays.asList(parts)); + } + + private static VectorLayer layer(String name, VectorFeature... features) { + return new VectorLayer(name, 4096, Arrays.asList(features)); + } + + private static List labels(VectorTile tile, MapStyle style, int zoom) { + return TileRenderer.extractLabels(tile, style, zoom, 2, 3, 256); + } + + private static List texts(List labels) { + List result = new java.util.ArrayList(); + for (LabelCandidate label : labels) { + result.add(label.text); + } + return result; + } +} From 0f9f2bc37a4c2144ba24a19f4cf074b649271f97 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 17 Sep 2026 07:11:29 +0300 Subject: [PATCH 2/3] maps: keep polygon labels inside parks --- .../codename1/maps/vector/TileRenderer.java | 81 ++++++++++++++++--- .../codename1/maps/vector/MapLabelsTest.java | 34 ++++++++ 2 files changed, 105 insertions(+), 10 deletions(-) diff --git a/CodenameOne/src/com/codename1/maps/vector/TileRenderer.java b/CodenameOne/src/com/codename1/maps/vector/TileRenderer.java index bd84e9f8159..72bace32e71 100644 --- a/CodenameOne/src/com/codename1/maps/vector/TileRenderer.java +++ b/CodenameOne/src/com/codename1/maps/vector/TileRenderer.java @@ -27,6 +27,7 @@ import com.codename1.ui.geom.GeneralPath; import java.util.ArrayList; +import java.util.Collections; import java.util.List; /// Rasterizes a decoded [VectorTile] into a tile-sized buffer according to a @@ -240,18 +241,78 @@ private static double[] anchorOf(VectorFeature f) { if (f.getGeometryType() == VectorFeature.GEOM_POINT) { return new double[]{first[0], first[1]}; } - // Average of the first polygon ring 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++; + return polygonAnchor(parts); + } + + // Find the midpoint of the widest interior horizontal span. Unlike a + // vertex average, this remains inside concave polygons and skips holes. + private static double[] polygonAnchor(List parts) { + double minX = Double.MAX_VALUE; + double minY = Double.MAX_VALUE; + double maxX = -Double.MAX_VALUE; + double maxY = -Double.MAX_VALUE; + for (Object partObj : parts) { + int[] ring = (int[]) partObj; + for (int i = 0; i + 1 < ring.length; i += 2) { + minX = Math.min(minX, ring[i]); + maxX = Math.max(maxX, ring[i]); + minY = Math.min(minY, ring[i + 1]); + maxY = Math.max(maxY, ring[i + 1]); + } } - if (n == 0) { + if (minX > maxX || minY > maxY) { return null; } - return new double[]{sx / n, sy / n}; + double height = maxY - minY; + int rows = height <= 0 ? 1 : 32; + double[] best = null; + double bestWidth = -1; + for (int row = 0; row < rows; row++) { + double y = height <= 0 ? minY : minY + height * (row + 0.5) / rows; + List intersections = new ArrayList(); + for (Object partObj : parts) { + int[] ring = (int[]) partObj; + for (int i = 0; i + 1 < ring.length; i += 2) { + int next = (i + 2) % ring.length; + double y0 = ring[i + 1]; + double y1 = ring[next + 1]; + if ((y0 > y) != (y1 > y)) { + double x0 = ring[i]; + double x1 = ring[next]; + intersections.add(Double.valueOf(x0 + (y - y0) * (x1 - x0) / (y1 - y0))); + } + } + } + Collections.sort(intersections); + for (int i = 0; i + 1 < intersections.size(); i += 2) { + double left = ((Double) intersections.get(i)).doubleValue(); + double right = ((Double) intersections.get(i + 1)).doubleValue(); + double width = right - left; + double x = (left + right) / 2; + if (width > bestWidth && pointInPolygon(x, y, parts)) { + bestWidth = width; + best = new double[]{x, y}; + } + } + } + return best; + } + + private static boolean pointInPolygon(double x, double y, List parts) { + boolean inside = false; + for (Object partObj : parts) { + int[] ring = (int[]) partObj; + for (int i = 0, j = ring.length - 2; i + 1 < ring.length; j = i, i += 2) { + double xi = ring[i]; + double yi = ring[i + 1]; + double xj = ring[j]; + double yj = ring[j + 1]; + if ((yi > y) != (yj > y) + && x < (xj - xi) * (y - yi) / (yj - yi) + xi) { + inside = !inside; + } + } + } + return inside; } } diff --git a/maven/core-unittests/src/test/java/com/codename1/maps/vector/MapLabelsTest.java b/maven/core-unittests/src/test/java/com/codename1/maps/vector/MapLabelsTest.java index 4c815b0cd95..d81af7166ba 100644 --- a/maven/core-unittests/src/test/java/com/codename1/maps/vector/MapLabelsTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/maps/vector/MapLabelsTest.java @@ -98,6 +98,40 @@ void customStyleKeepsControlOfLabelSelection() { assertTrue(labels(tile, style, 13).isEmpty()); } + @Test + void polygonLabelsUseAnInteriorPointAndAvoidHoles() { + VectorFeature concave = named("Concave", VectorFeature.GEOM_POLYGON, + new int[]{0, 0, 3000, 0, 3000, 3000, 2000, 3000, + 2000, 1000, 1000, 1000, 1000, 3000, 0, 3000, 0, 0}); + VectorFeature holed = named("Holed", VectorFeature.GEOM_POLYGON, + new int[]{0, 0, 4000, 0, 4000, 4000, 0, 4000, 0, 0}, + new int[]{1000, 1000, 1000, 3000, 3000, 3000, 3000, 1000, 1000, 1000}); + VectorTile tile = new VectorTile(Arrays.asList(layer("park", concave, holed))); + List found = labels(tile, MapStyle.light(), 12); + assertEquals(Arrays.asList("Concave", "Holed"), texts(found)); + for (LabelCandidate label : found) { + double x = (label.worldX - 2 * 256) * 16; + double y = (label.worldY - 3 * 256) * 16; + VectorFeature feature = "Concave".equals(label.text) ? concave : holed; + assertTrue(insideEvenOdd(x, y, feature.getParts()), label.text + " anchor must be inside"); + } + } + + private static boolean insideEvenOdd(double x, double y, List parts) { + boolean inside = false; + for (Object partObj : parts) { + int[] ring = (int[]) partObj; + for (int i = 0, j = ring.length - 2; i + 1 < ring.length; j = i, i += 2) { + double xi = ring[i], yi = ring[i + 1]; + double xj = ring[j], yj = ring[j + 1]; + if ((yi > y) != (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi) { + inside = !inside; + } + } + } + return inside; + } + private static VectorFeature named(String name, int geometry, int[]... parts) { Map attributes = new HashMap(); if (name != null) { From e8507b988535698f37b98738e1115da2f985dc47 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 17 Sep 2026 07:56:30 +0300 Subject: [PATCH 3/3] tests: use complete copyright headers --- .../com/codename1/maps/MapViewMarkerLabelTest.java | 12 ++++++++++++ .../com/codename1/maps/vector/MapLabelsTest.java | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/maven/core-unittests/src/test/java/com/codename1/maps/MapViewMarkerLabelTest.java b/maven/core-unittests/src/test/java/com/codename1/maps/MapViewMarkerLabelTest.java index bba3aa494ca..65008972c8e 100644 --- a/maven/core-unittests/src/test/java/com/codename1/maps/MapViewMarkerLabelTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/maps/MapViewMarkerLabelTest.java @@ -6,6 +6,18 @@ * 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). + * + * 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; diff --git a/maven/core-unittests/src/test/java/com/codename1/maps/vector/MapLabelsTest.java b/maven/core-unittests/src/test/java/com/codename1/maps/vector/MapLabelsTest.java index d81af7166ba..156a3096643 100644 --- a/maven/core-unittests/src/test/java/com/codename1/maps/vector/MapLabelsTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/maps/vector/MapLabelsTest.java @@ -6,6 +6,18 @@ * 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). + * + * 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;