A data visualization framework

from your friends at Team 9

Welcome! This document will help you get started using the framework to write source and view plugins. We'll start with a super brief, high-level overview of how everything works, and then dive into what you need to do to get your stuff working.

Just looking for the Javadoc? Head on over here.

This document has the following sections:

Key concepts

The following three things are the “big ideas” of the framework. It'd probably be a good idea to keep them in mind while writing your code. Here they are:

  • Everything is tabular. Data is stored in columns.
  • Columns have types: they ascribe to different specifications, like “numeric column” or “column that contains names of umbrella manufacturers.”
  • Views specify what kinds of columns they want via a view specification. The framework makes sure that they get them.

Diving in with an example view plugin

Remember choropleths? A choropleth is a map that has a value or color or something associated with each region.

Let's write a view plugin to draw choropleths.

Step 1: Think about the types. What kind of information does a choropleth require?

  • There'll be a bunch of regions.
  • Each region needs to have a name, a value (like, rainfall or something; the thing that controls the color), and a shape.
  • Each shape can be represented as a polygon, with x and y coordinates.
  • In particular, we can just have a column called “Shape” that will be specially designed to hold shapes.

Writing a column specification

Cool: so let's create a PolygonColumn! Happily, this is not actually difficult. We can start by extending AbstractColumn<Polygon> . Then our IDE will give us the following template:

class PolygonColumn extends AbstractColumn<Polygon> {

    /**
     * Create a new column with the given name.
     *
     * @param name
     *         the name of this column, like {@code "Pizza consumption"}
     */
    public PolygonColumn(String name) {
        super(name);
    }

    @Override
    protected Optional<Polygon> parse(String s) {
        return null;
    }

    @Override
    public String getTypeName() {
        return null;
    }
}

Okay, so there's a constructor, and it takes a name. No problem. Though we should change the doc comment, because pizza has no place on our maps.

    /**
     * […]
     * @param name
     *         the name of this column, like {@code "Region shape"}
     */

Now we need to implement two abstract methods. The second one looks pretty easy. If we look at the docs, we see that we're supposed to return a human-readable name of the type of the column. Well, this column stores polygons. So here we go:

    @Override
    public String getTypeName() {
        return "Polygon column";
    }

Now comes the fun bit: we get to specify how to parse a polygon. In particular, we're given a String as input, and this string might represent a valid polygon. If it represents a polygon p, we should return Optional.of(p); otherwise, we'll return Optional.empty(). (Recall that Optional<T> is a Java class that can represent values or computations that may have failed.)

We should probably actually specify what kind of input we want. For simplicity, let's use input of the form x1, y1; x2, y2; …; xn, yn; that is, points are separated by semicolons and coordinates are separated by commas.

Okay, so…we know that the java.awt.Polygon constructor needs an int[] x and an int[] y and an int nPoints. So we can start by extracting each point and creating some arrays:

    @Override
    protected Optional<Polygon> parse(String s) {
        final String[] parts = s.replaceAll("\\s", "").split(";");
        int n = parts.length;
        int[] x = new int[n], y = new int[n];
        // TODO: finish the parsing
    }

