plotform 0.1.0__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,199 @@
1
+ """Solid and soft exact-capsule legends with explicit semantics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+
7
+ import numpy as np
8
+ from matplotlib.colors import to_hex, to_rgb, to_rgba
9
+
10
+ from .palette import INK, WHITE
11
+ from .rounded import CapsuleBox, capsule_path
12
+
13
+
14
+ def _relative_luminance(color) -> float:
15
+ rgb = np.asarray(to_rgb(color), dtype=float)
16
+ linear = np.where(rgb <= 0.04045, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4)
17
+ return float(np.dot(linear, (0.2126, 0.7152, 0.0722)))
18
+
19
+
20
+ def _contrast_ratio(first, second) -> float:
21
+ light, dark = sorted((_relative_luminance(first), _relative_luminance(second)), reverse=True)
22
+ return (light + 0.05) / (dark + 0.05)
23
+
24
+
25
+ def _solid_text_color(color, target_contrast: float) -> str:
26
+ """Choose white or black for readable text on a solid category fill."""
27
+ white_ratio = _contrast_ratio(WHITE, color)
28
+ black_ratio = _contrast_ratio(INK, color)
29
+ if white_ratio >= target_contrast and white_ratio >= black_ratio:
30
+ return WHITE
31
+ if black_ratio >= target_contrast:
32
+ return INK
33
+ return WHITE if white_ratio > black_ratio else INK
34
+
35
+
36
+ def _text_color(color, background, alpha: float, target_contrast: float) -> str:
37
+ """Darken a category color until it reads on its own tinted fill."""
38
+ source = np.asarray(to_rgb(color), dtype=float)
39
+ backdrop = np.asarray(to_rgb(background), dtype=float)
40
+ fill = alpha * source + (1.0 - alpha) * backdrop
41
+ if _contrast_ratio(source, fill) >= target_contrast:
42
+ return to_hex(source)
43
+
44
+ low, high = 0.0, 1.0
45
+ for _ in range(36):
46
+ factor = (low + high) / 2
47
+ candidate = source * factor
48
+ if _contrast_ratio(candidate, fill) >= target_contrast:
49
+ low = factor
50
+ else:
51
+ high = factor
52
+ return to_hex(source * low)
53
+
54
+
55
+ def _first_rgba(values):
56
+ array = np.asarray(values)
57
+ if array.size == 0:
58
+ return None
59
+ if array.ndim == 1:
60
+ return array
61
+ return array.reshape(-1, array.shape[-1])[0]
62
+
63
+
64
+ def _artist_color(artist):
65
+ """Extract a representative color from common legendable artists."""
66
+ if hasattr(artist, "patches") and artist.patches:
67
+ artist = artist.patches[0]
68
+ elif hasattr(artist, "lines") and artist.lines:
69
+ lines = artist.lines
70
+ artist = lines[0] if isinstance(lines, (tuple, list)) else lines
71
+ elif hasattr(artist, "markerline"):
72
+ artist = artist.markerline
73
+
74
+ if hasattr(artist, "get_color"):
75
+ color = artist.get_color()
76
+ if isinstance(color, str):
77
+ return color
78
+ if color is not None:
79
+ first = _first_rgba(color)
80
+ if first is not None:
81
+ return first
82
+
83
+ if hasattr(artist, "get_facecolor"):
84
+ color = _first_rgba(artist.get_facecolor())
85
+ if color is not None and color[-1] > 0:
86
+ return color
87
+ if hasattr(artist, "get_edgecolor"):
88
+ color = _first_rgba(artist.get_edgecolor())
89
+ if color is not None:
90
+ return color
91
+ return INK
92
+
93
+
94
+ class _BackdropBox:
95
+ def __init__(self, pad: float):
96
+ self.pad = pad
97
+
98
+ def __call__(self, x0, y0, width, height, mutation_size):
99
+ pad = mutation_size * self.pad
100
+ return capsule_path(x0 - pad, y0 - pad, width + 2 * pad, height + 2 * pad)
101
+
102
+
103
+ def pill_legend(
104
+ ax,
105
+ *,
106
+ styles: Mapping[str, str] | None = None,
107
+ alpha: float = 0.14,
108
+ pad: float = 0.65,
109
+ pill_height: float | None = 1.9,
110
+ target_contrast: float = 4.5,
111
+ outside: bool = False,
112
+ backdrop: bool = False,
113
+ backdrop_alpha: float = 0.88,
114
+ backdrop_color: str = WHITE,
115
+ backdrop_pad: float = 0.45,
116
+ **kwargs,
117
+ ):
118
+ """Create an exact-capsule legend with solid or explicitly soft labels.
119
+
120
+ Solid entries use full category-color fills and adaptive white/black text.
121
+ ``styles={'95% interval': 'soft'}`` marks auxiliary regions for tinted
122
+ fills and darkened category text. Generated marker/line handles are hidden
123
+ rather than merely assigned zero width, preventing overlap with labels.
124
+
125
+ ``outside=True`` places the legend above the axes. ``backdrop=True`` adds a
126
+ semi-transparent white capsule behind the complete legend group.
127
+ """
128
+ if not 0 < alpha <= 1:
129
+ raise ValueError("alpha must be greater than 0 and at most 1")
130
+ if not 0 < backdrop_alpha <= 1:
131
+ raise ValueError("backdrop_alpha must be greater than 0 and at most 1")
132
+ if target_contrast < 1:
133
+ raise ValueError("target_contrast must be at least 1")
134
+
135
+ handles, labels = ax.get_legend_handles_labels()
136
+ styles = dict(styles or {})
137
+ unknown_labels = set(styles).difference(labels)
138
+ if unknown_labels:
139
+ raise ValueError(f"styles contains unknown legend labels: {sorted(unknown_labels)}")
140
+ invalid_styles = set(styles.values()).difference({"solid", "soft"})
141
+ if invalid_styles:
142
+ raise ValueError(f"Unknown legend styles: {sorted(invalid_styles)}")
143
+
144
+ colors = [_artist_color(handle) for handle in handles]
145
+ options = {
146
+ "handlelength": 0,
147
+ "handletextpad": 0,
148
+ "labelspacing": 1.25,
149
+ "columnspacing": 2.0,
150
+ "borderpad": 0.35,
151
+ "borderaxespad": 2.0,
152
+ "frameon": False,
153
+ }
154
+ if outside:
155
+ options.update(
156
+ {
157
+ "loc": "lower left",
158
+ "bbox_to_anchor": (0.0, 1.02),
159
+ "borderaxespad": 0.0,
160
+ "ncols": max(1, len(labels)),
161
+ }
162
+ )
163
+ options.update(kwargs)
164
+ legend = ax.legend(handles, labels, **options)
165
+
166
+ # handlelength=0 alone still allows some marker and errorbar artists to draw.
167
+ for handle in legend.legend_handles:
168
+ if handle is not None:
169
+ handle.set_visible(False)
170
+
171
+ background = ax.get_facecolor()
172
+ for text, label, color in zip(legend.get_texts(), labels, colors, strict=True):
173
+ style = styles.get(label, "solid")
174
+ if style == "soft":
175
+ facecolor = to_rgba(color, alpha)
176
+ textcolor = _text_color(color, background, alpha, target_contrast)
177
+ else:
178
+ facecolor = to_rgba(color, 1.0)
179
+ textcolor = _solid_text_color(color, target_contrast)
180
+ text.set_color(textcolor)
181
+ text.set_bbox(
182
+ {
183
+ "boxstyle": CapsuleBox(pad=pad, height=pill_height),
184
+ "facecolor": facecolor,
185
+ "edgecolor": "none",
186
+ }
187
+ )
188
+
189
+ if backdrop:
190
+ legend.set_frame_on(True)
191
+ frame = legend.get_frame()
192
+ frame.set_boxstyle(_BackdropBox(backdrop_pad))
193
+ frame.set_facecolor(backdrop_color)
194
+ frame.set_alpha(backdrop_alpha)
195
+ frame.set_edgecolor("none")
196
+ return legend
197
+
198
+
199
+ __all__ = ["pill_legend"]
plotform/palette.py ADDED
@@ -0,0 +1,103 @@
1
+ """Vivid categorical colors, scalar colormaps, and theme neutrals."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import colorsys
6
+ import operator
7
+
8
+ import matplotlib as mpl
9
+ from matplotlib.colors import LinearSegmentedColormap, to_hex
10
+
11
+ PRIMARY = "#008080"
12
+ CORAL = "#D55E00"
13
+ INK = "#000000"
14
+ MUTED = "#64727A"
15
+ GRID = "#E7ECEE"
16
+ AXIS = "#000000"
17
+ MUTED_AXIS = "#B9C4C9"
18
+ WHITE = "#FFFFFF"
19
+
20
+ # Vivid, accessibility-inspired categorical colors. Later colors are generated
21
+ # rather than repeating this cycle silently.
22
+ BASE_COLORS = (
23
+ PRIMARY,
24
+ "#E69F00", # amber
25
+ "#0072B2", # blue
26
+ CORAL, # vermillion/coral
27
+ "#6A51A3", # purple
28
+ "#4C9A2A", # green
29
+ "#CC79A7", # rose
30
+ "#4B5563", # dark slate
31
+ )
32
+
33
+ # Brand teal map retained for optional use. SCALAR_CMAP is the default for
34
+ # continuous data because every stop remains visible against white.
35
+ SEQUENTIAL_CMAP = "plotform_seq"
36
+ DIVERGING_CMAP = "plotform_div"
37
+ SCALAR_CMAP = "plotform_scalar"
38
+ SCALAR_STOPS = ("#17324D", "#0072B2", PRIMARY, "#4C9A2A", "#C47F00")
39
+
40
+
41
+ def _validate_count(n_colors: int) -> int:
42
+ n = operator.index(n_colors)
43
+ if n < 1:
44
+ raise ValueError("n_colors must be at least 1")
45
+ return n
46
+
47
+
48
+ def get_palette(n_colors: int = len(BASE_COLORS)) -> list[str]:
49
+ """Return *n_colors* distinct theme colors as hexadecimal strings.
50
+
51
+ The curated colors always come first. Additional colors use a deterministic
52
+ golden-angle hue sequence with alternating lightness and saturation. For
53
+ more than about 12 categories, combine color with labels, markers, line
54
+ styles, or small multiples.
55
+ """
56
+ n = _validate_count(n_colors)
57
+ if n <= len(BASE_COLORS):
58
+ return list(BASE_COLORS[:n])
59
+
60
+ colors = list(BASE_COLORS)
61
+ golden_angle = 0.618033988749895
62
+ lightness = (0.42, 0.52, 0.36, 0.58)
63
+ saturation = (0.65, 0.55, 0.70)
64
+
65
+ for i in range(n - len(colors)):
66
+ hue = (0.09 + i * golden_angle) % 1.0
67
+ rgb = colorsys.hls_to_rgb(
68
+ hue,
69
+ lightness[i % len(lightness)],
70
+ saturation[i % len(saturation)],
71
+ )
72
+ candidate = to_hex(rgb).upper()
73
+ while candidate in colors:
74
+ hue = (hue + 0.037) % 1.0
75
+ candidate = to_hex(
76
+ colorsys.hls_to_rgb(hue, lightness[i % 4], saturation[i % 3])
77
+ ).upper()
78
+ colors.append(candidate)
79
+
80
+ return colors
81
+
82
+
83
+ def build_colormaps() -> tuple[
84
+ LinearSegmentedColormap, LinearSegmentedColormap, LinearSegmentedColormap
85
+ ]:
86
+ """Build fresh brand-sequential, diverging, and full-range scalar maps."""
87
+ sequential = LinearSegmentedColormap.from_list(
88
+ SEQUENTIAL_CMAP,
89
+ ["#F2FAF9", "#C6E8E5", "#75BCB7", PRIMARY, "#004C4C"],
90
+ )
91
+ diverging = LinearSegmentedColormap.from_list(
92
+ DIVERGING_CMAP,
93
+ ["#B84F00", "#F0A6A0", "#FAFAF8", "#62C3BC", PRIMARY],
94
+ )
95
+ scalar = LinearSegmentedColormap.from_list(SCALAR_CMAP, SCALAR_STOPS)
96
+ return sequential, diverging, scalar
97
+
98
+
99
+ def register_colormaps() -> None:
100
+ """Register theme colormaps with Matplotlib, once per process."""
101
+ for cmap in build_colormaps():
102
+ if cmap.name not in mpl.colormaps:
103
+ mpl.colormaps.register(cmap, name=cmap.name)