Skip to content

Commit 15b1bdb

Browse files
authored
Adjust midzoom places display (#576)
* Add failing unit tests exposing Places prioritization bug at zoom 7 * Fix Places prioritization by passing pm:population to zoomsIndex When the Places.java code was refactored to support Overture data, the zoom calculation logic was split into separate indexes (osmKindsIndex and zoomsIndex). However, the computed pm:population value from osmKindsIndex was not being passed to zoomsIndex, causing population-based zoom rules to fail. This caused places without population tags (like place=locality) to incorrectly appear at zoom 7 instead of zoom 12+, while more important places (like cities) were correctly prioritized. * Fix Overture Places zoom levels by using pm:populationFallback Make Overture data processing always set pm:populationFallback marker to trigger fallback zoom rules. This ensures Overture places get the higher minzoom values (city=8, town=9) without adding source-specific logic to zoom rules. 🤖 Assisted by [Claude Code](https://claude.com/claude-code)
1 parent e25db88 commit 15b1bdb

File tree

4 files changed

+141
-24
lines changed

4 files changed

+141
-24
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
Tiles 4.14.3
2+
------
3+
- Fix OSM place selection regression at zoom=7 [#576]
4+
15
Tiles 4.14.2
26
------
37
- Upgrade to planetiler 0.10.1 with workaround for JTS polygon negative buffer bug [#538]

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

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

134134
@Override
135135
public String version() {
136-
return "4.14.2";
136+
return "4.14.3";
137137
}
138138

139139
@Override

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

Lines changed: 53 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,8 @@ public Places(CountryCoder countryCoder) {
7575
rule(with("place", "state", "province"), with("pm:country", "US", "CA", "BR", "IN", "CN", "AU"),
7676
use("pm:kind", "region")),
7777
rule(with("place", "city", "town"), use("pm:kind", "locality"), use("pm:kindDetail", fromTag("place"))),
78-
rule(with("place", "city"), without("population"), use("pm:population", 5000)),
79-
rule(with("place", "town"), without("population"), use("pm:population", 10000)),
78+
rule(with("place", "city"), without("population"), use("pm:populationFallback", 5000)),
79+
rule(with("place", "town"), without("population"), use("pm:populationFallback", 10000)),
8080

8181
// Neighborhood-scale places
8282

@@ -87,17 +87,17 @@ public Places(CountryCoder countryCoder) {
8787
// Smaller places detailed in OSM but not fully tested for Overture
8888

8989
rule(with("place", "village"), use("pm:kind", "locality"), use("pm:kindDetail", fromTag("place"))),
90-
rule(with("place", "village"), without("population"), use("pm:population", 2000)),
90+
rule(with("place", "village"), without("population"), use("pm:populationFallback", 2000)),
9191
rule(with("place", "locality"), use("pm:kind", "locality")),
92-
rule(with("place", "locality"), without("population"), use("pm:population", 1000)),
92+
rule(with("place", "locality"), without("population"), use("pm:populationFallback", 1000)),
9393
rule(with("place", "hamlet"), use("pm:kind", "locality")),
94-
rule(with("place", "hamlet"), without("population"), use("pm:population", 200)),
94+
rule(with("place", "hamlet"), without("population"), use("pm:populationFallback", 200)),
9595
rule(with("place", "isolated_dwelling"), use("pm:kind", "locality")),
96-
rule(with("place", "isolated_dwelling"), without("population"), use("pm:population", 100)),
96+
rule(with("place", "isolated_dwelling"), without("population"), use("pm:populationFallback", 100)),
9797
rule(with("place", "farm"), use("pm:kind", "locality")),
98-
rule(with("place", "farm"), without("population"), use("pm:population", 50)),
98+
rule(with("place", "farm"), without("population"), use("pm:populationFallback", 50)),
9999
rule(with("place", "allotments"), use("pm:kind", "locality")),
100-
rule(with("place", "allotments"), without("population"), use("pm:population", 1000))
100+
rule(with("place", "allotments"), without("population"), use("pm:populationFallback", 1000))
101101

102102
)).index();
103103

@@ -126,10 +126,14 @@ public Places(CountryCoder countryCoder) {
126126
rule(with("pm:kind", "region"), use("pm:minzoom", 8), use("pm:maxzoom", 11)),
127127
rule(with("pm:kind", "region"), with("pm:country", "US", "CA", "BR", "IN", "CN", "AU"), use("pm:kindRank", 1)),
128128

129-
rule(with("pm:kind", "locality"), use("pm:kindRank", 4), use("pm:minzoom", 7), use("pm:maxzoom", 15)),
130-
rule(with("pm:kind", "locality"), atLeast("pm:population", 1000), use("pm:minzoom", 12)),
131-
rule(with("pm:kind", "locality"), with("pm:kindDetail", "city"), use("pm:kindRank", 2), use("pm:minzoom", 8)),
132-
rule(with("pm:kind", "locality"), with("pm:kindDetail", "town"), use("pm:kindRank", 2), use("pm:minzoom", 9)),
129+
rule(with("pm:kind", "locality"), use("pm:kindRank", 4), use("pm:minzoom", 11), use("pm:maxzoom", 15)),
130+
rule(with("pm:kind", "locality"), with("pm:populationFallback"), use("pm:minzoom", 12)),
131+
rule(with("pm:kind", "locality"), with("pm:kindDetail", "city"), use("pm:kindRank", 2), use("pm:minzoom", 7)),
132+
rule(with("pm:kind", "locality"), with("pm:kindDetail", "city"), with("pm:populationFallback"),
133+
use("pm:minzoom", 8)),
134+
rule(with("pm:kind", "locality"), with("pm:kindDetail", "town"), use("pm:kindRank", 2), use("pm:minzoom", 7)),
135+
rule(with("pm:kind", "locality"), with("pm:kindDetail", "town"), with("pm:populationFallback"),
136+
use("pm:minzoom", 9)),
133137
rule(with("pm:kind", "locality"), with("pm:kindDetail", "village"), use("pm:kindRank", 3), use("pm:minzoom", 10)),
134138
rule(with("pm:kind", "locality"), with("pm:kindDetail", "village"), atLeast("pm:population", 2000),
135139
use("pm:minzoom", 11)),
@@ -226,6 +230,20 @@ static int getSortKey(double minZoom, int kindRank, long population, String name
226230
15, 3
227231
), 0);
228232

233+
private Map<String, Object> makeTagMap(String kind, String kindDetail, Integer population,
234+
Integer populationFallback) {
235+
Map<String, Object> computedTags = new HashMap<>();
236+
237+
computedTags.put("pm:kind", kind);
238+
computedTags.put("pm:kindDetail", kindDetail);
239+
computedTags.put("pm:population", population);
240+
if (populationFallback > 0) {
241+
computedTags.put("pm:populationFallback", populationFallback);
242+
}
243+
244+
return computedTags;
245+
}
246+
229247
public void processOsm(SourceFeature sf, FeatureCollector features) {
230248
if (!sf.isPoint() || !sf.hasTag("name") || !sf.hasTag("place")) {
231249
return;
@@ -245,6 +263,7 @@ public void processOsm(SourceFeature sf, FeatureCollector features) {
245263
String kind = getString(sf, matches, "pm:kind", "pm:undefined");
246264
String kindDetail = getString(sf, matches, "pm:kindDetail", "");
247265
Integer population = getInteger(sf, matches, "pm:population", 0);
266+
Integer populationFallback = getInteger(sf, matches, "pm:populationFallback", 0);
248267

249268
if ("pm:undefined".equals(kind)) {
250269
return;
@@ -254,9 +273,15 @@ public void processOsm(SourceFeature sf, FeatureCollector features) {
254273
Integer maxZoom;
255274
Integer kindRank;
256275

257-
var sf2 = new Matcher.SourceFeatureWithComputedTags(sf, Map.of("pm:kind", kind, "pm:kindDetail", kindDetail));
276+
var computedTags = makeTagMap(kind, kindDetail, population, populationFallback);
277+
var sf2 = new Matcher.SourceFeatureWithComputedTags(sf, computedTags);
258278
var zoomMatches = zoomsIndex.getMatches(sf2);
259279

280+
// Use populationFallback for sorting if no real population
281+
if (population == 0 && populationFallback > 0) {
282+
population = populationFallback;
283+
}
284+
260285
minZoom = getInteger(sf2, zoomMatches, "pm:minzoom", 99);
261286
maxZoom = getInteger(sf2, zoomMatches, "pm:maxzoom", 99);
262287
kindRank = getInteger(sf2, zoomMatches, "pm:kindRank", 99);
@@ -345,11 +370,25 @@ public void processOverture(SourceFeature sf, FeatureCollector features) {
345370
return;
346371
}
347372

373+
// Extract population (if available)
374+
Integer population = 0;
375+
if (sf.hasTag("population")) {
376+
Object popValue = sf.getTag("population");
377+
if (popValue instanceof Number number) {
378+
population = number.intValue();
379+
}
380+
}
381+
382+
// Overture always uses populationFallback for zoom calculations to get consistent behavior
383+
// This ensures Overture places get the higher minzoom levels (8 for city, 9 for town, etc)
384+
Integer populationFallback = 1; // Marker value to trigger fallback zoom levels
385+
348386
Integer minZoom;
349387
Integer maxZoom;
350388
Integer kindRank;
351389

352-
var sf2 = new Matcher.SourceFeatureWithComputedTags(sf, Map.of("pm:kind", kind, "pm:kindDetail", kindDetail));
390+
var computedTags = makeTagMap(kind, kindDetail, population, populationFallback);
391+
var sf2 = new Matcher.SourceFeatureWithComputedTags(sf, computedTags);
353392
var zoomMatches = zoomsIndex.getMatches(sf2);
354393

355394
minZoom = getInteger(sf2, zoomMatches, "pm:minzoom", 99);
@@ -359,15 +398,6 @@ public void processOverture(SourceFeature sf, FeatureCollector features) {
359398
// Extract name
360399
String name = sf.getString("names.primary");
361400

362-
// Extract population (if available)
363-
Integer population = 0;
364-
if (sf.hasTag("population")) {
365-
Object popValue = sf.getTag("population");
366-
if (popValue instanceof Number number) {
367-
population = number.intValue();
368-
}
369-
}
370-
371401
int populationRank = 0;
372402

373403
for (int i = 0; i < popBreaks.length; i++) {

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

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,89 @@ void testAllotmentsOsm() {
202202
0
203203
)));
204204
}
205+
206+
@Test
207+
void testLocalityNoPopulationHasCorrectMinZoom() {
208+
// Place Mahiouindo - OSM node/4535788658
209+
// place=locality without population should have _minzoom=12 (not 7)
210+
assertFeatures(7,
211+
List.of(Map.of("_minzoom", 12, "_maxzoom", 14, "kind", "locality", "kind_detail", "locality")),
212+
process(SimpleFeature.create(
213+
newPoint(2.5892, 7.3321),
214+
new HashMap<>(Map.of("place", "locality", "name", "Place Mahiouindo")),
215+
"osm",
216+
null,
217+
0
218+
)));
219+
}
220+
221+
@Test
222+
void testLocalityNoPopulationMinZoom() {
223+
// Place Mahiouindo - should have min_zoom=13 (minzoom=12 + 1)
224+
assertFeatures(12,
225+
List.of(Map.of("kind", "locality",
226+
"kind_detail", "locality",
227+
"min_zoom", 13,
228+
"population", 1000)),
229+
process(SimpleFeature.create(
230+
newPoint(2.5892, 7.3321),
231+
new HashMap<>(Map.of("place", "locality", "name", "Place Mahiouindo")),
232+
"osm",
233+
null,
234+
0
235+
)));
236+
}
237+
238+
@Test
239+
void testCityWithPopulationVisibleAtZoom7() {
240+
// Kétou - OSM node/2313302870
241+
// place=city with population=160000 should be visible at zoom 7 (minzoom=7, min_zoom=8)
242+
assertFeatures(7,
243+
List.of(Map.of("kind", "locality",
244+
"kind_detail", "city",
245+
"min_zoom", 8,
246+
"population", 160000)),
247+
process(SimpleFeature.create(
248+
newPoint(2.5892, 7.3632),
249+
new HashMap<>(Map.of("place", "city", "name", "Kétou", "population", "160000")),
250+
"osm",
251+
null,
252+
0
253+
)));
254+
}
255+
256+
@Test
257+
void testCityWithoutPopulationHasCorrectMinZoom() {
258+
// Cities without population tags get populationFallback=5000 and should have _minZoom=8
259+
assertFeatures(8,
260+
List.of(Map.of("_minzoom", 8, "kind", "locality", "kind_detail", "city", "population", 5000)),
261+
process(SimpleFeature.create(
262+
newPoint(2.5892, 7.3632),
263+
new HashMap<>(Map.of("place", "city", "name", "Some City")),
264+
"osm",
265+
null,
266+
0
267+
)));
268+
}
269+
270+
@Test
271+
void testOuidahWithWikidataVisibleAtZoom6() {
272+
// Ouidah - OSM node/313015821, wikidata Q850031
273+
// Has wikidata entry in places.csv: Q850031,6,-1,8
274+
// Should be visible at zoom 6 (wikidata override)
275+
assertFeatures(6,
276+
List.of(Map.of("kind", "locality",
277+
"kind_detail", "city",
278+
"population", 160000)),
279+
process(SimpleFeature.create(
280+
newPoint(2.0854, 6.3616),
281+
new HashMap<>(Map.of("place", "city", "name", "Ouidah", "population", "160000", "wikidata", "Q850031")),
282+
"osm",
283+
null,
284+
0
285+
)));
286+
}
287+
205288
}
206289

207290

0 commit comments

Comments
 (0)