(Note that we kill all the whitespace so that we don't have to worry about it; unlike trim, this replaceAll call will replace internal whitespace as well.)

Now, we can inspect each part in turn and try to parse it. First of all, each part should contain exactly one x coordinate and one y coordinate. If it doesn't, the input is invalid. So:

    @Override
    protected Optional<Polygon> parse(String s) {
        final String[] parts = s.replaceAll("\\s", "").split(";");
        int n = parts.length;
        int[] x = new int[n], y = new int[n];
        for (int i = 0; i < n; i++) {
            final String[] coordinates = parts[i].split(",");
            if (coordinates.length != 2) {
                return Optional.empty();
            }
            // TODO: handle the success case
        }
    }

I guess when it does have one of each coordinate we want to add them to the arrays, right? And these might be invalid numbers, like ha, ha; nice, try, so we should just error if that happens. No problem: we can catch that NumberFormatException:

    @Override
    protected Optional<Polygon> parse(String s) {
        final String[] parts = s.replaceAll("\\s", "").split(";");
        int n = parts.length;
        int[] x = new int[n], y = new int[n];
        try {
            for (int i = 0; i < n; i++) {
                final String[] coordinates = parts[i].split(",");
                if (coordinates.length != 2) {
                    return Optional.empty();
                }
                x[i] = Integer.parseInt(coordinates[0]);
                y[i] = Integer.parseInt(coordinates[1]);
            }
            // TODO: gather and return the result
        } catch (Exception ignored) {
            return Optional.empty();
        }
    }

Once we're done, we can just bundle up the arrays in a Polygon constructor, wrap it in a successful Optional, and return it! Here's the whole method:

    @Override
    protected Optional<Polygon> parse(String s) {
        final String[] parts = s.replaceAll("\\s", "").split(";");
        int n = parts.length;
        int[] x = new int[n], y = new int[n];
        try {
            for (int i = 0; i < n; i++) {
                final String[] coordinates = parts[i].split(",");
                if (coordinates.length != 2) {
                    return Optional.empty();
                }
                x[i] = Integer.parseInt(coordinates[0]);
                y[i] = Integer.parseInt(coordinates[1]);
            }
            return Optional.of(new Polygon(x, y, n));
        } catch (Exception ignored) {
            return Optional.empty();
        }
    }

And, just like that, our column specification is done.

Now, we can move on to designing our view!

Creating the view specification

To be able to interface with the framework, we'll need to define a view specification. Thankfully, this is super easy. We can create a ChoroplethViewSpecification class that implements DataViewSpecification, and see what our IDE requires that we fill in:

public class ChoroplethViewSpecification implements DataViewSpecification {
    @Override
    public String name() {
        return null;
    }

    @Override
    public DataView create() {
        return null;
    }

    @Override
    public List<ColumnSpecification> seriesDataSpecification() {
        return null;
    }

    @Override
    public List<ColumnSpecification> sharedDataSpecification() {
        return null;
    }
}

So, okay, there's four methods here:

  • void name(String) just wants the name of this kind of specification. Much like getTypeName in our column specification, it can just return a constant: here, something like "Choropleth view".
  • DataView create() wants us to give it the actual GUI view object. We're not there yet, but it will just be a return new ChoroplethView(); once we've created that class.
  • List<ColumnSpecification> seriesDataSpecification() is more interesting: we should return all the columns that a choropleth needs to render a single series. We'll look at this in more detail in a moment.
  • Finally, there's this List<ColumnSpecification> sharedDataSpecification() method. This is used for things like bar charts that need additional information that's separate from any series and shared among all of them. Our choropleth doesn't actually care about this, so we'll just return Collections.emptyList();.

The first thing to note, thus, is that three of the four methods are one-liners! Let's take a look at seriesDataSpecification, then.

We said earlier that each series needs a region name, a value, and a shape. It seems reasonable to assume that the region name may be any string, the value may be any number, and the shape may be…well, any PolygonColumn, of course—we didn't create it for nothing!

Using this information, we can go ahead and create some instances of ColumnSpecifications. These instances will be used by the specification class, the framework, and the view class to coordinate the data manipulation. It's super easy: just add three constants to the class:

    public static final StringColumn REGION_NAME = new StringColumn("Region name");
    public static final PolygonColumn REGION_SHAPE = new PolygonColumn("Region shape");
    public static final NumericColumn DATA_VALUE = new NumericColumn("Data value");

A note on static typing. Note that the static types of the columns are narrow: like PolygonColumn, not the abstract ColumnSpecification. This is important, because if you only know that it's some kind of ColumnSpecification then the Java type system can't ensure that you're actually getting a Polygon when you try to extract values from the column. If you statically declare your types as above, you won't have to use any casts anywhere, and everything will be 100% typesafe.

Note that the names passed to the constructors should be human-readable. End users will need to understand these to pick the right columns.

Now we can just box these up in a list in our seriesDataSpecification: that's all we need to do! Here's the full class, which will work once we've created the view itself:

public class ChoroplethViewSpecification implements DataViewSpecification {

    public static final StringColumn REGION_NAME = new StringColumn("Region name");
    public static final PolygonColumn REGION_SHAPE = new PolygonColumn("Region shape");
    public static final NumericColumn DATA_VALUE = new NumericColumn("Data value");

    @Override
    public String name() {
        return "Choropleth view";
    }

    @Override
    public DataView create() {
        return new ChoroplethView();
    }

    @Override
    public List<ColumnSpecification> seriesDataSpecification() {
        return Arrays.asList(REGION_NAME, REGION_SHAPE, DATA_VALUE);
    }

    @Override
    public List<ColumnSpecification> sharedDataSpecification() {
        return Collections.emptyList();
    }

}

Creating the actual view

Now to write the actual GUI! As before, let's create a class ChoroplethView that extends DataView, and see what we need to do:

public class ChoroplethView extends DataView {
  @Override
  public void loadData(Data data) {
  }
}

Uh, well, that's not much. I guess at some point the framework will give us this Data thing and then we're supposed to do something.

More precisely, the Data object has a list of Series. Each Series has a map from ColumnSpecifications to Columns that contain the actual data. So you could write something like this:

final Data data = getMyDataFromSomewhere();
final Series series88 = data.series().get(88);
final Map<ColumnSpecification, Column> series88Columns = series88.data();

Now we can use the columns that we share with our ChoroplethViewSpecification, along with a nifty helper method extractAllFrom(Column) (provided by AbstractColumn), to get all the names and stuff:

final List<String> names =
    ChoroplethViewSpecification.REGION_NAME.extractAllFrom(series88Columns);
final List<Polygon> shapes =
    ChoroplethViewSpecification.REGION_SHAPE.extractAllFrom(series88Columns);

So…let's do it! We'll make the simplifying assumption that we're just going to paint the first series. (I actually don't know how you'd make a choropleth with multiple series.) Also, we're going to ignore the region names. But you could have some nice mouse listener that showed stuff on hover, I guess.

When we get the data from the framework, we'd probably like to do a little preprocessing so that our painting code can be really fast. In particular, let's store the minimum and maximum “z”/color values so that we know how to scale the colors without having to do an extra pass through the data each time we paint. We might as well go ahead and extract the polygons and values to arrays, as well, to avoid having to parse them every time we render.

Let's go ahead and add some fields to our class, then:

    private int numRegions;
    private Polygon[] shapes;
    private double[] zValues;
    private double zMin;
    private double zMax;

Now, in our loadData method, we can just populate these and then repaint. Because the user might give us no data if they're feeling tricksy, we can use the following kind of skeleton:

    @Override
    public void loadData(Data data) {
        if (data.series().isEmpty()) {
            numRegions = 0;
            repaint();
            return;
        }
        // TODO: Populate the fields for painting.
        repaint();
    }

Let's start by getting all the things we care about out of the Data object, and figuring out how many regions there are:

        final Series series = data.series().get(0);
        final List<Polygon> polygons =
                ChoroplethViewSpecification.REGION_SHAPE.extractAllFrom(series.data());
        final List<Double> values =
                ChoroplethViewSpecification.DATA_VALUE.extractAllFrom(series.data());
        numRegions = Math.min(polygons.size(), values.size());

Note that we take the minimum, because nothing actually stops the user from inputting non-rectangular data, and we shouldn't choke on such an input.

Next, we can just loop through the data once, populating the arrays and updating the extrema as we go:

        shapes = new Polygon[numRegions];
        zValues = new double[numRegions];
        zMin = Double.POSITIVE_INFINITY;
        zMax = Double.NEGATIVE_INFINITY;
        for (int i = 0; i < numRegions; i++) {
            shapes[i] = polygons.get(i);
            final double z = values.get(i);
            zValues[i] = z;
            zMin = Math.min(zMin, z);
            zMax = Math.max(zMax, z);
        }

That's all we need to do to initialize the things! Here's the full method, in case you missed it:

    @Override
    public void loadData(Data data) {
        if (data.series().isEmpty()) {
            numRegions = 0;
            repaint();
            return;
        }
        final Series series = data.series().get(0);
        final List<Polygon> polygons =
                ChoroplethViewSpecification.REGION_SHAPE.extractAllFrom(series.data());
        final List<Double> values =
                ChoroplethViewSpecification.DATA_VALUE.extractAllFrom(series.data());
        numRegions = Math.min(polygons.size(), values.size());

        shapes = new Polygon[numRegions];
        zValues = new double[numRegions];
        zMin = Double.POSITIVE_INFINITY;
        zMax = Double.NEGATIVE_INFINITY;
        for (int i = 0; i < numRegions; i++) {
            shapes[i] = polygons.get(i);
            final double z = values.get(i);
            zValues[i] = z;
            zMin = Math.min(zMin, z);
            zMax = Math.max(zMax, z);
        }

        repaint();
    }

Now, we should actually say how to paint things! No problem.

There's always a tad of boilerplate at the top. As always with Swing painting, we want to upgrade our lowly Graphics object to a Graphics2D. Then, we'd like to antialias it; the convenience method in Utilities (provided with the framework) lets us do so easily:

    @Override
    protected void paintComponent(Graphics g) {
        final Graphics2D g2d = (Graphics2D) g;
        Utilities.antialias(g2d);
        // TODO: Paint the actual data.
    }

To paint the actual data, we can just loop using our pre-initialized values, and do a bit of processing to get the current value as a proportion of the range:

        for (int i = 0; i < numRegions; i++) {
            final Polygon polygon = shapes[i];
            final double z = zValues[i];
            final double t = (z - zMin) / (zMax - zMin);
            // TODO: Paint this polygon.
        }

Now we need to pick an appropriate color and fill in the polygon.

There are two ways we could pick a color:

  • Maybe we want a rainbow gradient color scale. The utilities class provides Utilities.gradient(double), which outputs a nice gradient exactly for purposes like this.
  • Or maybe we want to have different intensities of a single color. The Utilities.withAlphaFloating(Color, double) method will come in handy here.

In fact, we might as well extract this to a separate method, computeColor(double), for flexibility. (Not really required in practice, of course; just so that we can show both variants here.) Then the code is just

            g2d.setColor(computeColor(t));
            g2d.fill(polygon);

because Polygon is a built-in Shape class, so Graphics knows how to paint it.

Here's the gradient implementation of computeColor:

    private Color computeColor(double t) {
        return Utilities.gradient(t);
    }

And here's the intensity-modulating implementation:

    private Color computeColor(double t) {
        return Utilities.withAlphaFloating(Color.BLUE, t);
    }

And, finally, here's the whole paintComponent method:

    @Override
    protected void paintComponent(Graphics g) {
        final Graphics2D g2d = (Graphics2D) g;
        Utilities.antialias(g2d);
        for (int i = 0; i < numRegions; i++) {
            final Polygon polygon = shapes[i];
            final double z = zValues[i];
            final double t = (z - zMin) / (zMax - zMin);
            g2d.setColor(computeColor(t));
            g2d.fill(polygon);
        }
    }

This is it—really! There's nothing more that we need to do to implement the view. Now we can test it in just as few lines of code!

Testing our view with a fixture

Testing is important.

Testing GUI components is usually hard.

Testing data views is easy!

The framework provides a class called AbstractDataViewFixture. Its purpose is to make it really easy to write tests for fixtures: it has a super high power-to-weight ratio.

Let's see what our IDE advises us to implement when we try to create a fixture:

public class ChoroplethViewFixture extends AbstractDataViewFixture {
    @Override
    protected DataView createView() {
        return null;
    }

    @Override
    protected Series generateSeries(Random rnd, String seriesName) {
        return null;
    }
}

Well, okay, we can probably guess that our createView method should return a new ChoroplethView(). Great.

Then the only other thing we need to do is say how to actually generate the data.

It may be helpful to explain what the fixture does. It creates a bunch of data sets and shows your view in all these different configurations. This is useful to see a bunch of different kinds of settings at once: each data set is like a separate test case.

We need to specify how to generate the data because each view has different requirements and the framework can't know what to do. (Case in point: we created a brand new column specification, so of course the framework doesn't know what to do!) This is not a problem: we just need to generate some data, using the provided source of randomness, if we want.

