// Copyright 2017 JanusGraph Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package org.janusgraph.core.attribute;
import com.google.common.base.Preconditions;
import com.google.common.primitives.Doubles;
import com.spatial4j.core.context.SpatialContext;
import com.spatial4j.core.distance.DistanceUtils;
import com.spatial4j.core.shape.Circle;
import com.spatial4j.core.shape.Shape;
import com.spatial4j.core.shape.SpatialRelation;
import org.apache.tinkerpop.shaded.jackson.databind.ObjectReader;
import org.apache.tinkerpop.shaded.jackson.databind.ObjectWriter;
import org.janusgraph.diskstorage.ScanBuffer;
import org.janusgraph.diskstorage.WriteBuffer;
import org.janusgraph.graphdb.database.idhandling.VariableLong;
import org.apache.commons.io.output.ByteArrayOutputStream;
import org.apache.tinkerpop.gremlin.structure.io.graphson.GraphSONTokens;
import org.apache.tinkerpop.gremlin.structure.io.graphson.GraphSONUtil;
import org.apache.tinkerpop.shaded.jackson.core.JsonGenerator;
import org.apache.tinkerpop.shaded.jackson.core.JsonParser;
import org.apache.tinkerpop.shaded.jackson.core.JsonProcessingException;
import org.apache.tinkerpop.shaded.jackson.databind.DeserializationContext;
import org.apache.tinkerpop.shaded.jackson.databind.ObjectMapper;
import org.apache.tinkerpop.shaded.jackson.databind.SerializerProvider;
import org.apache.tinkerpop.shaded.jackson.databind.deser.std.StdDeserializer;
import org.apache.tinkerpop.shaded.jackson.databind.jsontype.TypeSerializer;
import org.apache.tinkerpop.shaded.jackson.databind.ser.std.StdSerializer;
import org.apache.tinkerpop.shaded.kryo.Kryo;
import org.apache.tinkerpop.shaded.kryo.Serializer;
import org.apache.tinkerpop.shaded.kryo.io.Input;
import org.apache.tinkerpop.shaded.kryo.io.Output;
import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.StringReader;
import java.lang.reflect.Array;
import java.text.ParseException;
import java.util.*;
import java.util.stream.Collectors;
/**
* A generic representation of a geographic shape, which can either be a single point,
* circle, box, line or polygon. Use {@link #getType()} to determine the type of shape of a particular Geoshape object.
* Use the static constructor methods to create the desired geoshape.
*
* @author Matthias Broecheler (me@matthiasb.com)
*/
public class Geoshape {
private static String FIELD_LABEL = "geometry";
private static String FIELD_TYPE = "type";
private static String FIELD_COORDINATES = "coordinates";
private static String FIELD_RADIUS = "radius";
public static final GeoshapeHelper HELPER;
static {
boolean haveJts = false;
try {
haveJts = Class.forName("com.vividsolutions.jts.geom.Geometry") != null;
} catch (ClassNotFoundException e) { }
HELPER = haveJts ? new JtsGeoshapeHelper() : new GeoshapeHelper();
}
private static final ObjectReader mapReader;
private static final ObjectWriter mapWriter;
static {
final ObjectMapper mapper = new ObjectMapper();
mapReader = mapper.readerWithView(LinkedHashMap.class).forType(LinkedHashMap.class);
mapWriter = mapper.writerWithView(Map.class);
}
/**
* The Type of a shape: a point, box, circle, line or polygon.
*/
public enum Type {
POINT("Point"),
BOX("Box"),
CIRCLE("Circle"),
LINE("Line"),
POLYGON("Polygon"),
MULTIPOINT("MultiPoint"),
MULTILINESTRING("MultiLineString"),
MULTIPOLYGON("MultiPolygon");
private final String gsonName;
Type(String gsonName) {
this.gsonName = gsonName;
}
public boolean gsonEquals(String otherGson) {
return otherGson != null && gsonName.equals(otherGson);
}
public static Type fromGson(String gsonShape) {
return Type.valueOf(gsonShape.toUpperCase());
}
@Override
public String toString() {
return gsonName;
}
}
private final Shape shape;
protected Geoshape(final Shape shape) {
Preconditions.checkNotNull(shape,"Invalid shape (null)");
this.shape = shape;
}
@Override
public int hashCode() {
return shape.hashCode();
}
@Override
public boolean equals(Object other) {
if (this==other) return true;
else if (other==null) return false;
else if (!getClass().isInstance(other)) return false;
Geoshape oth = (Geoshape)other;
return shape.equals(oth.shape);
}
/**
* Returns the WKT representation of the shape.
* @return
*/
@Override
public String toString() {
return HELPER.getWktWriter().toString(shape);
}
/**
* Returns the GeoJSON representation of the shape.
* @return
*/
public String toGeoJson() {
return GeoshapeGsonSerializer.toGeoJson(this);
}
public Map<String,Object> toMap() throws IOException {
return mapReader.readValue(toGeoJson());
}
/**
* Returns the underlying {@link Shape}.
* @return
*/
public Shape getShape() {
return shape;
}
/**
* Returns the {@link Type} of this geoshape.
*
* @return
*/
public Type getType() {
return HELPER.getType(shape);
}
/**
* Returns the number of points comprising this geoshape. A point and circle have only one point (center of cricle),
* a box has two points (the south-west and north-east corners). Lines and polygons have a variable number of points.
*
* @return
*/
public int size() {
return HELPER.size(shape);
}
/**
* Returns the point at the given position. The position must be smaller than {@link #size()}.
*
* @param position
* @return
*/
public Point getPoint(int position) {
return HELPER.getPoint(this, position);
}
/**
* Returns the singleton point of this shape. Only applicable for point and circle shapes.
*
* @return
*/
public Point getPoint() {
Preconditions.checkArgument(getType()==Type.POINT || getType()==Type.CIRCLE,"Shape does not have a single point");
return new Point(shape.getCenter().getY(), shape.getCenter().getX());
}
/**
* Returns the radius in kilometers of this circle. Only applicable to circle shapes.
* @return
*/
public double getRadius() {
Preconditions.checkArgument(getType()==Type.CIRCLE,"This shape is not a circle");
double radiusInDeg = ((Circle) shape).getRadius();
return DistanceUtils.degrees2Dist(radiusInDeg, DistanceUtils.EARTH_MEAN_RADIUS_KM);
}
private SpatialRelation getSpatialRelation(Geoshape other) {
Preconditions.checkNotNull(other);
return shape.relate(other.shape);
}
/**
* Whether this geometry has any points in common with the given geometry.
* @param other
* @return
*/
public boolean intersect(Geoshape other) {
SpatialRelation r = getSpatialRelation(other);
return r==SpatialRelation.INTERSECTS || r==SpatialRelation.CONTAINS || r==SpatialRelation.WITHIN;
}
/**
* Whether this geometry is within the given geometry.
* @param outer
* @return
*/
public boolean within(Geoshape outer) {
return getSpatialRelation(outer)==SpatialRelation.WITHIN;
}
/**
* Whether this geometry contains the given geometry.
* @param outer
* @return
*/
public boolean contains(Geoshape outer) {
return getSpatialRelation(outer)==SpatialRelation.CONTAINS;
}
/**
* Whether this geometry has no points in common with the given geometry.
* @param other
* @return
*/
public boolean disjoint(Geoshape other) {
return getSpatialRelation(other)==SpatialRelation.DISJOINT;
}
/**
* Constructs a point from its latitude and longitude information
* @param latitude
* @param longitude
* @return
*/
public static final Geoshape point(final double latitude, final double longitude) {
Preconditions.checkArgument(isValidCoordinate(latitude,longitude),"Invalid coordinate provided");
return new Geoshape(HELPER.getContext().makePoint(longitude, latitude));
}
/**
* Constructs a circle from a given center point and a radius in kilometer
* @param latitude
* @param longitude
* @param radiusInKM
* @return
*/
public static final Geoshape circle(final double latitude, final double longitude, final double radiusInKM) {
Preconditions.checkArgument(isValidCoordinate(latitude,longitude),"Invalid coordinate provided");
Preconditions.checkArgument(radiusInKM>0,"Invalid radius provided [%s]",radiusInKM);
double radius = DistanceUtils.dist2Degrees(radiusInKM, DistanceUtils.EARTH_MEAN_RADIUS_KM);
return new Geoshape(HELPER.getContext().makeCircle(longitude, latitude, radius));
}
/**
* Constructs a new box shape which is identified by its south-west and north-east corner points
* @param southWestLatitude
* @param southWestLongitude
* @param northEastLatitude
* @param northEastLongitude
* @return
*/
public static final Geoshape box(final double southWestLatitude, final double southWestLongitude,
final double northEastLatitude, final double northEastLongitude) {
Preconditions.checkArgument(isValidCoordinate(southWestLatitude,southWestLongitude),"Invalid south-west coordinate provided");
Preconditions.checkArgument(isValidCoordinate(northEastLatitude,northEastLongitude),"Invalid north-east coordinate provided");
return new Geoshape(HELPER.getContext().makeRectangle(southWestLongitude, northEastLongitude, southWestLatitude, northEastLatitude));
}
/**
* Constructs a line from list of coordinates
* @param coordinates Coordinate (lon,lat) pairs
* @return
*/
public static final Geoshape line(List<double[]> coordinates) {
Preconditions.checkArgument(coordinates.size() >= 2, "Too few coordinate pairs provided");
List<com.spatial4j.core.shape.Point> points = new ArrayList<>();
for (double[] coordinate : coordinates) {
Preconditions.checkArgument(isValidCoordinate(coordinate[1],coordinate[0]),"Invalid coordinate provided");
points.add(HELPER.getContext().makePoint(coordinate[0], coordinate[1]));
}
return new Geoshape(HELPER.getContext().makeLineString(points));
}
/**
* Constructs a polygon from list of coordinates
* @param coordinates Coordinate (lon,lat) pairs
* @return
*/
public static final Geoshape polygon(List<double[]> coordinates) {
return HELPER.polygon(coordinates);
}
/**
* Constructs a Geoshape from a spatial4j {@link Shape}.
* @param shape
* @return
*/
public static final Geoshape geoshape(Shape shape) {
return new Geoshape(shape);
}
/**
* Create Geoshape from WKT representation.
* @param wkt
* @return
* @throws ParseException
*/
public static final Geoshape fromWkt(String wkt) throws ParseException {
return new Geoshape(HELPER.getWktReader().parse(wkt));
}
/**
* Whether the given coordinates mark a point on earth.
* @param latitude
* @param longitude
* @return
*/
public static final boolean isValidCoordinate(final double latitude, final double longitude) {
return latitude>=-90.0 && latitude<=90.0 && longitude>=-180.0 && longitude<=180.0;
}
public static final SpatialContext getSpatialContext() {
return HELPER.getContext();
}
/**
* A single point representation. A point is identified by its coordinate on the earth sphere using the spherical
* system of latitudes and longitudes.
*/
public static final class Point {
private final double longitude;
private final double latitude;
/**
* Constructs a point with the given latitude and longitude
* @param latitude Between -90 and 90 degrees
* @param longitude Between -180 and 180 degrees
*/
Point(double latitude, double longitude) {
this.longitude = longitude;
this.latitude = latitude;
}
/**
* Longitude of this point
* @return
*/
public double getLongitude() {
return longitude;
}
/**
* Latitude of this point
* @return
*/
public double getLatitude() {
return latitude;
}
private com.spatial4j.core.shape.Point getSpatial4jPoint() {
return HELPER.getContext().makePoint(longitude,latitude);
}
/**
* Returns the distance to another point in kilometers
*
* @param other Point
* @return
*/
public double distance(Point other) {
return DistanceUtils.degrees2Dist(HELPER.getContext().getDistCalc().distance(getSpatial4jPoint(),other.getSpatial4jPoint()),DistanceUtils.EARTH_MEAN_RADIUS_KM);
}
}
/**
* Geoshape attribute serializer for JanusGraph.
* @author Matthias Broecheler (me@matthiasb.com)
*/
public static class GeoshapeSerializer implements AttributeSerializer<Geoshape> {
@Override
public void verifyAttribute(Geoshape value) {
//All values of Geoshape are valid
}
@Override
public Geoshape convert(Object value) {
if(value instanceof Map) {
return convertGeoJson(value);
}
if(value instanceof Collection) {
value = convertCollection((Collection<Object>) value);
}
if (value.getClass().isArray() && (value.getClass().getComponentType().isPrimitive() ||
Number.class.isAssignableFrom(value.getClass().getComponentType())) ) {
Geoshape shape = null;
int len= Array.getLength(value);
double[] arr = new double[len];
for (int i=0;i<len;i++) arr[i]=((Number)Array.get(value,i)).doubleValue();
if (len==2) shape= point(arr[0],arr[1]);
else if (len==3) shape= circle(arr[0],arr[1],arr[2]);
else if (len==4) shape= box(arr[0],arr[1],arr[2],arr[3]);
else throw new IllegalArgumentException("Expected 2-4 coordinates to create Geoshape, but given: " + value);
return shape;
} else if (value instanceof String) {
String[] components=null;
for (String delimiter : new String[]{",",";"}) {
components = ((String)value).split(delimiter);
if (components.length>=2 && components.length<=4) break;
else components=null;
}
Preconditions.checkArgument(components!=null,"Could not parse coordinates from string: %s",value);
double[] coords = new double[components.length];
try {
for (int i=0;i<components.length;i++) {
coords[i]=Double.parseDouble(components[i]);
}
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Could not parse coordinates from string: " + value, e);
}
return convert(coords);
} else return null;
}
private double[] convertCollection(Collection<Object> c) {
List<Double> numbers = c.stream().map(o -> {
if (!(o instanceof Number)) {
throw new IllegalArgumentException("Collections may only contain numbers to create a Geoshape");
}
return ((Number) o).doubleValue();
}).collect(Collectors.toList());
return Doubles.toArray(numbers);
}
private Geoshape convertGeoJson(Object value) {
//Note that geoJson is long,lat
try {
Map<String, Object> map = (Map) value;
String type = (String) map.get("type");
if("Feature".equals(type)) {
Map<String, Object> geometry = (Map) map.get("geometry");
return convertGeometry(geometry);
} else {
return convertGeometry(map);
}
} catch (ClassCastException | IOException | ParseException e) {
throw new IllegalArgumentException("GeoJSON was unparsable");
}
}
private Geoshape convertGeometry(Map<String, Object> geometry) throws IOException, ParseException {
String type = (String) geometry.get("type");
List<Object> coordinates = (List) geometry.get("coordinates");
if ("Point".equals(type)) {
double[] parsedCoordinates = convertCollection(coordinates);
return point(parsedCoordinates[1], parsedCoordinates[0]);
} else if ("Circle".equals(type)) {
Number radius = (Number) geometry.get("radius");
if (radius == null) {
throw new IllegalArgumentException("GeoJSON circles require a radius");
}
double[] parsedCoordinates = convertCollection(coordinates);
return circle(parsedCoordinates[1], parsedCoordinates[0], radius.doubleValue());
} else if ("Polygon".equals(type)) {
// check whether this is a box
if (coordinates.size() == 4) {
double[] p0 = convertCollection((Collection) coordinates.get(0));
double[] p1 = convertCollection((Collection) coordinates.get(1));
double[] p2 = convertCollection((Collection) coordinates.get(2));
double[] p3 = convertCollection((Collection) coordinates.get(3));
//This may be a clockwise or counterclockwise polygon, we have to verify that it is a box
if ((p0[0] == p1[0] && p1[1] == p2[1] && p2[0] == p3[0] && p3[1] == p0[1] && p3[0] != p0[0]) ||
(p0[1] == p1[1] && p1[0] == p2[0] && p2[1] == p3[1] && p3[0] == p0[0] && p3[1] != p0[1])) {
return box(min(p0[1], p1[1], p2[1], p3[1]), min(p0[0], p1[0], p2[0], p3[0]), max(p0[1], p1[1], p2[1], p3[1]), max(p0[0], p1[0], p2[0], p3[0]));
}
}
}
String json = mapWriter.writeValueAsString(geometry);
return new Geoshape(HELPER.getGeojsonReader().read(new StringReader(json)));
}
private double min(double... numbers) {
return Arrays.stream(numbers).min().getAsDouble();
}
private double max(double... numbers) {
return Arrays.stream(numbers).max().getAsDouble();
}
@Override
public Geoshape read(ScanBuffer buffer) {
long l = VariableLong.readPositive(buffer);
assert l>0 && l<Integer.MAX_VALUE;
int length = (int)l;
InputStream inputStream = new ByteArrayInputStream(buffer.getBytes(length));
try {
return GeoshapeBinarySerializer.read(inputStream);
} catch (IOException e) {
throw new RuntimeException("I/O exception reading geoshape");
}
}
@Override
public void write(WriteBuffer buffer, Geoshape attribute) {
try {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
GeoshapeBinarySerializer.write(outputStream, attribute);
byte[] bytes = outputStream.toByteArray();
VariableLong.writePositive(buffer,bytes.length);
buffer.putBytes(bytes);
} catch (IOException e) {
throw new RuntimeException("I/O exception writing geoshape");
}
}
}
/**
* Geoshape serializer for TinkerPop's Gryo.
*/
public static class GeoShapeGryoSerializer extends Serializer<Geoshape> {
@Override
public void write(Kryo kryo, Output output, Geoshape geoshape) {
try {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
GeoshapeBinarySerializer.write(outputStream, geoshape);
byte[] bytes = outputStream.toByteArray();
output.write(bytes.length);
output.write(bytes);
} catch (IOException e) {
throw new RuntimeException("I/O exception writing geoshape");
}
}
@Override
public Geoshape read(Kryo kryo, Input input, Class<Geoshape> aClass) {
int length = input.read();
assert length>0;
InputStream inputStream = new ByteArrayInputStream(input.readBytes(length));
try {
return GeoshapeBinarySerializer.read(inputStream);
} catch (IOException e) {
throw new RuntimeException("I/O exception reding geoshape");
}
}
}
/**
* Geoshape serializer supports writing GeoJSON (http://geojson.org/).
*/
public static class GeoshapeGsonSerializer extends StdSerializer<Geoshape> {
public GeoshapeGsonSerializer() {
super(Geoshape.class);
}
@Override
public void serialize(Geoshape value, JsonGenerator jgen, SerializerProvider provider) throws IOException, JsonProcessingException {
switch(value.getType()) {
case POINT:
jgen.writeStartObject();
jgen.writeFieldName(FIELD_TYPE);
jgen.writeString(Type.POINT.toString());
jgen.writeFieldName(FIELD_COORDINATES);
jgen.writeStartArray();
jgen.writeNumber(value.getPoint().getLongitude());
jgen.writeNumber(value.getPoint().getLatitude());
jgen.writeEndArray();
jgen.writeEndObject();
break;
default:
jgen.writeRawValue(toGeoJson(value));
break;
}
}
@Override
public void serializeWithType(Geoshape geoshape, JsonGenerator jgen, SerializerProvider serializerProvider,
TypeSerializer typeSerializer) throws IOException, JsonProcessingException {
jgen.writeStartObject();
if (typeSerializer != null) jgen.writeStringField(GraphSONTokens.CLASS, Geoshape.class.getName());
String geojson = toGeoJson(geoshape);
Map json = mapReader.readValue(geojson);
if (geoshape.getType() == Type.POINT) {
double[] coords = ((List<Number>) json.get("coordinates")).stream().map(i -> i.doubleValue()).mapToDouble(i -> i).toArray();
GraphSONUtil.writeWithType(FIELD_COORDINATES, coords, jgen, serializerProvider, typeSerializer);
} else {
GraphSONUtil.writeWithType(FIELD_LABEL, json, jgen, serializerProvider, typeSerializer);
}
jgen.writeEndObject();
}
public static String toGeoJson(Geoshape geoshape) {
return HELPER.getGeojsonWriter().toString(geoshape.shape);
}
}
/**
* Geoshape JSON deserializer supporting reading from GeoJSON (http://geojson.org/).
*/
public static class GeoshapeGsonDeserializer extends StdDeserializer<Geoshape> {
public GeoshapeGsonDeserializer() {
super(Geoshape.class);
}
@Override
public Geoshape deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JsonProcessingException {
jsonParser.nextToken();
if (jsonParser.getCurrentName().equals("coordinates")) {
double[] f = jsonParser.readValueAs(double[].class);
jsonParser.nextToken();
return Geoshape.point(f[1], f[0]);
} else {
try {
HashMap map = jsonParser.readValueAs(LinkedHashMap.class);
jsonParser.nextToken();
String json = mapWriter.writeValueAsString(map);
Geoshape shape = new Geoshape(HELPER.getGeojsonReader().read(new StringReader(json)));
return shape;
} catch (ParseException e) {
throw new IOException("Unable to read and parse geojson", e);
}
}
}
}
/**
* Geoshape binary serializer using spatial4j's {@link com.spatial4j.core.io.BinaryCodec}.
*
*/
public static class GeoshapeBinarySerializer {
/**
* Serialize a geoshape.
* @param outputStream
* @param attribute
* @return
* @throws IOException
*/
public static void write(OutputStream outputStream, Geoshape attribute) throws IOException {
outputStream.write(HELPER.isJts(attribute.shape) ? 0 : 1);
try (DataOutputStream dataOutput = new DataOutputStream(outputStream)) {
HELPER.write(dataOutput, attribute);
dataOutput.flush();
}
outputStream.flush();
}
/**
* Deserialize a geoshape.
* @param inputStream
* @return
* @throws IOException
*/
public static Geoshape read(InputStream inputStream) throws IOException {
boolean isJts = inputStream.read()==0;
try (DataInputStream dataInput = new DataInputStream(inputStream)) {
if (isJts) {
return new Geoshape(HELPER.readGeometry(dataInput));
} else {
return new Geoshape(HELPER.getBinaryCodec().readShape(dataInput));
}
}
}
}
}