diff --git a/internal/buffer/buffer.go b/internal/buffer/buffer.go new file mode 100644 index 00000000..64c75774 --- /dev/null +++ b/internal/buffer/buffer.go @@ -0,0 +1,169 @@ +package buffer + +import ( + "errors" + "math" + + "github.com/tidwall/geojson" + "github.com/tidwall/geojson/geo" + "github.com/tidwall/geojson/geometry" + "github.com/tidwall/gjson" +) + +// TODO: detect of pole and antimeridian crossing and generate +// valid multigeometries + +const bufferSteps = 15 + +// Simple performs a very simple buffer operation on a geojson object. +func Simple(g geojson.Object, meters float64) (geojson.Object, error) { + if meters <= 0 { + return g, nil + } + if math.IsInf(meters, 0) || math.IsNaN(meters) { + return g, errors.New("invalid meters") + } + switch g := g.(type) { + case *geojson.Point: + return bufferSimplePoint(g.Base(), meters), nil + case *geojson.SimplePoint: + return bufferSimplePoint(g.Base(), meters), nil + case *geojson.MultiPoint: + return bufferSimpleGeometries(g.Base(), meters) + case *geojson.LineString: + return bufferSimpleLineString(g, meters) + case *geojson.MultiLineString: + return bufferSimpleGeometries(g.Base(), meters) + case *geojson.Polygon: + return bufferSimplePolygon(g, meters) + case *geojson.MultiPolygon: + return bufferSimpleGeometries(g.Base(), meters) + case *geojson.FeatureCollection: + return bufferSimpleFeatures(g.Base(), meters) + case *geojson.Feature: + bg, err := Simple(g.Base(), meters) + if err != nil { + return nil, err + } + return geojson.NewFeature(bg, g.Members()), nil + case *geojson.Circle: + return Simple(g.Primative(), meters) + case nil: + return nil, errors.New("cannot buffer nil object") + default: + typ := gjson.Get(g.JSON(), "type").String() + return nil, errors.New("cannot buffer " + typ + " type") + } +} + +func bufferSimplePoint(p geometry.Point, meters float64) *geojson.Polygon { + meters = geo.NormalizeDistance(meters) + points := make([]geometry.Point, 0, bufferSteps+1) + + // calc the four corners + maxY, _ := geo.DestinationPoint(p.Y, p.X, meters, 0) + _, maxX := geo.DestinationPoint(p.Y, p.X, meters, 90) + minY, _ := geo.DestinationPoint(p.Y, p.X, meters, 180) + _, minX := geo.DestinationPoint(p.Y, p.X, meters, 270) + + // use the half width of the lat and lon + lons := (maxX - minX) / 2 + lats := (maxY - minY) / 2 + + // generate the circle polygon + for th := 0.0; th <= 360.0; th += 360.0 / float64(bufferSteps) { + radians := (math.Pi / 180) * th + x := p.X + lons*math.Cos(radians) + y := p.Y + lats*math.Sin(radians) + points = append(points, geometry.Point{X: x, Y: y}) + } + // add last connecting point, make a total of steps+1 + points = append(points, points[0]) + poly := geojson.NewPolygon( + geometry.NewPoly(points, nil, &geometry.IndexOptions{ + Kind: geometry.None, + }), + ) + return poly +} + +func bufferSimpleGeometries(objs []geojson.Object, meters float64, +) (*geojson.GeometryCollection, error) { + geoms := make([]geojson.Object, len(objs)) + for i := 0; i < len(objs); i++ { + g, err := Simple(objs[i], meters) + if err != nil { + return nil, err + } + geoms[i] = g + } + return geojson.NewGeometryCollection(geoms), nil +} + +func bufferSimpleFeatures(objs []geojson.Object, meters float64, +) (*geojson.FeatureCollection, error) { + geoms := make([]geojson.Object, len(objs)) + for i := 0; i < len(objs); i++ { + g, err := Simple(objs[i], meters) + if err != nil { + return nil, err + } + geoms[i] = g + } + return geojson.NewFeatureCollection(geoms), nil +} + +// appendBufferSimpleSeries buffers a series and appends its parts to dst +func appendBufferSimpleSeries(dst []geojson.Object, s geometry.Series, meters float64) []geojson.Object { + nsegs := s.NumSegments() + for i := 0; i < nsegs; i++ { + dst = appendSimpleBufferSegment(dst, s.SegmentAt(i), meters, i == 0) + } + return dst +} + +// appendSimpleBufferSegment buffers a segment and appends its parts to dst +func appendSimpleBufferSegment(dst []geojson.Object, seg geometry.Segment, + meters float64, first bool, +) []geojson.Object { + if first { + // endcap A + dst = append(dst, bufferSimplePoint(seg.A, meters)) + } + // line polygon + bear1 := geo.BearingTo(seg.A.Y, seg.A.X, seg.B.Y, seg.B.X) + lat1, lon1 := geo.DestinationPoint(seg.A.Y, seg.A.X, meters, bear1-90) + lat2, lon2 := geo.DestinationPoint(seg.A.Y, seg.A.X, meters, bear1+90) + bear2 := geo.BearingTo(seg.B.Y, seg.B.X, seg.A.Y, seg.A.X) + lat3, lon3 := geo.DestinationPoint(seg.B.Y, seg.B.X, meters, bear2-90) + lat4, lon4 := geo.DestinationPoint(seg.B.Y, seg.B.X, meters, bear2+90) + dst = append(dst, geojson.NewPolygon( + geometry.NewPoly([]geometry.Point{ + {X: lon1, Y: lat1}, + {X: lon2, Y: lat2}, + {X: lon3, Y: lat3}, + {X: lon4, Y: lat4}, + {X: lon1, Y: lat1}, + }, nil, nil))) + // endcap B + dst = append(dst, bufferSimplePoint(seg.B, meters)) + return dst +} + +func bufferSimplePolygon(p *geojson.Polygon, meters float64, +) (*geojson.GeometryCollection, error) { + var geoms []geojson.Object + b := p.Base() + geoms = appendBufferSimpleSeries(geoms, b.Exterior, meters) + for _, hole := range b.Holes { + geoms = appendBufferSimpleSeries(geoms, hole, meters) + } + geoms = append(geoms, p) + return geojson.NewGeometryCollection(geoms), nil +} + +func bufferSimpleLineString(l *geojson.LineString, meters float64, +) (*geojson.GeometryCollection, error) { + geoms := appendBufferSimpleSeries(nil, l.Base(), meters) + return geojson.NewGeometryCollection(geoms), nil +} diff --git a/internal/buffer/buffer_test.go b/internal/buffer/buffer_test.go new file mode 100644 index 00000000..3357c289 --- /dev/null +++ b/internal/buffer/buffer_test.go @@ -0,0 +1,113 @@ +package buffer + +import ( + "testing" + + "github.com/tidwall/geojson" + "github.com/tidwall/geojson/geometry" +) + +const lineString = `{"type":"LineString","coordinates":[ + [-116.40289306640624,34.125447565116126], + [-116.36444091796875,34.14818102254435], + [-116.0980224609375,34.15045403191448], + [-115.74920654296874,34.127721186043985], + [-115.54870605468749,34.075412438417395], + [-115.5267333984375,34.11407854333859], + [-115.21911621093749,34.048108084909835], + [-115.25207519531249,33.8339199536547], + [-115.40588378906249,33.71748624018193] +]}` + +var lineInPoints = []geometry.Point{ + {X: -115.64363479614258, Y: 34.108251327293296}, + {X: -115.54355621337892, Y: 34.07199987534163}, + {X: -115.21482467651367, Y: 34.051237154976164}, + {X: -115.4110336303711, Y: 33.715201644740844}, + {X: -116.40701293945311, Y: 34.12345809664606}, +} + +func TestBufferLineString(t *testing.T) { + g, err := geojson.Parse(lineString, nil) + if err != nil { + t.Fatal(err) + } + g2, err := Simple(g, 1000) + if err != nil { + t.Fatal(err) + } + for _, pt := range lineInPoints { + ok := g2.Contains(geojson.NewPoint(pt)) + if !ok { + t.Fatalf("!ok") + } + } +} + +const polygon = `{"type": "Polygon","coordinates":[ + [ + [116.46881103515624,34.277644878733824], + [115.87280273437499,34.20953080048952], + [115.70251464843749,34.397844946449865], + [115.9881591796875,34.61286625296406], + [116.46881103515624,34.277644878733824] + ], + [ + [115.90438842773436,34.38651267795365], + [116.05270385742188,34.35023911062779], + [115.99914550781249,34.44655621402982], + [115.90438842773436,34.38651267795365] + ] +]}` + +var polyInPoints = []geometry.Point{ + {X: 115.95837593078612, Y: 34.59887847065301}, + {X: 115.98755836486816, Y: 34.61879975173954}, + {X: 115.98833084106445, Y: 34.59795999847678}, + {X: 116.04536533355714, Y: 34.58082509817638}, + {X: 116.47567749023438, Y: 34.27651009584797}, + {X: 116.42005920410155, Y: 34.32018817684490}, + {X: 116.33216857910156, Y: 34.25948651450623}, + {X: 115.89340209960939, Y: 34.24132422972854}, + {X: 115.95588684082033, Y: 34.42786803680155}, + {X: 115.97236633300783, Y: 34.42107129982385}, + {X: 115.99639892578125, Y: 34.43579686485573}, + {X: 116.04652404785155, Y: 34.35364042469895}, + {X: 115.92155456542967, Y: 34.38877925439021}, + {X: 115.96755981445311, Y: 34.37687904351907}, + {X: 115.88859558105467, Y: 34.42956713470528}, + {X: 115.97511291503906, Y: 34.36327673174518}, + {X: 115.69564819335938, Y: 34.39784494644986}, + {X: 115.87005615234375, Y: 34.20385213966983}, + {X: 115.76980590820312, Y: 34.31678550602221}, +} +var polyOutPoints = []geometry.Point{ + {X: 115.68534851074217, Y: 34.40917568058836}, + {X: 115.98953247070312, Y: 34.63038297923298}, + {X: 115.98541259765624, Y: 34.39671178864245}, + {X: 116.31500244140626, Y: 34.22145474280257}, + {X: 115.85426330566406, Y: 34.18510984477340}, +} + +func TestBufferPolygon(t *testing.T) { + g, err := geojson.Parse(polygon, nil) + if err != nil { + t.Fatal(err) + } + g2, err := Simple(g, 1000) + if err != nil { + t.Fatal(err) + } + for _, pt := range polyInPoints { + ok := g2.Contains(geojson.NewPoint(pt)) + if !ok { + t.Fatalf("!ok") + } + } + for _, pt := range polyOutPoints { + ok := g2.Contains(geojson.NewPoint(pt)) + if ok { + t.Fatalf("ok") + } + } +} diff --git a/internal/server/hooks.go b/internal/server/hooks.go index c6697d8a..e2c22505 100644 --- a/internal/server/hooks.go +++ b/internal/server/hooks.go @@ -56,7 +56,7 @@ func (s *Server) cmdSetHook(msg *Message) ( } var commandvs []string var cmdlc string - var types []string + var types map[string]bool var expires float64 var expiresSet bool metaMap := make(map[string]string) diff --git a/internal/server/search.go b/internal/server/search.go index e9318b9d..925683a5 100644 --- a/internal/server/search.go +++ b/internal/server/search.go @@ -14,6 +14,7 @@ import ( "github.com/tidwall/geojson/geometry" "github.com/tidwall/resp" "github.com/tidwall/tile38/internal/bing" + "github.com/tidwall/tile38/internal/buffer" "github.com/tidwall/tile38/internal/clip" "github.com/tidwall/tile38/internal/glob" ) @@ -170,7 +171,7 @@ func parseRectArea(ltyp string, vs []string) (nvs []string, rect *geojson.Rect, } func (s *Server) cmdSearchArgs( - fromFenceCmd bool, cmd string, vs []string, types []string, + fromFenceCmd bool, cmd string, vs []string, types map[string]bool, ) (lfs liveFenceSwitches, err error) { var t searchScanBaseTokens if fromFenceCmd { @@ -198,13 +199,7 @@ func (s *Server) cmdSearchArgs( } } ltyp := strings.ToLower(typ) - var found bool - for _, t := range types { - if ltyp == t { - found = true - break - } - } + found := types[ltyp] if !found && lfs.searchScanBaseTokens.fence && ltyp == "roam" && cmd == "nearby" { // allow roaming for nearby fence searches. found = true @@ -215,7 +210,41 @@ func (s *Server) cmdSearchArgs( } switch ltyp { case "point": - fallthrough + var slat, slon, smeters string + if vs, slat, ok = tokenval(vs); !ok || slat == "" { + err = errInvalidNumberOfArguments + return + } + if vs, slon, ok = tokenval(vs); !ok || slon == "" { + err = errInvalidNumberOfArguments + return + } + var lat, lon, meters float64 + if lat, err = strconv.ParseFloat(slat, 64); err != nil { + err = errInvalidArgument(slat) + return + } + if lon, err = strconv.ParseFloat(slon, 64); err != nil { + err = errInvalidArgument(slon) + return + } + // radius is optional for nearby, but mandatory for others + if cmd == "nearby" { + if vs, smeters, ok = tokenval(vs); ok && smeters != "" { + meters, err = strconv.ParseFloat(smeters, 64) + if err != nil || meters < 0 { + err = errInvalidArgument(smeters) + return + } + } else { + meters = -1 + } + // Nearby used the Circle type + lfs.obj = geojson.NewCircle(geometry.Point{X: lon, Y: lat}, meters, defaultCircleSteps) + } else { + // Intersects and Within use the Point type + lfs.obj = geojson.NewPoint(geometry.Point{X: lon, Y: lat}) + } case "circle": if lfs.clip { err = errInvalidArgument("cannot clip with " + ltyp) @@ -239,33 +268,14 @@ func (s *Server) cmdSearchArgs( err = errInvalidArgument(slon) return } - // radius is optional for nearby, but mandatory for others - if cmd == "nearby" { - if vs, smeters, ok = tokenval(vs); ok && smeters != "" { - if meters, err = strconv.ParseFloat(smeters, 64); err != nil { - err = errInvalidArgument(smeters) - return - } - if meters < 0 { - err = errInvalidArgument(smeters) - return - } - } else { - meters = -1 - } - } else { - if vs, smeters, ok = tokenval(vs); !ok || smeters == "" { - err = errInvalidNumberOfArguments - return - } - if meters, err = strconv.ParseFloat(smeters, 64); err != nil { - err = errInvalidArgument(smeters) - return - } - if meters < 0 { - err = errInvalidArgument(smeters) - return - } + if vs, smeters, ok = tokenval(vs); !ok || smeters == "" { + err = errInvalidNumberOfArguments + return + } + meters, err = strconv.ParseFloat(smeters, 64) + if err != nil || meters < 0 { + err = errInvalidArgument(smeters) + return } lfs.obj = geojson.NewCircle(geometry.Point{X: lon, Y: lat}, meters, defaultCircleSteps) case "object": @@ -436,12 +446,24 @@ func (s *Server) cmdSearchArgs( return } } + + if lfs.hasbuffer { + lfs.obj, err = buffer.Simple(lfs.obj, lfs.buffer) + if err != nil { + return + } + + } return } -var nearbyTypes = []string{"point"} -var withinOrIntersectsTypes = []string{ - "geo", "bounds", "hash", "tile", "quadkey", "get", "object", "circle", "sector"} +var nearbyTypes = map[string]bool{ + "point": true, +} +var withinOrIntersectsTypes = map[string]bool{ + "geo": true, "bounds": true, "hash": true, "tile": true, "quadkey": true, + "get": true, "object": true, "circle": true, "point": true, "sector": true, +} func (s *Server) cmdNearby(msg *Message) (res resp.Value, err error) { start := time.Now() diff --git a/internal/server/token.go b/internal/server/token.go index 30d25bae..50925626 100644 --- a/internal/server/token.go +++ b/internal/server/token.go @@ -211,6 +211,8 @@ type searchScanBaseTokens struct { sparse uint8 desc bool clip bool + buffer float64 + hasbuffer bool } func (s *Server) parseSearchScanBaseTokens( @@ -234,6 +236,22 @@ func (s *Server) parseSearchScanBaseTokens( nvs, wtok, ok := tokenval(vs) if ok && len(wtok) > 0 { switch strings.ToLower(wtok) { + case "buffer": + vs = nvs + var sbuf string + if vs, sbuf, ok = tokenval(vs); !ok || sbuf == "" { + err = errInvalidNumberOfArguments + return + } + var buf float64 + buf, err = strconv.ParseFloat(sbuf, 64) + if err != nil || buf < 0 || math.IsInf(buf, 0) || math.IsNaN(buf) { + err = errInvalidArgument(sbuf) + return + } + t.buffer = buf + t.hasbuffer = true + continue case "cursor": vs = nvs if scursor != "" { diff --git a/tests/keys_search_test.go b/tests/keys_search_test.go index 11096ea4..1db10394 100644 --- a/tests/keys_search_test.go +++ b/tests/keys_search_test.go @@ -32,6 +32,7 @@ func subTestSearch(t *testing.T, mc *mockServer) { runStep(t, mc, "SEARCH_CURSOR", keys_SEARCH_CURSOR_test) runStep(t, mc, "MATCH", keys_MATCH_test) runStep(t, mc, "FIELDS", keys_FIELDS_search_test) + runStep(t, mc, "BUFFER", keys_BUFFER_search_test) } func keys_KNN_basic_test(mc *mockServer) error { @@ -696,6 +697,37 @@ func keys_FIELDS_search_test(mc *mockServer) error { }) } +func keys_BUFFER_search_test(mc *mockServer) error { + const lineString = `{"type":"LineString","coordinates":[ + [-116.40289306640624,34.125447565116126], + [-116.36444091796875,34.14818102254435], + [-116.0980224609375,34.15045403191448], + [-115.74920654296874,34.127721186043985], + [-115.54870605468749,34.075412438417395], + [-115.5267333984375,34.11407854333859], + [-115.21911621093749,34.048108084909835], + [-115.25207519531249,33.8339199536547], + [-115.40588378906249,33.71748624018193] + ]}` + + return mc.DoBatch([][]interface{}{ + // points in + {"SET", "fleet", "truck01", "POINT", "34.10825132729329", "-115.6436347961428"}, {"OK"}, + {"SET", "fleet", "truck02", "POINT", "34.07199987534163", "-115.5435562133782"}, {"OK"}, + {"SET", "fleet", "truck03", "POINT", "34.05123715497616", "-115.2148246765137"}, {"OK"}, + {"SET", "fleet", "truck04", "POINT", "33.71520164474084", "-115.4110336303711"}, {"OK"}, + {"SET", "fleet", "truck05", "POINT", "34.12345809664606", "-116.4070129394531"}, {"OK"}, + // points out + {"SET", "fleet", "truck06", "POINT", "35.10825132729329", "-115.6436347961428"}, {"OK"}, + {"SET", "fleet", "truck07", "POINT", "35.07199987534163", "-115.5435562133782"}, {"OK"}, + {"SET", "fleet", "truck08", "POINT", "35.05123715497616", "-115.2148246765137"}, {"OK"}, + {"SET", "fleet", "truck09", "POINT", "35.71520164474084", "-115.4110336303711"}, {"OK"}, + {"SET", "fleet", "truck10", "POINT", "35.12345809664606", "-116.4070129394531"}, {"OK"}, + // buffered intersects + {"INTERSECTS", "fleet", "BUFFER", "1000", "COUNT", "OBJECT", lineString}, {"5"}, + }) +} + // match sorts the response and compares to the expected input func match(expectIn string) func(org, v interface{}) (resp, expect interface{}) { return func(v, org interface{}) (resp, expect interface{}) {