Слишком часто я наблюдал за тем, как импровизирующий музыкант трясущимися руками пытается увеличить pdf размером A4 на крошечном экране телефона в самом разгаре исполнения. Мы обязаны создать плавный и отзывчивый рендеринг музыки для веба!
В вебе нотная запись должна быть столь же доступной и плавной, как текст; однако пока это не так, и это уязвляет мои чувства. Давайте решим эту актуальную проблему.
SVG, отрендеренный Scribe 0.2
Несколько лет назад я создал прототип рендерера музыки, который назвал Scribe. Он выполняет преобразование JSON в SVG. Изначально я стремился к созданию адаптивного рендерера музыки. Это было хорошее демо, но для дальнейшего развития пришлось бы писать сложный многопроходный движок генерации макетов, а у меня тогда возникли другие дела.
Вскоре после этого я занялся адаптированием Grid под проекты компании, и тут мне почудилось нечто знакомое: я задался вопросом, а не станет ли он решением некоторых проблем, с которыми я столкнулся при разработке Scribe?
Нотный стан выстроен в подобие сетки. Высота ноты откладывается по вертикальной оси, а время идёт влево по горизонтальной оси. Я определю эти две оси в двух отдельных классах. Вертикальная ось, описывающая строки сетки, будет называться
Если применить этот код к
![](https://habrastorage.org/r/w1560/webt/tq/gn/3u/tqgn3umz9g9j8lrgmeonn6frgy4.png)
Неплохо. Не особо информативно, но, изучив результат, мы увидим, что у каждой линейки и у каждого пустого поля теперь есть собственная строка сетки с названием высоты ноты, обозначающая каждую строку:
![](https://habrastorage.org/r/w1560/webt/pb/zo/ub/pbzoub3qnx74l8vax7f05pxjgpy.png)
Каждая из строк стана может содержать одну из нескольких высот нот. Например, ноты G♭, G и G♯ должны находиться на одной линейке G.
Чтобы разместить описывающие эти ноты элементы DOM в нужные строки, я помещу названия нот в атрибуты
Это правило обрабатывает ноты, начинающиеся с
И этого будет достаточно, чтобы начать размещать символы на нотном стане! У меня есть SVG-символы, которые я подготовил для прототипа Scribe. Давайте попробуем поместить парочку на стан:
![](https://habrastorage.org/r/w1560/webt/jd/su/79/jdsu79ebzsepcduen0yuwpn6ccw.png)
Выглядит многообещающе. Теперь займёмся временем.
С ритмом, пожалуй, работать сложнее. Не сразу понятно, какую выбрать наименьшую часть ритма, поддерживающую все возможные ритмы. Необходимо принять решение о том, какие минимальные длительности нот и какие кросс-ритмы нужно поддерживать в рамках сетки.
Если разделить такт на 24 столбца, то мы можем равномерно распределять восьмые (12 столбцов), шестнадцатые (6 столбцов), 32-е (3 столбца), а также значения триолей этих нот. Неплохо для начала.
Вот четырёхдольный такт, определённый как 4 × 24 = 96 столбцов сетки, плюс по столбцу в начале и в конце:
Добавим пару тактовых черт как контент
![](https://habrastorage.org/r/w1560/webt/ta/e5/1m/tae51mxcstzno9lq2a5v94gagvo.png)
При внимательном изучении можно заметить, что ключ попал в первый столбец, и что есть 96 столбцов нулевой длины, по 24 на долю, каждый из которых разделён небольшим
![](https://habrastorage.org/r/w1560/webt/vh/zt/2y/vhzt2ylcqugzz1hyjcnifsudpvm.png)
Теперь я воспользуюсь атрибутами
Селектор атрибута
Соединив это с классом
![](https://habrastorage.org/r/w1560/webt/sb/5j/vx/sb5jvx9pblshoyodzdn7cukydhy.png)
Отлично. Штили?
![](https://habrastorage.org/r/w1560/webt/1y/mq/ic/1ymqicjuf_mbqsdo2nqxad4p0da.png)
Готово. Флажки?
![](https://habrastorage.org/r/w1560/webt/ah/-n/-q/ah-n-qymgohcnkxqeyifgfnzpug.png)
Готово. Разнесённость флажков можно улучшить (что, наверно, можно сделать при помощи margin), но с позиционированием всё нормально.
Если засунуть несколько таких тактов в контейнер flexbox, то мы получим адаптивную нотную запись:
![](https://habrastorage.org/r/w1560/webt/7g/t9/ce/7gt9cexfnhxp_eed2u_wtr0z-ga.png)
Очевидно, что здесь ещё многого не хватает, но основание заложено. Результат уже рендерится красивее, чем в других онлайн-рендерерах музыки.
Пока не будем обращать внимания на вязки и отметим, что головки нот, которые ближе по времени друг к другу, рендерятся чуть ближе друг к другу:
![](https://habrastorage.org/r/w1560/webt/ow/es/5_/owes5_xbr8dakmala0lq74dbj2u.png)
Это сделано намеренно при помощи небольшого
Постоянство расстояний можно контролировать регулировкой margin символов. Чтобы расстановка была более постоянной, мы уменьшим
![](https://habrastorage.org/r/w1560/webt/y4/6v/-p/y46v-pzvxh47tpepgfumvo85lsi.png)
Но это выглядит некрасиво, потому что интервалы между головками не дают читателю никакого представления о том, насколько быстр ритм. Однако в CSS есть удобный способ управления метриками. И теперь наша цель — настроить эти метрики, чтобы повысить читаемость.
Возможно, вы задаётесь вопросом, почему я использовал для горизонтальных и вертикальных интервалов отдельные классы, а не один? Разделив оси, мы можем заменить одну, не касаясь другой. Возьмём для примера такую мелодию:
![](https://habrastorage.org/r/w1560/webt/ou/we/9x/ouwe9xtgywkthdl-zwhw6xbmrkk.png)
Чтобы отобразить ту же мелодию в басовом ключе, можно заменить класс
![](https://habrastorage.org/r/w1560/webt/ji/da/lj/jidaljlooagqter6y1u7lqwkmay.png)
Или если сопоставить
![](https://habrastorage.org/r/w1560/webt/a1/3y/mz/a13ymzpylwnkyjhj2podzwvjfxk.png)
Разумеется, я упростил объяснение. Не всё заканчивается сменой класса, необходимо также изменить расположение штилей и добавочных линеек.
Вот класс нотного стана, полностью меняющий сопоставление высот нот. В General MIDI голоса ударных инструментов находятся в группе нот в нижних октавах клавиатуры, но эти ноты не связаны с тем, где ударные печатаются на нотном стане. Можно определить в CSS класс
![](https://habrastorage.org/r/w1560/webt/jr/6k/5-/jr6k5-zcynhlb7f8bsohcmcsuxe.png)
![](https://habrastorage.org/r/w1560/webt/pt/hn/an/pthnan9leoa4fswouftrcqhf_sk.png)
Получилась очень читаемая нотация ударных, я очень ею доволен.
CSS Grid позволяет выравнивать в сетке нотации и другие символы. С временными событиями можно выравнивать, например, аккорды, тексты и динамику:
![](https://habrastorage.org/r/w1560/webt/_m/zb/wb/_mzbwbwdwi56ocw7ady6u7bffuu.png)
Вязки, аккорды и длинные паузы преобразуются в столбцы со span сопоставлением их атрибутов
Вся система имеет размер
![](https://habrastorage.org/r/w1560/webt/jt/ig/vk/jtigvkx_0svo7yq0nuom1alfqbs.png)
Идеальна ли эта система? Честно говоря, я поражён тем, насколько хорошо она работает, но если уж искать недостатки, то…
1. CSS не может автоматически располагать новый символ ключа в начале каждой перенесённой строки
2. Он не может связать головку ноты с новой головкой в новой строке.
3. Вязки под углом — это совершенно отдельная история; вязки 1/16-х и 1/32-х нот сложно выровнять, потому что мы не знаем точно, где будут их штили, пока их не разместит Grid:
![](https://habrastorage.org/r/w1560/webt/2u/c7/_u/2uc7_unmxze7b28gmxfq2lyj-ik.png)
Так что для полного завершения работы потребуется немного JavaScript, но основную работу по размещению элементов здесь выполняет CSS, а значит, для JavaScript остаётся довольно мало труда.
Специальный элемент для рендеринга музыки
Репозиторий кода: github.com/stephband/scribe/
Формат данных Scribe: github.com/soundio/music-json/
Я написал интерпретатор для этой новой системы CSS и обернул его в элемент
Элемент
![](https://habrastorage.org/r/w1560/webt/y-/ug/on/y-ugondtsjlxh6aqtcyvcy7v2bs.png)
Или из файла, полученного в атрибуте
![](https://habrastorage.org/r/w1560/webt/cr/ok/cr/crokcr208kbmcno5bifsghycx3c.png)
Или из объекта JS, указанного в свойстве
Основная документация по всему этому есть в README.
Можно протестировать текущую dev-сборку, импортировав в веб-страницу следующие файлы:
Как я сказал, проект пока в разработке. В дальнейшем я хочу исследовать и попробовать реализовать следующие функции:
![](https://habrastorage.org/r/w1560/webt/jx/md/ye/jxmdyendyev6uxwdkpnkdl77zac.png)
В вебе нотная запись должна быть столь же доступной и плавной, как текст; однако пока это не так, и это уязвляет мои чувства. Давайте решим эту актуальную проблему.
Прототип Scribe
![](https://habrastorage.org/webt/0u/kd/gj/0ukdgjf8xpcqiibqtx7nbkaajr4.png)
Несколько лет назад я создал прототип рендерера музыки, который назвал Scribe. Он выполняет преобразование JSON в SVG. Изначально я стремился к созданию адаптивного рендерера музыки. Это было хорошее демо, но для дальнейшего развития пришлось бы писать сложный многопроходный движок генерации макетов, а у меня тогда возникли другие дела.
Вскоре после этого я занялся адаптированием Grid под проекты компании, и тут мне почудилось нечто знакомое: я задался вопросом, а не станет ли он решением некоторых проблем, с которыми я столкнулся при разработке Scribe?
Класс .stave
Нотный стан выстроен в подобие сетки. Высота ноты откладывается по вертикальной оси, а время идёт влево по горизонтальной оси. Я определю эти две оси в двух отдельных классах. Вертикальная ось, описывающая строки сетки, будет называться
.stave
. К оси времени мы вернёмся чуть позже..stave
содержит строки сетки фиксированного размера, имеющие имена стандартных высот нот, и фоновое изображение, отрисовывающее стан. То есть для нотных линеек скрипичного ключа map строк может выглядеть так:.stave {
display: grid;
row-gap: 0;
grid-template-rows:
[A5] 0.25em [G5] 0.25em [F5] 0.25em [E5] 0.25em
[D5] 0.25em [C5] 0.25em [B4] 0.25em [A4] 0.25em
[G4] 0.25em [F4] 0.25em [E4] 0.25em [D4] 0.25em
[C4] 0.25em ;
background-image: url('/path/to/stave.svg');
background-repeat: no-repeat;
background-size: 100% 2.25em;
background-position: 0 50%;
}
Если применить этот код к
<div>
, то получим следующее:![](https://habrastorage.org/webt/tq/gn/3u/tqgn3umz9g9j8lrgmeonn6frgy4.png)
Неплохо. Не особо информативно, но, изучив результат, мы увидим, что у каждой линейки и у каждого пустого поля теперь есть собственная строка сетки с названием высоты ноты, обозначающая каждую строку:
![](https://habrastorage.org/webt/pb/zo/ub/pbzoub3qnx74l8vax7f05pxjgpy.png)
▍ Размещаем высоты нот на нотном стане
Каждая из строк стана может содержать одну из нескольких высот нот. Например, ноты G♭, G и G♯ должны находиться на одной линейке G.
Чтобы разместить описывающие эти ноты элементы DOM в нужные строки, я помещу названия нот в атрибуты
data-pitch
и использую CSS, чтобы сопоставить значения data-pitch
со строками линеек..stave > [data-pitch^="G"][data-pitch$="4"] { grid-row-start: G4; }
Это правило обрабатывает ноты, начинающиеся с
'G'
и заканчивающиеся на '4'
, то есть оно присваивает ноты 'G♭4'
, 'G4'
и 'G♯4'
(а также дубль-бемоль 'G𝄫4'
и дубль-диез 'G𝄪4'
) строке G4
. Это необходимо проделать для каждой строки нотного стана:.stave > [data-pitch^="A"][data-pitch$="5"] { grid-row-start: A5; }
.stave > [data-pitch^="G"][data-pitch$="5"] { grid-row-start: G5; }
.stave > [data-pitch^="F"][data-pitch$="5"] { grid-row-start: F5; }
.stave > [data-pitch^="E"][data-pitch$="5"] { grid-row-start: E5; }
.stave > [data-pitch^="D"][data-pitch$="5"] { grid-row-start: D5; }
...
.stave > [data-pitch^="D"][data-pitch$="4"] { grid-row-start: D4; }
.stave > [data-pitch^="C"][data-pitch$="4"] { grid-row-start: C4; }
И этого будет достаточно, чтобы начать размещать символы на нотном стане! У меня есть SVG-символы, которые я подготовил для прототипа Scribe. Давайте попробуем поместить парочку на стан:
<div class="stave">
<svg data-pitch="G4" class="head">
<use href="#head[2]"></use>
</svg>
<svg data-pitch="E5" class="head">
<use href="#head[2]"></use>
</svg>
</div>
![](https://habrastorage.org/webt/jd/su/79/jdsu79ebzsepcduen0yuwpn6ccw.png)
Выглядит многообещающе. Теперь займёмся временем.
Класс .bar и его такты
С ритмом, пожалуй, работать сложнее. Не сразу понятно, какую выбрать наименьшую часть ритма, поддерживающую все возможные ритмы. Необходимо принять решение о том, какие минимальные длительности нот и какие кросс-ритмы нужно поддерживать в рамках сетки.
Если разделить такт на 24 столбца, то мы можем равномерно распределять восьмые (12 столбцов), шестнадцатые (6 столбцов), 32-е (3 столбца), а также значения триолей этих нот. Неплохо для начала.
Вот четырёхдольный такт, определённый как 4 × 24 = 96 столбцов сетки, плюс по столбцу в начале и в конце:
.bar {
column-gap: 0.03125em;
grid-template-columns:
[bar-begin]
max-content
repeat(96, minmax(max-content, auto))
max-content
[bar-end];
}
Добавим пару тактовых черт как контент
::before
и ::after
, а затем добавим символ ключа, отцентрированный на стане при помощи data-pitch="B4"
, и получим следующее:<div class="stave bar">
<svg data-pitch="B4" class="treble-clef">
<use href="#treble-clef"></use>
</svg>
</div>
![](https://habrastorage.org/webt/ta/e5/1m/tae51mxcstzno9lq2a5v94gagvo.png)
При внимательном изучении можно заметить, что ключ попал в первый столбец, и что есть 96 столбцов нулевой длины, по 24 на долю, каждый из которых разделён небольшим
column-gap
:![](https://habrastorage.org/webt/vh/zt/2y/vhzt2ylcqugzz1hyjcnifsudpvm.png)
▍ Размещение символов в долях
Теперь я воспользуюсь атрибутами
data-beat
, чтобы присвоить доле элементы, а также применю правила CSS для сопоставления долей со столбцами сетки. После создания правила для каждой 1/24-й доли CSS map выглядит так:.bar > [data-beat^="1"] { grid-column-start: 2; }
.bar > [data-beat^="1.04"] { grid-column-start: 3; }
.bar > [data-beat^="1.08"] { grid-column-start: 4; }
.bar > [data-beat^="1.12"] { grid-column-start: 5; }
.bar > [data-beat^="1.16"] { grid-column-start: 6; }
.bar > [data-beat^="1.20"] { grid-column-start: 7; }
.bar > [data-beat^="1.25"] { grid-column-start: 8; }
...
.bar > [data-beat^="4.95"] { grid-column-start: 97; }
Селектор атрибута
^=
делает правило устойчивым к ошибкам. Рано или поздно неокруглённые числа или числа с плавающей запятой неизбежно отрендерятся в data-beat
. Двух десятичных знаков после запятой достаточно для идентификации 1/24-й доли на столбец сетки.Соединив это с классом
stave
, мы сможем размещать символы в зависимости от их высоты и доли, присваивая data-beat
значение доли от 1
до 5
, а data-pitch
имя ноты. В процессе столбцы долей, содержащие эти символы, будут адаптироваться под них:<div class="stave bar">
<svg class="clef" data-pitch="B4">…</svg>
<svg class="flat" data-beat="1" data-pitch="Bb4">…</svg>
<svg class="head" data-beat="1" data-pitch="Bb4">…</svg>
<svg class="head" data-beat="2" data-pitch="D4">…</svg>
<svg class="head" data-beat="3" data-pitch="G5">…</svg>
<svg class="rest" data-beat="4" data-pitch="B4">…</svg>
</div>
![](https://habrastorage.org/webt/sb/5j/vx/sb5jvx9pblshoyodzdn7cukydhy.png)
Отлично. Штили?
![](https://habrastorage.org/webt/1y/mq/ic/1ymqicjuf_mbqsdo2nqxad4p0da.png)
Готово. Флажки?
![](https://habrastorage.org/webt/ah/-n/-q/ah-n-qymgohcnkxqeyifgfnzpug.png)
Готово. Разнесённость флажков можно улучшить (что, наверно, можно сделать при помощи margin), но с позиционированием всё нормально.
Плавная и адаптивная нотация
Если засунуть несколько таких тактов в контейнер flexbox, то мы получим адаптивную нотную запись:
<figure class="flex">
<div class="treble-stave stave bar">…</div>
<div class="treble-stave stave bar">…</div>
<div class="treble-stave stave bar">…</div>
…
</figure>
![](https://habrastorage.org/webt/7g/t9/ce/7gt9cexfnhxp_eed2u_wtr0z-ga.png)
Очевидно, что здесь ещё многого не хватает, но основание заложено. Результат уже рендерится красивее, чем в других онлайн-рендерерах музыки.
Пространство между нотами
Пока не будем обращать внимания на вязки и отметим, что головки нот, которые ближе по времени друг к другу, рендерятся чуть ближе друг к другу:
![](https://habrastorage.org/webt/ow/es/5_/owes5_xbr8dakmala0lq74dbj2u.png)
Это сделано намеренно при помощи небольшого
column-gap
. Сами столбцы имеют нулевую ширину, если в них нет головки ноты, но между событиями есть другие column-gap (по 24 на долю), которые в долях расположены дальше друг от друга, поэтому расстояние увеличивается.Постоянство расстояний можно контролировать регулировкой margin символов. Чтобы расстановка была более постоянной, мы уменьшим
column-gap
, увеличив margin головок нот:![](https://habrastorage.org/webt/y4/6v/-p/y46v-pzvxh47tpepgfumvo85lsi.png)
Но это выглядит некрасиво, потому что интервалы между головками не дают читателю никакого представления о том, насколько быстр ритм. Однако в CSS есть удобный способ управления метриками. И теперь наша цель — настроить эти метрики, чтобы повысить читаемость.
▍ Ключи и обозначения размеров
Возможно, вы задаётесь вопросом, почему я использовал для горизонтальных и вертикальных интервалов отдельные классы, а не один? Разделив оси, мы можем заменить одну, не касаясь другой. Возьмём для примера такую мелодию:
![](https://habrastorage.org/webt/ou/we/9x/ouwe9xtgywkthdl-zwhw6xbmrkk.png)
Чтобы отобразить ту же мелодию в басовом ключе, можно заменить класс
stave
классом bass-stave
, сопоставляющим те же атрибуты data-pitch
с басовым нотным станом:<div class="bass-stave bar">...</div>
![](https://habrastorage.org/webt/ji/da/lj/jidaljlooagqter6y1u7lqwkmay.png)
Или если сопоставить
data-duration="5"
с 120 grid-template-columns
в .bar
, то тому же нотному стану можно присвоить размер 5/4:<div class="bass-stave bar" data-duration="5">...</div>
![](https://habrastorage.org/webt/a1/3y/mz/a13ymzpylwnkyjhj2podzwvjfxk.png)
Разумеется, я упростил объяснение. Не всё заканчивается сменой класса, необходимо также изменить расположение штилей и добавочных линеек.
Вот класс нотного стана, полностью меняющий сопоставление высот нот. В General MIDI голоса ударных инструментов находятся в группе нот в нижних октавах клавиатуры, но эти ноты не связаны с тем, где ударные печатаются на нотном стане. Можно определить в CSS класс
drums-stave
, сопоставляющий эти ноты с нужными строками:<div class="drums-stave bar" data-duration="4">...</div>
![](https://habrastorage.org/webt/jr/6k/5-/jr6k5-zcynhlb7f8bsohcmcsuxe.png)
<div class="percussion-stave bar" data-duration="4">...</div>
![](https://habrastorage.org/webt/pt/hn/an/pthnan9leoa4fswouftrcqhf_sk.png)
Получилась очень читаемая нотация ударных, я очень ею доволен.
▍ Аккорды и текст
CSS Grid позволяет выравнивать в сетке нотации и другие символы. С временными событиями можно выравнивать, например, аккорды, тексты и динамику:
![](https://habrastorage.org/webt/_m/zb/wb/_mzbwbwdwi56ocw7ady6u7bffuu.png)
▍ Но что насчёт вязок?
Вязки, аккорды и длинные паузы преобразуются в столбцы со span сопоставлением их атрибутов
data-duration
со значениями span grid-column-end
:.stave > [data-duration="0.25"] { grid-column-end: span 6; }
.stave > [data-duration="0.5"] { grid-column-end: span 12; }
.stave > [data-duration="0.75"] { grid-column-end: span 18; }
.stave > [data-duration="1"] { grid-column-end: span 24; }
.stave > [data-duration="1.25"] { grid-column-end: span 30; }
...
▍ Размеры
Вся система имеет размер
em
, так что для её масштабирования достаточно просто изменить font-size
:![](https://habrastorage.org/webt/jt/ig/vk/jtigvkx_0svo7yq0nuom1alfqbs.png)
▍ Ограничения Flex и Grid
Идеальна ли эта система? Честно говоря, я поражён тем, насколько хорошо она работает, но если уж искать недостатки, то…
1. CSS не может автоматически располагать новый символ ключа в начале каждой перенесённой строки
2. Он не может связать головку ноты с новой головкой в новой строке.
3. Вязки под углом — это совершенно отдельная история; вязки 1/16-х и 1/32-х нот сложно выровнять, потому что мы не знаем точно, где будут их штили, пока их не разместит Grid:
![](https://habrastorage.org/webt/2u/c7/_u/2uc7_unmxze7b28gmxfq2lyj-ik.png)
Так что для полного завершения работы потребуется немного JavaScript, но основную работу по размещению элементов здесь выполняет CSS, а значит, для JavaScript остаётся довольно мало труда.
<scribe-music>
Специальный элемент для рендеринга музыки
▍ Scribe
Репозиторий кода: github.com/stephband/scribe/
▍ JSON
Формат данных Scribe: github.com/soundio/music-json/
Я написал интерпретатор для этой новой системы CSS и обернул его в элемент
<scribe-music>
. Он ещё далёк от готовности, но уже способен рендерить адаптивный нотный лист. Мне кажется, это интересный и полезный проект.▍ Что он делает?
Элемент
<scribe-music>
рендерит музыкальную нотацию из данных, найденных в её содержимом:<scribe-music type="sequence">
0 chord D maj 4
0 F#5 0.2 4
0 A4 0.2 4
0 D4 0.2 4
</scribe-music>
![](https://habrastorage.org/webt/y-/ug/on/y-ugondtsjlxh6aqtcyvcy7v2bs.png)
Или из файла, полученного в атрибуте
src
, например, из этого JSON:<scribe-music
clef="drums"
type="application/json"
src="/static/blog/printing-music/data/caravan.json">
</scribe-music>
![](https://habrastorage.org/webt/cr/ok/cr/crokcr208kbmcno5bifsghycx3c.png)
Или из объекта JS, указанного в свойстве
.data
элемента.Основная документация по всему этому есть в README.
▍ Попробовать самостоятельно
Можно протестировать текущую dev-сборку, импортировав в веб-страницу следующие файлы:
<link rel="stylesheet" href="https://stephen.band/scribe/scribe-music/module.css" />
<script type="module" src="https://stephen.band/scribe/scribe-music/module.js"></script>
Как я сказал, проект пока в разработке. В дальнейшем я хочу исследовать и попробовать реализовать следующие функции:
- Поддержку шрифтов SMuFL — смену шрифта, используемого для символов нотации. Пока мне удаётся стабильным образом отображать их расширенные наборы символов в разных браузерах.
- Поддержку вложенных последовательностей, что позволит рендерить мелодии из нескольких партий.
- Рендеринг разделённого стана — размещение нескольких партий на одном стане. Половина механики уже готова: нотация ударных и нотация пианино автоматически разделяются по высотам нот.
- Рендеринг нескольких станов — размещение нескольких партий на нескольких выровненных станах.
Telegram-канал со скидками, розыгрышами призов и новостями IT 💻
![](https://habrastorage.org/webt/jx/md/ye/jxmdyendyev6uxwdkpnkdl77zac.png)