Home

three_d.js: interactive 3D scatter plots

(See examples, documentation, GitHub.)

The goal of three_d.js is to provide an easy way to make interactive three-dimensional scatter plots (line graphs and mesh plots will hopefully follow later in the year). It mostly works off mrdoob's three.js interface to WebGL, and has a bit of help from some of Mike Bostock's d3.js version 4 modules.

The WebGL may cause some problems on older phones; earlier incarnations of the code regularly failed or crashed the browser on my Samsung Galaxy S3, though eventually it started working for reasons unclear to me. Please let me know (dw.barry@gmail.com or @pappubahry) if it crashes for you, and I will make a list of troublesome devices here.

The following is an example of what's possible. Mouse controls: Left-click and drag to rotate; alt (Mac)- or ctrl (Windows)-click-drag or middle-click-drag to pan; scroll or shift-click-drag to zoom. Touch screen controls: one finger to rotate; two-finger scroll to pan; pinch to zoom. You can also click or tap on the spheres themselves, or the various control toggles.

Randomise; Toggle labels.

This page is intended to be a tutorial, and I've tried to assume no JavaScript knowledge, at least at the beginning – the hope being that at for simple plots – which will still allow the user to rotate, zoom, pan, and click the control toggles – simple pattern-matching from here and the examples will be enough to get going.

Getting started

The first essential component to making a plot is to include the source files for both three.js and three_d.js. I wrote three_d.js using release 81 of three.js; the latter has undergone enough major changes in recent years that I recommend grabbing a copy of r81. Those two scripts should be included somewhere in your HTML like so:

<script type="text/javascript" src="/path/to/three.r81.min.js"></script>
<script type="text/javascript" src="/path/to/three_d.js"></script>

Then, at the place in the document where you want the plot to appear, you need to create a <div> element with some id.

<p>The three variables are plotted in the figure below.</p>

<div id="div_plot_area"></div>

Somewhere after that (or earlier if in an onload), you put the script that defines the object containing the data points and any customisations to the plot, and pass the object to three_d.make_scatter() to generate the plot. There are several ways to define JavaScript objects, and several ways to organise this part of the code. (If your data is in an R data frame, then it may be easiest to use this function, which I used to get most of the examples started; I don't know how well it'll handle missing values etc. but on clean datasets it's worked for me.)

JavaScript objects have properties, the values of which can be all sorts of things – a number, a string, an array, another object, a function, .... The object defining the scatterplot needs to contain at least two properties: div_id, a string holding the id of the <div> element, and data, an array of objects, each object defining one data point.

The data point objects must each contain properties x, y, and z, and may optionally contain properties size (values should be positive), color (either a CSS colour string or a numeric value: "cyan", "#00FFFF", and 0x00FFFF are all equivalent), label, a string to display above the data point, and other, itself (usually) an object, which can hold various auxiliary pieces of data that you might want to refer back to when interacting with the plot.

The following code makes the minimal example, defining only x, y, and z.

<script type="text/javascript">
// Define a function to initialise the plot:
function init_plot() {
  // The object to hold all the information about the plot:
  var params = {};
  
  // Change this to whatever the div is called:
  params.div_id = "div_plot_area";
  
  // An array of objects, one per data point:
  params.data = [
    {"x": 0.49, "y": 60.30, "z": 1.39},
    {"x": 0.20, "y": 42.42, "z": 3.40},
    {"x": 0.35, "y": 43.89, "z": 2.09},
    {"x": 0.19, "y": 84.96, "z": 3.92},
    {"x": 0.30, "y": 77.37, "z": 3.75},
    {"x": 0.41, "y": 21.91, "z": 1.27},
    {"x": 0.02, "y": 72.37, "z": 0.58},
    {"x": 0.68, "y": 69.11, "z": 3.10},
    {"x": 0.19, "y":  0.75, "z": 2.06},
    {"x": 0.87, "y": 56.15, "z": 1.98},
    {"x": 0.84, "y": 68.93, "z": 3.39},
    {"x": 0.07, "y": 33.94, "z": 0.31},
    {"x": 0.40, "y": 13.79, "z": 3.02},
    {"x": 0.51, "y": 50.63, "z": 2.17},
    {"x": 0.48, "y": 37.98, "z": 2.46},
    {"x": 0.61, "y": 14.89, "z": 3.52},
    {"x": 0.03, "y": 82.20, "z": 3.05},
    {"x": 0.81, "y": 72.28, "z": 1.33},
    {"x": 0.63, "y": 54.21, "z": 3.84},
    {"x": 0.99, "y": 93.96, "z": 0.36}
  ];
  
  // Make the plot:
  three_d.make_scatter(params);
}

// Call the initialisation function:
init_plot();
</script>

(Use NaN for missing values.)

For large datasets, it is probably best not to draw labels (even if hidden; they still have to be drawn, stored in memory, and get rotated around as you change the perspective), and instead store any strings in data.other.

Customising

There are a number of properties you can add to the object (before the call to three_d.make_scatter()) to customise the look or details of the plot, which are listed on the documentation page. The plots default to white text and axes on a black background, but if you want the opposite, then you can define the relevant properties like so:

params.background_color = "#FFFFFF";
params.axis_color = "#000000";
params.axis_font_color = "#000000";
params.tick_font_color = "#000000";

By default, the box will be drawn so that it encompasses all the data (plus a 10% buffer either side), and the axes scaled so that it ends up as a cube, each side of the cube going from -1 to +1 along its coordinate in world space. There is not as much scope to change this behaviour as I'd originally intended (something for me to improve later on), but there are two things you can do instead: set the bounds of any or all of the box sides manually, and set two or all axes to have the same length scale (so that an axis running from 40 to 50 will be drawn as one quarter of the length of an axis running from 0 to 40).

// Fix the x- and y-axes; let the z-axis scale freely.
params.x_scale_bounds = [20, 60];
params.y_scale_bounds = [30, 40];
params.same_scale = [true, true, false];

Note that the size scale (if one exists) always starts from zero, so the size_scale_bound property is grammatically singular and should be a number, not an array of two numbers.

Of some possible importance is the geom_type property, which is either "quad" (the default) or "point". The latter should be more efficient, but a) points have a maximum size that varies with different GPU processors, so you can't be sure that the 100-pixel-tall sphere will actually be drawn that big on someone else's screen even if it's working on yours, and b) they disappear from the screen when the centre of their sphere leaves the plotting area. (To avoid the second problem, you can set the hidden_margins property to true, though it might (??) cause issues on some devices to draw things off-screen.)

