FawltyPlay
FawltyPlay

Reputation: 98

Altair chart mark_text background color/visual clarity

I've been playing with Altair charts in the context of Streamlit for making simple calculators. In one case I wanted to plot 3 bar charts next to each other to make an explainer for how progressive tax brackets work.

In one column is the total income, in the next is a bar drawn between the start of the bracket and the point at which the rate is paid for that bracket. Finally I stack all those intervals together to give the total tax owed.

Here's an image example of what I mean

I can't figure a good way to help the "Total Owed" number not have visual conflict with the grid lines. This is partly because I can't figure a good way to relate the chart's rendered size to the font size to do conditional checks for overlapping. I also believe there is no way to control the rendering of gridlines on a per-column basis.

Ideally I want to keep the gridlines thick and visible without disturbing the text. I could place the text for that column inside the stacked bars, but for certain configurations the multicolored bars make the text just as unreadable.

Here's a MRE:

import altair as alt
import pandas as pd
import streamlit as st

# Example data
data = pd.DataFrame({
    'category': ['A', 'B', 'C', 'D'],
    'value': [10, 20, 15, 25]
})

# Base chart with gridlines
chart = alt.Chart(data).mark_bar().encode(
    x='value:Q',
    y=alt.Y('category:N', axis=alt.Axis(grid=True, gridWidth=2, gridColor='darkslategray'))
)

text = alt.Chart(data).mark_text(
    align='left',
    baseline='middle',
    color='black'
).encode(
    x='value:Q',
    y='category:N',
    text=alt.Text('value:Q')
)

# Combine the charts
final_chart = chart + text

st.altair_chart(final_chart)

I tried using a mark_rect as a sort of backdrop for the text by adding this snippet to the above:

# Text with background
text_background = alt.Chart(data).mark_rect(
    color='black',
    opacity=0.7,
    height=20,
    width=20
).encode(
    x='value:Q',
    y='category:N'
)

and updating the composition of the final chart. However those rectangles are centered on the end of the bar and (again) with no way to relate the chart's size and the text font size I don't see a straightforward way to center it on the text (or even set an appropriate width to cover the whole text in response to its width).

Failed example image explained by the above paragraph

Is there a convenient way to do this? Failing that, is there some other chart library I could use?

Upvotes: 3

Views: 111

Answers (2)

kgoodrick
kgoodrick

Reputation: 883

In addition to Joel's suggestion of using a background box, I have also had good results adding a copy of the text with a stroke behind the main text. This automatically adjusts the background to the right size.

Chart with stroked background

import altair as alt
import pandas as pd

# Example data
data = pd.DataFrame({
    'category': ['A', 'B', 'C', 'D'],
    'value': [10, 20, 15, 25]
})

# Base chart with gridlines
chart = alt.Chart(data).mark_bar().encode(
    x='value:Q',
    y=alt.Y('category:N', axis=alt.Axis(grid=True, gridWidth=2, gridColor='darkslategray'))
)

text = alt.Chart(data).mark_text(
    align='left',
    baseline='middle',
    color='black',
    dx=3  # Nudge the text to the right
).encode(
    x='value:Q',
    y='category:N',
    text=alt.Text('value:Q')
)

text_background = text.mark_text(
    align='left',
    baseline='middle',
    stroke='white',
    strokeWidth=5,
    strokeJoin='round',
    dx=3
)

# Combine the charts
final_chart = chart + text_background + text

final_chart

Upvotes: 3

joelostblom
joelostblom

Reputation: 49054

I think your idea with a background rectangle would work. You can align it like you did with the text, and set the width to depend on the number of digits in the label:

# Example data
data = pd.DataFrame({
    'category': ['A', 'B', 'C', 'D', 'E'],
    'value': [5, 20, 150, 250, 3000]
})
# Base chart with gridlines
base = alt.Chart(data).mark_bar().encode(
    x=alt.X('value:Q').scale(domainMax=3500),
    y=alt.Y('category:N').axis(grid=True, gridWidth=2, gridColor='darkslategray')
)

text = base.mark_text(
    align='left',
    dx=2,
    color='black'
).encode(
    text=alt.Text('value:Q')
)

text_bg = base.mark_rect(
    color='white',
    height=20,
    # width=alt.expr('10 + 5 * length(toString(datum.value))'),  # Equivalent
    width=alt.expr(10 + 5 * alt.expr.length(alt.expr.toString((alt.datum.value)))),
    align='left'
)

base + text_bg + text

enter image description here

If you want to experiment with having the text overlaid on bars with different color instead, you could set the text color conditionally on the luminance of the bar bg color as per https://github.com/vega/altair/pull/3614

Upvotes: 2

Related Questions