Repository: RedisLabs/geo.lua Branch: master Commit: a5128f420d49 Files: 6 Total size: 38.0 KB Directory structure: gitextract_b7ntzx8_/ ├── CHANGELOG.md ├── LICENSE ├── README.md ├── dataset1.json ├── dataset1.lua └── geo.lua ================================================ FILE CONTENTS ================================================ ================================================ FILE: CHANGELOG.md ================================================ [0.1.6] - 2016- === Adds spatial geometries, increases similarity to LOLCODE. **IMPORTANT:** This release breaks the API. * New commands: `GEOMETRYADD`, `GEOMETRYGET` * `GEOPOLYGON` is now `GEOMETRYFILTER`. It: * Accepts a geometry as a filter (right now, only polygons) * Supports the `STORE` subcommand * Polygon search optimization [0.1.5] - 2016-02-20 === Initial release. [0.1.6]: https://github.com/RedisLabs/geo.lua/releases/tag/0.1.6 [0.1.5]: https://github.com/RedisLabs/geo.lua/releases/tag/0.1.5 ================================================ FILE: LICENSE ================================================ geo.lua is provided under the 3-Clause BSD License: http://opensource.org/licenses/BSD-3-Clause Copyright (c) 2016, Itamar Haber. Copyright (c) 2016, Redis Labs, Inc. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ================================================ FILE: README.md ================================================ geo.lua - helper library for Redis geospatial indices :earth_africa: === This is a Lua library containing miscellaneous geospatial helper routines for use with [Redis]. It requires the [Redis GEO API], available from v3.2. In broad strokes, the library provides: * [Polygon searches](#GEOMETRYFILTER) on geoset members * Navigational information such as [bearing](#GEOBEARING) and [path length](#GEOPATHLEN) * GeoJSON [decoding](#GEOJSONADD) and [encoding](#GEOJSONENCODE) * A playground for testing experimental geospatial APIs The library is strictly :straight_ruler: metric, sorry. ![Metric system adoption worldwide](https://upload.wikimedia.org/wikipedia/commons/thumb/a/ab/Metric_system_adoption_map.svg/2000px-Metric_system_adoption_map.svg.png) Using geo.lua --- The library is an ordinary Redis Lua script - use [`EVAL`] or [`EVALSHA`] to call it. The following example demonstrates usage from the prompt: ````Bash $ redis-cli SCRIPT LOAD "$(cat geo.lua)" "fd07..." $ redis-cli EVALSHA fd07... 0 help 1) "geo.lua (0.1.5): A helper library for Redis geospatial indices" ... ```` Library API --- ### GEO API completeness #### GEOBEARING KEYS[1] geoset ARGV[2] member1 3] member2 Return the initial and final bearing between two members. > Time complexity: O(1). For information about the calculation refer to http://mathforum.org/library/drmath/view/55417.html. **Return:** Array reply, specifically the initial and final bearings. #### GEOPATHLEN KEYS[1] geoset ARGV[2] member [...] The length of a path between members. > Time complexity: O(N) where N is the number of members. **Note:** This is basically a variadic form for [`GEODIST`]. **Return:** String reply, specifically the length in meters. #### GEODEL KEYS[1] geoset ARGV[2] member [...] Delete members from a geoset. > Time complexity: O(M*log(N)) with N being the number of elements in the geoset and M the number of elements to be removed. This command is an alias for [`ZREM`] to disprove the [GEO incompleteness theorem](https://twitter.com/monadic/status/690889597866393600). Technically it should be called `GEOREM` however. **Return:** Integer reply, specifically the number of members actually deleted. ### 2D geometries and polygon search Redis' geosets allow [storing](http://redis.io/commands/geoadd) (and [querying](http://redis.io/commands/georadius)) of `Point` (2D, x/y, longitude & latitude) geometries. Other geometries can be stored serialized (using [msgpack](http://msgpack.org/index.html)) in a Redis Hash data structure - the geomash. The geoset and the geomash are used for storing geometries in the following manner: | Geometry | Storage | `GEOMETRYADD` | `GEOMETRYGET` | `GEOMETRYFILTER` | `GEOJSONADD` | :--- | :--- | :---: | :---: | :---: | :---: | `Point` | geoset | N/A | N/A | No | Yes | `Polygon` | geomash | Yes | Yes | Yes | Yes | `MultiPolygon` | geomash | TODO | TODO | TODO | RODO | `MultiPoint` | TODO | TODO | TODO | No | TODO | `LineString` | TODO | TODO | TODO | TODO | TODO | `MultiLineString` | TODO | TODO | TODO | TODO | TODO | `GeometryCollection` | TODO | TODO | TODO | TODO | TODO #### GEOMETRYADD KEYS[1] geomash ARGV[2] geometry-type 3] id 4..] Upsert a single geometry to a geomash. > Time complexity: `ARGV[4]` and above describe the geometry. Depending on the geometry's type: * `POLYGON` - each pair of ARGVs (e.g., 4 and 5, 6 and 7, ...) represent the coordinates of a vertex (longitude and latitude). A minimum of 4 vertices are required to define a polygon, with the first and last vertices being equal (a LineRing). **Note:** there is no `GEOMETYREM`, use [`HDEL`](http://redis.io/commands/hdel) instead. **Return:** Integer reply, specifically 0 if updated and 1 if added. #### GEOMETRYGET KEYS[1] geomash ARGV[a] [WITHPERIMETER|WITHBOX|WITHCIRCLE] [...] ARGV[2] id [...] Returns geometries' coordinates. > Time complexity: The reply can be enriched with meta data about the geometry. The following sub-commands are supported, depending on the type of geometry. * `WITHPERIMETER`: For `Polygon`, The total length of edges * `WITHBOX`: The bounding box of the geometry * `WITHCIRCLE`: The bounding circle of the geometry **Return:** Array reply, specifically:` * The geometry's type * The geometry's coordinates * When called with `WITHPERIMETER`, the total length of edges * When called with `WITHBOX`, the minimum and maximum coordinates of the bounding box, as well as it's bounding circle's radius * When called with `WITHCIRCLE`, the center coordinates and radius of bounding circle #### GEOMETRYFILTER KEYS[1] geoset 2] geomash a] [target] ARGV[a] [STORE|WITHCOORD] [...] ARGV[2] id Search for geoset members inside a geometry. > Time complexity: This command performs a [`GEORADIUS`] search that contains the geometry's bounding box. The results are then filtered using a Point-In-Polygon (PIP) algorithm ([source](https://www.ecse.rpi.edu/~wrf/Research/Short_Notes/pnpoly.html#The C Code)). The following sub-commands are supported: * `STORE`: Stores the search results in the target geoset * `WITHCOORD`: Returns the members' coordinates as well **Return:** Array reply, specifically the members and their coordinates (if called with `WITHCOORD`). When the `STORE` directive is used, the reply is an Integer that indicates the number of members that were upserted to the target geoset. ### GeoJSON A minimal implementation of the [spec's v1.0](http://geojson.org/geojson-spec.html) for decoding and encoding geoset and geomash members from/to `FeatureCollection`s and `Feature`s. #### GEOJSONADD KEYS[1] geoset 2] geomash ARGV[1] GeoJSON > Time complexity: O(log(N)) for each feature in the GeoJSON object, where N is the number of members in the geoset. Upsert points to the geoset, other geometries to the geomash. A valid input GeoJSON object must be `FeatureCollection`. Each `Feature`'s type must be `Point` or a `Polygon`, and the feature's properties must include a member named `id`. **Return:** Integer reply, specifically the number of features upserted. #### GEOJSONENCODE KEYS[1] geoset ARGV[2] 3] [arg] [...] > Time complexity: depends on the GEO command and its arguments. Encodes the reply of GEO commands as a GeoJSON object. Valid GEO commands are: * [`GEOHASH`] * [`GEOPOS`] * [`GEORADIUS`] and [`GEORADIUSBYMEMBER`] * [`GEOMETRYFILTER`] **Return:** String, specifically the reply of the GEO command encoded as a GeoJSON object. ### Location updates Implements a real-time location tracking mechanism. Inspired by [Matt Stancliff @mattsta](https://matt.sh/redis-geo). #### GEOTRACK KEYS[1] geoset ARGV[2] longitude 3] latitude 4] member [...] > Time complexity: O(log(N)+M+P) for each item added, where N is the number of elements in the geoset, M is the number of clients subscribed to the receiving channel and P is the total number of subscribed patterns (by any client). [`GEOADD`]s a member and [`PUBLISH`]s on channel `__geo::` a message with the format of `:`. Clients can track updates made to a specific member by subscribing to that member's channel (i.e. [`SUBSCRIBE`]`__geo::`) or to all members updates (i.e. [`PSUBSCRIBE`]`__geo::*`). **Return:** Integer reply, specifically the number of members upserted. ### xyzsets Redis' geospatial indices only encode the longitude and latitude of members with no regard to their altitude. An xyzset uses two sorted sets, one as geoset and the other for storing altitudes. #### GEOZADD KEYS[1] geoset 2] azset ARGV[2] logitude 3] latitude 4] altitude 5] member [...] > Time complexity: O(log(N)) for each item added, where N is the number of elements in the geoset. Upsert members. Altitude is given as meters above (or below) sea level. **Return:** Integer reply, specifically the number of members upserted. #### GEOZREM KEYS[1] geoset 2] azset ARGV[2] member [...] > Time complexity: O(M*log(N)) with N being the number of elements in the geoset and M the number of elements to be removed. Remove members. **Return:** Integer reply, specifically the number of members actually deleted. #### GEOZPOS KEYS[1] geoset 2] azset ARGV[2] member [...] > Time complexity: O(log(N)) for each member requested, where N is the number of elements in the geoset. The position of members in 3D. **Return:** Array reply, specifically the members and their positions. #### TODO: GEOZDIST Returns the distance between members. #### TODO: GEOZCYLINDER Perform cylinder-bound search. #### TODO: GEOZCYLINDERBYMEMBER Perform cylinder-bound search by member (a 20 characters command!). #### TODO: GEOZSPHERE Useful for directing air traffic and impact research (bombs, comets). #### TODO: GEOZCONE Good for comparing :alien: sightings vis a vis :cow: abduction data. ### Motility Storing each member's vector in an additional hash data structure, where the field is the member's name and the value is the serialized vector (bearing & velocity). #### TODO: GEOMADD KEYS[1] geoset 2] vector hash ARGV[2] longitude 3] latitude 4] bearing 5] velocity 6] member [...] Upsert members. Bearing given in degrees, velocity in meters/second. #### TODO: GEOMREM KEYS[1] geoset 2] vector hash ARGV[2] member [...] Remove members. #### TODO: GEOMPOSWHEN KEYS[1] geoset 2] vector hash ARGV[2] seconds 3] member [...] Project members position in future or past. #### TODO: GEOMMEETWHENWHERE KEYS[1] geoset 2] vector hash ARGV[2] member1 3] member2 Help solving basic math exercises. License --- 3-Clause BSD. Contributing --- You're encouraged to contribute to the open source geo.lua project. There are two ways you can do so. ### Issues If you encounter an issue while using the geo.lua library, please report it at the project's issues tracker. Feature suggestions are also welcome. ### Pull request Code contributions to the geo.lua project can be made using pull requests. To submit a pull request: 1. Fork this project. 2. Make and commit your changes. 3. Submit your changes as a pull request. [Redis]: http://redis.io [Redis GEO API]: (http://redis.io/commands#geo) [`GEOADD`]: (http://redis.io/commands/geoadd) [`GEODIST`]: (http://redis.io/commands/geodist) [`GEOHASH`]: (http://redis.io/commands/geohash) [`GEOPOS`]: (http://redis.io/commands/geopos) [`GEORADIUS`]: (http://redis.io/commands/georadius) [`GEORADIUSBYMEMBER`]: (http://redis.io/commands/georadiusbymember) [`EVAL`]: (http://redis.io/commands/eval) [`EVALSHA`]: (http://redis.io/commands/evalsha) [`PUBLISH`]: (http://redis.io/commands/publish) [`SUBSCRIBE`]: (http://redis.io/commands/subscribe) [`PSUBSCRIBE`]: (http://redis.io/commands/psubscribe) [`ZREM`]: (http://redis.io/commands/zrem) ================================================ FILE: dataset1.json ================================================ { \"type\": \"FeatureCollection\", \"features\": [ { \"type\": \"Feature\", \"geometry\": {\"type\": \"Point\", \"coordinates\": [15.0074779, 37.4908267]}, \"properties\": {\"id\": \"RL@Catania\"} }, { \"type\": \"Feature\", \"geometry\": {\"type\": \"Point\", \"coordinates\": [34.84076, 32.10942]}, \"properties\": {\"id\": \"RL@TLV\"} }, { \"type\": \"Feature\", \"geometry\": {\"type\": \"Point\", \"coordinates\": [-122.0678325, 37.3775256]}, \"properties\": {\"id\": \"RL@MV\"} }, { \"type\": \"Feature\", \"geometry\": {\"type\": \"Point\", \"coordinates\": [34.8380433, 32.1098095]}, \"properties\": {\"id\": \"Hudson\"} } ] } ================================================ FILE: dataset1.lua ================================================ local data = { '15.0074779', '37.4908267', 'RL@Catania', '34.84076', '32.10942', 'RL@TLV', '-122.0678325', '37.3775256', 'RL@MV', '34.8380433', '32.1098095', 'Hudson' } local alt = { '97', 'RL@Catania', '18', 'RL@TLV', '38', 'RL@MV', '18', 'Hudson' } redis.call('DEL', unpack(KEYS)) redis.call('GEOADD', KEYS[1], unpack(data)) redis.call('ZADD', KEYS[2], unpack(data)) ================================================ FILE: geo.lua ================================================ local _NAME = 'geo.lua' local _VERSION = '0.1.6' local _DESCRIPTION = 'A helper library for Redis geospatial indices' local _COPYRIGHT = '2016 Itamar Haber, Redis Labs' local _USAGE = [[ Call with ARGV[1] being one of the following commands: - GEO API completeness - GEODEL - delete a member GEOBEARING - initial and final bearing between members GEOPATHLEN - length of a path between members - 2D geometries and polygon search - GEOMETRYADD - upsert a geometry to a polyhash GEOMETRYGET - returns coordinates and other stuff for polygons GEOMETRYFILER - search geoset inside a geometry - GeoJSON - GEOJSONADD - upsert members to geoset from GeoJSON object GEOJSONENCODE - return GeoJSON object for these GEO commands: GEOHASH, GEOPOS, GEORADIUS[BYMEMBER] and GEOMETRYFILTER - xyzsets (longitude, latitude & altitude) - GEOZADD - upsert member with altitude GEOZREM - remove member GEOZPOS - the 3d position of members - location updates (credit @mattsta) - GEOTRACK - positional updates notifications help - this text, for more information see the README file ]] local Geo = {} -- GEO library -- private -- Geospatial 2D Geometry local Geometry = {} Geometry.__index = Geometry setmetatable(Geometry, { __call = function(cls, ...) return cls.new(...) end }) Geometry._TYPES = { 'Point', -- TODO 'MultiPoint', -- TODO 'LineString', -- TODO 'MultiLineString', -- TODO 'Polygon', 'MultiPolygon' } -- TODO Geometry._TENUM = {} for i = 1, #Geometry._TYPES do Geometry._TENUM[Geometry._TYPES[i]:upper()] = i Geometry['_T' .. Geometry._TYPES[i]:upper()] = i end --- Calculates distance between two coordinates. -- Just like calling GEODIST, but slower and of slightly different -- accuracy. -- @param lon1 The longitude of the 1st coordinate -- @param lat1 The latitude of the 1st coordinate -- @param lon2 The longitude of the 2nd coordinate -- @param lat2 The latitude of the 2nd coordinate -- @return distance The distance in meters Geometry._distance = function (lon1, lat1, lon2, lat2) local R = 6372797.560856 -- Earth's, in meters local lon1r, lat1r, lon2r, lat2r = math.rad(lon1), math.rad(lat1), math.rad(lon2), math.rad(lat2) local u = math.sin((lat2r - lat1r) / 2) local v = math.sin((lon2r - lon1r) / 2) return 2.0 * R * math.asin(math.sqrt(u * u + math.cos(lat1r) * math.cos(lat2r) * v * v)) end --- Geometry constructor. -- Creates a geometry object. -- @param geomtype The geometry's type, optional -- @param ... The geometry's geometries -- @return self New Geometry object Geometry.new = function(geomtype, ...) local self = setmetatable({}, Geometry) self.geomtype = geomtype self.coordn, self.coordx, self.coordy = {}, {}, {} self.meta = {} if #arg > 0 then if self.geomtype == Geometry._TPOLYGON then -- A polygon's coordinates are given by one or more LineRings -- A LineRing is a LineString where the first and last vertices are identical -- The first and mandatory ring is the polygon's outer ring -- Any subsequent rings are considered holes, islands, and repeating... -- Polygon vertices are stored as two coordinate lists -- When there's more than one ring, O means the (0,0) and the two lists: -- begins with O -- rings are delimeted with O -- end with O self.coordx[#self.coordx+1], self.coordy[#self.coordy+1] = 0, 0 for _, ring in ipairs(arg[1]) do local n = #ring / 2 self.coordn[#self.coordn+1] = n for i = 1, n do self.coordx[#self.coordx+1], self.coordy[#self.coordy+1] = ring[2*i-1], ring[2*i] end self.coordx[#self.coordx+1], self.coordy[#self.coordy+1] = 0, 0 end -- the outer ring local op = 0 -- outer perimeter local ov = self.coordn[1] -- number of vertices local bbx1 = self.coordx[2] -- bounding box local bby1 = self.coordy[2] local bbx2 = self.coordx[2] local bby2 = self.coordy[2] local bbr = 0 local bcx, bcy, bcr = 0, 0, 0 -- bounding circle -- traverse outer vertices for i = 2, 1 + ov do local ix, iy = self.coordx[i], self.coordy[i] local jx, jy = self.coordx[i+1], self.coordy[i+1] -- grow the outer perimeter op = op + Geometry._distance(ix, iy, jx, jy) -- add up for the averages bcx = bcx + ix bcy = bcy + iy -- adjust bounding box if needed if bbx1 > ix then bbx1 = ix elseif bbx2 < ix then bbx2 = ix end if bby1 > iy then bby1 = iy elseif bby2 < iy then bby2 = iy end end bcx = bcx / (ov - 1) bcy = bcy / (ov - 1) bbr = Geometry._distance(bbx1, bby1, bbx2, bby2) / 2 -- farthest point from the centroid is the radius for i = 2, 1 + ov do local d = Geometry._distance(bcx, bcy, self.coordx[i], self.coordy[i]) if bcr < d then bcr = d end end -- lets not be too accurate bcr = math.ceil(bcr) bbr = math.ceil(bbr) self.meta = { op, ov, bcx, bcy, bcr, bbx1, bby1, bbx2, bby2, bbr } else error('Unknown argument(s) to for type') end end return self end --- Checks if a point is inside a polygon. -- https://www.ecse.rpi.edu/~wrf/Research/Short_Notes/pnpoly.html -- @param pt Point -- @param po Polygon -- @return boolean True if PIP function Geometry:PNPOLY(x, y) local c = false -- don't think outside the box if self.meta[6]<=x and self.meta[8]>=x and self.meta[7]<=y and self.meta[9]>=y then local nvert = #self.coordy local vertx = self.coordx local verty = self.coordy local testx = x local testy = y local j = nvert for i = 1, nvert do if ((verty[i]>testy) ~= (verty[j]>testy)) and testx < (vertx[j]-vertx[i]) * (testy-verty[i]) / (verty[j]-verty[i]) + vertx[i] then c = not c end j = i end end return c end --- Returns the bounding circle for a geometry. -- @return Table three elements: x, y and radius function Geometry:getBoundingCircle() -- TODO: error on `Point` return { self.meta[3], self.meta[4], self.meta[5] } end --- Returns the bounding box for a geometry. -- return Table five elements: min x and y, max x and y, radius function Geometry:getBoundingBox() -- TODO: error on `Point` return { self.meta[6], self.meta[7], self.meta[8], self.meta[9], self.meta[10] } end --- Returns the geometry's type name. -- @return name The geometry's name function Geometry:typeAsString() return Geometry._TYPES[self.geomtype] end --- Returns a table of geometry's coordinates. -- Every geometry is a table, and in it every element is a vertex table. -- @return coord The coordinates as a table for a RESP reply function Geometry:coordAsRESP() local reply = {} local verti = 2 for geomi, geomn in ipairs(self.coordn) do local r = {} local vertn = verti + geomn - 1 for i = verti, vertn do r[#r+1] = { tostring(self.coordx[i]), tostring(self.coordy[i]) } end verti = vertn + 2 reply[#reply+1] = r end return reply end --- Returns a table of the geometry's meta information. -- @return meta The meta information function Geometry:metaAsRESP(subcmds) local reply = {} if not subcmds or not subcmds['ANY'] then return end if self.geomtype == Geometry._TPOLYGON then if subcmds['WITHPERIMETER'] then reply[#reply+1] = tostring(self.meta[1]) end if subcmds['WITHBOX'] then local bbox = self:getBoundingBox() reply[#reply+1] = { { tostring(bbox[1]), tostring(bbox[2]) }, { tostring(bbox[3]), tostring(bbox[4]) }, bbox[5]} end if subcmds['WITHCIRCLE'] then local bcircle = self:getBoundingCircle() reply[#reply+1] = { { tostring(bcircle[1]), tostring(bcircle[2]) }, bcircle[3] } end end if #reply > 0 then return reply else return end end --- Returns the msgpack-serialized geometry object. -- @return string The serialized object function Geometry:dump() local payload = {} payload[#payload+1] = self.geomtype payload[#payload+1] = self.coordn payload[#payload+1] = self.coordx payload[#payload+1] = self.coordy payload[#payload+1] = self.meta return cmsgpack.pack(payload) end --- Loads the geometry from its serialized form. -- @param msgpack The serialized geometry -- @return null function Geometry:load(msgpack) local payload = cmsgpack.unpack(msgpack) self.geomtype = table.remove(payload, 1) self.coordn = table.remove(payload, 1) self.coordx = table.remove(payload, 1) self.coordy = table.remove(payload, 1) self.meta = table.remove(payload, 1) end -- Data structure type Geo._TYPE_GEO = 1 -- regular geoset Geo._TYPE_XYZ = 2 -- xyzset Geo._TYPE_GEOM = 3 -- geomash --- Keys validation. -- Extract and validate types of keys for command -- @param geotype The type of command -- @return geoset|polyhash Key name -- @return [azset] Key name Geo._getcommandkeys = function (geotype) local function asserttype(k, t) local r = redis.call('TYPE', k) assert(r['ok'] == t or r['ok'] == 'none', 'WRONGTYPE Operation against a key holding the wrong kind of value') end if geotype == Geo._TYPE_GEO then local geokey = assert(table.remove(KEYS, 1), 'No geoset key name provided') asserttype(geokey, 'zset') return geokey elseif geotype == Geo._TYPE_XYZ then local geokey = assert(table.remove(KEYS, 1), 'No geoset key name provided') asserttype(geokey, 'zset') local zsetkey = assert(table.remove(KEYS, 1), 'No altitude sorted set key name provided') asserttype(zsetkey, 'zset') return geokey, zsetkey elseif geotype == Geo._TYPE_GEOM then local geomkey = assert(table.remove(KEYS, 1), 'No geomash key name provided') asserttype(geomkey, 'hash') return geomkey end end -- public API --- Delete a member. -- @return deleted Number of deleted members Geo.GEODEL = function() local geokey = Geo._getcommandkeys(Geo._TYPE_GEO) return redis.call('ZREM', geokey, unpack(ARGV)) end --- Returns bearing between two members. -- @return bearings Table containing the initial and final bearings Geo.GEOBEARING = function() local geokey = Geo._getcommandkeys(Geo._TYPE_GEO) assert(#ARGV == 2, 'Two members must be provided') local member1, member2 = table.remove(ARGV, 1), table.remove(ARGV, 1) local coords = redis.call('GEOPOS', geokey, member1, member2) assert(coords[1] and coords[2], 'Two existing members must be provided') local function initialbearing(lon1, lat1, lon2, lat2) local p1, p2 = math.rad(lat1), math.rad(lat2) local dl = math.rad(lon2 - lon1) local y = math.sin(dl)*math.cos(p2) local x = math.cos(p1)*math.sin(p2) - math.sin(p1)*math.cos(p2)*math.cos(dl) local b = (math.deg(math.atan2(y, x)) + 360) % 360 return b end local ib = initialbearing(coords[1][1], coords[1][2], coords[2][1], coords[2][2]) local fb = (initialbearing(coords[2][1], coords[2][2], coords[1][1], coords[1][2]) + 180) % 360 return { tostring(ib), tostring(fb) } end --- Calculates the length of a path given by members. -- @return length Path's length in meters Geo.GEOPATHLEN = function() local geokey = Geo._getcommandkeys(Geo._TYPE_GEO) assert(#ARGV > 1, 'Need at least two members to make a path') local total = 0 local prev = table.remove(ARGV, 1) while #ARGV > 0 do local curr = table.remove(ARGV, 1) local dist = redis.call('GEODIST', geokey, prev, curr, 'm') if dist then total = total + dist prev = curr else return end end return total end --- Upserts a single geometry into a geomash. -- TODO: support more geometries besides an unholey `Polygon` -- TODO: support upsert of multiple geometries -- @return upserted Number 0 if updated, 1 if added Geo.GEOMETRYADD = function() local geomkey = Geo._getcommandkeys(Geo._TYPE_GEOM) local geomtype = assert(table.remove(ARGV, 1), 'Expecting a geometry type') geomtype = geomtype:upper() geomtype = assert(Geometry._TENUM[geomtype], 'Invalid geometry type') local id = assert(table.remove(ARGV, 1), 'Expecting a geometry id') assert(geomtype == Geometry._TPOLYGON, 'Sorry, atm only `POLYGON` geometry type is supported') assert(#ARGV > 7, 'Expecting at least 4 coordinates') assert(#ARGV % 2 == 0, 'Expecting an even number of arguments as coordinates') assert(ARGV[1] == ARGV[#ARGV-1] and ARGV[2] == ARGV[#ARGV], 'The first and last vertices must be the identical') local coord = {} for i, v in ipairs(ARGV) do coord[#coord+1] = assert(tonumber(v), 'Expecting numbers as coordinates') end local geom = Geometry.new(Geometry._TPOLYGON, { coord }) return redis.call('HSET', geomkey, id, geom:dump()) end --- Returns geometries from a geomash. -- @return vertices Table of vertices Geo.GEOMETRYGET = function() local geomkey = Geo._getcommandkeys(Geo._TYPE_GEOM) local subcmds = { WITHPERIMETER = false, WITHBOX = false, WITHCIRCLE = false } assert(#ARGV > 0, 'Expecting at least one argument') for i = 1, math.min(3, #ARGV) do local s = ARGV[1]:upper() -- there are edge cases this will not cover but good enough if subcmds[s] ~= nil and not subcmds[s] then subcmds['ANY'] = true subcmds[s] = true table.remove(ARGV, 1) end end assert(#ARGV > 0, 'Expecting at least one geometry id') local r = redis.call('HMGET', geomkey, unpack(ARGV)) -- cast each geometry in the reply to RESP for ri, rv in ipairs(r) do if rv then -- i.e. not (nil), meaning v is a geometry local geom = Geometry.new() geom:load(rv) local rep = {} rep[#rep+1] = geom:typeAsString() rep[#rep+1] = geom:coordAsRESP() local meta = geom:metaAsRESP(subcmds) if meta then rep[#rep+1]= meta end r[ri] = rep end end return r end --- Performs a search for members inside a geometry. -- @return members Table with the members Geo.GEOMETRYFILTER = function() local geokey = Geo._getcommandkeys(Geo._TYPE_GEO) local geomkey = Geo._getcommandkeys(Geo._TYPE_GEOM) assert(#ARGV > 0, 'Expecting at least one argument') local subcmds = { WITHCOORD = false, STORE = false } for i = 1, math.min(2, #ARGV) do local s = ARGV[1]:upper() -- there are edge cases this will not cover but good enough if subcmds[s] ~= nil and not subcmds[s] then subcmds[s] = true table.remove(ARGV, 1) end end local targetkey if subcmds['STORE'] then -- TODO: this currently triggers an ambiguous error targetkey = Geo._getcommandkeys(Geo._TYPE_GEO) end assert(#ARGV == 1, 'Expecting a single geometry id') local r = assert(redis.call('HGET', geomkey, ARGV[1]), 'Geometry id not found: ' .. ARGV[1]) local geom = Geometry:new() geom:load(r) assert(geom.geomtype == Geometry._TPOLYGON, 'Unsupported (TODO) filter geometry: ' .. geom:typeAsString()) local bbox = geom:getBoundingBox() local members = redis.call('GEORADIUS', geokey, (bbox[1] + bbox[3]) / 2, (bbox[2] + bbox[4]) / 2, bbox[5], 'm', 'WITHCOORD') local reply, geoadd = {} for i, v in ipairs(members) do if geom:PNPOLY(tonumber(v[2][1]), tonumber(v[2][2])) then if subcmds['WITHCOORD'] then reply[#reply+1] = v else reply[#reply+1] = v[1] end if subcmds['STORE'] then geoadd[#geoadd+1] = v[2][1] geoadd[#geoadd+1] = v[2][2] geoadd[#geoadd+1] = v[1] end end end if subcmds['STORE'] then return redis.call('GEOADD', targetkey, unpack(geoadd)) else return reply end end --- Adds members in a GeoJSON object to a geoset. -- @return added The number of members added Geo.GEOJSONADD = function() local geokey = Geo._getcommandkeys(Geo._TYPE_GEO) assert(#ARGV == 1, 'Expecting a single argument') local geojson = assert(cjson.decode(table.remove(ARGV, 1)), 'Expecting a valid JSON object') assert(geojson['type'], 'Expecting a valid GeoJSON object but got no type') assert(geojson['type'] == 'FeatureCollection', 'Expecting a FeatureCollection, got ' .. geojson['type']) assert(type(geojson['features']) == 'table', 'No features found in FeatureCollection') local geoadd = {} for i, v in ipairs(geojson['features']) do assert(v['type'], 'Expecting a valid GeoJSON object but got no type for feature') assert(v['type'] == 'Feature', 'Expecting Feature as type, got ' .. v['type']) assert(v['geometry'], 'No feature geometry') assert(v['geometry']['type'] == 'Point', 'Feature geometry must be a Point') local coord = assert(v['geometry']['coordinates'], 'No feature geometry coordinates provided') assert(type(coord) == 'table' and #coord > 1, 'Feature geometry coordinates must consist at least 2 values: longitude and latitude') geoadd[#geoadd+1] = coord[1] geoadd[#geoadd+1] = coord[2] local id = assert(v['properties']['id'], 'No id provided for member') table.insert(geoadd, id) end return redis.call('GEOADD', geokey, unpack(geoadd)) end --- Adds polygons in a GeoJSON object to a polyhash. -- Note: only the LineRing of the polygon. -- @return added The number of members added Geo.GEOJSONPOLYADD = function() local polykey = Geo._getcommandkeys(Geo._TYPE_GEOM) assert(#ARGV == 1, 'Expecting a single argument') local geojson = assert(cjson.decode(table.remove(ARGV, 1)), 'Expecting a valid JSON object') assert(geojson['type'], 'Expecting a valid GeoJSON object but got no type') assert(geojson['type'] == 'FeatureCollection', 'Expecting a FeatureCollection, got ' .. geojson['type']) assert(type(geojson['features']) == 'table', 'No features found in FeatureCollection') local polyadd = {} for i, v in ipairs(geojson['features']) do assert(v['type'], 'Expecting a valid GeoJSON object but got no type for feature') assert(v['type'] == 'Feature', 'Expecting Feature as type, got ' .. v['type']) local id = assert(v['properties']['id'], 'No id provided for member') assert(v['geometry'], 'No feature geometry') assert(v['geometry']['type'] == 'Polygon', 'Feature geometry must be a Polygon, got ' .. v['geometry']['type']) local coord = assert(v['geometry']['coordinates'][1], 'No feature geometry coordinates provided') assert(type(coord) == 'table' and #coord > 2, 'Feature geometry coordinates must have at least 3 coordinates ' .. id) local enc = Geo._polygonencode(coord) polyadd[#polyadd+1] = { id, enc } end return redis.call('HMSET', polykey, unpack(polyadd)) end --- Encodes the output of GEO commands as GeoJSON object. -- @return json The serialized GeoJSON object Geo.GEOJSONENCODE = function() local geokey = Geo._getcommandkeys(Geo._TYPE_GEO) assert(#ARGV > 1, 'Expecting at least two arguments') local geocmd = table.remove(ARGV, 1):upper() local subcmds = {} for _, v in ipairs(ARGV) do local s = v:upper() if s == 'WITHCOORD' or s == 'WITHHASH' or s == 'WITHDIST' then subcmds[s] = true end end local geojson = {type = 'FeatureCollection', features = {}} local r = {} if geocmd == 'GEOPOS' or geocmd == 'GEOHASH' then r = redis.call('GEOPOS', geokey, unpack(ARGV)) for i, v in pairs(r) do if v then r[i] = { ARGV[i], v } else -- (nil) r[i] = nil end end if geocmd == 'GEOHASH' then local h = redis.call(geocmd, geokey, unpack(ARGV)) for i, v in pairs(h) do if v then r[i] = { r[i][1], v, r[i][2] } end end end elseif geocmd == 'GEORADIUS' or geocmd == 'GEORADIUSBYMEMBER' then assert(subcmds['WITHCOORD'], geocmd .. ' must be called with WITHCOORD') r = redis.call(geocmd, geokey, unpack(ARGV)) elseif geocmd == 'GEOMETRYFILTER' then assert(subcmds['WITHCOORD'], geocmd .. ' must be called with WITHCOORD') -- return the geokey KEYS[#KEYS+1] = geokey -- return the geomash KEYS[#KEYS+1] = table.remove(ARGV, 1) r = Geo.GEOMETRYFILTER() else error('Unsupported command for GeoJSON encoding: ' .. geocmd) end for _, feature in pairs(r) do local jf = { type = 'Feature', geometry = { type = 'Point', coordinates = {} }, properties = {} } jf['properties']['id'] = table.remove(feature, 1) if geocmd == 'GEOHASH' then jf['properties']['geohash'] = table.remove(feature, 1) end if subcmds['WITHDIST'] then jf['properties']['distance'] = table.remove(feature, 1) end if subcmds['WITHHASH'] then jf['properties']['rawhash'] = table.remove(feature, 1) end local coords = table.remove(feature, 1) jf['geometry']['coordinates'] = {tonumber(coords[1]), tonumber(coords[2])} table.insert(geojson['features'], jf) end return cjson.encode(geojson) end --- Adds members to an xyzset. -- @return added The number of members added Geo.GEOZADD = function() local geokey, zsetkey = Geo._getcommandkeys(Geo._TYPE_XYZ) -- ARGV's should be made of tuples (longitude, latitude, altitude, member) assert(#ARGV > 0 and #ARGV % 4 == 0, 'Expecting a positive multiple of four arguements') local geoadd, zadd = {}, {} while #ARGV > 0 do geoadd[#geoadd+1] = table.remove(ARGV, 1) geoadd[#geoadd+1] = table.remove(ARGV, 1) zadd[#zadd+1] = table.remove(ARGV, 1) zadd[#zadd+1] = table.remove(ARGV, 1) geoadd[#geoadd+1] = zadd[#zadd] end redis.call('ZADD', zsetkey, unpack(zadd)) return redis.call('GEOADD', geokey, unpack(geoadd)) end --- Delete a member. -- @return deleted Number of deleted members Geo.GEOZREM = function() local geokey, zsetkey = Geo._getcommandkeys(Geo._TYPE_XYZ) assert(#ARGV > 0, 'No members to remove provided') redis.call('ZREM', zsetkey, unpack(ARGV)) return redis.call('ZREM', geokey, unpack(ARGV)) end --- The positions of members. -- @return members Table with members' positions Geo.GEOZPOS = function() local geokey, zsetkey = Geo._getcommandkeys(Geo._TYPE_XYZ) assert(#ARGV > 0, 'No members to remove provided') local r = redis.call('GEOPOS', geokey, unpack(ARGV)) for i, v in ipairs(r) do if v then -- not (nil) v[#v+1] = redis.call('ZSCORE', zsetkey, ARGV[i]) end end return r end --- Upserts a member's position and publishes a notification. -- @return upserted Number of upserted members Geo.GEOTRACK = function() local geokey = Geo._getcommandkeys(Geo._TYPE_GEO) -- ARGV's should be made of tuples (longitude, latitude, member) assert(#ARGV > 0 and #ARGV % 3 == 0, 'Expecting a positive multiple of three arguements') local reply = 0 while #ARGV > 0 do local lon, lat, member = table.remove(ARGV, 1), table.remove(ARGV, 1),table.remove(ARGV, 1) reply = reply + redis.call('GEOADD', geokey, lon, lat, member) redis.call('PUBLISH', '__geo:' .. geokey .. ':' .. member, lon .. ':' .. lat) end return reply end --- Provides comfort. -- @return help The help Geo.HELP = function() local reply = { } table.insert(reply, _NAME .. " (" .. _VERSION .. "): " .. _DESCRIPTION) local curr = 1 local from, to = string.find(_USAGE, '\n', curr) while from do table.insert(reply, string.sub(_USAGE, curr, from-1)) curr = to + 1 from, to = string.find(_USAGE, '\n', curr) end table.insert(reply, string.sub(_USAGE, curr)) return reply end -- "main" assert(redis.call('COMMAND', 'INFO', 'GEOADD'), 'Redis GEO API is missing (are you using v3.2 or above?)') local command_name = assert(table.remove(ARGV, 1), 'No command provided - try `help`') command_name = command_name:upper() local command = assert(Geo[command_name], 'Unknown command ' .. command_name) return command()