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

События

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

Интерактивные бэкенды, такие как GLMakie и WGLMakie, передают события в наблюдаемые объекты в структуре Events. Реагируя на них, можно создавать пользовательские взаимодействия.

Note Если вы еще не знакомы с наблюдаемыми объектами, сначала изучите раздел Наблюдаемые объекты.

Наблюдаемые объекты с приоритетом

С помощью наблюдаемых объектов можно также добавлять обратные вызовы с приоритетом. Это позволяет прослушивателям останавливать выполнение прослушивателей с более низким приоритетом, возвращая Consume(true) или просто Consume(). Любое другое возвращаемое значение будет обработано как Consume(false), что означает, что прослушиватель не будет блокировать другие прослушиватели.

Чтобы понять, как работает PriorityObserable, попробуйте выполнить этот пример:

using Makie

po = Observable(0)

println("With low priority listener:")
on(po, priority = -1) do x
    println("Low priority: $x")
end
po[] = 1

println("\nWith medium priority listener:")
on(po, priority = 0) do x
    println("Medium blocking priority: $x")
    return Consume()
end
po[] = 2

println("\nWith high priority listener:")
on(po, priority = 1) do x
    println("High Priority: $x")
    return Consume(false)
end
po[] = 3

Если подключен только первый прослушиватель, выводится Low priority: 1. В этом случае поведение такое же, как у обычных наблюдаемых объектов. Второй добавляемый нами прослушиватель является блокирующим, так как он возвращает Consume(true). Так как у него более высокий приоритет, чем у первого, будет активирован только второй прослушиватель. В результате мы получаем Medium blocking priority: 2. Третий прослушиватель неблокирующий и имеет еще более высокий приоритет. Поэтому мы получаем результат как от третьего, так и от второго прослушивателя.

Структура Events

События от бэкенда хранятся в наблюдаемых объектах в структуре Events. К ней можно получить доступ посредством events(x), где x — это объект Figure, Axis, Axis3, LScene, FigureAxisPlot или Scene. Независимо от используемого источника всегда получается одна и та же структура. Это также верно и при доступе напрямую посредством scene.events. Структура содержит следующие поля.

  • window_area::Observable{Rect2i}: содержит текущий размер окна в пикселях.

  • window_dpi::Observable{Float64}: содержит разрешение DPI окна.

  • window_open::Observable{Bool}: содержит true, пока окно открыто.

  • hasfocus::Observable{Bool}: содержит true, если окно имеет фокус (находится на переднем плане).

  • entered_window::Observable{Bool}: содержит true, если указатель мыши находится в пределах окна (независимо от наличия фокуса), то есть наведен на окно.

  • mousebutton::Observable{MouseButtonEvent}: содержит последнее событие MouseButtonEvent с соответствующими button::Mouse.Button и action::Mouse.Action.

  • mousebuttonstate::Set{Mouse.Button}: содержит все нажатые в данный момент кнопки мыши.

  • mouseposition::Observable{NTuple{2, Float64}}: содержит последнюю позицию курсора в пиксельных единицах относительно корневой сцены или окна.

  • scroll::Observable{NTuple{2, Float64}}: содержит последнее смещение прокрутки.

  • keyboardbutton::Observable{KeyEvent}: содержит последнее событие KeyEvent с соответствующими key::Keyboard.Button и action::Keyboard.Action.

  • keyboardstate::Observable{Keyboard.Button}: содержит все нажатые в данный момент клавиши.

  • unicode_input::Observable{Char}: содержит последний введенный символ.

  • dropped_files::Observable{Vector{String}}: содержит список путей к файлам коллекции, перетащенным в окно.

  • tick::Observable{Makie.Tick}: содержит последний объект Makie.Tick. Объект tick создается для каждого отрисовываемого кадра, то есть через регулярные интервалы для интерактивных рисунков при сохранении изображения или при использовании record().

Взаимодействие с помощью мыши

Есть три события мыши, на которые можно реагировать:

  • events.mousebutton, которое содержит MouseButtonEvent с соответствующими button и action;

  • events.mouseposition, которое содержит текущую позицию курсора относительно окна в виде NTuple{2, Float64} в пикселях;

  • events.scroll, которое содержит последнюю прокрутку в виде NTuple{2, Float64}.

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

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

