...

суббота, 2 ноября 2013 г.

Визуализируем в 3D, или как подружить D3 и Three.js

Если Вы уже слышали о 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 не создаёт в этом никаких проблем. Обе библиотеки имеют свои сильные стороны, и я надеюсь, что эта статья открыла Вам путь к их гармоничному сочетанию в Ваших будущих работах.

This entry passed through the Full-Text RSS service — if this is your content and you're reading it on someone else's site, please read the FAQ at fivefilters.org/content-only/faq.php#publishers. Five Filters recommends:



Комментариев нет:

Отправить комментарий