Skip to content

Instantly share code, notes, and snippets.

@Kcnarf
Last active April 11, 2017 13:58
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Kcnarf/5c989173d0e0c74ab4b62161b33bb0a8 to your computer and use it in GitHub Desktop.
Save Kcnarf/5c989173d0e0c74ab4b62161b33bb0a8 to your computer and use it in GitHub Desktop.
d3-beeswarm plugin
license: gpl-3.0

This block uses the d3-beeswarm plugin I made (see the Github project). This plugin produces a beeswarm arrangement, thanks to a dedicated algorithm and without the use a the d3.force layout.

Beeswarm is a one-dimensional scatter plot with closely-packed, non-overlapping points. The beeswarm plot is a useful technique when we wish to see not only the measured values of interest for each data point, but also the distribution of these values

Some beeswarm-like plot implementation uses force layout, but the force layout simulation has some drawbacks:

  • it naturally tries to reach its equilibrium by rearranging data points in the 2D space, which can be disruptive to the ordering of the data
  • it requires several iterations to reach its equilibrium

This beeswarm plugin uses a dedicated one pass algorithm. By default, the plugin arranges data in an horizontal way. In this case, the final arrangement is contraint in x and free in y. This means that data are arranged along the x-axis, and that the position of each data reflects its precise x value. y position doesn't reflect any data-related value, and only serves the non-overlapping constraint. This plugin can also arrange data in a vertical way.

Even if this block works fine, the plugin is an on-going work. See the Github project's issues for possible enhancements.

Usages

var swarm = d3.beeswarm()
  .data(data)                                 // set the data to arrange
  .distributeOn(function(d){                  // set the value accessor to distribute on
       return xScale(d.trend);                  // evaluated once on each element of data
   })                                           // when starting the arrangement
  .radius(4)                                  // set the radius for overlapping detection
  .orientation("horizontal")                  // set the orientation of the arrangement
                                                // could also be 'vertical'
  .side("symetric")                           // set side(s) available for accumulation;
                                                // could also be 'positive' or 'negative'
  .arrange();                                 // launch arrangement computation;
                                                // return an array of {datum: , x: , y: }
                                                // where datum refers to an element of data
                                                // each element of data remains unchanged

Then, later in your code, in order to draw the swarm:

d3.selectAll("circle")
  .data(swarm)
  .enter()
    .append("circle")
      .attr("cx", function(bee) { return bee.x; })
      .attr("cy", function(bee) { return bee.y; })
      .attr("r", 4)
      .style("fill", function(bee) { return fill(bee.datum.rank); })

In the last line, bee.datum refers to the original datum.

Acknowledgments to:

