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/render-tests/tests/roads/access-no/expected.png b/render-tests/tests/roads/access-no/expected.png index 192b8b5be..8b0138775 100644 Binary files a/render-tests/tests/roads/access-no/expected.png and b/render-tests/tests/roads/access-no/expected.png differ 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..defa69863 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,55 @@ 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..d61b30575 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