Because of the size issue – which also makes labels and mouse events inconsistent with what's on the screen – I've set the default geom_type to "quad".

By default the plotted points will appear as spheres. They're not really spheres, but rather 2D pictures of spheres that always point towards the camera. If instead you want solid squares, then you can set

params.point_type = "square";

Other built-in options are circle, plus, and cross. There's an example showing how to define a custom point. At present all points must be drawn with the same point type.

When there is a size variable defined in the data points, there needs to be a definition of how large the points can be; this is achieved with the max_point_height property, which defaults to 25 (pixels). This isn't always a maximum! It is instead the height of a point whose size variable is equal to the size_scale_bound property, which defaults to the maximum size value in the data, but which can be manually over-ridden for good or ill. By default, the heights of the points scale as the square root of the size variable in the input data, so that the area on screen is proportional to the size; this behaviour can be changed with the size_exponent property.

See the examples and documentation for more customisation options.

Mouse/touch events

(The remaining sections assume more JavaScript knowledge.) You can define mouseover, mouseout, and click properties for the object (click isn't precisely the same thing as a JS click event, but the details are either unimportant or you'll work them out), which fire based on which plotted point the mouse is (or was) over. Click events will also fire with touch-screen taps.

The basic structure goes like this:

params.mouseover = function(i_plot, i, d) {
  do_something(i_plot, i);
  print_some_data(d.input_data.other.important_variable);
};

The three arguments of the function are:

There are several helper functions to make manipulating the points easier:

If the plot doesn't seem to update immediately, it is probably because you need to call three_d.update_render(i_plot). I don't know when this is necessary except by trial and error.

The US elections example shows some of these mouse features; the 'toggle labels' at the top of this page also demonstrates hiding and showing labels.

Changing data

To have smoothly animated transitions (like what happens with the 'randomise' option at the top of this page), you need to create a new object, similar to what I called params in the introductory example, and pass it to three_d.change_data(i_plot, params[, new_dataset, animate]). Labels cannot be changed.

(The first optional argument is a boolean specifying whether or not it's the dataset – it defaults to true if the number of data points is the same. If the number of data points is different, or if new_dataset is set to true, then most of the plot is removed and it is re-built from scratch, just without moving the camera. If animate is set to false, then the points immediately move to their new positions.)

Examples of this are on this page or (again) the US elections.

Interacting directly with the plot

This section is if you want finer control over the plot. The plot is stored in the object three_d.plots[i_plot], and you can add extra properties to it if you like. Points are in an array three_d.plots[i_plot].points, and labels are in an array three_d.plots[i_plot].labels. Sometimes a label might be missing; three_d.plots[i_plot].points[i].have_label is a boolean that says if point i has a label.

If geom_type == "quad", then the .points[i] object is the point itself, and with knowledge of three.js you can change its properties directly. If geom_type == "point", then its three.js-internal properties are in buffer attributes attached to the three_d.plots[i_plot].points_merged object.

Anything more complicated than looping over the points/labels will probably require studying the source code. I would warn that my camera code is awful, so while you can call three_d.get_current_camera(i_plot) and manipulate its properties, it may lead to strange behaviour if you don't also update some other variables, stored in parallel, that should be redundant but aren't.

Happy graphing.

Posted 2016-10-02.


Home