scales-python 1.4.0.9000__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.
scales/colour_manip.py ADDED
@@ -0,0 +1,681 @@
1
+ """
2
+ Colour manipulation utilities for the scales package.
3
+
4
+ Python port of R/colour-manip.R from the R scales package
5
+ (https://github.com/r-lib/scales). Provides helpers for adjusting
6
+ transparency, desaturation, HCL manipulation, mixing, and display.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import List, Optional, Sequence, Union
12
+
13
+ import numpy as np
14
+
15
+ from ._colors import to_hex, to_rgb, to_rgba
16
+ from numpy.typing import ArrayLike
17
+
18
+ __all__ = [
19
+ "alpha",
20
+ "muted",
21
+ "col2hcl",
22
+ "show_col",
23
+ "col_mix",
24
+ "col_shift",
25
+ "col_lighter",
26
+ "col_darker",
27
+ "col_saturate",
28
+ "wrap_col_adjustment",
29
+ ]
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Internal colour-space helpers
34
+ # ---------------------------------------------------------------------------
35
+
36
+ def _linearize(c: float) -> float:
37
+ """sRGB channel [0, 1] -> linear RGB."""
38
+ if c <= 0.04045:
39
+ return c / 12.92
40
+ return ((c + 0.055) / 1.055) ** 2.4
41
+
42
+
43
+ def _delinearize(c: float) -> float:
44
+ """Linear RGB -> sRGB channel [0, 1]."""
45
+ if c <= 0.0031308:
46
+ return 12.92 * c
47
+ return 1.055 * (c ** (1.0 / 2.4)) - 0.055
48
+
49
+
50
+ def _rgb_to_xyz(r: float, g: float, b: float) -> tuple[float, float, float]:
51
+ """sRGB [0, 1] -> CIE XYZ (D65)."""
52
+ rl = _linearize(r)
53
+ gl = _linearize(g)
54
+ bl = _linearize(b)
55
+ x = 0.4124564 * rl + 0.3575761 * gl + 0.1804375 * bl
56
+ y = 0.2126729 * rl + 0.7151522 * gl + 0.0721750 * bl
57
+ z = 0.0193339 * rl + 0.1191920 * gl + 0.9503041 * bl
58
+ return x, y, z
59
+
60
+
61
+ def _xyz_to_rgb(x: float, y: float, z: float) -> tuple[float, float, float]:
62
+ """CIE XYZ (D65) -> sRGB [0, 1], clamped."""
63
+ rl = 3.2404542 * x - 1.5371385 * y - 0.4985314 * z
64
+ gl = -0.9692660 * x + 1.8760108 * y + 0.0415560 * z
65
+ bl = 0.0556434 * x - 0.2040259 * y + 1.0572252 * z
66
+ r = np.clip(_delinearize(rl), 0.0, 1.0)
67
+ g = np.clip(_delinearize(gl), 0.0, 1.0)
68
+ b = np.clip(_delinearize(bl), 0.0, 1.0)
69
+ return float(r), float(g), float(b)
70
+
71
+
72
+ def _rgb_to_lab(r: float, g: float, b: float) -> tuple[float, float, float]:
73
+ """
74
+ Convert sRGB [0, 1] to CIELAB (D65 illuminant).
75
+
76
+ Parameters
77
+ ----------
78
+ r, g, b : float
79
+ sRGB colour channels in [0, 1].
80
+
81
+ Returns
82
+ -------
83
+ tuple of float
84
+ ``(L, a, b)`` in CIELAB space.
85
+ """
86
+ # sRGB -> linear
87
+ rl = _linearize(r)
88
+ gl = _linearize(g)
89
+ bl = _linearize(b)
90
+
91
+ # linear RGB -> XYZ (D65)
92
+ x = 0.4124564 * rl + 0.3575761 * gl + 0.1804375 * bl
93
+ y = 0.2126729 * rl + 0.7151522 * gl + 0.0721750 * bl
94
+ z = 0.0193339 * rl + 0.1191920 * gl + 0.9503041 * bl
95
+
96
+ # D65 reference white
97
+ xn, yn, zn = 0.95047, 1.0, 1.08883
98
+
99
+ def _f(t: float) -> float:
100
+ delta = 6.0 / 29.0
101
+ if t > delta ** 3:
102
+ return t ** (1.0 / 3.0)
103
+ return t / (3.0 * delta * delta) + 4.0 / 29.0
104
+
105
+ fx = _f(x / xn)
106
+ fy = _f(y / yn)
107
+ fz = _f(z / zn)
108
+
109
+ L = 116.0 * fy - 16.0
110
+ a_star = 500.0 * (fx - fy)
111
+ b_star = 200.0 * (fy - fz)
112
+ return L, a_star, b_star
113
+
114
+
115
+ def _lab_to_rgb(
116
+ L: float, a_star: float, b_star: float
117
+ ) -> tuple[float, float, float]:
118
+ """
119
+ Convert CIELAB (D65) to sRGB [0, 1], clamped.
120
+
121
+ Parameters
122
+ ----------
123
+ L, a_star, b_star : float
124
+ CIELAB coordinates.
125
+
126
+ Returns
127
+ -------
128
+ tuple of float
129
+ ``(r, g, b)`` clamped to [0, 1].
130
+ """
131
+ xn, yn, zn = 0.95047, 1.0, 1.08883
132
+ delta = 6.0 / 29.0
133
+
134
+ fy = (L + 16.0) / 116.0
135
+ fx = fy + a_star / 500.0
136
+ fz = fy - b_star / 200.0
137
+
138
+ def _finv(t: float) -> float:
139
+ if t > delta:
140
+ return t ** 3
141
+ return 3.0 * delta * delta * (t - 4.0 / 29.0)
142
+
143
+ x = xn * _finv(fx)
144
+ y = yn * _finv(fy)
145
+ z = zn * _finv(fz)
146
+
147
+ # XYZ -> linear RGB
148
+ rl = 3.2404542 * x - 1.5371385 * y - 0.4985314 * z
149
+ gl = -0.9692660 * x + 1.8760108 * y + 0.0415560 * z
150
+ bl = 0.0556434 * x - 0.2040259 * y + 1.0572252 * z
151
+
152
+ r = np.clip(_delinearize(rl), 0.0, 1.0)
153
+ g = np.clip(_delinearize(gl), 0.0, 1.0)
154
+ b = np.clip(_delinearize(bl), 0.0, 1.0)
155
+ return float(r), float(g), float(b)
156
+
157
+
158
+ def _lab_to_hcl(
159
+ L: float, a: float, b: float
160
+ ) -> tuple[float, float, float]:
161
+ """
162
+ Convert CIELAB to HCL (cylindrical Lab).
163
+
164
+ Returns
165
+ -------
166
+ tuple of float
167
+ ``(H, C, L)`` where H is in degrees [0, 360).
168
+ """
169
+ h = np.degrees(np.arctan2(b, a)) % 360
170
+ c = np.sqrt(a ** 2 + b ** 2)
171
+ return float(h), float(c), L
172
+
173
+
174
+ def _hcl_to_lab(
175
+ h: float, c: float, l: float
176
+ ) -> tuple[float, float, float]:
177
+ """
178
+ Convert HCL back to CIELAB.
179
+
180
+ Parameters
181
+ ----------
182
+ h : float
183
+ Hue in degrees.
184
+ c : float
185
+ Chroma.
186
+ l : float
187
+ Luminance (CIELAB *L*).
188
+
189
+ Returns
190
+ -------
191
+ tuple of float
192
+ ``(L, a, b)`` in CIELAB.
193
+ """
194
+ a = c * np.cos(np.radians(h))
195
+ b = c * np.sin(np.radians(h))
196
+ return l, float(a), float(b)
197
+
198
+
199
+ def _hex_to_hcl(colour: str) -> tuple[float, float, float, float]:
200
+ """Return ``(H, C, L, alpha)`` for a colour string."""
201
+ rgba = to_rgba(colour)
202
+ L, a, b = _rgb_to_lab(rgba[0], rgba[1], rgba[2])
203
+ h, c, l = _lab_to_hcl(L, a, b)
204
+ return h, c, l, rgba[3]
205
+
206
+
207
+ def _hcl_to_hex(
208
+ h: float, c: float, l: float, alpha_val: float = 1.0
209
+ ) -> str:
210
+ """Return hex string from HCL + alpha."""
211
+ L, a, b = _hcl_to_lab(h, c, l)
212
+ r, g, bb = _lab_to_rgb(L, a, b)
213
+ return to_hex((r, g, bb, alpha_val), keep_alpha=True)
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # Public API
218
+ # ---------------------------------------------------------------------------
219
+
220
+ def alpha(
221
+ colour: Union[str, Sequence[str]],
222
+ alpha_value: Union[None, float, Sequence[Optional[float]]] = None,
223
+ ) -> Union[str, List[str]]:
224
+ """
225
+ Modify the alpha transparency of colour(s).
226
+
227
+ Parameters
228
+ ----------
229
+ colour : str or sequence of str
230
+ One or more colour specifications (names, hex codes, etc.).
231
+ alpha_value : float or sequence of float, optional
232
+ New alpha value(s) in [0, 1]. If *None*, colours are returned
233
+ unchanged.
234
+
235
+ Returns
236
+ -------
237
+ str or list of str
238
+ Colour(s) with the requested alpha channel, as ``#RRGGBBAA`` hex
239
+ strings.
240
+
241
+ Examples
242
+ --------
243
+ >>> alpha("red", 0.5)
244
+ '#ff000080'
245
+ """
246
+ scalar_input = isinstance(colour, str)
247
+ colours = [colour] if scalar_input else list(colour)
248
+
249
+ if alpha_value is None:
250
+ result = [to_hex(to_rgba(c), keep_alpha=True) for c in colours]
251
+ return result[0] if scalar_input else result
252
+
253
+ # Vectorise alpha_value
254
+ if isinstance(alpha_value, (int, float)):
255
+ alphas: List[Optional[float]] = [float(alpha_value)] * len(colours)
256
+ else:
257
+ alphas = list(alpha_value)
258
+
259
+ if len(alphas) == 1:
260
+ alphas = alphas * len(colours)
261
+ elif len(colours) == 1:
262
+ colours = colours * len(alphas)
263
+
264
+ if len(colours) != len(alphas):
265
+ raise ValueError(
266
+ "colour and alpha_value must have compatible lengths."
267
+ )
268
+
269
+ result = []
270
+ for c, a in zip(colours, alphas):
271
+ rgba = to_rgba(c)
272
+ # R semantics: NA alpha keeps the original colour's alpha
273
+ if a is None or (isinstance(a, float) and np.isnan(a)):
274
+ new_alpha = rgba[3]
275
+ else:
276
+ new_alpha = float(a)
277
+ # R's farver::encode_colour always includes alpha when provided,
278
+ # so force #RRGGBBAA format even for fully opaque.
279
+ r = int(round(rgba[0] * 255))
280
+ g = int(round(rgba[1] * 255))
281
+ b = int(round(rgba[2] * 255))
282
+ aa = int(round(new_alpha * 255))
283
+ result.append(f"#{r:02x}{g:02x}{b:02x}{aa:02x}")
284
+
285
+ return result[0] if scalar_input and isinstance(alpha_value, (int, float)) else result
286
+
287
+
288
+ def muted(colour: str, l: float = 30, c: float = 70) -> str:
289
+ """
290
+ Desaturate a colour by reducing luminance and chroma in HCL space.
291
+
292
+ Parameters
293
+ ----------
294
+ colour : str
295
+ A colour specification.
296
+ l : float, default 30
297
+ Target luminance (CIELAB *L*).
298
+ c : float, default 70
299
+ Target chroma.
300
+
301
+ Returns
302
+ -------
303
+ str
304
+ Muted colour as a hex string.
305
+
306
+ Examples
307
+ --------
308
+ >>> muted("red") # doctest: +SKIP
309
+ '#c66565ff'
310
+ """
311
+ h_val, _c, _l, a_val = _hex_to_hcl(colour)
312
+ return _hcl_to_hex(h_val, c, l, a_val)
313
+
314
+
315
+ def col2hcl(
316
+ colour: Union[str, Sequence[str]],
317
+ h: Optional[float] = None,
318
+ c: Optional[float] = None,
319
+ l: Optional[float] = None,
320
+ alpha_value: Optional[float] = None,
321
+ ) -> Union[str, List[str]]:
322
+ """
323
+ Convert colour(s) to HCL, optionally overriding components, and return hex.
324
+
325
+ Parameters
326
+ ----------
327
+ colour : str or sequence of str
328
+ Input colour(s).
329
+ h, c, l : float, optional
330
+ Override hue, chroma, and/or luminance.
331
+ alpha_value : float, optional
332
+ Override alpha.
333
+
334
+ Returns
335
+ -------
336
+ str or list of str
337
+ Hex colour string(s).
338
+ """
339
+ scalar_input = isinstance(colour, str)
340
+ colours = [colour] if scalar_input else list(colour)
341
+
342
+ result = []
343
+ for col in colours:
344
+ hv, cv, lv, av = _hex_to_hcl(col)
345
+ hv = h if h is not None else hv
346
+ cv = c if c is not None else cv
347
+ lv = l if l is not None else lv
348
+ av = alpha_value if alpha_value is not None else av
349
+ result.append(_hcl_to_hex(hv, cv, lv, av))
350
+
351
+ return result[0] if scalar_input else result
352
+
353
+
354
+ def show_col(
355
+ colours: Sequence[str],
356
+ labels: bool = True,
357
+ borders: Optional[str] = None,
358
+ cex_label: float = 1.0,
359
+ ncol: Optional[int] = None,
360
+ ) -> None:
361
+ """
362
+ Display colours in a rectangular grid using matplotlib.
363
+
364
+ Parameters
365
+ ----------
366
+ colours : sequence of str
367
+ Colour specifications to display.
368
+ labels : bool, default True
369
+ Whether to print the hex code inside each swatch.
370
+ borders : str, optional
371
+ Border colour for each swatch. *None* means no visible border.
372
+ cex_label : float, default 1.0
373
+ Relative text size for labels.
374
+ ncol : int, optional
375
+ Number of columns. If *None*, a roughly square layout is chosen.
376
+ """
377
+ try:
378
+ import matplotlib.pyplot as plt
379
+ except ImportError:
380
+ raise ImportError(
381
+ "show_col() requires matplotlib. Install with: "
382
+ "pip install scales_py[plot]"
383
+ )
384
+
385
+ n = len(colours)
386
+ if n == 0:
387
+ return
388
+
389
+ if ncol is None:
390
+ ncol = int(np.ceil(np.sqrt(n)))
391
+ nrow = int(np.ceil(n / ncol))
392
+
393
+ fig, axes = plt.subplots(
394
+ nrow, ncol, figsize=(ncol * 1.2, nrow * 1.2),
395
+ squeeze=False,
396
+ )
397
+
398
+ for idx in range(nrow * ncol):
399
+ row, col_idx = divmod(idx, ncol)
400
+ ax = axes[row][col_idx]
401
+ ax.set_xticks([])
402
+ ax.set_yticks([])
403
+
404
+ if idx < n:
405
+ ax.set_facecolor(colours[idx])
406
+ edge = borders if borders is not None else "none"
407
+ for spine in ax.spines.values():
408
+ spine.set_edgecolor(edge)
409
+ spine.set_linewidth(1 if borders else 0)
410
+ if labels:
411
+ r, g, b = to_rgb(colours[idx])
412
+ lum = 0.299 * r + 0.587 * g + 0.114 * b
413
+ text_col = "white" if lum < 0.5 else "black"
414
+ ax.text(
415
+ 0.5, 0.5,
416
+ to_hex(colours[idx]),
417
+ transform=ax.transAxes,
418
+ ha="center", va="center",
419
+ fontsize=8 * cex_label,
420
+ color=text_col,
421
+ )
422
+ else:
423
+ ax.set_visible(False)
424
+
425
+ fig.tight_layout(pad=0.5)
426
+ plt.show()
427
+
428
+
429
+ _COL_MIX_SPACES = {"rgb", "lab", "hcl", "lch", "hsl", "xyz"}
430
+
431
+
432
+ def wrap_col_adjustment(
433
+ inner: object,
434
+ outer_fn,
435
+ /,
436
+ **outer_kwargs,
437
+ ):
438
+ """Wrap a colour palette so *outer_fn* is applied to each output colour.
439
+
440
+ Port of R's ``wrap_col_adjustment`` (``R/colour-manip.R``). Used by
441
+ ``col_mix``/``col_shift``/``col_lighter``/``col_saturate`` when the
442
+ first argument is a palette rather than a bare colour string — the
443
+ result is a **new palette** of the same kind whose outputs are the
444
+ adjusted colours.
445
+
446
+ Parameters
447
+ ----------
448
+ inner : ContinuousPalette or DiscretePalette
449
+ The colour palette to wrap. Must be a colour palette
450
+ (``palette_type(inner) == "colour"``).
451
+ outer_fn : callable
452
+ One of ``col_mix`` / ``col_shift`` / ``col_lighter`` / ``col_darker``
453
+ / ``col_saturate`` — called as
454
+ ``outer_fn(inner(...), **outer_kwargs)``.
455
+ **outer_kwargs
456
+ Passed to *outer_fn* on each invocation.
457
+
458
+ Returns
459
+ -------
460
+ ContinuousPalette or DiscretePalette
461
+ A new palette, same kind as *inner*, returning adjusted colours.
462
+ """
463
+ from .palettes import (
464
+ ContinuousPalette,
465
+ DiscretePalette,
466
+ is_colour_pal,
467
+ palette_nlevels,
468
+ palette_na_safe,
469
+ )
470
+
471
+ if not is_colour_pal(inner):
472
+ raise TypeError("wrap_col_adjustment requires a colour palette")
473
+
474
+ def _adjusted(*args, **kwargs):
475
+ raw = inner(*args, **kwargs)
476
+ if isinstance(raw, str):
477
+ return outer_fn(raw, **outer_kwargs)
478
+ # list / tuple / ndarray of colours
479
+ return [outer_fn(c, **outer_kwargs) for c in raw]
480
+
481
+ if isinstance(inner, DiscretePalette):
482
+ return DiscretePalette(
483
+ _adjusted, type="colour", nlevels=palette_nlevels(inner)
484
+ )
485
+ return ContinuousPalette(
486
+ _adjusted, type="colour", na_safe=palette_na_safe(inner)
487
+ )
488
+
489
+
490
+ def _is_palette_input(x) -> bool:
491
+ """True if *x* is a scales palette object (discrete or continuous)."""
492
+ from .palettes import ContinuousPalette, DiscretePalette
493
+ return isinstance(x, (ContinuousPalette, DiscretePalette))
494
+
495
+
496
+ def col_mix(
497
+ a: str,
498
+ b: str,
499
+ amount: float = 0.5,
500
+ space: str = "rgb",
501
+ ) -> str:
502
+ """
503
+ Mix two colours.
504
+
505
+ Mirrors R's ``col_mix``: components are interpolated linearly in the
506
+ requested *space*. Hue channels are interpolated as plain numbers
507
+ (not circular shortest-path), matching ``farver``'s behaviour.
508
+
509
+ Parameters
510
+ ----------
511
+ a, b : str
512
+ Colour specifications.
513
+ amount : float, default 0.5
514
+ Mixing fraction. 0 returns *a*, 1 returns *b*.
515
+ space : str, default "rgb"
516
+ Interpolation space. One of ``"rgb"``, ``"lab"``, ``"hcl"``,
517
+ ``"lch"`` (alias of ``"hcl"``), or ``"hsl"``.
518
+
519
+ Returns
520
+ -------
521
+ str
522
+ Mixed colour as a hex string.
523
+ """
524
+ if not (0.0 <= amount <= 1.0):
525
+ raise ValueError(f"amount must be between 0 and 1, got {amount}")
526
+ if space not in _COL_MIX_SPACES:
527
+ raise ValueError(
528
+ f"space must be one of {sorted(_COL_MIX_SPACES)!r}, got {space!r}"
529
+ )
530
+
531
+ # S3-style dispatch on palette first arg (mirrors R's
532
+ # `col_mix.scales_pal` → `wrap_col_adjustment`).
533
+ if _is_palette_input(a):
534
+ return wrap_col_adjustment(a, col_mix, b=b, amount=amount, space=space)
535
+
536
+ rgba_a = to_rgba(a)
537
+ rgba_b = to_rgba(b)
538
+ alpha_m = rgba_a[3] + amount * (rgba_b[3] - rgba_a[3])
539
+
540
+ if space == "rgb":
541
+ mixed = tuple(
542
+ rgba_a[i] + amount * (rgba_b[i] - rgba_a[i]) for i in range(4)
543
+ )
544
+ return to_hex(mixed, keep_alpha=True)
545
+
546
+ if space == "lab":
547
+ La, aa, ba = _rgb_to_lab(*rgba_a[:3])
548
+ Lb, ab, bb = _rgb_to_lab(*rgba_b[:3])
549
+ Lm = La + amount * (Lb - La)
550
+ am = aa + amount * (ab - aa)
551
+ bm = ba + amount * (bb - ba)
552
+ r, g, bl = _lab_to_rgb(Lm, am, bm)
553
+ return to_hex((r, g, bl, alpha_m), keep_alpha=True)
554
+
555
+ if space in ("hcl", "lch"):
556
+ # HCL == LCH (cylindrical CIELAB), farver-style linear hue mix.
557
+ La, aa, ba = _rgb_to_lab(*rgba_a[:3])
558
+ Lb, ab, bb = _rgb_to_lab(*rgba_b[:3])
559
+ ha, ca, la_ = _lab_to_hcl(La, aa, ba)
560
+ hb, cb, lb_ = _lab_to_hcl(Lb, ab, bb)
561
+ hm = ha + amount * (hb - ha)
562
+ cm = ca + amount * (cb - ca)
563
+ lm = la_ + amount * (lb_ - la_)
564
+ L, a_, b_ = _hcl_to_lab(hm, cm, lm)
565
+ r, g, bl = _lab_to_rgb(L, a_, b_)
566
+ return to_hex((r, g, bl, alpha_m), keep_alpha=True)
567
+
568
+ if space == "xyz":
569
+ xa, ya, za = _rgb_to_xyz(*rgba_a[:3])
570
+ xb, yb, zb = _rgb_to_xyz(*rgba_b[:3])
571
+ xm = xa + amount * (xb - xa)
572
+ ym = ya + amount * (yb - ya)
573
+ zm = za + amount * (zb - za)
574
+ r, g, bl = _xyz_to_rgb(xm, ym, zm)
575
+ return to_hex((r, g, bl, alpha_m), keep_alpha=True)
576
+
577
+ # HSL via colorsys (which calls it HLS — same space, just channel
578
+ # ordering H/L/S instead of H/S/L).
579
+ import colorsys
580
+ ha, la_, sa = colorsys.rgb_to_hls(*rgba_a[:3])
581
+ hb, lb_, sb = colorsys.rgb_to_hls(*rgba_b[:3])
582
+ hm = ha + amount * (hb - ha)
583
+ lm = la_ + amount * (lb_ - la_)
584
+ sm = sa + amount * (sb - sa)
585
+ r, g, bl = colorsys.hls_to_rgb(hm, lm, sm)
586
+ return to_hex((r, g, bl, alpha_m), keep_alpha=True)
587
+
588
+
589
+ def col_shift(col, amount: float = 10):
590
+ """
591
+ Shift the hue of a colour.
592
+
593
+ Accepts a colour string **or** a colour palette. When a palette is
594
+ passed, returns a new palette whose colours are all hue-shifted by
595
+ *amount* (mirrors R's ``col_shift.scales_pal``).
596
+
597
+ Parameters
598
+ ----------
599
+ col : str or palette
600
+ A colour specification or a scales palette object.
601
+ amount : float, default 10
602
+ Degrees to shift hue.
603
+
604
+ Returns
605
+ -------
606
+ str or palette
607
+ Hue-shifted colour(s).
608
+ """
609
+ if _is_palette_input(col):
610
+ return wrap_col_adjustment(col, col_shift, amount=amount)
611
+ h, c, l, a = _hex_to_hcl(col)
612
+ return _hcl_to_hex((h + amount) % 360, c, l, a)
613
+
614
+
615
+ def col_lighter(col, amount: float = 10):
616
+ """
617
+ Increase the lightness of a colour in HSL space.
618
+
619
+ Matches R's ``farver::add_to_channel(col, "l", amount, space = "hsl")``.
620
+ Accepts a colour string or a palette (wrapped via
621
+ :func:`wrap_col_adjustment`).
622
+
623
+ Parameters
624
+ ----------
625
+ col : str or palette
626
+ A colour specification or a scales palette object.
627
+ amount : float, default 10
628
+ Amount to add to lightness (HSL *L*, range 0–100).
629
+
630
+ Returns
631
+ -------
632
+ str or palette
633
+ """
634
+ if _is_palette_input(col):
635
+ return wrap_col_adjustment(col, col_lighter, amount=amount)
636
+ import colorsys
637
+ rgba = to_rgba(col)
638
+ h, l, s = colorsys.rgb_to_hls(rgba[0], rgba[1], rgba[2])
639
+ # HSL L is [0,1], R uses [0,100] scale for amount
640
+ l = max(0.0, min(1.0, l + amount / 100.0))
641
+ r, g, b = colorsys.hls_to_rgb(h, l, s)
642
+ return to_hex((r, g, b, rgba[3]), keep_alpha=True)
643
+
644
+
645
+ def col_darker(col, amount: float = 10):
646
+ """
647
+ Decrease the luminance of a colour.
648
+
649
+ Equivalent to ``col_lighter(col, -amount)`` — and, for palette
650
+ inputs, returns a palette whose colours are darkened.
651
+ """
652
+ return col_lighter(col, -amount)
653
+
654
+
655
+ def col_saturate(col, amount: float = 10):
656
+ """
657
+ Increase the saturation of a colour in HSL space.
658
+
659
+ Matches R's ``farver::add_to_channel(col, "s", amount, space = "hsl")``.
660
+ Accepts a colour string or a palette.
661
+
662
+ Parameters
663
+ ----------
664
+ col : str or palette
665
+ A colour specification or a scales palette object.
666
+ amount : float, default 10
667
+ Amount to add to saturation (HSL *S*, range 0–100).
668
+
669
+ Returns
670
+ -------
671
+ str or palette
672
+ """
673
+ if _is_palette_input(col):
674
+ return wrap_col_adjustment(col, col_saturate, amount=amount)
675
+ import colorsys
676
+ rgba = to_rgba(col)
677
+ h, l, s = colorsys.rgb_to_hls(rgba[0], rgba[1], rgba[2])
678
+ # HSL S is [0,1], R uses [0,100] scale for amount
679
+ s = max(0.0, min(1.0, s + amount / 100.0))
680
+ r, g, b = colorsys.hls_to_rgb(h, l, s)
681
+ return to_hex((r, g, b, rgba[3]), keep_alpha=True)