Files
2023-10-18 01:56:01 -07:00

329 lines
10 KiB
Go

// Copyright 2023 Dolthub, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Copyright 2020 The Cockroach Authors.
//
// Use of this software is governed by the Business Source License
// included in the file licenses/BSL.txt.
//
// As of the Change Date specified in that file, in accordance with
// the Business Source License, use of this software will be governed
// by the Apache License, Version 2.0, included in the file
// licenses/APL.txt.
package geo
import (
"bytes"
"encoding/binary"
"fmt"
"strings"
"github.com/cockroachdb/errors"
"github.com/golang/geo/s1"
"github.com/pierrre/geohash"
"github.com/twpayne/go-geom"
"github.com/twpayne/go-geom/encoding/ewkb"
"github.com/twpayne/go-geom/encoding/geojson"
"github.com/twpayne/go-geom/encoding/kml"
"github.com/twpayne/go-geom/encoding/wkb"
"github.com/twpayne/go-geom/encoding/wkbcommon"
"github.com/twpayne/go-geom/encoding/wkbhex"
"github.com/twpayne/go-geom/encoding/wkt"
"github.com/dolthub/doltgresql/postgres/parser/geo/geopb"
"github.com/dolthub/doltgresql/postgres/parser/geo/geoprojbase"
)
// DefaultGeoJSONDecimalDigits is the default number of digits coordinates in GeoJSON.
const DefaultGeoJSONDecimalDigits = 9
// SpatialObjectToWKT transforms a given SpatialObject to WKT.
func SpatialObjectToWKT(so geopb.SpatialObject, maxDecimalDigits int) (geopb.WKT, error) {
t, err := ewkb.Unmarshal([]byte(so.EWKB))
if err != nil {
return "", err
}
ret, err := wkt.Marshal(t, wkt.EncodeOptionWithMaxDecimalDigits(maxDecimalDigits))
return geopb.WKT(ret), err
}
// SpatialObjectToEWKT transforms a given SpatialObject to EWKT.
func SpatialObjectToEWKT(so geopb.SpatialObject, maxDecimalDigits int) (geopb.EWKT, error) {
t, err := ewkb.Unmarshal([]byte(so.EWKB))
if err != nil {
return "", err
}
ret, err := wkt.Marshal(t, wkt.EncodeOptionWithMaxDecimalDigits(maxDecimalDigits))
if err != nil {
return "", err
}
if t.SRID() != 0 {
ret = fmt.Sprintf("SRID=%d;%s", t.SRID(), ret)
}
return geopb.EWKT(ret), err
}
// SpatialObjectToWKB transforms a given SpatialObject to WKB.
func SpatialObjectToWKB(so geopb.SpatialObject, byteOrder binary.ByteOrder) (geopb.WKB, error) {
t, err := ewkb.Unmarshal([]byte(so.EWKB))
if err != nil {
return nil, err
}
ret, err := wkb.Marshal(t, byteOrder, wkbcommon.WKBOptionEmptyPointHandling(wkbcommon.EmptyPointHandlingNaN))
return geopb.WKB(ret), err
}
// SpatialObjectToGeoJSONFlag maps to the ST_AsGeoJSON flags for PostGIS.
type SpatialObjectToGeoJSONFlag int
// These should be kept with ST_AsGeoJSON in PostGIS.
// 0: means no option
// 1: GeoJSON BBOX
// 2: GeoJSON Short CRS (e.g EPSG:4326)
// 4: GeoJSON Long CRS (e.g urn:ogc:def:crs:EPSG::4326)
// 8: GeoJSON Short CRS if not EPSG:4326 (default)
const (
SpatialObjectToGeoJSONFlagIncludeBBox SpatialObjectToGeoJSONFlag = 1 << (iota)
SpatialObjectToGeoJSONFlagShortCRS
SpatialObjectToGeoJSONFlagLongCRS
SpatialObjectToGeoJSONFlagShortCRSIfNot4326
SpatialObjectToGeoJSONFlagZero = 0
)
// geomToGeoJSONCRS converts a geom to its CRS GeoJSON form.
func geomToGeoJSONCRS(t geom.T, long bool) (*geojson.CRS, error) {
projection, ok := geoprojbase.Projection(geopb.SRID(t.SRID()))
if !ok {
return nil, errors.Newf("unknown SRID: %d", t.SRID())
}
var prop string
if long {
prop = fmt.Sprintf("urn:ogc:def:crs:%s::%d", projection.AuthName, projection.AuthSRID)
} else {
prop = fmt.Sprintf("%s:%d", projection.AuthName, projection.AuthSRID)
}
crs := &geojson.CRS{
Type: "name",
Properties: map[string]interface{}{
"name": prop,
},
}
return crs, nil
}
// SpatialObjectToGeoJSON transforms a given SpatialObject to GeoJSON.
func SpatialObjectToGeoJSON(
so geopb.SpatialObject, maxDecimalDigits int, flag SpatialObjectToGeoJSONFlag,
) ([]byte, error) {
t, err := ewkb.Unmarshal([]byte(so.EWKB))
if err != nil {
return nil, err
}
options := []geojson.EncodeGeometryOption{
geojson.EncodeGeometryWithMaxDecimalDigits(maxDecimalDigits),
}
if flag&SpatialObjectToGeoJSONFlagIncludeBBox != 0 {
// Do not encoding empty bounding boxes.
if so.BoundingBox != nil {
options = append(
options,
geojson.EncodeGeometryWithBBox(),
)
}
}
// Take CRS flag in order of precedence.
if t.SRID() != 0 {
if flag&SpatialObjectToGeoJSONFlagLongCRS != 0 {
crs, err := geomToGeoJSONCRS(t, true /* long */)
if err != nil {
return nil, err
}
options = append(options, geojson.EncodeGeometryWithCRS(crs))
} else if flag&SpatialObjectToGeoJSONFlagShortCRS != 0 {
crs, err := geomToGeoJSONCRS(t, false /* long */)
if err != nil {
return nil, err
}
options = append(options, geojson.EncodeGeometryWithCRS(crs))
} else if flag&SpatialObjectToGeoJSONFlagShortCRSIfNot4326 != 0 {
if t.SRID() != 4326 {
crs, err := geomToGeoJSONCRS(t, false /* long */)
if err != nil {
return nil, err
}
options = append(options, geojson.EncodeGeometryWithCRS(crs))
}
}
}
return geojson.Marshal(t, options...)
}
// SpatialObjectToWKBHex transforms a given SpatialObject to WKBHex.
func SpatialObjectToWKBHex(so geopb.SpatialObject) (string, error) {
t, err := ewkb.Unmarshal([]byte(so.EWKB))
if err != nil {
return "", err
}
ret, err := wkbhex.Encode(t, DefaultEWKBEncodingFormat, wkbcommon.WKBOptionEmptyPointHandling(wkbcommon.EmptyPointHandlingNaN))
return strings.ToUpper(ret), err
}
// SpatialObjectToKML transforms a given SpatialObject to KML.
func SpatialObjectToKML(so geopb.SpatialObject) (string, error) {
t, err := ewkb.Unmarshal([]byte(so.EWKB))
if err != nil {
return "", err
}
kmlElement, err := kml.Encode(t)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := kmlElement.Write(&buf); err != nil {
return "", err
}
return buf.String(), nil
}
// GeoHashAutoPrecision means to calculate the precision of SpatialObjectToGeoHash
// based on input, up to 32 characters.
const GeoHashAutoPrecision = 0
// GeoHashMaxPrecision is the maximum precision for GeoHashes.
// 20 is picked as doubles have 51 decimals of precision, and each base32 position
// can contain 5 bits of data. As we have two points, we use floor((2 * 51) / 5) = 20.
const GeoHashMaxPrecision = 20
// SpatialObjectToGeoHash transforms a given SpatialObject to a GeoHash.
func SpatialObjectToGeoHash(so geopb.SpatialObject, p int) (string, error) {
if so.BoundingBox == nil {
return "", nil
}
bbox := so.BoundingBox
if so.Type == geopb.SpatialObjectType_GeographyType {
// Convert bounding box back to degrees.
bbox = &geopb.BoundingBox{
LoX: s1.Angle(bbox.LoX).Degrees(),
HiX: s1.Angle(bbox.HiX).Degrees(),
LoY: s1.Angle(bbox.LoY).Degrees(),
HiY: s1.Angle(bbox.HiY).Degrees(),
}
}
if bbox.LoX < -180 || bbox.HiX > 180 || bbox.LoY < -90 || bbox.HiY > 90 {
return "", errors.Newf(
"object has bounds greater than the bounds of lat/lng, got (%f %f, %f %f)",
bbox.LoX, bbox.LoY,
bbox.HiX, bbox.HiY,
)
}
// Get precision using the bounding box if required.
if p <= GeoHashAutoPrecision {
p = getPrecisionForBBox(bbox)
}
// Support up to 20, which is the same as PostGIS.
if p > GeoHashMaxPrecision {
p = GeoHashMaxPrecision
}
bbCenterLng := bbox.LoX + (bbox.HiX-bbox.LoX)/2.0
bbCenterLat := bbox.LoY + (bbox.HiY-bbox.LoY)/2.0
return geohash.Encode(bbCenterLat, bbCenterLng, p), nil
}
// getPrecisionForBBox is a function imitating PostGIS's ability to go from
// a world bounding box and truncating a GeoHash to fit the given bounding box.
// The algorithm halves the world bounding box until it intersects with the
// feature bounding box to get a precision that will encompass the entire
// bounding box.
func getPrecisionForBBox(bbox *geopb.BoundingBox) int {
bitPrecision := 0
// This is a point, for points we use the full bitPrecision.
if bbox.LoX == bbox.HiX && bbox.LoY == bbox.HiY {
return GeoHashMaxPrecision
}
// Starts from a world bounding box:
lonMin := -180.0
lonMax := 180.0
latMin := -90.0
latMax := 90.0
// Each iteration shrinks the world bounding box by half in the dimension that
// does not fit, making adjustments each iteration until it intersects with
// the object bbox.
for {
lonWidth := lonMax - lonMin
latWidth := latMax - latMin
latMaxDelta, lonMaxDelta, latMinDelta, lonMinDelta := 0.0, 0.0, 0.0, 0.0
// Look at whether the longitudes of the bbox are to the left or
// the right of the world bbox longitudes, shrinks it and makes adjustments
// for the next iteration.
if bbox.LoX > lonMin+lonWidth/2.0 {
lonMinDelta = lonWidth / 2.0
} else if bbox.HiX < lonMax-lonWidth/2.0 {
lonMaxDelta = lonWidth / -2.0
}
// Look at whether the latitudes of the bbox are to the left or
// the right of the world bbox latitudes, shrinks it and makes adjustments
// for the next iteration.
if bbox.LoY > latMin+latWidth/2.0 {
latMinDelta = latWidth / 2.0
} else if bbox.HiY < latMax-latWidth/2.0 {
latMaxDelta = latWidth / -2.0
}
// Every change we make that splits the box up adds precision.
// If we detect no change, we've intersected a box and so must exit.
precisionDelta := 0
if lonMinDelta != 0.0 || lonMaxDelta != 0.0 {
lonMin += lonMinDelta
lonMax += lonMaxDelta
precisionDelta++
} else {
break
}
if latMinDelta != 0.0 || latMaxDelta != 0.0 {
latMin += latMinDelta
latMax += latMaxDelta
precisionDelta++
} else {
break
}
bitPrecision += precisionDelta
}
// Each character can represent 5 bits of bitPrecision.
// As such, divide by 5 to get GeoHash precision.
return bitPrecision / 5
}
// StringToByteOrder returns the byte order of string.
func StringToByteOrder(s string) binary.ByteOrder {
switch strings.ToLower(s) {
case "ndr":
return binary.LittleEndian
case "xdr":
return binary.BigEndian
default:
return DefaultEWKBEncodingFormat
}
}