/// /// module tf.graph.layout { /** Set of parameters that define the look and feel of the graph. */ export const PARAMS = { animation: { /** Default duration for graph animations in ms. */ duration: 250 }, graph: { /** Graph parameter for metanode. */ meta: { /** * Dagre's nodesep param - number of pixels that * separate nodes horizontally in the layout. * * See https://github.com/cpettitt/dagre/wiki#configuring-the-layout */ nodeSep: 110, /** * Dagre's ranksep param - number of pixels * between each rank in the layout. * * See https://github.com/cpettitt/dagre/wiki#configuring-the-layout */ rankSep: 25 }, /** Graph parameter for metanode. */ series: { /** * Dagre's nodesep param - number of pixels that * separate nodes horizontally in the layout. * * See https://github.com/cpettitt/dagre/wiki#configuring-the-layout */ nodeSep: 90, /** * Dagre's ranksep param - number of pixels * between each rank in the layout. * * See https://github.com/cpettitt/dagre/wiki#configuring-the-layout */ rankSep: 25, }, /** * Padding is used to correctly position the graph SVG inside of its parent * element. The padding amounts are applied using an SVG transform of X and * Y coordinates. */ padding: { paddingTop: 40, paddingLeft: 20 } }, subscene: { meta: { paddingTop: 10, paddingBottom: 10, paddingLeft: 10, paddingRight: 10, /** * Used to leave room for the label on top of the highest node in * the core graph. */ labelHeight: 20, /** X-space between each extracted node and the core graph. */ extractXOffset: 50, /** Y-space between each extracted node. */ extractYOffset: 20 }, series: { paddingTop: 10, paddingBottom: 10, paddingLeft: 10, paddingRight: 10, labelHeight: 10 } }, nodeSize: { /** Size of meta nodes. */ meta: { radius: 5, width: 60, /** A scale for the node's height based on number of nodes inside */ height: d3.scale.linear().domain([1, 200]).range([15, 60]).clamp(true), /** The radius of the circle denoting the expand button. */ expandButtonRadius: 3 }, /** Size of op nodes. */ op: { width: 15, height: 6, radius: 3, // for making annotation touching ellipse labelOffset: -8 }, /** Size of series nodes. */ series: { expanded: { // For expanded series nodes, width and height will be // computed to account for the subscene. radius: 10, labelOffset: 0, }, vertical: { // When unexpanded, series whose underlying metagraphs contain // one or more non-control edges will show as a vertical stack // of ellipses. width: 16, height: 13, labelOffset: -13, }, horizontal: { // When unexpanded, series whose underlying metagraphs contain // no non-control edges will show as a horizontal stack of // ellipses. width: 24, height: 8, radius: 10, // Forces annotations to center line. labelOffset: -10, }, }, /** Size of bridge nodes. */ bridge: { // NOTE: bridge nodes will normally be invisible, but they must // take up some space so that the layout step leaves room for // their edges. width: 20, height: 20, radius: 2, labelOffset: 0 } }, shortcutSize: { /** Size of shortcuts for op nodes */ op: { width: 10, height: 4 }, /** Size of shortcuts for meta nodes */ meta: { width: 12, height: 4, radius: 1 }, /** Size of shortcuts for series nodes */ series: { width: 14, height: 4, } }, annotations: { /** X-space between the shape and each annotation-node. */ xOffset: 10, /** Y-space between each annotation-node. */ yOffset: 3, /** X-space between each annotation-node and its label. */ labelOffset: 2, /** Estimate max width for annotation label */ labelWidth: 35 }, constant: { size: { width: 4, height: 4 } }, series: { /** Maximum number of repeated item for unexpanded series node. */ maxStackCount: 3, /** * Positioning offset ratio for collapsed stack * of parallel series (series without edges between its members). */ parallelStackOffsetRatio: 0.2, /** * Positioning offset ratio for collapsed stack * of tower series (series with edges between its members). */ towerStackOffsetRatio: 0.5 }, minimap : { /** The maximum width/height the minimap can have. */ size: 150 } }; /** Calculate layout for a scene of a group node. */ export function scene(renderNodeInfo: render.RenderGroupNodeInformation) : void { // Update layout, size, and annotations of its children nodes and edges. if (renderNodeInfo.node.isGroupNode) { layoutChildren(renderNodeInfo); } // Update position of its children nodes and edges if (renderNodeInfo.node.type === NodeType.META) { layoutMetanode(renderNodeInfo); } else if (renderNodeInfo.node.type === NodeType.SERIES) { layoutSeriesNode(renderNodeInfo); } }; /** * Update layout, size, and annotations of its children nodes and edges. */ function layoutChildren(renderNodeInfo: render.RenderGroupNodeInformation) : void { let children = renderNodeInfo.coreGraph.nodes().map(n => { return renderNodeInfo.coreGraph.node(n); }).concat(renderNodeInfo.isolatedInExtract, renderNodeInfo.isolatedOutExtract); _.each(children, childNodeInfo => { // Set size of each child switch (childNodeInfo.node.type) { case NodeType.OP: _.extend(childNodeInfo, PARAMS.nodeSize.op); break; case NodeType.BRIDGE: _.extend(childNodeInfo, PARAMS.nodeSize.bridge); break; case NodeType.META: if (!childNodeInfo.expanded) { // set fixed width and scalable height based on cardinality _.extend(childNodeInfo, PARAMS.nodeSize.meta); childNodeInfo.height = PARAMS.nodeSize.meta.height(childNodeInfo.node.cardinality); } else { let childGroupNodeInfo = childNodeInfo; scene(childGroupNodeInfo); // Recursively layout its subscene. } break; case NodeType.SERIES: if (childNodeInfo.expanded) { _.extend(childNodeInfo, PARAMS.nodeSize.series.expanded); let childGroupNodeInfo = childNodeInfo; scene(childGroupNodeInfo); // Recursively layout its subscene. } else { let childGroupNodeInfo = childNodeInfo; let seriesParams = childGroupNodeInfo.node.hasNonControlEdges ? PARAMS.nodeSize.series.vertical : PARAMS.nodeSize.series.horizontal; _.extend(childNodeInfo, seriesParams); } break; default: throw Error("Unrecognized node type: " + childNodeInfo.node.type); } // Layout each child's annotations layoutAnnotation(childNodeInfo); }); } /** * Calculate layout for a graph using dagre * @param graph the graph to be laid out * @param params layout parameters * @return width and height of the core graph */ function dagreLayout(graph: graphlib.Graph, params) : {height: number, width: number} { _.extend(graph.graph(), { nodeSep: params.nodeSep, rankSep: params.rankSep }); let bridgeNodeNames = []; let nonBridgeNodeNames = []; // Split out nodes into bridge and non-bridge nodes, and calculate the total // width we should use for bridge nodes. _.each(graph.nodes(), nodeName => { let nodeInfo = graph.node(nodeName); if (nodeInfo.node.type === NodeType.BRIDGE) { bridgeNodeNames.push(nodeName); } else { nonBridgeNodeNames.push(nodeName); } }); // If there are no non-bridge nodes, then the graph has zero size. if (!nonBridgeNodeNames.length) { return { width: 0, height: 0, }; } dagre.layout(graph); let graphLabel = graph.graph(); // Calculate the true bounding box of the graph by iterating over nodes and // edges rather than accepting dagre's word for it. In particular, we should // ignore the extra-wide bridge nodes and bridge edges, and allow for // annotation boxes and labels. let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; _.each(nonBridgeNodeNames, nodeName => { let nodeInfo = graph.node(nodeName); let w = 0.5 * nodeInfo.width; let x1 = nodeInfo.x - w - nodeInfo.inboxWidth; let x2 = nodeInfo.x + w + nodeInfo.outboxWidth; minX = x1 < minX ? x1 : minX; maxX = x2 > maxX ? x2 : maxX; let labelLength = nodeName.length - nodeName.lastIndexOf(NAMESPACE_DELIM); // TODO(jimbo): Account for font width rather than using a magic number. let charWidth = 3; // 3 pixels per character. let lw = 0.5 * labelLength * charWidth; let lx1 = nodeInfo.x - lw; let lx2 = nodeInfo.x + lw; minX = lx1 < minX ? lx1 : minX; maxX = lx2 > maxX ? lx2 : maxX; // TODO(jimbo): Account for the height of labels above op nodes here. let h = 0.5 * nodeInfo.outerHeight; let y1 = nodeInfo.y - h; let y2 = nodeInfo.y + h; minY = y1 < minY ? y1 : minY; maxY = y2 > maxY ? y2 : maxY; }); _.each(graph.edges(), edgeObj => { let renderMetaedgeInfo = graph.edge(edgeObj); if (renderMetaedgeInfo.structural) { return; // Skip structural edges from min/max calculations. } _.each(renderMetaedgeInfo.points, (point: { x: number, y: number }) => { minX = point.x < minX ? point.x : minX; maxX = point.x > maxX ? point.x : maxX; minY = point.y < minY ? point.y : minY; maxY = point.y > maxY ? point.y : maxY; }); }); // Shift all nodes and edge points to account for the left-padding amount, // and the invisble bridge nodes. _.each(graph.nodes(), nodeName => { let nodeInfo = graph.node(nodeName); nodeInfo.x -= minX; nodeInfo.y -= minY; }); _.each(graph.edges(), edgeObj => { _.each(graph.edge(edgeObj).points, (point: { x: number, y: number }) => { point.x -= minX; point.y -= minY; }); }); return { width: maxX - minX, height: maxY - minY, }; } /** Layout a metanode. */ function layoutMetanode(renderNodeInfo): void { // First, copy params specific to meta nodes onto this render info object. let params = PARAMS.subscene.meta; renderNodeInfo = _.extend(renderNodeInfo, params); // Invoke dagre.layout() on the core graph and record the bounding box // dimensions. _.extend(renderNodeInfo.coreBox, dagreLayout(renderNodeInfo.coreGraph, PARAMS.graph.meta)); // Calculate the position of nodes in isolatedInExtract relative to the // top-left corner of inExtractBox (the bounding box for all inExtract nodes) // and calculate the size of the inExtractBox. let hasInExtract = renderNodeInfo.isolatedInExtract.length > 0; renderNodeInfo.inExtractBox.width = hasInExtract ? _(renderNodeInfo.isolatedInExtract).pluck("outerWidth").max() : 0; renderNodeInfo.inExtractBox.height = _.reduce(renderNodeInfo.isolatedInExtract, (height, child: any, i) => { let yOffset = i > 0 ? params.extractYOffset : 0; // use outerWidth/Height here to avoid overlaps between extracts child.x = renderNodeInfo.inExtractBox.width / 2; child.y = height + yOffset + child.outerHeight / 2; return height + yOffset + child.outerHeight; }, 0); // Calculate the position of nodes in isolatedOutExtract relative to the // top-left corner of outExtractBox (the bounding box for all outExtract // nodes) and calculate the size of the outExtractBox. let hasOutExtract = renderNodeInfo.isolatedOutExtract.length > 0; renderNodeInfo.outExtractBox.width = hasOutExtract ? _(renderNodeInfo.isolatedOutExtract).pluck("outerWidth").max() : 0; renderNodeInfo.outExtractBox.height = _.reduce(renderNodeInfo.isolatedOutExtract, (height, child: any, i) => { let yOffset = i > 0 ? params.extractYOffset : 0; // use outerWidth/Height here to avoid overlaps between extracts child.x = renderNodeInfo.outExtractBox.width / 2; child.y = height + yOffset + child.outerHeight / 2; return height + yOffset + child.outerHeight; }, 0); // Determine the whole metanode's width (from left to right). renderNodeInfo.width = params.paddingLeft + renderNodeInfo.coreBox.width + params.paddingRight + (hasInExtract ? renderNodeInfo.inExtractBox.width + params.extractXOffset : 0) + (hasOutExtract ? params.extractXOffset + renderNodeInfo.outExtractBox.width : 0); // TODO(jimbo): Remove labelHeight and instead incorporate into box sizes. // Determine the whole metanode's height (from top to bottom). renderNodeInfo.height = renderNodeInfo.labelHeight + params.paddingTop + Math.max( renderNodeInfo.inExtractBox.height, renderNodeInfo.coreBox.height, renderNodeInfo.outExtractBox.height ) + params.paddingBottom; } /** * Calculate layout for series node's core graph. Only called for an expanded * series. */ function layoutSeriesNode(node: render.RenderGroupNodeInformation): void { let graph = node.coreGraph; let params = PARAMS.subscene.series; _.extend(node, params); // Layout the core. _.extend(node.coreBox, dagreLayout(node.coreGraph, PARAMS.graph.series)); _.each(graph.nodes(), nodeName => { graph.node(nodeName).excluded = false; }); // Series do not have in/outExtractBox so no need to include them here. node.width = node.coreBox.width + params.paddingLeft + params.paddingRight; node.height = node.coreBox.height + params.paddingTop + params.paddingBottom; } /** * Calculate layout for annotations of a given node. * This will modify positions of the the given node and its annotations. * * @see tf.graph.render.Node and tf.graph.render.Annotation * for description of each property of each render node. * */ function layoutAnnotation(renderNodeInfo: render.RenderNodeInformation): void { // If the render node is an expanded metanode, then its annotations will not // be visible and we should skip the annotation calculations. if (renderNodeInfo.expanded) { _.extend(renderNodeInfo, { inboxWidth: 0, inboxHeight: 0, outboxWidth: 0, outboxHeight: 0, outerWidth: renderNodeInfo.width, outerHeight: renderNodeInfo.height }); return; } let inAnnotations = renderNodeInfo.inAnnotations.list; let outAnnotations = renderNodeInfo.outAnnotations.list; // Calculate size for in-annotations _.each(inAnnotations, a => sizeAnnotation(a)); // Calculate size for out-annotations _.each(outAnnotations, a => sizeAnnotation(a)); let params = PARAMS.annotations; renderNodeInfo.inboxWidth = inAnnotations.length > 0 ? (_(inAnnotations).pluck("width").max()) + params.xOffset + params.labelWidth + params.labelOffset : 0; renderNodeInfo.outboxWidth = outAnnotations.length > 0 ? (_(outAnnotations).pluck("width").max()) + params.xOffset + params.labelWidth + params.labelOffset : 0; // Calculate annotation node position (a.dx, a.dy) // and total height for in-annotations // After this chunk of code: // inboxHeight = sum of annotation heights+ (annotation.length - 1 * yOffset) let inboxHeight = _.reduce(inAnnotations, (height, a: any, i) => { let yOffset = i > 0 ? params.yOffset : 0; a.dx = -(renderNodeInfo.width + a.width) / 2 - params.xOffset; a.dy = height + yOffset + a.height / 2; return height + yOffset + a.height; }, 0); _.each(inAnnotations, (a: any) => { a.dy -= inboxHeight / 2; a.labelOffset = params.labelOffset; }); // Calculate annotation node position position (a.dx, a.dy) // and total height for out-annotations // After this chunk of code: // outboxHeight = sum of annotation heights + // (annotation.length - 1 * yOffset) let outboxHeight = _.reduce(outAnnotations, (height, a: any, i) => { let yOffset = i > 0 ? params.yOffset : 0; a.dx = (renderNodeInfo.width + a.width) / 2 + params.xOffset; a.dy = height + yOffset + a.height / 2; return height + yOffset + a.height; }, 0); _.each(outAnnotations, (a: any) => { // adjust by (half of ) the total height // so dy is relative to the host node's center. a.dy -= outboxHeight / 2; a.labelOffset = params.labelOffset; }); // Creating scales for touch point between the in-annotation edges // and their hosts. let inTouchHeight = Math.min(renderNodeInfo.height / 2 - renderNodeInfo.radius, inboxHeight / 2); inTouchHeight = inTouchHeight < 0 ? 0 : inTouchHeight; let inY = d3.scale.linear() .domain([0, inAnnotations.length - 1]) .range([-inTouchHeight, inTouchHeight]); // Calculate annotation edge position _.each(inAnnotations, (a: any, i) => { a.points = [ // The annotation node end { dx: a.dx + a.width / 2, dy: a.dy }, // The host node end { dx: - renderNodeInfo.width / 2, // only use scale if there are more than one, // otherwise center it vertically dy: inAnnotations.length > 1 ? inY(i) : 0 } ]; }); // Creating scales for touch point between the out-annotation edges // and their hosts. let outTouchHeight = Math.min(renderNodeInfo.height / 2 - renderNodeInfo.radius, outboxHeight / 2); outTouchHeight = outTouchHeight < 0 ? 0 : outTouchHeight; let outY = d3.scale.linear() .domain([0, outAnnotations.length - 1]) .range([-outTouchHeight, outTouchHeight]); _.each(outAnnotations, (a: any, i) => { // Add point from the border of the annotation node a.points = [ // The host node end { dx: renderNodeInfo.width / 2, // only use scale if there are more than one, // otherwise center it vertically dy: outAnnotations.length > 1 ? outY(i) : 0 }, // The annotation node end { dx: a.dx - a.width / 2, dy: a.dy } ]; }); renderNodeInfo.outerWidth = renderNodeInfo.width + renderNodeInfo.inboxWidth + renderNodeInfo.outboxWidth; renderNodeInfo.outerHeight = Math.max(renderNodeInfo.height, inboxHeight, outboxHeight); } /** * Set size of an annotation node. */ function sizeAnnotation(a: render.Annotation): void { switch (a.annotationType) { case render.AnnotationType.CONSTANT: _.extend(a, PARAMS.constant.size); break; case render.AnnotationType.SHORTCUT: if (a.node.type === NodeType.OP) { _.extend(a, PARAMS.shortcutSize.op); } else if (a.node.type === NodeType.META) { _.extend(a, PARAMS.shortcutSize.meta); } else if (a.node.type === NodeType.SERIES) { _.extend(a, PARAMS.shortcutSize.series); } else { throw Error("Invalid node type: " + a.node.type); } break; case render.AnnotationType.SUMMARY: _.extend(a, PARAMS.constant.size); break; } } } // close module