This is an automated email from the ASF dual-hosted git repository. jiayu pushed a commit to branch feature/bing-tile-functions-1327 in repository https://gitbox.apache.org/repos/asf/sedona.git
commit 4920f152c74c4f39787405cd3064a688a84e8c6c Author: Jia Yu <[email protected]> AuthorDate: Sat Feb 21 00:53:19 2026 -0800 Add Bing Tile functions (issue #1327) Ported from bing-tile-hive and Trino implementations. Adds 9 functions: - ST_BingTile(x, y, zoom) / ST_BingTileAt(lat, lon, zoom) - ST_BingTileQuadKey, ST_BingTileZoomLevel, ST_BingTileX, ST_BingTileY - ST_BingTilePolygon, ST_BingTileCellIDs, ST_BingTileToGeom Includes bindings for Spark, Flink, and Python. Tests use reference values from Trino's TestBingTileFunctions. --- .../java/org/apache/sedona/common/Functions.java | 122 +++++ .../org/apache/sedona/common/utils/BingTile.java | 586 +++++++++++++++++++++ .../org/apache/sedona/common/FunctionsTest.java | 311 +++++++++++ docs/api/flink/Function.md | 183 +++++++ docs/api/snowflake/vector-data/Function.md | 162 ++++++ docs/api/sql/Function.md | 183 +++++++ .../main/java/org/apache/sedona/flink/Catalog.java | 9 + .../apache/sedona/flink/expressions/Functions.java | 89 ++++ .../java/org/apache/sedona/flink/FunctionTest.java | 178 +++++++ python/sedona/spark/sql/st_functions.py | 137 +++++ .../sedona/snowflake/snowsql/TestFunctions.java | 60 +++ .../sedona/snowflake/snowsql/TestFunctionsV2.java | 60 +++ .../org/apache/sedona/snowflake/snowsql/UDFs.java | 48 ++ .../apache/sedona/snowflake/snowsql/UDFsV2.java | 61 +++ .../scala/org/apache/sedona/sql/UDF/Catalog.scala | 9 + .../sql/sedona_sql/expressions/Functions.scala | 77 +++ .../sql/sedona_sql/expressions/st_functions.scala | 40 ++ .../apache/sedona/sql/dataFrameAPITestScala.scala | 107 ++++ .../sedona/sql/functions/STBingTileFunctions.scala | 254 +++++++++ 19 files changed, 2676 insertions(+) diff --git a/common/src/main/java/org/apache/sedona/common/Functions.java b/common/src/main/java/org/apache/sedona/common/Functions.java index 22b9a2fbf4..ac30e9980f 100644 --- a/common/src/main/java/org/apache/sedona/common/Functions.java +++ b/common/src/main/java/org/apache/sedona/common/Functions.java @@ -1815,6 +1815,128 @@ public class Functions { return polygons.toArray(new Polygon[0]); } + // ========================================================================= + // Bing Tile functions + // ========================================================================= + + /** + * Creates a Bing tile quadkey from tile XY coordinates and zoom level. + * + * @param tileX the tile X coordinate + * @param tileY the tile Y coordinate + * @param zoomLevel the zoom level (1 to 23) + * @return the quadkey string + */ + public static String bingTile(int tileX, int tileY, int zoomLevel) { + return BingTile.fromCoordinates(tileX, tileY, zoomLevel).toQuadKey(); + } + + /** + * Returns the Bing tile quadkey at a given zoom level containing the specified point. + * + * @param longitude the longitude (-180 to 180) + * @param latitude the latitude (-85.05112878 to 85.05112878) + * @param zoomLevel the zoom level (1 to 23) + * @return the quadkey string + */ + public static String bingTileAt(double longitude, double latitude, int zoomLevel) { + return BingTile.fromLatLon(latitude, longitude, zoomLevel).toQuadKey(); + } + + /** + * Returns the 3x3 neighborhood of Bing tiles around the tile containing the specified point. + * + * @param longitude the longitude + * @param latitude the latitude + * @param zoomLevel the zoom level (1 to 23) + * @return array of quadkey strings + */ + public static String[] bingTilesAround(double longitude, double latitude, int zoomLevel) { + List<BingTile> tiles = BingTile.tilesAround(latitude, longitude, zoomLevel); + return tiles.stream().map(BingTile::toQuadKey).toArray(String[]::new); + } + + /** + * Returns the zoom level of a Bing tile quadkey. + * + * @param quadKey the quadkey string + * @return the zoom level + */ + public static int bingTileZoomLevel(String quadKey) { + // Validate the quadkey by parsing it + BingTile.fromQuadKey(quadKey); + return quadKey.length(); + } + + /** + * Returns the X coordinate of a Bing tile from its quadkey. + * + * @param quadKey the quadkey string + * @return the tile X coordinate + */ + public static int bingTileX(String quadKey) { + return BingTile.fromQuadKey(quadKey).getX(); + } + + /** + * Returns the Y coordinate of a Bing tile from its quadkey. + * + * @param quadKey the quadkey string + * @return the tile Y coordinate + */ + public static int bingTileY(String quadKey) { + return BingTile.fromQuadKey(quadKey).getY(); + } + + /** + * Returns the polygon representation of a Bing tile given its quadkey. + * + * @param quadKey the quadkey string + * @return the tile polygon + */ + public static Geometry bingTilePolygon(String quadKey) { + return BingTile.fromQuadKey(quadKey).toPolygon(); + } + + /** + * Returns the quadkey of the Bing tile containing the specified point. (Alias for bingTileAt + * returning string directly.) + * + * @param quadKey the quadkey string + * @return the quadkey string (identity, but validates the input) + */ + public static String bingTileQuadKey(String quadKey) { + // Validate and return. This serves as a validation/normalization function. + return BingTile.fromQuadKey(quadKey).toQuadKey(); + } + + /** + * Returns the minimum set of Bing tile quadkeys that fully cover a given geometry at the + * specified zoom level. + * + * @param geometry the geometry to cover + * @param zoomLevel the zoom level (1 to 23) + * @return array of quadkey strings + */ + public static String[] bingTileCellIDs(Geometry geometry, int zoomLevel) { + List<BingTile> tiles = BingTile.tilesCovering(geometry, zoomLevel); + return tiles.stream().map(BingTile::toQuadKey).toArray(String[]::new); + } + + /** + * Converts an array of Bing tile quadkeys to their polygon geometries. + * + * @param quadKeys the array of quadkey strings + * @return array of polygon geometries + */ + public static Geometry[] bingTileToGeom(String[] quadKeys) { + Geometry[] polygons = new Geometry[quadKeys.length]; + for (int i = 0; i < quadKeys.length; i++) { + polygons[i] = BingTile.fromQuadKey(quadKeys[i]).toPolygon(); + } + return polygons; + } + public static Geometry simplify(Geometry geom, double distanceTolerance) { return DouglasPeuckerSimplifier.simplify(geom, distanceTolerance); } diff --git a/common/src/main/java/org/apache/sedona/common/utils/BingTile.java b/common/src/main/java/org/apache/sedona/common/utils/BingTile.java new file mode 100644 index 0000000000..d3ebf30c93 --- /dev/null +++ b/common/src/main/java/org/apache/sedona/common/utils/BingTile.java @@ -0,0 +1,586 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.sedona.common.utils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Envelope; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Polygon; + +/** + * Represents a Bing Maps tile. Bing Maps uses a quadtree-based tiling system where tiles are + * identified by (x, y, zoomLevel) coordinates or equivalently by a quadkey string. + * + * <p>The implementation is based on the Bing Maps Tile System specification: + * https://docs.microsoft.com/en-us/bingmaps/articles/bing-maps-tile-system + * + * <p>Ported from https://github.com/wwbrannon/bing-tile-hive (Apache-2.0 license), which itself is + * a port of Presto's Bing Tile implementation. Adapted for JTS geometry. + */ +public final class BingTile { + + public static final int MAX_ZOOM_LEVEL = 23; + private static final int TILE_PIXELS = 256; + private static final double MAX_LATITUDE = 85.05112878; + private static final double MIN_LATITUDE = -85.05112878; + private static final double MAX_LONGITUDE = 180; + private static final double MIN_LONGITUDE = -180; + + private static final int OPTIMIZED_TILING_MIN_ZOOM_LEVEL = 10; + + private static final String LATITUDE_OUT_OF_RANGE = + "Latitude must be between " + MIN_LATITUDE + " and " + MAX_LATITUDE; + private static final String LONGITUDE_OUT_OF_RANGE = + "Longitude must be between " + MIN_LONGITUDE + " and " + MAX_LONGITUDE; + private static final String QUAD_KEY_EMPTY = "QuadKey must not be empty string"; + private static final String QUAD_KEY_TOO_LONG = + "QuadKey must be " + MAX_ZOOM_LEVEL + " characters or less"; + private static final String ZOOM_LEVEL_TOO_SMALL = "Zoom level must be > 0"; + private static final String ZOOM_LEVEL_TOO_LARGE = "Zoom level must be <= " + MAX_ZOOM_LEVEL; + + private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory(); + + private final int x; + private final int y; + private final int zoomLevel; + + // ========================================================================= + // Constructors + // ========================================================================= + + private BingTile(int x, int y, int zoomLevel) { + checkZoomLevel(zoomLevel); + checkCoordinate(x, zoomLevel); + checkCoordinate(y, zoomLevel); + this.x = x; + this.y = y; + this.zoomLevel = zoomLevel; + } + + // ========================================================================= + // Validation methods + // ========================================================================= + + private static void checkCondition(boolean condition, String formatString, Object... args) { + if (!condition) { + throw new IllegalArgumentException(String.format(formatString, args)); + } + } + + private static void checkZoomLevel(long zoomLevel) { + checkCondition(zoomLevel > 0, ZOOM_LEVEL_TOO_SMALL); + checkCondition(zoomLevel <= MAX_ZOOM_LEVEL, ZOOM_LEVEL_TOO_LARGE); + } + + private static void checkCoordinate(long coordinate, long zoomLevel) { + checkCondition( + coordinate >= 0 && coordinate < (1 << zoomLevel), + "XY coordinates for a Bing tile at zoom level %s must be within [0, %s) range", + zoomLevel, + 1 << zoomLevel); + } + + private static void checkQuadKey(String quadkey) { + checkCondition(quadkey.length() > 0, QUAD_KEY_EMPTY); + checkCondition(quadkey.length() <= MAX_ZOOM_LEVEL, QUAD_KEY_TOO_LONG); + } + + private static void checkLatitude(double latitude) { + checkCondition(latitude >= MIN_LATITUDE && latitude <= MAX_LATITUDE, LATITUDE_OUT_OF_RANGE); + } + + private static void checkLongitude(double longitude) { + checkCondition( + longitude >= MIN_LONGITUDE && longitude <= MAX_LONGITUDE, LONGITUDE_OUT_OF_RANGE); + } + + // ========================================================================= + // Utility functions for converting to/from latitude/longitude + // ========================================================================= + + private static long mapSize(int zoomLevel) { + return 256L << zoomLevel; + } + + private static double clip(double n, double minValue, double maxValue) { + return Math.min(Math.max(n, minValue), maxValue); + } + + private static int axisToCoordinates(double axis, long mapSize) { + int tileAxis = (int) clip(axis * mapSize, 0, mapSize - 1); + return tileAxis / TILE_PIXELS; + } + + private static int longitudeToTileX(double longitude, long mapSize) { + double x = (longitude + 180) / 360; + return axisToCoordinates(x, mapSize); + } + + private static int latitudeToTileY(double latitude, long mapSize) { + double sinLatitude = Math.sin(latitude * Math.PI / 180); + double y = 0.5 - Math.log((1 + sinLatitude) / (1 - sinLatitude)) / (4 * Math.PI); + return axisToCoordinates(y, mapSize); + } + + /** + * Converts tile XY coordinates to the upper-left corner latitude/longitude of the tile. + * + * @return a Coordinate with x=longitude, y=latitude + */ + private static Coordinate tileXYToLatitudeLongitude(int tileX, int tileY, int zoomLevel) { + long mapSize = mapSize(zoomLevel); + double x = (clip(tileX * TILE_PIXELS, 0, mapSize) / mapSize) - 0.5; + double y = 0.5 - (clip(tileY * TILE_PIXELS, 0, mapSize) / mapSize); + double lat = 90 - 360 * Math.atan(Math.exp(-y * 2 * Math.PI)) / Math.PI; + double lon = 360 * x; + return new Coordinate(lon, lat); + } + + // ========================================================================= + // Factory methods + // ========================================================================= + + /** + * Creates a BingTile from XY coordinates and zoom level. + * + * @param x the tile X coordinate + * @param y the tile Y coordinate + * @param zoomLevel the zoom level (1 to 23) + * @return a new BingTile + */ + public static BingTile fromCoordinates(int x, int y, int zoomLevel) { + return new BingTile(x, y, zoomLevel); + } + + /** + * Creates a BingTile from a latitude/longitude point at the given zoom level. + * + * @param latitude the latitude (-85.05112878 to 85.05112878) + * @param longitude the longitude (-180 to 180) + * @param zoomLevel the zoom level (1 to 23) + * @return a new BingTile + */ + public static BingTile fromLatLon(double latitude, double longitude, int zoomLevel) { + checkLatitude(latitude); + checkLongitude(longitude); + checkZoomLevel(zoomLevel); + + long mapSize = mapSize(zoomLevel); + int tileX = longitudeToTileX(longitude, mapSize); + int tileY = latitudeToTileY(latitude, mapSize); + return new BingTile(tileX, tileY, zoomLevel); + } + + /** + * Creates a BingTile from a quadkey string. + * + * @param quadKey the quadkey string (e.g. "0231") + * @return a new BingTile + */ + public static BingTile fromQuadKey(String quadKey) { + checkQuadKey(quadKey); + + int zoomLevel = quadKey.length(); + checkZoomLevel(zoomLevel); + + int tileX = 0; + int tileY = 0; + + for (int i = zoomLevel; i > 0; i--) { + int mask = 1 << (i - 1); + switch (quadKey.charAt(zoomLevel - i)) { + case '0': + break; + case '1': + tileX |= mask; + break; + case '2': + tileY |= mask; + break; + case '3': + tileX |= mask; + tileY |= mask; + break; + default: + throw new IllegalArgumentException("Invalid QuadKey digit: " + quadKey); + } + } + return new BingTile(tileX, tileY, zoomLevel); + } + + // ========================================================================= + // Accessors + // ========================================================================= + + public int getX() { + return x; + } + + public int getY() { + return y; + } + + public int getZoomLevel() { + return zoomLevel; + } + + // ========================================================================= + // Conversion methods + // ========================================================================= + + /** + * Converts this tile to its quadkey string representation. + * + * @return the quadkey string + */ + public String toQuadKey() { + char[] quadKey = new char[this.zoomLevel]; + for (int i = this.zoomLevel; i > 0; i--) { + char digit = '0'; + int mask = 1 << (i - 1); + if ((this.x & mask) != 0) { + digit++; + } + if ((this.y & mask) != 0) { + digit += 2; + } + quadKey[this.zoomLevel - i] = digit; + } + return String.valueOf(quadKey); + } + + /** + * Returns the bounding box envelope of this tile. + * + * @return the JTS Envelope + */ + public Envelope toEnvelope() { + Coordinate upperLeftCorner = tileXYToLatitudeLongitude(this.x, this.y, this.zoomLevel); + Coordinate lowerRightCorner = tileXYToLatitudeLongitude(this.x + 1, this.y + 1, this.zoomLevel); + + return new Envelope( + upperLeftCorner.x, lowerRightCorner.x, lowerRightCorner.y, upperLeftCorner.y); + } + + /** + * Returns the polygon geometry of this tile. + * + * @return the JTS Polygon + */ + public Polygon toPolygon() { + Envelope envelope = toEnvelope(); + Coordinate[] coordinates = + new Coordinate[] { + new Coordinate(envelope.getMinX(), envelope.getMinY()), + new Coordinate(envelope.getMinX(), envelope.getMaxY()), + new Coordinate(envelope.getMaxX(), envelope.getMaxY()), + new Coordinate(envelope.getMaxX(), envelope.getMinY()), + new Coordinate(envelope.getMinX(), envelope.getMinY()) + }; + return GEOMETRY_FACTORY.createPolygon(coordinates); + } + + // ========================================================================= + // Overridden Object methods + // ========================================================================= + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + BingTile otherTile = (BingTile) other; + return this.x == otherTile.x && this.y == otherTile.y && this.zoomLevel == otherTile.zoomLevel; + } + + @Override + public int hashCode() { + return Objects.hash(x, y, zoomLevel); + } + + @Override + public String toString() { + return toQuadKey(); + } + + // ========================================================================= + // Spatial query methods + // ========================================================================= + + /** + * Returns the tiles around a given latitude/longitude at the specified zoom level. Returns the + * 3x3 neighborhood of tiles centered on the tile containing the point. + * + * @param latitude the latitude + * @param longitude the longitude + * @param zoomLevel the zoom level + * @return list of BingTile objects + */ + public static List<BingTile> tilesAround(double latitude, double longitude, int zoomLevel) { + checkLatitude(latitude); + checkLongitude(longitude); + checkZoomLevel(zoomLevel); + + List<BingTile> ret = new ArrayList<>(); + long mapSize = mapSize(zoomLevel); + long maxTileIndex = (mapSize / TILE_PIXELS) - 1; + + int tileX = longitudeToTileX(longitude, mapSize); + int tileY = latitudeToTileY(latitude, mapSize); + + for (int i = -1; i <= 1; i++) { + for (int j = -1; j <= 1; j++) { + int newX = tileX + i; + int newY = tileY + j; + if (newX >= 0 && newX <= maxTileIndex && newY >= 0 && newY <= maxTileIndex) { + ret.add(BingTile.fromCoordinates(newX, newY, zoomLevel)); + } + } + } + return ret; + } + + /** + * Returns the minimum set of Bing tiles that fully covers a given geometry at the specified zoom + * level. + * + * @param geometry the JTS Geometry to cover + * @param zoomLevel the zoom level (1 to 23) + * @return list of BingTile objects covering the geometry + */ + public static List<BingTile> tilesCovering(Geometry geometry, int zoomLevel) { + checkZoomLevel(zoomLevel); + + List<BingTile> ret = new ArrayList<>(); + if (geometry.isEmpty()) { + return ret; + } + + Envelope envelope = geometry.getEnvelopeInternal(); + + checkLatitude(envelope.getMinY()); + checkLatitude(envelope.getMaxY()); + checkLongitude(envelope.getMinX()); + checkLongitude(envelope.getMaxX()); + + boolean pointOrRectangle = isPointOrRectangle(geometry, envelope); + + BingTile leftUpperTile = fromLatLon(envelope.getMaxY(), envelope.getMinX(), zoomLevel); + BingTile rightLowerTile = getTileCoveringLowerRightCorner(envelope, zoomLevel); + + // XY coordinates start at (0,0) in the left upper corner and increase left to right and + // top to bottom + long tileCount = + (long) (rightLowerTile.getX() - leftUpperTile.getX() + 1) + * (rightLowerTile.getY() - leftUpperTile.getY() + 1); + + checkGeometryToBingTilesLimits(geometry, pointOrRectangle, tileCount); + + if (pointOrRectangle || zoomLevel <= OPTIMIZED_TILING_MIN_ZOOM_LEVEL) { + // Collect tiles covering the bounding box and check each tile for intersection + for (int x = leftUpperTile.getX(); x <= rightLowerTile.getX(); x++) { + for (int y = leftUpperTile.getY(); y <= rightLowerTile.getY(); y++) { + BingTile tile = BingTile.fromCoordinates(x, y, zoomLevel); + if (pointOrRectangle || !tileDisjoint(tile, geometry)) { + ret.add(tile); + } + } + } + } else { + // Optimized tiling: identify large tiles fully covered by geometry then expand + BingTile[] tiles = + getTilesInBetween(leftUpperTile, rightLowerTile, OPTIMIZED_TILING_MIN_ZOOM_LEVEL); + for (BingTile tile : tiles) { + appendIntersectingSubtiles(geometry, zoomLevel, tile, ret); + } + } + + return ret; + } + + // ========================================================================= + // Internal helper methods for tilesCovering + // ========================================================================= + + private static boolean isPointOrRectangle(Geometry geometry, Envelope envelope) { + if (geometry instanceof org.locationtech.jts.geom.Point) { + return true; + } + if (!(geometry instanceof Polygon)) { + return false; + } + Polygon polygon = (Polygon) geometry; + if (polygon.getNumInteriorRing() > 0) { + return false; + } + Coordinate[] coords = polygon.getExteriorRing().getCoordinates(); + // A closed rectangle has 5 coordinates (first == last) + if (coords.length != 5) { + return false; + } + // Check that all corners match the envelope corners + for (int i = 0; i < 4; i++) { + Coordinate c = coords[i]; + boolean matchesCorner = + (c.x == envelope.getMinX() || c.x == envelope.getMaxX()) + && (c.y == envelope.getMinY() || c.y == envelope.getMaxY()); + if (!matchesCorner) { + return false; + } + } + return true; + } + + private static void checkGeometryToBingTilesLimits( + Geometry geometry, boolean pointOrRectangle, long tileCount) { + if (pointOrRectangle) { + checkCondition( + tileCount <= 1_000_000, + "The number of input tiles is too large (more than 1M) to compute a set of covering Bing tiles."); + } else { + checkCondition( + (int) tileCount == tileCount, + "The zoom level is too high to compute a set of covering Bing tiles."); + + long pointCount = geometry.getNumPoints(); + long complexity; + try { + complexity = Math.multiplyExact(tileCount, pointCount); + } catch (ArithmeticException e) { + throw new IllegalArgumentException( + "The zoom level is too high or the geometry is too complex to compute a set of covering Bing tiles. " + + "Please use a lower zoom level or convert the geometry to its bounding box using the ST_Envelope function."); + } + checkCondition( + complexity <= 25_000_000, + "The zoom level is too high or the geometry is too complex to compute a set of covering Bing tiles. " + + "Please use a lower zoom level or convert the geometry to its bounding box using the ST_Envelope function."); + } + } + + private static BingTile getTileCoveringLowerRightCorner(Envelope envelope, int zoomLevel) { + BingTile tile = fromLatLon(envelope.getMinY(), envelope.getMaxX(), zoomLevel); + + // If the tile covering the lower right corner of the envelope overlaps the envelope only + // at the border then return a tile shifted to the left and/or up + int deltaX = 0; + int deltaY = 0; + + Coordinate upperLeftCorner = + tileXYToLatitudeLongitude(tile.getX(), tile.getY(), tile.getZoomLevel()); + if (upperLeftCorner.x == envelope.getMaxX()) { + deltaX = -1; + } + if (upperLeftCorner.y == envelope.getMinY()) { + deltaY = -1; + } + + if (deltaX == 0 && deltaY == 0) { + return tile; + } else { + return BingTile.fromCoordinates( + tile.getX() + deltaX, tile.getY() + deltaY, tile.getZoomLevel()); + } + } + + private static BingTile[] getTilesInBetween( + BingTile leftUpperTile, BingTile rightLowerTile, int zoomLevel) { + checkCondition( + leftUpperTile.getZoomLevel() == rightLowerTile.getZoomLevel(), "Mismatched zoom levels"); + checkCondition(leftUpperTile.getZoomLevel() > zoomLevel, "Tile zoom level too low"); + + int divisor = 1 << (leftUpperTile.getZoomLevel() - zoomLevel); + + int minX = (int) Math.floor((double) leftUpperTile.getX() / divisor); + int maxX = (int) Math.floor((double) rightLowerTile.getX() / divisor); + int minY = (int) Math.floor((double) leftUpperTile.getY() / divisor); + int maxY = (int) Math.floor((double) rightLowerTile.getY() / divisor); + + BingTile[] tiles = new BingTile[(maxX - minX + 1) * (maxY - minY + 1)]; + + int index = 0; + for (int x = minX; x <= maxX; x++) { + for (int y = minY; y <= maxY; y++) { + tiles[index] = BingTile.fromCoordinates(x, y, OPTIMIZED_TILING_MIN_ZOOM_LEVEL); + index++; + } + } + return tiles; + } + + private static boolean tileDisjoint(BingTile tile, Geometry geometry) { + Polygon tilePolygon = tile.toPolygon(); + return tilePolygon.disjoint(geometry); + } + + private static boolean geometryContainsTile(Geometry geometry, BingTile tile) { + Polygon tilePolygon = tile.toPolygon(); + return geometry.contains(tilePolygon); + } + + private static void appendIntersectingSubtiles( + Geometry geometry, int zoomLevel, BingTile tile, List<BingTile> result) { + int tileZoomLevel = tile.getZoomLevel(); + checkCondition(tileZoomLevel <= zoomLevel, "Tile zoom level too high"); + + if (tileZoomLevel == zoomLevel) { + if (!tileDisjoint(tile, geometry)) { + result.add(tile); + } + return; + } + + if (geometryContainsTile(geometry, tile)) { + // Tile is fully contained — add all sub-tiles at the target zoom level + int subTileCount = 1 << (zoomLevel - tileZoomLevel); + int minX = subTileCount * tile.getX(); + int minY = subTileCount * tile.getY(); + + for (int x = minX; x < minX + subTileCount; x++) { + for (int y = minY; y < minY + subTileCount; y++) { + result.add(BingTile.fromCoordinates(x, y, zoomLevel)); + } + } + return; + } + + if (tileDisjoint(tile, geometry)) { + return; + } + + // Recurse into the 4 children + int nextZoomLevel = tileZoomLevel + 1; + int minX = 2 * tile.getX(); + int minY = 2 * tile.getY(); + + for (int x = minX; x < minX + 2; x++) { + for (int y = minY; y < minY + 2; y++) { + appendIntersectingSubtiles( + geometry, zoomLevel, BingTile.fromCoordinates(x, y, nextZoomLevel), result); + } + } + } +} diff --git a/common/src/test/java/org/apache/sedona/common/FunctionsTest.java b/common/src/test/java/org/apache/sedona/common/FunctionsTest.java index 61fd47ae34..6d9e04b65f 100644 --- a/common/src/test/java/org/apache/sedona/common/FunctionsTest.java +++ b/common/src/test/java/org/apache/sedona/common/FunctionsTest.java @@ -5387,4 +5387,315 @@ public class FunctionsTest extends TestBase { // 13 chars * 5 bits = 65 > 64 (MAX_BIT_PRECISION) Functions.geohashNeighbors("0123456789abc"); } + + // ========================================================================= + // Bing Tile function tests + // ========================================================================= + + // --- ST_BingTile (from coordinates and from quadkey) --- + + @Test + public void testBingTileFromCoordinates() { + // bing_tile(3, 5, 3) = quadkey "213" + assertEquals("213", Functions.bingTile(3, 5, 3)); + // bing_tile(21845, 13506, 15) = quadkey "123030123010121" + assertEquals("123030123010121", Functions.bingTile(21845, 13506, 15)); + } + + @Test + public void testBingTileQuadKeyRoundTrip() { + assertEquals("213", Functions.bingTileQuadKey(Functions.bingTile(3, 5, 3))); + assertEquals( + "123030123010121", Functions.bingTileQuadKey(Functions.bingTile(21845, 13506, 15))); + } + + @Test(expected = IllegalArgumentException.class) + public void testBingTileInvalidEmptyQuadKey() { + Functions.bingTileQuadKey(""); + } + + @Test(expected = IllegalArgumentException.class) + public void testBingTileInvalidQuadKeyDigits() { + Functions.bingTileQuadKey("test"); + } + + @Test(expected = IllegalArgumentException.class) + public void testBingTileInvalidQuadKeyDigits2() { + // "12345" contains '4' and '5' which are invalid + Functions.bingTileQuadKey("12345"); + } + + @Test(expected = IllegalArgumentException.class) + public void testBingTileQuadKeyTooLong() { + // quadkey > 23 chars + Functions.bingTileQuadKey("101010101010101010101010101010100101010101001010"); + } + + @Test(expected = IllegalArgumentException.class) + public void testBingTileXYOutOfRange() { + // x=10 at zoom 3 is out of [0, 8) range + Functions.bingTile(10, 2, 3); + } + + @Test(expected = IllegalArgumentException.class) + public void testBingTileXYOutOfRange2() { + // y=10 at zoom 3 is out of [0, 8) range + Functions.bingTile(2, 10, 3); + } + + @Test(expected = IllegalArgumentException.class) + public void testBingTileZoomOutOfRange() { + // zoom=37 > 23 + Functions.bingTile(2, 7, 37); + } + + @Test(expected = IllegalArgumentException.class) + public void testBingTileZoomZero() { + // zoom=0 is invalid + Functions.bingTile(0, 0, 0); + } + + // --- ST_BingTileAt (point to tile) --- + + @Test + public void testBingTileAt() { + // bingTileAt(lon=60, lat=30.12, zoom=15) = tile(21845, 13506, 15) + String qk = Functions.bingTileAt(60, 30.12, 15); + assertEquals("123030123010121", qk); + assertEquals(21845, Functions.bingTileX(qk)); + assertEquals(13506, Functions.bingTileY(qk)); + + // bingTileAt(lon=-0.002, lat=0, zoom=1) = tile(0, 1, 1) + qk = Functions.bingTileAt(-0.002, 0, 1); + assertEquals(0, Functions.bingTileX(qk)); + assertEquals(1, Functions.bingTileY(qk)); + } + + @Test(expected = IllegalArgumentException.class) + public void testBingTileAtLongitudeOutOfRange() { + // longitude 600 is out of range + Functions.bingTileAt(600, 30.12, 15); + } + + @Test(expected = IllegalArgumentException.class) + public void testBingTileAtLatitudeOutOfRange() { + // latitude 300.12 is out of range + Functions.bingTileAt(60, 300.12, 15); + } + + @Test(expected = IllegalArgumentException.class) + public void testBingTileAtZoomTooSmall() { + // zoom=0 + Functions.bingTileAt(60, 30.12, 0); + } + + @Test(expected = IllegalArgumentException.class) + public void testBingTileAtZoomTooLarge() { + // zoom=40 + Functions.bingTileAt(60, 30.12, 40); + } + + // --- ST_BingTileCoordinates (X, Y) --- + + @Test + public void testBingTileCoordinates() { + // bing_tile_coordinates('213') = (3, 5) + assertEquals(3, Functions.bingTileX("213")); + assertEquals(5, Functions.bingTileY("213")); + + // bing_tile_coordinates('123030123010121') = (21845, 13506) + assertEquals(21845, Functions.bingTileX("123030123010121")); + assertEquals(13506, Functions.bingTileY("123030123010121")); + } + + // --- ST_BingTileZoomLevel --- + + @Test + public void testBingTileZoomLevel() { + assertEquals(3, Functions.bingTileZoomLevel("213")); + assertEquals(15, Functions.bingTileZoomLevel("123030123010121")); + } + + // --- ST_BingTilesAround --- + + @Test + public void testBingTilesAround() { + // bingTilesAround(lon=60, lat=30.12, zoom=1) = ["0", "2", "1", "3"] + String[] tiles = Functions.bingTilesAround(60, 30.12, 1); + assertEquals(4, tiles.length); + assertArrayEquals(new String[] {"0", "2", "1", "3"}, tiles); + + // bingTilesAround(lon=60, lat=30.12, zoom=15) = 9 tiles (3x3 neighborhood) + tiles = Functions.bingTilesAround(60, 30.12, 15); + assertEquals(9, tiles.length); + assertArrayEquals( + new String[] { + "123030123010102", + "123030123010120", + "123030123010122", + "123030123010103", + "123030123010121", + "123030123010123", + "123030123010112", + "123030123010130", + "123030123010132" + }, + tiles); + } + + @Test + public void testBingTilesAroundCorner() { + // corner case at (lon=-180, lat=-85.05112878, zoom=1) = all 4 tiles + String[] tiles = Functions.bingTilesAround(-180, -85.05112878, 1); + assertArrayEquals(new String[] {"0", "2", "1", "3"}, tiles); + + // corner at (lon=-180, lat=-85.05112878, zoom=3) = 4 tiles + tiles = Functions.bingTilesAround(-180, -85.05112878, 3); + assertArrayEquals(new String[] {"220", "222", "221", "223"}, tiles); + } + + @Test + public void testBingTilesAroundEdge() { + // edge case at (lon=0, lat=-85.05112878, zoom=2) = 6 tiles + String[] tiles = Functions.bingTilesAround(0, -85.05112878, 2); + assertArrayEquals(new String[] {"21", "23", "30", "32", "31", "33"}, tiles); + + // edge at (lon=0, lat=85.05112878, zoom=2) = 6 tiles + tiles = Functions.bingTilesAround(0, 85.05112878, 2); + assertArrayEquals(new String[] {"01", "03", "10", "12", "11", "13"}, tiles); + } + + // --- ST_BingTilePolygon --- + + @Test + public void testBingTilePolygon() { + // bing_tile_polygon('123030123010121') + Geometry polygon = Functions.bingTilePolygon("123030123010121"); + assertNotNull(polygon); + assertTrue(polygon instanceof Polygon); + assertEquals(5, polygon.getCoordinates().length); + + // Expected envelope for tile '123030123010121' + Envelope env = polygon.getEnvelopeInternal(); + assertEquals(59.996337890625, env.getMinX(), 1e-10); + assertEquals(60.00732421875, env.getMaxX(), 1e-10); + assertEquals(30.11662158281937, env.getMinY(), 1e-10); + assertEquals(30.12612436422458, env.getMaxY(), 1e-10); + } + + @Test + public void testBingTilePolygonBottomRightCorner() { + // bottom-right corner of tile(1,1,1) = (180, -85.05112877980659) + Geometry polygon = Functions.bingTilePolygon(Functions.bingTile(1, 1, 1)); + Envelope env = polygon.getEnvelopeInternal(); + assertEquals(180.0, env.getMaxX(), 1e-10); + assertEquals(-85.05112877980659, env.getMinY(), 1e-10); + + // bottom-right corner of tile(3,3,2) = (180, -85.05112877980659) + polygon = Functions.bingTilePolygon(Functions.bingTile(3, 3, 2)); + env = polygon.getEnvelopeInternal(); + assertEquals(180.0, env.getMaxX(), 1e-10); + assertEquals(-85.05112877980659, env.getMinY(), 1e-10); + } + + @Test + public void testBingTilePolygonOriginCorner() { + // top-left corner of tile(0,0,1) bottom-right = (0, 0) + Geometry polygon = Functions.bingTilePolygon(Functions.bingTile(0, 0, 1)); + Envelope env = polygon.getEnvelopeInternal(); + assertEquals(0.0, env.getMaxX(), 1e-10); + assertEquals(0.0, env.getMinY(), 1e-10); + + // top-left corner of tile(0,0,1) = (-180, 85.05112877980659) + assertEquals(-180.0, env.getMinX(), 1e-10); + assertEquals(85.05112877980659, env.getMaxY(), 1e-10); + } + + // --- ST_BingTileCellIDs (geometry_to_bing_tiles) --- + + @Test + public void testBingTileCellIDsPoint() { + // geometry_to_bing_tiles(POINT(60 30.12), 10) = ["1230301230"] + Geometry point = GEOMETRY_FACTORY.createPoint(new Coordinate(60, 30.12)); + String[] tiles = Functions.bingTileCellIDs(point, 10); + assertArrayEquals(new String[] {"1230301230"}, tiles); + + // geometry_to_bing_tiles(POINT(60 30.12), 15) = ["123030123010121"] + tiles = Functions.bingTileCellIDs(point, 15); + assertArrayEquals(new String[] {"123030123010121"}, tiles); + + // geometry_to_bing_tiles(POINT(60 30.12), 16) = ["1230301230101212"] + tiles = Functions.bingTileCellIDs(point, 16); + assertArrayEquals(new String[] {"1230301230101212"}, tiles); + } + + @Test + public void testBingTileCellIDsPolygon() { + // geometry_to_bing_tiles(POLYGON((0 0, 0 10, 10 10, 10 0)), 6) + Geometry polygon = + GEOMETRY_FACTORY.createPolygon( + coordArray(0.0, 0.0, 0.0, 10.0, 10.0, 10.0, 10.0, 0.0, 0.0, 0.0)); + String[] tiles = Functions.bingTileCellIDs(polygon, 6); + assertArrayEquals(new String[] {"122220", "122222", "122221", "122223"}, tiles); + } + + @Test + public void testBingTileCellIDsTriangle() { + // geometry_to_bing_tiles(POLYGON((10 10, -10 10, -20 -15, 10 10)), 3) + Geometry triangle = + GEOMETRY_FACTORY.createPolygon( + coordArray(10.0, 10.0, -10.0, 10.0, -20.0, -15.0, 10.0, 10.0)); + String[] tiles = Functions.bingTileCellIDs(triangle, 3); + assertArrayEquals(new String[] {"033", "211", "122"}, tiles); + } + + @Test + public void testBingTileCellIDsEmpty() { + // POINT EMPTY, POLYGON EMPTY → empty list + Geometry emptyPoint = GEOMETRY_FACTORY.createPoint(); + assertEquals(0, Functions.bingTileCellIDs(emptyPoint, 10).length); + + Geometry emptyPolygon = GEOMETRY_FACTORY.createPolygon(); + assertEquals(0, Functions.bingTileCellIDs(emptyPolygon, 10).length); + } + + @Test + public void testBingTileCellIDsSelfRoundTrip() { + // geometry_to_bing_tiles(bing_tile_polygon('1230301230'), 10) = ["1230301230"] + Geometry tilePolygon = Functions.bingTilePolygon("1230301230"); + String[] tiles = Functions.bingTileCellIDs(tilePolygon, 10); + assertArrayEquals(new String[] {"1230301230"}, tiles); + + // expanding to zoom 11 = 4 child tiles + tiles = Functions.bingTileCellIDs(tilePolygon, 11); + assertArrayEquals( + new String[] {"12303012300", "12303012302", "12303012301", "12303012303"}, tiles); + } + + @Test(expected = IllegalArgumentException.class) + public void testBingTileCellIDsZoomTooSmall() { + // zoom=0 is invalid + Geometry point = GEOMETRY_FACTORY.createPoint(new Coordinate(60, 30.12)); + Functions.bingTileCellIDs(point, 0); + } + + @Test(expected = IllegalArgumentException.class) + public void testBingTileCellIDsZoomTooLarge() { + // zoom=40 is invalid + Geometry point = GEOMETRY_FACTORY.createPoint(new Coordinate(60, 30.12)); + Functions.bingTileCellIDs(point, 40); + } + + // --- ST_BingTileToGeom --- + + @Test + public void testBingTileToGeom() { + String[] quadkeys = new String[] {"0", "1", "2", "3"}; + Geometry[] polygons = Functions.bingTileToGeom(quadkeys); + assertEquals(4, polygons.length); + for (Geometry g : polygons) { + assertTrue(g instanceof Polygon); + assertEquals(5, g.getCoordinates().length); + } + } } diff --git a/docs/api/flink/Function.md b/docs/api/flink/Function.md index 58871582a1..180ebccc21 100644 --- a/docs/api/flink/Function.md +++ b/docs/api/flink/Function.md @@ -663,6 +663,189 @@ Output: 32618 ``` +## ST_BingTile + +Introduction: Creates a Bing Tile quadkey from tile XY coordinates and a zoom level. + +Format: `ST_BingTile(tileX: Integer, tileY: Integer, zoomLevel: Integer)` + +Since: `v1.9.0` + +Example: + +```sql +SELECT ST_BingTile(3, 5, 3) +``` + +Output: + +``` +213 +``` + +## ST_BingTileAt + +Introduction: Returns the Bing Tile quadkey for a given point (longitude, latitude) at a specified zoom level. + +Format: `ST_BingTileAt(longitude: Double, latitude: Double, zoomLevel: Integer)` + +Since: `v1.9.0` + +Example: + +```sql +SELECT ST_BingTileAt(60, 30.12, 15) +``` + +Output: + +``` +123030123010121 +``` + +## ST_BingTileCellIDs + +Introduction: Returns an array of Bing Tile quadkey strings that cover the given geometry at the specified zoom level. + +Format: `ST_BingTileCellIDs(geom: Geometry, zoomLevel: Integer)` + +Since: `v1.9.0` + +Example: + +```sql +SELECT ST_BingTileCellIDs(ST_GeomFromText('POINT(60 30.12)'), 10) +``` + +Output: + +``` +[1230301230] +``` + +## ST_BingTilePolygon + +Introduction: Returns the bounding polygon (Geometry) of the Bing Tile identified by the given quadkey. + +Format: `ST_BingTilePolygon(quadKey: String)` + +Since: `v1.9.0` + +Example: + +```sql +SELECT ST_AsText(ST_BingTilePolygon('213')) +``` + +Output: + +``` +POLYGON ((0 0, 0 -40.97989806962013, 45 -40.97989806962013, 45 0, 0 0)) +``` + +## ST_BingTilesAround + +Introduction: Returns an array of Bing Tile quadkey strings representing the neighborhood tiles around the tile that contains the given point (longitude, latitude) at the specified zoom level. Returns the 3×3 neighborhood (up to 9 tiles), or fewer tiles at the edges/corners of the map. + +Format: `ST_BingTilesAround(longitude: Double, latitude: Double, zoomLevel: Integer)` + +Since: `v1.9.0` + +Example: + +```sql +SELECT ST_BingTilesAround(60, 30.12, 1) +``` + +Output: + +``` +[0, 2, 1, 3] +``` + +## ST_BingTileToGeom + +Introduction: Returns an array of Polygons for the corresponding Bing Tile quadkeys. + +!!!Hint + To convert a Polygon array to a single geometry, use [ST_Collect](#st_collect). + +Format: `ST_BingTileToGeom(quadKeys: Array[String])` + +Since: `v1.9.0` + +Example: + +```sql +SELECT ST_BingTileToGeom(array('0', '1', '2', '3')) +``` + +Output: + +``` +[POLYGON ((-180 85.05112877980659, -180 0, 0 0, 0 85.05112877980659, -180 85.05112877980659)), ...] +``` + +## ST_BingTileX + +Introduction: Returns the tile X coordinate of the Bing Tile identified by the given quadkey. + +Format: `ST_BingTileX(quadKey: String)` + +Since: `v1.9.0` + +Example: + +```sql +SELECT ST_BingTileX('213') +``` + +Output: + +``` +3 +``` + +## ST_BingTileY + +Introduction: Returns the tile Y coordinate of the Bing Tile identified by the given quadkey. + +Format: `ST_BingTileY(quadKey: String)` + +Since: `v1.9.0` + +Example: + +```sql +SELECT ST_BingTileY('213') +``` + +Output: + +``` +5 +``` + +## ST_BingTileZoomLevel + +Introduction: Returns the zoom level of the Bing Tile identified by the given quadkey. + +Format: `ST_BingTileZoomLevel(quadKey: String)` + +Since: `v1.9.0` + +Example: + +```sql +SELECT ST_BingTileZoomLevel('213') +``` + +Output: + +``` +3 +``` + ## ST_Boundary Introduction: Returns the closure of the combinatorial boundary of this Geometry. diff --git a/docs/api/snowflake/vector-data/Function.md b/docs/api/snowflake/vector-data/Function.md index fdb09919fb..185e009901 100644 --- a/docs/api/snowflake/vector-data/Function.md +++ b/docs/api/snowflake/vector-data/Function.md @@ -497,6 +497,168 @@ Output: 32618 ``` +## ST_BingTile + +Introduction: Creates a Bing Tile quadkey from tile XY coordinates and a zoom level. + +Format: `ST_BingTile(tileX: Int, tileY: Int, zoomLevel: Int)` + +SQL example: + +```sql +SELECT ST_BingTile(3, 5, 3) +``` + +Output: + +``` +213 +``` + +## ST_BingTileAt + +Introduction: Returns the Bing Tile quadkey for a given point (longitude, latitude) at a specified zoom level. + +Format: `ST_BingTileAt(longitude: Double, latitude: Double, zoomLevel: Int)` + +SQL example: + +```sql +SELECT ST_BingTileAt(60, 30.12, 15) +``` + +Output: + +``` +123030123010121 +``` + +## ST_BingTileCellIDs + +Introduction: Returns an array of Bing Tile quadkey strings that cover the given geometry at the specified zoom level. + +Format: `ST_BingTileCellIDs(geom: geometry, zoomLevel: Int)` + +SQL example: + +```sql +SELECT ST_BingTileCellIDs(ST_GeomFromText('POINT(60 30.12)'), 10) +``` + +Output: + +``` +[1230301230] +``` + +## ST_BingTilePolygon + +Introduction: Returns the bounding polygon (Geometry) of the Bing Tile identified by the given quadkey. + +Format: `ST_BingTilePolygon(quadKey: String)` + +SQL example: + +```sql +SELECT ST_AsText(ST_BingTilePolygon('213')) +``` + +Output: + +``` +POLYGON ((0 0, 0 -40.97989806962013, 45 -40.97989806962013, 45 0, 0 0)) +``` + +## ST_BingTilesAround + +Introduction: Returns an array of Bing Tile quadkey strings representing the neighborhood tiles around the tile that contains the given point (longitude, latitude) at the specified zoom level. Returns the 3×3 neighborhood (up to 9 tiles), or fewer tiles at the edges/corners of the map. + +Format: `ST_BingTilesAround(longitude: Double, latitude: Double, zoomLevel: Int)` + +SQL example: + +```sql +SELECT ST_BingTilesAround(60, 30.12, 1) +``` + +Output: + +``` +[0, 2, 1, 3] +``` + +## ST_BingTileToGeom + +Introduction: Returns a GeometryCollection of Polygons for the corresponding Bing Tile quadkeys. + +Format: `ST_BingTileToGeom(quadKeys: Array)` + +SQL example: + +```sql +SELECT ST_BingTileToGeom(ARRAY_CONSTRUCT('0', '1', '2', '3')) +``` + +Output: + +``` +GEOMETRYCOLLECTION (POLYGON ((-180 85.05112877980659, -180 0, 0 0, 0 85.05112877980659, -180 85.05112877980659)), ...) +``` + +## ST_BingTileX + +Introduction: Returns the tile X coordinate of the Bing Tile identified by the given quadkey. + +Format: `ST_BingTileX(quadKey: String)` + +SQL example: + +```sql +SELECT ST_BingTileX('213') +``` + +Output: + +``` +3 +``` + +## ST_BingTileY + +Introduction: Returns the tile Y coordinate of the Bing Tile identified by the given quadkey. + +Format: `ST_BingTileY(quadKey: String)` + +SQL example: + +```sql +SELECT ST_BingTileY('213') +``` + +Output: + +``` +5 +``` + +## ST_BingTileZoomLevel + +Introduction: Returns the zoom level of the Bing Tile identified by the given quadkey. + +Format: `ST_BingTileZoomLevel(quadKey: String)` + +SQL example: + +```sql +SELECT ST_BingTileZoomLevel('213') +``` + +Output: + +``` +3 +``` + ## ST_Boundary Introduction: Returns the closure of the combinatorial boundary of this Geometry. diff --git a/docs/api/sql/Function.md b/docs/api/sql/Function.md index 31a8f3d682..7afea7b69d 100644 --- a/docs/api/sql/Function.md +++ b/docs/api/sql/Function.md @@ -736,6 +736,189 @@ Output: 32618 ``` +## ST_BingTile + +Introduction: Creates a Bing Tile quadkey from tile XY coordinates and a zoom level. + +Format: `ST_BingTile(tileX: Integer, tileY: Integer, zoomLevel: Integer)` + +Since: `v1.9.0` + +SQL Example + +```sql +SELECT ST_BingTile(3, 5, 3) +``` + +Output: + +``` +213 +``` + +## ST_BingTileAt + +Introduction: Returns the Bing Tile quadkey for a given point (longitude, latitude) at a specified zoom level. + +Format: `ST_BingTileAt(longitude: Double, latitude: Double, zoomLevel: Integer)` + +Since: `v1.9.0` + +SQL Example + +```sql +SELECT ST_BingTileAt(60, 30.12, 15) +``` + +Output: + +``` +123030123010121 +``` + +## ST_BingTileCellIDs + +Introduction: Returns an array of Bing Tile quadkey strings that cover the given geometry at the specified zoom level. + +Format: `ST_BingTileCellIDs(geom: Geometry, zoomLevel: Integer)` + +Since: `v1.9.0` + +SQL Example + +```sql +SELECT ST_BingTileCellIDs(ST_GeomFromText('POINT(60 30.12)'), 10) +``` + +Output: + +``` +[1230301230] +``` + +## ST_BingTilePolygon + +Introduction: Returns the bounding polygon (Geometry) of the Bing Tile identified by the given quadkey. + +Format: `ST_BingTilePolygon(quadKey: String)` + +Since: `v1.9.0` + +SQL Example + +```sql +SELECT ST_AsText(ST_BingTilePolygon('213')) +``` + +Output: + +``` +POLYGON ((0 0, 0 -40.97989806962013, 45 -40.97989806962013, 45 0, 0 0)) +``` + +## ST_BingTilesAround + +Introduction: Returns an array of Bing Tile quadkey strings representing the neighborhood tiles around the tile that contains the given point (longitude, latitude) at the specified zoom level. Returns the 3×3 neighborhood (up to 9 tiles), or fewer tiles at the edges/corners of the map. + +Format: `ST_BingTilesAround(longitude: Double, latitude: Double, zoomLevel: Integer)` + +Since: `v1.9.0` + +SQL Example + +```sql +SELECT ST_BingTilesAround(60, 30.12, 1) +``` + +Output: + +``` +[0, 2, 1, 3] +``` + +## ST_BingTileToGeom + +Introduction: Returns an array of Polygons for the corresponding Bing Tile quadkeys. + +!!!Hint + To convert a Polygon array to a single geometry, use [ST_Collect](#st_collect). + +Format: `ST_BingTileToGeom(quadKeys: Array[String])` + +Since: `v1.9.0` + +SQL Example + +```sql +SELECT ST_BingTileToGeom(array('0', '1', '2', '3')) +``` + +Output: + +``` +[POLYGON ((-180 85.05112877980659, -180 0, 0 0, 0 85.05112877980659, -180 85.05112877980659)), ...] +``` + +## ST_BingTileX + +Introduction: Returns the tile X coordinate of the Bing Tile identified by the given quadkey. + +Format: `ST_BingTileX(quadKey: String)` + +Since: `v1.9.0` + +SQL Example + +```sql +SELECT ST_BingTileX('213') +``` + +Output: + +``` +3 +``` + +## ST_BingTileY + +Introduction: Returns the tile Y coordinate of the Bing Tile identified by the given quadkey. + +Format: `ST_BingTileY(quadKey: String)` + +Since: `v1.9.0` + +SQL Example + +```sql +SELECT ST_BingTileY('213') +``` + +Output: + +``` +5 +``` + +## ST_BingTileZoomLevel + +Introduction: Returns the zoom level of the Bing Tile identified by the given quadkey. + +Format: `ST_BingTileZoomLevel(quadKey: String)` + +Since: `v1.9.0` + +SQL Example + +```sql +SELECT ST_BingTileZoomLevel('213') +``` + +Output: + +``` +3 +``` + ## ST_BinaryDistanceBandColumn Introduction: Introduction: Returns a `weights` column containing every record in a dataframe within a specified `threshold` distance. diff --git a/flink/src/main/java/org/apache/sedona/flink/Catalog.java b/flink/src/main/java/org/apache/sedona/flink/Catalog.java index 5a02e2efdb..2d3646c43c 100644 --- a/flink/src/main/java/org/apache/sedona/flink/Catalog.java +++ b/flink/src/main/java/org/apache/sedona/flink/Catalog.java @@ -88,6 +88,15 @@ public class Catalog { new Functions.ST_H3CellIDs(), new Functions.ST_H3KRing(), new Functions.ST_H3ToGeom(), + new Functions.ST_BingTile(), + new Functions.ST_BingTileAt(), + new Functions.ST_BingTilesAround(), + new Functions.ST_BingTileZoomLevel(), + new Functions.ST_BingTileX(), + new Functions.ST_BingTileY(), + new Functions.ST_BingTilePolygon(), + new Functions.ST_BingTileCellIDs(), + new Functions.ST_BingTileToGeom(), new Functions.ST_Dump(), new Functions.ST_DumpPoints(), new Functions.ST_DelaunayTriangles(), diff --git a/flink/src/main/java/org/apache/sedona/flink/expressions/Functions.java b/flink/src/main/java/org/apache/sedona/flink/expressions/Functions.java index c4567a6a98..c5610fe1d6 100644 --- a/flink/src/main/java/org/apache/sedona/flink/expressions/Functions.java +++ b/flink/src/main/java/org/apache/sedona/flink/expressions/Functions.java @@ -2338,6 +2338,95 @@ public class Functions { } } + // ========================================================================= + // Bing Tile functions + // ========================================================================= + + public static class ST_BingTile extends ScalarFunction { + @DataTypeHint("STRING") + public String eval( + @DataTypeHint("INT") Integer tileX, + @DataTypeHint("INT") Integer tileY, + @DataTypeHint("INT") Integer zoomLevel) { + return org.apache.sedona.common.Functions.bingTile(tileX, tileY, zoomLevel); + } + } + + public static class ST_BingTileAt extends ScalarFunction { + @DataTypeHint("STRING") + public String eval( + @DataTypeHint("DOUBLE") Double longitude, + @DataTypeHint("DOUBLE") Double latitude, + @DataTypeHint("INT") Integer zoomLevel) { + return org.apache.sedona.common.Functions.bingTileAt(longitude, latitude, zoomLevel); + } + } + + public static class ST_BingTilesAround extends ScalarFunction { + @DataTypeHint("ARRAY<STRING>") + public String[] eval( + @DataTypeHint("DOUBLE") Double longitude, + @DataTypeHint("DOUBLE") Double latitude, + @DataTypeHint("INT") Integer zoomLevel) { + return org.apache.sedona.common.Functions.bingTilesAround(longitude, latitude, zoomLevel); + } + } + + public static class ST_BingTileZoomLevel extends ScalarFunction { + @DataTypeHint("INT") + public Integer eval(@DataTypeHint("STRING") String quadKey) { + return org.apache.sedona.common.Functions.bingTileZoomLevel(quadKey); + } + } + + public static class ST_BingTileX extends ScalarFunction { + @DataTypeHint("INT") + public Integer eval(@DataTypeHint("STRING") String quadKey) { + return org.apache.sedona.common.Functions.bingTileX(quadKey); + } + } + + public static class ST_BingTileY extends ScalarFunction { + @DataTypeHint("INT") + public Integer eval(@DataTypeHint("STRING") String quadKey) { + return org.apache.sedona.common.Functions.bingTileY(quadKey); + } + } + + public static class ST_BingTilePolygon extends ScalarFunction { + @DataTypeHint( + value = "RAW", + rawSerializer = GeometryTypeSerializer.class, + bridgedTo = Geometry.class) + public Geometry eval(@DataTypeHint("STRING") String quadKey) { + return org.apache.sedona.common.Functions.bingTilePolygon(quadKey); + } + } + + public static class ST_BingTileCellIDs extends ScalarFunction { + @DataTypeHint("ARRAY<STRING>") + public String[] eval( + @DataTypeHint( + value = "RAW", + rawSerializer = GeometryTypeSerializer.class, + bridgedTo = Geometry.class) + Object o, + @DataTypeHint("INT") Integer zoomLevel) { + Geometry geom = (Geometry) o; + return org.apache.sedona.common.Functions.bingTileCellIDs(geom, zoomLevel); + } + } + + public static class ST_BingTileToGeom extends ScalarFunction { + @DataTypeHint( + value = "RAW", + rawSerializer = GeometryArrayTypeSerializer.class, + bridgedTo = Geometry[].class) + public Geometry[] eval(@DataTypeHint("ARRAY<STRING>") String[] quadKeys) { + return org.apache.sedona.common.Functions.bingTileToGeom(quadKeys); + } + } + public static class ST_Simplify extends ScalarFunction { @DataTypeHint( value = "RAW", diff --git a/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java b/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java index 3c9b271941..a348c074d7 100644 --- a/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java +++ b/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java @@ -3077,4 +3077,182 @@ public class FunctionTest extends TestBase { .getField(0); assertEquals("ST_MultiLineString", actual); } + + // ========================================================================= + // Bing Tile function tests + // ========================================================================= + + @Test + public void testBingTile() { + // bing_tile(3, 5, 3) = "213" + String result = (String) first(tableEnv.sqlQuery("SELECT ST_BingTile(3, 5, 3)")).getField(0); + assertEquals("213", result); + + // bing_tile(21845, 13506, 15) = "123030123010121" + result = (String) first(tableEnv.sqlQuery("SELECT ST_BingTile(21845, 13506, 15)")).getField(0); + assertEquals("123030123010121", result); + } + + @Test + public void testBingTileAt() { + // ST_BingTileAt(lon, lat, zoom) — longitude first + // bingTileAt(lon=60, lat=30.12, zoom=15) = tile(21845, 13506, 15) = "123030123010121" + String result = + (String) first(tableEnv.sqlQuery("SELECT ST_BingTileAt(60.0, 30.12, 15)")).getField(0); + assertEquals("123030123010121", result); + + // bingTileAt(lon=-0.002, lat=0, zoom=1) = tile(0, 1, 1) + result = (String) first(tableEnv.sqlQuery("SELECT ST_BingTileAt(-0.002, 0.0, 1)")).getField(0); + int x = (int) first(tableEnv.sqlQuery("SELECT ST_BingTileX('" + result + "')")).getField(0); + int y = (int) first(tableEnv.sqlQuery("SELECT ST_BingTileY('" + result + "')")).getField(0); + assertEquals(0, x); + assertEquals(1, y); + } + + @Test + public void testBingTilesAround() { + // ST_BingTilesAround(lon, lat, zoom) — longitude first + // bingTilesAround(lon=60, lat=30.12, zoom=1) = ["0", "2", "1", "3"] + String[] result = + (String[]) + first(tableEnv.sqlQuery("SELECT ST_BingTilesAround(60.0, 30.12, 1)")).getField(0); + assertArrayEquals(new String[] {"0", "2", "1", "3"}, result); + + // bingTilesAround(lon=60, lat=30.12, zoom=15) = 9 tiles + result = + (String[]) + first(tableEnv.sqlQuery("SELECT ST_BingTilesAround(60.0, 30.12, 15)")).getField(0); + assertArrayEquals( + new String[] { + "123030123010102", + "123030123010120", + "123030123010122", + "123030123010103", + "123030123010121", + "123030123010123", + "123030123010112", + "123030123010130", + "123030123010132" + }, + result); + + // corner at (lon=-180, lat=-85.05112878, zoom=3) = 4 tiles + result = + (String[]) + first(tableEnv.sqlQuery("SELECT ST_BingTilesAround(-180.0, -85.05112878, 3)")) + .getField(0); + assertArrayEquals(new String[] {"220", "222", "221", "223"}, result); + + // edge at (lon=0, lat=-85.05112878, zoom=2) = 6 tiles + result = + (String[]) + first(tableEnv.sqlQuery("SELECT ST_BingTilesAround(0.0, -85.05112878, 2)")).getField(0); + assertArrayEquals(new String[] {"21", "23", "30", "32", "31", "33"}, result); + } + + @Test + public void testBingTileZoomLevel() { + int result = (int) first(tableEnv.sqlQuery("SELECT ST_BingTileZoomLevel('213')")).getField(0); + assertEquals(3, result); + + result = + (int) + first(tableEnv.sqlQuery("SELECT ST_BingTileZoomLevel('123030123010121')")).getField(0); + assertEquals(15, result); + } + + @Test + public void testBingTileXY() { + int x = (int) first(tableEnv.sqlQuery("SELECT ST_BingTileX('213')")).getField(0); + int y = (int) first(tableEnv.sqlQuery("SELECT ST_BingTileY('213')")).getField(0); + assertEquals(3, x); + assertEquals(5, y); + + x = (int) first(tableEnv.sqlQuery("SELECT ST_BingTileX('123030123010121')")).getField(0); + y = (int) first(tableEnv.sqlQuery("SELECT ST_BingTileY('123030123010121')")).getField(0); + assertEquals(21845, x); + assertEquals(13506, y); + + // round-trip bing_tile(x, y, zoom) = original quadkey + String reconstructed = + (String) + first( + tableEnv.sqlQuery( + "SELECT ST_BingTile(ST_BingTileX('123030123010121'), ST_BingTileY('123030123010121'), ST_BingTileZoomLevel('123030123010121'))")) + .getField(0); + assertEquals("123030123010121", reconstructed); + } + + @Test + public void testBingTilePolygon() { + Geometry result = + (Geometry) + first(tableEnv.sqlQuery("SELECT ST_BingTilePolygon('123030123010121')")).getField(0); + assertNotNull(result); + assertTrue(result instanceof Polygon); + Envelope env = result.getEnvelopeInternal(); + assertEquals(59.996337890625, env.getMinX(), 1e-10); + assertEquals(60.00732421875, env.getMaxX(), 1e-10); + assertEquals(30.11662158281937, env.getMinY(), 1e-10); + assertEquals(30.12612436422458, env.getMaxY(), 1e-10); + } + + @Test + public void testBingTileCellIDs() { + // geometry_to_bing_tiles(POINT(60 30.12), 10) = ["1230301230"] + String[] result = + (String[]) + first( + tableEnv.sqlQuery( + "SELECT ST_BingTileCellIDs(ST_GeomFromWKT('POINT (60 30.12)'), 10)")) + .getField(0); + assertArrayEquals(new String[] {"1230301230"}, result); + + // geometry_to_bing_tiles(POINT(60 30.12), 15) = ["123030123010121"] + result = + (String[]) + first( + tableEnv.sqlQuery( + "SELECT ST_BingTileCellIDs(ST_GeomFromWKT('POINT (60 30.12)'), 15)")) + .getField(0); + assertArrayEquals(new String[] {"123030123010121"}, result); + + // geometry_to_bing_tiles(POLYGON((0 0, 0 10, 10 10, 10 0)), 6) + result = + (String[]) + first( + tableEnv.sqlQuery( + "SELECT ST_BingTileCellIDs(ST_GeomFromWKT('POLYGON ((0 0, 0 10, 10 10, 10 0, 0 0))'), 6)")) + .getField(0); + assertArrayEquals(new String[] {"122220", "122222", "122221", "122223"}, result); + + // POINT EMPTY → empty list + result = + (String[]) + first(tableEnv.sqlQuery("SELECT ST_BingTileCellIDs(ST_GeomFromWKT('POINT EMPTY'), 10)")) + .getField(0); + assertEquals(0, result.length); + + // round-trip tile polygon back to the same tile + result = + (String[]) + first( + tableEnv.sqlQuery( + "SELECT ST_BingTileCellIDs(ST_BingTilePolygon('1230301230'), 10)")) + .getField(0); + assertArrayEquals(new String[] {"1230301230"}, result); + } + + @Test + public void testBingTileToGeom() { + Geometry[] result = + (Geometry[]) + first(tableEnv.sqlQuery("SELECT ST_BingTileToGeom(ARRAY['0', '1', '2', '3'])")) + .getField(0); + assertEquals(4, result.length); + for (Geometry g : result) { + assertTrue(g instanceof Polygon); + assertEquals(5, g.getCoordinates().length); + } + } } diff --git a/python/sedona/spark/sql/st_functions.py b/python/sedona/spark/sql/st_functions.py index 34afd113b0..0a52d8c4c2 100644 --- a/python/sedona/spark/sql/st_functions.py +++ b/python/sedona/spark/sql/st_functions.py @@ -917,6 +917,143 @@ def ST_H3ToGeom(cells: Union[ColumnOrName, list]) -> Column: return _call_st_function("ST_H3ToGeom", cells) +# ========================================================================= +# Bing Tile functions +# ========================================================================= + + +@validate_argument_types +def ST_BingTile( + tile_x: Union[ColumnOrName, int], + tile_y: Union[ColumnOrName, int], + zoom_level: Union[ColumnOrName, int], +) -> Column: + """Create a Bing tile quadkey from XY coordinates and zoom level. + :param tile_x: Tile X coordinate + :type tile_x: Union[ColumnOrName, int] + :param tile_y: Tile Y coordinate + :type tile_y: Union[ColumnOrName, int] + :param zoom_level: Zoom level (1 to 23) + :type zoom_level: Union[ColumnOrName, int] + :return: Quadkey string + :rtype: Column + """ + args = (tile_x, tile_y, zoom_level) + return _call_st_function("ST_BingTile", args) + + +@validate_argument_types +def ST_BingTileAt( + longitude: Union[ColumnOrName, float], + latitude: Union[ColumnOrName, float], + zoom_level: Union[ColumnOrName, int], +) -> Column: + """Return the Bing tile quadkey containing the given point at the specified zoom level. + :param longitude: Longitude (-180 to 180) + :type longitude: Union[ColumnOrName, float] + :param latitude: Latitude (-85.05112878 to 85.05112878) + :type latitude: Union[ColumnOrName, float] + :param zoom_level: Zoom level (1 to 23) + :type zoom_level: Union[ColumnOrName, int] + :return: Quadkey string + :rtype: Column + """ + args = (longitude, latitude, zoom_level) + return _call_st_function("ST_BingTileAt", args) + + +@validate_argument_types +def ST_BingTilesAround( + longitude: Union[ColumnOrName, float], + latitude: Union[ColumnOrName, float], + zoom_level: Union[ColumnOrName, int], +) -> Column: + """Return the 3x3 neighborhood of Bing tiles around the tile containing the specified point. + :param longitude: Longitude + :type longitude: Union[ColumnOrName, float] + :param latitude: Latitude + :type latitude: Union[ColumnOrName, float] + :param zoom_level: Zoom level (1 to 23) + :type zoom_level: Union[ColumnOrName, int] + :return: Array of quadkey strings + :rtype: Column + """ + args = (longitude, latitude, zoom_level) + return _call_st_function("ST_BingTilesAround", args) + + +@validate_argument_types +def ST_BingTileZoomLevel(quad_key: ColumnOrName) -> Column: + """Return the zoom level of a Bing tile quadkey. + :param quad_key: Quadkey string + :type quad_key: ColumnOrName + :return: Zoom level + :rtype: Column + """ + return _call_st_function("ST_BingTileZoomLevel", quad_key) + + +@validate_argument_types +def ST_BingTileX(quad_key: ColumnOrName) -> Column: + """Return the X coordinate of a Bing tile from its quadkey. + :param quad_key: Quadkey string + :type quad_key: ColumnOrName + :return: Tile X coordinate + :rtype: Column + """ + return _call_st_function("ST_BingTileX", quad_key) + + +@validate_argument_types +def ST_BingTileY(quad_key: ColumnOrName) -> Column: + """Return the Y coordinate of a Bing tile from its quadkey. + :param quad_key: Quadkey string + :type quad_key: ColumnOrName + :return: Tile Y coordinate + :rtype: Column + """ + return _call_st_function("ST_BingTileY", quad_key) + + +@validate_argument_types +def ST_BingTilePolygon(quad_key: ColumnOrName) -> Column: + """Return the polygon representation of a Bing tile given its quadkey. + :param quad_key: Quadkey string + :type quad_key: ColumnOrName + :return: Tile polygon geometry + :rtype: Column + """ + return _call_st_function("ST_BingTilePolygon", quad_key) + + +@validate_argument_types +def ST_BingTileCellIDs( + geometry: ColumnOrName, + zoom_level: Union[ColumnOrName, int], +) -> Column: + """Return the minimum set of Bing tile quadkeys that fully cover a given geometry at the specified zoom level. + :param geometry: Geometry to cover + :type geometry: ColumnOrName + :param zoom_level: Zoom level (1 to 23) + :type zoom_level: Union[ColumnOrName, int] + :return: Array of quadkey strings + :rtype: Column + """ + args = (geometry, zoom_level) + return _call_st_function("ST_BingTileCellIDs", args) + + +@validate_argument_types +def ST_BingTileToGeom(quad_keys: Union[ColumnOrName, list]) -> Column: + """Convert an array of Bing tile quadkeys to their polygon geometries. + :param quad_keys: Array of quadkey strings + :type quad_keys: Union[ColumnOrName, list] + :return: Array of polygon geometries + :rtype: Column + """ + return _call_st_function("ST_BingTileToGeom", quad_keys) + + @validate_argument_types def ST_InteriorRingN(polygon: ColumnOrName, n: Union[ColumnOrName, int]) -> Column: """Return the index n (0-th based) interior ring of a polygon geometry column. diff --git a/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctions.java b/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctions.java index b1f24e15f1..2c226f70ba 100644 --- a/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctions.java +++ b/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctions.java @@ -1403,4 +1403,64 @@ public class TestFunctions extends TestBase { "SELECT sedona.GeometryType(sedona.ST_ApproximateMedialAxis(sedona.ST_GeomFromWKT('POLYGON ((0 0, 10 0, 10 5, 5 5, 5 10, 0 10, 0 0))'), 100))", "MULTILINESTRING"); } + + @Test + public void test_ST_BingTile() { + registerUDF("ST_BingTile", int.class, int.class, int.class); + verifySqlSingleRes("select sedona.ST_BingTile(3, 5, 3)", "213"); + } + + @Test + public void test_ST_BingTileAt() { + registerUDF("ST_BingTileAt", double.class, double.class, int.class); + verifySqlSingleRes("select sedona.ST_BingTileAt(60, 30.12, 15)", "123030123010121"); + } + + @Test + public void test_ST_BingTilesAround() { + registerUDF("ST_BingTilesAround", double.class, double.class, int.class); + verifySqlSingleRes("select ARRAY_SIZE(sedona.ST_BingTilesAround(60, 30.12, 15))", 9); + } + + @Test + public void test_ST_BingTileZoomLevel() { + registerUDF("ST_BingTileZoomLevel", String.class); + verifySqlSingleRes("select sedona.ST_BingTileZoomLevel('213')", 3); + } + + @Test + public void test_ST_BingTileX() { + registerUDF("ST_BingTileX", String.class); + verifySqlSingleRes("select sedona.ST_BingTileX('213')", 3); + } + + @Test + public void test_ST_BingTileY() { + registerUDF("ST_BingTileY", String.class); + verifySqlSingleRes("select sedona.ST_BingTileY('213')", 5); + } + + @Test + public void test_ST_BingTilePolygon() { + registerUDF("ST_BingTilePolygon", String.class); + registerUDF("GeometryType", byte[].class); + verifySqlSingleRes("select sedona.GeometryType(sedona.ST_BingTilePolygon('213'))", "POLYGON"); + } + + @Test + public void test_ST_BingTileCellIDs() { + registerUDF("ST_BingTileCellIDs", byte[].class, int.class); + verifySqlSingleRes( + "select ARRAY_SIZE(sedona.ST_BingTileCellIDs(sedona.ST_GeomFromText('POINT(60 30.12)'), 10))", + 1); + } + + @Test + public void test_ST_BingTileToGeom() { + registerUDF("ST_BingTileToGeom", String[].class); + registerUDF("GeometryType", byte[].class); + verifySqlSingleRes( + "select sedona.GeometryType(sedona.ST_BingTileToGeom(ARRAY_CONSTRUCT('0', '1')))", + "GEOMETRYCOLLECTION"); + } } diff --git a/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctionsV2.java b/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctionsV2.java index ae26fedad2..67bc09b187 100644 --- a/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctionsV2.java +++ b/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctionsV2.java @@ -1344,4 +1344,64 @@ public class TestFunctionsV2 extends TestBase { "select sedona.GeometryType(sedona.ST_ApproximateMedialAxis(ST_GeometryFromWKT('POLYGON ((0 0, 10 0, 10 5, 5 5, 5 10, 0 10, 0 0))'), 100))", "MULTILINESTRING"); } + + @Test + public void test_ST_BingTile() { + registerUDFV2("ST_BingTile", int.class, int.class, int.class); + verifySqlSingleRes("select sedona.ST_BingTile(3, 5, 3)", "213"); + } + + @Test + public void test_ST_BingTileAt() { + registerUDFV2("ST_BingTileAt", double.class, double.class, int.class); + verifySqlSingleRes("select sedona.ST_BingTileAt(60, 30.12, 15)", "123030123010121"); + } + + @Test + public void test_ST_BingTilesAround() { + registerUDFV2("ST_BingTilesAround", double.class, double.class, int.class); + verifySqlSingleRes("select ARRAY_SIZE(sedona.ST_BingTilesAround(60, 30.12, 15))", 9); + } + + @Test + public void test_ST_BingTileZoomLevel() { + registerUDFV2("ST_BingTileZoomLevel", String.class); + verifySqlSingleRes("select sedona.ST_BingTileZoomLevel('213')", 3); + } + + @Test + public void test_ST_BingTileX() { + registerUDFV2("ST_BingTileX", String.class); + verifySqlSingleRes("select sedona.ST_BingTileX('213')", 3); + } + + @Test + public void test_ST_BingTileY() { + registerUDFV2("ST_BingTileY", String.class); + verifySqlSingleRes("select sedona.ST_BingTileY('213')", 5); + } + + @Test + public void test_ST_BingTilePolygon() { + registerUDFV2("ST_BingTilePolygon", String.class); + registerUDFV2("GeometryType", String.class); + verifySqlSingleRes("select sedona.GeometryType(sedona.ST_BingTilePolygon('213'))", "POLYGON"); + } + + @Test + public void test_ST_BingTileCellIDs() { + registerUDFV2("ST_BingTileCellIDs", String.class, int.class); + verifySqlSingleRes( + "select ARRAY_SIZE(sedona.ST_BingTileCellIDs(ST_GeometryFromWKT('POINT(60 30.12)'), 10))", + 1); + } + + @Test + public void test_ST_BingTileToGeom() { + registerUDFV2("ST_BingTileToGeom", String[].class); + registerUDFV2("GeometryType", String.class); + verifySqlSingleRes( + "select sedona.GeometryType(sedona.ST_BingTileToGeom(ARRAY_CONSTRUCT('0', '1')))", + "GEOMETRYCOLLECTION"); + } } diff --git a/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFs.java b/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFs.java index b4cde280c4..a7d98082ea 100644 --- a/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFs.java +++ b/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFs.java @@ -1418,4 +1418,52 @@ public class UDFs { return GeometrySerde.serialize( Functions.rotate(GeometrySerde.deserialize(geom), angle, originX, originY)); } + + // Bing Tile functions + + @UDFAnnotations.ParamMeta(argNames = {"tileX", "tileY", "zoomLevel"}) + public static String ST_BingTile(int tileX, int tileY, int zoomLevel) { + return Functions.bingTile(tileX, tileY, zoomLevel); + } + + @UDFAnnotations.ParamMeta(argNames = {"longitude", "latitude", "zoomLevel"}) + public static String ST_BingTileAt(double longitude, double latitude, int zoomLevel) { + return Functions.bingTileAt(longitude, latitude, zoomLevel); + } + + @UDFAnnotations.ParamMeta(argNames = {"longitude", "latitude", "zoomLevel"}) + public static String[] ST_BingTilesAround(double longitude, double latitude, int zoomLevel) { + return Functions.bingTilesAround(longitude, latitude, zoomLevel); + } + + @UDFAnnotations.ParamMeta(argNames = {"quadKey"}) + public static int ST_BingTileZoomLevel(String quadKey) { + return Functions.bingTileZoomLevel(quadKey); + } + + @UDFAnnotations.ParamMeta(argNames = {"quadKey"}) + public static int ST_BingTileX(String quadKey) { + return Functions.bingTileX(quadKey); + } + + @UDFAnnotations.ParamMeta(argNames = {"quadKey"}) + public static int ST_BingTileY(String quadKey) { + return Functions.bingTileY(quadKey); + } + + @UDFAnnotations.ParamMeta(argNames = {"quadKey"}) + public static byte[] ST_BingTilePolygon(String quadKey) { + return GeometrySerde.serialize(Functions.bingTilePolygon(quadKey)); + } + + @UDFAnnotations.ParamMeta(argNames = {"geometry", "zoomLevel"}) + public static String[] ST_BingTileCellIDs(byte[] geometry, int zoomLevel) { + return Functions.bingTileCellIDs(GeometrySerde.deserialize(geometry), zoomLevel); + } + + @UDFAnnotations.ParamMeta(argNames = {"quadKeys"}) + public static byte[] ST_BingTileToGeom(String[] quadKeys) { + Geometry[] geoms = Functions.bingTileToGeom(quadKeys); + return GeometrySerde.serialize(GeometrySerde.GEOMETRY_FACTORY.createGeometryCollection(geoms)); + } } diff --git a/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFsV2.java b/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFsV2.java index 7cff529ed3..8fcc980d8a 100644 --- a/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFsV2.java +++ b/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFsV2.java @@ -1751,4 +1751,65 @@ public class UDFsV2 { return GeometrySerde.serGeoJson( Functions.rotate(GeometrySerde.deserGeoJson(geom), angle, originX, originY)); } + + // Bing Tile functions + + @UDFAnnotations.ParamMeta(argNames = {"tileX", "tileY", "zoomLevel"}) + public static String ST_BingTile(int tileX, int tileY, int zoomLevel) { + return Functions.bingTile(tileX, tileY, zoomLevel); + } + + @UDFAnnotations.ParamMeta(argNames = {"longitude", "latitude", "zoomLevel"}) + public static String ST_BingTileAt(double longitude, double latitude, int zoomLevel) { + return Functions.bingTileAt(longitude, latitude, zoomLevel); + } + + @UDFAnnotations.ParamMeta(argNames = {"longitude", "latitude", "zoomLevel"}) + public static String[] ST_BingTilesAround(double longitude, double latitude, int zoomLevel) { + return Functions.bingTilesAround(longitude, latitude, zoomLevel); + } + + @UDFAnnotations.ParamMeta( + argNames = {"quadKey"}, + argTypes = {"String"}) + public static int ST_BingTileZoomLevel(String quadKey) { + return Functions.bingTileZoomLevel(quadKey); + } + + @UDFAnnotations.ParamMeta( + argNames = {"quadKey"}, + argTypes = {"String"}) + public static int ST_BingTileX(String quadKey) { + return Functions.bingTileX(quadKey); + } + + @UDFAnnotations.ParamMeta( + argNames = {"quadKey"}, + argTypes = {"String"}) + public static int ST_BingTileY(String quadKey) { + return Functions.bingTileY(quadKey); + } + + @UDFAnnotations.ParamMeta( + argNames = {"quadKey"}, + argTypes = {"String"}, + returnTypes = "Geometry") + public static String ST_BingTilePolygon(String quadKey) { + return GeometrySerde.serGeoJson(Functions.bingTilePolygon(quadKey)); + } + + @UDFAnnotations.ParamMeta( + argNames = {"geometry", "zoomLevel"}, + argTypes = {"Geometry", "int"}) + public static String[] ST_BingTileCellIDs(String geometry, int zoomLevel) { + return Functions.bingTileCellIDs(GeometrySerde.deserGeoJson(geometry), zoomLevel); + } + + @UDFAnnotations.ParamMeta( + argNames = {"quadKeys"}, + returnTypes = "Geometry") + public static String ST_BingTileToGeom(String[] quadKeys) { + Geometry[] geoms = Functions.bingTileToGeom(quadKeys); + return GeometrySerde.serGeoJson(GeometrySerde.GEOMETRY_FACTORY.createGeometryCollection(geoms)); + } } diff --git a/spark/common/src/main/scala/org/apache/sedona/sql/UDF/Catalog.scala b/spark/common/src/main/scala/org/apache/sedona/sql/UDF/Catalog.scala index a2d483398a..1c20dc5577 100644 --- a/spark/common/src/main/scala/org/apache/sedona/sql/UDF/Catalog.scala +++ b/spark/common/src/main/scala/org/apache/sedona/sql/UDF/Catalog.scala @@ -153,6 +153,15 @@ object Catalog extends AbstractCatalog with Logging { function[ST_H3CellIDs](), function[ST_H3ToGeom](), function[ST_H3KRing](), + function[ST_BingTile](), + function[ST_BingTileAt](), + function[ST_BingTilesAround](), + function[ST_BingTileZoomLevel](), + function[ST_BingTileX](), + function[ST_BingTileY](), + function[ST_BingTilePolygon](), + function[ST_BingTileCellIDs](), + function[ST_BingTileToGeom](), function[ST_InteriorRingN](), function[ST_InterpolatePoint](), function[ST_Dump](), diff --git a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala index da470ef6ff..31beddc720 100644 --- a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala +++ b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala @@ -1547,6 +1547,83 @@ private[apache] case class ST_H3ToGeom(inputExpressions: Seq[Expression]) } } +// ========================================================================= +// Bing Tile expressions +// ========================================================================= + +private[apache] case class ST_BingTile(inputExpressions: Seq[Expression]) + extends InferredExpression(Functions.bingTile _) { + + protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { + copy(inputExpressions = newChildren) + } +} + +private[apache] case class ST_BingTileAt(inputExpressions: Seq[Expression]) + extends InferredExpression(Functions.bingTileAt _) { + + protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { + copy(inputExpressions = newChildren) + } +} + +private[apache] case class ST_BingTilesAround(inputExpressions: Seq[Expression]) + extends InferredExpression(Functions.bingTilesAround _) { + + protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { + copy(inputExpressions = newChildren) + } +} + +private[apache] case class ST_BingTileZoomLevel(inputExpressions: Seq[Expression]) + extends InferredExpression(Functions.bingTileZoomLevel _) { + + protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { + copy(inputExpressions = newChildren) + } +} + +private[apache] case class ST_BingTileX(inputExpressions: Seq[Expression]) + extends InferredExpression(Functions.bingTileX _) { + + protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { + copy(inputExpressions = newChildren) + } +} + +private[apache] case class ST_BingTileY(inputExpressions: Seq[Expression]) + extends InferredExpression(Functions.bingTileY _) { + + protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { + copy(inputExpressions = newChildren) + } +} + +private[apache] case class ST_BingTilePolygon(inputExpressions: Seq[Expression]) + extends InferredExpression(Functions.bingTilePolygon _) { + + protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { + copy(inputExpressions = newChildren) + } +} + +private[apache] case class ST_BingTileCellIDs(inputExpressions: Seq[Expression]) + extends InferredExpression(Functions.bingTileCellIDs _) { + + protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { + copy(inputExpressions = newChildren) + } +} + +private[apache] case class ST_BingTileToGeom(inputExpressions: Seq[Expression]) + extends InferredExpression(Functions.bingTileToGeom _) + with FoldableExpression { + + protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { + copy(inputExpressions = newChildren) + } +} + private[apache] case class ST_CollectionExtract(inputExpressions: Seq[Expression]) extends InferredExpression(InferrableFunction.allowRightNull(Functions.collectionExtract)) { diff --git a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_functions.scala b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_functions.scala index 818055b906..f79fc78be5 100644 --- a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_functions.scala +++ b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_functions.scala @@ -291,6 +291,46 @@ object st_functions { def ST_H3ToGeom(cellIds: Array[Long]): Column = wrapExpression[ST_H3ToGeom](cellIds) + // Bing Tile functions + def ST_BingTile(tileX: Column, tileY: Column, zoomLevel: Column): Column = + wrapExpression[ST_BingTile](tileX, tileY, zoomLevel) + def ST_BingTile(tileX: Int, tileY: Int, zoomLevel: Int): Column = + wrapExpression[ST_BingTile](tileX, tileY, zoomLevel) + + def ST_BingTileAt(longitude: Column, latitude: Column, zoomLevel: Column): Column = + wrapExpression[ST_BingTileAt](longitude, latitude, zoomLevel) + def ST_BingTileAt(longitude: Double, latitude: Double, zoomLevel: Int): Column = + wrapExpression[ST_BingTileAt](longitude, latitude, zoomLevel) + + def ST_BingTilesAround(longitude: Column, latitude: Column, zoomLevel: Column): Column = + wrapExpression[ST_BingTilesAround](longitude, latitude, zoomLevel) + def ST_BingTilesAround(longitude: Double, latitude: Double, zoomLevel: Int): Column = + wrapExpression[ST_BingTilesAround](longitude, latitude, zoomLevel) + + def ST_BingTileZoomLevel(quadKey: Column): Column = + wrapExpression[ST_BingTileZoomLevel](quadKey) + def ST_BingTileZoomLevel(quadKey: String): Column = + wrapExpression[ST_BingTileZoomLevel](quadKey) + + def ST_BingTileX(quadKey: Column): Column = wrapExpression[ST_BingTileX](quadKey) + def ST_BingTileX(quadKey: String): Column = wrapExpression[ST_BingTileX](quadKey) + + def ST_BingTileY(quadKey: Column): Column = wrapExpression[ST_BingTileY](quadKey) + def ST_BingTileY(quadKey: String): Column = wrapExpression[ST_BingTileY](quadKey) + + def ST_BingTilePolygon(quadKey: Column): Column = + wrapExpression[ST_BingTilePolygon](quadKey) + def ST_BingTilePolygon(quadKey: String): Column = + wrapExpression[ST_BingTilePolygon](quadKey) + + def ST_BingTileCellIDs(geometry: Column, zoomLevel: Column): Column = + wrapExpression[ST_BingTileCellIDs](geometry, zoomLevel) + def ST_BingTileCellIDs(geometry: String, zoomLevel: Int): Column = + wrapExpression[ST_BingTileCellIDs](geometry, zoomLevel) + + def ST_BingTileToGeom(quadKeys: Column): Column = + wrapExpression[ST_BingTileToGeom](quadKeys) + def ST_InteriorRingN(polygon: Column, n: Column): Column = wrapExpression[ST_InteriorRingN](polygon, n) def ST_InteriorRingN(polygon: String, n: Int): Column = diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/dataFrameAPITestScala.scala b/spark/common/src/test/scala/org/apache/sedona/sql/dataFrameAPITestScala.scala index 8cdd05da15..d31da962dc 100644 --- a/spark/common/src/test/scala/org/apache/sedona/sql/dataFrameAPITestScala.scala +++ b/spark/common/src/test/scala/org/apache/sedona/sql/dataFrameAPITestScala.scala @@ -2659,5 +2659,112 @@ class dataFrameAPITestScala extends TestBaseScala { val medialAxis = result.asInstanceOf[Geometry] assert(medialAxis.getGeometryType == "MultiLineString") } + + // Bing Tile functions + it("Passed ST_BingTile with literal overload") { + val df = sparkSession.sql("SELECT 1 AS dummy").select(ST_BingTile(3, 5, 3)) + assert(df.first().getString(0) == "213") + } + + it("Passed ST_BingTile with Column overload") { + val df = sparkSession + .sql("SELECT 3 AS tileX, 5 AS tileY, 3 AS zoom") + .select(ST_BingTile(col("tileX"), col("tileY"), col("zoom"))) + assert(df.first().getString(0) == "213") + } + + it("Passed ST_BingTileAt with literal overload") { + val df = sparkSession.sql("SELECT 1 AS dummy").select(ST_BingTileAt(60.0, 30.12, 15)) + assert(df.first().getString(0) == "123030123010121") + } + + it("Passed ST_BingTileAt with Column overload") { + val df = sparkSession + .sql("SELECT 60.0 AS lon, 30.12 AS lat, 15 AS zoom") + .select(ST_BingTileAt(col("lon"), col("lat"), col("zoom"))) + assert(df.first().getString(0) == "123030123010121") + } + + it("Passed ST_BingTilesAround with literal overload") { + val df = sparkSession.sql("SELECT 1 AS dummy").select(ST_BingTilesAround(60.0, 30.12, 1)) + val result = df.first().getAs[WrappedArray[String]](0) + assert(result == WrappedArray.make(Array("0", "2", "1", "3"))) + } + + it("Passed ST_BingTilesAround with Column overload") { + val df = sparkSession + .sql("SELECT 60.0 AS lon, 30.12 AS lat, 1 AS zoom") + .select(ST_BingTilesAround(col("lon"), col("lat"), col("zoom"))) + val result = df.first().getAs[WrappedArray[String]](0) + assert(result == WrappedArray.make(Array("0", "2", "1", "3"))) + } + + it("Passed ST_BingTileZoomLevel with String column name") { + val df = sparkSession + .sql("SELECT '123030123010121' AS qk") + .select(ST_BingTileZoomLevel("qk")) + assert(df.first().getInt(0) == 15) + } + + it("Passed ST_BingTileZoomLevel with Column overload") { + val df = sparkSession + .sql("SELECT '123030123010121' AS qk") + .select(ST_BingTileZoomLevel(col("qk"))) + assert(df.first().getInt(0) == 15) + } + + it("Passed ST_BingTileX with String column name") { + val df = sparkSession.sql("SELECT '213' AS qk").select(ST_BingTileX("qk")) + assert(df.first().getInt(0) == 3) + } + + it("Passed ST_BingTileX with Column overload") { + val df = sparkSession.sql("SELECT '213' AS qk").select(ST_BingTileX(col("qk"))) + assert(df.first().getInt(0) == 3) + } + + it("Passed ST_BingTileY with String column name") { + val df = sparkSession.sql("SELECT '213' AS qk").select(ST_BingTileY("qk")) + assert(df.first().getInt(0) == 5) + } + + it("Passed ST_BingTileY with Column overload") { + val df = sparkSession.sql("SELECT '213' AS qk").select(ST_BingTileY(col("qk"))) + assert(df.first().getInt(0) == 5) + } + + it("Passed ST_BingTilePolygon with String column name") { + val df = sparkSession + .sql("SELECT '123030123010121' AS qk") + .select(ST_BingTilePolygon("qk")) + val geom = df.first().get(0).asInstanceOf[Geometry] + assert(geom != null) + assert(geom.toText.startsWith("POLYGON")) + } + + it("Passed ST_BingTilePolygon with Column overload") { + val df = sparkSession + .sql("SELECT '123030123010121' AS qk") + .select(ST_BingTilePolygon(col("qk"))) + val geom = df.first().get(0).asInstanceOf[Geometry] + assert(geom != null) + assert(geom.toText.startsWith("POLYGON")) + } + + it("Passed ST_BingTileCellIDs with Column overload") { + val df = sparkSession + .sql("SELECT ST_GeomFromWKT('POINT (60 30.12)') AS geom, 10 AS zoom") + .select(ST_BingTileCellIDs(col("geom"), col("zoom"))) + val result = df.first().getAs[WrappedArray[String]](0) + assert(result == WrappedArray.make(Array("1230301230"))) + } + + it("Passed ST_BingTileToGeom with Column overload") { + val df = sparkSession + .sql("SELECT array('0', '1', '2', '3') AS qks") + .select(ST_BingTileToGeom(col("qks"))) + val result = df.first().getAs[WrappedArray[Any]](0) + assert(result.length == 4) + } } } diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/functions/STBingTileFunctions.scala b/spark/common/src/test/scala/org/apache/sedona/sql/functions/STBingTileFunctions.scala new file mode 100644 index 0000000000..ba49646a7f --- /dev/null +++ b/spark/common/src/test/scala/org/apache/sedona/sql/functions/STBingTileFunctions.scala @@ -0,0 +1,254 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.sedona.sql.functions + +import org.apache.sedona.sql.{GeometrySample, TestBaseScala} +import org.apache.spark.sql.functions.expr +import org.scalatest.{GivenWhenThen, Matchers} + +import scala.collection.mutable + +// Bing Tile function tests +class STBingTileFunctions + extends TestBaseScala + with Matchers + with GeometrySample + with GivenWhenThen { + import sparkSession.implicits._ + + describe("should pass ST_BingTile") { + + it("should create tile (3,5,3) as quadkey '213'") { + val result = sparkSession.sql("SELECT ST_BingTile(3, 5, 3)").collect()(0).getString(0) + result should equal("213") + } + + it("should create tile (21845,13506,15) as quadkey '123030123010121'") { + val result = + sparkSession.sql("SELECT ST_BingTile(21845, 13506, 15)").collect()(0).getString(0) + result should equal("123030123010121") + } + } + + describe("should pass ST_BingTileAt") { + + // ST_BingTileAt(lon, lat, zoom) — longitude first + it("should return quadkey '123030123010121' for (lon=60, lat=30.12, zoom=15)") { + val result = + sparkSession.sql("SELECT ST_BingTileAt(60.0, 30.12, 15)").collect()(0).getString(0) + result should equal("123030123010121") + } + + it("should return tile(0,1,1) for (lon=-0.002, lat=0, zoom=1)") { + val qk = sparkSession.sql("SELECT ST_BingTileAt(-0.002, 0.0, 1)").collect()(0).getString(0) + val x = sparkSession.sql(s"SELECT ST_BingTileX('$qk')").collect()(0).getInt(0) + val y = sparkSession.sql(s"SELECT ST_BingTileY('$qk')").collect()(0).getInt(0) + x should equal(0) + y should equal(1) + } + } + + describe("should pass ST_BingTilesAround") { + + // ST_BingTilesAround(lon, lat, zoom) — longitude first + it("should return all 4 tiles at zoom 1 for (lon=60, lat=30.12)") { + val result = sparkSession + .sql("SELECT ST_BingTilesAround(60.0, 30.12, 1)") + .collect()(0) + .getAs[mutable.WrappedArray[String]](0) + result should equal(mutable.WrappedArray.make(Array("0", "2", "1", "3"))) + } + + it("should return 9 tiles at zoom 15 for (lon=60, lat=30.12)") { + val result = sparkSession + .sql("SELECT ST_BingTilesAround(60.0, 30.12, 15)") + .collect()(0) + .getAs[mutable.WrappedArray[String]](0) + result should equal( + mutable.WrappedArray.make(Array( + "123030123010102", + "123030123010120", + "123030123010122", + "123030123010103", + "123030123010121", + "123030123010123", + "123030123010112", + "123030123010130", + "123030123010132"))) + } + + it("should return 4 tiles at corner (lon=-180, lat=-85.05112878, zoom=3)") { + val result = sparkSession + .sql("SELECT ST_BingTilesAround(-180.0, -85.05112878, 3)") + .collect()(0) + .getAs[mutable.WrappedArray[String]](0) + result should equal(mutable.WrappedArray.make(Array("220", "222", "221", "223"))) + } + + it("should return 6 tiles at edge (lon=0, lat=-85.05112878, zoom=2)") { + val result = sparkSession + .sql("SELECT ST_BingTilesAround(0.0, -85.05112878, 2)") + .collect()(0) + .getAs[mutable.WrappedArray[String]](0) + result should equal(mutable.WrappedArray.make(Array("21", "23", "30", "32", "31", "33"))) + } + } + + describe("should pass ST_BingTileZoomLevel") { + + it("should return zoom level 3 for quadkey '213'") { + val result = + sparkSession.sql("SELECT ST_BingTileZoomLevel('213')").collect()(0).getInt(0) + result should equal(3) + } + + it("should return zoom level 15 for quadkey '123030123010121'") { + val result = sparkSession + .sql("SELECT ST_BingTileZoomLevel('123030123010121')") + .collect()(0) + .getInt(0) + result should equal(15) + } + } + + describe("should pass ST_BingTileX and ST_BingTileY") { + + it("should return X=3 and Y=5 for quadkey '213'") { + val x = sparkSession.sql("SELECT ST_BingTileX('213')").collect()(0).getInt(0) + val y = sparkSession.sql("SELECT ST_BingTileY('213')").collect()(0).getInt(0) + x should equal(3) + y should equal(5) + } + + it("should return X=21845 and Y=13506 for quadkey '123030123010121'") { + val x = + sparkSession.sql("SELECT ST_BingTileX('123030123010121')").collect()(0).getInt(0) + val y = + sparkSession.sql("SELECT ST_BingTileY('123030123010121')").collect()(0).getInt(0) + x should equal(21845) + y should equal(13506) + } + + it("should round-trip via ST_BingTile and ST_BingTileX/Y") { + val result = sparkSession + .sql("SELECT ST_BingTile(ST_BingTileX('123030123010121'), ST_BingTileY('123030123010121'), ST_BingTileZoomLevel('123030123010121'))") + .collect()(0) + .getString(0) + result should equal("123030123010121") + } + } + + describe("should pass ST_BingTilePolygon") { + + it("should return correct envelope for quadkey '123030123010121'") { + val result = sparkSession + .sql("SELECT ST_AsText(ST_BingTilePolygon('123030123010121'))") + .collect()(0) + .getString(0) + result should not be null + result should startWith("POLYGON") + result should include("59.996337890625") + result should include("60.00732421875") + } + + it("should return correct envelope for tile(0,0,1)") { + val result = sparkSession + .sql("SELECT ST_AsText(ST_BingTilePolygon(ST_BingTile(0, 0, 1)))") + .collect()(0) + .getString(0) + result should include("-180") + result should include("85.051128") + } + } + + describe("should pass ST_BingTileCellIDs") { + + it("should return one tile for a point at zoom 10") { + val result = sparkSession + .sql("SELECT ST_BingTileCellIDs(ST_GeomFromWKT('POINT (60 30.12)'), 10)") + .collect()(0) + .getAs[mutable.WrappedArray[String]](0) + result should equal(mutable.WrappedArray.make(Array("1230301230"))) + } + + it("should return one tile for a point at zoom 15") { + val result = sparkSession + .sql("SELECT ST_BingTileCellIDs(ST_GeomFromWKT('POINT (60 30.12)'), 15)") + .collect()(0) + .getAs[mutable.WrappedArray[String]](0) + result should equal(mutable.WrappedArray.make(Array("123030123010121"))) + } + + it("should return 4 tiles for a 10x10 polygon at zoom 6") { + val result = sparkSession + .sql("SELECT ST_BingTileCellIDs(ST_GeomFromWKT('POLYGON ((0 0, 0 10, 10 10, 10 0, 0 0))'), 6)") + .collect()(0) + .getAs[mutable.WrappedArray[String]](0) + result should equal( + mutable.WrappedArray.make(Array("122220", "122222", "122221", "122223"))) + } + + // geometry_to_bing_tiles(POLYGON((10 10, -10 10, -20 -15, 10 10)), 3) + it("should return 3 tiles for a triangle at zoom 3") { + val result = sparkSession + .sql("SELECT ST_BingTileCellIDs(ST_GeomFromWKT('POLYGON ((10 10, -10 10, -20 -15, 10 10))'), 3)") + .collect()(0) + .getAs[mutable.WrappedArray[String]](0) + result should equal(mutable.WrappedArray.make(Array("033", "211", "122"))) + } + + it("should return empty array for empty geometry") { + val result = sparkSession + .sql("SELECT ST_BingTileCellIDs(ST_GeomFromWKT('POINT EMPTY'), 10)") + .collect()(0) + .getAs[mutable.WrappedArray[String]](0) + result.length should equal(0) + } + + it("should round-trip tile polygon back to the same tile") { + val result = sparkSession + .sql("SELECT ST_BingTileCellIDs(ST_BingTilePolygon('1230301230'), 10)") + .collect()(0) + .getAs[mutable.WrappedArray[String]](0) + result should equal(mutable.WrappedArray.make(Array("1230301230"))) + } + + it("should return 4 child tiles when expanding tile to zoom+1") { + val result = sparkSession + .sql("SELECT ST_BingTileCellIDs(ST_BingTilePolygon('1230301230'), 11)") + .collect()(0) + .getAs[mutable.WrappedArray[String]](0) + result should equal( + mutable.WrappedArray.make( + Array("12303012300", "12303012302", "12303012301", "12303012303"))) + } + } + + describe("should pass ST_BingTileToGeom") { + + it("should convert quadkeys to polygon geometries") { + val result = sparkSession + .sql("SELECT ST_BingTileToGeom(array('0', '1', '2', '3'))") + .collect()(0) + .getAs[mutable.WrappedArray[Any]](0) + result.length should equal(4) + } + } + +}
