From 1566727e4511424f638e8d9aeccb6d682cc0196a Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Wed, 24 Feb 2016 16:17:45 +0100 Subject: [PATCH 1/4] SVG export button * applies CSS styles inline * exports SVG chart node * injects A tag to download * based on SVG-crowbar Fixes #555 --- client/app/scripts/actions/app-actions.js | 5 + client/app/scripts/charts/nodes-chart.js | 2 +- client/app/scripts/components/app.js | 5 +- client/app/scripts/utils/file-utils.js | 134 ++++++++++++++++++++++ client/package.json | 1 + 5 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 client/app/scripts/utils/file-utils.js 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..32c9d4eff 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -225,7 +225,7 @@ 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..80bb4f3c7 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'; @@ -124,6 +124,9 @@ export default class App extends React.Component { + + + diff --git a/client/app/scripts/utils/file-utils.js b/client/app/scripts/utils/file-utils.js new file mode 100644 index 000000000..2533e6f81 --- /dev/null +++ b/client/app/scripts/utils/file-utils.js @@ -0,0 +1,134 @@ +// 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' +}; + +function setInlineStyles(svg, emptySvgDeclarationComputed) { + function explicitlySetStyle(element) { + const cSSStyleDeclarationComputed = getComputedStyle(element); + let value; + let computedStyleStr = ''; + _.each(cSSStyleDeclarationComputed, key => { + value = cSSStyleDeclarationComputed.getPropertyValue(key); + if (value !== emptySvgDeclarationComputed.getPropertyValue(key)) { + computedStyleStr += key + ':' + value + ';'; + } + }); + element.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; + } + + // hardcode computed css styles inside svg + const allElements = traverse(svg); + let i = allElements.length; + while (i--) { + explicitlySetStyle(allElements[i]); + } + // set font + svg.setAttribute('style', 'font-family: "Roboto", sans-serif;'); +} + +function download(source) { + let filename = 'untitled'; + + if (source.id) { + filename = source.id; + } else if (source.class) { + filename = source.class; + } else if (window.document.title) { + filename = window.document.title.replace(/[^a-z0-9]/gi, '-').toLowerCase(); + } + + const url = window.URL.createObjectURL(new Blob(source.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'); + + svg.setAttribute('version', '1.1'); + + // removing attributes so they aren't doubled up + svg.removeAttribute('xmlns'); + svg.removeAttribute('xlink'); + + // These are needed for the svg + if (!svg.hasAttributeNS(prefix.xmlns, 'xmlns')) { + svg.setAttributeNS(prefix.xmlns, 'xmlns', prefix.svg); + } + + if (!svg.hasAttributeNS(prefix.xmlns, 'xmlns:xlink')) { + svg.setAttributeNS(prefix.xmlns, 'xmlns:xlink', prefix.xlink); + } + + setInlineStyles(svg, emptySvgDeclarationComputed); + + const source = (new XMLSerializer()).serializeToString(svg); + + return { + class: svg.getAttribute('class'), + id: svg.getAttribute('id'), + childElementCount: svg.childElementCount, + source: [doctype + source] + }; +} + +function cleanup() { + const crowbarElements = document.querySelectorAll('.svg-crowbar'); + + [].forEach.call(crowbarElements, function(el) { + el.parentNode.removeChild(el); + }); +} + +export function saveGraph() { + 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); + + cleanup(); +} diff --git a/client/package.json b/client/package.json index 824502f96..05a8b9756 100644 --- a/client/package.json +++ b/client/package.json @@ -6,6 +6,7 @@ "license": "Apache-2.0", "private": true, "dependencies": { + "browser-filesaver": "^1.1.0", "classnames": "^2.2.1", "d3": "~3.5.5", "dagre": "0.7.4", From bd821c159ee8c6cad8b19ad1548688715ad95b50 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Wed, 24 Feb 2016 17:03:27 +0100 Subject: [PATCH 2/4] Inject weavescope logo into exported SVG --- client/app/scripts/charts/nodes-chart.js | 2 + client/app/scripts/components/app.js | 6 +- client/app/scripts/components/logo.js | 104 +++++++++++------------ client/app/scripts/utils/file-utils.js | 32 +++---- client/app/styles/main.less | 11 +++ 5 files changed, 86 insertions(+), 69 deletions(-) diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 32c9d4eff..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'); @@ -226,6 +227,7 @@ 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 80bb4f3c7..89f6f6934 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -96,7 +96,11 @@ export default class App extends React.Component { details={this.state.nodeDetails} />}
- +
+ + + +
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 index 2533e6f81..72fcd7612 100644 --- a/client/app/scripts/utils/file-utils.js +++ b/client/app/scripts/utils/file-utils.js @@ -43,28 +43,31 @@ function setInlineStyles(svg, emptySvgDeclarationComputed) { return tree; } + // make sure logo shows up + svg.setAttribute('class', 'exported'); + // hardcode computed css styles inside svg const allElements = traverse(svg); let i = allElements.length; while (i--) { explicitlySetStyle(allElements[i]); } + // set font svg.setAttribute('style', 'font-family: "Roboto", sans-serif;'); } -function download(source) { +function download(source, name) { let filename = 'untitled'; - if (source.id) { - filename = source.id; - } else if (source.class) { - filename = source.class; + if (name) { + filename = name; } else if (window.document.title) { - filename = window.document.title.replace(/[^a-z0-9]/gi, '-').toLowerCase(); + filename = window.document.title.replace(/[^a-z0-9]/gi, '-').toLowerCase() + + '-' + (+new Date); } - const url = window.URL.createObjectURL(new Blob(source.source, + const url = window.URL.createObjectURL(new Blob(source, {'type': 'text\/xml'} )); @@ -103,12 +106,7 @@ function getSVG(doc, emptySvgDeclarationComputed) { const source = (new XMLSerializer()).serializeToString(svg); - return { - class: svg.getAttribute('class'), - id: svg.getAttribute('id'), - childElementCount: svg.childElementCount, - source: [doctype + source] - }; + return [doctype + source]; } function cleanup() { @@ -117,9 +115,13 @@ function cleanup() { [].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() { +export function saveGraph(filename) { window.URL = (window.URL || window.webkitURL); // add empty svg element @@ -128,7 +130,7 @@ export function saveGraph() { const emptySvgDeclarationComputed = getComputedStyle(emptySvg); const svgSource = getSVG(document, emptySvgDeclarationComputed); - download(svgSource); + download(svgSource, filename); cleanup(); } diff --git a/client/app/styles/main.less b/client/app/styles/main.less index 8ca23f663..aa49b27a0 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: scale(0.25); + } + } + text { font-family: @base-font; fill: @text-secondary-color; From 4ad74183b1f1d92d8f7f1526279de94ae09e961d Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Thu, 25 Feb 2016 10:14:34 +0100 Subject: [PATCH 3/4] Inline styles in cloned svg sub tree, skip default styles --- client/app/scripts/utils/file-utils.js | 38 ++++++++++++++++---------- client/app/styles/main.less | 2 +- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/client/app/scripts/utils/file-utils.js b/client/app/scripts/utils/file-utils.js index 72fcd7612..762865fd3 100644 --- a/client/app/scripts/utils/file-utils.js +++ b/client/app/scripts/utils/file-utils.js @@ -7,19 +7,25 @@ const prefix = { 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, emptySvgDeclarationComputed) { - function explicitlySetStyle(element) { +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)) { + if (value !== emptySvgDeclarationComputed.getPropertyValue(key) && !cssSkipValues[value]) { computedStyleStr += key + ':' + value + ';'; } }); - element.setAttribute('style', computedStyleStr); + targetEl.setAttribute('style', computedStyleStr); } function traverse(obj) { @@ -48,13 +54,14 @@ function setInlineStyles(svg, emptySvgDeclarationComputed) { // hardcode computed css styles inside svg const allElements = traverse(svg); + const allTargetElements = traverse(target); let i = allElements.length; while (i--) { - explicitlySetStyle(allElements[i]); + explicitlySetStyle(allElements[i], allTargetElements[i]); } // set font - svg.setAttribute('style', 'font-family: "Roboto", sans-serif;'); + target.setAttribute('style', 'font-family: "Roboto", sans-serif;'); } function download(source, name) { @@ -86,25 +93,26 @@ function download(source, name) { function getSVG(doc, emptySvgDeclarationComputed) { const svg = document.getElementById('nodes-chart-canvas'); + const target = svg.cloneNode(true); - svg.setAttribute('version', '1.1'); + target.setAttribute('version', '1.1'); // removing attributes so they aren't doubled up - svg.removeAttribute('xmlns'); - svg.removeAttribute('xlink'); + target.removeAttribute('xmlns'); + target.removeAttribute('xlink'); // These are needed for the svg - if (!svg.hasAttributeNS(prefix.xmlns, 'xmlns')) { - svg.setAttributeNS(prefix.xmlns, 'xmlns', prefix.svg); + if (!target.hasAttributeNS(prefix.xmlns, 'xmlns')) { + target.setAttributeNS(prefix.xmlns, 'xmlns', prefix.svg); } - if (!svg.hasAttributeNS(prefix.xmlns, 'xmlns:xlink')) { - svg.setAttributeNS(prefix.xmlns, 'xmlns:xlink', prefix.xlink); + if (!target.hasAttributeNS(prefix.xmlns, 'xmlns:xlink')) { + target.setAttributeNS(prefix.xmlns, 'xmlns:xlink', prefix.xlink); } - setInlineStyles(svg, emptySvgDeclarationComputed); + setInlineStyles(svg, target, emptySvgDeclarationComputed); - const source = (new XMLSerializer()).serializeToString(svg); + const source = (new XMLSerializer()).serializeToString(target); return [doctype + source]; } diff --git a/client/app/styles/main.less b/client/app/styles/main.less index aa49b27a0..352a07bbc 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -297,7 +297,7 @@ h2 { svg.exported { .logo { display: inline; - transform: scale(0.25); + transform: translate(24px, 24px) scale(0.25); } } From 6eedaa43614834c358c45cc36df9408f775be31b Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Thu, 25 Feb 2016 14:08:45 +0100 Subject: [PATCH 4/4] Removed unused package --- client/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/client/package.json b/client/package.json index 05a8b9756..824502f96 100644 --- a/client/package.json +++ b/client/package.json @@ -6,7 +6,6 @@ "license": "Apache-2.0", "private": true, "dependencies": { - "browser-filesaver": "^1.1.0", "classnames": "^2.2.1", "d3": "~3.5.5", "dagre": "0.7.4",