Сегодня я научу вас создавать анимированные, наполненные искусством диаграммы Вороного, как это видно в Студии Вороного, взяв за основу этот пример из Observable.



Это здорово, но что это?

Диаграмма Вороного разбивает плоскость на ячейки, сначала нанося ряд точек, а затем деля плоскость таким образом, чтобы каждая точка в ячейке была ближе к нанесенной точке внутри нее, чем любая другая нанесенная точка. Некоторые варианты использования диаграммы Вороного могут заключаться в выяснении того, кто из респондентов может первым добраться до кризиса (используя время в пути как ваше «расстояние»), или в качестве простого способа увидеть плотность точек (множество маленьких ячеек выглядят очень по-разному). чем несколько крупных). В основном мы используем его, потому что они могут выглядеть как витражи (моделирование разбития стекла — еще один вариант использования диаграмм Ворного) и в целом визуально приятны, что важно, когда речь идет об искусстве.

Начало работы: функции генератора

Если вы не знаете, функция-генератор, объявленная с *, либо как function*, либо как *methodName, если это метод, возвращает объект-генератор, который имеет метод next(), который при вызове продолжает с того места, где остановился. в функции (или вверху, если это первый вызов next(), и работает до тех пор, пока не встретит ключевое слово yield. Используя цикл while(true) в нашей функции-генераторе, который заканчивается на yield, мы можем вызывать next() неопределенное количество раз. Содерживая всю логику для рисования одной ячейки анимации в цикле while(true) и вызывая next() через интервал, мы можем, таким образом, создать анимацию.

Настройка (единичный код выполнения)

Во-первых, мы получаем наш контекст холста на основе селектора, переданного в качестве аргумента нашей функции, вместе с его шириной и высотой:

const canvas = d3.select(selector);
const context = canvas.node().getContext("2d");
const height = canvas.node().height;
const width = canvas.node().width;

Затем мы перебираем наш массив художественных данных, создавая элемент изображения для каждого индекса, а затем создаем шаблон холста из этого элемента изображения, сохраняя его в новый массив шаблонов:

const patterns = [];
artData.forEach((painting, index) => {
  //make a pattern out of each image in artData
  const image = new Image(); //make an <img> element
  image.src = painting.primaryImageSmall; //set it's source to the painting's url
  image.onload = function () {
   //when it loads
   patterns[index] = context.createPattern(image, "repeat"); //create a new canvas pattern, and save it to the patterns array
  };
 });

Затем мы инициализируем наши точки случайными значениями в пределах холста, а наши начальные скорости равны 0. Поскольку D3-Delaunay использует массив флаев для позиций (записи соответствуют [x₀, y₀, x₁, y₁, x₂, y₂ , …xₙ, yₙ]), мы используем тройку с условием i&1 для чередования ширины и высоты. Использование побитового И немного более производительно, чем %2, и поскольку мы хотим отобразить кадр анимации как можно быстрее, мы используем его во всей нашей функции:

const positions = Float64Array.from(
  { length: cellCount * 2 }, 
//create an array of alternating x and y coordinates
  (_, i) => Math.random() * (i & 1 ? height : width) 
//that are between 0 and height and width, respectively
 );
const velocities = new Float64Array(cellCount * 2);

и, наконец, мы создаем наш начальный Вороной:

const voronoi = new d3.Delaunay(positions).voronoi([
  0.5,
  0.5,
  width - 0.5,
  height - 0.5,
 ]);

Рендеринг кадра анимации (цикл while(true))

Изменение позиций и скоростей

Во-первых, мы собираемся просмотреть каждую запись в нашем массиве позиций и изменить ее на месте в зависимости от соответствующей скорости.

for (let i = 0; i < positions.length; ++i) {
positions[i] += velocities[i]; //change the position based on corresponding velocity

Затем нам нужно обработать любые точки, которые вышли за пределы нашего холста. Я решил, что мои точки отскакивают от краев, поэтому мне нужно зеркально отразить их положение относительно оси, заданной краем, через который они прошли. Для ребер 0px это так же просто, как умножение на -1. Для ребер, равных максимальной ширине и максимальной высоте, это эквивалентно максимальному размеру минус расстояние, на которое наша текущая координата выходит за указанный край, или size-(position-size), что сокращается до 2size-position. Затем я изменяю соответствующую скорость, чтобы точка не упиралась в стену, из-за чего точки в конце концов слипаются в углах:

const size = i & 1 ? height : width; //alternate between height and width
//below code block causes cells to bounce off of edges
if (positions[i] < 0) {
    positions[i] *= -1;
    velocities[i] *= -1;
} else if (positions[i] > size) {
   positions[i] = 2 * size - positions[i];
   velocities[i] *= -1;
}

Наконец, нам нужно обновить соответствующую скорость. Мы использовали модифицированную версию формулы в примере с Observable, но должна работать любая четная функция, имеющая максимальное значение. Если функция не четная, вы заметите, что ваши точки группируются в определенных углах, а если нет максимального значения, вы увидите, что некоторые точки достигают смехотворной скорости.

velocities[i] += 0.2 * (Math.random() - 0.5) - 0.01 * velocities[i];

Финальный цикл for должен выглядеть примерно так:

for (let i = 0; i < positions.length; ++i) {
    //change every position
    const size = i & 1 ? height : width; 
    //alternate between height and width
    positions[i] += velocities[i]; 
    //change the position based on corresponding velocity
    //below code block causes cells to bounce off of edges
    if (positions[i] < 0) {
        positions[i] *= -1;
        velocities[i] *= -1;
    } else if (positions[i] > size) {
        positions[i] = 2 * size - positions[i];
        velocities[i] *= -1;
    }
    velocities[i] += 0.2 * (Math.random() - 0.5) - 0.01 * velocities[i]; 
    //change the velocity by a random amount, with some consideration for it's previous value
}

Затем мы говорим D3-Delaunay обновить наш Voronoi с помощью voronoi.update().

Рисуем Вороного

Мы почти закончили! Нам просто нужно обвести каждую клетку на холсте и заполнить ее произведением искусства. Мы проследим ячейку с помощью метода renderCell Вороного, а затем заполним ее шаблоном, который мы создали еще в нашей первоначальной настройке.

for (let i = 0; i < cellCount; i++) {
//for each cell
    context.fillStyle = patterns[i % patterns.length]; //choose the next pattern in the array, looping back to the beginning if we go out of bounds
    context.beginPath(); //start a new path
    voronoi.renderCell(i, context); //trace the path of the current cell
    context.fill(); //fill the cell withour chosen pattern
}

и, наконец, мы yield возвращаемся к тому, что вызвало наш next() метод. Вся функция должна выглядеть примерно так:

function* chartRender(selector, artData, cellCount = artData.length) {
    const canvas = d3.select(selector);
    const height = canvas.node().height;
    const width = canvas.node().width;
    const context = canvas.node().getContext("2d");
    const patterns = [];
    artData.forEach((painting, index) => {
        //make a pattern out of each image in artData
        const image = new Image(); //make an <img> element
        image.src = painting.primaryImageSmall; //set it's source to the painting's url
        image.onload = function () {
       //when it loads
            patterns[index] = context.createPattern(image, "repeat"); //create a new canvas patter, and save it to the patterns array
         };
    });
    const positions = Float64Array.from(
        { length: cellCount * 2 }, //create an array of alternating x and y coordinates
        (_, i) => Math.random() * (i & 1 ? height : width) //that are between 0 and heigh and width, respectively
    );

    const velocities = new Float64Array(cellCount * 2); //create an array of alternating x and y velocities
    const voronoi = new d3.Delaunay(positions).voronoi([0.5, 0.5, width - 0.5, height - 0.5]); 
    //create a new voronoi from our positions array, with infinite polygons clipped at the provided minimums and maximums
    while (true) {
        //we will yield control back to the caller at the end of this loop, so it isn't actually infinite

        for (let i = 0; i < positions.length; ++i) {
        //change every position
        const size = i & 1 ? height : width; //alternate between height and width
        positions[i] += velocities[i]; //change the position based on corresponding velocity
       //below code block causes cells to bounce off of edges
      if (positions[i] < 0) {
          positions[i] *= -1;
          velocities[i] *= -1;
       } else if (positions[i] > size) {
           positions[i] = 2 * size - positions[i];
          velocities[i] *= -1;
        }
        velocities[i] += 0.2 * (Math.random() - 0.5) - 0.01 * velocities[i]; //change the velocity by a random amount, with some consideration for it's previous value
}
        voronoi.update(); //update the voronoi diagram with the new positions
        for (let i = 0; i < cellCount; i++) {
        //for each cell
        context.fillStyle = patterns[i % patterns.length]; 
        //choose the next pattern in the array, looping back to the beginning if we go out of bounds
        context.beginPath(); //start a new path
        voronoi.renderCell(i, context); //trace the path of the current cell
        context.fill(); //fill the cell withour chosen pattern
         }
        yield;
    }
}

Затем все, что вам нужно сделать, это вызвать chartRender, сохранить результирующий объект генератора в переменную и вызвать метод этой переменной .next() с интервалом, и вы должны получить что-то вроде этого:

Удачного кодирования!