|
5 | 5 | from collections import OrderedDict |
6 | 6 | from collections.abc import Iterable, Mapping, Sequence |
7 | 7 | from copy import copy |
| 8 | +from dataclasses import dataclass |
8 | 9 | from functools import partial |
9 | 10 | from pathlib import Path |
10 | 11 | from types import MappingProxyType |
@@ -2551,59 +2552,94 @@ def _convert_alpha_to_datashader_range(alpha: float) -> float: |
2551 | 2552 | return min([254, alpha * 255]) |
2552 | 2553 |
|
2553 | 2554 |
|
2554 | | -class ColorHandler: |
2555 | | - """Validate, parse and store a single color.""" |
| 2555 | +@dataclass |
| 2556 | +class Color: |
| 2557 | + """Validate, parse and store a single color. |
2556 | 2558 |
|
2557 | | - color_hex: str | None |
| 2559 | + Accepts a color and an alpha value. |
| 2560 | + If no color or "default" is given, the default color "lightgray" is used. |
| 2561 | + If no alpha is given, the default of completely opaque is used ("FF"). |
| 2562 | + At all times, if color indicates an alpha value, for instance as part of a hex string, the alpha parameter takes |
| 2563 | + precedence if given. |
| 2564 | + """ |
| 2565 | + |
| 2566 | + color: str |
| 2567 | + alpha: str |
2558 | 2568 |
|
2559 | | - def __init__(self, color: ColorLike | list[float] | None): |
| 2569 | + def __post__init__(self) -> None: |
2560 | 2570 | """Validate input and store as hex color. |
2561 | 2571 |
|
2562 | | - `color` can be |
| 2572 | + `self.color` can be |
2563 | 2573 | - str: hex representation, named color or "default" |
2564 | 2574 | - None (values won't be shown) |
2565 | 2575 | - tuple[float, ...] | list[float]: RGB(A) array (all values within [0, 1]!) |
2566 | 2576 | """ |
2567 | | - if color is None: |
2568 | | - self.color_hex = None |
2569 | | - elif isinstance(color, str): |
| 2577 | + # 1) Validate alpha value |
| 2578 | + if isinstance(self.alpha, float | int): |
| 2579 | + if self.alpha <= 1.0 and self.alpha >= 0.0: |
| 2580 | + # Convert float alpha to hex representation |
| 2581 | + self.alpha = int(np.round(self.alpha * 255)) |
| 2582 | + self.alpha = hex(self.alpha)[2:].upper() |
| 2583 | + if len(self.alpha) == 1: |
| 2584 | + self.alpha = "0" + self.alpha |
| 2585 | + else: |
| 2586 | + raise ValueError(f"Invalid alpha value `{self.alpha}`, must lie within [0.0, 1.0].") |
| 2587 | + elif self.alpha is not None: |
| 2588 | + raise ValueError(f"Invalid alpha value `{self.alpha}`, must be None or a float | int within [0.0, 1.0].") |
| 2589 | + |
| 2590 | + # 2) Validate color value |
| 2591 | + if self.color is None or self.color == "default": |
| 2592 | + self.color = colors.to_hex("lightgray", keep_alpha=False) |
| 2593 | + elif isinstance(self.color, str): |
2570 | 2594 | # already hex |
2571 | | - if color.startswith("#"): |
2572 | | - if len(color) not in [7, 9]: |
2573 | | - raise ValueError("Invalid hex color length: must be either '#RRGGBB' or '#RRGGBBAA'") |
2574 | | - self.color_hex = color.lower() |
2575 | | - if len(self.color_hex) == 7: |
2576 | | - self.color_hex += "ff" # add alpha of 1.0 if needed |
2577 | | - if not all(c in "0123456789abcdef" for c in self.color_hex[1:]): |
| 2595 | + if self.color.startswith("#"): |
| 2596 | + if len(self.color) not in [7, 9]: |
| 2597 | + raise ValueError("Invalid hex color length: only formats '#RRGGBB' and '#RRGGBBAA' are supported.") |
| 2598 | + self.color = self.color.upper() |
| 2599 | + if not all(c in "0123456789ABCDEF" for c in self.color[1:]): |
2578 | 2600 | raise ValueError("Invalid hex color: contains non-hex characters") |
2579 | | - # "default" should refer to lightgray |
2580 | | - elif color == "default": |
2581 | | - self.color_hex = to_hex("lightgray", keep_alpha=True) |
| 2601 | + if len(self.color) == 9: |
| 2602 | + if self.alpha is None: |
| 2603 | + self.alpha = self.color[7:] |
| 2604 | + self.color = self.color[:7] |
2582 | 2605 | # named color |
2583 | 2606 | else: |
2584 | 2607 | # matplotlib raises ValueError in case of invalid color name |
2585 | | - self.color_hex = to_hex(color, keep_alpha=True) |
2586 | | - elif isinstance(color, list[float] | tuple[float, ...]): |
2587 | | - if len(color) < 3: |
2588 | | - raise ValueError(f"Color `{color}` can't be interpreted as RGB(A) array, needs at least 3 values.") |
| 2608 | + self.color = colors.to_hex(self.color, keep_alpha=False) |
| 2609 | + elif isinstance(self.color, list[float] | tuple[float, ...]): |
| 2610 | + if len(self.color) < 3: |
| 2611 | + raise ValueError(f"Color `{self.color}` can't be interpreted as RGB(A) array, needs at least 3 values.") |
2589 | 2612 | # get first 3-4 values |
2590 | | - r, g, b = color[0], color[1], color[2] |
2591 | | - a = 1.0 if len(color) == 3 else color[3] |
| 2613 | + r, g, b = self.color[0], self.color[1], self.color[2] |
| 2614 | + a = 1.0 if len(self.color) == 3 else self.color[3] |
2592 | 2615 | if any(np.array([r, g, b, a]) > 1) or any(np.array([r, g, b, a]) < 0): |
2593 | | - raise ValueError(f"Invalid color `{color}`, all values in RGB(A) array must be within [0.0, 1.0].") |
2594 | | - self.color_hex = colors.rgb2hex((r, g, b, a), keep_alpha=True) |
| 2616 | + raise ValueError(f"Invalid color `{self.color}`, all values in RGB(A) array must be within [0.0, 1.0].") |
| 2617 | + self.color = colors.rgb2hex((r, g, b, a), keep_alpha=False) |
| 2618 | + if self.alpha is None: |
| 2619 | + self.alpha = colors.rgb2hex((r, g, b, a), keep_alpha=True)[7:] |
| 2620 | + elif isinstance(self.color, float | int): |
| 2621 | + raise TypeError( |
| 2622 | + f"Invalid type `{type(self.color)}` for a color, expecting str | None | tuple[float, ...] | " |
| 2623 | + "list[float]. Note that unlike in matplotlib, giving a float within [0, 1] as a greyscale value is not " |
| 2624 | + "supported here!" |
| 2625 | + ) |
2595 | 2626 | else: |
2596 | 2627 | raise TypeError( |
2597 | | - f"Invalid type `{type(color)}` for a color, expecting str | None | tuple[float, ...] | list[float]." |
| 2628 | + f"Invalid type `{type(self.color)}` for color, expecting str | None | tuple[float, ...] | list[float]." |
2598 | 2629 | ) |
2599 | 2630 |
|
2600 | | - def get_hex(self) -> str | None: |
| 2631 | + # 3) Set default alpha if still necessary |
| 2632 | + if self.alpha is None: |
| 2633 | + self.alpha = "FF" # default: completely opaque |
| 2634 | + |
| 2635 | + def get_hex_with_alpha(self) -> str: |
2601 | 2636 | """Get color value as '#RRGGBBAA'.""" |
2602 | | - return self.color_hex |
| 2637 | + return self.color + self.alpha |
2603 | 2638 |
|
2604 | | - def get_hex_without_alpha(self) -> str | None: |
| 2639 | + def get_hex(self) -> str: |
2605 | 2640 | """Get color value as '#RRGGBB'.""" |
2606 | | - if isinstance(self.color_hex, str): |
2607 | | - # return hex after stripping the last 2 positions (=alpha) |
2608 | | - return self.color_hex[0:7] |
2609 | | - return self.color_hex |
| 2641 | + return self.color |
| 2642 | + |
| 2643 | + def get_alpha_as_float(self) -> float: |
| 2644 | + """Return alpha as value within [0.0, 1.0].""" |
| 2645 | + return int(self.alpha, 16) / 255 |
0 commit comments