diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 713ff60a6..31fde7260 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -2,6 +2,7 @@ import debug from 'debug'; import AppDispatcher from '../dispatcher/app-dispatcher'; import ActionTypes from '../constants/action-types'; +import { saveGraph } from '../utils/file-utils'; import { updateRoute } from '../utils/router-utils'; import { doControlRequest, getNodesDelta, getNodeDetails, getTopologies, deletePipe } from '../utils/web-api-utils'; @@ -57,6 +58,10 @@ export function clickCloseTerminal(pipeId, closePipe) { updateRoute(); } +export function clickDownloadGraph() { + saveGraph(); +} + export function clickForceRelayout() { AppDispatcher.dispatch({ type: ActionTypes.CLICK_FORCE_RELAYOUT diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 4f16b1879..573440cfc 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -13,6 +13,7 @@ import { EDGE_ID_SEPARATOR } from '../constants/naming'; import { doLayout } from './nodes-layout'; import Node from './node'; import NodesError from './nodes-error'; +import Logo from '../components/logo'; const log = debug('scope:nodes-chart'); @@ -225,7 +226,8 @@ export default class NodesChart extends React.Component {
{errorEmpty} {errorMaxNodesExceeded} - + + {edgeElements} diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index 9a796b5a1..89f6f6934 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -7,7 +7,7 @@ import Status from './status.js'; import Topologies from './topologies.js'; import TopologyOptions from './topology-options.js'; import { getApiDetails, getTopologies, basePathSlash } from '../utils/web-api-utils'; -import { clickForceRelayout, hitEsc } from '../actions/app-actions'; +import { clickDownloadGraph, clickForceRelayout, hitEsc } from '../actions/app-actions'; import Details from './details'; import Nodes from './nodes'; import EmbeddedTerminal from './embedded-terminal'; @@ -96,7 +96,11 @@ export default class App extends React.Component { details={this.state.nodeDetails} />}
- +
+ + + +
@@ -124,6 +128,9 @@ export default class App extends React.Component { + + + diff --git a/client/app/scripts/components/logo.js b/client/app/scripts/components/logo.js index fe5c53e4d..90ae97c61 100644 --- a/client/app/scripts/components/logo.js +++ b/client/app/scripts/components/logo.js @@ -3,59 +3,57 @@ import React from 'react'; export default class Logo extends React.Component { render() { return ( -
- - - - - - - - - - - - - - - - - - -
+ + + + + + + + + + + + + + + + + + ); } } diff --git a/client/app/scripts/utils/file-utils.js b/client/app/scripts/utils/file-utils.js new file mode 100644 index 000000000..762865fd3 --- /dev/null +++ b/client/app/scripts/utils/file-utils.js @@ -0,0 +1,144 @@ +// adapted from https://github.com/NYTimes/svg-crowbar +import _ from 'lodash'; + +const doctype = ''; +const prefix = { + xmlns: 'http://www.w3.org/2000/xmlns/', + xlink: 'http://www.w3.org/1999/xlink', + svg: 'http://www.w3.org/2000/svg' +}; +const cssSkipValues = { + 'auto': true, + '0px 0px': true, + 'visible': true, + 'pointer': true +}; + +function setInlineStyles(svg, target, emptySvgDeclarationComputed) { + function explicitlySetStyle(element, targetEl) { + const cSSStyleDeclarationComputed = getComputedStyle(element); + let value; + let computedStyleStr = ''; + _.each(cSSStyleDeclarationComputed, key => { + value = cSSStyleDeclarationComputed.getPropertyValue(key); + if (value !== emptySvgDeclarationComputed.getPropertyValue(key) && !cssSkipValues[value]) { + computedStyleStr += key + ':' + value + ';'; + } + }); + targetEl.setAttribute('style', computedStyleStr); + } + + function traverse(obj) { + const tree = []; + + function visit(node) { + if (node && node.hasChildNodes()) { + let child = node.firstChild; + while (child) { + if (child.nodeType === 1 && child.nodeName !== 'SCRIPT') { + tree.push(child); + visit(child); + } + child = child.nextSibling; + } + } + } + + tree.push(obj); + visit(obj); + return tree; + } + + // make sure logo shows up + svg.setAttribute('class', 'exported'); + + // hardcode computed css styles inside svg + const allElements = traverse(svg); + const allTargetElements = traverse(target); + let i = allElements.length; + while (i--) { + explicitlySetStyle(allElements[i], allTargetElements[i]); + } + + // set font + target.setAttribute('style', 'font-family: "Roboto", sans-serif;'); +} + +function download(source, name) { + let filename = 'untitled'; + + if (name) { + filename = name; + } else if (window.document.title) { + filename = window.document.title.replace(/[^a-z0-9]/gi, '-').toLowerCase() + + '-' + (+new Date); + } + + const url = window.URL.createObjectURL(new Blob(source, + {'type': 'text\/xml'} + )); + + const a = document.createElement('a'); + document.body.appendChild(a); + a.setAttribute('class', 'svg-crowbar'); + a.setAttribute('download', filename + '.svg'); + a.setAttribute('href', url); + a.style.display = 'none'; + a.click(); + + setTimeout(function() { + window.URL.revokeObjectURL(url); + }, 10); +} + +function getSVG(doc, emptySvgDeclarationComputed) { + const svg = document.getElementById('nodes-chart-canvas'); + const target = svg.cloneNode(true); + + target.setAttribute('version', '1.1'); + + // removing attributes so they aren't doubled up + target.removeAttribute('xmlns'); + target.removeAttribute('xlink'); + + // These are needed for the svg + if (!target.hasAttributeNS(prefix.xmlns, 'xmlns')) { + target.setAttributeNS(prefix.xmlns, 'xmlns', prefix.svg); + } + + if (!target.hasAttributeNS(prefix.xmlns, 'xmlns:xlink')) { + target.setAttributeNS(prefix.xmlns, 'xmlns:xlink', prefix.xlink); + } + + setInlineStyles(svg, target, emptySvgDeclarationComputed); + + const source = (new XMLSerializer()).serializeToString(target); + + return [doctype + source]; +} + +function cleanup() { + const crowbarElements = document.querySelectorAll('.svg-crowbar'); + + [].forEach.call(crowbarElements, function(el) { + el.parentNode.removeChild(el); + }); + + // hide embedded logo + const svg = document.getElementById('nodes-chart-canvas'); + svg.setAttribute('class', ''); +} + +export function saveGraph(filename) { + window.URL = (window.URL || window.webkitURL); + + // add empty svg element + const emptySvg = window.document.createElementNS(prefix.svg, 'svg'); + window.document.body.appendChild(emptySvg); + const emptySvgDeclarationComputed = getComputedStyle(emptySvg); + + const svgSource = getSVG(document, emptySvgDeclarationComputed); + download(svgSource, filename); + + cleanup(); +} diff --git a/client/app/styles/main.less b/client/app/styles/main.less index a77207103..716c1699d 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -290,6 +290,17 @@ h2 { top: 0px; } + .logo { + display: none; + } + + svg.exported { + .logo { + display: inline; + transform: translate(24px, 24px) scale(0.25); + } + } + text { font-family: @base-font; fill: @text-secondary-color;