import cytoscape from "cytoscape"; import { isEqual } from "lodash"; import * as React from "react"; import ReactDOM from "react-dom"; import styled from "styled-components"; import tippy, { Instance } from "tippy.js"; import { DEFAULT_NODE_COLOR, HOVERED_NODE_COLOR, QUALITATIVE_COLOR_SCHEME, QUANTITATIVE_COLOR_SCHEME, SEARCH_RESULT_COLOR, SELECTED_NODE_COLOR } from "../../constants"; import { IColorScheme } from "../../types"; import { getBuckets, getTypeDisplayString } from "../../util"; const CytoscapeContainer = styled.div` width: 100%; height: 100%; flex: 1; `; interface ICytoscapeProps { colorScheme?: IColorScheme; currentNodeId: string | null; elements: cytoscape.ElementsDefinition; hoveringOver?: string; ranges?: { [key: string]: [number, number] }; searchResultIds?: string[]; showEdges: boolean; navigateToInstancePath?: (domain: string) => void; navigateToRoot?: () => void; } class Cytoscape extends React.PureComponent { private cy?: cytoscape.Core; public componentDidMount() { const container = ReactDOM.findDOMNode(this); this.cy = cytoscape({ autoungrabify: true, container: container as any, elements: this.props.elements, hideEdgesOnViewport: true, hideLabelsOnViewport: true, layout: { name: "preset" }, maxZoom: 2, minZoom: 0.01, pixelRatio: 1.0, selectionType: "single" }); // Setup node tooltip on hover this.cy.nodes().forEach(n => { const tooltipContent = `${n.data("id")} (${getTypeDisplayString(n.data("type"))})`; const ref = (n as any).popperRef(); const t = tippy(ref, { animateFill: false, animation: "fade", content: tooltipContent, 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, "font-size": "mapData(size, 1, 6, 10, 100)", "min-zoomed-font-size": 16 }) .selector(".hidden") // used to hide nodes not in the neighborhood of the selected, or to hide edges .style({ display: "none" }) .selector(".thickEdge") // when a node is selected, make edges thicker so you can actually see them .style({ width: 2 }); this.resetNodeColorScheme(style); // this function also called `update()` this.cy.nodes().on("select", e => { const instanceId = e.target.data("id"); if (instanceId && instanceId !== this.props.currentNodeId) { if (this.props.navigateToInstancePath) { this.props.navigateToInstancePath(instanceId); } } 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; if (!target || target === this.cy || target.isEdge()) { if (this.props.navigateToRoot) { // Go to the URL "/" this.props.navigateToRoot(); } } }); this.setNodeSelection(); } public componentDidUpdate(prevProps: ICytoscapeProps) { this.setNodeSelection(prevProps.currentNodeId); if (prevProps.colorScheme !== this.props.colorScheme) { this.updateColorScheme(); } if (prevProps.hoveringOver !== this.props.hoveringOver) { this.updateHoveredNodeClass(prevProps.hoveringOver); } if (!isEqual(prevProps.searchResultIds, this.props.searchResultIds)) { this.updateSearchResultNodeClass(); } if (prevProps.showEdges !== this.props.showEdges) { if (this.props.showEdges) { this.showEdges(); } else { this.hideEdges(); } } } public componentWillUnmount() { if (this.cy) { this.cy.destroy(); } } public render() { return ; } public resetGraphPosition() { if (!this.cy) { throw new Error("Expected cytoscape, but there wasn't one!"); } 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) { throw new Error("Expected cytoscape, but there wasn't one!"); } if (prevNodeId) { this.cy.$id(prevNodeId).unselect(); } const { currentNodeId } = this.props; if (currentNodeId) { // Select instance this.cy.$id(currentNodeId).select(); // Center it const selected = this.cy.$id(currentNodeId); this.cy.center(selected); } }; /** * 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) => { 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) => { if (!style) { style = this.cy!.style() as any; } style .selector("node.searchResult") .style({ "background-color": SEARCH_RESULT_COLOR, "border-color": SEARCH_RESULT_COLOR, "border-opacity": 0.7, "border-width": 250 }) .selector("node.hovered") .style({ "border-color": HOVERED_NODE_COLOR, "border-width": 1000 }) .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(); return; } else if (colorScheme.type === "qualitative") { colorScheme.values.forEach((v, idx) => { style = style.selector(`node[${colorScheme.cytoscapeDataKey} = '${v}']`).style({ "background-color": QUALITATIVE_COLOR_SCHEME[idx] }); }); } 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); 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 }); }); } this.setNodeSearchColorScheme(style); }; /** * 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"); } }); }; 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"); }; } export default Cytoscape;