Skip to content

Instantly share code, notes, and snippets.

@sjengle
Last active February 4, 2020 21:09
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 6 You must be signed in to fork a gist
  • Save sjengle/01c24c71016a97938beae8c778c15911 to your computer and use it in GitHub Desktop.
Save sjengle/01c24c71016a97938beae8c778c15911 to your computer and use it in GitHub Desktop.
Letter Count Bar Chart (2020 Edition)

Letter Count Bar Chart

In this demo, we asynchronously load a text file, use JavaScript to count the number of times each letter appears in that file, and generate a bar chart showing the letter count in D3.js v5.

This is meant to be an introductory demo to expose students to HTML, CSS, SVG, JavaScript, D3, Bl.ocks, Blockbuilder, and VizHub for the first time.

References

This example can be found at: Gist, Bl.ocks, Blockbuilder, VizHub

The starter template for this example can be found at: Gist

See the Wikipedia page Peter Piper Nursery Rhyme for the origins behind the text used in this example.

Inspirations

This example has undergone multiple iterations:

Below you can find videos related to an older version of this example. Please note that some content was discussed in-class only:

The original inspirations come from the following excellent tutorials on creating a bar chart in D3 for the first time:

  • D3 Bar Chart by Mike Bostock, Observable Notebook using D3v5, published on November 2017.

  • Let's Make a Bar Chart by Mike Bostock, Blog Post using D3v3, published on November 2013.

  • Making a Bar Chart by Scott Murray, Blog Post using D3v3, updated on December 2018.

