/* Copyright (c) 2021, MapTiler.com & OpenMapTiles contributors. All rights reserved. Code license: BSD 3-Clause License Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. Design license: CC-BY 4.0 See https://github.com/openmaptiles/openmaptiles/blob/master/LICENSE.md for details on usage */ package com.onthegomap.planetiler.basemap.layers; import static com.onthegomap.planetiler.basemap.util.Utils.coalesce; import static com.onthegomap.planetiler.basemap.util.Utils.nullIfEmpty; import static com.onthegomap.planetiler.basemap.util.Utils.nullOrEmpty; import static com.onthegomap.planetiler.collection.FeatureGroup.SORT_KEY_BITS; import com.carrotsearch.hppc.LongIntMap; import com.onthegomap.planetiler.FeatureCollector; import com.onthegomap.planetiler.VectorTile; import com.onthegomap.planetiler.basemap.BasemapProfile; import com.onthegomap.planetiler.basemap.generated.OpenMapTilesSchema; import com.onthegomap.planetiler.basemap.generated.Tables; import com.onthegomap.planetiler.basemap.util.LanguageUtils; import com.onthegomap.planetiler.collection.Hppc; import com.onthegomap.planetiler.config.PlanetilerConfig; import com.onthegomap.planetiler.geo.GeoUtils; import com.onthegomap.planetiler.geo.GeometryException; import com.onthegomap.planetiler.geo.PointIndex; import com.onthegomap.planetiler.geo.PolygonIndex; import com.onthegomap.planetiler.reader.SourceFeature; import com.onthegomap.planetiler.stats.Stats; import com.onthegomap.planetiler.util.Parse; import com.onthegomap.planetiler.util.SortKey; import com.onthegomap.planetiler.util.Translations; import com.onthegomap.planetiler.util.ZoomFunction; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.TreeMap; import java.util.stream.Collectors; import java.util.stream.DoubleStream; import java.util.stream.Stream; import org.apache.commons.lang3.StringUtils; import org.locationtech.jts.geom.Point; /** * Defines the logic for generating label points for populated places like continents, countries, cities, and towns in * the {@code place} layer from source features. *

* This class is ported to Java from * OpenMapTiles place sql files. */ public class Place implements OpenMapTilesSchema.Place, BasemapProfile.NaturalEarthProcessor, Tables.OsmContinentPoint.Handler, Tables.OsmCountryPoint.Handler, Tables.OsmStatePoint.Handler, Tables.OsmIslandPoint.Handler, Tables.OsmIslandPolygon.Handler, Tables.OsmCityPoint.Handler, BasemapProfile.FeaturePostProcessor { /* * Place labels locations and names come from OpenStreetMap, but we also join with natural * earth state/country geographic areas and city point labels to give a hint for what rank * and minimum zoom level to use for those points. */ private static final TreeMap ISLAND_AREA_RANKS = new TreeMap<>(Map.of( Double.MAX_VALUE, 3, squareMetersToWorldArea(40_000_000), 4, squareMetersToWorldArea(15_000_000), 5, squareMetersToWorldArea(1_000_000), 6 )); private static final double MIN_ISLAND_WORLD_AREA = Math.pow(4, -26); // 2^14 tiles, 2^12 pixels per tile private static final double CITY_JOIN_DISTANCE = GeoUtils.metersToPixelAtEquator(0, 50_000) / 256d; // constants for packing place label precedence into the sort-key field private static final double MAX_CITY_POPULATION = 100_000_000d; private static final Set MAJOR_CITY_PLACES = Set.of("city", "town", "village"); private static final ZoomFunction LABEL_GRID_LIMITS = ZoomFunction.fromMaxZoomThresholds(Map.of( 8, 4, 9, 8, 10, 12, 12, 14 ), 0); private final Translations translations; private final Stats stats; // spatial indexes for joining natural earth place labels with their corresponding points // from openstreetmap private PolygonIndex countries = PolygonIndex.create(); private PolygonIndex states = PolygonIndex.create(); private PointIndex cities = PointIndex.create(); public Place(Translations translations, PlanetilerConfig config, Stats stats) { this.translations = translations; this.stats = stats; } /** Returns the portion of the world that {@code squareMeters} covers where 1 is the entire planet. */ private static double squareMetersToWorldArea(double squareMeters) { double oneSideMeters = Math.sqrt(squareMeters); double oneSideWorld = GeoUtils.metersToPixelAtEquator(0, oneSideMeters) / 256d; return Math.pow(oneSideWorld, 2); } /** * Packs place precedence ordering ({@code rank asc, place asc, population desc, name.length asc}) into an integer for * the sort-key field. */ static int getSortKey(Integer rank, PlaceType place, long population, String name) { return SortKey // ORDER BY "rank" ASC NULLS LAST, .orderByInt(rank == null ? 15 : rank, 0, 15) // 4 bits // place ASC NULLS LAST, .thenByInt(place == null ? 15 : place.ordinal(), 0, 15) // 4 bits // population DESC NULLS LAST, .thenByLog(population, MAX_CITY_POPULATION, 1, 1 << (SORT_KEY_BITS - 13) - 1) // length(name) ASC .thenByInt(name == null ? 0 : name.length(), 0, 31) // 5 bits .get(); } @Override public void release() { countries = null; states = null; cities = null; } @Override public void processNaturalEarth(String table, SourceFeature feature, FeatureCollector features) { // store data from natural earth to help with ranks and min zoom levels when actually // emitting features from openstreetmap data. try { switch (table) { case "ne_10m_admin_0_countries" -> countries.put(feature.worldGeometry(), new NaturalEarthRegion( feature.getString("name"), 6, feature.getLong("scalerank"), feature.getLong("labelrank") )); case "ne_10m_admin_1_states_provinces" -> { Double scalerank = Parse.parseDoubleOrNull(feature.getTag("scalerank")); Double labelrank = Parse.parseDoubleOrNull(feature.getTag("labelrank")); if (scalerank != null && scalerank <= 6 && labelrank != null && labelrank <= 7) { states.put(feature.worldGeometry(), new NaturalEarthRegion( feature.getString("name"), 6, scalerank, labelrank, feature.getLong("datarank") )); } } case "ne_10m_populated_places" -> cities.put(feature.worldGeometry(), new NaturalEarthPoint( feature.getString("name"), feature.getString("wikidataid"), (int) feature.getLong("scalerank"), Stream.of("name", "namealt", "meganame", "gn_ascii", "nameascii").map(feature::getString) .filter(Objects::nonNull) .map(s -> s.toLowerCase(Locale.ROOT)) .collect(Collectors.toSet()) )); } } catch (GeometryException e) { e.log(stats, "omt_place_ne", "Error getting geometry for natural earth feature " + table + " " + feature.getTag("ogc_fid")); } } @Override public void process(Tables.OsmContinentPoint element, FeatureCollector features) { if (!nullOrEmpty(element.name())) { features.point(LAYER_NAME).setBufferPixels(BUFFER_SIZE) .putAttrs(LanguageUtils.getNames(element.source().tags(), translations)) .setAttr(Fields.CLASS, FieldValues.CLASS_CONTINENT) .setAttr(Fields.RANK, 1) .putAttrs(LanguageUtils.getNames(element.source().tags(), translations)) .setZoomRange(0, 3); } } @Override public void process(Tables.OsmCountryPoint element, FeatureCollector features) { if (nullOrEmpty(element.name())) { return; } String isoA2 = coalesce( nullIfEmpty(element.countryCodeIso31661Alpha2()), nullIfEmpty(element.iso31661Alpha2()), nullIfEmpty(element.iso31661()) ); if (isoA2 == null) { return; } try { // set country rank to 6, unless there is a match in natural earth that indicates it // should be lower int rank = 7; NaturalEarthRegion country = countries.get(element.source().worldGeometry().getCentroid()); var names = LanguageUtils.getNames(element.source().tags(), translations); if (country != null) { if (nullOrEmpty(names.get(Fields.NAME_EN))) { names.put(Fields.NAME_EN, country.name); } rank = country.rank; } rank = Math.min(6, Math.max(1, rank)); features.point(LAYER_NAME).setBufferPixels(BUFFER_SIZE) .putAttrs(names) .setAttr(Fields.ISO_A2, isoA2) .setAttr(Fields.CLASS, FieldValues.CLASS_COUNTRY) .setAttr(Fields.RANK, rank) .setMinZoom(rank - 1) .setSortKey(rank); } catch (GeometryException e) { e.log(stats, "omt_place_country", "Unable to get point for OSM country " + element.source().id()); } } @Override public void process(Tables.OsmStatePoint element, FeatureCollector features) { try { // want the containing (not nearest) state polygon since we pre-filter the states in the polygon index // use natural earth to filter out any spurious states, and to set the rank field NaturalEarthRegion state = states.getOnlyContaining(element.source().worldGeometry().getCentroid()); if (state != null) { var names = LanguageUtils.getNames(element.source().tags(), translations); if (nullOrEmpty(names.get(Fields.NAME_EN))) { names.put(Fields.NAME_EN, state.name); } int rank = Math.min(6, Math.max(1, state.rank)); features.point(LAYER_NAME).setBufferPixels(BUFFER_SIZE) .putAttrs(names) .setAttr(Fields.CLASS, element.place()) .setAttr(Fields.RANK, rank) .setMinZoom(2) .setSortKey(rank); } } catch (GeometryException e) { e.log(stats, "omt_place_state", "Unable to get point for OSM state " + element.source().id()); } } @Override public void process(Tables.OsmIslandPolygon element, FeatureCollector features) { try { double area = element.source().area(); int rank = ISLAND_AREA_RANKS.ceilingEntry(area).getValue(); int minzoom = rank <= 3 ? 8 : rank <= 4 ? 9 : 10; features.pointOnSurface(LAYER_NAME).setBufferPixels(BUFFER_SIZE) .putAttrs(LanguageUtils.getNames(element.source().tags(), translations)) .setAttr(Fields.CLASS, "island") .setAttr(Fields.RANK, rank) .setMinZoom(minzoom) .setSortKey(SortKey.orderByLog(area, 1d, MIN_ISLAND_WORLD_AREA).get()); } catch (GeometryException e) { e.log(stats, "omt_place_island_poly", "Unable to get point for OSM island polygon " + element.source().id()); } } @Override public void process(Tables.OsmIslandPoint element, FeatureCollector features) { features.point(LAYER_NAME).setBufferPixels(BUFFER_SIZE) .putAttrs(LanguageUtils.getNames(element.source().tags(), translations)) .setAttr(Fields.CLASS, "island") .setAttr(Fields.RANK, 7) .setMinZoom(12); } @Override public void process(Tables.OsmCityPoint element, FeatureCollector features) { Integer rank = null; if (MAJOR_CITY_PLACES.contains(element.place())) { // only for major cities, attempt to find a nearby natural earth label with a similar // name and use that to set a rank from OSM that causes the label to be shown at lower // zoom levels try { Point point = element.source().worldGeometry().getCentroid(); List neCities = cities.getWithin(point, CITY_JOIN_DISTANCE); String rawName = coalesce(element.name(), ""); String name = coalesce(rawName, "").toLowerCase(Locale.ROOT); String nameEn = coalesce(element.nameEn(), "").toLowerCase(Locale.ROOT); String normalizedName = StringUtils.stripAccents(rawName); String wikidata = element.source().getString("wikidata", ""); for (var neCity : neCities) { if (wikidata.equals(neCity.wikidata) || neCity.names.contains(name) || neCity.names.contains(nameEn) || normalizedName.equals(neCity.name)) { rank = neCity.scaleRank <= 5 ? neCity.scaleRank + 1 : neCity.scaleRank; break; } } } catch (GeometryException e) { e.log(stats, "omt_place_city", "Unable to get point for OSM city " + element.source().id()); } } String capital = element.capital(); PlaceType placeType = PlaceType.forName(element.place()); int minzoom = rank != null && rank == 1 ? 2 : rank != null && rank <= 8 ? Math.max(3, rank - 1) : placeType.ordinal() <= PlaceType.TOWN.ordinal() ? 7 : placeType.ordinal() <= PlaceType.VILLAGE.ordinal() ? 8 : placeType.ordinal() <= PlaceType.SUBURB.ordinal() ? 11 : 14; var feature = features.point(LAYER_NAME).setBufferPixels(BUFFER_SIZE) .putAttrs(LanguageUtils.getNames(element.source().tags(), translations)) .setAttr(Fields.CLASS, element.place()) .setAttr(Fields.RANK, rank) .setMinZoom(minzoom) .setSortKey(getSortKey(rank, placeType, element.population(), element.name())) .setPointLabelGridPixelSize(12, 128); if (rank == null) { feature.setPointLabelGridLimit(LABEL_GRID_LIMITS); } if ("2".equals(capital) || "yes".equals(capital)) { feature.setAttr(Fields.CAPITAL, 2); } else if ("4".equals(capital)) { feature.setAttr(Fields.CAPITAL, 4); } } @Override public List postProcess(int zoom, List items) { // infer the rank field from ordering of the place labels with each label grid square LongIntMap groupCounts = Hppc.newLongIntHashMap(); for (VectorTile.Feature feature : items) { int gridrank = groupCounts.getOrDefault(feature.group(), 1); groupCounts.put(feature.group(), gridrank + 1); if (!feature.attrs().containsKey(Fields.RANK)) { feature.attrs().put(Fields.RANK, 10 + gridrank); } } return items; } /** Ordering defines the precedence of place classes. */ enum PlaceType { CITY("city"), TOWN("town"), VILLAGE("village"), HAMLET("hamlet"), SUBURB("suburb"), QUARTER("quarter"), NEIGHBORHOOD("neighbourhood"), ISOLATED_DWELLING("isolated_dwelling"), UNKNOWN("unknown"); private static final Map byName = new HashMap<>(); static { for (PlaceType place : values()) { byName.put(place.name, place); } } private final String name; PlaceType(String name) { this.name = name; } public static PlaceType forName(String name) { return byName.getOrDefault(name, UNKNOWN); } } /** * Information extracted from a natural earth geographic region that will be inspected when joining with OpenStreetMap * data. */ private record NaturalEarthRegion(String name, int rank) { NaturalEarthRegion(String name, int maxRank, double... ranks) { this(name, (int) Math.ceil(DoubleStream.of(ranks).average().orElse(maxRank))); } } /** * Information extracted from a natural earth place label that will be inspected when joining with OpenStreetMap data. */ private record NaturalEarthPoint(String name, String wikidata, int scaleRank, Set names) {} }