an example that uses SVG Crowbar to extract an SVG from the page and download it as an .svg
file
inspired by this stackoverflow answer
an alternate method that works with Firefox is demonstrated in @currankelleher‘s nice Download SVG from Data URL example
A d3js ROC Chart. Might be useful for evaluating models.
the data shown are from models that predict various tennis stats. curious about the source data? browse through the R script that @ilanthedataman uses to generate the data.
check out the animated version
inspired by the Interactive ROC Curve bl.ock from ilanman
<!DOCTYPE html>
<meta charset='utf-8'>
<head>
<link href='https://fonts.googleapis.com/css?family=Roboto:400,700,300,100,900|Open+Sans:400,300,700,600,800' rel='stylesheet' type='text/css'>
<script src='https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.22.1/babel.min.js'></script>
<script src='https://d3js.org/d3.v4.min.js' charset='utf-8'></script>
<script src='rocChart.js'></script>
</head>
<style>
body {
font-size: 12px;
font-family: 'Open Sans';
}
path {
stroke-width: 3;
fill: none;
opacity: .7;
}
.axis path,
.axis line {
fill: none;
stroke: grey;
stroke-width: 2;
shape-rendering: crispEdges;
opacity: 1;
}
.d3-tip {
font-family: Verdana;
background: rgba(0, 0, 0, 0.8);
padding: 8px;
color: #fff;
z-index: 5070;
}
.download {
background: darkgray;
color: #FFF;
font-weight: 600;
border: 2px solid #FFF;
border-radius: 8px;
padding: 6px;
margin:4px;
position: fixed;
left: 187px;
}
</style>
<body>
<div id='roc'> </div>
<button class="download" onClick="(function () { var e = document.createElement('script'); e.setAttribute('src', 'svg-crowbar.js'); e.setAttribute('class', 'svg-crowbar'); document.body.appendChild(e); })();">
<big>⇩</big> Download SVG
</button>
<script>
const margin = {top: 30, right: 61, bottom: 70, left: 61};
const width = 470 - margin.left - margin.right;
const height = 450 - margin.top - margin.bottom;
// fpr for 'false positive rate'
// tpr for 'true positive rate'
const rocChartOptions = {
'margin': margin,
'width': width,
'height': height,
'interpolationMode': 'basis',
'fpr': 'X',
'tprVariables': [
{
'name': 'BPC',
'label': 'Break Points'
},
{
'name': 'WNR',
'label': 'Winners'
},
{
'name': 'FSP',
'label': 'First Serve %',
},
{
'name': 'NPW',
'label': 'Net Points Won'
}
],
'animate': false,
'smooth': true
}
d3.json('data.json', (error, data) => rocChart('#roc', data, rocChartOptions))
</script>
</body>
function rocChart(id, data, options) {
// set default configuration
const cfg = {
'margin': {top: 30, right: 20, bottom: 70, left: 61},
'width': 470,
'height': 450,
'interpolationMode': 'basis',
'ticks': undefined,
'tickValues': [0, .1, .25, .5, .75, .9, 1],
'fpr': 'fpr',
'tprVariables': [{
'name': 'tpr0',
}],
'animate': true
};
//Put all of the options into a variable called cfg
if('undefined' !== typeof options){
Object.keys(options).forEach(key => {
if('undefined' !== typeof options[key]){ cfg[key] = options[key]; }
})
}
const tprVariables = cfg['tprVariables'];
// if values for labels are not specified
// set the default values for the labels to the corresponding
// true positive rate variable name
tprVariables.forEach((d, i) => {
if('undefined' === typeof d.label){
d.label = d.name;
}
})
console.log('tprVariables', tprVariables);
const interpolationMode = cfg['interpolationMode'];
const fpr = cfg['fpr'];
const width = cfg['width'];
const height = cfg['height'];
const animate = cfg['animate'];
const format = d3.format('.2');
const aucFormat = d3.format('.4r');
const x = d3.scaleLinear().range([0, width]);
const y = d3.scaleLinear().range([height, 0]);
const color = d3.scaleOrdinal()
.range(d3.schemeCategory10); // d3.scaleOrdinal().range(['steelblue', 'red', 'green', 'purple']);
const xAxis = d3.axisTop()
.scale(x)
.tickSizeOuter(0);
const yAxis = d3.axisRight()
.scale(y)
.tickSizeOuter(0);
// set the axis ticks based on input parameters,
// if ticks or tickValues are specified
if('undefined' !== typeof cfg['ticks']) {
xAxis.ticks(cfg['ticks']);
yAxis.ticks(cfg['ticks']);
} else if ('undefined' !== typeof cfg['tickValues']) {
xAxis.tickValues(cfg['tickValues']);
yAxis.tickValues(cfg['tickValues']);
} else {
xAxis.ticks(5);
yAxis.ticks(5);
}
// apply the format to the ticks we chose
xAxis.tickFormat(format);
yAxis.tickFormat(format);
// a function that returns a line generator
function curve(data, tpr) {
const lineGenerator = d3.line()
.curve(d3.curveBasis)
.x(d => x(d[fpr]))
.y(d => y(d[tpr]));
return lineGenerator(data);
}
// a function that returns an area generator
function areaUnderCurve(data, tpr) {
const areaGenerator = d3.area()
.x(d => x(d[fpr]))
.y0(height)
.y1(d => y(d[tpr]));
return areaGenerator(data);
}
const svg = d3.select('#roc')
.append('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom)
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
x.domain([0, 1]);
y.domain([0, 1]);
svg.append('g')
.attr('class', 'x axis')
.attr('transform', `translate(0,${height})`)
.call(xAxis)
.append('text')
.attr('x', width / 2)
.attr('y', 40 )
.style('text-anchor', 'middle')
.text('False Positive Rate')
const xAxisG = svg.select('g.x.axis');
// draw the top boundary line
xAxisG.append('line')
.attr({
'x1': -1,
'x2': width + 1,
'y1': -height,
'y2': -height
});
// draw a bottom boundary line over the existing
// x-axis domain path to make even corners
xAxisG.append('line')
.attr({
'x1': -1,
'x2': width + 1,
'y1': 0,
'y2': 0
});
// position the axis tick labels below the x-axis
xAxisG.selectAll('.tick text')
.attr('transform', `translate(0,${25})`);
// hide the y-axis ticks for 0 and 1
xAxisG.selectAll('g.tick line')
.style('opacity', d => // if d is an integer
(d % 1 === 0 ? 0 : 1));
svg.append('g')
.attr('class', 'y axis')
.call(yAxis)
.append('text')
.attr('transform', 'rotate(-90)')
.attr('y', -35)
// manually configured so that the label is centered vertically
.attr('x', 0 - height/1.56)
.style('font-size','12px')
.style('text-anchor', 'left')
.text('True Positive Rate');
yAxisG = svg.select('g.y.axis');
// add the right boundary line
yAxisG.append('line')
.attr({
'x1': width,
'x2': width,
'y1': 0,
'y2': height
})
// position the axis tick labels to the right of
// the y-axis and
// translate the first and the last tick labels
// so that they are right aligned
// or even with the 2nd digit of the decimal number
// tick labels
yAxisG.selectAll('g.tick text')
.attr('transform', d => {
if(d % 1 === 0) { // if d is an integer
return `translate(${-22},0)`;
} else if((d*10) % 1 === 0) { // if d is a 1 place decimal
return `translate(${-32},0)`;
} else {
return `translate(${-42},0)`;
}
})
// hide the y-axis ticks for 0 and 1
yAxisG.selectAll('g.tick line')
.style('opacity', d => // if d is an integer
(d % 1 === 0 ? 0 : 1));
// draw the random guess line
svg.append('line')
.attr('class', 'curve')
.style('stroke', 'black')
.attr('x1', 0)
.attr('x2', width)
.attr('y1', height)
.attr('y2', 0)
.style('stroke-width', 2)
.style('stroke-dasharray', '8')
.style('opacity', 0.4);
// draw the ROC curves
function drawCurve(data, tpr, stroke){
svg.append('path')
.attr('class', 'curve')
.style('stroke', stroke)
.attr('d', curve(data, tpr))
.on('mouseover', d => {
const areaID = `#${tpr}Area`;
svg.select(areaID)
.style('opacity', .4)
const aucText = `.${tpr}text`;
svg.selectAll(aucText)
.style('opacity', .9)
})
.on('mouseout', () => {
const areaID = `#${tpr}Area`;
svg.select(areaID)
.style('opacity', 0)
const aucText = `.${tpr}text`;
svg.selectAll(aucText)
.style('opacity', 0)
});
}
// draw the area under the ROC curves
function drawArea(data, tpr, fill) {
svg.append('path')
.attr('class', 'area')
.attr('id', `${tpr}Area`)
.style('fill', fill)
.style('opacity', 0)
.attr('d', areaUnderCurve(data, tpr))
}
function drawAUCText(auc, tpr, label) {
svg.append('g')
.attr('class', `${tpr}text`)
.style('opacity', 0)
.attr('transform', `translate(${.5*width},${.79*height})`)
.append('text')
.text(label)
.style({
'fill': 'white',
'font-size': 18
});
svg.append('g')
.attr('class', `${tpr}text`)
.style('opacity', 0)
.attr('transform', `translate(${.5*width},${.84*height})`)
.append('text')
.text(`AUC = ${aucFormat(auc)}`)
.style({
'fill': 'white',
'font-size': 18
});
}
// calculate the area under each curve
tprVariables.forEach(d => {
const tpr = d.name;
const points = generatePoints(data, fpr, tpr);
const auc = calculateArea(points);
d['auc'] = auc;
})
console.log('tprVariables', tprVariables);
// draw curves, areas, and text for each
// true-positive rate in the data
tprVariables.forEach((d, i) => {
console.log('drawing the curve for', d.label)
console.log('color(', i, ')', color(i));
const tpr = d.name;
drawArea(data, tpr, color(i))
drawCurve(data, tpr, color(i));
drawAUCText(d.auc, tpr, d.label);
})
///////////////////////////////////////////////////
////// animate through areas for each curve ///////
///////////////////////////////////////////////////
if(animate && animate !== 'false') {
//sort tprVariables ascending by AUC
const tprVariablesAscByAUC = tprVariables.sort((a, b) => a.auc - b.auc);
console.log('tprVariablesAscByAUC', tprVariablesAscByAUC);
for(let i = 0; i < tprVariablesAscByAUC.length; i++) {
areaID = `#${tprVariablesAscByAUC[i]['name']}Area`;
svg.select(areaID)
.transition()
.delay(2000 * (i+1))
.duration(250)
.style('opacity', .4)
.transition()
.delay(2000 * (i+2))
.duration(250)
.style('opacity', 0)
textClass = `.${tprVariablesAscByAUC[i]['name']}text`;
svg.selectAll(textClass)
.transition()
.delay(2000 * (i+1))
.duration(250)
.style('opacity', .9)
.transition()
.delay(2000 * (i+2))
.duration(250)
.style('opacity', 0)
}
}
///////////////////////////////////////////////////
///////////////////////////////////////////////////
///////////////////////////////////////////////////
function generatePoints(data, x, y) {
const points = [];
data.forEach(d => {
points.push([ Number(d[x]), Number(d[y]) ])
})
return points;
}
// numerical integration
function calculateArea(points) {
let area = 0.0;
const length = points.length;
if (length <= 2) {
return area;
}
points.forEach((d, i) => {
const x = 0;
const y = 1;
if('undefined' !== typeof points[i-1]){
area += (points[i][x] - points[i-1][x]) * (points[i-1][y] + points[i][y]) / 2;
}
});
return area;
}
} // rocChart
(function() {
var 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">';
window.URL = (window.URL || window.webkitURL);
var body = document.body;
var prefix = {
xmlns: "http://www.w3.org/2000/xmlns/",
xlink: "http://www.w3.org/1999/xlink",
svg: "http://www.w3.org/2000/svg"
}
initialize();
function initialize() {
var documents = [window.document],
SVGSources = [];
iframes = document.querySelectorAll("iframe"),
objects = document.querySelectorAll("object");
[].forEach.call(iframes, function(el) {
try {
if (el.contentDocument) {
documents.push(el.contentDocument);
}
} catch(err) {
console.log(err)
}
});
[].forEach.call(objects, function(el) {
try {
if (el.contentDocument) {
documents.push(el.contentDocument);
}
} catch(err) {
console.log(err)
}
});
documents.forEach(function(doc) {
var styles = getStyles(doc);
var newSources = getSources(doc, styles);
// because of prototype on NYT pages
for (var i = 0; i < newSources.length; i++) {
SVGSources.push(newSources[i]);
};
})
if (SVGSources.length > 1) {
createPopover(SVGSources);
} else if (SVGSources.length > 0) {
download(SVGSources[0]);
} else {
alert("The Crowbar couldn’t find any SVG nodes.");
}
}
function createPopover(sources) {
cleanup();
sources.forEach(function(s1) {
sources.forEach(function(s2) {
if (s1 !== s2) {
if ((Math.abs(s1.top - s2.top) < 38) && (Math.abs(s1.left - s2.left) < 38)) {
s2.top += 38;
s2.left += 38;
}
}
})
});
var buttonsContainer = document.createElement("div");
body.appendChild(buttonsContainer);
buttonsContainer.setAttribute("class", "svg-crowbar");
buttonsContainer.style["z-index"] = 1e7;
buttonsContainer.style["position"] = "absolute";
buttonsContainer.style["top"] = 0;
buttonsContainer.style["left"] = 0;
var background = document.createElement("div");
body.appendChild(background);
background.setAttribute("class", "svg-crowbar");
background.style["background"] = "rgba(255, 255, 255, 0.7)";
background.style["position"] = "fixed";
background.style["left"] = 0;
background.style["top"] = 0;
background.style["width"] = "100%";
background.style["height"] = "100%";
sources.forEach(function(d, i) {
var buttonWrapper = document.createElement("div");
buttonsContainer.appendChild(buttonWrapper);
buttonWrapper.setAttribute("class", "svg-crowbar");
buttonWrapper.style["position"] = "absolute";
buttonWrapper.style["top"] = (d.top + document.body.scrollTop) + "px";
buttonWrapper.style["left"] = (document.body.scrollLeft + d.left) + "px";
buttonWrapper.style["padding"] = "4px";
buttonWrapper.style["border-radius"] = "3px";
buttonWrapper.style["color"] = "white";
buttonWrapper.style["text-align"] = "center";
buttonWrapper.style["font-family"] = "'Helvetica Neue'";
buttonWrapper.style["background"] = "rgba(0, 0, 0, 0.8)";
buttonWrapper.style["box-shadow"] = "0px 4px 18px rgba(0, 0, 0, 0.4)";
buttonWrapper.style["cursor"] = "move";
buttonWrapper.textContent = "SVG #" + i + ": " + (d.id ? "#" + d.id : "") + (d.class ? "." + d.class : "");
var button = document.createElement("button");
buttonWrapper.appendChild(button);
button.setAttribute("data-source-id", i)
button.style["width"] = "150px";
button.style["font-size"] = "12px";
button.style["line-height"] = "1.4em";
button.style["margin"] = "5px 0 0 0";
button.textContent = "Download";
button.onclick = function(el) {
// console.log(el, d, i, sources)
download(d);
};
});
}
function cleanup() {
var crowbarElements = document.querySelectorAll(".svg-crowbar");
[].forEach.call(crowbarElements, function(el) {
el.parentNode.removeChild(el);
});
}
function getSources(doc, styles) {
var svgInfo = [],
svgs = doc.querySelectorAll("svg");
styles = (styles === undefined) ? "" : styles;
[].forEach.call(svgs, function (svg) {
svg.setAttribute("version", "1.1");
var defsEl = document.createElement("defs");
svg.insertBefore(defsEl, svg.firstChild); //TODO .insert("defs", ":first-child")
// defsEl.setAttribute("class", "svg-crowbar");
var styleEl = document.createElement("style")
defsEl.appendChild(styleEl);
styleEl.setAttribute("type", "text/css");
// 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);
}
var source = (new XMLSerializer()).serializeToString(svg).replace('</style>', '<![CDATA[' + styles + ']]></style>');
var rect = svg.getBoundingClientRect();
svgInfo.push({
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
class: svg.getAttribute("class"),
id: svg.getAttribute("id"),
childElementCount: svg.childElementCount,
source: [doctype + source]
});
});
return svgInfo;
}
function download(source) {
var 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();
}
var url = window.URL.createObjectURL(new Blob(source.source, { "type" : "text\/xml" }));
var a = document.createElement("a");
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 getStyles(doc) {
var styles = "",
styleSheets = doc.styleSheets;
if (styleSheets) {
for (var i = 0; i < styleSheets.length; i++) {
processStyleSheet(styleSheets[i]);
}
}
function processStyleSheet(ss) {
if (ss.cssRules) {
for (var i = 0; i < ss.cssRules.length; i++) {
var rule = ss.cssRules[i];
if (rule.type === 3) {
// Import Rule
processStyleSheet(rule.styleSheet);
} else {
// hack for illustrator crashing on descendent selectors
if (rule.selectorText) {
if (rule.selectorText.indexOf(">") === -1) {
styles += "\n" + rule.cssText;
}
}
}
}
}
}
return styles;
}
})();