From 71eaa18ec7c0260176fefdd0846a793b0c421c24 Mon Sep 17 00:00:00 2001 From: Brandon Liu Date: Mon, 20 Jul 2026 15:29:54 -0400 Subject: [PATCH 1/3] Derive road shields from route relations with multiple networks [#587] Roads previously took shield text and network from the way's own ref tag, which is unreliable and can only represent a single route. Source shields from type=route/route=road relations instead, emitting up to 6 concurrent shields as network_1..network_6 / shield_text_1..shield_text_6, ordered by a per-country network priority. Singular network/shield_text are retained as aliases of the primary shield for backwards compatibility. Add New Jersey visual example. Assisted by Claude Opus 4.8. --- CHANGELOG.md | 7 ++ app/src/examples.json | 7 ++ .../java/com/protomaps/basemap/Basemap.java | 2 +- .../com/protomaps/basemap/layers/Roads.java | 47 ++++++-- .../basemap/locales/CartographicLocale.java | 71 ++++++++++- .../com/protomaps/basemap/locales/NL.java | 55 +++++++-- .../com/protomaps/basemap/locales/US.java | 51 +++++--- .../protomaps/basemap/layers/LayerTest.java | 3 +- .../protomaps/basemap/layers/RoadsTest.java | 110 ++++++++++++++++++ .../locales/CartographicLocaleTest.java | 33 ++++++ .../com/protomaps/basemap/locales/NLTest.java | 55 ++++++--- .../com/protomaps/basemap/locales/USTest.java | 59 +++++++--- 12 files changed, 423 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ced582bb4..b2f771d66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +Tiles 4.15.0 +------ +- derive road shields from route relations instead of way `ref` tags +- normalize Dutch municipal stadsroute networks (`NL:S:Amsterdam`, `NL:S:Rotterdam`, `NL:S:Den Haag`, `NL:S:Nijmegen`, `NL:S:Zaanstad`, `NL:S:Parkstad`) to `NL:S-road`. + `NL:S-road` is not an OpenStreetMap value — it was synthesized by the previous way-ref implementation and is kept here only so existing sprite sheets and styles keep working. + **In the next breaking release this network will be renamed to `NL:S`.** + Tiles 4.14.11 ------ - fix missing `elevation` values and numeric types for peaks via @candux [#619] diff --git a/app/src/examples.json b/app/src/examples.json index ea20b5d80..33e492dec 100644 --- a/app/src/examples.json +++ b/app/src/examples.json @@ -236,5 +236,12 @@ "tags": ["roads", "tunnels"], "center": [-73.9684, 40.747621], "zoom": 17 + }, + { + "name": "nj-concurrencies", + "description": "New Jersey motorway concurrency with multiple shields: US Route 1-9", + "tags": ["roads", "shields", "concurrencies"], + "center": [-74.10479, 40.7282], + "zoom": 15 } ] diff --git a/tiles/src/main/java/com/protomaps/basemap/Basemap.java b/tiles/src/main/java/com/protomaps/basemap/Basemap.java index b1aa5f148..28fcbbb89 100644 --- a/tiles/src/main/java/com/protomaps/basemap/Basemap.java +++ b/tiles/src/main/java/com/protomaps/basemap/Basemap.java @@ -134,7 +134,7 @@ public String description() { @Override public String version() { - return "4.14.11"; + return "4.15.0"; } @Override diff --git a/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java b/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java index 4986841b0..03be0498a 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java @@ -313,7 +313,8 @@ public String name() { private record RouteRelationInfo( @Override long id, - String network + String network, + String ref ) implements OsmRelationInfo {} @Override @@ -321,7 +322,8 @@ public List preprocessOsmRelation(OsmElement.Relation relation) if (relation.hasTag("type", "route") && relation.hasTag("route", "road")) { return List.of(new RouteRelationInfo( relation.id(), - relation.getString("network") + relation.getString("network"), + relation.getString("ref") )); } return new ArrayList<>(); @@ -341,13 +343,6 @@ private void processOsmHighways(SourceFeature sf, FeatureCollector features) { var locale = new CartographicLocale(); - for (var routeInfo : sf.relationInfo(RouteRelationInfo.class)) { - RouteRelationInfo relation = routeInfo.relation(); - if (relation.network != null) { - sf.setTag("_r_network_" + relation.network, "yes"); - } - } - try { var code = countryCoder.getCountryCode(sf.latLonGeometry()); code.ifPresent(s -> sf.setTag("pm:country", s)); @@ -356,7 +351,23 @@ private void processOsmHighways(SourceFeature sf, FeatureCollector features) { e.log("Failed to determine country code"); } - CartographicLocale.Shield shield = locale.getShield(sf); + var relationShields = new ArrayList(); + + for (var routeInfo : sf.relationInfo(RouteRelationInfo.class)) { + RouteRelationInfo relation = routeInfo.relation(); + if (relation.network != null) { + // Collapse carriageway variants (US:I:Local, US:I:Express) onto their base network so + // the shield and the minzoom rules below treat them like the route they belong to. + String network = locale.normalizeNetwork(relation.network); + sf.setTag("_r_network_" + network, "yes"); + relationShields.add(new CartographicLocale.Shield(relation.ref, network)); + } + } + + // Shields come only from route relations; the locale orders, de-duplicates and caps them. + // Roads that are not a member of any route relation get no shield (the way's own ref tag is + // ignored, since it produces networkless, often low-quality shields). + var shields = locale.orderShields(relationShields); var matches = osmKindsIndex.getMatches(sf); @@ -386,8 +397,6 @@ private void processOsmHighways(SourceFeature sf, FeatureCollector features) { // To power better client label collisions .setAttr("min_zoom", minZoom + 1) .setAttrWithMinzoom("ref", sf.getString("ref"), minZoomShieldText) - .setAttrWithMinzoom("shield_text", shield.text(), minZoomShieldText) - .setAttrWithMinzoom("network", shield.network(), minZoomShieldText) .setAttrWithMinzoom("oneway", sf.getString("oneway"), 14) .setAttrWithMinzoom("access", sf.getTag("access"), 15) // temporary attribute that gets removed in the post-process step @@ -397,6 +406,20 @@ private void processOsmHighways(SourceFeature sf, FeatureCollector features) { .setPixelTolerance(0) .setMinZoom(minZoom); + // Emit one network_N / shield_text_N pair per concurrent route shield, primary first. + for (int i = 0; i < shields.size(); i++) { + CartographicLocale.Shield s = shields.get(i); + feat.setAttrWithMinzoom("network_" + (i + 1), s.network(), minZoomShieldText); + feat.setAttrWithMinzoom("shield_text_" + (i + 1), s.text(), minZoomShieldText); + } + + // Backwards-compatible singular aliases mirror the primary shield. + if (!shields.isEmpty()) { + CartographicLocale.Shield primary = shields.get(0); + feat.setAttrWithMinzoom("network", primary.network(), minZoomShieldText); + feat.setAttrWithMinzoom("shield_text", primary.text(), minZoomShieldText); + } + if (!kindDetail.isEmpty()) { feat.setAttr("kind_detail", kindDetail); } else { diff --git a/tiles/src/main/java/com/protomaps/basemap/locales/CartographicLocale.java b/tiles/src/main/java/com/protomaps/basemap/locales/CartographicLocale.java index 5c2194419..93ea3a7ac 100644 --- a/tiles/src/main/java/com/protomaps/basemap/locales/CartographicLocale.java +++ b/tiles/src/main/java/com/protomaps/basemap/locales/CartographicLocale.java @@ -1,15 +1,19 @@ package com.protomaps.basemap.locales; import com.onthegomap.planetiler.reader.SourceFeature; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; /* * Encapsulates country-specific logic applied to OpenStreetMap tags. *

- * This is a grab-bag of logic functions for determining output tags in tiled features - * based on a spatial join of input features to polygon locales. - * CartographicLocale is the parent class that applies to locales outside of any - * polygon, locales that are unimplemented, or default behavior when a locale - * does not override a method. + * This is the per-country extension point for cartographic logic that depends on a spatial + * join of input features to country polygons: shield network priority today, and a natural + * home for things like admin_level normalization or road reclassification in the future. + * CartographicLocale is the parent class that applies to locales outside of any polygon, + * locales that are unimplemented, or default behavior when a locale does not override a method. * * Each implemented locale is named by 2-letter ISO code. */ @@ -17,6 +21,12 @@ public class CartographicLocale { public record Shield(String text, String network) {} + /** Maximum number of concurrent shields emitted per road (network_1..network_N). */ + public static final int MAX_SHIELDS = 6; + + /** Rank assigned to networks a locale does not explicitly prioritize. */ + protected static final int DEFAULT_RANK = 1000; + protected String strip(String s) { if (s != null) { return s.replaceAll("\\s", ""); @@ -24,6 +34,57 @@ protected String strip(String s) { return null; } + /** + * Normalize a raw OSM route network to the network used for shield symbolization, priority + * and minzoom. The base implementation returns the network unchanged; locales collapse + * variants that share a base route's shield (e.g. carriageway suffixes) onto it. + */ + public String normalizeNetwork(String network) { + return network; + } + + /** + * Priority of a route network when ordering the concurrent shields on a single road. + * Lower rank sorts earlier, so it becomes a lower shield index (network_1 is the primary + * shield). Priority is a national convention, so the base implementation treats every + * network equally and leaves ordering to the ref tiebreak. + */ + public int networkRank(String network) { + return DEFAULT_RANK; + } + + /** + * Normalize, de-duplicate, prioritize and cap a road's concurrent shields. + *

+ * Input order is not significant: directional (forward/backward) route relations produce + * duplicate (network, ref) pairs, and {@code SourceFeature.relationInfo()} ordering is not + * stable across builds, so the deterministic ordering here comes entirely from + * {@link #networkRank(String)} with the shield text as a tiebreak. + */ + public List orderShields(List shields) { + List normalized = new ArrayList<>(); + for (Shield s : shields) { + String text = strip(s.text()); + if (text != null) { + normalized.add(new Shield(text, s.network())); + } + } + + List deduped = new ArrayList<>(new LinkedHashSet<>(normalized)); + deduped.sort( + Comparator.comparingInt((Shield s) -> networkRank(s.network())) + .thenComparing(Shield::text)); + + if (deduped.size() > MAX_SHIELDS) { + return new ArrayList<>(deduped.subList(0, MAX_SHIELDS)); + } + return deduped; + } + + /** + * Generic shield derived from the way's own {@code ref} tag, used as a fallback when a road + * is not a member of any route relation. The network is unknown on this path, so "other". + */ public Shield getShield(SourceFeature sf) { String ref = sf.getString("ref"); if (ref != null) { diff --git a/tiles/src/main/java/com/protomaps/basemap/locales/NL.java b/tiles/src/main/java/com/protomaps/basemap/locales/NL.java index 55ddb3a1f..f35ff1e35 100644 --- a/tiles/src/main/java/com/protomaps/basemap/locales/NL.java +++ b/tiles/src/main/java/com/protomaps/basemap/locales/NL.java @@ -1,22 +1,53 @@ package com.protomaps.basemap.locales; -import com.onthegomap.planetiler.reader.SourceFeature; +import java.util.List; +/* + * Logic specific to the Netherlands. + *

+ * Prioritizes route networks for shield ordering: A-roads (motorways) over N-roads + * (national / provincial) over S-roads (city distributor rings). + */ public class NL extends CartographicLocale { + + /** + * OSM scopes stadsroute networks per municipality: NL:S:Amsterdam, NL:S:Rotterdam, + * NL:S:Den Haag, NL:S:Nijmegen, NL:S:Zaanstad, NL:S:Parkstad. They share one shield design, + * so they collapse onto a single network for symbolization. + */ + private static final String S_ROAD_PREFIX = "NL:S:"; + + /** + * Legacy name for the collapsed stadsroute network. This is not an OSM value — it was + * synthesized by an older way-ref-based implementation, and the sprite sheets and styles are + * keyed to it. Renaming it to "NL:S" is deferred to the next breaking release. + */ + private static final String S_ROAD_NETWORK = "NL:S-road"; + + // Ordered by shield priority; the first matching prefix wins. + private static final List NETWORK_PRIORITY = List.of( + "NL:A", + "NL:N", + S_ROAD_NETWORK + ); + @Override - public CartographicLocale.Shield getShield(SourceFeature sf) { - String ref = sf.getString("ref"); - String network = "other"; + public String normalizeNetwork(String network) { + if (network != null && network.startsWith(S_ROAD_PREFIX)) { + return S_ROAD_NETWORK; + } + return super.normalizeNetwork(network); + } - if (ref != null) { - String firstRef = ref.split(";")[0]; - String shieldText = firstRef; - if (firstRef.startsWith("S")) { - network = "NL:S-road"; + @Override + public int networkRank(String network) { + if (network != null) { + for (int i = 0; i < NETWORK_PRIORITY.size(); i++) { + if (network.startsWith(NETWORK_PRIORITY.get(i))) { + return i; + } } - return new CartographicLocale.Shield(strip(shieldText), network); } - - return new CartographicLocale.Shield(null, null); + return super.networkRank(network); } } diff --git a/tiles/src/main/java/com/protomaps/basemap/locales/US.java b/tiles/src/main/java/com/protomaps/basemap/locales/US.java index f674daec1..12e928626 100644 --- a/tiles/src/main/java/com/protomaps/basemap/locales/US.java +++ b/tiles/src/main/java/com/protomaps/basemap/locales/US.java @@ -1,32 +1,49 @@ package com.protomaps.basemap.locales; -import com.onthegomap.planetiler.reader.SourceFeature; +import java.util.List; /* * Logic specific to the 50 US states. *

- * Assigns highway shield text and networks. + * Prioritizes route networks for shield ordering: Interstate over US highway over any + * state / county / local network. */ public class US extends CartographicLocale { - @Override - public Shield getShield(SourceFeature sf) { - String ref = sf.getString("ref"); - String network = "other"; + // Ordered by shield priority; the first matching prefix wins. "US:" is a catch-all for every + // state, county and local network (US:CO, US:CA, US:CO:Denver, ...) that sorts below the + // national Interstate and US-highway networks. + private static final List NETWORK_PRIORITY = List.of( + "US:I", + "US:US", + "US:" + ); + + // Carriageway suffixes distinguish which roadway carries a route (e.g. the local and express + // lanes of a collector-express freeway). They share the base route's shield, so collapse them. + private static final List CARRIAGEWAY_SUFFIXES = List.of(":Local", ":Express"); - if (ref != null) { - String firstRef = ref.split(";")[0]; - String shieldText = firstRef; - if (firstRef.startsWith("US ")) { - shieldText = firstRef.replace("US ", ""); - network = "US:US"; - } else if (firstRef.startsWith("I ")) { - shieldText = firstRef.replace("I ", ""); - network = "US:I"; + @Override + public String normalizeNetwork(String network) { + if (network != null) { + for (String suffix : CARRIAGEWAY_SUFFIXES) { + if (network.endsWith(suffix)) { + return network.substring(0, network.length() - suffix.length()); + } } - return new Shield(strip(shieldText), network); } + return network; + } - return new Shield(null, null); + @Override + public int networkRank(String network) { + if (network != null) { + for (int i = 0; i < NETWORK_PRIORITY.size(); i++) { + if (network.startsWith(NETWORK_PRIORITY.get(i))) { + return i; + } + } + } + return super.networkRank(network); } } diff --git a/tiles/src/test/java/com/protomaps/basemap/layers/LayerTest.java b/tiles/src/test/java/com/protomaps/basemap/layers/LayerTest.java index a376eb851..1edef510f 100644 --- a/tiles/src/test/java/com/protomaps/basemap/layers/LayerTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/layers/LayerTest.java @@ -22,8 +22,9 @@ abstract class LayerTest { final Stats stats = Stats.inMemory(); final FeatureCollector.Factory featureCollectorFactory = new FeatureCollector.Factory(params, stats); + // Coarse bounding boxes, not real borders: just enough for tests to land inside a locale. final CountryCoder countryCoder = CountryCoder.fromJsonString( - "{\"type\":\"FeatureCollection\",\"features\":[{\"type\":\"Feature\",\"properties\":{\"iso1A2\":\"US\",\"nameEn\":\"United States\"},\"geometry\":{\"type\":\"MultiPolygon\",\"coordinates\":[[[[-124,47],[-124,25],[-71,25],[-71,47],[-124,47]]]]}}]}"); + "{\"type\":\"FeatureCollection\",\"features\":[{\"type\":\"Feature\",\"properties\":{\"iso1A2\":\"US\",\"nameEn\":\"United States\"},\"geometry\":{\"type\":\"MultiPolygon\",\"coordinates\":[[[[-124,47],[-124,25],[-71,25],[-71,47],[-124,47]]]]}},{\"type\":\"Feature\",\"properties\":{\"iso1A2\":\"NL\",\"nameEn\":\"Netherlands\"},\"geometry\":{\"type\":\"MultiPolygon\",\"coordinates\":[[[[3.3,53.6],[3.3,50.7],[7.3,50.7],[7.3,53.6],[3.3,53.6]]]]}}]}"); final QrankDb qrankDb = new QrankDb(LongLongHashMap.from(new long[]{8888}, new long[]{100000})); diff --git a/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java b/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java index 2698d150a..f39aadefd 100644 --- a/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java @@ -7,6 +7,8 @@ import com.onthegomap.planetiler.reader.SimpleFeature; import com.onthegomap.planetiler.reader.osm.OsmElement; import com.onthegomap.planetiler.reader.osm.OsmReader; +import com.onthegomap.planetiler.reader.osm.OsmRelationInfo; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -61,6 +63,32 @@ private FeatureCollector processWithRelationAndCoords(String network, double sta )); } + // Builds a motorway that is a member of one route relation per (network, ref) pair supplied. + private FeatureCollector processWithRelationShields(double startLon, double startLat, double endLon, double endLat, + String... networkRefPairs) { + var members = new ArrayList>(); + for (int i = 0; i + 1 < networkRefPairs.length; i += 2) { + var relationResult = profile.preprocessOsmRelation(new OsmElement.Relation(i + 10L, Map.of( + "type", "route", + "route", "road", + "network", networkRefPairs[i], + "ref", networkRefPairs[i + 1] + ), List.of( + new OsmElement.Relation.Member(OsmElement.Type.WAY, 2, "role") + ))); + relationResult.forEach(info -> members.add(new OsmReader.RelationMember<>("role", info))); + } + + return process(SimpleFeature.createFakeOsmFeature( + newLineString(startLon, startLat, endLon, endLat), + new HashMap<>(Map.of("highway", "motorway")), + "osm", + null, + 2, + members + )); + } + @Test void simple() { assertFeatures(12, @@ -123,6 +151,88 @@ void relation4() { ); } + @Test + void relationShieldsOrderedByNetworkPriority() { + // Concurrent I 70 / US 6 relations: Interstate becomes the primary shield regardless of + // relation order, and the singular aliases mirror it. Denver - Boulder. + assertFeatures(12, + List.of(Map.of( + "network_1", "US:I", + "shield_text_1", "70", + "network_2", "US:US", + "shield_text_2", "6", + "network", "US:I", + "shield_text", "70" + )), + processWithRelationShields(-104.97235, 39.73867, -105.260503, 40.010771, + "US:US", "6", "US:I", "70") + ); + } + + @Test + void carriagewayNetworkNormalizedToBaseInterstate() { + // A US:I:Local relation shares the Interstate shield and gets the Interstate minzoom (3). + // Denver - Boulder. + assertFeatures(12, + List.of(Map.of( + "_minzoom", 3, + "network_1", "US:I", + "shield_text_1", "70", + "network", "US:I", + "shield_text", "70" + )), + processWithRelationShields(-104.97235, 39.73867, -105.260503, 40.010771, + "US:I:Local", "70") + ); + } + + @Test + void municipalStadsrouteNormalizedToSRoad() { + // OSM scopes stadsroute networks per municipality (NL:S:Amsterdam); they collapse onto the + // single NL:S-road network the sprite sheet is keyed to. Amsterdam S100. + assertFeatures(12, + List.of(Map.of( + "network_1", "NL:S-road", + "shield_text_1", "S100", + "network", "NL:S-road", + "shield_text", "S100" + )), + processWithRelationShields(4.86, 52.36, 4.90, 52.38, + "NL:S:Amsterdam", "S100") + ); + } + + @Test + void stadsrouteRanksBelowAAndNRoads() { + // A concurrent A-road outranks the stadsroute even though the S relation comes first. + assertFeatures(12, + List.of(Map.of( + "network_1", "NL:A", + "shield_text_1", "10", + "network_2", "NL:S-road", + "shield_text_2", "S100", + "network", "NL:A" + )), + processWithRelationShields(4.86, 52.36, 4.90, 52.38, + "NL:S:Amsterdam", "S100", "NL:A", "10") + ); + } + + @Test + void bareWayRefProducesNoShield() { + // With no route relation the way's own ref tag is ignored: the road is emitted but gets no shield. + assertFeatures(12, + List.of(Map.of( + "kind", "highway", + "network", "", + "shield_text", "", + "network_1", "", + "shield_text_1", "" + )), + processWith("highway", "motorway", "ref", "A1") + ); + } + @ParameterizedTest @CsvSource({ "proposed", diff --git a/tiles/src/test/java/com/protomaps/basemap/locales/CartographicLocaleTest.java b/tiles/src/test/java/com/protomaps/basemap/locales/CartographicLocaleTest.java index cf9f4dce0..d27fe733e 100644 --- a/tiles/src/test/java/com/protomaps/basemap/locales/CartographicLocaleTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/locales/CartographicLocaleTest.java @@ -5,7 +5,9 @@ import static org.junit.jupiter.api.Assertions.assertNull; import com.onthegomap.planetiler.reader.SimpleFeature; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -39,4 +41,35 @@ void shieldNull() { assertNull(shield.text()); assertNull(shield.network()); } + + @Test + void orderShieldsDedupesAndStripsAndTiebreaksOnText() { + // Duplicate (from directional relations) is removed; whitespace stripped; with no locale + // priority the base class tiebreaks deterministically on shield text. + var shields = locale.orderShields(List.of( + new CartographicLocale.Shield("B 5", "de:BAB"), + new CartographicLocale.Shield("A 3", "de:BAB"), + new CartographicLocale.Shield("A3", "de:BAB"))); + assertEquals(List.of( + new CartographicLocale.Shield("A3", "de:BAB"), + new CartographicLocale.Shield("B5", "de:BAB")), shields); + } + + @Test + void orderShieldsDropsNullText() { + // A relation contributing a network but no ref yields no renderable shield. + var shields = locale.orderShields(List.of( + new CartographicLocale.Shield(null, "de:BAB"), + new CartographicLocale.Shield("A3", "de:BAB"))); + assertEquals(List.of(new CartographicLocale.Shield("A3", "de:BAB")), shields); + } + + @Test + void orderShieldsCapsAtMaxShields() { + var input = new ArrayList(); + for (int i = 0; i < CartographicLocale.MAX_SHIELDS + 3; i++) { + input.add(new CartographicLocale.Shield("A" + i, "network" + i)); + } + assertEquals(CartographicLocale.MAX_SHIELDS, locale.orderShields(input).size()); + } } diff --git a/tiles/src/test/java/com/protomaps/basemap/locales/NLTest.java b/tiles/src/test/java/com/protomaps/basemap/locales/NLTest.java index ac0e50a3b..d6010b5ee 100644 --- a/tiles/src/test/java/com/protomaps/basemap/locales/NLTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/locales/NLTest.java @@ -1,28 +1,55 @@ package com.protomaps.basemap.locales; -import static com.onthegomap.planetiler.TestUtils.newLineString; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; -import com.onthegomap.planetiler.reader.SimpleFeature; -import java.util.HashMap; -import java.util.Map; +import java.util.List; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; class NLTest { CartographicLocale locale = new NL(); @ParameterizedTest - @CsvSource({ - "S100,S100,NL:S-road", + @ValueSource(strings = { + "NL:S:Amsterdam", + "NL:S:Rotterdam", + "NL:S:Den Haag", + "NL:S:Nijmegen", + "NL:S:Zaanstad", + "NL:S:Parkstad" }) - void shieldNl(String refTag, String expectedText, String expectedNetwork) { - var feature = SimpleFeature.create( - newLineString(0, 0, 1, 1), - new HashMap<>(Map.of("ref", refTag))); - var shield = locale.getShield(feature); - assertEquals(expectedText, shield.text()); - assertEquals(expectedNetwork, shield.network()); + void municipalStadsrouteNetworksCollapse(String network) { + // Every municipality's stadsroute network symbolizes with the same shield. + assertEquals("NL:S-road", locale.normalizeNetwork(network)); + } + + @Test + void unrelatedNetworksArePassedThrough() { + assertEquals("NL:A", locale.normalizeNetwork("NL:A")); + assertEquals("NL:N", locale.normalizeNetwork("NL:N")); + // The A10 ring is not a stadsroute network and is left alone. + assertEquals("NL:ring:Amsterdam", locale.normalizeNetwork("NL:ring:Amsterdam")); + } + + @Test + void networkPriority() { + // A-roads outrank N-roads outrank S-road city rings. + assertTrue(locale.networkRank("NL:A") < locale.networkRank("NL:N")); + assertTrue(locale.networkRank("NL:N") < locale.networkRank("NL:S-road")); + // Unknown networks rank below every prioritized one. + assertTrue(locale.networkRank("NL:S-road") < locale.networkRank("e-road")); + } + + @Test + void orderShields() { + var shields = locale.orderShields(List.of( + new CartographicLocale.Shield("S100", "NL:S-road"), + new CartographicLocale.Shield("2", "NL:A"))); + assertEquals(List.of( + new CartographicLocale.Shield("2", "NL:A"), + new CartographicLocale.Shield("S100", "NL:S-road")), shields); } } diff --git a/tiles/src/test/java/com/protomaps/basemap/locales/USTest.java b/tiles/src/test/java/com/protomaps/basemap/locales/USTest.java index 7d8d2eb53..3d81a4a31 100644 --- a/tiles/src/test/java/com/protomaps/basemap/locales/USTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/locales/USTest.java @@ -3,34 +3,63 @@ import static com.onthegomap.planetiler.TestUtils.newLineString; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import com.onthegomap.planetiler.reader.SimpleFeature; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; class USTest { CartographicLocale locale = new US(); - @ParameterizedTest - @CsvSource({ - "US 1,1,US:US", - "US 1;US 5,1,US:US", - "I 5,5,US:I", - "6,6,other", - "US 1 ,1,US:US", - "HI-3920,HI-3920,other" - }) - void shieldUs(String refTag, String expectedText, String expectedNetwork) { + @Test + void networkPriority() { + // Interstate outranks US highway outranks any state / county / local network. + assertTrue(locale.networkRank("US:I") < locale.networkRank("US:US")); + assertTrue(locale.networkRank("US:US") < locale.networkRank("US:CO")); + // State, county and local networks all share the catch-all rank. + assertEquals(locale.networkRank("US:CO"), locale.networkRank("US:CO:Denver")); + // Unknown networks rank below every prioritized one. + assertTrue(locale.networkRank("US:CO") < locale.networkRank("e-road")); + } + + @Test + void normalizeCarriagewayNetworks() { + // Local / express carriageways collapse onto the base Interstate network; other networks + // (including deeper hierarchies) are left untouched. + assertEquals("US:I", locale.normalizeNetwork("US:I:Local")); + assertEquals("US:I", locale.normalizeNetwork("US:I:Express")); + assertEquals("US:US", locale.normalizeNetwork("US:US:Express")); + assertEquals("US:I", locale.normalizeNetwork("US:I")); + assertEquals("US:CO:Denver", locale.normalizeNetwork("US:CO:Denver")); + assertNull(locale.normalizeNetwork(null)); + } + + @Test + void orderShields() { + // Concurrent I 70 / US 6 / CO 91 relations sort by network priority regardless of input order. + var shields = locale.orderShields(List.of( + new CartographicLocale.Shield("91", "US:CO"), + new CartographicLocale.Shield("6", "US:US"), + new CartographicLocale.Shield("70", "US:I"))); + assertEquals(List.of( + new CartographicLocale.Shield("70", "US:I"), + new CartographicLocale.Shield("6", "US:US"), + new CartographicLocale.Shield("91", "US:CO")), shields); + } + + @Test + void fallbackShieldIsGeneric() { + // Without route relations there is no network to guess, so the way ref falls back to "other". var feature = SimpleFeature.create( newLineString(0, 0, 1, 1), - new HashMap<>(Map.of("ref", refTag))); + new HashMap<>(Map.of("ref", "US 1"))); var shield = locale.getShield(feature); - assertEquals(expectedText, shield.text()); - assertEquals(expectedNetwork, shield.network()); + assertEquals("US1", shield.text()); + assertEquals("other", shield.network()); } @Test From 184bf6680b6da33445e0afd98ced508fb1918f77 Mon Sep 17 00:00:00 2001 From: Brandon Liu Date: Mon, 20 Jul 2026 15:58:19 -0400 Subject: [PATCH 2/3] linting --- .../basemap/locales/CartographicLocale.java | 24 +++++++++---------- .../com/protomaps/basemap/locales/NL.java | 12 +++++----- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/tiles/src/main/java/com/protomaps/basemap/locales/CartographicLocale.java b/tiles/src/main/java/com/protomaps/basemap/locales/CartographicLocale.java index 93ea3a7ac..defa69863 100644 --- a/tiles/src/main/java/com/protomaps/basemap/locales/CartographicLocale.java +++ b/tiles/src/main/java/com/protomaps/basemap/locales/CartographicLocale.java @@ -35,19 +35,18 @@ protected String strip(String s) { } /** - * Normalize a raw OSM route network to the network used for shield symbolization, priority - * and minzoom. The base implementation returns the network unchanged; locales collapse - * variants that share a base route's shield (e.g. carriageway suffixes) onto it. + * Normalize a raw OSM route network to the network used for shield symbolization, priority and minzoom. The base + * implementation returns the network unchanged; locales collapse variants that share a base route's shield (e.g. + * carriageway suffixes) onto it. */ public String normalizeNetwork(String network) { return network; } /** - * Priority of a route network when ordering the concurrent shields on a single road. - * Lower rank sorts earlier, so it becomes a lower shield index (network_1 is the primary - * shield). Priority is a national convention, so the base implementation treats every - * network equally and leaves ordering to the ref tiebreak. + * Priority of a route network when ordering the concurrent shields on a single road. Lower rank sorts earlier, so it + * becomes a lower shield index (network_1 is the primary shield). Priority is a national convention, so the base + * implementation treats every network equally and leaves ordering to the ref tiebreak. */ public int networkRank(String network) { return DEFAULT_RANK; @@ -56,10 +55,9 @@ public int networkRank(String network) { /** * Normalize, de-duplicate, prioritize and cap a road's concurrent shields. *

- * Input order is not significant: directional (forward/backward) route relations produce - * duplicate (network, ref) pairs, and {@code SourceFeature.relationInfo()} ordering is not - * stable across builds, so the deterministic ordering here comes entirely from - * {@link #networkRank(String)} with the shield text as a tiebreak. + * Input order is not significant: directional (forward/backward) route relations produce duplicate (network, ref) + * pairs, and {@code SourceFeature.relationInfo()} ordering is not stable across builds, so the deterministic ordering + * here comes entirely from {@link #networkRank(String)} with the shield text as a tiebreak. */ public List orderShields(List shields) { List normalized = new ArrayList<>(); @@ -82,8 +80,8 @@ public List orderShields(List shields) { } /** - * Generic shield derived from the way's own {@code ref} tag, used as a fallback when a road - * is not a member of any route relation. The network is unknown on this path, so "other". + * Generic shield derived from the way's own {@code ref} tag, used as a fallback when a road is not a member of any + * route relation. The network is unknown on this path, so "other". */ public Shield getShield(SourceFeature sf) { String ref = sf.getString("ref"); diff --git a/tiles/src/main/java/com/protomaps/basemap/locales/NL.java b/tiles/src/main/java/com/protomaps/basemap/locales/NL.java index f35ff1e35..d61b30575 100644 --- a/tiles/src/main/java/com/protomaps/basemap/locales/NL.java +++ b/tiles/src/main/java/com/protomaps/basemap/locales/NL.java @@ -11,16 +11,16 @@ public class NL extends CartographicLocale { /** - * OSM scopes stadsroute networks per municipality: NL:S:Amsterdam, NL:S:Rotterdam, - * NL:S:Den Haag, NL:S:Nijmegen, NL:S:Zaanstad, NL:S:Parkstad. They share one shield design, - * so they collapse onto a single network for symbolization. + * OSM scopes stadsroute networks per municipality: NL:S:Amsterdam, NL:S:Rotterdam, NL:S:Den Haag, NL:S:Nijmegen, + * NL:S:Zaanstad, NL:S:Parkstad. They share one shield design, so they collapse onto a single network for + * symbolization. */ private static final String S_ROAD_PREFIX = "NL:S:"; /** - * Legacy name for the collapsed stadsroute network. This is not an OSM value — it was - * synthesized by an older way-ref-based implementation, and the sprite sheets and styles are - * keyed to it. Renaming it to "NL:S" is deferred to the next breaking release. + * Legacy name for the collapsed stadsroute network. This is not an OSM value — it was synthesized by an older + * way-ref-based implementation, and the sprite sheets and styles are keyed to it. Renaming it to "NL:S" is deferred + * to the next breaking release. */ private static final String S_ROAD_NETWORK = "NL:S-road"; From 75390518f7cf21f998e83b8eb6632b4f5c17f686 Mon Sep 17 00:00:00 2001 From: Brandon Liu Date: Mon, 20 Jul 2026 16:03:43 -0400 Subject: [PATCH 3/3] update visual render test, because shields no longer come from ways --- .../tests/roads/access-no/expected.png | Bin 7863 -> 7224 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/render-tests/tests/roads/access-no/expected.png b/render-tests/tests/roads/access-no/expected.png index 192b8b5be66dc247c1d2d9fc43cb5250679b67d4..8b0138775ae5161efbc65cc35cc4a5940c8435a2 100644 GIT binary patch literal 7224 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 literal 7863 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