polytope-theme 0.1.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- polytope_theme/__init__.py +107 -0
- polytope_theme/_tokens.py +127 -0
- polytope_theme/gradio_theme.py +181 -0
- polytope_theme/mpl.py +275 -0
- polytope_theme/plotly_theme.py +133 -0
- polytope_theme/streamlit_theme.py +100 -0
- polytope_theme/styles/polytope-dark.mplstyle +35 -0
- polytope_theme/styles/polytope-light.mplstyle +35 -0
- polytope_theme-0.1.4.dist-info/METADATA +148 -0
- polytope_theme-0.1.4.dist-info/RECORD +13 -0
- polytope_theme-0.1.4.dist-info/WHEEL +4 -0
- polytope_theme-0.1.4.dist-info/licenses/LICENSE +202 -0
- polytope_theme-0.1.4.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Polytope for Python: palette data and chart/app themes.
|
|
2
|
+
|
|
3
|
+
The palette data is generated from tokens/tokens.json by scripts/build.mjs and
|
|
4
|
+
lives in ``_tokens.py``. This module re-exports it and adds small accessors.
|
|
5
|
+
Library integrations live in submodules so that importing this package never
|
|
6
|
+
imports matplotlib, plotly, gradio or streamlit:
|
|
7
|
+
|
|
8
|
+
- ``polytope_theme.mpl`` matplotlib style, colormaps, direct labels
|
|
9
|
+
- ``polytope_theme.plotly_theme`` plotly template
|
|
10
|
+
- ``polytope_theme.gradio_theme`` gradio theme
|
|
11
|
+
- ``polytope_theme.streamlit_theme`` streamlit config.toml
|
|
12
|
+
|
|
13
|
+
Chart rules encoded here:
|
|
14
|
+
|
|
15
|
+
- Categorical colors are assigned in fixed order and never cycled. At most 8
|
|
16
|
+
series; fold the rest into "Other" or split into small multiples.
|
|
17
|
+
- Scatter plots and small multiples use only the first 3 slots.
|
|
18
|
+
- Sequential data uses the cobalt ramp (SEQUENTIAL).
|
|
19
|
+
- Diverging data runs cobalt -> gray midpoint -> tomato (DIVERGING).
|
|
20
|
+
- STATUS colors are reserved for good/warning/serious/critical and must be
|
|
21
|
+
paired with a label or marker, never used as the only signal.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from ._tokens import CATEGORICAL, COLORS, DIVERGING, FONTS, SEQUENTIAL, STATUS, SURFACE
|
|
27
|
+
|
|
28
|
+
__version__ = "0.1.4"
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"CATEGORICAL",
|
|
32
|
+
"COLORS",
|
|
33
|
+
"DIVERGING",
|
|
34
|
+
"FONTS",
|
|
35
|
+
"MAX_SERIES",
|
|
36
|
+
"MODES",
|
|
37
|
+
"SCATTER_SLOTS",
|
|
38
|
+
"SEQUENTIAL",
|
|
39
|
+
"STATUS",
|
|
40
|
+
"SURFACE",
|
|
41
|
+
"__version__",
|
|
42
|
+
"categorical",
|
|
43
|
+
"colors",
|
|
44
|
+
"use",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
MODES = ("light", "dark")
|
|
48
|
+
|
|
49
|
+
# Number of categorical slots. Charts with more series must be restructured.
|
|
50
|
+
MAX_SERIES = len(CATEGORICAL["names"])
|
|
51
|
+
|
|
52
|
+
# Scatter plots and small multiples use only the first SCATTER_SLOTS colors.
|
|
53
|
+
SCATTER_SLOTS = 3
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _check_mode(mode: str) -> str:
|
|
57
|
+
if mode not in MODES:
|
|
58
|
+
raise ValueError(f"mode must be one of {MODES!r}, got {mode!r}")
|
|
59
|
+
return mode
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def colors(mode: str = "light") -> dict[str, str]:
|
|
63
|
+
"""Return the semantic UI colors for ``mode`` as a new dict.
|
|
64
|
+
|
|
65
|
+
Keys match the token names, for example ``bg``, ``text``, ``text_muted``,
|
|
66
|
+
``rule``, ``accent``, ``link`` and ``note``. Chart backgrounds use
|
|
67
|
+
``SURFACE[mode]``, which differs from ``bg`` in dark mode.
|
|
68
|
+
"""
|
|
69
|
+
return dict(COLORS[_check_mode(mode)])
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def categorical(mode: str = "light", n: int | None = None) -> list[str]:
|
|
73
|
+
"""Return the first ``n`` categorical colors in their fixed order.
|
|
74
|
+
|
|
75
|
+
With ``n=None`` all 8 colors are returned. Colors are never cycled, so
|
|
76
|
+
asking for more than 8 raises ValueError.
|
|
77
|
+
"""
|
|
78
|
+
palette = CATEGORICAL[_check_mode(mode)]
|
|
79
|
+
if n is None:
|
|
80
|
+
return list(palette)
|
|
81
|
+
if not isinstance(n, int) or isinstance(n, bool) or n < 0:
|
|
82
|
+
raise ValueError(f"n must be a non-negative integer or None, got {n!r}")
|
|
83
|
+
if n > MAX_SERIES:
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"categorical() supports at most {MAX_SERIES} series, got {n}. "
|
|
86
|
+
"Colors are not cycled. Fold the extra series into an \"Other\" "
|
|
87
|
+
"group, or split the chart into small multiples."
|
|
88
|
+
)
|
|
89
|
+
return list(palette[:n])
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def use(mode: str = "light") -> None:
|
|
93
|
+
"""Apply the matplotlib style for ``mode`` and register the colormaps.
|
|
94
|
+
|
|
95
|
+
matplotlib is imported only when this function is called.
|
|
96
|
+
"""
|
|
97
|
+
_check_mode(mode)
|
|
98
|
+
try:
|
|
99
|
+
import matplotlib # noqa: F401
|
|
100
|
+
except ImportError as exc:
|
|
101
|
+
raise ImportError(
|
|
102
|
+
"polytope_theme.use() needs matplotlib. Install it with "
|
|
103
|
+
"pip install \"polytope-theme[matplotlib]\"."
|
|
104
|
+
) from exc
|
|
105
|
+
from . import mpl
|
|
106
|
+
|
|
107
|
+
mpl.use(mode)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Generated from tokens/tokens.json by scripts/build.mjs. Do not edit by hand."""
|
|
2
|
+
|
|
3
|
+
COLORS = {
|
|
4
|
+
"light": {
|
|
5
|
+
"bg": "#FFFFFF",
|
|
6
|
+
"surface_sunken": "#EEF0EF",
|
|
7
|
+
"panel": "#141414",
|
|
8
|
+
"panel_text": "#F6F7F6",
|
|
9
|
+
"panel_muted": "#B9BAB4",
|
|
10
|
+
"panel_rule": "#4A4A47",
|
|
11
|
+
"text": "#141414",
|
|
12
|
+
"text_muted": "#4A4A47",
|
|
13
|
+
"text_subtle": "#6B6B66",
|
|
14
|
+
"rule_strong": "#141414",
|
|
15
|
+
"rule": "#D9DAD6",
|
|
16
|
+
"accent": "#D9412B",
|
|
17
|
+
"accent_text": "#B8321F",
|
|
18
|
+
"link": "#0F6E73",
|
|
19
|
+
"link_hover": "#141414",
|
|
20
|
+
"note": "#E9A81C",
|
|
21
|
+
"note_soft": "#FBEFD2",
|
|
22
|
+
"focus": "#0F6E73",
|
|
23
|
+
"focus_on_panel": "#F2B83A",
|
|
24
|
+
"selection": "#E9A81C",
|
|
25
|
+
"code_comment": "#F2B83A",
|
|
26
|
+
"code_keyword": "#F0654F",
|
|
27
|
+
"code_string": "#5CC2C7",
|
|
28
|
+
"code_number": "#95AEE3"
|
|
29
|
+
},
|
|
30
|
+
"dark": {
|
|
31
|
+
"bg": "#161615",
|
|
32
|
+
"surface_sunken": "#1C1C1A",
|
|
33
|
+
"panel": "#262624",
|
|
34
|
+
"panel_text": "#F6F7F6",
|
|
35
|
+
"panel_muted": "#B9BAB4",
|
|
36
|
+
"panel_rule": "#4A4A47",
|
|
37
|
+
"text": "#F2F2EE",
|
|
38
|
+
"text_muted": "#B9BAB4",
|
|
39
|
+
"text_subtle": "#8F8F89",
|
|
40
|
+
"rule_strong": "#F2F2EE",
|
|
41
|
+
"rule": "#3A3A37",
|
|
42
|
+
"accent": "#F0654F",
|
|
43
|
+
"accent_text": "#F0654F",
|
|
44
|
+
"link": "#5CC2C7",
|
|
45
|
+
"link_hover": "#F2F2EE",
|
|
46
|
+
"note": "#E9A81C",
|
|
47
|
+
"note_soft": "#3A2F14",
|
|
48
|
+
"focus": "#F2B83A",
|
|
49
|
+
"focus_on_panel": "#F2B83A",
|
|
50
|
+
"selection": "#E9A81C",
|
|
51
|
+
"code_comment": "#F2B83A",
|
|
52
|
+
"code_keyword": "#F0654F",
|
|
53
|
+
"code_string": "#5CC2C7",
|
|
54
|
+
"code_number": "#95AEE3"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
CATEGORICAL = {
|
|
59
|
+
"light": [
|
|
60
|
+
"#D9412B",
|
|
61
|
+
"#009B93",
|
|
62
|
+
"#3A62C4",
|
|
63
|
+
"#4C8C2B",
|
|
64
|
+
"#2E8FD0",
|
|
65
|
+
"#C28A0A",
|
|
66
|
+
"#9A3E96",
|
|
67
|
+
"#E06C91"
|
|
68
|
+
],
|
|
69
|
+
"dark": [
|
|
70
|
+
"#E04B34",
|
|
71
|
+
"#00A39A",
|
|
72
|
+
"#5A7FDB",
|
|
73
|
+
"#5A9A35",
|
|
74
|
+
"#2E8FD0",
|
|
75
|
+
"#B98200",
|
|
76
|
+
"#9A52C8",
|
|
77
|
+
"#D9658B"
|
|
78
|
+
],
|
|
79
|
+
"names": [
|
|
80
|
+
"tomato",
|
|
81
|
+
"teal",
|
|
82
|
+
"cobalt",
|
|
83
|
+
"grass",
|
|
84
|
+
"sky",
|
|
85
|
+
"mustard",
|
|
86
|
+
"plum",
|
|
87
|
+
"pink"
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
SEQUENTIAL = [
|
|
92
|
+
"#E3EAF8",
|
|
93
|
+
"#C2D1F0",
|
|
94
|
+
"#95AEE3",
|
|
95
|
+
"#6A8BD4",
|
|
96
|
+
"#3A62C4",
|
|
97
|
+
"#2B4A99",
|
|
98
|
+
"#1D336B"
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
DIVERGING = [
|
|
102
|
+
"#2B4A99",
|
|
103
|
+
"#6A8BD4",
|
|
104
|
+
"#C2D1F0",
|
|
105
|
+
"#D9DAD6",
|
|
106
|
+
"#F6C3B9",
|
|
107
|
+
"#E8765F",
|
|
108
|
+
"#B8321F"
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
STATUS = {
|
|
112
|
+
"good": "#1E7A3C",
|
|
113
|
+
"warning": "#F2B71F",
|
|
114
|
+
"serious": "#E8743B",
|
|
115
|
+
"critical": "#B3261E"
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
SURFACE = {
|
|
119
|
+
"light": "#FFFFFF",
|
|
120
|
+
"dark": "#1C1C1A"
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
FONTS = {
|
|
124
|
+
"display": "Ultra",
|
|
125
|
+
"text": "Libre Franklin",
|
|
126
|
+
"mono": "JetBrains Mono"
|
|
127
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""gradio integration: a theme with square corners and exhibit-black buttons.
|
|
2
|
+
|
|
3
|
+
Usage (gradio 6 passes the theme to ``launch``; gradio 4 and 5 take it in
|
|
4
|
+
``gr.Blocks(theme=...)``)::
|
|
5
|
+
|
|
6
|
+
import gradio as gr
|
|
7
|
+
from polytope_theme.gradio_theme import theme
|
|
8
|
+
|
|
9
|
+
with gr.Blocks() as demo:
|
|
10
|
+
...
|
|
11
|
+
demo.launch(theme=theme())
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import inspect
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from ._tokens import CATEGORICAL, COLORS, FONTS
|
|
20
|
+
|
|
21
|
+
__all__ = ["theme"]
|
|
22
|
+
|
|
23
|
+
_MODES = ("light", "dark", "auto")
|
|
24
|
+
_INK = "#141414"
|
|
25
|
+
_WHITE = "#FFFFFF"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _require():
|
|
29
|
+
try:
|
|
30
|
+
import gradio as gr
|
|
31
|
+
except ImportError as exc:
|
|
32
|
+
raise ImportError(
|
|
33
|
+
"polytope_theme.gradio_theme needs gradio. Install it with "
|
|
34
|
+
"pip install \"polytope-theme[gradio]\"."
|
|
35
|
+
) from exc
|
|
36
|
+
return gr
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _mix(a: str, b: str, t: float) -> str:
|
|
40
|
+
"""Blend hex color ``a`` toward ``b`` by fraction ``t``."""
|
|
41
|
+
ca = [int(a[i : i + 2], 16) for i in (1, 3, 5)]
|
|
42
|
+
cb = [int(b[i : i + 2], 16) for i in (1, 3, 5)]
|
|
43
|
+
return "#" + "".join(f"{round(x + (y - x) * t):02X}" for x, y in zip(ca, cb))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _ramp(base: str) -> list[str]:
|
|
47
|
+
"""11-step c50..c950 scale around ``base`` (c500), tinted toward white and ink."""
|
|
48
|
+
lighter = [_mix(base, _WHITE, t) for t in (0.92, 0.84, 0.66, 0.46, 0.24)]
|
|
49
|
+
darker = [_mix(base, _INK, t) for t in (0.16, 0.32, 0.48, 0.62, 0.74)]
|
|
50
|
+
return lighter + [base] + darker
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Warm gray scale taken straight from the token values.
|
|
54
|
+
_NEUTRAL = [
|
|
55
|
+
COLORS["light"]["panel_text"], # c50 #F6F7F6
|
|
56
|
+
COLORS["light"]["surface_sunken"], # c100 #EEF0EF
|
|
57
|
+
COLORS["light"]["rule"], # c200 #D9DAD6
|
|
58
|
+
COLORS["light"]["panel_muted"], # c300 #B9BAB4
|
|
59
|
+
COLORS["dark"]["text_subtle"], # c400 #8F8F89
|
|
60
|
+
COLORS["light"]["text_subtle"], # c500 #6B6B66
|
|
61
|
+
COLORS["light"]["text_muted"], # c600 #4A4A47
|
|
62
|
+
COLORS["dark"]["rule"], # c700 #3A3A37
|
|
63
|
+
COLORS["dark"]["panel"], # c800 #262624
|
|
64
|
+
COLORS["dark"]["surface_sunken"], # c900 #1C1C1A
|
|
65
|
+
COLORS["light"]["text"], # c950 #141414
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _variables(c: dict[str, str]) -> dict[str, str]:
|
|
70
|
+
"""Theme variables for one mode, mirroring css/src/components/button.css and form.css."""
|
|
71
|
+
return {
|
|
72
|
+
"body_background_fill": c["bg"],
|
|
73
|
+
"body_text_color": c["text"],
|
|
74
|
+
"body_text_color_subdued": c["text_muted"],
|
|
75
|
+
"background_fill_primary": c["bg"],
|
|
76
|
+
"background_fill_secondary": c["surface_sunken"],
|
|
77
|
+
"border_color_primary": c["rule"],
|
|
78
|
+
"border_color_accent": c["accent"],
|
|
79
|
+
"border_color_accent_subdued": c["rule"],
|
|
80
|
+
"color_accent": c["accent"],
|
|
81
|
+
"color_accent_soft": c["note_soft"],
|
|
82
|
+
"link_text_color": c["link"],
|
|
83
|
+
"link_text_color_active": c["link"],
|
|
84
|
+
"link_text_color_hover": c["link_hover"],
|
|
85
|
+
"link_text_color_visited": c["link"],
|
|
86
|
+
"code_background_fill": c["surface_sunken"],
|
|
87
|
+
"panel_background_fill": c["surface_sunken"],
|
|
88
|
+
"block_background_fill": c["bg"],
|
|
89
|
+
"block_border_color": c["rule"],
|
|
90
|
+
"block_label_background_fill": c["bg"],
|
|
91
|
+
"block_label_text_color": c["text_muted"],
|
|
92
|
+
"block_title_text_color": c["text"],
|
|
93
|
+
"input_background_fill": c["bg"],
|
|
94
|
+
"input_background_fill_focus": c["bg"],
|
|
95
|
+
"input_border_color": c["rule_strong"],
|
|
96
|
+
"input_border_color_focus": c["focus"],
|
|
97
|
+
"checkbox_background_color_selected": c["text"],
|
|
98
|
+
"checkbox_border_color_selected": c["text"],
|
|
99
|
+
"slider_color": c["text"],
|
|
100
|
+
"loader_color": c["accent"],
|
|
101
|
+
"table_even_background_fill": c["bg"],
|
|
102
|
+
"table_odd_background_fill": c["surface_sunken"],
|
|
103
|
+
# Primary: exhibit black, tomato on hover, white label on hover.
|
|
104
|
+
"button_primary_background_fill": c["text"],
|
|
105
|
+
"button_primary_background_fill_hover": c["accent"],
|
|
106
|
+
"button_primary_border_color": c["text"],
|
|
107
|
+
"button_primary_border_color_hover": c["accent"],
|
|
108
|
+
"button_primary_text_color": c["bg"],
|
|
109
|
+
"button_primary_text_color_hover": _WHITE,
|
|
110
|
+
# Secondary: outlined, fills with ink on hover.
|
|
111
|
+
"button_secondary_background_fill": "transparent",
|
|
112
|
+
"button_secondary_background_fill_hover": c["text"],
|
|
113
|
+
"button_secondary_border_color": c["text"],
|
|
114
|
+
"button_secondary_border_color_hover": c["text"],
|
|
115
|
+
"button_secondary_text_color": c["text"],
|
|
116
|
+
"button_secondary_text_color_hover": c["bg"],
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# Mode-independent variables: no shadows, square corners, semibold labels.
|
|
121
|
+
_FLAT = {
|
|
122
|
+
"shadow_drop": "none",
|
|
123
|
+
"shadow_drop_lg": "none",
|
|
124
|
+
"shadow_spread": "0px",
|
|
125
|
+
"block_shadow": "none",
|
|
126
|
+
"block_label_shadow": "none",
|
|
127
|
+
"input_shadow": "none",
|
|
128
|
+
"input_shadow_focus": "none",
|
|
129
|
+
"checkbox_shadow": "none",
|
|
130
|
+
"button_primary_shadow": "none",
|
|
131
|
+
"button_primary_shadow_hover": "none",
|
|
132
|
+
"button_primary_shadow_active": "none",
|
|
133
|
+
"button_secondary_shadow": "none",
|
|
134
|
+
"button_secondary_shadow_hover": "none",
|
|
135
|
+
"button_secondary_shadow_active": "none",
|
|
136
|
+
"block_title_text_weight": "600",
|
|
137
|
+
"button_large_text_weight": "600",
|
|
138
|
+
"button_medium_text_weight": "600",
|
|
139
|
+
"button_small_text_weight": "600",
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def theme(mode: str = "light") -> Any:
|
|
144
|
+
"""Return a ``gr.themes.Base`` configured for the design system.
|
|
145
|
+
|
|
146
|
+
``mode`` picks which token set fills gradio's light and dark variables:
|
|
147
|
+
|
|
148
|
+
- "light": light tokens for both, so the app stays light.
|
|
149
|
+
- "dark": dark tokens for both, so the app stays dark.
|
|
150
|
+
- "auto": light tokens for light mode and dark tokens for dark mode,
|
|
151
|
+
following the browser or the ``?__theme=dark`` URL parameter.
|
|
152
|
+
|
|
153
|
+
Fonts load from Google Fonts. Ultra is reserved for display titles and is
|
|
154
|
+
not applied by the theme.
|
|
155
|
+
"""
|
|
156
|
+
if mode not in _MODES:
|
|
157
|
+
raise ValueError(f"mode must be one of {_MODES!r}, got {mode!r}")
|
|
158
|
+
gr = _require()
|
|
159
|
+
themes = gr.themes
|
|
160
|
+
|
|
161
|
+
light = COLORS["dark" if mode == "dark" else "light"]
|
|
162
|
+
dark = COLORS["light" if mode == "light" else "dark"]
|
|
163
|
+
|
|
164
|
+
base = themes.Base(
|
|
165
|
+
primary_hue=themes.Color(*_ramp(CATEGORICAL["light"][0]), name="dl_tomato"),
|
|
166
|
+
secondary_hue=themes.Color(*_ramp(CATEGORICAL["light"][1]), name="dl_teal"),
|
|
167
|
+
neutral_hue=themes.Color(*_NEUTRAL, name="dl_gray"),
|
|
168
|
+
radius_size=themes.sizes.radius_none,
|
|
169
|
+
font=[themes.GoogleFont(FONTS["text"], weights=(400, 600, 700)), "Franklin Gothic Medium", "Helvetica Neue", "Arial", "sans-serif"],
|
|
170
|
+
font_mono=[themes.GoogleFont(FONTS["mono"], weights=(400, 700)), "ui-monospace", "Menlo", "monospace"],
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
values: dict[str, str] = dict(_FLAT)
|
|
174
|
+
for key, value in _variables(light).items():
|
|
175
|
+
values[key] = value
|
|
176
|
+
for key, value in _variables(dark).items():
|
|
177
|
+
values[f"{key}_dark"] = value
|
|
178
|
+
|
|
179
|
+
# Variable names shift between gradio releases; drop any this version lacks.
|
|
180
|
+
accepted = inspect.signature(base.set).parameters
|
|
181
|
+
return base.set(**{k: v for k, v in values.items() if k in accepted})
|
polytope_theme/mpl.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""matplotlib integration: style files, colormaps and direct line labels.
|
|
2
|
+
|
|
3
|
+
The module is named ``mpl`` so that it does not shadow ``matplotlib`` itself.
|
|
4
|
+
Importing it registers the colormaps ``ply-sequential`` and ``ply-diverging``
|
|
5
|
+
(plus their ``_r`` reversals) when matplotlib is installed. ``style_path()``
|
|
6
|
+
works without matplotlib.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING, Any, Iterable, Sequence
|
|
15
|
+
|
|
16
|
+
from ._tokens import DIVERGING, SEQUENTIAL
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import matplotlib as _matplotlib
|
|
20
|
+
from matplotlib.colors import LinearSegmentedColormap
|
|
21
|
+
except ImportError: # matplotlib is an optional extra
|
|
22
|
+
_matplotlib = None
|
|
23
|
+
LinearSegmentedColormap = None
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from matplotlib.axes import Axes
|
|
27
|
+
from matplotlib.text import Annotation
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"DIVERGING_NAME",
|
|
31
|
+
"SEQUENTIAL_NAME",
|
|
32
|
+
"diverging_cmap",
|
|
33
|
+
"label_lines",
|
|
34
|
+
"rc_params",
|
|
35
|
+
"register_cmaps",
|
|
36
|
+
"sequential_cmap",
|
|
37
|
+
"style_path",
|
|
38
|
+
"use",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
SEQUENTIAL_NAME = "ply-sequential"
|
|
42
|
+
DIVERGING_NAME = "ply-diverging"
|
|
43
|
+
|
|
44
|
+
_STYLE_DIR = Path(__file__).resolve().parent / "styles"
|
|
45
|
+
_MODES = ("light", "dark")
|
|
46
|
+
_HEX_VALUE = re.compile(r"^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?(\s|$)")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _require() -> None:
|
|
50
|
+
if _matplotlib is None:
|
|
51
|
+
raise ImportError(
|
|
52
|
+
"This function needs matplotlib. Install it with "
|
|
53
|
+
"pip install \"polytope-theme[matplotlib]\"."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def style_path(mode: str = "light") -> Path:
|
|
58
|
+
"""Return the path of the generated .mplstyle file for ``mode``.
|
|
59
|
+
|
|
60
|
+
Prefer ``use(mode)`` or ``rc_params(mode)``: they read "#RRGGBB" colors
|
|
61
|
+
that ``plt.style.use(path)`` skips when the file writes them unquoted.
|
|
62
|
+
"""
|
|
63
|
+
if mode not in _MODES:
|
|
64
|
+
raise ValueError(f"mode must be one of {_MODES!r}, got {mode!r}")
|
|
65
|
+
return _STYLE_DIR / f"polytope-{mode}.mplstyle"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def sequential_cmap(name: str = SEQUENTIAL_NAME, n: int = 256) -> "LinearSegmentedColormap":
|
|
69
|
+
"""Cobalt ramp from light to dark, built from the SEQUENTIAL tokens."""
|
|
70
|
+
_require()
|
|
71
|
+
return LinearSegmentedColormap.from_list(name, SEQUENTIAL, N=n)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def diverging_cmap(name: str = DIVERGING_NAME, n: int = 256) -> "LinearSegmentedColormap":
|
|
75
|
+
"""Cobalt -> gray midpoint -> tomato, built from the DIVERGING tokens.
|
|
76
|
+
|
|
77
|
+
Center the data on the midpoint, for example with
|
|
78
|
+
``matplotlib.colors.TwoSlopeNorm(vcenter=0)`` or symmetric vmin/vmax.
|
|
79
|
+
"""
|
|
80
|
+
_require()
|
|
81
|
+
return LinearSegmentedColormap.from_list(name, DIVERGING, N=n)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def register_cmaps(force: bool = False) -> None:
|
|
85
|
+
"""Register ply-sequential, ply-diverging and their _r reversals.
|
|
86
|
+
|
|
87
|
+
Names already present are left alone unless ``force`` is true.
|
|
88
|
+
"""
|
|
89
|
+
_require()
|
|
90
|
+
for cmap in (sequential_cmap(), diverging_cmap()):
|
|
91
|
+
for item in (cmap, cmap.reversed()):
|
|
92
|
+
if force or item.name not in _matplotlib.colormaps:
|
|
93
|
+
_matplotlib.colormaps.register(item, name=item.name, force=force)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def rc_params(mode: str = "light") -> dict[str, str]:
|
|
97
|
+
"""Return the style for ``mode`` as a dict of rcParams values (strings).
|
|
98
|
+
|
|
99
|
+
The generated .mplstyle file stores colors as bare hex (``FFFFFF``) because
|
|
100
|
+
matplotlib's rc parser treats an unquoted "#" as a comment. This returns them
|
|
101
|
+
as ``#RRGGBB``. The dict works with ``matplotlib.style.use`` and
|
|
102
|
+
``matplotlib.rc_context``.
|
|
103
|
+
"""
|
|
104
|
+
params: dict[str, str] = {}
|
|
105
|
+
for raw in style_path(mode).read_text(encoding="utf-8").splitlines():
|
|
106
|
+
line = raw.strip()
|
|
107
|
+
if not line or line.startswith("#") or ":" not in line:
|
|
108
|
+
continue
|
|
109
|
+
key, value = (part.strip() for part in line.split(":", 1))
|
|
110
|
+
if _HEX_VALUE.match(value):
|
|
111
|
+
value = value.split()[0]
|
|
112
|
+
else:
|
|
113
|
+
value = value.split(" #", 1)[0].strip()
|
|
114
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
115
|
+
value = value[1:-1]
|
|
116
|
+
if re.fullmatch(r"[0-9A-Fa-f]{6}", value):
|
|
117
|
+
value = "#" + value
|
|
118
|
+
params[key] = value
|
|
119
|
+
return params
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def use(mode: str = "light") -> None:
|
|
123
|
+
"""Apply the style for ``mode`` and make ply-sequential the default image cmap."""
|
|
124
|
+
_require()
|
|
125
|
+
import matplotlib.style
|
|
126
|
+
|
|
127
|
+
matplotlib.style.use(rc_params(mode))
|
|
128
|
+
register_cmaps()
|
|
129
|
+
_matplotlib.rcParams["image.cmap"] = SEQUENTIAL_NAME
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _line_end(line: Any) -> tuple[float, float] | None:
|
|
133
|
+
"""Return the finite data point with the largest x, or None."""
|
|
134
|
+
xs = line.get_xdata(orig=False)
|
|
135
|
+
ys = line.get_ydata(orig=False)
|
|
136
|
+
best = None
|
|
137
|
+
for x, y in zip(xs, ys):
|
|
138
|
+
try:
|
|
139
|
+
fx, fy = float(x), float(y)
|
|
140
|
+
except (TypeError, ValueError):
|
|
141
|
+
continue
|
|
142
|
+
if not (math.isfinite(fx) and math.isfinite(fy)):
|
|
143
|
+
continue
|
|
144
|
+
if best is None or fx >= best[0]:
|
|
145
|
+
best = (fx, fy)
|
|
146
|
+
return best
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _spread(positions: Sequence[float], gap: float) -> list[float]:
|
|
150
|
+
"""Move 1-D positions apart so neighbours are at least ``gap`` apart.
|
|
151
|
+
|
|
152
|
+
Colliding labels are grouped and each group is centred on the mean of its
|
|
153
|
+
members, so a label only moves when it overlaps another one.
|
|
154
|
+
"""
|
|
155
|
+
order = sorted(range(len(positions)), key=lambda i: positions[i])
|
|
156
|
+
groups: list[tuple[list[int], float]] = [] # (members, first position)
|
|
157
|
+
for idx in order:
|
|
158
|
+
groups.append(([idx], positions[idx]))
|
|
159
|
+
while len(groups) > 1:
|
|
160
|
+
(prev, prev_start), (cur, cur_start) = groups[-2], groups[-1]
|
|
161
|
+
if cur_start - (prev_start + gap * (len(prev) - 1)) >= gap:
|
|
162
|
+
break
|
|
163
|
+
members = prev + cur
|
|
164
|
+
mean = sum(positions[i] for i in members) / len(members)
|
|
165
|
+
groups[-2:] = [(members, mean - gap * (len(members) - 1) / 2)]
|
|
166
|
+
out = [0.0] * len(positions)
|
|
167
|
+
for members, first in groups:
|
|
168
|
+
for k, idx in enumerate(members):
|
|
169
|
+
out[idx] = first + k * gap
|
|
170
|
+
return out
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def label_lines(
|
|
174
|
+
ax: "Axes",
|
|
175
|
+
labels: Iterable[str] | None = None,
|
|
176
|
+
*,
|
|
177
|
+
offset: float = 6.0,
|
|
178
|
+
color: str | None = None,
|
|
179
|
+
fontsize: float | str | None = None,
|
|
180
|
+
min_gap: float | None = None,
|
|
181
|
+
leader: bool = True,
|
|
182
|
+
remove_legend: bool = False,
|
|
183
|
+
) -> list["Annotation"]:
|
|
184
|
+
"""Direct-label each line at its right end, in the text color.
|
|
185
|
+
|
|
186
|
+
Labels use ``rcParams["text.color"]`` (or ``color``) and never the series
|
|
187
|
+
color, so the line itself carries the hue and the label stays readable.
|
|
188
|
+
Call it after plotting and after setting axis limits. Overlapping labels
|
|
189
|
+
are spread vertically, and a label moved away from its line gets a thin
|
|
190
|
+
leader line back to the line end.
|
|
191
|
+
|
|
192
|
+
Parameters
|
|
193
|
+
----------
|
|
194
|
+
ax:
|
|
195
|
+
Axes holding the lines.
|
|
196
|
+
labels:
|
|
197
|
+
Optional labels in line order. Defaults to each line's own label;
|
|
198
|
+
lines whose label starts with "_" are skipped.
|
|
199
|
+
offset:
|
|
200
|
+
Horizontal distance from the line end, in points.
|
|
201
|
+
color:
|
|
202
|
+
Label color. Defaults to the style's text color.
|
|
203
|
+
fontsize:
|
|
204
|
+
Label size. Defaults to ``rcParams["legend.fontsize"]``.
|
|
205
|
+
min_gap:
|
|
206
|
+
Minimum vertical distance between labels, in points. Defaults to
|
|
207
|
+
1.2 times the font size.
|
|
208
|
+
leader:
|
|
209
|
+
Draw a leader line for labels moved more than a third of the font size.
|
|
210
|
+
remove_legend:
|
|
211
|
+
Remove an existing legend. Off by default: with two or more series a
|
|
212
|
+
legend stays as the primary key and the labels supplement it.
|
|
213
|
+
"""
|
|
214
|
+
_require()
|
|
215
|
+
from matplotlib.font_manager import FontProperties
|
|
216
|
+
|
|
217
|
+
rc = _matplotlib.rcParams
|
|
218
|
+
color = color or rc["text.color"]
|
|
219
|
+
fontsize = fontsize if fontsize is not None else rc["legend.fontsize"]
|
|
220
|
+
size_pt = FontProperties(size=fontsize).get_size_in_points()
|
|
221
|
+
gap_pt = min_gap if min_gap is not None else 1.2 * size_pt
|
|
222
|
+
|
|
223
|
+
lines = ax.get_lines()
|
|
224
|
+
if labels is None:
|
|
225
|
+
items = [(ln, ln.get_label()) for ln in lines if not str(ln.get_label()).startswith("_")]
|
|
226
|
+
else:
|
|
227
|
+
items = list(zip(lines, labels))
|
|
228
|
+
|
|
229
|
+
ends = []
|
|
230
|
+
for line, text in items:
|
|
231
|
+
end = _line_end(line)
|
|
232
|
+
if end is not None:
|
|
233
|
+
ends.append((line, str(text), end))
|
|
234
|
+
if not ends:
|
|
235
|
+
return []
|
|
236
|
+
|
|
237
|
+
fig = ax.figure
|
|
238
|
+
points_per_px = 72.0 / fig.dpi
|
|
239
|
+
to_px = ax.transData.transform
|
|
240
|
+
y_pt = [to_px(end)[1] * points_per_px for _, _, end in ends]
|
|
241
|
+
new_y_pt = _spread(y_pt, gap_pt)
|
|
242
|
+
|
|
243
|
+
out = []
|
|
244
|
+
for (line, text, end), y0, y1 in zip(ends, y_pt, new_y_pt):
|
|
245
|
+
moved = abs(y1 - y0) > size_pt / 3
|
|
246
|
+
arrowprops = None
|
|
247
|
+
if leader and moved:
|
|
248
|
+
arrowprops = dict(
|
|
249
|
+
arrowstyle="-",
|
|
250
|
+
color=rc["axes.edgecolor"],
|
|
251
|
+
linewidth=0.8,
|
|
252
|
+
shrinkA=1,
|
|
253
|
+
shrinkB=2,
|
|
254
|
+
)
|
|
255
|
+
ann = ax.annotate(
|
|
256
|
+
text,
|
|
257
|
+
xy=end,
|
|
258
|
+
xytext=(offset * (2 if arrowprops else 1), y1 - y0),
|
|
259
|
+
textcoords="offset points",
|
|
260
|
+
ha="left",
|
|
261
|
+
va="center",
|
|
262
|
+
color=color,
|
|
263
|
+
fontsize=fontsize,
|
|
264
|
+
annotation_clip=False,
|
|
265
|
+
arrowprops=arrowprops,
|
|
266
|
+
)
|
|
267
|
+
out.append(ann)
|
|
268
|
+
|
|
269
|
+
if remove_legend and ax.get_legend() is not None:
|
|
270
|
+
ax.get_legend().remove()
|
|
271
|
+
return out
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
if _matplotlib is not None:
|
|
275
|
+
register_cmaps()
|