diff --git a/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/domain/CrsTransformerFactory.java b/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/domain/CrsTransformerFactory.java index 9ccc28983..3f878c9c2 100644 --- a/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/domain/CrsTransformerFactory.java +++ b/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/domain/CrsTransformerFactory.java @@ -20,4 +20,25 @@ public interface CrsTransformerFactory extends Volatile2 { Optional getTransformer(EpsgCrs sourceCrs, EpsgCrs targetCrs); Optional getTransformer(EpsgCrs sourceCrs, EpsgCrs targetCrs, boolean force2d); + + /** + * Returns a transformer that is selected for an area of interest. When several coordinate + * operations between the two coordinate reference systems are available, the most accurate one + * whose area of use contains the area of interest is chosen, e.g. a regional grid instead of a + * national one. Without an area of interest, or with an area that covers the whole world, the + * selection is the same as for {@link #getTransformer(EpsgCrs, EpsgCrs, boolean)}. + * + * @param areaOfInterest the area of interest, a box in any supported coordinate reference system + */ + Optional getTransformer( + EpsgCrs sourceCrs, EpsgCrs targetCrs, boolean force2d, Optional areaOfInterest); + + /** + * Returns a view of this factory that selects all transformers for the given area of interest, + * see {@link #getTransformer(EpsgCrs, EpsgCrs, boolean, Optional)}. All other methods delegate to + * this factory. If this factory also implements {@link CrsInfo}, so does the view. + */ + default CrsTransformerFactory withAreaOfInterest(BoundingBox areaOfInterest) { + return CrsTransformerFactoryForArea.of(this, areaOfInterest); + } } diff --git a/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/domain/CrsTransformerFactoryForArea.java b/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/domain/CrsTransformerFactoryForArea.java new file mode 100644 index 000000000..3bfbd5d26 --- /dev/null +++ b/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/domain/CrsTransformerFactoryForArea.java @@ -0,0 +1,153 @@ +/* + * 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.crs.domain; + +import de.ii.xtraplatform.base.domain.resiliency.VolatileRegistry.ChangeHandler; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalInt; +import javax.measure.Unit; +import org.opengis.referencing.cs.AxisDirection; + +/** + * A view of a {@link CrsTransformerFactory} that selects every transformer for a fixed area of + * interest. The view is not registered as a volatile itself, the delegate is. + */ +class CrsTransformerFactoryForArea implements CrsTransformerFactory { + + private final CrsTransformerFactory delegate; + private final Optional areaOfInterest; + + CrsTransformerFactoryForArea(CrsTransformerFactory delegate, BoundingBox areaOfInterest) { + this.delegate = Objects.requireNonNull(delegate); + this.areaOfInterest = Optional.of(areaOfInterest); + } + + static CrsTransformerFactory of(CrsTransformerFactory delegate, BoundingBox areaOfInterest) { + return delegate instanceof CrsInfo + ? new WithCrsInfo(delegate, areaOfInterest) + : new CrsTransformerFactoryForArea(delegate, areaOfInterest); + } + + @Override + public boolean isSupported(EpsgCrs crs) { + return delegate.isSupported(crs); + } + + @Override + public Optional getTransformer(EpsgCrs sourceCrs, EpsgCrs targetCrs) { + return delegate.getTransformer(sourceCrs, targetCrs, false, areaOfInterest); + } + + @Override + public Optional getTransformer( + EpsgCrs sourceCrs, EpsgCrs targetCrs, boolean force2d) { + return delegate.getTransformer(sourceCrs, targetCrs, force2d, areaOfInterest); + } + + /** An explicit area of interest takes precedence over the area of this view. */ + @Override + public Optional getTransformer( + EpsgCrs sourceCrs, EpsgCrs targetCrs, boolean force2d, Optional areaOfInterest) { + return delegate.getTransformer( + sourceCrs, targetCrs, force2d, areaOfInterest.or(() -> this.areaOfInterest)); + } + + @Override + public CrsTransformerFactory withAreaOfInterest(BoundingBox areaOfInterest) { + return of(delegate, areaOfInterest); + } + + @Override + public String getUniqueKey() { + return delegate.getUniqueKey(); + } + + @Override + public Optional getInstanceId() { + return delegate.getInstanceId(); + } + + @Override + public State getState() { + return delegate.getState(); + } + + @Override + public Optional getMessage() { + return delegate.getMessage(); + } + + @Override + public Runnable onStateChange(ChangeHandler handler, boolean initialCall) { + return delegate.onStateChange(handler, initialCall); + } + + /** The view for a delegate that also provides {@link CrsInfo}. */ + static final class WithCrsInfo extends CrsTransformerFactoryForArea implements CrsInfo { + + private final CrsInfo crsInfo; + + WithCrsInfo(CrsTransformerFactory delegate, BoundingBox areaOfInterest) { + super(delegate, areaOfInterest); + this.crsInfo = (CrsInfo) delegate; + } + + @Override + public boolean is3d(EpsgCrs crs) { + return crsInfo.is3d(crs); + } + + @Override + public Unit getUnit(EpsgCrs crs) { + return crsInfo.getUnit(crs); + } + + @Override + public List getAxisAbbreviations(EpsgCrs crs) { + return crsInfo.getAxisAbbreviations(crs); + } + + @Override + public List> getAxisUnits(EpsgCrs crs) { + return crsInfo.getAxisUnits(crs); + } + + @Override + public List> getAxisMinimums(EpsgCrs crs) { + return crsInfo.getAxisMinimums(crs); + } + + @Override + public List> getAxisMaximums(EpsgCrs crs) { + return crsInfo.getAxisMaximums(crs); + } + + @Override + public OptionalInt getAxisWithWraparound(EpsgCrs crs) { + return crsInfo.getAxisWithWraparound(crs); + } + + @Override + public List getAxisDirections(EpsgCrs crs) { + return crsInfo.getAxisDirections(crs); + } + + @Override + public Optional getDomainOfValidity(EpsgCrs crs) { + return crsInfo.getDomainOfValidity(crs); + } + + @Override + public List getPrecisionList(EpsgCrs crs, Map coordinatePrecision) { + return crsInfo.getPrecisionList(crs, coordinatePrecision); + } + } +} diff --git a/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/infra/CrsTransformerFactoryProj.java b/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/infra/CrsTransformerFactoryProj.java index 60a63f15d..50688b930 100644 --- a/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/infra/CrsTransformerFactoryProj.java +++ b/xtraplatform-crs/src/main/java/de/ii/xtraplatform/crs/infra/CrsTransformerFactoryProj.java @@ -18,6 +18,7 @@ import de.ii.xtraplatform.blobs.domain.ResourceStore; 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.CrsTransformer; import de.ii.xtraplatform.crs.domain.CrsTransformerFactory; import de.ii.xtraplatform.crs.domain.EpsgCrs; @@ -86,8 +87,7 @@ public class CrsTransformerFactoryProj extends AbstractVolatile private final ProjLoader projLoader; private final ResourceStore projStore; private final Map crsCache; - private final Map> transformerCache; - private final Map> transformerCacheForce2d; + private final Map transformerCache; private final Lock projLock; private final boolean useCaches; private final boolean asyncStartup; @@ -103,7 +103,6 @@ public CrsTransformerFactoryProj( this.projStore = resourceStore.with("proj"); this.crsCache = new ConcurrentHashMap<>(); this.transformerCache = new ConcurrentHashMap<>(); - this.transformerCacheForce2d = new ConcurrentHashMap<>(); this.projLock = new ReentrantLock(); this.useCaches = true; this.asyncStartup = appContext.getConfiguration().getModules().isStartupAsync(); @@ -117,7 +116,6 @@ public CrsTransformerFactoryProj( this.projStore = resourceStore.with("proj"); this.crsCache = new ConcurrentHashMap<>(); this.transformerCache = new ConcurrentHashMap<>(); - this.transformerCacheForce2d = new ConcurrentHashMap<>(); this.projLock = new ReentrantLock(); this.useCaches = true; this.asyncStartup = false; @@ -355,6 +353,12 @@ public Optional getTransformer(EpsgCrs sourceCrs, EpsgCrs target @Override public Optional getTransformer( EpsgCrs sourceCrs, EpsgCrs targetCrs, boolean force2d) { + return getTransformer(sourceCrs, targetCrs, force2d, Optional.empty()); + } + + @Override + public Optional getTransformer( + EpsgCrs sourceCrs, EpsgCrs targetCrs, boolean force2d, Optional areaOfInterest) { if (Objects.equals(sourceCrs, targetCrs)) { return Optional.empty(); } @@ -366,21 +370,58 @@ public Optional getTransformer( targetProjCrs = getHorizontalCrs(targetProjCrs); } + Optional area = toGeographicArea(areaOfInterest); CoordinateReferenceSystem finalSourceProjCrs = sourceProjCrs; CoordinateReferenceSystem finalTargetProjCrs = targetProjCrs; CrsTransformer transformer = useCaches - ? getCacheForSource(sourceCrs, force2d) - .computeIfAbsent( - targetCrs, - ignore -> - createCrsTransformer( - sourceCrs, targetCrs, finalSourceProjCrs, finalTargetProjCrs)) - : createCrsTransformer(sourceCrs, targetCrs, sourceProjCrs, targetProjCrs); + ? transformerCache.computeIfAbsent( + new TransformerKey(sourceCrs, targetCrs, force2d, area), + ignore -> + createCrsTransformer( + sourceCrs, targetCrs, finalSourceProjCrs, finalTargetProjCrs, area)) + : createCrsTransformer(sourceCrs, targetCrs, sourceProjCrs, targetProjCrs, area); return Optional.of(transformer); } + /** + * Converts an area of interest to a 2D box in CRS84. An area that covers the whole world does not + * restrict the selection of operations and is dropped, so it shares the cache entry and the + * operation of a request without an area. + */ + private Optional toGeographicArea(Optional areaOfInterest) { + if (areaOfInterest.isEmpty()) { + return Optional.empty(); + } + + BoundingBox area = areaOfInterest.get(); + if (!OgcCrs.CRS84.equals(area.getEpsgCrs()) && !OgcCrs.CRS84h.equals(area.getEpsgCrs())) { + try { + Optional toCrs84 = + getTransformer(area.getEpsgCrs(), OgcCrs.CRS84, true, Optional.empty()); + if (toCrs84.isPresent()) { + area = toCrs84.get().transformBoundingBox(area); + } + } catch (CrsTransformationException e) { + throw new IllegalArgumentException( + String.format("The area of interest cannot be converted to CRS84: %s", e.getMessage()), + e); + } + } + + if (area.getXmin() <= -180 + && area.getYmin() <= -90 + && area.getXmax() >= 180 + && area.getYmax() >= 90) { + return Optional.empty(); + } + + return Optional.of( + BoundingBox.of( + area.getXmin(), area.getYmin(), area.getXmax(), area.getYmax(), OgcCrs.CRS84)); + } + @Override public List getPrecisionList(EpsgCrs crs, Map coordinatePrecision) { ImmutableList.Builder precisionListBuilder = new ImmutableList.Builder<>(); @@ -543,18 +584,13 @@ private CoordinateReferenceSystem applyAxisOrder( return crs; } - private Map getCacheForSource(EpsgCrs crs, boolean force2d) { - return force2d - ? transformerCacheForce2d.computeIfAbsent(crs, ignore -> new ConcurrentHashMap<>()) - : transformerCache.computeIfAbsent(crs, ignore -> new ConcurrentHashMap<>()); - } - @SuppressWarnings("PMD.CyclomaticComplexity") private CrsTransformer createCrsTransformer( EpsgCrs sourceCrs, EpsgCrs targetCrs, CoordinateReferenceSystem sourceProjCrs, - CoordinateReferenceSystem targetProjCrs) { + CoordinateReferenceSystem targetProjCrs, + Optional areaOfInterest) { projLock.lock(); try { @@ -568,12 +604,28 @@ private CrsTransformer createCrsTransformer( coordinateOperationContext.setSpatialCriterion(SpatialCriterion.PARTIAL_INTERSECTION); coordinateOperationContext.setGridAvailabilityUse( GridAvailabilityUse.DISCARD_OPERATION_IF_MISSING_GRID); + areaOfInterest.ifPresent( + area -> + coordinateOperationContext.setAreaOfInterest( + area.getXmin(), area.getXmax(), area.getYmin(), area.getYmax())); CoordinateOperation coordinateOperation = Proj.createCoordinateOperation(sourceProjCrs, targetProjCrs, coordinateOperationContext); checkForMissingGridFiles(coordinateOperation); - if (LOGGER.isDebugEnabled()) { + if (areaOfInterest.isPresent() && LOGGER.isInfoEnabled()) { + BoundingBox area = areaOfInterest.get(); + LOGGER.info( + "Chosen operation for {} -> {} in area [{}, {}, {}, {}]: {}{}", + sourceCrs.toHumanReadableString(), + targetCrs.toHumanReadableString(), + area.getXmin(), + area.getYmin(), + area.getXmax(), + area.getYmax(), + coordinateOperation.getName().getCode(), + getGridFile(coordinateOperation).orElse("")); + } else if (LOGGER.isDebugEnabled()) { LOGGER.debug( "Chosen operation for {} -> {}: {}{}", sourceCrs.toHumanReadableString(), @@ -648,4 +700,43 @@ private Optional getGridFile(CoordinateOperation coordinateOperation) { } return Optional.empty(); } + + /** Cache key of a transformer; the area of interest is a 2D box in CRS84 or empty. */ + private static final class TransformerKey { + private final EpsgCrs sourceCrs; + private final EpsgCrs targetCrs; + private final boolean force2d; + private final Optional areaOfInterest; + + TransformerKey( + EpsgCrs sourceCrs, + EpsgCrs targetCrs, + boolean force2d, + Optional areaOfInterest) { + this.sourceCrs = sourceCrs; + this.targetCrs = targetCrs; + this.force2d = force2d; + this.areaOfInterest = areaOfInterest; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof TransformerKey)) { + return false; + } + TransformerKey that = (TransformerKey) o; + return force2d == that.force2d + && sourceCrs.equals(that.sourceCrs) + && targetCrs.equals(that.targetCrs) + && areaOfInterest.equals(that.areaOfInterest); + } + + @Override + public int hashCode() { + return Objects.hash(sourceCrs, targetCrs, force2d, areaOfInterest); + } + } } diff --git a/xtraplatform-crs/src/test/groovy/de/ii/xtraplatform/crs/infra/CrsTransformerAreaOfInterestSpec.groovy b/xtraplatform-crs/src/test/groovy/de/ii/xtraplatform/crs/infra/CrsTransformerAreaOfInterestSpec.groovy new file mode 100644 index 000000000..4c9eda303 --- /dev/null +++ b/xtraplatform-crs/src/test/groovy/de/ii/xtraplatform/crs/infra/CrsTransformerAreaOfInterestSpec.groovy @@ -0,0 +1,157 @@ +/* + * 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.crs.infra + +import de.ii.xtraplatform.base.domain.resiliency.VolatileRegistry +import de.ii.xtraplatform.blobs.domain.ResourceStore +import de.ii.xtraplatform.crs.domain.BoundingBox +import de.ii.xtraplatform.crs.domain.CrsInfo +import de.ii.xtraplatform.crs.domain.CrsTransformer +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.proj.domain.ProjLoaderImpl +import spock.lang.Shared +import spock.lang.Specification + +import java.nio.file.Path +import java.util.concurrent.CompletableFuture + +/** + * The PROJ data directory of the tests contains no grid files; the only grid is a cropped copy of + * HeTA2010 (DHDN to ETRS89 for Hessen) in the custom PROJ directory (test resources, `proj`). + * Without an area of interest, PROJ ranks a gridless Helmert transformation for the whole zone + * first; for an area inside Hessen, HeTA2010 is the most accurate operation. + */ +class CrsTransformerAreaOfInterestSpec extends Specification { + + static final EpsgCrs GK3 = EpsgCrs.of(5677) + static final EpsgCrs UTM32 = EpsgCrs.of(25832) + + // Frankfurt am Main in EPSG:5677, and in EPSG:25832 transformed with the full HeTA2010 grid + static final double[] FRANKFURT_GK3 = [3477335.33, 5552790.87] + static final double[] FRANKFURT_UTM32_HETA2010 = [477269.5063, 5551009.5743] + + // EPSG area of use of DHDN to ETRS89 (11), the operation with HeTA2010 + static final BoundingBox HESSEN = BoundingBox.of(7.77, 49.39, 10.24, 51.66, OgcCrs.CRS84) + + static final BoundingBox WORLD = BoundingBox.of(-180, -90, 180, 90, OgcCrs.CRS84) + + @Shared + CrsTransformerFactoryProj transformerFactory + + def setupSpec() { + Path gridDirectory = Path.of(getClass().getResource("/proj/de_hvbg_hessen_HeTA2010.tif").toURI()).getParent() + ResourceStore projStore = Stub() + projStore.asLocalPath(*_) >> Optional.of(gridDirectory) + ResourceStore resourceStore = Stub() + resourceStore.with(*_) >> projStore + VolatileRegistry volatileRegistry = Stub() + volatileRegistry.onAvailable(*_) >> CompletableFuture.completedFuture(null) + transformerFactory = new CrsTransformerFactoryProj(new ProjLoaderImpl(Path.of(System.getProperty("java.io.tmpdir"), "proj", "data")), resourceStore, volatileRegistry) + transformerFactory.onStart(false).toCompletableFuture().join() + } + + static double distanceToHeTA2010(CrsTransformer transformer) { + def result = transformer.transform(FRANKFURT_GK3[0], FRANKFURT_GK3[1]) + return Math.hypot(result.getX() - FRANKFURT_UTM32_HETA2010[0], result.getY() - FRANKFURT_UTM32_HETA2010[1]) + } + + def 'without an area of interest, HeTA2010 is not selected'() { + when: + CrsTransformer transformer = transformerFactory.getTransformer(GK3, UTM32).get() + + then: + distanceToHeTA2010(transformer) > 0.1 + } + + def 'an area of interest inside Hessen selects HeTA2010 - #name'() { + when: + CrsTransformer transformer = transformerFactory.getTransformer(GK3, UTM32, false, Optional.of(area)).get() + + then: + distanceToHeTA2010(transformer) < 0.001 + + where: + name | area + "area of use" | HESSEN + "Frankfurt" | BoundingBox.of(8.6, 50.1, 8.7, 50.2, OgcCrs.CRS84) + "Frankfurt in EPSG:25832" | BoundingBox.of(470000, 5550000, 480000, 5560000, UTM32) + } + + def 'an area of interest beyond the area of use does not select HeTA2010 - #name'() { + when: + CrsTransformer transformer = transformerFactory.getTransformer(GK3, UTM32, false, Optional.of(area)).get() + + then: + distanceToHeTA2010(transformer) > 0.1 + + where: + name | area + "0.02 degrees larger" | BoundingBox.of(7.70, 49.37, 10.26, 51.68, OgcCrs.CRS84) + "crossing the east" | BoundingBox.of(8.0, 50.0, 10.6, 51.0, OgcCrs.CRS84) + "whole world" | WORLD + } + + def 'the inverse transformation uses HeTA2010 for an area of interest inside Hessen'() { + when: + CrsTransformer transformer = transformerFactory.getTransformer(UTM32, GK3, false, Optional.of(HESSEN)).get() + def result = transformer.transform(FRANKFURT_UTM32_HETA2010[0], FRANKFURT_UTM32_HETA2010[1]) + + then: + Math.hypot(result.getX() - FRANKFURT_GK3[0], result.getY() - FRANKFURT_GK3[1]) < 0.001 + } + + def 'a whole-world area of interest shares the transformer of no area of interest'() { + when: + CrsTransformer world = transformerFactory.getTransformer(GK3, UTM32, false, Optional.of(WORLD)).get() + CrsTransformer none = transformerFactory.getTransformer(GK3, UTM32).get() + + then: + world.is(none) + } + + def 'transformers are cached per area of interest'() { + when: + CrsTransformer first = transformerFactory.getTransformer(GK3, UTM32, false, Optional.of(HESSEN)).get() + CrsTransformer second = transformerFactory.getTransformer(GK3, UTM32, false, Optional.of(HESSEN)).get() + CrsTransformer none = transformerFactory.getTransformer(GK3, UTM32).get() + + then: + first.is(second) + !first.is(none) + } + + def 'a factory view for an area of interest selects HeTA2010 and delegates everything else'() { + given: + CrsTransformerFactory view = transformerFactory.withAreaOfInterest(HESSEN) + + when: + CrsTransformer transformer = view.getTransformer(GK3, UTM32).get() + + then: + transformer.is(transformerFactory.getTransformer(GK3, UTM32, false, Optional.of(HESSEN)).get()) + distanceToHeTA2010(transformer) < 0.001 + view instanceof CrsInfo + ((CrsInfo) view).is3d(GK3) == transformerFactory.is3d(GK3) + view.isSupported(GK3) + view.getUniqueKey() == transformerFactory.getUniqueKey() + view.getState() == transformerFactory.getState() + } + + def 'an explicit area of interest takes precedence over the area of a factory view'() { + given: + CrsTransformerFactory view = transformerFactory.withAreaOfInterest(HESSEN) + + when: + CrsTransformer transformer = view.getTransformer(GK3, UTM32, false, Optional.of(WORLD)).get() + + then: + transformer.is(transformerFactory.getTransformer(GK3, UTM32).get()) + } +} diff --git a/xtraplatform-crs/src/test/resources/proj/README.md b/xtraplatform-crs/src/test/resources/proj/README.md new file mode 100644 index 000000000..9944cba5a --- /dev/null +++ b/xtraplatform-crs/src/test/resources/proj/README.md @@ -0,0 +1,11 @@ +# Test grid + +`de_hvbg_hessen_HeTA2010.tif` is a cropped copy (8.5–8.9 E, 50.0–50.3 N, around Frankfurt am +Main) of the HeTA2010 NTv2 grid for the transformation DHDN → ETRS89 in Hessen, as distributed in +GeoTIFF form by the PROJ data package (`de_hvbg_hessen_HeTA2010.tif`). It is used to test the +selection of coordinate operations for an area of interest. + +- Source: Hessische Verwaltung für Bodenmanagement und Geoinformation (HVBG), + https://www.hvbg.hessen.de +- License: Data licence Germany – attribution – version 2.0, https://www.govdata.de/dl-de/by-2-0 +- Modification: cropped with `gdal_translate -projwin 8.5 50.3 8.9 50.0`; values unchanged. diff --git a/xtraplatform-crs/src/test/resources/proj/de_hvbg_hessen_HeTA2010.tif b/xtraplatform-crs/src/test/resources/proj/de_hvbg_hessen_HeTA2010.tif new file mode 100644 index 000000000..b7f8cb752 Binary files /dev/null and b/xtraplatform-crs/src/test/resources/proj/de_hvbg_hessen_HeTA2010.tif differ 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 35865736a..cd91f12fb 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 @@ -17,6 +17,7 @@ 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.CrsTransformerFactory; import de.ii.xtraplatform.crs.domain.EpsgCrs; @@ -77,6 +78,8 @@ public abstract class AbstractFeatureProvider< implements FeatureProviderEntity, FeatureInfo, FeatureQueries { private static final Logger LOGGER = LoggerFactory.getLogger(AbstractFeatureProvider.class); + private static final String COULD_NOT_START = + "Feature provider with id '{}' could not be started: {}"; protected static final WithScope WITH_SCOPE_RETURNABLE = new WithScope(Scope.RETURNABLE); protected static final WithScope WITH_SCOPE_QUERIES = new WithScope( @@ -86,7 +89,13 @@ public abstract class AbstractFeatureProvider< private final ConnectorFactory connectorFactory; private final Reactive reactive; + + /** Selects transformations for the `transformationArea` of the provider, if set. */ protected final CrsTransformerFactory crsTransformerFactory; + + /** The injected factory, registered as subcomponent; a scoped view is not a volatile. */ + private final CrsTransformerFactory crsTransformerFactoryComponent; + protected final CrsInfo crsInfo; protected final AuditLog auditLog; protected final Encryption encryption; @@ -114,7 +123,11 @@ protected AbstractFeatureProvider( super(data, volatileRegistry); this.connectorFactory = connectorFactory; this.reactive = reactive; - this.crsTransformerFactory = crsTransformerFactory; + this.crsTransformerFactory = + data.getTransformationArea() + .map(crsTransformerFactory::withAreaOfInterest) + .orElse(crsTransformerFactory); + this.crsTransformerFactoryComponent = crsTransformerFactory; this.crsInfo = crsInfo; this.extensionRegistry = extensionRegistry; this.codelistStore = codelistStore; @@ -137,6 +150,25 @@ public String getType() { return ProviderData.ENTITY_TYPE; } + private static Optional validateArea(BoundingBox area) { + if (!OgcCrs.CRS84.equals(area.getEpsgCrs())) { + return Optional.of("'transformationArea' must be given in CRS84."); + } + if (area.getXmin() > area.getXmax() || area.getYmin() > area.getYmax()) { + return Optional.of( + "'transformationArea' is invalid: 'xmin' and 'ymin' must not exceed 'xmax' and 'ymax'."); + } + if (area.getXmin() < -180 + || area.getXmax() > 180 + || area.getYmin() < -90 + || area.getYmax() > 90) { + return Optional.of( + "'transformationArea' is invalid: longitudes must be within [-180, 180] and latitudes" + + " within [-90, 90]."); + } + return Optional.empty(); + } + @Override protected State reconcileStateNoComponents(@Nullable String capability) { return State.AVAILABLE; @@ -170,7 +202,7 @@ protected boolean onStartup() throws InterruptedException { if (multiQueries().isSupported()) { addCapability(MultiFeatureQueries.CAPABILITY); } - addSubcomponent(crsTransformerFactory, FeatureCrs.CAPABILITY); + addSubcomponent(crsTransformerFactoryComponent, FeatureCrs.CAPABILITY); addSubcomponent( connector, FeatureQueries.CAPABILITY, @@ -201,7 +233,7 @@ protected boolean onStartup() throws InterruptedException { Optional connectionError = getConnector().getConnectionError(); String message = connectionError.map(Throwable::getMessage).orElse("unknown reason"); - LOGGER.error("Feature provider with id '{}' could not be started: {}", getId(), message); + LOGGER.error(COULD_NOT_START, getId(), message); if (connectionError.isPresent() && LOGGER.isDebugEnabled()) { LOGGER.debug("Stacktrace:", connectionError.get()); } @@ -216,8 +248,7 @@ protected boolean onStartup() throws InterruptedException { Optional runnerError = getRunnerError(getConnectionInfo()); if (runnerError.isPresent()) { - LOGGER.error( - "Feature provider with id '{}' could not be started: {}", getId(), runnerError.get()); + LOGGER.error(COULD_NOT_START, getId(), runnerError.get()); // TODO: volatile defective // NOPMD return false; } @@ -227,8 +258,15 @@ protected boolean onStartup() throws InterruptedException { getData().getTypes().values(), encryption.isEnabled(), supportsEncryptedProperties()); if (encryptionError.isPresent()) { - LOGGER.error( - "Feature provider with id '{}' could not be started: {}", getId(), encryptionError.get()); + LOGGER.error(COULD_NOT_START, getId(), encryptionError.get()); + return false; + } + + Optional transformationAreaError = + getData().getTransformationArea().flatMap(AbstractFeatureProvider::validateArea); + + if (transformationAreaError.isPresent()) { + LOGGER.error(COULD_NOT_START, getId(), transformationAreaError.get()); return false; } 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 e4aff9998..cee2fd2a1 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 @@ -12,6 +12,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import de.ii.xtraplatform.cql.domain.CustomFunction; +import de.ii.xtraplatform.crs.domain.BoundingBox; import de.ii.xtraplatform.crs.domain.EpsgCrs; import de.ii.xtraplatform.docs.DocIgnore; import de.ii.xtraplatform.entities.domain.AutoEntity; @@ -78,6 +79,47 @@ default long getEntitySchemaVersion() { @JsonDeserialize(converter = ZoneIdFromString.class) Optional getNativeTimeZone(); + /** + * @langEn Area, as a bounding box in CRS84 (`xmin`, `ymin`, `xmax`, `ymax`), that is used to + * select the transformation between two coordinate reference systems when several are + * available, for example a regional NTv2 grid instead of a national one. The most accurate + * transformation whose area of use contains this box is used; grid files that are not part of + * the default installation have to be provided in `resources/proj`. Without a value, the + * transformation is selected independently of an area. + *

The box has to lie completely within the area of use of the intended transformation. If + * it extends beyond it, even by a few hundredths of a degree, a less accurate transformation + * that covers the whole box is selected instead. For this reason, the spatial extent of the + * data, of an API or of a provider is often not suitable: for a dataset that covers the + * region of a regional transformation completely, the extent usually extends slightly beyond + * its area of use, for example because of features on the boundary, or because the extent of + * data stored in a projected coordinate reference system becomes larger when it is converted + * to CRS84. Use the area of use of the intended transformation as given in the EPSG registry + * instead, for example `7.77, 49.39, 10.24, 51.66` for the transformation from DHDN to ETRS89 + * in Hessen (HeTA2010). Coordinates slightly outside of the area of use are still transformed + * as long as they are covered by the grid. + * @langDe Gebiet als Bounding Box in CRS84 (`xmin`, `ymin`, `xmax`, `ymax`), das bei mehreren + * möglichen Transformationen zwischen zwei Koordinatenreferenzsystemen die Auswahl bestimmt, + * z.B. ein regionales NTv2-Gitter statt eines bundesweiten. Verwendet wird die genaueste + * Transformation, deren Gültigkeitsbereich dieses Gebiet enthält; Gitterdateien, die nicht + * zur Standardinstallation gehören, sind in `resources/proj` bereitzustellen. Ohne Angabe + * wird die Transformation unabhängig von einem Gebiet ausgewählt. + *

Das Gebiet muss vollständig im Gültigkeitsbereich der gewünschten Transformation liegen. + * Ragt es darüber hinaus, auch nur um wenige Hundertstel Grad, wird stattdessen eine + * ungenauere Transformation ausgewählt, die das ganze Gebiet abdeckt. Deshalb eignet sich die + * räumliche Ausdehnung der Daten, einer API oder eines Providers oft nicht: Bei einem + * Datensatz, der das Gebiet einer regionalen Transformation vollständig abdeckt, ragt die + * Ausdehnung meist knapp über deren Gültigkeitsbereich hinaus, z.B. wegen Objekten auf der + * Grenze oder weil die Ausdehnung von Daten in einem projizierten Koordinatenreferenzsystem + * bei der Umrechnung nach CRS84 größer wird. Stattdessen ist der Gültigkeitsbereich der + * gewünschten Transformation laut EPSG-Registry anzugeben, z.B. `7.77, 49.39, 10.24, 51.66` + * für die Transformation von DHDN nach ETRS89 in Hessen (HeTA2010). Koordinaten knapp + * außerhalb des Gültigkeitsbereichs werden weiterhin transformiert, solange sie vom Gitter + * abgedeckt sind. + * @default null + * @since v4.9 + */ + Optional getTransformationArea(); + @DocIgnore Optional getDefaultLanguage();