altair icon indicating copy to clipboard operation
altair copied to clipboard

Is it possible to use alt.condition when passing options to text encodings?

Open palewire opened this issue 5 years ago • 8 comments

Here's the chart I want to make. You can see that it has text placed above positive bars and below negative bars.

download

Here's the data I used.

import pandas as pd
import altair as alt
last_13 = pd.read_json("https://raw.githubusercontent.com/datadesk/cpi/master/notebooks/last_13.json", dtype={"date_label": pd.np.datetime64})

Here's the base chart I wrote.

base = alt.Chart(
    last_13, 
    title="One-month percent change in CPI for All Urban Consumers (CPI-U), seasonally adjusted"
).properties(width=700)

bars = base.mark_bar().encode(
    x=alt.X(
        "date:O",
        timeUnit="yearmonth",
        axis=alt.Axis(title=None, labelAngle=0, format="%b %y"),
    ),
    y=alt.Y(
        "pct_change_rounded:Q",
        axis=alt.Axis(title=None),
        scale=alt.Scale(domain=[
            last_13['pct_change'].min()-0.1,
            last_13['pct_change'].max()+0.05
        ])
    )
)

And here's how I handled the text.

text = base.encode(
    x=alt.X("date:O", timeUnit="yearmonth"),
    y="pct_change_rounded:Q",
    text='pct_change_rounded'
)

textAbove = text.transform_filter(alt.datum.pct_change > 0).mark_text(
    align='center',
    baseline='middle',
    fontSize=14,
    dy=-10
)

textBelow = text.transform_filter(alt.datum.pct_change < 0).mark_text(
    align='center',
    baseline='middle',
    fontSize=14,
    dy=12
)

bars + textAbove + textBelow

It all works fine. But why can't I do this?

text = base.encode(
    x=alt.X("date:O", timeUnit="yearmonth"),
    y="pct_change_rounded:Q",
    text='pct_change_rounded',
    align='center',
    baseline='middle',
    fontSize=14,
    dy=alt.condition(alt.datum.pct_change_rounded >= 0, 12, -10)
)

bars + text

I get this error:

---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-15-ab79ad07b15f> in <module>()
     27     baseline='middle',
     28     fontSize=14,
---> 29     dy=alt.condition(alt.datum.pct_change_rounded >= 0, 12, -10)
     30 )
     31 

/home/palewire/.virtualenvs/cpi/src/altair/altair/vegalite/v2/api.py in condition(predicate, if_true, if_false, **kwargs)
    333         if_true = {'shorthand': if_true}
    334         if_true.update(kwargs)
--> 335     condition.update(if_true)
    336 
    337     if isinstance(if_false, core.SchemaBase):

TypeError: 'int' object is not iterable

palewire avatar Jul 28 '18 17:07 palewire

conditions are only supported for encodings, unfortunately.

jakevdp avatar Jul 29 '18 04:07 jakevdp

FYI: for the power user, often the best way to answer "is this possible" questions is to look at the vega-lite schema. For example, here it's clear that dy can only be a number, not any sort of conditional expression:

https://github.com/altair-viz/altair/blob/28378bd52efbf4eae5275b6fb598e74b51bf5f4c/altair/vegalite/v2/schema/vega-lite-schema.json#L4700-L4703

jakevdp avatar Jul 29 '18 18:07 jakevdp

Thanks for the tip. Too bad Vega doesn't support this. In a perfect world such a feature would be nice.

Short of that, is the pattern I developed above the one you would recommend. If so, perhaps it's worth enshrining it in an example?

palewire avatar Jul 29 '18 19:07 palewire

FYI, we do plan to support xOffset/yOffset as encoding channels in the future, but doing so require some design in terms of how the x/yOffset channels interact with x/y channels, so that's why it doesn't happen yet.

Also, maybe reading the type descriptions in Vega-Lite docs is less painful than reading the JSON schema, which is more like a format for machine to read IMHO.

kanitw avatar Jul 29 '18 22:07 kanitw

Are there workarounds with conditional formatting mark_text? I was assuming I could create a separate feature that just displayed the information I wanted to have displayed, but I can't quite get it to appear in the place that I want.

