Chapter 4. Time Series
The time series is a ubiquitous type of data set. It describes how some measurable feature (for instance, population, snowfall, or items sold) has changed over a period of time. Edward Tufte credits Johann Heinrich Lambert with the formal introduction of the time series to scientific literature in the 1700s.[2]
Because of its ubiquity, the time series is a good place to start when learning about visualization. With it we can cover:
Acquiring a table of data from a text file
Parsing the contents of the file into a usable data structure
Calculating the boundaries of the data to facilitate representation
Finding a suitable representation and considering alternatives
Refining the representation with consideration for placement, type, line weight, and color
Providing a means of interacting with the data so that we can compare variables against one another or against the average of the whole data set
For a straightforward data set, let’s turn to the U.S. Department of Agriculture (USDA) for statistics on beverage consumption. Government sites are a terrific resource for data; see Chapter 9 for more information about them and other sources of data.
Most methods will be implemented “by hand” in this section. Further down the line, we’ll make generalized code to handle different scenarios, such as reading a table from a file or placing labels and grid lines on a plot.
Milk, Tea, and Coffee (Acquire and Parse)
The data set we use was originally downloaded from http://www.ers.usda.gov/data/foodconsumption/foodavailqueriable.aspx.
The page lets you define a query to download a data set of interest. The site claims that the data is in Excel format, but a glance at the contents of the resulting file shows that it’s only an HTML file with an .xls extension that fools Excel into opening it. Rather than getting into the specifics of how to download and clean the data, I offer an already processed version here:
http://benfry.com/writing/series/milk-tea-coffee.tsv |
This data set contains three columns: the first for milk, the second for coffee, and the third for tea consumption in the United States from 1910 to 2004.
To read this file, use this modified version of the Table
class from the previous chapter:
http://benfry.com/writing/series/floattable.pde |
The modified version handles data stored as float
values, making it more efficient than
the previous version, which simply converted the data whenever getString( )
, getFloat( )
, or getInt( )
were used.
Open Processing and start a new sketch. Add both files to the sketch by either dragging each into the editor window or using Sketch → Add File.
Cleaning the Table (Filter and Mine)
It’s necessary to determine the minimum and maximum of each of the columns in the pre-filtered data set. These values are used to properly scale plotted points to locations on the screen.
The FloatTable
class has
methods for calculating the min and max for the rows and columns. These
methods are worth discussing because they are important in later code.
The following example calculates the minimum value for a column
(comments denote important portions of the code):
float getColumnMax(int col) { // Set the value of m to the lowest possible value, // so that the first value found will automatically be larger. float m = MIN_FLOAT; // Loop through each row. for (int row = 0; row < rowCount; row++) { // Only consider valid data elements (see later text). if (isValid(row, col)) { // Finally, check to see if the value // is greater than the maximum found so far. if (data[row][col] > m) { m = data[row][col]; } } } return m; }
The isValid( )
method is
important because most data sets have incomplete data. In the milk-tea-coffee.tsv file, all of the data is
valid, but in most data sets (including others used in this chapter),
missing values require extra consideration.
Because the values for milk, coffee, and tea will be compared against one another, it’s necessary to calculate the maximum value across all of the columns. The following bit of code does this after loading the milk-tea-coffee.tsv file:
FloatTable data; float dataMin, dataMax; void setup( ) { data = new FloatTable("milk-tea-coffee.tsv"); dataMin = 0; dataMax = data.getTableMax( ); }
Sometimes, it’s also useful to calculate the minimum value, but
setting the minimum to zero provides a more accurate comparison between
the three data sets. The minimum for this data set is 5.1, and the
values for the tea column hover around 6, so using 5.1 as the dataMin
value would produce a chart that
looked as though the beverage history included periods of no (or nearly
no) tea consumption in the U.S. In addition, if the value is 6, it’s
important that the relative difference seen by the viewer is not just
0.9, but that it shows the full range from 0 up to 5.1 and how it
compares to a value of 6.
Each row name specifies a year, which will be used later to draw
labels on the plot. To make them useful in code, it’s also necessary to
get the minimum and maximum year after converting the entire group to an
int
array. The getRowNames( )
method inside FloatTable
returns a String
array that can be converted with the
int( )
casting function:
FloatTable data; float dataMin, dataMax;int yearMin, yearMax; int[] years; void setup( ) { data = new FloatTable("milk-tea-coffee.tsv"); years = int(data.getRowNames( )); yearMin = years[0]; yearMax = years[years.length - 1]; dataMin = 0; dataMax = data.getTableMax( ); }
A Simple Plot (Represent and Refine)
To begin the representation, it’s first necessary to set the
boundaries for the plot location. The plotX1
, plotY1
, plotX2
, and plotY2
variables define the corners of the
plot. To provide a nice margin on the left, set plotX1
to 50
, and then set the plotX2
coordinate by subtracting this value
from width
. This keeps the two sides
even, and requires only a single change to adjust the position of both.
The same technique is used for the vertical location of the
plot:
FloatTable data; float dataMin, dataMax;float plotX1, plotY1; float plotX2, plotY2; int yearMin, yearMax; int[] years; void setup( ) { size(720, 405); data = new FloatTable("milk-tea-coffee.tsv"); years = int(data.getRowNames( )); yearMin = years[0]; yearMax = years[years.length - 1]; dataMin = 0; dataMax = data.getTableMax( ); // Corners of the plotted time series plotX1 = 50; plotX2 = width - plotX1; plotY1 = 60; plotY2 = height - plotY1; smooth( ); }
Next, add a draw( )
method that
sets the background to a light gray and draws a filled white rectangle
for the plotting area. That will make the plot stand out against the
background, rather than a color behind the plot itself—which can muddy
its appearance.
The rect( )
function normally
takes the form rect(x, y, width,
height)
, but rectMode(CORNERS)
changes the parameters to
rect(left, top, right, bottom)
, which
is useful because our plot’s shape is defined by the corners. Like other
methods that affect drawing properties, such as fill( )
and stroke(
)
, rectMode( )
affects all
geometry that is drawn after it until the next time rectMode( )
is called:
void draw( ) { background(224); // Show the plot area as a white box. fill(255); rectMode(CORNERS); noStroke( ); rect(plotX1, plotY1, plotX2, plotY2); strokeWeight(5); // Draw the data for the first column. stroke(#5679C1); drawDataPoints(0); } // Draw the data as a series of points. void drawDataPoints(int col) { int rowCount = data.getRowCount( ); for (int row = 0; row < rowCount; row++) { if (data.isValid(row, col)) { float value = data.getFloat(row, col); float x = map(years[row], yearMin, yearMax, plotX1, plotX2); float y = map(value, dataMin, dataMax, plotY2, plotY1); point(x, y); } } }
Because the data is drawn as points using the drawDataPoints( )
method, a stroke color and
weight are set. This method also takes a column index to draw as a
parameter. The results are in Figure 4-1. For the first step, I’ve shown
only the first column of data (the values for milk consumption).
The map( )
function does most
of the work. The x
coordinate is
calculated by mapping the year for each row from yearMin
and yearMax
to plotX1
and plotX2
. Another option would be to use the
row
variable, instead of the
year:
float x = map(row, 0, rowCount-1, plotX1, plotX2);
But a value for row
would be
less accurate because a year or two might be missing from the data set,
which would skew the representation. Again, this data set is complete,
but often that is not the case.
Labeling the Current Data Set (Refine and Interact)
Missing from the previous code is an indicator of the currently
visible column of data (whether milk, tea, or coffee) and a means to
swap between each of the three. For this, we add a variable to keep
track of the current column, and another for the font used for the
title. And few lines of code are added to the draw( )
method to write the name of the column
with the text( )
method:
FloatTable data; float dataMin, dataMax; float plotX1, plotY1; float plotX2, plotY2;int currentColumn = 0; int columnCount; int yearMin, yearMax; int[] years; PFont plotFont; void setup( ) { size(720, 405); data = new FloatTable("milk-tea-coffee.tsv"); columnCount = data.getColumnCount( ); years = int(data.getRowNames( )); yearMin = years[0]; yearMax = years[years.length - 1]; dataMin = 0; dataMax = data.getTableMax( ); // Corners of the plotted time series plotX1 = 50; plotX2 = width - plotX1; plotY1 = 60; plotY2 = height - plotY1; plotFont = createFont("SansSerif", 20); textFont(plotFont); smooth( ); } void draw( ) { background(224); // Show the plot area as a white box. fill(255); rectMode(CORNERS); noStroke( ); rect(plotX1, plotY1, plotX2, plotY2); // Draw the title of the current plot. fill(0); textSize(20); String title = data.getColumnName(currentColumn); text(title, plotX1, plotY1 - 10); stroke(#5679C1); strokeWeight(5); drawDataPoints(currentColumn); }
The text( )
line draws the text
10 pixels above plotY1
, which
represents the top of the plot, and the drawDataPoints( )
line uses currentColumn
instead of just 0. Results are
shown in Figure 4-2.
The createFont( )
function is
used to create a font from one of the built-in typefaces. The built-in
typefaces are Serif
, SansSerif
, Monospaced
, Dialog
, and DialogInput
; they map to the default fonts on
each operating system. On Mac OS X, for instance, SansSerif
maps to Lucida Sans, whereas on
Windows it maps to Arial. The default fonts are useful when you don’t
want to deal with the Create Font tool, but the font choices are not
particularly inspiring, and they don’t guarantee consistent output
across different operating systems. For instance, making pixel-level
decisions with a built-in font is a bad idea because the shaping and
spacing of the characters can be significantly different on other
operating systems.
One advantage of using createFont(
)
is that the text will look smooth at any size, unlike a font
used with loadFont( )
, which may be
distorted as it is resized.
It is possible to use createFont(
)
to specify something besides a built-in font, but there’s no
guarantee that the font will be installed on another user’s system. This
can be useful for testing, after which you can use the Create Font tool
before deployment. The name of a font used by createFont( )
should be identical to how it is
listed in the Create Font tool. You can also get a list of the available
fonts with the PFont.list( )
method,
which returns a String
array. The
following will print the list of all available fonts to the
console:
println(PFont.list( ));
Tip
If you have a lot of fonts installed on your system, there might be a long delay before they are listed.
The createFont( )
method can
also be used with a TrueType (.ttf)
or OpenType (.otf) file added to
the data folder. Most TrueType
fonts will work, but OpenType support varies by platform. Be mindful of
copyrighted fonts when using this method in a sketch for public
distribution.
A simple means of swapping between columns of data is to add a
keyPressed( )
method, which will
automatically run any time a key is pressed:
void keyPressed( ) { if (key == '[') { currentColumn--; if (currentColumn < 0) { currentColumn = columnCount - 1; } } else if (key == ']') { currentColumn++; if (currentColumn == columnCount) { currentColumn = 0; } } }
This method will rotate through the columns as the user presses
the [ and ] (bracket) keys. When the number gets too big or too small,
it wraps around to the beginning or end of the list. Because columnCount
is 3, the possible currentColumn
values are 0, 1, and 2. So, when
currentColumn
reaches a value less
than zero, it wraps around to 2 (columnCount -
1
).
Drawing Axis Labels (Refine)
An unlabeled plot has minimal utility. It clearly displays relative up or down swings, but without a sense of the time period or amounts to indicate the degree of swing, it’s impossible to know whether values have changed by, say, 5% or 50%. And some indication is required to explain that the horizontal axis represents the year and the vertical axis represents actual volumes: the amount consumed of a particular beverage, measured in gallons per capita per year.
There are clever (and complicated) means of selecting intervals, but for this project, we will pick the interval by hand. Choosing a proper interval and deciding whether to include major and minor tick marks depends on the data, but a general rule of thumb is that five intervals is at the low end, and more than ten is likely a problem. Too many labels make the diagram look like graph paper, and too few suggests that only the minimum and maximum values need to be shown.
The most important consideration is the way the data is used. Are minute, year-by-year comparisons needed? Always use the fewest intervals you can get away with, as long as the plot shows the level of detail the reader needs. Sometimes no labels are necessary—if values are only meant to be compared against one another. For instance, you might dispense with labels if you want to show only upward and downward trends. Other factors, such as the width of the plot, also play a role, so determining the correct level of detail usually requires a little trial and error.
Year Labels
Creating the year axis is straightforward. The data ranges from
1910 to 2004, so an interval of 10 years means marking 10 individual
years: 1910, 1920, 1930, and so on, up to 2000. Add the yearInterval
variable to the beginning of
the code before setup( )
:
int yearInterval = 10;
Next, add the following function to draw the year labels:
void drawYearLabels( ) { fill(0); textSize(10); textAlign(CENTER, TOP); for (int row = 0; row < rowCount; row++) { if (years[row] % yearInterval == 0) { float x = map(years[row], yearMin, yearMax, plotX1, plotX2); text(years[row], x, plotY2 + 10); } } }
The fill color is set to black, the text size to 10, and the alignment to the middle so that the year number centers on the position of the data point for that year.
Two lines in this code deserve further consideration. The first
is the line that makes use of the %
, or modulo, operator. A modulo operation
returns the remainder from a division. So, for example, 7 % 2 is equal
to 1, and 8 % 5 equals 3. It’s useful for drawing labels because it
provides a way to easily identify a year ending in 0. Dividing 1910 by
10 returns 0, so a label is drawn, whereas dividing 1911 by 10
produces 1, and so it continues until the loop reaches 1920, which
also returns 0 when divided by 10.
A second parameter to textAlign(
)
sets the vertical alignment of the text. The options are
TOP
, BOTTOM
, CENTER
, and BASELINE
(the default). The TOP
and CENTER
parameters are straightforward. The
BOTTOM
parameter is the same as
BASELINE
when only one line of text
is used, but for multiple lines, the final line will be aligned to the
baseline, with the previous lines appearing above it. When only one
parameter is used, the vertical alignment resets to BASELINE
.
The resulting image is shown in Figure 4-3.
Tip
To draw text that does not bump into the elements above it, you need to know the height of the tallest character in the font. Typographers refer to this as the ascent. Traditionally, the ascent of a font is the height to the top of a capital H character. Characters such as the capital O or a capital B are in fact slightly taller than the letter H and dip slightly below the baseline—the bottom line from which text is drawn. The ascent value essentially refers to the optical height of the font, which is the height perceived by our eyes.
Simple grid lines can also help the presentation by identifying
each interval. The following modifications add a grid to the drawYearLabels( )
function:
void drawYearLabels( ) { fill(0); textSize(10); textAlign(CENTER, TOP);// Use thin, gray lines to draw the grid. stroke(224); strokeWeight(1); for (int row = 0; row < rowCount; row++) { if (years[row] % yearInterval == 0) { float x = map(years[row], yearMin, yearMax, plotX1, plotX2); text(years[row], x, plotY2 + 10); line(x, plotY1, x, plotY2); } } }
Figure 4-4 shows the result.
Notice that because the fill color does not affect lines, and a
stroke color does not affect text, it is not necessary to use noFill( )
or noStroke( )
in this method.
With a separate method to draw the year labels, it makes sense
to put the code that draws the title into its own method. The drawTitle( )
method takes this code from the
draw( )
function. Just replace the
title drawing code inside draw( )
with:
drawTitle( );
and then add the following method to the code:
void drawTitle( ) { fill(0); textSize(20); textAlign(LEFT); String title = data.getColumnName(currentColumn); text(title, plotX1, plotY1 - 10); }
Because the drawYearLabels( )
function changes the text alignment, a line is added to reset to
textAlign(LEFT)
before drawing the
title. Otherwise, the title would appear centered at plotX1
on the next trip through the draw( )
method, inheriting the text
alignment settings from the previous draw(
)
.
Labeling Volume on the Vertical Axis
The vertical axis can be handled the same way as the horizontal,
but it is a bit trickier. A quick println(dataMax)
added to setup( )
tells us that the maximum value is
46.4. Intervals of 10 will again suffice, this time producing only 5
divisions (as opposed to 10 in the horizontal):
int volumeInterval = 10;
With a dataMax
value of 46.4
and intervals of 10, rounding up dataMax
to the nearest interval will make
the maximum value on the plot 50, making it a little easier to read
changes in vertical values. To do so automatically, divide dataMax
by volumeInterval
. The result is 4.64. Next,
use ceil( )
, which rounds a
float
up to the next int
value (in this case, 5), called the
ceiling of a float
. Then, set dataMax
to the rounded value multiplied by
volumeInterval
. That calculation
took a few sentences to explain, but the code consists of a one-line
change to setup( )
:
dataMax = ceil(data.getTableMax( ) / volumeInterval) * volumeInterval;
To draw the labels, create a loop that iterates from the minimum
to maximum data values. Use an increment of volumeInterval
to draw a label at each
interval:
void drawVolumeLabels( ) { fill(0); textSize(10); textAlign(RIGHT, CENTER); for (float v = dataMin; v <= dataMax; v += volumeInterval) { float y = map(v, dataMin, dataMax, plotY2, plotY1); text(floor(v), plotX1 - 10, y); } }
Add a draw VolumeLabels( )
call to the draw( )
method, next to drawTitle(
)
. When you’re drawing the text label, the floor(
)
function removes decimals from the number value because there’s no need to
write 10.0, 20.0, 30.0, etc. when 10, 20, and 30 will suffice. If dataInterval
included decimal points, the nf(
)
method could be used instead to format the value to a specific number of
decimal places.
Tip
The text( )
method can draw
int
or float
values instead of just String
objects. For float
values, it is best to use the
nf( )
method to first convert the
float
to a specific number of
decimal places. By default, text(
)
will format a float
to three decimal places. That is different from Java, which can have
many digits in the decimal place for a float, because using just a
few digits is usually more useful for a graphical display. To get
the full 4-, 8-, or 15-digit version of the float value, use the
str( )
function to convert the
float
to a String
. For Java programmers, using
str( )
is equivalent to String.valueOf( )
.
The x-coordinate of the label text is the lefthand edge of the
plot minus a few pixels. Also note the use of textAlign( )
to vertically center the
text.
With the vertical centering, the label drawn at 0 is visually a
little too close to the year markers below. In its current state, this
example is not detailed enough to be used for real analysis and is
better at showing upward and downward trends. In that context, it’s
clear from a glance that the bottom of the plot is 0, so the bottom
label could be left out completely. The same goes for the top value,
which gets close to the title. To leave these out, alter the first
value drawn by adding a volumeInterval
to dataMin
, and end the loop at v < dataMax
instead of v <= dataMax
so that the 50 won’t be
drawn:
void drawVolumeLabels( ) {
fill(0);
textSize(10);
textAlign(RIGHT, CENTER);float dataFirst = dataMin + volumeInterval;
for (float v = dataFirst; v < dataMax; v += volumeInterval) {
float y = map(v, dataMin, dataMax, plotY2, plotY1);
text(floor(v), plotX1 - 10, y);
}
}
In other cases, it might not be appropriate to remove upper and
lower values. If dataMin
were
something other than 0, or the intervals more awkward than simple
intervals of 10, viewers might be confused without the minimum and
maximum values. In such cases, the maximum value (50) can be
vertically aligned to the top of the plot, and the minimum value (0)
to the bottom, rather than centered vertically like the rest of the
labels:
void drawVolumeLabels( ) { fill(0); textSize(10);for (float v = dataMin; v <= dataMax; v += volumeInterval) { float y = map(v, dataMin, dataMax, plotY2, plotY1); if (v == dataMin) { textAlign(RIGHT); // Align by the bottom } else if (v == dataMax) { textAlign(RIGHT, TOP); // Align by the top } else { textAlign(RIGHT, CENTER); // Center vertically } text(floor(v), plotX1 - 10, y); } }
Horizontal lines can be fashioned in the same manner as those for the year. Choosing whether to use a horizontal or vertical grid depends on the axis with data that is most important to be measured. If this plot is being used to analyze exact changes in milk consumption, the horizontal gridlines will better help with identifying changes. But if the purpose is to compare upward and downward trends across different years (for instance, to understand how milk consumption changed during and after World War II), the vertical gridlines are more valuable. For this data set, it’s most interesting to compare changes over the years, so we’ll stick with vertical lines.
Instead of gridlines, small tick marks near the labels on the
vertical axis can be produced with the same technique, by drawing a
short line just outside the edge of the plot. Minor gridlines or tick
marks can be drawn by including a variable for a second interval
that’s a multiple of the first and incrementing by that interval in
the loop. The following modification to drawVolumeLabels( )
adds major and minor
tick marks to the volume axis:
int volumeIntervalMinor = 5; // Add this above setup( )void drawVolumeLabels( ) { fill(0); textSize(10);stroke(128); strokeWeight(1); for (float v = dataMin; v <= dataMax; v += volumeIntervalMinor) { float y = map(v, dataMin, dataMax, plotY2, plotY1); if (v % volumeInterval == 0) { // If a major tick mark if (v == dataMin) { textAlign(RIGHT); // Align by the bottom } else if (v == dataMax) { textAlign(RIGHT, TOP); // Align by the top } else { textAlign(RIGHT, CENTER); // Center vertically } text(floor(v), plotX1 - 10, y); line(plotX1 - 4, y, plotX1, y); // Draw major tick } else { line(plotX1 - 2, y, plotX1, y); // Draw minor tick } } }
The result with the tick marks and vertical labels is shown in Figure 4-5.
Strictly speaking, the minor tickmarks in this example are not very informative. They can be removed to avoid visual clutter; simply comment out the line that draws the minor ticks.
Bringing It All Together and Titling Both Axes
So far, anyone looking at this diagram should be able to guess that it has something to do with milk from 1910 to sometime after 2000. To further explain the plot, the next step is to provide titles for the year and volume axes. Informative axis titles are important for the people viewing your data.
The year axis title is simple: just a piece of text centered
between plotX1
and plotX2
. After centering the text in both
directions with textAlign(CENTER,
CENTER)
, the text is drawn centered between plotY1
and plotY2
. To fit both, the values for plotX1
and friends must be changed to make
room for the labels. In this case, eyeballing the placement is
sufficient, though textWidth( )
could be used to accurately size the lefthand margin, and textAscent( )
could do the same for the
label below.
For the vertical axis, it might be tempting to rotate the title
on its side, but more often than not it is more effective at giving
your viewer eyestrain than it is at communicating. I’ve kept the text
horizontal and broken the label into three lines by inserting newline
characters (\n
) into the
string.
Figure 4-6 shows our progress.
Here’s the code listing for the program thus far, with the lines highlighted that were altered to display the titles:
FloatTable data; float dataMin, dataMax; float plotX1, plotY1; float plotX2, plotY2;float labelX, labelY; int rowCount; int columnCount; int currentColumn = 0; int yearMin, yearMax; int[] years; int yearInterval = 10; int volumeInterval = 10; int volumeIntervalMinor = 5; PFont plotFont; void setup( ) { size(720, 405); data = new FloatTable("milk-tea-coffee.tsv"); rowCount = data.getRowCount( ); columnCount = data.getColumnCount( ); years = int(data.getRowNames( )); yearMin = years[0]; yearMax = years[years.length - 1]; dataMin = 0; dataMax = ceil(data.getTableMax( ) / volumeInterval) * volumeInterval; // Corners of the plotted time series plotX1 = 120; plotX2 = width - 80; labelX = 50; plotY1 = 60; plotY2 = height - 70; labelY = height - 25; plotFont = createFont("SansSerif", 20); textFont(plotFont); smooth( ); } void draw( ) { background(224); // Show the plot area as a white box fill(255); rectMode(CORNERS); noStroke( ); rect(plotX1, plotY1, plotX2, plotY2); drawTitle( ); drawAxisLabels( ); drawYearLabels( ); drawVolumeLabels( ); stroke(#5679C1); strokeWeight(5); drawDataPoints(currentColumn); } void drawTitle( ) { fill(0); textSize(20); textAlign(LEFT); String title = data.getColumnName(currentColumn); text(title, plotX1, plotY1 - 10); } void drawAxisLabels( ) { fill(0); textSize(13); textLeading(15); textAlign(CENTER, CENTER); // Use \n (aka enter or linefeed) to break the text into separate lines. text("Gallons\nconsumed\nper capita", labelX, (plotY1+plotY2)/2); textAlign(CENTER); text("Year", (plotX1+plotX2)/2, labelY); } void drawYearLabels( ) { fill(0); textSize(10); textAlign(CENTER, TOP); // Use thin, gray lines to draw the grid. stroke(224); strokeWeight(1); for (int row = 0; row < rowCount; row++) { if (years[row] % yearInterval == 0) { float x = map(years[row], yearMin, yearMax, plotX1, plotX2); text(years[row], x, plotY2 + 10); line(x, plotY1, x, plotY2); } } } void drawVolumeLabels( ) { fill(0); textSize(10); stroke(128); strokeWeight(1); for (float v = dataMin; v <= dataMax; v += volumeIntervalMinor) { float y = map(v, dataMin, dataMax, plotY2, plotY1); if (v % volumeInterval == 0) { // If a major tick mark if (v == dataMin) { textAlign(RIGHT); // Align by the bottom } else if (v == dataMax) { textAlign(RIGHT, TOP); // Align by the top } else { textAlign(RIGHT, CENTER); // Center vertically } text(floor(v), plotX1 - 10, y); line(plotX1 - 4, y, plotX1, y); // Draw major tick } else { // Commented out; too distracting visually //line(plotX1 - 2, y, plotX1, y); // Draw minor tick } } } void drawDataPoints(int col) { for (int row = 0; row < rowCount; row++) { if (data.isValid(row, col)) { float value = data.getFloat(row, col); float x = map(years[row], yearMin, yearMax, plotX1, plotX2); float y = map(value, dataMin, dataMax, plotY2, plotY1); point(x, y); } } } void keyPressed( ) { if (key == '[') { currentColumn--; if (currentColumn < 0) { currentColumn = columnCount - 1; } } else if (key == ']') { currentColumn++; if (currentColumn == columnCount) { currentColumn = 0; } } }
Choosing a Proper Representation (Represent and Refine)
A series of points can be difficult to follow if they’re not connected. It’s not as easy to compare milk and coffee in these images, for instance, because the predominant difference between the two plots is that the coffee values are far more erratic than those for milk. Instead of a specific shape, the points make an indeterminate cloud that is difficult to make sense of at a quick glance.
When values are truly a series and there is no missing data, it’s
possible to use a line graph and simply connect the points. The beginShape( )
and endShape( )
methods provide a means for
drawing irregular shapes. The vertex(
)
method adds a single point to the shape. To connect the dots
in a line, replace the point( )
method with vertex( )
.
Three examples follow that show the basic drawing modes of
beginShape( )
and endShape( )
. See Figure 4-7. Using noFill( )
will produce the image at left, and
the default fill and stroke settings will produce the image in the
center. The CLOSE
parameter in the
endShape( )
method handles the
connection of the final point to the first, so that the stroke
completely outlines the shape. Always use endShape(CLOSE)
when closing a shape because
the alternative—repeating the first point—may cause unexpected visual
defects.
// Leftmost image: fill disabled and the default stroke noFill( ); beginShape( ); vertex(10, 10); vertex(90, 30); vertex(40, 90); vertex(50, 40); endShape( ); // Center image: default fill (white) and stroke (black) beginShape( ); vertex(10, 10); vertex(90, 30); vertex(40, 90); vertex(50, 40); endShape( ); // Rightmost image: default fill and stroke, closed shape beginShape( ); vertex(10, 10); vertex(90, 30); vertex(40, 90); vertex(50, 40); endShape(CLOSE);
To represent a time series, we want a simple line with no fill, so
we’ll use the nofill( )
form of the
shape. The following method is a variation of drawPoints( )
that draws the data with
beginShape( )
and endShape( )
, with the alterations
highlighted:
void drawDataLine(int col) { beginShape( ); int rowCount = data.getRowCount( ); for (int row = 0; row < rowCount; row++) { if (data.isValid(row, col)) { float value = data.getFloat(row, col); float x = map(years[row], yearMin, yearMax, plotX1, plotX2); float y = map(value, dataMin, dataMax, plotY2, plotY1);vertex(x, y); } } endShape( ); }
Inside draw( )
, comment out the
line that reads:
drawDataPoints(currentColumn);
by placing a pair of slashes (//
) in front of it. On the line that follows,
add:
noFill( ); drawDataLine(currentColumn);
The noFill( )
command is
important; without it, the shape would have a strange black background
because the fill was last set to black in the prior lines that draw the
text label for the plot. This version of the code produces the image
shown in Figure 4-8.
It could also be used to draw all three series (milk, tea, and
coffee) on a single plot. To do this, call drawDataLine( )
once for each of the three
columns, and set a different stroke color for each.
It’s also easy to mix lines and points in the representation to
create a background line that highlights the individual data points. To
do so, set the stroke weight to something smaller while drawing the
lines and keep the thicker weight for the points. Modify the end of
draw( )
to read as follows:
stroke(#5679C1); strokeWeight(5); drawDataPoints(currentColumn); noFill( ); strokeWeight(0.5); drawDataLine(currentColumn);
The result appears in Figure 4-9.
Note that the functions themselves should not be merged, as other
shape commands (such as point( )
) are
not permitted inside a beginShape( )
and endShape( )
block.
Depending on how you use this code, it may be important to draw the points after the lines. For example, if you set the stroke of the line to a light gray, it would be best to draw the blue points on top of the line so that the points are not bisected by an odd gray line (which has poor contrast with blue).
Using Rollovers to Highlight Points (Interact)
The lines and points combination is overkill for this data set: there are so many data points horizontally that the individual dots (at a size of five pixels) are nearly the size of the space allotted for each data point (around seven pixels), leaving only two pixels between them. Another option is to highlight individual points when the mouse is nearby. This is technique is nearly identical to the one used at the end of the previous chapter, and the function looks like the following:
void drawDataHighlight(int col) { for (int row = 0; row < rowCount; row++) { if (data.isValid(row, col)) { float value = data.getFloat(row, col); float x = map(years[row], yearMin, yearMax, plotX1, plotX2); float y = map(value, dataMin, dataMax, plotY2, plotY1); if (dist(mouseX, mouseY, x, y) < 3) { strokeWeight(10); point(x, y); fill(0); textSize(10); textAlign(CENTER); text(nf(value, 0, 2) + " (" + years[row] + ")", x, y-8); } } } }
The stroke weight for the point is set to 10 because the weight
used in the drawDataPoints( )
method
(5) would not contrast enough with the rest of the image. Similarly, the
stroke weight for the lines is set to 2, rather than the 0.5 stroke used
when combining drawDataLines( )
and
drawDataPoints( )
, because it should
stand out more. But strokeWeight(2)
is still thinner than the strokeWeight(5)
used when the drawDataLines( )
method is run by itself
because if the line itself is too thick, the rollover won’t be prominent
enough.
The modified draw( )
method to
draw the highlight follows:
void draw( ) {
background(224);
// Show the plot area as a white box.
fill(255);
rectMode(CORNERS);
noStroke( );
rect(plotX1, plotY1, plotX2, plotY2);
drawTitle( );
drawAxisLabels( );
drawYearLabels( );
drawVolumeLabels( );
stroke(#5679C1);noFill( );
strokeWeight(2);
drawDataLine(currentColumn);
drawDataHighlight(currentColumn);
}
An image of the result is shown in Figure 4-10.
Ways to Connect Points (Refine)
Connecting the points with a curve is often a better option
because it prevents the spikiness of the plot from overwhelming the data
itself. The curveVertex( )
function
is similar to the vertex( )
function,
except that it connects successive points by fitting them to a
curve.
The drawDataCurve( )
method, a
modification of drawDataLine( )
,
follows:
void drawDataCurve(int col) {
beginShape( );
for (int row = 0; row < rowCount; row++) {
if (data.isValid(row, col)) {
float value = data.getFloat(row, col);
float x = map(years[row], yearMin, yearMax, plotX1, plotX2);
float y = map(value, dataMin, dataMax, plotY2, plotY1);curveVertex(x, y);
// Double the curve points for the start and stop
if ((row == 0) || (row == rowCount-1)) {
curveVertex(x, y);
}
}
}
endShape( );
}
To draw a curve with curveVertex(
)
, at least four points are necessary because the first and
last coordinates in curveVertex( )
are used to guide the angle at which the curve begins and ends. In this
particular example, doubling start and stop points will work fine. In
other cases, additional points can be used to maintain continuity
between two connected curves.
The results of using a smooth curve can be seen most clearly when
comparing the coffee data drawn with vertex(
)
and curveVertex( )
in
Figure 4-11.
Showing Data As an Area
Another variation of drawDataLine(
)
draws the values as a filled area. Before calling endShape( )
, add the lower-right corner and
then the lower-left corner to complete the outline of the shape to be
filled. And instead of endShape( )
with no parameters, use endShape(CLOSE)
to close it, reconnecting it
to the first vertex.
The new drawDataArea( )
function is:
void drawDataArea(int col) { beginShape( ); for (int row = 0; row < rowCount; row++) { if (data.isValid(row, col)) { float value = data.getFloat(row, col); float x = map(years[row], yearMin, yearMax, plotX1, plotX2); float y = map(value, dataMin, dataMax, plotY2, plotY1); vertex(x, y); } }// Draw the lower-right and lower-left corners. vertex(plotX2, plotY2); vertex(plotX1, plotY2); endShape(CLOSE); }
Next, modify the end of the draw(
)
method to replace the stroke(#5679C1)
line with fill(#5679C1)
, and change noFill( )
to noStroke( )
; drawing an outline around an
already filled shape is unnecessary:
noStroke( ); fill(#5679C1); drawDataArea(currentColumn);
The new plot is shown in Figure 4-12.
This makes a more attractive plot, and because the data set considers the actual volume of consumption—that is, the vertical axis starts at 0—it makes sense to fill the area beneath the data points. Whenever filling a plot, consider whether the data being shown refers to some kind of actual area or volume. For instance, it would not be appropriate to fill the area beneath a plot of temperature because the lower bound is arbitrary (unless you’re measuring temperatures above absolute zero). A graph of rainfall, however, refers to the actual volume or amount that can be measured upward from “none,” making it a candidate for a filled plot.
Further Refinements and Erasing Elements
The highest priority of any information graphic is to place the
data it represents first and foremost. A filled area can seem too much
like the background, so sometimes it’s best to remove the background.
Without the gray background, the grid lines become awkward without
some kind of box around them to contain the plot. A box adds no
additional usefulness, just clutters the composition, so a better
option is to remove the background and make the gridlines part of the
graphic itself by setting their color to white. To draw the gridlines
on top of the data, move the drawYearLabels(
)
method after drawDataArea(
)
inside draw( )
so that
the grid lines will be drawn after the filled shape. The new draw( )
method is very sparse:
void draw( ) { background(255); drawTitle( ); drawAxisLabels( ); drawVolumeLabels( ); noStroke( ); fill(#5679C1); drawDataArea(currentColumn); drawYearLabels( ); }
Inside drawYearLabels( )
, use
stroke(255)
instead of stroke(224)
to make the gridlines white. The
results are shown in Figure 4-13.
Such minimization of graphic elements has long been the province of those who champion a “less is more” approach to design. Edward Tufte later popularized this approach in his series of books on information graphics.
Discrete Values with a Bar Chart (Represent)
When values are discrete and cannot be shown in a series, a bar chart might be more suitable. A common example is when data is missing and therefore does not represent a complete series. Drawing a bar chart is a matter of using rectangles instead of individual points, and then drawing the data centered at each horizontal location.
The following replacement for drawDataArea( )
creates a bar chart:
float barWidth = 4; // Add to the top of the page, before setup( ) void drawDataBars(int col) { noStroke( );rectMode(CORNERS); for (int row = 0; row < rowCount; row++) { if (data.isValid(row, col)) { float value = data.getFloat(row, col); float x = map(years[row], yearMin, yearMax, plotX1, plotX2); float y = map(value, dataMin, dataMax, plotY2, plotY1); rect(x-barWidth/2, y, x+barWidth/2, plotY2); } } }
Here, the barWidth
variable
makes the bars four pixels wide. Calculating widths for a bar chart
can be done with algebra (by dividing the distance between plotX2
and plotX1
by the number of rows of data) or by
trial and error.
It’s also necessary to disable the lines drawn in drawYearLabels( )
because vertical grid
lines will conflict with the bars.
Unfortunately, this is too much data to show at this width, resulting in the vibrating texture shown in Figure 4-14, which looks more like a swatch of patterned fabric.
This example highlights an important consideration: when deciding on a representation, use a bar chart only when there’s enough room to leave clear gaps between bars.
Once a bar chart is laid out properly, the method of using white grid lines in Figure 4-13 could be better utilized to highlight the divisions on the left axis by erasing thin horizontal lines across the plot. Like the version that sliced the area plot into individual decades, this would provide another cue to help the viewer quickly read data values.
Text Labels As Tabbed Panes (Interact)
Using keys to navigate an interface should be used only during
testing. A more sophisticated method is to use on-screen buttons, as
users expect from a modern interface. This section describes how to
replace the drawTitle( )
function
with drawTitleTabs( )
to introduce a
series of tabbed panel—one for each data series.
Adding the Necessary Variables
The tabTop
and tabBottom
variables specify the upper and
lower edge of the tabs. The tabLeft
and tabRight
variables store the
coordinates for the left and right edges of each tab so that we can
detect mouse clicks inside the tabs. The tabPad
variable specifies the amount of
padding on the left and right of the tab text:
float[] tabLeft, tabRight; // Add above setup( ) float tabTop, tabBottom; float tabPad = 10;
Drawing Tabs Instead of a Single Title
The important part of this method keeps track of a value named
runningX
to calculate the positions
of each tab. The width of each tab is calculated using textWidth( )
, and the tabPad
value is added to provide padding on
the sides:
void drawTitleTabs( ) { rectMode(CORNERS); noStroke( ); textSize(20); textAlign(LEFT); // On first use of this method, allocate space for an array // to store the values for the left and right edges of the tabs. if (tabLeft == null) { tabLeft = new float[columnCount]; tabRight = new float[columnCount]; } float runningX = plotX1; tabTop = plotY1 - textAscent( ) - 15; tabBottom = plotY1; for (int col = 0; col < columnCount; col++) { String title = data.getColumnName(col); tabLeft[col] = runningX; float titleWidth = textWidth(title); tabRight[col] = tabLeft[col] + tabPad + titleWidth + tabPad; // If the current tab, set its background white; otherwise use pale gray. fill(col == currentColumn ? 255 : 224); rect(tabLeft[col], tabTop, tabRight[col], tabBottom); // If the current tab, use black for the text; otherwise use dark gray. fill(col == currentColumn ? 0 : 64); text(title, runningX + tabPad, plotY1 − 10); runningX = tabRight[col]; } }
This piece of code also introduces the conditional operator,
identified by the ?
. The
conditional statement:
fill(col == currentColumn ? 0 : 64);
is equivalent to writing:
if (col == currentColumn) { fill(0); } else { fill(64); }
The benefit of the former is compact code: a single line instead
of five. The conditional operator is most useful in situations such as
this one, where a simple if
test is
used to control something straightforward like the fill color. In this
case, it can be argued that the shorter code is more readable than all
five lines. However, use the conditional operator sparingly because
overuse can result in code that is difficult to read.
Handling Mouse Input
Next, we’ll add the mousePressed(
)
method, which tests whether the mouse is inside one tab or
another. This method is a simple matter of iterating through each tab
and checking the mouseX
and
mouseY
coordinates against the
variables that contain the boundaries of each tab rectangle. If the
mouseY
value is in the correct
range, mouseX
is tested against
each tabLeft
and tabRight
value. If inside, the value of
currentColumn
is updated with the
setColumn( )
method:
void mousePressed( ) { if (mouseY > tabTop && mouseY < tabBottom) { for (int col = 0; col < columnCount; col++) { if (mouseX > tabLeft[col] && mouseX < tabRight[col]) { setColumn(col); } } } } void setColumn(int col) { if (col != currentColumn) { currentColumn = col; } }
The setColumn( )
method is
expressed in a separate piece of code because it will be modified in
the next section, and the keyPressed(
)
method should simply be removed.
Finally, the result is shown in Figure 4-15.
Better Tab Images (Refine)
The tabs in Figure 4-15 look pretty boring, but some tweaking of the text, the colors, and a line here and there could improve them. Another option is to load the tabs from a series of image files. Three separate image files would be used for the nonselected state of the tabs, and three others would be used for the selected state. Then, instead of setting the fill differently for the rectangle and the text title, one of the six images would be used in its place. A modified version of the code looks like this:
float[] tabLeft, tabRight; // Add above setup( ) float tabTop, tabBottom; float tabPad = 10;PImage[] tabImageNormal; PImage[] tabImageHighlight; void drawTitleTabs( ) { rectMode(CORNERS); noStroke( ); textSize(20); textAlign(LEFT); // Allocate the tab position array, and load the tab images. if (tabLeft == null) { tabLeft = new float[columnCount]; tabRight = new float[columnCount]; tabImageNormal = new PImage[columnCount]; tabImageHighlight = new PImage[columnCount]; for (int col = 0; col < columnCount; col++) { String title = data.getColumnName(col); tabImageNormal[col] = loadImage(title + "-unselected.png"); tabImageHighlight[col] = loadImage(title + "-selected.png"); } } float runningX = plotX1; tabBottom = plotY1; // Size based on the height of the tabs by checking the // height of the first (all images are the same height) tabTop = plotY1 - tabImageNormal[0].height; for (int col = 0; col < columnCount; col++) { String title = data.getColumnName(col); tabLeft[col] = runningX; float titleWidth = tabImageNormal[col].width; tabRight[col] = tabLeft[col] + tabPad + titleWidth + tabPad; PImage tabImage = (col == currentColumn) ? tabImageHighlight[col] : tabImageNormal[col]; image(tabImage, tabLeft[col], tabTop); runningX = tabRight[col]; } }
When preparing the images, be sure to keep their heights the same. As with the text version, the widths of the titles can vary, but the width of the selected versus non-selected version should always be the same. The images should be named based on the title of each column, so, in this case, the following six files are used:
Milk-selected.png
Tea-selected.png
Coffee-selected.png
Milk-unselected.png
Tea-unselected.png
Coffee-unselected.png
For those who want to use standard interface components instead of making their own, later chapters cover integrating Processing with Java code. Custom components are useful when a unique interface is preferred, but they are less helpful if a standard interface is more appropriate for your audience.
Interpolation Between Data Sets (Interact)
Chapter 3 showed how to interpolate between
values in a data set with the use of the Integrator
class. Download itfrom http://benfry.com/writing/series/Integrator.pde.
The changes are identical to those in the previous chapter. First,
declare the array of Integrator
objects before setup( )
:
Integrator[] interpolators;
Inside setup( )
, create each
Integrator
and set its initial
value:
interpolators = new Integrator[rowCount]; for (int row = 0; row < rowCount; row++) { float initialValue = data.getFloat(row, 0); interpolators[row] = new Integrator(initialValue); interpolators[row].attraction = 0.1; // Set lower than the default }
The attraction
value is set to
0.1 (instead of the default, 0.2) so that the interpolation occurs at a
less frantic pace.
In draw( )
, each Integrator
is updated:
for (int row = 0; row < rowCount; row++) { interpolators[row].update( ); }
Next, for whatever variation of the drawData( )
function you would like to use,
replace its data.getFloat( )
line.
The original looks like this:
float value = data.getFloat(row, col);
Change the line to the following to use the interpolated values:
float value = interpolators[row].value;
Finally, modify setColumn( )
to
set each Integrator
to target the
value for the current column:
void setColumn(int col) { currentColumn = col; for (int row = 0; row < rowCount; row++) { interpolators[row].target(data.getFloat(row, col)); } }
The final code, with modifications highlighted, follows:
FloatTable data; float dataMin, dataMax; float plotX1, plotY1; float plotX2, plotY2; float labelX, labelY; int rowCount; int columnCount; int currentColumn = 0; int yearMin, yearMax; int[] years; int yearInterval = 10; int volumeInterval = 10; int volumeIntervalMinor = 5; float[] tabLeft, tabRight; float tabTop, tabBottom; float tabPad = 10;Integrator[] interpolators; PFont plotFont; void setup( ) { size(720, 405); data = new FloatTable("milk-tea-coffee.tsv"); rowCount = data.getRowCount( ); columnCount = data.getColumnCount( ); years = int(data.getRowNames( )); yearMin = years[0]; yearMax = years[years.length - 1]; dataMin = 0; dataMax = ceil(data.getTableMax( ) / volumeInterval) * volumeInterval; interpolators = new Integrator[rowCount]; for (int row = 0; row < rowCount; row++) { float initialValue = data.getFloat(row, 0); interpolators[row] = new Integrator(initialValue); interpolators[row].attraction = 0.1; // Set lower than the default } plotX1 = 120; plotX2 = width - 80; labelX = 50; plotY1 = 60; plotY2 = height - 70; labelY = height - 25; plotFont = createFont("SansSerif", 20); textFont(plotFont); smooth( ); } void draw( ) { background(224); // Show the plot area as a white box fill(255); rectMode(CORNERS); noStroke( ); rect(plotX1, plotY1, plotX2, plotY2); drawTitleTabs( ); drawAxisLabels( ); for (int row = 0; row < rowCount; row++) { interpolators[row].update( ); } drawYearLabels( ); drawVolumeLabels( ); noStroke( ); fill(#5679C1); drawDataArea(currentColumn); } void drawTitleTabs( ) { rectMode(CORNERS); noStroke( ); textSize(20); textAlign(LEFT); // On first use of this method, allocate space for an array // to store the values for the left and right edges of the tabs. if (tabLeft == null) { tabLeft = new float[columnCount]; tabRight = new float[columnCount]; } float runningX = plotX1; tabTop = plotY1 - textAscent( ) - 15; tabBottom = plotY1; for (int col = 0; col < columnCount; col++) { String title = data.getColumnName(col); tabLeft[col] = runningX; float titleWidth = textWidth(title); tabRight[col] = tabLeft[col] + tabPad + titleWidth + tabPad; // If the current tab, set its background white; otherwise use pale gray. fill(col == currentColumn ? 255 : 224); rect(tabLeft[col], tabTop, tabRight[col], tabBottom); // If the current tab, use black for the text; otherwise use dark gray. fill(col == currentColumn ? 0 : 64); text(title, runningX + tabPad, plotY1 - 10); runningX = tabRight[col]; } } void mousePressed( ) { if (mouseY > tabTop && mouseY < tabBottom) { for (int col = 0; col < columnCount; col++) { if (mouseX > tabLeft[col] && mouseX < tabRight[col]) { setColumn(col); } } } } void setCurrent(int col) { currentColumn = col; for (int row = 0; row < rowCount; row++) { interpolators[row].target(data.getFloat(row, col)); } } void drawAxisLabels( ) { fill(0); textSize(13); textLeading(15); textAlign(CENTER, CENTER); text("Gallons\nconsumed\nper capita", labelX, (plotY1+plotY2)/2); textAlign(CENTER); text("Year", (plotX1+plotX2)/2, labelY); } void drawYearLabels( ) { fill(0); textSize(10); textAlign(CENTER); // Use thin, gray lines to draw the grid stroke(224); strokeWeight(1); for (int row = 0; row < rowCount; row++) { if (years[row] % yearInterval == 0) { float x = map(years[row], yearMin, yearMax, plotX1, plotX2); text(years[row], x, plotY2 + textAscent( ) + 10); line(x, plotY1, x, plotY2); } } } void drawVolumeLabels( ) { fill(0); textSize(10); textAlign(RIGHT); stroke(128); strokeWeight(1); for (float v = dataMin; v <= dataMax; v += volumeIntervalMinor) { float y = map(v, dataMin, dataMax, plotY2, plotY1); if (v % volumeInterval == 0) { // If a major tick mark float textOffset = textAscent( )/2; // Center vertically if (v == dataMin) { textOffset = 0; // Align by the bottom } else if (v == dataMax) { textOffset = textAscent( ); // Align by the top } text(floor(v), plotX1 - 10, y + textOffset); line(plotX1 - 4, y, plotX1, y); // Draw major tick } else { //line(plotX1 - 2, y, plotX1, y); // Draw minor tick } } } void drawDataArea(int col) { beginShape( ); for (int row = 0; row < rowCount; row++) { if (data.isValid(row, col)) { float value = interpolators[row].value; float x = map(years[row], yearMin, yearMax, plotX1, plotX2); float y = map(value, dataMin, dataMax, plotY2, plotY1); vertex(x, y); } } vertex(plotX2, plotY2); vertex(plotX1, plotY2); endShape(CLOSE); }
End of the Series
In this chapter, we looked at the most common form of data plot:
the time series. The point was to get comfortable with functions such as
map( )
, pick up some principles on
how to choose a representation, and see how a few lines of code can help
produce an alternate representation or a more refined appearance. The
techniques implemented here are useful for nearly any type of plot, as
the algebra for placement and considerations for use will be identical
across all other data sets that we examine.
Developers familiar with Processing or Java might want to make
this code into a class. Classes are a useful means for encapsulating
data sets. For instance, this code could be made into a class named
TimeSeries
to handle arbitrary data
stored in a table. This might be a useful abstraction, but keep in mind
how you customize the code once it’s in a class. The final version of
the program listing in this chapter is just over 200 lines (a little
more than three printed pages). Once you’ve moved this code into a
200-line class, how do you keep it flexible? Do you modify it directly
or subclass it? Is it necessary to create a new subclass for each new
type of representation, when each new representation is between 5 and 20
lines apiece? Always weigh such decisions in terms of how the code will
be used. If only one representation is required for your particular
project, why bother maintaining multiple subclasses? Just do the
representation right the first time. And when reusing the code in your
next project, you’ll probably change at least 10% of the base code
anyway, so there’s no need to maintain several subclasses. As our
projects become more complicated, we’ll do more to encapsulate code into
modular units, while doing our best to avoid needless levels of
abstraction.
Of course, there are libraries that allow you to plot data in a number of ways, particularly for simple things such as time series or bar charts. For Java coders, JFreeChart is a widely used example (see http://www.jfree.org/jfreechart). JFreeChart is a nice tool for basic charting and graphing, but it doesn’t allow the kind of flexible customization taught here—which you are hopefully coming to appreciate. This book intends to teach you the starting point for drawing basic representations, such as a plot or chart, and then goes on to show how they can be manipulated in a more sophisticated manner than can be done with standard tools.
[2] * Tufte, Edward R. The Visual Display of Quantitative Information. Cheshire, Conn.: Graphics Press, 1983.
Get Visualizing Data now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.