A note on randomness. It's important to use the provided source of randomness, as opposed to Math.random, within a fixture. The former has a constant seed, ensuring predictable test cases. The latter will cause your data to vary from run to run, which will make you sad when things break.

Note that the data we're providing is in raw, pre-parsed form. So we'll need to return a bunch of strings, essentially. We might begin thus:

        final int regionCount = rnd.nextInt(30) + 20;
        final List<String> names = new ArrayList<>();
        final List<String> shapes = new ArrayList<>();
        final List<String> values = new ArrayList<>();
        // TODO: Populate these lists and return them in a Series.

We can start by generating the names for each region, and the values, too:

        for (int i = 0; i < regionCount; i++) {
            final String name = "Region " + (i + 1);
            names.add(name);

            // TODO: Add something to the `shapes` list.

            final double value = rnd.nextDouble() * 33;
            values.add(Double.toString(value));
        }

Now, to generate a shape, we'll need to encode something in the 1, 2; 3, 4 form expected by our source. No problem. We can start by initializing a StringBuilder and picking our starting point:

            final StringBuilder sb = new StringBuilder();
            final int nPoints = rnd.nextInt(7) + 3;
            int lastX = rnd.nextInt(600);
            int lastY = rnd.nextInt(400);
            sb.append(lastX);
            sb.append(", ");
            sb.append(lastY);
            // TODO: Add the rest of the points.
            shapes.add(sb.toString());

