Home

three_d.js: interactive 3D scatter plots

(Updated 2018-07-06. See examples, documentation, GitHub, change history, or download all code and examples (16.0 MB).)

The goal of three_d.js is to provide an easy way to make interactive three-dimensional scatter plots, line graphs, and surface/mesh plots. 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, and while the scatterplots eventually started working for reasons unclear to me, detailed surfaces can still cause crashes. 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 two examples show some 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 points; Toggle labels.



Randomise surface.

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 either three_d.make_scatter() to generate a scatterplot, or to three_d.make_surface() for a surface and/or mesh. There are several ways to define JavaScript objects, and several ways to organise this part of the code. (If your data is to be turned into a scatterplot and is in an R data frame, then it may be easiest to use this function, which I used to get several 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.)

Scatterplots

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, group to define groups of data (e.g. to be coloured separately), 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 null 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.

Surfaces

For surfaces or meshes, the idea is that you have some rectangular grid of locations, and at each grid node you define a z-value (i.e., you're plotting z = f(x, y)). The grid has to be aligned to the x- and y-axes, though the spacing does not have to be regular*. The setup therefore differs from the scatterplots shown above: the object passed to three_d.make_surface() contains a data object as before, but this data object is organised quite differently, with data.x the array of x-values, data.y the array of y-values, and data.z an array of length equal to data.x, each entry being an array of length equal to data.y.

*By repeating either x- or y-values, and inserting a row of null z-values, you can create the effect of two or more surfaces, as done in this example.

Optional extras in the data object are color and other, both of which must have the same dimensions as z. The following code makes the minimal surface example.

<script type="text/javascript">
function init_plot() {
  var params = {};
  
  // (If you have more than one plot on a page, then you'll need a
  // different div_id for the second plot.)
  params.div_id = "div_plot_area";
  
  params.data = {};
  
  // A grid with 6 values along x, 4 values along y:
  params.data.x = [5, 6, 7, 8, 9, 10];
  params.data.y = [0, 0.5, 1, 1.5];
  
  // z array has length 6, each entry an array of length 4:
  params.data.z = [
    [10, 11, 9.5, 10],
    [10, 12, 11,  8],
    [11, 13, 11,  9],
    [10, 15, 12,  9],
    [11, 14, 11, 10],
    [11, 10, 10,  9]
  ];
  
  three_d.make_surface(params);
}

init_plot();
</script>

Customising

There are a number of properties you can add to the object (before the call to three_d.make_scatter() or three_d.make_surface()) 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 are three ways to deviate from this behaviour: set the bounds of any or all of the box sides manually; 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); or define the ratios of the axis lengths in world-space (in this case, the longest axis will run from -1 to +1 in world space, and the others shortened).

// 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];
// Alternatively:
//params.axis_length_ratios = [4, 1, 4];

Scatterplots

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 importance is the geom_type property, which is either "none" (which only draws lines between points, not the points themselves) "quad" (the default), or "point". The latter should be more efficient than "quad", 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 square_outlined, circle, triangle, 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.

To connect the points with lines, then you can either set geom_type to "none" (which will omit drawing the points entirely), or set the connect_points property to true. Lines will be the same colour as the points they connect (and if the two points are different colours, then the line will linearly vary between them).

Data points can be separated into groups: each group can have its own default_color, default_point_height, and point_type, and any connecting lines will only connect points within the same group. Each point's group is defined by its group property in its data object, and all groups must be defined in a separate groups property of the object passed to three_d.make_scatter(). The latter must be an array of objects, one object per group, with each group at least containing the name property. The group names can be integers or strings. The following code is from the minimal lines example.

<script type="text/javascript">
function init_plot() {
  var params = {};
  params.div_id = "div_plot_area";
  params.connect_points = true;
  
  // To omit the points and show only lines, set
  //params.geom_type = "none";
  
  params.data = [
    {"x": 1.2, "y": 60.30, "z": 1.39, "group": "A"},
    {"x": 2.5, "y": 42.42, "z": 3.40, "group": "A"},
    {"x": 4.0, "y": 43.89, "z": 2.09, "group": "A"},
    {"x": 0.6, "y": 84.96, "z": 3.92, "group": "B"},
    {"x": 1.5, "y": 77.37, "z": 3.75, "group": "B"},
    {"x": 1.0, "y": 72.37, "z": 0.58, "group": "C"}, // data entries can be shuffled; what's
    {"x": 3.2, "y": 21.91, "z": 1.27, "group": "B"}, // important for the lines is the order
    {"x": 2.0, "y": 69.11, "z": 3.10, "group": "C"}, // for each group individually.
    {"x": 3.4, "y":  0.75, "z": 2.06, "group": "C"},
    {"x": 5.0, "y": 56.15, "z": 1.98, "group": "C"} // groups don't need the same number of points.
  ];
  
  // Compulsory to have at least have each group's name property here;
  // can also add properties "point_type" and "default_point_height".
  params.groups = [
    {"name": "A", "default_color": 0x1B9E77},
    {"name": "B", "default_color": 0xD95F02},
    {"name": "C", "default_color": 0x7570B3}
  ];
  
  three_d.make_scatter(params);
}

