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

в 1:14, , рубрики: d3.js, javascript, three.js, Веб-разработка, Инфографика, метки: ,

Если Вы уже слышали о D3 и Three.js, эта статья может показаться Вам интересной. В ней речь пойдёт о том, как заставить эти библиотеки работать вместе для создания динамических трёхмерных сцен, на примере этой простой гистограммы:

Визуализируем в 3D, или как подружить 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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js