Dummy data and my latest graph code below.

import pandas as pd
import altair as alt
fig1_example = pd.DataFrame({
    'ami': ['0-50% AMI','50-80% AMI', 
'80-120% AMI','120% AMI +', '0-50% AMI', '50-80% AMI', 
'80-120% AMI', '120% AMI +'],
    'variable': ['Permits','Permits','Permits','Permits',
          'Target','Target','Target','Target'],
    'value': [1127, 37, 12998, 14862, 
1690, 1997, 10759, 9803],
     'order':[1, 2, 3, 4, 1, 2, 3, 4],
    'label':['1127', '37', '12998', '14862', '', '', '', '']})

bars = alt.Chart(fig1_example).mark_bar(stroke="#000000").encode(
    alt.X('ami:O',title=" ",axis=alt.Axis( labelAngle=360), 
sort=['0-50% AMI', '50-80% AMI', 
'80-120% AMI', '120% AMI +']), alt.Y('value:Q',title=""),
color=alt.Color('variable:O',title="",
scale=alt.Scale(domain=['Permits', 'Target'],
 range=["#FFB81D", "#FFFFFF"])), order=alt.Order(
      'order', sort= 'ascending'))
text = alt.Chart(fig1_example).mark_text(fontSize = 14, 
color='black', dy=-10).encode(
    x=alt.X('ami:O',sort=['0-50% AMI', '50-80% AMI',
 '80-120% AMI', '120% AMI +']),
    y=alt.Y('value:Q'),
    detail='label:O',
    text=alt.Text('label:O'))

layer = alt.layer(bars, text).configure_legend(symbolStrokeWidth=1, 
orient="bottom", direction="horizontal", 
labelLimit= 0, titleLimit=0, titleFontSize=18,
labelFontSize=20, labelFont="Georgia", 
titleFont="Georgia").configure_axis(domain=False, 
labelFont='Georgia', titleFont='Georgia', 
titleFontWeight="normal", labelFontSize=15,
 titleFontSize=15).properties(width=400)
layer

The current output of the above code looks like this image

I want to have the right two bar charts text above the bars, like in this chart image

Any advice on how to implement this would be really appreciated, been struggling with it.

qunderriner avatar Feb 25 '22 03:02 qunderriner

Once the offset encoding is available, this will be easier. I think that should be in Altair once #2528 is merged and released.

jakevdp avatar Feb 25 '22 17:02 jakevdp

@qunderriner A workaround for this issue is to create text labels in all the positions where you want them for all the bars and then encode their opacity with a condition as in this SO answer. Another workaround would be to create a custom dataframe with the y-values in the right positions for where you want the text to be for each bar.

Btw, I don't think the offset functionality is part of a Vega-Lite release yet, so it might only come to Altair after #2528

joelostblom avatar Mar 01 '22 05:03 joelostblom

@joelostblom That worked perfectly - thanks so much!

qunderriner avatar Mar 01 '22 18:03 qunderriner

@palewire @qunderriner This is now possible to do via expressions https://altair-viz.github.io/user_guide/interactions.html#expressions. Using the same setup as above:

text = base.encode(
    x=alt.X("date:O", timeUnit="yearmonth"),
    y="pct_change_rounded:Q",
    text='pct_change_rounded',
).mark_text(
    fontSize=14,
    dy=alt.expr(alt.expr.if_(alt.datum.pct_change_rounded >= 0, -10, 10))
)

bars + text

image

I think it would be helpful to have an example of this in the docs, maybe under the text mark label section https://altair-viz.github.io/user_guide/marks/text.html#labels? Happy to review a PR if someone wants to take that on.

Another approach would be to use the yOffset channel like this yOffset=alt.datum(alt.expr(alt.expr.if_(alt.datum.pct_change_rounded >= 0, -10, 10))) but then the offset is in axis units rather than pixels so that it often less desirable. I don't think condition will work as it requires one of the outcomes to be a field encoding and not a fixed value

joelostblom avatar Sep 21 '23 16:09 joelostblom