Оригинальное изображение из задач к Yandex Cup 2023
Оригинальное изображение из задач к Yandex Cup 2023

Участие в таких событиях дает неплохой пинок для того, чтобы пощупать даже то, что еще находится на этапе эксперимента в мире JS и не только. Вот и я решил в этом году, все же попробовать решить пару тройку задач. И хочу представить вашему вниманию разбор задачи про экспериментальную фитчу в JS - это стандарт относящийся к декораторам.


Вообще я ранее слышал про то, что обсуждается в сообществе возможность применения декораторов нативно, "как есть", в JS. Такая удобная техника внедрена уже давно, к примеру, в питоне. Ну и конечно же, декораторы, помогут коду быть лучше и в плане транспиляции из TypeScript в JavaScript.

Приступив к решению этой задачи, я абсолютно не представлял нынешнего состояния дел в этом направлении. Потому конечно же, традиционно, потратил больше времени, чем если бы практиковал использование "экспериментальной фичи" в решении практических задач. И так, приступим.

Условие задачи B - "Больше, чем музыка"

B. Больше, чем музыка (20 баллов)

Однажды Цанс Химмер, популярный композитор XII века, известный своими саундтреками к фильмам, понял, что пишет настолько красивую музыку, что просто слушать её недостаточно. Поэтому он решил провести невиданный ранее перформанс — сделать из одного произведения искусства совершенно другое в реальном времени.

Для этого Цанс планирует сыграть своё уже ставшее классическим произведение Time и одновременно создавать картину на огромном экране, опираясь на те звуки, что издаёт его фортепиано.

Ваша задача — разработать такой конвертер, который сможет перевести ноты в цвета палитры OKLCH, чтобы мастер смог реализовать задуманное.

Условие

Цвета в OKLCH имеют следующий вид: "48% 0.27 274 / 1" (L C H / a)

Видимые цвета задаются в следующих диапазонах (подробнее в https://web-standards.ru/articles/oklch-in-css-why-quit-rgb-hsl/#section-1):

  • L меняется от 0 до 100% (обратите внимание, что величина задается в процентах)

  • C меняется от 0 до 0.37

  • H меняется от 0 до 360

  • a меняется от 0 до 1

Необходимо реализовать декораторы @Color и @ColorPlayer, которые свяжут игру нот с цветом.

Рассмотрим на примере декоратор @Color:

@Color("L", "10%") playA(octave)

@Color("С", "0.11") playB(octave)

Данный декоратор задает следующее:

  • при вызове функции playA (имя функции может быть любым) с некоторой октавой octave (от 0 до 8), значение L изменится на L + (octave - 3) * 10 (так как L задается в процентах).

  • при вызове функции playB с некоторой октавой octave (от 0 до 8), значение C изменится на C + (octave - 3) * 0.11.

Т.е. первый параметр задает имя параметра, а второй параметр задает коэффициент изменения октавы относительно третьей октавы (коэффициент всегда положительный). Важно учитывать границы диапазонов каждого значения, заданные выше. Получившееся число округляем до двух знаков, используя .toFixed(2).

Декоратор @ColorPlayer задает начальное значение и колбэк, что надо вызвать на каждое нажатие клавиши (вызов play*) и вначале перед всеми запусками с начальным цветом.

Полный пример использования данных декораторов:

@ColorPlayer("48% 0.27 274 / 1", (color) => console.log(color))
class Piano {
    @Color("L", "10%")
    playA(octave) {}
@Color("C", "0.15")
playB(octave) {}

@Color("H", "0.03")
playC(octave) {}

@Color("L", "10%")
playD(octave) {}

@Color("C", "0.15")
playE(octave) {}

@Color("H", "0.1")
playF(octave) {}

@Color("a", "0.4")
playG(octave) {}

}
const piano = new Piano(); // console.log("48.00% 0.27 274.00 / 1.00")
piano.playA(2) // console.log("38.00% 0.27 274.00 / 1.00")
piano.playA(4) // console.log("48.00% 0.27 274.00 / 1.00")
piano.playB(3) // console.log("48.00% 0.27 274.00 / 1.00")

В качестве решения надо предоставить в файл с двумя реализованными декораторами:

export function ColorPlayer(initialColor, cb) {
}
export function Color(component, coeff) {
}

Важно: для реализации декораторов необходимо использовать спецификацию 2023 года https://github.com/tc39/proposal-decorators Для запуска будет использоваться babel. На русском ознакомиться с декораторами можно тут https://habr.com/ru/companies/otus/articles/531664/, но обратите внимание, что надо использовать спецификацию и синтаксис 2023 года по ссылке выше.

Подготовка рабочего окружения

Так как я хотел проверить, как работает экспериментальная фича декораторов, "как есть", привожу ниже настройки, которые использовал для того, чтобы заставить работать в правильном виде декораторы и решить эту задачу получив 20 из 20 баллов.

package.json
{
  "name": "project",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "babel src -d lib",
    "start": "babel src -d lib && node lib/index.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@babel/cli": "^7.23.0",
    "@babel/core": "^7.23.2",
    "@babel/plugin-proposal-decorators": "^7.23.2",
    "@babel/preset-env": "^7.23.2"
  }
}

