Если Вы уже слышали о D3 и Three.js, эта статья может показаться Вам интересной. В ней речь пойдёт о том, как заставить эти библиотеки работать вместе для создания динамических трёхмерных сцен, на примере этой простой гистограммы:
Откуда ноги растут?
Некоторое время назад мы в CodeOrchestra экспериментировали с портом D3 на AS3/DSL под кодовым названием «D6» (от D3 + 3D). Наш порт покрывал лишь самые базовые функции D3, но зато умел работать с популярными 3D движками на AS3 «из коробки». И хотя мы так и не вывели D6 в свет, та самая идея использовать D3 для 3D с тех пор не покидает наши умы. Действительно, если Вы заглянете в галерею D3, Вы не найдёте там ни одного трёхмерного примера. Причина в том, что D3 сильно заточена на работу с DOM браузера, и вроде как не поддерживает выборки произвольных объектов. Однако, обладая достаточной мотивацией, мы можем её заставить.
Итак, начнём
Начнём с простейшего примера двухмерной гистограммы с использованием D3 (тут и далее код адаптирован из официальных уроков D3 [1] и [2], и сокращён ради читабельности):
d3.select(".chart")
.selectAll()
.data(data)
.enter().append("div")
.style("width", function(d) { return d * 10 + "px"; });
В этом примере видно, что основные методы D3 принимают в качестве аргументов волшебные DOM-зависимые строки (такие как селектор .chart
или имя тега div
), что крайне неудобно для наших целей. К счастью, у этих методов имеются альтернативные сигнатуры. Эти сигнатуры существуют для скучных вещей вроде повторного использования выборок. Мы же воспользуемся ими, чтобы переписать наш пример следующим образом:
function newDiv() {
return document.createElement("div");
}
var chart = {
appendChild: function (child) {
// эта функция будет вызвана из append() после newDiv()
return document.getElementById("chartId")
.appendChild(child);
},
querySelectorAll: function () {
// эта функция будет вызвана из selectAll()
return [];
}
}
d3.select( chart )
.selectAll()
.data(data)
.enter().append( newDiv )
.style("width", function(d) { return d * 10 + "px"; });
Как видим, мы 1) указали D3 как создавать div
в явном виде, и 2) убедили D3 в том, что наш объект chart — утка. При этом результат нашего кода совершенно не изменился.
Так что насчёт 3D ?
Стандартом де-факто для 3D графики в JavaScript на сегодняшний день является Three.js. Если мы хотим делать 3D в D3, нам надо аналогичным образом убедить D3 работать с выборками из трёхмерных объектов Three.js. Для этого мы добавим следующие методы в прототип Object3D:
// эти методы нужны для D3-шных .append() и .selectAll()
THREE.Object3D.prototype.appendChild = function (c) { this.add(c); return c; };
THREE.Object3D.prototype.querySelectorAll = function () { return []; };
// а этот - для D3-шного .attr()
THREE.Object3D.prototype.setAttribute = function (name, value) {
var chain = name.split('.');
var object = this;
for (var i = 0; i < chain.length - 1; i++) {
object = object[chain[i]];
}
object[chain[chain.length - 1]] = value;
}
Этого вполне достаточно для создания простейшей трёхмерной гистограммы:
function newBar () {
return new THREE.Mesh( geometry, material );
}
chart3d = new THREE.Object3D();
d3.select( chart3d )
.selectAll()
.data(data)
.enter().append( newBar )
.attr("position.x", function(d, i) { return 30 * (i - 3); })
.attr("position.y", function(d, i) { return d; })
.attr("scale.y", function(d, i) { return d / 10; })
Это всё ?
Вовсе нет. Чтобы использовать главную фишку D3 — обработку изменения данных — нам необходимо пересмотреть наши обманки. Во-первых, чтобы D3 могла интерполировать значения «аттрибутов», нам необходимо добавить в прототип Object3D метод getAttribute:
THREE.Object3D.prototype.getAttribute = function (name) {
var chain = name.split('.');
var object = this;
for (var i = 0; i < chain.length - 1; i++) {
object = object[chain[i]];
}
return object[chain[chain.length - 1]];
}
Во-вторых, selectAll() должен на самом деле работать, чтобы построить выборку обновляющихся объектов. Например, мы можем реализовать отбор наследников Object3D определённого типа:
THREE.Object3D.prototype.querySelectorAll = function (selector) {
var matches = [];
var type = eval(selector);
for (var i = 0; i < this.children.length; i++) {
var child = this.children[i];
if (child instanceof type) {
matches.push(child);
}
}
return matches;
}
Чтобы заставить наши столбцы танцевать, теперь достаточно просто периодически изменять данные:
var N = 9, v = 30, data = d3.range(9).map(next);
function next () {
return (v = ~~Math.max(10, Math.min(90, v + 20 * (Math.random() - .5))));
}
setInterval(function () {
data.shift(); data.push(next()); update();
}, 1500);
function update () {
// используем D3 для стоздания и обновления 3D столбцов
var bars = d3.select( chart3d )
.selectAll("THREE.Mesh")
.data(data);
bars.enter().append( newBar )
.attr("position.x", function(d, i) { return 30 * (i - N/2); });
bars.transition()
.duration(1000)
.attr("position.y", function(d, i) { return d; })
.attr("scale.y", function(d, i) { return d / 10; });
}
Итак, общий принцип спаривания D3 с Three.js Вам должен быть ясен — мы постепенно добавляем в прототип Object3D методы, достаточные для работы интересующего нас функционала D3. Но для закрепления рассмотрим последний вариант гистограммы, в котором используем привязку данных по ключу и работу с выборкой удаляемых объектов. Добавим в прототип Object3D метод removeChild:
THREE.Object3D.prototype.removeChild = function (c) { this.remove(c); }
Если бы Вы попробовали теперь воспользоваться методом remove() выборки удаляемых объектов, то обнаружили бы, что ничего не происходит. Почему? Ответ легко увидеть в исходниках D3 — метод remove() не использует parentNode выборки, а пытается удалять объект из своего непосредственного родителя. Чтобы сделать это возможным, необходимо скорректировать нашу реализацию appendChild():
THREE.Object3D.prototype.appendChild = function (c) {
this.add(c);
// создаём свойство parentNode
c.parentNode = this;
return c;
}
Итог
А в итоге у нас получилась вот такая красота:
var N = 9, t = 123, v = 30, data = d3.range(9).map(next);
function next () {
return {
time: ++t,
value: v = ~~Math.max(10, Math.min(90, v + 20 * (Math.random() - .5)))
};
}
function update () {
// используем D3 для стоздания, обновления и удаления 3D столбцов
var bars = d3.select( chart3d )
.selectAll("THREE.Mesh")
.data(data, function(d) { return d.time; });
bars.transition()
.duration(1000)
.attr("position.x", function(d, i) { return 30 * (i - N / 2); })
bars.enter().append( newBar )
.attr("position.x", function(d, i) { return 30 * (i - N / 2 + 1); })
.attr("position.y", 0)
.attr("scale.y", 1e-3)
.transition()
.duration(1000)
.attr("position.x", function(d, i) { return 30 * (i - N / 2); })
.attr("position.y", function(d, i) { return d.value; })
.attr("scale.y", function(d, i) { return d.value / 10; })
bars.exit().transition()
.duration(1000)
.attr("position.x", function(d, i) { return 30 * (i - N / 2 - 1); })
.attr("position.y", 0)
.attr("scale.y", 1e-3)
.remove()
}
Как видим, D3 прекрасно справляется с 3D, если ей немного помочь, а Three.js не создаёт в этом никаких проблем. Обе библиотеки имеют свои сильные стороны, и я надеюсь, что эта статья открыла Вам путь к их гармоничному сочетанию в Ваших будущих работах.
Автор: makc3d