Spaces:
Building
Building
| """ | |
| ui_theme.py | |
| ----------- | |
| Miami University branded theme and styling utilities for Streamlit apps. | |
| Provides: | |
| - CSS injection for Streamlit components (buttons, sidebar, metrics, cards) | |
| - Matplotlib rcParams styled with Miami branding | |
| - ColorBrewer palette loading via palettable with graceful fallback | |
| - Color-swatch preview figure generation | |
| """ | |
| from __future__ import annotations | |
| import itertools | |
| from typing import Dict, List, Optional | |
| import matplotlib.figure | |
| import matplotlib.pyplot as plt | |
| import streamlit as st | |
| # --------------------------------------------------------------------------- | |
| # Brand constants — Miami University (Ohio) official palette | |
| # --------------------------------------------------------------------------- | |
| MIAMI_RED: str = "#C41230" | |
| MIAMI_BLACK: str = "#000000" | |
| MIAMI_WHITE: str = "#FFFFFF" | |
| # Secondary palette tokens used only inside the CSS below. | |
| _WHITE = "#FFFFFF" | |
| _BLACK = "#000000" | |
| _LIGHT_GRAY = "#F5F5F5" | |
| _BORDER_GRAY = "#E0E0E0" | |
| _DARK_TEXT = "#000000" | |
| _HOVER_RED = "#9E0E26" | |
| # --------------------------------------------------------------------------- | |
| # Streamlit CSS injection | |
| # --------------------------------------------------------------------------- | |
| def apply_miami_theme() -> None: | |
| """Inject Miami-branded CSS into the active Streamlit page. | |
| Styles affected: | |
| * Primary buttons -- Miami Red background with white text | |
| * Card containers -- subtle border and rounded corners | |
| * Sidebar header -- Miami Red accent bar | |
| * Metric cards -- light background with left red accent | |
| """ | |
| css = f""" | |
| <style> | |
| /* ---- Primary buttons ---- */ | |
| .stButton > button[kind="primary"], | |
| .stButton > button {{ | |
| background-color: {MIAMI_RED}; | |
| color: {_WHITE}; | |
| border: none; | |
| border-radius: 6px; | |
| padding: 0.5rem 1.25rem; | |
| font-weight: 600; | |
| transition: background-color 0.2s ease; | |
| }} | |
| .stButton > button:hover {{ | |
| background-color: {_HOVER_RED}; | |
| color: {_WHITE}; | |
| border: none; | |
| }} | |
| .stButton > button:active, | |
| .stButton > button:focus {{ | |
| background-color: {_HOVER_RED}; | |
| color: {_WHITE}; | |
| box-shadow: none; | |
| }} | |
| /* ---- Expander card borders (box-shadow to avoid layout shift) ---- */ | |
| div[data-testid="stExpander"] {{ | |
| box-shadow: 0 0 0 1px {_BORDER_GRAY}; | |
| border-radius: 8px; | |
| }} | |
| /* ---- Sidebar header accent ---- */ | |
| section[data-testid="stSidebar"] > div:first-child {{ | |
| border-top: 4px solid {MIAMI_RED}; | |
| }} | |
| section[data-testid="stSidebar"] h1, | |
| section[data-testid="stSidebar"] h2, | |
| section[data-testid="stSidebar"] h3 {{ | |
| color: {MIAMI_RED}; | |
| }} | |
| /* ---- Metric cards (inset shadow for left accent, no layout impact) ---- */ | |
| div[data-testid="stMetric"] {{ | |
| background-color: {_LIGHT_GRAY}; | |
| box-shadow: inset 4px 0 0 0 {MIAMI_RED}; | |
| border-radius: 6px; | |
| }} | |
| div[data-testid="stMetric"] label {{ | |
| color: {_BLACK}; | |
| font-size: 0.85rem; | |
| }} | |
| div[data-testid="stMetric"] div[data-testid="stMetricValue"] {{ | |
| color: {_BLACK}; | |
| font-weight: 700; | |
| }} | |
| /* ---- Sidebar developer card ---- */ | |
| .dev-card {{ | |
| padding: 0; | |
| background: transparent; | |
| }} | |
| .dev-row {{ | |
| display: flex; | |
| gap: 0.5rem; | |
| align-items: flex-start; | |
| }} | |
| .dev-avatar {{ | |
| width: 28px; | |
| height: 28px; | |
| min-width: 28px; | |
| fill: {_BLACK}; | |
| }} | |
| .dev-name {{ | |
| font-weight: 600; | |
| color: {_BLACK}; | |
| font-size: 0.82rem; | |
| line-height: 1.3; | |
| }} | |
| .dev-role {{ | |
| font-size: 0.7rem; | |
| color: #6c757d; | |
| line-height: 1.3; | |
| }} | |
| .dev-links {{ | |
| display: flex; | |
| gap: 0.3rem; | |
| flex-wrap: wrap; | |
| margin-top: 0.35rem; | |
| }} | |
| .dev-link {{ | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 0.2rem; | |
| padding: 0.15rem 0.4rem; | |
| border: 1px solid #dee2e6; | |
| border-radius: 4px; | |
| font-size: 0.65rem; | |
| color: {_BLACK}; | |
| text-decoration: none; | |
| background: {_WHITE}; | |
| line-height: 1.4; | |
| white-space: nowrap; | |
| }} | |
| .dev-link svg {{ | |
| width: 11px; | |
| height: 11px; | |
| fill: currentColor; | |
| }} | |
| .dev-link:hover {{ | |
| border-color: {MIAMI_RED}; | |
| color: {MIAMI_RED}; | |
| }} | |
| </style> | |
| """ | |
| st.markdown(css, unsafe_allow_html=True) | |
| # --------------------------------------------------------------------------- | |
| # Matplotlib style dictionary | |
| # --------------------------------------------------------------------------- | |
| def get_miami_mpl_style() -> Dict[str, object]: | |
| """Return a dictionary of matplotlib rcParams for Miami branding. | |
| Usage:: | |
| import matplotlib as mpl | |
| mpl.rcParams.update(get_miami_mpl_style()) | |
| Or apply to a single figure:: | |
| with mpl.rc_context(get_miami_mpl_style()): | |
| fig, ax = plt.subplots() | |
| ... | |
| """ | |
| return { | |
| # Figure | |
| "figure.facecolor": _WHITE, | |
| "figure.edgecolor": _WHITE, | |
| "figure.figsize": (10, 5), | |
| "figure.dpi": 100, | |
| # Axes | |
| "axes.facecolor": _WHITE, | |
| "axes.edgecolor": _BLACK, | |
| "axes.labelcolor": _BLACK, | |
| "axes.titlecolor": MIAMI_RED, | |
| "axes.labelsize": 12, | |
| "axes.titlesize": 14, | |
| "axes.titleweight": "bold", | |
| "axes.prop_cycle": plt.cycler( | |
| color=[MIAMI_RED, _BLACK, "#4E79A7", "#F28E2B", "#76B7B2"] | |
| ), | |
| # Grid | |
| "axes.grid": True, | |
| "grid.color": _BORDER_GRAY, | |
| "grid.linestyle": "--", | |
| "grid.linewidth": 0.6, | |
| "grid.alpha": 0.7, | |
| # Ticks | |
| "xtick.color": _BLACK, | |
| "ytick.color": _BLACK, | |
| "xtick.labelsize": 10, | |
| "ytick.labelsize": 10, | |
| # Legend | |
| "legend.fontsize": 10, | |
| "legend.frameon": True, | |
| "legend.framealpha": 0.9, | |
| "legend.edgecolor": _BORDER_GRAY, | |
| # Font | |
| "font.size": 11, | |
| "font.family": "sans-serif", | |
| # Savefig | |
| "savefig.dpi": 150, | |
| "savefig.bbox": "tight", | |
| } | |
| # --------------------------------------------------------------------------- | |
| # ColorBrewer palette loading | |
| # --------------------------------------------------------------------------- | |
| # Mapping of short friendly names to palettable module paths. | |
| _PALETTE_MAP: Dict[str, str] = { | |
| "Set1": "colorbrewer.qualitative.Set1", | |
| "Set2": "colorbrewer.qualitative.Set2", | |
| "Set3": "colorbrewer.qualitative.Set3", | |
| "Dark2": "colorbrewer.qualitative.Dark2", | |
| "Paired": "colorbrewer.qualitative.Paired", | |
| "Pastel1": "colorbrewer.qualitative.Pastel1", | |
| "Pastel2": "colorbrewer.qualitative.Pastel2", | |
| "Accent": "colorbrewer.qualitative.Accent", | |
| "Tab10": "colorbrewer.qualitative.Set1", # fallback alias | |
| } | |
| _FALLBACK_COLORS: List[str] = [ | |
| MIAMI_RED, | |
| MIAMI_BLACK, | |
| "#4E79A7", | |
| "#F28E2B", | |
| "#76B7B2", | |
| "#E15759", | |
| "#59A14F", | |
| "#EDC948", | |
| ] | |
| def _resolve_palette(name: str) -> Optional[List[str]]: | |
| """Dynamically import a palettable ColorBrewer palette by *name*. | |
| Palettable organises palettes by maximum number of classes, e.g. | |
| ``colorbrewer.qualitative.Set2_8``. We find the variant with the | |
| most colours available so the caller gets the richest palette. | |
| """ | |
| import importlib | |
| module_path = _PALETTE_MAP.get(name) | |
| if module_path is None: | |
| # Try a direct guess: colorbrewer.qualitative.<Name> | |
| module_path = f"colorbrewer.qualitative.{name}" | |
| # palettable stores each size variant as <Name>_<N> inside the module. | |
| try: | |
| mod = importlib.import_module(f"palettable.{module_path}") | |
| except (ImportError, ModuleNotFoundError): | |
| return None | |
| # Discover the variant with the most colours. | |
| best = None | |
| best_n = 0 | |
| base = name.split(".")[-1] if "." in name else name | |
| for attr_name in dir(mod): | |
| if not attr_name.startswith(base + "_"): | |
| continue | |
| try: | |
| suffix = int(attr_name.split("_")[-1]) | |
| except ValueError: | |
| continue | |
| if suffix > best_n: | |
| best_n = suffix | |
| best = attr_name | |
| if best is None: | |
| return None | |
| palette_obj = getattr(mod, best, None) | |
| if palette_obj is None: | |
| return None | |
| return [ | |
| "#{:02X}{:02X}{:02X}".format(*rgb) for rgb in palette_obj.colors | |
| ] | |
| def get_palette_colors(name: str = "Set2", n: int = 8) -> List[str]: | |
| """Load *n* hex colour strings from a ColorBrewer palette. | |
| Parameters | |
| ---------- | |
| name: | |
| Friendly palette name such as ``"Set2"``, ``"Dark2"``, ``"Paired"``. | |
| n: | |
| Number of colours required. If *n* exceeds the palette length the | |
| colours are cycled. | |
| Returns | |
| ------- | |
| list[str] | |
| List of *n* hex colour strings (e.g. ``["#66C2A5", ...]``). | |
| Notes | |
| ----- | |
| If the requested palette cannot be found, a sensible fallback list is | |
| returned so that calling code never receives an empty list. | |
| """ | |
| n = max(1, n) | |
| colors = _resolve_palette(name) | |
| if colors is None: | |
| colors = _FALLBACK_COLORS | |
| # Cycle if the caller needs more colours than the palette provides. | |
| cycled = list(itertools.islice(itertools.cycle(colors), n)) | |
| return cycled | |
| # --------------------------------------------------------------------------- | |
| # Palette preview swatch | |
| # --------------------------------------------------------------------------- | |
| def render_palette_preview( | |
| colors: List[str], | |
| swatch_width: float = 1.0, | |
| swatch_height: float = 0.4, | |
| ) -> matplotlib.figure.Figure: | |
| """Create a small matplotlib figure showing colour swatches. | |
| Parameters | |
| ---------- | |
| colors: | |
| List of hex colour strings to display. | |
| swatch_width: | |
| Width of each individual swatch in inches. | |
| swatch_height: | |
| Height of the swatch strip in inches. | |
| Returns | |
| ------- | |
| matplotlib.figure.Figure | |
| A Figure instance ready to be passed to ``st.pyplot()`` or saved. | |
| """ | |
| n = len(colors) | |
| fig_width = max(swatch_width * n, 2.0) | |
| fig, ax = plt.subplots( | |
| figsize=(fig_width, swatch_height + 0.3), dpi=100 | |
| ) | |
| for i, colour in enumerate(colors): | |
| ax.add_patch( | |
| plt.Rectangle( | |
| (i, 0), | |
| width=1, | |
| height=1, | |
| facecolor=colour, | |
| edgecolor=_WHITE, | |
| linewidth=1.5, | |
| ) | |
| ) | |
| ax.set_xlim(0, n) | |
| ax.set_ylim(0, 1) | |
| ax.set_aspect("equal") | |
| ax.axis("off") | |
| fig.subplots_adjust(left=0, right=1, top=1, bottom=0) | |
| plt.close(fig) # prevent display in non-Streamlit contexts | |
| return fig | |