Search code examples
pythonchartsslideraltairerrorbar

Altair mark_text position based on condition


enter image description here

I have this chart with mark_text to denote the value of each bar. There is a slider to go back/forth in time. I would like to change the positioning of the text (the numbers in the bars) depending on the Y value: if it is larger than a certain value, it will go just below the top of the bar (baseline='line-top), otherwise just above (baseline='line-bottom'). This way, as I slide through time the text will appear below/above the top of the bar depending on Y.

I think I have to use some condition or expression, but I cannot figure out how.

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

np.random.seed(0)

################################################################################

model_keys = ['M1', 'M2']
scene_keys = ['S1', 'S2']
layer_keys = ['L1', 'L2']
time_keys = [1, 2, 3]

ys = []
models = []
dataset = []
layers = []
scenes = []
times = []

for sc in scene_keys:
    for m in model_keys:
        for l in layer_keys:
            for s in range(10):
                y = np.random.rand(10) / 10
                if m == 'M1':
                    y *= 10
                if l == 'L1':
                    y *= 5
                for t in time_keys:
                    y += 1

                    ys += list(y)
                    scenes += [sc] * len(y)
                    models += [m] * len(y)
                    layers += [l] * len(y)
                    times += [t] * len(y)


# ------------------------------------------------------------------------------

df = pd.DataFrame({'Y': ys,
                   'Model': models,
                   'Layer': layers,
                   'Scenes': scenes,
                   'Time': times})

bars = alt.Chart(df, width=100, height=90).mark_bar().encode(
    x=alt.X('Scenes:N',
        title=None,
        axis=alt.Axis(
            grid=False,
            title=None,
            labels=False,
        ),
    ),
    y=alt.Y('Y:Q',
        aggregate='mean',
        axis=alt.Axis(
            grid=True,
            title='Y',
            titleFontWeight='normal',
        ),
    ),
)

text = alt.Chart(df).mark_text(align='center',
    baseline='line-top',
    color='black',
    dy=5,
    fontSize=13,
).encode(
    x=alt.X('Scenes:N'),
    y=alt.Y('mean(Y):Q'),
    text=alt.Text('mean(Y):Q',
        format='.1f'
    ),
)

bars = bars + text

bars = bars.facet(
    row=alt.Row('Model:N',
        title=None,
    ),
    column=alt.Column('Layer:N',
        title=None,
    ),
    spacing={"row": 10, "column": 10},
)

slider = alt.binding_range(
    min=1,
    max=3,
    step=1,
    name='Time',
)
selector = alt.selection_single(
    name='Selector',
    fields=['Time'],
    bind=slider,
    init={'Time': 3}, 
)
bars = bars.add_selection(
    selector
).transform_filter(
    selector
)

bars.save('test.html')

Solution

  • Unfortunately, you can only use conditions with encodings, not (yet) with mark properties, so there is no way to dynamically change the text baseline. However, you could add a second text mark that is above the bars and then set the opacity encoding of both text marks to depend on the value of y, so that it is either the text above or below the bar that is visible.

    I believe you need to do the calculation of the average y value in a separate transform, so that you can access the column name in the conditional expression. I also believe that the charts need to build on each other to filter correctly when the Time value changes, but I am not 100% on either of these two points.

    Taking the above into consideration, something like this would work:

    bars = alt.Chart(df, width=100, height=90).mark_bar().transform_aggregate(
        y_mean = 'mean(Y)',
        groupby=['Scenes']
    ).encode(
        x=alt.X(
            'Scenes:N',
            title=None,
            axis=alt.Axis(
                grid=False,
                title=None,
                labels=False,
            ),
        ),
        y=alt.Y(
            'y_mean:Q',
            axis=alt.Axis(
                grid=True,
                title='Y',
                titleFontWeight='normal',
            ),
        ),
    )
    
    text = bars.mark_text(
        align='center',
        baseline='line-top',
        color='black',
        dy=5,
        fontSize=13,
    ).encode(
        text=alt.Text( 'y_mean:Q', format='.1f'),
        opacity=alt.condition( 'datum.y_mean < 3', alt.value(1), alt.value(0))
    )
    
    text2 = text.mark_text(
        align='center',
        baseline='line-bottom',
        fontSize=13,
    ).encode(
        opacity=alt.condition( 'datum.y_mean >= 3', alt.value(1), alt.value(0))
    )
    
    bars = bars + text + text2
    
    bars = bars.facet(
        row=alt.Row('Model:N', title=None),
        column=alt.Column('Layer:N', title=None),
        spacing={"row": 10, "column": 10},
    )
    
    slider = alt.binding_range(
        min=1,
        max=3,
        step=1,
        name='Time',
    )
    selector = alt.selection_single(
        name='Selector',
        fields=['Time'],
        bind=slider,
        init={'Time': 2}, 
    )
    bars = bars.add_selection(
        selector
    ).transform_filter(
        selector
    )
    bars
    

    enter image description here