package com.onthegomap.planetiler.custommap; import static com.onthegomap.planetiler.custommap.expression.ConfigExpression.constOf; import static com.onthegomap.planetiler.expression.Expression.not; import com.onthegomap.planetiler.FeatureCollector; import com.onthegomap.planetiler.FeatureCollector.Feature; import com.onthegomap.planetiler.custommap.configschema.AttributeDefinition; import com.onthegomap.planetiler.custommap.configschema.FeatureGeometry; import com.onthegomap.planetiler.custommap.configschema.FeatureItem; import com.onthegomap.planetiler.custommap.expression.ScriptEnvironment; import com.onthegomap.planetiler.expression.Expression; import com.onthegomap.planetiler.geo.GeometryException; import com.onthegomap.planetiler.reader.SourceFeature; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.function.BiConsumer; import java.util.function.Function; /** * A map feature, configured from a YML configuration file. * * {@link #matchExpression()} returns a filtering expression to limit input elements to ones this feature cares about, * and {@link #processFeature(Contexts.FeaturePostMatch, FeatureCollector)} processes matching elements. */ public class ConfiguredFeature { private static final double LOG4 = Math.log(4); private final Expression geometryTest; private final Function geometryFactory; private final Expression tagTest; private final TagValueProducer tagValueProducer; private final List> featureProcessors; private final Set sources; private final ScriptEnvironment processFeatureContext; private final ScriptEnvironment featureAttributeContext; private ScriptEnvironment featurePostMatchContext; public ConfiguredFeature(String layer, TagValueProducer tagValueProducer, FeatureItem feature, Contexts.Root rootContext) { sources = Set.copyOf(feature.source()); FeatureGeometry geometryType = feature.geometry(); //Test to determine whether this type of geometry is included geometryTest = geometryType.featureTest(); //Factory to treat OSM tag values as specific data type values this.tagValueProducer = tagValueProducer; processFeatureContext = Contexts.ProcessFeature.description(rootContext); featurePostMatchContext = Contexts.FeaturePostMatch.description(rootContext); featureAttributeContext = Contexts.FeatureAttribute.description(rootContext); //Test to determine whether this feature is included based on tagging Expression filter; if (feature.includeWhen() == null) { filter = Expression.TRUE; } else { filter = BooleanExpressionParser.parse(feature.includeWhen(), tagValueProducer, processFeatureContext); } if (feature.excludeWhen() != null) { filter = Expression.and( filter, Expression.not( BooleanExpressionParser.parse(feature.excludeWhen(), tagValueProducer, processFeatureContext)) ); } tagTest = filter; //Factory to generate the right feature type from FeatureCollector geometryFactory = geometryType.newGeometryFactory(layer); //Configure logic for each attribute in the output tile List> processors = new ArrayList<>(); for (var attribute : feature.attributes()) { processors.add(attributeProcessor(attribute)); } processors.add(makeFeatureProcessor(feature.minZoom(), Integer.class, Feature::setMinZoom)); processors.add(makeFeatureProcessor(feature.maxZoom(), Integer.class, Feature::setMaxZoom)); processors.add(makeFeatureProcessor(feature.minSize(), Double.class, Feature::setMinPixelSize)); featureProcessors = processors.stream().filter(Objects::nonNull).toList(); } private BiConsumer makeFeatureProcessor(Object input, Class clazz, BiConsumer consumer) { if (input == null) { return null; } var expression = ConfigExpressionParser.parse( input, tagValueProducer, featurePostMatchContext, clazz ); if (expression.equals(constOf(null))) { return null; } return (context, feature) -> { var result = expression.apply(context); if (result != null) { consumer.accept(feature, result); } }; } private static int minZoomFromTilePercent(SourceFeature sf, Double minTilePercent) { if (minTilePercent == null) { return 0; } try { return (int) (Math.log(minTilePercent / sf.area()) / LOG4); } catch (GeometryException e) { return 14; } } /** * Produces logic that generates attribute values based on configuration and input data. If both a constantValue * configuration and a tagValue configuration are set, this is likely a mistake, and the constantValue will take * precedence. * * @param attribute - attribute definition configured from YML * @return a function that generates an attribute value from a {@link SourceFeature} based on an attribute * configuration. */ private Function attributeValueProducer(AttributeDefinition attribute) { Object type = attribute.type(); // some expression features are hoisted to the top-level for attribute values for brevity, // so just map them to what the equivalent expression syntax would be and parse as an expression. Map value = new HashMap<>(); if ("match_key".equals(type)) { value.put("value", "${match_key}"); } else if ("match_value".equals(type)) { value.put("value", "${match_value}"); } else { if (type != null) { value.put("type", type); } if (attribute.coalesce() != null) { value.put("coalesce", attribute.coalesce()); } else if (attribute.value() != null) { value.put("value", attribute.value()); } else if (attribute.tagValue() != null) { value.put("tag_value", attribute.tagValue()); } else if (attribute.argValue() != null) { value.put("arg_value", attribute.argValue()); } else { value.put("tag_value", attribute.key()); } } return ConfigExpressionParser.parse(value, tagValueProducer, featurePostMatchContext, Object.class); } /** * Generate logic which determines the minimum zoom level for a feature based on a configured pixel size limit. * * @param minTilePercent - minimum percentage of a tile that a feature must cover to be shown * @param rawMinZoom - global minimum zoom for this feature, or an expression providing the min zoom dynamically * @param minZoomByValue - map of tag values to zoom level * @return minimum zoom function */ private Function attributeZoomThreshold( Double minTilePercent, Object rawMinZoom, Map minZoomByValue) { var result = ConfigExpressionParser.parse(rawMinZoom, tagValueProducer, featureAttributeContext, Integer.class); if ((result.equals(constOf(0)) || result.equals(constOf(null))) && minZoomByValue.isEmpty()) { return null; } if (minZoomByValue.isEmpty()) { return context -> Math.max(result.apply(context), minZoomFromTilePercent(context.feature(), minTilePercent)); } //Attribute value-specific zooms override static zooms return context -> { var value = minZoomByValue.get(context.value()); return value != null ? value : Math.max(result.apply(context), minZoomFromTilePercent(context.feature(), minTilePercent)); }; } /** * Generates a function which produces a fully-configured attribute for a feature. * * @param attribute - configuration for this attribute * @return processing logic */ private BiConsumer attributeProcessor(AttributeDefinition attribute) { var tagKey = attribute.key(); Object attributeMinZoom = attribute.minZoom(); attributeMinZoom = attributeMinZoom == null ? "0" : attributeMinZoom; var minZoomByValue = attribute.minZoomByValue(); minZoomByValue = minZoomByValue == null ? Map.of() : minZoomByValue; //Workaround because numeric keys are mapped as String minZoomByValue = tagValueProducer.remapKeysByType(tagKey, minZoomByValue); var attributeValueProducer = attributeValueProducer(attribute); var fallback = attribute.fallback(); var attrIncludeWhen = attribute.includeWhen(); var attrExcludeWhen = attribute.excludeWhen(); var attributeTest = Expression.and( attrIncludeWhen == null ? Expression.TRUE : BooleanExpressionParser.parse(attrIncludeWhen, tagValueProducer, featurePostMatchContext), attrExcludeWhen == null ? Expression.TRUE : not(BooleanExpressionParser.parse(attrExcludeWhen, tagValueProducer, featurePostMatchContext)) ).simplify(); var minTileCoverage = attrIncludeWhen == null ? null : attribute.minTileCoverSize(); Function attributeZoomProducer = attributeZoomThreshold(minTileCoverage, attributeMinZoom, minZoomByValue); return (context, f) -> { Object value = null; if (attributeTest.evaluate(context)) { value = attributeValueProducer.apply(context); if ("".equals(value)) { value = null; } } if (value == null) { value = fallback; } if (value != null) { if (attributeZoomProducer != null) { Integer minzoom = attributeZoomProducer.apply(context.createAttrZoomContext(value)); if (minzoom != null) { f.setAttrWithMinzoom(tagKey, value, minzoom); } else { f.setAttr(tagKey, value); } } else { f.setAttr(tagKey, value); } } }; } /** * Returns an expression that evaluates to true if a source feature should be included in the output. */ public Expression matchExpression() { return Expression.and(geometryTest, tagTest); } /** * Generates a tile feature based on a source feature. * * @param context The evaluation context containing the source feature * @param features output rendered feature collector */ public void processFeature(Contexts.FeaturePostMatch context, FeatureCollector features) { var sourceFeature = context.feature(); // Ensure that this feature is from the correct source (index should enforce this, so just check when assertions enabled) assert sources.contains(sourceFeature.getSource()); var f = geometryFactory.apply(features); for (var processor : featureProcessors) { processor.accept(context, f); } } }