From 3eea223cc10581d5b440308edb5c6f20406dab75 Mon Sep 17 00:00:00 2001 From: David Weber Date: Fri, 10 Jul 2026 13:11:56 +0200 Subject: [PATCH 1/2] fix(tiles): mark occupied level 3 boundaries disputed OSM models Cyprus's de facto regions at admin_level=3, including Northern Cyprus. The Northern Cyprus relation identifies its boundary with border_type=occupied but does not carry one of the dispute tags currently recognized by the boundaries layer, so its OSM output loses the disputed state when the source switches from Natural Earth at z6. Treat the exact admin_level=3 and border_type=occupied combination as a dispute signal. Preserve the OSM hierarchy as kind=region and kind_detail=3, while emitting disputed=true and sort_rank=288. Ordinary level 3 boundaries remain undisputed regional boundaries. Add regression coverage for both the occupied and ordinary level 3 cases. Verification: BoundariesTest, full Maven package (399 tests), Spotless, git diff --check, and a Cyprus PMTiles scan at z6-z10. AI assistance: this change was prepared with assistance from OpenAI Codex. --- .../protomaps/basemap/layers/Boundaries.java | 5 +- .../basemap/layers/BoundariesTest.java | 56 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/tiles/src/main/java/com/protomaps/basemap/layers/Boundaries.java b/tiles/src/main/java/com/protomaps/basemap/layers/Boundaries.java index fb9c1ade2..e1d6553d0 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Boundaries.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Boundaries.java @@ -226,7 +226,10 @@ public List preprocessOsmRelation(OsmElement.Relation relation) if (relation.hasTag("type", "boundary") && relation.hasTag("boundary", "administrative", "disputed", "claim")) { Integer adminLevel = Parse.parseIntOrNull(relation.getString("admin_level")); - Integer disputed = relation.hasTag("boundary", "disputed") || relation.hasTag("disputed", "yes") || + boolean occupiedBoundary = adminLevel != null && adminLevel == 3 && + relation.hasTag("border_type", "occupied"); + Integer disputed = occupiedBoundary || relation.hasTag("boundary", "disputed") || + relation.hasTag("disputed", "yes") || relation.hasTag("disputed_by") || relation.hasTag("claimed_by") ? 1 : 0; if (adminLevel == null || adminLevel > 8) diff --git a/tiles/src/test/java/com/protomaps/basemap/layers/BoundariesTest.java b/tiles/src/test/java/com/protomaps/basemap/layers/BoundariesTest.java index 9f103599c..bc4f59094 100644 --- a/tiles/src/test/java/com/protomaps/basemap/layers/BoundariesTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/layers/BoundariesTest.java @@ -74,6 +74,62 @@ void testDisputedOsm() { collector); } + @Test + void testOccupiedAdminLevelThreeIsDisputedRegionBoundary() { + var infos = profile.preprocessOsmRelation( + new OsmElement.Relation(1, + Map.of("type", "boundary", "boundary", "administrative", "admin_level", "3", "border_type", "occupied"), + List.of(new OsmElement.Relation.Member(OsmElement.Type.WAY, 123, "")))); + + var way = SimpleFeature.createFakeOsmFeature( + newLineString(0, 0, 1, 1), + new HashMap<>(), + "osm", + null, + 123, + infos.stream().map(r -> new OsmReader.RelationMember<>("", r)).toList() + ); + + var collector = featureCollectorFactory.get(way); + profile.processFeature(way, collector); + + assertFeatures(6, + List.of(Map.of( + "kind", "region", + "kind_detail", 3, + "disputed", true, + "sort_rank", 288, + "_minzoom", 6)), + collector); + } + + @Test + void testOrdinaryAdminLevelThreeRemainsRegionBoundary() { + var infos = profile.preprocessOsmRelation( + new OsmElement.Relation(1, Map.of("type", "boundary", "boundary", "administrative", "admin_level", "3"), + List.of(new OsmElement.Relation.Member(OsmElement.Type.WAY, 123, "")))); + + var way = SimpleFeature.createFakeOsmFeature( + newLineString(0, 0, 1, 1), + new HashMap<>(), + "osm", + null, + 123, + infos.stream().map(r -> new OsmReader.RelationMember<>("", r)).toList() + ); + + var collector = featureCollectorFactory.get(way); + profile.processFeature(way, collector); + + assertFeatures(6, + List.of(Map.of( + "kind", "region", + "kind_detail", 3, + "sort_rank", 289, + "_minzoom", 6)), + collector); + } + @ParameterizedTest @CsvSource(value = { "disputed,yes", From d85d4712cfc2a98058f9f6081099de1079fcdc7f Mon Sep 17 00:00:00 2001 From: David Weber Date: Fri, 10 Jul 2026 13:12:08 +0200 Subject: [PATCH 2/2] fix(styles): emphasize disputed level 3 boundaries The bundled boundary style classifies line weight solely from kind_detail, so every level 3 boundary uses the thinner regional treatment. That creates a visible discontinuity for occupied or disputed de facto regions when low-zoom Natural Earth country boundaries give way to level 3 OSM boundaries. Include disputed level 3 features in the country-weight boundary layer and exclude the same features from the regional layer. Keep the filters complementary so each feature renders exactly once. Ordinary level 3 boundaries remain unchanged. This remains backward compatible in both directions: old styles continue to render new level 3 tiles as regional boundaries, while new styles render old tiles without a disputed attribute using the previous regional treatment. Add a regression test that locks down both boundary filters. Verification: all 11 style tests, TypeScript checking, Biome, MapLibre style validation, browser rendering, and git diff --check. AI assistance: this change was prepared with assistance from OpenAI Codex. --- styles/src/base_layers.ts | 12 ++++++++++-- styles/test/base_layers.test.ts | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/styles/src/base_layers.ts b/styles/src/base_layers.ts index 65980120f..713e0352a 100644 --- a/styles/src/base_layers.ts +++ b/styles/src/base_layers.ts @@ -1066,7 +1066,11 @@ export function nolabels_layers( type: "line", source: source, "source-layer": "boundaries", - filter: ["<=", "kind_detail", 2], + filter: [ + "any", + ["<=", "kind_detail", 2], + ["all", ["==", "kind_detail", 3], ["==", "disputed", true]], + ], paint: { "line-color": t.boundaries, "line-width": 0.7, @@ -1084,7 +1088,11 @@ export function nolabels_layers( type: "line", source: source, "source-layer": "boundaries", - filter: [">", "kind_detail", 2], + filter: [ + "all", + [">", "kind_detail", 2], + ["none", ["all", ["==", "kind_detail", 3], ["==", "disputed", true]]], + ], paint: { "line-color": t.boundaries, "line-width": 0.4, diff --git a/styles/test/base_layers.test.ts b/styles/test/base_layers.test.ts index 65ab917b3..d5b171792 100644 --- a/styles/test/base_layers.test.ts +++ b/styles/test/base_layers.test.ts @@ -20,3 +20,22 @@ test("the nolayers label has no labels", () => { assert.notEqual("symbol", layer.type); } }); + +test("country boundary styling includes disputed level three boundaries", () => { + const boundaryLayers = nolabels_layers("dummy", LIGHT); + const country = boundaryLayers.find( + (layer) => layer.id === "boundaries_country", + ); + const other = boundaryLayers.find((layer) => layer.id === "boundaries"); + + assert.deepEqual(country?.filter, [ + "any", + ["<=", "kind_detail", 2], + ["all", ["==", "kind_detail", 3], ["==", "disputed", true]], + ]); + assert.deepEqual(other?.filter, [ + "all", + [">", "kind_detail", 2], + ["none", ["all", ["==", "kind_detail", 3], ["==", "disputed", true]]], + ]); +});