using GLMakie
GLMakie.activate!() # hide

points = Observable(Point2f[])

scene = Scene(camera = campixel!)
linesegments!(scene, points, color = :black)
scatter!(scene, points, color = :gray)

on(events(scene).mousebutton) do event
    if event.button == Mouse.left
        if event.action == Mouse.press || event.action == Mouse.release
            mp = events(scene).mouseposition[]
            push!(points[], mp)
            notify(points)
        end
    end
end

scene

В таких простых случаях не требуется использовать приоритет для регистрации обратного вызова mousebutton. Приоритет и Consume() становятся важными только тогда, когда несколько взаимодействий реагируют на один и тот же источник и должны происходить в определенном порядке или блокировать друг друга.

Чтобы сделать этот пример более наглядным, давайте будем обновлять вторую точку (конец линии) при каждом перемещении мыши. Для этого следует установить начальную и конечную точки при Mouse.press и обновлять конечную точку при изменении events(scene).mouseposition, пока кнопка нажата.

using GLMakie
GLMakie.activate!() # hide

points = Observable(Point2f[])

scene = Scene(camera = campixel!)
linesegments!(scene, points, color = :black)
scatter!(scene, points, color = :gray)

on(events(scene).mousebutton) do event
    if event.button == Mouse.left && event.action == Mouse.press
        mp = events(scene).mouseposition[]
        push!(points[], mp, mp)
        notify(points)
    end
end

on(events(scene).mouseposition) do mp
    mb = events(scene).mousebutton[]
    if mb.button == Mouse.left && (mb.action == Mouse.press || mb.action == Mouse.repeat)
        points[][end] = mp
        notify(points)
    end
end

scene

Чтобы продемонстрировать пример использования scroll, давайте будем циклически перебирать цвета с помощью колеса прокрутки. scroll содержит два числа с плавающей запятой, описывающих последнее изменение в направлениях x и y, обычно +1 или -1.

using GLMakie
GLMakie.activate!() # hide

colors = to_colormap(:cyclic_mrybm_35_75_c68_n256)
idx = Observable(1)
color = map(i -> colors[mod1(i, length(colors))], idx)
points = Observable(Point2f[])

scene = Scene(camera = campixel!)
linesegments!(scene, points, color = color)
scatter!(scene, points, color = :gray, strokecolor = color, strokewidth = 1)

on(events(scene).mousebutton) do event
    if event.button == Mouse.left && event.action == Mouse.press
        mp = events(scene).mouseposition[]
        push!(points[], mp, mp)
        notify(points)
    end
end

on(events(scene).mouseposition) do mp
    mb = events(scene).mousebutton[]
    if mb.button == Mouse.left && (mb.action == Mouse.press || mb.action == Mouse.repeat)
        points[][end] = mp
        notify(points)
    end
end

on(events(scene).scroll) do (dx, dy)
    idx[] = idx[] + sign(dy)
end

scene

Взаимодействие с помощью клавиатуры

С помощью events.keyboardbutton можно реагировать на событие KeyEvent, а с помощью events.unicode_input — на ввод определенных символов. Как и в случае с взаимодействием с помощью мыши, также имеется объект events.keyboardstate, содержащий все нажатые в данный момент клавиши.

Продолжим пример. Пока мы можем добавлять точки щелчками мыши и менять цвет прокруткой. Нам не хватает возможности удалять точки. Давайте реализуем ее с помощью событий клавиатуры. Здесь мы выбираем backspace для удаления точек с конца и delete для удаления с начала.

using GLMakie
GLMakie.activate!() # hide

colors = to_colormap(:cyclic_mrybm_35_75_c68_n256)
idx = Observable(1)
color = map(i -> colors[mod1(i, length(colors))], idx)
points = Observable(Point2f[])

scene = Scene(camera = campixel!)
linesegments!(scene, points, color = color)
scatter!(scene, points, color = :gray, strokecolor = color, strokewidth = 1)

on(events(scene).mousebutton) do event
    if event.button == Mouse.left && event.action == Mouse.press
        mp = events(scene).mouseposition[]
        push!(points[], mp, mp)
        notify(points)
    end
end

