diff --git a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/app/FeatureProviderWfs.java b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/app/FeatureProviderWfs.java index 4620e2d8f..0c642dd6c 100644 --- a/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/app/FeatureProviderWfs.java +++ b/xtraplatform-features-gml/src/main/java/de/ii/xtraplatform/features/gml/app/FeatureProviderWfs.java @@ -15,7 +15,6 @@ import de.ii.xtraplatform.cql.domain.Cql; import de.ii.xtraplatform.crs.domain.BoundingBox; import de.ii.xtraplatform.crs.domain.CrsInfo; -import de.ii.xtraplatform.crs.domain.CrsTransformationException; import de.ii.xtraplatform.crs.domain.CrsTransformerFactory; import de.ii.xtraplatform.crs.domain.EpsgCrs; import de.ii.xtraplatform.crs.domain.OgcCrs; @@ -311,10 +310,15 @@ public FeatureSchema getSortablesSchema( @Override public Optional getSpatialExtent(String typeName) { - if (getData().getTypes().containsKey(typeName)) { + if (!getData().getTypes().containsKey(typeName)) { return Optional.empty(); } + Optional configured = getConfiguredSpatialExtent(typeName); + if (configured.isPresent()) { + return configured; + } + try { Stream> extentGraph = aggregateStatsReader.getSpatialExtent( @@ -337,23 +341,16 @@ public Optional getSpatialExtent(String typeName) { @Override public Optional getSpatialExtent(String typeName, EpsgCrs crs) { return getSpatialExtent(typeName) - .flatMap( - boundingBox -> - crsTransformerFactory - .getTransformer(getNativeCrs(), crs, false) - .flatMap( - crsTransformer -> { - try { - return Optional.of(crsTransformer.transformBoundingBox(boundingBox)); - } catch (CrsTransformationException e) { - return Optional.empty(); - } - })); + .flatMap(boundingBox -> transformSpatialExtent(boundingBox, crs)); } @Override public Optional getTemporalExtent(String typeName) { - return Optional.empty(); + if (!getData().getTypes().containsKey(typeName)) { + return Optional.empty(); + } + + return getConfiguredTemporalExtent(typeName); } @Override diff --git a/xtraplatform-features-graphql/src/main/java/de/ii/xtraplatform/features/graphql/app/FeatureProviderGraphQl.java b/xtraplatform-features-graphql/src/main/java/de/ii/xtraplatform/features/graphql/app/FeatureProviderGraphQl.java index 4158afa5f..a880989ac 100644 --- a/xtraplatform-features-graphql/src/main/java/de/ii/xtraplatform/features/graphql/app/FeatureProviderGraphQl.java +++ b/xtraplatform-features-graphql/src/main/java/de/ii/xtraplatform/features/graphql/app/FeatureProviderGraphQl.java @@ -14,7 +14,6 @@ import de.ii.xtraplatform.cql.domain.Cql; import de.ii.xtraplatform.crs.domain.BoundingBox; import de.ii.xtraplatform.crs.domain.CrsInfo; -import de.ii.xtraplatform.crs.domain.CrsTransformationException; import de.ii.xtraplatform.crs.domain.CrsTransformerFactory; import de.ii.xtraplatform.crs.domain.EpsgCrs; import de.ii.xtraplatform.crs.domain.OgcCrs; @@ -307,7 +306,7 @@ public boolean is3dSupported() { @Override @SuppressWarnings("PMD.AvoidCatchingGenericException") public long getFeatureCount(String typeName) { - if (getData().getTypes().containsKey(typeName)) { + if (!getData().getTypes().containsKey(typeName)) { return -1; } @@ -353,10 +352,15 @@ public FeatureSchema getSortablesSchema( @Override @SuppressWarnings("PMD.AvoidCatchingGenericException") public Optional getSpatialExtent(String typeName) { - if (getData().getTypes().containsKey(typeName)) { + if (!getData().getTypes().containsKey(typeName)) { return Optional.empty(); } + Optional configured = getConfiguredSpatialExtent(typeName); + if (configured.isPresent()) { + return configured; + } + try { Stream> extentGraph = aggregateStatsReader.getSpatialExtent( @@ -378,23 +382,15 @@ public Optional getSpatialExtent(String typeName) { @Override public Optional getSpatialExtent(String typeName, EpsgCrs crs) { return getSpatialExtent(typeName) - .flatMap( - boundingBox -> - crsTransformerFactory - .getTransformer(getNativeCrs(), crs, false) - .flatMap( - crsTransformer -> { - try { - return Optional.of(crsTransformer.transformBoundingBox(boundingBox)); - } catch (CrsTransformationException e) { - return Optional.empty(); - } - })); + .flatMap(boundingBox -> transformSpatialExtent(boundingBox, crs)); } @Override public Optional getTemporalExtent(String typeName) { - return Optional.empty(); + if (!getData().getTypes().containsKey(typeName)) { + return Optional.empty(); + } + return getConfiguredTemporalExtent(typeName); } @Override diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/FeatureProviderSql.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/FeatureProviderSql.java index 4274a15a0..1f1cd230e 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/FeatureProviderSql.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/FeatureProviderSql.java @@ -1026,6 +1026,11 @@ public Optional getSpatialExtent(String typeName) { return Optional.empty(); } + Optional configured = getConfiguredSpatialExtent(typeName); + if (configured.isPresent()) { + return configured; + } + String[] cacheKey = {typeName, "stats", "spatial"}; String cacheValidator = getData().getStableHash(); @@ -1081,18 +1086,7 @@ public Optional getSpatialExtent(String typeName) { @Override public Optional getSpatialExtent(String typeName, EpsgCrs crs) { return getSpatialExtent(typeName) - .flatMap( - boundingBox -> - crsTransformerFactory - .getTransformer(getNativeCrs(), crs, false) - .flatMap( - crsTransformer -> { - try { - return Optional.of(crsTransformer.transformBoundingBox(boundingBox)); - } catch (Exception e) { - return Optional.empty(); - } - })); + .flatMap(boundingBox -> transformSpatialExtent(boundingBox, crs)); } @Override @@ -1101,6 +1095,11 @@ public Optional getTemporalExtent(String typeName) { return Optional.empty(); } + Optional configured = getConfiguredTemporalExtent(typeName); + if (configured.isPresent()) { + return configured; + } + String[] cacheKey = {typeName, "stats", "temporal"}; String cacheValidator = getData().getStableHash(); diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/AbstractFeatureProvider.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/AbstractFeatureProvider.java index 076431621..4835716b9 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/AbstractFeatureProvider.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/AbstractFeatureProvider.java @@ -16,7 +16,9 @@ import de.ii.xtraplatform.base.domain.resiliency.Volatile2; import de.ii.xtraplatform.base.domain.resiliency.VolatileRegistry; import de.ii.xtraplatform.codelists.domain.Codelist; +import de.ii.xtraplatform.crs.domain.BoundingBox; import de.ii.xtraplatform.crs.domain.CrsInfo; +import de.ii.xtraplatform.crs.domain.CrsTransformationException; import de.ii.xtraplatform.crs.domain.CrsTransformerFactory; import de.ii.xtraplatform.crs.domain.EpsgCrs; import de.ii.xtraplatform.crs.domain.OgcCrs; @@ -39,6 +41,9 @@ import de.ii.xtraplatform.streams.domain.Reactive.Stream; import de.ii.xtraplatform.values.domain.Values; import java.io.IOException; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.util.EnumSet; import java.util.HashMap; import java.util.List; @@ -56,6 +61,7 @@ import javax.annotation.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.threeten.extra.Interval; public abstract class AbstractFeatureProvider< T, U, V extends FeatureProviderConnector.QueryOptions, W extends SchemaBase> @@ -543,6 +549,72 @@ protected Query preprocessQuery(Query query) { return query; } + protected Optional getConfiguredSpatialExtent(String typeName) { + Optional fromType = + Optional.ofNullable(getData().getTypes().get(typeName)) + .flatMap(FeatureSchema::getExtent) + .flatMap(FeatureTypeExtent::getSpatial); + Optional fromProvider = + getData().getExtent().flatMap(FeatureTypeExtent::getSpatial); + + return fromType + .or(() -> fromProvider) + .filter(extent -> !Boolean.TRUE.equals(extent.getComputed())) + .flatMap(extent -> extent.toBoundingBox(getData().getNativeCrs().orElse(OgcCrs.CRS84))); + } + + protected Optional getConfiguredTemporalExtent(String typeName) { + Optional fromType = + Optional.ofNullable(getData().getTypes().get(typeName)) + .flatMap(FeatureSchema::getExtent) + .flatMap(FeatureTypeExtent::getTemporal); + Optional fromProvider = + getData().getExtent().flatMap(FeatureTypeExtent::getTemporal); + + return fromType + .or(() -> fromProvider) + .filter(extent -> !Boolean.TRUE.equals(extent.getComputed())) + .flatMap( + extent -> { + if (extent.getStart() == null && extent.getEnd() == null) { + return Optional.empty(); + } + OffsetDateTime start = + extent.getStart() != null + ? parseConfiguredTemporalBound(extent.getStart(), false) + : OffsetDateTime.parse("0001-01-01T00:00:00Z"); + OffsetDateTime end = + extent.getEnd() != null + ? parseConfiguredTemporalBound(extent.getEnd(), true) + : OffsetDateTime.parse("9999-12-31T23:59:59Z"); + return Optional.of(Interval.of(start.toInstant(), end.toInstant())); + }); + } + + protected Optional transformSpatialExtent( + BoundingBox boundingBox, EpsgCrs targetCrs) { + return crsTransformerFactory + .getTransformer(getData().getNativeCrs().orElse(OgcCrs.CRS84), targetCrs, false) + .flatMap( + crsTransformer -> { + try { + return Optional.of(crsTransformer.transformBoundingBox(boundingBox)); + } catch (CrsTransformationException e) { + return Optional.empty(); + } + }); + } + + private static OffsetDateTime parseConfiguredTemporalBound(String value, boolean endOfDay) { + if (value.contains("T")) { + return OffsetDateTime.parse(value); + } + + return endOfDay + ? LocalDate.parse(value).atTime(23, 59, 59).atOffset(ZoneOffset.UTC) + : LocalDate.parse(value).atStartOfDay().atOffset(ZoneOffset.UTC); + } + @Override public FeatureChanges changes() { return changeHandler; diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureProviderDataV2.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureProviderDataV2.java index 454670de1..968b78c87 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureProviderDataV2.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureProviderDataV2.java @@ -198,6 +198,14 @@ default List getCql2Functions() { @Override Optional getAuto(); + /** + * @langEn Optional spatial and temporal extent for all types in this provider. If set, disables + * automatic calculation. + * @langDe Optionaler räumlicher und zeitlicher Extent für alle Types dieses Providers. Wenn + * gesetzt, wird keine automatische Berechnung durchgeführt. + */ + Optional getExtent(); + // custom builder to automatically use keys of types as name of FeatureTypeV2 abstract class Builder> implements EntityDataBuilder { diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureSchema.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureSchema.java index 93cba3fe9..4008bb5ac 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureSchema.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureSchema.java @@ -239,6 +239,12 @@ default Type getType() { */ Optional getDescription(); + /** + * @langEn Optional spatial and temporal extent for this feature type. + * @langDe Optionaler räumlicher und zeitlicher Extent für diesen Feature-Type. + */ + Optional getExtent(); + /** * @langEn The unit of measurement of the value, only relevant for numeric properties. * @langDe Die Maßeinheit des Wertes, nur relevant bei numerischen Eigenschaften. diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTypeConfiguration.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTypeConfiguration.java index 968af0820..6d0c2f816 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTypeConfiguration.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTypeConfiguration.java @@ -35,4 +35,12 @@ public interface FeatureTypeConfiguration { * @default "" */ Optional getDescription(); + + /** + * @langEn Optional spatial and temporal extent for this type. If set, disables automatic + * calculation for this type. + * @langDe Optionaler räumlicher und zeitlicher Extent für diesen Type. Wenn gesetzt, wird keine + * automatische Berechnung für diesen Type durchgeführt. + */ + Optional getExtent(); } diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTypeExtent.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTypeExtent.java new file mode 100644 index 000000000..bfe1a1110 --- /dev/null +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTypeExtent.java @@ -0,0 +1,21 @@ +/* + * Copyright 2026 interactive instruments GmbH + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package de.ii.xtraplatform.features.domain; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import java.util.Optional; +import org.immutables.value.Value; + +/** Extent object for spatial and temporal extents. */ +@Value.Immutable +@JsonDeserialize(builder = ImmutableFeatureTypeExtent.Builder.class) +public interface FeatureTypeExtent { + Optional getSpatial(); + + Optional getTemporal(); +} diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SpatialExtent.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SpatialExtent.java new file mode 100644 index 000000000..23ae71f1e --- /dev/null +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/SpatialExtent.java @@ -0,0 +1,83 @@ +/* + * Copyright 2026 interactive instruments GmbH + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package de.ii.xtraplatform.features.domain; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.google.common.base.Preconditions; +import de.ii.xtraplatform.crs.domain.BoundingBox; +import de.ii.xtraplatform.crs.domain.EpsgCrs; +import java.util.Optional; +import javax.annotation.Nullable; +import org.immutables.value.Value; + +/** Spatial extent in native CRS. */ +@Value.Immutable +@JsonDeserialize(builder = ImmutableSpatialExtent.Builder.class) +public interface SpatialExtent { + + @Nullable + Double getXmin(); + + @Nullable + Double getYmin(); + + @Nullable + Double getZmin(); + + @Nullable + Double getXmax(); + + @Nullable + Double getYmax(); + + @Nullable + Double getZmax(); + + @Nullable + Boolean getComputed(); + + @Value.Check + default void checkExclusiveComputed() { + boolean hasCoordinates = + getXmin() != null + || getYmin() != null + || getZmin() != null + || getXmax() != null + || getYmax() != null + || getZmax() != null; + boolean autoCompute = Boolean.TRUE.equals(getComputed()); + + Preconditions.checkState( + !(hasCoordinates && autoCompute), + "SpatialExtent: 'computed' and explicit coordinates must not be set at the same time."); + + if (hasCoordinates) { + Preconditions.checkState( + getXmin() != null && getYmin() != null && getXmax() != null && getYmax() != null, + "SpatialExtent: xmin, ymin, xmax, ymax are required when coordinates are used."); + + Preconditions.checkState( + (getZmin() == null && getZmax() == null) || (getZmin() != null && getZmax() != null), + "SpatialExtent: zmin and zmax must both be set or both be absent."); + } + } + + default Optional toBoundingBox(EpsgCrs nativeCrs) { + if (getXmin() == null || getYmin() == null || getXmax() == null || getYmax() == null) { + return Optional.empty(); + } + + if (getZmin() != null && getZmax() != null) { + return Optional.of( + BoundingBox.of( + getXmin(), getYmin(), getZmin(), getXmax(), getYmax(), getZmax(), nativeCrs)); + } + + return Optional.of(BoundingBox.of(getXmin(), getYmin(), getXmax(), getYmax(), nativeCrs)); + } +} diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/TemporalExtent.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/TemporalExtent.java new file mode 100644 index 000000000..7fa599d5e --- /dev/null +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/TemporalExtent.java @@ -0,0 +1,87 @@ +/* + * Copyright 2026 interactive instruments GmbH + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package de.ii.xtraplatform.features.domain; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.google.common.base.Preconditions; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import javax.annotation.Nullable; +import org.immutables.value.Value; + +/** Temporal extent with UTC ISO-8601 instants (yyyy-MM-ddTHH:mm:ss.SSSZ). */ +@Value.Immutable +@JsonDeserialize(builder = ImmutableTemporalExtent.Builder.class) +public interface TemporalExtent { + + /** + * Start of the temporal extent as UTC ISO-8601 instant (yyyy-MM-ddTHH:mm:ss[.SSS]Z), or {@code + * null} for open start. + */ + @Nullable + String getStart(); + + /** + * End of the temporal extent as UTC ISO-8601 instant (yyyy-MM-ddTHH:mm:ss[.SSS]Z), or {@code + * null} for open end. + */ + @Nullable + String getEnd(); + + @Nullable + Boolean getComputed(); + + @Value.Derived + @JsonIgnore + default Instant getStartInstant() { + return getStart() == null + ? null + : Instant.from(DateTimeFormatter.ISO_INSTANT.parse(getStart())); + } + + @Value.Derived + @JsonIgnore + default Instant getEndInstant() { + return getEnd() == null ? null : Instant.from(DateTimeFormatter.ISO_INSTANT.parse(getEnd())); + } + + @Value.Check + default void checkExclusiveComputed() { + boolean hasBounds = getStart() != null || getEnd() != null; + boolean autoCompute = Boolean.TRUE.equals(getComputed()); + + Preconditions.checkState( + !(hasBounds && autoCompute), + "TemporalExtent: 'computed' and explicit start/end must not be set at the same time."); + + if (getStart() != null) { + validateIsoInstant(getStart(), "start"); + } + if (getEnd() != null) { + validateIsoInstant(getEnd(), "end"); + } + } + + private static void validateIsoInstant(String value, String field) { + Preconditions.checkState( + !value.matches("^-?\\d+$"), + "TemporalExtent: '%s' must be a UTC ISO-8601 instant string, not a numeric timestamp.", + field); + try { + DateTimeFormatter.ISO_INSTANT.parse(value); + } catch (DateTimeParseException e) { + Preconditions.checkState( + false, + "TemporalExtent: '%s' is not a valid UTC ISO-8601 instant (yyyy-MM-ddTHH:mm:ss.SSSZ): %s", + field, + value); + } + } +} diff --git a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/TileProviderFeatures.java b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/TileProviderFeatures.java index 329ff3f32..b1accc13a 100644 --- a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/TileProviderFeatures.java +++ b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/TileProviderFeatures.java @@ -26,6 +26,8 @@ import de.ii.xtraplatform.crs.domain.BoundingBox; import de.ii.xtraplatform.crs.domain.CrsInfo; import de.ii.xtraplatform.crs.domain.CrsTransformerFactory; +import de.ii.xtraplatform.crs.domain.EpsgCrs; +import de.ii.xtraplatform.crs.domain.OgcCrs; import de.ii.xtraplatform.entities.domain.Entity; import de.ii.xtraplatform.entities.domain.Entity.SubType; import de.ii.xtraplatform.entities.domain.EntityRegistry; @@ -37,6 +39,7 @@ import de.ii.xtraplatform.features.domain.FeatureProvider.FeatureVolatileCapability; import de.ii.xtraplatform.features.domain.FeatureSchema; import de.ii.xtraplatform.features.domain.ProviderData; +import de.ii.xtraplatform.features.domain.SpatialExtent; import de.ii.xtraplatform.jobs.domain.JobQueue; import de.ii.xtraplatform.tiles.domain.Cache; import de.ii.xtraplatform.tiles.domain.Cache.Storage; @@ -999,20 +1002,30 @@ private TilesetMetadata loadMetadata(TilesetFeatures tileset) { getLayers(tileset).stream() .map(id -> tileGenerator.getVectorSchema(id, FeatureEncoderMVT.FORMAT)) .collect(Collectors.toList()); - Optional bounds = - getLayers(tileset).stream() - .map(tileGenerator::getBounds) - .reduce( - Optional.empty(), - (a, b) -> { - if (b.isEmpty()) { - return a; - } - if (a.isPresent()) { - return Optional.of(BoundingBox.merge(b.get(), a.get())); - } - return b; - }); + + Optional configuredExtent = + toBoundingBox( + tileset.getExtent().or(() -> getData().getTilesetDefaults().getExtent()), tileset); + + Optional bounds; + if (configuredExtent.isPresent()) { + bounds = configuredExtent; + } else { + bounds = + getLayers(tileset).stream() + .map(tileGenerator::getBounds) + .reduce( + Optional.empty(), + (a, b) -> { + if (b.isEmpty()) { + return a; + } + if (a.isPresent()) { + return Optional.of(BoundingBox.merge(b.get(), a.get())); + } + return b; + }); + } return ImmutableTilesetMetadata.builder() .addEncodings(TilesFormat.MVT) @@ -1124,4 +1137,40 @@ private List getFeatureProviders() { private String getFeatureProviderId(TilesetFeatures tileset) { return tileset.getFeatureProvider().orElse(TileProviderFeatures.clean(getData().getId())); } + + private Optional toBoundingBox( + Optional extent, TilesetFeatures tileset) { + EpsgCrs nativeCrs = resolveNativeCrs(tileset); + return extent + .filter(e -> e.getComputed() == null) + .flatMap( + e -> { + if (e.getXmin() == null + || e.getYmin() == null + || e.getXmax() == null + || e.getYmax() == null) { + return Optional.empty(); + } + if (e.getZmin() != null && e.getZmax() != null) { + return Optional.of( + BoundingBox.of( + e.getXmin(), + e.getYmin(), + e.getZmin(), + e.getXmax(), + e.getYmax(), + e.getZmax(), + nativeCrs)); + } + return Optional.of( + BoundingBox.of(e.getXmin(), e.getYmin(), e.getXmax(), e.getYmax(), nativeCrs)); + }); + } + + private EpsgCrs resolveNativeCrs(TilesetFeatures tileset) { + return tileGenerator + .getFeatureProvider(getFeatureProviderId(tileset)) + .map(provider -> provider.crs().get().getNativeCrs()) + .orElse(OgcCrs.CRS84); + } } diff --git a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/TileProviderHttp.java b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/TileProviderHttp.java index 8b924f3c6..6daa17789 100644 --- a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/TileProviderHttp.java +++ b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/TileProviderHttp.java @@ -11,9 +11,12 @@ import dagger.assisted.Assisted; import dagger.assisted.AssistedInject; import de.ii.xtraplatform.base.domain.resiliency.VolatileRegistry; +import de.ii.xtraplatform.crs.domain.BoundingBox; +import de.ii.xtraplatform.crs.domain.OgcCrs; import de.ii.xtraplatform.entities.domain.Entity; import de.ii.xtraplatform.entities.domain.Entity.SubType; import de.ii.xtraplatform.features.domain.ProviderData; +import de.ii.xtraplatform.features.domain.SpatialExtent; import de.ii.xtraplatform.tiles.domain.ChainedTileProvider; import de.ii.xtraplatform.tiles.domain.ImmutableTilesetMetadata; import de.ii.xtraplatform.tiles.domain.TileAccess; @@ -117,6 +120,9 @@ private void loadMetadata() { } private TilesetMetadata loadMetadata(TilesetHttp tileset) { + Optional bounds = + toBoundingBox(tileset.getExtent().or(() -> getData().getTilesetDefaults().getExtent())); + return ImmutableTilesetMetadata.builder() .encodings( tileset.getEncodings().isEmpty() @@ -127,6 +133,34 @@ private TilesetMetadata loadMetadata(TilesetHttp tileset) { ? getData().getTilesetDefaults().getLevels() : tileset.getLevels()) .center(tileset.getCenter().or(() -> getData().getTilesetDefaults().getCenter())) + .bounds(bounds) .build(); } + + private Optional toBoundingBox(Optional extent) { + return extent + .filter(e -> e.getComputed() == null) + .flatMap( + e -> { + if (e.getXmin() == null + || e.getYmin() == null + || e.getXmax() == null + || e.getYmax() == null) { + return Optional.empty(); + } + if (e.getZmin() != null && e.getZmax() != null) { + return Optional.of( + BoundingBox.of( + e.getXmin(), + e.getYmin(), + e.getZmin(), + e.getXmax(), + e.getYmax(), + e.getZmax(), + OgcCrs.CRS84)); + } + return Optional.of( + BoundingBox.of(e.getXmin(), e.getYmin(), e.getXmax(), e.getYmax(), OgcCrs.CRS84)); + }); + } } diff --git a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/TileProviderMbTiles.java b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/TileProviderMbTiles.java index d0c33dc53..a9c278e9d 100644 --- a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/TileProviderMbTiles.java +++ b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/app/TileProviderMbTiles.java @@ -20,6 +20,7 @@ import de.ii.xtraplatform.entities.domain.Entity.SubType; import de.ii.xtraplatform.features.domain.FeatureSchema; import de.ii.xtraplatform.features.domain.ProviderData; +import de.ii.xtraplatform.features.domain.SpatialExtent; import de.ii.xtraplatform.tiles.domain.ChainedTileProvider; import de.ii.xtraplatform.tiles.domain.ImmutableMinMax; import de.ii.xtraplatform.tiles.domain.ImmutableTilesetMetadata; @@ -177,12 +178,23 @@ private void loadMetadata(Map tilesetSources) { (key, path) -> { Tuple tilesetKey = toTuple(key); - metadata.put(tilesetKey.first(), loadMetadata(tilesetKey.second(), path)); + TilesetMbTiles tileset = + getData() + .getTilesets() + .get(tilesetKey.first()) + .mergeDefaults(getData().getTilesetDefaults()); + Optional configuredExtent = + toBoundingBox( + tileset.getExtent().or(() -> getData().getTilesetDefaults().getExtent())); + + metadata.put( + tilesetKey.first(), loadMetadata(tilesetKey.second(), path, configuredExtent)); tmsRanges.put(tilesetKey.first(), metadata.get(tilesetKey.first()).getTmsRanges()); }); } - private TilesetMetadata loadMetadata(String tms, Path path) { + private TilesetMetadata loadMetadata( + String tms, Path path, Optional configuredExtent) { try { MbtilesMetadata metadata = new MbtilesTileset(path, false).getMetadata(); TileMatrixSet tileMatrixSet = @@ -210,11 +222,14 @@ private TilesetMetadata loadMetadata(String tms, Path path) { tms, new ImmutableMinMax.Builder().min(minzoom).max(maxzoom).getDefault(defzoom).build()); List bbox = metadata.getBounds(); - Optional bounds = + Optional boundsFromMetadata = bbox.size() == 4 ? Optional.of( BoundingBox.of(bbox.get(0), bbox.get(1), bbox.get(2), bbox.get(3), OgcCrs.CRS84)) : Optional.empty(); + // Configured extent takes priority; fall back to bounds from MBTiles metadata + Optional bounds = + configuredExtent.isPresent() ? configuredExtent : boundsFromMetadata; TilesFormat format = metadata.getFormat(); List vectorSchemas = metadata.getVectorLayers().stream() @@ -241,4 +256,31 @@ private Tuple toTuple(String tilesetKey) { String[] split = tilesetKey.split("/"); return Tuple.of(split[0], split[1]); } + + private Optional toBoundingBox(Optional extent) { + return extent + .filter(e -> e.getComputed() == null) + .flatMap( + e -> { + if (e.getXmin() == null + || e.getYmin() == null + || e.getXmax() == null + || e.getYmax() == null) { + return Optional.empty(); + } + if (e.getZmin() != null && e.getZmax() != null) { + return Optional.of( + BoundingBox.of( + e.getXmin(), + e.getYmin(), + e.getZmin(), + e.getXmax(), + e.getYmax(), + e.getZmax(), + OgcCrs.CRS84)); + } + return Optional.of( + BoundingBox.of(e.getXmin(), e.getYmin(), e.getXmax(), e.getYmax(), OgcCrs.CRS84)); + }); + } } diff --git a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TilesetCommon.java b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TilesetCommon.java index 870bdeecb..49ca3bb45 100644 --- a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TilesetCommon.java +++ b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TilesetCommon.java @@ -8,6 +8,7 @@ package de.ii.xtraplatform.tiles.domain; import de.ii.xtraplatform.entities.domain.maptobuilder.BuildableMap; +import de.ii.xtraplatform.features.domain.SpatialExtent; import de.ii.xtraplatform.tiles.domain.ImmutableMinMax.Builder; import java.util.Optional; @@ -24,4 +25,10 @@ public interface TilesetCommon extends TilesetCommonDefaults { @Override Optional getCenter(); + + /** + * @langEn Optional fixed spatial extent for this tileset in native CRS. + * @langDe Optionaler fester räumlicher Extent für dieses Tileset im nativen CRS. + */ + Optional getExtent(); } diff --git a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TilesetFeaturesDefaults.java b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TilesetFeaturesDefaults.java index f650d3f42..2941a408f 100644 --- a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TilesetFeaturesDefaults.java +++ b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TilesetFeaturesDefaults.java @@ -11,6 +11,8 @@ import de.ii.xtraplatform.entities.domain.maptobuilder.Buildable; import de.ii.xtraplatform.entities.domain.maptobuilder.BuildableBuilder; import de.ii.xtraplatform.entities.domain.maptobuilder.BuildableMap; +import de.ii.xtraplatform.features.domain.SpatialExtent; +import java.util.Optional; import org.immutables.value.Value; /** @@ -35,4 +37,10 @@ default ImmutableTilesetFeaturesDefaults.Builder getBuilder() { } abstract class Builder implements BuildableBuilder {} + + /** + * @langEn Optional fixed spatial extent for this tileset in native CRS. + * @langDe Optionaler fester räumlicher Extent für dieses Tileset im nativen CRS. + */ + Optional getExtent(); } diff --git a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TilesetHttpDefaults.java b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TilesetHttpDefaults.java index acd094cd5..9c9250e94 100644 --- a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TilesetHttpDefaults.java +++ b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TilesetHttpDefaults.java @@ -11,6 +11,8 @@ import de.ii.xtraplatform.entities.domain.maptobuilder.Buildable; import de.ii.xtraplatform.entities.domain.maptobuilder.BuildableBuilder; import de.ii.xtraplatform.entities.domain.maptobuilder.BuildableMap; +import de.ii.xtraplatform.features.domain.SpatialExtent; +import java.util.Optional; import org.immutables.value.Value; /** @@ -31,5 +33,11 @@ default ImmutableTilesetHttpDefaults.Builder getBuilder() { return new ImmutableTilesetHttpDefaults.Builder().from(this); } + /** + * @langEn Optional fixed spatial extent for this tileset in native CRS. + * @langDe Optionaler fester räumlicher Extent für dieses Tileset im nativen CRS. + */ + Optional getExtent(); + abstract class Builder implements BuildableBuilder {} } diff --git a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TilesetMbTilesDefaults.java b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TilesetMbTilesDefaults.java index ab183c13f..1ccdcff11 100644 --- a/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TilesetMbTilesDefaults.java +++ b/xtraplatform-tiles/src/main/java/de/ii/xtraplatform/tiles/domain/TilesetMbTilesDefaults.java @@ -10,6 +10,7 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import de.ii.xtraplatform.docs.DocIgnore; import de.ii.xtraplatform.entities.domain.maptobuilder.BuildableMap; +import de.ii.xtraplatform.features.domain.SpatialExtent; import de.ii.xtraplatform.tiles.domain.ImmutableMinMax.Builder; import java.util.Optional; import org.immutables.value.Value; @@ -36,4 +37,10 @@ public interface TilesetMbTilesDefaults extends TilesetCommonDefaults { default String getTileMatrixSet() { return "WebMercatorQuad"; } + + /** + * @langEn Optional fixed spatial extent for this tileset in native CRS. + * @langDe Optionaler fester räumlicher Extent für dieses Tileset im nativen CRS. + */ + Optional getExtent(); }