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.
- plotform/__init__.py +53 -0
- plotform/api.py +47 -0
- plotform/assets/fonts/PlotformMono-OFL.txt +93 -0
- plotform/assets/fonts/PlotformMono-Regular.ttf +0 -0
- plotform/assets/fonts/PlotformMono-TRADEMARKS.md +17 -0
- plotform/assets/fonts/PlotformSans-OFL.txt +93 -0
- plotform/assets/fonts/PlotformSans-Rounded.ttf +0 -0
- plotform/assets/fonts/PlotformSans-TRADEMARKS.md +17 -0
- plotform/assets/fonts/README.md +30 -0
- plotform/colorbar_utils.py +75 -0
- plotform/fonts.py +56 -0
- plotform/legend_utils.py +199 -0
- plotform/palette.py +103 -0
- plotform/rounded.py +519 -0
- plotform/theme.py +168 -0
- plotform/three_d.py +40 -0
- plotform-0.1.0.dist-info/METADATA +267 -0
- plotform-0.1.0.dist-info/RECORD +21 -0
- plotform-0.1.0.dist-info/WHEEL +5 -0
- plotform-0.1.0.dist-info/licenses/LICENSE +24 -0
- plotform-0.1.0.dist-info/top_level.txt +1 -0
plotform/legend_utils.py
ADDED
|
@@ -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)
|