From e42ce5168ee85c4a9bf0b62b6eed46ba15e46ae6 Mon Sep 17 00:00:00 2001 From: bipoool Date: Sun, 18 May 2025 20:39:28 +0000 Subject: [PATCH 1/7] Added GEOADD, GEODIST, GEOSEARCH commands --- internal/cmd/cmd_geoadd.go | 126 ++++++++++++++++++++++ internal/cmd/cmd_geodist.go | 120 +++++++++++++++++++++ internal/cmd/cmd_geosearch.go | 190 ++++++++++++++++++++++++++++++++++ internal/cmd/cmd_set.go | 2 + internal/cmd/cmd_zadd.go | 4 +- internal/errors/errors.go | 6 ++ internal/geo/geo.go | 153 +++++++++++++++++++++++++++ internal/types/params.go | 9 ++ 8 files changed, 609 insertions(+), 1 deletion(-) create mode 100644 internal/cmd/cmd_geoadd.go create mode 100644 internal/cmd/cmd_geodist.go create mode 100644 internal/cmd/cmd_geosearch.go create mode 100644 internal/geo/geo.go diff --git a/internal/cmd/cmd_geoadd.go b/internal/cmd/cmd_geoadd.go new file mode 100644 index 000000000..7750c88e6 --- /dev/null +++ b/internal/cmd/cmd_geoadd.go @@ -0,0 +1,126 @@ +// Copyright (c) 2022-present, DiceDB contributors +// All rights reserved. Licensed under the BSD 3-Clause License. See LICENSE file in the project root for full license information. + +package cmd + +import ( + "strconv" + + "github.com/dicedb/dice/internal/errors" + geoUtil "github.com/dicedb/dice/internal/geo" + "github.com/dicedb/dice/internal/object" + "github.com/dicedb/dice/internal/shardmanager" + dsstore "github.com/dicedb/dice/internal/store" + "github.com/dicedb/dice/internal/types" + "github.com/dicedb/dicedb-go/wire" +) + +var cGEOADD = &CommandMeta{ + Name: "GEOADD", + Syntax: "GEOADD key [NX | XX] [CH] longitude latitude member [longitude latitude member ...]", + HelpShort: "GEOADD adds all the specified GEO members with the specified longitude & latitude pair to the sorted set stored at key", + HelpLong: ` +GEOADD adds all the specified GEO members with the specified longitude & latitude pair to the sorted set stored at key +The command takes arguments in the standard format x,y so the longitude must be specified before the latitude. +There are limits to the coordinates that can be indexed: areas very near to the poles are not indexable. + +The exact limits, as specified by EPSG:900913 / EPSG:3785 / OSGEO:41001 are the following: + +- Valid longitudes are from -180 to 180 degrees. +- Valid latitudes are from -85.05112878 to 85.05112878 degrees. + +This has similar options as ZADD +- NX: Only add new elements and do not update existing elements +- XX: Only update existing elements and do not add new elements +`, + Examples: ` +localhost:7379> GEOADD Delhi NX 77.2096 28.6145 "Central Delhi" +OK 1 +localhost:7379> GEOADD Delhi 77.2167 28.6315 CP 77.2295 28.6129 IndiaGate 77.1197 28.6412 Rajouri 77.1000 28.5562 Airport 77.1900 28.6517 KarolBagh +OK 5 +localhost:7379> GEOADD Delhi NX 77.2096 280 "Central Delhi" +ERR Invalid Longitude, Latitude pair ('77.209600', '280.000000')! Check the range in Docs + `, + Eval: evalGEOADD, + Execute: executeGEOADD, +} + +func init() { + CommandRegistry.AddCommand(cGEOADD) +} + +func newGEOADDRes(count int64) *CmdRes { + return &CmdRes{ + Rs: &wire.Result{ + Message: "OK", + Status: wire.Status_OK, + Response: &wire.Result_GEOADDRes{ + GEOADDRes: &wire.GEOADDRes{ + Count: count, + }, + }, + }, + } +} + +var ( + GEOADDResNilRes = newGEOADDRes(0) +) + +func evalGEOADD(c *Cmd, s *dsstore.Store) (*CmdRes, error) { + if len(c.C.Args) < 4 { + return GEOADDResNilRes, errors.ErrWrongArgumentCount("GEOADD") + } + + key := c.C.Args[0] + geoHashs, members := []int64{}, []string{} + params, nonParams := parseParams(c.C.Args[1:]) + + if len(nonParams)%3 != 0 { + return GEOADDResNilRes, errors.ErrWrongArgumentCount("GEOADD") + } + + for i := 0; i < len(nonParams); i += 3 { + lon, errLon := strconv.ParseFloat(nonParams[i], 10) + lat, errLat := strconv.ParseFloat(nonParams[i+1], 10) + if errLon != nil || errLat != nil { + return GEOADDResNilRes, errors.ErrInvalidNumberFormat + } + if err := geoUtil.ValidateLonLat(lon, lat); err != nil { + return GEOADDResNilRes, err + } + geoHash := geoUtil.EncodeHash(lon, lat) + + geoHashs = append(geoHashs, int64(geoHash)) + members = append(members, nonParams[i+2]) + } + + var ss *types.SortedSet + obj := s.Get(key) + if obj == nil { + ss = types.NewSortedSet() + } else { + if obj.Type != object.ObjTypeSortedSet { + return GEOADDResNilRes, errors.ErrWrongTypeOperation + } + ss = obj.Value.(*types.SortedSet) + } + + // Note: Validation of the params is done in the types.SortedSet.ZADD method + count, err := ss.ZADD(geoHashs, members, params) + if err != nil { + return GEOADDResNilRes, err + } + + s.Put(key, s.NewObj(ss, -1, object.ObjTypeSortedSet), dsstore.WithPutCmd(dsstore.ZAdd)) + return newGEOADDRes(count), nil +} + +func executeGEOADD(c *Cmd, sm *shardmanager.ShardManager) (*CmdRes, error) { + if len(c.C.Args) < 4 { + return GEOADDResNilRes, errors.ErrWrongArgumentCount("GEOADD") + } + + shard := sm.GetShardForKey(c.C.Args[0]) + return evalGEOADD(c, shard.Thread.Store()) +} diff --git a/internal/cmd/cmd_geodist.go b/internal/cmd/cmd_geodist.go new file mode 100644 index 000000000..041c73fc9 --- /dev/null +++ b/internal/cmd/cmd_geodist.go @@ -0,0 +1,120 @@ +// Copyright (c) 2022-present, DiceDB contributors +// All rights reserved. Licensed under the BSD 3-Clause License. See LICENSE file in the project root for full license information. + +package cmd + +import ( + "github.com/dicedb/dice/internal/errors" + geoUtil "github.com/dicedb/dice/internal/geo" + "github.com/dicedb/dice/internal/object" + "github.com/dicedb/dice/internal/shardmanager" + dsstore "github.com/dicedb/dice/internal/store" + "github.com/dicedb/dice/internal/types" + "github.com/dicedb/dicedb-go/wire" +) + +var cGEODIST = &CommandMeta{ + Name: "GEODIST", + Syntax: "GEODIST key member1 member2 [M | KM | FT | MI]", + HelpShort: "GEODIST Return the distance between two members in the geospatial index represented by the sorted set.", + HelpLong: ` +GEODIST Return the distance between two members in the geospatial index represented by the sorted set. +If any of the member is null, this will return Nil Output + +The unit must be one of the following, and defaults to meters: +- m for meters. +- km for kilometers. +- mi for miles. +- ft for feet. + `, + Examples: ` +localhost:7379> GEOADD Delhi 77.2096 28.6145 centralDelhi 77.2167 28.6315 CP 77.2295 28.6129 IndiaGate +OK 3 +localhost:7379> GEODIST Delhi CP IndiaGate km +OK 2.416700 + + `, + Eval: evalGEODIST, + Execute: executeGEODIST, +} + +func init() { + CommandRegistry.AddCommand(cGEODIST) +} + +func newGEODISTRes(distance float64) *CmdRes { + return &CmdRes{ + Rs: &wire.Result{ + Message: "OK", + Status: wire.Status_OK, + Response: &wire.Result_GEODISTRes{ + GEODISTRes: &wire.GEODISTRes{ + Distance: distance, + }, + }, + }, + } +} + +var ( + GEODISTResNilRes = newGEODISTRes(0) +) + +func evalGEODIST(c *Cmd, s *dsstore.Store) (*CmdRes, error) { + if len(c.C.Args) < 3 || len(c.C.Args) > 4 { + return GEODISTResNilRes, errors.ErrWrongArgumentCount("GEODIST") + } + + key := c.C.Args[0] + params, nonParams := parseParams(c.C.Args[1:]) + + unit := getUnitTypeFromParsedParams(params) + if len(c.C.Args) == 4 && len(unit) == 0 { + return GEODISTResNilRes, errors.ErrInvalidUnit(c.C.Args[3]) + } else if len(unit) == 0 { + unit = types.M + } + + var ss *types.SortedSet + obj := s.Get(key) + if obj == nil { + return GEODISTResNilRes, nil + } + + if obj.Type != object.ObjTypeSortedSet { + return GEODISTResNilRes, errors.ErrWrongTypeOperation + } + ss = obj.Value.(*types.SortedSet) + + node1 := ss.GetByKey(nonParams[0]) + node2 := ss.GetByKey(nonParams[1]) + + // @doubt - Should return error here? + if node1 == nil || node2 == nil { + return GEODISTResNilRes, nil + } + + hash1 := node1.Score() + hash2 := node2.Score() + + lon1, lat1 := geoUtil.DecodeHash(uint64(hash1)) + lon2, lat2 := geoUtil.DecodeHash(uint64(hash2)) + + dist, err := geoUtil.ConvertDistance(geoUtil.GetDistance(lon1, lat1, lon2, lat2), unit) + + if err != nil { + return GEODISTResNilRes, err + } + + return newGEODISTRes(dist), nil + +} + +func executeGEODIST(c *Cmd, sm *shardmanager.ShardManager) (*CmdRes, error) { + if len(c.C.Args) < 3 || len(c.C.Args) > 4 { + return GEODISTResNilRes, errors.ErrWrongArgumentCount("GEODIST") + } + + shard := sm.GetShardForKey(c.C.Args[0]) + return evalGEODIST(c, shard.Thread.Store()) +} diff --git a/internal/cmd/cmd_geosearch.go b/internal/cmd/cmd_geosearch.go new file mode 100644 index 000000000..3273abb0f --- /dev/null +++ b/internal/cmd/cmd_geosearch.go @@ -0,0 +1,190 @@ +// Copyright (c) 2022-present, DiceDB contributors +// All rights reserved. Licensed under the BSD 3-Clause License. See LICENSE file in the project root for full license information. + +package cmd + +import ( + "strconv" + + "github.com/dicedb/dice/internal/errors" + geoUtil "github.com/dicedb/dice/internal/geo" + "github.com/dicedb/dice/internal/object" + "github.com/dicedb/dice/internal/shardmanager" + dsstore "github.com/dicedb/dice/internal/store" + "github.com/dicedb/dice/internal/types" + "github.com/dicedb/dicedb-go/wire" +) + +var cGEOSEARCH = &CommandMeta{ + Name: "GEOSEARCH", + Syntax: "GEOSEARCH key longitude latitude radius [WITHCOORD] [WITHDIST] [WITHHASH]", + HelpShort: "GEOSEARCH Returns the members within the borders of the area specified by a given shape", + HelpLong: ` +GEOSEARCH Returns the members within the borders of the area specified by a given shape +For now this only supports FROMLONLAT & BYRADIUS - You have to give longitude, latitude and the radius of the area. +The command optionally returns additional information using the following options: + +WITHDIST: Also return the distance of the returned items from the specified center point. The distance is returned in the same unit as specified for the radius or height and width arguments. +WITHCOORD: Also return the longitude and latitude of the matching items. +WITHHASH: Also return the raw geohash-encoded sorted set score of the item, in the form of a 52 bit unsigned integer. This is only useful for low level hacks or debugging and is otherwise of little interest for the general user. + +The elements are considered to be ordered from the lowest to the highest distance. + `, + Examples: ` +localhost:7379> GEOADD Delhi 77.2167 28.6315 CP 77.2295 28.6129 IndiaGate 77.1197 28.6412 Rajouri 77.1000 28.5562 Airport 77.1900 28.6517 KarolBagh +OK 5 +localhost:7379> GEOSEARCH Delhi 77.1000 28.5562 10 km +OK +0) Airport +0) Rajouri +localhost:7379> GEOSEARCH Delhi 77.1000 28.5562 10 km WITHCOORD WITHDIST WITHHASH +OK +0) 3631198180857159, 0.000300, (77.099997, 28.556200), Airport +0) 3631199276102297, 9.648000, (77.119700, 28.641200), Rajouri +localhost:7379> GEOSEARCH Delhi 77.1000 28.5562 10 unknownUnit +ERR invalid syntax for 'GEOSEARCH' command + `, + Eval: evalGEOSEARCH, + Execute: executeGEOSEARCH, +} + +func init() { + CommandRegistry.AddCommand(cGEOSEARCH) +} + +func newGEOSEARCHRes(elements []*wire.GEOElement) *CmdRes { + return &CmdRes{ + Rs: &wire.Result{ + Message: "OK", + Status: wire.Status_OK, + Response: &wire.Result_GEOSEARCHRes{ + GEOSEARCHRes: &wire.GEOSEARCHRes{ + Elements: elements, + }, + }, + }, + } +} + +var ( + GEOSEARCHResNilRes = newGEOSEARCHRes([]*wire.GEOElement{}) +) + +func getUnitTypeFromParsedParams(params map[types.Param]string) types.Param { + if params[types.M] != "" { + return types.M + } else if params[types.KM] != "" { + return types.KM + } else if params[types.MI] != "" { + return types.MI + } else if params[types.FT] != "" { + return types.FT + } else { + return "" + } +} + +func evalGEOSEARCH(c *Cmd, s *dsstore.Store) (*CmdRes, error) { + if len(c.C.Args) < 5 { + return GEOSEARCHResNilRes, errors.ErrWrongArgumentCount("GEOSEARCH") + } + + key := c.C.Args[0] + params, nonParams := parseParams(c.C.Args[1:]) + + if len(nonParams) > 3 { + return GEOSEARCHResNilRes, errors.ErrInvalidSyntax("GEOSEARCH") + } + unit := getUnitTypeFromParsedParams(params) + if len(unit) == 0 { + return GEOSEARCHResNilRes, errors.ErrInvalidUnit(c.C.Args[3]) + } + + lon, errLon := strconv.ParseFloat(nonParams[0], 10) + lat, errLat := strconv.ParseFloat(nonParams[1], 10) + radius, errRad := strconv.ParseFloat(nonParams[2], 10) + + if errLon != nil || errLat != nil || errRad != nil { + return GEOSEARCHResNilRes, errors.ErrInvalidNumberFormat + } + if err := geoUtil.ValidateLonLat(lon, lat); err != nil { + return GEOSEARCHResNilRes, err + } + finalRadius, _ := geoUtil.ConvertToMeter(radius, unit) + + var ss *types.SortedSet + obj := s.Get(key) + if obj == nil { + return GEODISTResNilRes, nil + } + + if obj.Type != object.ObjTypeSortedSet { + return GEOSEARCHResNilRes, errors.ErrWrongTypeOperation + } + ss = obj.Value.(*types.SortedSet) + + var withCoord, withDist, withHash bool = false, false, false + + if params[types.WITHCOORD] != "" { + withCoord = true + } + if params[types.WITHDIST] != "" { + withDist = true + } + if params[types.WITHHASH] != "" { + withHash = true + } + + boudingBox := geoUtil.GetBoundingBoxWithLonLat(lon, lat, finalRadius) + + minLon := boudingBox[0] + minLat := boudingBox[1] + maxLon := boudingBox[2] + maxLat := boudingBox[3] + + minHash := geoUtil.EncodeHash(minLon, minLat) + maxHash := geoUtil.EncodeHash(maxLon, maxLat) + + zElements := ss.ZRANGE(int(minHash), int(maxHash), true, false) + geoElements := []*wire.GEOElement{} + + for _, ele := range zElements { + + eleLon, eleLat := geoUtil.DecodeHash(uint64(ele.Score)) + dist := geoUtil.GetDistance(eleLon, eleLat, lon, lat) + + if dist <= finalRadius { + + geoElement := wire.GEOElement{ + Member: ele.Member, + } + + if withCoord { + geoElement.Coordinates = &wire.GEOCoordinates{ + Longitude: eleLon, + Latitude: eleLat, + } + } + if withDist { + geoElement.Distance, _ = geoUtil.ConvertDistance(dist, unit) + } + if withHash { + geoElement.Hash = uint64(ele.Score) + } + geoElements = append(geoElements, &geoElement) + } + + } + + return newGEOSEARCHRes(geoElements), nil + +} + +func executeGEOSEARCH(c *Cmd, sm *shardmanager.ShardManager) (*CmdRes, error) { + if len(c.C.Args) < 5 { + return GEOSEARCHResNilRes, errors.ErrWrongArgumentCount("GEOSEARCH") + } + + shard := sm.GetShardForKey(c.C.Args[0]) + return evalGEOSEARCH(c, shard.Thread.Store()) +} diff --git a/internal/cmd/cmd_set.go b/internal/cmd/cmd_set.go index 0e00b298a..fe0722dbf 100644 --- a/internal/cmd/cmd_set.go +++ b/internal/cmd/cmd_set.go @@ -95,6 +95,8 @@ func parseParams(args []string) (params map[types.Param]string, nonParams []stri i++ case types.XX, types.NX, types.KEEPTTL, types.LT, types.GT, types.CH, types.INCR: params[arg] = "true" + case types.M, types.KM, types.MI, types.FT, types.WITHCOORD, types.WITHDIST, types.WITHHASH: + params[arg] = "true" default: nonParams = append(nonParams, args[i]) } diff --git a/internal/cmd/cmd_zadd.go b/internal/cmd/cmd_zadd.go index 1abbfd62a..c1eb5dedb 100644 --- a/internal/cmd/cmd_zadd.go +++ b/internal/cmd/cmd_zadd.go @@ -98,7 +98,6 @@ func evalZADD(c *Cmd, s *dsstore.Store) (*CmdRes, error) { obj := s.Get(key) if obj == nil { ss = types.NewSortedSet() - s.Put(key, s.NewObj(ss, -1, object.ObjTypeSortedSet), dsstore.WithPutCmd(dsstore.ZAdd)) } else { if obj.Type != object.ObjTypeSortedSet { return ZADDResNilRes, errors.ErrWrongTypeOperation @@ -111,6 +110,9 @@ func evalZADD(c *Cmd, s *dsstore.Store) (*CmdRes, error) { if err != nil { return ZADDResNilRes, err } + + // Add the key after the SortedSet is updated/created successfully + s.Put(key, s.NewObj(ss, -1, object.ObjTypeSortedSet), dsstore.WithPutCmd(dsstore.ZAdd)) return newZADDRes(count), nil } diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 9d66932ec..23b33aa3f 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -83,6 +83,12 @@ var ( return fmt.Errorf("number of elements to peek should be a positive number less than %d", max) // Signals an invalid count for elements to peek. } + ErrInvalidUnit = func(unit string) error { + return fmt.Errorf("Unsupported unit '%s'. please use m, km, ft, mi", unit) // Signals an invalid unit + } + ErrInvalidLonLatPair = func(lon, lat float64) error { + return fmt.Errorf("Invalid Longitude, Latitude pair ('%f', '%f')! Check the range in Docs", lon, lat) + } ErrGeneral = func(err string) error { return fmt.Errorf("%s", err) // General error format for various commands. } diff --git a/internal/geo/geo.go b/internal/geo/geo.go new file mode 100644 index 000000000..60337b3b3 --- /dev/null +++ b/internal/geo/geo.go @@ -0,0 +1,153 @@ +package geoUtil + +import ( + "math" + + "github.com/dicedb/dice/internal/errors" + "github.com/dicedb/dice/internal/types" + "github.com/mmcloughlin/geohash" +) + +// Bit precision - Same as redis (https://github.com/redis/redis/blob/5d0d64b062c160093dc287ed5d18ec7c807873cf/src/geohash_helper.c#L213) +const BIT_PRECISION = 52 + +// Earth's radius in meters +const EARTH_RADIUS float64 = 6372797.560856 + +// Limits from EPSG:900913 / EPSG:3785 / OSGEO:41001 +const LAT_MIN float64 = -85.05112878 +const LAT_MAX float64 = 85.05112878 +const LONG_MIN float64 = -180 +const LONG_MAX float64 = 180 + +func ValidateLonLat(longitude, latitude float64) error { + if latitude > LAT_MAX || latitude < LAT_MIN || longitude > LONG_MAX || longitude < LONG_MIN { + return errors.ErrInvalidLonLatPair(longitude, latitude) + } + return nil +} + +func EncodeHash(longitude, latitude float64) uint64 { + return geohash.EncodeIntWithPrecision(latitude, longitude, BIT_PRECISION) +} + +// DecodeHash returns the latitude and longitude from a geo hash +func DecodeHash(hash uint64) (lon, lat float64) { + lat, lon = geohash.DecodeIntWithPrecision(hash, BIT_PRECISION) + return lon, lat +} + +func GetDistance(lon1, lat1, lon2, lat2 float64) float64 { + lon1r := DegToRad(lon1) + lon2r := DegToRad(lon2) + lat1r := DegToRad(lat1) + lat2r := DegToRad(lat2) + + v := math.Sin((lon2r - lon1r) / 2) + // if v == 0 we can avoid doing expensive math when lons are practically the same (This impl is same as redis) + if v == 0.0 { + return GetLatDistance(lat1r, lat2r) + } + + u := math.Sin((lat2r - lat1r) / 2) + + a := u*u + math.Cos(lat1r)*math.Cos(lat2r)*v*v + + return 2.0 * EARTH_RADIUS * math.Asin(math.Sqrt(a)) +} + +func DegToRad(deg float64) float64 { + return deg * math.Pi / 180 +} + +func RadToDeg(rad float64) float64 { + return 180.0 * rad / math.Pi +} + +func GetLatDistance(lat1, lat2 float64) float64 { + return EARTH_RADIUS * math.Abs(lat2-lat1) +} + +// ConvertDistance converts a distance from meters to the desired unit +func ConvertDistance(distance float64, unit types.Param) (float64, error) { + var result float64 + + switch unit { + case types.M: + result = distance + case types.KM: + result = distance / 1000 + case types.MI: + result = distance / 1609.34 + case types.FT: + result = distance / 0.3048 + default: + return 0, errors.ErrInvalidUnit(string(unit)) + } + + // Round to 4 decimal places + return math.Round(result*10000) / 10000, nil +} + +// ConvertToMeter converts a distance to meters from the given unit +func ConvertToMeter(distance float64, unit types.Param) (float64, error) { + var result float64 + + switch unit { + case types.M: + result = distance + case types.KM: + result = distance * 1000 + case types.MI: + result = distance * 1609.34 + case types.FT: + result = distance * 0.3048 + default: + return 0, errors.ErrInvalidUnit(string(unit)) + } + // Round to 4 decimal places + return math.Round(result*10000) / 10000, nil +} + +// Return the bounding box of the search circle +// bounds[0] - bounds[2] is the minimum and maximum longitude +// bounds[1] - bounds[3] is the minimum and maximum latitude. +// Refer to this link to understand this function in detail - https://www.notion.so/Geo-Bounding-Box-Research-1f6a37dc1a9a80e7ac43feeeab7215bb?pvs=4 +// since the higher the latitude, the shorter the arc length, the box shape is as follows +// +// \-----------------/ -------- \-----------------/ +// \ / / \ \ / +// \ (long,lat) / / (long,lat) \ \ (long,lat) / +// \ / / \ / \ +// --------- /----------------\ /---------------\ +// Northern Hemisphere Southern Hemisphere Around the equator +func GetBoundingBoxWithHash(hash uint64, radius float64) [4]float64 { + + boudingBox := [4]float64{} + lon, lat := DecodeHash(hash) + latDelta := RadToDeg(radius / EARTH_RADIUS) + lonDeltaTop := RadToDeg(radius / EARTH_RADIUS / math.Cos(DegToRad(lat+latDelta))) + lonDeltaBottom := RadToDeg(radius / EARTH_RADIUS / math.Cos(DegToRad(lat-latDelta))) + + boudingBox[1] = lat - latDelta + boudingBox[3] = lat + latDelta + + if lat < 0 { + boudingBox[0] = lon - lonDeltaBottom + boudingBox[2] = lon + lonDeltaBottom + } else { + boudingBox[0] = lon - lonDeltaTop + boudingBox[2] = lon + lonDeltaTop + } + + return boudingBox + +} + +func GetBoundingBoxWithLonLat(lon, lat float64, radius float64) [4]float64 { + return GetBoundingBoxWithHash(EncodeHash(lon, lat), radius) +} + +func parseParams() { + +} diff --git a/internal/types/params.go b/internal/types/params.go index a35b1d04d..bd5d21faa 100644 --- a/internal/types/params.go +++ b/internal/types/params.go @@ -20,4 +20,13 @@ const ( KEEPTTL Param = "KEEPTTL" PERSIST Param = "PERSIST" + + M Param = "M" + KM Param = "KM" + MI Param = "MI" + FT Param = "FT" + + WITHCOORD Param = "WITHCOORD" + WITHDIST Param = "WITHDIST" + WITHHASH Param = "WITHHASH" ) From 3fb836945163d51ed7dc494b4f151a9b1c16a273 Mon Sep 17 00:00:00 2001 From: bipoool Date: Wed, 21 May 2025 17:43:34 +0000 Subject: [PATCH 2/7] Added GEOSEARCH command --- go.mod | 3 + go.sum | 2 + internal/cmd/cmd_geosearch.go | 391 +++++++++++++++++++++++++++++----- internal/cmd/cmd_set.go | 7 +- internal/errors/errors.go | 21 ++ internal/geo/geo.go | 195 +++++++++++++++-- internal/geo/geoShape.go | 115 ++++++++++ internal/types/params.go | 12 ++ 8 files changed, 676 insertions(+), 70 deletions(-) create mode 100644 internal/geo/geoShape.go diff --git a/go.mod b/go.mod index 0bd398776..f7b54733a 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,9 @@ require ( github.com/dgryski/go-metro v0.0.0-20211217172704-adc40b04c140 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/mattn/go-colorable v0.1.14 // indirect diff --git a/go.sum b/go.sum index 2c4f13ea2..83e581b33 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/dgryski/go-metro v0.0.0-20211217172704-adc40b04c140 h1:y7y0Oa6UawqTFP github.com/dgryski/go-metro v0.0.0-20211217172704-adc40b04c140/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= github.com/dicedb/dicedb-go v1.0.10 h1:inKRSmzpXp7M4vleOfJUV4woISCUFVceDckeum461aw= github.com/dicedb/dicedb-go v1.0.10/go.mod h1:V1fiCJnPfSObKWrOJ/zrhHEGlLwT9k3pKCto3sz1oW8= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= diff --git a/internal/cmd/cmd_geosearch.go b/internal/cmd/cmd_geosearch.go index 3273abb0f..2f62d5aae 100644 --- a/internal/cmd/cmd_geosearch.go +++ b/internal/cmd/cmd_geosearch.go @@ -13,6 +13,7 @@ import ( dsstore "github.com/dicedb/dice/internal/store" "github.com/dicedb/dice/internal/types" "github.com/dicedb/dicedb-go/wire" + "github.com/emirpasic/gods/queues/priorityqueue" ) var cGEOSEARCH = &CommandMeta{ @@ -20,29 +21,64 @@ var cGEOSEARCH = &CommandMeta{ Syntax: "GEOSEARCH key longitude latitude radius [WITHCOORD] [WITHDIST] [WITHHASH]", HelpShort: "GEOSEARCH Returns the members within the borders of the area specified by a given shape", HelpLong: ` -GEOSEARCH Returns the members within the borders of the area specified by a given shape -For now this only supports FROMLONLAT & BYRADIUS - You have to give longitude, latitude and the radius of the area. +Return the members of a sorted set populated with geospatial information using GEOADD, which are within the borders of the area specified by a given shape. +This command extends the GEORADIUS command, so in addition to searching within circular areas, it supports searching within rectangular areas. + +This command should be used in place of the deprecated GEORADIUS and GEORADIUSBYMEMBER commands. + +The query's center point is provided by one of these mandatory options: + +FROMMEMBER: Use the position of the given existing in the sorted set. +FROMLONLAT: Use the given and position. +The query's shape is provided by one of these mandatory options: + +BYRADIUS: Similar to GEORADIUS, search inside circular area according to given . +BYBOX: Search inside an axis-aligned rectangle, determined by and . The command optionally returns additional information using the following options: WITHDIST: Also return the distance of the returned items from the specified center point. The distance is returned in the same unit as specified for the radius or height and width arguments. WITHCOORD: Also return the longitude and latitude of the matching items. WITHHASH: Also return the raw geohash-encoded sorted set score of the item, in the form of a 52 bit unsigned integer. This is only useful for low level hacks or debugging and is otherwise of little interest for the general user. -The elements are considered to be ordered from the lowest to the highest distance. +Matching items are returned unsorted by default. To sort them, use one of the following two options: + +ASC: Sort returned items from the nearest to the farthest, relative to the center point. +DESC: Sort returned items from the farthest to the nearest, relative to the center point. + +All matching items are returned by default. To limit the results to the first N matching items, +use the COUNT option. When the ANY option is used, the command returns as soon as enough matches are found. +This means that the results returned may not be the ones closest to the specified point, +but the effort invested by the server to generate them is significantly less. When ANY is not provided, +the command will perform an effort that is proportional to the number of items matching the specified area and sort them, +so to query very large areas with a very small COUNT option may be slow even if just a few results are returned. + +NOTE: +1. If ANY is used with ASC or DESC then sorting is avoided. +2. IF COUNT is used without ASC or DESC then result is automatically sorted with ASC `, Examples: ` localhost:7379> GEOADD Delhi 77.2167 28.6315 CP 77.2295 28.6129 IndiaGate 77.1197 28.6412 Rajouri 77.1000 28.5562 Airport 77.1900 28.6517 KarolBagh OK 5 -localhost:7379> GEOSEARCH Delhi 77.1000 28.5562 10 km +localhost:7379> GEOSEARCH Delhi FROMLONLAT 77.1000 28.5562 BYRADIUS 10 km COUNT 2 ANY OK 0) Airport +1) Rajouri +localhost:7379> GEOSEARCH Delhi FROMLONLAT 77.1000 28.5562 BYRADIUS 10 km COUNT 2 DESC +OK 0) Rajouri -localhost:7379> GEOSEARCH Delhi 77.1000 28.5562 10 km WITHCOORD WITHDIST WITHHASH +1) Airport +localhost:7379> GEOSEARCH Delhi FROMLONLAT 77.1000 28.5562 BYRADIUS 20 km COUNT 2 DESC OK -0) 3631198180857159, 0.000300, (77.099997, 28.556200), Airport -0) 3631199276102297, 9.648000, (77.119700, 28.641200), Rajouri -localhost:7379> GEOSEARCH Delhi 77.1000 28.5562 10 unknownUnit -ERR invalid syntax for 'GEOSEARCH' command +0) CP +1) IndiaGate +localhost:7379> GEOSEARCH Delhi FROMLONLAT 77.1000 28.5562 BYBOX 40 40 km COUNT 2 ASC +OK +0) Airport +1) Rajouri +localhost:7379> GEOSEARCH Delhi FROMMEMBER CP BYBOX 40 40 km COUNT 2 ASC +OK +0) IndiaGate +0) KarolBagh `, Eval: evalGEOSEARCH, Execute: executeGEOSEARCH, @@ -92,36 +128,195 @@ func evalGEOSEARCH(c *Cmd, s *dsstore.Store) (*CmdRes, error) { key := c.C.Args[0] params, nonParams := parseParams(c.C.Args[1:]) - if len(nonParams) > 3 { + // Get sorted set else return + var ss *types.SortedSet + obj := s.Get(key) + if obj == nil { + return GEODISTResNilRes, nil + } + if obj.Type != object.ObjTypeSortedSet { + return GEOSEARCHResNilRes, errors.ErrWrongTypeOperation + } + ss = obj.Value.(*types.SortedSet) + + // Validate all the parameters + if len(nonParams) < 2 { return GEOSEARCHResNilRes, errors.ErrInvalidSyntax("GEOSEARCH") } unit := getUnitTypeFromParsedParams(params) if len(unit) == 0 { - return GEOSEARCHResNilRes, errors.ErrInvalidUnit(c.C.Args[3]) + return GEOSEARCHResNilRes, errors.ErrInvalidUnit(string(unit)) + } + + // Return error if both FROMLONLAT & FROMMEMBER are set + if params[types.FROMLONLAT] != "" && params[types.FROMMEMBER] != "" { + return GEOSEARCHResNilRes, errors.ErrInvalidSetOfOptions(string(types.FROMLONLAT), string(types.FROMMEMBER)) } - lon, errLon := strconv.ParseFloat(nonParams[0], 10) - lat, errLat := strconv.ParseFloat(nonParams[1], 10) - radius, errRad := strconv.ParseFloat(nonParams[2], 10) + // Return error if none of FROMLONLAT & FROMMEMBER are set + if params[types.FROMLONLAT] == "" && params[types.FROMMEMBER] == "" { + return GEOSEARCHResNilRes, errors.ErrNeedOneOfTheOptions(string(types.FROMLONLAT), string(types.FROMMEMBER)) + } + + // Return error if both BYBOX & BYRADIUS are set + if params[types.BYBOX] != "" && params[types.BYRADIUS] != "" { + return GEOSEARCHResNilRes, errors.ErrInvalidSetOfOptions(string(types.BYBOX), string(types.BYRADIUS)) + } + + // Return error if none of BYBOX & BYRADIUS are set + if params[types.BYBOX] == "" && params[types.BYRADIUS] == "" { + return GEOSEARCHResNilRes, errors.ErrNeedOneOfTheOptions(string(types.BYBOX), string(types.BYRADIUS)) + } + + // Return error if ANY is used without COUNT + if params[types.ANY] != "" && params[types.COUNT] == "" { + return GEOSEARCHResNilRes, errors.ErrGeneral("ANY argument requires COUNT argument") + } - if errLon != nil || errLat != nil || errRad != nil { - return GEOSEARCHResNilRes, errors.ErrInvalidNumberFormat + // Return error if Both ASC & DESC are used + if params[types.ASC] != "" && params[types.DESC] != "" { + return GEOSEARCHResNilRes, errors.ErrGeneral("Use one of ASC or DESC") } + + // Fetch Longitute and Latitude based on FROMLONLAT & FROMMEMBER param + var lon, lat float64 + var errLon, errLat error + + // Fetch Longitute and Latitude from params + if params[types.FROMLONLAT] != "" { + if len(nonParams) < 2 { + return GEOSEARCHResNilRes, errors.ErrWrongArgumentCount("GEOSEARCH") + } + lon, errLon = strconv.ParseFloat(nonParams[0], 10) + lat, errLat = strconv.ParseFloat(nonParams[1], 10) + + if errLon != nil || errLat != nil { + return GEOSEARCHResNilRes, errors.ErrInvalidNumberFormat + } + + // Adjust the nonParams array for further operations + nonParams = nonParams[2:] + } + + // Fetch Longitute and Latitude from member + if params[types.FROMMEMBER] != "" { + if len(nonParams) < 1 { + return GEOSEARCHResNilRes, errors.ErrWrongArgumentCount("GEOSEARCH") + } + member := nonParams[0] + node := ss.GetByKey(member) + if node == nil { + return GEOSEARCHResNilRes, errors.ErrMemberNotFoundInSortedSet(member) + } + hash := node.Score() + lon, lat = geoUtil.DecodeHash(uint64(hash)) + + // Adjust the nonParams array for further operations + nonParams = nonParams[1:] + } + + // Validate Longitute and Latitude if err := geoUtil.ValidateLonLat(lon, lat); err != nil { - return GEOSEARCHResNilRes, err + return GEOADDResNilRes, err } - finalRadius, _ := geoUtil.ConvertToMeter(radius, unit) - var ss *types.SortedSet - obj := s.Get(key) - if obj == nil { - return GEODISTResNilRes, nil + // Create shape based on BYBOX or BYRADIUS param + var searchShape geoUtil.GeoShape + + // Create shape from BYBOX + if params[types.BYBOX] != "" { + if len(nonParams) < 2 { + return GEOSEARCHResNilRes, errors.ErrWrongArgumentCount("GEOSEARCH") + } + + var width, height float64 + var errWidth, errHeight error + width, errWidth = strconv.ParseFloat(nonParams[0], 10) + height, errHeight = strconv.ParseFloat(nonParams[1], 10) + + if errWidth != nil || errHeight != nil { + return GEOSEARCHResNilRes, errors.ErrInvalidNumberFormat + } + if height <= 0 || width <= 0 { + return GEOSEARCHResNilRes, errors.ErrGeneral("HEIGHT, WIDTH should be > 0") + } + + searchShape, _ = geoUtil.GetNewGeoShapeRectangle(width, height, lon, lat, unit) + + // Adjust the nonParams array for further operations + nonParams = nonParams[2:] } - if obj.Type != object.ObjTypeSortedSet { - return GEOSEARCHResNilRes, errors.ErrWrongTypeOperation + // Create shape from BYRADIUS + if params[types.BYRADIUS] != "" { + if len(nonParams) < 1 { + return GEOSEARCHResNilRes, errors.ErrWrongArgumentCount("GEOSEARCH") + } + + var radius float64 + var errRad error + radius, errRad = strconv.ParseFloat(nonParams[0], 10) + + if errRad != nil { + return GEOSEARCHResNilRes, errors.ErrInvalidNumberFormat + } + if radius <= 0 { + return GEOSEARCHResNilRes, errors.ErrGeneral("RADIUS should be > 0") + } + + searchShape, _ = geoUtil.GetNewGeoShapeCircle(radius, lon, lat, unit) + + // Adjust the nonParams array for further operations + nonParams = nonParams[1:] + } + + // Get COUNT based on Params + var count int = -1 + var errCount error + + // Check for COUNT to limit the output + if params[types.COUNT] != "" { + if len(nonParams) < 1 { + return GEOSEARCHResNilRes, errors.ErrWrongArgumentCount("GEOSEARCH") + } + count, errCount = strconv.Atoi(nonParams[0]) + if errCount != nil { + return GEOSEARCHResNilRes, errors.ErrInvalidNumberFormat + } + if count <= 0 { + return GEOSEARCHResNilRes, errors.ErrGeneral("COUNT must be > 0") + } + + // Adjust the nonParams array for further operations + nonParams = nonParams[1:] + } + + // If all the params are not used till now + // Means there're some unknown param + if len(nonParams) != 0 { + return GEOSEARCHResNilRes, errors.ErrUnknownOption(nonParams[0]) + } + + // Check for ANY option + var anyOption bool = false + if params[types.ANY] != "" { + anyOption = true + } + + // Check for Sorting Key ASC or DESC (-1 = DESC, 0 = NoSort, 1 = ASC) + var sortType float64 = 0 + if params[types.ASC] != "" { + sortType = 1 + } + if params[types.DESC] != "" { + sortType = -1 + } + + // COUNT without ordering does not make much sense (we need to sort in order to return the closest N entries) + // Note that this is not needed for ANY option + if count != -1 && sortType == 0 && !anyOption { + sortType = 1 } - ss = obj.Value.(*types.SortedSet) var withCoord, withDist, withHash bool = false, false, false @@ -135,48 +330,124 @@ func evalGEOSEARCH(c *Cmd, s *dsstore.Store) (*CmdRes, error) { withHash = true } - boudingBox := geoUtil.GetBoundingBoxWithLonLat(lon, lat, finalRadius) + // Find Neighbors from the shape + neighbors, steps := geoUtil.GetNeighborsForGeoSearchUsingRadius(searchShape) + neighborsArr := geoUtil.NeightborsToArray(neighbors) - minLon := boudingBox[0] - minLat := boudingBox[1] - maxLon := boudingBox[2] - maxLat := boudingBox[3] + // HashMap of all the nodes (we are making map for deduplication) + geoElementMap := map[string]*wire.GEOElement{} + totalElements := 0 - minHash := geoUtil.EncodeHash(minLon, minLat) - maxHash := geoUtil.EncodeHash(maxLon, maxLat) + // Find all the elements in the neighbor and the center block + for _, neighbor := range neighborsArr { - zElements := ss.ZRANGE(int(minHash), int(maxHash), true, false) - geoElements := []*wire.GEOElement{} + // Discarded neighbors + if neighbor == 0 { + continue + } + + // If ANY option is used and totalElements == count + // Break the loop and Return the current result + if anyOption && count == totalElements { + break + } - for _, ele := range zElements { + maxHash, minHash := geoUtil.GetMaxAndMinHashForBoxHash(neighbor, steps) - eleLon, eleLat := geoUtil.DecodeHash(uint64(ele.Score)) - dist := geoUtil.GetDistance(eleLon, eleLat, lon, lat) + zElements := ss.ZRANGE(int(minHash), int(maxHash), true, false) - if dist <= finalRadius { + for _, ele := range zElements { - geoElement := wire.GEOElement{ - Member: ele.Member, + eleLon, eleLat := geoUtil.DecodeHash(uint64(ele.Score)) + dist := searchShape.GetDistanceIfWithinShape(eleLon, eleLat) + + if anyOption && totalElements == count { + break } - if withCoord { - geoElement.Coordinates = &wire.GEOCoordinates{ - Longitude: eleLon, - Latitude: eleLat, + if dist != 0 { + + distConverted, _ := geoUtil.ConvertDistance(dist, unit) + geoElement := wire.GEOElement{ + Member: ele.Member, + Coordinates: &wire.GEOCoordinates{ + Longitude: eleLon, + Latitude: eleLat, + }, + Distance: distConverted, + Hash: uint64(ele.Score), } + geoElementMap[ele.Member] = &geoElement + totalElements++ } - if withDist { - geoElement.Distance, _ = geoUtil.ConvertDistance(dist, unit) - } - if withHash { - geoElement.Hash = uint64(ele.Score) - } - geoElements = append(geoElements, &geoElement) + } + } + + // Convert map to array + geoElements := []*wire.GEOElement{} + + for _, ele := range geoElementMap { + geoElements = append(geoElements, ele) + } + + // Let count be the total elements we need + if count == -1 { + count = totalElements + } + // Return unsorted result if ANY is used or sortType = 0 + if anyOption || sortType == 0 { + filterDimensionsBasedOnFlags(geoElements, withCoord, withDist, withHash) + return newGEOSEARCHRes(geoElements), nil + } + + // Comparator function for MaxHeap + // If ASC is set -> we use MaxHeap -> To Pop out the largest element if LEN > COUNT + // If DESC is set -> we use MinHeap -> To Pop out the smallest element if LEN > COUNT + // So Reverse the final array + cmp := func(a, b interface{}) int { + distance1 := a.(*wire.GEOElement).Distance + distance2 := b.(*wire.GEOElement).Distance + if distance1*sortType < distance2*sortType { + return 1 + } else if distance1*sortType > distance2*sortType { + return -1 + } + return 0 + } + + // Create a priority Queue to store the 'COUNT' results + pq := priorityqueue.NewWith(cmp) + + for _, ele := range geoElements { + pq.Enqueue(ele) + if pq.Size() > count { + pq.Dequeue() + } } - return newGEOSEARCHRes(geoElements), nil + // Final result Arr + resultGeoElements := []*wire.GEOElement{} + + // Transfer elements from priority Queue to Arr + for pq.Size() > 0 { + queueEle, _ := pq.Dequeue() + geoEle := queueEle.(*wire.GEOElement) + resultGeoElements = append(resultGeoElements, geoEle) + } + + // Reverse the output array Because + // If ASC is set -> we use MaxHeap -> Which will give use DESC array + // If DESC is set -> we use MinHeap -> Which will give use ASC array + // So Reverse the final array + for i, j := 0, len(resultGeoElements)-1; i < j; i, j = i+1, j-1 { + resultGeoElements[i], resultGeoElements[j] = resultGeoElements[j], resultGeoElements[i] + } + + filterDimensionsBasedOnFlags(resultGeoElements, withCoord, withDist, withHash) + + return newGEOSEARCHRes(resultGeoElements), nil } @@ -188,3 +459,19 @@ func executeGEOSEARCH(c *Cmd, sm *shardmanager.ShardManager) (*CmdRes, error) { shard := sm.GetShardForKey(c.C.Args[0]) return evalGEOSEARCH(c, shard.Thread.Store()) } + +func filterDimensionsBasedOnFlags(geoElements []*wire.GEOElement, withCoord, withDist, withHash bool) { + for _, ele := range geoElements { + if !withCoord { + ele.Coordinates = nil + } + + if !withDist { + ele.Distance = 0 + } + + if !withHash { + ele.Hash = 0 + } + } +} diff --git a/internal/cmd/cmd_set.go b/internal/cmd/cmd_set.go index fe0722dbf..7a2df8649 100644 --- a/internal/cmd/cmd_set.go +++ b/internal/cmd/cmd_set.go @@ -95,7 +95,12 @@ func parseParams(args []string) (params map[types.Param]string, nonParams []stri i++ case types.XX, types.NX, types.KEEPTTL, types.LT, types.GT, types.CH, types.INCR: params[arg] = "true" - case types.M, types.KM, types.MI, types.FT, types.WITHCOORD, types.WITHDIST, types.WITHHASH: + case types.M, types.KM, types.MI, types.FT, + types.WITHCOORD, types.WITHDIST, types.WITHHASH, + types.FROMLONLAT, types.FROMMEMBER, + types.BYBOX, types.BYRADIUS, + types.ASC, types.DESC, + types.COUNT, types.ANY: params[arg] = "true" default: nonParams = append(nonParams, args[i]) diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 23b33aa3f..696192e51 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -89,6 +89,23 @@ var ( ErrInvalidLonLatPair = func(lon, lat float64) error { return fmt.Errorf("Invalid Longitude, Latitude pair ('%f', '%f')! Check the range in Docs", lon, lat) } + ErrMemberNotFoundInSortedSet = func(member string) error { + return fmt.Errorf("Member not found: %s", member) + } + ErrInvalidSetOfOptions = func(options ...string) error { + errorString := "Invalid set of options:" + for i := range options { + errorString += " '" + options[i] + "'" + } + return fmt.Errorf(errorString) + } + ErrNeedOneOfTheOptions = func(options ...string) error { + errorString := "Need one of the options:" + for i := range options { + errorString += " '" + options[i] + "'" + } + return fmt.Errorf(errorString) + } ErrGeneral = func(err string) error { return fmt.Errorf("%s", err) // General error format for various commands. } @@ -119,6 +136,10 @@ var ( ErrUnknownCmd = func(cmd string) error { return fmt.Errorf("ERROR unknown command '%v'", cmd) // Indicates that an unsupported encoding type was provided. } + + ErrUnknownOption = func(option string) error { + return fmt.Errorf("ERROR unknown option '%v'", option) // Indicates that an unsupported option. + } ) type PreProcessError struct { diff --git a/internal/geo/geo.go b/internal/geo/geo.go index 60337b3b3..af34a3246 100644 --- a/internal/geo/geo.go +++ b/internal/geo/geo.go @@ -14,12 +14,78 @@ const BIT_PRECISION = 52 // Earth's radius in meters const EARTH_RADIUS float64 = 6372797.560856 +// The maximum/minumum projected coordinate value (in meters) in the Web Mercator projection (EPSG:3857) +// Earth’s equator: ~40,075 km → half of that = ~20,037 km +// The Mercator projection transforms the globe into a square map. +// MERCATOR_MAX is the extent of that square in meters. +const MERCATOR_MAX float64 = 20037726.37 +const MERCATOR_MIN float64 = -20037726.37 + // Limits from EPSG:900913 / EPSG:3785 / OSGEO:41001 const LAT_MIN float64 = -85.05112878 const LAT_MAX float64 = 85.05112878 const LONG_MIN float64 = -180 const LONG_MAX float64 = 180 +type Neighbors struct { + North uint64 + NorthEast uint64 + East uint64 + SouthEast uint64 + South uint64 + SouthWest uint64 + West uint64 + NorthWest uint64 + Center uint64 +} + +func ArrayToNeighbors(arr []uint64) *Neighbors { + neightbors := Neighbors{} + if len(arr) < 8 { + return &neightbors + } + + neightbors.North = arr[0] + neightbors.NorthEast = arr[1] + neightbors.East = arr[2] + neightbors.SouthEast = arr[3] + neightbors.South = arr[4] + neightbors.SouthWest = arr[5] + neightbors.West = arr[6] + neightbors.NorthWest = arr[7] + neightbors.Center = arr[8] + + return &neightbors +} + +func NeightborsToArray(neightbors *Neighbors) [9]uint64 { + arr := [9]uint64{} + + arr[0] = neightbors.North + arr[1] = neightbors.NorthEast + arr[2] = neightbors.East + arr[3] = neightbors.SouthEast + arr[4] = neightbors.South + arr[5] = neightbors.SouthWest + arr[6] = neightbors.West + arr[7] = neightbors.NorthWest + arr[8] = neightbors.Center + + return arr +} + +// Computes the min (inclusive) and max (exclusive) scores for a given hash box. +// Aligns the geohash bits to BIT_PRECISION score by left-shifting +func GetMaxAndMinHashForBoxHash(hash uint64, steps uint) (max, min uint64) { + shift := BIT_PRECISION - (steps) + base := hash << shift + rangeSize := uint64(1) << shift + min = base + max = base + rangeSize - 1 + + return max, min +} + func ValidateLonLat(longitude, latitude float64) error { if latitude > LAT_MAX || latitude < LAT_MIN || longitude > LONG_MAX || longitude < LONG_MIN { return errors.ErrInvalidLonLatPair(longitude, latitude) @@ -27,6 +93,7 @@ func ValidateLonLat(longitude, latitude float64) error { return nil } +// Encode given Lon and Lat to GEOHASH func EncodeHash(longitude, latitude float64) uint64 { return geohash.EncodeIntWithPrecision(latitude, longitude, BIT_PRECISION) } @@ -65,7 +132,7 @@ func RadToDeg(rad float64) float64 { } func GetLatDistance(lat1, lat2 float64) float64 { - return EARTH_RADIUS * math.Abs(lat2-lat1) + return EARTH_RADIUS * math.Abs(DegToRad(lat2)-DegToRad(lat1)) } // ConvertDistance converts a distance from meters to the desired unit @@ -85,7 +152,7 @@ func ConvertDistance(distance float64, unit types.Param) (float64, error) { return 0, errors.ErrInvalidUnit(string(unit)) } - // Round to 4 decimal places + // Round to 5 decimal places return math.Round(result*10000) / 10000, nil } @@ -121,33 +188,127 @@ func ConvertToMeter(distance float64, unit types.Param) (float64, error) { // \ / / \ / \ // --------- /----------------\ /---------------\ // Northern Hemisphere Southern Hemisphere Around the equator -func GetBoundingBoxWithHash(hash uint64, radius float64) [4]float64 { +func GetBoundingBoxForRectangleWithHash(hash uint64, widht float64, height float64) *geohash.Box { + + boudingBox := geohash.Box{} - boudingBox := [4]float64{} + widht /= 2 + height /= 2 lon, lat := DecodeHash(hash) - latDelta := RadToDeg(radius / EARTH_RADIUS) - lonDeltaTop := RadToDeg(radius / EARTH_RADIUS / math.Cos(DegToRad(lat+latDelta))) - lonDeltaBottom := RadToDeg(radius / EARTH_RADIUS / math.Cos(DegToRad(lat-latDelta))) + latDelta := RadToDeg(height / EARTH_RADIUS) + lonDeltaTop := RadToDeg(widht / EARTH_RADIUS / math.Cos(DegToRad(lat+latDelta))) + lonDeltaBottom := RadToDeg(widht / EARTH_RADIUS / math.Cos(DegToRad(lat-latDelta))) - boudingBox[1] = lat - latDelta - boudingBox[3] = lat + latDelta + boudingBox.MinLat = lat - latDelta + boudingBox.MaxLat = lat + latDelta if lat < 0 { - boudingBox[0] = lon - lonDeltaBottom - boudingBox[2] = lon + lonDeltaBottom + boudingBox.MinLng = lon - lonDeltaBottom + boudingBox.MaxLng = lon + lonDeltaBottom } else { - boudingBox[0] = lon - lonDeltaTop - boudingBox[2] = lon + lonDeltaTop + boudingBox.MinLng = lon - lonDeltaTop + boudingBox.MaxLng = lon + lonDeltaTop } - return boudingBox + return &boudingBox } -func GetBoundingBoxWithLonLat(lon, lat float64, radius float64) [4]float64 { - return GetBoundingBoxWithHash(EncodeHash(lon, lat), radius) +func GetBoundingBoxForRectangleWithLonLat(lon, lat float64, widht float64, height float64) *geohash.Box { + return GetBoundingBoxForRectangleWithHash(EncodeHash(lon, lat), widht, height) } -func parseParams() { +// Find the step → Precision at which 9 cells (3x3 cells) can cover the entire area +func EstimateStepsByRadius(radius float64, latitude float64) uint { + if radius == 0 { + return 26 + } + var step uint = 1 + for radius < MERCATOR_MAX { + radius *= 2 + step++ + } + step -= 2 /* Make sure range is included in most of the base cases. */ + + // Wider range towards the poles + if latitude > 66 || latitude < -66 { + step-- + if latitude > 80 || latitude < -80 { + step-- + } + } + + /* Frame to valid range. */ + if step < 1 { + step = 1 + } + if step > 26 { + step = 26 + } + return step +} + +func GetNeighborsForGeoSearchUsingRadius(geoShape GeoShape) (neighbors *Neighbors, steps uint) { + lon, lat := geoShape.GetLonLat() + width, height := geoShape.GetBoudingBoxWidhtAndHeight() + radius := geoShape.GetRadius() + // Create bounding box to validate/invalidate neighbors later + boudingBox := GetBoundingBoxForRectangleWithLonLat(lon, lat, width, height) + + // as (mmcloughlin/geohash) requires total number of bits, not steps + steps = 2 * EstimateStepsByRadius(radius, lat) + + centerHash := geohash.EncodeIntWithPrecision(lat, lon, steps) + centerBox := geohash.BoundingBoxIntWithPrecision(centerHash, steps) + neighborsArr := geohash.NeighborsIntWithPrecision(centerHash, steps) + neighborsArr = append(neighborsArr, centerHash) + neighbors = ArrayToNeighbors(neighborsArr) + + // Check if the step is enough at the limits of the covered area. + // Decode each of the 8 neighbours to get max and min (lon, lat) + // If North.maxLatitude < maxLatitude(from bouding box) then we have to reduce step to increase neighbour size + // Do this for N, S, E, W + northBox := geohash.BoundingBoxIntWithPrecision(neighbors.North, steps) + eastBox := geohash.BoundingBoxIntWithPrecision(neighbors.East, steps) + southBox := geohash.BoundingBoxIntWithPrecision(neighbors.South, steps) + westBox := geohash.BoundingBoxIntWithPrecision(neighbors.West, steps) + + if northBox.MaxLat < boudingBox.MaxLat || southBox.MinLat > boudingBox.MinLat || eastBox.MaxLng < boudingBox.MaxLng || westBox.MinLng > boudingBox.MinLng { + steps-- + centerHash = geohash.EncodeIntWithPrecision(lat, lon, steps) + centerBox = geohash.BoundingBoxIntWithPrecision(centerHash, steps) + neighborsArr := geohash.NeighborsIntWithPrecision(centerHash, steps) + neighborsArr = append(neighborsArr, centerHash) + neighbors = ArrayToNeighbors(neighborsArr) + } + + // Update the center block as well + neighbors.Center = centerHash + + // Exclude search areas that are useless + // why not at step == 1? Because geohash cells are so large that excluding neighbors could miss valid points. + if steps >= 2 { + if centerBox.MinLat < boudingBox.MinLat { + neighbors.South = 0 + neighbors.SouthWest = 0 + neighbors.SouthEast = 0 + } + if centerBox.MaxLat > boudingBox.MaxLat { + neighbors.North = 0 + neighbors.NorthEast = 0 + neighbors.NorthWest = 0 + } + if centerBox.MinLng < boudingBox.MinLng { + neighbors.West = 0 + neighbors.SouthWest = 0 + neighbors.NorthWest = 0 + } + if centerBox.MaxLng > boudingBox.MaxLng { + neighbors.East = 0 + neighbors.SouthEast = 0 + neighbors.NorthEast = 0 + } + } + return neighbors, steps } diff --git a/internal/geo/geoShape.go b/internal/geo/geoShape.go new file mode 100644 index 000000000..308ea09c2 --- /dev/null +++ b/internal/geo/geoShape.go @@ -0,0 +1,115 @@ +package geoUtil + +import ( + "math" + + "github.com/dicedb/dice/internal/types" +) + +// This represents a shape in which we have to find nodes +type GeoShape interface { + GetBoudingBoxWidhtAndHeight() (float64, float64) + GetRadius() float64 + GetLonLat() (float64, float64) + GetDistanceIfWithinShape(lon float64, lat float64) (distance float64) +} + +// GeoShapeCircle Implementation of GeoShape +type GeoShapeCircle struct { + Radius float64 + Longitude float64 + Latitude float64 + Unit types.Param +} + +func (circle *GeoShapeCircle) GetBoudingBoxWidhtAndHeight() (float64, float64) { + return circle.Radius * 2, circle.Radius * 2 +} + +func (circle *GeoShapeCircle) GetRadius() float64 { + return circle.Radius +} + +func (circle *GeoShapeCircle) GetLonLat() (float64, float64) { + return circle.Longitude, circle.Latitude +} + +func (circle *GeoShapeCircle) GetDistanceIfWithinShape(lon float64, lat float64) (distance float64) { + distance = GetDistance(lon, lat, circle.Longitude, circle.Latitude) + if distance > circle.Radius { + return 0 + } + return distance +} + +func GetNewGeoShapeCircle(radius float64, longitude float64, latitude float64, unit types.Param) (*GeoShapeCircle, error) { + var convRadius float64 + var err error + if convRadius, err = ConvertToMeter(radius, unit); err != nil { + return nil, err + } + return &GeoShapeCircle{ + Radius: convRadius, + Longitude: longitude, + Latitude: latitude, + Unit: unit, + }, nil +} + +// GeoShapeRectangle Implementation of GeoShape +type GeoShapeRectangle struct { + Widht float64 + Height float64 + Longitude float64 + Latitude float64 + Unit types.Param +} + +func (rec *GeoShapeRectangle) GetBoudingBoxWidhtAndHeight() (float64, float64) { + return rec.Widht, rec.Height +} + +func (rec *GeoShapeRectangle) GetRadius() float64 { + radius := math.Sqrt((rec.Widht/2)*(rec.Widht/2) + (rec.Height/2)*(rec.Height/2)) + return radius +} + +func (rec *GeoShapeRectangle) GetLonLat() (float64, float64) { + return rec.Longitude, rec.Latitude +} + +func (rec *GeoShapeRectangle) GetDistanceIfWithinShape(lon float64, lat float64) (distance float64) { + // latitude distance is less expensive to compute than longitude distance + // so we check first for the latitude condition + latDistance := GetLatDistance(lat, rec.Latitude) + if latDistance > rec.Height/2 { + return 0 + } + + lonDistance := GetDistance(lon, lat, rec.Longitude, lat) + if lonDistance > rec.Widht/2 { + return 0 + } + + distance = GetDistance(lon, lat, rec.Longitude, rec.Latitude) + + return distance +} + +func GetNewGeoShapeRectangle(widht float64, height float64, longitude float64, latitude float64, unit types.Param) (*GeoShapeRectangle, error) { + var convWidth, convHeight float64 + var err error + if convWidth, err = ConvertToMeter(widht, unit); err != nil { + return nil, err + } + if convHeight, err = ConvertToMeter(height, unit); err != nil { + return nil, err + } + return &GeoShapeRectangle{ + Widht: convWidth, + Height: convHeight, + Longitude: longitude, + Latitude: latitude, + Unit: unit, + }, nil +} diff --git a/internal/types/params.go b/internal/types/params.go index bd5d21faa..b8dfbb1ed 100644 --- a/internal/types/params.go +++ b/internal/types/params.go @@ -26,6 +26,18 @@ const ( MI Param = "MI" FT Param = "FT" + FROMMEMBER Param = "FROMMEMBER" + FROMLONLAT Param = "FROMLONLAT" + + BYRADIUS Param = "BYRADIUS" + BYBOX Param = "BYBOX" + + COUNT Param = "COUNT" + ANY Param = "ANY" + + ASC Param = "ASC" + DESC Param = "DESC" + WITHCOORD Param = "WITHCOORD" WITHDIST Param = "WITHDIST" WITHHASH Param = "WITHHASH" From 84d9048a1cece65f68734b4980f9b3fb86cf100a Mon Sep 17 00:00:00 2001 From: bipoool Date: Fri, 23 May 2025 09:47:08 +0000 Subject: [PATCH 3/7] Created GEO type --- internal/cmd/cmd_geoadd.go | 37 +-- internal/cmd/cmd_geodist.go | 24 +- internal/cmd/cmd_geosearch.go | 375 ++------------------- internal/geo/geo.go | 314 ------------------ internal/geo/geoShape.go | 115 ------- internal/types/geo.go | 604 ++++++++++++++++++++++++++++++++++ internal/types/geoShape.go | 281 ++++++++++++++++ 7 files changed, 928 insertions(+), 822 deletions(-) delete mode 100644 internal/geo/geo.go delete mode 100644 internal/geo/geoShape.go create mode 100644 internal/types/geo.go create mode 100644 internal/types/geoShape.go diff --git a/internal/cmd/cmd_geoadd.go b/internal/cmd/cmd_geoadd.go index 7750c88e6..ae202cf1c 100644 --- a/internal/cmd/cmd_geoadd.go +++ b/internal/cmd/cmd_geoadd.go @@ -7,7 +7,6 @@ import ( "strconv" "github.com/dicedb/dice/internal/errors" - geoUtil "github.com/dicedb/dice/internal/geo" "github.com/dicedb/dice/internal/object" "github.com/dicedb/dice/internal/shardmanager" dsstore "github.com/dicedb/dice/internal/store" @@ -73,46 +72,44 @@ func evalGEOADD(c *Cmd, s *dsstore.Store) (*CmdRes, error) { } key := c.C.Args[0] - geoHashs, members := []int64{}, []string{} params, nonParams := parseParams(c.C.Args[1:]) if len(nonParams)%3 != 0 { return GEOADDResNilRes, errors.ErrWrongArgumentCount("GEOADD") } + var gr *types.GeoRegistry + obj := s.Get(key) + if obj == nil { + gr = types.NewGeoRegistry() + } else { + if obj.Type != object.ObjTypeSortedSet { + return GEOADDResNilRes, errors.ErrWrongTypeOperation + } + gr = obj.Value.(*types.GeoRegistry) + } + + GeoCoordinates, members := []*types.GeoCoordinate{}, []string{} for i := 0; i < len(nonParams); i += 3 { lon, errLon := strconv.ParseFloat(nonParams[i], 10) lat, errLat := strconv.ParseFloat(nonParams[i+1], 10) if errLon != nil || errLat != nil { return GEOADDResNilRes, errors.ErrInvalidNumberFormat } - if err := geoUtil.ValidateLonLat(lon, lat); err != nil { + coordinate, err := types.NewGeoCoordinateFromLonLat(lon, lat) + if err != nil { return GEOADDResNilRes, err } - geoHash := geoUtil.EncodeHash(lon, lat) - - geoHashs = append(geoHashs, int64(geoHash)) + GeoCoordinates = append(GeoCoordinates, coordinate) members = append(members, nonParams[i+2]) } - var ss *types.SortedSet - obj := s.Get(key) - if obj == nil { - ss = types.NewSortedSet() - } else { - if obj.Type != object.ObjTypeSortedSet { - return GEOADDResNilRes, errors.ErrWrongTypeOperation - } - ss = obj.Value.(*types.SortedSet) - } - - // Note: Validation of the params is done in the types.SortedSet.ZADD method - count, err := ss.ZADD(geoHashs, members, params) + count, err := gr.Add(GeoCoordinates, members, params) if err != nil { return GEOADDResNilRes, err } - s.Put(key, s.NewObj(ss, -1, object.ObjTypeSortedSet), dsstore.WithPutCmd(dsstore.ZAdd)) + s.Put(key, s.NewObj(gr, -1, object.ObjTypeSortedSet), dsstore.WithPutCmd(dsstore.ZAdd)) return newGEOADDRes(count), nil } diff --git a/internal/cmd/cmd_geodist.go b/internal/cmd/cmd_geodist.go index 041c73fc9..eb7443d5d 100644 --- a/internal/cmd/cmd_geodist.go +++ b/internal/cmd/cmd_geodist.go @@ -5,7 +5,6 @@ package cmd import ( "github.com/dicedb/dice/internal/errors" - geoUtil "github.com/dicedb/dice/internal/geo" "github.com/dicedb/dice/internal/object" "github.com/dicedb/dice/internal/shardmanager" dsstore "github.com/dicedb/dice/internal/store" @@ -68,39 +67,24 @@ func evalGEODIST(c *Cmd, s *dsstore.Store) (*CmdRes, error) { key := c.C.Args[0] params, nonParams := parseParams(c.C.Args[1:]) - unit := getUnitTypeFromParsedParams(params) + unit := types.GetUnitTypeFromParsedParams(params) if len(c.C.Args) == 4 && len(unit) == 0 { return GEODISTResNilRes, errors.ErrInvalidUnit(c.C.Args[3]) } else if len(unit) == 0 { unit = types.M } - var ss *types.SortedSet + var gr *types.GeoRegistry obj := s.Get(key) if obj == nil { return GEODISTResNilRes, nil } - if obj.Type != object.ObjTypeSortedSet { return GEODISTResNilRes, errors.ErrWrongTypeOperation } - ss = obj.Value.(*types.SortedSet) - - node1 := ss.GetByKey(nonParams[0]) - node2 := ss.GetByKey(nonParams[1]) - - // @doubt - Should return error here? - if node1 == nil || node2 == nil { - return GEODISTResNilRes, nil - } - - hash1 := node1.Score() - hash2 := node2.Score() - - lon1, lat1 := geoUtil.DecodeHash(uint64(hash1)) - lon2, lat2 := geoUtil.DecodeHash(uint64(hash2)) + gr = obj.Value.(*types.GeoRegistry) - dist, err := geoUtil.ConvertDistance(geoUtil.GetDistance(lon1, lat1, lon2, lat2), unit) + dist, err := gr.GetDistanceBetweenMembers(nonParams[0], nonParams[1], unit) if err != nil { return GEODISTResNilRes, err diff --git a/internal/cmd/cmd_geosearch.go b/internal/cmd/cmd_geosearch.go index 2f62d5aae..a49d9e384 100644 --- a/internal/cmd/cmd_geosearch.go +++ b/internal/cmd/cmd_geosearch.go @@ -4,16 +4,12 @@ package cmd import ( - "strconv" - "github.com/dicedb/dice/internal/errors" - geoUtil "github.com/dicedb/dice/internal/geo" "github.com/dicedb/dice/internal/object" "github.com/dicedb/dice/internal/shardmanager" dsstore "github.com/dicedb/dice/internal/store" "github.com/dicedb/dice/internal/types" "github.com/dicedb/dicedb-go/wire" - "github.com/emirpasic/gods/queues/priorityqueue" ) var cGEOSEARCH = &CommandMeta{ @@ -21,7 +17,7 @@ var cGEOSEARCH = &CommandMeta{ Syntax: "GEOSEARCH key longitude latitude radius [WITHCOORD] [WITHDIST] [WITHHASH]", HelpShort: "GEOSEARCH Returns the members within the borders of the area specified by a given shape", HelpLong: ` -Return the members of a sorted set populated with geospatial information using GEOADD, which are within the borders of the area specified by a given shape. +Return the members of a sorted set populated with geospatial information using GEOADD, which are within the borders of the area specified by a given shape. This command extends the GEORADIUS command, so in addition to searching within circular areas, it supports searching within rectangular areas. This command should be used in place of the deprecated GEORADIUS and GEORADIUSBYMEMBER commands. @@ -45,11 +41,11 @@ Matching items are returned unsorted by default. To sort them, use one of the fo ASC: Sort returned items from the nearest to the farthest, relative to the center point. DESC: Sort returned items from the farthest to the nearest, relative to the center point. -All matching items are returned by default. To limit the results to the first N matching items, -use the COUNT option. When the ANY option is used, the command returns as soon as enough matches are found. -This means that the results returned may not be the ones closest to the specified point, -but the effort invested by the server to generate them is significantly less. When ANY is not provided, -the command will perform an effort that is proportional to the number of items matching the specified area and sort them, +All matching items are returned by default. To limit the results to the first N matching items, +use the COUNT option. When the ANY option is used, the command returns as soon as enough matches are found. +This means that the results returned may not be the ones closest to the specified point, +but the effort invested by the server to generate them is significantly less. When ANY is not provided, +the command will perform an effort that is proportional to the number of items matching the specified area and sort them, so to query very large areas with a very small COUNT option may be slow even if just a few results are returned. NOTE: @@ -60,23 +56,23 @@ NOTE: localhost:7379> GEOADD Delhi 77.2167 28.6315 CP 77.2295 28.6129 IndiaGate 77.1197 28.6412 Rajouri 77.1000 28.5562 Airport 77.1900 28.6517 KarolBagh OK 5 localhost:7379> GEOSEARCH Delhi FROMLONLAT 77.1000 28.5562 BYRADIUS 10 km COUNT 2 ANY -OK +OK 0) Airport 1) Rajouri localhost:7379> GEOSEARCH Delhi FROMLONLAT 77.1000 28.5562 BYRADIUS 10 km COUNT 2 DESC -OK +OK 0) Rajouri 1) Airport localhost:7379> GEOSEARCH Delhi FROMLONLAT 77.1000 28.5562 BYRADIUS 20 km COUNT 2 DESC -OK +OK 0) CP 1) IndiaGate localhost:7379> GEOSEARCH Delhi FROMLONLAT 77.1000 28.5562 BYBOX 40 40 km COUNT 2 ASC -OK +OK 0) Airport 1) Rajouri localhost:7379> GEOSEARCH Delhi FROMMEMBER CP BYBOX 40 40 km COUNT 2 ASC -OK +OK 0) IndiaGate 0) KarolBagh `, @@ -106,20 +102,6 @@ var ( GEOSEARCHResNilRes = newGEOSEARCHRes([]*wire.GEOElement{}) ) -func getUnitTypeFromParsedParams(params map[types.Param]string) types.Param { - if params[types.M] != "" { - return types.M - } else if params[types.KM] != "" { - return types.KM - } else if params[types.MI] != "" { - return types.MI - } else if params[types.FT] != "" { - return types.FT - } else { - return "" - } -} - func evalGEOSEARCH(c *Cmd, s *dsstore.Store) (*CmdRes, error) { if len(c.C.Args) < 5 { return GEOSEARCHResNilRes, errors.ErrWrongArgumentCount("GEOSEARCH") @@ -128,8 +110,13 @@ func evalGEOSEARCH(c *Cmd, s *dsstore.Store) (*CmdRes, error) { key := c.C.Args[0] params, nonParams := parseParams(c.C.Args[1:]) + // Validate all the parameters + if len(nonParams) < 2 { + return GEOSEARCHResNilRes, errors.ErrInvalidSyntax("GEOSEARCH") + } + // Get sorted set else return - var ss *types.SortedSet + var gr *types.GeoRegistry obj := s.Get(key) if obj == nil { return GEODISTResNilRes, nil @@ -137,317 +124,15 @@ func evalGEOSEARCH(c *Cmd, s *dsstore.Store) (*CmdRes, error) { if obj.Type != object.ObjTypeSortedSet { return GEOSEARCHResNilRes, errors.ErrWrongTypeOperation } - ss = obj.Value.(*types.SortedSet) - - // Validate all the parameters - if len(nonParams) < 2 { - return GEOSEARCHResNilRes, errors.ErrInvalidSyntax("GEOSEARCH") - } - unit := getUnitTypeFromParsedParams(params) - if len(unit) == 0 { - return GEOSEARCHResNilRes, errors.ErrInvalidUnit(string(unit)) - } - - // Return error if both FROMLONLAT & FROMMEMBER are set - if params[types.FROMLONLAT] != "" && params[types.FROMMEMBER] != "" { - return GEOSEARCHResNilRes, errors.ErrInvalidSetOfOptions(string(types.FROMLONLAT), string(types.FROMMEMBER)) - } - - // Return error if none of FROMLONLAT & FROMMEMBER are set - if params[types.FROMLONLAT] == "" && params[types.FROMMEMBER] == "" { - return GEOSEARCHResNilRes, errors.ErrNeedOneOfTheOptions(string(types.FROMLONLAT), string(types.FROMMEMBER)) - } - - // Return error if both BYBOX & BYRADIUS are set - if params[types.BYBOX] != "" && params[types.BYRADIUS] != "" { - return GEOSEARCHResNilRes, errors.ErrInvalidSetOfOptions(string(types.BYBOX), string(types.BYRADIUS)) - } - - // Return error if none of BYBOX & BYRADIUS are set - if params[types.BYBOX] == "" && params[types.BYRADIUS] == "" { - return GEOSEARCHResNilRes, errors.ErrNeedOneOfTheOptions(string(types.BYBOX), string(types.BYRADIUS)) - } - - // Return error if ANY is used without COUNT - if params[types.ANY] != "" && params[types.COUNT] == "" { - return GEOSEARCHResNilRes, errors.ErrGeneral("ANY argument requires COUNT argument") - } - - // Return error if Both ASC & DESC are used - if params[types.ASC] != "" && params[types.DESC] != "" { - return GEOSEARCHResNilRes, errors.ErrGeneral("Use one of ASC or DESC") - } - - // Fetch Longitute and Latitude based on FROMLONLAT & FROMMEMBER param - var lon, lat float64 - var errLon, errLat error - - // Fetch Longitute and Latitude from params - if params[types.FROMLONLAT] != "" { - if len(nonParams) < 2 { - return GEOSEARCHResNilRes, errors.ErrWrongArgumentCount("GEOSEARCH") - } - lon, errLon = strconv.ParseFloat(nonParams[0], 10) - lat, errLat = strconv.ParseFloat(nonParams[1], 10) - - if errLon != nil || errLat != nil { - return GEOSEARCHResNilRes, errors.ErrInvalidNumberFormat - } - - // Adjust the nonParams array for further operations - nonParams = nonParams[2:] - } - - // Fetch Longitute and Latitude from member - if params[types.FROMMEMBER] != "" { - if len(nonParams) < 1 { - return GEOSEARCHResNilRes, errors.ErrWrongArgumentCount("GEOSEARCH") - } - member := nonParams[0] - node := ss.GetByKey(member) - if node == nil { - return GEOSEARCHResNilRes, errors.ErrMemberNotFoundInSortedSet(member) - } - hash := node.Score() - lon, lat = geoUtil.DecodeHash(uint64(hash)) - - // Adjust the nonParams array for further operations - nonParams = nonParams[1:] - } - - // Validate Longitute and Latitude - if err := geoUtil.ValidateLonLat(lon, lat); err != nil { - return GEOADDResNilRes, err - } - - // Create shape based on BYBOX or BYRADIUS param - var searchShape geoUtil.GeoShape - - // Create shape from BYBOX - if params[types.BYBOX] != "" { - if len(nonParams) < 2 { - return GEOSEARCHResNilRes, errors.ErrWrongArgumentCount("GEOSEARCH") - } - - var width, height float64 - var errWidth, errHeight error - width, errWidth = strconv.ParseFloat(nonParams[0], 10) - height, errHeight = strconv.ParseFloat(nonParams[1], 10) - - if errWidth != nil || errHeight != nil { - return GEOSEARCHResNilRes, errors.ErrInvalidNumberFormat - } - if height <= 0 || width <= 0 { - return GEOSEARCHResNilRes, errors.ErrGeneral("HEIGHT, WIDTH should be > 0") - } - - searchShape, _ = geoUtil.GetNewGeoShapeRectangle(width, height, lon, lat, unit) - - // Adjust the nonParams array for further operations - nonParams = nonParams[2:] - } - - // Create shape from BYRADIUS - if params[types.BYRADIUS] != "" { - if len(nonParams) < 1 { - return GEOSEARCHResNilRes, errors.ErrWrongArgumentCount("GEOSEARCH") - } - - var radius float64 - var errRad error - radius, errRad = strconv.ParseFloat(nonParams[0], 10) - - if errRad != nil { - return GEOSEARCHResNilRes, errors.ErrInvalidNumberFormat - } - if radius <= 0 { - return GEOSEARCHResNilRes, errors.ErrGeneral("RADIUS should be > 0") - } - - searchShape, _ = geoUtil.GetNewGeoShapeCircle(radius, lon, lat, unit) - - // Adjust the nonParams array for further operations - nonParams = nonParams[1:] - } - - // Get COUNT based on Params - var count int = -1 - var errCount error - - // Check for COUNT to limit the output - if params[types.COUNT] != "" { - if len(nonParams) < 1 { - return GEOSEARCHResNilRes, errors.ErrWrongArgumentCount("GEOSEARCH") - } - count, errCount = strconv.Atoi(nonParams[0]) - if errCount != nil { - return GEOSEARCHResNilRes, errors.ErrInvalidNumberFormat - } - if count <= 0 { - return GEOSEARCHResNilRes, errors.ErrGeneral("COUNT must be > 0") - } - - // Adjust the nonParams array for further operations - nonParams = nonParams[1:] - } + gr = obj.Value.(*types.GeoRegistry) - // If all the params are not used till now - // Means there're some unknown param - if len(nonParams) != 0 { - return GEOSEARCHResNilRes, errors.ErrUnknownOption(nonParams[0]) - } + geoElements, err := gr.GeoSearchElementsWithinShape(params, nonParams) - // Check for ANY option - var anyOption bool = false - if params[types.ANY] != "" { - anyOption = true - } - - // Check for Sorting Key ASC or DESC (-1 = DESC, 0 = NoSort, 1 = ASC) - var sortType float64 = 0 - if params[types.ASC] != "" { - sortType = 1 - } - if params[types.DESC] != "" { - sortType = -1 - } - - // COUNT without ordering does not make much sense (we need to sort in order to return the closest N entries) - // Note that this is not needed for ANY option - if count != -1 && sortType == 0 && !anyOption { - sortType = 1 - } - - var withCoord, withDist, withHash bool = false, false, false - - if params[types.WITHCOORD] != "" { - withCoord = true - } - if params[types.WITHDIST] != "" { - withDist = true + if err != nil { + return GEOSEARCHResNilRes, err } - if params[types.WITHHASH] != "" { - withHash = true - } - - // Find Neighbors from the shape - neighbors, steps := geoUtil.GetNeighborsForGeoSearchUsingRadius(searchShape) - neighborsArr := geoUtil.NeightborsToArray(neighbors) - - // HashMap of all the nodes (we are making map for deduplication) - geoElementMap := map[string]*wire.GEOElement{} - totalElements := 0 - - // Find all the elements in the neighbor and the center block - for _, neighbor := range neighborsArr { - - // Discarded neighbors - if neighbor == 0 { - continue - } - - // If ANY option is used and totalElements == count - // Break the loop and Return the current result - if anyOption && count == totalElements { - break - } - - maxHash, minHash := geoUtil.GetMaxAndMinHashForBoxHash(neighbor, steps) - zElements := ss.ZRANGE(int(minHash), int(maxHash), true, false) - - for _, ele := range zElements { - - eleLon, eleLat := geoUtil.DecodeHash(uint64(ele.Score)) - dist := searchShape.GetDistanceIfWithinShape(eleLon, eleLat) - - if anyOption && totalElements == count { - break - } - - if dist != 0 { - - distConverted, _ := geoUtil.ConvertDistance(dist, unit) - geoElement := wire.GEOElement{ - Member: ele.Member, - Coordinates: &wire.GEOCoordinates{ - Longitude: eleLon, - Latitude: eleLat, - }, - Distance: distConverted, - Hash: uint64(ele.Score), - } - geoElementMap[ele.Member] = &geoElement - totalElements++ - } - - } - } - - // Convert map to array - geoElements := []*wire.GEOElement{} - - for _, ele := range geoElementMap { - geoElements = append(geoElements, ele) - } - - // Let count be the total elements we need - if count == -1 { - count = totalElements - } - - // Return unsorted result if ANY is used or sortType = 0 - if anyOption || sortType == 0 { - filterDimensionsBasedOnFlags(geoElements, withCoord, withDist, withHash) - return newGEOSEARCHRes(geoElements), nil - } - - // Comparator function for MaxHeap - // If ASC is set -> we use MaxHeap -> To Pop out the largest element if LEN > COUNT - // If DESC is set -> we use MinHeap -> To Pop out the smallest element if LEN > COUNT - // So Reverse the final array - cmp := func(a, b interface{}) int { - distance1 := a.(*wire.GEOElement).Distance - distance2 := b.(*wire.GEOElement).Distance - if distance1*sortType < distance2*sortType { - return 1 - } else if distance1*sortType > distance2*sortType { - return -1 - } - return 0 - } - - // Create a priority Queue to store the 'COUNT' results - pq := priorityqueue.NewWith(cmp) - - for _, ele := range geoElements { - pq.Enqueue(ele) - if pq.Size() > count { - pq.Dequeue() - } - } - - // Final result Arr - resultGeoElements := []*wire.GEOElement{} - - // Transfer elements from priority Queue to Arr - for pq.Size() > 0 { - queueEle, _ := pq.Dequeue() - geoEle := queueEle.(*wire.GEOElement) - resultGeoElements = append(resultGeoElements, geoEle) - } - - // Reverse the output array Because - // If ASC is set -> we use MaxHeap -> Which will give use DESC array - // If DESC is set -> we use MinHeap -> Which will give use ASC array - // So Reverse the final array - for i, j := 0, len(resultGeoElements)-1; i < j; i, j = i+1, j-1 { - resultGeoElements[i], resultGeoElements[j] = resultGeoElements[j], resultGeoElements[i] - } - - filterDimensionsBasedOnFlags(resultGeoElements, withCoord, withDist, withHash) - - return newGEOSEARCHRes(resultGeoElements), nil + return newGEOSEARCHRes(geoElements), nil } @@ -459,19 +144,3 @@ func executeGEOSEARCH(c *Cmd, sm *shardmanager.ShardManager) (*CmdRes, error) { shard := sm.GetShardForKey(c.C.Args[0]) return evalGEOSEARCH(c, shard.Thread.Store()) } - -func filterDimensionsBasedOnFlags(geoElements []*wire.GEOElement, withCoord, withDist, withHash bool) { - for _, ele := range geoElements { - if !withCoord { - ele.Coordinates = nil - } - - if !withDist { - ele.Distance = 0 - } - - if !withHash { - ele.Hash = 0 - } - } -} diff --git a/internal/geo/geo.go b/internal/geo/geo.go deleted file mode 100644 index af34a3246..000000000 --- a/internal/geo/geo.go +++ /dev/null @@ -1,314 +0,0 @@ -package geoUtil - -import ( - "math" - - "github.com/dicedb/dice/internal/errors" - "github.com/dicedb/dice/internal/types" - "github.com/mmcloughlin/geohash" -) - -// Bit precision - Same as redis (https://github.com/redis/redis/blob/5d0d64b062c160093dc287ed5d18ec7c807873cf/src/geohash_helper.c#L213) -const BIT_PRECISION = 52 - -// Earth's radius in meters -const EARTH_RADIUS float64 = 6372797.560856 - -// The maximum/minumum projected coordinate value (in meters) in the Web Mercator projection (EPSG:3857) -// Earth’s equator: ~40,075 km → half of that = ~20,037 km -// The Mercator projection transforms the globe into a square map. -// MERCATOR_MAX is the extent of that square in meters. -const MERCATOR_MAX float64 = 20037726.37 -const MERCATOR_MIN float64 = -20037726.37 - -// Limits from EPSG:900913 / EPSG:3785 / OSGEO:41001 -const LAT_MIN float64 = -85.05112878 -const LAT_MAX float64 = 85.05112878 -const LONG_MIN float64 = -180 -const LONG_MAX float64 = 180 - -type Neighbors struct { - North uint64 - NorthEast uint64 - East uint64 - SouthEast uint64 - South uint64 - SouthWest uint64 - West uint64 - NorthWest uint64 - Center uint64 -} - -func ArrayToNeighbors(arr []uint64) *Neighbors { - neightbors := Neighbors{} - if len(arr) < 8 { - return &neightbors - } - - neightbors.North = arr[0] - neightbors.NorthEast = arr[1] - neightbors.East = arr[2] - neightbors.SouthEast = arr[3] - neightbors.South = arr[4] - neightbors.SouthWest = arr[5] - neightbors.West = arr[6] - neightbors.NorthWest = arr[7] - neightbors.Center = arr[8] - - return &neightbors -} - -func NeightborsToArray(neightbors *Neighbors) [9]uint64 { - arr := [9]uint64{} - - arr[0] = neightbors.North - arr[1] = neightbors.NorthEast - arr[2] = neightbors.East - arr[3] = neightbors.SouthEast - arr[4] = neightbors.South - arr[5] = neightbors.SouthWest - arr[6] = neightbors.West - arr[7] = neightbors.NorthWest - arr[8] = neightbors.Center - - return arr -} - -// Computes the min (inclusive) and max (exclusive) scores for a given hash box. -// Aligns the geohash bits to BIT_PRECISION score by left-shifting -func GetMaxAndMinHashForBoxHash(hash uint64, steps uint) (max, min uint64) { - shift := BIT_PRECISION - (steps) - base := hash << shift - rangeSize := uint64(1) << shift - min = base - max = base + rangeSize - 1 - - return max, min -} - -func ValidateLonLat(longitude, latitude float64) error { - if latitude > LAT_MAX || latitude < LAT_MIN || longitude > LONG_MAX || longitude < LONG_MIN { - return errors.ErrInvalidLonLatPair(longitude, latitude) - } - return nil -} - -// Encode given Lon and Lat to GEOHASH -func EncodeHash(longitude, latitude float64) uint64 { - return geohash.EncodeIntWithPrecision(latitude, longitude, BIT_PRECISION) -} - -// DecodeHash returns the latitude and longitude from a geo hash -func DecodeHash(hash uint64) (lon, lat float64) { - lat, lon = geohash.DecodeIntWithPrecision(hash, BIT_PRECISION) - return lon, lat -} - -func GetDistance(lon1, lat1, lon2, lat2 float64) float64 { - lon1r := DegToRad(lon1) - lon2r := DegToRad(lon2) - lat1r := DegToRad(lat1) - lat2r := DegToRad(lat2) - - v := math.Sin((lon2r - lon1r) / 2) - // if v == 0 we can avoid doing expensive math when lons are practically the same (This impl is same as redis) - if v == 0.0 { - return GetLatDistance(lat1r, lat2r) - } - - u := math.Sin((lat2r - lat1r) / 2) - - a := u*u + math.Cos(lat1r)*math.Cos(lat2r)*v*v - - return 2.0 * EARTH_RADIUS * math.Asin(math.Sqrt(a)) -} - -func DegToRad(deg float64) float64 { - return deg * math.Pi / 180 -} - -func RadToDeg(rad float64) float64 { - return 180.0 * rad / math.Pi -} - -func GetLatDistance(lat1, lat2 float64) float64 { - return EARTH_RADIUS * math.Abs(DegToRad(lat2)-DegToRad(lat1)) -} - -// ConvertDistance converts a distance from meters to the desired unit -func ConvertDistance(distance float64, unit types.Param) (float64, error) { - var result float64 - - switch unit { - case types.M: - result = distance - case types.KM: - result = distance / 1000 - case types.MI: - result = distance / 1609.34 - case types.FT: - result = distance / 0.3048 - default: - return 0, errors.ErrInvalidUnit(string(unit)) - } - - // Round to 5 decimal places - return math.Round(result*10000) / 10000, nil -} - -// ConvertToMeter converts a distance to meters from the given unit -func ConvertToMeter(distance float64, unit types.Param) (float64, error) { - var result float64 - - switch unit { - case types.M: - result = distance - case types.KM: - result = distance * 1000 - case types.MI: - result = distance * 1609.34 - case types.FT: - result = distance * 0.3048 - default: - return 0, errors.ErrInvalidUnit(string(unit)) - } - // Round to 4 decimal places - return math.Round(result*10000) / 10000, nil -} - -// Return the bounding box of the search circle -// bounds[0] - bounds[2] is the minimum and maximum longitude -// bounds[1] - bounds[3] is the minimum and maximum latitude. -// Refer to this link to understand this function in detail - https://www.notion.so/Geo-Bounding-Box-Research-1f6a37dc1a9a80e7ac43feeeab7215bb?pvs=4 -// since the higher the latitude, the shorter the arc length, the box shape is as follows -// -// \-----------------/ -------- \-----------------/ -// \ / / \ \ / -// \ (long,lat) / / (long,lat) \ \ (long,lat) / -// \ / / \ / \ -// --------- /----------------\ /---------------\ -// Northern Hemisphere Southern Hemisphere Around the equator -func GetBoundingBoxForRectangleWithHash(hash uint64, widht float64, height float64) *geohash.Box { - - boudingBox := geohash.Box{} - - widht /= 2 - height /= 2 - lon, lat := DecodeHash(hash) - latDelta := RadToDeg(height / EARTH_RADIUS) - lonDeltaTop := RadToDeg(widht / EARTH_RADIUS / math.Cos(DegToRad(lat+latDelta))) - lonDeltaBottom := RadToDeg(widht / EARTH_RADIUS / math.Cos(DegToRad(lat-latDelta))) - - boudingBox.MinLat = lat - latDelta - boudingBox.MaxLat = lat + latDelta - - if lat < 0 { - boudingBox.MinLng = lon - lonDeltaBottom - boudingBox.MaxLng = lon + lonDeltaBottom - } else { - boudingBox.MinLng = lon - lonDeltaTop - boudingBox.MaxLng = lon + lonDeltaTop - } - - return &boudingBox - -} - -func GetBoundingBoxForRectangleWithLonLat(lon, lat float64, widht float64, height float64) *geohash.Box { - return GetBoundingBoxForRectangleWithHash(EncodeHash(lon, lat), widht, height) -} - -// Find the step → Precision at which 9 cells (3x3 cells) can cover the entire area -func EstimateStepsByRadius(radius float64, latitude float64) uint { - if radius == 0 { - return 26 - } - var step uint = 1 - for radius < MERCATOR_MAX { - radius *= 2 - step++ - } - step -= 2 /* Make sure range is included in most of the base cases. */ - - // Wider range towards the poles - if latitude > 66 || latitude < -66 { - step-- - if latitude > 80 || latitude < -80 { - step-- - } - } - - /* Frame to valid range. */ - if step < 1 { - step = 1 - } - if step > 26 { - step = 26 - } - return step -} - -func GetNeighborsForGeoSearchUsingRadius(geoShape GeoShape) (neighbors *Neighbors, steps uint) { - lon, lat := geoShape.GetLonLat() - width, height := geoShape.GetBoudingBoxWidhtAndHeight() - radius := geoShape.GetRadius() - - // Create bounding box to validate/invalidate neighbors later - boudingBox := GetBoundingBoxForRectangleWithLonLat(lon, lat, width, height) - - // as (mmcloughlin/geohash) requires total number of bits, not steps - steps = 2 * EstimateStepsByRadius(radius, lat) - - centerHash := geohash.EncodeIntWithPrecision(lat, lon, steps) - centerBox := geohash.BoundingBoxIntWithPrecision(centerHash, steps) - neighborsArr := geohash.NeighborsIntWithPrecision(centerHash, steps) - neighborsArr = append(neighborsArr, centerHash) - neighbors = ArrayToNeighbors(neighborsArr) - - // Check if the step is enough at the limits of the covered area. - // Decode each of the 8 neighbours to get max and min (lon, lat) - // If North.maxLatitude < maxLatitude(from bouding box) then we have to reduce step to increase neighbour size - // Do this for N, S, E, W - northBox := geohash.BoundingBoxIntWithPrecision(neighbors.North, steps) - eastBox := geohash.BoundingBoxIntWithPrecision(neighbors.East, steps) - southBox := geohash.BoundingBoxIntWithPrecision(neighbors.South, steps) - westBox := geohash.BoundingBoxIntWithPrecision(neighbors.West, steps) - - if northBox.MaxLat < boudingBox.MaxLat || southBox.MinLat > boudingBox.MinLat || eastBox.MaxLng < boudingBox.MaxLng || westBox.MinLng > boudingBox.MinLng { - steps-- - centerHash = geohash.EncodeIntWithPrecision(lat, lon, steps) - centerBox = geohash.BoundingBoxIntWithPrecision(centerHash, steps) - neighborsArr := geohash.NeighborsIntWithPrecision(centerHash, steps) - neighborsArr = append(neighborsArr, centerHash) - neighbors = ArrayToNeighbors(neighborsArr) - } - - // Update the center block as well - neighbors.Center = centerHash - - // Exclude search areas that are useless - // why not at step == 1? Because geohash cells are so large that excluding neighbors could miss valid points. - if steps >= 2 { - if centerBox.MinLat < boudingBox.MinLat { - neighbors.South = 0 - neighbors.SouthWest = 0 - neighbors.SouthEast = 0 - } - if centerBox.MaxLat > boudingBox.MaxLat { - neighbors.North = 0 - neighbors.NorthEast = 0 - neighbors.NorthWest = 0 - } - if centerBox.MinLng < boudingBox.MinLng { - neighbors.West = 0 - neighbors.SouthWest = 0 - neighbors.NorthWest = 0 - } - if centerBox.MaxLng > boudingBox.MaxLng { - neighbors.East = 0 - neighbors.SouthEast = 0 - neighbors.NorthEast = 0 - } - } - return neighbors, steps -} diff --git a/internal/geo/geoShape.go b/internal/geo/geoShape.go deleted file mode 100644 index 308ea09c2..000000000 --- a/internal/geo/geoShape.go +++ /dev/null @@ -1,115 +0,0 @@ -package geoUtil - -import ( - "math" - - "github.com/dicedb/dice/internal/types" -) - -// This represents a shape in which we have to find nodes -type GeoShape interface { - GetBoudingBoxWidhtAndHeight() (float64, float64) - GetRadius() float64 - GetLonLat() (float64, float64) - GetDistanceIfWithinShape(lon float64, lat float64) (distance float64) -} - -// GeoShapeCircle Implementation of GeoShape -type GeoShapeCircle struct { - Radius float64 - Longitude float64 - Latitude float64 - Unit types.Param -} - -func (circle *GeoShapeCircle) GetBoudingBoxWidhtAndHeight() (float64, float64) { - return circle.Radius * 2, circle.Radius * 2 -} - -func (circle *GeoShapeCircle) GetRadius() float64 { - return circle.Radius -} - -func (circle *GeoShapeCircle) GetLonLat() (float64, float64) { - return circle.Longitude, circle.Latitude -} - -func (circle *GeoShapeCircle) GetDistanceIfWithinShape(lon float64, lat float64) (distance float64) { - distance = GetDistance(lon, lat, circle.Longitude, circle.Latitude) - if distance > circle.Radius { - return 0 - } - return distance -} - -func GetNewGeoShapeCircle(radius float64, longitude float64, latitude float64, unit types.Param) (*GeoShapeCircle, error) { - var convRadius float64 - var err error - if convRadius, err = ConvertToMeter(radius, unit); err != nil { - return nil, err - } - return &GeoShapeCircle{ - Radius: convRadius, - Longitude: longitude, - Latitude: latitude, - Unit: unit, - }, nil -} - -// GeoShapeRectangle Implementation of GeoShape -type GeoShapeRectangle struct { - Widht float64 - Height float64 - Longitude float64 - Latitude float64 - Unit types.Param -} - -func (rec *GeoShapeRectangle) GetBoudingBoxWidhtAndHeight() (float64, float64) { - return rec.Widht, rec.Height -} - -func (rec *GeoShapeRectangle) GetRadius() float64 { - radius := math.Sqrt((rec.Widht/2)*(rec.Widht/2) + (rec.Height/2)*(rec.Height/2)) - return radius -} - -func (rec *GeoShapeRectangle) GetLonLat() (float64, float64) { - return rec.Longitude, rec.Latitude -} - -func (rec *GeoShapeRectangle) GetDistanceIfWithinShape(lon float64, lat float64) (distance float64) { - // latitude distance is less expensive to compute than longitude distance - // so we check first for the latitude condition - latDistance := GetLatDistance(lat, rec.Latitude) - if latDistance > rec.Height/2 { - return 0 - } - - lonDistance := GetDistance(lon, lat, rec.Longitude, lat) - if lonDistance > rec.Widht/2 { - return 0 - } - - distance = GetDistance(lon, lat, rec.Longitude, rec.Latitude) - - return distance -} - -func GetNewGeoShapeRectangle(widht float64, height float64, longitude float64, latitude float64, unit types.Param) (*GeoShapeRectangle, error) { - var convWidth, convHeight float64 - var err error - if convWidth, err = ConvertToMeter(widht, unit); err != nil { - return nil, err - } - if convHeight, err = ConvertToMeter(height, unit); err != nil { - return nil, err - } - return &GeoShapeRectangle{ - Widht: convWidth, - Height: convHeight, - Longitude: longitude, - Latitude: latitude, - Unit: unit, - }, nil -} diff --git a/internal/types/geo.go b/internal/types/geo.go new file mode 100644 index 000000000..0caee2890 --- /dev/null +++ b/internal/types/geo.go @@ -0,0 +1,604 @@ +package types + +import ( + "math" + "strconv" + + "github.com/dicedb/dice/internal/errors" + "github.com/dicedb/dicedb-go/wire" + "github.com/emirpasic/gods/queues/priorityqueue" + "github.com/mmcloughlin/geohash" +) + +// Bit precision - Same as redis (https://github.com/redis/redis/blob/5d0d64b062c160093dc287ed5d18ec7c807873cf/src/geohash_helper.c#L213) +const BIT_PRECISION = 52 + +// Earth's radius in meters +const EARTH_RADIUS float64 = 6372797.560856 + +// The maximum/minumum projected coordinate value (in meters) in the Web Mercator projection (EPSG:3857) +// Earth’s equator: ~40,075 km → half of that = ~20,037 km +// The Mercator projection transforms the globe into a square map. +// MERCATOR_MAX is the extent of that square in meters. +const MERCATOR_MAX float64 = 20037726.37 +const MERCATOR_MIN float64 = -20037726.37 + +// Limits from EPSG:900913 / EPSG:3785 / OSGEO:41001 +const LAT_MIN float64 = -85.05112878 +const LAT_MAX float64 = 85.05112878 +const LONG_MIN float64 = -180 +const LONG_MAX float64 = 180 + +// GEO Neighbors +type Neighbors struct { + North uint64 + NorthEast uint64 + East uint64 + SouthEast uint64 + South uint64 + SouthWest uint64 + West uint64 + NorthWest uint64 + Center uint64 + Steps uint +} + +func CreateNeighborsFromArray(arr []uint64) *Neighbors { + neightbors := Neighbors{} + if len(arr) < 8 { + return &neightbors + } + + neightbors.North = arr[0] + neightbors.NorthEast = arr[1] + neightbors.East = arr[2] + neightbors.SouthEast = arr[3] + neightbors.South = arr[4] + neightbors.SouthWest = arr[5] + neightbors.West = arr[6] + neightbors.NorthWest = arr[7] + neightbors.Center = arr[8] + + return &neightbors +} + +func (neightbors *Neighbors) ToArray() [9]uint64 { + arr := [9]uint64{} + + arr[0] = neightbors.North + arr[1] = neightbors.NorthEast + arr[2] = neightbors.East + arr[3] = neightbors.SouthEast + arr[4] = neightbors.South + arr[5] = neightbors.SouthWest + arr[6] = neightbors.West + arr[7] = neightbors.NorthWest + arr[8] = neightbors.Center + + return arr +} + +// Struct to save coordinates +type GeoCoordinate struct { + Hash uint64 + Longitude float64 + Latitude float64 +} + +func NewGeoCoordinateFromLonLat(longitude, latitude float64) (*GeoCoordinate, error) { + if err := ValidateLonLat(longitude, latitude); err != nil { + return nil, err + } + hash := EncodeHash(longitude, latitude) + return &GeoCoordinate{ + Hash: hash, + Longitude: longitude, + Latitude: latitude, + }, nil +} + +func NewGeoCoordinateFromHash(hash uint64) (*GeoCoordinate, error) { + longitude, latitude := DecodeHash(hash) + if err := ValidateLonLat(longitude, latitude); err != nil { + return nil, err + } + return &GeoCoordinate{ + Hash: hash, + Longitude: longitude, + Latitude: latitude, + }, nil +} + +func (coord *GeoCoordinate) GetLatDistanceFromCoordinate(otherCoord *GeoCoordinate) float64 { + return EARTH_RADIUS * math.Abs(DegToRad(otherCoord.Latitude)-DegToRad(coord.Latitude)) +} + +func (coord *GeoCoordinate) GetDistanceFromCoordinate(otherCoord *GeoCoordinate) float64 { + lon1r := DegToRad(coord.Longitude) + lat1r := DegToRad(coord.Latitude) + + lon2r := DegToRad(otherCoord.Longitude) + lat2r := DegToRad(otherCoord.Latitude) + + v := math.Sin((lon2r - lon1r) / 2) + // if v == 0 we can avoid doing expensive math when lons are practically the same + if v == 0.0 { + return coord.GetLatDistanceFromCoordinate(otherCoord) + } + + u := math.Sin((lat2r - lat1r) / 2) + + a := u*u + math.Cos(lat1r)*math.Cos(lat2r)*v*v + + return 2.0 * EARTH_RADIUS * math.Asin(math.Sqrt(a)) +} + +// This saves only the Hash of Longitude and Latitude +type GeoRegistry struct { + *SortedSet +} + +func NewGeoRegistry() *GeoRegistry { + return &GeoRegistry{ + SortedSet: NewSortedSet(), + } +} + +func (geoReg *GeoRegistry) Add(coordinates []*GeoCoordinate, members []string, params map[Param]string) (int64, error) { + + hashArr := []int64{} + for _, coord := range coordinates { + hashArr = append(hashArr, int64(coord.Hash)) + } + + // Note: Validation of the params is done in the SortedSet.ZADD method + return geoReg.ZADD(hashArr, members, params) + +} + +func (geoReg *GeoRegistry) GetDistanceBetweenMembers(member1 string, member2 string, unit Param) (float64, error) { + node1 := geoReg.GetByKey(member1) + node2 := geoReg.GetByKey(member2) + + if node1 == nil || node2 == nil { + return 0, nil + } + + hash1 := node1.Score() + hash2 := node2.Score() + + coord1, _ := NewGeoCoordinateFromHash(uint64(hash1)) + coord2, _ := NewGeoCoordinateFromHash(uint64(hash2)) + + dist, err := ConvertDistance(coord1.GetDistanceFromCoordinate(coord2), unit) + + if err != nil { + return 0, err + } + + return dist, nil + +} + +// This returns all the nodes which are in the given shape +func (geoReg *GeoRegistry) GeoSearchElementsWithinShape(params map[Param]string, nonParams []string) ([]*wire.GEOElement, error) { + unit := GetUnitTypeFromParsedParams(params) + if len(unit) == 0 { + return nil, errors.ErrInvalidUnit(string(unit)) + } + + // Return error if both FROMLONLAT & FROMMEMBER are set + if params[FROMLONLAT] != "" && params[FROMMEMBER] != "" { + return nil, errors.ErrInvalidSetOfOptions(string(FROMLONLAT), string(FROMMEMBER)) + } + + // Return error if none of FROMLONLAT & FROMMEMBER are set + if params[FROMLONLAT] == "" && params[FROMMEMBER] == "" { + return nil, errors.ErrNeedOneOfTheOptions(string(FROMLONLAT), string(FROMMEMBER)) + } + + // Return error if both BYBOX & BYRADIUS are set + if params[BYBOX] != "" && params[BYRADIUS] != "" { + return nil, errors.ErrInvalidSetOfOptions(string(BYBOX), string(BYRADIUS)) + } + + // Return error if none of BYBOX & BYRADIUS are set + if params[BYBOX] == "" && params[BYRADIUS] == "" { + return nil, errors.ErrNeedOneOfTheOptions(string(BYBOX), string(BYRADIUS)) + } + + // Return error if ANY is used without COUNT + if params[ANY] != "" && params[COUNT] == "" { + return nil, errors.ErrGeneral("ANY argument requires COUNT argument") + } + + // Return error if Both ASC & DESC are used + if params[ASC] != "" && params[DESC] != "" { + return nil, errors.ErrGeneral("Use one of ASC or DESC") + } + + // Fetch Longitute and Latitude based on FROMLONLAT & FROMMEMBER param + var centerCoordinate *GeoCoordinate + var err error + + // Fetch Longitute and Latitude from params + if params[FROMLONLAT] != "" { + if len(nonParams) < 2 { + return nil, errors.ErrWrongArgumentCount("GEOSEARCH") + } + lon, errLon := strconv.ParseFloat(nonParams[0], 10) + lat, errLat := strconv.ParseFloat(nonParams[1], 10) + + if errLon != nil || errLat != nil { + return nil, errors.ErrInvalidNumberFormat + } + + centerCoordinate, err = NewGeoCoordinateFromLonLat(lon, lat) + if err != nil { + return nil, err + } + + // Adjust the nonParams array for further operations + nonParams = nonParams[2:] + } + + // Fetch Longitute and Latitude from member + if params[FROMMEMBER] != "" { + if len(nonParams) < 1 { + return nil, errors.ErrWrongArgumentCount("GEOSEARCH") + } + member := nonParams[0] + node := geoReg.GetByKey(member) + if node == nil { + return nil, errors.ErrMemberNotFoundInSortedSet(member) + } + hash := node.Score() + centerCoordinate, err = NewGeoCoordinateFromHash(uint64(hash)) + if err != nil { + return nil, err + } + + // Adjust the nonParams array for further operations + nonParams = nonParams[1:] + } + + // Create shape based on BYBOX or BYRADIUS param + var searchShape GeoShape + + // Create shape from BYBOX + if params[BYBOX] != "" { + if len(nonParams) < 2 { + return nil, errors.ErrWrongArgumentCount("GEOSEARCH") + } + + var width, height float64 + var errWidth, errHeight error + width, errWidth = strconv.ParseFloat(nonParams[0], 10) + height, errHeight = strconv.ParseFloat(nonParams[1], 10) + + if errWidth != nil || errHeight != nil { + return nil, errors.ErrInvalidNumberFormat + } + if height <= 0 || width <= 0 { + return nil, errors.ErrGeneral("HEIGHT, WIDTH should be > 0") + } + + searchShape, _ = GetNewGeoShapeRectangle(width, height, centerCoordinate, unit) + + // Adjust the nonParams array for further operations + nonParams = nonParams[2:] + } + + // Create shape from BYRADIUS + if params[BYRADIUS] != "" { + if len(nonParams) < 1 { + return nil, errors.ErrWrongArgumentCount("GEOSEARCH") + } + + var radius float64 + var errRad error + radius, errRad = strconv.ParseFloat(nonParams[0], 10) + + if errRad != nil { + return nil, errors.ErrInvalidNumberFormat + } + if radius <= 0 { + return nil, errors.ErrGeneral("RADIUS should be > 0") + } + + searchShape, _ = GetNewGeoShapeCircle(radius, centerCoordinate, unit) + + // Adjust the nonParams array for further operations + nonParams = nonParams[1:] + } + + // Get COUNT based on Params + var count int = -1 + var errCount error + + // Check for COUNT to limit the output + if params[COUNT] != "" { + if len(nonParams) < 1 { + return nil, errors.ErrWrongArgumentCount("GEOSEARCH") + } + count, errCount = strconv.Atoi(nonParams[0]) + if errCount != nil { + return nil, errors.ErrInvalidNumberFormat + } + if count <= 0 { + return nil, errors.ErrGeneral("COUNT must be > 0") + } + + // Adjust the nonParams array for further operations + nonParams = nonParams[1:] + } + + // If all the params are not used till now + // Means there're some unknown param + if len(nonParams) != 0 { + return nil, errors.ErrUnknownOption(nonParams[0]) + } + + // Check for ANY option + var anyOption bool = false + if params[ANY] != "" { + anyOption = true + } + + // Check for Sorting Key ASC or DESC (-1 = DESC, 0 = NoSort, 1 = ASC) + var sortType float64 = 0 + if params[ASC] != "" { + sortType = 1 + } + if params[DESC] != "" { + sortType = -1 + } + + // COUNT without ordering does not make much sense (we need to sort in order to return the closest N entries) + // Note that this is not needed for ANY option + if count != -1 && sortType == 0 && !anyOption { + sortType = 1 + } + + var withCoord, withDist, withHash bool = false, false, false + + if params[WITHCOORD] != "" { + withCoord = true + } + if params[WITHDIST] != "" { + withDist = true + } + if params[WITHHASH] != "" { + withHash = true + } + + // Find Neighbors from the shape + boudingBox := GetBoundingBoxForShape(searchShape) + neighbors := GetGeohashNeighborsWithinShape(searchShape, boudingBox) + neighborsArr := neighbors.ToArray() + + // HashMap of all the nodes (we are making map for deduplication) + geoElementMap := map[string]*wire.GEOElement{} + totalElements := 0 + + // Find all the elements in the neighbor and the center block + for _, neighbor := range neighborsArr { + + // Discarded neighbors + if neighbor == 0 { + continue + } + + // If ANY option is used and totalElements == count + // Break the loop and Return the current result + if anyOption && count == totalElements { + break + } + + maxHash, minHash := GetMaxAndMinHashForBoxHash(neighbor, neighbors.Steps) + + zElements := geoReg.ZRANGE(int(minHash), int(maxHash), true, false) + + for _, ele := range zElements { + + if anyOption && totalElements == count { + break + } + + eleCoord, _ := NewGeoCoordinateFromHash(uint64(ele.Score)) + dist := searchShape.GetDistanceIfWithinShape(eleCoord) + + if dist != 0 { + geoElement := wire.GEOElement{ + Member: ele.Member, + Coordinates: &wire.GEOCoordinates{ + Longitude: centerCoordinate.Longitude, + Latitude: centerCoordinate.Latitude, + }, + Distance: dist, + Hash: uint64(ele.Score), + } + geoElementMap[ele.Member] = &geoElement + totalElements++ + } + + } + } + + // Convert map to array + geoElements := []*wire.GEOElement{} + + for _, ele := range geoElementMap { + geoElements = append(geoElements, ele) + } + + // Return unsorted result if ANY is used or sortType = 0 + if anyOption || sortType == 0 { + filterDimensionsBasedOnFlags(geoElements, withCoord, withDist, withHash) + return geoElements, nil + } + + // Let count be the total elements we need + if count == -1 { + count = totalElements + } + + // Comparator function for MaxHeap + // If ASC is set -> we use MaxHeap -> To Pop out the largest element if LEN > COUNT + // If DESC is set -> we use MinHeap -> To Pop out the smallest element if LEN > COUNT + // So Reverse the final array + cmp := func(a, b interface{}) int { + distance1 := a.(*wire.GEOElement).Distance + distance2 := b.(*wire.GEOElement).Distance + if distance1*sortType < distance2*sortType { + return 1 + } else if distance1*sortType > distance2*sortType { + return -1 + } + return 0 + } + + // Create a priority Queue to store the 'COUNT' results + pq := priorityqueue.NewWith(cmp) + + for _, ele := range geoElements { + pq.Enqueue(ele) + if pq.Size() > count { + pq.Dequeue() + } + } + + // Final result Arr + resultGeoElements := []*wire.GEOElement{} + + // Transfer elements from priority Queue to Arr + for pq.Size() > 0 { + queueEle, _ := pq.Dequeue() + geoEle := queueEle.(*wire.GEOElement) + resultGeoElements = append(resultGeoElements, geoEle) + } + + // Reverse the output array Because + // If ASC is set -> we use MaxHeap -> Which will give use DESC array + // If DESC is set -> we use MinHeap -> Which will give use ASC array + // So Reverse the final array + for i, j := 0, len(resultGeoElements)-1; i < j; i, j = i+1, j-1 { + resultGeoElements[i], resultGeoElements[j] = resultGeoElements[j], resultGeoElements[i] + } + filterDimensionsBasedOnFlags(resultGeoElements, withCoord, withDist, withHash) + + return resultGeoElements, nil +} + +func filterDimensionsBasedOnFlags(geoElements []*wire.GEOElement, withCoord, withDist, withHash bool) { + for _, ele := range geoElements { + if !withCoord { + ele.Coordinates = nil + } + + if !withDist { + ele.Distance = 0 + } + + if !withHash { + ele.Hash = 0 + } + } +} + +// ///////////////////////////////////////////////////// +// //////////// Utility Functions ////////////////////// +// ///////////////////////////////////////////////////// + +// Encode given Lon and Lat to GEOHASH +func EncodeHash(longitude, latitude float64) uint64 { + return geohash.EncodeIntWithPrecision(latitude, longitude, BIT_PRECISION) +} + +// DecodeHash returns the latitude and longitude from a geo hash +func DecodeHash(hash uint64) (lon, lat float64) { + lat, lon = geohash.DecodeIntWithPrecision(hash, BIT_PRECISION) + return lon, lat +} + +func ValidateLonLat(lon, lat float64) error { + if lat > LAT_MAX || lat < LAT_MIN || lon > LONG_MAX || lon < LONG_MIN { + return errors.ErrInvalidLonLatPair(lon, lat) + } + return nil +} + +func GetUnitTypeFromParsedParams(params map[Param]string) Param { + if params[M] != "" { + return M + } else if params[KM] != "" { + return KM + } else if params[MI] != "" { + return MI + } else if params[FT] != "" { + return FT + } else { + return "" + } +} + +func DegToRad(deg float64) float64 { + return deg * math.Pi / 180 +} + +func RadToDeg(rad float64) float64 { + return 180.0 * rad / math.Pi +} + +// ConvertDistance converts a distance from meters to the desired unit +func ConvertDistance(distance float64, unit Param) (float64, error) { + var result float64 + + switch unit { + case M: + result = distance + case KM: + result = distance / 1000 + case MI: + result = distance / 1609.34 + case FT: + result = distance / 0.3048 + default: + return 0, errors.ErrInvalidUnit(string(unit)) + } + + // Round to 5 decimal places + return math.Round(result*10000) / 10000, nil +} + +// ConvertToMeter converts a distance to meters from the given unit +func ConvertToMeter(distance float64, unit Param) (float64, error) { + var result float64 + + switch unit { + case M: + result = distance + case KM: + result = distance * 1000 + case MI: + result = distance * 1609.34 + case FT: + result = distance * 0.3048 + default: + return 0, errors.ErrInvalidUnit(string(unit)) + } + // Round to 4 decimal places + return math.Round(result*10000) / 10000, nil +} + +// Computes the min (inclusive) and max (exclusive) scores for a given hash box. +// Aligns the geohash bits to BIT_PRECISION score by left-shifting +func GetMaxAndMinHashForBoxHash(hash uint64, steps uint) (max, min uint64) { + shift := BIT_PRECISION - (steps) + base := hash << shift + rangeSize := uint64(1) << shift + min = base + max = base + rangeSize - 1 + + return max, min +} diff --git a/internal/types/geoShape.go b/internal/types/geoShape.go new file mode 100644 index 000000000..feecf8315 --- /dev/null +++ b/internal/types/geoShape.go @@ -0,0 +1,281 @@ +package types + +import ( + "math" + + "github.com/dicedb/dice/internal/errors" + "github.com/mmcloughlin/geohash" +) + +// This represents a shape in which we have to find nodes +type GeoShape interface { + GetBoudingBoxWidhtAndHeight() (float64, float64) + GetRadius() float64 + GetCoordinate() *GeoCoordinate + GetDistanceIfWithinShape(coordinate *GeoCoordinate) (distance float64) +} + +// GeoShapeCircle Implementation of GeoShape +type GeoShapeCircle struct { + Radius float64 + CenterCoordinate *GeoCoordinate + Unit Param +} + +func (circle *GeoShapeCircle) GetBoudingBoxWidhtAndHeight() (float64, float64) { + return circle.Radius * 2, circle.Radius * 2 +} + +func (circle *GeoShapeCircle) GetRadius() float64 { + return circle.Radius +} + +func (circle *GeoShapeCircle) GetCoordinate() *GeoCoordinate { + return circle.CenterCoordinate +} + +func (circle *GeoShapeCircle) GetDistanceIfWithinShape(coordinate *GeoCoordinate) (distance float64) { + distance = coordinate.GetDistanceFromCoordinate(circle.CenterCoordinate) + if distance > circle.Radius { + return 0 + } + distance, _ = ConvertDistance(distance, circle.Unit) + return distance +} + +func GetNewGeoShapeCircle(radius float64, centerCoordinate *GeoCoordinate, unit Param) (*GeoShapeCircle, error) { + var convRadius float64 + var errRad error + if radius <= 0 { + return nil, errors.ErrGeneral("RADIUS should be > 0") + } + + if convRadius, errRad = ConvertToMeter(radius, unit); errRad != nil { + return nil, errRad + } + + longitude, latitude := centerCoordinate.Longitude, centerCoordinate.Latitude + + var coord *GeoCoordinate + var errCoord error + if coord, errCoord = NewGeoCoordinateFromLonLat(longitude, latitude); errCoord != nil { + return nil, errCoord + } + + return &GeoShapeCircle{ + Radius: convRadius, + CenterCoordinate: coord, + Unit: unit, + }, nil +} + +// GeoShapeRectangle Implementation of GeoShape +type GeoShapeRectangle struct { + Widht float64 + Height float64 + CenterCoordinate *GeoCoordinate + Unit Param +} + +func (rec *GeoShapeRectangle) GetBoudingBoxWidhtAndHeight() (float64, float64) { + return rec.Widht, rec.Height +} + +func (rec *GeoShapeRectangle) GetRadius() float64 { + radius := math.Sqrt((rec.Widht/2)*(rec.Widht/2) + (rec.Height/2)*(rec.Height/2)) + return radius +} + +func (rec *GeoShapeRectangle) GetCoordinate() *GeoCoordinate { + return rec.CenterCoordinate +} + +func (rec *GeoShapeRectangle) GetDistanceIfWithinShape(coordinate *GeoCoordinate) (distance float64) { + // latitude distance is less expensive to compute than longitude distance + // so we check first for the latitude condition + latDistance := coordinate.GetLatDistanceFromCoordinate(rec.CenterCoordinate) + if latDistance > rec.Height/2 { + return 0 + } + + // Creating a coordinate with same latitude, to get longitude distance + sameLatitudeCoord, _ := NewGeoCoordinateFromLonLat(rec.CenterCoordinate.Longitude, coordinate.Latitude) + lonDistance := coordinate.GetDistanceFromCoordinate(sameLatitudeCoord) + if lonDistance > rec.Widht/2 { + return 0 + } + + distance = coordinate.GetDistanceFromCoordinate(rec.CenterCoordinate) + distance, _ = ConvertDistance(distance, rec.Unit) + return distance +} + +func GetNewGeoShapeRectangle(widht float64, height float64, centerCoordinate *GeoCoordinate, unit Param) (*GeoShapeRectangle, error) { + var convWidth, convHeight float64 + var err error + + if widht <= 0 || height <= 0 { + return nil, errors.ErrGeneral("HEIGHT, WIDTH should be > 0") + } + if convWidth, err = ConvertToMeter(widht, unit); err != nil { + return nil, err + } + if convHeight, err = ConvertToMeter(height, unit); err != nil { + return nil, err + } + + longitude, latitude := centerCoordinate.Longitude, centerCoordinate.Latitude + + var coord *GeoCoordinate + var errCoord error + if coord, errCoord = NewGeoCoordinateFromLonLat(longitude, latitude); errCoord != nil { + return nil, errCoord + } + + return &GeoShapeRectangle{ + Widht: convWidth, + Height: convHeight, + CenterCoordinate: coord, + Unit: unit, + }, nil +} + +// Return the bounding box of the shape +// bounds[0] - bounds[2] is the minimum and maximum longitude +// bounds[1] - bounds[3] is the minimum and maximum latitude. +// Refer to this link to understand this function in detail - https://www.notion.so/Geo-Bounding-Box-Research-1f6a37dc1a9a80e7ac43feeeab7215bb?pvs=4 +// since the higher the latitude, the shorter the arc length, the box shape is as follows +// +// \-----------------/ -------- \-----------------/ +// \ / / \ \ / +// \ (long,lat) / / (long,lat) \ \ (long,lat) / +// \ / / \ / \ +// --------- /----------------\ /---------------\ +// Northern Hemisphere Southern Hemisphere Around the equator +func GetBoundingBoxForShape(geoShape GeoShape) *geohash.Box { + + coord := geoShape.GetCoordinate() + lon, lat := coord.Longitude, coord.Latitude + width, height := geoShape.GetBoudingBoxWidhtAndHeight() + boudingBox := geohash.Box{} + + width /= 2 + height /= 2 + latDelta := RadToDeg(height / EARTH_RADIUS) + lonDeltaTop := RadToDeg(width / EARTH_RADIUS / math.Cos(DegToRad(lat+latDelta))) + lonDeltaBottom := RadToDeg(width / EARTH_RADIUS / math.Cos(DegToRad(lat-latDelta))) + + boudingBox.MinLat = lat - latDelta + boudingBox.MaxLat = lat + latDelta + + if lat < 0 { + boudingBox.MinLng = lon - lonDeltaBottom + boudingBox.MaxLng = lon + lonDeltaBottom + } else { + boudingBox.MinLng = lon - lonDeltaTop + boudingBox.MaxLng = lon + lonDeltaTop + } + + return &boudingBox + +} + +// Find the step → Precision at which 9 cells (3x3 cells) can cover the entire given shape +func EstimatePrecisionForShapeCoverage(geoShape GeoShape) uint { + + radius := geoShape.GetRadius() + coord := geoShape.GetCoordinate() + _, latitude := coord.Longitude, coord.Latitude + + if radius == 0 { + return 26 + } + + var step int = 1 + for radius < MERCATOR_MAX { + radius *= 2 + step++ + } + step -= 2 /* Make sure range is included in most of the base cases. */ + + // Wider range towards the poles + if latitude > 66 || latitude < -66 { + step-- + if latitude > 80 || latitude < -80 { + step-- + } + } + + /* Frame to valid range. */ + if step < 1 { + step = 1 + } + if step > 26 { + step = 26 + } + + // as (mmcloughlin/geohash) requires total number of bits, not steps, so we multiply by 2 + return uint(step * 2) +} + +func GetGeohashNeighborsWithinShape(geoShape GeoShape, boudingBox *geohash.Box) (neighbors *Neighbors) { + coord := geoShape.GetCoordinate() + lon, lat := coord.Longitude, coord.Latitude + steps := uint(2 * EstimatePrecisionForShapeCoverage(geoShape)) + + centerHash := geohash.EncodeIntWithPrecision(lat, lon, steps) + centerBox := geohash.BoundingBoxIntWithPrecision(centerHash, steps) + neighborsArr := geohash.NeighborsIntWithPrecision(centerHash, steps) + neighborsArr = append(neighborsArr, centerHash) + neighbors = CreateNeighborsFromArray(neighborsArr) + + // Check if the step is enough at the limits of the covered area. + // Decode each of the 8 neighbours to get max and min (lon, lat) + // If North.maxLatitude < maxLatitude(from bouding box) then we have to reduce step to increase neighbour size + // Do this for N, S, E, W + northBox := geohash.BoundingBoxIntWithPrecision(neighbors.North, steps) + eastBox := geohash.BoundingBoxIntWithPrecision(neighbors.East, steps) + southBox := geohash.BoundingBoxIntWithPrecision(neighbors.South, steps) + westBox := geohash.BoundingBoxIntWithPrecision(neighbors.West, steps) + + if northBox.MaxLat < boudingBox.MaxLat || southBox.MinLat > boudingBox.MinLat || eastBox.MaxLng < boudingBox.MaxLng || westBox.MinLng > boudingBox.MinLng { + steps -= 2 + centerHash = geohash.EncodeIntWithPrecision(lat, lon, steps) + centerBox = geohash.BoundingBoxIntWithPrecision(centerHash, steps) + neighborsArr := geohash.NeighborsIntWithPrecision(centerHash, steps) + neighborsArr = append(neighborsArr, centerHash) + neighbors = CreateNeighborsFromArray(neighborsArr) + } + + // Update the center block as well + neighbors.Center = centerHash + + // Exclude search areas that are useless + // why not at step == 1? Because geohash cells are so large that excluding neighbors could miss valid points. + if steps >= 2 { + if centerBox.MinLat < boudingBox.MinLat { + neighbors.South = 0 + neighbors.SouthWest = 0 + neighbors.SouthEast = 0 + } + if centerBox.MaxLat > boudingBox.MaxLat { + neighbors.North = 0 + neighbors.NorthEast = 0 + neighbors.NorthWest = 0 + } + if centerBox.MinLng < boudingBox.MinLng { + neighbors.West = 0 + neighbors.SouthWest = 0 + neighbors.NorthWest = 0 + } + if centerBox.MaxLng > boudingBox.MaxLng { + neighbors.East = 0 + neighbors.SouthEast = 0 + neighbors.NorthEast = 0 + } + } + + // Set Steps in neighbors + neighbors.Steps = steps + return neighbors +} From fa454ed6a5888cd64c345b8e5ae9a2cfc6f1676e Mon Sep 17 00:00:00 2001 From: bipoool Date: Tue, 27 May 2025 10:37:31 +0000 Subject: [PATCH 4/7] Added GEOPOS & GEOHASH as well --- internal/cmd/cmd_geoadd.go | 4 +- internal/cmd/cmd_geodist.go | 3 +- internal/cmd/cmd_geohash.go | 89 +++++++++++++++++++++++++++++++++ internal/cmd/cmd_geopos.go | 94 +++++++++++++++++++++++++++++++++++ internal/cmd/cmd_geosearch.go | 5 +- internal/object/object.go | 1 + internal/types/geo.go | 62 ++++++++++++++++++++--- 7 files changed, 245 insertions(+), 13 deletions(-) create mode 100644 internal/cmd/cmd_geohash.go create mode 100644 internal/cmd/cmd_geopos.go diff --git a/internal/cmd/cmd_geoadd.go b/internal/cmd/cmd_geoadd.go index ae202cf1c..f5d83f057 100644 --- a/internal/cmd/cmd_geoadd.go +++ b/internal/cmd/cmd_geoadd.go @@ -83,7 +83,7 @@ func evalGEOADD(c *Cmd, s *dsstore.Store) (*CmdRes, error) { if obj == nil { gr = types.NewGeoRegistry() } else { - if obj.Type != object.ObjTypeSortedSet { + if obj.Type != object.ObjTypeGeoRegistry { return GEOADDResNilRes, errors.ErrWrongTypeOperation } gr = obj.Value.(*types.GeoRegistry) @@ -109,7 +109,7 @@ func evalGEOADD(c *Cmd, s *dsstore.Store) (*CmdRes, error) { return GEOADDResNilRes, err } - s.Put(key, s.NewObj(gr, -1, object.ObjTypeSortedSet), dsstore.WithPutCmd(dsstore.ZAdd)) + s.Put(key, s.NewObj(gr, -1, object.ObjTypeGeoRegistry), dsstore.WithPutCmd(dsstore.ZAdd)) return newGEOADDRes(count), nil } diff --git a/internal/cmd/cmd_geodist.go b/internal/cmd/cmd_geodist.go index eb7443d5d..ecdaf597a 100644 --- a/internal/cmd/cmd_geodist.go +++ b/internal/cmd/cmd_geodist.go @@ -31,7 +31,6 @@ localhost:7379> GEOADD Delhi 77.2096 28.6145 centralDelhi 77.2167 28.6315 CP 77. OK 3 localhost:7379> GEODIST Delhi CP IndiaGate km OK 2.416700 - `, Eval: evalGEODIST, Execute: executeGEODIST, @@ -79,7 +78,7 @@ func evalGEODIST(c *Cmd, s *dsstore.Store) (*CmdRes, error) { if obj == nil { return GEODISTResNilRes, nil } - if obj.Type != object.ObjTypeSortedSet { + if obj.Type != object.ObjTypeGeoRegistry { return GEODISTResNilRes, errors.ErrWrongTypeOperation } gr = obj.Value.(*types.GeoRegistry) diff --git a/internal/cmd/cmd_geohash.go b/internal/cmd/cmd_geohash.go new file mode 100644 index 000000000..c53bba1e1 --- /dev/null +++ b/internal/cmd/cmd_geohash.go @@ -0,0 +1,89 @@ +// Copyright (c) 2022-present, DiceDB contributors +// All rights reserved. Licensed under the BSD 3-Clause License. See LICENSE file in the project root for full license information. + +package cmd + +import ( + "github.com/dicedb/dice/internal/errors" + "github.com/dicedb/dice/internal/object" + "github.com/dicedb/dice/internal/shardmanager" + dsstore "github.com/dicedb/dice/internal/store" + "github.com/dicedb/dice/internal/types" + "github.com/dicedb/dicedb-go/wire" +) + +var cGEOHASH = &CommandMeta{ + Name: "GEOHASH", + Syntax: "GEOHASH key [member [member ...]]", + HelpShort: "Returns valid Geohash strings representing the position of one or more elements in a sorted set value representing a geospatial index", + HelpLong: ` + The command returns 11 characters Geohash strings, so no precision is lost compared to the Redis internal 52 bit representation + The returned Geohashes have the following properties: + 1. They can be shortened removing characters from the right. It will lose precision but will still point to the same area. + 2. Strings with a similar prefix are nearby, but the contrary is not true, it is possible that strings with different prefixes are nearby too. + 3. It is possible to use them in geohash.org URLs such as http://geohash.org/. + `, + Examples: ` +localhost:7379> GEOADD Delhi 77.2167 28.6315 CP 77.2295 28.6129 IndiaGate 77.1197 28.6412 Rajouri 77.1000 28.5562 Airport 77.1900 28.6517 KarolBagh +OK 5 +localhost:7379> GEOHASh Delhi CP IndiaGate +OK +0) ttnfvh5qxd0 +1) ttnfv2uf1z0 + `, + Eval: evalGEOHASH, + Execute: executeGEOHASH, +} + +func init() { + CommandRegistry.AddCommand(cGEOHASH) +} + +func newGEOHASHRes(hashArr []string) *CmdRes { + return &CmdRes{ + Rs: &wire.Result{ + Message: "OK", + Status: wire.Status_OK, + Response: &wire.Result_GEOHASHRes{ + GEOHASHRes: &wire.GEOHASHRes{ + Hashes: hashArr, + }, + }, + }, + } +} + +var ( + GEOHASHResNilRes = newGEOHASHRes([]string{}) +) + +func evalGEOHASH(c *Cmd, s *dsstore.Store) (*CmdRes, error) { + if len(c.C.Args) < 2 { + return GEOHASHResNilRes, errors.ErrWrongArgumentCount("GEOHASH") + } + + key := c.C.Args[0] + var gr *types.GeoRegistry + obj := s.Get(key) + if obj == nil { + return GEOHASHResNilRes, nil + } + if obj.Type != object.ObjTypeGeoRegistry { + return GEOHASHResNilRes, errors.ErrWrongTypeOperation + } + gr = obj.Value.(*types.GeoRegistry) + + hashArr := gr.Get11BytesHash(c.C.Args[1:]) + + return newGEOHASHRes(hashArr), nil + +} + +func executeGEOHASH(c *Cmd, sm *shardmanager.ShardManager) (*CmdRes, error) { + if len(c.C.Args) < 2 { + return GEOHASHResNilRes, errors.ErrWrongArgumentCount("GEOHASH") + } + + shard := sm.GetShardForKey(c.C.Args[0]) + return evalGEOHASH(c, shard.Thread.Store()) +} diff --git a/internal/cmd/cmd_geopos.go b/internal/cmd/cmd_geopos.go new file mode 100644 index 000000000..5ff758547 --- /dev/null +++ b/internal/cmd/cmd_geopos.go @@ -0,0 +1,94 @@ +// Copyright (c) 2022-present, DiceDB contributors +// All rights reserved. Licensed under the BSD 3-Clause License. See LICENSE file in the project root for full license information. + +package cmd + +import ( + "github.com/dicedb/dice/internal/errors" + "github.com/dicedb/dice/internal/object" + "github.com/dicedb/dice/internal/shardmanager" + dsstore "github.com/dicedb/dice/internal/store" + "github.com/dicedb/dice/internal/types" + "github.com/dicedb/dicedb-go/wire" +) + +var cGEOPOS = &CommandMeta{ + Name: "GEOPOS", + Syntax: "GEOPOS key [member [member ...]]", + HelpShort: "Return the positions (longitude,latitude) of all the specified members of the geospatial index.", + HelpLong: ` + Given a sorted set representing a geospatial index, populated using the GEOADD command, it is often useful to obtain back the coordinates of specified member. + When the geospatial index is populated via GEOADD the coordinates are converted into a 52 bit geohash, + so the coordinates returned may not be exactly the ones used in order to add the elements, but small errors may be introduced. + `, + Examples: ` +localhost:7379> GEOADD Delhi 77.2167 28.6315 CP 77.2295 28.6129 IndiaGate 77.1197 28.6412 Rajouri 77.1000 28.5562 Airport 77.1900 28.6517 KarolBagh +OK 5 +localhost:7379> GEOPOS Delhi CP nonEx +OK +0) 77.216700, 28.631498 +1) (nil) + `, + Eval: evalGEOPOS, + Execute: executeGEOPOS, +} + +func init() { + CommandRegistry.AddCommand(cGEOPOS) +} + +func newGEOPOSRes(coordinates []*types.GeoCoordinate) *CmdRes { + reponseCoordinates := make([]*wire.GEOCoordinates, len(coordinates)) + for i, coord := range coordinates { + if coord == nil { + continue + } + reponseCoordinates[i] = &wire.GEOCoordinates{ + Longitude: coord.Longitude, + Latitude: coord.Latitude, + } + } + return &CmdRes{ + Rs: &wire.Result{ + Message: "OK", + Status: wire.Status_OK, + Response: &wire.Result_GEOPOSRes{ + GEOPOSRes: &wire.GEOPOSRes{ + Coordinates: reponseCoordinates, + }, + }, + }, + } +} + +var ( + GEOPOSResNilRes = newGEOPOSRes([]*types.GeoCoordinate{}) +) + +func evalGEOPOS(c *Cmd, s *dsstore.Store) (*CmdRes, error) { + if len(c.C.Args) < 2 { + return GEOPOSResNilRes, errors.ErrWrongArgumentCount("GEOPOS") + } + + key := c.C.Args[0] + var gr *types.GeoRegistry + obj := s.Get(key) + if obj == nil { + return GEOPOSResNilRes, nil + } + if obj.Type != object.ObjTypeGeoRegistry { + return GEOPOSResNilRes, errors.ErrWrongTypeOperation + } + gr = obj.Value.(*types.GeoRegistry) + coordinates := gr.GetCoordinates(c.C.Args[1:]) + return newGEOPOSRes(coordinates), nil +} + +func executeGEOPOS(c *Cmd, sm *shardmanager.ShardManager) (*CmdRes, error) { + if len(c.C.Args) < 2 { + return GEOPOSResNilRes, errors.ErrWrongArgumentCount("GEOPOS") + } + + shard := sm.GetShardForKey(c.C.Args[0]) + return evalGEOPOS(c, shard.Thread.Store()) +} diff --git a/internal/cmd/cmd_geosearch.go b/internal/cmd/cmd_geosearch.go index a49d9e384..e52967fe1 100644 --- a/internal/cmd/cmd_geosearch.go +++ b/internal/cmd/cmd_geosearch.go @@ -110,7 +110,6 @@ func evalGEOSEARCH(c *Cmd, s *dsstore.Store) (*CmdRes, error) { key := c.C.Args[0] params, nonParams := parseParams(c.C.Args[1:]) - // Validate all the parameters if len(nonParams) < 2 { return GEOSEARCHResNilRes, errors.ErrInvalidSyntax("GEOSEARCH") } @@ -121,12 +120,12 @@ func evalGEOSEARCH(c *Cmd, s *dsstore.Store) (*CmdRes, error) { if obj == nil { return GEODISTResNilRes, nil } - if obj.Type != object.ObjTypeSortedSet { + if obj.Type != object.ObjTypeGeoRegistry { return GEOSEARCHResNilRes, errors.ErrWrongTypeOperation } gr = obj.Value.(*types.GeoRegistry) - geoElements, err := gr.GeoSearchElementsWithinShape(params, nonParams) + geoElements, err := gr.SearchElementsWithinShape(params, nonParams) if err != nil { return GEOSEARCHResNilRes, err diff --git a/internal/object/object.go b/internal/object/object.go index b35d8472b..62cac3437 100644 --- a/internal/object/object.go +++ b/internal/object/object.go @@ -89,6 +89,7 @@ const ( ObjTypeSet ObjTypeSSMap ObjTypeSortedSet + ObjTypeGeoRegistry ObjTypeCountMinSketch ObjTypeBF ObjTypeDequeue diff --git a/internal/types/geo.go b/internal/types/geo.go index 0caee2890..480863a23 100644 --- a/internal/types/geo.go +++ b/internal/types/geo.go @@ -181,7 +181,7 @@ func (geoReg *GeoRegistry) GetDistanceBetweenMembers(member1 string, member2 str } // This returns all the nodes which are in the given shape -func (geoReg *GeoRegistry) GeoSearchElementsWithinShape(params map[Param]string, nonParams []string) ([]*wire.GEOElement, error) { +func (geoReg *GeoRegistry) SearchElementsWithinShape(params map[Param]string, nonParams []string) ([]*wire.GEOElement, error) { unit := GetUnitTypeFromParsedParams(params) if len(unit) == 0 { return nil, errors.ErrInvalidUnit(string(unit)) @@ -490,6 +490,60 @@ func (geoReg *GeoRegistry) GeoSearchElementsWithinShape(params map[Param]string, return resultGeoElements, nil } +// This returns 11 characters geohash representation of the position of the specified elements. +func (geoReg *GeoRegistry) Get11BytesHash(members []string) []string { + + result := make([]string, len(members)) + geoAlphabet := []rune("0123456789bcdefghjkmnpqrstuvwxyz") + + for idx, member := range members { + + // Get GEOHASH of the member + hashNode := geoReg.GetByKey(member) + if hashNode == nil { + continue + } + hash := uint64(hashNode.Score()) + + // Convert the hash to 11 character string (base32) + hashRune := make([]rune, 11) + for i := 0; i < 11; i++ { + var idx uint64 + if i == 10 { + idx = 0 // pad last char due to only 52 bits + } else { + shift := 52 - ((i + 1) * 5) + idx = (hash >> shift) & 0x1F + } + hashRune[i] = geoAlphabet[idx] + } + result[idx] = string(hashRune) + + } + return result +} + +// This returns coordinates (longitute, latitude) of all the members +func (geoReg *GeoRegistry) GetCoordinates(members []string) []*GeoCoordinate { + result := make([]*GeoCoordinate, len(members)) + for i, member := range members { + // Get GEOHASH of the member + hashNode := geoReg.GetByKey(member) + if hashNode == nil { + continue + } + hash := uint64(hashNode.Score()) + coordinate, _ := NewGeoCoordinateFromHash(hash) + result[i] = coordinate + } + return result +} + +// ///////////////////////////////////////////////////// +// //////////// Utility Functions ////////////////////// +// ///////////////////////////////////////////////////// + +// Filter dimensions based on flags for GEO Search func filterDimensionsBasedOnFlags(geoElements []*wire.GEOElement, withCoord, withDist, withHash bool) { for _, ele := range geoElements { if !withCoord { @@ -506,10 +560,6 @@ func filterDimensionsBasedOnFlags(geoElements []*wire.GEOElement, withCoord, wit } } -// ///////////////////////////////////////////////////// -// //////////// Utility Functions ////////////////////// -// ///////////////////////////////////////////////////// - // Encode given Lon and Lat to GEOHASH func EncodeHash(longitude, latitude float64) uint64 { return geohash.EncodeIntWithPrecision(latitude, longitude, BIT_PRECISION) @@ -587,7 +637,7 @@ func ConvertToMeter(distance float64, unit Param) (float64, error) { default: return 0, errors.ErrInvalidUnit(string(unit)) } - // Round to 4 decimal places + // Round to 5 decimal places return math.Round(result*10000) / 10000, nil } From 9c112076cf91fe4feefd2e55b042d90dadf6dae0 Mon Sep 17 00:00:00 2001 From: bipoool Date: Tue, 27 May 2025 10:48:32 +0000 Subject: [PATCH 5/7] Updated go.mod --- go.mod | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f7b54733a..c18f4e4e0 100644 --- a/go.mod +++ b/go.mod @@ -11,9 +11,6 @@ require ( github.com/dgryski/go-metro v0.0.0-20211217172704-adc40b04c140 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect - github.com/emirpasic/gods v1.18.1 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/mattn/go-colorable v0.1.14 // indirect @@ -39,6 +36,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da github.com/dicedb/dicedb-go v1.0.10 + github.com/emirpasic/gods v1.18.1 github.com/gobwas/glob v0.2.3 github.com/google/btree v1.1.3 github.com/google/go-cmp v0.6.0 From 5fdb9e0a62f722f406ae423a4dc98d35177462da Mon Sep 17 00:00:00 2001 From: bipoool Date: Tue, 27 May 2025 11:41:41 +0000 Subject: [PATCH 6/7] Steps -> Precision --- internal/types/geo.go | 8 ++++---- internal/types/geoShape.go | 32 ++++++++++++++++---------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/internal/types/geo.go b/internal/types/geo.go index 480863a23..ecd437744 100644 --- a/internal/types/geo.go +++ b/internal/types/geo.go @@ -40,7 +40,7 @@ type Neighbors struct { West uint64 NorthWest uint64 Center uint64 - Steps uint + Precision uint } func CreateNeighborsFromArray(arr []uint64) *Neighbors { @@ -395,7 +395,7 @@ func (geoReg *GeoRegistry) SearchElementsWithinShape(params map[Param]string, no break } - maxHash, minHash := GetMaxAndMinHashForBoxHash(neighbor, neighbors.Steps) + maxHash, minHash := GetMaxAndMinHashForBoxHash(neighbor, neighbors.Precision) zElements := geoReg.ZRANGE(int(minHash), int(maxHash), true, false) @@ -643,8 +643,8 @@ func ConvertToMeter(distance float64, unit Param) (float64, error) { // Computes the min (inclusive) and max (exclusive) scores for a given hash box. // Aligns the geohash bits to BIT_PRECISION score by left-shifting -func GetMaxAndMinHashForBoxHash(hash uint64, steps uint) (max, min uint64) { - shift := BIT_PRECISION - (steps) +func GetMaxAndMinHashForBoxHash(hash uint64, precision uint) (max, min uint64) { + shift := BIT_PRECISION - (precision) base := hash << shift rangeSize := uint64(1) << shift min = base diff --git a/internal/types/geoShape.go b/internal/types/geoShape.go index feecf8315..5790955c5 100644 --- a/internal/types/geoShape.go +++ b/internal/types/geoShape.go @@ -221,11 +221,11 @@ func EstimatePrecisionForShapeCoverage(geoShape GeoShape) uint { func GetGeohashNeighborsWithinShape(geoShape GeoShape, boudingBox *geohash.Box) (neighbors *Neighbors) { coord := geoShape.GetCoordinate() lon, lat := coord.Longitude, coord.Latitude - steps := uint(2 * EstimatePrecisionForShapeCoverage(geoShape)) + precision := EstimatePrecisionForShapeCoverage(geoShape) - centerHash := geohash.EncodeIntWithPrecision(lat, lon, steps) - centerBox := geohash.BoundingBoxIntWithPrecision(centerHash, steps) - neighborsArr := geohash.NeighborsIntWithPrecision(centerHash, steps) + centerHash := geohash.EncodeIntWithPrecision(lat, lon, precision) + centerBox := geohash.BoundingBoxIntWithPrecision(centerHash, precision) + neighborsArr := geohash.NeighborsIntWithPrecision(centerHash, precision) neighborsArr = append(neighborsArr, centerHash) neighbors = CreateNeighborsFromArray(neighborsArr) @@ -233,16 +233,16 @@ func GetGeohashNeighborsWithinShape(geoShape GeoShape, boudingBox *geohash.Box) // Decode each of the 8 neighbours to get max and min (lon, lat) // If North.maxLatitude < maxLatitude(from bouding box) then we have to reduce step to increase neighbour size // Do this for N, S, E, W - northBox := geohash.BoundingBoxIntWithPrecision(neighbors.North, steps) - eastBox := geohash.BoundingBoxIntWithPrecision(neighbors.East, steps) - southBox := geohash.BoundingBoxIntWithPrecision(neighbors.South, steps) - westBox := geohash.BoundingBoxIntWithPrecision(neighbors.West, steps) + northBox := geohash.BoundingBoxIntWithPrecision(neighbors.North, precision) + eastBox := geohash.BoundingBoxIntWithPrecision(neighbors.East, precision) + southBox := geohash.BoundingBoxIntWithPrecision(neighbors.South, precision) + westBox := geohash.BoundingBoxIntWithPrecision(neighbors.West, precision) if northBox.MaxLat < boudingBox.MaxLat || southBox.MinLat > boudingBox.MinLat || eastBox.MaxLng < boudingBox.MaxLng || westBox.MinLng > boudingBox.MinLng { - steps -= 2 - centerHash = geohash.EncodeIntWithPrecision(lat, lon, steps) - centerBox = geohash.BoundingBoxIntWithPrecision(centerHash, steps) - neighborsArr := geohash.NeighborsIntWithPrecision(centerHash, steps) + precision -= 2 + centerHash = geohash.EncodeIntWithPrecision(lat, lon, precision) + centerBox = geohash.BoundingBoxIntWithPrecision(centerHash, precision) + neighborsArr := geohash.NeighborsIntWithPrecision(centerHash, precision) neighborsArr = append(neighborsArr, centerHash) neighbors = CreateNeighborsFromArray(neighborsArr) } @@ -251,8 +251,8 @@ func GetGeohashNeighborsWithinShape(geoShape GeoShape, boudingBox *geohash.Box) neighbors.Center = centerHash // Exclude search areas that are useless - // why not at step == 1? Because geohash cells are so large that excluding neighbors could miss valid points. - if steps >= 2 { + // why not at step < 4? Because geohash cells are so large that excluding neighbors could miss valid points. + if precision >= 4 { if centerBox.MinLat < boudingBox.MinLat { neighbors.South = 0 neighbors.SouthWest = 0 @@ -275,7 +275,7 @@ func GetGeohashNeighborsWithinShape(geoShape GeoShape, boudingBox *geohash.Box) } } - // Set Steps in neighbors - neighbors.Steps = steps + // Set Precision in neighbors + neighbors.Precision = precision return neighbors } From c41c3241500bd4cb591fc4d60a5cbd7f2a2d26dc Mon Sep 17 00:00:00 2001 From: bipoool Date: Tue, 27 May 2025 12:27:07 +0000 Subject: [PATCH 7/7] Added notion link --- internal/types/geo.go | 1 + internal/types/geoShape.go | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/types/geo.go b/internal/types/geo.go index ecd437744..6e11c69f1 100644 --- a/internal/types/geo.go +++ b/internal/types/geo.go @@ -181,6 +181,7 @@ func (geoReg *GeoRegistry) GetDistanceBetweenMembers(member1 string, member2 str } // This returns all the nodes which are in the given shape +// Refer to this link for a detailed explanation of the function - https://www.notion.so/Geo-Bounding-Box-Research-1f6a37dc1a9a80e7ac43feeeab7215bb func (geoReg *GeoRegistry) SearchElementsWithinShape(params map[Param]string, nonParams []string) ([]*wire.GEOElement, error) { unit := GetUnitTypeFromParsedParams(params) if len(unit) == 0 { diff --git a/internal/types/geoShape.go b/internal/types/geoShape.go index 5790955c5..8743be45c 100644 --- a/internal/types/geoShape.go +++ b/internal/types/geoShape.go @@ -143,7 +143,6 @@ func GetNewGeoShapeRectangle(widht float64, height float64, centerCoordinate *Ge // Return the bounding box of the shape // bounds[0] - bounds[2] is the minimum and maximum longitude // bounds[1] - bounds[3] is the minimum and maximum latitude. -// Refer to this link to understand this function in detail - https://www.notion.so/Geo-Bounding-Box-Research-1f6a37dc1a9a80e7ac43feeeab7215bb?pvs=4 // since the higher the latitude, the shorter the arc length, the box shape is as follows // // \-----------------/ -------- \-----------------/