Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
6bc9e1a
space removal
O957 Aug 5, 2025
7990fd3
Merge remote-tracking branch 'origin/main' into 117-legend-in-plots
O957 Aug 5, 2025
8f16fd0
try color encoding
O957 Aug 5, 2025
8cda371
Merge remote-tracking branch 'origin/main' into 117-legend-in-plots
O957 Aug 7, 2025
83015c3
Merge remote-tracking branch 'origin/main' into 117-legend-in-plots
O957 Aug 12, 2025
2becfc8
attempt to use color scale for target data and forecast charts
O957 Aug 12, 2025
61ac0bf
good enough legend
O957 Aug 12, 2025
7185da2
fix marker size
O957 Aug 12, 2025
4bd04e2
CI labels
O957 Aug 12, 2025
5624d75
minor corrections
O957 Aug 12, 2025
1cdba52
Merge remote-tracking branch 'origin/main' into 117-legend-in-plots
O957 Aug 13, 2025
043dd46
minor update
O957 Aug 13, 2025
7ba3e72
remove comment
O957 Aug 13, 2025
2adb8db
still some issues
O957 Aug 13, 2025
bf0074b
Merge remote-tracking branch 'origin/main' into 117-legend-in-plots
O957 Aug 13, 2025
a2e9535
add legend atop
O957 Aug 13, 2025
8530a62
different attempt
O957 Aug 13, 2025
6c1736c
finally nice
O957 Aug 13, 2025
a8dd896
add colorbrewer
O957 Aug 13, 2025
dffa4d9
Merge remote-tracking branch 'origin/main' into 117-legend-in-plots
O957 Aug 14, 2025
90cb0d2
colorbrewer use
O957 Aug 14, 2025
527adba
add mpl
O957 Aug 14, 2025
c8d2ded
ci spec items
O957 Aug 14, 2025
fe3e833
clean plotting ui a bit
O957 Aug 14, 2025
cb8b5ab
update build from df
O957 Aug 14, 2025
c823da8
clean up wide df
O957 Aug 14, 2025
50b57b2
remove unecessary items
O957 Aug 14, 2025
bc0cbc2
no quantile levels
O957 Aug 14, 2025
80852ad
rename func to verb
O957 Aug 14, 2025
59d3d20
redo ci pairs
O957 Aug 14, 2025
7d9ba4c
format percent from babel
O957 Aug 14, 2025
2a911de
max num cis
O957 Aug 14, 2025
8b57127
Merge remote-tracking branch 'origin/main' into 117-legend-in-plots
O957 Aug 25, 2025
2be5c6c
try use existing define scheme
O957 Aug 25, 2025
53764a5
vega scheme
O957 Aug 25, 2025
89b6a47
deptry fix
O957 Aug 25, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 46 additions & 4 deletions hubverse_annotator/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import streamlit as st
from streamlit_shortcuts import add_shortcuts
from utils import (
build_ci_specs_from_df,
get_available_locations,
get_initial_window_range,
get_reference_dates,
Expand All @@ -31,6 +32,7 @@
CHART_TITLE_FONT_SIZE = 18
REF_DATE_STROKE_WIDTH = 2.5
REF_DATE_STROKE_DASH = [6, 6]
MARKER_SIZE = 65
ROOT = pathlib.Path(__file__).resolve().parent.parent


Expand Down Expand Up @@ -406,11 +408,44 @@ def plotting_ui(
# empty streamlit object (DeltaGenerator) needed for
# plots to reload successfully with new data.
base_chart = st.empty()
forecast_layer = quantile_forecast_chart(
forecasts_to_plot, selected_target, scale=scale, grid=show_grid
)

has_obs = not data_to_plot.is_empty()
has_fc = not forecasts_to_plot.is_empty()
ci_specs = build_ci_specs_from_df(forecasts_to_plot) if has_fc else {}

legend_labels = ["Observations"] if has_obs else []
color_range = ["limegreen"] if has_obs else []

if has_fc and ci_specs:
legend_labels.extend(ci_specs.keys())
color_range.extend(["blue"] * len(ci_specs))

if len(legend_labels) > 1:
color_enc = alt.Color(
"legend_label:N",
title=None,
scale=alt.Scale(domain=legend_labels, scheme="blues"),
)
else:
color_enc = alt.Color(
"legend_label:N",
title=None,
scale=alt.Scale(domain=legend_labels, range=color_range),
)
observed_layer = target_data_chart(
data_to_plot, selected_target, scale=scale, grid=show_grid
data_to_plot,
selected_target,
color_enc=color_enc,
scale=scale,
grid=show_grid,
)
forecast_layer = quantile_forecast_chart(
forecasts_to_plot,
selected_target,
ci_specs,
color_enc=color_enc,
scale=scale,
grid=show_grid,
)
sub_layers = [
layer for layer in [forecast_layer, observed_layer] if not is_empty_chart(layer)
Expand Down Expand Up @@ -462,6 +497,13 @@ def plotting_ui(
.interactive()
.resolve_scale(y="independent")
.resolve_axis(x="independent")
.configure_legend(
orient="top",
direction="horizontal",
symbolType="circle",
symbolSize=MARKER_SIZE,
titleAnchor="middle",
)
)
base_chart.altair_chart(
chart,
Expand Down
131 changes: 107 additions & 24 deletions hubverse_annotator/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@
import polars as pl
import polars.selectors as cs
import streamlit as st
from babel.numbers import format_percent
from streamlit.runtime.uploaded_file_manager import UploadedFile

PLOT_WIDTH = 625
STROKE_WIDTH = 2
MARKER_SIZE = 55
MARKER_SIZE = 65
MAX_NUM_CIS = 7


type ScaleType = Literal["linear", "log"]

Expand Down Expand Up @@ -130,6 +133,65 @@ def get_initial_window_range(
return (start_date, end_date)


def pivot_quantile_df(forecast_table: pl.DataFrame) -> pl.DataFrame:
"""
Converts long-format quantile forecast table into
wide format where each quantile is a column.
"""
return (
forecast_table.filter(pl.col("output_type") == "quantile")
.pivot(
on="output_type_id",
index=cs.exclude("output_type_id", "value"),
values="value",
)
.rename({"0.5": "median"})
)


def build_ci_specs_from_df(
forecast_table: pl.DataFrame,
) -> dict[str, dict[str, str]]:
"""
Automatically constructs a CI_SPECS-style dict for
altair legend creation by finding quantile columns in
a wide forecast table.

Parameters
----------
forecast_table : pl.DataFrame
A Polars DataFrame of forecasts, with a quantile
forecast columns.

Returns
-------
dict[str, dict[str, str]]
Mapping from CI label (e.g. "95% CI") to its
bounds and color.
"""
df_wide = pivot_quantile_df(forecast_table)
quant_vals = sorted(float(col) for col in df_wide.columns if col.startswith("0."))
ci_pairs = sorted(
[(q, 1 - q) for q in quant_vals if q < 0.5 and (1 - q) in quant_vals],
key=lambda p: p[1] - p[0],
reverse=True,
)[:MAX_NUM_CIS]

labels = [
f"{format_percent(high - low, locale='en_US', format='#,##0%')} CI"
for low, high in ci_pairs
]

specs = {
label: {
"low": f"{low:.3f}".rstrip("0").rstrip("."),
"high": f"{high:.3f}".rstrip("0").rstrip("."),
}
for (low, high), label in zip(ci_pairs, labels, strict=False)
}
return specs


def is_empty_chart(chart: alt.LayerChart) -> bool:
"""
Checks if an altair layer is empty. Primarily used for
Expand Down Expand Up @@ -168,6 +230,7 @@ def is_empty_chart(chart: alt.LayerChart) -> bool:
def target_data_chart(
observed_data_table: pl.DataFrame,
selected_target: str,
color_enc: alt.Color,
scale: ScaleType = "log",
grid: bool = True,
) -> alt.Chart | alt.LayerChart:
Expand All @@ -182,6 +245,9 @@ def target_data_chart(
selected_target : str
The target for filtering in the forecast and or
observed hubverse tables.
color_enc : alt.Color
An Altair color encoding used for plotting the
observations color and legend.
scale : str
The scale to use for the Y axis during plotting.
Defaults to logarithmic.
Expand All @@ -199,7 +265,6 @@ def target_data_chart(
x_enc = alt.X(
"date:T",
axis=alt.Axis(title="Date", grid=grid, ticks=True, labels=True),
scale=alt.Scale(type=scale),
)
y_enc = alt.Y(
"observation:Q",
Expand All @@ -214,10 +279,15 @@ def target_data_chart(
)
obs_layer = (
alt.Chart(observed_data_table, width=PLOT_WIDTH)
.mark_point(filled=True, size=MARKER_SIZE, color="limegreen")
.transform_calculate(legend_label="'Observations'")
.mark_point(
filled=True,
size=MARKER_SIZE,
)
.encode(
x=x_enc,
y=y_enc,
color=color_enc,
tooltip=[
alt.Tooltip("date:T", title="Date"),
alt.Tooltip("observation:Q", title="Value"),
Expand All @@ -230,6 +300,8 @@ def target_data_chart(
def quantile_forecast_chart(
forecast_table: pl.DataFrame,
selected_target: str,
ci_specs,
color_enc: alt.Color,
scale: ScaleType = "log",
grid: bool = True,
) -> alt.LayerChart:
Expand All @@ -246,6 +318,9 @@ def quantile_forecast_chart(
selected_target : str
The target for filtering in the forecast and or
observed hubverse tables.
color_enc : alt.Color
An Altair color encoding used for plotting the
quantile bands color and legend.
scale : str
The scale to use for the Y axis during plotting.
Defaults to logarithmic.
Expand All @@ -260,24 +335,26 @@ def quantile_forecast_chart(
"""
if forecast_table.is_empty():
return alt.layer()
df_wide = (
forecast_table.filter(pl.col("output_type") == "quantile")
.pivot(
on="output_type_id",
index=cs.exclude("output_type_id", "value"),
values="value",
)
.rename({"0.5": "median"})
)
df_wide = pivot_quantile_df(forecast_table)
x_enc = alt.X("target_end_date:T", title="Date", axis=alt.Axis(grid=grid))
y_enc = alt.Y(
"median:Q",
axis=alt.Axis(grid=grid),
scale=alt.Scale(type=scale),
)
base = alt.Chart(df_wide, width=PLOT_WIDTH).encode(x=x_enc, y=y_enc)
base = (
alt.Chart(df_wide, width=PLOT_WIDTH)
.transform_calculate(
date="toDate(datum.target_end_date)",
data_type="'Forecast'",
)
.encode(
x=x_enc,
y=y_enc,
)
)

def band(low: str, high: str, opacity: float) -> alt.Chart:
def band(low: str, high: str, label: str) -> alt.Chart:
"""
Builds an errorband layer for a quantile.

Expand All @@ -289,27 +366,33 @@ def band(low: str, high: str, opacity: float) -> alt.Chart:
high : str
Upper-bound column name in the wide forecast
table (e.g., "0.975").
opacity : float
Fill opacity for the band in the range
[0.0, 1.0].
label : str
The label in the legend for the confidence
interval (e.g. "97.5% CI").

Returns
-------
alt.Chart
An Altair layer with the filled band from
``low`` to ``high``, with step interpolation.
"""
return base.mark_errorband(opacity=opacity, interpolate="step").encode(
y=alt.Y(f"{low}:Q", title=f"{selected_target}"),
y2=f"{high}:Q",
fill=alt.value("steelblue"),
return (
base.transform_calculate(legend_label=f"'{label}'")
.mark_errorband(interpolate="step")
.encode(
y=alt.Y(f"{low}:Q", title=f"{selected_target}"),
y2=f"{high}:Q",
color=color_enc,
opacity=alt.value(1.0),
)
)

bands = [
band("0.025", "0.975", 0.10),
band("0.1", "0.9", 0.20),
band("0.25", "0.75", 0.30),
band(spec["low"], spec["high"], label)
for label, spec in ci_specs.items()
if spec["low"] in df_wide.columns and spec["high"] in df_wide.columns
Comment on lines 390 to +393
Copy link
Collaborator

@damonbayer damonbayer Aug 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Structure overall feels a bit off.

What about

  1. We haveget_available_ci(list_of_quantiles) which returns all of the available ci's (width only, or width, lower, and upper)
  2. Then somewhere later we have the availability to subsample the available ci's (either by giving the list of widths we want or by limiting to some max number).
  3. Then we get the colors based on the length of the subsampled list of ci's.
  4. Then we make the plot with legend.

Copy link
Collaborator Author

@O957 O957 Aug 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will try this out.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Get the colors" step may be obviated by https://vega.github.io/vega/docs/schemes/#blues

]

median = base.mark_line(strokeWidth=STROKE_WIDTH, interpolate="step", color="navy")

return alt.layer(*bands, median)
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ dependencies = [
"streamlit>=1.47.0",
"polars>=0.19.37",
"streamlit-shortcuts>=1.1.5",
"babel>=2.17.0",
]

[project.urls]
Expand Down
11 changes: 11 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.