import { IGraph, ITooltip } from "@hpcc-js/api";
import { ISize, Platform, Spacer, SVGGlowFilter, SVGZoomWidget, ToggleButton, Utility, Widget } from "@hpcc-js/common";
import { drag as d3Drag } from "d3-drag";
import { event as d3Event, select as d3Select } from "d3-selection";
import "d3-transition";
import { Edge } from "./Edge";
import { GraphData } from "./GraphData";
import * as GraphLayouts from "./GraphLayouts";
import { Subgraph } from "./Subgraph";
import { Vertex } from "./Vertex";
import "../src/Graph.css";
export interface Lineage {
parent: Widget;
child: Widget;
}
export interface IGraphData {
subgraphs?: Widget[];
vertices: Widget[];
edges: Edge[];
hierarchy?: Lineage[];
}
export type GraphLayoutType = "Hierarchy" | "ForceDirected" | "ForceDirected2" | "Circle" | "None";
export class Graph extends SVGZoomWidget {
static Subgraph = Subgraph;
static Vertex = Vertex;
static Edge = Edge;
private _toggleHierarchy = new ToggleButton().faChar("fa-sitemap").tooltip("Hierarchy").on("click", () => this.layoutClick("Hierarchy"));
private _toggleForceDirected = new ToggleButton().faChar("fa-expand").tooltip("Force Directed").on("click", () => this.layoutClick("ForceDirected"));
private _toggleForceDirected2 = new ToggleButton().faChar("fa-arrows").tooltip("Spring").on("click", () => this.layoutClick("ForceDirected2"));
private _toggleCircle = new ToggleButton().faChar("fa-circle-o").tooltip("Circle").on("click", () => this.layoutClick("Circle"));
private _graphData: GraphData;
protected highlight;
protected _selection;
protected _dragging;
protected forceLayout;
protected _d3Drag;
protected defs;
protected _centroidFilter: SVGGlowFilter;
protected svgFragment;
protected svg;
protected svgC;
protected svgE;
protected svgV;
constructor() {
super();
IGraph.call(this);
ITooltip.call(this);
this.tooltipHTML(function (d: any) {
let content;
if (d instanceof Subgraph) {
content = d.title().replace("\n", "
");
} else if (d instanceof Vertex || d instanceof Edge) {
content = d.text().replace("\n", "
");
}
if (content) {
return `
${content}
`; } return null; }); this._drawStartPos = "origin"; const buttons: Widget[] = [ this._toggleHierarchy, this._toggleForceDirected, this._toggleForceDirected2, this._toggleCircle, new Spacer()]; this._iconBar.buttons(buttons.concat(this._iconBar.buttons())); this._graphData = new GraphData(); this.highlight = { zoom: 1.1, opacity: 0.33, edge: "1.25px" }; this._selection = new Utility.Selection(this); this.zoomToFitLimit(1); } iconBarButtons(): Widget[] { return this._iconBar.buttons(); } layoutClick(layout: GraphLayoutType) { this.layout(layout); if (layout !== "ForceDirected2") this.applyScaleOnLayout(true); this .layout(layout) .render(w => { this.applyScaleOnLayout(false); }); } // Properties --- getOffsetPos() { return { x: 0, y: 0 }; } size(): ISize; size(_): this; size(_?): ISize | this { const retVal = super.size.apply(this, arguments); return retVal; } clear() { this.data({ subgraphs: [], vertices: [], edges: [], hierarchy: [] }, false); } _dataHash = 0; data(): IGraphData; data(_: IGraphData, merge?: boolean): this; data(_?: IGraphData, merge?: boolean): IGraphData | this { const retVal = super.data.apply(this, arguments); if (arguments.length) { if (!merge) { this._graphData = new GraphData(); this._renderCount = 0; } const data = this._graphData.setData(_.subgraphs || [], _.vertices || [], _.edges || [], _.hierarchy || [], merge || false); if (data.addedVertices.length) { this._dataHash++; } const context = this; data.addedVertices.forEach(function (item) { item._graphID = context._id; }); data.addedEdges.forEach(function (item) { item._graphID = context._id; }); // Recalculate edge arcs --- const dupMap = {}; this._graphData.edges().forEach(function (item) { if (!dupMap[item._sourceVertex._id]) { dupMap[item._sourceVertex._id] = {}; } if (!dupMap[item._sourceVertex._id][item._targetVertex._id]) { dupMap[item._sourceVertex._id][item._targetVertex._id] = 0; } const dupEdgeCount = ++dupMap[item._sourceVertex._id][item._targetVertex._id]; item.arcDepth(16 * dupEdgeCount); }); } return retVal; } graphData(): any { return this._graphData; } selection(_: Widget[]): this; selection(): Widget[]; selection(_?: Widget[]): Widget[] | this { if (!arguments.length) return this._selection.get(); this._selection.set(_); return this; } private _linkcolor: string; linkcolor_default(): string; linkcolor_default(_: string): this; linkcolor_default(_?: string): string | this { if (!arguments.length) return this._linkcolor; this._linkcolor = _; return this; } private _linktooltip: string; linktooltip_default(): string; linktooltip_default(_: string): this; linktooltip_default(_?: string): string | this { if (!arguments.length) return this._linktooltip; this._linktooltip = _; return this; } // Drag --- private _neighborOffsets: Array<{ neighbor: Vertex, offsetX: number, offsetY: number }> = []; dragstart(d) { if (this.allowDragging()) { d3Event.sourceEvent.stopPropagation(); d.__drag_dx = d3Event.x - d.x(); d.__drag_dy = d3Event.y - d.y(); this._dragging = true; if (this.forceLayout) { if (!d3Event.active) this.forceLayout.force.alphaTarget(0.3).restart(); const forceNode = this.forceLayout.vertexMap[d.id()]; forceNode.fixed = true; forceNode.fx = forceNode.x; forceNode.fy = forceNode.y; } this._neighborOffsets = []; if (this.dragSingleNeighbors()) { this._neighborOffsets = this._graphData.singleNeighbors(d.id()).map((neighbor: any) => { d3Select(neighbor.target()).raise(); return { neighbor, offsetX: d.x() - neighbor.x(), offsetY: d.y() - neighbor.y() }; }); } // Safe Raise - does not interfere with current click event --- const target = d.target(); let nextSibling = target.nextSibling; while (nextSibling) { target.parentNode.insertBefore(nextSibling, target); nextSibling = target.nextSibling; } if (Platform.svgMarkerGlitch) { this._graphData.nodeEdges(d.id()).forEach(glEdge => { const edge = this._graphData.edge(glEdge); this._pushMarkers(edge.element()); }); } } } dragging(d) { if (this.allowDragging()) { d3Event.sourceEvent.stopPropagation(); d.move({ x: d3Event.x - d.__drag_dx, y: d3Event.y - d.__drag_dy }); if (this.forceLayout) { const forceNode = this.forceLayout.vertexMap[d.id()]; forceNode.fixed = true; forceNode.fx = d3Event.x - d.__drag_dx; forceNode.fy = d3Event.y - d.__drag_dy; } // Drag singleton child nodes this._neighborOffsets.forEach(neighborOffset => { const neighborX = d3Event.x - d.__drag_dx - neighborOffset.offsetX; const neighborY = d3Event.y - d.__drag_dy - neighborOffset.offsetY; if (this.forceLayout) { const forceNode = this.forceLayout.vertexMap[neighborOffset.neighbor.id()]; forceNode.fixed = true; forceNode.fx = neighborX; forceNode.fy = neighborY; } neighborOffset.neighbor.move({ x: neighborX, y: neighborY }); }); this.refreshIncidentEdges(d, true); } } dragend(d) { if (this.allowDragging()) { d3Event.sourceEvent.stopPropagation(); this._dragging = false; if (this.snapToGrid()) { const snapLoc = d.calcSnap(this.snapToGrid()); d.move(snapLoc[0]); this.refreshIncidentEdges(d, true); } if (this.forceLayout) { const forceNode = this.forceLayout.vertexMap[d.id()]; forceNode.fixed = false; forceNode.fx = null; forceNode.fy = null; this._neighborOffsets.forEach(neighborOffset => { const forceNode = this.forceLayout.vertexMap[neighborOffset.neighbor.id()]; forceNode.fixed = false; forceNode.fx = null; forceNode.fy = null; }); } this._neighborOffsets = []; if (Platform.svgMarkerGlitch) { this._graphData.nodeEdges(d.id()).forEach(function (id) { const edge = this._graphData.edge(id); this._popMarkers(edge.element()); }); } } } enter(domNode, element) { super.enter(domNode, element); this._zoomGrab.on("click.clear", () => { if (this.selectionClearOnBackgroundClick()) { this._selection.clear(); } }); this._d3Drag = d3Drag() // .origin(function (d) { // return d.pos(); // }) .on("start", d => this.dragstart(d)) .on("end", d => this.dragend(d)) .on("drag", d => this.dragging(d)) ; // SVG --- this.defs = this._renderElement.append("defs"); this.addMarkers(); this._centroidFilter = new SVGGlowFilter(this.defs, this._id + "_glow"); // element.call(this.zoom); this.svg = this._renderElement.append("svg:g"); // this._svgBrush = this.svg.append("g").attr("class", "selectionBrush").call(this.brush); // this._svgBrush.select(".background").style("cursor", null); // context._svgBrush.call(context.brush.clear()); this.svgC = this.svg.append("g").attr("id", this._id + "C"); this.svgE = this.svg.append("g").attr("id", this._id + "E"); this.svgV = this.svg.append("g").attr("id", this._id + "V"); } getBounds(items, layoutEngine?) { const vBounds = [[null, null], [null, null]]; items.forEach(function (item) { const pos = layoutEngine ? layoutEngine.nodePos(item._id) : { x: item.x(), y: item.y(), width: item.width(), height: item.height() }; const leftX = pos.x - pos.width / 2; const rightX = pos.x + pos.width / 2; const topY = pos.y - pos.height / 2; const bottomY = pos.y + pos.height / 2; if (vBounds[0][0] === null || vBounds[0][0] > leftX) { vBounds[0][0] = leftX; } if (vBounds[0][1] === null || vBounds[0][1] > topY) { vBounds[0][1] = topY; } if (vBounds[1][0] === null || vBounds[1][0] < rightX) { vBounds[1][0] = rightX; } if (vBounds[1][1] === null || vBounds[1][1] < bottomY) { vBounds[1][1] = bottomY; } }); return vBounds; } getVertexBounds(layoutEngine) { return this.getBounds(this._graphData.nodes(), layoutEngine); } getSelectionBounds(layoutEngine) { return this.getBounds(this._selection.get(), layoutEngine); } centerOn(bounds, transitionDuration?) { const x = (bounds[0][0] + bounds[1][0]) / 2; const y = (bounds[0][1] + bounds[1][1]) / 2; const translate = [x, y]; this.zoomTo(translate, 1, transitionDuration); } centerOnItem(item: Widget) { const bbox = item.getBBox(true); const deltaX = bbox.x + bbox.width / 2; const deltaY = bbox.y + bbox.height / 2; const itemBBox = { x: item.x() + deltaX - bbox.width / 2, y: item.y() + deltaY - bbox.height / 2, width: bbox.width, height: bbox.height }; this.centerOnBBox(itemBBox); } zoomToItem(item: Widget) { const bbox = item.getBBox(true); const deltaX = bbox.x + bbox.width / 2; const deltaY = bbox.y + bbox.height / 2; const itemBBox = { x: item.x() + deltaX - bbox.width / 2, y: item.y() + deltaY - bbox.height / 2, width: bbox.width, height: bbox.height }; this.zoomToBBox(itemBBox); } // Render --- updateVertices(rootElement, rootSuffix, data) { const width = this.width(); const height = this.height(); const context = this; const vertexElements = rootElement.selectAll("#" + this._id + rootSuffix + " > .graphVertex").data(data, function (d) { return d.id(); }); vertexElements.enter().append("g") .attr("class", "graphVertex") .style("opacity", 1e-6) // TODO: Events need to be optional --- .on("click.selectionBag", function (d) { context._selection.click(d, d3Event); context.selectionChanged(); }) .on("click", function (this: SVGElement, d) { const vertexElement = d3Select(this).select(".graph_Vertex"); let selected = false; if (!vertexElement.empty()) { selected = vertexElement.classed("selected"); } context.vertex_click(context.rowToObj(d.data()), "", selected, { vertex: d }); }) .on("dblclick", function (this: SVGElement, d) { const vertexElement = d3Select(this).select(".graph_Vertex"); let selected = false; if (!vertexElement.empty()) { selected = vertexElement.classed("selected"); } context.vertex_dblclick(context.rowToObj(d.data()), "", selected, { vertex: d }); }) .on("contextmenu", function (this: SVGElement, d) { const vertexElement = d3Select(this).select(".graph_Vertex"); let selected = false; if (!vertexElement.empty()) { selected = vertexElement.classed("selected"); } context.vertex_contextmenu(context.rowToObj(d.data()), "", selected, { vertex: d }); }) .on("mouseout.tooltip", this.tooltip.hide) .on("mousemove.tooltip", this.tooltip.show) .on("mouseover", function (this: SVGElement, d) { if (context._dragging) return; context.vertex_mouseover(d3Select(this), d); }) .on("mouseout", function (this: SVGElement, d) { if (context._dragging) return; context.vertex_mouseout(d3Select(this), d); }) .each(createV) .transition() .duration(750) .style("opacity", 1) ; function createV(this: SVGElement, d) { d3Select(this).style("cursor", context.allowDragging() ? "move" : "pointer"); d .target(this) .pos({ x: d.x() || width / 2, y: d.y() || height / 2 }) .animationFrameRender() ; if (context.allowDragging()) { d3Select(this) .call(context._d3Drag) ; } if (d.dispatch) { d.dispatch.on("sizestart", function (d2) { d2.allowResize(context.allowDragging()); if (context.allowDragging()) { context._dragging = true; } }); d.dispatch.on("size", function (d2) { context.refreshIncidentEdges(d2, false); }); d.dispatch.on("sizeend", function (d2) { context._dragging = false; if (context.snapToGrid()) { const snapLoc = d2.calcSnap(context.snapToGrid()); d2 .pos(snapLoc[0]) .size(snapLoc[1]) .render() ; context.refreshIncidentEdges(d2, false); } }); } } vertexElements .each(updateV) ; function updateV(d) { d .animationFrameRender() ; } vertexElements.exit() .each(function (d) { d.target(null); }) .remove() ; vertexElements.order(); } update(domNode, element) { super.update(domNode, element); this.tooltip.hide(); this._centroidFilter.update(this.centroidColor()); // IconBar --- const layout = this.layout(); this._toggleHierarchy.selected(layout === "Hierarchy").render(); this._toggleForceDirected.selected(layout === "ForceDirected").render(); this._toggleForceDirected2.selected(layout === "ForceDirected2").render(); this._toggleCircle.selected(layout === "Circle").render(); // Create --- const context = this; this.updateVertices(this.svgC, "C", this._graphData.nodes().filter(v => this.layout() === "Hierarchy" ? (v instanceof Subgraph) : false)); this.updateVertices(this.svgV, "V", this._graphData.nodes().filter(v => !(v instanceof Subgraph))); const edgeElements = this.svgE.selectAll("#" + this._id + "E > .graphEdge").data(this.showEdges() ? this._graphData.edges() : [], function (d) { return d.id(); }); edgeElements.enter().append("g") .attr("class", "graphEdge") .style("opacity", 1e-6) .on("click.selectionBag", function (d) { context._selection.click(d, d3Event); }) .on("click", function (this: SVGElement, d) { const edgeElement = d3Select(this).select(".graph_Edge"); let selected = false; if (!edgeElement.empty()) { selected = edgeElement.classed("selected"); } context.edge_click(context.rowToObj(d.data()), "", selected, { edge: d }); }) .on("dblclick", function (this: SVGElement, d) { const edgeElement = d3Select(this).select(".graph_Edge"); let selected = false; if (!edgeElement.empty()) { selected = edgeElement.classed("selected"); } context.edge_dblclick(context.rowToObj(d.data()), "", selected, { edge: d }); }) .on("mouseout.tooltip", this.tooltip.hide) .on("mousemove.tooltip", this.tooltip.show) .on("mouseover", function (this: SVGElement, d) { if (context._dragging) return; context.edge_mouseover(d3Select(this), d); }) .on("mouseout", function (this: SVGElement, d) { if (context._dragging) return; context.edge_mouseout(d3Select(this), d); }) .each(createE) .transition() .duration(750) .style("opacity", 1) ; function createE(this: SVGElement, d) { d .target(this) .animationFrameRender() ; } edgeElements .each(updateE) ; function updateE(d) { d .animationFrameRender() ; } edgeElements.exit() .each(function (d) { d.target(null); }) .remove() ; if (!this._renderCount) { this._renderCount++; this.layout(this.layout()); } } exit(domNode, element) { this._graphData.nodes().forEach(v => v.target(null)); this._graphData.edges().forEach(e => e.target(null)); super.exit(domNode, element); } static profileID = 0; render(callback?: (w: Widget) => void): this { this.progress("start"); super.render(w => { this.doLayout().then(() => { this.progress("end"); if (callback) { callback(w); } }); }); return this; } // Methods --- _prevLayout; _prevDataHash; doLayout(transitionDuration = 0): Promise