Введение в D3 / Хабр
D3.js (или просто D3) это JavaScript-библиотека для обработки и визуализации данных. Она предоставляет удобные утилиты для обработки и загрузки массивов данных и создания DOM-элементов. Эта заметка описывает работу с основными методами библиотеки, она подойдёт для изучения основ библиотеки и погружения в её логику и возможности.Для понимания статьи пригодятся знания JS, HTML и CSS.
Текучий интерфейс (fluent interface)
D3 реализует подход, называемый fluent interface. При чтении кода он выглядит как цепочка методов. Каждый метод вызывается на объекте, который вернул предыдущий метод. Чтобы код было удобно читать, каждый вызов располагается на отдельной строчке:
d3.select('body') // выбор в документе body .append('svg') // добавление в body svg-контейнера .append('text') // добавление в svg-контейнер элемента text .text('Click somewhere, please...') // изменение текста в элементе text . attr('x', 50) // задание координат элемента text .attr('y', 50) .style("fill","firebrick") // заливка текста цветом
Этот пример на jsfiddle.net
Выборка
В D3, как и в других JS-библиотеках, работающих с DOM-элементами, взаимодействие с документом начинается с поиска элементов в документе и создания выборки — обёртки набора элементов. Она даёт доступ к методам библиотеки для модификации выбранных элементов.
Выборка (selection) в D3 создается с помощью методов d3.select() и d3.selectAll(). Для создания выборки D3 использует querySelector/querySelectorAll или Sizzle, если он подключён к странице (например, с jQuery).
d3.select('span') // выбор первого span в документе d3.selectAll('span') // выбор всех span в документе
Полученную выборку используют для работы с элементами и для создания выборки из потомков (subselection).
<span>будет зеленым</span> <p> <span>будет красным</span> <span>эти</span> <span>будут</span> <span>жёлтыми</span> </p> <p>останется чёрным</p>
d3. select('span') // выбор первого span в документе .style('color', 'darkgreen') // установка цвета d3.selectAll('p') // выбор всех параграфов .selectAll('span') // выбор всех span в этих параграфах. .style('color', 'goldenrod') // установка цвета d3.select('p') // выбор первого параграфа в документе .select('span') // выбор первого span в этом параграфе .style('color', 'firebrick') // установка цвета
Всегда помните, с какой выборкой вы сейчас работаете. Распространённые ошибки при работе с D3: вызов на элементе-потомке вместо родителя и попытка изменения свойств несуществующего (удаленного или ещё не созданного) элемента.
В примере уже используются операции над элементами (selection.style(name[, value])), дальше рассмотрим их более подробно.
Вычисление значений и функторы
Для работы с DOM D3 использует схожий API для всех вызовов. Давайте разберём его на примере популярной задачи: добавления или удаления класса у элемента. Для этого нам понадобятся некоторые методы выборок:
- selection.classed(name, value) добавляет или удаляет класс name в зависимости от булевого значения value.
- selection.on(event, callback) используется для обработки событий, передавая название события event (например, «click») и функцию-обработчик callback. Функции-обработчики вызываются с текущим элементом в this, а также data и index в аргументах. Событие можно получить в переменной d3.event. Повторная установка обработчика заменит предыдущий.
var pressed = false var button = d3.select('button') // выбор кнопки .on('click', function (data, index) { // установка обработчика нажатия мыши button.classed('pressed', pressed = !pressed) // в обработчике меняем значение переменной и вычисляем класс })
Этот пример на jsfiddle.net
Обратите внимание, что мы пользуемся сохранённой в переменную button выборкой: вызовы on (а так же classed, attr, style, property, html, text) возвращают выборку, на которой они вызваны, что типично для «текучих» интерфейсов.
D3 обрабатывает переданные значения схожим образом. Если вы видите в документации [value], скорее всего речь идёт о следующем:
- Если вы подадите значение, являющееся функцией, оно будет вызвано с параметрами data, index (см. ниже), а контекстом (объектом this) будет элемент, DOM-узел.
- Если вы подадите значение, не являющееся функцией, оно будет обёрнуто в «функтор» (функцию, всегда возвращающую переданное значение)
- Если вы не подадите значение, функция сработает как getter и вернёт значение, о котором идёт речь (например, selection.style(‘color’) вернёт цвет текста, если он установлен для элемента).
О последнем нюансе важно помнить, если вы строите цепочку вызовов (такой getter обычно должен быть последним элементом цепочки).
Важно понимать, что значения или функции используются один раз для каждого элемента в выборке, после чего D3 о них «забывает». Иными словами, изменения в наборе данных или события в документе не заставят D3 «повторно вычислить» значение, поэтому это поведение нужно задавать самостоятельно, как мы выше сделали с classed.
Обратите внимание на аргументы функции (data и index). Они имеют специальное значение: index — номер элемента в выборке, а data — заданный для него элемент данных. Присутствие этих параметров в каждой функции, вызываемой на выборке является одним из важнейших контрактов в D3. Это позволяет писать лаконичный код, вычисляющий состояния свойств элементов в зависимости от данных.
Типичная работа с выборкой
Рассмотрим популярные методы на более сложном примере, демонстрирующем работу с DOM-узлами документа через выборку:
var svg = d3.select('body').append('svg') svg .append('text') .text('click somewhere') .attr('x', 50) .attr('y', 50) var events = [] svg.on('click', function () { events.push(d3.event) if (events.length > 5) events.shift() var circles = svg.selectAll('circle') .data(events, function (e) { return e.timeStamp }) .attr('fill', 'gray') circles .enter() . append('circle') .attr('cx', function (d) { return d.x || d.pageX }) .attr('cy', function (d) { return d.y || d.pageY }) .attr('fill', 'red') .attr('r', 10) circles .exit() .remove() })
Этот пример на jsfiddle.net
- Методы selection.html() и selection.text() задают или возвращают содержимое элементов в виде HTML или текста.
- Методы selection.style(), selection.attr() и selection.property() задают или возвращают CSS-свойства элемента, его аттрибуты и свойства. Чаще всего мы будем пользоваться style и attr, особенно при описании свойств новых элементов.
- Метод selection.remove() удаляет элементы текущей выборки.
- selection.append() добавляет потомка к каждому элементу текущей выборки.
- Переданные в selection.data() данные сохраняются в поле __data__ DOM-элемента, при вызове методов на выборке происходит их извлечение из элемента.
- Получить или записать данные в один элемент можно, используя selection. datum().
Связанные множества
В примере особое внимание стоит обратить на метод data(). В отличие от других методов он возвращает модифицированную выборку, хранящую помимо списка элементов соответствие данных элементам. В переводе статьи Thinking with Joins мы подробно рассказываем о методах enter() и exit() которые есть у такой выборки и возможностях которые они дают.
Анимация и настройка
Анимировать изменение свойств элемента в D3 легко, нужно вызвать метод selection.transition(). Этот метод возвращает выборку, которая постепенно изменяет текущие значения на новые, создавая анимационный эффект. Длительность анимации задаётся методом transition.duration().
Добавим анимацию при добавлении и удалении элементов в предыдущий пример:
var svg = d3.select('body').append('svg') svg .append('text') .text('click here') .attr('x', 50) .attr('y', 50) var events = [] svg. on('click', function () { events.push(d3.event) if (events.length > 5) events.shift() var circles = svg.selectAll('circle') .data(events, function (e) { return e.timeStamp }) .attr('fill', 'gray') circles .enter() .append('circle') .attr('cx', function (d) { return d.x || d.pageX }) .attr('cy', function (d) { return d.y || d.pageY }) .attr('fill', 'red') .attr('r', 0) // Начальное значение .transition() .duration(1000) // Длительность перехода от начального значения к конечному .attr('r', 10) // Конечное значение circles .exit() .transition() .attr('r', 0) .remove() })
Этот пример на jsfiddle.net
В этой статье я рассказал о возможностях D3 по работе с выборками. Следующие заметки планирую посвятить утилитам для обработки и загрузки данных, рисованию наборов SVG-элементов и созданию интерактивных элементов визуализации.Я преподаю D3 на курсе «Визуализация данных». Если вы хотите освоить этот инструмент и начать применять его в своей работе, приходите к нам. Ближайший курс пройдёт в Москве в эти выходные, запись и отзывы участников январского курса: http://brainwashing.pro/dataviz.
Быстро о главном: визуализация с D3.js | by Jenny V | NOP::Nuances of Programming
D3, сокращенно от Data-Driven Documents, — это библиотека JavaScript, которая позволяет отображать данные самыми разными способами и содействует проверке и управлению элементами DOM.
D3 не относится к разряду типичных библиотек для построения графиков и диаграмм. Она обладает намного большей гибкостью и усилена возможностями Recharts, C3js и Raw graphs. Ее преимущество очевидно: там, где обычная библиотека терпит фиаско, когда вы слишком увлекаетесь индивидуальными настройками, D3 продолжает уверенно работать
В статье мы познакомимся с D3 на основе конкретных примеров. Общий процесс сводится к следующим этапам: загрузка данных — выборка элементов — привязка данных — создание/изменение/ удаление элементов.
Но прежде немного порисуем на веб-странице и попрактикуемся в применении селекторов и фигур разных цветов.
Рисование базовых фигур
Как же конкретно рисовать с D3? Прежде всего создаем HTML-страницу, содержащую пустой элемент SVG (аббревиатура расшифровывается как язык разметки масштабируемой векторной графики). Для тех кто не знает, SVG — это изображение, состоящее из основных геометрических фигур, благодаря чему оно становится хорошо масштабируемым. Как раз для рисования таких фигур и понадобится D3.
Сначала для импорта D3 создаем HTML-документ, с который мы будем работать на протяжении всей статьи. Редактироваться будет только код между выделенными тегами скрипта.
В строке 4 импортируем D3. Если у вас frontend-фреймворк с Node.js, то вы можете использовать NPM. В строке 7 определяем элемент SVG, в котором будем рисовать фигуры.
Напишем первые строки кода D3:
В строке 2 задействуем метод d3.select()
. Он будет искать первый элемент с CSS-селектором, который вы передали в функцию. В рассматриваемом примере на странице только один тег <svg>
, поэтому он вернет единственно имеющийся элемент SVG.
Позже обратимся к другому методу, а именно d3.selectAll()
, который вернет не один, а все элементы, соответствующие CSS-селектору.
В строке 4 к элементу SVG добавляем “circle” (круг). Помимо него в нашем распоряжении есть еще множество разных фигур. В строке 5 применяем цепочку методов D3, что позволяет поочередно вызывать несколько функций и избавляет от необходимости писать svg.function(...)
в каждой отдельной строке.
В строках 5, 6, 7 и 8 устанавливаем атрибуты с помощью функции d3.attr
, которые потребуются для визуализации фигуры. cx
и cy
— координаты x и y центра круга; r
— радиус; fill
— цвет заливки. Это фиксированные атрибуты. С примерами их использования для основных геометрических форм вы можете ознакомиться на freeCopeCamp.
Итак, в браузере увидим следующий результат:
Кто-то может подумать, что здесь нет ничего особенного. Однако позволю себе не согласиться. Даже самые сложные графические изображения складываются из нескольких простых фигур.
Теперь дополним изображение квадратом:
Добавляем фигуру rect
и устанавливаем нужные атрибуты для ее отображения. Затем выполняем код и получаем следующий результат:
С основными принципами добавления фигур разобрались, переходим к вопросу загрузки данных и графическим изображениям на их основе.
Загрузка данных
Для получения разных типов данных в D3 существует множество функций. Начнем с самых интересных: d3.json('...')
, d3.csv('...')
и d3.xml('...')
.
Для вышеуказанной цели все из них предусматривают применение Fetch
. CSV, в отличие от JSON и XML, не допускает вложенных объектов.
В последующих разделах мы будем работать с файлом данных (socialnetwork. json
) из вымышленной социальной сети и стилями, представленными ниже:
Сначала построим гистограмму для библиотеки. По горизонтали отобразим годы (years), а по вертикали — количество комментариев (comments). Для этой цели воспользуемся соответственно d3.axisBottom()
и d3.axisLeft()
. В коде загрузка данных и рисование осей происходят следующим образом:
Как много кода! Без паники, рассмотрим каждую строку по отдельности.
В строке 3 получаем JSON. Поскольку d3.json()
возвращает промис, нам не обойтись без .then()
или await
, чтобы дождаться окончания процедуры. Как только это происходит, в строках 5 и 10 приступаем к созданию функций масштабирования.
Функции масштабирования
Функции масштабирования играют важную роль в отрисовке данных. Допустим, мы рассматриваем временной промежуток с 2015 по 2020 год при разрешении 500 х 500 пикселей. При отрисовке номера года на оси x необходимо сопоставить 2015–2020 в 0–500.
Самое время для функций масштабирования. Одна из них, .scaleLinear()
, линейно преобразует один диапазон чисел (domain) в другой (range). В строке 7 мы передаем этой функции диапазон (range)[100,800]
, поскольку нам нужно начать отрисовку с 100-го пикселя по оси x, а закончить на 800-м.
После этого происходит передача диапазона входных значений (domain), которые надлежит преобразовать. .domain()
ожидает массив с минимальным и максимальным значениями. К счастью, у D3 есть функции d3.extent()
, d3.max()
и d3.min()
. В строке 8 устанавливаем минимальное значение minimum of years — 1
и максимальное maximum of years + 1
. Это позволит в дальнейшем отцентрировать столбцы гистограммы.
В строке 13 делаем немного по-другому, поскольку ось должна начинаться с 0, а заканчиваться максимальным числом комментариев. Наша задача — отобразить диапазон от 0 до 150, а не от 50 до 150.
d3. extent()
вернет массив из максимального и минимального значений [max, min]
, избавляя от необходимости вызывать их по отдельности.
Приступаем к рисованию оси: в строке 15 добавляем ось x. В целях стилизации присваиваем ей класс и затем перемещаем элемент g, поскольку ось x всегда рисуется из начала координат, расположенного в верхнем левом углу. Поэтому сдвигаем g
на горизонталь, после чего выполняем .call(d3.axisBottom(x).tickFormat(d3.format("d"))
. Функция .axisBottom
рисует ось. (Обратите внимание, что мы передали ей функцию range
, поэтому она знает, в каком диапазоне range/domain нужно выполняться). Затем дополнительная функция .tickFormat()
сообщает D3, как отображать деления на оси. В данном случае используем "d"
для обозначения простых чисел в интервале от 2014 до 2021.
Далее практически то же самое мы делаем для отрисовки оси y, только меняем атрибут transform
, поскольку необходимо переместить эту ось горизонтально для отображения делений.
Полученный результат:
Выборка элементов и привязка данных
На этом этапе нужно нарисовать прямоугольники для каждого года в зависимости от числа комментариев. С этой целью проведем выборку SVG и привязку данных. Пишем следующий код:
Сначала воспользуемся функцией d3.select()
для получения SVG. (Обратите внимание, что в предыдущих фрагментах кода мы ее сохранили в переменной svg
). Затем с помощью селектора CSS selector .barchartrectangle
проводим поиск и отбираем все существующие прямоугольники гистограммы. Это связано с процессами обновления: если массив изменится, мы будем выбирать имеющиеся прямоугольники для их корректировки либо удаления.
Далее вызываем функцию .data()
, которая содержит один обязательный аргумент, представляющий data
, которые нужно отрисовать. При этом второй аргумент является необязательным и определяет ключ. Для этого мы передаем ему функцию, принимающую элемент (item) и индекс (index). В данном примере возвращается индекс.
Однако, как мы увидим в следующем разделе, возможно применение поля элемента для выявления тех элементов, которые подлежат изменению/удалению/вставке. При наличии свойства id
в датасете можно написать что-то подобное (item, index)=> item.id
.
Итак, привязывать данные мы научились, переходим к добавлению прямоугольников.
Создание, изменение и удаление элементов
Когда происходит изменение данных в массиве (например, путем фильтрации), D3 сравнивает массивы и решает, что делать.
Здесь мы рассмотрим только 2 наиболее важные функции: добавление .enter()
и удаление .exit()
.
Понять логику поможет следующий код:
Рассмотрим использование этого массива в качестве данных [1,2,3]
при первом выполнении кода.
Селектор .barchartrectangle
вернет 0 элементов, поэтому D3 в курсе, что элементы в массиве являются тремя новыми фрагментами данных. Следовательно, она задействует функции, измененные на инструкцию .enter()
. В ней мы присоединяем новые rect
каждому новому фрагменту данных и присваиваем им класс barchartrectangle
. Поскольку “удалений” нет, инструкция .exit()
будет проигнорирована.
Теперь при изменении массива на [1,2] и повторном выполнении кода селектор .barchartrectangle
вернет три существующих элемента. Однако D3 отметит наличие в массиве только двух. Тогда она воспользуется функциями, связанными с инструкцией .exit()
, и в этом случае удалит .remove()
третий из них.
Как D3 понимает, какой элемент следует удалять? Это зависит от ключа данных, определенного ранее. В нашем примере им по-прежнему является индекс, поэтому он удалит третий добавленный элемент. Но если элемент оказался фактическим id (item, index)=> item
, и мы изменили массив на [1,3], то вместо третьего будет удален второй barchartrectangle
.
Работа инструкций .enter()
и .exit()
теперь понятна, переходим к отображению гистограммы.
В строке 4 применяем .enter()
и тем самым инструктируем D3, что делать с данными. В строке 5 добавляем новый rect
и устанавливаем необходимые атрибуты. Как видно в строках 6 и 7, функция .attr()
принимает не только фиксированные значения, но и функцию, которая использует элемент и его индекс, что позволяет отрисовывать элементы в зависимости от данных.
Сначала в строке 6 получаем масштабированное положение x путем вызова функции масштабирования x, определенной ранее с номером года. В ответ вернется значение пикселя, на котором нужно разместить гистограмму. Поскольку далее ширина будет установлена как 100, то мы подстраиваем столбец к центральной точке этого значения, выполняя -50
. Для координаты y просто вызываем функцию масштабирования. В строке 9 для определения высоты выполняем 800- y(item. comments)
из-за обратной функции масштабирования. Напомню, что диапазон был определен как [800, 100]
, поэтому малые значения комментариев будут отображаться в большие значения пикселей. В связи с этим мы обращаем эту функцию, выполняя 800 — y(item.comments)
.
Наконец, для привлекательности задаем синий цвет заливки. Итоговый результат:
Отлично! На основе данных мы отрисовали на экране гистограмму. В этом и состоит вся суть D3. Но у нее намного больше возможностей. На D3.js — Data-Driven Documents (d3js.org) вы можете ознакомиться с интересными графиками и фигурами.
Заключение
Теперь у вас есть общее представление о визуализации посредством библиотеки D3. Вы можете рисовать фигуры в SVG на основе динамически генерируемых данных.
А впереди еще много интересного: геокартирование, управление HTML, визуализация сетевых процессов и т.д. D3 ценна прежде всего тем, что предоставляет почти безграничные возможности, которыми вы обязательно должны воспользоваться.
Читайте также:
- notebookJS: JavaScript и D3 в Jupyter Notebook
- Почему я больше не пользуюсь D3.js
- ТОП-25 библиотек React 2021–2022: новые, полезные, но малоизвестные пакеты JavaScript
Читайте нас в Telegram, VK и Яндекс.Дзен
d3-hierarchy / D3 / Observable
Алгоритмы 2D компоновки для визуализации иерархических данных.
с показателем 1-30 из 32 списков
D3. Группы в качестве иерархии
D3 • FIL
15 сентября 2020 г. •
54
4
Случайное дерево
D3 • Mike Bostock
Secp 29, Secp 29. 2018•
36
Обход иерархии, анимированный
D3•Fil
6 июля 2020 г.•
21
Bilevel Edge Bundling
Эта записная книжка представляет собой FORKD3 • Mike Bostock
24 мая 2018 г. •
33
СДЕЛАННЫЕ ДЕРЕВО
Эта ноутбук — FOKD3 • Mike Bostock
1, 2020 •
90
Эта записная книжка представляет собой FORKD3 • Mike Bostock
Dec 30, 2019 •
86
Иерархический град
Эта ноутбука — BOSKD3 • Mike Bostock
9 000, 2019 •
99
. График
D3•Mike Bostock
Jan 7, 2019•
42
d3.packEnclose
D3•Mike Bostock
May 26, 2017•
56
Hierarchical Bar Chart
D3•Mike Bostock
5 июля 2019 г. •
116
, направленное на силовое дерево
Эта записная книжка-это вилкаD3 • Mike Bostock
26 июня 2019 г. •
91
3
D3. Стратификация
D3 • FIL
14 июня 2019 г. •
31
Посещение D3.Hierarchy
D3 • FIL
14 июня 2019 г. •
24
D3.HIERARCHY Записная книжка — это вилка
D3 • Mike Bostock
10 марта 10, 2019 •
22
Внутренний TreeMap
Эта ноутбук — BostkD3 • Mike Bostock
10 марта 2019 г. •
38
1 0003
Dreemp по количеству
Этот блокнот представляет собой вилкуD3 • Mike Bostock
10 марта 2019 г. •
7
Растянутая TreeMap
Эта записная книжка представляет собой вилкуD3 • Mike Bostock
10 марта 2019 •
14
Собработанное дерево
D3 • Mike Bost Bost Bost Bost Bost Bost Bost Bost Bost Bost Bost Bost Bost Bost Bost Bost Bost Bost Bost Bost Bost Bost Bost Bost Bost.
1 октября 2018 г. •
294
Иерархический края объединения
Эта записная книжка — это вилкаD3 • Mike Bostock
23 мая 2018 •
183
3
D3 • Mike Bostock
26 ноября 2018 г. •
258
Zoomable Sunburst
Эта ноутбука — вилкаD3 • Mike Bostock
30 апреля 2018 •
390
3
, Sunbure
3
. D3 • Mike Bostock
28 апреля 2018 г. •
139
4
TREEMAP, CSV
D3 • Mike Bostock
Dec 28, 2017 •
126
3
Tree, Radial Tidy
3Tree, Radial Tidy
3
Tree, Radial Tidy
3
Tree, Radial Tidy
3
Tree, Radial Tidy
3
Tree. • Майк Босток
16 ноября 2017 г. •
167
Tree, Cluster
Эта записная книжка представляет собой ForkD3 • Mike Bostock
декабря 2017 г. •
76
4
Цильсы. D3 • Mike Bostock
30 апреля 2018 г. •
93
Tree, Tidy
Эта записная книжка — это вилкаD3 • Mike Bostock
Dec 28, 2017 •
169
Кружная упаковка, Bubble Chart
это вилкаD3 • Mike Bostock
28 декабря 2017 г. •
112
2
ПЕРЕДИТ, Icicle
Эта записная книжка — ForkD3 • Mike Bostock
28 апреля 2018 •
33
1 0003
Появление. 1-30 из 32 объявлений
Избранные авторы / Observable / Observable
Observable
@observablehq
ProfileNotebooksCollections0003 Эта записная книжка представляет собой VOK
Helena Oliveira
сентября 28
Voronoi Cells
Эта ноутбука — вилкаBen Simonds
Sep 28 •
22
Taylor Polynomials
Amit Sch
SEP 28.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
Euclidean Rhythms
Torben Jansen
24 сентября •
64
2
Население сетки (15 минут) — Соединенные Штаты
Evan Galloway
Sep 2 •
5
0003
Marcas Y Canales
Эта записная книжка представляет собой ForkNéstor Andrés Peña
сентябрь 14 •
1
Отталкивание
Это ноутбук — FORBGRAB SUSMANN
SEP 5 •
21
Zoomble
SEP 5 •
9000 21Zoomble
SEP 5 •
21
Zoom. Schudel
август 18 •
43
Океанские токи
Tom Macwright
Aug 12 •
24
MAROPLETH MAP
Крис Хенрик
Aug 26 •
13
Animated Mapbox Vector Fields (aka Wind Maps)
Corey Guastini
Aug 15•
35
New Yorkers from Canada to the Amboys
This notebook is a forkBenjamin Schmidt
Jul 10•
29
2
Калифорнийцы от Орегона до Мексики
Джеффри Бейкер
9 июля 9•
24
Пять лет слежения за свободой прессы
90 Aug• 3 Harris 90 Lapiroff00035
Женщины в космосе 👩🏼🚀
Raluca Nicola
28 марта, 2021 •
62
Японский от Hokkaido до Okinawa
. Этот ноутбук — FORKSorami Hisamoto
Aug 21.
Как адаптировать Dataviz
Tom Larkworthy
АГЛИТАЯ 16 •
22
Голландцы от Eemshaven до Epen
Жюль Блам
Aug 17 •
21
Том. согласование древовидных структур
Матье Жуэ
31 июля •
27
Насколько мужчины выше женщин?
Tereza iofciu
апрель 28 •
6
Атомные агенты: полилины и силы
Эта ноутбук — вилкаGraham McNeill
5 •
65
Строижи
3 мая 2021 г. •
24
Таблица умножения
SUGIMOTO Tatsuo
август 3 •
21
Движение
JO WOOD
13 июля •
60
2
Использование участка вокруг Интернета
Anjana Vakil
Apr 19 •
4
Историческая карта: день: день: день: день: день: день: день: день: день: день: день.