diff options
Diffstat (limited to 'tensorflow/tensorboard/components/tf-graph-common/lib/scene')
5 files changed, 1603 insertions, 0 deletions
diff --git a/tensorflow/tensorboard/components/tf-graph-common/lib/scene/annotation.ts b/tensorflow/tensorboard/components/tf-graph-common/lib/scene/annotation.ts new file mode 100644 index 0000000000..82609e8652 --- /dev/null +++ b/tensorflow/tensorboard/components/tf-graph-common/lib/scene/annotation.ts @@ -0,0 +1,223 @@ +/// <reference path="../graph.ts" /> +/// <reference path="../render.ts" /> +/// <reference path="scene.ts" /> +/// <reference path="edge.ts" /> + +module tf.graph.scene.annotation { + +/** + * Populate a given annotation container group + * + * <g class="{in|out}-annotations"></g> + * + * with annotation group of the following structure: + * + * <g class="annotation"> + * <g class="annotation-node"> + * <!-- + * Content here determined by Scene.node.buildGroup. + * --> + * </g> + * </g> + * + * @param container selection of the container. + * @param annotationData node.{in|out}Annotations + * @param d node to build group for. + * @param sceneBehavior polymer scene element. + * @return selection of appended objects + */ +export function buildGroup(container, annotationData: render.AnnotationList, + d: render.RenderNodeInformation, sceneBehavior) { + // Select all children and join with data. + let annotationGroups = container.selectAll(function() { + // using d3's selector function + // See https://github.com/mbostock/d3/releases/tag/v2.0.0 + // (It's not listed in the d3 wiki.) + return this.childNodes; + }) + .data(annotationData.list, d => { return d.node.name; }); + + annotationGroups.enter() + .append("g") + .attr("data-name", a => { return a.node.name; }) + .each(function(a) { + let aGroup = d3.select(this); + + // Add annotation to the index in the scene + sceneBehavior.addAnnotationGroup(a, d, aGroup); + // Append annotation edge + let edgeType = Class.Annotation.EDGE; + let metaedge = a.renderMetaedgeInfo && a.renderMetaedgeInfo.metaedge; + if (metaedge && !metaedge.numRegularEdges) { + edgeType += " " + Class.Annotation.CONTROL_EDGE; + } + // If any edges are reference edges, add the reference edge class. + if (metaedge && metaedge.numRefEdges) { + edgeType += " " + Class.Edge.REF_LINE; + } + edge.appendEdge(aGroup, a, sceneBehavior, edgeType); + + if (a.annotationType !== tf.graph.render.AnnotationType.ELLIPSIS) { + addAnnotationLabelFromNode(aGroup, a); + buildShape(aGroup, a, sceneBehavior); + } else { + addAnnotationLabel(aGroup, a.node.name, a, Class.Annotation.ELLIPSIS); + } + }); + + annotationGroups + .attr("class", a => { + return Class.Annotation.GROUP + " " + + annotationToClassName(a.annotationType) + + " " + node.nodeClass(a); + }) + .each(function(a) { + let aGroup = d3.select(this); + update(aGroup, d, a, sceneBehavior); + if (a.annotationType !== tf.graph.render.AnnotationType.ELLIPSIS) { + addInteraction(aGroup, d, sceneBehavior); + } + }); + + annotationGroups.exit() + .each(function(a) { + let aGroup = d3.select(this); + + // Remove annotation from the index in the scene + sceneBehavior.removeAnnotationGroup(a, d, aGroup); + }) + .remove(); + return annotationGroups; +}; + +/** + * Maps an annotation enum to a class name used in css rules. + */ +function annotationToClassName(annotationType: render.AnnotationType) { + return (tf.graph.render.AnnotationType[annotationType] || "") + .toLowerCase() || null; +} + +function buildShape(aGroup, a: render.Annotation, sceneBehavior) { + if (a.annotationType === tf.graph.render.AnnotationType.SUMMARY) { + let image = scene.selectOrCreateChild(aGroup, "image"); + image.attr({ + "xlink:href": sceneBehavior.resolveUrl("../../lib/svg/summary-icon.svg"), + "height": "12px", + "width": "12px", + "cursor": "pointer" + }); + } else { + let shape = node.buildShape(aGroup, a, Class.Annotation.NODE); + // add title tag to get native tooltips + scene.selectOrCreateChild(shape, "title").text(a.node.name); + } +} + +function addAnnotationLabelFromNode(aGroup, a: render.Annotation) { + let namePath = a.node.name.split("/"); + let text = namePath[namePath.length - 1]; + let shortenedText = text.length > 8 ? text.substring(0, 8) + "..." : text; + return addAnnotationLabel(aGroup, shortenedText, a, null, text); +} + +function addAnnotationLabel(aGroup, label, a, additionalClassNames, + fullLabel?) { + let classNames = Class.Annotation.LABEL; + if (additionalClassNames) { + classNames += " " + additionalClassNames; + } + let titleText = fullLabel ? fullLabel : label; + return aGroup.append("text") + .attr("class", classNames) + .attr("dy", ".35em") + .attr("text-anchor", a.isIn ? "end" : "start") + .text(label) + .append("title").text(titleText); +} + +function addInteraction(selection, d: render.RenderNodeInformation, + sceneBehavior) { + selection + .on("mouseover", a => { + sceneBehavior.fire("annotation-highlight", { + name: a.node.name, + hostName: d.node.name + }); + }) + .on("mouseout", a => { + sceneBehavior.fire("annotation-unhighlight", { + name: a.node.name, + hostName: d.node.name + }); + }) + .on("click", a => { + // Stop this event"s propagation so that it isn't also considered a + // graph-select. + (<Event>d3.event).stopPropagation(); + sceneBehavior.fire("annotation-select", { + name: a.node.name, + hostName: d.node.name + }); + }); +}; + +/** + * Adjust annotation's position. + * + * @param aGroup selection of a "g.annotation" element. + * @param d Host node data. + * @param a annotation node data. + * @param scene Polymer scene element. + */ +function update(aGroup, d: render.RenderNodeInformation, a: render.Annotation, + sceneBehavior) { + // Annotations that point to embedded nodes (constants,summary) + // don't have a render information attached so we don't stylize these. + // Also we don't stylize ellipsis annotations (the string "... and X more"). + if (a.renderNodeInfo && + a.annotationType !== tf.graph.render.AnnotationType.ELLIPSIS) { + node.stylize(aGroup, a.renderNodeInfo, sceneBehavior, + Class.Annotation.NODE); + } + + if (a.annotationType === tf.graph.render.AnnotationType.SUMMARY) { + // Update the width of the annotation to give space for the image. + a.width += 10; + } + + // label position + aGroup.select("text." + Class.Annotation.LABEL).transition().attr({ + x: d.x + a.dx + (a.isIn ? -1 : 1) * (a.width / 2 + a.labelOffset), + y: d.y + a.dy + }); + + // Some annotations (such as summary) are represented using a 12x12 image tag. + // Purposely ommited units (e.g. pixels) since the images are vector graphics. + // If there is an image, we adjust the location of the image to be vertically + // centered with the node and horizontally centered between the arrow and the + // text label. + aGroup.select("image").transition().attr({ + x: d.x + a.dx - 3, + y: d.y + a.dy - 6 + }); + + // Node position (only one of the shape selection will be non-empty.) + scene.positionEllipse(aGroup.select("." + Class.Annotation.NODE + " ellipse"), + d.x + a.dx, d.y + a.dy, a.width, a.height); + scene.positionRect(aGroup.select("." + Class.Annotation.NODE + " rect"), + d.x + a.dx, d.y + a.dy, a.width, a.height); + scene.positionRect(aGroup.select("." + Class.Annotation.NODE + " use"), + d.x + a.dx, d.y + a.dy, a.width, a.height); + + // Edge position + aGroup.select("path." + Class.Annotation.EDGE).transition().attr("d", a => { + // map relative position to absolute position + let points = a.points.map(p => { + return {x: p.dx + d.x, y: p.dy + d.y}; + }); + return edge.interpolate(points); + }); +}; + +} // close module diff --git a/tensorflow/tensorboard/components/tf-graph-common/lib/scene/edge.ts b/tensorflow/tensorboard/components/tf-graph-common/lib/scene/edge.ts new file mode 100644 index 0000000000..e11ec97f80 --- /dev/null +++ b/tensorflow/tensorboard/components/tf-graph-common/lib/scene/edge.ts @@ -0,0 +1,177 @@ +/// <reference path="../graph.ts" /> +/// <reference path="../render.ts" /> +/// <reference path="scene.ts" /> + +module tf.graph.scene.edge { + +let Scene = tf.graph.scene; // Aliased + +export function getEdgeKey(edgeObj) { + return edgeObj.v + tf.graph.EDGE_KEY_DELIM + edgeObj.w; +} + +/** + * Select or Create a "g.edges" group to a given sceneGroup + * and builds a number of "g.edge" groups inside the group. + * + * Structure Pattern: + * + * <g class="edges"> + * <g class="edge"> + * <path class="edgeline"/> + * </g> + * ... + * </g> + * + * + * @param sceneGroup container + * @param graph + * @param sceneBehavior Parent scene module. + * @return selection of the created nodeGroups + */ +export function buildGroup(sceneGroup, + graph: graphlib.Graph<tf.graph.render.RenderNodeInformation, + tf.graph.render.RenderMetaedgeInformation>, sceneBehavior) { + let edgeData = _.reduce(graph.edges(), (edges, edgeObj) => { + let edgeLabel = graph.edge(edgeObj); + edges.push({ + v: edgeObj.v, + w: edgeObj.w, + label: edgeLabel + }); + return edges; + }, []); + + let container = scene.selectOrCreateChild(sceneGroup, "g", + Class.Edge.CONTAINER); + let containerNode = container.node(); + + // Select all children and join with data. + // (Note that all children of g.edges are g.edge) + let edgeGroups = container.selectAll(function() { + // using d3's selector function + // See https://github.com/mbostock/d3/releases/tag/v2.0.0 + // (It's not listed in the d3 wiki.) + return this.childNodes; + }) + .data(edgeData, getEdgeKey); + + // Make edges a group to support rendering multiple lines for metaedge + edgeGroups.enter() + .append("g") + .attr("class", Class.Edge.GROUP) + .attr("data-edge", getEdgeKey) + .each(function(d) { + let edgeGroup = d3.select(this); + d.label.edgeGroup = edgeGroup; + // index node group for quick highlighting + sceneBehavior._edgeGroupIndex[getEdgeKey(d)] = edgeGroup; + + // If any edges are reference edges, add the reference edge class. + let extraEdgeClass = d.label.metaedge && d.label.metaedge.numRefEdges + ? Class.Edge.REF_LINE + " " + Class.Edge.LINE + : undefined; + // Add line during enter because we're assuming that type of line + // normally does not change. + appendEdge(edgeGroup, d, scene, extraEdgeClass); + }); + + edgeGroups.each(position); + edgeGroups.each(function(d) { + stylize(d3.select(this), d, sceneBehavior); + }); + + edgeGroups.exit() + .each(d => { + delete sceneBehavior._edgeGroupIndex[getEdgeKey(d)]; + }) + .remove(); + return edgeGroups; +}; + +/** + * For a given d3 selection and data object, create a path to represent the + * edge described in d.label. + * + * If d.label is defined, it will be a RenderMetaedgeInformation instance. It + * will sometimes be undefined, for example for some Annotation edges for which + * there is no underlying Metaedge in the hierarchical graph. + */ +export function appendEdge(edgeGroup, d, sceneBehavior, edgeClass?) { + edgeClass = edgeClass || Class.Edge.LINE; // set default type + + if (d.label && d.label.structural) { + edgeClass += " " + Class.Edge.STRUCTURAL; + } + + edgeGroup.append("path") + .attr("class", edgeClass); +}; + +/** + * Returns a tween interpolator for the endpoint of an edge path. + */ +function getEdgePathInterpolator(d, i, a) { + let renderMetaedgeInfo = d.label; + let adjoiningMetaedge = renderMetaedgeInfo.adjoiningMetaedge; + if (!adjoiningMetaedge) { + return d3.interpolate(a, interpolate(renderMetaedgeInfo.points)); + } + + let renderPath = this; + + // Get the adjoining path that matches the adjoining metaedge. + let adjoiningPath = + <SVGPathElement>((<HTMLElement>adjoiningMetaedge.edgeGroup.node()) + .firstChild); + + // Find the desired SVGPoint along the adjoining path, then convert those + // coordinates into the space of the renderPath using its Current + // Transformation Matrix (CTM). + let inbound = renderMetaedgeInfo.metaedge.inbound; + + return function(t) { + let adjoiningPoint = adjoiningPath + .getPointAtLength(inbound ? adjoiningPath.getTotalLength() : 0) + .matrixTransform(adjoiningPath.getCTM()) + .matrixTransform(renderPath.getCTM().inverse()); + + // Update the relevant point in the renderMetaedgeInfo's points list, then + // re-interpolate the path. + let points = renderMetaedgeInfo.points; + let index = inbound ? 0 : points.length - 1; + points[index].x = adjoiningPoint.x; + points[index].y = adjoiningPoint.y; + let dPath = interpolate(points); + return dPath; + }; +} + +export let interpolate = d3.svg.line() + .interpolate("basis") + .x((d: any) => { return d.x; }) + .y((d: any) => { return d.y; }); + +function position(d) { + d3.select(this).select("path." + Class.Edge.LINE) + .each(function(d) { + let path = d3.select(this); + path.transition().attrTween("d", getEdgePathInterpolator); + }); +}; + +/** + * For a given d3 selection and data object, mark the edge as a control + * dependency if it contains only control edges. + * + * d's label property will be a RenderMetaedgeInformation object. + */ +function stylize(edgeGroup, d, stylize) { + let a; + let metaedge = d.label.metaedge; + edgeGroup + .select("path." + Class.Edge.LINE) + .classed("control-dep", metaedge && !metaedge.numRegularEdges); +}; + +} // close module diff --git a/tensorflow/tensorboard/components/tf-graph-common/lib/scene/minimap.ts b/tensorflow/tensorboard/components/tf-graph-common/lib/scene/minimap.ts new file mode 100644 index 0000000000..1a34132765 --- /dev/null +++ b/tensorflow/tensorboard/components/tf-graph-common/lib/scene/minimap.ts @@ -0,0 +1,269 @@ +/// <reference path="../../../../typings/tsd.d.ts" /> +/// <reference path="../common.ts" /> + +module tf.scene { + +/** Show minimap when the viewpoint area is less than X% of the whole area. */ +const FRAC_VIEWPOINT_AREA: number = 0.8; + +export class Minimap { + /** The minimap container. */ + private minimap: HTMLElement; + /** The canvas used for drawing the mini version of the svg. */ + private canvas: HTMLCanvasElement; + /** A buffer canvas used for temporary drawing to avoid flickering. */ + private canvasBuffer: HTMLCanvasElement; + + /** The minimap svg used for holding the viewpoint rectangle. */ + private minimapSvg: SVGSVGElement; + /** The rectangle showing the current viewpoint. */ + private viewpoint: SVGRectElement; + /** + * The scale factor for the minimap. The factor is determined automatically + * so that the minimap doesn't violate the maximum width/height specified + * in the constructor. The minimap maintains the same aspect ratio as the + * original svg. + */ + private scaleMinimap: number; + /** The main svg element. */ + private svg: SVGSVGElement; + /** The svg group used for panning and zooming the main svg. */ + private zoomG: SVGGElement; + /** The zoom behavior of the main svg. */ + private mainZoom: d3.behavior.Zoom<any>; + /** The maximum width and height for the minimap. */ + private maxWandH: number; + /** The last translation vector used in the main svg. */ + private translate: [number, number]; + /** The last scaling factor used in the main svg. */ + private scaleMain: number; + /** The coordinates of the viewpoint rectangle. */ + private viewpointCoord: {x: number, y: number}; + /** The current size of the minimap */ + private minimapSize: {width: number, height: number}; + /** Padding (px) due to the main labels of the graph. */ + private labelPadding: number; + /** + * Constructs a new minimap. + * + * @param svg The main svg element. + * @param zoomG The svg group used for panning and zooming the main svg. + * @param mainZoom The main zoom behavior. + * @param minimap The minimap container. + * @param maxWandH The maximum width/height for the minimap. + * @param labelPadding Padding in pixels due to the main graph labels. + */ + constructor(svg: SVGSVGElement, zoomG: SVGGElement, + mainZoom: d3.behavior.Zoom<any>, minimap: HTMLElement, + maxWandH: number, labelPadding: number) { + this.svg = svg; + this.labelPadding = labelPadding; + this.zoomG = zoomG; + this.mainZoom = mainZoom; + this.maxWandH = maxWandH; + let $minimap = d3.select(minimap); + // The minimap will have 2 main components: the canvas showing the content + // and an svg showing a rectangle of the currently zoomed/panned viewpoint. + let $minimapSvg = $minimap.select("svg"); + + // Make the viewpoint rectangle draggable. + let $viewpoint = $minimapSvg.select("rect"); + let dragmove = (d) => { + this.viewpointCoord.x = (<DragEvent>d3.event).x; + this.viewpointCoord.y = (<DragEvent>d3.event).y; + this.updateViewpoint(); + }; + this.viewpointCoord = {x: 0, y: 0}; + let drag = d3.behavior.drag().origin(Object).on("drag", dragmove); + $viewpoint.datum(this.viewpointCoord).call(drag); + + // Make the minimap clickable. + $minimapSvg.on("click", () => { + if ((<Event>d3.event).defaultPrevented) { + // This click was part of a drag event, so suppress it. + return; + } + // Update the coordinates of the viewpoint. + let width = Number($viewpoint.attr("width")); + let height = Number($viewpoint.attr("height")); + let clickCoords = d3.mouse($minimapSvg.node()); + this.viewpointCoord.x = clickCoords[0] - width / 2; + this.viewpointCoord.y = clickCoords[1] - height / 2; + this.updateViewpoint(); + }); + this.viewpoint = <SVGRectElement> $viewpoint.node(); + this.minimapSvg = <SVGSVGElement> $minimapSvg.node(); + this.minimap = minimap; + this.canvas = <HTMLCanvasElement> $minimap.select("canvas.first").node(); + this.canvasBuffer = + <HTMLCanvasElement> $minimap.select("canvas.second").node(); + } + + /** + * Updates the position and the size of the viewpoint rectangle. + * It also notifies the main svg about the new panned position. + */ + private updateViewpoint(): void { + // Update the coordinates of the viewpoint rectangle. + d3.select(this.viewpoint) + .attr("x", this.viewpointCoord.x) + .attr("y", this.viewpointCoord.y); + // Update the translation vector of the main svg to reflect the + // new viewpoint. + let mainX = - this.viewpointCoord.x * this.scaleMain / this.scaleMinimap; + let mainY = - this.viewpointCoord.y * this.scaleMain / this.scaleMinimap; + let zoomEvent = this.mainZoom.translate([mainX, mainY]).event; + d3.select(this.zoomG).call(zoomEvent); + } + + /** + * Redraws the minimap. Should be called whenever the main svg + * was updated (e.g. when a node was expanded). + */ + update(): void { + let $svg = d3.select(this.svg); + // Read all the style rules in the document and embed them into the svg. + // The svg needs to be self contained, i.e. all the style rules need to be + // embedded so the canvas output matches the origin. + let stylesText = ""; + for (let k = 0; k < document.styleSheets.length; k++) { + try { + let cssRules = (<any>document.styleSheets[k]).cssRules || + (<any>document.styleSheets[k]).rules; + if (cssRules == null) { + continue; + } + for (let i = 0; i < cssRules.length; i++) { + stylesText += cssRules[i].cssText + "\n"; + } + } catch (e) { + if (e.name !== "SecurityError") { + throw e; + } + } + } + + // Temporarily add the css rules to the main svg. + let svgStyle = $svg.append("style"); + svgStyle.text(stylesText); + + // Temporarily remove the zoom/pan transform from the main svg since we + // want the minimap to show a zoomed-out and centered view. + let $zoomG = d3.select(this.zoomG); + let zoomTransform = $zoomG.attr("transform"); + $zoomG.attr("transform", null); + + // Get the size of the entire scene. + let sceneSize = this.zoomG.getBBox(); + // Since we add padding, account for that here. + sceneSize.height += this.labelPadding; + + // Temporarily assign an explicit width/height to the main svg, since + // it doesn't have one (uses flex-box), but we need it for the canvas + // to work. + $svg.attr({ + width: sceneSize.width, + height: sceneSize.height, + }); + + // Since the content inside the svg changed (e.g. a node was expanded), + // the aspect ratio have also changed. Thus, we need to update the scale + // factor of the minimap. The scale factor is determined such that both + // the width and height of the minimap are <= maximum specified w/h. + this.scaleMinimap = + this.maxWandH / Math.max(sceneSize.width, sceneSize.height); + + this.minimapSize = { + width: sceneSize.width * this.scaleMinimap, + height: sceneSize.height * this.scaleMinimap + }; + + // Update the size of the minimap's svg, the buffer canvas and the + // viewpoint rect. + d3.select(this.minimapSvg).attr(<any>this.minimapSize); + d3.select(this.canvasBuffer).attr(<any>this.minimapSize); + if (this.translate != null && this.zoom != null) { + // Update the viewpoint rectangle shape since the aspect ratio of the + // map has changed. + requestAnimationFrame(() => this.zoom()); + } + + // Serialize the main svg to a string which will be used as the rendering + // content for the canvas. + let svgXml = (new XMLSerializer()).serializeToString(this.svg); + + // Now that the svg is serialized for rendering, remove the temporarily + // assigned styles, explicit width and height and bring back the pan/zoom + // transform. + svgStyle.remove(); + $svg.attr({ + width: null, + height: null + }); + $zoomG.attr("transform", zoomTransform); + let image = new Image(); + image.onload = () => { + // Draw the svg content onto the buffer canvas. + let context = this.canvasBuffer.getContext("2d"); + context.clearRect(0, 0, this.canvasBuffer.width, + this.canvasBuffer.height); + context.drawImage(image, 0, 0, + this.minimapSize.width, this.minimapSize.height); + requestAnimationFrame(() => { + // Hide the old canvas and show the new buffer canvas. + d3.select(this.canvasBuffer).style("display", null); + d3.select(this.canvas).style("display", "none"); + // Swap the two canvases. + [this.canvas, this.canvasBuffer] = [this.canvasBuffer, this.canvas]; + }); + }; + image.src = "data:image/svg+xml;base64," + btoa(svgXml); + } + + /** + * Handles changes in zooming/panning. Should be called from the main svg + * to notify that a zoom/pan was performed and this minimap will update it's + * viewpoint rectangle. + * + * @param translate The translate vector, or none to use the last used one. + * @param scale The scaling factor, or none to use the last used one. + */ + zoom(translate?: [number, number], scale?: number): void { + // Update the new translate and scale params, only if specified. + this.translate = translate || this.translate; + this.scaleMain = scale || this.scaleMain; + // Update the location of the viewpoint rectangle. + let svgRect = this.svg.getBoundingClientRect(); + let $viewpoint = d3.select(this.viewpoint); + this.viewpointCoord.x = -this.translate[0] * this.scaleMinimap / + this.scaleMain; + this.viewpointCoord.y = -this.translate[1] * this.scaleMinimap / + this.scaleMain; + let viewpointWidth = svgRect.width * this.scaleMinimap / this.scaleMain; + let viewpointHeight = svgRect.height * this.scaleMinimap / this.scaleMain; + $viewpoint.attr({ + x: this.viewpointCoord.x, + y: this.viewpointCoord.y, + width: viewpointWidth, + height: viewpointHeight + }); + // Show/hide the minimap depending on the viewpoint area as fraction of the + // whole minimap. + let mapWidth = this.minimapSize.width; + let mapHeight = this.minimapSize.height; + let x = this.viewpointCoord.x; + let y = this.viewpointCoord.y; + let w = Math.min(Math.max(0, x + viewpointWidth), mapWidth) - + Math.min(Math.max(0, x), mapWidth); + let h = Math.min(Math.max(0, y + viewpointHeight), mapHeight) - + Math.min(Math.max(0, y), mapHeight); + let fracIntersect = (w * h) / (mapWidth * mapHeight); + if (fracIntersect < FRAC_VIEWPOINT_AREA) { + this.minimap.classList.remove("hidden"); + } else { + this.minimap.classList.add("hidden"); + } + } +} + +} // close module tf.scene diff --git a/tensorflow/tensorboard/components/tf-graph-common/lib/scene/node.ts b/tensorflow/tensorboard/components/tf-graph-common/lib/scene/node.ts new file mode 100644 index 0000000000..8c74b37e07 --- /dev/null +++ b/tensorflow/tensorboard/components/tf-graph-common/lib/scene/node.ts @@ -0,0 +1,525 @@ +/// <reference path="../graph.ts" /> +/// <reference path="scene.ts" /> +/// <reference path="annotation.ts" /> + +module tf.graph.scene.node { + +/** + * Select or Create a "g.nodes" group to a given sceneGroup + * and builds a number of "g.node" groups inside the group. + * + * Structure Pattern: + * + * <g class="nodes"> + * <g class="node"> + * <g class="in-annotations"> + * ... + * </g> + * <g class="out-annotations"> + * ... + * </g> + * <g class="nodeshape"> + * <!-- + * Content of the node shape should be for the node itself. For example a + * Metanode would have a <rect> with rounded edges, an op would have an + * <ellipse>. More complex nodes like series may contain multiple elements + * which are conditionally visible based on whether the node is expanded. + * --> + * </g> + * <text class="label">node name</text> + * <g class="subscene"> + * <!-- + * Content of the subscene (only for metanode and series node). + * + * Subscene is a svg group that contains content of the + * metanode's metagraph that is recursively generated by Scene.build(). + * + * When the graph is expanded multiple times, a subscene can contain + * nested subscenes inside. + * --> + * </g> + * </g> + * ... + * </g> + * + * + * @param sceneGroup selection of the container + * @param nodeData array of render node information to map + * @param sceneBehavior parent scene module + * @return selection of the created nodeGroups + */ +export function buildGroup(sceneGroup, + nodeData: render.RenderNodeInformation[], sceneBehavior) { + let container = scene.selectOrCreateChild(sceneGroup, "g", + Class.Node.CONTAINER); + // Select all children and join with data. + // (Note that all children of g.nodes are g.node) + let nodeGroups = container.selectAll(function() { + // using d3's selector function + // See https://github.com/mbostock/d3/releases/tag/v2.0.0 + // (It's not listed in the d3 wiki.) + return this.childNodes; // this here refers to container.node() + }) + .data(nodeData, (d: any) => { + // make sure that we don't have to swap shape type + return d.node.name + ":" + d.node.type; + }); + + // ENTER + nodeGroups.enter() + .append("g") + .attr("data-name", d => { return d.node.name; }) + .each(function(d) { + let nodeGroup = d3.select(this); + // index node group for quick stylizing + sceneBehavior.addNodeGroup(d.node.name, nodeGroup); + }); + + // UPDATE + nodeGroups + .attr("class", d => { + return Class.Node.GROUP + " " + nodeClass(d); + }) + .each(function(d) { + let nodeGroup = d3.select(this); + // add g.in-annotations (always add -- to keep layer order consistent.) + let inAnnotationBox = scene.selectOrCreateChild(nodeGroup, "g", + Class.Annotation.INBOX); + annotation.buildGroup(inAnnotationBox, d.inAnnotations, d, + sceneBehavior); + + // add g.out-annotations (always add -- to keep layer order consistent.) + let outAnnotationBox = scene.selectOrCreateChild(nodeGroup, "g", + Class.Annotation.OUTBOX); + annotation.buildGroup(outAnnotationBox, d.outAnnotations, d, + sceneBehavior); + + // label + let label = labelBuild(nodeGroup, d, sceneBehavior); + // Do not add interaction to metanode labels as they live inside the + // metanode shape which already has the same interactions. + addInteraction(label, d, sceneBehavior, d.node.type === NodeType.META); + + // build .shape below label + let shape = buildShape(nodeGroup, d, Class.Node.SHAPE, label.node()); + if (d.node.isGroupNode) { + addButton(shape, d, sceneBehavior); + } + addInteraction(shape, d, sceneBehavior); + + // build subscene on the top + subsceneBuild(nodeGroup, d, sceneBehavior); + + stylize(nodeGroup, d, sceneBehavior); + position(nodeGroup, d, sceneBehavior); + }); + + // EXIT + nodeGroups.exit() + .each(function(d) { + // remove all indices on remove + sceneBehavior.removeNodeGroup(d.node.name); + + let nodeGroup = d3.select(this); + if (d.inAnnotations.list.length > 0) { + nodeGroup.select("." + Class.Annotation.INBOX) + .selectAll("." + Class.Annotation.GROUP) + .each(a => { + sceneBehavior.removeAnnotationGroup(a, d); + }); + } + if (d.outAnnotations.list.length > 0) { + nodeGroup.select("." + Class.Annotation.OUTBOX) + .selectAll("." + Class.Annotation.GROUP) + .each(a => { + sceneBehavior.removeAnnotationGroup(a, d); + }); + } + }) + .remove(); + return nodeGroups; +}; + +/** + * Update or remove the subscene of a render group node depending on whether it + * is a expanded. If the node is not a group node, this method has no effect. + * + * @param nodeGroup selection of the container + * @param renderNodeInfo the render information for the node. + * @param sceneBehavior parent scene module + * @return Selection of the subscene group, or null if node group does not have + * a subscene. Op nodes, bridge nodes and unexpanded group nodes will + * not have a subscene. + */ +function subsceneBuild(nodeGroup, + renderNodeInfo: render.RenderGroupNodeInformation, sceneBehavior) { + if (renderNodeInfo.node.isGroupNode) { + if (renderNodeInfo.expanded) { + // Recursively build the subscene. + return scene.buildGroup(nodeGroup, renderNodeInfo, sceneBehavior, + Class.Subscene.GROUP); + } + // Clean out existing subscene if the node is not expanded. + scene.selectChild(nodeGroup, "g", Class.Subscene.GROUP).remove(); + } + return null; +}; + +/** + * Translate the subscene of the given node group + */ +function subscenePosition(nodeGroup, d: render.RenderNodeInformation) { + let x0 = d.x - d.width / 2.0 + d.paddingLeft; + let y0 = d.y - d.height / 2.0 + d.paddingTop; + + let subscene = scene.selectChild(nodeGroup, "g", Class.Subscene.GROUP); + scene.translate(subscene, x0, y0); +}; + +/** + * Add an expand/collapse button to a group node + * + * @param selection The group node selection. + * @param d Info about the node being rendered. + * @param sceneBehavior parent scene module. + */ +function addButton(selection, d: render.RenderNodeInformation, sceneBehavior) { + let group = scene.selectOrCreateChild( + selection, "g", Class.Node.BUTTON_CONTAINER); + scene.selectOrCreateChild(group, "circle", Class.Node.BUTTON_CIRCLE); + scene.selectOrCreateChild(group, "path", Class.Node.EXPAND_BUTTON).attr( + "d", "M0,-2.2 V2.2 M-2.2,0 H2.2"); + scene.selectOrCreateChild(group, "path", Class.Node.COLLAPSE_BUTTON).attr( + "d", "M-2.2,0 H2.2"); + group.on("click", d => { + // Stop this event's propagation so that it isn't also considered a + // node-select. + (<Event>d3.event).stopPropagation(); + sceneBehavior.fire("node-toggle-expand", { name: d.node.name }); + }); + scene.positionButton(group, d); +}; + +/** + * Fire node-* events when the selection is interacted. + * + * @param disableInteraction When true, have the provided selection + * ignore all pointer events. Used for text labels inside of metanodes, which + * don't need interaction as their surrounding shape has interaction, and if + * given interaction would cause conflicts with the expand/collapse button. + */ +function addInteraction(selection, d: render.RenderNodeInformation, + sceneBehavior, disableInteraction?: boolean) { + if (disableInteraction) { + selection.attr("pointer-events", "none"); + return; + } + selection.on("dblclick", d => { + sceneBehavior.fire("node-toggle-expand", { name: d.node.name }); + }) + .on("mouseover", d => { + // don't send mouseover over expanded group, + // otherwise it is causing too much glitches + if (sceneBehavior.isNodeExpanded(d)) { return; } + + sceneBehavior.fire("node-highlight", { name: d.node.name }); + }) + .on("mouseout", d => { + // don't send mouseover over expanded group, + // otherwise it is causing too much glitches + if (sceneBehavior.isNodeExpanded(d)) { return; } + + sceneBehavior.fire("node-unhighlight", { name: d.node.name }); + }) + .on("click", d => { + // Stop this event's propagation so that it isn't also considered + // a graph-select. + (<Event>d3.event).stopPropagation(); + sceneBehavior.fire("node-select", { name: d.node.name }); + }); +}; + +/** + * Append svg text for label and assign data. + * @param nodeGroup + * @param renderNodeInfo The render node information for the label. + * @param sceneBehavior parent scene module. + */ +function labelBuild(nodeGroup, renderNodeInfo: render.RenderNodeInformation, + sceneBehavior) { + let namePath = renderNodeInfo.node.name.split("/"); + let text = namePath[namePath.length - 1]; + + // Truncate long labels for unexpanded Metanodes. + let useFontScale = renderNodeInfo.node.type === NodeType.META && + !renderNodeInfo.expanded; + + let label = scene.selectOrCreateChild(nodeGroup, "text", Class.Node.LABEL); + label.attr("dy", ".35em") + .attr("text-anchor", "middle"); + if (useFontScale) { + if (text.length > sceneBehavior.maxMetanodeLabelLength) { + text = text.substr(0, sceneBehavior.maxMetanodeLabelLength - 2) + "..."; + } + let scale = getLabelFontScale(sceneBehavior); + label.attr("font-size", scale(text.length) + "px"); + } + label.text(text); + return label; +}; + +/** + * d3 scale used for sizing font of labels, used by labelBuild, + * initialized once by getLabelFontScale. + */ +let fontScale = null; +function getLabelFontScale(sceneBehavior) { + if (!fontScale) { + fontScale = d3.scale.linear() + .domain([sceneBehavior.maxMetanodeLabelLengthLargeFont, + sceneBehavior.maxMetanodeLabelLength]) + .range([sceneBehavior.maxMetanodeLabelLengthFontSize, + sceneBehavior.minMetanodeLabelLengthFontSize]).clamp(true); + } + return fontScale; +} +/** + * Set label position of a given node group + */ +function labelPosition(nodeGroup, d: render.RenderNodeInformation, + yOffset: number) { + scene.selectChild(nodeGroup, "text", Class.Node.LABEL).transition() + .attr("x", d.x) + .attr("y", d.y + yOffset); +}; + +/** + * Select or append/insert shape for a node and assign renderNode + * as the shape's data. + * + * @param nodeGroup + * @param d RenderNodeInformation + * @param nodeClass class for the element. + * @param before Reference DOM node for insertion. + * @return Selection of the shape. + */ +export function buildShape(nodeGroup, d, nodeClass: string, before?) { + // Create a group to house the underlying visual elements. + let shapeGroup = scene.selectOrCreateChild(nodeGroup, "g", nodeClass, + before); + // TODO(jimbo): DOM structure should be templated in HTML somewhere, not JS. + switch (d.node.type) { + case NodeType.OP: + scene.selectOrCreateChild(shapeGroup, "ellipse", + Class.Node.COLOR_TARGET); + break; + case NodeType.SERIES: + // Choose the correct stamp to use to represent this series. + let stampType = "annotation"; + let groupNodeInfo = <render.RenderGroupNodeInformation>d; + if (groupNodeInfo.coreGraph) { + stampType = groupNodeInfo.node.hasNonControlEdges + ? "vertical" : "horizontal"; + } + scene.selectOrCreateChild(shapeGroup, "use", Class.Node.COLOR_TARGET) + .attr("xlink:href", "#op-series-" + stampType + "-stamp"); + scene.selectOrCreateChild(shapeGroup, "rect", Class.Node.COLOR_TARGET) + .attr({ rx: d.radius, ry: d.radius }); + break; + case NodeType.BRIDGE: + scene.selectOrCreateChild(shapeGroup, "rect", Class.Node.COLOR_TARGET) + .attr({ rx: d.radius, ry: d.radius }); + break; + case NodeType.META: + scene.selectOrCreateChild(shapeGroup, "rect", Class.Node.COLOR_TARGET) + .attr({ rx: d.radius, ry: d.radius }); + break; + default: + throw Error("Unrecognized node type: " + d.node.type); + } + return shapeGroup; +}; + +export function nodeClass(d: render.RenderNodeInformation) { + switch (d.node.type) { + case NodeType.OP: + return Class.OPNODE; + case NodeType.META: + return Class.METANODE; + case NodeType.SERIES: + return Class.SERIESNODE; + case NodeType.BRIDGE: + return Class.BRIDGENODE; + case NodeType.ELLIPSIS: + return Class.ELLIPSISNODE; + }; + throw Error("Unrecognized node type: " + d.node.type); +}; + +/** Modify node and its subscene and its label's positional attributes */ +function position(nodeGroup, d: render.RenderNodeInformation, sceneBehavior) { + let shapeGroup = scene.selectChild(nodeGroup, "g", Class.Node.SHAPE); + switch (d.node.type) { + case NodeType.OP: { + // position shape + let shape = scene.selectChild(shapeGroup, "ellipse"); + scene.positionEllipse(shape, d.x, d.y, d.width, d.height); + labelPosition(nodeGroup, d, d.labelOffset); + break; + } + case NodeType.META: { + // position shape + let shape = scene.selectChild(shapeGroup, "rect"); + scene.positionRect(shape, d.x, d.y, d.width, d.height); + + if (d.expanded) { + subscenePosition(nodeGroup, d); + + // put label on top + labelPosition(nodeGroup, d, + - d.height / 2 + d.labelHeight / 2); + } else { + labelPosition(nodeGroup, d, 0); + } + break; + } + case NodeType.SERIES: { + let shape = scene.selectChild(shapeGroup, "use"); + scene.positionRect(shape, d.x, d.y, d.width, d.height); + if (d.expanded) { + subscenePosition(nodeGroup, d); + + // put label on top + labelPosition(nodeGroup, d, + - d.height / 2 + d.labelHeight / 2); + } else { + labelPosition(nodeGroup, d, d.labelOffset); + } + } + case NodeType.BRIDGE: { + // position shape + // NOTE: In reality, these will not be visible, but it helps to put them + // in the correct position for debugging purposes. + let shape = scene.selectChild(shapeGroup, "rect"); + scene.positionRect(shape, d.x, d.y, d.width, d.height); + break; + } + default: { + throw Error("Unrecognized node type: " + d.node.type); + } + } +}; + +/** Enum specifying the options to color nodes by */ +let ColorBy = { + STRUCTURE: 0, + DEVICE: 1, + COMPUTE_TIME: 2, + MEMORY: 3 +}; + +/** + * Returns the fill color for the node given its state and the "color by" + * option. + */ +function getFillForNode(sceneBehavior, colorBy, + renderInfo: render.RenderNodeInformation, isExpanded: boolean): string { + let colorParams = tf.graph.render.MetanodeColors; + switch (colorBy) { + case ColorBy.STRUCTURE: + if (renderInfo.node.type === tf.graph.NodeType.META) { + let tid = (<Metanode>renderInfo.node).templateId; + return tid === null ? colorParams.UNKNOWN : colorParams.STRUCTURE_PALETTE( + sceneBehavior.templateIndex(tid), renderInfo.expanded); + } else if (renderInfo.node.type === tf.graph.NodeType.SERIES) { + // If expanded, we're showing the background rect, which we want to + // appear gray. Otherwise we're showing a stack of ellipses which we + // want to show white. + return renderInfo.expanded ? colorParams.EXPANDED_COLOR : "white"; + } else if (renderInfo.node.type === NodeType.BRIDGE) { + return renderInfo.structural ? "#f0e" : + (<BridgeNode>renderInfo.node).inbound ? "#0ef" : "#fe0"; + } else { + // Op nodes are white. + return "white"; + } + case ColorBy.DEVICE: + if (renderInfo.deviceColors == null) { + // Return the hue for unknown device. + return colorParams.UNKNOWN; + } + let id = renderInfo.node.name; + let escapedId = tf.escapeQuerySelector(id); + let gradientDefs = d3.select("svg#svg defs #linearGradients"); + let linearGradient = + gradientDefs.select("linearGradient#" + escapedId); + // If the linear gradient is not there yet, create it. + if (linearGradient.size() === 0) { + linearGradient = gradientDefs.append("linearGradient").attr("id", id); + // Re-create the stops of the linear gradient. + linearGradient.selectAll("*").remove(); + let cumulativeProportion = 0; + // For each device, create a stop using the proportion of that device. + _.each(renderInfo.deviceColors, (d: any) => { + let color = d.color; + linearGradient.append("stop") + .attr("offset", cumulativeProportion) + .attr("stop-color", color); + linearGradient.append("stop") + .attr("offset", cumulativeProportion + d.proportion) + .attr("stop-color", color); + cumulativeProportion += d.proportion; + }); + } + return isExpanded ? colorParams.EXPANDED_COLOR : `url(#${escapedId})`; + case ColorBy.COMPUTE_TIME: + return isExpanded ? + colorParams.EXPANDED_COLOR : renderInfo.computeTimeColor || + colorParams.UNKNOWN; + case ColorBy.MEMORY: + return isExpanded ? + colorParams.EXPANDED_COLOR : renderInfo.memoryColor || + colorParams.UNKNOWN; + default: + throw new Error("Unknown case to color nodes by"); + } +} + +/** + * Modify node style by toggling class and assign attributes (only for things + * that can't be done in css). + */ +export function stylize(nodeGroup, renderInfo: render.RenderNodeInformation, + sceneBehavior, nodeClass?) { + nodeClass = nodeClass || Class.Node.SHAPE; + let isHighlighted = sceneBehavior.isNodeHighlighted(renderInfo.node.name); + let isSelected = sceneBehavior.isNodeSelected(renderInfo.node.name); + let isExtract = renderInfo.isInExtract || renderInfo.isOutExtract; + let isExpanded = renderInfo.expanded; + nodeGroup.classed("highlighted", isHighlighted); + nodeGroup.classed("selected", isSelected); + nodeGroup.classed("extract", isExtract); + nodeGroup.classed("expanded", isExpanded); + + // Main node always exists here and it will be reached before subscene, + // so d3 selection is fine here. + let node = nodeGroup.select("." + nodeClass + " ." + Class.Node.COLOR_TARGET); + let fillColor = getFillForNode(sceneBehavior, + ColorBy[sceneBehavior.colorBy.toUpperCase()], + renderInfo, isExpanded); + node.style("fill", fillColor); + + // Choose outline to be darker version of node color if the node is a single + // color and is not selected. + if (isSelected) { + node.style("stroke", null); + } else { + // If node is colored by a gradient, then use a dark gray outline. + let outlineColor = fillColor.substring(0, 3) === "url" ? + tf.graph.render.MetanodeColors.GRADIENT_OUTLINE : + d3.rgb(fillColor).darker().toString(); + node.style("stroke", outlineColor); + } +}; + +} // close module diff --git a/tensorflow/tensorboard/components/tf-graph-common/lib/scene/scene.ts b/tensorflow/tensorboard/components/tf-graph-common/lib/scene/scene.ts new file mode 100644 index 0000000000..2e2467f039 --- /dev/null +++ b/tensorflow/tensorboard/components/tf-graph-common/lib/scene/scene.ts @@ -0,0 +1,409 @@ +/// <reference path="../graph.ts" /> +/// <reference path="edge.ts" /> +/// <reference path="node.ts" /> +/// <reference path="../layout.ts" /> + +module tf.graph.scene { + +/** Enums element class of objects in the scene */ +export let Class = { + Node: { + // <g> element that contains nodes. + CONTAINER: "nodes", + // <g> element that contains detail about a node. + GROUP: "node", + // <g> element that contains visual elements (like rect, ellipse). + SHAPE: "nodeshape", + // <*> element(s) under SHAPE that should receive color updates. + COLOR_TARGET: "nodecolortarget", + // <text> element showing the node's label. + LABEL: "nodelabel", + // <g> element that contains all visuals for the expand/collapse + // button for expandable group nodes. + BUTTON_CONTAINER: "buttoncontainer", + // <circle> element that surrounds expand/collapse buttons. + BUTTON_CIRCLE: "buttoncircle", + // <path> element of the expand button. + EXPAND_BUTTON: "expandbutton", + // <path> element of the collapse button. + COLLAPSE_BUTTON: "collapsebutton" + }, + Edge: { + CONTAINER: "edges", + GROUP: "edge", + LINE: "edgeline", + REF_LINE: "refline", + STRUCTURAL: "structural" + }, + Annotation: { + OUTBOX: "out-annotations", + INBOX: "in-annotations", + GROUP: "annotation", + NODE: "annotation-node", + EDGE: "annotation-edge", + CONTROL_EDGE: "annotation-control-edge", + LABEL: "annotation-label", + ELLIPSIS: "annotation-ellipsis" + }, + Scene: { + GROUP: "scene", + CORE: "core", + INEXTRACT: "in-extract", + OUTEXTRACT: "out-extract" + }, + Subscene: { + GROUP: "subscene" + }, + OPNODE: "op", + METANODE: "meta", + SERIESNODE: "series", + BRIDGENODE: "bridge", + ELLIPSISNODE: "ellipsis" +}; + +/** + * Helper method for fitting the graph in the svg view. + * + * @param svg The main svg. + * @param zoomG The svg group used for panning and zooming. + * @param d3zoom The zoom behavior. + * @param callback Called when the fitting is done. + */ +export function fit(svg, zoomG, d3zoom, callback) { + let svgRect = svg.getBoundingClientRect(); + let sceneSize = zoomG.getBBox(); + let scale = 0.9 * Math.min( + svgRect.width / sceneSize.width, + svgRect.height / sceneSize.height, + 2 + ); + let params = layout.PARAMS.graph; + let zoomEvent = d3zoom.scale(scale) + .on("zoomend.fitted", () => { + // Remove the listener for the zoomend event, + // so we don't get called at the end of regular zoom events, + // just those that fit the graph to screen. + d3zoom.on("zoomend.fitted", null); + callback(); + }) + .translate([params.padding.paddingLeft, params.padding.paddingTop]) + .event; + d3.select(zoomG).transition().duration(500).call(zoomEvent); +}; + +/** + * Helper method for panning the graph to center on the provided node, + * if the node is currently off-screen. + * + * @param nodeName The node to center the graph on + * @param svg The root SVG element for the graph + * @param zoomG The svg group used for panning and zooming. + * @param d3zoom The zoom behavior. + * @return True if the graph had to be panned to display the + * provided node. + */ +export function panToNode(nodeName: String, svg, zoomG, d3zoom): boolean { + let node: any = d3.selectAll("[data-name='" + nodeName + "']." + + Class.Node.GROUP)[0][0]; + if (!node) { + return false; + } + let translate = d3zoom.translate(); + // Check if the selected node is off-screen in either + // X or Y dimension in either direction. + let nodeBox = node.getBBox(); + let nodeCtm = node.getScreenCTM(); + let pointTL = svg.createSVGPoint(); + let pointBR = svg.createSVGPoint(); + pointTL.x = nodeBox.x; + pointTL.y = nodeBox.y; + pointBR.x = nodeBox.x + nodeBox.width; + pointBR.y = nodeBox.y + nodeBox.height; + pointTL = pointTL.matrixTransform(nodeCtm); + pointBR = pointBR.matrixTransform(nodeCtm); + let isOutsideOfBounds = (start, end, bound) => { + return end < 0 || start > bound; + }; + let svgRect = svg.getBoundingClientRect(); + if (isOutsideOfBounds(pointTL.x, pointBR.x, svgRect.width) || + isOutsideOfBounds(pointTL.y, pointBR.y, svgRect.height)) { + // Determine the amount to transform the graph in both X and Y + // dimensions in order to center the selected node. This takes into + // acount the position of the node, the size of the svg scene, the + // amount the scene has been scaled by through zooming, and any previous + // transform already performed by this logic. + let centerX = (pointTL.x + pointBR.x) / 2; + let centerY = (pointTL.y + pointBR.y) / 2; + let dx = ((svgRect.width / 2) - centerX); + let dy = ((svgRect.height / 2) - centerY); + let zoomEvent = d3zoom.translate([translate[0] + dx, translate[1] + dy]) + .event; + d3.select(zoomG).transition().duration(500).call(zoomEvent); + return true; + } + return false; +}; + +/** + * Given a container d3 selection, select a child svg element of a given tag + * and class if exists or append / insert one otherwise. If multiple children + * matches the tag and class name, returns only the first one. + * + * @param container + * @param tagName tag name. + * @param className (optional) Class name. + * @param before (optional) reference DOM node for insertion. + * @return selection of the element + */ +export function selectOrCreateChild(container, tagName: string, + className?: string, before?) { + let child = selectChild(container, tagName, className); + if (!child.empty()) { + return child; + } + let newElement = document.createElementNS("http://www.w3.org/2000/svg", + tagName); + if (className) { + newElement.classList.add(className); + } + + if (before) { // if before exists, insert + container.node().insertBefore(newElement, before); + } else { // otherwise, append + container.node().appendChild(newElement); + } + return d3.select(newElement) + // need to bind data to emulate d3_selection.append + .datum(container.datum()); +}; + +/** + * Given a container d3 selection, select a child element of a given tag and + * class. If multiple children matches the tag and class name, returns only + * the first one. + * + * @param container + * @param tagName tag name. + * @param className (optional) Class name. + * @return selection of the element, or an empty selection + */ +export function selectChild(container, tagName: string, className?: string) { + let children = container.node().childNodes; + for (let i = 0; i < children.length; i++) { + let child = children[i]; + if (child.tagName === tagName && + (!className || child.classList.contains(className)) + ) { + return d3.select(child); + } + } + return d3.select(null); +}; + +/** + * Select or create a sceneGroup and build/update its nodes and edges. + * + * Structure Pattern: + * + * <g class="scene"> + * <g class="core"> + * <g class="edges"> + * ... stuff from tf.graph.scene.edges.build ... + * </g> + * <g class="nodes"> + * ... stuff from tf.graph.scene.nodes.build ... + * </g> + * </g> + * <g class="in-extract"> + * <g class="nodes"> + * ... stuff from tf.graph.scene.nodes.build ... + * </g> + * </g> + * <g class="out-extract"> + * <g class="nodes"> + * ... stuff from tf.graph.scene.nodes.build ... + * </g> + * </g> + * </g> + * + * @param container D3 selection of the parent. + * @param renderNode render node of a metanode or series node. + * @param sceneBehavior Parent scene module. + * @param sceneClass class attribute of the scene (default="scene"). + */ +export function buildGroup(container, + renderNode: render.RenderGroupNodeInformation, + sceneBehavior, + sceneClass: string) { + sceneClass = sceneClass || Class.Scene.GROUP; + let isNewSceneGroup = selectChild(container, "g", sceneClass).empty(); + let sceneGroup = selectOrCreateChild(container, "g", sceneClass); + + // core + let coreGroup = selectOrCreateChild(sceneGroup, "g", Class.Scene.CORE); + let coreNodes = _.reduce(renderNode.coreGraph.nodes(), (nodes, name) => { + let node = renderNode.coreGraph.node(name); + if (!node.excluded) { + nodes.push(node); + } + return nodes; + }, []); + + if (renderNode.node.type === NodeType.SERIES) { + // For series, we want the first item on top, so reverse the array so + // the first item in the series becomes last item in the top, and thus + // is rendered on the top. + coreNodes.reverse(); + } + + // Create the layer of edges for this scene (paths). + edge.buildGroup(coreGroup, renderNode.coreGraph, sceneBehavior); + + // Create the layer of nodes for this scene (ellipses, rects etc). + node.buildGroup(coreGroup, coreNodes, sceneBehavior); + + // In-extract + if (renderNode.isolatedInExtract.length > 0) { + let inExtractGroup = selectOrCreateChild(sceneGroup, "g", + Class.Scene.INEXTRACT); + node.buildGroup(inExtractGroup, renderNode.isolatedInExtract, + sceneBehavior); + } else { + selectChild(sceneGroup, "g", Class.Scene.INEXTRACT).remove(); + } + + // Out-extract + if (renderNode.isolatedOutExtract.length > 0) { + let outExtractGroup = selectOrCreateChild(sceneGroup, "g", + Class.Scene.OUTEXTRACT); + node.buildGroup(outExtractGroup, renderNode.isolatedOutExtract, + sceneBehavior); + } else { + selectChild(sceneGroup, "g", Class.Scene.OUTEXTRACT).remove(); + } + + position(sceneGroup, renderNode); + + // Fade in the scene group if it didn't already exist. + if (isNewSceneGroup) { + sceneGroup.attr("opacity", 0) + .transition().attr("opacity", 1); + } + + return sceneGroup; +}; + +/** + * Given a scene's svg group, set g.in-extract, g.coreGraph, g.out-extract svg + * groups' position relative to the scene. + * + * @param sceneGroup + * @param renderNode render node of a metanode or series node. + */ +function position(sceneGroup, renderNode: render.RenderGroupNodeInformation) { + // Translate scenes down by the label height so that when showing graphs in + // expanded metanodes, the graphs are below the labels. Do not shift them + // down for series nodes as series nodes don't have labels inside of their + // bounding boxes. + let yTranslate = renderNode.node.type === NodeType.SERIES ? + 0 : layout.PARAMS.subscene.meta.labelHeight; + + // core + translate(selectChild(sceneGroup, "g", Class.Scene.CORE), + 0, yTranslate); + + // in-extract + let inExtractX = renderNode.coreBox.width === 0 ? + 0 : renderNode.coreBox.width; + let hasInExtract = renderNode.isolatedInExtract.length > 0; + if (hasInExtract) { + translate(selectChild(sceneGroup, "g", Class.Scene.INEXTRACT), + inExtractX, yTranslate); + } + + // out-extract + let hasOutExtract = renderNode.isolatedOutExtract.length > 0; + if (hasOutExtract) { + let outExtractX = inExtractX + renderNode.inExtractBox.width + + renderNode.extractXOffset; + translate(selectChild(sceneGroup, "g", Class.Scene.OUTEXTRACT), + outExtractX, yTranslate); + } +}; + +/** Adds a click listener to a group that fires a graph-select event */ +export function addGraphClickListener(graphGroup, sceneBehavior) { + d3.select(graphGroup).on("click", () => { + sceneBehavior.fire("graph-select"); + }); +}; + +/** Helper for adding transform: translate(x0, y0) */ +export function translate(selection, x0: number, y0: number) { + selection.attr("transform", "translate(" + x0 + "," + y0 + ")"); +}; + +/** + * Helper for setting position of a svg rect + * @param rect rect to set position of. + * @param cx Center x. + * @param cy Center x. + * @param width Width to set. + * @param height Height to set. + */ +export function positionRect(rect, cx: number, cy: number, width: number, + height: number) { + rect.transition().attr({ + x: cx - width / 2, + y: cy - height / 2, + width: width, + height: height + }); +}; + +/** + * Helper for setting position of a svg expand/collapse button + * @param button container group + * @param renderNode the render node of the group node to position + * the button on. + */ +export function positionButton(button, + renderNode: render.RenderNodeInformation) { + // Position the button in the top-right corner of the group node, + // with space given the draw the button inside of the corner. + let x = renderNode.x + renderNode.width / 2 - 6; + let y = renderNode.y - renderNode.height / 2 + 6; + // For unexpanded series nodes, the button has special placement due + // to the unique visuals of this group node. + if (renderNode.node.type === NodeType.SERIES && !renderNode.expanded) { + x += 10; + y -= 2; + } + let translateStr = "translate(" + x + "," + y + ")"; + button.selectAll("path").transition().attr("transform", translateStr); + button.select("circle").transition().attr({ + cx: x, + cy: y, + r: layout.PARAMS.nodeSize.meta.expandButtonRadius + }); +}; + +/** + * Helper for setting position of a svg ellipse + * @param ellipse ellipse to set position of. + * @param cx Center x. + * @param cy Center x. + * @param width Width to set. + * @param height Height to set. + */ +export function positionEllipse(ellipse, cx: number, cy: number, + width: number, height: number) { + ellipse.transition().attr({ + cx: cx, + cy: cy, + rx: width / 2, + ry: height / 2 + }); +}; + +} // close module |