Skip to content

Instantly share code, notes, and snippets.

@timelyportfolio
Last active September 9, 2016 14:29
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save timelyportfolio/58f8fe3e167ef47145d0 to your computer and use it in GitHub Desktop.
Save timelyportfolio/58f8fe3e167ef47145d0 to your computer and use it in GitHub Desktop.
R + d3.js Parallel Coordinates of partykit ver 2 with interactive querying
license: mit

This example builds on the first example by adding the ability to explore the partykit / rpart splits by clicking on the node information. When clicked, the parallel coordinates will be brushed corresponding to the query from the clicked node.

Introduction

This d3.js parallel coordinates plot is another experiment in how we might use interactive plots in Javascript to represent a partykit / rpart object from R. The example builds on this d3.js collapsible tree plot. Eventually, it would be nice to combine the tree and parallel coordinates into a layout with synced interactivity.


### Almost No [`rCharts`](http://rcharts.io) Also, this is fairly different from most of my interactive plots from R in that it almost completely avoids [`rCharts`](http://rcharts.io) (almost because I did use its `publish` to make this gist). I chose to exclude `rCharts` for two reasons:
  1. demo how we can use htmltools to build html/js in R
  2. help users understand some of the things rCharts does for us, such as dependency management, rendering, sharing, multi-format publishing, etc.

### Reproduce I would love for others to reproduce, fork, and improve this.

code

live example


### Thanks

It is impossible to make this list complete, but I would like to thank

