diff --git a/docs/colormaps.md b/docs/colormaps.md index 1b701eff8..9f6c82388 100644 --- a/docs/colormaps.md +++ b/docs/colormaps.md @@ -83,6 +83,12 @@ to the [`cmap.Colormap`][] constructor; `cmap` refers to these objects collectiv *(same as `['blue', (0.4, 'green'), (0.8, 'yellow'), 'red']`)* + A single color yields a constant colormap that returns that color for every + value of `x` (i.e. `f(x) = c`). Any explicit stop position on the single + color is ignored, since position is meaningless for a constant mapping. + + - `Colormap(['violet'])` {{ cmap_expr: ['violet'] }} + ### `numpy.ndarray` A [`numpy.ndarray`][], in one of the following formats: diff --git a/src/cmap/_colormap.py b/src/cmap/_colormap.py index 433072c2f..1285e6607 100644 --- a/src/cmap/_colormap.py +++ b/src/cmap/_colormap.py @@ -98,7 +98,9 @@ class Colormap: specifying the position of the color in the gradient. When using color stops, the stop position values should be in the range [0, 1]. If no scalar stop positions are given, they will be linearly interpolated between any - neighboring stops (or 0-1 if there are no stops). + neighboring stops (or 0-1 if there are no stops). A single color (e.g. + `["red"]` or `{0.5: "red"}`) yields a constant colormap, where `f(x)` + returns that color for every value of `x`. - a `dict` mapping scalar values to color-like values: e.g. `{0.0: "red", 0.5: (0, 1, 0), 1.0: "#0000FF"}`. - a matplotlib-style [segmentdata @@ -1502,7 +1504,18 @@ def _parse_colorstops( _clr_seq = list(val) if len(_clr_seq) == 1: - _clr_seq = [None, _clr_seq[0]] + # A single color creates a constant colormap f(x) = c for all x in [0, 1]. + # We duplicate the color at positions 0 and 1, discarding any explicit + # stop position the user may have provided (which is meaningless for a + # constant mapping). + item = _clr_seq[0] + if isinstance(item, (tuple, list)) and len(item) == 2: + item = item[1] + elif (isinstance(item, (tuple, list)) and len(item) == 5) or ( + isinstance(item, np.ndarray) and item.shape == (5,) + ): + item = list(item)[1:] + _clr_seq = [item, item] _positions: list[float | None] = [] _colors: list[Color] = [] diff --git a/tests/test_colormap.py b/tests/test_colormap.py index d1550136b..2d05792ed 100644 --- a/tests/test_colormap.py +++ b/tests/test_colormap.py @@ -106,6 +106,27 @@ def test_colormap_copy() -> None: assert cmap1 != {"1234"} +def test_single_color_colormap() -> None: + """A single-color Colormap should evaluate to that color for all x in [0, 1]. + + Regression test for #139: previously a single color injected an implicit + transparent stop at position 0 (creating a transparent-to-color gradient). + The new behavior is f(x) = c, represented as two stops at 0 and 1, both + with the same color. + """ + expected = ColorStops.parse([(0.0, "violet"), (1.0, "violet")]) + # any of these single-color forms should produce the same constant colormap + for value in ( + ["violet"], + {0.5: "violet"}, + [(0.5, "violet")], # explicit position is dropped — meaningless for f(x)=c + ): + cmap = Colormap(value) + assert cmap.color_stops == expected + for x in (0.0, 0.25, 0.5, 0.75, 1.0): + assert cmap(x) == Color("violet") + + def test_colormap_errors() -> None: with pytest.raises(ValueError, match="Colormap 'bad_string' not found"): Colormap("bad_string")