babel.config.json
{
    "presets": [
        "@babel/preset-env"
    ],
    "plugins": [
        [
            "@babel/plugin-proposal-decorators",
            {
               "version": "2023-05"
            }
        ]
    ]
}

Изучение информации по декораторам

Для начала, нужно было найти примеры реализации декоратора, как для класса, так и для его методов. Привожу ссылки ниже:

Реализация парсинга цвета в нотации (L C H / a)

Данная подзадача, необходима для облегчения оперирования со строкой данного формата и для уменьшения повторяемости кода. По этой причине, я решил реализовать класс InitialColor. Но в задаче, так же нужно было учесть границы, за которые может выйти число, но не выдавать при этом ошибку, а просто взять либо нижнюю границу, либо верхнюю, в зависимости от того, куда устремилось новое число. В этом хорошо помогают геттеры и сеттеры для свойства класса. К примеру, ниже, приведен код геттера и сеттера для свойства L класса InitialColor:

...
get L() {
    return this.Lv
}

set L(v) {
    if (v < 0 || v > 100) {
        this.Lv = Math.min(100, Math.max(0, v))
    } else {
        this.Lv = v
    }
}
...

Для контроля выхода значений - использовал довольно простой и действенный трюк с min/max.

Math.min(100 /* число_верхней_границы */, Math.max(0 /* число_нижней_границы */, v))

Теперь, нужно в конструкторе класса, разделить значения из строки довольно простым способом.

class InitialColor {
    constructor(value) {
        const [Lv, Cv, Hv, ,av] = value.split(' ')
        this.Lv = parseFloat(Lv)
        this.Cv = parseFloat(Cv)
        this.Hv = parseFloat(Hv)
        this.av = parseFloat(av)
    }
  
    ...
}

И еще необходимо реализовать удобный и понятный метод, для вывода значения класса в строку. Для этого, подходит перегрузка метода toString().

class InitialColor {
    // ...
    toString() {
        return `${this.L.toFixed(2)}% ${this.C.toFixed(2)} ${this.H.toFixed(2)} / ${this.a.toFixed(2)}`
    }
}

После реализации данного функционала, мы готовы пойти дальше.

Декоратор ColorPlayer

Для успешного использования цвета инициализации и функции логирования, внутри декоратора Color, нужно сделать доступными данные, о переданном цвете и функции логирования, в декораторе класса ColorPlayer. Нам нужно реализовать установку свойств, в оборачиваемый класс. Проделать этот трюк, можно лишь унаследовавшись от оборачиваемого класса. По принципам паттерна декоратор, мы можем только расширить функционал, но не изменить существующий, так сказать декорировать, что позволит убрать декоратор, без вреда функционалу исходного объекта. Привожу реализацию декоратора ColorPlayer:

export function ColorPlayer(initialColor /*цвет*/, cb /*функция логирования*/) {
    // ...
    return function decorator(target, { kind, addInitializer }) {
        if (kind === 'class') {
            // Возвращаем новый класс, унаследованный от target
            return class extends target {
                cb = cb // инициализируем свойство с колбеком логирования
                initialColor = new InitialColor(initialColor) // инициализируем экземпляр класса цвета
                constructor() {
                    super(...arguments)
                    // Производим логирование первоначального значения цвета (требуется по условию)
                    cb(new InitialColor(initialColor).toString())
                }
            }
        }
    }
}

Для вывода информации об инициирующих параметрах, производим вызов функции логирования cb с использованием значения свойства initialColor (данное действие, требуется по условию и лишь однократно, для каждого нового инстанса класса):

cb(new InitialColor(initialColor).toString())

Декоратор Color

В этом декораторе, нам необходимо произвести операцию над одним из свойств initialColor, который благодаря декоратору ColorPlayer, доступен в инстансе класса. Привожу код ниже:

export function Color(component, coeff) {
    return function (fn, { kind }) {
        if (kind === 'method') {
            return function (octave) {
                this.initialColor[component] += ((parseFloat(octave) - 3) * parseFloat(coeff))
                this.cb(this.initialColor.toString())
                fn.call(this, octave)
            }
        }
        return fn
    }
}

По условию, в задаче видно, что нам нужно уменьшать значение октавы на 3 и умножать на коэффициент. Так как в реализации InitialColor класса присутствуют сеттеры, внутри логики которых можно обрабатывать выходы за границы, то мы можем позволить себе, производить модификацию значения, в одну строку, как ниже:

this.initialColor[component] += ((parseFloat(octave) - 3) * parseFloat(coeff))

А так же, по условию задачи, требовалось вывести значение initialColor, используя функцию cb , а также не забыть вызвать, само оборачиваемое свойство, через fn.call(this, octave).

Ну вот собственно и все, что нужно было проделать, для того, чтобы получить решение задачи на 20 из 20 баллов.

Заключение

В данном заключении, осталось выложить полный код решения и я его привожу ниже:

Полный код реализации декороаторв ColorPlayer и Color (decorators.js)
export function ColorPlayer(initialColor, cb) {
    class InitialColor {
        constructor(value) {
            const [Lv, Cv, Hv, ,av] = value.split(' ')
            this.Lv = parseFloat(Lv)
            this.Cv = parseFloat(Cv)
            this.Hv = parseFloat(Hv)
            this.av = parseFloat(av)
        }

        get L() {
            return this.Lv
        }

        set L(v) {
            if (v < 0 || v > 100) {
                this.Lv = Math.min(100, Math.max(0, v))
            } else {
                this.Lv = v
            }
        }

        get C() {
            return this.Cv
        }

        set C(v) {
            if (v < 0 || v > 0.37) {
                this.Cv = Math.min(0.37, Math.max(0, v))
            } else {
                this.Cv = v
            }
        }

        get H() {
            return this.Hv
        }

        set H(v) {
            if (v < 0 || v > 360) {
                this.Hv = Math.min(360, Math.max(0, v))
            } else {
                this.Hv = v
            }
        }

        get a() {
            return this.av
        }

        set a(v) {
            if (v < 0 || v > 1) {
                this.av = Math.min(1, Math.max(0, v))
            } else {
                this.av = v
            }
        }

        toString() {
            return `${this.L.toFixed(2)}% ${this.C.toFixed(2)} ${this.H.toFixed(2)} / ${this.a.toFixed(2)}`
        }
    }
    return function decorator(target, { kind, addInitializer }) {
        if (kind === 'class') {
            return class extends target {
                cb = cb
                initialColor = new InitialColor(initialColor)
                constructor() {
                    super(...arguments)
                    cb(new InitialColor(initialColor).toString())
                }
            }
        }
    }
}

export function Color(component, coeff) {
    return function (fn, { kind }) {
        if (kind === 'method') {
            return function (octave) {
                this.initialColor[component] += ((parseFloat(octave) - 3) * parseFloat(coeff))
                this.cb(this.initialColor.toString())
                fn.call(this, octave)
            }
        }
        return fn
    }
}

Полный код тестового скрипта для проверки функционала декораторов (index.js)
import { ColorPlayer, Color } from './decorators'

@ColorPlayer("48% 0.27 274 / 1", (color) => console.log(color))

class Piano {
    @Color("L", "10%")
    playA(octave) {
        console.log("A" + octave);
    }

    @Color("C", "0.15")
    playB(octave) {
        console.log("B" + octave)
    }

    @Color("H", "0.03")
    playC(octave) {
        console.log("C" + octave);
    }

    @Color("L", "10%")
    playD(octave) { }

    @Color("C", "0.15")
    playE(octave) { }

    @Color("H", "0.1")
    playF(octave) { }

    @Color("a", "0.4")
    playG(octave) { }
}

const piano = new Piano(); // console.log("48.00% 0.27 274.00 / 1.00")
piano.playA(2) // console.log("38.00% 0.27 274.00 / 1.00")
piano.playA(4) // console.log("48.00% 0.27 274.00 / 1.00")
piano.playB(3) // console.log("48.00% 0.27 274.00 / 1.00")
console.log('-------------------------------------')
const piano2 = new Piano(); // console.log("48.00% 0.27 274.00 / 1.00")
piano2.playA(2) // console.log("38.00% 0.27 274.00 / 1.00")
piano2.playA(4) // console.log("48.00% 0.27 274.00 / 1.00")
piano2.playB(3) // console.log("48.00% 0.27 274.00 / 1.00")

Так, я пощупал текущую реализацию декораторов и буду с удовольствием наблюдать за дальнейшими событиями и развитием декораторов в JS. Отдельное спасибо Яндексу за то, что вдохновляет на изучение чего-то нового. Надеюсь, мой разбор решения этой задачи, будет полезен.

P.S.: А для тех, кто хочет поиграть с кодом онлайн, привожу ссылку на онлайн редактор с кодом из этого поста. Для того чтобы увидеть результат выполнения скрипта, вам нужно открыть консоль в браузере. Должно получиться что-то похожее, на изображении ниже.

Окно кода, транспиллера и консоли
Окно кода, транспиллера и консоли

Комментарии (0)