Then, to add the rest of the points, we just…add the rest of the points. I've chosen to make each point a maximal constant distance from the previous points; you could do something nicer with convex hulls or polar coordinates or actual data, but whatever:

            for (int j = 1; j < nPoints; j++) {
                sb.append("; ");
                lastX = lastX + rnd.nextInt(20) - 10;
                lastY = lastY + rnd.nextInt(20) - 10;
                sb.append(lastX);
                sb.append(", ");
                sb.append(lastY);
            }

Great! Now we have our lists, so we can create our columns:

        final Column namesColumn = new Column(Optional.of("Country"), names);
        final Column shapesColumn = new Column(Optional.of("Shape"), shapes);
        final Column valuesColumn = new Column(Optional.of("Rainfall (meters)"), values);

(Note that columns can optionally have headers; in this case, we may as well include them. Our plugin ignores them, but it doesn't have to: it could show a legend, for example.)

Then, we have to say which columns are associated with which specifications, to complete the linkage from source to view:

        final Map<ColumnSpecification, Column> columnMap = new HashMap<>();
        columnMap.put(ChoroplethViewSpecification.REGION_NAME, namesColumn);
        columnMap.put(ChoroplethViewSpecification.REGION_SHAPE, shapesColumn);
        columnMap.put(ChoroplethViewSpecification.DATA_VALUE, valuesColumn);

A note on type safety. In this last block of code, we manually bound the columns to the column specifications. When we do so, we as programmers are asserting that each column is valid according to the relevant specification. In normal program operation, the framework will ensure this, but we're not using any of its GUI so it's got no say in the matter. If something breaks at this point—maybe you get "null column, or this is not a key," which means you forgot to include a column, or maybe you get a validation error, which means your column generation was busted—then it is a problem with your code and not with the framework.

Finally, we can wrap this up in the desired Series object (with some name, which we don't care about) and return it to the caller:

        return new Series(seriesName, columnMap);

Finally, we need a main entry point. The fixture takes care of this, too; we just need to let Java know about it. Just call runMain on a new instance:

    public static void main(String[] args) {
        new ChoroplethViewFixture().runMain();
    }

So here's the whole shebang:

public class ChoroplethViewFixture extends AbstractDataViewFixture {

    public static void main(String[] args) {
        new ChoroplethViewFixture().runMain();
    }

    @Override
    protected Series generateSeries(Random rnd, String seriesName) {
        final int regionCount = rnd.nextInt(30) + 20;
        final List<String> names = new ArrayList<>();
        final List<String> shapes = new ArrayList<>();
        final List<String> values = new ArrayList<>();

        for (int i = 0; i < regionCount; i++) {
            final String name = "Region " + (i + 1);
            names.add(name);

            final StringBuilder sb = new StringBuilder();
            final int nPoints = rnd.nextInt(7) + 3;
            int lastX = rnd.nextInt(600);
            int lastY = rnd.nextInt(400);
            sb.append(lastX);
            sb.append(", ");
            sb.append(lastY);
            for (int j = 1; j < nPoints; j++) {
                sb.append("; ");
                lastX = lastX + rnd.nextInt(20) - 10;
                lastY = lastY + rnd.nextInt(20) - 10;
                sb.append(lastX);
                sb.append(", ");
                sb.append(lastY);
            }
            shapes.add(sb.toString());

            final double value = rnd.nextDouble() * 33;
            values.add(Double.toString(value));
        }

        final Column namesColumn = new Column(Optional.of("Country"), names);
        final Column shapesColumn = new Column(Optional.of("Shape"), shapes);
        final Column valuesColumn = new Column(Optional.of("Rainfall (meters)"), values);

        final Map<ColumnSpecification, Column> columnMap = new HashMap<>();
        columnMap.put(ChoroplethViewSpecification.REGION_NAME, namesColumn);
        columnMap.put(ChoroplethViewSpecification.REGION_SHAPE, shapesColumn);
        columnMap.put(ChoroplethViewSpecification.DATA_VALUE, valuesColumn);

        return new Series(seriesName, columnMap);
    }

    @Override
    protected DataView createView() {
        return new ChoroplethView();
    }
}

Here's a sample of what it looks like: Choropleth view in action

Here's the same view using the gradient color scheme (which, you will notice, omits cyan, because cyan is evil): Choropleth view with a gradient color scheme

It looks like it's working! And not too shabby a set of test cases for some fifty lines of code.

Some things to note:

  • Our data is awful and not interesting, but that doesn't mean the rendering isn't working.
  • We didn't do any auto-scaling, so the points appear with whatever literal x and y coordinates were provided. This is something that you should do in a real plugin.
  • We don't have a legend or anything. This is also something that you should add in a real plugin.

This concludes the process of writing a plugin. Next, we'll see how you can get it into the main framework.

For information about how to distribute your plugin, please see the section “Distributing your plugin” below.

Continuing with a sample data plugin

All the traditional sources of data are kind of boring, you know? Like, you can read from a file or maybe do something fancy like pull down from the web. In this section, we'll implement something a bit more exciting…with an element of randomness…a customizable magic 8-ball!

To be more specific, here's how our plugin will work. The user will be able to input some number of lines of text. The user can also select a number n. To generate the data, we randomly pick n lines from the lines provided and return them. (We sample “with replacement”—that is, we can pick the same thing twice.) If the user asks for more than 0 lines but didn't actually enter any, that's going to be an error.

Defining the specification

We'll start off by creating the DataSourceSpecification, which tells the framework about our source and enables it to be linked into the GUI. So let's create a class called Magic8BallSpecification that implements DataSourceSpecification, and see what we need to do:

public class Magic8BallSpecification implements DataSourceSpecification {
    @Override
    public String name() {
        return null;
    }

    @Override
    public DataSource create() {
        return null;
    }
}

You can see that this is even simpler than the view plugin! The name docs say that we should return a human-readable name for the source, so I imagine that "Magic 8-ball" will do. And to create it we just need to return the actual source that we'll create! Presumably, we'll create a class in a moment called Magic8BallSource, so we can complete the specification by just filling in the template:

public class Magic8BallSpecification implements DataSourceSpecification {
    @Override
    public String name() {
        return "Magic 8-ball";
    }

    @Override
    public DataSource create() {
        return new Magic8BallSource();
    }
}

Check!

Setting up the source component's GUI

Now, we can create our Magic8BallSource class, extending the DataSource framework class, and again see what we need to implement:

public class Magic8BallSource extends DataSource {

    @Override
    public DataResult retrieveData() {
        return null;
    }

}

So there's a method called retrieveData and it returns something funny called a DataResult. What might this be?

It turns out that DataResult is really simple. Basically, the retrieveData method means, “hey, plugin, try to give me your data, but if the user hasn't finished filling everything out yet or they did something wrong, that's okay.” In particular, the plugin is allowed to return xeither a successful list of columns representing the data or a human-readable failure message detailing what went wrong. You can create a DataResult by invoking DataResult.success(List<Column>) or DataResult.failure(String).

Well, this is all nice, but we can't actually do anything until we have some input from the user. So we had better add some GUI components to our data source! (By the way, DataSource extends JPanel, so that's probably good to know.)

As we mentioned above, we'll need to keep track of the number of things to generate and the content to use for the lines. Let's go ahead and add those as fields:

    private int outputCount = 10;
    private final List<String> outputValues = new ArrayList<>();

(Note that outputValues is final even though we'll modify its contents.)

Now, in the GUI, we'll need to add a JSpinner for the output count and, say, a JTextArea for the values. We probably want these to have little labels that say, you know, “Number of outputs” and “Possible output values.”. And it'd be nice if these were aligned in some clean tabular form layout.

Luckily, the framework provides just what we need! There's a JKeyValuePanel class that will do just this. Let's start by creating it:

    public Magic8BallSource() {
        setLayout(new BorderLayout());
        final JKeyValuePanel pnlTable = new JKeyValuePanel();
        add(pnlTable);

        final JSpinner spnOutputCount = null;    // TODO: Create this
        pnlTable.addEntry("Output count", spnOutputCount);

        final JTextArea txrOutputValues = null;  // TODO: Create this
        pnlTable.addEntry("Possible output values", txrOutputValues);
    }

See that addEntry method? That's where the magic is. (Actually, it's just, like, three lines of code for the whole class. But that doesn't mean it isn't semantically useful: it means you don't have to worry about layout at all, which, if you've done any GUI programming, you'll know is wonderful.)

So, let's create the things!

One thing you should know about JSpinners, by the way, is that they're just a UI wrapper around a stateful object called the model. The model is what stores the underlying state of the spinner, and parameters like the minimum and maximum values. So let's start by creating that:

        final SpinnerNumberModel mdlOutputCount = new SpinnerNumberModel(outputCount, 0, 1000, 1);

(Those parameters are value, minimum, maximum, and stepSize, by the way.)

Now, when this changes, we want to update our field:

        mdlOutputCount.addChangeListener(ce -> outputCount = mdlOutputCount.getNumber().intValue());

And then we can just create a new JSpinner for this object! So here are the four relevant lines:

        final SpinnerNumberModel mdlOutputCount = new SpinnerNumberModel(outputCount, 0, 1000, 1);
        mdlOutputCount.addChangeListener(ce -> outputCount = mdlOutputCount.getNumber().intValue());
        final JSpinner spnOutputCount = new JSpinner(mslOutputCount);
        pnlTable.addEntry("Output count", spnOutputCount);

(You could even inline spnOutputCount into the addEntry call if you like.)

Now we can move on to the text area. We'll start by creating a normal text area, and then we'll add a change listener that updates the field, as before. We'll also add the thing in a scroll pane so that users can type as much as they want:

        final JTextArea txrOutputValues = new JTextArea();
        // TODO: Add a listener.
        pnlTable.addEntry("Possible output values", new JScrollPane(txrOutputValues));

Unfortunately, the only listener supported by JTextArea is the kind-of-annoying DocumentListener, which requires you to implement three separate methods that will almost always all do the same thing. In this case, they will, too. Here's what the skeleton looks like:

        txrOutputValues.getDocument().addDocumentListener(new DocumentListener() {
            @Override
            public void insertUpdate(DocumentEvent e) {
                handle(e);
            }

            @Override
            public void removeUpdate(DocumentEvent e) {
                handle(e);
            }

            @Override
            public void changedUpdate(DocumentEvent e) {
                handle(e);
            }

            private void handle(DocumentEvent e) {
                // TODO: Handle this.
            }
        });

(By the way, if you've never done Swing programming without lambda expressions, this is what everything used to look like! This listener skeleton alone is longer than the rest of our constructor code.)

Actually handling the event, however, is easy:

            private void handle(DocumentEvent e) {
                outputValues.clear();
                Collections.addAll(outputValues, txrOutputValues.getText().split("\n"));
            }

(There's no need to synchronize because this is going to be run on the event dispatch thread; I think it's fast enough to not require a SwingWorker.)

Now we can move on to using these values to generate our data!

Generating the data

Okay: we're supposed to implement the retrieveData method, which takes no parameters and returns a DataResult indicating either success or failure.

I guess the failure case is easy: we said above that we fail if (and only if) the user wants a nonzero number of outputs but hasn't provided any input:

        if (outputValues.isEmpty() && outputCount > 0) {
            return DataResult.failure("No output choices listed: " +
                    "please enter some possible outputs into the text area.");
        }
        // TODO: Handle the success case.

In the success case, we'll eventually need to call DataResult.success. This needs a List of Columns. And each Column is created from an optional header and a List of Strings containing the contents.

Just to show off, let's include two columns: one to index the values from 1 through n, and one to hold the actual results. We can set up lists to store the values for the columns, and then eventually actually create column objects out of them and return the result:

        final List<String> indices = new ArrayList<>();
        final List<String> outputs = new ArrayList<>();
        // TODO: Populate these lists.
        final Column indicesColumn = new Column(Optional.of("Index"), indices);
        final Column outputsColumn = new Column(Optional.of("Output"), outputs);
        return DataResult.success(Arrays.asList(indicesColumn, outputsColumn));

Note that Columns have an optional column header; it's good practice to include this whenever possible, which is pretty much always the case for generated data. (If the user provides, say, a CSV file without headers, it'd be reasonable not to include it.) And to generate the result we just throw our columns in a list.

Populating those lists is easy, too. We can just loop over each trial and choose things from the list of possible values:

        final Random rnd = new Random();
        for (int i = 0; i < outputCount; i++) {
            indices.add(Integer.toString(i + 1));
            outputs.add(outputValues.get(rnd.nextInt(outputValues.size())));
        }

Recall that outputValues is our field that has the possible values, as populated by the JTextField.

So that's it, actually: we're all done! Here's the full method:

    @Override
    public DataResult retrieveData() {
        if (outputValues.isEmpty() && outputCount > 0) {
            return DataResult.failure("No output choices listed: " +
                    "please enter some possible outputs into the text area.");
        }
        final List<String> indices = new ArrayList<>();
        final List<String> outputs = new ArrayList<>();

        final Random rnd = new Random();
        for (int i = 0; i < outputCount; i++) {
            indices.add(Integer.toString(i + 1));
            outputs.add(outputValues.get(rnd.nextInt(outputValues.size())));
        }

        final Column indicesColumn = new Column(Optional.of("Index"), indices);
        final Column outputsColumn = new Column(Optional.of("Output"), outputs);
        return DataResult.success(Arrays.asList(indicesColumn, outputsColumn));
    }

For information about how to distribute your plugin, please see the section “Distributing your plugin” below.

Distributing your plugin

The easiest way to distribute your plugin is to make sure that everything is in the package called plugins. (Not edu.cmu.cs.cs214.hw5.plugins—just plugins, at the top level.)

Then, export your stuff to a JAR file (use your search engine of choice if you don't know how to do this). It doesn't need to be a runnable JAR file, but it should contain your view specification and any dependent classes. (In particular, dependent classes include the view or source itself and any column specifications you used, but you don't have to include the fixtures.)

Then, run the framework, click "Register plugins…", and navigate to your JAR file. The plugin should be automatically loaded and show up in the list.

If you put it in a different package, or if it doesn't show up, try entering the fully qualified class name of the specification class—like edu.cmu.cs.cs214.hw5.myplugins.ChoroplethViewSpecification or edu.cmu.cs.cs214.hw5.myplugins.Magic8BallSpecification—in the text field and registering it that way.

Plugins will stay loaded even across program runs until you explicitly “forget” them using the button at the bottom of the same dialog.

Exploring additional goodies

In addition to the actual data analysis framework, we've provided a couple of utilities that you may find useful. You can find these in the edu.cmu.cs.cs214.hw5.util package.

JKeyValuePanel

JKeyValuePanel is a Swing component for making forms or other two-column table layouts. We talked about this class briefly in the section about creating a data source. Basically, it lets you write code like this…

        final JKeyValuePanel pnlTable = new JKeyValuePanel();
        // create cbxSeriesSelect, lblValue{Mean,Median,Variance,Stdev}
        pnlTable.addEntry("Data series", cbxSeriesSelect);
        pnlTable.addEntry("Mean", lblValueMean);
        pnlTable.addEntry("Median", lblValueMedian);
        pnlTable.addEntry("Variance", lblValueVariance);
        pnlTable.addEntry("Standard deviation", lblValueStdev);

…to create panels like this:

Sample `JKeyValuePanel`, showing a two-column table layout with the relevant labels and fields

By the way, that's actual code from a StatisticalAnalysisView plugin! (Except we actually create the combo box and labels.)

Utilities

The edu.cmu.cs.cs214.hw5.util.Utilities class contains a few useful methods.

gradient

One of these methods is Color gradient(double). If you give it a value of t between 0.0 and 1.0, it'll give you a color along a continuous gradient. (So gradient(0.00) is close in hue to gradient(0.03), but far from gradient(0.77).) If you have a bunch of series in a data set, this can be useful to differentiate them: space the t-values evenly along the unit interval and pick a gradient value for each series.

This produces the following gradient:

The gradient output by the `gradient` method, with `t` along the horizontal axis.

Note that cyan is absent from the gradient, because it's just too light. (The perceived chroma spectrum is almost monotonic without cyan.)

Here's an example of a histogram plugin that uses a gradient value to differentiate the series (here, using points 0.0 / 3.0, 1.0 / 3.0, and 2.0 / 3.0 for the three series):

An example histogram view with three data series, each colored by the gradient.

withAlpha/withAlphaFloating

Have you ever wondered how to get a semitransparent version of a Java color? I mean, there's a constructor Color(int, int, int, int) that takes an alpha value as the fourth argument. But to use it you'd have to extract all the channels of the existing color and feed them back in. It's really annoying.

Well, thanks to Utilities.withAlpha, you can just specify an alpha value (in the range 0–255) and apply that to an existing color:

final Color semitransparentBlue = Utilities.withAlpha(Color.BLUE, 64);

Or, if you happen to have your alpha value as a floating point number in the range 0.0–1.0, you can use withAlphaFloating:

final Color alsoSemitransparentBlue = Utilities.withAlphaFloating(Color.BLUE, 0.25);

This is very useful when you're writing your own drawing code and you want to have more subtle control over the final appearance!

lerp

One more thing that some data visualization plugins might want to do is linear interpolation. If you have values x0 and x1, and you want to find the value that's 42% of the way from x0 to x1, you can use Utilities.lerp(x0, x1, 0.42).

This can be useful for scaling values, calculating bounds, or lots of other things.


Congratulations: you made it to the end! If you're trying to think of plugins to implement, perhaps you'd like to read an article called “Visualizing Algorithms” for some inspiration.