/*
* this function will grab the latest text from our text area and update
* the letter counts
*/
let updateData = function() {
// get the textarea "value" (i.e. the entered text)
let text = d3.select("body").select("textarea").node().value;
// make sure we got the right text
// console.log('updated %d characters', text.length);
// get letter count
let count = countLetters(text);
// some browsers support console.table()
// javascript supports try/catch blocks
try {
// console.table(Array.from(count));
}
catch (e) {
// console.log(count);
}
return count;
};
/*
* our massive function to draw a bar chart. note some stuff in here
* is bonus material (for transitions and updating the text)
*/
let drawBarChart = function() {
// get the data to visualize
let count = updateData();
// get the svg to draw on
let svg = d3.select("body").select("svg");
// make sure we selected exactly 1 element
console.assert(svg.size() == 1);
/*
* we will need to map our data domain to our svg range, which
* means we need to calculate the min and max of our data
*
* since we have an iterable instead of an array, we should use
* https://github.com/d3/d3-array/blob/master/README.md
*
* note: include the latest version of d3-array for this!
*/
let countMin = 0; // always include 0 in a bar chart!
let countMax = d3.max(count.values());
// this catches the case where all the bars are removed, so there
// is no maximum value to compute
if (isNaN(countMax)) {
countMax = 0;
}
console.log("count bounds:", [countMin, countMax]);
/*
* before we draw, we should decide what kind of margins we
* want. this will be the space around the core plot area,
* where the tick marks and axis labels will be placed
* https://bl.ocks.org/mbostock/3019563
*/
let margin = {
top: 15,
right: 35, // leave space for y-axis
bottom: 30, // leave space for x-axis
left: 10
};
// now we can calculate how much space we have to plot
let bounds = svg.node().getBoundingClientRect();
let plotWidth = bounds.width - margin.right - margin.left;
let plotHeight = bounds.height - margin.top - margin.bottom;
/*
* okay now somehow we have to figure out how to map a count value
* to a bar height, decide bar widths, and figure out how to space
* bars for each letter along the x-axis
*
* this is where the scales in d3 come in very handy
* https://github.com/d3/d3-scale#api-reference
*/
/*
* the counts are easiest because they are numbers and we can use
* a simple linear scale, but the complicating matter is the
* coordinate system in svgs:
* https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Positions
*
* so we want to map our min count (0) to the max height of the plot area
*/
let countScale = d3.scaleLinear()
.domain([countMin, countMax])
.range([plotHeight, 0])
.nice(); // rounds the domain a bit for nicer output
/*
* the letters need an ordinal scale instead, which is used for
* categorical data. we want a bar space for all letters, not just
* the ones we found, and spaces between bars.
* https://github.com/d3/d3-scale#band-scales
*/
let letterScale = d3.scaleBand()
.domain(letters) // all letters (not using the count here)
.rangeRound([0, plotWidth])
.paddingInner(0.1); // space between bars
// try using these scales in the console
console.log("using count scale:", [countScale(countMin), countScale(countMax)]);
console.log("using letter scale:", [letterScale('a'), letterScale('z')]);
// we are actually going to draw on the "plot area"
let plot = svg.append("g").attr("id", "plot");
// notice in the "elements" view we now have a g element!
// shift the plot area over by our margins to leave room
// for the x- and y-axis
plot.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
console.assert(plot.size() == 1);
// now lets draw our x- and y-axis
// these require our x (letter) and y (count) scales
let xAxis = d3.axisBottom(letterScale);
let yAxis = d3.axisRight(countScale);
let xGroup = plot.append("g").attr("id", "x-axis");
xGroup.call(xAxis);
// notice it is at the top of our svg
// we need to translate/shift it down to the bottom
xGroup.attr("transform", "translate(0," + plotHeight + ")");
// do the same for our y axix
let yGroup = plot.append("g").attr("id", "y-axis");
yGroup.call(yAxis);
yGroup.attr("transform", "translate(" + plotWidth + ",0)");
// now how about some bars!
/*
* time to bind each data element to a rectangle in our visualization
* hence the name data-driven documents (d3)
*/
/*
* we need our data as an array of key, value pairs before binding
*/
let pairs = Array.from(count.entries());
console.log("pairs:", pairs);
let bars = plot.selectAll("rect")
.data(pairs, function(d) { return d[0]; });
// setting the "key" is important... this is how d3 will tell
// what is existing data, new data, or old data
/*
* okay, this is where things get weird. d3 uses an enter, update,
* exit pattern for dealing with data. think of it as new data,
* existing data, and old data. for the first time, everything is new!
* https://bost.ocks.org/mike/selection/
* https://bost.ocks.org/mike/join/
*/
// we use the enter() selection to add new bars for new data
bars.enter().append("rect")
// we will style using css
.attr("class", "bar")
// the width of our bar is determined by our band scale
.attr("width", letterScale.bandwidth())
// we must now map our letter to an x pixel position
// note the use of arrow functions here
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Functions#Arrow_functions
.attr("x", d => letterScale(d[0]))
//
// function(d) {
// return letterScale(d[0]);
// })
// and do something similar for our y pixel position
.attr("y", d => countScale(d[1]))
// here it gets weird again, how do we set the bar height?
.attr("height", d => plotHeight - countScale(d[1]))
.each(function(d, i, nodes) {
console.log("Added bar for:", d[0]);
});
// so we can access some of these elements later...
// add them to our chart global
chart.plotWidth = plotWidth;
chart.plotHeight = plotHeight;
chart.xAxis = xAxis;
chart.yAxis = yAxis;
chart.countScale = countScale;
chart.letterScale = letterScale;
};
/*
* optional: code that allows us to update the chart when
* the data changes.
*/
let updateBarChart = function() {
// get latest version of data
let count = updateData();
// recalculate counts
let countMin = 0; // always include 0 in a bar chart!
let countMax = d3.max(count.values());
if (isNaN(countMax)) {
countMax = 0;
}
// update our scale based on the new data
chart.countScale.domain([countMin, countMax]);
console.log("count bounds:", chart.countScale.domain());
// re-select our elements
let svg = d3.select("body").select("svg");
let plot = svg.select("g#plot");
console.assert(svg.size() == 1);
console.assert(plot.size() == 1);
// now lets update our y-axis
plot.select("g#y-axis").call(chart.yAxis);
// and lets re-create our data join
let pairs = Array.from(count.entries());
let bars = plot.selectAll("rect")
.data(pairs, function(d) { return d[0]; });
// so what happens when we change the text?
// well our data changed, and there will be a new enter selection!
// only new letters will get new bars
bars.enter().append("rect")
.attr("class", "bar")
.attr("width", chart.letterScale.bandwidth())
.attr("x", d => chart.letterScale(d[0]))
.attr("y", d => chart.countScale(d[1]))
.attr("height", d => chart.plotHeight - chart.countScale(d[1]))
// for bars that already existed, we must use the update selection
// and then update their height accordingly
// we use transitions for this to avoid change blindness
bars.transition()
.attr("y", d => chart.countScale(d[1]))
.attr("height", d => chart.plotHeight - chart.countScale(d[1]));
// what about letters that disappeared?
// we use the exit selection for those to remove the bars
bars.exit()
.each(function(d, i, nodes) {
console.log("Removing bar for:", d[0]);
})
.transition()
// can change how the transition happens
// https://github.com/d3/d3-ease
.ease(d3.easeBounceOut)
.attr("y", d => chart.countScale(countMin))
.attr("height", d=> chart.plotHeight - chart.countScale(countMin))
.remove();
/*
* we broke up the draw and update methods, but really it could be done
* all in one method with a bit more logic. also possible to simplify with
* using the new join() method, but it is important to understand the
* enter/update/exit pattern.
*/
};
// this file includes code for the letter count
// array of all lowercase letters
let letters = "abcdefghijklmnopqrstuvwxyz".split("");
/*
* try this out in the console! you can access any variable or function
* defined globally in the console
*
* and, you can right-click output in the console to make it global too!
*/
/*
* removes any character (including spaces) that is not a letter
* and converts all remaining letters to lowercase
*/
let keepLetters = function(text) {
// there are multiple ways to define a function in javascript!
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp
let notLetter = /[^a-z]/g;
return text.toLowerCase().replace(notLetter, "");
};
// in console try: keepLetters("Hello World!");
/*
* counts the letters in the input text and stores the counts as a map object
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map
*/
let countLetters = function(input) {
let text = keepLetters(input);
let count = new Map();
/*
* you can loop through strings as if they are arrays
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for
*/
for (let i = 0; i < text.length; i++) {
let letter = text[i];
// check if we have seen this letter before
if (count.has(letter)) {
count.set(letter, count.get(letter) + 1);
}
else {
count.set(letter, 1);
}
}
return count;
};
// in console try: countLetters("Hello World!");
// in console try: countLetters("Hello World!").keys();
// in console try: countLetters("Hello World!").entries();
<!DOCTYPE html>
<!-- we are using html 5 -->
<head>
<meta charset="utf-8">
<title>Letter Count Bar Chart</title>
<!-- this allows us to use the non-standard Roboto web font -->
<link href="https://fonts.googleapis.com/css?family=Roboto:300,300italic" rel="stylesheet" type="text/css">
<!-- this is our custom css stylesheet -->
<link href="style.css" rel="stylesheet" type="text/css">
</head>
<body>
<!-- we will place our visualization in this svg using d3.js -->
<svg></svg>
<!-- we will place the text to analyze here using javascript -->
<textarea></textarea>
<!-- include d3.js v5 -->
<script src="https://d3js.org/d3.v5.min.js"></script>
<!-- include d3.js modules -->
<script src="https://d3js.org/d3-array.v2.min.js"></script>
<!-- include custom javascript -->
<script src="count.js"></script>
<script src="chart.js"></script>
<!-- here is our core javascript -->
<script type="text/javascript">
// inside the script tag, // and /* */ are comments
// outside the script tag, <!-- --> are comments
// creating a global variable to hold our axis and scales
let chart = {};
console.log("before d3.text() call");
// we need to load the text file into the textarea
// this will be done asynchronously!
// https://github.com/d3/d3-fetch/blob/master/README.md#text
d3.text("peter.txt").then(function(text) {
// we will use the console and developer tools extensively
console.log("data loaded:");
console.log(text);
// we select the textarea from the DOM and update
// https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model
// select statements use css selectors to find elements in the DOM
// https://github.com/d3/d3-selection/blob/master/README.md#select
d3.select("body").select("textarea").text(text);
drawBarChart();
});
// this message will appear BEFORE the text is logged!
console.log("after d3.text() call");
// add an event listener to our text area and
// update our chart every time new data is entered
d3.select("body").select("textarea")
.on("keyup", updateBarChart);
</script>
</body>
Peter Piper picked a peck of pickled peppers.
A peck of pickled peppers Peter Piper picked.
If Peter Piper picked a peck of pickled peppers,
Where's the peck of pickled peppers that Peter Piper picked?
/*
* we use css to style our page and our svg elements
* the classes/ids defined here must match our d3 code
*/
body, textarea {
font-family: 'Roboto', sans-serif;
font-weight: 300;
font-size: 11pt;
}
body {
margin: 5px;
padding: 0px;
/* see https://developer.mozilla.org/en-US/docs/Web/CSS/color_value */
background-color: whitesmoke;
}
textarea {
/* position the textarea on top of the svg */
position: fixed;
top: 5px;
left: 5px;
margin: 0px;
padding: 5px;
width: 400px;
height: 75px;
/* try changing this color in blockbuilder! */
background-color: rgba(255, 255, 255, 0.8);
}
textarea, svg {
border: 1px solid gainsboro;
border-radius: 10px;
}
svg {
/* bl.ocks.org defaults to 960px by 500px */
width: 950px;
height: 490px;
background-color: white;
}
/* svg elements are styled differently from html elements */
rect.bar {
stroke: none;
fill: #00543c;
}
#x-axis text,
#y-axis text {
font-size: 10pt;
fill: #888888;
}
#x-axis line {
/* tick marks */
fill: none;
stroke: none;
}
#x-axis path,
#y-axis path,
#y-axis line {
fill: none;
stroke: #bbbbbb;
stroke-width: 1px;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment