(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.
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.)
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
.
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
.
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>
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];
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>
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).
(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:
i_plot
: the index of the plot (you might make more than one);
i
: the index of the point in this dataset;
d
: an object from which you can access d.input_data
, which contains the data from what was passed to
three_d.make_scatter()
as params.data
. (If geom_type == "quad"
, then d
is the three.js object
itself, and you could manipulate it directly – e.g., change its position and rotation – if you so desired.)
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):
three_d.set_point_color(i_plot, i, col[, set_segments])
sets the point's colour to col
(numeric value like 0xFF0000
);
three_d.set_label_color(i_plot, i, col)
sets the point's label's text colour to col
;
three_d.set_label_background_color(i_plot, i, col)
sets the point's label's background colour to col
;
three_d.set_point_size(i_plot, i, size[, scale_factor])
sets the point's size to size
pixels (the scale_factor
argument is usually unnecessary but if you are changing a large number of sizes, then it might be more efficient to pre-calculate
scale_factor = 2 * three_d.get_scale_factor(i_plot);
and pass that argument);
three_d.hide_point(i_plot, i[, hide_segments])
hides the point;
three_d.hide_label(i_plot, i)
hides the label;
three_d.show_point(i_plot, i[, show_segments])
shows the point;
three_d.show_label(i_plot, i)
shows the label;
three_d.set_point_position(i_plot, i, position[, set_segments, world_space])
changes the location of a point to the [x, y, z] defined
by position
(if world_space
is true
, then the x-, y-, and z-values are world-space co-ordinates, where the longest
axis runs from -1 to +1; the code sometimes uses this internally).
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.
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:
i_plot
: the index of the plot (you might make more than one);
i
: the index of the grid node in the x-direction;
j
: the index of the grid node in the y-direction;
d
: an object from which you can access d.input_data
, which contains the data from what was passed to
three_d.make_surface()
as params.data
.
(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:
three_d.set_surface_point_color(i_plot, i, j, col[, permanent])
sets the point's colour to col
(numeric value like
0xFF0000
); the optional argument permanent
will if true also interpolate the surrounding colours fully, change the mesh
point colour, and update d.input_data.color
;
three_d.set_mesh_point_color(i_plot, i, j, col)
sets the mesh point's colour to col
;
three_d.hide_surface_point(i_plot, i, j)
hides the surface point;
three_d.hide_mesh_point(i_plot, i, j)
hides the mesh point;
three_d.show_surface_point(i_plot, i, j)
shows the surface point;
three_d.show_mesh_point(i_plot, i, j)
shows the mesh point;
three_d.set_surface_point_z(i_plot, i, j, z)
changes the z-value of the point to the z.
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.
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.
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.
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.
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).