on(events(scene).mouseposition) do mp
    mb = events(scene).mousebutton[]
    if mb.button == Mouse.left && (mb.action == Mouse.press || mb.action == Mouse.repeat)
        points[][end] = mp
        notify(points)
    end
end

on(events(scene).scroll) do (dx, dy)
    idx[] = idx[] + sign(dy)
end

on(events(scene).keyboardbutton) do event
    if event.action == Keyboard.press || event.action == Keyboard.repeat
        length(points[]) > 1 || return nothing
        if event.key == Keyboard.backspace
            pop!(points[])
            pop!(points[])
            notify(points)
        elseif event.key == Keyboard.delete
            popfirst!(points[])
            popfirst!(points[])
            notify(points)
        end
    end
end

scene

Выбор точек

В Makie есть функция pick(x[, position = events(x).mouseposition[]]) для отображения графика в определенной позиции, где x может быть объектом Figure, Axis, FigureAxisPlot или Scene. Эта функция возвращает примитивный график и индекс. Примитивные графики — это базовые графики, которые можно отрисовывать в бэкендах:

  • scatter

  • text

  • lines

  • linesegments

  • mesh

  • meshscatter

  • surface

  • volume

  • voxels

  • image

  • heatmap

Все остальные графики так или иначе строятся на их основе. Например, для fig, ax, p = scatterlines(rand(10)) примитивными графиками в p.plots являются Lines и Scatter.

Возвращаемый pick() индекс связан с основным входом соответствующего примитивного графика.

  • Для scatter и meshscatter это индекс позиций, заданных для графика.

  • Для text это индекс в объединенном массиве символов.

  • Для lines и linesegments это позиция конца выбранного отрезка.

  • Для image, heatmap и surface это линейный индекс в аргументе матрицы графика (т. е. заданное изображение, значение или матрица z-значений), который находится ближе всего к выбранной позиции.

  • Для voxels это линейный индекс в заданном трехмерном массиве.

  • Для mesh это наибольший индекс вершины выбранной треугольной грани.

  • Для volume это всегда 0.

Давайте в качестве примера реализуем добавление, перемещение и удаление маркеров рассеяния. Мы могли бы реализовать добавление и удаление с помощью нажатий левой и правой кнопок мыши, однако при этом будут переопределены существующие взаимодействия с осями. Чтобы избежать этого, мы реализуем добавление посредством a + left click и удаление посредством d + left click. Поскольку это более сложные комбинации, нам нужно, чтобы в Makie сначала проверялось их нажатие и в случае отрицательного результата происходило обычное взаимодействие с осями. Это означает, что данные взаимодействия должны иметь более высокий приоритет, чем взаимодействия по умолчанию, и при выполнении условия производили блокировку.

Чтобы оценить приоритет существующего взаимодействия с осью, можно проверить Observables.listeners(events(fig).mousebutton) после создания Axis. В результате будут показаны зарегистрированные обратные вызовы с их приоритетом. Взаимодействие с наибольшим числом (typemax(Int)) имеет максимальный приоритет, и его можно игнорировать. (Таким образом поддерживается актуальное состояние events(fig).mousebuttonstate.) Остается приоритет priority = 1.

Чтобы правильно разместить новый маркер, нам также потребуется получить положение мыши в единицах оси. В Makie есть функция для этой цели: mouseposition([scene = hovered_scene()]). Существует также вспомогательная функция для определения положения мыши в пиксельном пространстве относительно определенной сцены mouseposition_px([scene = hovered_scene()]). Оба эти положения обычно отличаются от events.mouseposition, которое всегда измеряется в пикселях и всегда рассчитывается на основе всего окна.

Наконец, для удаления точки нам нужно выяснить, над каким именно маркером рассеяния находится курсор. Это можно сделать с помощью функции pick(). Как упоминалось ранее, pick(ax) возвращает график и (для графика рассеяния) индекс в массиве позиций, соответствующий маркеру. С помощью этого теперь можно настроить добавление и удаление маркеров.

using GLMakie

positions = Observable(rand(Point2f, 10))

fig, ax, p = scatter(positions)