init_plot();
</script>

Surfaces

Whenever you create a surface, you also create a mesh – the mesh is the set of straight lines connecting neighbouring points together; the surface is the solid... surface... connecting them. The basic customisation options are which of these two to make visible, and what colour they should be.

By default, both surface and mesh will be visible. To hide the mesh, set the show_mesh property of the object passed to three_d.make_surface() to false; this will probably make the surface look quite smooth, and it's what I used for the Mount Beerwah example. To hide the surface, set the show_surface property to false.

You can also hide the x- and y-directions of the mesh independently, with hide_mesh_x and hide_mesh_y.

// Only show mesh lines parallel to x-axis:
params.show_surface = false;
params.hide_mesh_y = true;

(The hiding of the mesh directions works independently of the showing/hiding the mesh as a whole, which is part of why I flip the property name from show_mesh to hide_mesh_x and hide_mesh_y. Both mesh and surface can be switched on and off, as shown in this example.)

The default colour behaviour is that the mesh will have a constant colour, and the surface will be coloured by z-value using the viridis colour scale. If the uniform_mesh_color property is set to false, then the mesh will be the same colour as the surface. The waves example shows this, along with show_surface = false.

The colour scale can be changed with the color_scale property (and varied later, as shown in this example); choices are "viridis", "inferno", "magma", "plasma", "grayscale", or a function that takes an argument (usually between 0 and 1) and returns a vector of length 3 for RGB channels, each channel on a 0-1 scale.

// Built-in scale:
//params.color_scale = "plasma";

// Custom scale:
params.color_scale = function(t) {
  // Black to orange:
  return [t, t/2, 0];
};

Completely custom colours can be set grid node by grid node by defining data.color (as in Beerwah).

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.

Scatterplots

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 (in some of the following functions there's an optional boolean segments argument, which will if true apply the function to the lines connecting the point to its neighbours, if the points are indeed connected):

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.

Surfaces

The basic structure is similar to scatterplots; there is an extra argument because the grid is in 2D:

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

The three arguments of the function are:

(Even if a colour scale is used to define surface colours, the numerical colour value is still stored in d.input_data.color, which is useful for resetting the colour on mouseout.)

The "chosen" grid node (i, j) is based on the surface; each triangle in the surface has vertices at two grid nodes, and the returned node will be the one closest to camera. If there are null values in the surface, or if part of the surface is hidden, then some points might not have any surface connecting to them, and for such points the mouse events will never fire, even if a mesh line is connected to it. (The mouse events will still work if surface values are defined and the whole surface is hidden, e.g. by setting params.show_surface = false when creating the plot.)

Functions to make surface/mesh manipulation easier are:

The sinc function example shows mouseover and click events, changing the colour of the surface point; the Beerwah and yield curves examples print data to the screen when a point is clicked.

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, append]). Labels cannot be changed. If scatterplot points are connected, then groups should not be changed. (Changing the group of a data point can also lead to irregular behaviour if you call three_d.hide_group(), then after changing groups, three_d.show_group() – points may remain hidden.)

(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. Using the final optional argument to append data to the existing dataset is shown in near-minimal examples for a scatterplot and for a surface (for which you also need to specify a direction to append in). Animated surface transitions at 100 × 100 grid nodes are noticeably slow on my S3 phone.

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.

Scatterplots

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.

If points are connected, then each group has its own object, three_d.plots[i_plot].groups["group_name_goes_here"].segment_lines. If no groups were specified, then the group name is default_group.

Some group details are stored in three_d.plots[i_plot].groups; in particular, .groups["group_name"].i_main is an array which gives the index of each point in the group in the plot as a whole; this allows you to use the functions like three_d.set_point_color() on, e.g., the third point in a given group, by (in this case) passing the .i_main[2] value as the i argument.

Surfaces

The surface and mesh are both created with three.js's BufferGeometry(); the surface is three_d.plots[i_plot].surface, and the mesh is .surface_mesh. Input data for the (i, j) point can be retrieved from three_d.plots[i_plot].mesh_points[i][j].input_data.

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,
updated 2016-12-22 (version 1),
updated 2016-12-27 (version 1.1),
updated 2018-07-06 (version 1.2).


Home