stem rank trend
jean 1 0.0221834557
excelent 2 0.0172573247
bon 3 0.0152706367
conseil 4 0.0142771343
part 5 0.0139849763
ecout 6 0.0125657668
promotion 7 0.0116647465
disponibilit 8 0.0107923086
serviabl 9 0.0099251458
acesoir 10 0.0098934332
haut 11 0.009431776
pantalon 12 0.0093578819
bele 13 0.0092871115
aceuil 14 0.0092121588
nouveaut 15 0.0090155204
achat 16 0.008722844
promo 17 0.0086278463
tendanc 18 0.0085356217
manqu 19 0.0084932708
parfait 20 0.0083623534
done 21 0.008355304
feme 22 0.0083248405
vent 23 0.0083132016
avoi 24 0.0080424169
chaleureu 25 0.0078822066
comand 26 0.0075604839
quelqu 27 0.0074075752
tenu 28 0.0072492987
chausur 29 0.00711808
disponibl 30 0.0068948326
person 31 0.0068073546
originalit 32 0.0067217905
vendeu 33 0.0067063822
rien 34 0.0067047717
esay 35 0.0067006641
satisfait 36 0.006684763
cher 37 0.0066836778
acueil 38 0.0066314869
colection 39 0.0064813458
metr 40 0.0064580388
tre 41 0.0064376867
servic 42 0.0063193197
sympath 43 0.0061779954
être 44 0.0061047823
sup 45 0.0060575513
dispos 46 0.0060483871
goût 47 0.0059637097
ador 48 0.0059467335
boutiqu 49 0.0059089954
beau 50 0.0058717403
regulier 51 0.0057329804
certain 52 0.005686449
ofre 53 0.005585669
gentil 54 0.0055481446
styl 55 0.005431152
fidelit 56 0.005398658
foi 57 0.0053355415
propos 58 0.00518158
profesion 59 0.0051523297
game 60 0.0051314774
cart 61 0.0050913638
chos 62 0.0050861437
coup 63 0.0050283014
souriant 64 0.0049009439
tail 65 0.004843215
raport 66 0.0048075105
autr 67 0.0046451013
general 68 0.0046430266
reduction 69 0.0046182266
mode 70 0.004570462
achet 71 0.0045116366
amelior 72 0.0045047883
present 73 0.004487011
elev 74 0.0044661415
rest 75 0.0044256491
jol 76 0.0043042359
acueilant 77 0.0041950808
marqu 78 0.0040932108
produit 79 0.0039764739
diferent 80 0.0039120302
modern 81 0.0038882488
enseign 82 0.0038190136
agreabl 83 0.0037894
trouv 84 0.0037702036
parfoi 85 0.0036276761
port 86 0.0035256142
magasin 87 0.0034050179
chang 88 0.0031922043
agenc 89 0.003190358
clai 90 0.0031812945
ainsi 91 0.0031549695
choi 92 0.0030694137
rayon 93 0.0030580465
client 94 0.0029360714
vête 95 0.0028722762
dire 96 0.0027721774
qualit 97 0.0027403058
var 98 0.0026126169
personel 99 0.0025779703
aimabl 100 0.0025749765
internet 101 0.0025134409
pri 102 0.00238414
sai 103 0.0023536115
abordabl 104 0.0022652921
bone 105 0.0022487593
matier 106 0.0021982583
promod 107 0.0020265214
larg 108 0.0018647201
stock 109 0.0018290033
sympa 110 0.0018003201
corect 111 0.0017615927
vraiment 112 0.0016633065
petit 113 0.0016439206
aime 114 0.0015219369
model 115 0.0014867544
equip 116 0.0011670831
couleu 117 0.0011177858
seul 118 0.0010356827
bais 119 0.0010036041
renouvel 120 0.0009602366
nouvel 121 0.0009557945
niveau 122 0.0004725302
articl 123 0.0003851409
vete 124 0.000353736
domag 125 0.0002193117
raisonabl 126 -0.0001944124
esayag 127 -0.00025102
valeu 128 -0.0002974617
site 129 -0.00040451
amabilit 130 -0.0005457864
grand 131 -0.0008885675
original 132 -0.0009822167
acesibl 133 -0.0010942806
sourir 134 -0.0011092602
diversit 135 -0.001424439
tisu 136 -0.0016639137
cabin 137 -0.0025446106
ane 138 -0.0032602282
propr 139 -0.0039869126
joli 140 -0.0051917043
clas 141 -0.0052463184
variet 142 -0.007044379
atractif 143 -0.0072967461
cais 144 -0.0080716152
rang 145 -0.0081059257
robe 146 -0.0084245392
espac 147 -0.0087132705
atent 148 -0.0109695748
interesant 149 -0.0113475782
sold 150 -0.0611010533
<!DOCTYPE html>
<meta charset="utf-8">
<style>
#under-construction {
display: none;
position: absolute;
top: 200px;
left: 300px;
font-size: 40px;
}
circle {
stroke-width: 1.5px;
}
line {
stroke: lightGrey;
}
</style>
<body>
<div id="under-construction">
UNDER CONSTRUCTION
</div>
<script src="https://d3js.org/d3.v3.min.js"></script>
<script src="https://raw.githack.com/dataarts/dat.gui/master/build/dat.gui.min.js">
</script>
<script src="https://raw.githack.com/Kcnarf/d3-beeswarm/master/build/d3-beeswarm.js"></script>
<script>
var width = 960,
height = 500;
var maxRadius = 20,
maxHeight = height/2-maxRadius;
var csvData = []; // data retrieve from CSV
var memorizedSortedData = []; // allow to maintain sort when changing radius
var arrangementMax = -Infinity; // allow to detect extremes accumulations
var beeswarmArrangement = []; // allow to manage extreme accumulations wihtout executing Beeswarm arrangement
var showMetrics = false;
var availableSortings = ["shuffled", "minToMax", "maxToMin", "fromExtremes"];
var availableOrientations = ["horizontal", "vertical"];
var availableSides = ["symetric", "positive", "negative"];
var availableExtremesManagementStrategies = ["none"/*, "omit"*/, "wrap", "modulo", "linear stretch", "log stretch"];
var ctrls, config = {
manyPoints: false,
sorting: "maxToMin",
reshuffle: function() {
if (config.sorting === "shuffled") {
renewData();
drawBeeswarm();
}
},
radius: 4,
orientation: "horizontal",
side: "symetric",
strategy: "none"
};
insertControls();
var fill = d3.scale.linear().domain([1,150]).range(['lightgreen', 'pink']);
var xScale = function (x) { return width/2 + 6000*x; };
var yScale = function (x) { return height/2 - 3500*x; };
var svg = d3.select("body").append("svg")
.attr("width", width)
.attr("height", height);
var axis = svg.append("line")
.attr("id", "axis");
var nodeContainer = svg.append("g").attr("id", "node-container");
var tooltip, stem, rank, value;
prepareTooltip();
//-->for metrics purpose
var informationPanel, computationTimeInfo, dataLengthInfo, posibleCollidersInfo, placementInfo, visitedCollidersInfo;
prepareMetricsPanel();
//<--for metrics purpose
function showAxis () {
if (config.orientation === "horizontal") {
axis.attr("x1", 0)
.attr("y1", height/2)
.attr("x2", width)
.attr("y2", height/2);
} else {
axis.attr("x1", width/2)
.attr("y1", 0)
.attr("x2", width/2)
.attr("y2", height);
}
};
function manageExtremeAccumulation (bee) {
var freeCoord = (config.orientation==="horizontal")? bee.y : bee.x;
if (arrangementMax <= maxHeight) {
return freeCoord;
} else if (config.strategy === "none") {
return freeCoord;
} else if (config.strategy === "wrap") {
return (Math.abs(freeCoord)>maxHeight)? Math.sign(freeCoord)*maxHeight : freeCoord;
} else if (config.strategy === "modulo") {
return freeCoord%maxHeight;
} else if (config.strategy === "linear stretch") {
return maxHeight*freeCoord/arrangementMax;
} else if (config.strategy === "log stretch") {
//log strecth allows to have litle overlapping near the axis, and huge overlapping at maxHeight, so that areas where there is no extreme accumulation are still sparse
// return freeCoord - Math.sign(freeCoord)*(arrangementMax-maxHeight)*Math.pow(freeCoord/arrangementMax,2);
// return Math.sign(freeCoord)*maxHeight*(Math.pow(Math.abs(freeCoord)/arrangementMax,0.5));
return maxHeight*Math.sign(freeCoord)*Math.log((Math.E-1)*Math.abs(freeCoord)/arrangementMax+1);
}
}
function showCircles () {
nodeContainer.selectAll("circle").remove();
var node = nodeContainer.selectAll("circle")
.data(beeswarmArrangement)
.enter().append("circle")
.attr("r", config.radius-0.75)
.attr("cx", function(bee) {
if (config.orientation === "horizontal") {
return bee.x;
} else {
return width/2 + manageExtremeAccumulation(bee);
}
})
.attr("cy", function(bee) {
if (config.orientation === "vertical") {
return bee.y;
} else {
return height/2 + manageExtremeAccumulation(bee);
}
})
.style("fill", function(d) { return fill(d.datum.rank); })
.style("stroke", function(d) { return d3.rgb(fill(d.datum.rank)).darker(); })
.on("mouseenter", function(d) {
stem.text(d.datum.stem);
rank.text(d.datum.rank);
value.text(d.datum.trend);
tooltip.transition().duration(0).style("opacity", 1); // remove fade out transition on mouseleave
})
.on("mouseleave", function(d) {
tooltip.transition().duration(1000).style("opacity", 0);
});
};
function drawBeeswarm() {
var data = copyData(memorizedSortedData);
var startTime = Date.now();
var swarm = d3.beeswarm()
.data(data)
.radius(config.radius)
.orientation(config.orientation)
.side(config.side)
.distributeOn(function(d) {
if (config.orientation === "horizontal") {
return xScale(d.trend);
} else {
return yScale(d.trend);
}
})
beeswarmArrangement = swarm.arrange();
if (showMetrics) {
updateMetrics((Date.now()-startTime), data.length, swarm.metrics());
}
computeArrangementMax();
showOrHideExtremeAccumulationCtrl();
showAxis();
showCircles();
};
d3.csv("data.csv", dottype, function(error, data) {
if (error) throw error;
renewData();
drawBeeswarm();
});
////////////////////////
// bl.ocks' utilities //
////////////////////////
function dottype(d) {
d.id = d.stem;
d.stem = d.stem;
d.rank = +d.rank;
d.trend = +d.trend;
csvData.push(d);
return d;
};
function copyData(data) {
return data.map(function(d) {
return {
id: d.id,
stem: d.stem,
rank: d.rank,
trend: d.trend
}
});
};
function quadruple(data) {
// Quadruples data while maintaining order and uniq id
var quadrupledData = [],
i;
data.forEach(function(d) {
for (i=3; i>0; i--) {
quadrupledData.push({
id: d.id+"_"+i,
stem: d.stem,
rank: d.rank,
trend: d.trend+i*1E-6
})
}
quadrupledData.push(d);
})
return quadrupledData;
};
function computeArrangementMax () {
arrangementMax = -Infinity;
if (config.orientation === "horizontal") {
beeswarmArrangement.forEach(function(bee) {
if (arrangementMax < Math.abs(bee.y)) {
arrangementMax = Math.abs(bee.y);
}
})
} else {
beeswarmArrangement.forEach(function(bee) {
if (arrangementMax < Math.abs(bee.x)) {
arrangementMax = Math.abs(bee.x);
}
})
}
}
function renewData () {
var newData = copyData(csvData);
if (config.manyPoints) {
newData = quadruple(newData);
}
if (config.sorting === "maxToMin" ) {
memorizedSortedData = newData;
} else if (config.sorting === "minToMax" ) {
memorizedSortedData = newData.reverse();
} else if (config.sorting === "fromExtremes" ) {
var dataLength = newData.length;
memorizedSortedData = [];
for (var i=0; i<(dataLength-1)/2; i++) {
memorizedSortedData.push(newData[i]);
memorizedSortedData.push(newData[dataLength-1-i]);
}
if (dataLength%2 === 1) {
memorizedSortedData.push(newData[(dataLength-1)/2]);
}
} else {
memorizedSortedData = d3.shuffle(newData);
}
};
function insertControls () {
ctrls = new dat.GUI({width: 200});
var inputDataFolder = ctrls.addFolder("Input Data");
inputDataFolder.open();
var manyPointsCtrl = inputDataFolder.add(config, "manyPoints");
manyPointsCtrl.onChange(function(value) {
renewData();
drawBeeswarm();
});
var sortingCtrl = inputDataFolder.add(config, "sorting", availableSortings);
sortingCtrl.onChange(function(value) {
showOrHideReshuffle();
renewData();
drawBeeswarm();
});
inputDataFolder.add(config, "reshuffle");
var beeswarmFolder = ctrls.addFolder("Beeswarm Configuration");
beeswarmFolder.open();
var radiusCtrl = beeswarmFolder.add(config, "radius", 1, maxRadius);
radiusCtrl.onChange(function(value) {
drawBeeswarm();
});
var orientaionCtrl = beeswarmFolder.add(config, "orientation", availableOrientations);
orientaionCtrl.onChange(function(value) {
drawBeeswarm();
});
var sideCtrl = beeswarmFolder.add(config, "side", availableSides);
sideCtrl.onChange(function(value) {
drawBeeswarm();
});
var extremesManagementFolder = ctrls.addFolder("Extreme Accumulation Mngt.");
extremesManagementFolder.open();
var strategyCtrl = extremesManagementFolder.add(config, "strategy", availableExtremesManagementStrategies);
strategyCtrl.onChange(function(value) {
// no need to recompute Beeswarm arrangment
// these strategies only applies when rendering data (and not when arranging data)
showCircles();
})
showOrHideReshuffle();
showOrHideExtremeAccumulationCtrl();
};
function showOrHideReshuffle () {
ctrls.__folders["Input Data"].__controllers.forEach(function(c){
if(c.property==="reshuffle"){
c.domElement.parentElement.parentElement.style.display = (config.sorting==="shuffled")? "block" : "none";
}
})
};
function showOrHideExtremeAccumulationCtrl () {
if (arrangementMax>maxHeight) {
ctrls.__folders["Extreme Accumulation Mngt."].domElement.style.display = "block";
} else {
ctrls.__folders["Extreme Accumulation Mngt."].domElement.style.display = "none";
}
};
function prepareTooltip() {
tooltip = svg.append("g")
.attr("id", "tooltip")
.attr("transform", "translate("+[width/2, 50]+")")
.style("opacity", 0);
var titles = tooltip.append("g").attr("transform", "translate("+[-5,0]+")")
titles.append("text").attr("text-anchor", "end").text("stem(fr):");
titles.append("text")
.attr("text-anchor", "end")
.attr("transform", "translate("+[0,15]+")")
.text("rank:");
titles.append("text")
.attr("text-anchor", "end")
.attr("transform", "translate("+[0,30]+")")
.text("x-value:");
var values = tooltip.append("g").attr("transform", "translate("+[5,0]+")")
stem = values.append("text")
.attr("text-anchor", "start");
rank = values.append("text")
.attr("text-anchor", "start")
.attr("transform", "translate("+[0,15]+")");
value = values.append("text")
.attr("text-anchor", "start")
.attr("transform", "translate("+[0,30]+")");
};
function prepareMetricsPanel() {
var i=4;
informationPanel = svg.append("g")
.attr("id", "infomation-panel")
.attr("transform", "translate("+[width-20, height-20]+")");
computationTimeInfo = informationPanel.append("text")
.attr("text-anchor", "end")
.attr("transform", "translate("+[0,-15*i--]+")");
dataLengthInfo = informationPanel.append("text")
.attr("text-anchor", "end")
.attr("transform", "translate("+[0,-15*i--]+")");
possibleCollidersInfo = informationPanel.append("text")
.attr("text-anchor", "end")
.attr("transform", "translate("+[0,-15*i--]+")");
placementInfo = informationPanel.append("text")
.attr("text-anchor", "end")
.attr("transform", "translate("+[0,-15*i--]+")");
visitedCollidersInfo = informationPanel.append("text")
.attr("text-anchor", "end");
};
function updateMetrics(elapsed, length, metrics) {
computationTimeInfo.text("Arrangement took: "+elapsed+" ms");
dataLengthInfo.text("# data: "+length);
possibleCollidersInfo.text("# possible colliders: ~"+Math.round(metrics.totalPossibleColliders*100/length)/100+" per data ("+metrics.maxPossibleColliders+" max, "+metrics.totalPossibleColliders+" total)");
placementInfo.text("# tested placements: ~"+Math.round(metrics.totalTestedPlacements*100/length)/100+" per data ("+(metrics.maxPossibleColliders*2)+" max, "+metrics.totalTestedPlacements+" total)");
visitedCollidersInfo.text("# collision checks: ~"+Math.round(metrics.totalVisitedColliders*100/metrics.totalTestedPlacements)/100+" per placement ("+metrics.maxVisitedColliders+" max, "+metrics.totalVisitedColliders+" total)")
};
function showOnTheFlyCircleArrangement(d, type) {
nodeContainer.selectAll("circle.test").remove();
nodeContainer.append("circle")
.datum(d)
.classed(type, true)
.attr("r", config.radius)
.attr("cx", function(d) { return d.x; })
.attr("cy", function(d) { return height/2 + d.y; })
.style("fill", function(d) { return fill(d.rank); })
.style("stroke", function(d) { return d3.rgb(fill(d.rank)).darker(); })
};
</script>
</body>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment