2019-07-19 18:19:53 +00:00
|
|
|
import cytoscape from "cytoscape";
|
2019-07-26 22:30:11 +00:00
|
|
|
import { isEqual } from "lodash";
|
2019-07-19 18:19:53 +00:00
|
|
|
import * as React from "react";
|
|
|
|
import ReactDOM from "react-dom";
|
|
|
|
import styled from "styled-components";
|
|
|
|
import tippy, { Instance } from "tippy.js";
|
2019-07-26 22:30:11 +00:00
|
|
|
import {
|
|
|
|
DEFAULT_NODE_COLOR,
|
|
|
|
HOVERED_NODE_COLOR,
|
|
|
|
QUALITATIVE_COLOR_SCHEME,
|
2019-07-27 17:58:40 +00:00
|
|
|
QUANTITATIVE_COLOR_SCHEME,
|
2019-07-26 22:30:11 +00:00
|
|
|
SEARCH_RESULT_COLOR,
|
|
|
|
SELECTED_NODE_COLOR
|
|
|
|
} from "../../constants";
|
2019-07-27 17:58:40 +00:00
|
|
|
import { IColorScheme } from "../../types";
|
2019-08-18 15:16:01 +00:00
|
|
|
import { getBuckets, getTypeDisplayString } from "../../util";
|
2019-07-21 18:05:07 +00:00
|
|
|
|
|
|
|
const CytoscapeContainer = styled.div`
|
|
|
|
width: 100%;
|
|
|
|
height: 100%;
|
2019-07-24 09:43:36 +00:00
|
|
|
flex: 1;
|
2019-07-19 18:19:53 +00:00
|
|
|
`;
|
|
|
|
|
|
|
|
interface ICytoscapeProps {
|
2019-07-27 17:58:40 +00:00
|
|
|
colorScheme?: IColorScheme;
|
2019-07-21 18:05:07 +00:00
|
|
|
currentNodeId: string | null;
|
2019-07-19 18:19:53 +00:00
|
|
|
elements: cytoscape.ElementsDefinition;
|
2019-07-26 22:30:11 +00:00
|
|
|
hoveringOver?: string;
|
2019-07-27 17:58:40 +00:00
|
|
|
ranges?: { [key: string]: [number, number] };
|
2019-07-26 22:30:11 +00:00
|
|
|
searchResultIds?: string[];
|
2019-08-04 12:41:44 +00:00
|
|
|
showEdges: boolean;
|
2019-07-23 16:32:43 +00:00
|
|
|
navigateToInstancePath?: (domain: string) => void;
|
|
|
|
navigateToRoot?: () => void;
|
2019-07-19 18:19:53 +00:00
|
|
|
}
|
2019-07-24 15:51:44 +00:00
|
|
|
class Cytoscape extends React.PureComponent<ICytoscapeProps> {
|
2019-07-21 18:05:07 +00:00
|
|
|
private cy?: cytoscape.Core;
|
|
|
|
|
2019-07-19 18:19:53 +00:00
|
|
|
public componentDidMount() {
|
|
|
|
const container = ReactDOM.findDOMNode(this);
|
|
|
|
this.cy = cytoscape({
|
|
|
|
autoungrabify: true,
|
|
|
|
container: container as any,
|
2019-11-21 19:44:27 +00:00
|
|
|
elements: this.cleanElements(this.props.elements),
|
2019-07-19 18:19:53 +00:00
|
|
|
hideEdgesOnViewport: true,
|
|
|
|
hideLabelsOnViewport: true,
|
|
|
|
layout: {
|
|
|
|
name: "preset"
|
|
|
|
},
|
|
|
|
maxZoom: 2,
|
2019-08-09 17:50:12 +00:00
|
|
|
minZoom: 0.01,
|
2019-07-19 18:19:53 +00:00
|
|
|
pixelRatio: 1.0,
|
|
|
|
selectionType: "single"
|
|
|
|
});
|
|
|
|
|
|
|
|
// Setup node tooltip on hover
|
|
|
|
this.cy.nodes().forEach(n => {
|
2019-08-18 15:16:01 +00:00
|
|
|
const tooltipContent = `${n.data("id")} (${getTypeDisplayString(n.data("type"))})`;
|
2019-07-19 18:19:53 +00:00
|
|
|
const ref = (n as any).popperRef();
|
|
|
|
const t = tippy(ref, {
|
|
|
|
animateFill: false,
|
|
|
|
animation: "fade",
|
2019-08-18 15:16:01 +00:00
|
|
|
content: tooltipContent,
|
2019-07-19 18:19:53 +00:00
|
|
|
duration: 100,
|
|
|
|
trigger: "manual"
|
|
|
|
});
|
|
|
|
n.on("mouseover", e => {
|
|
|
|
(t as Instance).show();
|
|
|
|
});
|
|
|
|
n.on("mouseout", e => {
|
|
|
|
(t as Instance).hide();
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
const style = this.cy.style() as any;
|
|
|
|
style
|
|
|
|
.clear()
|
|
|
|
.selector("edge")
|
|
|
|
.style({
|
|
|
|
"curve-style": "haystack", // fast edges
|
|
|
|
"line-color": DEFAULT_NODE_COLOR,
|
|
|
|
width: "mapData(weight, 0, 0.5, 1, 20)"
|
|
|
|
})
|
|
|
|
.selector("node[label]")
|
|
|
|
.style({
|
|
|
|
color: DEFAULT_NODE_COLOR,
|
2019-07-21 19:56:17 +00:00
|
|
|
"font-size": "mapData(size, 1, 6, 10, 100)",
|
2019-07-19 18:19:53 +00:00
|
|
|
"min-zoomed-font-size": 16
|
|
|
|
})
|
2019-08-04 12:41:44 +00:00
|
|
|
.selector(".hidden") // used to hide nodes not in the neighborhood of the selected, or to hide edges
|
2019-07-19 18:19:53 +00:00
|
|
|
.style({
|
|
|
|
display: "none"
|
|
|
|
})
|
2019-07-21 18:05:07 +00:00
|
|
|
.selector(".thickEdge") // when a node is selected, make edges thicker so you can actually see them
|
2019-07-19 18:19:53 +00:00
|
|
|
.style({
|
|
|
|
width: 2
|
2019-07-24 15:51:44 +00:00
|
|
|
});
|
|
|
|
this.resetNodeColorScheme(style); // this function also called `update()`
|
2019-07-19 18:19:53 +00:00
|
|
|
|
|
|
|
this.cy.nodes().on("select", e => {
|
|
|
|
const instanceId = e.target.data("id");
|
2019-07-21 18:05:07 +00:00
|
|
|
if (instanceId && instanceId !== this.props.currentNodeId) {
|
2019-07-23 16:32:43 +00:00
|
|
|
if (this.props.navigateToInstancePath) {
|
|
|
|
this.props.navigateToInstancePath(instanceId);
|
|
|
|
}
|
2019-07-19 18:19:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
const neighborhood = this.cy!.$id(instanceId).closedNeighborhood();
|
|
|
|
// Reset graph visibility
|
|
|
|
this.cy!.batch(() => {
|
|
|
|
this.cy!.nodes().removeClass("hidden");
|
|
|
|
this.cy!.edges().removeClass("thickEdge");
|
|
|
|
// Then hide everything except neighborhood
|
|
|
|
this.cy!.nodes()
|
|
|
|
.diff(neighborhood)
|
|
|
|
.left.addClass("hidden");
|
|
|
|
neighborhood.connectedEdges().addClass("thickEdge");
|
|
|
|
});
|
|
|
|
});
|
|
|
|
this.cy.nodes().on("unselect", e => {
|
|
|
|
this.cy!.batch(() => {
|
|
|
|
this.cy!.nodes().removeClass("hidden");
|
|
|
|
this.cy!.edges().removeClass("thickEdge");
|
|
|
|
});
|
|
|
|
});
|
|
|
|
this.cy.on("click", e => {
|
|
|
|
// Clicking on the background should also deselect
|
|
|
|
const target = e.target;
|
2019-07-21 18:05:07 +00:00
|
|
|
if (!target || target === this.cy || target.isEdge()) {
|
2019-07-23 16:32:43 +00:00
|
|
|
if (this.props.navigateToRoot) {
|
|
|
|
// Go to the URL "/"
|
|
|
|
this.props.navigateToRoot();
|
|
|
|
}
|
2019-07-19 18:19:53 +00:00
|
|
|
}
|
|
|
|
});
|
2019-07-21 18:05:07 +00:00
|
|
|
|
|
|
|
this.setNodeSelection();
|
|
|
|
}
|
|
|
|
|
|
|
|
public componentDidUpdate(prevProps: ICytoscapeProps) {
|
|
|
|
this.setNodeSelection(prevProps.currentNodeId);
|
2019-07-24 15:51:44 +00:00
|
|
|
if (prevProps.colorScheme !== this.props.colorScheme) {
|
|
|
|
this.updateColorScheme();
|
|
|
|
}
|
2019-07-26 22:30:11 +00:00
|
|
|
if (prevProps.hoveringOver !== this.props.hoveringOver) {
|
|
|
|
this.updateHoveredNodeClass(prevProps.hoveringOver);
|
|
|
|
}
|
|
|
|
if (!isEqual(prevProps.searchResultIds, this.props.searchResultIds)) {
|
|
|
|
this.updateSearchResultNodeClass();
|
|
|
|
}
|
2019-08-04 12:41:44 +00:00
|
|
|
if (prevProps.showEdges !== this.props.showEdges) {
|
|
|
|
if (this.props.showEdges) {
|
|
|
|
this.showEdges();
|
|
|
|
} else {
|
|
|
|
this.hideEdges();
|
|
|
|
}
|
|
|
|
}
|
2019-07-19 18:19:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public componentWillUnmount() {
|
|
|
|
if (this.cy) {
|
|
|
|
this.cy.destroy();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
public render() {
|
2019-07-21 18:05:07 +00:00
|
|
|
return <CytoscapeContainer />;
|
2019-07-19 18:19:53 +00:00
|
|
|
}
|
2019-07-21 18:05:07 +00:00
|
|
|
|
|
|
|
public resetGraphPosition() {
|
|
|
|
if (!this.cy) {
|
2019-07-23 12:20:34 +00:00
|
|
|
throw new Error("Expected cytoscape, but there wasn't one!");
|
2019-07-21 18:05:07 +00:00
|
|
|
}
|
|
|
|
const { currentNodeId } = this.props;
|
|
|
|
if (currentNodeId) {
|
|
|
|
this.cy.zoom({
|
|
|
|
level: 0.2,
|
|
|
|
position: this.cy.$id(currentNodeId).position()
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
this.cy.zoom({
|
|
|
|
level: 0.2,
|
|
|
|
position: { x: 0, y: 0 }
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Updates cytoscape's internal state to match our props.
|
|
|
|
*/
|
|
|
|
private setNodeSelection = (prevNodeId?: string | null) => {
|
|
|
|
if (!this.cy) {
|
2019-07-23 12:20:34 +00:00
|
|
|
throw new Error("Expected cytoscape, but there wasn't one!");
|
2019-07-21 18:05:07 +00:00
|
|
|
}
|
|
|
|
if (prevNodeId) {
|
|
|
|
this.cy.$id(prevNodeId).unselect();
|
|
|
|
}
|
|
|
|
|
|
|
|
const { currentNodeId } = this.props;
|
|
|
|
if (currentNodeId) {
|
|
|
|
// Select instance
|
|
|
|
this.cy.$id(currentNodeId).select();
|
|
|
|
// Center it
|
2019-08-22 09:15:56 +00:00
|
|
|
const selected = this.cy.$id(currentNodeId);
|
|
|
|
this.cy.center(selected);
|
2019-07-21 18:05:07 +00:00
|
|
|
}
|
|
|
|
};
|
2019-07-24 15:51:44 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Set the color scheme to the default. A style object can optionally be passed.
|
|
|
|
* This is used during initilization to avoid having to do multiple renderings of the graph.
|
|
|
|
*/
|
|
|
|
private resetNodeColorScheme = (style?: any) => {
|
2019-07-26 22:30:11 +00:00
|
|
|
if (!style) {
|
|
|
|
style = this.cy!.style() as any;
|
|
|
|
}
|
|
|
|
style = style.selector("node").style({
|
|
|
|
"background-color": DEFAULT_NODE_COLOR,
|
|
|
|
// The size from the backend is log_10(userCount), which from 10 <= userCount <= 1,000,000 gives us the range
|
|
|
|
// 1-6. We map this to the range of sizes we want.
|
|
|
|
// TODO: I should probably check that that the backend is actually using log_10 and not log_e, but it look
|
|
|
|
// quite good as it is, so...
|
|
|
|
height: "mapData(size, 1, 6, 20, 200)",
|
|
|
|
label: "data(id)",
|
|
|
|
width: "mapData(size, 1, 6, 20, 200)"
|
|
|
|
});
|
|
|
|
|
|
|
|
this.setNodeSearchColorScheme(style);
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* We always want to set node search/hover styles at the end of a style change to make sure they don't get overwritten.
|
|
|
|
*/
|
|
|
|
private setNodeSearchColorScheme = (style?: any) => {
|
2019-07-24 15:51:44 +00:00
|
|
|
if (!style) {
|
|
|
|
style = this.cy!.style() as any;
|
|
|
|
}
|
|
|
|
style
|
2019-07-26 22:30:11 +00:00
|
|
|
.selector("node.searchResult")
|
|
|
|
.style({
|
|
|
|
"background-color": SEARCH_RESULT_COLOR,
|
|
|
|
"border-color": SEARCH_RESULT_COLOR,
|
|
|
|
"border-opacity": 0.7,
|
|
|
|
"border-width": 250
|
|
|
|
})
|
|
|
|
.selector("node.hovered")
|
2019-07-24 15:51:44 +00:00
|
|
|
.style({
|
2019-07-26 22:30:11 +00:00
|
|
|
"border-color": HOVERED_NODE_COLOR,
|
|
|
|
"border-width": 1000
|
2019-07-24 15:51:44 +00:00
|
|
|
})
|
|
|
|
.selector("node:selected")
|
|
|
|
.style({
|
|
|
|
"background-color": SELECTED_NODE_COLOR
|
|
|
|
})
|
|
|
|
.update();
|
|
|
|
};
|
|
|
|
|
|
|
|
private updateColorScheme = () => {
|
|
|
|
if (!this.cy) {
|
|
|
|
throw new Error("Expected cytoscape, but there wasn't one!");
|
|
|
|
}
|
|
|
|
const { colorScheme } = this.props;
|
|
|
|
let style = this.cy.style() as any;
|
|
|
|
if (!colorScheme) {
|
|
|
|
this.resetNodeColorScheme();
|
2019-07-27 17:58:40 +00:00
|
|
|
return;
|
|
|
|
} else if (colorScheme.type === "qualitative") {
|
2019-07-24 15:51:44 +00:00
|
|
|
colorScheme.values.forEach((v, idx) => {
|
|
|
|
style = style.selector(`node[${colorScheme.cytoscapeDataKey} = '${v}']`).style({
|
|
|
|
"background-color": QUALITATIVE_COLOR_SCHEME[idx]
|
|
|
|
});
|
|
|
|
});
|
2019-07-27 17:58:40 +00:00
|
|
|
} else if (colorScheme.type === "quantitative") {
|
|
|
|
const dataKey = colorScheme.cytoscapeDataKey;
|
|
|
|
if (!this.props.ranges || !this.props.ranges[dataKey]) {
|
|
|
|
throw new Error("Expected a range but did not receive one!");
|
|
|
|
}
|
|
|
|
// Create buckets for the range and corresponding classes
|
|
|
|
const [minVal, maxVal] = this.props.ranges[dataKey];
|
|
|
|
const buckets = getBuckets(minVal, maxVal, QUANTITATIVE_COLOR_SCHEME.length, colorScheme.exponential);
|
2019-07-26 22:30:11 +00:00
|
|
|
|
2019-07-27 17:58:40 +00:00
|
|
|
QUANTITATIVE_COLOR_SCHEME.forEach((color, idx) => {
|
|
|
|
const min = buckets[idx];
|
|
|
|
// Make sure the max value is also included in a bucket!
|
|
|
|
const max = idx === QUANTITATIVE_COLOR_SCHEME.length - 1 ? maxVal + 1 : buckets[idx + 1];
|
|
|
|
const selector = `node[${dataKey} >= ${min}][${dataKey} < ${max}]`;
|
|
|
|
style = style.selector(selector).style({
|
|
|
|
"background-color": color
|
|
|
|
});
|
|
|
|
});
|
2019-07-24 15:51:44 +00:00
|
|
|
}
|
2019-07-27 17:58:40 +00:00
|
|
|
this.setNodeSearchColorScheme(style);
|
2019-07-24 15:51:44 +00:00
|
|
|
};
|
2019-07-26 22:30:11 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* This function sets the hover class on the node that's currently being hovered over in the search results
|
|
|
|
* (and removes it from the previous one if there was one).
|
|
|
|
*
|
|
|
|
* We explicitly pass the ID of the previously hovered node, rather than just using a class selector.
|
|
|
|
* This is because lookups by ID are significantly faster than class selectors.
|
|
|
|
*/
|
|
|
|
private updateHoveredNodeClass = (prevHoveredId?: string) => {
|
|
|
|
if (!this.cy) {
|
|
|
|
throw new Error("Expected cytoscape, but there wasn't one!");
|
|
|
|
}
|
|
|
|
const { hoveringOver } = this.props;
|
|
|
|
|
|
|
|
if (!!prevHoveredId) {
|
|
|
|
this.cy.$id(prevHoveredId).removeClass("hovered");
|
|
|
|
}
|
|
|
|
if (!!hoveringOver) {
|
|
|
|
this.cy.$id(hoveringOver).addClass("hovered");
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
private updateSearchResultNodeClass = () => {
|
|
|
|
if (!this.cy) {
|
|
|
|
throw new Error("Expected cytoscape, but there wasn't one!");
|
|
|
|
}
|
|
|
|
const { searchResultIds } = this.props;
|
|
|
|
|
|
|
|
this.cy.batch(() => {
|
|
|
|
this.cy!.nodes().removeClass("searchResult");
|
|
|
|
|
|
|
|
if (!!searchResultIds && searchResultIds.length > 0) {
|
|
|
|
const currentResultSelector = searchResultIds.map(id => `node[id = "${id}"]`).join(", ");
|
|
|
|
this.cy!.$(currentResultSelector).addClass("searchResult");
|
|
|
|
}
|
|
|
|
});
|
|
|
|
};
|
2019-08-04 12:41:44 +00:00
|
|
|
|
|
|
|
private showEdges = () => {
|
|
|
|
if (!this.cy) {
|
|
|
|
throw new Error("Expected cytoscape, but there wasn't one!");
|
|
|
|
}
|
|
|
|
this.cy.edges().removeClass("hidden");
|
|
|
|
};
|
|
|
|
|
|
|
|
private hideEdges = () => {
|
|
|
|
if (!this.cy) {
|
|
|
|
throw new Error("Expected cytoscape, but there wasn't one!");
|
|
|
|
}
|
|
|
|
this.cy.edges().addClass("hidden");
|
|
|
|
};
|
2019-11-21 19:44:27 +00:00
|
|
|
|
|
|
|
/* Helper function to remove edges if source or target node is missing */
|
|
|
|
private cleanElements = (elements: cytoscape.ElementsDefinition): cytoscape.ElementsDefinition => {
|
|
|
|
const domains = new Set(elements.nodes.map(n => n.data.id));
|
|
|
|
const edges = elements.edges.filter(e => domains.has(e.data.source) && domains.has(e.data.target));
|
|
|
|
return {
|
|
|
|
edges,
|
|
|
|
nodes: elements.nodes
|
|
|
|
};
|
|
|
|
};
|
2019-07-19 18:19:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
export default Cytoscape;
|