diff --git a/js/src/10_colormaps.ts b/js/src/10_colormaps.ts index 7a93f964..d1353121 100644 --- a/js/src/10_colormaps.ts +++ b/js/src/10_colormaps.ts @@ -5,6 +5,11 @@ const COLORMAP_STOPS = { binary: [[255, 255, 255], [0, 0, 0]], + reds: [[255, 245, 240], [254, 229, 216], [253, 202, 181], [252, 171, 143], [252, 138, 106], [251, 105, 74], [241, 68, 50], [217, 37, 35], [188, 20, 26], [152, 12, 19], [103, 0, 13]], + bone: [[0, 0, 0], [22, 22, 30], [45, 45, 62], [66, 66, 93], [89, 92, 121], [112, 123, 144], [134, 154, 166], [157, 185, 188], [185, 210, 210], [221, 233, 233], [255, 255, 255]], + autumn: [[255, 0, 0], [255, 25, 0], [255, 51, 0], [255, 76, 0], [255, 102, 0], [255, 128, 0], [255, 153, 0], [255, 179, 0], [255, 204, 0], [255, 230, 0], [255, 255, 0]], + winter: [[0, 0, 255], [0, 25, 242], [0, 51, 230], [0, 76, 217], [0, 102, 204], [0, 128, 191], [0, 153, 178], [0, 179, 166], [0, 204, 153], [0, 230, 140], [0, 255, 128]], + bupu: [[247, 252, 253], [229, 239, 246], [204, 221, 236], [178, 202, 225], [154, 180, 214], [140, 149, 198], [140, 116, 181], [138, 81, 165], [133, 45, 144], [118, 12, 113], [77, 0, 75]], gray: [[0, 0, 0], [25, 25, 25], [51, 51, 51], [76, 76, 76], [102, 102, 102], [128, 128, 128], [153, 153, 153], [179, 179, 179], [204, 204, 204], [230, 230, 230], [255, 255, 255]], viridis: [[68, 1, 84], [72, 36, 117], [65, 68, 135], [53, 95, 141], [42, 120, 142], [33, 145, 140], [34, 168, 132], [68, 191, 112], [122, 209, 81], [189, 223, 38], [253, 231, 37]], plasma: [[13, 8, 135], [65, 4, 157], [106, 0, 168], [143, 13, 164], [177, 42, 144], [204, 71, 120], [225, 100, 98], [242, 132, 75], [252, 166, 54], [252, 206, 37], [240, 249, 33]], diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 3f235e58..834b54db 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -46,6 +46,71 @@ def _stroke_opacity(style: dict[str, Any], default: float = 1.0) -> float: # Mirrors js/src/10_colormaps.ts COLORMAP_STOPS (ยง36) โ€” test-guarded. COLORMAP_STOPS: dict[str, list[tuple[int, int, int]]] = { "binary": [(255, 255, 255), (0, 0, 0)], + "reds": [ + (255, 245, 240), + (254, 229, 216), + (253, 202, 181), + (252, 171, 143), + (252, 138, 106), + (251, 105, 74), + (241, 68, 50), + (217, 37, 35), + (188, 20, 26), + (152, 12, 19), + (103, 0, 13), + ], + "bone": [ + (0, 0, 0), + (22, 22, 30), + (45, 45, 62), + (66, 66, 93), + (89, 92, 121), + (112, 123, 144), + (134, 154, 166), + (157, 185, 188), + (185, 210, 210), + (221, 233, 233), + (255, 255, 255), + ], + "autumn": [ + (255, 0, 0), + (255, 25, 0), + (255, 51, 0), + (255, 76, 0), + (255, 102, 0), + (255, 128, 0), + (255, 153, 0), + (255, 179, 0), + (255, 204, 0), + (255, 230, 0), + (255, 255, 0), + ], + "winter": [ + (0, 0, 255), + (0, 25, 242), + (0, 51, 230), + (0, 76, 217), + (0, 102, 204), + (0, 128, 191), + (0, 153, 178), + (0, 179, 166), + (0, 204, 153), + (0, 230, 140), + (0, 255, 128), + ], + "bupu": [ + (247, 252, 253), + (229, 239, 246), + (204, 221, 236), + (178, 202, 225), + (154, 180, 214), + (140, 149, 198), + (140, 116, 181), + (138, 81, 165), + (133, 45, 144), + (118, 12, 113), + (77, 0, 75), + ], "gray": [ (0, 0, 0), (25, 25, 25), diff --git a/python/xy/channels.py b/python/xy/channels.py index d5417853..668f7324 100644 --- a/python/xy/channels.py +++ b/python/xy/channels.py @@ -45,6 +45,11 @@ "rdbu", "jet", "binary", + "reds", + "bone", + "autumn", + "winter", + "bupu", ) diff --git a/python/xy/pyplot/__init__.py b/python/xy/pyplot/__init__.py index 5afbf7fc..497ccedd 100644 --- a/python/xy/pyplot/__init__.py +++ b/python/xy/pyplot/__init__.py @@ -2833,12 +2833,16 @@ def get_cmap(name: str | None = None, lut: int | None = None) -> Cmap: return get_cmap(name, lut) def __getattr__(self, name: str) -> Any: - from ._colors import CMAPS + from ._colors import resolve_cmap if name == "ScalarMappable": return type("ScalarMappable", (), {"__init__": lambda self, **kwargs: None}) - if name.lower() in CMAPS: + try: + resolve_cmap(name) + except ValueError: + pass + else: return name raise AttributeError(f"colormap {name!r} is not supported; see spec/matplotlib/compat.md") diff --git a/python/xy/pyplot/_colors.py b/python/xy/pyplot/_colors.py index 6aa20083..dc274ced 100644 --- a/python/xy/pyplot/_colors.py +++ b/python/xy/pyplot/_colors.py @@ -91,6 +91,11 @@ def scalar_float(value: Any) -> float: "rdgy": "rdgy", "jet": "jet", "binary": "binary", + "reds": "reds", + "bone": "bone", + "autumn": "autumn", + "winter": "winter", + "bupu": "bupu", } diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index c11fb4fd..38006c6a 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -83,7 +83,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `plt.show()` | notebooks: inline HTML display; scripts: opens the default browser | | Artists: `set_data` / `set_ydata` / `set_color` / `set_label` / `set_linewidth` / `remove` | mutating a handle rebuilds the chart on next render. Scatter collections additionally vectorize facecolors, edgecolors, alpha, linewidths, and sizes; `alpha=None` restores intrinsic paint alpha | | Colors | single letters, `C0`โ€“`C9`, `tab:*`, gray `'0.5'`, RGB(A) tuples, `(color, alpha)` pairs, per-item RGB(A) arrays, and any CSS color | -| `plt.cm.*` / `plt.colormaps[...]` / `cmap=` names | viridis, plasma, inferno, magma, cividis, gray, turbo, coolwarm, Blues, Purples, PuBu, RdBu, RdYlGn, RdGy, PiYG, PRGn, jet, rainbow, Spectral, binary, aliases, and true `*_r` reversal (RdGy/jet render from 11-stop anchor tables sampled from Matplotlib 3.11, linearly interpolated) | +| `plt.cm.*` / `plt.colormaps[...]` / `cmap=` names | viridis, plasma, inferno, magma, cividis, gray, bone, autumn, winter, turbo, coolwarm, Blues, Purples, Reds, PuBu, BuPu, RdBu, RdYlGn, RdGy, PiYG, PRGn, jet, rainbow, Spectral, binary, aliases, and true `*_r` reversal resolved generically for every listed name, including `plt.cm._r` attribute access (RdGy/jet/Reds/bone/autumn/winter/BuPu render from 11-stop anchor tables sampled from Matplotlib 3.11, linearly interpolated) | | `LinearSegmentedColormap.from_list` / `ListedColormap` | Python-side callables (`cmap(np.arange(cmap.N))` โ†’ RGBA) for scripts that colormap values themselves; they cannot be passed as `cmap=` to plotting calls (no engine table), which fails loudly | | `plt.colorbar()` / `fig.colorbar()` / `plt.clim()` / `plt.gci()` | Returns a live handle (`set_label`, `set_ticks`); with no mappable it uses the current image the way pyplot does. `ticks=`/`extend=` render in PNG and SVG (the HTML colorbar stays a minimal gradient without tick text); `clim` retargets the mappable's color window and any colorbar derived from it | | `rcParams` | Figure size/DPI, line width/marker size, image cmap/origin, axes color cycle, and all four `axes.spines.*` switches affect every exporter. Pyplot axes default to Matplotlib's four-sided box and each spine can be hidden independently. The chrome keys (axes face/edge/label/title styles, font family/size, tick colors/sizes, legend defaults, figure facecolor) reach the HTML renderer and multi-panel PNG stitching; single-chart PNG and SVG export currently render their own fixed chrome and ignore them. Unknown keys warn once | diff --git a/tests/pyplot/test_pdsh_gap_features.py b/tests/pyplot/test_pdsh_gap_features.py index 62071dbe..8f5f09e7 100644 --- a/tests/pyplot/test_pdsh_gap_features.py +++ b/tests/pyplot/test_pdsh_gap_features.py @@ -223,6 +223,25 @@ def test_rdgy_and_jet_resolve_and_render(): _png() +@pytest.mark.parametrize( + ("name", "canonical"), + [ + ("Reds_r", "reds_r"), + ("bone", "bone"), + ("autumn", "autumn"), + ("winter", "winter"), + ("BuPu", "bupu"), + ], +) +def test_matplotlib_gallery_colormaps_resolve_and_render(name, canonical): + assert plt.get_cmap(name).name == canonical + assert plt.colormaps[name].name == canonical + assert getattr(plt.cm, name) == name + fig, ax = plt.subplots() + ax.imshow(np.arange(16.0).reshape(4, 4), cmap=name) + _png(fig) + + def test_linear_segmented_colormap_from_list_matches_anchors(): table = np.array([[0.0, 0.0, 0.0, 1.0], [1.0, 1.0, 1.0, 1.0]]) cmap = plt.LinearSegmentedColormap.from_list("g", table, 8) diff --git a/tests/test_svg_export.py b/tests/test_svg_export.py index ec25bba3..269e57c7 100644 --- a/tests/test_svg_export.py +++ b/tests/test_svg_export.py @@ -12,8 +12,9 @@ import pytest import xy +from xy import channels from xy._figure import Figure -from xy._svg import COLORMAP_STOPS, _axis_tick_label_layout, _Scale +from xy._svg import COLORMAP_STOPS, _axis_tick_label_layout, _colormap_stops, _Scale ROOT = Path(__file__).resolve().parents[1] @@ -684,6 +685,84 @@ def test_colormap_stops_stay_in_sync_with_js_client() -> None: assert f"[{r}, {g}, {b}]" in body, ( f"{name} stop ({r},{g},{b}) missing in 10_colormaps.ts" ) + assert set(COLORMAP_STOPS) == set(channels.COLORMAPS), ( + "renderer and public colormap registries diverged" + ) + + +def test_matplotlib_gallery_colormap_stops_and_reversal() -> None: + expected = { + "reds": [ + (255, 245, 240), + (254, 229, 216), + (253, 202, 181), + (252, 171, 143), + (252, 138, 106), + (251, 105, 74), + (241, 68, 50), + (217, 37, 35), + (188, 20, 26), + (152, 12, 19), + (103, 0, 13), + ], + "bone": [ + (0, 0, 0), + (22, 22, 30), + (45, 45, 62), + (66, 66, 93), + (89, 92, 121), + (112, 123, 144), + (134, 154, 166), + (157, 185, 188), + (185, 210, 210), + (221, 233, 233), + (255, 255, 255), + ], + "autumn": [ + (255, 0, 0), + (255, 25, 0), + (255, 51, 0), + (255, 76, 0), + (255, 102, 0), + (255, 128, 0), + (255, 153, 0), + (255, 179, 0), + (255, 204, 0), + (255, 230, 0), + (255, 255, 0), + ], + "winter": [ + (0, 0, 255), + (0, 25, 242), + (0, 51, 230), + (0, 76, 217), + (0, 102, 204), + (0, 128, 191), + (0, 153, 178), + (0, 179, 166), + (0, 204, 153), + (0, 230, 140), + (0, 255, 128), + ], + "bupu": [ + (247, 252, 253), + (229, 239, 246), + (204, 221, 236), + (178, 202, 225), + (154, 180, 214), + (140, 149, 198), + (140, 116, 181), + (138, 81, 165), + (133, 45, 144), + (118, 12, 113), + (77, 0, 75), + ], + } + for name, stops in expected.items(): + assert COLORMAP_STOPS[name] == stops + assert channels.is_colormap(name) + assert channels.is_colormap(f"{name}_r") + assert _colormap_stops(f"{name}_r") == list(reversed(stops)) def test_scalar_stroke_color_survives_vectorized_style_path() -> None: