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.
@@ -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()