Search code examples
pythonaltairvega-lite

Combine hover and click selections in Altair


I want to combine hover and click in selections in Altair plot. The code below produces the result that I want: points are mostly transparent by default, hovering over the point increases the opacity, and then clicking on the point increases the opacity even more. I find this useful so that users can hover over a point to get a quick sense of the results, and then click on the point to "lock" the selection. While I am satisfied with the results, the method seems a bit cumbersome because I need to define different chart layers for the hover and click selections. If I could construct a multi-way condition expression, then it seems like I would be able to simplify the code quite a bit. I tried writing the opacity condition as alt.condition(click_selection, CLICK_OPACITY, alt.condition(hover_selection, HOVER_OPACITY, DEFAULT_OPACITY)), but I got an error. Is there a way to simplify my code below to combine hover and click selections?

import altair as alt
import numpy as np
import pandas as pd

a_values = np.arange(1, 4)
x_values = np.linspace(0, 2, 1000)
DEFAULT_OPACITY = 0.3
HOVER_OPACITY = 0.5
CLICK_OPACITY = 1.0

a_df = pd.DataFrame({'a': a_values})

df = pd.DataFrame({
    'a': np.tile(A=a_values, reps=len(x_values)),
    'x': np.repeat(a=x_values, repeats=len(a_values)),
})
df['y'] = df['a'] * np.sin(2 * np.pi * df['x'])

hover_selection = alt.selection_single(
    clear='mouseout',
    empty='none',
    fields=['a'],
    name='hover_selection',
    on='mouseover',
)
click_selection = alt.selection_single(
    empty='none',
    fields=['a'],
    name='click_selection',
    on='click',
)

a_base = alt.Chart(a_df).mark_point(
    filled=True, size=100,
).encode(
    x=alt.X(shorthand='a:Q', scale=alt.Scale(domain=(min(a_values) - 1, max(a_values) + 1))),
    y=alt.Y(shorthand='a:Q', scale=alt.Scale(domain=(min(a_values) - 1, max(a_values) + 1))),
)
a_hover = a_base.encode(
    opacity=alt.condition(hover_selection, alt.value(HOVER_OPACITY), alt.value(DEFAULT_OPACITY))
).add_selection(hover_selection)
a_click = a_base.encode(
    opacity=alt.condition(click_selection, alt.value(CLICK_OPACITY), alt.value(0.0)),
).add_selection(click_selection)

y_base = alt.Chart(df).mark_line().encode(
    x=alt.X(shorthand='x:Q', scale=alt.Scale(domain=(0, 2))),
    y=alt.Y(shorthand='y:Q', scale=alt.Scale(domain=(-3, 3))),
)
y_hover = y_base.encode(
    opacity=alt.value(HOVER_OPACITY),
).transform_filter(hover_selection)
y_click = y_base.encode(
    opacity=alt.value(CLICK_OPACITY),
).transform_filter(click_selection)

alt.hconcat(
    alt.layer(a_hover, a_click),
    alt.layer(y_hover, y_click),
)

Solution

  • VegaLite supports multiple selections in the same condition, but I don't think it is possible to write within alt.Condition. However, you can see the alt.Condition returns a dictionary so you could write this directly passing a list of selections.

    This way you could clarify this section

    a_base = alt.Chart(a_df).mark_point(
        filled=True, size=100,
    ).encode(
        x=alt.X(shorthand='a:Q', scale=alt.Scale(domain=(min(a_values) - 1, max(a_values) + 1))),
        y=alt.Y(shorthand='a:Q', scale=alt.Scale(domain=(min(a_values) - 1, max(a_values) + 1))),
    )
    a_hover = a_base.encode(
        opacity=alt.condition(hover_selection, alt.value(HOVER_OPACITY), alt.value(DEFAULT_OPACITY))
    ).add_selection(hover_selection)
    a_click = a_base.encode(
        opacity=alt.condition(click_selection, alt.value(CLICK_OPACITY), alt.value(0.0)),
    ).add_selection(click_selection)
    

    to something like this:

    hover_and_click_condition = {
        'condition': [
            {'selection': 'hover_selection', 'value': HOVER_OPACITY},
            {'selection': 'click_selection', 'value': CLICK_OPACITY}],
         'value': DEFAULT_OPACITY}
    
    a = alt.Chart(a_df).mark_point(
        filled=True, size=100,
    ).encode(
        x=alt.X(shorthand='a:Q', scale=alt.Scale(domain=(min(a_values) - 1, max(a_values) + 1))),
        y=alt.Y(shorthand='a:Q', scale=alt.Scale(domain=(min(a_values) - 1, max(a_values) + 1))),
        opacity=hover_and_click_condition
    ).add_selection(hover_selection, click_selection)
    

    For the transform filters, you could rewrite this section

    y_base = alt.Chart(df).mark_line().encode(
        x=alt.X(shorthand='x:Q', scale=alt.Scale(domain=(0, 2))),
        y=alt.Y(shorthand='y:Q', scale=alt.Scale(domain=(-3, 3))),
    )
    y_hover = y_base.encode(
        opacity=alt.value(HOVER_OPACITY),
    ).transform_filter(hover_selection)
    y_click = y_base.encode(
        opacity=alt.value(CLICK_OPACITY),
    ).transform_filter(click_selection)
    
    alt.hconcat(
        alt.layer(a_hover, a_click),
        alt.layer(y_hover, y_click),
    )
    

    like this:

    y = alt.Chart(df).mark_line().encode(
        x=alt.X(shorthand='x:Q', scale=alt.Scale(domain=(0, 2))),
        y=alt.Y(shorthand='y:Q', scale=alt.Scale(domain=(-3, 3))),
        opacity=hover_and_click_condition
    )
    
    a | (y.transform_filter(click_selection) + y.transform_filter(hover_selection))