blob: 1cd3584866cc49a86bc5d1c2b161b250c99a79b2 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.apache.tinkerpop.gremlin.console.plugin
import groovy.json.JsonOutput
import groovy.transform.CompileStatic
import org.apache.http.client.methods.RequestBuilder
import org.apache.http.entity.StringEntity
import org.apache.http.impl.client.CloseableHttpClient
import org.apache.http.impl.client.HttpClients
import org.apache.http.util.EntityUtils
import org.apache.tinkerpop.gremlin.groovy.plugin.RemoteAcceptor
import org.apache.tinkerpop.gremlin.groovy.plugin.RemoteException
import org.apache.tinkerpop.gremlin.process.traversal.Path
import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource
import org.apache.tinkerpop.gremlin.structure.Direction
import org.apache.tinkerpop.gremlin.structure.Edge
import org.apache.tinkerpop.gremlin.structure.Graph
import org.apache.tinkerpop.gremlin.structure.Vertex
import groovy.json.JsonSlurper
import org.apache.tinkerpop.gremlin.util.iterator.IteratorUtils
import org.codehaus.groovy.tools.shell.Groovysh
import org.codehaus.groovy.tools.shell.IO
import org.javatuples.Pair
import java.util.stream.Collectors
/**
* @author Stephen Mallette (http://stephen.genoprime.com)
* @author Randall Barnhart (randompi@gmail.com)
*/
class GephiRemoteAcceptor implements RemoteAcceptor {
private String host = "localhost"
private int port = 8080
private String workspace = "workspace0"
private final Groovysh shell
private final IO io
boolean traversalSubmittedForViz = false
long vizStepDelay
private float[] vizStartRGBColor
private float[] vizDefaultRGBColor
private char vizColorToFade
private float vizColorFadeRate
private float vizStartSize
private float vizSizeDecrementRate
private Map vertexAttributes = [:]
private CloseableHttpClient httpclient = HttpClients.createDefault();
public GephiRemoteAcceptor(final Groovysh shell, final IO io) {
this.shell = shell
this.io = io
// traversal visualization defaults
vizStepDelay = 1000; // 1 second pause between viz of steps
vizStartRGBColor = [0.0f, 1.0f, 0.5f] // light aqua green
vizDefaultRGBColor = [0.6f, 0.6f, 0.6f] // light grey
vizColorToFade = 'g' // will fade so blue is strongest
vizColorFadeRate = 0.7 // the multiplicative rate to fade visited vertices
vizStartSize = 20
vizSizeDecrementRate = 0.33f
}
@Override
connect(final List<String> args) throws RemoteException {
if (args.size() >= 1)
workspace = args[0]
if (args.size() >= 2)
host = args[1]
if (args.size() >= 3) {
try {
port = Integer.parseInt(args[2])
} catch (Exception ex) {
throw new RemoteException("Port must be an integer value")
}
}
def vizConfig = " with stepDelay:$vizStepDelay, startRGBColor:$vizStartRGBColor, " +
"colorToFade:$vizColorToFade, colorFadeRate:$vizColorFadeRate, startSize:$vizStartSize," +
"sizeDecrementRate:$vizSizeDecrementRate"
return "Connection to Gephi - http://$host:$port/$workspace" + vizConfig
}
@Override
Object configure(final List<String> args) throws RemoteException {
if (args.size() < 2)
throw new RemoteException("Invalid config arguments - check syntax")
if (args[0] == "host")
host = args[1]
else if (args[0] == "port") {
try {
port = Integer.parseInt(args[1])
} catch (Exception ignored) {
throw new RemoteException("Port must be an integer value")
}
} else if (args[0] == "workspace")
workspace = args[1]
else if (args[0] == "stepDelay")
parseVizStepDelay(args[1])
else if (args[0] == "startRGBColor")
parseVizStartRGBColor(args[1])
else if (args[0] == "colorToFade")
parseVizColorToFade(args[1])
else if (args[0] == "colorFadeRate")
parseVizColorFadeRate(args[1])
else if (args[0] == "sizeDecrementRate")
parseVizSizeDecrementRate(args[1])
else if (args[0] == "startSize")
parseVizStartSize(args[1])
else if (args[0] == "visualTraversal") {
def graphVar = shell.interp.context.getVariable(args[1])
if (!(graphVar instanceof Graph))
throw new RemoteException("Invalid argument to 'visualTraversal' - first parameter must be a Graph instance")
def gVar = args.size() == 3 ? args[2] : "vg"
def theG = GraphTraversalSource.build().with(new GephiTraversalVisualizationStrategy(this)).create(graphVar)
shell.interp.context.setVariable(gVar, theG)
} else
throw new RemoteException("Invalid config arguments - check syntax")
return "Connection to Gephi - http://$host:$port/$workspace" +
" with stepDelay:$vizStepDelay, startRGBColor:$vizStartRGBColor, " +
"colorToFade:$vizColorToFade, colorFadeRate:$vizColorFadeRate, startSize:$vizStartSize," +
"sizeDecrementRate:$vizSizeDecrementRate"
}
@Override
@CompileStatic
Object submit(final List<String> args) throws RemoteException {
final String line = String.join(" ", args)
if (line.trim() == "clear") {
clearGraph()
io.out.println("Gephi workspace cleared")
return
}
// need to clear the vertex attributes
vertexAttributes.clear()
// this tells the GraphTraversalVisualizationStrategy that if the line eval's to a traversal it should
// try to visualize it
traversalSubmittedForViz = true
// get the colors/sizes back to basics before trying visualize
resetColorsSizes()
final Object o = shell.execute(line)
if (o instanceof Graph) {
clearGraph()
def graph = (Graph) o
def g = graph.traversal()
g.V().sideEffect { addVertexToGephi(g, it.get()) }.iterate()
}
traversalSubmittedForViz = false
}
@Override
void close() throws IOException {
httpclient.close()
}
/**
* Visits the last set of vertices traversed and degrades their color and size.
*/
def updateVisitedVertices(def List except = []) {
vertexAttributes.keySet().findAll{ vertexId -> !except.contains(vertexId) }.each { String vertexId ->
def attrs = vertexAttributes[vertexId]
float currentColor = attrs.color
currentColor *= vizColorFadeRate
int currentSize = attrs["size"]
currentSize = Math.max(1, currentSize - (currentSize * vizSizeDecrementRate))
vertexAttributes.get(vertexId).color = currentColor
vertexAttributes.get(vertexId).size = currentSize
changeVertexAttributes(vertexId)
}
}
def touch(Vertex v) {
vertexAttributes.get(v.id().toString()).touch++
}
def applyRelativeSizingInGephi() {
def touches = vertexAttributes.values().collect{it["touch"]}
def max = touches.max()
def min = touches.min()
vertexAttributes.each { k, v ->
double touch = v.touch
// establishes the relative size of the node with a lower limit of 0.25 of vizStartSize
def relative = Math.max((touch - min) / Math.max((max - min).doubleValue(), 0.00001), 0.25)
int size = Math.max(1, vizStartSize * relative)
v.size = size
changeVertexAttributes(k)
}
}
def changeVertexAttributes(def String vertexId) {
def props = [:]
props.put(vizColorToFade.toString(), vertexAttributes[vertexId].color)
props.put("size", vertexAttributes[vertexId].size)
updateGephiGraph([cn: [(vertexId): props]])
}
/**
* Visit a vertex traversed and initialize its color and size.
*/
def visitVertexInGephi(def Vertex v, def size = vizStartSize) {
def props = [:]
props.put('r', vizStartRGBColor[0])
props.put('g', vizStartRGBColor[1])
props.put('b', vizStartRGBColor[2])
props.put('size', size)
props.put('visited', 1)
updateGephiGraph([cn: [(v.id().toString()): props]])
vertexAttributes[v.id().toString()] = [color: vizStartRGBColor[fadeColorIndex()], size: size, touch: 1]
}
def visitEdgeInGephi(def Edge e) {
def props = [:]
props.put('r', vizStartRGBColor[0])
props.put('g', vizStartRGBColor[1])
props.put('b', vizStartRGBColor[2])
props.put('size', vizStartSize)
props.put('visited', 1)
updateGephiGraph([ce: [(e.id().toString()): props]])
}
def fadeColorIndex() {
if (vizColorToFade == 'r')
return 0
else if (vizColorToFade == 'g')
return 1
else if (vizColorToFade == 'b')
return 2
}
def addVertexToGephi(def GraphTraversalSource g, def Vertex v, def boolean ignoreEdges = false) {
// grab the first property value from the strategies of values
def props = g.V(v).valueMap().next().collectEntries { kv -> [(kv.key): kv.value[0]] }
props << [label: v.label()]
props.put('r', vizDefaultRGBColor[0])
props.put('g', vizDefaultRGBColor[1])
props.put('b', vizDefaultRGBColor[2])
props.put('visited', 0)
// only add if it does not exist in graph already
if (!getFromGephiGraph([operation: "getNode", id: v.id().toString()]).isPresent())
updateGephiGraph([an: [(v.id().toString()): props]])
if (!ignoreEdges) {
g.V(v).outE().sideEffect {
addEdgeToGephi(g, it.get())
}.iterate()
}
}
@CompileStatic
def addEdgeToGephi(def GraphTraversalSource g, def Edge e) {
def props = g.E(e).valueMap().next()
props.put('label', e.label())
props.put('source', e.outVertex().id().toString())
props.put('target', e.inVertex().id().toString())
props.put('directed', true)
props.put('visited', 0)
// make sure the in vertex is there but don't add its edges - that will happen later as we are looping
// all vertices in the graph
addVertexToGephi(g, e.inVertex(), true)
// both vertices are definitely there now, so add the edge
updateGephiGraph([ae: [(e.id().toString()): props]])
}
def clearGraph() {
updateGephiGraph([dn: [filter: "ALL"]])
}
def resetColorsSizes() {
updateGephiGraph([cn: [filter: [nodeAttribute: [attribute: "visited", value: 1]],
attributes: [size: 1, r: vizDefaultRGBColor[0], g: vizDefaultRGBColor[1], b: vizDefaultRGBColor[2]]]])
updateGephiGraph([ce: [filter: [edgeAttribute: [attribute: "visited", value: 1]],
attributes: [size: 1, r: vizDefaultRGBColor[0], g: vizDefaultRGBColor[1], b: vizDefaultRGBColor[2]]]])
}
def getFromGephiGraph(def Map queryArgs) {
def requestBuilder = RequestBuilder.get("http://$host:$port/$workspace")
queryArgs.each { requestBuilder = requestBuilder.addParameter(it.key, it.value) }
def resp = EntityUtils.toString(httpclient.execute(requestBuilder.build()).entity)
// gephi streaming plugin does not set the content type or respect the Accept header - treat as text
if (resp.isEmpty())
return Optional.empty()
else
return Optional.of(new JsonSlurper().parseText(resp))
}
def updateGephiGraph(def Map postBody) {
def requestBuilder = RequestBuilder.post("http://$host:$port/$workspace")
.addParameter("format", "JSON")
.addParameter("operation", "updateGraph")
.setEntity(new StringEntity(JsonOutput.toJson(postBody)))
EntityUtils.consume(httpclient.execute(requestBuilder.build()).entity)
}
@Override
public String toString() {
return "Gephi - [$workspace]"
}
private void parseVizStepDelay(String arg) {
try {
vizStepDelay = Long.parseLong(arg)
} catch (Exception ignored) {
throw new RemoteException("The stepDelay must be a long value")
}
}
private void parseVizStartRGBColor(String arg) {
try {
vizStartRGBColor = arg[1..-2].tokenize(',')*.toFloat()
assert (vizStartRGBColor.length == 3)
} catch (Exception ignored) {
throw new RemoteException("The vizStartRGBColor must be an array of 3 float values, e.g. [0.0,1.0,0.5]")
}
}
private void parseVizColorToFade(String arg) {
try {
vizColorToFade = arg.charAt(0).toLowerCase();
assert (vizColorToFade == 'r' || vizColorToFade == 'g' || vizColorToFade == 'b')
} catch (Exception ignored) {
throw new RemoteException("The vizColorToFade must be one character value among: r, g, b, R, G, B")
}
}
private void parseVizColorFadeRate(String arg) {
try {
vizColorFadeRate = Float.parseFloat(arg)
} catch (Exception ignored) {
throw new RemoteException("The colorFadeRate must be a float value")
}
}
private void parseVizSizeDecrementRate(String arg) {
try {
vizSizeDecrementRate = Float.parseFloat(arg)
} catch (Exception ignored) {
throw new RemoteException("The sizeDecrementRate must be a float value")
}
}
private void parseVizStartSize(String arg) {
try {
vizStartSize = Float.parseFloat(arg)
} catch (Exception ignored) {
throw new RemoteException("The startSize must be a float value")
}
}
}