Skip to content

Commit

Permalink
SVG export button
Browse files Browse the repository at this point in the history
* applies CSS styles inline
* exports SVG chart node
* injects A tag to download
* based on SVG-crowbar

Fixes #555
  • Loading branch information
davkal committed Feb 24, 2016
1 parent d759e9f commit 1566727
Show file tree
Hide file tree
Showing 5 changed files with 145 additions and 2 deletions.
5 changes: 5 additions & 0 deletions client/app/scripts/actions/app-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -57,6 +58,10 @@ export function clickCloseTerminal(pipeId, closePipe) {
updateRoute();
}

export function clickDownloadGraph() {
saveGraph();
}

export function clickForceRelayout() {
AppDispatcher.dispatch({
type: ActionTypes.CLICK_FORCE_RELAYOUT
Expand Down
2 changes: 1 addition & 1 deletion client/app/scripts/charts/nodes-chart.js
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ export default class NodesChart extends React.Component {
<div className="nodes-chart">
{errorEmpty}
{errorMaxNodesExceeded}
<svg width="100%" height="100%" className={svgClassNames} onClick={this.handleMouseClick}>
<svg width="100%" height="100%" id="nodes-chart-canvas" className={svgClassNames} onClick={this.handleMouseClick}>
<g className="canvas" transform={transform}>
<g className="edges">
{edgeElements}
Expand Down
5 changes: 4 additions & 1 deletion client/app/scripts/components/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -124,6 +124,9 @@ export default class App extends React.Component {
<a className={forceRelayoutClassName} onClick={clickForceRelayout} title={forceRelayoutTitle}>
<span className="fa fa-refresh" />
</a>
<a className="footer-label footer-label-icon" onClick={clickDownloadGraph} title="Save canvas as SVG">
<span className="fa fa-download" />
</a>
<a className="footer-label footer-label-icon" href={otherContrastModeUrl} title={otherContrastModeTitle}>
<span className="fa fa-adjust" />
</a>
Expand Down
134 changes: 134 additions & 0 deletions client/app/scripts/utils/file-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// adapted from https://github.com/NYTimes/svg-crowbar
import _ from 'lodash';

const doctype = '<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">';
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();
}
1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down

0 comments on commit 1566727

Please sign in to comment.