(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.
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.
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.
(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:
three_d.set_color(i_plot, i, col) 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_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) hides the point;
three_d.hide_label(i_plot, i) hides the label;
three_d.show_point(i_plot, i) shows the point;
three_d.show_label(i_plot, i) shows the label.
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.
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.
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.