koaning / fh-altair

Makes it easy to use altair from FastHTML
MIT License
20 stars 1 forks source link

Responsive sized charts #9

Open ddanieltan opened 4 days ago

ddanieltan commented 4 days ago

Issue: The current altair chart does not resize responsively with the Div().

I found this discussion in Altair's issues that describe a fix by adding some CSS for the vega-embed class. https://github.com/vega/altair/pull/2867

My suggestion to incorporate this fix:

  1. We add the additional CSS into altair_headers
  2. In altair2fasthtml(), we default to include .properties(width="container", height=240)

This allows the chart to resize responsively based on the width of it parent container, with a fixed height. I experimented with setting both width and height to "responsive" but I did not get the desired result.

Here's an example FastHTML app that reproduces this responsively sized chart

import altair as alt
from fasthtml.common import *
from vega_datasets import data

css = Style("""
.vega-embed {
  width: 100%;
  display: flex;
}
.vega-embed details,
.vega-embed details summary {
  position: relative;
}
""")

altair_headers = [
    Script(src="https://cdn.jsdelivr.net/npm/vega@5"),
    Script(src="https://cdn.jsdelivr.net/npm/vega-lite@5"),
    Script(src="https://cdn.jsdelivr.net/npm/vega-embed@6"),
]

hdrs = (
    picolink,
    css,
    altair_headers,
)

app, rt = fast_app(live=True, hdrs=hdrs, htmlkw={"data-theme": "light"})

def chart():
    source = data.seattle_weather()
    brush = alt.selection_interval(encodings=["x"])

    bars = (
        alt.Chart()
        .mark_bar()
        .encode(
            x="month(date):O",
            y="mean(precipitation):Q",
            opacity=alt.condition(brush, alt.OpacityValue(1), alt.OpacityValue(0.7)),
        )
        .add_params(brush)
    )

    line = (
        alt.Chart()
        .mark_rule(color="firebrick")
        .encode(y="mean(precipitation):Q", size=alt.SizeValue(3))
        .transform_filter(brush)
    )

    return alt.layer(bars, line, data=source)

def altair2html(chart):
    jsonstr = chart.properties(width="container", height=240).to_json()
    chart_id = f"uniq-{1}"
    settings = "{actions: false}"
    return Div(Script(f"vegaEmbed('#{chart_id}', {jsonstr}, {settings});"), id=chart_id)

@rt("/")
def get():
    return Title("Responsive Altair"), Container(
        altair2html(chart()),
    )

serve()

Happy to submit a PR if you like the idea

koaning commented 3 days ago

Ah nice, this was on my mental to-do list for a bit so it is nice to see folks mentioning the same need.

I am wondering what the simplest solution might be for folks who know altair but not the vega ecosystem. I would for sure love a PR, but what do you link about adding an argument to the altair2html function. Something like:

def altair2html(chart, full_width=True, full_height=False):
   ...

This way the end user needs to know less but can still get the chart to render the way they like and we might be able to handle all that is needed internally.

ddanieltan commented 2 days ago

folks who know altair but not the vega ecosystem

I think I belong in that category 😅

I like your suggestion but I do have an issue trying to implement responsive height. Thus far, I can only get responsive width to work. Here are some screenshots for illustration.

Basic chart without size params

image

Chart with width=container fills width of container

image

But chart with height=container behaves unexpectedly

image
koaning commented 2 days ago

That's fine, lets drop the height. My thinking for the height was for consistently mostly, but it is fair to assume most people are interested in the width.