Source code for phenotypic.sdk_.viz.figures._theme

"""Centralized Plotly theme for PhenoTypic figures.

Registers a single ``plotly.io`` template named ``"phenotypic"`` carrying
the brand palette, Okabe-Ito data series order, and the IBM Plex Sans
typography defined in ``DESIGN.md`` (and mirrored by
``phenotypic.gui._design``). The ``@figure`` decorator applies this
theme to every figure via :func:`apply_theme`, so individual call sites
never re-spell hex codes, fonts, or axis styling.

Design-token sources (single source of truth is ``DESIGN.md``):

* Brand / UI palette -- ``01 -- Color Palette / Primary Colors``
  (navy ``#003660``, blue ``#1b75bc``, gold ``#febc11``).
* Data series order -- ``06 -- Data Visualization / Categorical Series
  Order`` and the matplotlib ``OKABE_ITO`` block in ``07 -- Code
  Integration`` (navy-anchored: navy, orange, sky, green, blue, purple,
  vermilion).
* Typography -- ``02 -- Typography`` (IBM Plex Sans body stack).
* Chart styling -- ``06 -- Data Visualization / Chart Styling Rules``
  (gridlines ``#e8ecf2``, axes ``#dde3ed``, muted axis labels
  ``#8892a4``, navy title).

This module imports only ``plotly`` and the stdlib. It is deliberately
free of ``dash`` / project imports so the theme stays cheap to import
from any layer (CLI, GUI, notebooks, tests).
"""

from __future__ import annotations

import plotly.graph_objects as go
import plotly.io as pio

__all__ = [
    "PHENOTYPIC_TEMPLATE_NAME",
    "NAVY",
    "BLUE",
    "GOLD",
    "WHITE",
    "BG",
    "GRID",
    "AXIS",
    "MUTED",
    "BODY",
    "OKABE_ITO",
    "SEQUENTIAL_COLORSCALE",
    "FAILED_FILL",
    "FONT_FAMILY",
    "FONT_FAMILY_MONO",
    "register_phenotypic_template",
    "apply_theme",
]

#: Name the template is registered under in :data:`plotly.io.templates`.
PHENOTYPIC_TEMPLATE_NAME: str = "phenotypic"

# ---------------------------------------------------------------------------
# Brand / UI palette (DESIGN.md "01 -- Color Palette / Primary Colors")
# ---------------------------------------------------------------------------

#: Brand navy -- headings, title font, primary anchor.
NAVY: str = "#003660"
#: Brand blue -- accents, interactive states.
BLUE: str = "#1b75bc"
#: Brand gold -- highlights, emphasized accents.
GOLD: str = "#febc11"

# ---------------------------------------------------------------------------
# Surface / neutral tokens (DESIGN.md "Surface & Neutral Tokens" +
# "Chart Styling Rules")
# ---------------------------------------------------------------------------

#: Card / plot surface.
WHITE: str = "#ffffff"
#: App background (paper backdrop behind the plotting area; DESIGN.md canvas).
BG: str = "#FBFEF8"
#: Chart gridlines (``--color-rule``).
GRID: str = "#e8ecf2"
#: Axis lines (``--color-border``).
AXIS: str = "#dde3ed"
#: Secondary text -- axis labels, captions (``--color-muted``).
MUTED: str = "#8892a4"
#: Primary body text (``--color-body``).
BODY: str = "#2e3a4e"

# ---------------------------------------------------------------------------
# Okabe-Ito data palette (DESIGN.md "Categorical Series Order" + the
# matplotlib OKABE_ITO block in "07 -- Code Integration")
# ---------------------------------------------------------------------------
#
# Colorblind-safe series order is fixed and must not be reordered:
# navy (series 1, UI-harmonized), orange, sky, green, blue, purple, then
# vermilion reserved for the error/alert series. Black closes the cycle so
# overflow series past the named seven fall back to a neutral ink rather
# than wrapping to navy.

#: Categorical color cycle for data series, in DESIGN.md's fixed order.
OKABE_ITO: tuple[str, ...] = (
    "#003660",  # navy      -- series 1, UI-harmonized
    "#E69F00",  # orange    -- series 2
    "#56B4E9",  # sky blue  -- series 3
    "#009E73",  # green     -- series 4
    "#0072B2",  # blue      -- series 5
    "#CC79A7",  # purple    -- series 6
    "#D55E00",  # vermilion -- error / alert series
    "#000000",  # black     -- overflow / ink
)

#: Single-variable sequential colorscale (DESIGN.md "06 -- Heatmap Colorscale"
#: and "12 -- Continuous Colorbar"): near-transparent navy -> sky -> full navy.
#: The one continuous ramp for plate maps / heatmaps / intensity overlays;
#: never build a sequential scale from the categorical ``OKABE_ITO`` order.
SEQUENTIAL_COLORSCALE: tuple[tuple[float, str], ...] = (
    (0.0, "rgba(0,54,96,0.08)"),
    (0.5, "#56B4E9"),
    (1.0, "#003660"),
)

