Как сделать ёлку, если ты Unicode

от автора

Поздравляю Хабр и Хаброжителей с Новым 2025 годом! Несколькими годами ранее я писал о том, как сделать ёлку из функций, в этот раз сказ пойдёт о ёлке из Unicode символов. Ограничение — должна быть музыка, а результат должен помещаться в QR код.

Идея и ограничения

Современные браузеры поддерживают dataUrl, особый формат, хранящий все данные прямо в url. Это могут быть картинки, текст и любые другие форматы данных. Из всего этого нас интересует только текст, рассмотрим поближе:

data:[<media-type>][;base64],<data>

Поскольку dataUrl вставляется в адресную строку, это накладывает ограничения на символы, которые могут быть использованы: [a-zA-Z0-9$\-_.+!*'()] . Среди этих символов нет треугольных кавычек, необходимых для html тегов, #, необходимой для цвета, пробелов и многих других символов. Подобные символы будут заменены на escape последовательности url, начинающиеся на % (%20 — пробел).

Не смотря на то, что треугольные кавычки браузеры обрабатывают корректно, на пробеле и решётке всё равно прерывают парс (на пробеле — хром, на # — firefox), в результате пользователь попадает на страницу поиска, а не на страницу, хранящуюся в dataUrl.

Одним из решений — использовать base64, позволяющую писать любые символы, ценой увеличенного объёма в байтах.

Итоговый dataUrl занимает 22 символа и начинается на:

data:text/html;base64,

В самом большом QR-коде максимум можно записать 2953 символа, теоретически, размер QR кода не ограничен, но 2953 символа — гарантированная поддержка в большинстве сканеров.

Остаток: 2953 — 22 = 2931 символ base64 кодировки, что равняется 2931 * 6/8 = 2198 байтам, или ascii символам.

2198 символов, не так уж мало. Поехали!

Верстка странички

Статический html:

<html> <head>   <meta name=viewport content=width=device-width,initial-scale=1.0>   <style>     body {         overflow: hidden;         display: flex;         justify-content: center     }     #X {         margin: auto;         font-size: calc(min(70vw, 40vh));         line-height: 1em;         position: relative     }     #Y, .A {         position: absolute     }   </style> </head> <body> <div class=A style=z-index:9>   By <a href=https://rigellab.ru target=_blank>Rigellab</a> 2024<br>   Click FIR to play </div> <div id=Y></div> <div id=X></div> </body> <script> </script> </html>

Для начала выровняем всё по центру в body и скроем всё, что за пределами экрана: overflow hidden, display flex, justify-content center.

В блоке div с id Y будут снежинки, а с id X будет ёлка, подарки, снеговики и текст «2025». Чтобы ёлка красиво выглядела и на широких мониторах, и на телефонах нужно ограничить размер шрифта (неявно являющимся размером ёлки) до минимума от 70% ширины экрана и 40% высоты экрана (calc(min(70vw, 40vh))) — на компе и на телефоне ёлка будет располагаться по центру экрана и не вылезать за его края.

Обязательно указываем position relative, чтобы выставлять подарки и снеговиков рядом с ёлкой.

Подарки и снеговики по своей сути — повторяющийся html код, поэтому для сокращения символов сгенерируем подарки и снеговиков через js и подставим в innerHTML блока X.

// функция для приведения числа к hex формату, нужна для цвета снеговиков и снежинок let S = e => e.toString(16);  // так как задали id блока, можем обращаться напрямую, без document.getElementById X.innerHTML = // ёлка '&#127876;' + // координаты x подарков [8, 23, 57, 72]   .map(e => `<div class=A style=font-size:20%;line-height:20%;`        +`bottom:1%;left:${e}%;z-index:1>&#127873;</div>`)   .join('') // кординаты x и y снеговиков + [[82, 10], [78, 65], [95, 38]]    .map(([a, b], i) => `<div class=A style=font-size:50%;`        // цвет снеговика зависит от порядкового номера         +`bottom:-${a}%;left:${b}%;color:#${S(8 + i * 3)}df;z-index:2>&#9731;</div>`)   .join('') + `<div class=A style=font-size:40%;top:-80%;text-align:center;`   + `width:100%;font-family:sans-serif;color:#9df>2025</div>`;

Как можно заметить, в строках повторяется много раз они и те же подстроки: div, z-index, color, вынесем их отдельно и будем подставлять через конкатенацию.

let A='<div class=A style=font-size:',W=';z-index:',J=';color:#';  X.innerHTML = '&#127876;' +  [8, 23, 57, 72]   .map(e => A + '20%;line-height:20%;bottom:1%;left:' + e + '%' + W + '1>&#127873;</div>')   .join('') +  [[82, 10], [78, 65], [95, 38]]   .map(([a, b], i) => A + '50%;bottom:-' + a + '%;left:' + b + '%' + J + S(8 + i * 3) + 'df' + W + '2>&#9731;</div>')   .join('') +  A + '40%;top:-80%;text-align:center;width:100%;font-family:sans-serif' + J + '9df>2025</div>';

Вполне оптимально.

Аналогично поступим со снежинками, но будем их обновлять каждые 20 мс в блоке Y.

let V = 0;  setInterval(e => {   V++;   e = '';   for (let i = 50; i < 100; i++)      e += A + `${2 + i % 7}vh;top:${(i * 57 - 10 + V / i * 7) % 105}vh;left:${(i * 23 + V / i * 5) % 107 - 53}vw`       + J + S(i % 7 + 5) + S(i % 5 + 9) + 'f' + W + `${i % 5}>&#10052;</div>`;   Y.innerHTML = e }, 20);

Что здесь происходит:

Каждые 20 мс вызывается функция, увеличивающая V на единицу и обновляющая все снежинки на поле. Снежинка — это &#10052. Каждая снежинка в своём блоке div, с индивидуальным размером шрифта: 2 + i % 7 и псевдослучайной позицией.

Позиция по каждой из координат вычисляется на основе начальной точки, зависящей только от порядкового номера снежинки и «времени», переменной V, поделённой на порядковый номер снежинки. Из-за этого у каждой снежинки свой размер и своя скорость и траектория перемещения. Числа для умножения и нахождения остатка от деления подобраны так, чтобы глазу не был заметен паттерн. Вот что будет, если поменять значения на пару единиц:

Другие множители в формуле позиций снежинок

Другие множители в формуле позиций снежинок
Текущий dataUrl — скопируйте и вставьте в адресную строку вашего браузера

data:text/html;base64,PGh0bWw+PGhlYWQ+PG1ldGEgbmFtZT12aWV3cG9ydCBjb250ZW50PXdpZHRoPWRldmljZS13aWR0aCxpbml0aWFsLXNjYWxlPTEuMD48c3R5bGU+Ym9keXtvdmVyZmxvdzpoaWRkZW47ZGlzcGxheTpmbGV4O2p1c3RpZnktY29udGVudDpjZW50ZXJ9I1h7bWFyZ2luOmF1dG87Zm9udC1zaXplOmNhbGMobWluKDcwdncsNDB2aCkpO2xpbmUtaGVpZ2h0OjFlbTtwb3NpdGlvbjpyZWxhdGl2ZX0jWSwuQXtwb3NpdGlvbjphYnNvbHV0ZX08L3N0eWxlPjwvaGVhZD48Ym9keT48ZGl2IGNsYXNzPUEgc3R5bGU9ei1pbmRleDo5PkJ5IDxhIGhyZWY9aHR0cHM6Ly9yaWdlbGxhYi5ydSB0YXJnZXQ9X2JsYW5rPlJpZ2VsbGFiPC9hPiAyMDI0PC9kaXY+PGRpdiBpZD1ZPjwvZGl2PjxkaXYgaWQ9WD48L2Rpdj48L2JvZHk+PHNjcmlwdD5sZXQgVj0wLEE9JzxkaXYgY2xhc3M9QSBzdHlsZT1mb250LXNpemU6JyxXPSc7ei1pbmRleDonLEo9Jztjb2xvcjojJyxTPWU9PmUudG9TdHJpbmcoMTYpO1guaW5uZXJIVE1MPScmIzEyNzg3NjsnK1s4LDIzLDU3LDcyXS5tYXAoZT0+QSsnMjAlO2xpbmUtaGVpZ2h0OjIwJTtib3R0b206MSU7bGVmdDonK2UrJyUnK1crJzE+JiMxMjc4NzM7PC9kaXY+Jykuam9pbignJykrW1s4MiwxMF0sWzc4LDY1XSxbOTUsMzhdXS5tYXAoKFthLGJdLGkpPT5BKyc1MCU7Ym90dG9tOi0nK2ErJyU7bGVmdDonK2IrJyUnK0orUyg4K2kqMykrJ2RmJytXKycyPiYjOTczMTs8L2Rpdj4nKS5qb2luKCcnKStBKyc0MCU7dG9wOi04MCU7dGV4dC1hbGlnbjpjZW50ZXI7d2lkdGg6MTAwJTtmb250LWZhbWlseTpzYW5zLXNlcmlmJytKKyc5ZGY+MjAyNTwvZGl2Pic7c2V0SW50ZXJ2YWwoZT0+e1YrKztlPScnO2ZvcihsZXQgaT01MDtpPDEwMDtpKyspZSs9QStgJHsyK2klN312aDt0b3A6JHsoaSo1Ny0xMCtWL2kqNyklMTA1fXZoO2xlZnQ6JHsoaSoyMytWL2kqNSklMTA3LTUzfXZ3YCtKK1MoaSU3KzUpK1MoaSU1KzkpKydmJytXK2Ake2klNX0+JiMxMDA1Mjs8L2Rpdj5gO1kuaW5uZXJIVE1MPWV9LDIwKTs8L3NjcmlwdD48L2h0bWw+

1440 символов из 2953, или 1041 из 2198 — ещё полно места!

Музыка

Елочка — есть.

Снежинки — есть.

Не хватает новогодней музыки.

После недолгих раздумий выбор пал на мелодию Carol of the Bells. На online sequensor найден подходящий midi файл с малым количеством нот.

Начнём с генерации ноты фортепиано. Для начала, громкость ноты нелинейна и меняется примерно так:

Подробнее https://xssracademy.com/blog/adsr.html

Во-вторых, нота имеет множество гармоник:

Подробнее https://prosound.ixbt.com/education/spektr-analys.shtml

Гармоники затухают с увеличением частоты. Для упрощения будем учитывать только ноту и 2 гармоники (х2 и х3).

Громкость ноты будет меняться по следующей формуле:

e^{-\frac{j}{2500\cdot D}}\cdot min (1, \frac{j}{250})

Амплитуда ноты

Амплитуда ноты

Достаточно близко к ADSR.

То же в js:

let F = (N, j, D) =>  Math.sin(27.5 * Math.pow(1.059463, N) * j / 3820) * Math.exp(-j / 2500 / D) * Math.min(1, j / 250);

N — номер ноты, j — текущий тик, D — долгота ноты (в долях, кратных 0.25)

Магическое число 3820 получается путём деления частоты дискретизации 24000 на 2 pi: 24000 / 6.2832 = 3820.

27.5 * Math.pow(1.059463, N) — это формула частоты ноты. Частота ноты увеличивается вдвое каждые 12 нот, то есть, зная частоту первой ноты можно вычислить частоты всех нот. 1.059463 — корень 12-й степени из 2.

Для преобразования .mid в более компактный вариант я написал простой скрипт, который генерирует строку из пар символов. Первый символ в каждой паре — номер ноты, второй — длительность и задержка после старта предыдущей ноты. Для компактности я добавил словарь длительностей, поэтому для его нужно будет переделывать под каждый .mid файл.

Код на питоне
import mido import struct   midi_file = mido.MidiFile('sound.mid') notes = [] curr_play = {} time = 0  for i, track in enumerate(midi_file.tracks):     print(f"Дорожка {i}: {track.name if track.name else '<без имени>'}")     for msg in track:         time += msg.time         if msg.type == 'note_on':             if msg.note not in curr_play:                 curr_play[msg.note] = time         elif msg.type == 'note_off':             if msg.note in curr_play:                 start = curr_play[msg.note]                 delta = time - start                 del curr_play[msg.note]                 notes.append([msg.note, start, delta]) notes.sort(key=lambda x: x[1]) print(len(notes)) binary = bytes() prev = 0  # длительности нот m = {1: 0, 2: 1, 6: 2, 8: 3, 12: 4, 30: 5} for e in notes:     note, start, duration = e     start //= 96     duration //= 96     delta = start - prev     prev = start     binary += struct.pack('BB', note - 21, 35 + delta * len(m) + m[duration])  print(binary) print(prev / 96 * 24000)

Получается такая строка

let M = `C$B/C)@*C0B/C)@*C0B/C)@*C0B/C)@*C04%B/C)@*C02%B/C)@*C00%B/C)@*C0/%B/C)@*C04%B/C)@*C02%B/C)@*C00%B/C)@*C0/%B/C)@*C0(%B/C)@*C0&%B/C)@*C0$%B/C)@*C0/%B/C)@*G04%E/G)C*G02%E/G)C*G00%E/G)C*G0/%E/G)C*L04%L/L)J)H)G*2%G/G)E)C)E*0%E/E)G)E)C*/%B/C)@*;//'=)?)@)B)C)E)G)E*C0G//'I)K)L)N)O)Q)S)Q*O0O04%N/O)L*O02%N/O)L*O00%N/O)L*O0/%N/O)L*O04%N/O)L*O02%N/O)L*O00%N/O)L*O0/%N/O)L*O04%N/O)L*O02%N/O)L*O00%N/O)L*O0/%N/O)L*O04%N/O)L*L02%L/L)L)H)G*0%G/G)E)C)E*/%E/E)G)E)C*/%B/C)@*G//'I)K)L)N)O)Q)S)Q*O0G//'I)K)L)N)O)Q)S)Q*O0O04%N/O)L*O02%N/O)L*O00%N/O)L*O0/%N/O)L*S04%Q/S)O*S02%Q/S)O*S00%Q/S)O*S0/(Q/S)O*O0N/O)L*O0N/O)L*O0N/O)L*O0N/O)L*O04(N/O)L*O0N/O)L*O0N/O)L*O0N/O)L,`

В ней много повторов 🙂

Музыкальный плеер

Теперь нужно воспроизвести мелодию, при загрузке страницы это сделать невозможно, только после действия пользователя, например, клика по блоку X, он достаточно большой, чтобы пользователь попал по нему пальцем 🙂

Код плеера на js, минифицирован.

X.onclick = (   a, // это единственный не undefined параметр, событие клика   l, t, i, j, N, I, D, W,  // все undefined   P = 0,   f = new AudioContext /* js позволяет вызвать конструктор без скобок */) => {   X.onclick = null;  // защита от повторных кликов    // t - созданный аудиобуфер размером 2400000 семплов и частотой дискретизации 24 кГц   // l - Float32Array, массив семплов   l = (t = f.createBuffer(1, 24e5, 24e3)).getChannelData(0);    // цикл по всем нотам, M - строка нот   for (i = 0; i < M.length; i++) {     // первый символ - нота, не теряем места и сразу переходим на следующий символ     N = M[i++].charCodeAt();     // второй - длительность и задержка     I = M[i].charCodeAt() - 35;     // длительность берётся из "словаря"     D = [1, 2, 6, 8, 12, 30][I % 6];     // а задержка считается напрямую, для взятого midi она не превышает 2     W = I / 6 | 0;     // момент начала текущей ноты. 6e3 = 6000 = 1/4 от секунды,     // при частоте дискретизации 24кГц     P += W * 6e3;      // генерация ноты     for (j = 0; j < D * 6e3; j++)       // добавляем в буфер ноту и две гармоники       l[P + j] += F(N, j, D) * .35 + F(N + 12, j, D) * .1 + F(N + 24, j, D) * .05   }   // создаём AudioBufferSourceNode, который можно проиграть,   // передаём ему буффер, в который записали семплы   (i = f.createBufferSource()).buffer = t;   // соединяем с выходом   i.connect(f.destination);   // проигрываем с начала   i.start(0); };
Итоговый dataUrl — скопируйте и вставьте в адресную строку вашего браузера

data:text/html;base64,PGh0bWw+PGhlYWQ+PG1ldGEgbmFtZT12aWV3cG9ydCBjb250ZW50PXdpZHRoPWRldmljZS13aWR0aCxpbml0aWFsLXNjYWxlPTEuMD48c3R5bGU+Ym9keXtvdmVyZmxvdzpoaWRkZW47ZGlzcGxheTpmbGV4O2p1c3RpZnktY29udGVudDpjZW50ZXJ9I1h7bWFyZ2luOmF1dG87Zm9udC1zaXplOmNhbGMobWluKDcwdncsNDB2aCkpO2xpbmUtaGVpZ2h0OjFlbTtwb3NpdGlvbjpyZWxhdGl2ZX0jWSwuQXtwb3NpdGlvbjphYnNvbHV0ZX08L3N0eWxlPjwvaGVhZD48Ym9keT48ZGl2IGNsYXNzPUEgc3R5bGU9ei1pbmRleDo5PkJ5IDxhIGhyZWY9aHR0cHM6Ly9yaWdlbGxhYi5ydSB0YXJnZXQ9X2JsYW5rPlJpZ2VsbGFiPC9hPiAyMDI0PGJyPkNsaWNrIEZJUiBmb3IgVGhlIEJlbGxzPC9kaXY+PGRpdiBpZD1ZPjwvZGl2PjxkaXYgaWQ9WD48L2Rpdj48L2JvZHk+PHNjcmlwdD5sZXQgVj0wLEE9JzxkaXYgY2xhc3M9QSBzdHlsZT1mb250LXNpemU6JyxXPSc7ei1pbmRleDonLEo9Jztjb2xvcjojJyxTPWU9PmUudG9TdHJpbmcoMTYpLEY9KE4saixEKT0+TWF0aC5zaW4oMjcuNSpNYXRoLnBvdygxLjA1OTQ2MyxOKSpqLzM4MjApKk1hdGguZXhwKC1qLzI1MDAvRCkqTWF0aC5taW4oMSxqLzI1MCksTT1gQyRCL0MpQCpDMEIvQylAKkMwQi9DKUAqQzBCL0MpQCpDMDQlQi9DKUAqQzAyJUIvQylAKkMwMCVCL0MpQCpDMC8lQi9DKUAqQzA0JUIvQylAKkMwMiVCL0MpQCpDMDAlQi9DKUAqQzAvJUIvQylAKkMwKCVCL0MpQCpDMCYlQi9DKUAqQzAkJUIvQylAKkMwLyVCL0MpQCpHMDQlRS9HKUMqRzAyJUUvRylDKkcwMCVFL0cpQypHMC8lRS9HKUMqTDA0JUwvTClKKUgpRyoyJUcvRylFKUMpRSowJUUvRSlHKUUpQyovJUIvQylAKjsvLyc9KT8pQClCKUMpRSlHKUUqQzBHLy8nSSlLKUwpTilPKVEpUylRKk8wTzA0JU4vTylMKk8wMiVOL08pTCpPMDAlTi9PKUwqTzAvJU4vTylMKk8wNCVOL08pTCpPMDIlTi9PKUwqTzAwJU4vTylMKk8wLyVOL08pTCpPMDQlTi9PKUwqTzAyJU4vTylMKk8wMCVOL08pTCpPMC8lTi9PKUwqTzA0JU4vTylMKkwwMiVML0wpTClIKUcqMCVHL0cpRSlDKUUqLyVFL0UpRylFKUMqLyVCL0MpQCpHLy8nSSlLKUwpTilPKVEpUylRKk8wRy8vJ0kpSylMKU4pTylRKVMpUSpPME8wNCVOL08pTCpPMDIlTi9PKUwqTzAwJU4vTylMKk8wLyVOL08pTCpTMDQlUS9TKU8qUzAyJVEvUylPKlMwMCVRL1MpTypTMC8oUS9TKU8qTzBOL08pTCpPME4vTylMKk8wTi9PKUwqTzBOL08pTCpPMDQoTi9PKUwqTzBOL08pTCpPME4vTylMKk8wTi9PKUwsYDtYLm9uY2xpY2s9KGEsbCx0LGksaixOLEksRCxXLFA9MCxmPW5ldyBBdWRpb0NvbnRleHQpPT57WC5vbmNsaWNrPW51bGw7bD0odD1mLmNyZWF0ZUJ1ZmZlcigxLDI0ZTUsMjRlMykpLmdldENoYW5uZWxEYXRhKDApO2ZvcihpPTA7aTxNLmxlbmd0aDtpKyspe049TVtpKytdLmNoYXJDb2RlQXQoKTtJPU1baV0uY2hhckNvZGVBdCgpLTM1O0Q9WzEsIDIsIDYsIDgsIDEyLCAzMF1bSSU2XTtXPUkvNnwwO1ArPVcqNmUzO2ZvcihqPTA7ajxEKjZlMztqKyspbFtQK2pdKz1GKE4saixEKSouMzUrRihOKzEyLGosRCkqLjErRihOKzI0LGosRCkqLjA1fShpPWYuY3JlYXRlQnVmZmVyU291cmNlKCkpLmJ1ZmZlcj10LGkuY29ubmVjdChmLmRlc3RpbmF0aW9uKSxpLnN0YXJ0KDApfTtYLmlubmVySFRNTD0nJiMxMjc4NzY7JytbOCwyMyw1Nyw3Ml0ubWFwKGU9PkErJzIwJTtsaW5lLWhlaWdodDoyMCU7Ym90dG9tOjElO2xlZnQ6JytlKyclJytXKycxPiYjMTI3ODczOzwvZGl2PicpLmpvaW4oJycpK1tbODIsMTBdLFs3OCw2NV0sWzk1LDM4XV0ubWFwKChbYSxiXSxpKT0+QSsnNTAlO2JvdHRvbTotJythKyclO2xlZnQ6JytiKyclJytKK1MoOCtpKjMpKydkZicrVysnMj4mIzk3MzE7PC9kaXY+Jykuam9pbignJykrQSsnNDAlO3RvcDotODAlO3RleHQtYWxpZ246Y2VudGVyO3dpZHRoOjEwMCU7Zm9udC1mYW1pbHk6c2Fucy1zZXJpZicrSisnOWRmPjIwMjU8L2Rpdj4nO3NldEludGVydmFsKGU9PntWKys7ZT0nJztmb3IobGV0IGk9NTA7aTwxMDA7aSsrKWUrPUErYCR7MitpJTd9dmg7dG9wOiR7KGkqNTctMTArVi9pKjcpJTEwNX12aDtsZWZ0OiR7KGkqMjMrVi9pKjUpJTEwNy01M312d2ArSitTKGklNys1KStTKGklNSs5KSsnZicrVytgJHtpJTV9PiYjMTAwNTI7PC9kaXY+YDtZLmlubmVySFRNTD1lfSwyMCk7PC9zY3JpcHQ+PC9odG1sPg==

2950 символов, почти максимум

И он же в виде QR — кода:

Всех с Новым Годом!

P.S. По какой-то причине Firefox ёлка прилипает к верху, в хромеподобных браузерах (Chrome, Edge, Yandex Browser) всё отлично.

Так как ёлка, подарки и снеговики — unicode символы, то дизайн ёлки меняется от браузера к браузеру, от ОС к ОС. Покажите свою уникальную ёлку близким!


ссылка на оригинал статьи https://habr.com/ru/articles/871116/


Комментарии

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

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