Skip to content

Instantly share code, notes, and snippets.

@Goomble
Created February 27, 2017 08:01
Show Gist options
  • Save Goomble/cbf739e5f7399a51210cbb036e16ab9c to your computer and use it in GitHub Desktop.
Save Goomble/cbf739e5f7399a51210cbb036e16ab9c to your computer and use it in GitHub Desktop.
d3 | Parallel Coordinates
license: mit
Bud1%  @� @� @� @ E%DSDB`� @� @� @
d3.parcoords = function(config) {
var __ = {
data: [],
highlighted: [],
dimensions: [],
dimensionTitles: {},
dimensionTitleRotation: 0,
types: {},
brushed: false,
mode: "default",
rate: 20,
width: 940,
height: 450,
margin: { top: 24, right: 0, bottom: 24, 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", "axesreorder"].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);
__.highlighted.forEach(path_highlight);
} else {
__.data.forEach(path_foreground);
__.highlighted.forEach(path_highlight);
}
};
var rqueue = d3.renderQueue(path_foreground)
.rate(50)
.clear(function() {
pc.clear('foreground');
pc.clear('highlight');
});
pc.render.queue = function() {
if (__.brushed) {
rqueue(__.brushed);
__.highlighted.forEach(path_highlight);
} else {
rqueue(__.data);
__.highlighted.forEach(path_highlight);
}
};
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, i, ctx) {
ctx.strokeStyle = d3.functor(__.color)(d, i);
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, i) {
return color_path(d, i, ctx.foreground);
};
function path_highlight(d, i) {
return color_path(d, i, ctx.highlight);
};
pc.clear = function(layer) {
ctx[layer].clearRect(0,0,w()+2,h()+2);
return this;
};
function flipAxisAndUpdatePCP(dimension, i) {
var g = pc.svg.selectAll(".dimension");
pc.flip(dimension);
d3.select(g[0][i])
.transition()
.duration(1100)
.call(axis.scale(yscale[dimension]));
pc.render();
if (flags.shadows) paths(__.data, ctx.shadows);
}
function rotateLabels() {
var delta = d3.event.deltaY;
delta = delta < 0 ? -5 : delta;
delta = delta > 0 ? 5 : delta;
__.dimensionTitleRotation += delta;
pc.svg.selectAll("text.label")
.attr("transform", "translate(0,-5) rotate(" + __.dimensionTitleRotation + ")");
d3.event.preventDefault();
}
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,-5) rotate(" + __.dimensionTitleRotation + ")",
"x": 0,
"class": "label"
})
.text(function(d) {
return d in __.dimensionTitles ? __.dimensionTitles[d] : d; // dimension display names
})
.on("dblclick", flipAxisAndUpdatePCP)
.on("wheel", rotateLabels);
flags.axes= true;
return this;
};
pc.removeAxes = function() {
g.remove();
return this;
};
pc.updateAxes = function() {
var g_data = pc.svg.selectAll(".dimension").data(__.dimensions);
// Enter
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,-5) rotate(" + __.dimensionTitleRotation + ")",
"x": 0,
"class": "label"
})
.text(String)
.on("dblclick", flipAxisAndUpdatePCP)
.on("wheel", rotateLabels);
// Update
g_data.attr("opacity", 0);
g_data.select(".axis")
.transition()
.duration(1100)
.each(function(d) {
d3.select(this).call(axis.scale(yscale[d]));
});
g_data.select(".label")
.transition()
.duration(1100)
.text(String)
.attr("transform", "translate(0,-5) rotate(" + __.dimensionTitleRotation + ")");
// Exit
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();
if (pc.brushMode() !== "None") {
var mode = pc.brushMode();
pc.brushMode("None");
pc.brushMode(mode);
}
return this;
};
// Jason Davies, http://bl.ocks.org/1341281
pc.reorderable = function() {
if (!g) pc.createAxes();
// Keep track of the order of the axes to verify if the order has actually
// changed after a drag ends. Changed order might have consequence (e.g.
// strums that need to be reset).
var dimsAtDragstart;
g.style("cursor", "move")
.call(d3.behavior.drag()
.on("dragstart", function(d) {
dragging[d] = this.__origin__ = xscale(d);
dimsAtDragstart = __.dimensions.slice();
})
.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) {
// Let's see if the order has changed and send out an event if so.
var orderChanged = dimsAtDragstart.some(function(d, i) {
return d !== __.dimensions[i];
});
if (orderChanged) {
events.axesreorder.call(pc, __.dimensions);
}
delete this.__origin__;
delete dragging[d];
d3.select(this).transition().attr("transform", "translate(" + xscale(d) + ")");
pc.render();
if (flags.shadows) paths(__.data, ctx.shadows);
}));
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;
};
var brush = {
modes: {
"None": {
install: function(pc) {}, // Nothing to be done.
uninstall: function(pc) {}, // Nothing to be done.
selected: function() { return []; } // Nothing to return
}
},
mode: "None",
predicate: "AND",
currentMode: function() {
return this.modes[this.mode];
}
};
// This function can be used for 'live' updates of brushes. That is, during the
// specification of a brush, this method can be called to update the view.
//
// @param newSelection - The new set of data items that is currently contained
// by the brushes
function brushUpdated(newSelection) {
__.brushed = newSelection;
events.brush.call(pc,__.brushed);
pc.render();
}
function brushPredicate(predicate) {
if (!arguments.length) { return brush.predicate; }
predicate = String(predicate).toUpperCase();
if (predicate !== "AND" && predicate !== "OR") {
throw "Invalid predicate " + predicate;
}
brush.predicate = predicate;
__.brushed = brush.currentMode().selected();
pc.render();
return pc;
}
pc.brushModes = function() {
return Object.getOwnPropertyNames(brush.modes);
};
pc.brushMode = function(mode) {
if (arguments.length === 0) {
return brush.mode;
}
if (pc.brushModes().indexOf(mode) === -1) {
throw "pc.brushmode: Unsupported brush mode: " + mode;
}
// Make sure that we don't trigger unnecessary events by checking if the mode
// actually changes.
if (mode !== brush.mode) {
// When changing brush modes, the first thing we need to do is clearing any
// brushes from the current mode, if any.
if (brush.mode !== "None") {
pc.brushReset();
}
// Next, we need to 'uninstall' the current brushMode.
brush.modes[brush.mode].uninstall(pc);
// Finally, we can install the requested one.
brush.mode = mode;
brush.modes[brush.mode].install();
if (mode === "None") {
delete pc.brushPredicate;
} else {
pc.brushPredicate = brushPredicate;
}
}
return pc;
};
// brush mode: 1D-Axes
(function() {
var brushes = {};
function is_brushed(p) {
return !brushes[p].empty();
}
// data within extents
function selected() {
var actives = __.dimensions.filter(is_brushed),
extents = actives.map(function(p) { return brushes[p].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;
// Resolves broken examples for now. They expect to get the full dataset back from empty brushes
if (actives.length === 0) return __.data;
// 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) {
switch(brush.predicate) {
case "AND":
return actives.every(function(p, dimension) {
return within[__.types[p]](d,p,dimension);
});
case "OR":
return actives.some(function(p, dimension) {
return within[__.types[p]](d,p,dimension);
});
default:
throw "Unknown brush predicate " + __.brushPredicate;
}
});
};
function brushExtents() {
var extents = {};
__.dimensions.forEach(function(d) {
var brush = brushes[d];
if (!brush.empty()) {
var extent = brush.extent();
extent.sort(d3.ascending);
extents[d] = extent;
}
});
return extents;
}
function brushFor(axis) {
var brush = d3.svg.brush();
brush
.y(yscale[axis])
.on("brushstart", function() { d3.event.sourceEvent.stopPropagation() })
.on("brush", function() {
brushUpdated(selected());
})
.on("brushend", function() {
events.brushend.call(pc, __.brushed);
});
brushes[axis] = brush;
return brush;
}
function brushReset(dimension) {
__.brushed = false;
if (g) {
g.selectAll('.brush')
.each(function(d) {
d3.select(this).call(
brushes[d].clear()
);
});
pc.render();
}
return this;
};
function install() {
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(brushFor(d));
})
.selectAll("rect")
.style("visibility", null)
.attr("x", -15)
.attr("width", 30);
pc.brushExtents = brushExtents;
pc.brushReset = brushReset;
return pc;
}
brush.modes["1D-axes"] = {
install: install,
uninstall: function() {
g.selectAll(".brush").remove();
brushes = {};
delete pc.brushExtents;
delete pc.brushReset;
},
selected: selected
}
})();
// brush mode: 2D-strums
// bl.ocks.org/syntagmatic/5441022
(function() {
var strums = {},
strumRect;
function drawStrum(strum, activePoint) {
var svg = pc.selection.select("svg").select("g#strums"),
id = strum.dims.i,
points = [strum.p1, strum.p2],
line = svg.selectAll("line#strum-" + id).data([strum]),
circles = svg.selectAll("circle#strum-" + id).data(points),
drag = d3.behavior.drag();
line.enter()
.append("line")
.attr("id", "strum-" + id)
.attr("class", "strum");
line
.attr("x1", function(d) { return d.p1[0]; })
.attr("y1", function(d) { return d.p1[1]; })
.attr("x2", function(d) { return d.p2[0]; })
.attr("y2", function(d) { return d.p2[1]; })
.attr("stroke", "black")
.attr("stroke-width", 2);
drag
.on("drag", function(d, i) {
var ev = d3.event;
i = i + 1;
strum["p" + i][0] = Math.min(Math.max(strum.minX + 1, ev.x), strum.maxX);
strum["p" + i][1] = Math.min(Math.max(strum.minY, ev.y), strum.maxY);
drawStrum(strum, i - 1);
})
.on("dragend", onDragEnd());
circles.enter()
.append("circle")
.attr("id", "strum-" + id)
.attr("class", "strum");
circles
.attr("cx", function(d) { return d[0]; })
.attr("cy", function(d) { return d[1]; })
.attr("r", 5)
.style("opacity", function(d, i) {
return (activePoint !== undefined && i === activePoint) ? 0.8 : 0;
})
.on("mouseover", function() {
d3.select(this).style("opacity", 0.8);
})
.on("mouseout", function() {
d3.select(this).style("opacity", 0);
})
.call(drag);
}
function dimensionsForPoint(p) {
var dims = { i: -1, left: undefined, right: undefined };
__.dimensions.some(function(dim, i) {
if (xscale(dim) < p[0]) {
var next = __.dimensions[i + 1];
dims.i = i;
dims.left = dim;
dims.right = next;
return false;
}
return true;
});
if (dims.left === undefined) {
// Event on the left side of the first axis.
dims.i = 0;
dims.left = __.dimensions[0];
dims.right = __.dimensions[1];
} else if (dims.right === undefined) {
// Event on the right side of the last axis
dims.i = __.dimensions.length - 1;
dims.right = dims.left;
dims.left = __.dimensions[__.dimensions.length - 2];
}
return dims;
}
function onDragStart() {
// First we need to determine between which two axes the sturm was started.
// This will determine the freedom of movement, because a strum can
// logically only happen between two axes, so no movement outside these axes
// should be allowed.
return function() {
var p = d3.mouse(strumRect[0][0]),
dims = dimensionsForPoint(p),
strum = {
p1: p,
dims: dims,
minX: xscale(dims.left),
maxX: xscale(dims.right),
minY: 0,
maxY: h()
};
strums[dims.i] = strum;
strums.active = dims.i;
// Make sure that the point is within the bounds
strum.p1[0] = Math.min(Math.max(strum.minX, p[0]), strum.maxX);
strum.p1[1] = p[1] - __.margin.top;
strum.p2 = strum.p1.slice();
};
}
function onDrag() {
return function() {
var ev = d3.event,
strum = strums[strums.active];
// Make sure that the point is within the bounds
strum.p2[0] = Math.min(Math.max(strum.minX + 1, ev.x), strum.maxX);
strum.p2[1] = Math.min(Math.max(strum.minY, ev.y - __.margin.top), strum.maxY);
drawStrum(strum, 1);
};
}
function containmentTest(strum, width) {
var p1 = [strum.p1[0] - strum.minX, strum.p1[1] - strum.minX],
p2 = [strum.p2[0] - strum.minX, strum.p2[1] - strum.minX],
m1 = 1 - width / p1[0],
b1 = p1[1] * (1 - m1),
m2 = 1 - width / p2[0],
b2 = p2[1] * (1 - m2);
// test if point falls between lines
return function(p) {
var x = p[0],
y = p[1],
y1 = m1 * x + b1,
y2 = m2 * x + b2;
if (y > Math.min(y1, y2) && y < Math.max(y1, y2)) {
return true;
}
return false;
};
}
function selected() {
var ids = Object.getOwnPropertyNames(strums),
brushed = __.data;
// Get the ids of the currently active strums.
ids = ids.filter(function(d) {
return !isNaN(d);
});
function crossesStrum(d, id) {
var strum = strums[id],
test = containmentTest(strum, strums.width(id)),
d1 = strum.dims.left,
d2 = strum.dims.right,
y1 = yscale[d1],
y2 = yscale[d2],
point = [y1(d[d1]) - strum.minX, y2(d[d2]) - strum.minX];
return test(point);
}
if (ids.length === 0) { return brushed; }
return brushed.filter(function(d) {
switch(brush.predicate) {
case "AND":
return ids.every(function(id) { return crossesStrum(d, id); });
case "OR":
return ids.some(function(id) { return crossesStrum(d, id); });
default:
throw "Unknown brush predicate " + __.brushPredicate;
}
});
}
function removeStrum() {
var strum = strums[strums.active],
svg = pc.selection.select("svg").select("g#strums");
delete strums[strums.active];
strums.active = undefined;
svg.selectAll("line#strum-" + strum.dims.i).remove();
svg.selectAll("circle#strum-" + strum.dims.i).remove();
}
function onDragEnd() {
return function() {
var brushed = __.data,
strum = strums[strums.active];
// Okay, somewhat unexpected, but not totally unsurprising, a mousclick is
// considered a drag without move. So we have to deal with that case
if (strum && strum.p1[0] === strum.p2[0] && strum.p1[1] === strum.p2[1]) {
removeStrum(strums);
}
brushed = selected(strums);
strums.active = undefined;
__.brushed = brushed;
pc.render();
events.brushend.call(pc, __.brushed);
};
}
function brushReset(strums) {
return function() {
var ids = Object.getOwnPropertyNames(strums).filter(function(d) {
return !isNaN(d);
});
ids.forEach(function(d) {
strums.active = d;
removeStrum(strums);
});
onDragEnd(strums)();
};
}
function install() {
var drag = d3.behavior.drag();
// Map of current strums. Strums are stored per segment of the PC. A segment,
// being the area between two axes. The left most area is indexed at 0.
strums.active = undefined;
// Returns the width of the PC segment where currently a strum is being
// placed. NOTE: even though they are evenly spaced in our current
// implementation, we keep for when non-even spaced segments are supported as
// well.
strums.width = function(id) {
var strum = strums[id];
if (strum === undefined) {
return undefined;
}
return strum.maxX - strum.minX;
};
pc.on("axesreorder.strums", function() {
var ids = Object.getOwnPropertyNames(strums).filter(function(d) {
return !isNaN(d);
});
// Checks if the first dimension is directly left of the second dimension.
function consecutive(first, second) {
var length = __.dimensions.length;
return __.dimensions.some(function(d, i) {
return (d === first)
? i + i < length && __.dimensions[i + 1] === second
: false;
});
}
if (ids.length > 0) { // We have some strums, which might need to be removed.
ids.forEach(function(d) {
var dims = strums[d].dims;
strums.active = d;
// If the two dimensions of the current strum are not next to each other
// any more, than we'll need to remove the strum. Otherwise we keep it.
if (!consecutive(dims.left, dims.right)) {
removeStrum(strums);
}
});
onDragEnd(strums)();
}
});
// Add a new svg group in which we draw the strums.
pc.selection.select("svg").append("g")
.attr("id", "strums")
.attr("transform", "translate(" + __.margin.left + "," + __.margin.top + ")");
// Install the required brushReset function
pc.brushReset = brushReset(strums);
drag
.on("dragstart", onDragStart(strums))
.on("drag", onDrag(strums))
.on("dragend", onDragEnd(strums));
// NOTE: The styling needs to be done here and not in the css. This is because
// for 1D brushing, the canvas layers should not listen to
// pointer-events.
strumRect = pc.selection.select("svg").insert("rect", "g#strums")
.attr("id", "strum-events")
.attr("x", __.margin.left)
.attr("y", __.margin.top)
.attr("width", w())
.attr("height", h() + 2)
.style("opacity", 0)
.call(drag);
}
brush.modes["2D-strums"] = {
install: install,
uninstall: function() {
pc.selection.select("svg").select("g#strums").remove();
pc.selection.select("svg").select("rect#strum-events").remove();
pc.on("axesreorder.strums", undefined);
delete pc.brushReset;
strumRect = undefined;
},
selected: selected
};
}());
pc.interactive = function() {
flags.interactive = true;
return this;
};
// expose a few objects
pc.xscale = xscale;
pc.yscale = yscale;
pc.ctx = ctx;
pc.canvas = canvas;
pc.g = function() { return g; };
// 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) {
if (arguments.length === 0) {
return __.highlighted;
}
__.highlighted = 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() {
__.highlighted = [];
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 position(d) {
var v = dragging[d];
return v == null ? xscale(d) : v;
}
pc.version = "0.5.0";
// this descriptive text should live with other introspective methods
pc.toString = function() { return "Parallel Coordinates: " + __.dimensions.length + " dimensions (" + d3.keys(__.data[0]).length + " total) , " + __.data.length + " rows"; };
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;
// Typical d3 behavior is to pass a data item *and* its index. As the
// render queue splits the original data set, we'll have to be slightly
// more carefull about passing the correct index with the data item.
var end = Math.min(_i + _rate, _queue.length);
for (var i = _i; i < end; i++) {
func(_queue[i], i);
}
_i += _rate;
}
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;
});
// http://bl.ocks.org/3687826
d3.divgrid = function(config) {
var columns = [];
var dg = function(selection) {
if (columns.length == 0) columns = d3.keys(selection.data()[0][0]);
// header
selection.selectAll(".header")
.data([true])
.enter().append("div")
.attr("class", "header")
var header = selection.select(".header")
.selectAll(".cell")
.data(columns);
header.enter().append("div")
.attr("class", function(d,i) { return "col-" + i; })
.classed("cell", true)
selection.selectAll(".header .cell")
.text(function(d) { return d; });
header.exit().remove();
// rows
var rows = selection.selectAll(".row")
.data(function(d) { return d; })
rows.enter().append("div")
.attr("class", "row")
rows.exit().remove();
var cells = selection.selectAll(".row").selectAll(".cell")
.data(function(d) { return columns.map(function(col){return d[col];}) })
// cells
cells.enter().append("div")
.attr("class", function(d,i) { return "col-" + i; })
.classed("cell", true)
cells.exit().remove();
selection.selectAll(".cell")
.text(function(d) { return d; });
return dg;
};
dg.columns = function(_) {
if (!arguments.length) return columns;
columns = _;
return this;
};
return dg;
};
<!doctype html>
<title>Linking to Data Table</title>
<!-- http://syntagmatic.github.com/parallel-coordinates/ -->
<link rel="stylesheet" type="text/css" href="style.css">
<script src="//cdnjs.cloudflare.com/ajax/libs/d3/3.4.13/d3.min.js"></script>
<script src="d3.parcoords.js"></script>
<script src="divgrid.js"></script>
<div id="example" class="parcoords"></div>
<div id="grid"></div>
<script id="brushing">// quantitative colour scale
var green_to_blue = d3.scale.linear()
.domain([9, 50])
.range(["#7AC143", "#00B0DD"])
.interpolate(d3.interpolateLab);
var color = function(d) { return green_to_blue(d['Length of Day (hours)']); };
var parcoords = d3.parcoords()("#example")
.color(color)
.alpha(0.4);
// load csv file and create the chart
d3.csv('planet.csv', function(data) {
parcoords
.data(data)
.render()
.brushMode("1D-axes"); // enable brushing
// create data table, row hover highlighting
var grid = d3.divgrid();
d3.select("#grid")
.datum(data.slice(0,10))
.call(grid)
.selectAll(".row")
.on({
"mouseover": function(d) { parcoords.highlight([d]) },
"mouseout": parcoords.unhighlight
});
// update data table on brush event
parcoords.on("brush", function(d) {
d3.select("#grid")
.datum(d.slice(0,10))
.call(grid)
.selectAll(".row")
.on({
"mouseover": function(d) { parcoords.highlight([d]) },
"mouseout": parcoords.unhighlight
});
});
});
</script>
Planet Length of Day (hours) Distance from Sun (km) Orbital Period (years) Mass (ME) Diameter (km)
Mercury 4222.6 57.9 0.2408467 0.05527 4879
Venus 2802 108.2 0.61519726 0.815 12104
Earth 24 149.6 1.0000174 1 12756
Mars 24.7 227.9 1.8808158 0.10745 6792
Jupiter 9.9 778.6 11.862615 317.83 142984
Saturn 10.7 1433.5 29.447498 95.159 120536
Uranus 17.2 2872.5 84.016846 14.5 51118
Neptune 16.1 4495.1 164.79132 17.204 49528
@import url(http://fonts.googleapis.com/css?family=Source+Code+Pro:400,600);
body {
font-size: 14px;
font-family: "Source Code Pro", Consolas, monaco, monospace;
margin: 20px auto 20px;
width: 960px;
line-height: 1.45em;
}
a {
color: #454545;
}
a:hover {
color: #000;
}
ul {
margin: 0 20px;
padding: 0;
}
.dark {
background: #222;
}
#example {
min-height: 300px;
margin: 12px 0;
}
p {
width: 560px;
}
pre {
color: #444;
font-family: Ubuntu Mono, Monaco, monospace;
padding: 4px 8px;
background: #f2f2f2;
border: 1px solid #ccc;
}
h1 small {
font-weight: normal;
font-size: 0.5em;
}
h3 {
margin-top: 40px;
}
.float {
float: left;
}
.centered {
text-align: center;
}
.hide {
display: none;
}
input {
font-size: 16px;
}
/* data table styles */
#grid { height: 240px; }
.row, .header { clear: left; font-size: 11px; line-height: 24px; height: 24px; }
.row:nth-child(odd) { background: rgba(0,0,0,0.05); }
.header { font-weight: bold; }
.cell { float: left; overflow: hidden; white-space: nowrap; width: 160px; height: 18px; }
.col-0 { width: 120px; }
/* parcoords styles */
.parcoords > svg, .parcoords > canvas {
font: 11px "Source Code Pro", Consolas, monaco, monospace;
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: #454545;
shape-rendering: crispEdges;
}
.parcoords canvas {
opacity: 1;
-moz-transition: opacity 0.5s;
-webkit-transition: opacity 0.5s;
-o-transition: opacity 0.5s;
}
.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;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment