diff --git a/arcadia_pycolor/gradient.py b/arcadia_pycolor/gradient.py index 3de6458..2cac517 100644 --- a/arcadia_pycolor/gradient.py +++ b/arcadia_pycolor/gradient.py @@ -6,6 +6,7 @@ from arcadia_pycolor.hexcode import HexCode from arcadia_pycolor.palette import ColorSequence, Palette from arcadia_pycolor.utils import ( + NumericSequence, distribute_values, interpolate_x_values, is_monotonic, @@ -94,6 +95,48 @@ def resample_as_palette(self, steps: int = 5) -> Palette: colors=colors, ) + def map_values( + self, + values: NumericSequence, + min_value: Union[float, None] = None, + max_value: Union[float, None] = None, + ) -> list[HexCode]: + """Map a sequence of values to their corresponding colors from a gradient + + Args: + min_value: + Determines which value corresponds to the first color in the spectrum. + Any values below this minimum are assigned to the first color. If not + provided, min(values) is chosen. + max_value: + Determines which value corresponds to the last color in the spectrum. + Any values greater than this maximum are assigned to the last color. If + not provided, max(values) is chosen. + + Returns: + list[HexCode]: A list of hex codes. + """ + + if not len(values): + return [] + + if min_value is None: + min_value = min(values) + + if max_value is None: + max_value = max(values) + + if min_value >= max_value: + raise ValueError( + f"max_value ({max_value}) must be greater than min_value ({min_value})." + ) + + cmap = self.to_mpl_cmap() + + normalized_values = [(value - min_value) / (max_value - min_value) for value in values] + + return [HexCode(f"{value}", mcolors.to_hex(cmap(value))) for value in normalized_values] + def interpolate_lightness(self) -> "Gradient": """ Interpolates the gradient to new values based on lightness. diff --git a/arcadia_pycolor/tests/test_gradients.py b/arcadia_pycolor/tests/test_gradients.py index 8d4ea3e..7fa4cba 100644 --- a/arcadia_pycolor/tests/test_gradients.py +++ b/arcadia_pycolor/tests/test_gradients.py @@ -2,6 +2,7 @@ import pytest from arcadia_pycolor import Gradient, HexCode +from arcadia_pycolor.colors import black, white from .test_hexcode import INVALID_HEXCODES @@ -116,3 +117,56 @@ def test_gradient_swatch_steps(): ) def test_gradient_swatch_repr(name, colors, swatch): assert Gradient(name, colors).__repr__() == swatch + + +@pytest.fixture +def black_to_white_gradient() -> Gradient: + return Gradient("_", [black, white], [0.0, 1.0]) + + +@pytest.mark.parametrize( + "values, expected_colors", + [ + ([0, 1], ["#000000", "#ffffff"]), + ([0, 0.5, 1], ["#000000", "#808080", "#ffffff"]), + ([1, 2, 3, 4, 5], ["#000000", "#404040", "#808080", "#c0c0c0", "#ffffff"]), + ([-1, 0, 1], ["#000000", "#808080", "#ffffff"]), + ([-3, -2, -1], ["#000000", "#808080", "#ffffff"]), + ([], []), + ], +) +def test_map_values_basic_cases( + black_to_white_gradient: Gradient, + values: list[float], + expected_colors: list[str], +): + assert black_to_white_gradient.map_values(values) == expected_colors + + +@pytest.mark.parametrize( + "values, min_value, max_value, expected_colors", + [ + ([0, 0.5, 1], 0, 1, ["#000000", "#808080", "#ffffff"]), + ([0, 0.5, 1], 0.25, 0.75, ["#000000", "#808080", "#ffffff"]), + ([-1, 0.5, 2], 0, 1, ["#000000", "#808080", "#ffffff"]), + ([0, 10], 0, 20, ["#000000", "#808080"]), + ], +) +def test_map_values_custom_ranges( + black_to_white_gradient: Gradient, + values: list[float], + min_value: float, + max_value: float, + expected_colors: list[str], +): + assert black_to_white_gradient.map_values(values, min_value, max_value) == expected_colors + + +def test_map_values_invalid_cases(black_to_white_gradient: Gradient): + # You can't pass min larger than max + with pytest.raises(ValueError, match="must be greater than"): + black_to_white_gradient.map_values([0, 1], min_value=1, max_value=0) + + # Or min equal to max + with pytest.raises(ValueError, match="must be greater than"): + black_to_white_gradient.map_values([0, 1], min_value=1, max_value=1)