#: Fill for failed / null / removed cells on a heatmap or plate map: vermilion
#: at 70% opacity (DESIGN.md "06" / "10"). Reads as a non-data exclusion against
#: the navy-to-blue ramp under every CB type.
FAILED_FILL: str = "rgba(213,94,0,0.7)"

# ---------------------------------------------------------------------------
# Typography (DESIGN.md "02 -- Typography")
# ---------------------------------------------------------------------------

#: Body font stack (IBM Plex Sans) matching ``phenotypic.gui._design``'s
#: ``FONT_FAMILY_BODY``. The ``test_font_family_does_not_drift_from_gui_design``
#: guard keeps the two in sync. Used for chart titles, axis titles, and legend
#: series names.
FONT_FAMILY: str = (
    "'IBM Plex Sans', -apple-system, BlinkMacSystemFont, "
    '"Segoe UI", "Helvetica Neue", Arial, sans-serif'
)

#: Mono font stack (JetBrains Mono) matching ``phenotypic.gui._design``'s
#: ``FONT_FAMILY_MONO``. Per DESIGN.md "02", all numeric data -- axis tick
#: labels, hover values, colorbar ticks, annotations -- render in mono. Kept in
#: sync with the GUI by ``test_mono_font_does_not_drift_from_gui_design``.
FONT_FAMILY_MONO: str = (
    "'JetBrains Mono', ui-monospace, \"SFMono-Regular\", Menlo, "
    'Consolas, "Liberation Mono", "Courier New", monospace'
)


[docs] def register_phenotypic_template() -> None: """Build and register the ``"phenotypic"`` Plotly template. Constructs a :class:`plotly.graph_objects.layout.Template` carrying the brand palette, Okabe-Ito ``colorway``, IBM Plex Sans typography, and the axis / grid / legend styling from ``DESIGN.md``, then stores it in :data:`plotly.io.templates` under :data:`PHENOTYPIC_TEMPLATE_NAME`. The function is idempotent: it rebuilds and re-assigns the template on every call, so importing this module (which calls it once) and any later explicit calls leave a single, identical entry. It is invoked automatically at import time. Example: >>> import plotly.io as pio >>> register_phenotypic_template() >>> "phenotypic" in pio.templates True """ # Numeric data renders in mono (axis ticks, hover, colorbar, annotations); # titles, axis titles, and legend series names render in the body font # (DESIGN.md "02 -- Typography" / "06 -- Chart Styling Rules"). template = go.layout.Template( layout=dict( colorway=list(OKABE_ITO), font=dict(family=FONT_FAMILY_MONO, color=BODY), paper_bgcolor=BG, plot_bgcolor=WHITE, title=dict(font=dict(family=FONT_FAMILY, color=NAVY)), xaxis=dict( gridcolor=GRID, linecolor=AXIS, zerolinecolor=GRID, tickfont=dict(family=FONT_FAMILY_MONO, color=MUTED), title=dict(font=dict(family=FONT_FAMILY, color=BODY)), ), yaxis=dict( gridcolor=GRID, linecolor=AXIS, zerolinecolor=GRID, tickfont=dict(family=FONT_FAMILY_MONO, color=MUTED), title=dict(font=dict(family=FONT_FAMILY, color=BODY)), ), legend=dict(font=dict(family=FONT_FAMILY, color=BODY)), ) ) pio.templates[PHENOTYPIC_TEMPLATE_NAME] = template
[docs] def apply_theme(fig: go.Figure) -> go.Figure: """Apply the PhenoTypic theme to ``fig`` in place and return it. Ensures the ``"phenotypic"`` template is registered, then sets the figure to use it *composed* with Plotly's default template (``"plotly+phenotypic"``). Composition preserves Plotly's built-in trace defaults while letting the PhenoTypic layer override layout styling (colorway, fonts, axes, legend). Traces (``fig.data``) are never read, deleted, or mutated; only ``fig.layout.template`` is set. The function returns the same figure object it was given and is idempotent -- applying it repeatedly leaves a single combined template and the traces untouched. Args: fig: The figure to theme. Returns: The same ``fig`` object, with its layout template set to the composed ``"plotly+phenotypic"`` template. Example: >>> import plotly.graph_objects as go >>> fig = go.Figure(go.Scatter(x=[1, 2], y=[3, 4])) >>> themed = apply_theme(fig) >>> themed is fig True >>> len(themed.data) 1 """ if PHENOTYPIC_TEMPLATE_NAME not in pio.templates: register_phenotypic_template() fig.layout.template = f"plotly+{PHENOTYPIC_TEMPLATE_NAME}" return fig
# Register the template once at import time so importing this module is # sufficient to make the "phenotypic" template available. register_phenotypic_template()