"""Plotly helpers tuned for the RHTP dashboard (dark theme).""" from __future__ import annotations from typing import Iterable, Optional import numpy as np import pandas as pd import plotly.express as px import plotly.graph_objects as go import plotly.io as pio from .styling import PALETTE # Build a custom dark template once at import time so every plot # inherits the same surface / type / grid styling. _template = go.layout.Template() _template.layout = go.Layout( paper_bgcolor=PALETTE["bg"], plot_bgcolor=PALETTE["bg"], font=dict(family="Inter, system-ui, sans-serif", size=12, color=PALETTE["text"]), xaxis=dict( gridcolor=PALETTE["border"], zeroline=False, linecolor=PALETTE["border"], tickfont=dict(color=PALETTE["text_dim"]), title=dict(font=dict(color=PALETTE["text_dim"])), ), yaxis=dict( gridcolor=PALETTE["border"], zeroline=False, linecolor=PALETTE["border"], tickfont=dict(color=PALETTE["text_dim"]), title=dict(font=dict(color=PALETTE["text_dim"])), ), legend=dict( bgcolor="rgba(0,0,0,0)", font=dict(color=PALETTE["text"], size=11), ), hoverlabel=dict( bgcolor=PALETTE["surface2"], bordercolor=PALETTE["border"], font_color=PALETTE["text"], font_size=12, ), colorway=[ PALETTE["secondary"], PALETTE["primary"], PALETTE["good"], PALETTE["accent"], "#A487E0", "#D679A4", "#5BCFA9", "#E08F5B", ], ) pio.templates["rhtp_dark"] = _template PLOT_TEMPLATE = "rhtp_dark" def _layout(fig: go.Figure, height: int = 380, **kwargs) -> go.Figure: default_title = dict( font=dict(size=14, color=PALETTE["text"], family="Inter, system-ui, sans-serif"), x=0.0, xanchor="left", ) if "title" in kwargs: t = kwargs.pop("title") if isinstance(t, dict): default_title.update(t) else: default_title["text"] = t fig.update_layout( template=PLOT_TEMPLATE, height=height, margin=dict(t=46, l=12, r=12, b=42), title=default_title, legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0.0, font=dict(size=11)), **kwargs, ) return fig def line_by_group( df: pd.DataFrame, x: str, y: str, group: str, *, title: str = "", y_label: Optional[str] = None, treatment_band: Optional[tuple[float, float]] = None, hover_extra: Iterable[str] = (), height: int = 380, ) -> go.Figure: fig = px.line( df, x=x, y=y, color=group, hover_data=list(hover_extra), markers=True, ) if treatment_band: x0, x1 = treatment_band fig.add_vrect( x0=x0, x1=x1, fillcolor=PALETTE["accent"], opacity=0.10, line_width=0, annotation_text="RHTP rollout", annotation_position="top left", annotation_font_size=10, annotation_font_color=PALETTE["accent"]) fig.update_yaxes(title=y_label or y) fig.update_xaxes(title=x) return _layout(fig, height=height, title=dict(text=title)) def scatter_with_trend( df: pd.DataFrame, x: str, y: str, *, color: Optional[str] = None, size: Optional[str] = None, title: str = "", height: int = 380, log_x: bool = False, ) -> go.Figure: fig = px.scatter( df, x=x, y=y, color=color, size=size, opacity=0.80, trendline="ols", trendline_color_override=PALETTE["accent"], log_x=log_x, ) fig.update_traces(marker=dict(line=dict(width=0.4, color=PALETTE["bg"]))) return _layout(fig, height=height, title=dict(text=title)) def event_study_plot( coefs: pd.DataFrame, *, title: str = "Event-study: dynamic treatment effect", y_label: str = "Estimated effect (vs. period -1)", height: int = 420, ) -> go.Figure: fig = go.Figure() fig.add_trace(go.Scatter( x=coefs["event_time"], y=coefs["coef"], error_y=dict(type="data", array=coefs["ci_high"] - coefs["coef"], arrayminus=coefs["coef"] - coefs["ci_low"], color=PALETTE["secondary"], thickness=1.4, width=4), mode="markers+lines", line=dict(color=PALETTE["secondary"], width=2), marker=dict(size=8, color=PALETTE["secondary"], line=dict(color=PALETTE["bg"], width=1.5)), name="point estimate (95% CI)", )) fig.add_hline(y=0, line=dict(color=PALETTE["text_dim"], dash="dash", width=1)) fig.add_vline( x=-0.5, line=dict(color=PALETTE["primary"], dash="dot", width=1.6), annotation_text="implementation", annotation_position="top right", annotation_font_color=PALETTE["primary"]) fig.update_xaxes(title="Event time (periods relative to implementation)", dtick=1) fig.update_yaxes(title=y_label) return _layout(fig, height=height, title=dict(text=title)) def coef_forest( coef_df: pd.DataFrame, *, title: str = "Coefficient estimates", height: int = 380, ) -> go.Figure: coef_df = coef_df.copy().iloc[::-1] fig = go.Figure() fig.add_trace(go.Scatter( x=coef_df["coef"], y=coef_df["term"], error_x=dict( type="data", array=coef_df["ci_high"] - coef_df["coef"], arrayminus=coef_df["coef"] - coef_df["ci_low"], color=PALETTE["secondary"], thickness=1.5, width=6, ), mode="markers", marker=dict(size=10, color=PALETTE["secondary"], line=dict(color=PALETTE["bg"], width=1)), showlegend=False, )) fig.add_vline(x=0, line=dict(color=PALETTE["text_dim"], dash="dash", width=1)) fig.update_xaxes(title="Estimated coefficient (95% CI)") fig.update_yaxes(title="") return _layout(fig, height=height, title=dict(text=title)) def montana_county_choropleth( counties: pd.DataFrame, *, color: str, color_label: str, title: str = "", height: int = 540, ) -> go.Figure: fig = px.choropleth_mapbox( counties, geojson="https://raw.githubusercontent.com/plotly/datasets/master/geojson-counties-fips.json", locations="fips", color=color, color_continuous_scale="Cividis", mapbox_style="carto-darkmatter", center={"lat": 47.0, "lon": -110.5}, zoom=5.2, hover_name="county_name", hover_data={ "fips": False, "rurality": True, "population_2020": ":,", color: ":.2f", }, labels={color: color_label}, ) fig.update_layout( template=PLOT_TEMPLATE, margin=dict(l=0, r=0, t=42, b=0), height=height, paper_bgcolor=PALETTE["bg"], title=dict(text=title, x=0.0, xanchor="left", font=dict(size=14, color=PALETTE["text"])), ) return fig def montana_with_hospitals( counties: pd.DataFrame, hospitals: pd.DataFrame, *, height: int = 620, facility_type_filter: Optional[list[str]] = None, ) -> go.Figure: cdf = counties.copy() cdf["rurality_code"] = cdf["rurality"].map({"Urban": 0, "Rural": 1, "Tribal": 2}) if facility_type_filter: hdf = hospitals[hospitals["facility_type"].isin(facility_type_filter)].copy() else: hdf = hospitals.copy() hdf["marker_size"] = np.clip(hdf["staffed_beds"] / 4 + 6, 6, 32) type_color = { "Tertiary": "#5BA3DA", "PPS": "#7AB6E0", "Sole Community": "#9CC8E8", "CAH": "#E0B458", "Tribal/IHS": "#E15A63", } fig = go.Figure() # Choropleth base — subtle differentiation between rurality strata. fig.add_choroplethmapbox( geojson="https://raw.githubusercontent.com/plotly/datasets/master/geojson-counties-fips.json", locations=cdf["fips"], z=cdf["rurality_code"], colorscale=[[0, "#1A2030"], [0.5, "#2A2438"], [1, "#3A2030"]], marker_opacity=0.9, marker_line_width=0.5, marker_line_color="#0B0E13", showscale=False, text=cdf["county_name"] + " — pop " + cdf["population_2020"].astype(int).map("{:,}".format) + " (" + cdf["rurality"] + ")", hovertemplate="%{text}", name="Counties", ) for ftype, sub in hdf.groupby("facility_type"): fig.add_scattermapbox( lat=sub["lat"], lon=sub["lon"], mode="markers", marker=dict(size=sub["marker_size"], color=type_color.get(ftype, "#9CA3AF"), opacity=0.92), text=sub["facility_name"] + "
" + sub["county_name"] + " County" + "
" + ftype + " — " + sub["staffed_beds"].astype(str) + " staffed beds
CCN " + sub["ccn"].astype(str) + "
" + sub["ownership"], hovertemplate="%{text}", name=ftype, ) fig.update_layout( mapbox=dict( style="carto-darkmatter", center={"lat": 47.0, "lon": -110.0}, zoom=5.4, ), paper_bgcolor=PALETTE["bg"], plot_bgcolor=PALETTE["bg"], font=dict(color=PALETTE["text"]), margin=dict(l=0, r=0, t=10, b=0), height=height, legend=dict( orientation="h", yanchor="bottom", y=0.0, xanchor="left", x=0.0, bgcolor="rgba(11,14,19,0.85)", bordercolor=PALETTE["border"], borderwidth=1, font=dict(color=PALETTE["text"]), ), ) return fig def stacked_bar(df: pd.DataFrame, x: str, ys: list[str], *, title: str = "", y_label: str = "", height: int = 360) -> go.Figure: fig = go.Figure() palette = [PALETTE["secondary"], "#7AB6E0", "#9CC8E8", PALETTE["primary"], PALETTE["accent"], "#A487E0"] for i, y in enumerate(ys): fig.add_bar(x=df[x], y=df[y], name=y, marker_color=palette[i % len(palette)]) fig.update_layout(barmode="stack") fig.update_yaxes(title=y_label) fig.update_xaxes(title=x) return _layout(fig, height=height, title=dict(text=title))