Поздравляю Хабр и Хаброжителей с Новым 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 =
// ёлка
'🎄' +
// координаты x подарков
[8, 23, 57, 72]
.map(e => `<div class=A style=font-size:20%;line-height:20%;`
+`bottom:1%;left:${e}%;z-index:1>🎁</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>☃</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 =
'🎄' +
[8, 23, 57, 72]
.map(e => A + '20%;line-height:20%;bottom:1%;left:' + e + '%' + W + '1>🎁</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>☃</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}>❄</div>`;
Y.innerHTML = e
}, 20);
Что здесь происходит:
Каждые 20 мс вызывается функция, увеличивающая V на единицу и обновляющая все снежинки на поле. Снежинка - это ❄. Каждая снежинка в своём блоке 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 файл с малым количеством нот.
Начнём с генерации ноты фортепиано. Для начала, громкость ноты нелинейна и меняется примерно так:
Во-вторых, нота имеет множество гармоник:
Гармоники затухают с увеличением частоты. Для упрощения будем учитывать только ноту и 2 гармоники (х2 и х3).
Громкость ноты будет меняться по следующей формуле:
Достаточно близко к 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 символы, то дизайн ёлки меняется от браузера к браузеру, от ОС к ОС. Покажите свою уникальную ёлку близким!
Комментарии (7)
checkpoint
01.01.2025 09:38До распространения вирусов через QR-коды осталось 3...2...1...
RigelGL Автор
01.01.2025 09:38Отличная идея, учитывая что через JS можно запросить FullScreen и рисовать на нём что угодно, например синий экран смерти или что-то подобное :)))
moroz69off
01.01.2025 09:38Курсор поменяйте, молодой человек.
Не ленитесь "делать красиво".
(не елке курсор не намекает на клик, но должен...)
vanxant
Добавьте height: 100% на body
RigelGL Автор
О, спасибо. Обновлю QR и код, как будет время.