Skip to content

Commit 7936922

Browse files
authored
Derive road shields from route relations with multiple networks [#587] (#623)
* 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. * linting * update visual render test, because shields no longer come from ways
1 parent e6d65ec commit 7936922

13 files changed

Lines changed: 421 additions & 77 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
Tiles 4.15.0
2+
------
3+
- derive road shields from route relations instead of way `ref` tags
4+
- 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`.
5+
`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.
6+
**In the next breaking release this network will be renamed to `NL:S`.**
7+
18
Tiles 4.14.11
29
------
310
- fix missing `elevation` values and numeric types for peaks via @candux [#619]

app/src/examples.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,5 +236,12 @@
236236
"tags": ["roads", "tunnels"],
237237
"center": [-73.9684, 40.747621],
238238
"zoom": 17
239+
},
240+
{
241+
"name": "nj-concurrencies",
242+
"description": "New Jersey motorway concurrency with multiple shields: US Route 1-9",
243+
"tags": ["roads", "shields", "concurrencies"],
244+
"center": [-74.10479, 40.7282],
245+
"zoom": 15
239246
}
240247
]
-639 Bytes
Loading

tiles/src/main/java/com/protomaps/basemap/Basemap.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ public String description() {
134134

135135
@Override
136136
public String version() {
137-
return "4.14.11";
137+
return "4.15.0";
138138
}
139139

140140
@Override

tiles/src/main/java/com/protomaps/basemap/layers/Roads.java

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -313,15 +313,17 @@ public String name() {
313313

314314
private record RouteRelationInfo(
315315
@Override long id,
316-
String network
316+
String network,
317+
String ref
317318
) implements OsmRelationInfo {}
318319

319320
@Override
320321
public List<OsmRelationInfo> preprocessOsmRelation(OsmElement.Relation relation) {
321322
if (relation.hasTag("type", "route") && relation.hasTag("route", "road")) {
322323
return List.of(new RouteRelationInfo(
323324
relation.id(),
324-
relation.getString("network")
325+
relation.getString("network"),
326+
relation.getString("ref")
325327
));
326328
}
327329
return new ArrayList<>();
@@ -341,13 +343,6 @@ private void processOsmHighways(SourceFeature sf, FeatureCollector features) {
341343

342344
var locale = new CartographicLocale();
343345

344-
for (var routeInfo : sf.relationInfo(RouteRelationInfo.class)) {
345-
RouteRelationInfo relation = routeInfo.relation();
346-
if (relation.network != null) {
347-
sf.setTag("_r_network_" + relation.network, "yes");
348-
}
349-
}
350-
351346
try {
352347
var code = countryCoder.getCountryCode(sf.latLonGeometry());
353348
code.ifPresent(s -> sf.setTag("pm:country", s));
@@ -356,7 +351,23 @@ private void processOsmHighways(SourceFeature sf, FeatureCollector features) {
356351
e.log("Failed to determine country code");
357352
}
358353

359-
CartographicLocale.Shield shield = locale.getShield(sf);
354+
var relationShields = new ArrayList<CartographicLocale.Shield>();
355+
356+
for (var routeInfo : sf.relationInfo(RouteRelationInfo.class)) {
357+
RouteRelationInfo relation = routeInfo.relation();
358+
if (relation.network != null) {
359+
// Collapse carriageway variants (US:I:Local, US:I:Express) onto their base network so
360+
// the shield and the minzoom rules below treat them like the route they belong to.
361+
String network = locale.normalizeNetwork(relation.network);
362+
sf.setTag("_r_network_" + network, "yes");
363+
relationShields.add(new CartographicLocale.Shield(relation.ref, network));
364+
}
365+
}
366+
367+
// Shields come only from route relations; the locale orders, de-duplicates and caps them.
368+
// Roads that are not a member of any route relation get no shield (the way's own ref tag is
369+
// ignored, since it produces networkless, often low-quality shields).
370+
var shields = locale.orderShields(relationShields);
360371

361372
var matches = osmKindsIndex.getMatches(sf);
362373

@@ -386,8 +397,6 @@ private void processOsmHighways(SourceFeature sf, FeatureCollector features) {
386397
// To power better client label collisions
387398
.setAttr("min_zoom", minZoom + 1)
388399
.setAttrWithMinzoom("ref", sf.getString("ref"), minZoomShieldText)
389-
.setAttrWithMinzoom("shield_text", shield.text(), minZoomShieldText)
390-
.setAttrWithMinzoom("network", shield.network(), minZoomShieldText)
391400
.setAttrWithMinzoom("oneway", sf.getString("oneway"), 14)
392401
.setAttrWithMinzoom("access", sf.getTag("access"), 15)
393402
// temporary attribute that gets removed in the post-process step
@@ -397,6 +406,20 @@ private void processOsmHighways(SourceFeature sf, FeatureCollector features) {
397406
.setPixelTolerance(0)
398407
.setMinZoom(minZoom);
399408

409+
// Emit one network_N / shield_text_N pair per concurrent route shield, primary first.
410+
for (int i = 0; i < shields.size(); i++) {
411+
CartographicLocale.Shield s = shields.get(i);
412+
feat.setAttrWithMinzoom("network_" + (i + 1), s.network(), minZoomShieldText);
413+
feat.setAttrWithMinzoom("shield_text_" + (i + 1), s.text(), minZoomShieldText);
414+
}
415+
416+
// Backwards-compatible singular aliases mirror the primary shield.
417+
if (!shields.isEmpty()) {
418+
CartographicLocale.Shield primary = shields.get(0);
419+
feat.setAttrWithMinzoom("network", primary.network(), minZoomShieldText);
420+
feat.setAttrWithMinzoom("shield_text", primary.text(), minZoomShieldText);
421+
}
422+
400423
if (!kindDetail.isEmpty()) {
401424
feat.setAttr("kind_detail", kindDetail);
402425
} else {

tiles/src/main/java/com/protomaps/basemap/locales/CartographicLocale.java

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,88 @@
11
package com.protomaps.basemap.locales;
22

33
import com.onthegomap.planetiler.reader.SourceFeature;
4+
import java.util.ArrayList;
5+
import java.util.Comparator;
6+
import java.util.LinkedHashSet;
7+
import java.util.List;
48

59
/*
610
* Encapsulates country-specific logic applied to OpenStreetMap tags.
711
* <p>
8-
* This is a grab-bag of logic functions for determining output tags in tiled features
9-
* based on a spatial join of input features to polygon locales.
10-
* CartographicLocale is the parent class that applies to locales outside of any
11-
* polygon, locales that are unimplemented, or default behavior when a locale
12-
* does not override a method.
12+
* This is the per-country extension point for cartographic logic that depends on a spatial
13+
* join of input features to country polygons: shield network priority today, and a natural
14+
* home for things like admin_level normalization or road reclassification in the future.
15+
* CartographicLocale is the parent class that applies to locales outside of any polygon,
16+
* locales that are unimplemented, or default behavior when a locale does not override a method.
1317
*
1418
* Each implemented locale is named by 2-letter ISO code.
1519
*/
1620
public class CartographicLocale {
1721

1822
public record Shield(String text, String network) {}
1923

24+
/** Maximum number of concurrent shields emitted per road (network_1..network_N). */
25+
public static final int MAX_SHIELDS = 6;
26+
27+
/** Rank assigned to networks a locale does not explicitly prioritize. */
28+
protected static final int DEFAULT_RANK = 1000;
29+
2030
protected String strip(String s) {
2131
if (s != null) {
2232
return s.replaceAll("\\s", "");
2333
}
2434
return null;
2535
}
2636

37+
/**
38+
* Normalize a raw OSM route network to the network used for shield symbolization, priority and minzoom. The base
39+
* implementation returns the network unchanged; locales collapse variants that share a base route's shield (e.g.
40+
* carriageway suffixes) onto it.
41+
*/
42+
public String normalizeNetwork(String network) {
43+
return network;
44+
}
45+
46+
/**
47+
* Priority of a route network when ordering the concurrent shields on a single road. Lower rank sorts earlier, so it
48+
* becomes a lower shield index (network_1 is the primary shield). Priority is a national convention, so the base
49+
* implementation treats every network equally and leaves ordering to the ref tiebreak.
50+
*/
51+
public int networkRank(String network) {
52+
return DEFAULT_RANK;
53+
}
54+
55+
/**
56+
* Normalize, de-duplicate, prioritize and cap a road's concurrent shields.
57+
* <p>
58+
* Input order is not significant: directional (forward/backward) route relations produce duplicate (network, ref)
59+
* pairs, and {@code SourceFeature.relationInfo()} ordering is not stable across builds, so the deterministic ordering
60+
* here comes entirely from {@link #networkRank(String)} with the shield text as a tiebreak.
61+
*/
62+
public List<Shield> orderShields(List<Shield> shields) {
63+
List<Shield> normalized = new ArrayList<>();
64+
for (Shield s : shields) {
65+
String text = strip(s.text());
66+
if (text != null) {
67+
normalized.add(new Shield(text, s.network()));
68+
}
69+
}
70+
71+
List<Shield> deduped = new ArrayList<>(new LinkedHashSet<>(normalized));
72+
deduped.sort(
73+
Comparator.comparingInt((Shield s) -> networkRank(s.network()))
74+
.thenComparing(Shield::text));
75+
76+
if (deduped.size() > MAX_SHIELDS) {
77+
return new ArrayList<>(deduped.subList(0, MAX_SHIELDS));
78+
}
79+
return deduped;
80+
}
81+
82+
/**
83+
* Generic shield derived from the way's own {@code ref} tag, used as a fallback when a road is not a member of any
84+
* route relation. The network is unknown on this path, so "other".
85+
*/
2786
public Shield getShield(SourceFeature sf) {
2887
String ref = sf.getString("ref");
2988
if (ref != null) {
Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,53 @@
11
package com.protomaps.basemap.locales;
22

3-
import com.onthegomap.planetiler.reader.SourceFeature;
3+
import java.util.List;
44

5+
/*
6+
* Logic specific to the Netherlands.
7+
* <p>
8+
* Prioritizes route networks for shield ordering: A-roads (motorways) over N-roads
9+
* (national / provincial) over S-roads (city distributor rings).
10+
*/
511
public class NL extends CartographicLocale {
12+
13+
/**
14+
* OSM scopes stadsroute networks per municipality: NL:S:Amsterdam, NL:S:Rotterdam, NL:S:Den Haag, NL:S:Nijmegen,
15+
* NL:S:Zaanstad, NL:S:Parkstad. They share one shield design, so they collapse onto a single network for
16+
* symbolization.
17+
*/
18+
private static final String S_ROAD_PREFIX = "NL:S:";
19+
20+
/**
21+
* Legacy name for the collapsed stadsroute network. This is not an OSM value — it was synthesized by an older
22+
* way-ref-based implementation, and the sprite sheets and styles are keyed to it. Renaming it to "NL:S" is deferred
23+
* to the next breaking release.
24+
*/
25+
private static final String S_ROAD_NETWORK = "NL:S-road";
26+
27+
// Ordered by shield priority; the first matching prefix wins.
28+
private static final List<String> NETWORK_PRIORITY = List.of(
29+
"NL:A",
30+
"NL:N",
31+
S_ROAD_NETWORK
32+
);
33+
634
@Override
7-
public CartographicLocale.Shield getShield(SourceFeature sf) {
8-
String ref = sf.getString("ref");
9-
String network = "other";
35+
public String normalizeNetwork(String network) {
36+
if (network != null && network.startsWith(S_ROAD_PREFIX)) {
37+
return S_ROAD_NETWORK;
38+
}
39+
return super.normalizeNetwork(network);
40+
}
1041

11-
if (ref != null) {
12-
String firstRef = ref.split(";")[0];
13-
String shieldText = firstRef;
14-
if (firstRef.startsWith("S")) {
15-
network = "NL:S-road";
42+
@Override
43+
public int networkRank(String network) {
44+
if (network != null) {
45+
for (int i = 0; i < NETWORK_PRIORITY.size(); i++) {
46+
if (network.startsWith(NETWORK_PRIORITY.get(i))) {
47+
return i;
48+
}
1649
}
17-
return new CartographicLocale.Shield(strip(shieldText), network);
1850
}
19-
20-
return new CartographicLocale.Shield(null, null);
51+
return super.networkRank(network);
2152
}
2253
}
Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,49 @@
11
package com.protomaps.basemap.locales;
22

3-
import com.onthegomap.planetiler.reader.SourceFeature;
3+
import java.util.List;
44

55
/*
66
* Logic specific to the 50 US states.
77
* <p>
8-
* Assigns highway shield text and networks.
8+
* Prioritizes route networks for shield ordering: Interstate over US highway over any
9+
* state / county / local network.
910
*/
1011
public class US extends CartographicLocale {
1112

12-
@Override
13-
public Shield getShield(SourceFeature sf) {
14-
String ref = sf.getString("ref");
15-
String network = "other";
13+
// Ordered by shield priority; the first matching prefix wins. "US:" is a catch-all for every
14+
// state, county and local network (US:CO, US:CA, US:CO:Denver, ...) that sorts below the
15+
// national Interstate and US-highway networks.
16+
private static final List<String> NETWORK_PRIORITY = List.of(
17+
"US:I",
18+
"US:US",
19+
"US:"
20+
);
21+
22+
// Carriageway suffixes distinguish which roadway carries a route (e.g. the local and express
23+
// lanes of a collector-express freeway). They share the base route's shield, so collapse them.
24+
private static final List<String> CARRIAGEWAY_SUFFIXES = List.of(":Local", ":Express");
1625

17-
if (ref != null) {
18-
String firstRef = ref.split(";")[0];
19-
String shieldText = firstRef;
20-
if (firstRef.startsWith("US ")) {
21-
shieldText = firstRef.replace("US ", "");
22-
network = "US:US";
23-
} else if (firstRef.startsWith("I ")) {
24-
shieldText = firstRef.replace("I ", "");
25-
network = "US:I";
26+
@Override
27+
public String normalizeNetwork(String network) {
28+
if (network != null) {
29+
for (String suffix : CARRIAGEWAY_SUFFIXES) {
30+
if (network.endsWith(suffix)) {
31+
return network.substring(0, network.length() - suffix.length());
32+
}
2633
}
27-
return new Shield(strip(shieldText), network);
2834
}
35+
return network;
36+
}
2937

30-
return new Shield(null, null);
38+
@Override
39+
public int networkRank(String network) {
40+
if (network != null) {
41+
for (int i = 0; i < NETWORK_PRIORITY.size(); i++) {
42+
if (network.startsWith(NETWORK_PRIORITY.get(i))) {
43+
return i;
44+
}
45+
}
46+
}
47+
return super.networkRank(network);
3148
}
3249
}

tiles/src/test/java/com/protomaps/basemap/layers/LayerTest.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ abstract class LayerTest {
2222
final Stats stats = Stats.inMemory();
2323
final FeatureCollector.Factory featureCollectorFactory = new FeatureCollector.Factory(params, stats);
2424

25+
// Coarse bounding boxes, not real borders: just enough for tests to land inside a locale.
2526
final CountryCoder countryCoder = CountryCoder.fromJsonString(
26-
"{\"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]]]]}}]}");
27+
"{\"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]]]]}}]}");
2728

2829

2930
final QrankDb qrankDb = new QrankDb(LongLongHashMap.from(new long[]{8888}, new long[]{100000}));

0 commit comments

Comments
 (0)