diff --git a/nitrite-spatial/pom.xml b/nitrite-spatial/pom.xml index 46bfa49c6..50d021b48 100644 --- a/nitrite-spatial/pom.xml +++ b/nitrite-spatial/pom.xml @@ -52,6 +52,11 @@ com.fasterxml.jackson.core jackson-databind + + net.sf.geographiclib + GeographicLib-Java + 2.0 + junit diff --git a/nitrite-spatial/src/main/java/org/dizitart/no2/spatial/GeoNearFilter.java b/nitrite-spatial/src/main/java/org/dizitart/no2/spatial/GeoNearFilter.java new file mode 100644 index 000000000..f282d624b --- /dev/null +++ b/nitrite-spatial/src/main/java/org/dizitart/no2/spatial/GeoNearFilter.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2017-2020. Nitrite author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dizitart.no2.spatial; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.util.GeometricShapeFactory; + +/** + * Spatial filter for finding geometries near a geographic point, + * using geodesic distance on Earth's surface (WGS84 ellipsoid). + * + *

This filter is specifically designed for geographic coordinates (lat/long). + * It always uses geodesic distance calculations, eliminating the ambiguity + * of {@link NearFilter}'s auto-detection.

+ * + *

Usage Example:

+ *
{@code
+ * GeoPoint center = new GeoPoint(45.0, -93.2650); // Minneapolis
+ * collection.find(where("location").geoNear(center, 5000.0)); // 5km radius
+ * }
+ * + *

Distance Units: The distance parameter must be in meters.

+ * + *

Accuracy: This filter uses two-pass query execution for accurate results: + * Phase 1 performs a fast R-tree bounding box search, and Phase 2 refines results using + * precise JTS geometric operations to eliminate false positives.

+ * + * @since 4.3.3 + * @author Anindya Chatterjee + * @see GeoPoint + * @see NearFilter + */ +class GeoNearFilter extends WithinFilter { + + /** + * Creates a filter to find geometries near a GeoPoint. + * + * @param field the field to filter on + * @param point the geographic point to check proximity to + * @param distanceMeters the maximum distance in meters + */ + GeoNearFilter(String field, GeoPoint point, Double distanceMeters) { + super(field, createGeodesicCircle(point.getCoordinate(), distanceMeters)); + } + + /** + * Creates a filter to find geometries near a coordinate. + * The coordinate is validated to ensure it represents a valid geographic point. + * + * @param field the field to filter on + * @param point the coordinate to check proximity to (x=longitude, y=latitude) + * @param distanceMeters the maximum distance in meters + * @throws IllegalArgumentException if coordinates are not valid geographic coordinates + */ + GeoNearFilter(String field, Coordinate point, Double distanceMeters) { + super(field, createGeodesicCircle(validateAndGetCoordinate(point), distanceMeters)); + } + + private static Coordinate validateAndGetCoordinate(Coordinate coord) { + double lat = coord.getY(); + double lon = coord.getX(); + + if (lat < -90.0 || lat > 90.0) { + throw new IllegalArgumentException( + "GeoNearFilter requires valid latitude (-90 to 90), got: " + lat); + } + if (lon < -180.0 || lon > 180.0) { + throw new IllegalArgumentException( + "GeoNearFilter requires valid longitude (-180 to 180), got: " + lon); + } + + return coord; + } + + private static Geometry createGeodesicCircle(Coordinate center, double radiusMeters) { + GeometricShapeFactory shapeFactory = new GeometricShapeFactory(); + shapeFactory.setNumPoints(64); + shapeFactory.setCentre(center); + + // Always use geodesic calculations for GeoNearFilter + double radiusInDegrees = GeodesicUtils.metersToDegreesRadius(center, radiusMeters); + shapeFactory.setSize(radiusInDegrees * 2); + return shapeFactory.createCircle(); + } + + @Override + public String toString() { + return "(" + getField() + " geoNear " + getValue() + ")"; + } +} diff --git a/nitrite-spatial/src/main/java/org/dizitart/no2/spatial/GeoPoint.java b/nitrite-spatial/src/main/java/org/dizitart/no2/spatial/GeoPoint.java new file mode 100644 index 000000000..df47fdedd --- /dev/null +++ b/nitrite-spatial/src/main/java/org/dizitart/no2/spatial/GeoPoint.java @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2017-2020. Nitrite author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dizitart.no2.spatial; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Point; +import org.locationtech.jts.geom.PrecisionModel; + +import java.io.Serializable; + +/** + * Represents a geographic point with latitude and longitude coordinates + * on Earth's surface (WGS84 ellipsoid). + * + *

This class provides explicit type safety for geographic coordinates, + * eliminating the ambiguity of auto-detection. It validates coordinates + * on construction and provides clear latitude/longitude accessors.

+ * + *

Usage Example:

+ *
{@code
+ * // Create a geographic point for Minneapolis
+ * GeoPoint minneapolis = new GeoPoint(45.0, -93.2650);
+ * 
+ * // Use with GeoNearFilter
+ * collection.find(where("location").geoNear(minneapolis, 5000.0));
+ * }
+ * + *

Coordinate Order: Constructor takes (latitude, longitude) + * which differs from JTS Point (x, y) = (longitude, latitude) to avoid confusion.

+ * + * @since 4.3.3 + * @author Anindya Chatterjee + */ +public class GeoPoint implements Serializable { + private static final long serialVersionUID = 1L; + private static final GeometryFactory FACTORY = new GeometryFactory(new PrecisionModel(), 4326); + private final Point point; + private final double latitude; + private final double longitude; + + /** + * Creates a new GeoPoint with the specified geographic coordinates. + * + * @param latitude the latitude in degrees (-90 to 90) + * @param longitude the longitude in degrees (-180 to 180) + * @throws IllegalArgumentException if coordinates are out of valid range + */ + public GeoPoint(double latitude, double longitude) { + validateCoordinates(latitude, longitude); + this.latitude = latitude; + this.longitude = longitude; + this.point = FACTORY.createPoint(new Coordinate(longitude, latitude)); + } + + /** + * Creates a GeoPoint from a JTS Coordinate. + * The coordinate's Y value is treated as latitude and X as longitude. + * + * @param coordinate the coordinate (x=longitude, y=latitude) + * @throws IllegalArgumentException if coordinates are out of valid range + */ + public GeoPoint(Coordinate coordinate) { + this(coordinate.getY(), coordinate.getX()); + } + + private void validateCoordinates(double latitude, double longitude) { + if (latitude < -90.0 || latitude > 90.0) { + throw new IllegalArgumentException( + "Latitude must be between -90 and 90 degrees, got: " + latitude); + } + if (longitude < -180.0 || longitude > 180.0) { + throw new IllegalArgumentException( + "Longitude must be between -180 and 180 degrees, got: " + longitude); + } + } + + /** + * Gets the latitude in degrees. + * + * @return the latitude (-90 to 90) + */ + public double getLatitude() { + return latitude; + } + + /** + * Gets the longitude in degrees. + * + * @return the longitude (-180 to 180) + */ + public double getLongitude() { + return longitude; + } + + /** + * Gets the underlying JTS Point. + * + * @return the JTS Point representation + */ + public Point getPoint() { + return point; + } + + /** + * Gets the coordinate of this GeoPoint. + * + * @return the coordinate (x=longitude, y=latitude) + */ + public Coordinate getCoordinate() { + return point.getCoordinate(); + } + + @Override + public String toString() { + return String.format("GeoPoint(lat=%.6f, lon=%.6f)", latitude, longitude); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + GeoPoint other = (GeoPoint) obj; + return Double.compare(latitude, other.latitude) == 0 + && Double.compare(longitude, other.longitude) == 0; + } + + @Override + public int hashCode() { + long latBits = Double.doubleToLongBits(latitude); + long lonBits = Double.doubleToLongBits(longitude); + return (int) (latBits ^ (latBits >>> 32) ^ lonBits ^ (lonBits >>> 32)); + } +} diff --git a/nitrite-spatial/src/main/java/org/dizitart/no2/spatial/GeodesicUtils.java b/nitrite-spatial/src/main/java/org/dizitart/no2/spatial/GeodesicUtils.java new file mode 100644 index 000000000..b7ff259d9 --- /dev/null +++ b/nitrite-spatial/src/main/java/org/dizitart/no2/spatial/GeodesicUtils.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2017-2020. Nitrite author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dizitart.no2.spatial; + +import net.sf.geographiclib.Geodesic; +import net.sf.geographiclib.GeodesicData; +import org.locationtech.jts.geom.Coordinate; + +/** + * Utility class for geodesic distance calculations on Earth's surface. + * This class handles the conversion between meters and degrees of latitude/longitude, + * accounting for the curvature of the Earth using the WGS84 ellipsoid model. + * + *

This class is used internally by {@link NearFilter} for backward compatibility + * with auto-detection. For new code, use {@link GeoPoint} and {@link GeoNearFilter} + * for explicit geographic coordinate handling.

+ * + * @since 4.0 + * @author Anindya Chatterjee + */ +class GeodesicUtils { + private static final Geodesic WGS84 = Geodesic.WGS84; + + /** + * Determines if coordinates appear to be geographic (lat/long) rather than Cartesian. + * This is a heuristic check based on valid lat/long ranges: + * - Latitude: -90 to 90 + * - Longitude: -180 to 180 + * + *

Limitation: This heuristic may incorrectly classify Cartesian + * coordinates that happen to fall within ±90°/±180° range (e.g., game world coordinates).

+ * + *

Recommendation: For new code, use {@link GeoPoint} and + * {@link GeoNearFilter} to explicitly indicate geographic coordinates and avoid + * auto-detection ambiguity.

+ * + * @param center the coordinate to check + * @return true if the coordinate appears to be geographic, false otherwise + */ + static boolean isGeographic(Coordinate center) { + double x = center.getX(); + double y = center.getY(); + + // Check if coordinates fall within valid lat/long ranges + // We use slightly relaxed bounds to be conservative + return Math.abs(y) <= 90.0 && Math.abs(x) <= 180.0; + } + + /** + * Calculates the approximate radius in degrees for a given distance in meters + * at a specific geographic coordinate. This accounts for the fact that one degree + * of longitude varies with latitude. + * + *

This method calculates geodesic distances in both E-W and N-S directions and + * returns the maximum to ensure complete circular coverage. Combined with the + * two-pass query execution in {@link SpatialIndex}, this provides accurate results + * while maintaining performance.

+ * + * @param center the center coordinate (longitude, latitude) + * @param radiusMeters the radius in meters + * @return the approximate radius in degrees + */ + static double metersToDegreesRadius(Coordinate center, double radiusMeters) { + double lat = center.getY(); + double lon = center.getX(); + + // Calculate how many degrees we need to go in different directions + // to cover the specified radius in meters + + // East-West: Calculate a point at the given distance east + GeodesicData eastPoint = WGS84.Direct(lat, lon, 90.0, radiusMeters); + double lonDiff = Math.abs(eastPoint.lon2 - lon); + + // North-South: Calculate a point at the given distance north + GeodesicData northPoint = WGS84.Direct(lat, lon, 0.0, radiusMeters); + double latDiff = Math.abs(northPoint.lat2 - lat); + + // Use the maximum of the two to ensure we cover the full circle + // This creates a slightly larger search area but ensures we don't miss points + return Math.max(lonDiff, latDiff); + } +} diff --git a/nitrite-spatial/src/main/java/org/dizitart/no2/spatial/NearFilter.java b/nitrite-spatial/src/main/java/org/dizitart/no2/spatial/NearFilter.java index fe4d4764e..ef2c1a414 100644 --- a/nitrite-spatial/src/main/java/org/dizitart/no2/spatial/NearFilter.java +++ b/nitrite-spatial/src/main/java/org/dizitart/no2/spatial/NearFilter.java @@ -22,6 +22,23 @@ import org.locationtech.jts.util.GeometricShapeFactory; /** + * A spatial filter that finds geometries near a point within a specified distance. + * + *

This filter automatically detects whether the coordinates are geographic (latitude/longitude) + * or Cartesian, and applies the appropriate distance calculation:

+ * + * + *

Recommendation: For new code, use {@link GeoPoint} and {@link GeoNearFilter} + * for explicit geographic coordinate handling with better type safety and no auto-detection ambiguity.

+ * + *

Note: Combined with two-pass query execution in {@link SpatialIndex}, + * this filter provides accurate results by eliminating false positives from bounding box approximation.

+ * * @since 4.0 * @author Anindya Chatterjee */ @@ -34,11 +51,24 @@ class NearFilter extends WithinFilter { super(field, createCircle(point.getCoordinate(), distance)); } - private static Geometry createCircle(Coordinate center, double radius) { + private static Geometry createCircle(Coordinate center, double radiusMeters) { GeometricShapeFactory shapeFactory = new GeometricShapeFactory(); shapeFactory.setNumPoints(64); shapeFactory.setCentre(center); - shapeFactory.setSize(radius * 2); + + // Determine if we're dealing with geographic coordinates (lat/long) + // or simple Cartesian coordinates + double radiusInDegrees; + if (GeodesicUtils.isGeographic(center)) { + // Convert meters to degrees accounting for Earth's curvature + radiusInDegrees = GeodesicUtils.metersToDegreesRadius(center, radiusMeters); + } else { + // For non-geographic coordinates, use the radius as-is + // This maintains backward compatibility with existing tests + radiusInDegrees = radiusMeters; + } + + shapeFactory.setSize(radiusInDegrees * 2); return shapeFactory.createCircle(); } diff --git a/nitrite-spatial/src/main/java/org/dizitart/no2/spatial/SpatialFluentFilter.java b/nitrite-spatial/src/main/java/org/dizitart/no2/spatial/SpatialFluentFilter.java index 9f3e78ba3..c160b9be3 100644 --- a/nitrite-spatial/src/main/java/org/dizitart/no2/spatial/SpatialFluentFilter.java +++ b/nitrite-spatial/src/main/java/org/dizitart/no2/spatial/SpatialFluentFilter.java @@ -91,4 +91,40 @@ public Filter near(Coordinate point, Double distance) { public Filter near(Point point, Double distance) { return new NearFilter(field, point, distance); } + + /** + * Creates a spatial filter for geographic coordinates that matches documents + * where the spatial data is near the specified point, using geodesic distance. + * + *

This method is specifically for geographic coordinates (latitude/longitude) + * and always uses geodesic distance calculations on Earth's WGS84 ellipsoid.

+ * + *

Usage Example:

+ *
{@code
+     * GeoPoint minneapolis = new GeoPoint(45.0, -93.2650);
+     * collection.find(where("location").geoNear(minneapolis, 5000.0)); // 5km radius
+     * }
+ * + * @param point the geographic point to check proximity to + * @param distanceMeters the maximum distance in meters + * @return the new {@link Filter} instance + */ + public Filter geoNear(GeoPoint point, Double distanceMeters) { + return new GeoNearFilter(field, point, distanceMeters); + } + + /** + * Creates a spatial filter for geographic coordinates that matches documents + * where the spatial data is near the specified coordinate, using geodesic distance. + * + *

The coordinate is validated to ensure it represents valid geographic coordinates.

+ * + * @param point the coordinate to check proximity to (x=longitude, y=latitude) + * @param distanceMeters the maximum distance in meters + * @return the new {@link Filter} instance + * @throws IllegalArgumentException if coordinates are not valid geographic coordinates + */ + public Filter geoNear(Coordinate point, Double distanceMeters) { + return new GeoNearFilter(field, point, distanceMeters); + } } diff --git a/nitrite-spatial/src/main/java/org/dizitart/no2/spatial/SpatialIndex.java b/nitrite-spatial/src/main/java/org/dizitart/no2/spatial/SpatialIndex.java index 33f5e0fd1..2d612d3a4 100644 --- a/nitrite-spatial/src/main/java/org/dizitart/no2/spatial/SpatialIndex.java +++ b/nitrite-spatial/src/main/java/org/dizitart/no2/spatial/SpatialIndex.java @@ -32,6 +32,7 @@ import org.dizitart.no2.index.BoundingBox; import org.dizitart.no2.index.IndexDescriptor; import org.dizitart.no2.index.NitriteIndex; +import org.dizitart.no2.store.NitriteMap; import org.dizitart.no2.store.NitriteRTree; import org.dizitart.no2.store.NitriteStore; import org.locationtech.jts.geom.Envelope; @@ -131,29 +132,117 @@ public LinkedHashSet findNitriteIds(FindPlan findPlan) { throw new FilterException("Spatial filter must be the first filter for index scan"); } - RecordStream keys; + // Phase 1: R-tree bounding box search (fast but may include false positives) + RecordStream candidateKeys; NitriteRTree indexMap = findIndexMap(); SpatialFilter spatialFilter = (SpatialFilter) filter; - Geometry geometry = spatialFilter.getValue(); - BoundingBox boundingBox = fromGeometry(geometry); + Geometry searchGeometry = spatialFilter.getValue(); + BoundingBox boundingBox = fromGeometry(searchGeometry); - if (filter instanceof WithinFilter) { - keys = indexMap.findContainedKeys(boundingBox); + if (filter instanceof WithinFilter || filter instanceof GeoNearFilter) { + // For Within/Near filters, we want points that intersect the search geometry + // Note: We use intersecting here because we're searching for points WITHIN a circle + // The R-tree stores point bounding boxes, and we want those that overlap with + // the circle's bbox + candidateKeys = indexMap.findIntersectingKeys(boundingBox); } else if (filter instanceof IntersectsFilter) { - keys = indexMap.findIntersectingKeys(boundingBox); + candidateKeys = indexMap.findIntersectingKeys(boundingBox); } else { throw new FilterException("Unsupported spatial filter " + filter); } - LinkedHashSet nitriteIds = new LinkedHashSet<>(); - if (keys != null) { - for (NitriteId nitriteId : keys) { - nitriteIds.add(nitriteId); + LinkedHashSet results = new LinkedHashSet<>(); + if (candidateKeys == null) { + return results; + } + + // Phase 2: Geometry refinement (precise filtering using actual JTS operations) + // This eliminates false positives from the bounding box approximation + for (NitriteId nitriteId : candidateKeys) { + if (matchesGeometryFilter(nitriteId, spatialFilter, searchGeometry)) { + results.add(nitriteId); } } - return nitriteIds; + return results; + } + + /** + * Performs precise geometry matching using JTS operations. + * This is the second pass that eliminates false positives from the R-tree bbox search. + * + * @param nitriteId the document ID to check + * @param filter the spatial filter being applied + * @param searchGeometry the geometry to search with + * @return true if the stored geometry matches the filter criteria + */ + private boolean matchesGeometryFilter(NitriteId nitriteId, SpatialFilter filter, Geometry searchGeometry) { + try { + // Retrieve the stored geometry for this document + Geometry storedGeometry = getStoredGeometry(nitriteId); + + if (storedGeometry == null) { + // If geometry is null, it matches only if the search is for null/empty + return searchGeometry == null; + } + + // Apply the appropriate JTS geometric operation based on filter type + if (filter instanceof WithinFilter || filter instanceof GeoNearFilter) { + // For Within and Near filters: the search geometry should contain the stored geometry + // OR the stored geometry should be within the search geometry + // For point-in-circle queries, we want to check if the point is within the circle + boolean result = searchGeometry.contains(storedGeometry) || searchGeometry.covers(storedGeometry); + return result; + } else if (filter instanceof IntersectsFilter) { + // For Intersects filter: geometries must intersect + return searchGeometry.intersects(storedGeometry); + } + + return false; + } catch (Exception e) { + // If there's an error (e.g., invalid geometry), exclude this result + return false; + } + } + + /** + * Retrieves the stored geometry from the collection for a given document ID. + * + * @param nitriteId the document ID + * @return the stored geometry, or null if not found + */ + private Geometry getStoredGeometry(NitriteId nitriteId) { + try { + // Get the collection map name from the index descriptor + String collectionName = indexDescriptor.getCollectionName(); + + // Open the collection's document map + NitriteMap documentMap = + nitriteStore.openMap(collectionName, NitriteId.class, Document.class); + + // Retrieve the document + Document document = documentMap.get(nitriteId); + if (document == null) { + return null; + } + + // Get the field name from the index descriptor + Fields fields = indexDescriptor.getFields(); + List fieldNames = fields.getFieldNames(); + if (fieldNames.isEmpty()) { + return null; + } + + String fieldName = fieldNames.get(0); + Object fieldValue = document.get(fieldName); + + // Parse the geometry from the field value + return parseGeometry(fieldName, fieldValue); + } catch (Exception e) { + // If there's an error retrieving the geometry, return null + return null; + } } private NitriteRTree findIndexMap() { @@ -165,14 +254,25 @@ private Geometry parseGeometry(String field, Object fieldValue) { if (fieldValue == null) return null; if (fieldValue instanceof String) { return GeometryUtils.fromString((String) fieldValue); + } else if (fieldValue instanceof GeoPoint) { + // Handle GeoPoint - get its underlying Point geometry + return ((GeoPoint) fieldValue).getPoint(); } else if (fieldValue instanceof Geometry) { return (Geometry) fieldValue; } else if (fieldValue instanceof Document) { - // in case of document, check if it contains geometry field + // in case of document, check if it contains geometry field or lat/lon fields // GeometryConverter convert a geometry to document with geometry field + // GeoPointConverter converts to document with latitude/longitude fields Document document = (Document) fieldValue; if (document.containsField("geometry")) { return GeometryUtils.fromString(document.get("geometry", String.class)); + } else if (document.containsField("latitude") && document.containsField("longitude")) { + // Reconstruct GeoPoint from lat/lon + Double lat = document.get("latitude", Double.class); + Double lon = document.get("longitude", Double.class); + if (lat != null && lon != null) { + return new GeoPoint(lat, lon).getPoint(); + } } } throw new IndexingException("Field " + field + " does not contain Geometry data"); diff --git a/nitrite-spatial/src/main/java/org/dizitart/no2/spatial/SpatialModule.java b/nitrite-spatial/src/main/java/org/dizitart/no2/spatial/SpatialModule.java index c08448b32..1b99c1a7f 100644 --- a/nitrite-spatial/src/main/java/org/dizitart/no2/spatial/SpatialModule.java +++ b/nitrite-spatial/src/main/java/org/dizitart/no2/spatial/SpatialModule.java @@ -18,6 +18,7 @@ import org.dizitart.no2.common.module.NitriteModule; import org.dizitart.no2.common.module.NitritePlugin; +import org.dizitart.no2.spatial.converter.GeoPointConverter; import org.dizitart.no2.spatial.converter.GeometryConverter; import java.util.Set; @@ -41,6 +42,6 @@ public class SpatialModule implements NitriteModule { */ @Override public Set plugins() { - return setOf(new SpatialIndexer(), new GeometryConverter()); + return setOf(new SpatialIndexer(), new GeometryConverter(), new GeoPointConverter()); } } diff --git a/nitrite-spatial/src/main/java/org/dizitart/no2/spatial/converter/GeoPointConverter.java b/nitrite-spatial/src/main/java/org/dizitart/no2/spatial/converter/GeoPointConverter.java new file mode 100644 index 000000000..460f12eb1 --- /dev/null +++ b/nitrite-spatial/src/main/java/org/dizitart/no2/spatial/converter/GeoPointConverter.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2017-2020. Nitrite author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dizitart.no2.spatial.converter; + +import org.dizitart.no2.collection.Document; +import org.dizitart.no2.common.mapper.EntityConverter; +import org.dizitart.no2.common.mapper.NitriteMapper; +import org.dizitart.no2.spatial.GeoPoint; + +/** + * Converter for {@link GeoPoint} to/from Nitrite {@link Document}. + * + *

Stores GeoPoint as a document with latitude and longitude fields.

+ * + * @since 4.3.3 + * @author Anindya Chatterjee + */ +public class GeoPointConverter implements EntityConverter { + + @Override + public Class getEntityType() { + return GeoPoint.class; + } + + @Override + public Document toDocument(GeoPoint entity, NitriteMapper nitriteMapper) { + return Document.createDocument("latitude", entity.getLatitude()) + .put("longitude", entity.getLongitude()); + } + + @Override + public GeoPoint fromDocument(Document document, NitriteMapper nitriteMapper) { + Double latitude = document.get("latitude", Double.class); + Double longitude = document.get("longitude", Double.class); + + if (latitude == null || longitude == null) { + return null; + } + + return new GeoPoint(latitude, longitude); + } +} diff --git a/nitrite-spatial/src/test/java/org/dizitart/no2/spatial/GeoPointTest.java b/nitrite-spatial/src/test/java/org/dizitart/no2/spatial/GeoPointTest.java new file mode 100644 index 000000000..c0fb8d650 --- /dev/null +++ b/nitrite-spatial/src/test/java/org/dizitart/no2/spatial/GeoPointTest.java @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2017-2020. Nitrite author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dizitart.no2.spatial; + +import org.dizitart.no2.collection.Document; +import org.dizitart.no2.collection.DocumentCursor; +import org.dizitart.no2.collection.NitriteCollection; +import org.dizitart.no2.filters.FluentFilter; +import org.dizitart.no2.index.IndexOptions; +import org.junit.Test; +import org.locationtech.jts.geom.Coordinate; + +import static org.dizitart.no2.collection.Document.createDocument; +import static org.dizitart.no2.spatial.SpatialFluentFilter.where; +import static org.dizitart.no2.spatial.SpatialIndexer.SPATIAL_INDEX; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +/** + * Test cases for GeoPoint and GeoNearFilter functionality. + * + * @author Anindya Chatterjee + */ +public class GeoPointTest extends BaseSpatialTest { + + @Test + public void testGeoPointCreation() { + GeoPoint point = new GeoPoint(45.0, -93.2650); + assertEquals(45.0, point.getLatitude(), 0.0001); + assertEquals(-93.2650, point.getLongitude(), 0.0001); + } + + @Test + public void testGeoPointFromCoordinate() { + Coordinate coord = new Coordinate(-93.2650, 45.0); // x=lon, y=lat + GeoPoint point = new GeoPoint(coord); + assertEquals(45.0, point.getLatitude(), 0.0001); + assertEquals(-93.2650, point.getLongitude(), 0.0001); + } + + @Test(expected = IllegalArgumentException.class) + public void testGeoPointInvalidLatitudeTooHigh() { + new GeoPoint(91.0, 0.0); + } + + @Test(expected = IllegalArgumentException.class) + public void testGeoPointInvalidLatitudeTooLow() { + new GeoPoint(-91.0, 0.0); + } + + @Test(expected = IllegalArgumentException.class) + public void testGeoPointInvalidLongitudeTooHigh() { + new GeoPoint(0.0, 181.0); + } + + @Test(expected = IllegalArgumentException.class) + public void testGeoPointInvalidLongitudeTooLow() { + new GeoPoint(0.0, -181.0); + } + + @Test + public void testGeoPointBoundaryValues() { + // Test boundary values + GeoPoint northPole = new GeoPoint(90.0, 0.0); + assertEquals(90.0, northPole.getLatitude(), 0.0001); + + GeoPoint southPole = new GeoPoint(-90.0, 0.0); + assertEquals(-90.0, southPole.getLatitude(), 0.0001); + + GeoPoint dateLine = new GeoPoint(0.0, 180.0); + assertEquals(180.0, dateLine.getLongitude(), 0.0001); + + GeoPoint antiMeridian = new GeoPoint(0.0, -180.0); + assertEquals(-180.0, antiMeridian.getLongitude(), 0.0001); + } + + @Test + public void testGeoPointSerialization() { + NitriteCollection testCollection = db.getCollection("geo_point_test"); + + GeoPoint minneapolis = new GeoPoint(45.0, -93.2650); + Document doc = createDocument("name", "Minneapolis") + .put("location", minneapolis); + + testCollection.insert(doc); + + Document retrieved = testCollection.find().firstOrNull(); + assertNotNull(retrieved); + + GeoPoint retrievedPoint = retrieved.get("location", GeoPoint.class); + assertNotNull(retrievedPoint); + assertEquals(45.0, retrievedPoint.getLatitude(), 0.0001); + assertEquals(-93.2650, retrievedPoint.getLongitude(), 0.0001); + + testCollection.remove(FluentFilter.where("name").eq("Minneapolis")); + } + + @Test + public void testGeoNearFilterWithGeoPoint() { + NitriteCollection testCollection = db.getCollection("geo_near_test"); + testCollection.createIndex(IndexOptions.indexOptions(SPATIAL_INDEX), "location"); + + GeoPoint center = new GeoPoint(0.001, 0.001); + GeoPoint point1km = new GeoPoint(0.001, 0.011); // ~1.1km east + GeoPoint point5km = new GeoPoint(0.001, 0.051); // ~5.7km east + GeoPoint point100km = new GeoPoint(0.001, 1.001); // ~111km east + + Document docCenter = createDocument("name", "center").put("location", center); + Document doc1km = createDocument("name", "1km").put("location", point1km); + Document doc5km = createDocument("name", "5km").put("location", point5km); + Document doc100km = createDocument("name", "100km").put("location", point100km); + + testCollection.insert(docCenter, doc1km, doc5km, doc100km); + + // Test: Within 2km should find center and 1km + DocumentCursor within2km = testCollection.find(where("location").geoNear(center, 2000.0)); + assertEquals("Should find 2 points within 2km", 2, within2km.size()); + + // Test: Within 10km should find center, 1km, and 5km + DocumentCursor within10km = testCollection.find(where("location").geoNear(center, 10000.0)); + assertEquals("Should find 3 points within 10km", 3, within10km.size()); + + // Test: Within 200km should find all points + DocumentCursor within200km = testCollection.find(where("location").geoNear(center, 200000.0)); + assertEquals("Should find all 4 points within 200km", 4, within200km.size()); + + testCollection.remove(FluentFilter.where("name").eq("center")); + testCollection.remove(FluentFilter.where("name").eq("1km")); + testCollection.remove(FluentFilter.where("name").eq("5km")); + testCollection.remove(FluentFilter.where("name").eq("100km")); + } + + @Test + public void testGeoNearFilterWithCoordinate() { + NitriteCollection testCollection = db.getCollection("geo_near_coord_test"); + testCollection.createIndex(IndexOptions.indexOptions(SPATIAL_INDEX), "location"); + + GeoPoint center = new GeoPoint(0.001, 0.001); + GeoPoint nearby = new GeoPoint(0.010, 0.001); // ~1km north + + testCollection.insert( + createDocument("name", "center").put("location", center), + createDocument("name", "nearby").put("location", nearby) + ); + + // Test with Coordinate instead of GeoPoint + Coordinate centerCoord = new Coordinate(0.001, 0.001); // lon, lat + DocumentCursor results = testCollection.find(where("location").geoNear(centerCoord, 2000.0)); + + assertEquals("Should find 2 points within 2km", 2, results.size()); + + testCollection.remove(FluentFilter.where("name").eq("center")); + testCollection.remove(FluentFilter.where("name").eq("nearby")); + } + + @Test(expected = IllegalArgumentException.class) + public void testGeoNearFilterInvalidCoordinate() { + NitriteCollection testCollection = db.getCollection("invalid_test"); + testCollection.createIndex(IndexOptions.indexOptions(SPATIAL_INDEX), "location"); + + // Invalid latitude > 90 + Coordinate invalid = new Coordinate(0.0, 100.0); + testCollection.find(where("location").geoNear(invalid, 1000.0)); + } + + @Test + public void testGeoNearFilterMidLatitude() { + NitriteCollection testCollection = db.getCollection("geo_near_midlat_test"); + testCollection.createIndex(IndexOptions.indexOptions(SPATIAL_INDEX), "location"); + + // Test at 45°N where longitude degrees are shorter + GeoPoint center = new GeoPoint(45.0, -93.2650); + GeoPoint nearby = new GeoPoint(45.01, -93.2650); // ~1.1km north + GeoPoint faraway = new GeoPoint(45.0, -92.2650); // ~80km east + + testCollection.insert( + createDocument("name", "center").put("location", center), + createDocument("name", "nearby").put("location", nearby), + createDocument("name", "faraway").put("location", faraway) + ); + + DocumentCursor within2km = testCollection.find(where("location").geoNear(center, 2000.0)); + assertEquals("Should find 2 points within 2km at 45°N", 2, within2km.size()); + + DocumentCursor within100km = testCollection.find(where("location").geoNear(center, 100000.0)); + assertEquals("Should find all 3 points within 100km", 3, within100km.size()); + + testCollection.remove(FluentFilter.where("name").eq("center")); + testCollection.remove(FluentFilter.where("name").eq("nearby")); + testCollection.remove(FluentFilter.where("name").eq("faraway")); + } + + @Test + public void testGeoPointToString() { + GeoPoint point = new GeoPoint(45.123456, -93.654321); + String str = point.toString(); + assertNotNull(str); + // Should contain formatted lat/lon + assert(str.contains("45.123456")); + assert(str.contains("-93.654321")); + } +} diff --git a/nitrite-spatial/src/test/java/org/dizitart/no2/spatial/GeodesicNearFilterTest.java b/nitrite-spatial/src/test/java/org/dizitart/no2/spatial/GeodesicNearFilterTest.java new file mode 100644 index 000000000..fa3a64ba6 --- /dev/null +++ b/nitrite-spatial/src/test/java/org/dizitart/no2/spatial/GeodesicNearFilterTest.java @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2017-2020. Nitrite author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dizitart.no2.spatial; + +import org.dizitart.no2.collection.Document; +import org.dizitart.no2.collection.DocumentCursor; +import org.dizitart.no2.collection.NitriteCollection; +import org.dizitart.no2.filters.FluentFilter; +import org.dizitart.no2.index.IndexOptions; +import org.junit.Test; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Point; +import org.locationtech.jts.io.ParseException; +import org.locationtech.jts.io.WKTReader; + +import static org.dizitart.no2.collection.Document.createDocument; +import static org.dizitart.no2.spatial.SpatialFluentFilter.where; +import static org.dizitart.no2.spatial.SpatialIndexer.SPATIAL_INDEX; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Test cases for NearFilter with real-world geodesic coordinates. + * These tests verify that the NearFilter properly handles lat/long coordinates + * on Earth's surface and correctly converts meters to degrees. + * + * @author Anindya Chatterjee + */ +public class GeodesicNearFilterTest extends BaseSpatialTest { + + @Test + public void testNearFilterAtEquator() throws ParseException { + WKTReader reader = new WKTReader(); + + // Use slightly offset center to avoid potential R-tree edge case at exactly (0,0) + // Center point near equator in Atlantic Ocean + Point centerPoint = (Point) reader.read("POINT (0.001 0.001)"); + + // Point approximately 1km east: at equator, 1 degree ≈ 111km + // So 0.01 degrees ≈ 1.11km from (0,0), and similar from (0.001, 0.001) + Point point1kmEast = (Point) reader.read("POINT (0.011 0.001)"); + + // Point approximately 111km east (1 degree at equator) + Point point111kmEast = (Point) reader.read("POINT (1.001 0.001)"); + + // Point approximately 222km east (2 degrees at equator) + Point point222kmEast = (Point) reader.read("POINT (2.001 0.001)"); + + NitriteCollection testCollection = db.getCollection("geodesic_test"); + testCollection.createIndex(IndexOptions.indexOptions(SPATIAL_INDEX), "location"); + + Document docCenter = createDocument("name", "center") + .put("location", centerPoint); + Document doc1km = createDocument("name", "1km_east") + .put("location", point1kmEast); + Document doc111km = createDocument("name", "111km_east") + .put("location", point111kmEast); + Document doc222km = createDocument("name", "222km_east") + .put("location", point222kmEast); + + testCollection.insert(docCenter, doc1km, doc111km, doc222km); + + // Test 1: Within 2km should return center and 1km_east only + DocumentCursor within2km = testCollection.find(where("location").near(centerPoint, 2000.0)); + assertEquals("Should find 2 points within 2km", 2, within2km.size()); + + // Test 2: Within 20cm should return only center + DocumentCursor within20cm = testCollection.find(where("location").near(centerPoint, 0.2)); + assertEquals("Should find only center within 20cm", 1, within20cm.size()); + + // Test 3: Within 150km should return center, 1km, and 111km + DocumentCursor within150km = testCollection.find(where("location").near(centerPoint, 150000.0)); + assertEquals("Should find 3 points within 150km", 3, within150km.size()); + + testCollection.remove(FluentFilter.where("name").eq("center")); + testCollection.remove(FluentFilter.where("name").eq("1km_east")); + testCollection.remove(FluentFilter.where("name").eq("111km_east")); + testCollection.remove(FluentFilter.where("name").eq("222km_east")); + } + + @Test + public void testNearFilterWithCoordinate() throws ParseException { + WKTReader reader = new WKTReader(); + + Point centerPoint = (Point) reader.read("POINT (0.001 0.001)"); + Coordinate centerCoord = centerPoint.getCoordinate(); + + Point point500m = (Point) reader.read("POINT (0.006 0.001)"); + Point point5km = (Point) reader.read("POINT (0.051 0.001)"); + + NitriteCollection testCollection = db.getCollection("geodesic_coord_test"); + testCollection.createIndex(IndexOptions.indexOptions(SPATIAL_INDEX), "location"); + + Document docCenter = createDocument("name", "center") + .put("location", centerPoint); + Document doc500m = createDocument("name", "500m_east") + .put("location", point500m); + Document doc5km = createDocument("name", "5km_east") + .put("location", point5km); + + testCollection.insert(docCenter, doc500m, doc5km); + + // Test with Coordinate instead of Point + DocumentCursor within1km = testCollection.find(where("location").near(centerCoord, 1000.0)); + assertEquals("Should find 2 points within 1km", 2, within1km.size()); + + DocumentCursor within10km = testCollection.find(where("location").near(centerCoord, 10000.0)); + assertEquals("Should find all 3 points within 10km", 3, within10km.size()); + + testCollection.remove(FluentFilter.where("name").eq("center")); + testCollection.remove(FluentFilter.where("name").eq("500m_east")); + testCollection.remove(FluentFilter.where("name").eq("5km_east")); + } + + @Test + public void testNearFilterAtMidLatitude() throws ParseException { + WKTReader reader = new WKTReader(); + + // Center point at 45°N (e.g., near Minneapolis, MN) + // At 45°N, longitude degrees are shorter: ~78.8km per degree + Point centerPoint = (Point) reader.read("POINT (-93.2650 45.0000)"); + + // Point approximately 1km east at 45°N + Point point1kmEast = (Point) reader.read("POINT (-93.2523 45.0000)"); + + // Point approximately 80km east + Point point80kmEast = (Point) reader.read("POINT (-92.2650 45.0000)"); + + NitriteCollection testCollection = db.getCollection("geodesic_midlat_test"); + testCollection.createIndex(IndexOptions.indexOptions(SPATIAL_INDEX), "location"); + + Document docCenter = createDocument("name", "center") + .put("location", centerPoint); + Document doc1km = createDocument("name", "1km_east") + .put("location", point1kmEast); + Document doc80km = createDocument("name", "80km_east") + .put("location", point80kmEast); + + testCollection.insert(docCenter, doc1km, doc80km); + + // Within 2km should find center and 1km_east + DocumentCursor within2km = testCollection.find(where("location").near(centerPoint, 2000.0)); + assertEquals("Should find 2 points within 2km at 45°N", 2, within2km.size()); + + // Within 100km should find all points + DocumentCursor within100km = testCollection.find(where("location").near(centerPoint, 100000.0)); + assertEquals("Should find all 3 points within 100km", 3, within100km.size()); + + testCollection.remove(FluentFilter.where("name").eq("center")); + testCollection.remove(FluentFilter.where("name").eq("1km_east")); + testCollection.remove(FluentFilter.where("name").eq("80km_east")); + } + + @Test + public void testNearFilterNorthSouth() throws ParseException { + WKTReader reader = new WKTReader(); + + // Test north-south distances (latitude changes) + // These are consistent across all longitudes: ~111km per degree + Point centerPoint = (Point) reader.read("POINT (0.001 0.001)"); + + // Point approximately 1km north + Point point1kmNorth = (Point) reader.read("POINT (0.001 0.010)"); + + // Point approximately 111km north (1 degree) + Point point111kmNorth = (Point) reader.read("POINT (0.001 1.001)"); + + NitriteCollection testCollection = db.getCollection("geodesic_ns_test"); + testCollection.createIndex(IndexOptions.indexOptions(SPATIAL_INDEX), "location"); + + Document docCenter = createDocument("name", "center") + .put("location", centerPoint); + Document doc1km = createDocument("name", "1km_north") + .put("location", point1kmNorth); + Document doc111km = createDocument("name", "111km_north") + .put("location", point111kmNorth); + + testCollection.insert(docCenter, doc1km, doc111km); + + // Within 2km should find center and 1km_north + DocumentCursor within2km = testCollection.find(where("location").near(centerPoint, 2000.0)); + assertEquals("Should find 2 points within 2km", 2, within2km.size()); + + // Within 200km should find all points + DocumentCursor within200km = testCollection.find(where("location").near(centerPoint, 200000.0)); + assertEquals("Should find all 3 points within 200km", 3, within200km.size()); + + testCollection.remove(FluentFilter.where("name").eq("center")); + testCollection.remove(FluentFilter.where("name").eq("1km_north")); + testCollection.remove(FluentFilter.where("name").eq("111km_north")); + } + + @Test + public void testNearFilterSmallDistances() throws ParseException { + WKTReader reader = new WKTReader(); + + Point centerPoint = (Point) reader.read("POINT (0.001 0.001)"); + + // Very small distances + Point point10m = (Point) reader.read("POINT (0.00109 0.001)"); // ~10m + Point point100m = (Point) reader.read("POINT (0.0019 0.001)"); // ~100m + + NitriteCollection testCollection = db.getCollection("geodesic_small_test"); + testCollection.createIndex(IndexOptions.indexOptions(SPATIAL_INDEX), "location"); + + Document docCenter = createDocument("name", "center") + .put("location", centerPoint); + Document doc10m = createDocument("name", "10m_east") + .put("location", point10m); + Document doc100m = createDocument("name", "100m_east") + .put("location", point100m); + + testCollection.insert(docCenter, doc10m, doc100m); + + // Within 50m should find center and 10m only + DocumentCursor within50m = testCollection.find(where("location").near(centerPoint, 50.0)); + assertEquals("Should find 2 points within 50m", 2, within50m.size()); + + // Within 5m should find only center + DocumentCursor within5m = testCollection.find(where("location").near(centerPoint, 5.0)); + assertEquals("Should find only center within 5m", 1, within5m.size()); + + testCollection.remove(FluentFilter.where("name").eq("center")); + testCollection.remove(FluentFilter.where("name").eq("10m_east")); + testCollection.remove(FluentFilter.where("name").eq("100m_east")); + } +}