diff --git a/CHANGELOG.md b/CHANGELOG.md index a43b52e7..01b27634 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +Tiles 4.14.4 +------ +- Continue expanding Overture support to lower zoom levels with Places and some infrastructure [#579] + Tiles 4.14.3 ------ - Fix OSM place selection regression at zoom=7 [#576] diff --git a/app/src/examples.json b/app/src/examples.json index 89214eb6..1a272736 100644 --- a/app/src/examples.json +++ b/app/src/examples.json @@ -1,4 +1,116 @@ [ + { + "name": "oakland-downtown-z4", + "description": "Area around downtown Oakland", + "tags": ["oakland", "oakland-downtown"], + "center": [-122.21885, 37.71324], + "zoom": 4 + }, + { + "name": "oakland-downtown-z5", + "description": "Area around downtown Oakland", + "tags": ["oakland", "oakland-downtown"], + "center": [-122.21885, 37.71324], + "zoom": 5 + }, + { + "name": "oakland-downtown-z6", + "description": "Area around downtown Oakland", + "tags": ["oakland", "oakland-downtown"], + "center": [-122.21885, 37.71324], + "zoom": 6 + }, + { + "name": "oakland-downtown-z7", + "description": "Area around downtown Oakland", + "tags": ["oakland", "oakland-downtown"], + "center": [-122.2713, 37.8043], + "zoom": 7 + }, + { + "name": "oakland-downtown-z8", + "description": "Area around downtown Oakland", + "tags": ["oakland", "oakland-downtown"], + "center": [-122.21885, 37.71324], + "zoom": 8 + }, + { + "name": "oakland-downtown-z9", + "description": "Area around downtown Oakland", + "tags": ["oakland", "oakland-downtown"], + "center": [-122.2713, 37.8043], + "zoom": 9 + }, + { + "name": "oakland-downtown-z10", + "description": "Area around downtown Oakland", + "tags": ["oakland", "oakland-downtown"], + "center": [-122.2713, 37.8043], + "zoom": 10 + }, + { + "name": "oakland-downtown-z11", + "description": "Area around downtown Oakland", + "tags": ["oakland", "oakland-downtown"], + "center": [-122.2713, 37.8043], + "zoom": 11 + }, + { + "name": "oakland-airport-z15", + "description": "Area around Oakland Airport terminals", + "tags": ["oakland", "oakland-airport"], + "center": [-122.21885, 37.71324], + "zoom": 15 + }, + { + "name": "oakland-airport-z14", + "description": "Area around Oakland Airport terminals", + "tags": ["oakland", "oakland-airport"], + "center": [-122.21885, 37.71324], + "zoom": 14 + }, + { + "name": "oakland-airport-z13", + "description": "Area around Oakland Airport terminals", + "tags": ["oakland", "oakland-airport"], + "center": [-122.21885, 37.71324], + "zoom": 13 + }, + { + "name": "oakland-airport-z12", + "description": "Area around Oakland Airport terminals", + "tags": ["oakland", "oakland-airport"], + "center": [-122.21885, 37.71324], + "zoom": 12 + }, + { + "name": "oakland-lake-merritt-z15", + "description": "Area around Oakland Lake Merritt", + "tags": ["oakland", "oakland-lake-merritt"], + "center": [-122.26467, 37.80661], + "zoom": 15 + }, + { + "name": "oakland-lake-merritt-z14", + "description": "Area around Oakland Lake Merritt", + "tags": ["oakland", "oakland-lake-merritt"], + "center": [-122.26467, 37.80661], + "zoom": 14 + }, + { + "name": "oakland-lake-merritt-z13", + "description": "Area around Oakland Lake Merritt", + "tags": ["oakland", "oakland-lake-merritt"], + "center": [-122.26467, 37.80661], + "zoom": 13 + }, + { + "name": "oakland-lake-merritt-z12", + "description": "Area around Oakland Lake Merritt", + "tags": ["oakland", "oakland-lake-merritt"], + "center": [-122.26467, 37.80661], + "zoom": 12 + }, { "name": "taiwan-z8", "description": "Compare the appearance of highways", diff --git a/tiles/src/main/java/com/protomaps/basemap/Basemap.java b/tiles/src/main/java/com/protomaps/basemap/Basemap.java index bc470a57..e31fb03b 100644 --- a/tiles/src/main/java/com/protomaps/basemap/Basemap.java +++ b/tiles/src/main/java/com/protomaps/basemap/Basemap.java @@ -133,7 +133,7 @@ public String description() { @Override public String version() { - return "4.14.3"; + return "4.14.4"; } @Override diff --git a/tiles/src/main/java/com/protomaps/basemap/layers/Places.java b/tiles/src/main/java/com/protomaps/basemap/layers/Places.java index 5b3c3b2b..577d5892 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Places.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Places.java @@ -269,42 +269,19 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { return; } - Integer minZoom; - Integer maxZoom; - Integer kindRank; - var computedTags = makeTagMap(kind, kindDetail, population, populationFallback); var sf2 = new Matcher.SourceFeatureWithComputedTags(sf, computedTags); - var zoomMatches = zoomsIndex.getMatches(sf2); // Use populationFallback for sorting if no real population if (population == 0 && populationFallback > 0) { population = populationFallback; } - minZoom = getInteger(sf2, zoomMatches, "pm:minzoom", 99); - maxZoom = getInteger(sf2, zoomMatches, "pm:maxzoom", 99); - kindRank = getInteger(sf2, zoomMatches, "pm:kindRank", 99); - - int populationRank = 0; - - for (int i = 0; i < popBreaks.length; i++) { - if (population >= popBreaks[i]) { - populationRank = i + 1; - } - } - - if (WIKIDATA_CONFIGS.containsKey(sf.getString("wikidata"))) { - var wikidataConfig = WIKIDATA_CONFIGS.get(sf.getString("wikidata")); - if (kind.equals("country") || kind.equals("region")) { - minZoom = wikidataConfig.minZoom(); - maxZoom = wikidataConfig.maxZoom(); - } - if (kind.equals("locality")) { - minZoom = wikidataConfig.minZoom(); - populationRank = wikidataConfig.rankMax(); - } - } + var zp = getZoomsPops(sf2, kind, population); + int minZoom = zp.minZoom(); + int maxZoom = zp.maxZoom(); + int kindRank = zp.kindRank(); + int populationRank = zp.populationRank(); var feat = features.point(this.name()) .setId(FeatureId.create(sf)) @@ -379,32 +356,19 @@ public void processOverture(SourceFeature sf, FeatureCollector features) { } } - // Overture always uses populationFallback for zoom calculations to get consistent behavior - // This ensures Overture places get the higher minzoom levels (8 for city, 9 for town, etc) - Integer populationFallback = 1; // Marker value to trigger fallback zoom levels - - Integer minZoom; - Integer maxZoom; - Integer kindRank; + Integer populationFallback = (population > 0) ? 0 : 1; var computedTags = makeTagMap(kind, kindDetail, population, populationFallback); var sf2 = new Matcher.SourceFeatureWithComputedTags(sf, computedTags); - var zoomMatches = zoomsIndex.getMatches(sf2); - - minZoom = getInteger(sf2, zoomMatches, "pm:minzoom", 99); - maxZoom = getInteger(sf2, zoomMatches, "pm:maxzoom", 99); - kindRank = getInteger(sf2, zoomMatches, "pm:kindRank", 99); // Extract name String name = sf.getString("names.primary"); - int populationRank = 0; - - for (int i = 0; i < popBreaks.length; i++) { - if (population >= popBreaks[i]) { - populationRank = i + 1; - } - } + var zp = getZoomsPops(sf2, kind, population); + int minZoom = zp.minZoom(); + int maxZoom = zp.maxZoom(); + int kindRank = zp.kindRank(); + int populationRank = zp.populationRank(); var feat = features.point(this.name()) .setAttr("kind", kind) @@ -418,6 +382,10 @@ public void processOverture(SourceFeature sf, FeatureCollector features) { feat.setAttr("kind_detail", kindDetail); } + if (sf.hasTag("wikidata")) { + feat.setAttr("wikidata", sf.getString("wikidata")); + } + int sortKey = getSortKey(minZoom, kindRank, population, name); feat.setSortKey(sortKey); feat.setAttr("sort_key", sortKey); @@ -428,6 +396,36 @@ public void processOverture(SourceFeature sf, FeatureCollector features) { feat.setBufferPixelOverrides(ZoomFunction.maxZoom(12, 64)); } + record ZoomsPops(int minZoom, int maxZoom, int kindRank, int populationRank) {} + + private ZoomsPops getZoomsPops(Matcher.SourceFeatureWithComputedTags sf2, String kind, int population) { + var zoomMatches = zoomsIndex.getMatches(sf2); + int minZoom = getInteger(sf2, zoomMatches, "pm:minzoom", 99); + int maxZoom = getInteger(sf2, zoomMatches, "pm:maxzoom", 99); + int kindRank = getInteger(sf2, zoomMatches, "pm:kindRank", 99); + + int populationRank = 0; + for (int i = 0; i < popBreaks.length; i++) { + if (population >= popBreaks[i]) { + populationRank = i + 1; + } + } + + if (WIKIDATA_CONFIGS.containsKey(sf2.getString("wikidata"))) { + var wikidataConfig = WIKIDATA_CONFIGS.get(sf2.getString("wikidata")); + if (kind.equals("country") || kind.equals("region")) { + minZoom = wikidataConfig.minZoom(); + maxZoom = wikidataConfig.maxZoom(); + } + if (kind.equals("locality")) { + minZoom = wikidataConfig.minZoom(); + populationRank = wikidataConfig.rankMax(); + } + } + + return new ZoomsPops(minZoom, maxZoom, kindRank, populationRank); + } + @Override public List postProcess(int zoom, List items) { return items; diff --git a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java index edba20bf..bc9d13af 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -562,6 +562,13 @@ public void processOverture(SourceFeature sf, FeatureCollector features) { if (kind.equals("pm:undefined")) return; + // Drop low-confidence features. Below 0.65, features are dominated by uncertain data: + // real estate listings, auto repair, beauty salons, ATMs from low-quality sources. + double confidence = sf.getTag("confidence")instanceof Number n ? n.doubleValue() : 0.0; + if (confidence < 0.65) { + return; + } + // QRank may override minZoom entirely String wikidata = sf.getString("wikidata"); long qrank = (wikidata != null) ? qrankDb.get(wikidata) : 0; @@ -581,6 +588,10 @@ public void processOverture(SourceFeature sf, FeatureCollector features) { String name = sf.getString("names.primary"); + // Sort key: lower = higher rendering priority. Within the same minZoom bucket, + // higher confidence wins (subtract confidence*100 so 0.99 → -99, 0.65 → -65). + int sortKey = minZoom * 1000 - (int) (confidence * 100); + features.point(this.name()) // all POIs should receive their IDs at all zooms // (there is no merging of POIs like with lines and polygons in other layers) @@ -593,7 +604,8 @@ public void processOverture(SourceFeature sf, FeatureCollector features) { .setAttr("min_zoom", minZoom + 1) // .setBufferPixels(8) - .setZoomRange(Math.min(minZoom, 15), 15); + .setZoomRange(Math.min(minZoom, 15), 15) + .setSortKey(sortKey); } @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 f86b8b41..8360c74d 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java @@ -219,6 +219,17 @@ public Roads(CountryCoder countryCoder) { )).index(); + private static final MultiExpression.Index> overtureAerowayKindsIndex = + MultiExpression.ofOrdered(List.of( + rule(use("pm:kind", "pm:undefined"), use("pm:kindDetail", "pm:undefined"), use("pm:highway", "pm:undefined")), + rule(with("class", "runway"), use("pm:kind", "aeroway"), use("pm:kindDetail", "runway"), + use("pm:highway", "aeroway")), + rule(with("class", "taxiway"), use("pm:kind", "aeroway"), use("pm:kindDetail", "taxiway"), + use("pm:highway", "aeroway")), + rule(with("class", "taxilane"), use("pm:kind", "aeroway"), use("pm:kindDetail", "taxiway"), + use("pm:highway", "aeroway")) + )).index(); + // Protomaps kind/kind_detail to min_zoom mapping private static final MultiExpression.Index> highwayZoomsIndex = MultiExpression.ofOrdered(List.of( @@ -505,6 +516,30 @@ private static class OvertureSegmentProperties { } public void processOverture(SourceFeature sf, FeatureCollector features) { + if ("base".equals(sf.getString("theme")) && "infrastructure".equals(sf.getString("type"))) { + if (!"airport".equals(sf.getString("subtype"))) + return; + + List> kindMatches = overtureAerowayKindsIndex.getMatches(sf); + String kind = getString(sf, kindMatches, "pm:kind", "pm:undefined"); + String kindDetail = getString(sf, kindMatches, "pm:kindDetail", "pm:undefined"); + if ("pm:undefined".equals(kind)) + return; + + int minZoom = "runway".equals(kindDetail) ? 9 : 10; + String name = sf.getString("names.primary"); + + if (!sf.canBePolygon()) { + try { + LineString line = (LineString) sf.latLonGeometry(); + emitOvertureFeature(features, sf, line, kind, kindDetail, name, "aeroway", minZoom, + new OvertureSegmentProperties()); + } catch (GeometryException e) { + /* skip */ } + } + return; + } + // Filter by type field - Overture transportation theme if (!"transportation".equals(sf.getString("theme"))) { return; diff --git a/tiles/src/test/java/com/protomaps/basemap/layers/PlacesTest.java b/tiles/src/test/java/com/protomaps/basemap/layers/PlacesTest.java index 31cbe771..47f6f7b6 100644 --- a/tiles/src/test/java/com/protomaps/basemap/layers/PlacesTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/layers/PlacesTest.java @@ -290,6 +290,125 @@ void testOuidahWithWikidataVisibleAtZoom6() { class PlacesOvertureTest extends LayerTest { + @Test + void testSanFranciscoOvertureCity() { + assertFeatures(12, + List.of(Map.of( + "kind", "locality", + "kind_detail", "city", + "name", "San Francisco", + "_minzoom", 2, + "min_zoom", 3, + "population", 873965, + "population_rank", 12 + )), + process(SimpleFeature.create( + newPoint(-122.4194, 37.7749), + new HashMap<>(Map.of( + "id", "dd84743c-4b27-476c-8da9-6e9730216bbd", + "theme", "divisions", + "type", "division", + "subtype", "locality", + "class", "city", + "names.primary", "San Francisco", + "wikidata", "Q62", + "population", 873965 + )), + "pm:overture", + null, + 0 + ))); + } + + @Test + void testSanJoseOvertureCity() { + assertFeatures(12, + List.of(Map.of( + "kind", "locality", + "kind_detail", "city", + "name", "San Jose", + "_minzoom", 4, + "min_zoom", 5, + "population", 1035317, + "population_rank", 12 + )), + process(SimpleFeature.create( + newPoint(-121.8863, 37.3382), + new HashMap<>(Map.of( + "id", "f5b6f0d6-6d89-4d16-b31d-fc7ab1bf8e44", + "theme", "divisions", + "type", "division", + "subtype", "locality", + "class", "city", + "names.primary", "San Jose", + "wikidata", "Q16553", + "population", 1035317 + )), + "pm:overture", + null, + 0 + ))); + } + + @Test + void testSanMateoOvertureCity() { + assertFeatures(12, + List.of(Map.of( + "kind", "locality", + "kind_detail", "city", + "name", "San Mateo", + "_minzoom", 6, + "min_zoom", 7, + "population", 103536, + "population_rank", 11 + )), + process(SimpleFeature.create( + newPoint(-122.3255, 37.5630), + new HashMap<>(Map.of( + "id", "2910cbac-cb82-4093-9f08-aeebd96a1c14", + "theme", "divisions", + "type", "division", + "subtype", "locality", + "class", "city", + "names.primary", "San Mateo", + "wikidata", "Q169943", + "population", 103536 + )), + "pm:overture", + null, + 0 + ))); + } + + @Test + void testSaratogaOvertureTown() { + assertFeatures(12, + List.of(Map.of( + "kind", "locality", + "kind_detail", "town", + "name", "Saratoga", + "_minzoom", 7, + "min_zoom", 8, + "population", 30153 + )), + process(SimpleFeature.create( + newPoint(-122.0233, 37.2638), + new HashMap<>(Map.of( + "id", "3a6c1a95-cf1b-4429-a0fd-eb966a2fea72", + "theme", "divisions", + "type", "division", + "subtype", "locality", + "class", "town", + "names.primary", "Saratoga", + "wikidata", "Q927163", + "population", 30153 + )), + "pm:overture", + null, + 0 + ))); + } + @Test void testOaklandCity() { assertFeatures(12, @@ -297,7 +416,7 @@ void testOaklandCity() { "kind", "locality", "kind_detail", "city", "name", "Oakland", - "min_zoom", 9, + "min_zoom", 8, "population", 433031, "population_rank", 10 )), diff --git a/tiles/src/test/java/com/protomaps/basemap/layers/PoisTest.java b/tiles/src/test/java/com/protomaps/basemap/layers/PoisTest.java index 860216bb..7ea79337 100644 --- a/tiles/src/test/java/com/protomaps/basemap/layers/PoisTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/layers/PoisTest.java @@ -1140,16 +1140,16 @@ class PoisOvertureTest extends LayerTest { @Test void kind_nationalPark_fromBasicCategory() { assertFeatures(15, - List.of(Map.of("kind", "national_park", "min_zoom", 12, "name", "Alcatraz National Park")), + List.of(Map.of("kind", "national_park", "min_zoom", 12, "name", "Pinnacles National Park")), process(SimpleFeature.create( newPoint(1, 1), new HashMap<>(Map.of( - "id", "814b8a78-161f-4273-a4bb-7d686d0e3be4", // https://www.openstreetmap.org/way/295140461/history/15 + "id", "4d619bc0-d30c-4dbe-9f8b-079cf06c1a39", "theme", "places", "type", "place", "basic_category", "national_park", - "names.primary", "Alcatraz National Park", - "confidence", 0.64 + "names.primary", "Pinnacles National Park", + "confidence", 0.917024286724829 )), "pm:overture", null, 0 ))); @@ -1372,4 +1372,20 @@ void kind_hostel_fromBasicCategory() { "pm:overture", null, 0 ))); } + + @Test + void lowConfidence_dropped() { + // JetBlue counter (confidence=0.64) is below the 0.65 cutoff and must be dropped. + // Before the confidence cutoff this would have appeared at min_zoom=16. + var tags = new HashMap(); + tags.put("id", "e67dea74-eb8c-47e8-bfd3-80af26dd7d5c"); + tags.put("theme", "places"); + tags.put("type", "place"); + tags.put("basic_category", "air_transport_facility_service"); + tags.put("confidence", 0.64); + tags.put("names.primary", "JetBlue Airways"); + assertFeatures(15, + List.of(), + process(SimpleFeature.create(newPoint(1, 1), tags, "pm:overture", null, 0))); + } } 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 a35485ff..2698d150 100644 --- a/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java @@ -819,6 +819,42 @@ void kind_sidewalk_fromFootwayClass() { ))); } + @Test + void kind_aeroway_fromRunwayClass() { + // Overture feature 0205eaa2-ffeb-3aa3-bac3-2595270de305 + // Source: https://www.openstreetmap.org/way/27194437 + assertFeatures(12, + List.of(Map.of("kind", "aeroway", "kind_detail", "runway", "_minzoom", 9)), + process(SimpleFeature.create( + newLineString(0, 0, 1, 1), + new HashMap<>(Map.of( + "id", "0205eaa2-ffeb-3aa3-bac3-2595270de305", + "theme", "base", "type", "infrastructure", + "subtype", "airport", "class", "runway" + )), + "pm:overture", null, 0 + )) + ); + } + + @Test + void kind_aeroway_fromTaxiwayClass() { + // Overture feature 00244611-9db0-3fab-be3b-ad8001dad572 + // Source: https://www.openstreetmap.org/way/517100793 + assertFeatures(12, + List.of(Map.of("kind", "aeroway", "kind_detail", "taxiway", "_minzoom", 10)), + process(SimpleFeature.create( + newLineString(0, 0, 1, 1), + new HashMap<>(Map.of( + "id", "00244611-9db0-3fab-be3b-ad8001dad572", + "theme", "base", "type", "infrastructure", + "subtype", "airport", "class", "taxiway" + )), + "pm:overture", null, 0 + )) + ); + } + // Tests for partial application of properties (bridge, tunnel, oneway, level) requiring line splitting @Test