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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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]
Expand Down
7 changes: 7 additions & 0 deletions app/src/examples.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
Binary file modified render-tests/tests/roads/access-no/expected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion tiles/src/main/java/com/protomaps/basemap/Basemap.java
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ public String description() {

@Override
public String version() {
return "4.14.11";
return "4.15.0";
}

@Override
Expand Down
47 changes: 35 additions & 12 deletions tiles/src/main/java/com/protomaps/basemap/layers/Roads.java
Original file line number Diff line number Diff line change
Expand Up @@ -313,15 +313,17 @@ public String name() {

private record RouteRelationInfo(
@Override long id,
String network
String network,
String ref
) implements OsmRelationInfo {}

@Override
public List<OsmRelationInfo> 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<>();
Expand All @@ -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));
Expand All @@ -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<CartographicLocale.Shield>();

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);

Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,88 @@
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.
* <p>
* 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.
*/
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", "");
}
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) {

Check warning on line 51 in tiles/src/main/java/com/protomaps/basemap/locales/CartographicLocale.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unused method parameter "network".

See more on https://sonarcloud.io/project/issues?id=protomaps_basemaps&issues=AZ-BBCarltpcPPRUlZyW&open=AZ-BBCarltpcPPRUlZyW&pullRequest=623
return DEFAULT_RANK;
}

/**
* Normalize, de-duplicate, prioritize and cap a road's concurrent shields.
* <p>
* 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<Shield> orderShields(List<Shield> shields) {
List<Shield> normalized = new ArrayList<>();
for (Shield s : shields) {
String text = strip(s.text());
if (text != null) {
normalized.add(new Shield(text, s.network()));
}
}

List<Shield> 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) {
Expand Down
55 changes: 43 additions & 12 deletions tiles/src/main/java/com/protomaps/basemap/locales/NL.java
Original file line number Diff line number Diff line change
@@ -1,22 +1,53 @@
package com.protomaps.basemap.locales;

import com.onthegomap.planetiler.reader.SourceFeature;
import java.util.List;

/*
* Logic specific to the Netherlands.
* <p>
* 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<String> 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);
}
}
51 changes: 34 additions & 17 deletions tiles/src/main/java/com/protomaps/basemap/locales/US.java
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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<String> 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<String> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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}));
Expand Down
Loading
Loading