on(events(fig).mousebutton, priority = 2) do event
    if event.button == Mouse.left && event.action == Mouse.press
        if Keyboard.d in events(fig).keyboardstate
            # Удаляем маркер
            plt, i = pick(fig)
            if plt == p
                deleteat!(positions[], i)
                notify(positions)
                return Consume(true)
            end
        elseif Keyboard.a in events(fig).keyboardstate
            # Добавляем маркер
            push!(positions[], mouseposition(ax))
            notify(positions)
            return Consume(true)
        end
    end
    return Consume(false)
end

fig

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

using GLMakie

positions = Observable(rand(Point2f, 10))
dragging = false
idx = 1

fig, ax, p = scatter(positions)

on(events(fig).mousebutton, priority = 2) do event
    global dragging, idx
    if event.button == Mouse.left
        if event.action == Mouse.press
            plt, i = pick(fig)
            if Keyboard.d in events(fig).keyboardstate && plt == p
                # Удаляем маркер
                deleteat!(positions[], i)
                notify(positions)
                return Consume(true)
            elseif Keyboard.a in events(fig).keyboardstate
                # Добавляем маркер
                push!(positions[], mouseposition(ax))
                notify(positions)
                return Consume(true)
            else
                # Инициируем перетаскивание
                dragging = plt == p
                idx = i
                return Consume(dragging)
            end
        elseif event.action == Mouse.release
            # Завершаем перетаскивание
            dragging = false
            return Consume(false)
        end
    end
    return Consume(false)
end

on(events(fig).mouseposition, priority = 2) do mp
    if dragging
        positions[][idx] = mouseposition(ax)
        notify(positions)
        return Consume(true)
    end
    return Consume(false)
end

fig

Существует несколько различных методов и функций, связанных с pick. Базовый метод pick(scene, pos) выбирает конкретную точку. Для небольших маркеров или тонких линий может вместо этого потребоваться выбрать ближайший элемент графика в заданном диапазоне. Это можно сделать с помощью pick(scene, position, range). Можно также получить все графики и индексы в диапазоне, отсортированные по расстоянию, с помощью pick_sorted(scene, position, range). Это может быть полезно, если нужно отфильтровать определенные графики, например фон Axis.

Если просто требуется узнать, находится ли курсор на определенном графике или наборе графиков, можно использовать mouseover(scene, plots...). При этом происходит вызов Makie.flatten_plots(plots) для разбиения всех графиков на примитивные графики и проверки относительно pick. Чтобы продолжить использовать выходные данные pick, можно воспользоваться функцией onpick(f, scene, plots...; range=1), которая выполняет эту проверку и вызывает f(plot, index), если она успешна.

Функция ispressed

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

hotkey = Keyboard.a
on(events(fig).keyboardbutton) do event
    if event.key == hotkey
        ...
    end
end

Это позволит изменить hotkey на любую другую клавишу, не меняя функцию обратного вызова. Проблема в том, что при этом вы ограничены одной клавишей. Чтобы переключиться на сочетание наподобие CTRL + A, все равно придется заменить обратный вызов. Функция ispressed() решает эту задачу. Вам достаточно заменить сравнение:

hotkey = Keyboard.a
on(events(fig).keyboardbutton) do event
    if ispressed(fig, hotkey)
        ...
    end
end

В этом случае hotkey может быть одним из следующих объектов:

  • значением типа Bool, которое возвращается напрямую;

  • одной клавишей или кнопкой мыши;

  • объектом Tuple, Vector или Set, содержащим клавиши и кнопки мыши, которые должны быть нажаты;

  • логическим выражением из клавиш и кнопок мыши с операторами !, & и |. Каждая клавиша проверятся отдельно, а результат объединяется в соответствии с выражением.

Кроме того, вы можете заключить любой из перечисленных выше объектов в Exclusively, чтобы отбросить совпадения с дополнительными нажатыми кнопками. Все эти варианты не зависят от очередности. Вот ряд примеров.

  • hotkey = Mouse.left соответствует любому состоянию с нажатой левой кнопкой мыши.

  • hotkey = (Keyboard.left_control, Keyboard.a) соответствует любому состоянию с нажатыми левой клавишей CTRL и A.

  • hotkey = ExclusivelyKeyboard.left_control, Keyboard.a соответствует только нажатым левой клавише CTRL и A.

  • hotkey = Keyboard.left_control & Keyboard.a эквивалентно (Keyboard.left_control, Keyboard.a)

  • hotkey = (Keyboard.left_control | Keyboard.right_control) & Keyboard.a соответствует нажатым левой или правой клавише CTRL и A.

