Документация Engee

Наблюдаемые объекты

Страница в процессе перевода.

Взаимодействия и анимации в Makie обрабатываются с помощью Observables.jl. Observable — это объект-контейнер, хранимое значение которого можно обновлять в интерактивном режиме. Вы можете создавать функции, которые выполняются при каждом изменении наблюдаемого объекта. Можно также создавать наблюдаемые объекты, значения которых обновляются при изменении других наблюдаемых объектов. Таким образом можно легко создавать динамичные интерактивные визуализации.

На этой странице вы узнаете, как работает конвейер объектов Observable и система взаимодействия на основе событий. Помимо этого, есть видеоруководство по созданию интерактивных визуализаций (или анимаций) с помощью Makie.jl и системы Observable.

Структура объекта Observable

Observable — это объект, значение которого можно обновлять в интерактивном режиме. Начнем с создания такого объекта.

using GLMakie, Makie

x = Observable(0.0)

У каждого объекта Observable есть параметр типа, который определяет, какие объекты могут в нем храниться. Если вы используете показанный выше способ создания, параметром типа будет тип аргумента. Имейте в виду, что иногда может потребоваться более широкий параметрический тип, поскольку позднее в Observable будут помещаться объекты других типов. Например, можно написать такой код:

x2 = Observable{Real}(0.0)
x3 = Observable{Any}(0.0)

Это часто происходит в случае с атрибутами, которые могут иметь разные формы. Например, цвет может быть задан как :red или RGB(1,0,0).

Инициирование изменения

Значение наблюдаемого объекта изменяется посредством нотации пустого индексирования.

x[] = 3.34

Это не особо интересно. Однако регистрируемые объекты позволяют регистрировать функции, которые выполняются при каждом изменении содержимого этих объектов.

Одна из таких функций — on. Давайте зарегистрируем что-нибудь в наблюдаемом объекте x и изменим значение x.

on(x) do x
    println("New value of x is $x")
end

x[] = 5.0

Note Если объект Observable был обновлен посредством синтаксиса выполнения на месте (например, img[] .= colorant"red"), необходимо вручную выполнить notify(img), чтобы вызвать функцию.

Note Все зарегистрированные в Observable функции выполняются синхронно в порядке регистрации. Это означает, что, если два наблюдаемых объекта изменяются по очереди, все эффекты изменения первого из них произойдут до изменения второго.

Получить доступ к значению Observable можно двумя способами. Можно использовать синтаксис индексирования или функцию to_value.

value = x[]
value = to_value(x)

Преимущество функции to_value в том, что ее можно применять как к наблюдаемым объектам, так и к обычным значениям. В последнем случае to_value просто возвращает исходное значение, например identity.

Объединение объектов Observable в цепочку с помощью lift

Вы можете создать наблюдаемый объект, зависящий от другого наблюдаемого объекта, используя lift. Первый аргумент lift должен быть функцией, которая вычисляет значение выходного наблюдаемого объекта с учетом значений входных наблюдаемых объектов.

f(x) = x^2
y = lift(f, x)

В результате при каждом изменении x производный объект Observable y сразу будет получать значение f(x). В свою очередь, изменение y может вызвать обновление других связанных наблюдаемых объектов. Давайте привяжем еще один наблюдаемый объект и обновим x.

z = lift(y) do y
    -y
end

x[] = 10.0

@show x[]
@show y[]
@show z[]

При изменении x это также произойдет с y, а затем с z.

Однако следует отметить, что изменение y не приводит к изменению x. Нет никакой гарантии, что связанные в цепочку наблюдаемые объекты будут всегда синхронизированы, поскольку они могут изменяться в разных местах и даже в обход механизма инициации изменений.

y[] = 20.0

@show x[]
@show y[]
@show z[]

Макрос-сокращение для lift

При использовании lift может быть утомительно указывать каждый участвующий в цепочке объект Observable по крайней мере три раза: один раз как аргумент lift, еще один раз как аргумент замыкания в первом аргументе и по крайней мере один раз внутри замыкания:

x = Observable(rand(100))
y = Observable(rand(100))
z = lift((x, y) -> x .+ y, x, y)

Чтобы обойти эту проблему, можно использовать макрос @lift. Вы просто записываете операцию, которую нужно выполнить с объединяемыми посредством lift объектами Observable, и добавляете перед каждой переменной Observable знак доллара, $. Макрос объединяет все найденные переменные Observable и заключает все выражение в замыкание. Вот эквивалент приведенному выше оператору с использованием @lift:

z = @lift($x .+ $y)

Это также работает с многострочными операторами и индексированием кортежей или массивов.

multiline_node = @lift begin
    a = $x[1:50] .* $y[51:100]
    b = sum($z)
    a .- b
end

Если наблюдаемый объект, на который нужно сослаться, является результатом вычисления какого-либо выражения, просто используйте $ со скобками вокруг этого выражения.

container = (x = Observable(1), y = Observable(2))

@lift($(container.x) + $(container.y))

Проблемы с синхронными обновлениями

Одной из очень распространенных проблем конвейера на основе нескольких наблюдаемых объектов является то, что изменять наблюдаемые объекты можно только по одному. Теоретически каждое изменение наблюдаемого объекта немедленно активирует его прослушиватели. Если функция зависит от двух наблюдаемых объектов или более, изменение одного из них сразу после другого приведет к многократному вызову функции, что зачастую нежелательно.

В этом примере определяются два наблюдаемых объекта, к которым привязывается третий:

xs = Observable(1:10)
ys = Observable(rand(10))

zs = @lift($xs .+ $ys)

Теперь давайте обновим и xs, и ys.

xs[] = 2:11
ys[] = rand(10)

В результате объект zs был инициирован дважды, хотя на самом деле планировалось только одно обновление данных. Однако такая двойная инициация — это лишь часть проблемы.

И у xs, и у ys в этом примере длина равна 10, поэтому их все равно можно было бы сложить без проблем. Если же мы бы хотели добавить значения к объектам xs и ys, то в момент изменения длины одного из них базовая функция zs выдала бы ошибку из-за несоответствия формы. Иногда единственный способ исправить эту ситуацию — изменить содержимое одного наблюдаемого объекта, не активируя его прослушиватели, а затем активировать второй.

xs.val = 1:11 # изменение без активации прослушивателей
ys[] = rand(11) # активируем прослушиватели ys (в данном случае те же, что у xs)

Используйте этот прием с осторожностью, так как он усложняет код и может затруднить его понимание. Кроме того, он работает только в том случае, если все прослушиватели можно правильно активировать. Например, если бы другой наблюдаемый объект прослушивал только xs, мы бы не смогли правильно обновить его в приведенном выше примере. Часто проблем в связи с изменением длины можно избежать, используя массивы контейнеров, такие как Point2f или Vec3f, вместо синхронизации двух или трех наблюдаемых объектов, содержащих векторы отдельных элементов, вручную.