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

ссылка на оригинал статьи http://habrahabr.ru/post/200584/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *