figkit 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.
figkit/colors.py ADDED
@@ -0,0 +1,304 @@
1
+ """Colour parsing, manipulation and colormaps (no third-party deps)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import colorsys
6
+ import math
7
+ import re
8
+ from typing import Sequence
9
+
10
+ __all__ = [
11
+ "parse_color", "to_hex", "rgba", "mix", "lighten", "darken",
12
+ "saturate", "desaturate", "alpha", "contrast_color", "colormap",
13
+ "PALETTES", "COLORMAPS",
14
+ ]
15
+
16
+ # A small set of useful named colours (CSS names people actually reach for).
17
+ NAMED = {
18
+ "transparent": (0, 0, 0, 0.0), "none": None,
19
+ "black": (0, 0, 0), "white": (255, 255, 255),
20
+ "red": (255, 0, 0), "green": (0, 128, 0), "blue": (0, 0, 255),
21
+ "yellow": (255, 255, 0), "orange": (255, 165, 0), "purple": (128, 0, 128),
22
+ "gray": (128, 128, 128), "grey": (128, 128, 128),
23
+ "lightgray": (211, 211, 211), "lightgrey": (211, 211, 211),
24
+ "darkgray": (169, 169, 169), "darkgrey": (169, 169, 169),
25
+ "whitesmoke": (245, 245, 245), "gainsboro": (220, 220, 220),
26
+ "silver": (192, 192, 192), "dimgray": (105, 105, 105),
27
+ "navy": (0, 0, 128), "teal": (0, 128, 128), "olive": (128, 128, 0),
28
+ "maroon": (128, 0, 0), "lime": (0, 255, 0), "aqua": (0, 255, 255),
29
+ "cyan": (0, 255, 255), "magenta": (255, 0, 255), "fuchsia": (255, 0, 255),
30
+ "pink": (255, 192, 203), "brown": (165, 42, 42), "gold": (255, 215, 0),
31
+ "beige": (245, 245, 220), "ivory": (255, 255, 240), "khaki": (240, 230, 140),
32
+ "salmon": (250, 128, 114), "coral": (255, 127, 80), "tomato": (255, 99, 71),
33
+ "crimson": (220, 20, 60), "indigo": (75, 0, 130), "violet": (238, 130, 238),
34
+ "steelblue": (70, 130, 180), "skyblue": (135, 206, 235),
35
+ "lightblue": (173, 216, 230), "royalblue": (65, 105, 225),
36
+ "cornflowerblue": (100, 149, 237), "midnightblue": (25, 25, 112),
37
+ "seagreen": (46, 139, 87), "forestgreen": (34, 139, 34),
38
+ "lightgreen": (144, 238, 144), "darkgreen": (0, 100, 0),
39
+ "slategray": (112, 128, 144), "lightslategray": (119, 136, 153),
40
+ "aliceblue": (240, 248, 255), "lavender": (230, 230, 250),
41
+ "linen": (250, 240, 230), "snow": (255, 250, 250),
42
+ }
43
+
44
+ _HEX_RE = re.compile(r"^#([0-9a-fA-F]{3,8})$")
45
+ _FUNC_RE = re.compile(r"^(rgba?|hsla?)\(([^)]*)\)$", re.I)
46
+
47
+
48
+ def parse_color(c) -> tuple | None:
49
+ """Parse any colour spec into ``(r, g, b, a)`` with 0-255 ints and 0-1 alpha.
50
+
51
+ Returns ``None`` for ``"none"``/``None`` (meaning "do not paint").
52
+ Accepts: hex (``#rgb``, ``#rgba``, ``#rrggbb``, ``#rrggbbaa``), CSS
53
+ ``rgb()``/``rgba()``/``hsl()``/``hsla()``, named colours, ``(r, g, b)`` and
54
+ ``(r, g, b, a)`` tuples.
55
+ """
56
+ if c is None:
57
+ return None
58
+ if isinstance(c, (tuple, list)):
59
+ vals = list(c)
60
+ if len(vals) == 3:
61
+ return (_i(vals[0]), _i(vals[1]), _i(vals[2]), 1.0)
62
+ if len(vals) == 4:
63
+ return (_i(vals[0]), _i(vals[1]), _i(vals[2]), float(vals[3]))
64
+ raise ValueError(f"bad colour tuple: {c!r}")
65
+ s = str(c).strip()
66
+ if not s:
67
+ return None
68
+ low = s.lower()
69
+ if low == "none":
70
+ return None
71
+ if low in NAMED:
72
+ v = NAMED[low]
73
+ if v is None:
74
+ return None
75
+ return (v[0], v[1], v[2], v[3] if len(v) > 3 else 1.0)
76
+ m = _HEX_RE.match(s)
77
+ if m:
78
+ h = m.group(1)
79
+ if len(h) == 3:
80
+ r, g, b = (int(ch * 2, 16) for ch in h)
81
+ return (r, g, b, 1.0)
82
+ if len(h) == 4:
83
+ r, g, b, a = (int(ch * 2, 16) for ch in h)
84
+ return (r, g, b, a / 255.0)
85
+ if len(h) == 6:
86
+ return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), 1.0)
87
+ if len(h) == 8:
88
+ return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16),
89
+ int(h[6:8], 16) / 255.0)
90
+ raise ValueError(f"bad hex colour: {s!r}")
91
+ m = _FUNC_RE.match(s)
92
+ if m:
93
+ kind = m.group(1).lower()
94
+ parts = [p.strip() for p in re.split(r"[,\s/]+", m.group(2)) if p.strip()]
95
+ if kind.startswith("rgb"):
96
+ r, g, b = (_pct(p, 255) for p in parts[:3])
97
+ a = _alpha(parts[3]) if len(parts) > 3 else 1.0
98
+ return (_i(r), _i(g), _i(b), a)
99
+ hh = float(parts[0].rstrip("deg")) / 360.0
100
+ ss = _pct(parts[1], 1.0)
101
+ ll = _pct(parts[2], 1.0)
102
+ a = _alpha(parts[3]) if len(parts) > 3 else 1.0
103
+ r, g, b = colorsys.hls_to_rgb(hh % 1.0, ll, ss)
104
+ return (_i(r * 255), _i(g * 255), _i(b * 255), a)
105
+ raise ValueError(f"cannot parse colour {c!r}")
106
+
107
+
108
+ def _i(v) -> int:
109
+ return max(0, min(255, int(round(float(v)))))
110
+
111
+
112
+ def _pct(tok: str, scale: float) -> float:
113
+ tok = str(tok)
114
+ if tok.endswith("%"):
115
+ return float(tok[:-1]) / 100.0 * scale
116
+ return float(tok)
117
+
118
+
119
+ def _alpha(tok: str) -> float:
120
+ tok = str(tok)
121
+ if tok.endswith("%"):
122
+ return float(tok[:-1]) / 100.0
123
+ return float(tok)
124
+
125
+
126
+ def to_hex(c, keep_alpha: bool = True) -> str:
127
+ """Render a colour as a hex string (``#rrggbb`` or ``#rrggbbaa``)."""
128
+ p = parse_color(c)
129
+ if p is None:
130
+ return "none"
131
+ r, g, b, a = p
132
+ if keep_alpha and a < 1.0:
133
+ return f"#{r:02x}{g:02x}{b:02x}{_i(a * 255):02x}"
134
+ return f"#{r:02x}{g:02x}{b:02x}"
135
+
136
+
137
+ def rgba(c, a: float) -> str:
138
+ """Return colour ``c`` with alpha replaced by ``a``."""
139
+ return alpha(c, a)
140
+
141
+
142
+ def alpha(c, a: float) -> str:
143
+ """Return colour ``c`` with its alpha replaced by ``a`` (0..1)."""
144
+ p = parse_color(c)
145
+ if p is None:
146
+ return "none"
147
+ r, g, b, _ = p
148
+ return f"rgba({r},{g},{b},{round(float(a), 4)})"
149
+
150
+
151
+ def mix(c1, c2, t: float = 0.5) -> str:
152
+ """Linear blend: ``t=0`` gives ``c1``, ``t=1`` gives ``c2``."""
153
+ a = parse_color(c1) or (0, 0, 0, 0.0)
154
+ b = parse_color(c2) or (0, 0, 0, 0.0)
155
+ t = max(0.0, min(1.0, float(t)))
156
+ out = tuple(_i(a[i] + (b[i] - a[i]) * t) for i in range(3))
157
+ aa = a[3] + (b[3] - a[3]) * t
158
+ return to_hex((out[0], out[1], out[2], aa))
159
+
160
+
161
+ def lighten(c, amount: float = 0.2) -> str:
162
+ """Move a colour toward white (``amount`` in 0..1)."""
163
+ return mix(c, "#ffffff", amount)
164
+
165
+
166
+ def darken(c, amount: float = 0.2) -> str:
167
+ """Move a colour toward black (``amount`` in 0..1)."""
168
+ return mix(c, "#000000", amount)
169
+
170
+
171
+ def _hls(c):
172
+ p = parse_color(c) or (0, 0, 0, 1.0)
173
+ h, l, s = colorsys.rgb_to_hls(p[0] / 255, p[1] / 255, p[2] / 255)
174
+ return h, l, s, p[3]
175
+
176
+
177
+ def saturate(c, amount: float = 0.2) -> str:
178
+ """Increase saturation by ``amount`` (0..1); negative desaturates."""
179
+ h, l, s, a = _hls(c)
180
+ r, g, b = colorsys.hls_to_rgb(h, l, max(0.0, min(1.0, s + amount)))
181
+ return to_hex((r * 255, g * 255, b * 255, a))
182
+
183
+
184
+ def desaturate(c, amount: float = 0.2) -> str:
185
+ """Wash a colour out toward grey by ``amount`` (0..1)."""
186
+ return saturate(c, -amount)
187
+
188
+
189
+ def luminance(c) -> float:
190
+ """Relative luminance per WCAG."""
191
+ p = parse_color(c)
192
+ if p is None:
193
+ return 1.0
194
+
195
+ def ch(v):
196
+ v = v / 255.0
197
+ return v / 12.92 if v <= 0.03928 else ((v + 0.055) / 1.055) ** 2.4
198
+
199
+ return 0.2126 * ch(p[0]) + 0.7152 * ch(p[1]) + 0.0722 * ch(p[2])
200
+
201
+
202
+ def contrast_color(bg, dark="#111111", light="#ffffff") -> str:
203
+ """Pick whichever of ``dark``/``light`` reads better on ``bg``."""
204
+ return dark if luminance(bg) > 0.45 else light
205
+
206
+
207
+ # --------------------------------------------------------------------------
208
+ # Palettes
209
+ # --------------------------------------------------------------------------
210
+
211
+ PALETTES = {
212
+ # Muted, print-friendly default: works well for ML paper figures.
213
+ "figkit": ["#4C72B0", "#DD8452", "#55A868", "#C44E52", "#8172B3",
214
+ "#937860", "#DA8BC3", "#8C8C8C", "#CCB974", "#64B5CD"],
215
+ "tab10": ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
216
+ "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf"],
217
+ "pastel": ["#a1c9f4", "#ffb482", "#8de5a1", "#ff9f9b", "#d0bbff",
218
+ "#debb9b", "#fab0e4", "#cfcfcf", "#fffea3", "#b9f2f0"],
219
+ "bold": ["#7F3C8D", "#11A579", "#3969AC", "#F2B701", "#E73F74",
220
+ "#80BA5A", "#E68310", "#008695", "#CF1C90", "#f97b72"],
221
+ "grays": ["#111111", "#444444", "#777777", "#999999", "#bbbbbb", "#dddddd"],
222
+ }
223
+
224
+ # Compact colormap definitions: anchor stops that get interpolated.
225
+ COLORMAPS = {
226
+ "viridis": ["#440154", "#414487", "#2a788e", "#22a884", "#7ad151", "#fde725"],
227
+ "plasma": ["#0d0887", "#6a00a8", "#b12a90", "#e16462", "#fca636", "#f0f921"],
228
+ "magma": ["#000004", "#3b0f70", "#8c2981", "#de4968", "#fe9f6d", "#fcfdbf"],
229
+ "inferno": ["#000004", "#420a68", "#932667", "#dd513a", "#fca50a", "#fcffa4"],
230
+ "cividis": ["#00224e", "#123570", "#3b496c", "#575d6d", "#707173", "#8a8678",
231
+ "#a59c74", "#c3b369", "#e1cc55", "#fee838"],
232
+ "gray": ["#000000", "#ffffff"],
233
+ "grey": ["#000000", "#ffffff"],
234
+ "grays": ["#ffffff", "#111111"],
235
+ "greys": ["#ffffff", "#111111"],
236
+ "blues": ["#f7fbff", "#deebf7", "#c6dbef", "#9ecae1", "#6baed6",
237
+ "#4292c6", "#2171b5", "#08519c", "#08306b"],
238
+ "reds": ["#fff5f0", "#fee0d2", "#fcbba1", "#fc9272", "#fb6a4a",
239
+ "#ef3b2c", "#cb181d", "#a50f15", "#67000d"],
240
+ "greens": ["#f7fcf5", "#e5f5e0", "#c7e9c0", "#a1d99b", "#74c476",
241
+ "#41ab5d", "#238b45", "#006d2c", "#00441b"],
242
+ "oranges": ["#fff5eb", "#fee6ce", "#fdd0a2", "#fdae6b", "#fd8d3c",
243
+ "#f16913", "#d94801", "#a63603", "#7f2704"],
244
+ "purples": ["#fcfbfd", "#efedf5", "#dadaeb", "#bcbddc", "#9e9ac8",
245
+ "#807dba", "#6a51a3", "#54278f", "#3f007d"],
246
+ # Diverging
247
+ "coolwarm": ["#3b4cc0", "#7b9ff9", "#c0d4f5", "#f2cbb7", "#ee8468", "#b40426"],
248
+ "rdbu": ["#67001f", "#d6604d", "#f7f7f7", "#4393c3", "#053061"],
249
+ "bwr": ["#0000ff", "#ffffff", "#ff0000"],
250
+ "spectral": ["#9e0142", "#f46d43", "#fee08b", "#e6f598", "#66c2a5", "#5e4fa2"],
251
+ }
252
+
253
+
254
+ def colormap(name_or_stops, t: float) -> str:
255
+ """Sample a colormap at ``t`` in ``[0, 1]``.
256
+
257
+ ``name_or_stops`` is a colormap name (see :data:`COLORMAPS`), a list of
258
+ colour stops, or a callable ``t -> colour``.
259
+ """
260
+ if callable(name_or_stops):
261
+ return name_or_stops(t)
262
+ if isinstance(name_or_stops, str):
263
+ key = name_or_stops.lower()
264
+ rev = key.endswith("_r")
265
+ if rev:
266
+ key = key[:-2]
267
+ stops = COLORMAPS.get(key)
268
+ if stops is None:
269
+ raise KeyError(f"unknown colormap {name_or_stops!r}; "
270
+ f"available: {sorted(COLORMAPS)}")
271
+ if rev:
272
+ stops = list(reversed(stops))
273
+ else:
274
+ stops = list(name_or_stops)
275
+ if not stops:
276
+ return "#000000"
277
+ if len(stops) == 1:
278
+ return to_hex(stops[0])
279
+ t = 0.0 if t is None or math.isnan(t) else max(0.0, min(1.0, float(t)))
280
+ pos = t * (len(stops) - 1)
281
+ i = min(int(pos), len(stops) - 2)
282
+ return mix(stops[i], stops[i + 1], pos - i)
283
+
284
+
285
+ def palette(name="figkit", n: int = None) -> list:
286
+ """Return a categorical palette, cycled/truncated to ``n`` entries."""
287
+ cols = PALETTES.get(name) if isinstance(name, str) else list(name)
288
+ if cols is None:
289
+ raise KeyError(f"unknown palette {name!r}; available: {sorted(PALETTES)}")
290
+ if n is None:
291
+ return list(cols)
292
+ return [cols[i % len(cols)] for i in range(n)]
293
+
294
+
295
+ def normalize(values: Sequence, vmin=None, vmax=None) -> list:
296
+ """Scale values into ``[0, 1]`` (used for heatmaps / colour coding)."""
297
+ vals = [float(v) for v in values]
298
+ if not vals:
299
+ return []
300
+ lo = min(vals) if vmin is None else float(vmin)
301
+ hi = max(vals) if vmax is None else float(vmax)
302
+ if hi == lo:
303
+ return [0.5] * len(vals)
304
+ return [(v - lo) / (hi - lo) for v in vals]
figkit/component.py ADDED
@@ -0,0 +1,110 @@
1
+ """Reusable components with named anchors.
2
+
3
+ A plain function returning a :class:`~figkit.core.Group` already gets you a
4
+ reusable unit. What it cannot do is *name* the interesting points on that
5
+ unit, so callers end up reaching into ``group.children[1]``. A
6
+ :class:`Component` builds itself and publishes the anchors that matter:
7
+
8
+ class PortBlock(Component):
9
+ def build(self, label):
10
+ body = Box(label, w=120)
11
+ self.expose("port", body.e)
12
+ self.expose("body", body)
13
+ return [body]
14
+
15
+ block = PortBlock("filter")
16
+ arrow(source.e, block.port) # a live anchor, like any other
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from .core import Element, Group
22
+
23
+ __all__ = ["Component"]
24
+
25
+ #: Constructor arguments that belong to the Group, not to ``build()``.
26
+ _GROUP_KEYS = ("style", "classes", "theme", "name", "z", "visible", "opacity",
27
+ "clip", "audit", "add")
28
+
29
+
30
+ class Component(Group):
31
+ """Base class for a reusable group that publishes named anchors.
32
+
33
+ Subclasses implement :meth:`build`, returning the elements to hold, and
34
+ call :meth:`expose` for anything callers should be able to point at.
35
+ Group keyword arguments (``name``, ``z``, ``theme``, ``style``, …) are
36
+ understood by the constructor and never reach ``build()``.
37
+ """
38
+
39
+ def __init__(self, *args, **kwargs):
40
+ group_kwargs = {k: kwargs.pop(k) for k in _GROUP_KEYS if k in kwargs}
41
+ self._exposed: dict = {}
42
+ super().__init__(**group_kwargs)
43
+ children = self.build(*args, **kwargs)
44
+ if children is None:
45
+ children = []
46
+ elif isinstance(children, Element):
47
+ children = [children]
48
+ self.add(*children)
49
+
50
+ # -- subclass hook ---------------------------------------------------
51
+ def build(self, *args, **kwargs):
52
+ """Create and return this component's elements.
53
+
54
+ Called once from the constructor with whatever positional and keyword
55
+ arguments were not consumed as group options.
56
+ """
57
+ raise NotImplementedError(
58
+ f"{type(self).__name__} must implement build()")
59
+
60
+ # -- named anchors ---------------------------------------------------
61
+ def expose(self, name: str, target) -> "Component":
62
+ """Publish ``target`` under ``name``.
63
+
64
+ ``target`` may be an :class:`~figkit.core.Anchor` (``body.e``), an
65
+ element (exposed as itself), or a callable returning either — use a
66
+ callable when the answer depends on state that changes later.
67
+ """
68
+ if name in _RESERVED:
69
+ raise ValueError(f"{name!r} is reserved; pick another anchor name")
70
+ self._exposed[str(name)] = target
71
+ return self
72
+
73
+ @property
74
+ def exposed(self) -> tuple:
75
+ """The names this component publishes."""
76
+ return tuple(self._exposed)
77
+
78
+ def anchor(self, name: str):
79
+ """Named anchors first, then the usual bounding-box anchors."""
80
+ if name in self._exposed:
81
+ return self._resolve(name)
82
+ return super().anchor(name)
83
+
84
+ def _resolve(self, name: str):
85
+ target = self._exposed[name]
86
+ return target() if callable(target) else target
87
+
88
+ def __getattr__(self, name: str):
89
+ # only reached when normal attribute lookup fails
90
+ exposed = self.__dict__.get("_exposed")
91
+ if exposed and name in exposed:
92
+ target = exposed[name]
93
+ return target() if callable(target) else target
94
+ raise AttributeError(
95
+ f"{type(self).__name__!r} object has no attribute {name!r}")
96
+
97
+ def __repr__(self) -> str:
98
+ bb = self.bbox
99
+ names = ", ".join(self.exposed)
100
+ return (f"<{type(self).__name__} {len(self._children)} children "
101
+ f"[{names}] x={bb.x:.4g} y={bb.y:.4g} "
102
+ f"w={bb.w:.4g} h={bb.h:.4g}>")
103
+
104
+
105
+ #: Names that would shadow the Element API if exposed.
106
+ _RESERVED = frozenset({
107
+ "n", "s", "e", "w", "ne", "nw", "se", "sw", "c", "center", "bbox", "parent",
108
+ "style", "theme", "children", "point", "top", "bottom", "left", "right",
109
+ "build", "expose", "exposed", "anchor", "add", "move", "at",
110
+ })