Обратите внимание, что при использовании ispressed выше условие будет выполняться для событий нажатия и повтора. Выбрать только одно из этих событий можно путем проверки event.action. Для реагирования на событие отпускания необходимо передать event.key или event.button в качестве третьего аргумента в ispressed(fig, hotkey, event.key). В результате функция ispressed предполагает, что клавиша или кнопка нажата, если она является частью сочетания быстрого вызова.

Интерактивные виджеты

В Makie есть несколько полезных интерактивных виджетов, таких как ползунки, кнопки и меню, о которых можно узнать в разделе Блоки.

Запись анимаций с взаимодействиями

В процессе взаимодействия с объектом Scene его можно записать. Просто используйте функцию record (см. также страницу Анимации) и разрешите взаимодействие с помощью sleep в цикле.

В этом примере выполняется выборка из сцены scene в течение 10 секунд со скоростью 10 кадров в секунду.

fps = 10
record(scene, "test.mp4"; framerate = fps) do io
    for i = 1:100
        sleep(1/fps)
        recordframe!(io)
    end
end

События тактов

События тактов генерируются циклом отрисовки в GLMakie и WGLMakie, а также функциями Makie.save и Makie.record для всех бэкендов. Они позволяют синхронизировать такие задачи, как анимация с отрисовкой. Объект Tick содержит следующую информацию.

  • state::Makie.TickState: описывает ситуацию, в которой произошел такт. Возможные варианты

    • Makie.UnknownTickState: универсальный вариант для неклассифицированных тактов. В настоящее время используется только для инициализации события такта.

    • Makie.PausedRenderTick: такт в результате приостановки цикла отрисовки в GLMakie. (Связан с последней попыткой отрисовки кадра.)

    • Makie.SkippedRenderTick: такт в результате цикла отрисовки render_on_demand = true в GLMakie с повторным использованием последнего кадра. (Происходит, когда в отображаемом кадре ничего не изменилось. Для анимации это можно считать обычным тактом отрисовки.)

    • Makie.RegularRenderTick: такт в результате цикла отрисовки с повторной отрисовкой последнего кадра.

    • Makie.OneTimeRenderTick: такт, выполняемый перед генерированием изображения для Makie.save и Makie.record.

  • count::Int64: количество тактов с момента выполнения первого такта. Во время выполнения record это количество с момента первого записанного кадра.

  • time::Float64: время, прошедшее с первого такта, в секундах. Во время выполнения record это время с момента первого записанного кадра; значение увеличивается согласно framerate, заданному в record.

  • delta_time::Float64: время, прошедшее с последнего такта, в секундах. Во время выполнения record равно 1 / framerate.

При анимации, как правило, не требуется беспокоиться о состоянии такта. Можно просто обновлять соответствующие данные требуемым образом.

on(events(fig).tick) do tick
    # Для моделирования можно использовать время дельты для обновления.
    position[] = position[] + tick.delta_time * velocity

    # Для решенной системы может потребоваться использовать общее время для вычисления текущего состояния.
    position[] = trajectory(tick.time)

    # Для записи данных может потребоваться использовать счетчик, чтобы перейти к следующим точкам данных.
    position[] = position_record[mod1(tick.count, end)]
end

Для интерактивного рисунка создается анимация, синхронизированная с реальным временем. В record время тактов соответствует заданному значению framerate, так что анимация в созданном видео соответствует реальному времени.

Обратите внимание, что базовый объект VideoStream отфильтровывает события тактов, отличные от state = OneTimeRenderTick. Это делается для предотвращения скачков (из-за неправильного количества или времени) или ускорения анимации в видео из-за лишних тактов. (Эта проблема особенно актуальна в бэкенде WGLMakie, поскольку во время записи в нем выполняется обычный цикл отрисовки.) После удаления объекта VideoStream или сохранения видео такты больше не отфильтровываются. Это поведение также можно отключить, задав filter_ticks = false.

Имейте в виду, что tick обычно происходит после обработки остальных событий и до отрисовки следующего кадра. Исключением является бэкенд WGLMakie, в котором есть независимый таймер для предотвращения передачи лишних сообщений между JavaScript и Julia.