library(htmltools)
library(partykit)
library(rpart)
library(pipeR)
library(rlist)
library(whisker)
#key is to define how to handle the data
#for the parallel coordinates
#expect the data = list ( partykit object, original data )
rpart_Parcoords <- function( pk = NULL, data = NULL ){
# transform our data in the way we would like
# to send it to JSON and plug into our template
# since this will be parallel coordinates
# data should be in the records form (jsonlite default)
# 1) combine the fitted data from partykit with the original data
# which allow us to see which groups each row belongs
# we'll try to be smart and sort column order by the order of splits
colorder = rapply(pk,unclass,how="unlist") %>>% #unclass and unlist the partykit
list.match("varid") %>>% #get all varids
unique %>>% unlist %>>% #squash into a vector
( names(attr(pk$terms,"dataClasses"))[c(1,.)] ) %>>% #match them with column names
( unique( c(.,colnames(data) ) ) ) #get other column names from data
# get the column name of the varids from above
data = jsonlite::toJSON( cbind( pk$fitted, data[colorder] ) )
t <- tagList(
tags$div( style = "width:100%;"
# try to get the split / node information to interact with the parcoords brush
,tags$pre( id = "partykit_info", style = "width:100%;"
# add intro.js so people know nodes are clickable
,'data-step' = "1", 'data-intro' = "click on node info to query the chart below"
,capture.output( pk %>>% print ) %>>%
(
gsub(
x = .
, pattern = "(.*)(\\[)([0-9]*)(\\])(.*)"
, replacement = "<span class = 'querynode'>\\1\\2\\3\\4\\5</span>"
)
) %>>%
paste0(collapse="\n") %>>% HTML
)
,tags$div( id = "par_container", class = "parcoords", style = "height:400px;width:100%;" )
,tags$script(
whisker.render( readLines("./layouts/chart_parcoords.html") ) %>>% HTML
#whisker.render( readLines("http://timelyportfolio.github.io/rCharts_rpart/layouts/chart_parcoords.html") ) %>>% HTML
)
)
) %>>%
attachDependencies(list(
htmlDependency(
name="d3"
,version="3.0"
,src=c("href"="http://d3js.org/")
,script="d3.v3.js"
)
,htmlDependency(
name="pc"
,version="0.4.0"
,src=c("href"="http://syntagmatic.github.com/parallel-coordinates/")
,script="d3.parcoords.js"
,stylesheet="d3.parcoords.css"
)
,htmlDependency(
name="intro"
,version="0.5.0"
,src=c("href"="http://cdnjs.cloudflare.com/ajax/libs/intro.js/0.5.0/")
,script="intro.min.js"
,stylesheet="introjs.css"
)
))
return(t)
}
#set up a little rpart as an example
rp <- rpart(
hp ~ cyl + disp + mpg + drat + wt + qsec + vs + am + gear + carb,
method = "anova",
data = mtcars,
control = rpart.control(minsplit = 4)
)
str(rp)
rpk <- as.party(rp)
#now make it a parallel coordinates
#with our rpart_Parcoords function
rpart_Parcoords( rpk, mtcars ) %>>% html_print() -> fpath
#rCharts:::publish_.gist(fpath,description="R + d3.js Parallel Coordinates of partykit ver 2 with interactive querying",id=NULL)
.parcoords > svg, .parcoords > canvas {
font: 14px sans-serif;
position: absolute;
}
.parcoords > canvas {
pointer-events: none;
}
.parcoords text.label {
cursor: default;
}
.parcoords rect.background {
fill: transparent;
}
.parcoords rect.background:hover {
fill: rgba(120,120,120,0.2);
}
.parcoords .resize rect {
fill: rgba(0,0,0,0.1);
}
.parcoords rect.extent {
fill: rgba(255,255,255,0.25);
stroke: rgba(0,0,0,0.6);
}
.parcoords .axis line, .parcoords .axis path {
fill: none;
stroke: #222;
shape-rendering: crispEdges;
}
.parcoords canvas {
opacity: 1;
-moz-transition: opacity 0.3s;
-webkit-transition: opacity 0.3s;
-o-transition: opacity 0.3s;
}
.parcoords canvas.faded {
opacity: 0.25;
}
.parcoords {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
d3.parcoords = function(config) {
var __ = {
data: [],
dimensions: [],
dimensionTitles: {},
types: {},
brushed: false,
mode: "default",
rate: 20,
width: 600,
height: 300,
margin: { top: 24, right: 0, bottom: 12, left: 0 },
color: "#069",
composite: "source-over",
alpha: 0.7,
bundlingStrength: 0.5,
bundleDimension: null,
smoothness: 0.25,
showControlPoints: false,
hideAxis : []
};
extend(__, config);
var pc = function(selection) {
selection = pc.selection = d3.select(selection);
__.width = selection[0][0].clientWidth;
__.height = selection[0][0].clientHeight;
// canvas data layers
["shadows", "marks", "foreground", "highlight"].forEach(function(layer) {
canvas[layer] = selection
.append("canvas")
.attr("class", layer)[0][0];
ctx[layer] = canvas[layer].getContext("2d");
});
// svg tick and brush layers
pc.svg = selection
.append("svg")
.attr("width", __.width)
.attr("height", __.height)
.append("svg:g")
.attr("transform", "translate(" + __.margin.left + "," + __.margin.top + ")");
return pc;
};
var events = d3.dispatch.apply(this,["render", "resize", "highlight", "brush", "brushend"].concat(d3.keys(__))),
w = function() { return __.width - __.margin.right - __.margin.left; },
h = function() { return __.height - __.margin.top - __.margin.bottom; },
flags = {
brushable: false,
reorderable: false,
axes: false,
interactive: false,
shadows: false,
debug: false
},
xscale = d3.scale.ordinal(),
yscale = {},
dragging = {},
line = d3.svg.line(),
axis = d3.svg.axis().orient("left").ticks(5),
g, // groups for axes, brushes
ctx = {},
canvas = {},
clusterCentroids = [];
// side effects for setters
var side_effects = d3.dispatch.apply(this,d3.keys(__))
.on("composite", function(d) { ctx.foreground.globalCompositeOperation = d.value; })
.on("alpha", function(d) { ctx.foreground.globalAlpha = d.value; })
.on("width", function(d) { pc.resize(); })
.on("height", function(d) { pc.resize(); })
.on("margin", function(d) { pc.resize(); })
.on("rate", function(d) { rqueue.rate(d.value); })
.on("data", function(d) {
if (flags.shadows){paths(__.data, ctx.shadows);}
})
.on("dimensions", function(d) {
xscale.domain(__.dimensions);
if (flags.interactive){pc.render().updateAxes();}
})
.on("bundleDimension", function(d) {
if (!__.dimensions.length) pc.detectDimensions();
if (!(__.dimensions[0] in yscale)) pc.autoscale();
if (typeof d.value === "number") {
if (d.value < __.dimensions.length) {
__.bundleDimension = __.dimensions[d.value];
} else if (d.value < __.hideAxis.length) {
__.bundleDimension = __.hideAxis[d.value];
}
} else {
__.bundleDimension = d.value;
}
__.clusterCentroids = compute_cluster_centroids(__.bundleDimension);
})
.on("hideAxis", function(d) {
if (!__.dimensions.length) pc.detectDimensions();
pc.dimensions(without(__.dimensions, d.value));
});
// expose the state of the chart
pc.state = __;
pc.flags = flags;
// create getter/setters
getset(pc, __, events);
// expose events
d3.rebind(pc, events, "on");
// tick formatting
d3.rebind(pc, axis, "ticks", "orient", "tickValues", "tickSubdivide", "tickSize", "tickPadding", "tickFormat");
// getter/setter with event firing
function getset(obj,state,events) {
d3.keys(state).forEach(function(key) {
obj[key] = function(x) {
if (!arguments.length) {
return state[key];
}
var old = state[key];
state[key] = x;
side_effects[key].call(pc,{"value": x, "previous": old});
events[key].call(pc,{"value": x, "previous": old});
return obj;
};
});
};
function extend(target, source) {
for (key in source) {
target[key] = source[key];
}
return target;
};
function without(arr, item) {
return arr.filter(function(elem) { return item.indexOf(elem) === -1; })
};
pc.autoscale = function() {
// yscale
var defaultScales = {
"date": function(k) {
return d3.time.scale()
.domain(d3.extent(__.data, function(d) {
return d[k] ? d[k].getTime() : null;
}))
.range([h()+1, 1]);
},
"number": function(k) {
return d3.scale.linear()
.domain(d3.extent(__.data, function(d) { return +d[k]; }))
.range([h()+1, 1]);
},
"string": function(k) {
return d3.scale.ordinal()
.domain(__.data.map(function(p) { return p[k]; }))
.rangePoints([h()+1, 1]);
}
};
__.dimensions.forEach(function(k) {
yscale[k] = defaultScales[__.types[k]](k);
});
__.hideAxis.forEach(function(k) {
yscale[k] = defaultScales[__.types[k]](k);
});
// hack to remove ordinal dimensions with many values
pc.dimensions(pc.dimensions().filter(function(p,i) {
var uniques = yscale[p].domain().length;
if (__.types[p] == "string" && (uniques > 60 || uniques < 2)) {
return false;
}
return true;
}));
// xscale
xscale.rangePoints([0, w()], 1);
// canvas sizes
pc.selection.selectAll("canvas")
.style("margin-top", __.margin.top + "px")
.style("margin-left", __.margin.left + "px")
.attr("width", w()+2)
.attr("height", h()+2);
// default styles, needs to be set when canvas width changes
ctx.foreground.strokeStyle = __.color;
ctx.foreground.lineWidth = 1.4;
ctx.foreground.globalCompositeOperation = __.composite;
ctx.foreground.globalAlpha = __.alpha;
ctx.highlight.lineWidth = 3;
ctx.shadows.strokeStyle = "#dadada";
return this;
};
pc.scale = function(d, domain) {
yscale[d].domain(domain);
return this;
};
pc.flip = function(d) {
//yscale[d].domain().reverse(); // does not work
yscale[d].domain(yscale[d].domain().reverse()); // works
return this;
};
pc.commonScale = function(global, type) {
var t = type || "number";
if (typeof global === 'undefined') {
global = true;
}
// scales of the same type
var scales = __.dimensions.concat(__.hideAxis).filter(function(p) {
return __.types[p] == t;
});
if (global) {
var extent = d3.extent(scales.map(function(p,i) {
return yscale[p].domain();
}).reduce(function(a,b) {
return a.concat(b);
}));
scales.forEach(function(d) {
yscale[d].domain(extent);
});
} else {
scales.forEach(function(k) {
yscale[k].domain(d3.extent(__.data, function(d) { return +d[k]; }));
});
}
// update centroids
if (__.bundleDimension !== null) {
pc.bundleDimension(__.bundleDimension);
}
return this;
};pc.detectDimensions = function() {
pc.types(pc.detectDimensionTypes(__.data));
pc.dimensions(d3.keys(pc.types()));
return this;
};
// a better "typeof" from this post: http://stackoverflow.com/questions/7390426/better-way-to-get-type-of-a-javascript-variable
pc.toType = function(v) {
return ({}).toString.call(v).match(/\s([a-zA-Z]+)/)[1].toLowerCase();
};
// try to coerce to number before returning type
pc.toTypeCoerceNumbers = function(v) {
if ((parseFloat(v) == v) && (v != null)) {
return "number";
}
return pc.toType(v);
};
// attempt to determine types of each dimension based on first row of data
pc.detectDimensionTypes = function(data) {
var types = {};
d3.keys(data[0])
.forEach(function(col) {
types[col] = pc.toTypeCoerceNumbers(data[0][col]);
});
return types;
};
pc.render = function() {
// try to autodetect dimensions and create scales
if (!__.dimensions.length) pc.detectDimensions();
if (!(__.dimensions[0] in yscale)) pc.autoscale();
pc.render[__.mode]();
events.render.call(this);
return this;
};
pc.render['default'] = function() {
pc.clear('foreground');
if (__.brushed) {
__.brushed.forEach(path_foreground);
} else {
__.data.forEach(path_foreground);
}
};
var rqueue = d3.renderQueue(path_foreground)
.rate(50)
.clear(function() { pc.clear('foreground'); });
pc.render.queue = function() {
if (__.brushed) {
rqueue(__.brushed);
} else {
rqueue(__.data);
}
};
function compute_cluster_centroids(d) {
var clusterCentroids = d3.map();
var clusterCounts = d3.map();
// determine clusterCounts
__.data.forEach(function(row) {
var scaled = yscale[d](row[d]);
if (!clusterCounts.has(scaled)) {
clusterCounts.set(scaled, 0);
}
var count = clusterCounts.get(scaled);
clusterCounts.set(scaled, count + 1);
});
__.data.forEach(function(row) {
__.dimensions.map(function(p, i) {
var scaled = yscale[d](row[d]);
if (!clusterCentroids.has(scaled)) {
var map = d3.map();
clusterCentroids.set(scaled, map);
}
if (!clusterCentroids.get(scaled).has(p)) {
clusterCentroids.get(scaled).set(p, 0);
}
var value = clusterCentroids.get(scaled).get(p);
value += yscale[p](row[p]) / clusterCounts.get(scaled);
clusterCentroids.get(scaled).set(p, value);
});
});
return clusterCentroids;
}
function compute_centroids(row) {
var centroids = [];
var p = __.dimensions;
var cols = p.length;
var a = 0.5; // center between axes
for (var i = 0; i < cols; ++i) {
// centroids on 'real' axes
var x = position(p[i]);
var y = yscale[p[i]](row[p[i]]);
centroids.push($V([x, y]));
// centroids on 'virtual' axes
if (i < cols - 1) {
var cx = x + a * (position(p[i+1]) - x);
var cy = y + a * (yscale[p[i+1]](row[p[i+1]]) - y);
if (__.bundleDimension !== null) {
var leftCentroid = __.clusterCentroids.get(yscale[__.bundleDimension](row[__.bundleDimension])).get(p[i]);
var rightCentroid = __.clusterCentroids.get(yscale[__.bundleDimension](row[__.bundleDimension])).get(p[i+1]);
var centroid = 0.5 * (leftCentroid + rightCentroid);
cy = centroid + (1 - __.bundlingStrength) * (cy - centroid);
}
centroids.push($V([cx, cy]));
}
}
return centroids;
}
function compute_control_points(centroids) {
var cols = centroids.length;
var a = __.smoothness;
var cps = [];
cps.push(centroids[0]);
cps.push($V([centroids[0].e(1) + a*2*(centroids[1].e(1)-centroids[0].e(1)), centroids[0].e(2)]));
for (var col = 1; col < cols - 1; ++col) {
var mid = centroids[col];
var left = centroids[col - 1];
var right = centroids[col + 1];
var diff = left.subtract(right);
cps.push(mid.add(diff.x(a)));
cps.push(mid);
cps.push(mid.subtract(diff.x(a)));
}
cps.push($V([centroids[cols-1].e(1) + a*2*(centroids[cols-2].e(1)-centroids[cols-1].e(1)), centroids[cols-1].e(2)]));
cps.push(centroids[cols - 1]);
return cps;
};pc.shadows = function() {
flags.shadows = true;
if (__.data.length > 0) {
paths(__.data, ctx.shadows);
}
return this;
};
// draw little dots on the axis line where data intersects
pc.axisDots = function() {
var ctx = pc.ctx.marks;
ctx.globalAlpha = d3.min([ 1 / Math.pow(data.length, 1 / 2), 1 ]);
__.data.forEach(function(d) {
__.dimensions.map(function(p, i) {
ctx.fillRect(position(p) - 0.75, yscale[p](d[p]) - 0.75, 1.5, 1.5);
});
});
return this;
};
// draw single cubic bezier curve
function single_curve(d, ctx) {
var centroids = compute_centroids(d);
var cps = compute_control_points(centroids);
ctx.moveTo(cps[0].e(1), cps[0].e(2));
for (var i = 1; i < cps.length; i += 3) {
if (__.showControlPoints) {
for (var j = 0; j < 3; j++) {
ctx.fillRect(cps[i+j].e(1), cps[i+j].e(2), 2, 2);
}
}
ctx.bezierCurveTo(cps[i].e(1), cps[i].e(2), cps[i+1].e(1), cps[i+1].e(2), cps[i+2].e(1), cps[i+2].e(2));
}
};
// draw single polyline
function color_path(d, ctx) {
ctx.strokeStyle = d3.functor(__.color)(d);
ctx.beginPath();
if (__.bundleDimension === null || (__.bundlingStrength === 0 && __.smoothness == 0)) {
single_path(d, ctx);
} else {
single_curve(d, ctx);
}
ctx.stroke();
};
// draw many polylines of the same color
function paths(data, ctx) {
ctx.clearRect(-1, -1, w() + 2, h() + 2);
ctx.beginPath();
data.forEach(function(d) {
if (__.bundleDimension === null || (__.bundlingStrength === 0 && __.smoothness == 0)) {
single_path(d, ctx);
} else {
single_curve(d, ctx);
}
});
ctx.stroke();
};
function single_path(d, ctx) {
__.dimensions.map(function(p, i) {
if (i == 0) {
ctx.moveTo(position(p), yscale[p](d[p]));
} else {
ctx.lineTo(position(p), yscale[p](d[p]));
}
});
}
function path_foreground(d) {
return color_path(d, ctx.foreground);
};
function path_highlight(d) {
return color_path(d, ctx.highlight);
};
pc.clear = function(layer) {
ctx[layer].clearRect(0,0,w()+2,h()+2);
return this;
};
pc.createAxes = function() {
if (g) pc.removeAxes();
// Add a group element for each dimension.
g = pc.svg.selectAll(".dimension")
.data(__.dimensions, function(d) { return d; })
.enter().append("svg:g")
.attr("class", "dimension")
.attr("transform", function(d) { return "translate(" + xscale(d) + ")"; });
// Add an axis and title.
g.append("svg:g")
.attr("class", "axis")
.attr("transform", "translate(0,0)")
.each(function(d) { d3.select(this).call(axis.scale(yscale[d])); })
.append("svg:text")
.attr({
"text-anchor": "middle",
"y": 0,
"transform": "translate(0,-12)",
"x": 0,
"class": "label"
})
.text(function(d) {
return d in __.dimensionTitles ? __.dimensionTitles[d] : d; // dimension display names
});
flags.axes= true;
return this;
};
pc.removeAxes = function() {
g.remove();
return this;
};
pc.updateAxes = function() {
var g_data = pc.svg.selectAll(".dimension")
.data(__.dimensions, function(d) { return d; });
g_data.enter().append("svg:g")
.attr("class", "dimension")
.attr("transform", function(p) { return "translate(" + position(p) + ")"; })
.style("opacity", 0)
.append("svg:g")
.attr("class", "axis")
.attr("transform", "translate(0,0)")
.each(function(d) { d3.select(this).call(axis.scale(yscale[d])); })
.append("svg:text")
.attr({
"text-anchor": "middle",
"y": 0,
"transform": "translate(0,-12)",
"x": 0,
"class": "label"
})
.text(String);
g_data.exit().remove();
g = pc.svg.selectAll(".dimension");
g.transition().duration(1100)
.attr("transform", function(p) { return "translate(" + position(p) + ")"; })
.style("opacity", 1);
pc.svg.selectAll(".axis").transition().duration(1100)
.each(function(d) { d3.select(this).call(axis.scale(yscale[d])); });
if (flags.shadows) paths(__.data, ctx.shadows);
if (flags.brushable) pc.brushable();
if (flags.reorderable) pc.reorderable();
return this;
};
pc.brushable = function() {
if (!g) pc.createAxes();
// Add and store a brush for each axis.
g.append("svg:g")
.attr("class", "brush")
.each(function(d) {
d3.select(this).call(
yscale[d].brush = d3.svg.brush()
.y(yscale[d])
.on("brushstart", function() {
d3.event.sourceEvent.stopPropagation();
})
.on("brush", pc.brush)
.on("brushend", function() {
events.brushend.call(pc, __.brushed);
})
);
})
.selectAll("rect")
.style("visibility", null)
.attr("x", -15)
.attr("width", 30);
flags.brushable = true;
return this;
};
pc.brushExtents = function() {
var extents = {};
__.dimensions.forEach(function(d) {
var brush = yscale[d].brush;
if (!brush.empty()) {
// https://github.com/mbostock/d3/wiki/SVG-Controls#brush_extent
// NOTE: According to the documentation, inversion is required *if* the
// brush is moved by the user (on mousemove following a mousedown).
// However, this gets me the wrong values, so no inversion here.
// See issue: mbostock/d3 #1981
var extent = brush.extent();
extent.sort(d3.ascending);
extents[d] = extent;
}
});
return extents;
};
// Jason Davies, http://bl.ocks.org/1341281
pc.reorderable = function() {
if (!g) pc.createAxes();
g.style("cursor", "move")
.call(d3.behavior.drag()
.on("dragstart", function(d) {
dragging[d] = this.__origin__ = xscale(d);
})
.on("drag", function(d) {
dragging[d] = Math.min(w(), Math.max(0, this.__origin__ += d3.event.dx));
__.dimensions.sort(function(a, b) { return position(a) - position(b); });
xscale.domain(__.dimensions);
pc.render();
g.attr("transform", function(d) { return "translate(" + position(d) + ")"; });
})
.on("dragend", function(d) {
delete this.__origin__;
delete dragging[d];
d3.select(this).transition().attr("transform", "translate(" + xscale(d) + ")");
pc.render();
}));
flags.reorderable = true;
return this;
};
// pairs of adjacent dimensions
pc.adjacent_pairs = function(arr) {
var ret = [];
for (var i = 0; i < arr.length-1; i++) {
ret.push([arr[i],arr[i+1]]);
};
return ret;
};
pc.interactive = function() {
flags.interactive = true;
return this;
};
// Get data within brushes
pc.brush = function() {
__.brushed = selected();
events.brush.call(pc,__.brushed);
pc.render();
};
// expose a few objects
pc.xscale = xscale;
pc.yscale = yscale;
pc.ctx = ctx;
pc.canvas = canvas;
pc.g = function() { return g; };
pc.brushReset = function(dimension) {
__.brushed = false;
if (g) {
g.selectAll('.brush')
.each(function(d) {
d3.select(this).call(
yscale[d].brush.clear()
);
});
pc.render();
}
return this;
};
// rescale for height, width and margins
// TODO currently assumes chart is brushable, and destroys old brushes
pc.resize = function() {
// selection size
pc.selection.select("svg")
.attr("width", __.width)
.attr("height", __.height)
pc.svg.attr("transform", "translate(" + __.margin.left + "," + __.margin.top + ")");
// FIXME: the current brush state should pass through
if (flags.brushable) pc.brushReset();
// scales
pc.autoscale();
// axes, destroys old brushes.
if (g) pc.createAxes();
if (flags.shadows) paths(__.data, ctx.shadows);
if (flags.brushable) pc.brushable();
if (flags.reorderable) pc.reorderable();
events.resize.call(this, {width: __.width, height: __.height, margin: __.margin});
return this;
};
// highlight an array of data
pc.highlight = function(data) {
pc.clear("highlight");
d3.select(canvas.foreground).classed("faded", true);
data.forEach(path_highlight);
events.highlight.call(this,data);
return this;
};
// clear highlighting
pc.unhighlight = function(data) {
pc.clear("highlight");
d3.select(canvas.foreground).classed("faded", false);
return this;
};
// calculate 2d intersection of line a->b with line c->d
// points are objects with x and y properties
pc.intersection = function(a, b, c, d) {
return {
x: ((a.x * b.y - a.y * b.x) * (c.x - d.x) - (a.x - b.x) * (c.x * d.y - c.y * d.x)) / ((a.x - b.x) * (c.y - d.y) - (a.y - b.y) * (c.x - d.x)),
y: ((a.x * b.y - a.y * b.x) * (c.y - d.y) - (a.y - b.y) * (c.x * d.y - c.y * d.x)) / ((a.x - b.x) * (c.y - d.y) - (a.y - b.y) * (c.x - d.x))
};
};
function is_brushed(p) {
return !yscale[p].brush.empty();
};
// data within extents
function selected() {
var actives = __.dimensions.filter(is_brushed),
extents = actives.map(function(p) { return yscale[p].brush.extent(); });
// We don't want to return the full data set when there are no axes brushed.
// Actually, when there are no axes brushed, by definition, no items are
// selected. So, let's avoid the filtering and just return false.
if (actives.length === 0) return false;
// test if within range
var within = {
"date": function(d,p,dimension) {
return extents[dimension][0] <= d[p] && d[p] <= extents[dimension][1]
},
"number": function(d,p,dimension) {
return extents[dimension][0] <= d[p] && d[p] <= extents[dimension][1]
},
"string": function(d,p,dimension) {
return extents[dimension][0] <= yscale[p](d[p]) && yscale[p](d[p]) <= extents[dimension][1]
}
};
return __.data
.filter(function(d) {
return actives.every(function(p, dimension) {
return within[__.types[p]](d,p,dimension);
});
});
};
function position(d) {
var v = dragging[d];
return v == null ? xscale(d) : v;
}
pc.toString = function() { return "Parallel Coordinates: " + __.dimensions.length + " dimensions (" + d3.keys(__.data[0]).length + " total) , " + __.data.length + " rows"; };
pc.version = "0.4.0";
return pc;
};
d3.renderQueue = (function(func) {
var _queue = [], // data to be rendered
_rate = 10, // number of calls per frame
_clear = function() {}, // clearing function
_i = 0; // current iteration
var rq = function(data) {
if (data) rq.data(data);
rq.invalidate();
_clear();
rq.render();
};
rq.render = function() {
_i = 0;
var valid = true;
rq.invalidate = function() { valid = false; };
function doFrame() {
if (!valid) return true;
if (_i > _queue.length) return true;
var chunk = _queue.slice(_i,_i+_rate);
_i += _rate;
chunk.map(func);
}
d3.timer(doFrame);
};
rq.data = function(data) {
rq.invalidate();
_queue = data.slice(0);
return rq;
};
rq.rate = function(value) {
if (!arguments.length) return _rate;
_rate = value;
return rq;
};
rq.remaining = function() {
return _queue.length - _i;
};
// clear the canvas
rq.clear = function(func) {
if (!arguments.length) {
_clear();
return rq;
}
_clear = func;
return rq;
};
rq.invalidate = function() {};
return rq;
});
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<script src="http://d3js.org/d3.v3.js"></script>
<link href="d3.parcoords.css" rel="stylesheet" />
<script src="d3.parcoords.js"></script>
<link href="http://cdnjs.cloudflare.com/ajax/libs/intro.js/0.5.0/introjs.css" rel="stylesheet" />
<script src="http://cdnjs.cloudflare.com/ajax/libs/intro.js/0.5.0/intro.min.js"></script>
</head>
<body>
<div style="width:100%;">
<pre id="partykit_info" style="width:100%;" data-step="1" data-intro="click on node info to query the chart below">
Model formula:
hp ~ cyl + disp + mpg + drat + wt + qsec + vs + am + gear + carb
Fitted party:
<span class = 'querynode'>[1] root</span>
<span class = 'querynode'>| [2] cyl < 7</span>
<span class = 'querynode'>| | [3] mpg >= 21.45</span>
<span class = 'querynode'>| | | [4] disp < 87.05: 62.250 (n = 4, err = 140.8)</span>
<span class = 'querynode'>| | | [5] disp >= 87.05: 91.833 (n = 6, err = 1376.8)</span>
<span class = 'querynode'>| | [6] mpg < 21.45</span>
<span class = 'querynode'>| | | [7] qsec >= 15.98: 112.857 (n = 7, err = 306.9)</span>
<span class = 'querynode'>| | | [8] qsec < 15.98: 175.000 (n = 1, err = 0.0)</span>
<span class = 'querynode'>| [9] cyl >= 7</span>
<span class = 'querynode'>| | [10] drat < 3.18</span>
<span class = 'querynode'>| | | [11] mpg >= 12.8: 170.000 (n = 7, err = 1150.0)</span>
<span class = 'querynode'>| | | [12] mpg < 12.8: 210.000 (n = 2, err = 50.0)</span>
<span class = 'querynode'>| | [13] drat >= 3.18</span>
<span class = 'querynode'>| | | [14] carb < 6: 246.000 (n = 4, err = 582.0)</span>
<span class = 'querynode'>| | | [15] carb >= 6: 335.000 (n = 1, err = 0.0)</span>
Number of inner nodes: 7
Number of terminal nodes: 8</pre>
<div id="par_container" class="parcoords" style="height:400px;width:100%;"></div>
<script> var data = [{"$row":"Mazda RX4","(fitted)":7,"(response)":110,"hp":110,"cyl":6,"mpg":21,"disp":160,"drat":3.9,"wt":2.62,"qsec":16.46,"am":1,"carb":4,"gear":4,"vs":0},{"$row":"Mazda RX4 Wag","(fitted)":7,"(response)":110,"hp":110,"cyl":6,"mpg":21,"disp":160,"drat":3.9,"wt":2.875,"qsec":17.02,"am":1,"carb":4,"gear":4,"vs":0},{"$row":"Datsun 710","(fitted)":5,"(response)":93,"hp":93,"cyl":4,"mpg":22.8,"disp":108,"drat":3.85,"wt":2.32,"qsec":18.61,"am":1,"carb":1,"gear":4,"vs":1},{"$row":"Hornet 4 Drive","(fitted)":7,"(response)":110,"hp":110,"cyl":6,"mpg":21.4,"disp":258,"drat":3.08,"wt":3.215,"qsec":19.44,"am":0,"carb":1,"gear":3,"vs":1},{"$row":"Hornet Sportabout","(fitted)":11,"(response)":175,"hp":175,"cyl":8,"mpg":18.7,"disp":360,"drat":3.15,"wt":3.44,"qsec":17.02,"am":0,"carb":2,"gear":3,"vs":0},{"$row":"Valiant","(fitted)":7,"(response)":105,"hp":105,"cyl":6,"mpg":18.1,"disp":225,"drat":2.76,"wt":3.46,"qsec":20.22,"am":0,"carb":1,"gear":3,"vs":1},{"$row":"Duster 360","(fitted)":14,"(response)":245,"hp":245,"cyl":8,"mpg":14.3,"disp":360,"drat":3.21,"wt":3.57,"qsec":15.84,"am":0,"carb":4,"gear":3,"vs":0},{"$row":"Merc 240D","(fitted)":5,"(response)":62,"hp":62,"cyl":4,"mpg":24.4,"disp":146.7,"drat":3.69,"wt":3.19,"qsec":20,"am":0,"carb":2,"gear":4,"vs":1},{"$row":"Merc 230","(fitted)":5,"(response)":95,"hp":95,"cyl":4,"mpg":22.8,"disp":140.8,"drat":3.92,"wt":3.15,"qsec":22.9,"am":0,"carb":2,"gear":4,"vs":1},{"$row":"Merc 280","(fitted)":7,"(response)":123,"hp":123,"cyl":6,"mpg":19.2,"disp":167.6,"drat":3.92,"wt":3.44,"qsec":18.3,"am":0,"carb":4,"gear":4,"vs":1},{"$row":"Merc 280C","(fitted)":7,"(response)":123,"hp":123,"cyl":6,"mpg":17.8,"disp":167.6,"drat":3.92,"wt":3.44,"qsec":18.9,"am":0,"carb":4,"gear":4,"vs":1},{"$row":"Merc 450SE","(fitted)":11,"(response)":180,"hp":180,"cyl":8,"mpg":16.4,"disp":275.8,"drat":3.07,"wt":4.07,"qsec":17.4,"am":0,"carb":3,"gear":3,"vs":0},{"$row":"Merc 450SL","(fitted)":11,"(response)":180,"hp":180,"cyl":8,"mpg":17.3,"disp":275.8,"drat":3.07,"wt":3.73,"qsec":17.6,"am":0,"carb":3,"gear":3,"vs":0},{"$row":"Merc 450SLC","(fitted)":11,"(response)":180,"hp":180,"cyl":8,"mpg":15.2,"disp":275.8,"drat":3.07,"wt":3.78,"qsec":18,"am":0,"carb":3,"gear":3,"vs":0},{"$row":"Cadillac Fleetwood","(fitted)":12,"(response)":205,"hp":205,"cyl":8,"mpg":10.4,"disp":472,"drat":2.93,"wt":5.25,"qsec":17.98,"am":0,"carb":4,"gear":3,"vs":0},{"$row":"Lincoln Continental","(fitted)":12,"(response)":215,"hp":215,"cyl":8,"mpg":10.4,"disp":460,"drat":3,"wt":5.424,"qsec":17.82,"am":0,"carb":4,"gear":3,"vs":0},{"$row":"Chrysler Imperial","(fitted)":14,"(response)":230,"hp":230,"cyl":8,"mpg":14.7,"disp":440,"drat":3.23,"wt":5.345,"qsec":17.42,"am":0,"carb":4,"gear":3,"vs":0},{"$row":"Fiat 128","(fitted)":4,"(response)":66,"hp":66,"cyl":4,"mpg":32.4,"disp":78.7,"drat":4.08,"wt":2.2,"qsec":19.47,"am":1,"carb":1,"gear":4,"vs":1},{"$row":"Honda Civic","(fitted)":4,"(response)":52,"hp":52,"cyl":4,"mpg":30.4,"disp":75.7,"drat":4.93,"wt":1.615,"qsec":18.52,"am":1,"carb":2,"gear":4,"vs":1},{"$row":"Toyota Corolla","(fitted)":4,"(response)":65,"hp":65,"cyl":4,"mpg":33.9,"disp":71.1,"drat":4.22,"wt":1.835,"qsec":19.9,"am":1,"carb":1,"gear":4,"vs":1},{"$row":"Toyota Corona","(fitted)":5,"(response)":97,"hp":97,"cyl":4,"mpg":21.5,"disp":120.1,"drat":3.7,"wt":2.465,"qsec":20.01,"am":0,"carb":1,"gear":3,"vs":1},{"$row":"Dodge Challenger","(fitted)":11,"(response)":150,"hp":150,"cyl":8,"mpg":15.5,"disp":318,"drat":2.76,"wt":3.52,"qsec":16.87,"am":0,"carb":2,"gear":3,"vs":0},{"$row":"AMC Javelin","(fitted)":11,"(response)":150,"hp":150,"cyl":8,"mpg":15.2,"disp":304,"drat":3.15,"wt":3.435,"qsec":17.3,"am":0,"carb":2,"gear":3,"vs":0},{"$row":"Camaro Z28","(fitted)":14,"(response)":245,"hp":245,"cyl":8,"mpg":13.3,"disp":350,"drat":3.73,"wt":3.84,"qsec":15.41,"am":0,"carb":4,"gear":3,"vs":0},{"$row":"Pontiac Firebird","(fitted)":11,"(response)":175,"hp":175,"cyl":8,"mpg":19.2,"disp":400,"drat":3.08,"wt":3.845,"qsec":17.05,"am":0,"carb":2,"gear":3,"vs":0},{"$row":"Fiat X1-9","(fitted)":4,"(response)":66,"hp":66,"cyl":4,"mpg":27.3,"disp":79,"drat":4.08,"wt":1.935,"qsec":18.9,"am":1,"carb":1,"gear":4,"vs":1},{"$row":"Porsche 914-2","(fitted)":5,"(response)":91,"hp":91,"cyl":4,"mpg":26,"disp":120.3,"drat":4.43,"wt":2.14,"qsec":16.7,"am":1,"carb":2,"gear":5,"vs":0},{"$row":"Lotus Europa","(fitted)":5,"(response)":113,"hp":113,"cyl":4,"mpg":30.4,"disp":95.1,"drat":3.77,"wt":1.513,"qsec":16.9,"am":1,"carb":2,"gear":5,"vs":1},{"$row":"Ford Pantera L","(fitted)":14,"(response)":264,"hp":264,"cyl":8,"mpg":15.8,"disp":351,"drat":4.22,"wt":3.17,"qsec":14.5,"am":1,"carb":4,"gear":5,"vs":0},{"$row":"Ferrari Dino","(fitted)":8,"(response)":175,"hp":175,"cyl":6,"mpg":19.7,"disp":145,"drat":3.62,"wt":2.77,"qsec":15.5,"am":1,"carb":6,"gear":5,"vs":0},{"$row":"Maserati Bora","(fitted)":15,"(response)":335,"hp":335,"cyl":8,"mpg":15,"disp":301,"drat":3.54,"wt":3.57,"qsec":14.6,"am":1,"carb":8,"gear":5,"vs":0},{"$row":"Volvo 142E","(fitted)":7,"(response)":109,"hp":109,"cyl":4,"mpg":21.4,"disp":121,"drat":4.11,"wt":2.78,"qsec":18.6,"am":1,"carb":2,"gear":4,"vs":1}]
//sort our data by fitted or the assigned group
data = data.sort(function(a,b){
return d3.ascending(a["(fitted)"],b["(fitted)"])
});
var colorgen = d3.scale.category10();
var colors = {};
data.map(function(d,i){
colors[d["(fitted)"]] = colorgen(d["(fitted)"])
});
var color = function(d) { return colors[d["(fitted)"]]; };
var parcoords = d3.parcoords()("#par_container")
.color(color)
.alpha(0.4)
.data(data)
//.bundlingStrength(0.8) // set bundling strength
//.smoothness(0.15)
//.bundleDimension("rtn_rank")
.showControlPoints(false)
.margin({ top: 100, left: 150, bottom: 12, right: 20 })
.render()
.brushable() // enable brushing
.reorderable()
.interactive() // command line mode
//remove rownames (first) label for axis
d3.select(".dimension .axis > text").remove();
//highlight paths on hover of rownames / label
d3.selectAll("#par_container > svg > g > g:nth-child(1) > g.axis > g > text")
.on("mouseover", highlight )
.on("mouseout", unhighlight )
.style("fill",function(d){
return colors[d];
})
function highlight(e){
var that = this;
var tohighlight = data.filter(function(row){
return row["$row"] == d3.select(that).datum();
});
parcoords.highlight(
tohighlight
);
}
function unhighlight(e){
var that = this;
parcoords.unhighlight(
data.filter(function(row){
return row["$row"] == d3.select(that).datum();
})
);
}
introJs().start();
// add interactivity for the node information to query / brush the parcoords
// we classed these as querynode
// however ignore root by doing nth-child(n+2)
d3.selectAll("#partykit_info > .querynode:nth-child(n+2) ")
.style("cursor","pointer")
.on("click",queryNode)
.each(function(d){
var that = d3.select(this);
that.datum(getQuery( that ).split(/[<,>,=]/)[0].replace(/\s/g,""));
return that;
})
function queryNode(){
var node = d3.select(this)
var queried = !node.classed("queried")
// get the query
var q = getQuery(node);
if(queried){
// to eliminate extra css do the bolding here
// eventually though move to css style file
drawBrush( q );
node
.style("font-size","125%")
.style("font-weight","bold")
.classed("queried",queried)
} else {
// clear the query
node
.style("font-size","")
.style("font-weight","")
.classed("queried",queried)
clearBrush( q )
}
}
// function to strip the query out of the text
function getQuery( s ){
// for now text will be the text contained in the span
// we'll use some regex to strip out the query
var q = s.text().replace(/\|/g,"").split(/[\],:]/)[1]
return q;
}
function drawBrush( q ){
// our variable will be before <,>,=
var queryVar = q.split(/[<,>,=]/)[0].replace(/\s/g,"");
// if brush already defined on this variable then remove it
// actually just remove the queried class and style
// new brush will supersede old brushed points
// not ideal behavior but joint brushes will get very complex
d3.selectAll("#partykit_info > .querynode:nth-child(n+2) ").filter(function(d){
return d == queryVar
}).style("font-size","")
.style("font-weight","")
.classed("queried",false)
var queryBrush = parcoords.yscale[queryVar].brush
.on("brushstart", function() {});
// define our brush extent to be from the split up or down to top of axis
// if we find a < then draw down so extent min will be bottom of axis
// and extent max will be our condition
if(q.match(/</)){
queryBrush.extent([
parcoords.yscale[queryVar].domain()[0] ,
q.split(/[<,>,=,:]/).slice(+q.split(/[<,>,=,:]/).length-1)[0].replace(/\s/g,"")
])
} else {
queryBrush.extent([
q.split(/[<,>,=,:]/).slice(+q.split(/[<,>,=,:]/).length-1)[0].replace(/\s/g,""),
parcoords.yscale[queryVar].domain()[1]
])
}
// now draw the brush to match our extent
// use transition to slow it down so we can see what is happening
// remove transition so just d3.select(".brush") to just draw
queryBrush(d3.selectAll(".brush").filter(function(b){return b == queryVar}).transition());
// now fire the brushstart, brushmove, and brushend events
// remove transition so just d3.select(".brush") to just draw
queryBrush.event(d3.selectAll(".brush").filter(function(b){return b == queryVar}).transition())
}
function clearBrush( q ){
// our variable will be before <,>,=
var queryVar = q.split(/[<,>,=]/)[0].replace(/\s/g,"");
var queryBrush = parcoords.yscale[queryVar].brush
queryBrush.extent([parcoords.yscale[queryVar].domain()[1],parcoords.yscale[queryVar].domain()[1]])
// now draw the brush to match our extent
// use transition to slow it down so we can see what is happening
// remove transition so just d3.select(".brush") to just draw
queryBrush(d3.selectAll(".brush").filter(function(b){return b == queryVar}).transition());
// now fire the brushstart, brushmove, and brushend events
// remove transition so just d3.select(".brush") to just draw
queryBrush.event(d3.selectAll(".brush").filter(function(b){return b == queryVar}).transition())
}</script>
</div>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment