From dd4e4b1a2574d1d3e97aaf2e20883981722beec7 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 17 Oct 2026 18:27:57 +0200 Subject: [PATCH 01/32] docs: migrate to zensical, slim down catalog pages, add compare page - Build with zensical instead of mkdocs + mkdocs-material. MkDocs hooks and gen-files are replaced by a pre-build step (`docs/_gen_catalog.py`) and the zensical macros extension (`docs/_macros.py`). - Catalog sidebar now lists categories (with new per-category pages) instead of every colormap, which shrinks colormap pages from ~340 KB to ~30 KB and the built site from ~208 MB to ~46 MB. - Catalog grid: filter by name/alias, source and monotonic lightness. - Colormap pages: CVD simulation now applies to the colorbars and sine ramp (rendered client-side), charts follow the light/dark theme, code tabs for matplotlib/napari/CSS, prev/next links and a "compare" button. - New compare page with shareable URLs. - Relative links throughout, so the GitHub Pages copy (served under /cmap/) works too. Absolute catalog links in colormap `info` are rewritten. - Chart.js 4.1.2 -> 4.5.1; drop the Material Icons font, unused chart data, and unused docs dependencies (colorcet, imageio). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docs.yml | 10 +- .gitignore | 6 +- .readthedocs.yaml | 12 +- docs/_gen_catalog.py | 350 ++++++++++++++++++++++++ docs/_gen_cmaps.py | 188 ------------- docs/_hooks.py | 209 -------------- docs/_macros.py | 170 ++++++++++++ docs/assets/charts/lightness.json | 28 -- docs/assets/charts/rgb.json | 53 ---- docs/catalog/index.md | 20 +- docs/colormaps.md | 38 +-- docs/colors.md | 6 +- docs/compare.md | 30 +++ docs/faq.md | 9 + docs/javascripts/cmap.js | 433 ++++++++++++++++++++++++++++++ docs/javascripts/cmap_charts.js | 215 --------------- docs/javascripts/filter.js | 56 ---- docs/stylesheets/extra.css | 370 ++++++++++++++++--------- mkdocs.yml | 126 --------- pyproject.toml | 11 +- zensical.toml | 152 +++++++++++ 21 files changed, 1452 insertions(+), 1040 deletions(-) create mode 100644 docs/_gen_catalog.py delete mode 100644 docs/_gen_cmaps.py delete mode 100644 docs/_hooks.py create mode 100644 docs/_macros.py delete mode 100644 docs/assets/charts/lightness.json delete mode 100644 docs/assets/charts/rgb.json create mode 100644 docs/compare.md create mode 100644 docs/javascripts/cmap.js delete mode 100644 docs/javascripts/cmap_charts.js delete mode 100644 docs/javascripts/filter.js delete mode 100644 mkdocs.yml create mode 100644 zensical.toml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 43509b3c4..93d22a38d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -8,9 +8,17 @@ on: jobs: deploy: runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: actions/checkout@v7 - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: python-version: 3.12 - - run: uv run --group docs mkdocs gh-deploy --strict --force + - run: uv run --group docs python docs/_gen_catalog.py + - run: uv run --group docs zensical build --strict + - name: Deploy to gh-pages + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + uvx ghp-import --no-jekyll --push --force site diff --git a/.gitignore b/.gitignore index 5476bf774..b74d9ae90 100644 --- a/.gitignore +++ b/.gitignore @@ -97,7 +97,7 @@ ENV/ # Rope project settings .ropeproject -# mkdocs documentation +# docs build output /site # mypy @@ -112,3 +112,7 @@ docs/assets/_data/ .ruff_cache/ uv.lock + +# generated docs (see docs/_gen_catalog.py) +docs/catalog/*/ +.cache/ diff --git a/.readthedocs.yaml b/.readthedocs.yaml index a6382c65d..6690ea892 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,4 +1,5 @@ # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details +# and https://docs.readthedocs.com/platform/stable/intro/zensical.html version: 2 @@ -10,7 +11,10 @@ build: post_install: - pip install uv - UV_PROJECT_ENVIRONMENT=$READTHEDOCS_VIRTUALENV_PATH uv sync --all-extras --group docs --link-mode=copy - -mkdocs: - configuration: mkdocs.yml - fail_on_warning: true + build: + html: + - $READTHEDOCS_VIRTUALENV_PATH/bin/python docs/_gen_catalog.py + - $READTHEDOCS_VIRTUALENV_PATH/bin/zensical build --strict + post_build: + - mkdir -p $READTHEDOCS_OUTPUT/html/ + - cp --recursive site/* $READTHEDOCS_OUTPUT/html/ diff --git a/docs/_gen_catalog.py b/docs/_gen_catalog.py new file mode 100644 index 000000000..41b764963 --- /dev/null +++ b/docs/_gen_catalog.py @@ -0,0 +1,350 @@ +"""Generate the colormap catalog pages and data. + +This must run before `zensical build` / `zensical serve`: + + uv run --group docs python docs/_gen_catalog.py + +Outputs (all git-ignored, under `docs/catalog/`): + +- `/index.md`: one page per category, with a grid of its colormaps. +- `/.md`: one page per (non-alias) colormap. +- `//index.html`: redirects from aliases to the canonical page. +- `_data/index.json`: metadata used by the catalog grid and compare page. +- `_data/.json`: lightness/hue/etc data for each colormap and CVD type. +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path +from typing import TYPE_CHECKING, Any +from urllib.parse import quote + +import jinja2 +import natsort +import numpy as np + +from cmap import Colormap +from cmap._util import report_cvds + +sys.path.insert(0, str(Path(__file__).parent)) +from _macros import CATEGORIES, cmap_bar, cmap_page, relative + +if TYPE_CHECKING: + from cmap._catalog import CatalogItem + +DOCS = Path(__file__).parent +OUT = DOCS / "catalog" + +LICENSE_URL = { + "CC0": "https://creativecommons.org/publicdomain/zero/1.0/", + "CC-BY-4.0": "https://creativecommons.org/licenses/by/4.0/", + "Apache-2.0": "https://www.apache.org/licenses/LICENSE-2.0", + "MIT": "https://opensource.org/licenses/MIT", + "BSD-3-Clause": "https://opensource.org/licenses/BSD-3-Clause", + "BSD-2-Clause": "https://opensource.org/licenses/BSD-2-Clause", + "PSF": "https://opensource.org/licenses/Python-2.0", +} +CVD_LABELS = { + "normal": "Normal vision", + "protan": "Protanopia", + "deutan": "Deuteranopia", + "tritan": "Tritanopia", +} +# (key in report, key in output json, decimals) +REPORT_FIELDS = ( + ("J", "J", 1), + ("lightness_derivs", "dJ", 0), + ("hue", "hue", 0), + ("saturation", "saturation", 1), + ("chroma", "chroma", 1), +) +MAX_SAMPLES = 256 +# max per-step decrease in J' for a colormap to still count as "monotonic" +MONOTONIC_TOL = 0.5 +# absolute links to catalog pages in `info` strings, e.g. "/catalog/sequential/rdbu/" +CATALOG_LINK = re.compile(r"\]\(/catalog/[^/]+/([^/)]+)/?\)") + +PAGE = jinja2.Template( + """\ +--- +title: {{ name | tojson }} +description: {{ description | tojson }} +--- + +# {{ name }} + +{% if aliases %}**Aliases**: {{ aliases }} +{% endif %} +{{ info }} + +| category | license | authors | source | +| --- | --- | --- | --- | +| [{{ category | title }}](index.md) | {{ license }} | {{ authors }} | {{ source }} | + +=== "cmap" + + ```python + from cmap import Colormap + + cm = Colormap("{{ name }}") # case insensitive + ``` + +=== "matplotlib" + + ```python + import matplotlib.pyplot as plt + from cmap import Colormap + + plt.imshow(data, cmap=Colormap("{{ name }}").to_mpl()) + ``` + +=== "napari" + + ```python + import napari + from cmap import Colormap + + napari.view_image(data, colormap=Colormap("{{ name }}").to_napari()) + ``` + +=== "CSS" + + ```css + {{ css | indent(4) }} + ``` + +
+{{ bar }} +{{ gray }} + +
+{% for key, label in cvd_labels.items() %} +{% endfor %}
+
+ +[:lucide-git-compare: Compare with other colormaps](../../compare.md?cmaps={{ name | urlencode }}){ .md-button } + +## Perceptual lightness + +
+ +

+Lightness (J′) measured in +CAM02 Uniform Color Space (CAM02-UCS) +

+ +## RGB components + +
+ +## Hue & saturation + +
+ +--- + + +""" +) + +CATEGORY_PAGE = """\ +--- +title: {title} colormaps +render_macros: true +--- + +# {title} colormaps + +{{{{ cmap_catalog("{category}") }}}} +""" + +REDIRECT = """ + + + +Redirecting… + + + + + +Redirecting to {url}… + +""" + + +def _report_data(cm: Colormap) -> dict[str, dict[str, Any]]: + """Return the (trimmed) chart data for each CVD type.""" + out = {} + for cvd, report in report_cvds(cm).items(): + n = len(report["color"]) + idx = np.linspace(0, n - 1, min(n, MAX_SAMPLES)).round().astype(int) + d: dict[str, Any] = {"color": [report["color"][i][1:] for i in idx]} + for src, dst, decimals in REPORT_FIELDS: + vals = np.around(np.asarray(report[src], dtype=float)[idx], decimals) + # NaN isn't valid JSON (e.g. hue of grays) + d[dst] = [ + (int(v) if decimals == 0 else float(v)) if np.isfinite(v) else None + for v in vals + ] + out[cvd] = d + return out + + +def _is_monotonic(lightness: list[float]) -> bool: + diff = np.diff(lightness) + return bool(np.all(diff >= -MONOTONIC_TOL) or np.all(diff <= MONOTONIC_TOL)) + + +def _fix_info_links(info: str, page: str) -> str: + """Make absolute catalog links in `info` relative to `page`.""" + + def _sub(m: re.Match) -> str: + return f"]({relative(page, cmap_page(Colormap(m[1])))})" + + return CATALOG_LINK.sub(_sub, info) + + +def _md_link(url: str) -> str: + return f"[{url}]({url})" if url.startswith("http") else f"`{url}`" + + +def _iter_items() -> list[tuple[str, CatalogItem]]: + catalog = Colormap.catalog() + items = [] + for name in natsort.natsorted(catalog, alg=natsort.ns.IGNORECASE): + if ":" not in name: + continue + info = catalog[name] + # FIXME: not a great way to determine aliases... + # prone to false positives if normalization is not perfect/consistent + if info.qualified_name.lower() != name.lower(): + continue + items.append((name, info)) + return items + + +class _Writer: + """Write files under `OUT`, skipping unchanged ones (keeps `zensical serve` happy). + + Call `prune()` at the end to remove previously generated files that are stale. + """ + + def __init__(self) -> None: + self.written: set[Path] = set() + + def __call__(self, rel: str, content: str) -> None: + path = OUT / rel + self.written.add(path) + if path.exists() and path.read_text(encoding="utf-8") == content: + return + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + def prune(self) -> None: + for d in (*CATEGORIES, "_data"): + for path in sorted((OUT / d).rglob("*"), reverse=True): + if path.is_file() and path not in self.written: + path.unlink() + elif path.is_dir() and not any(path.iterdir()): + path.rmdir() + + +def _write_redirects(write: _Writer) -> None: + """Write redirect pages for every alias in the catalog.""" + for name, details in Colormap.catalog()._data.items(): + if "alias" not in details: + continue + real = Colormap(details["alias"]) # type: ignore[typeddict-item] + # relative to the redirect page's own url ("...//") + url = "../" + Path(cmap_page(real)).stem + "/" + alias_dir = name.replace(":", "-") + write(f"{real.category}/{alias_dir}/index.html", REDIRECT.format(url=url)) + + +def _render_page( + name: str, info: CatalogItem, prev: str | None, next_: str | None +) -> str: + cm = Colormap(name) + page = cmap_page(cm) + + def _link(other: str | None) -> dict | None: + if other is None: + return None + return {"name": other, "href": relative(page, cmap_page(Colormap(other)))} + + license_ = info.license + if license_ in LICENSE_URL: + license_ = f"[{license_}]({LICENSE_URL[license_]})" + return PAGE.render( + name=name, + description=f"{name}: a {info.category} colormap from {info.namespace}.", + aliases=", ".join(f"`{a}`" for a in info.aliases if a != info.name), + info=_fix_info_links(info.info, page), + category=info.category, + license=license_, + authors=", ".join(info.authors), + source=_md_link(info.source), + css=cm.to_css(max_stops=32, as_hex=True).strip(), + bar=cmap_bar(name, page, height=40, link=False), + gray=cmap_bar(name, page, height=30, link=False, classes=["grayscale"]), + cvd_labels=CVD_LABELS, + data_url=f"../../_data/{quote(Path(page).stem)}.json", + prev=_link(prev), + next=_link(next_), + ) + + +def build() -> None: + write = _Writer() + by_category: dict[str, list[tuple[str, CatalogItem]]] = {} + for name, info in _iter_items(): + by_category.setdefault(info.category, []).append((name, info)) + + # every name (short or namespaced) that resolves to a given colormap + aliases: dict[str, set[str]] = {} + for alias, details in Colormap.catalog()._data.items(): + if target := details.get("alias"): + aliases.setdefault(str(target).lower(), set()).add(alias) + + index = [] + for category, cat_items in by_category.items(): + title = category.title() + write( + f"{category}/index.md", CATEGORY_PAGE.format(title=title, category=category) + ) + names = [n for n, _ in cat_items] + for i, (name, info) in enumerate(cat_items): + page = cmap_page(Colormap(name)).removeprefix("catalog/") + data = _report_data(Colormap(name)) + index.append( + { + "name": name, + "category": category, + "namespace": info.namespace, + "aliases": natsort.natsorted(aliases.get(name.lower(), ())), + "monotonic": _is_monotonic(data["normal"]["J"]), + "path": page.removesuffix(".md"), + } + ) + write( + f"_data/{Path(page).stem}.json", json.dumps(data, separators=(",", ":")) + ) + prev = names[i - 1] if i > 0 else None + next_ = names[i + 1] if i + 1 < len(names) else None + write(page, _render_page(name, info, prev, next_)) + + write("_data/index.json", json.dumps(index, separators=(",", ":"))) + _write_redirects(write) + write.prune() + print(f"Generated {len(index)} colormap pages in {OUT}") + + +if __name__ == "__main__": + build() diff --git a/docs/_gen_cmaps.py b/docs/_gen_cmaps.py deleted file mode 100644 index 0ad0547bd..000000000 --- a/docs/_gen_cmaps.py +++ /dev/null @@ -1,188 +0,0 @@ -import json -from pathlib import Path -from typing import TYPE_CHECKING - -import mkdocs_gen_files -import natsort -import numpy as np - -if TYPE_CHECKING: - from cmap import _catalog -from cmap import Colormap -from cmap._util import report_cvds - -# TODO: convert to jinja -TEMPLATE = """# {name} - -{aliases} - -{info} - -| category | license | authors | source | -| --- | --- | --- | --- | -| {category} | {license} | {authors} | {source} | - -```python -from cmap import Colormap - -cm = Colormap('{name}') # case insensitive -``` - -{{{{ cmap: {name} 40 }}}} -{{{{ cmap_gray: {name} 30 }}}} -{{{{ cmap_sineramp: {name} }}}} - -
- - - - -
- -## Perceptual Lightness - - -

- -L* measured in -CAM02 Uniform Color Space (CAM02-UCS) - -

- -## RGB components - - - -## Hue & Saturation - - - - - -""" - -DOCS = Path(__file__).parent -LICENSE_URL = { - "CC0": "https://creativecommons.org/publicdomain/zero/1.0/", - "CC-BY-4.0": "https://creativecommons.org/licenses/by/4.0/", - "Apache-2.0": "https://www.apache.org/licenses/LICENSE-2.0", - "MIT": "https://opensource.org/licenses/MIT", - "BSD-3-Clause": "https://opensource.org/licenses/BSD-3-Clause", - "BSD-2-Clause": "https://opensource.org/licenses/BSD-2-Clause", - "PSF": "https://opensource.org/licenses/Python-2.0", -} - - -INCLUDE_DATA = ( - "x", - "color", - "R", - "G", - "B", - "J", - "lightness_derivs", - "hue", - "saturation", - "chroma", -) - - -def build_catalog(catalog: "_catalog.Catalog") -> None: - nav = mkdocs_gen_files.Nav() - for name in natsort.natsorted(catalog, alg=natsort.ns.IGNORECASE): - if ":" not in name: - continue - try: - info = catalog[name] - category = info.category - license_: str = info.license - except KeyError as e: - raise KeyError(f"Missing info for {name}: {e}") from e - - # FIXME: not a great way to determine aliases... - # prone to false positives if normalization is not perfect/consistent - if info.qualified_name.lower() != name.lower(): - # skip aliases - continue - - source = info.source - source = f"[{source}]({source})" if source.startswith("http") else f"`{source}`" - authors = ", ".join(info.authors) - if license_ in LICENSE_URL: - license_ = f"[{license_}]({LICENSE_URL[license_]})" - - # write data used for charts - cm = Colormap(name) - cmap_data = { - cvd_type: { - k: np.around(v, 4).tolist() if isinstance(v, np.ndarray) else v - for k, v in report.items() - if k in INCLUDE_DATA - } - for cvd_type, report in report_cvds(cm).items() - } - _aliases = [x for x in info.aliases if x != info.name] - aliases = _make_aliases_md(_aliases) if _aliases else "" - - # write the actual markdown file - doc_path = f"{category}/{name.lower()}.md" - nav[(category.title(), name)] = doc_path - with mkdocs_gen_files.open(f"catalog/{doc_path}", "w") as f: - f.write( - TEMPLATE.format( - name=name, - category=category.title(), - license=license_, - authors=authors, - source=source, - aliases=aliases, - info=info.info, - data=json.dumps({name: cmap_data}, separators=(",", ":")), - ) - ) - - # sort categories alphabetically - nav._data = dict(sorted(nav._data.items(), key=lambda x: x[0][0])) - with mkdocs_gen_files.open("catalog/SUMMARY.md", "w") as nav_file: - nav_file.writelines(["# SUMMARY { data-search-exclude }\n"]) - nav_file.writelines(nav.build_literate_nav()) - - -def _make_aliases_md(aliases: list[str]) -> str: - return "**Aliases**: " + ", ".join(f"`{a}`" for a in aliases) - - -build_catalog(Colormap.catalog()) diff --git a/docs/_hooks.py b/docs/_hooks.py deleted file mode 100644 index f3e6056d3..000000000 --- a/docs/_hooks.py +++ /dev/null @@ -1,209 +0,0 @@ -import base64 -import re -import sys -from collections.abc import Sequence -from functools import partial -from pathlib import Path -from typing import TYPE_CHECKING, Any, cast - -import natsort -import numpy as np - -from cmap import Colormap, _util - -if TYPE_CHECKING: - from cmap._catalog import CatalogDict -from cmap._color import NAME_TO_RGB - -CATALOG = cast("CatalogDict", Colormap.catalog()._data) # type: ignore - -# the template for a single colormap -CMAP_DIV = """ -
-
{name}
- {img} -
-""" -CMAP_LINK = '' + CMAP_DIV + "" -DEV_MODE = "serve" in sys.argv -SINERAMP = _util.sineramp((96, 826))[:, ::-1] - - -def _to_img_tag( - cm: Colormap, - height: str = "32px", - width: str = "100%", - img: np.ndarray | None = None, -) -> str: - """Return a base64-encoded tag for the given colormap.""" - _img = cm._repr_png_(width=826, height=1, img=img) - data = base64.b64encode(_img).decode("ascii") - return ( - f'{cm.name} colormap' - ) - - -def _cm_url(cm: Colormap) -> str: - name = cm.name.lower() - if name.endswith("_r"): - name = name[:-2] - return f"/catalog/{cm.category}/{name}/" - - -def _cmap_div(match: re.Match | str, class_list: Sequence[str] = ()) -> str: - """Convert a `cmap` tag to a div with the colormap. - - {{ cmap: name }} ->
- """ - map_name = match if isinstance(match, str) else match[1].strip() - cm = Colormap(map_name) - height = (match[2] if isinstance(match, re.Match) else None) or 32 - img = _to_img_tag(cm, height=f"{height}px") - return CMAP_LINK.format( - name=map_name, img=img, class_list=" ".join(class_list), url=_cm_url(cm) - ) - - -def _cmap_expr(match: re.Match) -> str: - """Convert a `cmap_expr` tag to a div with the colormap. - - {{ cmap_expr: {0: 'blue', 0.5: 'yellow', 1: 'red'} }} ->
... - """ - cm = Colormap(eval(match[1].strip())) # noqa: S307 - return CMAP_DIV.format(name="", img=_to_img_tag(cm), class_list="cmap-expr") - - -def _cmap_sineramp(match: re.Match) -> str: - """Convert a `cmap_sineramp` tag to an img element with sineramp applied. - - {{ cmap_sineramp: viridis }} -> ... - """ - # if DEV_MODE: - # return "[sineramp slow -- disabled in `mkdocs serve`]" - - map_name = match[1].strip() - return _to_img_tag(Colormap(map_name), f"{SINERAMP.shape[0]}px", img=SINERAMP) - - -def _cmap_catalog() -> str: - """Return the HTML for the colormap catalog page. - - this works in conjunction with the `javascripts/extra.js` script. - """ - categories = set() - lines = [] - for cmap_name, details in natsort.natsorted( - CATALOG.items(), key=lambda x: x[0].lower() - ): - if "alias" in details: - continue - category = details.get("category") or "Uncategorized" - categories.add(category) - classes = ["filterDiv", category.lower()] - lines.append(_cmap_div(cmap_name, classes)) - - btns = [ - '
', - """""", - ] - btns.extend( - f'' - for c in sorted(categories) - ) - btns.append("
") - lines = btns + lines - - return "\n".join(lines) - - -COLORBOX = """
- {name}
- {hex}
- {rgb}
-
-""" - - -def _color_list() -> str: - """Return the HTML for the color list page.""" - colors = [] - for name, c in NAME_TO_RGB.items(): - if (hsl := c.to_hsl()).l == 0.5: - text_color = "#000" if hsl.h <= 0.5 else "#FFF" - else: - text_color = "#000" if hsl.l > 0.50 or not c.a else "#FFF" - cbox = COLORBOX.format( - name=name, - hex=c.to_hex(), - text_color=text_color, - rgb=c.rgba_string(), - ) - colors.append(cbox) - return '
{}
'.format("\n".join(colors)) - - -# ----------------------------------------------------------------------------- -# mkdocs hooks -# ----------------------------------------------------------------------------- - -# markdown tag for a single colormap: {{ cmap: name }} -CSS_CMAP = re.compile(r"{{\s?cmap:\s?([^}^\s]+)\s?(\d+)?\s?}}") -CSS_CMAP_GRAY = re.compile(r"{{\s?cmap_gray:\s?([^}^\s]+)\s?(\d+)?\s?}}") -CMAP_SINERAMP = re.compile(r"{{\s?cmap_sineramp:\s?([^}]+)\s?}}") -CMAP_EXPR = re.compile(r"{{\s?cmap_expr:\s(.+)\s}}") -CMAP_CATALOG = r"{{ CMAP_CATALOG }}" -COLOR_LIST = r"{{ COLOR_LIST }}" - - -def on_page_content(html: str, **kwargs: Any) -> str: - html = CSS_CMAP.sub(_cmap_div, html) - html = CSS_CMAP_GRAY.sub(partial(_cmap_div, class_list=["grayscale"]), html) - html = CMAP_EXPR.sub(_cmap_expr, html) - html = CMAP_SINERAMP.sub(_cmap_sineramp, html) - if CMAP_CATALOG in html: - html = html.replace(CMAP_CATALOG, _cmap_catalog()) - if COLOR_LIST in html: - html = html.replace(COLOR_LIST, _color_list()) - return html - - -REDIRECT_TEMPLATE = """ - - - - Redirecting... - - - - - - -Redirecting... - - -""" - - -def _write_cmap_redirects(site_dir: str) -> None: - sd = Path(site_dir) - for cmap_name, details in natsort.natsorted( - CATALOG.items(), key=lambda x: x[0].lower() - ): - if "alias" in details: - cmap_name = cmap_name.replace(":", "-") - real = Colormap(details["alias"]) # type: ignore - old_path_abs = ( - sd / "catalog" / str(real.category) / cmap_name / "index.html" - ) - old_path_abs.parent.mkdir(parents=True, exist_ok=True) - content = REDIRECT_TEMPLATE.format(url=_cm_url(real)) - with open(old_path_abs, "w", encoding="utf-8") as f: - f.write(content) - - -def on_post_build(config: dict, **kwargs: Any) -> None: - """Copy the extra javascripts to the output directory.""" - _write_cmap_redirects(config["site_dir"]) diff --git a/docs/_macros.py b/docs/_macros.py new file mode 100644 index 000000000..a94c4946d --- /dev/null +++ b/docs/_macros.py @@ -0,0 +1,170 @@ +"""Macros for rendering colormaps and colors in the docs. + +Loaded by the zensical `macros` extension (see `zensical.toml`). Pages opt in with +`render_macros: true` in their front matter. `_gen_catalog.py` also imports this +module to render the generated catalog pages. +""" + +from __future__ import annotations + +import base64 +import html +import json +import posixpath +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import natsort +import numpy as np + +from cmap import Colormap +from cmap._color import NAME_TO_RGB + +if TYPE_CHECKING: + from collections.abc import Sequence + +DOCS = Path(__file__).parent +CATALOG_INDEX = DOCS / "catalog" / "_data" / "index.json" +CATEGORIES = ("sequential", "diverging", "cyclic", "qualitative", "miscellaneous") + + +def png_uri(cm: Colormap, width: int = 826, img: np.ndarray | None = None) -> str: + """Return a base64-encoded PNG data URI for `cm`.""" + data = cm._repr_png_(width=width, height=1, img=img) + return "data:image/png;base64," + base64.b64encode(data).decode("ascii") + + +def cmap_page(cm: Colormap) -> str: + """Return the docs-relative source path of the catalog page for `cm`.""" + name = cm.name.lower().removesuffix("_r") + return f"catalog/{cm.category}/{name}.md" + + +def relative(from_page: str, target: str) -> str: + """Return a link to `target` relative to `from_page` (both docs-relative).""" + rel = posixpath.relpath(target, posixpath.dirname(from_page) or ".") + # leading "./" so that names like "bids:viridis.md" aren't parsed as a scheme + return rel if rel.startswith(".") else f"./{rel}" + + +def cmap_bar( + name: str, + page: str, + *, + height: int = 32, + classes: Sequence[str] = (), + link: bool = True, + attrs: str = "", +) -> str: + """Return single-line HTML for a labeled colorbar of colormap `name`.""" + cm = Colormap(name) + cls = " ".join(["cmap", *classes]) + img = ( + f'{html.escape(name)} colormap' + ) + label = f'{html.escape(name)}' + if not link: + return f'
{label}{img}
' + href = relative(page, cmap_page(cm)) + return f'{label}{img}' + + +def catalog_grid(page: str, category: str | None = None) -> str: + """Return HTML for the filterable grid of catalog colormaps.""" + if not CATALOG_INDEX.exists(): + raise FileNotFoundError( + f"{CATALOG_INDEX} not found. Run `python docs/_gen_catalog.py` first." + ) + entries = json.loads(CATALOG_INDEX.read_text()) + if category: + entries = [e for e in entries if e["category"] == category] + + items = [] + for e in entries: + search = " ".join([e["name"], *e["aliases"]]).lower() + attrs = ( + f' data-search="{html.escape(search)}"' + f' data-category="{e["category"]}"' + f' data-namespace="{e["namespace"]}"' + f' data-monotonic="{int(e["monotonic"])}"' + ) + items.append(cmap_bar(e["name"], page, attrs=attrs)) + + namespaces = natsort.natsorted({e["namespace"] for e in entries}) + ns_options = "".join(f'' for n in namespaces) + cat_buttons = "" + if not category: + cats = [c for c in CATEGORIES if any(e["category"] == c for e in entries)] + cat_buttons = '
' + "".join( + f'' + for c in ("all", *cats) + ) + cat_buttons += "
" + + controls = ( + '
' + '' + '' + '' + f"{cat_buttons}" + f'{len(items)} colormaps' + "
" + ) + return ( + f'
{controls}' + f'
{"".join(items)}
' + '
' + ) + + +COLORBOX = ( + '
' + "{name}
{hex}
{rgb}
" +) + + +def color_list() -> str: + """Return HTML for the list of named colors.""" + boxes = [] + for name, c in NAME_TO_RGB.items(): + if (hsl := c.to_hsl()).l == 0.5: + text = "#000" if hsl.h <= 0.5 else "#FFF" + else: + text = "#000" if hsl.l > 0.50 or not c.a else "#FFF" + boxes.append( + COLORBOX.format(name=name, hex=c.to_hex(), text=text, rgb=c.rgba_string()) + ) + return f'
{"".join(boxes)}
' + + +def define_env(env: Any) -> None: + """Register macros with the zensical macros extension.""" + + def _page() -> str: + return env.page.path if env.page else "" + + @env.macro + def cmap(name: str, height: int = 32) -> str: + return cmap_bar(name, _page(), height=height) + + @env.macro + def cmap_gray(name: str, height: int = 32) -> str: + return cmap_bar(name, _page(), height=height, classes=["grayscale"]) + + @env.macro + def cmap_expr(expr: str) -> str: + cm = Colormap(eval(expr, {"np": np})) # noqa: S307 + img = f'colormap from {html.escape(expr)}' + return f'
{img}
' + + @env.macro + def cmap_catalog(category: str | None = None) -> str: + return catalog_grid(_page(), category) + + env.macro(color_list) diff --git a/docs/assets/charts/lightness.json b/docs/assets/charts/lightness.json deleted file mode 100644 index 99609f2ae..000000000 --- a/docs/assets/charts/lightness.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "$schema": "https://vega.github.io/schema/vega-lite/v5.json", - "data": { - "url": "/cmap/assets/data/jet.csv" - }, - "encoding": { - "color": { - "field": "color", - "scale": null, - "type": "nominal" - }, - "size": { - "value": 400 - }, - "x": { - "field": "x", - "type": "quantitative" - }, - "y": { - "field": "J", - "type": "quantitative" - } - }, - "mark": { - "tooltip": true, - "type": "circle" - } -} diff --git a/docs/assets/charts/rgb.json b/docs/assets/charts/rgb.json deleted file mode 100644 index b881c2e64..000000000 --- a/docs/assets/charts/rgb.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "$schema": "https://vega.github.io/schema/vega-lite/v5.json", - "data": { - "url": "/cmap/assets/data/jet.csv" - }, - "encoding": { - "size": { - "value": 4 - }, - "x": { - "field": "x", - "type": "quantitative" - } - }, - "layer": [ - { - "encoding": { - "color": { - "value": "#D22" - }, - "y": { - "field": "R", - "type": "quantitative" - } - }, - "mark": "line" - }, - { - "encoding": { - "color": { - "value": "#0C0" - }, - "y": { - "field": "G", - "type": "quantitative" - } - }, - "mark": "line" - }, - { - "encoding": { - "color": { - "value": "blue" - }, - "y": { - "field": "B", - "type": "quantitative" - } - }, - "mark": "line" - } - ] -} diff --git a/docs/catalog/index.md b/docs/catalog/index.md index c04048f2c..084826355 100644 --- a/docs/catalog/index.md +++ b/docs/catalog/index.md @@ -1,3 +1,7 @@ +--- +render_macros: true +--- + # Colormap Catalog To use any colormap in the catalog, you can pass its name to the [`Colormap`][cmap.Colormap] @@ -42,17 +46,17 @@ view_image(img, colormap=cmap.to_napari()) You can append `'_r'` to any of the colormap names listed below to get a reversed version of that colormap. For example, `'viridis_r'` - {{ cmap: viridis }} - {{ cmap: viridis_r }} + {{ cmap("viridis") }} + {{ cmap("viridis_r") }} This works with function based colormaps as well: - {{ cmap: cubehelix }} - {{ cmap: cubehelix_r }} + {{ cmap("cubehelix") }} + {{ cmap("cubehelix_r") }} -## Colormaps by category { data-search-exclude } +## All colormaps -{{ CMAP_CATALOG }} +Filter by name, source, or lightness profile below, or +[compare colormaps side by side](../compare.md). - - +{{ cmap_catalog() }} diff --git a/docs/colormaps.md b/docs/colormaps.md index 16b812bfe..68b901fe0 100644 --- a/docs/colormaps.md +++ b/docs/colormaps.md @@ -1,3 +1,7 @@ +--- +render_macros: true +--- + # Colormaps The [`cmap.Colormap`][cmap.Colormap] type represents a colormap. @@ -32,8 +36,8 @@ to the [`cmap.Colormap`][] constructor; `cmap` refers to these objects collectiv - A `string` containing a [recognized colormap name](catalog/index.md). - - `Colormap('viridis')` {{ cmap_expr: 'viridis' }} - - `Colormap('batlow')` {{ cmap_expr: 'batlow' }} + - `Colormap('viridis')` {{ cmap_expr("'viridis'") }} + - `Colormap('batlow')` {{ cmap_expr("'batlow'") }} !!!note "Matplotlib names ✅" Any valid matplotlib colormap key that could be used in `matplotlib.colormaps[...]` @@ -42,15 +46,15 @@ to the [`cmap.Colormap`][] constructor; `cmap` refers to these objects collectiv - A string containing a [recognized colormap name](catalog/index.md) suffixed with `"_r"` to reverse the colormap: - - `Colormap('viridis_r')` {{ cmap_expr: 'viridis_r' }} - - `Colormap('batlow_r')` {{ cmap_expr: 'batlow_r' }} + - `Colormap('viridis_r')` {{ cmap_expr("'viridis_r'") }} + - `Colormap('batlow_r')` {{ cmap_expr("'batlow_r'") }} ### `Iterable[ColorLike | tuple]` - An [`Iterable`][typing.Iterable] of [`ColorLike`](colors.md#colorlike-objects) objects: - - `Colormap(['blue', 'yellow', 'red'])` {{ cmap_expr: ['blue', 'yellow', 'red'] }} - - `Colormap([(0, 0, 1.), "#FF0", "rgb(255, 0, 0)"])` {{ cmap_expr: [(0, 0, 1.), "#FF0", "rgb(255, 0, 0)"] }} + - `Colormap(['blue', 'yellow', 'red'])` {{ cmap_expr("['blue', 'yellow', 'red']") }} + - `Colormap([(0, 0, 1.), "#FF0", "rgb(255, 0, 0)"])` {{ cmap_expr('[(0, 0, 1.), "#FF0", "rgb(255, 0, 0)"]') }} !!!note In the case of an iterable of colors, all colors are assumed to be @@ -60,17 +64,17 @@ to the [`cmap.Colormap`][] constructor; `cmap` refers to these objects collectiv ColorLike]` objects, where the `float` represents the position of the color along the colormap from 0-1 (*aka* the "color stop"): - - `Colormap([(0, 'blue'), (0.8, 'yellow'), (1, 'red')])` {{ cmap_expr: [(0, 'blue'), (0.8, 'yellow'), (1, 'red')] }} + - `Colormap([(0, 'blue'), (0.8, 'yellow'), (1, 'red')])` {{ cmap_expr("[(0, 'blue'), (0.8, 'yellow'), (1, 'red')]") }} If omitted, the first and last color stops are assumed to be at 0 and 1, respectively. - - `Colormap(['blue', (0.8, 'yellow'), 'red'])` {{ cmap_expr: ['blue', (0.8, 'yellow'), 'red'] }} + - `Colormap(['blue', (0.8, 'yellow'), 'red'])` {{ cmap_expr("['blue', (0.8, 'yellow'), 'red']") }} If the first or last color stop is *not* at 0 or 1, the first/last color is repeated at the 0th or 1 position, respectively. - - `Colormap([(0.4, 'blue'), (0.8, 'yellow'), 'red'])` {{ cmap_expr: [(0.4, 'blue'), (0.8, 'yellow'), 'red'] }} + - `Colormap([(0.4, 'blue'), (0.8, 'yellow'), 'red'])` {{ cmap_expr("[(0.4, 'blue'), (0.8, 'yellow'), 'red']") }} *(same as `['blue', (0.4, 'blue'), (0.8, 'yellow'), 'red']`)* @@ -79,7 +83,7 @@ to the [`cmap.Colormap`][] constructor; `cmap` refers to these objects collectiv (or 0 and 1, if none are provided). They are **NOT** placed at their global `index / (len(colors) - 1)`. - - `Colormap(['blue', 'green', (0.8, 'yellow'), 'red'])` {{ cmap_expr: ['blue', 'green', (0.8, 'yellow'), 'red'] }} + - `Colormap(['blue', 'green', (0.8, 'yellow'), 'red'])` {{ cmap_expr("['blue', 'green', (0.8, 'yellow'), 'red']") }} *(same as `['blue', (0.4, 'green'), (0.8, 'yellow'), 'red']`)* @@ -89,11 +93,11 @@ A [`numpy.ndarray`][], in one of the following formats: - an **`(N, 3)`** array of `N` RGB colors equally spaced along the colormap: - `Colormap(np.array([[0, 0, 1.], [1., 1., 0], [1., 0, 0]]))` {{ cmap_expr: np.array([[0, 0, 1.], [1., 1., 0], [1., 0, 0]]) }} + `Colormap(np.array([[0, 0, 1.], [1., 1., 0], [1., 0, 0]]))` {{ cmap_expr("np.array([[0, 0, 1.], [1., 1., 0], [1., 0, 0]])") }} - an **`(N, 4)`** array of `N` RGBA colors equally spaced along the colormap: - `Colormap(np.array([[0, 0, 1., 1.], [1., 1., 0, 0.7], [1., 0, 0, 0.3]]))` {{ cmap_expr: np.array([[0, 0, 1., 1.], [1., 1., 0, 0.7], [1., 0, 0, 0.3]]) }} + `Colormap(np.array([[0, 0, 1., 1.], [1., 1., 0, 0.7], [1., 0, 0, 0.3]]))` {{ cmap_expr("np.array([[0, 0, 1., 1.], [1., 1., 0, 0.7], [1., 0, 0, 0.3]])") }} - an **`(N, 5)`** array of color stops, where the first column is the position of the color stop and the remaining 4 columns are the RGBA colors: @@ -110,14 +114,14 @@ A [`numpy.ndarray`][], in one of the following formats: ) ``` - {{ cmap_expr: np.array([[0, 0, 0, 1., 1.], [0.8, 1., 1., 0, 1.], [1., 1., 0, 0, 1.]]) }} + {{ cmap_expr("np.array([[0, 0, 0, 1., 1.], [0.8, 1., 1., 0, 1.], [1., 1., 0, 0, 1.]])") }} ### `dict` - A `{position -> color}` `dict` of color stops, where the keys are the positions of the color stops and the values are the colors: - - `Colormap({0: 'blue', 0.5: 'yellow', 1: 'red'})` {{ cmap_expr: {0: 'blue', 0.5: 'yellow', 1: 'red'} }} + - `Colormap({0: 'blue', 0.5: 'yellow', 1: 'red'})` {{ cmap_expr("{0: 'blue', 0.5: 'yellow', 1: 'red'}") }} - A [matplotlib-style `segmentdata` dict](https://matplotlib.org/stable/tutorials/colors/colormap-manipulation.html#creating-linear-segmented-colormaps), @@ -144,19 +148,19 @@ A [`numpy.ndarray`][], in one of the following formats: ``` - {{ cmap_expr: {'red': [[0,0,0],[0.5,1.,1.],[1.,1.,1.]], 'green': [[0,0,0],[0.25,0,0],[0.75,1.,1.],[1.,1.,1.]], 'blue': [[0,0,0],[0.5,0,0],[1.,1.,1.]]} }} + {{ cmap_expr("{'red': [[0,0,0],[0.5,1.,1.],[1.,1.,1.]], 'green': [[0,0,0],[0.25,0,0],[0.75,1.,1.],[1.,1.,1.]], 'blue': [[0,0,0],[0.5,0,0],[1.,1.,1.]]}") }} - a callable that accepts an array of 0-1 values and returns another array of 0-1 values: `Colormap({"red": lambda x: x, "green": lambda x: x**2, "blue": lambda x: x**0.5})` - {{ cmap_expr: {"red": lambda x: x, "green": lambda x: x\*\*2, "blue": lambda x: x\*\*0.5} }} + {{ cmap_expr('{"red": lambda x: x, "green": lambda x: x**2, "blue": lambda x: x**0.5}') }} ### `Callable` - A single [`Callable`][typing.Callable], object which must accept an array of values and return an `(N, 3)` or `(N, 4)` array of colors - `Colormap(lambda x: np.stack([x, np.sin(x*10), np.cos(x*10)], axis=1))` - {{ cmap_expr: lambda x: np.stack([x, np.sin(x\*10), np.cos(x\*10)], axis=1) }} + {{ cmap_expr("lambda x: np.stack([x, np.sin(x*10), np.cos(x*10)], axis=1)") }} ## Usage diff --git a/docs/colors.md b/docs/colors.md index edc98de0c..8e941f2d6 100644 --- a/docs/colors.md +++ b/docs/colors.md @@ -1,3 +1,7 @@ +--- +render_macros: true +--- + # Colors The `cmap.Color` type represents an individual color. @@ -269,4 +273,4 @@ The following names may be used as string inputs to the `Color` constructor. (case insensitive; spaces, dashes, and underscores are ignored) -{{ COLOR_LIST }} +{{ color_list() }} diff --git a/docs/compare.md b/docs/compare.md new file mode 100644 index 000000000..8edea05ca --- /dev/null +++ b/docs/compare.md @@ -0,0 +1,30 @@ +--- +title: Compare colormaps +description: Compare colormaps side by side, including perceptual lightness and simulated color vision deficiencies. +--- + +# Compare colormaps + +Add colormaps from the [catalog](catalog/index.md) to compare them side by side. +Each colormap is shown as a colorbar and applied to a +[sine ramp](https://www.peterkovesi.com/matlabfns/) test image: in a good colormap, +the sine wave at the top should be equally visible across the whole range. The +chart below shows the perceptual lightness of each colormap. + +The URL updates as you go, so you can share a comparison. + +
+
+ + + +
+
+ + + + +
+
+
+
diff --git a/docs/faq.md b/docs/faq.md index 84cbf1b35..61fcf44ca 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -67,6 +67,15 @@ Once you have picked a namespace: When opening a PR, please include a screenshot of the colormap, along with a brief description of the colormap design and its intended use. +!!!tip "Previewing the docs" + To see your colormap in the catalog locally, generate the catalog pages and + start the docs server: + + ```bash + uv run --group docs python docs/_gen_catalog.py + uv run --group docs zensical serve + ``` + !!!info "Licensing" Please ensure that the colormap you are adding is available under a permissive license. Note that namespaces share a license, so if you diff --git a/docs/javascripts/cmap.js b/docs/javascripts/cmap.js new file mode 100644 index 000000000..f02796d5b --- /dev/null +++ b/docs/javascripts/cmap.js @@ -0,0 +1,433 @@ +// Interactive bits of the cmap docs. Data comes from docs/_gen_catalog.py. +// Everything is (re-)initialized on each page load via `document$`, which +// zensical emits on initial load and on every instant navigation. + +const CVD_TYPES = ["normal", "protan", "deutan", "tritan"]; +const SINERAMP_SHAPE = [96, 826]; +const dataCache = new Map(); + +document$.subscribe(() => { + document.querySelectorAll(".cmap-catalog").forEach(initCatalogFilter); + document.querySelectorAll(".cmap-detail").forEach(initDetail); + document.querySelectorAll(".cmap-compare").forEach(initCompare); +}); + +// re-render charts when the color scheme changes +new MutationObserver(() => { + for (const canvas of document.querySelectorAll("canvas.cmap-chart")) { + canvas.dispatchEvent(new Event("cmap:rerender")); + } +}).observe(document.body, { attributeFilter: ["data-md-color-scheme"] }); + +// --------------------------------------------------------------------------- +// Data helpers +// --------------------------------------------------------------------------- + +function fetchData(url) { + if (!dataCache.has(url)) { + dataCache.set( + url, + fetch(url).then((r) => { + if (!r.ok) throw new Error(`${r.status} fetching ${url}`); + return r.json(); + }) + ); + } + return dataCache.get(url); +} + +const hexToRgb = (hex) => [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16)); +const linspace = (n) => Array.from({ length: n }, (_, i) => (n > 1 ? i / (n - 1) : 0)); + +// Port of cmap._util.sineramp(...)[:, ::-1] (Peter Kovesi's test image), values in +// [0, 1]. +function sineramp([rows, cols], amp = 0.05, wavelen = 8, power = 2) { + cols = Math.round(cols / wavelen) * wavelen; + const im = new Float32Array(rows * cols); + for (let r = 0; r < rows; r++) { + const a = ((rows - 1 - r) / (rows - 1)) ** power; + let min = Infinity; + let max = -Infinity; + for (let c = 0; c < cols; c++) { + const v = + a * amp * Math.sin((2 * Math.PI * c) / wavelen) + (c / (cols - 1)) * -2 * amp; + // columns are flipped so that the ramp goes from low to high, left to right + im[r * cols + (cols - 1 - c)] = v; + min = Math.min(min, v); + max = Math.max(max, v); + } + for (let c = 0; c < cols; c++) { + im[r * cols + c] = (im[r * cols + c] - min) / (max - min); + } + } + return { im, rows, cols }; +} + +let _sineramp; +const getSineramp = () => (_sineramp ??= sineramp(SINERAMP_SHAPE)); + +// Paint values in [0, 1] (row-major, `rows` x `cols`) through the LUT `colors`. +function paint(canvas, colors, values, rows, cols) { + const lut = colors.map(hexToRgb); + const n = lut.length; + canvas.width = cols; + canvas.height = rows; + const ctx = canvas.getContext("2d"); + const img = ctx.createImageData(cols, rows); + for (let i = 0; i < values.length; i++) { + const [r, g, b] = lut[Math.max(0, Math.min(n - 1, Math.floor(values[i] * n)))]; + img.data.set([r, g, b, 255], i * 4); + } + ctx.putImageData(img, 0, 0); +} + +function paintBar(canvas, colors, width = 826) { + paint(canvas, colors, linspace(width), 1, width); +} + +// --------------------------------------------------------------------------- +// Catalog grid filtering +// --------------------------------------------------------------------------- + +function initCatalogFilter(root) { + if (root.dataset.ready) return; + root.dataset.ready = "1"; + const text = root.querySelector(".cmap-filter-text"); + const ns = root.querySelector(".cmap-filter-ns"); + const mono = root.querySelector(".cmap-filter-mono input"); + const catButtons = [...root.querySelectorAll(".cmap-filter-cats button")]; + const count = root.querySelector(".cmap-filter-count"); + const empty = root.querySelector(".cmap-empty"); + const items = [...root.querySelectorAll(".cmap-grid > .cmap")]; + let category = "all"; + + function apply() { + const words = text.value.toLowerCase().split(/\s+/).filter(Boolean); + let shown = 0; + for (const el of items) { + const d = el.dataset; + const visible = + (category === "all" || d.category === category) && + (!ns.value || d.namespace === ns.value) && + (!mono.checked || d.monotonic === "1") && + words.every((w) => d.search.includes(w)); + el.hidden = !visible; + shown += visible; + } + count.textContent = `${shown} of ${items.length} colormaps`; + empty.hidden = shown > 0; + } + + for (const btn of catButtons) { + btn.addEventListener("click", () => { + category = btn.dataset.category; + catButtons.forEach((b) => b.setAttribute("aria-pressed", String(b === btn))); + apply(); + }); + } + text.addEventListener("input", apply); + ns.addEventListener("change", apply); + mono.addEventListener("change", apply); + apply(); +} + +// --------------------------------------------------------------------------- +// Colormap detail page +// --------------------------------------------------------------------------- + +async function initDetail(root) { + if (root.dataset.ready) return; + root.dataset.ready = "1"; + const name = root.dataset.cmapName; + const data = await fetchData(new URL(root.dataset.json, location.href)); + const bars = [...root.querySelectorAll(".cmap > img")]; + const originals = bars.map((img) => img.src); + const sine = root.querySelector(".cmap-sineramp"); + const charts = [...document.querySelectorAll(`canvas.cmap-chart[data-cmap-name="${name}"]`)]; + const { im, rows, cols } = getSineramp(); + + function render(cvd) { + const d = data[cvd] ?? data.normal; + if (cvd === "normal") { + bars.forEach((img, i) => (img.src = originals[i])); + } else { + const tmp = document.createElement("canvas"); + paintBar(tmp, d.color); + const url = tmp.toDataURL(); + bars.forEach((img) => (img.src = url)); + } + paint(sine, d.color, im, rows, cols); + for (const canvas of charts) drawChart(canvas, d); + } + + for (const canvas of charts) { + canvas.addEventListener("cmap:rerender", () => render(currentCvd())); + } + const currentCvd = () => root.querySelector('.cmap-cvd input:checked')?.value ?? "normal"; + root.querySelector(".cmap-cvd").addEventListener("change", () => render(currentCvd())); + render(currentCvd()); +} + +// --------------------------------------------------------------------------- +// Charts +// --------------------------------------------------------------------------- + +function themeColors() { + const style = getComputedStyle(document.body); + return { + fg: style.getPropertyValue("--md-default-fg-color--light").trim(), + grid: style.getPropertyValue("--md-default-fg-color--lightest").trim(), + }; +} + +function baseOptions(yScales) { + const { fg, grid } = themeColors(); + const scales = { x: { type: "linear", min: 0, max: 1 }, ...yScales }; + for (const axis of Object.values(scales)) { + axis.ticks = { color: fg, ...axis.ticks }; + axis.grid = { color: grid, ...axis.grid }; + if (axis.title) axis.title.color = fg; + } + return { + responsive: true, + maintainAspectRatio: false, + animation: false, + interaction: { mode: "index", intersect: false }, + plugins: { legend: { display: false } }, + scales, + }; +} + +function points(xs, ys) { + return ys.map((y, i) => ({ x: xs[i], y })); +} + +function drawChart(canvas, d) { + Chart.getChart(canvas)?.destroy(); + const x = linspace(d.color.length); + const colors = d.color.map((c) => `#${c}`); + const kind = canvas.dataset.chart; + + if (kind === "lightness") { + const options = baseOptions({ + y: { min: 0, max: 100, title: { text: "Lightness J′", display: true } }, + y2: { + min: -500, + max: 500, + position: "right", + grid: { drawOnChartArea: false }, + title: { text: "Lightness derivative", display: true }, + }, + }); + new Chart(canvas, { + type: "scatter", + data: { + datasets: [ + { + label: "J′", + data: points(x, d.J), + backgroundColor: colors, + pointRadius: 10, + pointHoverRadius: 12, + borderWidth: 0, + }, + { + label: "dJ′", + data: points(x, d.dJ), + showLine: true, + pointRadius: 0, + borderColor: "#88888866", + borderWidth: 3, + yAxisID: "y2", + }, + ], + }, + options, + }); + } else if (kind === "rgb") { + const rgb = d.color.map(hexToRgb); + const channel = (i, label, color) => ({ + label, + data: points(x, rgb.map((c) => c[i] / 255)), + showLine: true, + pointRadius: 0, + borderWidth: 4, + borderColor: color, + }); + new Chart(canvas, { + type: "scatter", + data: { + datasets: [ + channel(0, "red", "#DD2222BB"), + channel(1, "green", "#22BB22BB"), + channel(2, "blue", "#2255FFBB"), + ], + }, + options: baseOptions({ + y: { min: 0, max: 1, title: { text: "Component value", display: true } }, + }), + }); + } else if (kind === "hsl") { + const options = baseOptions({ + y: { min: 0, max: 100, title: { text: "Saturation / chroma", display: true } }, + y2: { + min: 0, + max: 360, + position: "right", + ticks: { stepSize: 60 }, + grid: { drawOnChartArea: false }, + title: { text: "Hue (degrees)", display: true }, + }, + }); + options.plugins.legend = { + display: true, + labels: { boxHeight: 2, color: themeColors().fg }, + }; + const line = (label, ys, axis = "y") => ({ + label, + data: points(x, ys), + showLine: true, + pointRadius: 0, + borderWidth: 3, + yAxisID: axis, + }); + // break the hue line where it wraps around, instead of drawing a vertical line + const hue = d.hue.map((h, i) => (i && Math.abs(h - d.hue[i - 1]) > 95 ? null : h)); + new Chart(canvas, { + type: "scatter", + data: { + datasets: [ + line("saturation", d.saturation), + line("chroma", d.chroma), + line("hue", hue, "y2"), + ], + }, + options, + }); + } +} + +// --------------------------------------------------------------------------- +// Compare page +// --------------------------------------------------------------------------- + +const COMPARE_MAX = 6; + +async function initCompare(root) { + if (root.dataset.ready) return; + root.dataset.ready = "1"; + const base = new URL(root.dataset.base, location.href); + const index = await fetchData(new URL("index.json", base)); + const byName = new Map(index.map((e) => [e.name.toLowerCase(), e])); + for (const e of index) { + for (const a of e.aliases) byName.set(a.toLowerCase(), e); + } + + const input = root.querySelector(".cmap-compare-input"); + const list = root.querySelector("datalist"); + list.replaceChildren( + ...index.map((e) => Object.assign(document.createElement("option"), { value: e.name })) + ); + const rowsEl = root.querySelector(".cmap-compare-rows"); + const chart = root.querySelector("canvas.cmap-chart"); + const cvdGroup = root.querySelector(".cmap-cvd"); + const params = new URLSearchParams(location.search); + const resolve = (n) => byName.get(n.trim().toLowerCase().replace(/_r$/, "")); + let selected = (params.get("cmaps") ?? "") + .split(",") + .map(resolve) + .filter(Boolean) + .slice(0, COMPARE_MAX); + if (!selected.length) selected = ["bids:viridis", "cmocean:thermal", "crameri:batlow"] + .map(resolve) + .filter(Boolean); + + const cvd = () => cvdGroup.querySelector("input:checked")?.value ?? "normal"; + + function syncUrl() { + const url = new URL(location.href); + url.searchParams.set("cmaps", selected.map((e) => e.name).join(",")); + history.replaceState(history.state, "", url); + } + + async function render() { + syncUrl(); + const datas = await Promise.all( + selected.map((e) => fetchData(new URL(`${encodeURIComponent(e.path.split("/").pop())}.json`, base))) + ); + const { im, rows, cols } = getSineramp(); + rowsEl.replaceChildren( + ...selected.map((e, i) => { + const d = datas[i][cvd()] ?? datas[i].normal; + const row = document.createElement("div"); + row.className = "cmap-compare-row"; + const head = document.createElement("div"); + head.className = "cmap-compare-head"; + const link = Object.assign(document.createElement("a"), { + href: new URL(`../${e.path}/`, base).href, + textContent: e.name, + }); + const remove = Object.assign(document.createElement("button"), { + type: "button", + className: "cmap-compare-remove", + title: `Remove ${e.name}`, + textContent: "✕", + }); + remove.addEventListener("click", () => { + selected = selected.filter((s) => s !== e); + render(); + }); + head.append(link, remove); + const bar = document.createElement("canvas"); + bar.className = "cmap-compare-bar"; + paintBar(bar, d.color); + const sine = document.createElement("canvas"); + sine.className = "cmap-sineramp"; + paint(sine, d.color, im, rows, cols); + row.append(head, bar, sine); + return row; + }) + ); + + Chart.getChart(chart)?.destroy(); + const options = baseOptions({ + y: { min: 0, max: 100, title: { text: "Lightness J′", display: true } }, + }); + options.plugins.legend = { display: true, labels: { color: themeColors().fg } }; + new Chart(chart, { + type: "scatter", + data: { + datasets: selected.map((e, i) => { + const d = datas[i][cvd()] ?? datas[i].normal; + const x = linspace(d.color.length); + const colors = d.color.map((c) => `#${c}`); + return { + label: e.name, + data: points(x, d.J), + showLine: true, + pointRadius: 0, + borderWidth: 5, + borderColor: colors[Math.floor(colors.length / 2)], + // color each segment of the line by the colormap itself + segment: { borderColor: (ctx) => colors[ctx.p0DataIndex] }, + }; + }), + }, + options, + }); + } + + chart.addEventListener("cmap:rerender", render); + cvdGroup.addEventListener("change", render); + root.querySelector(".cmap-compare-form").addEventListener("submit", (ev) => { + ev.preventDefault(); + const e = resolve(input.value); + input.setCustomValidity(e ? "" : "Unknown colormap"); + input.reportValidity(); + if (!e) return; + if (!selected.includes(e)) selected = [...selected, e].slice(-COMPARE_MAX); + input.value = ""; + render(); + }); + render(); +} diff --git a/docs/javascripts/cmap_charts.js b/docs/javascripts/cmap_charts.js deleted file mode 100644 index b16c6b9d1..000000000 --- a/docs/javascripts/cmap_charts.js +++ /dev/null @@ -1,215 +0,0 @@ -GLOBAL_OPTIONS = { - interaction: { mode: "index", intersect: false }, - plugins: { legend: { display: false } }, - clip: false, -}; - -initCharts(); - -async function initCharts() { - var charts = document.getElementsByClassName("cmap-chart"); - if (charts.length == 0) { - return; - } - - var chartElems = {}; - // Make object mapping chart name to document element - for (var i = 0; i < charts.length; i++) { - var cmap_name = charts[i].getAttribute("data-cmap-name"); - if (cmap_name in chartElems) { - chartElems[cmap_name].push(charts[i]); - } else { - chartElems[cmap_name] = [charts[i]]; - } - } - var cvd_type = document.getElementById("cvd").querySelector('input[name="cvd_button"]:checked')?.value; - // Make all charts for each cmap name - for (var cmap_name in chartElems) { - // NOTE: we're using a global window variable here that will be - // injected into the _gen_cmaps page... because it's much faster - // on readthedocs than making an additional fetch request - var cmap_data = window.cmap_data[cmap_name][cvd_type] || window.cmap_data[cmap_name]["normal"]; - for (var i = 0; i < chartElems[cmap_name].length; i++) { - var canv = chartElems[cmap_name][i]; - const chart = Chart.getChart(canv); - if (chart) { - chart.destroy(); - } - - if (canv.classList.contains("rgb-chart")) { - makeRGBChart(canv, cmap_data); - } else if (canv.classList.contains("hsl-chart")) { - makeHSLChart(canv, cmap_data); - } else if (canv.classList.contains("linearity-chart")) { - makeLinearityChart(canv, cmap_data); - } - } - } -} - -async function makeLinearityChart(canvas, data) { - var lightness_data = []; - var deltas = []; - var a = []; - var b = []; - for (i = 0; i < data.x.length; i++) { - lightness_data.push({ x: data.x[i], y: data.J[i] }); - deltas.push({ x: data.x[i], y: data.lightness_derivs[i] }); - // a.push({ x: cmap_data.x[i], y: cmap_data.a[i] }) - // b.push({ x: cmap_data.x[i], y: cmap_data.b[i] }) - } - - new Chart(canvas, { - type: "scatter", - data: { - datasets: [ - { - label: "J", - backgroundColor: data.color, - data: lightness_data, - pointRadius: 12, - borderWidth: 0, - }, - { - label: "deltas", - data: deltas, - showLine: true, - radius: 0, - borderColor: "#AAAAAA66", - borderWidth: 3, - yAxisID: "y2", - }, - // { - // label: "a", - // data: a, - // showLine: true, - // radius: 0, - // borderColor: "#AA222266", - // borderWidth: 2, - // yAxisID: "y3", - // }, - // { - // label: "b", - // data: b, - // showLine: true, - // radius: 0, - // borderColor: "#2222AA66", - // borderWidth: 2, - // yAxisID: "y3", - // }, - ], - }, - options: { - ...GLOBAL_OPTIONS, - scales: { - y: { max: 100, min: 0, title: { text: "Lightness L*", display: true } }, - y2: { - max: 500, - min: -500, - title: { text: "Perceptual Lightness Deltas", display: true }, - position: "right", - }, - // y3: { max: 120, min: -120, display: false }, - }, - }, - }); -} - -async function makeRGBChart(canvas, data) { - var rdata = []; - var gdata = []; - var bdata = []; - for (i = 0; i < data.x.length; i++) { - rdata.push({ x: data.x[i], y: data.R[i] }); - gdata.push({ x: data.x[i], y: data.G[i] }); - bdata.push({ x: data.x[i], y: data.B[i] }); - } - - new Chart(canvas, { - type: "scatter", - data: { - datasets: [ - { label: "red", showLine: true, borderColor: "#BB0000BB", data: rdata }, - { - label: "green", - showLine: true, - borderColor: "#00CC00BB", - data: gdata, - }, - { - label: "blue", - showLine: true, - borderColor: "#0000FFBB", - data: bdata, - }, - ], - }, - options: { - ...GLOBAL_OPTIONS, - scales: { - y: { - max: 1, - min: 0, - title: { text: "Component Value", display: true }, - }, - }, - elements: { - line: { borderWidth: 4 }, - point: { radius: 0 }, - }, - }, - }); -} - -async function makeHSLChart(canvas, data) { - const datasets = []; - var label_data, _data, val, lastval; - - ["saturation", "chroma", "hue"].forEach((label) => { - label_data = data[label]; - _data = []; - for (i = 0; i < data.x.length; i++) { - val = label_data[i]; - // If the hue is jumping by more than 90 degrees, insert a null - // value to break the line - if (label == "hue" && Math.abs(val - lastval) > 95) { - // we just fully skip this point, which does mean a data point is missed - // but if we insert a null value, the chartjs hover tooltip will be offset - // for all later points - _data.push({ x: data.x[i], y: null }); - } else { - _data.push({ x: data.x[i], y: val }); - } - lastval = val; - } - datasets.push({ - label: label, - showLine: true, - data: _data, - yAxisID: label == "hue" ? "y2" : "y", - }); - }); - - new Chart(canvas, { - type: "scatter", - data: { datasets: datasets }, - options: { - ...GLOBAL_OPTIONS, - plugins: { legend: { display: true, labels: { boxHeight: 1 } } }, - scales: { - y: { max: 100, min: 0, title: { text: "Saturation/Chroma %", display: true } }, - y2: { - max: 360, - min: 0, - ticks: { stepSize: 36 }, - title: { text: "Hue degree", display: true }, - position: "right", - }, - }, - elements: { - line: { borderWidth: 4 }, - point: { radius: 0 }, - }, - }, - }); -} diff --git a/docs/javascripts/filter.js b/docs/javascripts/filter.js deleted file mode 100644 index 7fa2c738c..000000000 --- a/docs/javascripts/filter.js +++ /dev/null @@ -1,56 +0,0 @@ -// This code pairs with the _hooks.py:_cmap_catalog() -// and controls the buttons on the category.md page -// from the very basic https://www.w3schools.com/howto/howto_js_filter_elements.asp -// :P - -function filterSelection(c) { - var x, i; - x = document.getElementsByClassName("filterDiv"); - if (c == "all") c = ""; - for (i = 0; i < x.length; i++) { - w3RemoveClass(x[i], "show"); - if (x[i].className.indexOf(c) > -1) w3AddClass(x[i], "show"); - } -} - -function w3AddClass(element, name) { - var i, arr1, arr2; - arr1 = element.className.split(" "); - arr2 = name.split(" "); - for (i = 0; i < arr2.length; i++) { - if (arr1.indexOf(arr2[i]) == -1) { - element.className += " " + arr2[i]; - } - } -} - -function w3RemoveClass(element, name) { - var i, arr1, arr2; - arr1 = element.className.split(" "); - arr2 = name.split(" "); - for (i = 0; i < arr2.length; i++) { - while (arr1.indexOf(arr2[i]) > -1) { - arr1.splice(arr1.indexOf(arr2[i]), 1); - } - } - element.className = arr1.join(" "); -} - -function initFilters() { - // Add active class to the current button (highlight it) - var btnContainer = document.getElementById("cmapFilterButtons"); - if (btnContainer) { - // if the element exists, initialize the event listeners - filterSelection("all"); - var btns = btnContainer.getElementsByClassName("btn"); - for (var i = 0; i < btns.length; i++) { - btns[i].addEventListener("click", function () { - var current = document.getElementsByClassName("active"); - current[0].className = current[0].className.replace(" active", ""); - this.className += " active"; - }); - } - } -} - -initFilters(); diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 14a061686..24a3f8700 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -1,12 +1,48 @@ +/* ---------------------------------------------------------------------------- + Theme + ------------------------------------------------------------------------- */ + +.md-header { + /* magma ... generated with Colormap('magma').to_css() */ + background: rgb(0, 0, 4); + background: linear-gradient( + 90deg, + rgba(0, 0, 4, 1) 0%, + rgba(29, 17, 70, 1) 12.5%, + rgba(80, 18, 124, 1) 25%, + rgba(130, 38, 129, 1) 37.5%, + rgba(182, 55, 121, 1) 50%, + rgba(230, 81, 99, 1) 62.5%, + rgba(251, 135, 97, 1) 75%, + rgba(254, 195, 135, 1) 87.5%, + rgba(252, 253, 191, 1) 100% + ); + color: white; +} + +.md-header__source, +.md-header [for="__search"] { + mix-blend-mode: difference; +} + +[data-md-color-scheme="slate"] { + --md-default-bg-color: hsla(var(--md-hue), 15%, 10%, 1); +} + +#readthedocs-ea, +.ethical-alabaster { + display: none; +} + .md-typeset .admonition, .md-typeset details { border-width: 0; border-left-width: 4px; } -.md-typeset .doc-children { - font-size: 0.7rem; -} +/* ---------------------------------------------------------------------------- + API docs (mkdocstrings) + ------------------------------------------------------------------------- */ /* name of a method */ code.highlight span.n:first-child { @@ -16,24 +52,16 @@ code.highlight span.n:not(:first-child) { color: #666; } -/* Indentation. */ -div.doc-contents:not(.first) { - padding-left: 25px; - border-left: 0.05rem solid var(--md-typeset-table-color); -} - /* Mark external links as such. */ a.autorefs-external::after { /* https://primer.style/octicons/arrow-up-right-24 */ background-image: url('data:image/svg+xml,'); content: " "; - display: inline-block; position: relative; top: 0.1em; margin-left: 0.2em; margin-right: 0.1em; - height: 1em; width: 1em; border-radius: 100%; @@ -43,182 +71,276 @@ a.autorefs-external:hover::after { background-color: var(--md-accent-fg-color); } -.md-header { - background: rgba(0, 0, 4, 1); - /* magma ... generated with Colormap('magma').to_css() */ - background: linear-gradient( - 90deg, - rgba(0, 0, 4, 1) 0%, - rgba(29, 17, 70, 1) 12.5%, - rgba(80, 18, 124, 1) 25%, - rgba(130, 38, 129, 1) 37.5%, - rgba(182, 55, 121, 1) 50%, - rgba(230, 81, 99, 1) 62.5%, - rgba(251, 135, 97, 1) 75%, - rgba(254, 195, 135, 1) 87.5%, - rgba(252, 253, 191, 1) 100% - ); -} - -nav.md-header__inner { - min-height: 72px; +.md-typeset .doc-children { + font-size: 0.7rem; } -nav .md-header__source { - mix-blend-mode: difference; +div.doc-contents:not(.first) { + padding-left: 25px; + border-left: 0.05rem solid var(--md-typeset-table-color); } -nav > label.md-header__button[for="__search"] { - mix-blend-mode: difference; -} +/* ---------------------------------------------------------------------------- + Colorbars ({{ cmap(...) }} macros and the catalog grid) + ------------------------------------------------------------------------- */ -div.cmap { +.md-typeset .cmap { position: relative; - margin: 0; + display: block; + margin: 0 0 2px; + line-height: 0; } -div.cmap-bar { - height: 30px; + +.md-typeset .cmap[hidden] { + display: none; } -div.cmap-expr > .cmap-bar { - height: 24px; +.md-typeset .cmap img { + display: block; + width: 100%; + height: 32px; } -div.cmap.grayscale > .cmap-bar, div.cmap.grayscale > img { +.md-typeset .cmap.grayscale img { filter: grayscale(100%); } -a div.cmap-bar:hover { - cursor: pointer; - filter: drop-shadow(0 0 0.4rem gray); +.md-typeset .cmap-expr { + display: inline-block; + vertical-align: middle; + width: min(100%, 20rem); + margin: 0.2rem 0; } -div.cmap-name { +.md-typeset .cmap-expr img { + height: 20px; +} + +.md-typeset .cmap-name { position: absolute; - vertical-align: middle; - line-height: 30px; /* same as cmap-bar */ - margin: 0 0.8rem; - font-weight: bold; + inset: 0 auto 0 0.8rem; + display: flex; + align-items: center; font-size: 0.75rem; - text-shadow: #000 0 1px 2px; + font-weight: 700; + line-height: 1; color: white; + text-shadow: #000 0 1px 2px; mix-blend-mode: difference; + pointer-events: none; } -div.cmap:hover .cmap-name { - color: red; + +.md-typeset a.cmap { + transition: transform 0.1s, box-shadow 0.1s; } -.container { - overflow: hidden; +.md-typeset a.cmap:hover, +.md-typeset a.cmap:focus-visible { + z-index: 1; + transform: scaleY(1.15); + box-shadow: 0 0 0.4rem var(--md-default-fg-color--light); } -.filterDiv { - width: 100%; - display: none; /* Hidden by default */ +/* ---------------------------------------------------------------------------- + Catalog filter + ------------------------------------------------------------------------- */ + +.cmap-filter { + position: sticky; + top: 2.4rem; + z-index: 2; + display: flex; + flex-wrap: wrap; + gap: 0.5rem 0.8rem; + align-items: center; + padding: 0.6rem 0; + margin-bottom: 0.4rem; + background: var(--md-default-bg-color); + font-size: 0.7rem; } -/* The "show" class is added to the filtered elements */ -.show { - display: block; +.cmap-filter input[type="search"], +.cmap-filter select, +.cmap-compare-input { + padding: 0.35rem 0.6rem; + font: inherit; + color: var(--md-default-fg-color); + background: var(--md-default-fg-color--lightest); + border: 1px solid transparent; + border-radius: 0.3rem; +} + +.cmap-filter input[type="search"] { + flex: 1 1 12rem; } -/* Style the buttons */ -.btn { - border: none; +.cmap-filter input:focus, +.cmap-filter select:focus, +.cmap-compare-input:focus { outline: none; - padding: 8px 16px; - background-color: #f1f1f1; + border-color: var(--md-accent-fg-color); +} + +.cmap-filter-mono { + display: inline-flex; + gap: 0.3rem; + align-items: center; cursor: pointer; } -body[data-md-color-scheme="slate"] .btn { - background-color: #414352; - color: white; + +.cmap-filter-cats { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; } -body[data-md-color-scheme="slate"] { - /* default is hsla(var(--md-hue),75%,95%,1) */ - --md-default-fg-color: hsla(var(--md-hue),40%,95%,.75); - /* default is hsla(var(--md-hue), 15%, 21%, 1); */ - --md-default-bg-color: hsla(var(--md-hue), 15%, 10%, 1); +.cmap-filter-cats button, +.cmap-cvd label { + padding: 0.25rem 0.7rem; + font: inherit; + color: var(--md-default-fg-color--light); + cursor: pointer; + background: var(--md-default-fg-color--lightest); + border-radius: 1rem; } -/* Add a light grey background on mouse-over */ -.btn:hover { - background-color: #ddd; +.cmap-filter-cats button[aria-pressed="true"], +.cmap-cvd label:has(input:checked) { + color: var(--md-accent-bg-color); + background: var(--md-accent-fg-color); } -body[data-md-color-scheme="slate"] .btn:hover { - background-color: #a3a5b1; + +.cmap-filter-count { + margin-left: auto; + color: var(--md-default-fg-color--light); } -/* Add a dark background to the active button */ -.btn.active { - background-color: #666; - color: white; +.cmap-empty { + color: var(--md-default-fg-color--light); } -body[data-md-color-scheme="slate"] .btn.active { - background-color: #a0a3b7; - color: black; + +/* ---------------------------------------------------------------------------- + Colormap detail page + ------------------------------------------------------------------------- */ + +.md-typeset .cmap-detail .cmap img { + height: 40px; } -.colorlist { +.md-typeset .cmap-detail .cmap.grayscale img { + height: 30px; +} + +.cmap-sineramp { + display: block; + width: 100%; + height: auto; + aspect-ratio: 826 / 96; + margin-top: 2px; +} + +.md-typeset .cmap-cvd { display: flex; flex-wrap: wrap; - justify-content: center; - font-size: 0.7rem; + gap: 0.3rem; + padding: 0; + margin: 0.8rem 0; + font-size: 0.65rem; + border: 0; } -.colorbox { - width: 7rem; - height: 3.8rem; - padding-top: 0.22rem; - margin: 0.2rem; + +.cmap-cvd input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.cmap-cvd label:has(input:focus-visible) { + outline: 2px solid var(--md-accent-fg-color); +} + +.cmap-chart-wrap { + position: relative; + height: 350px; +} + +.md-typeset .cmap-chart-caption { + font-size: 0.65rem; + font-style: italic; + color: var(--md-default-fg-color--light); text-align: center; } -#readthedocs-ea, .ethical-alabaster { - display: none; + +.md-typeset .cmap-pager { + display: flex; + justify-content: space-between; } -.radio-group { +.md-typeset .cmap-pager a:only-child:last-child { + margin-left: auto; +} + +/* ---------------------------------------------------------------------------- + Compare page + ------------------------------------------------------------------------- */ + +.cmap-compare-form { display: flex; - gap: 1rem; + gap: 0.5rem; + align-items: center; +} + +.cmap-compare-input { + flex: 1; + font-size: 0.8rem; +} + +.md-typeset .cmap-compare-form .md-button { + padding: 0.35rem 1rem; + margin: 0; +} + +.cmap-compare-row { margin-bottom: 1rem; } -.radio-option { - position: relative; - display: inline-flex; +.cmap-compare-head { + display: flex; + justify-content: space-between; align-items: center; - justify-content: center; - padding: 0.5em; - border-radius: 0.5em; - cursor: pointer; - transition: background-color 0.2s, box-shadow 0.2s; + font-weight: 700; } -.radio-option input[type="radio"] { - appearance: none; - position: absolute; - opacity: 0; - pointer-events: none; +.cmap-compare-remove { + color: var(--md-default-fg-color--light); + cursor: pointer; } -.radio-option .material-icons { - font-size: 1rem; - color: var(--md-default-fg-color); - transition: color 0.2s; +.cmap-compare-remove:hover { + color: var(--md-accent-fg-color); } -.radio-option input[type="radio"]:checked ~ .material-icons, -.radio-option input[type="radio"]:checked ~ .radio-label { - color: var(--md-accent-fg-color); +.cmap-compare-bar { + display: block; + width: 100%; + height: 32px; } +/* ---------------------------------------------------------------------------- + Named colors (colors.md) + ------------------------------------------------------------------------- */ -.radio-option:hover { - background-color: var(--md-default-bg-color--lighter); - box-shadow: 0 0 0 2px var(--md-accent-fg-color); +.colorlist { + display: flex; + flex-wrap: wrap; + justify-content: center; + font-size: 0.7rem; } -.radio-option .radio-label { - font-size: 0.65rem; - color: var(--md-default-fg-color); +.colorbox { + width: 7rem; + height: 3.8rem; + padding-top: 0.22rem; + margin: 0.2rem; + text-align: center; } diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index 967c1f94d..000000000 --- a/mkdocs.yml +++ /dev/null @@ -1,126 +0,0 @@ -site_name: cmap -site_description: Colors and scientific colormaps for python, no dependencies -# strict: true - -repo_name: pyapp-kit/cmap -repo_url: https://github.com/pyapp-kit/cmap -edit_uri: edit/main/docs/ - -watch: - - src - -nav: - - Overview: index.md - - Colors: colors.md - - Colormaps: colormaps.md - - Colormap Catalog: catalog/ - - FAQ: faq.md - -theme: - name: "material" - features: - - content.tabs.link - - content.code.annotate - - navigation.indexes - - navigation.instant - - content.code.copy - icon: - logo: fontawesome/solid/swatchbook - repo: fontawesome/brands/github - favicon: images/favicon.ico - palette: - # Palette toggle for light mode - - media: "(prefers-color-scheme: light)" - scheme: default - toggle: - icon: fontawesome/regular/lightbulb - name: "Switch to dark mode" - # Palette toggle for dark mode - - media: "(prefers-color-scheme: dark)" - scheme: slate - primary: pink - accent: pink - toggle: - icon: fontawesome/solid/lightbulb - name: "Switch to light mode" - -markdown_extensions: - - admonition - - abbr - - attr_list - - md_in_html - - pymdownx.snippets - - pymdownx.emoji: - emoji_generator: !!python/name:material.extensions.emoji.to_svg - emoji_index: !!python/name:material.extensions.emoji.twemoji - - pymdownx.extra - - pymdownx.highlight - - pymdownx.tabbed: - alternate_style: true - - tables - - toc: - permalink: "#" - -hooks: - - docs/_hooks.py - -plugins: - - search: - separator: '[\s\-\_,:!=\[\]()"/]+|(?!\b)(?=[A-Z][a-z])|\.(?!\d)|&[lg]t;' - - autorefs: - resolve_closest: true - - api-autonav: - modules: ['src/cmap'] - exclude: ['cmap.data'] - - literate-nav: - implicit_index: true - nav_file: SUMMARY.md - - gen-files: - scripts: - - docs/_gen_cmaps.py - - minify: - minify_html: true - minify_js: true - minify_css: true - htmlmin_opts: - remove_comments: true - js_files: - - javascripts/filter.js - - javascripts/cmap_charts.js - css_files: - - stylesheets/extra.css - - mkdocstrings: - handlers: - python: - inventories: - - https://docs.python.org/3/objects.inv - - https://numpy.org/doc/stable/objects.inv - options: - show_root_heading: true # default is false - docstring_style: "numpy" - line_length: 50 # default is 60 - # show_if_no_docstring: true # default is false - # show_signature: false # default is true - show_signature_annotations: true # default is false - annotations_path: "source" # default is 'brief' - show_bases: false # default is true - show_source: false # default is true - docstring_section_style: list - merge_init_into_class: true - - filters: ['(__call__|^[^_])'] - heading_level: 1 - parameter_headings: true - separate_signature: true - show_symbol_type_heading: true - show_symbol_type_toc: true - summary: true - -extra_css: - - https://fonts.googleapis.com/icon?family=Material+Icons - - stylesheets/extra.css - -extra_javascript: - - javascripts/filter.js - - https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.1.2/chart.umd.min.js - - javascripts/cmap_charts.js diff --git a/pyproject.toml b/pyproject.toml index 5f13dfa11..28334f76e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,17 +62,10 @@ dev = [ "pyqt6", ] docs = [ - 'colorcet', 'colorspacious', - 'imageio', - 'mkdocs-gen-files', - 'mkdocs-literate-nav', - 'mkdocs-api-autonav', - 'mkdocs-material', - 'mkdocs-minify-plugin', - 'mkdocs', 'mkdocstrings-python', 'natsort', + 'zensical', ] [tool.uv.sources] @@ -200,7 +193,7 @@ ignore = [ "tests/**/*", "setup.py", "docs/**/*", - "mkdocs.yml", + "zensical.toml", ".readthedocs.yaml", ] diff --git a/zensical.toml b/zensical.toml new file mode 100644 index 000000000..1d94bfd25 --- /dev/null +++ b/zensical.toml @@ -0,0 +1,152 @@ +# https://zensical.org/docs/setup/basics/ +# +# The colormap catalog is generated before building: +# +# uv run --group docs python docs/_gen_catalog.py +# uv run --group docs zensical serve + +[project] +site_name = "cmap" +site_description = "Colors and scientific colormaps for python, no dependencies" +site_url = "https://cmap-docs.readthedocs.io/" +repo_name = "pyapp-kit/cmap" +repo_url = "https://github.com/pyapp-kit/cmap" +edit_uri = "edit/main/docs/" + +extra_css = ["stylesheets/extra.css"] +extra_javascript = [ + "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.5.1/chart.umd.min.js", + "javascripts/cmap.js", +] + +exclude_docs = """ +_*.py +__pycache__/ +""" +# colormap pages are reached from the catalog / category pages and search +not_in_nav = """ +catalog/*/*.md +""" + +nav = [ + { "Overview" = "index.md" }, + { "Colors" = "colors.md" }, + { "Colormaps" = "colormaps.md" }, + { "Colormap Catalog" = [ + "catalog/index.md", + { "Sequential" = "catalog/sequential/index.md" }, + { "Diverging" = "catalog/diverging/index.md" }, + { "Cyclic" = "catalog/cyclic/index.md" }, + { "Qualitative" = "catalog/qualitative/index.md" }, + { "Miscellaneous" = "catalog/miscellaneous/index.md" }, + ] }, + { "Compare" = "compare.md" }, + { "FAQ" = "faq.md" }, +] + +[project.theme] +language = "en" +favicon = "images/favicon.ico" +features = [ + "content.action.edit", + "content.code.annotate", + "content.code.copy", + "content.tabs.link", + "content.tooltips", + "navigation.footer", + "navigation.indexes", + "navigation.instant", + "navigation.instant.prefetch", + "navigation.path", + "navigation.top", + "navigation.tracking", + "search.highlight", +] + +[project.theme.icon] +logo = "lucide/swatch-book" +repo = "fontawesome/brands/github" + +[[project.theme.palette]] +media = "(prefers-color-scheme)" +toggle.icon = "lucide/sun-moon" +toggle.name = "Switch to light mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: light)" +scheme = "default" +toggle.icon = "lucide/sun" +toggle.name = "Switch to dark mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: dark)" +scheme = "slate" +primary = "pink" +accent = "pink" +toggle.icon = "lucide/moon" +toggle.name = "Switch to system preference" + +[project.markdown_extensions] +abbr = {} +admonition = {} +attr_list = {} +md_in_html = {} +tables = {} +toc.permalink = "#" +pymdownx.details = {} +pymdownx.emoji.emoji_generator = "zensical.extensions.emoji.to_svg" +pymdownx.emoji.emoji_index = "zensical.extensions.emoji.twemoji" +pymdownx.extra = {} +pymdownx.highlight = {} +pymdownx.inlinehilite = {} +pymdownx.snippets = {} +pymdownx.superfences = {} +pymdownx.tabbed.alternate_style = true + +[project.plugins.search] +separator = '[\s\-\_,:!=\[\]()"/]+|(?!\b)(?=[A-Z][a-z])|\.(?!\d)|&[lg]t;' + +[project.plugins.autorefs] +resolve_closest = true + +[project.plugins.api-autonav] +modules = ["src/cmap"] +exclude = ["cmap.data"] + +[project.plugins.macros] +module_name = "docs/_macros" +# by default, every file in docs/ is watched as a potential jinja include, +# which makes builds *very* slow with the generated catalog. We don't use includes. +include_dir = "docs/_includes" +render_by_default = false +on_error_fail = true +on_undefined = "strict" + +[project.plugins.minify] +minify_html = true +minify_js = true +minify_css = true + +[project.plugins.mkdocstrings.handlers.python] +inventories = [ + "https://docs.python.org/3/objects.inv", + "https://numpy.org/doc/stable/objects.inv", +] + +[project.plugins.mkdocstrings.handlers.python.options] +show_root_heading = true +docstring_style = "numpy" +line_length = 50 +show_signature_annotations = true +annotations_path = "source" +show_bases = false +show_source = false +docstring_section_style = "list" +merge_init_into_class = true +filters = ['(__call__|^[^_])'] +heading_level = 1 +parameter_headings = true +separate_signature = true +show_symbol_type_heading = true +show_symbol_type_toc = true +summary = true From 8c580159308bee2144dfbca8bc73c2c916fe8d70 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 17 Oct 2026 18:34:08 +0200 Subject: [PATCH 02/32] docs: colormap swatches in compare legend, tidy source dropdown Co-Authored-By: Claude Opus 5.5 --- docs/javascripts/cmap.js | 28 ++++++++++++++++++++++++++-- docs/stylesheets/extra.css | 21 +++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/docs/javascripts/cmap.js b/docs/javascripts/cmap.js index f02796d5b..8241d2d5e 100644 --- a/docs/javascripts/cmap.js +++ b/docs/javascripts/cmap.js @@ -85,6 +85,13 @@ function paintBar(canvas, colors, width = 826) { paint(canvas, colors, linspace(width), 1, width); } +// A small colorbar canvas, e.g. for chart legends. +function swatch(colors, width = 32, height = 10) { + const canvas = document.createElement("canvas"); + paint(canvas, colors, Array.from({ length: height }, () => linspace(width)).flat(), height, width); + return canvas; +} + // --------------------------------------------------------------------------- // Catalog grid filtering // --------------------------------------------------------------------------- @@ -281,7 +288,7 @@ function drawChart(canvas, d) { }); options.plugins.legend = { display: true, - labels: { boxHeight: 2, color: themeColors().fg }, + labels: { usePointStyle: true, pointStyle: "line", color: themeColors().fg }, }; const line = (label, ys, axis = "y") => ({ label, @@ -393,7 +400,22 @@ async function initCompare(root) { const options = baseOptions({ y: { min: 0, max: 100, title: { text: "Lightness J′", display: true } }, }); - options.plugins.legend = { display: true, labels: { color: themeColors().fg } }; + // legend swatches are little colorbars of each colormap + const swatches = datas.map((data) => swatch((data[cvd()] ?? data.normal).color)); + options.plugins.legend = { + display: true, + labels: { + color: themeColors().fg, + usePointStyle: true, + pointStyleWidth: 32, + boxHeight: 10, + padding: 16, + generateLabels: (c) => + Chart.defaults.plugins.legend.labels + .generateLabels(c) + .map((item, i) => ({ ...item, pointStyle: swatches[i] })), + }, + }; new Chart(chart, { type: "scatter", data: { @@ -407,6 +429,8 @@ async function initCompare(root) { showLine: true, pointRadius: 0, borderWidth: 5, + // round caps hide the seams between the separately-colored segments + borderCapStyle: "round", borderColor: colors[Math.floor(colors.length / 2)], // color each segment of the line by the colormap itself segment: { borderColor: (ctx) => colors[ctx.p0DataIndex] }, diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 24a3f8700..bb4f63abc 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -170,10 +170,31 @@ div.doc-contents:not(.first) { border-radius: 0.3rem; } +.cmap-filter input[type="search"], +.cmap-filter select { + box-sizing: border-box; + height: 1.9rem; +} + .cmap-filter input[type="search"] { flex: 1 1 12rem; } +/* custom chevron, so the native one isn't jammed against the right edge */ +.cmap-filter select { + padding-right: 1.8rem; + appearance: none; + cursor: pointer; + background-image: + linear-gradient(45deg, transparent 50%, currentColor 50%), + linear-gradient(135deg, currentColor 50%, transparent 50%); + background-position: + right 0.85rem center, + right 0.6rem center; + background-size: 0.25rem 0.25rem; + background-repeat: no-repeat; +} + .cmap-filter input:focus, .cmap-filter select:focus, .cmap-compare-input:focus { From 37371f3b2af76127ce0b045a13ad516eca7f24fa Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 17 Oct 2026 18:36:23 +0200 Subject: [PATCH 03/32] docs: use zensical's default search separator The separator carried over from mkdocs.yml breaks zensical's search worker ("Search is unavailable"). Co-Authored-By: Claude Opus 5.5 --- zensical.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/zensical.toml b/zensical.toml index 1d94bfd25..03eff2fa5 100644 --- a/zensical.toml +++ b/zensical.toml @@ -103,9 +103,6 @@ pymdownx.snippets = {} pymdownx.superfences = {} pymdownx.tabbed.alternate_style = true -[project.plugins.search] -separator = '[\s\-\_,:!=\[\]()"/]+|(?!\b)(?=[A-Z][a-z])|\.(?!\d)|&[lg]t;' - [project.plugins.autorefs] resolve_closest = true From 4c395b10c4af7e56d38cf1622773ffdafd686a0b Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 17 Oct 2026 18:41:50 +0200 Subject: [PATCH 04/32] docs: trim search index, hide modules summary on API page Exclude code tabs, chart sections, the pager and catalog grids from search (colormap pages themselves stay indexed). search.json: 1.3 MB -> 380 KB. Co-Authored-By: Claude Opus 5.5 --- docs/_gen_catalog.py | 10 +++++++--- docs/_macros.py | 2 +- zensical.toml | 3 ++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/_gen_catalog.py b/docs/_gen_catalog.py index 41b764963..7b473c6a9 100644 --- a/docs/_gen_catalog.py +++ b/docs/_gen_catalog.py @@ -84,6 +84,8 @@ | --- | --- | --- | --- | | [{{ category | title }}](index.md) | {{ license }} | {{ authors }} | {{ source }} | +
+ === "cmap" ```python @@ -116,6 +118,8 @@ {{ css | indent(4) }} ``` +
+
{{ bar }} {{ gray }} @@ -127,7 +131,7 @@ [:lucide-git-compare: Compare with other colormaps](../../compare.md?cmaps={{ name | urlencode }}){ .md-button } -## Perceptual lightness +## Perceptual lightness { data-search-exclude }
@@ -136,11 +140,11 @@ CAM02 Uniform Color Space (CAM02-UCS)

-## RGB components +## RGB components { data-search-exclude }
-## Hue & saturation +## Hue & saturation { data-search-exclude }
diff --git a/docs/_macros.py b/docs/_macros.py index a94c4946d..f29184ff6 100644 --- a/docs/_macros.py +++ b/docs/_macros.py @@ -117,7 +117,7 @@ def catalog_grid(page: str, category: str | None = None) -> str: "
" ) return ( - f'
{controls}' + f'
{controls}' f'
{"".join(items)}
' '
' ) diff --git a/zensical.toml b/zensical.toml index 03eff2fa5..ee63b9dea 100644 --- a/zensical.toml +++ b/zensical.toml @@ -146,4 +146,5 @@ parameter_headings = true separate_signature = true show_symbol_type_heading = true show_symbol_type_toc = true -summary = true +# no "Modules" section: `cmap.data` is the only submodule, and it has no public API +summary = { attributes = true, functions = true, classes = true, modules = false } From 41405b6d078095f9aa55a98bb91342aecd02a00e Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 17 Oct 2026 18:48:28 +0200 Subject: [PATCH 05/32] docs: circular sine ramp (cyclic) and peaks (diverging) test images Rendered client-side like the sine ramp, so they follow the CVD toggle. Ported from Kovesi's circlesineramp.m and MATLAB's peaks. Co-Authored-By: Claude Opus 5.5 --- docs/_gen_catalog.py | 20 +++++++++- docs/javascripts/cmap.js | 79 +++++++++++++++++++++++++++++++++----- docs/stylesheets/extra.css | 16 +++++++- 3 files changed, 103 insertions(+), 12 deletions(-) diff --git a/docs/_gen_catalog.py b/docs/_gen_catalog.py index 7b473c6a9..6cd97e0b5 100644 --- a/docs/_gen_catalog.py +++ b/docs/_gen_catalog.py @@ -47,6 +47,20 @@ "BSD-2-Clause": "https://opensource.org/licenses/BSD-2-Clause", "PSF": "https://opensource.org/licenses/Python-2.0", } +# extra test image (rendered client-side, see cmap.js) per category +SQUARE_TEST_IMAGES = { + "cyclic": { + "kind": "circlesineramp", + "label": "circular sine ramp", + "title": "Circular sine ramp test image (Kovesi): the sine wave should be " + "equally visible all the way around, with no seam on the right", + }, + "diverging": { + "kind": "peaks", + "label": "peaks", + "title": "MATLAB's peaks surface, scaled so that 0 is the colormap's midpoint", + }, +} CVD_LABELS = { "normal": "Normal vision", "protan": "Protanopia", @@ -123,7 +137,10 @@
{{ bar }} {{ gray }} - +
+ +{% if square %} +{% endif %}
{% for key, label in cvd_labels.items() %} {% endfor %}
@@ -299,6 +316,7 @@ def _link(other: str | None) -> dict | None: bar=cmap_bar(name, page, height=40, link=False), gray=cmap_bar(name, page, height=30, link=False, classes=["grayscale"]), cvd_labels=CVD_LABELS, + square=SQUARE_TEST_IMAGES.get(info.category), data_url=f"../../_data/{quote(Path(page).stem)}.json", prev=_link(prev), next=_link(next_), diff --git a/docs/javascripts/cmap.js b/docs/javascripts/cmap.js index 8241d2d5e..65ad8ae41 100644 --- a/docs/javascripts/cmap.js +++ b/docs/javascripts/cmap.js @@ -63,8 +63,63 @@ function sineramp([rows, cols], amp = 0.05, wavelen = 8, power = 2) { return { im, rows, cols }; } -let _sineramp; -const getSineramp = () => (_sineramp ??= sineramp(SINERAMP_SHAPE)); +// Port of Peter Kovesi's circlesineramp: test image for cyclic colormaps. A sine +// wave on a spiral ramp, values in [0, 1], NaN outside the annulus. +// https://www.peterkovesi.com/matlabfns/Misc/circlesineramp.m +function circlesineramp(size = 320, amp = Math.PI / 10, wavelen = 8, power = 2) { + const maxr = (size / 2) * 0.9; + const minr = 0.15 * size; + const cycles = Math.round((2 * Math.PI * ((maxr + minr) / 2)) / wavelen); + const im = new Float32Array(size * size); + for (let row = 0; row < size; row++) { + for (let col = 0; col < size; col++) { + const x = col - size / 2; + const y = row - size / 2; + const theta = (Math.atan2(-y, x) + 2 * Math.PI) % (2 * Math.PI); + const rad = (Math.hypot(x, y) - minr) / (maxr - minr); + const v = amp * rad ** power * Math.sin(cycles * theta) + theta; + const wrapped = ((v % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI); + im[row * size + col] = rad < 0 || rad > 1 ? NaN : wrapped / (2 * Math.PI); + } + } + return { im, rows: size, cols: size }; +} + +// MATLAB's `peaks` surface over [-3, 3]^2, scaled so that 0 maps to the middle of +// the colormap (for diverging colormaps). +// https://www.mathworks.com/help/matlab/ref/peaks.html +function peaks(size = 320) { + const z = new Float32Array(size * size); + let absmax = 0; + for (let row = 0; row < size; row++) { + for (let col = 0; col < size; col++) { + const x = -3 + (6 * col) / (size - 1); + const y = 3 - (6 * row) / (size - 1); + const v = + 3 * (1 - x) ** 2 * Math.exp(-(x ** 2) - (y + 1) ** 2) - + 10 * (x / 5 - x ** 3 - y ** 5) * Math.exp(-(x ** 2) - y ** 2) - + (1 / 3) * Math.exp(-((x + 1) ** 2) - y ** 2); + z[row * size + col] = v; + absmax = Math.max(absmax, Math.abs(v)); + } + } + for (let i = 0; i < z.length; i++) z[i] = 0.5 + z[i] / (2 * absmax); + return { im: z, rows: size, cols: size }; +} + +const TEST_IMAGES = { sineramp: () => sineramp(SINERAMP_SHAPE), circlesineramp, peaks }; +// which extra (square) test image to show for each category +const CATEGORY_TEST_IMAGE = { cyclic: "circlesineramp", diverging: "peaks" }; +const _testImages = new Map(); +function getTestImage(kind) { + if (!_testImages.has(kind)) _testImages.set(kind, TEST_IMAGES[kind]()); + return _testImages.get(kind); +} + +function paintTestImage(canvas, colors, kind) { + const { im, rows, cols } = getTestImage(kind); + paint(canvas, colors, im, rows, cols); +} // Paint values in [0, 1] (row-major, `rows` x `cols`) through the LUT `colors`. function paint(canvas, colors, values, rows, cols) { @@ -75,6 +130,7 @@ function paint(canvas, colors, values, rows, cols) { const ctx = canvas.getContext("2d"); const img = ctx.createImageData(cols, rows); for (let i = 0; i < values.length; i++) { + if (Number.isNaN(values[i])) continue; // leave transparent const [r, g, b] = lut[Math.max(0, Math.min(n - 1, Math.floor(values[i] * n)))]; img.data.set([r, g, b, 255], i * 4); } @@ -149,9 +205,8 @@ async function initDetail(root) { const data = await fetchData(new URL(root.dataset.json, location.href)); const bars = [...root.querySelectorAll(".cmap > img")]; const originals = bars.map((img) => img.src); - const sine = root.querySelector(".cmap-sineramp"); + const testImages = [...root.querySelectorAll("canvas[data-test-image]")]; const charts = [...document.querySelectorAll(`canvas.cmap-chart[data-cmap-name="${name}"]`)]; - const { im, rows, cols } = getSineramp(); function render(cvd) { const d = data[cvd] ?? data.normal; @@ -163,7 +218,7 @@ async function initDetail(root) { const url = tmp.toDataURL(); bars.forEach((img) => (img.src = url)); } - paint(sine, d.color, im, rows, cols); + for (const canvas of testImages) paintTestImage(canvas, d.color, canvas.dataset.testImage); for (const canvas of charts) drawChart(canvas, d); } @@ -362,7 +417,6 @@ async function initCompare(root) { const datas = await Promise.all( selected.map((e) => fetchData(new URL(`${encodeURIComponent(e.path.split("/").pop())}.json`, base))) ); - const { im, rows, cols } = getSineramp(); rowsEl.replaceChildren( ...selected.map((e, i) => { const d = datas[i][cvd()] ?? datas[i].normal; @@ -388,10 +442,15 @@ async function initCompare(root) { const bar = document.createElement("canvas"); bar.className = "cmap-compare-bar"; paintBar(bar, d.color); - const sine = document.createElement("canvas"); - sine.className = "cmap-sineramp"; - paint(sine, d.color, im, rows, cols); - row.append(head, bar, sine); + const tests = document.createElement("div"); + tests.className = "cmap-test-images"; + for (const kind of ["sineramp", CATEGORY_TEST_IMAGE[e.category]].filter(Boolean)) { + const canvas = document.createElement("canvas"); + canvas.className = kind === "sineramp" ? "cmap-sineramp" : "cmap-test-square"; + paintTestImage(canvas, d.color, kind); + tests.append(canvas); + } + row.append(head, bar, tests); return row; }) ); diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index bb4f63abc..da3a140d2 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -252,12 +252,26 @@ div.doc-contents:not(.first) { height: 30px; } +.cmap-test-images { + display: flex; + flex-wrap: wrap; + gap: 0.8rem; + justify-content: center; + margin-top: 2px; +} + .cmap-sineramp { display: block; width: 100%; height: auto; aspect-ratio: 826 / 96; - margin-top: 2px; +} + +/* shown at (close to) native resolution: the test patterns depend on pixel scale */ +.cmap-test-square { + width: min(100%, 320px); + height: auto; + aspect-ratio: 1; } .md-typeset .cmap-cvd { From 1cdc948a9073f45b65c3b3f71b2f5970fee21adb Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 18 Oct 2026 00:17:53 +0200 Subject: [PATCH 06/32] docs: square test image beside colorbars, hover tips, dark theme by default Co-Authored-By: Claude Opus 5.5 --- docs/_gen_catalog.py | 24 +++++++++++--- docs/javascripts/cmap.js | 58 +++++++++++++++++++++----------- docs/stylesheets/extra.css | 68 +++++++++++++++++++++++++++++++++----- zensical.toml | 16 +++------ 4 files changed, 122 insertions(+), 44 deletions(-) diff --git a/docs/_gen_catalog.py b/docs/_gen_catalog.py index 6cd97e0b5..b7241342a 100644 --- a/docs/_gen_catalog.py +++ b/docs/_gen_catalog.py @@ -47,6 +47,11 @@ "BSD-2-Clause": "https://opensource.org/licenses/BSD-2-Clause", "PSF": "https://opensource.org/licenses/Python-2.0", } +BAR_TITLE = "The colormap, from its lowest value (left) to its highest (right)" +GRAY_TITLE = ( + "Grayscale version, roughly how the colormap looks printed in black and white: " + "brightness should change smoothly, with no flat spots or sudden jumps" +) # extra test image (rendered client-side, see cmap.js) per category SQUARE_TEST_IMAGES = { "cyclic": { @@ -135,11 +140,13 @@
+
+
{{ bar }} {{ gray }} -
- -{% if square %} + +
+{% if square %} {% endif %}
{% for key, label in cvd_labels.items() %} @@ -313,8 +320,15 @@ def _link(other: str | None) -> dict | None: authors=", ".join(info.authors), source=_md_link(info.source), css=cm.to_css(max_stops=32, as_hex=True).strip(), - bar=cmap_bar(name, page, height=40, link=False), - gray=cmap_bar(name, page, height=30, link=False, classes=["grayscale"]), + bar=cmap_bar(name, page, height=40, link=False, attrs=f' title="{BAR_TITLE}"'), + gray=cmap_bar( + name, + page, + height=30, + link=False, + classes=["grayscale"], + attrs=f' title="{GRAY_TITLE}"', + ), cvd_labels=CVD_LABELS, square=SQUARE_TEST_IMAGES.get(info.category), data_url=f"../../_data/{quote(Path(page).stem)}.json", diff --git a/docs/javascripts/cmap.js b/docs/javascripts/cmap.js index 65ad8ae41..c59bafb21 100644 --- a/docs/javascripts/cmap.js +++ b/docs/javascripts/cmap.js @@ -3,7 +3,6 @@ // zensical emits on initial load and on every instant navigation. const CVD_TYPES = ["normal", "protan", "deutan", "tritan"]; -const SINERAMP_SHAPE = [96, 826]; const dataCache = new Map(); document$.subscribe(() => { @@ -107,18 +106,41 @@ function peaks(size = 320) { return { im: z, rows: size, cols: size }; } -const TEST_IMAGES = { sineramp: () => sineramp(SINERAMP_SHAPE), circlesineramp, peaks }; -// which extra (square) test image to show for each category -const CATEGORY_TEST_IMAGE = { cyclic: "circlesineramp", diverging: "peaks" }; +// Test images are generated at their displayed size in device pixels (`s` per CSS +// pixel), so the 8px wavelength of the sine patterns is the same in every layout. +const TEST_IMAGES = { + sineramp: (rows, cols, s) => sineramp([rows, cols], 0.05, 8 * s), + circlesineramp: (rows, cols, s) => circlesineramp(Math.min(rows, cols), Math.PI / 10, 8 * s), + peaks: (rows, cols) => peaks(Math.min(rows, cols)), +}; const _testImages = new Map(); -function getTestImage(kind) { - if (!_testImages.has(kind)) _testImages.set(kind, TEST_IMAGES[kind]()); - return _testImages.get(kind); +function getTestImage(kind, rows, cols, s) { + const key = `${kind}:${rows}x${cols}`; + if (_testImages.get(kind)?.key !== key) { + _testImages.set(kind, { key, value: TEST_IMAGES[kind](rows, cols, s) }); + } + return _testImages.get(kind).value; +} + +const testImageColors = new WeakMap(); +const testImageObserver = new ResizeObserver((entries) => { + for (const { target } of entries) paintTestImage(target); +}); + +// Paint `canvas` (with a `data-test-image` kind) through `colors`, now and on resize. +function setTestImageColors(canvas, colors) { + testImageColors.set(canvas, colors); + testImageObserver.observe(canvas); + paintTestImage(canvas); } -function paintTestImage(canvas, colors, kind) { - const { im, rows, cols } = getTestImage(kind); - paint(canvas, colors, im, rows, cols); +function paintTestImage(canvas) { + const s = Math.max(1, Math.round(window.devicePixelRatio || 1)); + const rows = Math.round(canvas.clientHeight * s); + const cols = Math.round(canvas.clientWidth * s); + if (!rows || !cols) return; // not laid out yet: the ResizeObserver will repaint + const { im, rows: r, cols: c } = getTestImage(canvas.dataset.testImage, rows, cols, s); + paint(canvas, testImageColors.get(canvas), im, r, c); } // Paint values in [0, 1] (row-major, `rows` x `cols`) through the LUT `colors`. @@ -218,7 +240,7 @@ async function initDetail(root) { const url = tmp.toDataURL(); bars.forEach((img) => (img.src = url)); } - for (const canvas of testImages) paintTestImage(canvas, d.color, canvas.dataset.testImage); + for (const canvas of testImages) setTestImageColors(canvas, d.color); for (const canvas of charts) drawChart(canvas, d); } @@ -442,15 +464,11 @@ async function initCompare(root) { const bar = document.createElement("canvas"); bar.className = "cmap-compare-bar"; paintBar(bar, d.color); - const tests = document.createElement("div"); - tests.className = "cmap-test-images"; - for (const kind of ["sineramp", CATEGORY_TEST_IMAGE[e.category]].filter(Boolean)) { - const canvas = document.createElement("canvas"); - canvas.className = kind === "sineramp" ? "cmap-sineramp" : "cmap-test-square"; - paintTestImage(canvas, d.color, kind); - tests.append(canvas); - } - row.append(head, bar, tests); + const sine = document.createElement("canvas"); + sine.className = "cmap-sineramp"; + sine.dataset.testImage = "sineramp"; + row.append(head, bar, sine); + setTestImageColors(sine, d.color); return row; }) ); diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index da3a140d2..d91ff2a9f 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -252,28 +252,79 @@ div.doc-contents:not(.first) { height: 30px; } +/* Colorbar(s) + sine ramp stacked on the left, with the square test image (cyclic + and diverging maps only) on the right at the same height as the stack. + Test images are rendered at their displayed size by cmap.js. */ +.cmap-detail { + --cmap-square: 170px; + container-type: inline-size; +} + .cmap-test-images { - display: flex; - flex-wrap: wrap; + display: grid; gap: 0.8rem; - justify-content: center; - margin-top: 2px; +} + +.cmap-test-stack { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.md-typeset .cmap-test-stack > .cmap { + margin: 0; } .cmap-sineramp { display: block; width: 100%; - height: auto; - aspect-ratio: 826 / 96; + height: 96px; } -/* shown at (close to) native resolution: the test patterns depend on pixel scale */ .cmap-test-square { - width: min(100%, 320px); + display: block; + width: var(--cmap-square); height: auto; aspect-ratio: 1; } +.cmap-test-images:has(> .cmap-test-square) { + grid-template-columns: minmax(0, 1fr) auto; +} + +/* the sine ramp takes whatever height is left after the colorbars */ +.cmap-test-images:has(> .cmap-test-square) .cmap-test-stack { + height: var(--cmap-square); +} + +.cmap-test-images:has(> .cmap-test-square) .cmap-sineramp { + flex: 1; + min-height: 0; + height: auto; +} + +/* too narrow for side by side: square goes below, centered and larger */ +@container (width < 560px) { + .cmap-test-images:has(> .cmap-test-square) { + grid-template-columns: minmax(0, 1fr); + } + + .cmap-test-images:has(> .cmap-test-square) .cmap-test-stack { + height: auto; + } + + .cmap-test-images:has(> .cmap-test-square) .cmap-sineramp { + flex: none; + height: 96px; + } + + .cmap-test-square { + justify-self: center; + width: min(100%, 280px); + } +} + .md-typeset .cmap-cvd { display: flex; flex-wrap: wrap; @@ -359,6 +410,7 @@ div.doc-contents:not(.first) { display: block; width: 100%; height: 32px; + margin-bottom: 2px; } /* ---------------------------------------------------------------------------- diff --git a/zensical.toml b/zensical.toml index ee63b9dea..a70aaad4e 100644 --- a/zensical.toml +++ b/zensical.toml @@ -67,25 +67,19 @@ features = [ logo = "lucide/swatch-book" repo = "fontawesome/brands/github" +# dark by default (the first palette is used until the user picks one) [[project.theme.palette]] -media = "(prefers-color-scheme)" -toggle.icon = "lucide/sun-moon" +scheme = "slate" +primary = "pink" +accent = "pink" +toggle.icon = "lucide/moon" toggle.name = "Switch to light mode" [[project.theme.palette]] -media = "(prefers-color-scheme: light)" scheme = "default" toggle.icon = "lucide/sun" toggle.name = "Switch to dark mode" -[[project.theme.palette]] -media = "(prefers-color-scheme: dark)" -scheme = "slate" -primary = "pink" -accent = "pink" -toggle.icon = "lucide/moon" -toggle.name = "Switch to system preference" - [project.markdown_extensions] abbr = {} admonition = {} From 759a14bbc8cb5e7f820b989d4cf005fda70c9e1c Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 18 Oct 2026 00:17:57 +0200 Subject: [PATCH 07/32] docs, tests: use pydantic v2 forms Co-Authored-By: Claude Opus 5.5 --- docs/colormaps.md | 47 ++++++++++---------------- docs/colors.md | 31 +++++++---------- tests/test_color.py | 7 +--- tests/test_model_fields.py | 68 ++++++++++++-------------------------- 4 files changed, 52 insertions(+), 101 deletions(-) diff --git a/docs/colormaps.md b/docs/colormaps.md index 68b901fe0..24df90e7c 100644 --- a/docs/colormaps.md +++ b/docs/colormaps.md @@ -401,8 +401,11 @@ external visualization libraries. To that end, `cmap.Colormap` provides ## Usage with pydantic -`Colormap` can be used as a field type in -[pydantic](https://pydantic-docs.helpmanual.io/usage/types/). models. +`Colormap` can be used as a field type in [pydantic](https://docs.pydantic.dev/) +models. Fields accept any [`ColormapLike`](#colormaplike-objects) value. +Colormaps from the catalog serialize to their name, and other colormaps +serialize to the output of [`Colormap.as_dict`][cmap.Colormap.as_dict]. Either +way, the result validates back to an equal colormap. ```python from pydantic import BaseModel @@ -413,14 +416,20 @@ class Foo(BaseModel): colormap: Colormap -foo = Foo(colormap="viridis") # or any other ColormapLike -foo.colormap +Foo(colormap="viridis").model_dump_json() +# '{"colormap":"bids:viridis"}' + +Foo(colormap=["red", "green"]).model_dump_json() +# '{"colormap":{"name":"custom colormap","identifier":"custom_colormap", +# "category":null,"value":[[0.0,[1.0,0.0,0.0,1]],[1.0,[0.0,0.50196,0.0,1]]]}}' ``` -!!!tip "Serialization in pydantic" - Unfortunately, serialization with the `json` module is not easily - pluggable, so if you want to serialize a pydantic model with a `Colormap` - field to JSON, add the following encoder to your model: +This also works in +[`psygnal.EventedModel`](https://psygnal.readthedocs.io/en/latest/API/model/). + +??? note "pydantic v1" + With pydantic v1, `Colormap` fields validate the same way, but serializing + to JSON requires an encoder: ```python class Foo(BaseModel): @@ -428,30 +437,8 @@ foo.colormap class Config: json_encoders = {Colormap: Colormap.as_dict} - - - # example - Foo(colormap=["red", "green"]).json() ``` - results in - - ```json - {"colormap": - {"name": "custom colormap", - "identifier": "custom_colormap", - "category": null, - "color_stops": [[0.0, [1.0, 0.0, 0.0, 1]], [1.0, [0.0, 0.5019607843137255, 0.0, 1]]] - } - } - ``` - -!!!tip "Serialization in `psygnal.EventedModel`" - `Colormap` supports serialization in - [`psygnal.EventedModel`](https://psygnal.readthedocs.io/en/latest/API/model/) - out of the box. The `json_encoders = {Colormap: Colormap.as_dict}` line in the `Config` - class mentioned above is not necessary. - ## Rich repr If you use [`rich` pretty diff --git a/docs/colors.md b/docs/colors.md index 8e941f2d6..861e497cb 100644 --- a/docs/colors.md +++ b/docs/colors.md @@ -40,7 +40,7 @@ Briefly, valid arguments are of type: - `None` - `int` - `cmap.Color` -- `pydantic.color.Color` +- `pydantic_extra_types.color.Color` - `colour.Color` In detail: @@ -130,7 +130,7 @@ In detail: 1. An instance of [`cmap.Color`][] itself: which is returned unchanged - `Color(Color('red')) is Color('red')`) -1. (*Third-party object*): An instance of [`pydantic.color.Color`](https://docs.pydantic.dev/usage/types/#color-type). +1. (*Third-party object*): An instance of [`pydantic_extra_types.color.Color`](https://docs.pydantic.dev/latest/api/pydantic_extra_types_color/#pydantic_extra_types.color.Color). 1. (*Third-party object*): An instance of [`colour.Color`](https://github.com/vaab/colour) @@ -213,8 +213,8 @@ trq.name = "foo" # AttributeError: Color is immutable ## Usage with pydantic -`Color` can be used as a field type in -[pydantic](https://pydantic-docs.helpmanual.io/usage/types/). models. +`Color` can be used as a field type in [pydantic](https://docs.pydantic.dev/) +models. Fields accept any `ColorLike` value, and serialize to a color string: ```python from pydantic import BaseModel @@ -227,13 +227,16 @@ class Foo(BaseModel): foo = Foo(color="red") foo.color is Color("red") # True + +Foo(color=(0, 11, 23)).model_dump_json() # '{"color":"#000B17"}' ``` -!!!tip "Serialization in pydantic" - Unfortunately, serialization with the `json` module is not easily - pluggable, so if you want to serialize a pydantic model with a `Color` - field to JSON, add the following encoder to your model to cast - `Color` objects to strings: +This also works in +[`psygnal.EventedModel`](https://psygnal.readthedocs.io/en/latest/API/model/). + +??? note "pydantic v1" + With pydantic v1, `Color` fields validate the same way, but serializing to + JSON requires an encoder: ```python class Foo(BaseModel): @@ -241,18 +244,8 @@ foo.color is Color("red") # True class Config: json_encoders = {Color: str} - - - # example - Foo(color=(0, 11, 23)).json() # '{"color": "#000B17"}' ``` -!!!tip "Serialization in `psygnal.EventedModel`" - Color supports serialization in - [`psygnal.EventedModel`](https://psygnal.readthedocs.io/en/latest/API/model/) - out of the box. The `json_encoders = {Color: str}` line in the `Config` - class mentioned above is not necessary. - ## Rich repr If you use [`rich` pretty diff --git a/tests/test_color.py b/tests/test_color.py index 9c566c9fe..f05e94bcd 100644 --- a/tests/test_color.py +++ b/tests/test_color.py @@ -17,12 +17,7 @@ pydanticRed = pydantic_extra_types.color.Color("red") except ImportError: - try: - import pydantic.color - - pydanticRed = pydantic.color.Color("red") - except ImportError: - pydanticRed = "red" + pydanticRed = "red" @pytest.mark.parametrize( diff --git a/tests/test_model_fields.py b/tests/test_model_fields.py index 43c42add1..c3e63a670 100644 --- a/tests/test_model_fields.py +++ b/tests/test_model_fields.py @@ -1,5 +1,4 @@ -import os -from typing import ClassVar +import json import numpy as np import pytest @@ -8,66 +7,49 @@ from cmap._colormap import ColorStops try: - import pydantic - from pydantic_compat import BaseModel - - V2 = int(pydantic.__version__.split(".")[0]) >= 2 + from pydantic import BaseModel except ImportError: pytest.skip("pydantic not installed", allow_module_level=True) -try: - import pydantic_extra_types.color as pydantic_color -except (ImportError, NotImplementedError): - import pydantic.color as pydantic_color - def test_pydantic_casting() -> None: + pydantic_color = pytest.importorskip("pydantic_extra_types.color") assert Color(pydantic_color.Color("red")) is Color("red") -# we're interested in testing serializeability... -# Color can serialized with `str`, and Colormap can be serialized with `as_dict` -@pytest.mark.filterwarnings("ignore:`json_encoders` is deprecated") def test_pydantic_validate() -> None: class MyModel(BaseModel): color: Color colormap: Colormap - if V2: - - class Config: - # since json.dump is not extendable, this just needs to be documented. - json_encoders: ClassVar[dict] = {Color: str, Colormap: Colormap.as_dict} - obj = MyModel(color=np.array([1.0, 0, 0]), colormap=["r", (0.7, "b"), "w"]) assert obj.color is Color("red") assert obj.colormap == Colormap(["r", (0.7, "b"), "w"]) - serialized = obj.json() - if os.getenv("CI"): - # not sure why this is different in CI - assert serialized == ( - '{"color":"red",' - '"colormap":{"name":"custom colormap","identifier":"custom_colormap",' - '"category":null,' - '"value":[' - "[0.0,[1.0,0.0,0.0,1]]," - "[0.7,[0.0,0.0,1.0,1]]," - "[1.0,[1.0,1.0,1.0,1]]]}" - "}" - ) - if hasattr(MyModel, "model_validate_json"): - assert MyModel.model_validate_json(serialized) == obj - else: - assert MyModel.parse_raw(serialized) == obj + serialized = obj.model_dump_json() + # (parsed, because alpha may serialize as either `1` or `1.0`) + assert json.loads(serialized) == { + "color": "red", + "colormap": { + "name": "custom colormap", + "identifier": "custom_colormap", + "category": None, + "value": [ + [0.0, [1.0, 0.0, 0.0, 1]], + [0.7, [0.0, 0.0, 1.0, 1]], + [1.0, [1.0, 1.0, 1.0, 1]], + ], + }, + } + assert MyModel.model_validate_json(serialized) == obj # with category colormaps, we only use the qualified name obj2 = MyModel(color="red", colormap=Colormap("viridis")) - serialized2 = obj2.json() + serialized2 = obj2.model_dump_json() assert '"colormap":"bids:viridis"' in serialized2 + assert MyModel.model_validate_json(serialized2) == obj2 def test_psygnal_serialization() -> None: - # support for _json_encode_ is built into psygnal, ... don't need json_encoders psygnal = pytest.importorskip("psygnal") class MyModel(psygnal.EventedModel): # type: ignore @@ -78,10 +60,4 @@ class MyModel(psygnal.EventedModel): # type: ignore obj = MyModel( color=np.array([1, 0, 0]), colormap=["r", (0.7, "b"), "w"], stops="green_r" ) - - data = obj.model_dump_json() if V2 else obj.json() - - if hasattr(MyModel, "model_validate_json"): - assert MyModel.model_validate_json(data) == obj - else: - assert MyModel.parse_raw(data) == obj + assert MyModel.model_validate_json(obj.model_dump_json()) == obj From 2f8251750046c19f9fdb2c41d76918d696f32a25 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 18 Oct 2026 11:05:26 +0200 Subject: [PATCH 08/32] ci: build docs as a check only, stop deploying to gh-pages Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docs.yml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 93d22a38d..97d792005 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,15 +1,19 @@ name: docs +# The docs are hosted on Read the Docs (see .readthedocs.yaml). +# This only checks that they build. + on: push: branches: - main + pull_request: jobs: - deploy: + build: runs-on: ubuntu-latest permissions: - contents: write + contents: read steps: - uses: actions/checkout@v7 - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 @@ -17,8 +21,3 @@ jobs: python-version: 3.12 - run: uv run --group docs python docs/_gen_catalog.py - run: uv run --group docs zensical build --strict - - name: Deploy to gh-pages - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - uvx ghp-import --no-jekyll --push --force site From 3e5e6aeb43d832a1c2e24a46de63d8b6fb82ae3d Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 18 Oct 2026 11:09:21 +0200 Subject: [PATCH 09/32] css --- docs/stylesheets/extra.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index d91ff2a9f..9a468899d 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -29,9 +29,9 @@ --md-default-bg-color: hsla(var(--md-hue), 15%, 10%, 1); } -#readthedocs-ea, -.ethical-alabaster { - display: none; + +div.ethical-sidebar, div.ethical-footer { + display: none !important; } .md-typeset .admonition, From 20a75f5925ab319c2d6ff2d39499a2805510a2ae Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 18 Oct 2026 11:11:16 +0200 Subject: [PATCH 10/32] docs: use zensical's dark background so code blocks stand out Co-Authored-By: Claude Opus 5.5 --- docs/stylesheets/extra.css | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 9a468899d..53d756c8b 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -25,10 +25,6 @@ mix-blend-mode: difference; } -[data-md-color-scheme="slate"] { - --md-default-bg-color: hsla(var(--md-hue), 15%, 10%, 1); -} - div.ethical-sidebar, div.ethical-footer { display: none !important; From 5b963bfeccc08508d8f2ffd573d927ed0d279802 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 18 Oct 2026 11:15:28 +0200 Subject: [PATCH 11/32] docs: don't stretch colormap labels on hover Co-Authored-By: Claude Opus 5.5 --- docs/stylesheets/extra.css | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 53d756c8b..672dea233 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -115,6 +115,7 @@ div.doc-contents:not(.first) { .md-typeset .cmap-name { position: absolute; inset: 0 auto 0 0.8rem; + z-index: 1; display: flex; align-items: center; font-size: 0.75rem; @@ -126,13 +127,18 @@ div.doc-contents:not(.first) { pointer-events: none; } -.md-typeset a.cmap { +/* grow only the bar (not the label) on hover */ +.md-typeset a.cmap img { transition: transform 0.1s, box-shadow 0.1s; } .md-typeset a.cmap:hover, .md-typeset a.cmap:focus-visible { z-index: 1; +} + +.md-typeset a.cmap:hover img, +.md-typeset a.cmap:focus-visible img { transform: scaleY(1.15); box-shadow: 0 0 0.4rem var(--md-default-fg-color--light); } From a9aba6613af9f6566a6a1754f65a61a8d8f52b5f Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 18 Oct 2026 11:18:17 +0200 Subject: [PATCH 12/32] docs: drop css overrides that are no-ops under zensical Co-Authored-By: Claude Opus 5.5 --- docs/stylesheets/extra.css | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 672dea233..cd7b9f44a 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -30,24 +30,10 @@ div.ethical-sidebar, div.ethical-footer { display: none !important; } -.md-typeset .admonition, -.md-typeset details { - border-width: 0; - border-left-width: 4px; -} - /* ---------------------------------------------------------------------------- API docs (mkdocstrings) ------------------------------------------------------------------------- */ -/* name of a method */ -code.highlight span.n:first-child { - color: red; -} -code.highlight span.n:not(:first-child) { - color: #666; -} - /* Mark external links as such. */ a.autorefs-external::after { /* https://primer.style/octicons/arrow-up-right-24 */ @@ -67,10 +53,6 @@ a.autorefs-external:hover::after { background-color: var(--md-accent-fg-color); } -.md-typeset .doc-children { - font-size: 0.7rem; -} - div.doc-contents:not(.first) { padding-left: 25px; border-left: 0.05rem solid var(--md-typeset-table-color); From 63483f6c01b994922c57b446a79567b1a2873c99 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 18 Oct 2026 11:36:44 +0200 Subject: [PATCH 13/32] docs: don't list parameters in the API page's table of contents Co-Authored-By: Claude Opus 5.5 --- zensical.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zensical.toml b/zensical.toml index a70aaad4e..f5b38004a 100644 --- a/zensical.toml +++ b/zensical.toml @@ -136,7 +136,7 @@ docstring_section_style = "list" merge_init_into_class = true filters = ['(__call__|^[^_])'] heading_level = 1 -parameter_headings = true +parameter_headings = false separate_signature = true show_symbol_type_heading = true show_symbol_type_toc = true From 1963022b7e8ea70e2559e44c45995e9b6a5d84e4 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 18 Oct 2026 12:10:51 +0200 Subject: [PATCH 14/32] docs: address review findings - canonical urls point at /en/stable/; remove 'edit this page' - breadcrumbs on colormap pages; fix prev/next pager layout - alphabetical 'All' catalog; leaner search entries for colormap pages - deep purple accent in light mode (pink fails contrast on white) - installation, quick start and 'mapping data to colors' docs - minify css/js, watch src, svg favicon, fix data cache key - fix typos and add the typos pre-commit hook Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 - .pre-commit-config.yaml | 6 +++++ LICENSES/ISC.txt | 7 ++++++ REUSE.toml | 8 ++++++ docs/_gen_catalog.py | 11 ++++++++- docs/_macros.py | 6 ++++- docs/catalog/index.md | 22 ++--------------- docs/colormaps.md | 36 ++++++++++++++++++++++++++- docs/colors.md | 10 +++++--- docs/faq.md | 9 +++---- docs/images/favicon.svg | 1 + docs/index.md | 35 +++++++++++++++++++++++++- docs/javascripts/cmap.js | 9 ++++--- docs/overrides/partials/path.html | 41 +++++++++++++++++++++++++++++++ docs/stylesheets/extra.css | 3 ++- pyproject.toml | 13 ++++++++++ src/cmap/_catalog.py | 4 +-- src/cmap/_util.py | 2 +- zensical.toml | 16 +++++++++--- 19 files changed, 194 insertions(+), 46 deletions(-) create mode 100644 LICENSES/ISC.txt create mode 100644 docs/images/favicon.svg create mode 100644 docs/overrides/partials/path.html diff --git a/.gitignore b/.gitignore index b74d9ae90..3c3558667 100644 --- a/.gitignore +++ b/.gitignore @@ -108,7 +108,6 @@ ENV/ cmap/_version.py src/cmap/_version.py -docs/assets/_data/ .ruff_cache/ uv.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 78cd743bf..2ba93037a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,6 +12,12 @@ repos: - id: pretty-format-json args: [--autofix] + - repo: https://github.com/adhtruong/mirrors-typos + rev: v1.50.2 + hooks: + - id: typos + args: [--force-exclude] # omitting --write-changes + - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.17.1 hooks: diff --git a/LICENSES/ISC.txt b/LICENSES/ISC.txt new file mode 100644 index 000000000..205eb790a --- /dev/null +++ b/LICENSES/ISC.txt @@ -0,0 +1,7 @@ +ISC License + +Copyright + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/REUSE.toml b/REUSE.toml index 70bb47e46..3f72af611 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -182,3 +182,11 @@ SPDX-License-Identifier = "CC0-1.0" # freely shared with an attribution request (not a formal license). The matlab # colormaps are trivial mathematical formulas independently re-implemented across # many projects. Both fall through to the project default for now. + +# --- Docs assets --- + +[[annotations]] +path = "docs/images/favicon.svg" +precedence = "override" +SPDX-FileCopyrightText = "2026 Lucide Icons and Contributors" +SPDX-License-Identifier = "ISC" diff --git a/docs/_gen_catalog.py b/docs/_gen_catalog.py index b7241342a..8f220d4fd 100644 --- a/docs/_gen_catalog.py +++ b/docs/_gen_catalog.py @@ -91,6 +91,11 @@ --- title: {{ name | tojson }} description: {{ description | tojson }} +# rendered by overrides/partials/path.html (these pages aren't in the nav) +breadcrumbs: + - {title: Overview, url: ""} + - {title: Colormap Catalog, url: catalog/} + - {title: {{ category | title }}, url: catalog/{{ category }}/} --- # {{ name }} @@ -139,7 +144,7 @@
-
+
{{ bar }} @@ -153,8 +158,12 @@ {% endfor %}
+
+ [:lucide-git-compare: Compare with other colormaps](../../compare.md?cmaps={{ name | urlencode }}){ .md-button } +
+ ## Perceptual lightness { data-search-exclude }
diff --git a/docs/_macros.py b/docs/_macros.py index f29184ff6..46e324f05 100644 --- a/docs/_macros.py +++ b/docs/_macros.py @@ -76,7 +76,11 @@ def catalog_grid(page: str, category: str | None = None) -> str: raise FileNotFoundError( f"{CATALOG_INDEX} not found. Run `python docs/_gen_catalog.py` first." ) - entries = json.loads(CATALOG_INDEX.read_text()) + entries = natsort.natsorted( + json.loads(CATALOG_INDEX.read_text()), + key=lambda e: e["name"], + alg=natsort.ns.IGNORECASE, + ) if category: entries = [e for e in entries if e["category"] == category] diff --git a/docs/catalog/index.md b/docs/catalog/index.md index 084826355..927b7c37f 100644 --- a/docs/catalog/index.md +++ b/docs/catalog/index.md @@ -20,26 +20,8 @@ cmap = Colormap("viridis") `matplotlib.colors.Colormap` instance, you may call [`.to_mpl()`][cmap.Colormap.to_matplotlib]. -See the [Colormap API docs][cmap.Colormap] for details. - - +See [Colormaps](../colormaps.md#mapping-data-to-colors) for how to apply it to +your data, and the [Colormap API docs][cmap.Colormap] for details. !!!tip "Tip: Reversed colormaps" diff --git a/docs/colormaps.md b/docs/colormaps.md index 24df90e7c..4e80814d5 100644 --- a/docs/colormaps.md +++ b/docs/colormaps.md @@ -164,6 +164,40 @@ A [`numpy.ndarray`][], in one of the following formats: ## Usage +### Mapping data to colors + +Call a colormap with an array of values to get an array of RGBA colors, with +shape `data.shape + (4,)`. Float values are expected to be normalized to +[0, 1]: + +```python +import numpy as np +from cmap import Colormap + +cm = Colormap("viridis") +data = np.random.random((64, 64)) + +cm(data) # float RGBA array, shape (64, 64, 4) +cm(data, bytes=True) # uint8 RGBA array, shape (64, 64, 4) +cm(0.5) # a single Color +``` + +Values below 0, above 1, and NaN use the colormap's `under`, `over`, and +`bad` colors. By default these are the first color, the last color, and +transparent: + +```python +cm = Colormap("viridis", under="black", over="white", bad="gray") +cm([-0.1, 0.5, 1.2, np.nan], bytes=True) +# array([[ 0, 0, 0, 255], +# [ 32, 144, 140, 255], +# [255, 255, 255, 255], +# [128, 128, 128, 255]], dtype=uint8) +``` + +Integer arrays are used as indices directly into the colormap's lookup table +(LUT). See [`Colormap.__call__`][cmap.Colormap.__call__] for details. + ### Using qualitative colormaps Consider a ColorBrewer qualitative colormap from the default catalog with eight color stops @@ -421,7 +455,7 @@ Foo(colormap="viridis").model_dump_json() Foo(colormap=["red", "green"]).model_dump_json() # '{"colormap":{"name":"custom colormap","identifier":"custom_colormap", -# "category":null,"value":[[0.0,[1.0,0.0,0.0,1]],[1.0,[0.0,0.50196,0.0,1]]]}}' +# "category":null,"value":[[0.0,[1.0,0.0,0.0,1]],[1.0,[0.0,0.5019607843137255,0.0,1]]]}}' ``` This also works in diff --git a/docs/colors.md b/docs/colors.md index 861e497cb..0a0c7062d 100644 --- a/docs/colors.md +++ b/docs/colors.md @@ -85,7 +85,7 @@ In detail: !!!warning "Ambiguities with tuples of 0 and 1" While it's nice to support both the 8-bit and float inputs, there is - amgiguity in the case of a tuple of integers containing only zeros + ambiguity in the case of a tuple of integers containing only zeros and ones (e.g. `(0, 0, 1)`). The convention in `cmap` is to treat a tuple of *all* integers as 8-bit RGB values, and a tuple with *any* floats (excepting the 4th alpha value) as float RGB values, so @@ -125,10 +125,10 @@ In detail: !!!Note [Base-16 integers](https://docs.python.org/3/library/functions.html#int) look the same as their hexadecimal color string representation, but - lacks the quotes, and are preceded by `0x` instead of `#`. + lack the quotes, and are preceded by `0x` instead of `#`. 1. An instance of [`cmap.Color`][] itself: which is returned unchanged - - `Color(Color('red')) is Color('red')`) + - `Color(Color('red')) is Color('red')` 1. (*Third-party object*): An instance of [`pydantic_extra_types.color.Color`](https://docs.pydantic.dev/latest/api/pydantic_extra_types_color/#pydantic_extra_types.color.Color). @@ -166,7 +166,9 @@ Comparing a color to anything will attempt to cast the other object: ```python trq == "turquoise" # True -trq is "turquoise" # False ... naturally, it's not the same object +trq is Color( + (64, 224, 208) +) # True ... colors are cached, so equal colors are the same object ``` !!!tip diff --git a/docs/faq.md b/docs/faq.md index 61fcf44ca..f89fa1f2e 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -7,13 +7,13 @@ We welcome contributions! To add a colormap: you first need to pick a namespace for your colormap. Namespaces are directories in the `src/cmap/data` folder. -- If the colormap is part of known broader collection (e.g., `cet`, `cmocean`, +- If the colormap is part of a known broader collection (e.g., `cet`, `cmocean`, `colorbrewer`, etc.), please check to see whether the collection already exists in the [`cmap/data` directory](https://github.com/pyapp-kit/cmap/tree/main/src/cmap/data). If so, please extend that collection. If not, please create a new namespace and directory for that collection. -- If you would like to contribute a colormap that doesn't nicely fit into an +- If you would like to contribute a colormap that doesn't nicely fit into a broader collection of colormaps (regardless of whether that collection exists in `cmap`), please contribute to the `cmap/contrib` namespace. @@ -35,11 +35,10 @@ Once you have picked a namespace: _The following keys are optional_: - - `tags`: a list of tags to help categorize the colormap. These appear on - the website. + - `tags`: a list of tags to help categorize the colormap. - `info`: a brief description of the colormap. This appears on the website (recommended). - - `interpolation`: whether the colormap should be interpolated, between + - `interpolation`: whether the colormap should be interpolated between stops. If not provided, the assumption is `True`. - `aliases`: a list of alternative names for the colormap. - `over`: color to show when values are over the range. diff --git a/docs/images/favicon.svg b/docs/images/favicon.svg new file mode 100644 index 000000000..5731119f0 --- /dev/null +++ b/docs/images/favicon.svg @@ -0,0 +1 @@ + diff --git a/docs/index.md b/docs/index.md index 30b32e73a..5258321be 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,6 +3,39 @@ `cmap` is a colormap library for python, providing all of the colormaps in matplotlib, vispy, cmocean, (and more), with no dependencies beyond numpy. +## Installation + +`cmap` is available on [PyPI](https://pypi.org/project/cmap/) and +[conda-forge](https://anaconda.org/conda-forge/cmap): + +=== "pip" + + ```bash + pip install cmap + ``` + +=== "conda" + + ```bash + conda install -c conda-forge cmap + ``` + +## Quick start + +```python +import numpy as np +from cmap import Colormap + +cm = Colormap("viridis") # any name from the catalog + +data = np.random.random((64, 64)) # values in [0, 1] +rgba = cm(data) # (64, 64, 4) array of RGBA colors + +cm.to_mpl() # or .to_napari(), .to_vispy(), .to_pygfx(), ... +``` + +See [Colormaps](colormaps.md) for more. + ## Purpose Mapping scalar values to colors is a very common procedure in scientific @@ -45,4 +78,4 @@ For details on using the [`cmap.Colormap`][cmap.Colormap] object, see [Colormaps This library also offers a simple [`cmap.Color`][cmap.Color] object. It can cast a variety of inputs (including strings, tuples/lists, arrays, integers) to an RGBA color representation, and offers some basic conversions. See [Colors](colors.md) for -details +details. diff --git a/docs/javascripts/cmap.js b/docs/javascripts/cmap.js index c59bafb21..3db357033 100644 --- a/docs/javascripts/cmap.js +++ b/docs/javascripts/cmap.js @@ -23,6 +23,7 @@ new MutationObserver(() => { // --------------------------------------------------------------------------- function fetchData(url) { + url = String(url); // (URL objects are compared by identity) if (!dataCache.has(url)) { dataCache.set( url, @@ -436,12 +437,12 @@ async function initCompare(root) { async function render() { syncUrl(); - const datas = await Promise.all( + const allData = await Promise.all( selected.map((e) => fetchData(new URL(`${encodeURIComponent(e.path.split("/").pop())}.json`, base))) ); rowsEl.replaceChildren( ...selected.map((e, i) => { - const d = datas[i][cvd()] ?? datas[i].normal; + const d = allData[i][cvd()] ?? allData[i].normal; const row = document.createElement("div"); row.className = "cmap-compare-row"; const head = document.createElement("div"); @@ -478,7 +479,7 @@ async function initCompare(root) { y: { min: 0, max: 100, title: { text: "Lightness J′", display: true } }, }); // legend swatches are little colorbars of each colormap - const swatches = datas.map((data) => swatch((data[cvd()] ?? data.normal).color)); + const swatches = allData.map((data) => swatch((data[cvd()] ?? data.normal).color)); options.plugins.legend = { display: true, labels: { @@ -497,7 +498,7 @@ async function initCompare(root) { type: "scatter", data: { datasets: selected.map((e, i) => { - const d = datas[i][cvd()] ?? datas[i].normal; + const d = allData[i][cvd()] ?? allData[i].normal; const x = linspace(d.color.length); const colors = d.color.map((c) => `#${c}`); return { diff --git a/docs/overrides/partials/path.html b/docs/overrides/partials/path.html new file mode 100644 index 000000000..cf38ece4d --- /dev/null +++ b/docs/overrides/partials/path.html @@ -0,0 +1,41 @@ +{#- + Same as the theme's partials/path.html, except that a page can give its own + breadcrumbs in front matter (a list of `{title, url}`, with site-relative urls). + The generated colormap pages do this, since they aren't in the nav. +-#} +{% import "partials/path-item.html" as item with context %} +{% if page.meta and page.meta.hide %} + {% set hidden = "hidden" if "path" in page.meta.hide %} +{% endif %} +{% if page.meta and page.meta.breadcrumbs %} + +{% else %} + {% set depth = page.ancestors | length %} + {% if nav.homepage %} + {% set depth = depth + 1 %} + {% endif %} + {% if depth > 1 %} + + {% endif %} +{% endif %} diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index cd7b9f44a..6def135a5 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -341,7 +341,8 @@ div.doc-contents:not(.first) { text-align: center; } -.md-typeset .cmap-pager { +/* (the links are wrapped in a

by markdown) */ +.md-typeset .cmap-pager > p { display: flex; justify-content: space-between; } diff --git a/pyproject.toml b/pyproject.toml index 28334f76e..0c296b656 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -186,6 +186,19 @@ source = ["src"] # https://github.com/mgedmin/check-manifest#configuration +[tool.typos.files] +extend-exclude = ["CHANGELOG.md", "src/cmap/data/**/*.py"] + +[tool.typos.default] +extend-ignore-identifiers-re = [] + +[tool.typos.default.extend-words] +ser = "ser" # pydantic's `..._ser_schema` +datas = "datas" # pyinstaller's `collect_data_files(include_datas=...)` +arange = "arange" # `np.arange` +buda = "buda" # crameri colormap name +OPF = "OPF" # chrisluts colormap names + [tool.check-manifest] ignore = [ ".github_changelog_generator", diff --git a/src/cmap/_catalog.py b/src/cmap/_catalog.py index 093a8f502..3724195ef 100644 --- a/src/cmap/_catalog.py +++ b/src/cmap/_catalog.py @@ -246,10 +246,10 @@ def __init__( # sort record files. Put matplotlib-related names first so that un-namespaced # names resolve to the matplotlib colormaps by default. - MPL_PRIORTY_NAMESPACES = ("matplotlib", "bids", "matlab", "gnuplot") + MPL_PRIORITY_NAMESPACES = ("matplotlib", "bids", "matlab", "gnuplot") def _sorter(x: Path) -> tuple[int, Path]: - if any(s in str(x) for s in MPL_PRIORTY_NAMESPACES): + if any(s in str(x) for s in MPL_PRIORITY_NAMESPACES): return (0, x) return (1, x) diff --git a/src/cmap/_util.py b/src/cmap/_util.py index efd12c6e2..0c15bebba 100644 --- a/src/cmap/_util.py +++ b/src/cmap/_util.py @@ -577,7 +577,7 @@ def to_mpl( N: int = 256, gamma: float = 1.0, ) -> matplotlib.colors.Colormap: - """Convienence function to create a matplotlib colormap. + """Convenience function to create a matplotlib colormap. Arguments are the same as for [cmap.Colormap][]. This simply creates the colormap instance and returns the value of `to_matplotlib()`. diff --git a/zensical.toml b/zensical.toml index f5b38004a..8c50f3f06 100644 --- a/zensical.toml +++ b/zensical.toml @@ -8,10 +8,9 @@ [project] site_name = "cmap" site_description = "Colors and scientific colormaps for python, no dependencies" -site_url = "https://cmap-docs.readthedocs.io/" +site_url = "https://cmap-docs.readthedocs.io/en/stable/" repo_name = "pyapp-kit/cmap" repo_url = "https://github.com/pyapp-kit/cmap" -edit_uri = "edit/main/docs/" extra_css = ["stylesheets/extra.css"] extra_javascript = [ @@ -22,12 +21,16 @@ extra_javascript = [ exclude_docs = """ _*.py __pycache__/ +overrides/ """ # colormap pages are reached from the catalog / category pages and search not_in_nav = """ catalog/*/*.md """ +# rebuild when docstrings change +watch = ["src"] + nav = [ { "Overview" = "index.md" }, { "Colors" = "colors.md" }, @@ -46,9 +49,9 @@ nav = [ [project.theme] language = "en" -favicon = "images/favicon.ico" +favicon = "images/favicon.svg" +custom_dir = "docs/overrides" features = [ - "content.action.edit", "content.code.annotate", "content.code.copy", "content.tabs.link", @@ -75,8 +78,11 @@ accent = "pink" toggle.icon = "lucide/moon" toggle.name = "Switch to light mode" +# (pink links are too low-contrast on white) [[project.theme.palette]] scheme = "default" +primary = "deep purple" +accent = "deep purple" toggle.icon = "lucide/sun" toggle.name = "Switch to dark mode" @@ -117,6 +123,8 @@ on_undefined = "strict" minify_html = true minify_js = true minify_css = true +js_files = ["javascripts/cmap.js"] +css_files = ["stylesheets/extra.css"] [project.plugins.mkdocstrings.handlers.python] inventories = [ From 9ac9def39e3b90a223f89e87d852ef3659cca890 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 18 Oct 2026 12:12:03 +0200 Subject: [PATCH 15/32] specific --- docs/stylesheets/extra.css | 402 +++++++++++++++++++------------------ 1 file changed, 202 insertions(+), 200 deletions(-) diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 6def135a5..d3e4b4ad1 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -3,31 +3,31 @@ ------------------------------------------------------------------------- */ .md-header { - /* magma ... generated with Colormap('magma').to_css() */ - background: rgb(0, 0, 4); - background: linear-gradient( - 90deg, - rgba(0, 0, 4, 1) 0%, - rgba(29, 17, 70, 1) 12.5%, - rgba(80, 18, 124, 1) 25%, - rgba(130, 38, 129, 1) 37.5%, - rgba(182, 55, 121, 1) 50%, - rgba(230, 81, 99, 1) 62.5%, - rgba(251, 135, 97, 1) 75%, - rgba(254, 195, 135, 1) 87.5%, - rgba(252, 253, 191, 1) 100% - ); - color: white; + /* magma ... generated with Colormap('magma').to_css() */ + background: rgb(0, 0, 4); + background: linear-gradient( + 90deg, + rgba(0, 0, 4, 1) 0%, + rgba(29, 17, 70, 1) 12.5%, + rgba(80, 18, 124, 1) 25%, + rgba(130, 38, 129, 1) 37.5%, + rgba(182, 55, 121, 1) 50%, + rgba(230, 81, 99, 1) 62.5%, + rgba(251, 135, 97, 1) 75%, + rgba(254, 195, 135, 1) 87.5%, + rgba(252, 253, 191, 1) 100% + ); + color: white; } .md-header__source, .md-header [for="__search"] { - mix-blend-mode: difference; + mix-blend-mode: difference; } - -div.ethical-sidebar, div.ethical-footer { - display: none !important; +html body div.ethical-sidebar, +html body div.ethical-footer { + display: none !important; } /* ---------------------------------------------------------------------------- @@ -36,26 +36,26 @@ div.ethical-sidebar, div.ethical-footer { /* Mark external links as such. */ a.autorefs-external::after { - /* https://primer.style/octicons/arrow-up-right-24 */ - background-image: url('data:image/svg+xml,'); - content: " "; - display: inline-block; - position: relative; - top: 0.1em; - margin-left: 0.2em; - margin-right: 0.1em; - height: 1em; - width: 1em; - border-radius: 100%; - background-color: var(--md-typeset-a-color); + /* https://primer.style/octicons/arrow-up-right-24 */ + background-image: url('data:image/svg+xml,'); + content: " "; + display: inline-block; + position: relative; + top: 0.1em; + margin-left: 0.2em; + margin-right: 0.1em; + height: 1em; + width: 1em; + border-radius: 100%; + background-color: var(--md-typeset-a-color); } a.autorefs-external:hover::after { - background-color: var(--md-accent-fg-color); + background-color: var(--md-accent-fg-color); } div.doc-contents:not(.first) { - padding-left: 25px; - border-left: 0.05rem solid var(--md-typeset-table-color); + padding-left: 25px; + border-left: 0.05rem solid var(--md-typeset-table-color); } /* ---------------------------------------------------------------------------- @@ -63,66 +63,68 @@ div.doc-contents:not(.first) { ------------------------------------------------------------------------- */ .md-typeset .cmap { - position: relative; - display: block; - margin: 0 0 2px; - line-height: 0; + position: relative; + display: block; + margin: 0 0 2px; + line-height: 0; } .md-typeset .cmap[hidden] { - display: none; + display: none; } .md-typeset .cmap img { - display: block; - width: 100%; - height: 32px; + display: block; + width: 100%; + height: 32px; } .md-typeset .cmap.grayscale img { - filter: grayscale(100%); + filter: grayscale(100%); } .md-typeset .cmap-expr { - display: inline-block; - vertical-align: middle; - width: min(100%, 20rem); - margin: 0.2rem 0; + display: inline-block; + vertical-align: middle; + width: min(100%, 20rem); + margin: 0.2rem 0; } .md-typeset .cmap-expr img { - height: 20px; + height: 20px; } .md-typeset .cmap-name { - position: absolute; - inset: 0 auto 0 0.8rem; - z-index: 1; - display: flex; - align-items: center; - font-size: 0.75rem; - font-weight: 700; - line-height: 1; - color: white; - text-shadow: #000 0 1px 2px; - mix-blend-mode: difference; - pointer-events: none; + position: absolute; + inset: 0 auto 0 0.8rem; + z-index: 1; + display: flex; + align-items: center; + font-size: 0.75rem; + font-weight: 700; + line-height: 1; + color: white; + text-shadow: #000 0 1px 2px; + mix-blend-mode: difference; + pointer-events: none; } /* grow only the bar (not the label) on hover */ .md-typeset a.cmap img { - transition: transform 0.1s, box-shadow 0.1s; + transition: + transform 0.1s, + box-shadow 0.1s; } .md-typeset a.cmap:hover, .md-typeset a.cmap:focus-visible { - z-index: 1; + z-index: 1; } .md-typeset a.cmap:hover img, .md-typeset a.cmap:focus-visible img { - transform: scaleY(1.15); - box-shadow: 0 0 0.4rem var(--md-default-fg-color--light); + transform: scaleY(1.15); + box-shadow: 0 0 0.4rem var(--md-default-fg-color--light); } /* ---------------------------------------------------------------------------- @@ -130,98 +132,98 @@ div.doc-contents:not(.first) { ------------------------------------------------------------------------- */ .cmap-filter { - position: sticky; - top: 2.4rem; - z-index: 2; - display: flex; - flex-wrap: wrap; - gap: 0.5rem 0.8rem; - align-items: center; - padding: 0.6rem 0; - margin-bottom: 0.4rem; - background: var(--md-default-bg-color); - font-size: 0.7rem; + position: sticky; + top: 2.4rem; + z-index: 2; + display: flex; + flex-wrap: wrap; + gap: 0.5rem 0.8rem; + align-items: center; + padding: 0.6rem 0; + margin-bottom: 0.4rem; + background: var(--md-default-bg-color); + font-size: 0.7rem; } .cmap-filter input[type="search"], .cmap-filter select, .cmap-compare-input { - padding: 0.35rem 0.6rem; - font: inherit; - color: var(--md-default-fg-color); - background: var(--md-default-fg-color--lightest); - border: 1px solid transparent; - border-radius: 0.3rem; + padding: 0.35rem 0.6rem; + font: inherit; + color: var(--md-default-fg-color); + background: var(--md-default-fg-color--lightest); + border: 1px solid transparent; + border-radius: 0.3rem; } .cmap-filter input[type="search"], .cmap-filter select { - box-sizing: border-box; - height: 1.9rem; + box-sizing: border-box; + height: 1.9rem; } .cmap-filter input[type="search"] { - flex: 1 1 12rem; + flex: 1 1 12rem; } /* custom chevron, so the native one isn't jammed against the right edge */ .cmap-filter select { - padding-right: 1.8rem; - appearance: none; - cursor: pointer; - background-image: - linear-gradient(45deg, transparent 50%, currentColor 50%), - linear-gradient(135deg, currentColor 50%, transparent 50%); - background-position: - right 0.85rem center, - right 0.6rem center; - background-size: 0.25rem 0.25rem; - background-repeat: no-repeat; + padding-right: 1.8rem; + appearance: none; + cursor: pointer; + background-image: + linear-gradient(45deg, transparent 50%, currentColor 50%), + linear-gradient(135deg, currentColor 50%, transparent 50%); + background-position: + right 0.85rem center, + right 0.6rem center; + background-size: 0.25rem 0.25rem; + background-repeat: no-repeat; } .cmap-filter input:focus, .cmap-filter select:focus, .cmap-compare-input:focus { - outline: none; - border-color: var(--md-accent-fg-color); + outline: none; + border-color: var(--md-accent-fg-color); } .cmap-filter-mono { - display: inline-flex; - gap: 0.3rem; - align-items: center; - cursor: pointer; + display: inline-flex; + gap: 0.3rem; + align-items: center; + cursor: pointer; } .cmap-filter-cats { - display: flex; - flex-wrap: wrap; - gap: 0.25rem; + display: flex; + flex-wrap: wrap; + gap: 0.25rem; } .cmap-filter-cats button, .cmap-cvd label { - padding: 0.25rem 0.7rem; - font: inherit; - color: var(--md-default-fg-color--light); - cursor: pointer; - background: var(--md-default-fg-color--lightest); - border-radius: 1rem; + padding: 0.25rem 0.7rem; + font: inherit; + color: var(--md-default-fg-color--light); + cursor: pointer; + background: var(--md-default-fg-color--lightest); + border-radius: 1rem; } .cmap-filter-cats button[aria-pressed="true"], .cmap-cvd label:has(input:checked) { - color: var(--md-accent-bg-color); - background: var(--md-accent-fg-color); + color: var(--md-accent-bg-color); + background: var(--md-accent-fg-color); } .cmap-filter-count { - margin-left: auto; - color: var(--md-default-fg-color--light); + margin-left: auto; + color: var(--md-default-fg-color--light); } .cmap-empty { - color: var(--md-default-fg-color--light); + color: var(--md-default-fg-color--light); } /* ---------------------------------------------------------------------------- @@ -229,126 +231,126 @@ div.doc-contents:not(.first) { ------------------------------------------------------------------------- */ .md-typeset .cmap-detail .cmap img { - height: 40px; + height: 40px; } .md-typeset .cmap-detail .cmap.grayscale img { - height: 30px; + height: 30px; } /* Colorbar(s) + sine ramp stacked on the left, with the square test image (cyclic and diverging maps only) on the right at the same height as the stack. Test images are rendered at their displayed size by cmap.js. */ .cmap-detail { - --cmap-square: 170px; - container-type: inline-size; + --cmap-square: 170px; + container-type: inline-size; } .cmap-test-images { - display: grid; - gap: 0.8rem; + display: grid; + gap: 0.8rem; } .cmap-test-stack { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; } .md-typeset .cmap-test-stack > .cmap { - margin: 0; + margin: 0; } .cmap-sineramp { - display: block; - width: 100%; - height: 96px; + display: block; + width: 100%; + height: 96px; } .cmap-test-square { - display: block; - width: var(--cmap-square); - height: auto; - aspect-ratio: 1; + display: block; + width: var(--cmap-square); + height: auto; + aspect-ratio: 1; } .cmap-test-images:has(> .cmap-test-square) { - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: minmax(0, 1fr) auto; } /* the sine ramp takes whatever height is left after the colorbars */ .cmap-test-images:has(> .cmap-test-square) .cmap-test-stack { - height: var(--cmap-square); + height: var(--cmap-square); } .cmap-test-images:has(> .cmap-test-square) .cmap-sineramp { - flex: 1; - min-height: 0; - height: auto; + flex: 1; + min-height: 0; + height: auto; } /* too narrow for side by side: square goes below, centered and larger */ @container (width < 560px) { - .cmap-test-images:has(> .cmap-test-square) { - grid-template-columns: minmax(0, 1fr); - } + .cmap-test-images:has(> .cmap-test-square) { + grid-template-columns: minmax(0, 1fr); + } - .cmap-test-images:has(> .cmap-test-square) .cmap-test-stack { - height: auto; - } + .cmap-test-images:has(> .cmap-test-square) .cmap-test-stack { + height: auto; + } - .cmap-test-images:has(> .cmap-test-square) .cmap-sineramp { - flex: none; - height: 96px; - } + .cmap-test-images:has(> .cmap-test-square) .cmap-sineramp { + flex: none; + height: 96px; + } - .cmap-test-square { - justify-self: center; - width: min(100%, 280px); - } + .cmap-test-square { + justify-self: center; + width: min(100%, 280px); + } } .md-typeset .cmap-cvd { - display: flex; - flex-wrap: wrap; - gap: 0.3rem; - padding: 0; - margin: 0.8rem 0; - font-size: 0.65rem; - border: 0; + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + padding: 0; + margin: 0.8rem 0; + font-size: 0.65rem; + border: 0; } .cmap-cvd input { - position: absolute; - opacity: 0; - pointer-events: none; + position: absolute; + opacity: 0; + pointer-events: none; } .cmap-cvd label:has(input:focus-visible) { - outline: 2px solid var(--md-accent-fg-color); + outline: 2px solid var(--md-accent-fg-color); } .cmap-chart-wrap { - position: relative; - height: 350px; + position: relative; + height: 350px; } .md-typeset .cmap-chart-caption { - font-size: 0.65rem; - font-style: italic; - color: var(--md-default-fg-color--light); - text-align: center; + font-size: 0.65rem; + font-style: italic; + color: var(--md-default-fg-color--light); + text-align: center; } /* (the links are wrapped in a

by markdown) */ .md-typeset .cmap-pager > p { - display: flex; - justify-content: space-between; + display: flex; + justify-content: space-between; } .md-typeset .cmap-pager a:only-child:last-child { - margin-left: auto; + margin-left: auto; } /* ---------------------------------------------------------------------------- @@ -356,46 +358,46 @@ div.doc-contents:not(.first) { ------------------------------------------------------------------------- */ .cmap-compare-form { - display: flex; - gap: 0.5rem; - align-items: center; + display: flex; + gap: 0.5rem; + align-items: center; } .cmap-compare-input { - flex: 1; - font-size: 0.8rem; + flex: 1; + font-size: 0.8rem; } .md-typeset .cmap-compare-form .md-button { - padding: 0.35rem 1rem; - margin: 0; + padding: 0.35rem 1rem; + margin: 0; } .cmap-compare-row { - margin-bottom: 1rem; + margin-bottom: 1rem; } .cmap-compare-head { - display: flex; - justify-content: space-between; - align-items: center; - font-weight: 700; + display: flex; + justify-content: space-between; + align-items: center; + font-weight: 700; } .cmap-compare-remove { - color: var(--md-default-fg-color--light); - cursor: pointer; + color: var(--md-default-fg-color--light); + cursor: pointer; } .cmap-compare-remove:hover { - color: var(--md-accent-fg-color); + color: var(--md-accent-fg-color); } .cmap-compare-bar { - display: block; - width: 100%; - height: 32px; - margin-bottom: 2px; + display: block; + width: 100%; + height: 32px; + margin-bottom: 2px; } /* ---------------------------------------------------------------------------- @@ -403,16 +405,16 @@ div.doc-contents:not(.first) { ------------------------------------------------------------------------- */ .colorlist { - display: flex; - flex-wrap: wrap; - justify-content: center; - font-size: 0.7rem; + display: flex; + flex-wrap: wrap; + justify-content: center; + font-size: 0.7rem; } .colorbox { - width: 7rem; - height: 3.8rem; - padding-top: 0.22rem; - margin: 0.2rem; - text-align: center; + width: 7rem; + height: 3.8rem; + padding-top: 0.22rem; + margin: 0.2rem; + text-align: center; } From cc773c4f1764dda17ec8ef06c050884851f7cdf1 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 18 Oct 2026 12:19:35 +0200 Subject: [PATCH 16/32] drop separate license annotation for the favicon Co-Authored-By: Claude Opus 5.5 --- LICENSES/ISC.txt | 7 ------- REUSE.toml | 8 -------- 2 files changed, 15 deletions(-) delete mode 100644 LICENSES/ISC.txt diff --git a/LICENSES/ISC.txt b/LICENSES/ISC.txt deleted file mode 100644 index 205eb790a..000000000 --- a/LICENSES/ISC.txt +++ /dev/null @@ -1,7 +0,0 @@ -ISC License - -Copyright - -Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/REUSE.toml b/REUSE.toml index 3f72af611..70bb47e46 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -182,11 +182,3 @@ SPDX-License-Identifier = "CC0-1.0" # freely shared with an attribution request (not a formal license). The matlab # colormaps are trivial mathematical formulas independently re-implemented across # many projects. Both fall through to the project default for now. - -# --- Docs assets --- - -[[annotations]] -path = "docs/images/favicon.svg" -precedence = "override" -SPDX-FileCopyrightText = "2026 Lucide Icons and Contributors" -SPDX-License-Identifier = "ISC" From 3bf369ae05fbc13700b5f3fe35af4ecfd3340045 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 18 Oct 2026 12:24:10 +0200 Subject: [PATCH 17/32] docs: label CVD buttons with the missing cone type (L/M/S) Co-Authored-By: Claude Opus 5.5 --- docs/_gen_catalog.py | 9 ++++++++- docs/compare.md | 6 +++--- docs/stylesheets/extra.css | 13 +++++++++++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/_gen_catalog.py b/docs/_gen_catalog.py index 8f220d4fd..3f82035c8 100644 --- a/docs/_gen_catalog.py +++ b/docs/_gen_catalog.py @@ -72,6 +72,12 @@ "deutan": "Deuteranopia", "tritan": "Tritanopia", } +# the cone type that each simulated deficiency is missing +CVD_CONES = { + "protan": ("L", "long-wavelength, red"), + "deutan": ("M", "medium-wavelength, green"), + "tritan": ("S", "short-wavelength, blue"), +} # (key in report, key in output json, decimals) REPORT_FIELDS = ( ("J", "J", 1), @@ -154,7 +160,7 @@ {% if square %} {% endif %}

-{% for key, label in cvd_labels.items() %} +{% for key, label in cvd_labels.items() %}{% set cone = cvd_cones.get(key) %}{% if cone %}{% endif %}{{ label }} {% endfor %}
@@ -339,6 +345,7 @@ def _link(other: str | None) -> dict | None: attrs=f' title="{GRAY_TITLE}"', ), cvd_labels=CVD_LABELS, + cvd_cones=CVD_CONES, square=SQUARE_TEST_IMAGES.get(info.category), data_url=f"../../_data/{quote(Path(page).stem)}.json", prev=_link(prev), diff --git a/docs/compare.md b/docs/compare.md index 8edea05ca..28d71ca03 100644 --- a/docs/compare.md +++ b/docs/compare.md @@ -21,9 +21,9 @@ The URL updates as you go, so you can share a comparison.
- - - + + +
diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index d3e4b4ad1..1546876c2 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -321,6 +321,19 @@ div.doc-contents:not(.first) { border: 0; } +/* L / M / S: the cone type that each deficiency is missing */ +.cmap-cvd-cone { + display: inline-block; + min-width: 1.1em; + margin-right: 0.35em; + font-family: var(--md-code-font-family); + font-weight: 700; + line-height: 1.1em; + text-align: center; + border: 1px solid currentColor; + border-radius: 0.2em; +} + .cmap-cvd input { position: absolute; opacity: 0; From e9f48e717233f95bb62c77a7715c66743ed423e0 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 18 Oct 2026 12:25:49 +0200 Subject: [PATCH 18/32] docs: put colormap names on a dark pill so they're legible on any colormap Co-Authored-By: Claude Opus 5.5 --- docs/stylesheets/extra.css | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 1546876c2..eaefb2d04 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -94,18 +94,20 @@ div.doc-contents:not(.first) { height: 20px; } +/* a dark pill keeps the name legible on any colormap */ .md-typeset .cmap-name { position: absolute; - inset: 0 auto 0 0.8rem; + top: 50%; + left: 0.4rem; z-index: 1; - display: flex; - align-items: center; + padding: 0.2em 0.45em; font-size: 0.75rem; font-weight: 700; line-height: 1; color: white; - text-shadow: #000 0 1px 2px; - mix-blend-mode: difference; + background: rgb(0 0 0 / 0.55); + border-radius: 0.25em; + transform: translateY(-50%); pointer-events: none; } From ef82332b56a17bc8c2d66bc07a022ee601e99157 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 18 Oct 2026 14:55:19 +0200 Subject: [PATCH 19/32] docs: lighter colormap name pills Co-Authored-By: Claude Opus 5.5 --- docs/stylesheets/extra.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index eaefb2d04..2f64055b1 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -102,10 +102,10 @@ div.doc-contents:not(.first) { z-index: 1; padding: 0.2em 0.45em; font-size: 0.75rem; - font-weight: 700; + font-weight: 500; line-height: 1; color: white; - background: rgb(0 0 0 / 0.55); + background: rgb(0 0 0 / 0.65); border-radius: 0.25em; transform: translateY(-50%); pointer-events: none; From ad978269d749805aec6b067a45dd18c07ae579ba Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 18 Oct 2026 14:58:49 +0200 Subject: [PATCH 20/32] docs: smaller name pills so more of each colorbar shows Co-Authored-By: Claude Opus 5.5 --- docs/stylesheets/extra.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 2f64055b1..01a7f3c09 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -98,10 +98,10 @@ div.doc-contents:not(.first) { .md-typeset .cmap-name { position: absolute; top: 50%; - left: 0.4rem; + left: 0.6rem; z-index: 1; - padding: 0.2em 0.45em; - font-size: 0.75rem; + padding: 0.15em 0.4em; + font-size: 0.65rem; font-weight: 500; line-height: 1; color: white; From 162d01dacfb290ff45c0217ff7ef1a821badba1c Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 18 Oct 2026 15:22:59 +0200 Subject: [PATCH 21/32] docs: compare chart dash patterns and distinct-color mode, nicer tooltips - compare: dash pattern per line, gradient lines, optional petroff6 colors, explicit 6-map limit - category intros, 'All collections', non-sticky filter on small screens - named colors: aliases share a box, contrast-based text, no clipping - API page in __all__ order (most important first) - higher-contrast pills; tidier colormaps page examples; subtler external-link arrow; solid tooltip swatches Co-Authored-By: Claude Opus 5.5 --- docs/_gen_catalog.py | 25 ++++++++- docs/_macros.py | 40 +++++++++++---- docs/colormaps.md | 52 ++++++++++--------- docs/compare.md | 9 +++- docs/javascripts/cmap.js | 101 ++++++++++++++++++++++++++++++------- docs/stylesheets/extra.css | 88 ++++++++++++++++++++------------ src/cmap/__init__.py | 21 ++++---- zensical.toml | 3 ++ 8 files changed, 240 insertions(+), 99 deletions(-) diff --git a/docs/_gen_catalog.py b/docs/_gen_catalog.py index 3f82035c8..ca17ff586 100644 --- a/docs/_gen_catalog.py +++ b/docs/_gen_catalog.py @@ -159,7 +159,7 @@
{% if square %} {% endif %}
-
+
{% for key, label in cvd_labels.items() %}{% set cone = cvd_cones.get(key) %}{% if cone %}{% endif %}{{ label }} {% endfor %}
@@ -204,9 +204,27 @@ # {title} colormaps +{intro} + {{{{ cmap_catalog("{category}") }}}} """ +CATEGORY_INTROS = { + "sequential": "Sequential colormaps run from low to high, usually with steadily " + "changing lightness. Use them for ordered data, like intensity, density or " + "temperature.", + "diverging": "Diverging colormaps have two contrasting hues that meet at a " + "neutral midpoint. Use them for data that deviates in both directions from a " + "meaningful center, like differences or anomalies around zero.", + "cyclic": "Cyclic colormaps start and end on the same color. Use them for data " + "that wraps around, like phase, angle, direction or time of day.", + "qualitative": "Qualitative colormaps are sets of distinct colors with no " + "implied order. Use them to label categories.", + "miscellaneous": "Colormaps that don't fit the other categories, including " + "many classic and specialty maps. Check a colormap's lightness plot before " + "using it for quantitative data.", +} + REDIRECT = """ @@ -369,7 +387,10 @@ def build() -> None: for category, cat_items in by_category.items(): title = category.title() write( - f"{category}/index.md", CATEGORY_PAGE.format(title=title, category=category) + f"{category}/index.md", + CATEGORY_PAGE.format( + title=title, category=category, intro=CATEGORY_INTROS[category] + ), ) names = [n for n, _ in cat_items] for i, (name, info) in enumerate(cat_items): diff --git a/docs/_macros.py b/docs/_macros.py index 46e324f05..f5d7d30e8 100644 --- a/docs/_macros.py +++ b/docs/_macros.py @@ -111,8 +111,8 @@ def catalog_grid(page: str, category: str | None = None) -> str: '
' '' - '' + '' '' @@ -129,20 +129,42 @@ def catalog_grid(page: str, category: str | None = None) -> str: COLORBOX = ( '
' - "{name}
{hex}
{rgb}
" + "{name}{hex}{rgb}{aliases}
" ) +def _luminance(r: int, g: int, b: int) -> float: + """Return the WCAG relative luminance of an 8-bit sRGB color.""" + lin = [ + (v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4) + for v in (r / 255, g / 255, b / 255) + ] + return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2] + + def color_list() -> str: - """Return HTML for the list of named colors.""" - boxes = [] + """Return HTML for the list of named colors (aliases share a box).""" + names: dict[Any, list[str]] = {} for name, c in NAME_TO_RGB.items(): - if (hsl := c.to_hsl()).l == 0.5: - text = "#000" if hsl.h <= 0.5 else "#FFF" + names.setdefault(c, []).append(name) + + boxes = [] + for c, (name, *aliases) in names.items(): + if not c.a: + text = "inherit" # transparent: use the page's text color else: - text = "#000" if hsl.l > 0.50 or not c.a else "#FFF" + # whichever of black or white text has the higher contrast + lum = _luminance(c.r, c.g, c.b) + text = "#000" if (lum + 0.05) / 0.05 > 1.05 / (lum + 0.05) else "#FFF" + also = f'also: {", ".join(aliases)}' boxes.append( - COLORBOX.format(name=name, hex=c.to_hex(), text=text, rgb=c.rgba_string()) + COLORBOX.format( + name=name, + hex=c.to_hex(), + text=text, + rgb=c.rgba_string(), + aliases=also if aliases else "", + ) ) return f'
{"".join(boxes)}
' diff --git a/docs/colormaps.md b/docs/colormaps.md index 4e80814d5..44b56f370 100644 --- a/docs/colormaps.md +++ b/docs/colormaps.md @@ -26,11 +26,11 @@ The following objects can be interpreted as a colormap, and used as the first ar to the [`cmap.Colormap`][] constructor; `cmap` refers to these objects collectively as ["`ColormapLike`"][cmap.ColormapLike]. Briefly, valid arguments are of type: -- [⬇️ `str`](#str) -- [⬇️ `Iterable[ColorLike | tuple[float, ColorLike]]`](#iterablecolorlike-tuple) *(see [`ColorLike`](colors.md#colorlike-objects))* -- [⬇️ `numpy.ndarray`](#numpyndarray) -- [⬇️ `dict`](#dict) -- [⬇️ `Callable[[ArrayLike], ArrayLike]`](#callable) +- [`str`](#str) +- [`Iterable[ColorLike | tuple[float, ColorLike]]`](#iterablecolorlike-tuple) *(see [`ColorLike`](colors.md#colorlike-objects))* +- [`numpy.ndarray`](#numpyndarray) +- [`dict`](#dict) +- [`Callable[[ArrayLike], ArrayLike]`](#callable) ### `str` @@ -39,7 +39,7 @@ to the [`cmap.Colormap`][] constructor; `cmap` refers to these objects collectiv - `Colormap('viridis')` {{ cmap_expr("'viridis'") }} - `Colormap('batlow')` {{ cmap_expr("'batlow'") }} - !!!note "Matplotlib names ✅" + !!!note "Matplotlib names" Any valid matplotlib colormap key that could be used in `matplotlib.colormaps[...]` is also a valid `cmap` colormap name. @@ -102,17 +102,16 @@ A [`numpy.ndarray`][], in one of the following formats: - an **`(N, 5)`** array of color stops, where the first column is the position of the color stop and the remaining 4 columns are the RGBA colors: + ```python - Colormap( - np.array( - [ - [0.0, 0.0, 0.0, 1.0, 1.0], - [0.8, 1.0, 1.0, 0.0, 1.0], - [1.0, 1.0, 0.0, 0.0, 1.0], - ] - ) - ) + Colormap(np.array([ + # pos, r, g, b, a + [0.0, 0.0, 0.0, 1.0, 1.0], + [0.8, 1.0, 1.0, 0.0, 1.0], + [1.0, 1.0, 0.0, 0.0, 1.0], + ])) ``` + {{ cmap_expr("np.array([[0, 0, 0, 1., 1.], [0.8, 1., 1., 0, 1.], [1., 1., 0, 0, 1.]])") }} @@ -134,16 +133,19 @@ A [`numpy.ndarray`][], in one of the following formats: ```python - cdict = {'red': [[0.0, 0.0, 0.0], - [0.5, 1.0, 1.0], - [1.0, 1.0, 1.0]], - 'green': [[0.0, 0.0, 0.0], - [0.25, 0.0, 0.0], - [0.75, 1.0, 1.0], - [1.0, 1.0, 1.0]], - 'blue': [[0.0, 0.0, 0.0], - [0.5, 0.0, 0.0], - [1.0, 1.0, 1.0]]} + cdict = { + # x y0 y1 + "red": [[0.0, 0.0, 0.0], + [0.5, 1.0, 1.0], + [1.0, 1.0, 1.0]], + "green": [[0.0, 0.0, 0.0], + [0.25, 0.0, 0.0], + [0.75, 1.0, 1.0], + [1.0, 1.0, 1.0]], + "blue": [[0.0, 0.0, 0.0], + [0.5, 0.0, 0.0], + [1.0, 1.0, 1.0]], + } Colormap(cdict) ``` diff --git a/docs/compare.md b/docs/compare.md index 28d71ca03..afeb075ad 100644 --- a/docs/compare.md +++ b/docs/compare.md @@ -5,7 +5,8 @@ description: Compare colormaps side by side, including perceptual lightness and # Compare colormaps -Add colormaps from the [catalog](catalog/index.md) to compare them side by side. +Add up to six colormaps from the [catalog](catalog/index.md) to compare them +side by side. Each colormap is shown as a colorbar and applied to a [sine ramp](https://www.peterkovesi.com/matlabfns/) test image: in a good colormap, the sine wave at the top should be equally visible across the whole range. The @@ -19,12 +20,16 @@ The URL updates as you go, so you can share a comparison. -
+
+
+ + +
diff --git a/docs/javascripts/cmap.js b/docs/javascripts/cmap.js index 3db357033..bd9907ab8 100644 --- a/docs/javascripts/cmap.js +++ b/docs/javascripts/cmap.js @@ -164,13 +164,6 @@ function paintBar(canvas, colors, width = 826) { paint(canvas, colors, linspace(width), 1, width); } -// A small colorbar canvas, e.g. for chart legends. -function swatch(colors, width = 32, height = 10) { - const canvas = document.createElement("canvas"); - paint(canvas, colors, Array.from({ length: height }, () => linspace(width)).flat(), height, width); - return canvas; -} - // --------------------------------------------------------------------------- // Catalog grid filtering // --------------------------------------------------------------------------- @@ -278,7 +271,24 @@ function baseOptions(yScales) { maintainAspectRatio: false, animation: false, interaction: { mode: "index", intersect: false }, - plugins: { legend: { display: false } }, + plugins: { + legend: { display: false }, + tooltip: { + boxPadding: 4, + callbacks: { + title: (items) => `x = ${items[0].parsed.x.toFixed(3)}`, + label: ({ dataset, parsed }) => + `${dataset.label}: ${parsed.y == null ? "–" : Number(parsed.y.toPrecision(3))}`, + // a solid swatch of the line's color (or, for lines colored by the colormap, + // of the color at that point) + labelColor: ({ dataset, dataIndex }) => { + let color = dataset.tooltipColors?.[dataIndex] ?? dataset.borderColor; + if (typeof color !== "string") color = themeColors().fg; + return { borderColor: color, backgroundColor: color, borderWidth: 0, borderRadius: 2 }; + }, + }, + }, + }, scales, }; } @@ -312,6 +322,7 @@ function drawChart(canvas, d) { label: "J′", data: points(x, d.J), backgroundColor: colors, + tooltipColors: colors, pointRadius: 10, pointHoverRadius: 12, borderWidth: 0, @@ -397,6 +408,39 @@ function drawChart(canvas, d) { // --------------------------------------------------------------------------- const COMPARE_MAX = 6; +// one dash pattern per line, so lines can be told apart even when the colormaps are +// similar. (with round caps, each dash grows by the line width and each gap shrinks) +const COMPARE_DASHES = [[], [12, 10], [0.1, 9], [18, 9, 0.1, 9], [6, 9], [26, 10]]; +const COMPARE_LINE_WIDTH = 5; +// petroff:petroff6 from the catalog: six colors chosen to stay distinguishable, +// including with color vision deficiencies (https://arxiv.org/abs/2107.02270) +const COMPARE_COLORS = ["5790FC", "F89C20", "E42536", "964A8B", "9C9CA1", "7A21DD"]; + +// A horizontal gradient through `colors`, spanning `x0` to `x1` on `ctx`. +function lutGradient(ctx, colors, x0, x1) { + const gradient = ctx.createLinearGradient(x0, 0, x1, 0); + const n = Math.max(colors.length - 1, 1); + colors.forEach((c, i) => gradient.addColorStop(i / n, `#${c}`)); + return gradient; +} + +// A small dashed line in the colormap's colors, for the chart legend. +function lineSwatch(colors, dash, width = 40, height = 10) { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + const lineWidth = 4; + ctx.lineWidth = lineWidth; + ctx.lineCap = "round"; + ctx.setLineDash(dash.map((d) => d * (lineWidth / COMPARE_LINE_WIDTH))); + ctx.strokeStyle = lutGradient(ctx, colors, 0, width); + ctx.beginPath(); + ctx.moveTo(lineWidth / 2, height / 2); + ctx.lineTo(width - lineWidth / 2, height / 2); + ctx.stroke(); + return canvas; +} async function initCompare(root) { if (root.dataset.ready) return; @@ -428,6 +472,12 @@ async function initCompare(root) { .filter(Boolean); const cvd = () => cvdGroup.querySelector("input:checked")?.value ?? "normal"; + const lineColorsGroup = root.querySelector(".cmap-line-colors"); + // the colors of the i-th line: its colormap's, or one distinct color + const lineColors = (data, i) => + lineColorsGroup.querySelector("input:checked")?.value === "distinct" + ? [COMPARE_COLORS[i % COMPARE_COLORS.length]] + : (data[cvd()] ?? data.normal).color; function syncUrl() { const url = new URL(location.href); @@ -478,14 +528,14 @@ async function initCompare(root) { const options = baseOptions({ y: { min: 0, max: 100, title: { text: "Lightness J′", display: true } }, }); - // legend swatches are little colorbars of each colormap - const swatches = allData.map((data) => swatch((data[cvd()] ?? data.normal).color)); + // legend swatches are little dashed lines in each colormap's colors + const swatches = allData.map((data, i) => lineSwatch(lineColors(data, i), COMPARE_DASHES[i])); options.plugins.legend = { display: true, labels: { color: themeColors().fg, usePointStyle: true, - pointStyleWidth: 32, + pointStyleWidth: 40, boxHeight: 10, padding: 16, generateLabels: (c) => @@ -500,18 +550,25 @@ async function initCompare(root) { datasets: selected.map((e, i) => { const d = allData[i][cvd()] ?? allData[i].normal; const x = linspace(d.color.length); - const colors = d.color.map((c) => `#${c}`); + const colors = lineColors(allData[i], i); return { label: e.name, data: points(x, d.J), showLine: true, pointRadius: 0, - borderWidth: 5, - // round caps hide the seams between the separately-colored segments + borderWidth: COMPARE_LINE_WIDTH, borderCapStyle: "round", - borderColor: colors[Math.floor(colors.length / 2)], - // color each segment of the line by the colormap itself - segment: { borderColor: (ctx) => colors[ctx.p0DataIndex] }, + borderDash: COMPARE_DASHES[i], + tooltipColors: x.map((v) => `#${colors[Math.round(v * (colors.length - 1))]}`), + // color the line by the colormap itself (one gradient for the whole line, + // rather than per-segment colors, so that the dash pattern is continuous) + borderColor: ({ chart }) => { + const x0 = chart.scales.x?.getPixelForValue(0); + const x1 = chart.scales.x?.getPixelForValue(1); + // (before the first layout, there are no pixel positions yet) + if (!Number.isFinite(x0) || !Number.isFinite(x1)) return `#${colors[0]}`; + return lutGradient(chart.ctx, colors, x0, x1); + }, }; }), }, @@ -521,13 +578,21 @@ async function initCompare(root) { chart.addEventListener("cmap:rerender", render); cvdGroup.addEventListener("change", render); + lineColorsGroup.addEventListener("change", render); + // (a custom validity message blocks submitting the form until it's cleared) + input.addEventListener("input", () => input.setCustomValidity("")); root.querySelector(".cmap-compare-form").addEventListener("submit", (ev) => { ev.preventDefault(); const e = resolve(input.value); input.setCustomValidity(e ? "" : "Unknown colormap"); input.reportValidity(); if (!e) return; - if (!selected.includes(e)) selected = [...selected, e].slice(-COMPARE_MAX); + if (!selected.includes(e) && selected.length >= COMPARE_MAX) { + input.setCustomValidity(`Up to ${COMPARE_MAX} colormaps: remove one first`); + input.reportValidity(); + return; + } + if (!selected.includes(e)) selected = [...selected, e]; input.value = ""; render(); }); diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 01a7f3c09..5db7b2b66 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -34,23 +34,18 @@ html body div.ethical-footer { API docs (mkdocstrings) ------------------------------------------------------------------------- */ -/* Mark external links as such. */ +/* Mark external links as such (a small arrow, in the link's color). */ a.autorefs-external::after { /* https://primer.style/octicons/arrow-up-right-24 */ - background-image: url('data:image/svg+xml,'); - content: " "; + mask: url('data:image/svg+xml,') + center / contain no-repeat; + content: ""; display: inline-block; - position: relative; - top: 0.1em; - margin-left: 0.2em; - margin-right: 0.1em; - height: 1em; - width: 1em; - border-radius: 100%; - background-color: var(--md-typeset-a-color); -} -a.autorefs-external:hover::after { - background-color: var(--md-accent-fg-color); + width: 0.75em; + height: 0.75em; + margin-left: 0.15em; + vertical-align: 0.05em; + background-color: currentColor; } div.doc-contents:not(.first) { @@ -83,15 +78,16 @@ div.doc-contents:not(.first) { filter: grayscale(100%); } +/* example bars under their code, aligned with it */ .md-typeset .cmap-expr { - display: inline-block; - vertical-align: middle; - width: min(100%, 20rem); - margin: 0.2rem 0; + display: block; + width: min(100%, 24rem); + margin: 0.35rem 0 0.6rem; } .md-typeset .cmap-expr img { height: 20px; + border-radius: 0.15rem; } /* a dark pill keeps the name legible on any colormap */ @@ -147,6 +143,13 @@ div.doc-contents:not(.first) { font-size: 0.7rem; } +/* on small screens, a sticky filter bar would cover too much of the page */ +@media (max-width: 600px) { + .cmap-filter { + position: static; + } +} + .cmap-filter input[type="search"], .cmap-filter select, .cmap-compare-input { @@ -204,19 +207,20 @@ div.doc-contents:not(.first) { } .cmap-filter-cats button, -.cmap-cvd label { +.cmap-pills label { padding: 0.25rem 0.7rem; font: inherit; - color: var(--md-default-fg-color--light); + color: var(--md-default-fg-color); cursor: pointer; background: var(--md-default-fg-color--lightest); border-radius: 1rem; } .cmap-filter-cats button[aria-pressed="true"], -.cmap-cvd label:has(input:checked) { +.cmap-pills label:has(input:checked) { color: var(--md-accent-bg-color); - background: var(--md-accent-fg-color); + /* (darkened a little, for white text) */ + background: color-mix(in srgb, var(--md-accent-fg-color) 85%, black); } .cmap-filter-count { @@ -313,7 +317,7 @@ div.doc-contents:not(.first) { } } -.md-typeset .cmap-cvd { +.md-typeset .cmap-pills { display: flex; flex-wrap: wrap; gap: 0.3rem; @@ -336,13 +340,13 @@ div.doc-contents:not(.first) { border-radius: 0.2em; } -.cmap-cvd input { +.cmap-pills input { position: absolute; opacity: 0; pointer-events: none; } -.cmap-cvd label:has(input:focus-visible) { +.cmap-pills label:has(input:focus-visible) { outline: 2px solid var(--md-accent-fg-color); } @@ -420,16 +424,34 @@ div.doc-contents:not(.first) { ------------------------------------------------------------------------- */ .colorlist { - display: flex; - flex-wrap: wrap; - justify-content: center; - font-size: 0.7rem; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(7.5rem, 1fr)); + gap: 0.4rem; + font-size: 0.65rem; } .colorbox { - width: 7rem; - height: 3.8rem; - padding-top: 0.22rem; - margin: 0.2rem; + display: flex; + flex-direction: column; + gap: 0.1rem; + justify-content: center; + min-height: 4.4rem; + padding: 0.4rem; + line-height: 1.3; text-align: center; + border-radius: 0.3rem; + /* outline, so that white / transparent boxes are visible */ + box-shadow: inset 0 0 0 1px rgb(127 127 127 / 0.25); +} + +.md-typeset .colorbox code { + padding: 0; + font-size: inherit; + color: inherit; + background: none; +} + +.colorbox-aliases { + font-style: italic; + opacity: 0.8; } diff --git a/src/cmap/__init__.py b/src/cmap/__init__.py index 359f24aff..bda54a72d 100644 --- a/src/cmap/__init__.py +++ b/src/cmap/__init__.py @@ -64,18 +64,19 @@ def resolve(self, name: str) -> str: else: from ._catalog import Catalog, CatalogItem -__all__ = [ - "HSLA", - "HSVA", - "RGBA", - "RGBA8", - "Catalog", - "CatalogItem", +# (in order of importance: this sets the order of the API docs) +__all__ = [ # noqa: RUF022 + "Colormap", "Color", + "ColormapLike", "ColorLike", - "ColorStop", "ColorStops", - "Colormap", - "ColormapLike", + "ColorStop", + "Catalog", + "CatalogItem", "to_mpl", + "RGBA", + "RGBA8", + "HSLA", + "HSVA", ] diff --git a/zensical.toml b/zensical.toml index 8c50f3f06..caf665d01 100644 --- a/zensical.toml +++ b/zensical.toml @@ -142,6 +142,9 @@ show_bases = false show_source = false docstring_section_style = "list" merge_init_into_class = true +# module members in `__all__` order (most important first), class members alphabetical +members_order = ["__all__", "alphabetical"] +group_by_category = false filters = ['(__call__|^[^_])'] heading_level = 1 parameter_headings = false From 2de921ab972aad21aab4dfc9d2803025beb10f8c Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 19 Oct 2026 10:12:18 +0200 Subject: [PATCH 22/32] docs: clean chart tooltip swatches and labels Co-Authored-By: Claude Opus 5.5 --- docs/javascripts/cmap.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/javascripts/cmap.js b/docs/javascripts/cmap.js index bd9907ab8..4cbf300f7 100644 --- a/docs/javascripts/cmap.js +++ b/docs/javascripts/cmap.js @@ -275,6 +275,8 @@ function baseOptions(yScales) { legend: { display: false }, tooltip: { boxPadding: 4, + // (point-style swatches: the default boxes have a white backing and outline) + usePointStyle: true, callbacks: { title: (items) => `x = ${items[0].parsed.x.toFixed(3)}`, label: ({ dataset, parsed }) => @@ -284,8 +286,9 @@ function baseOptions(yScales) { labelColor: ({ dataset, dataIndex }) => { let color = dataset.tooltipColors?.[dataIndex] ?? dataset.borderColor; if (typeof color !== "string") color = themeColors().fg; - return { borderColor: color, backgroundColor: color, borderWidth: 0, borderRadius: 2 }; + return { borderColor: color, backgroundColor: color, borderWidth: 0 }; }, + labelPointStyle: () => ({ pointStyle: "rectRounded", rotation: 0 }), }, }, }, @@ -333,6 +336,7 @@ function drawChart(canvas, d) { showLine: true, pointRadius: 0, borderColor: "#88888866", + tooltipColors: Array(x.length).fill("#888888"), borderWidth: 3, yAxisID: "y2", }, From d27d3eaadcdf697a0b80497fa0f48fece29a85e6 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 19 Oct 2026 10:16:00 +0200 Subject: [PATCH 23/32] docs: don't pin pill tooltips open after a click Zensical's tooltips also open on focus, and clicking a label focuses its input, so put the titles on the labels' text instead. Co-Authored-By: Claude Opus 5.5 --- docs/_gen_catalog.py | 2 +- docs/_macros.py | 8 +++++--- docs/compare.md | 10 +++++----- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/_gen_catalog.py b/docs/_gen_catalog.py index ca17ff586..838baca18 100644 --- a/docs/_gen_catalog.py +++ b/docs/_gen_catalog.py @@ -160,7 +160,7 @@ {% if square %} {% endif %}
-{% for key, label in cvd_labels.items() %}{% set cone = cvd_cones.get(key) %}{% if cone %}{% endif %}{{ label }} +{% for key, label in cvd_labels.items() %}{% set cone = cvd_cones.get(key) %} {% endfor %}
diff --git a/docs/_macros.py b/docs/_macros.py index f5d7d30e8..6b8500d7c 100644 --- a/docs/_macros.py +++ b/docs/_macros.py @@ -113,9 +113,11 @@ def catalog_grid(page: str, category: str | None = None) -> str: ' aria-label="Filter colormaps by name">' '' - '' + # (the title is on the text, not the label: clicking a label focuses its + # input, and zensical's tooltips also open on focus, so they'd stay open) + '' f"{cat_buttons}" f'{len(items)} colormaps' "" diff --git a/docs/compare.md b/docs/compare.md index afeb075ad..0ca3e8ae0 100644 --- a/docs/compare.md +++ b/docs/compare.md @@ -22,14 +22,14 @@ The URL updates as you go, so you can share a comparison.
- - - + + +
- - + +
From 7f34874cf9bfcd3be6920cc4a7c3b7aa2ab9b879 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 19 Oct 2026 10:24:12 +0200 Subject: [PATCH 24/32] docs: update Kovesi links (moved to Codeberg); link the sine ramp paper Co-Authored-By: Claude Opus 5.5 --- docs/compare.md | 2 +- docs/javascripts/cmap.js | 2 +- src/cmap/_util.py | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/compare.md b/docs/compare.md index 0ca3e8ae0..54d8d6571 100644 --- a/docs/compare.md +++ b/docs/compare.md @@ -8,7 +8,7 @@ description: Compare colormaps side by side, including perceptual lightness and Add up to six colormaps from the [catalog](catalog/index.md) to compare them side by side. Each colormap is shown as a colorbar and applied to a -[sine ramp](https://www.peterkovesi.com/matlabfns/) test image: in a good colormap, +[sine ramp](https://arxiv.org/abs/1509.03700) test image: in a good colormap, the sine wave at the top should be equally visible across the whole range. The chart below shows the perceptual lightness of each colormap. diff --git a/docs/javascripts/cmap.js b/docs/javascripts/cmap.js index 4cbf300f7..f35d3d9b3 100644 --- a/docs/javascripts/cmap.js +++ b/docs/javascripts/cmap.js @@ -65,7 +65,7 @@ function sineramp([rows, cols], amp = 0.05, wavelen = 8, power = 2) { // Port of Peter Kovesi's circlesineramp: test image for cyclic colormaps. A sine // wave on a spiral ramp, values in [0, 1], NaN outside the annulus. -// https://www.peterkovesi.com/matlabfns/Misc/circlesineramp.m +// https://codeberg.org/peterkovesi/matlabfns/src/branch/main/Misc/circlesineramp.m function circlesineramp(size = 320, amp = Math.PI / 10, wavelen = 8, power = 2) { const maxr = (size / 2) * 0.9; const minr = 0.15 * size; diff --git a/src/cmap/_util.py b/src/cmap/_util.py index 0c15bebba..08b040de7 100644 --- a/src/cmap/_util.py +++ b/src/cmap/_util.py @@ -258,8 +258,8 @@ def sineramp( """Generate test image consisting of a sine wave superimposed on a ramp function. This function and all documentation is based on the MATLAB function of the same - name, written by Peter Kovesi and available from his website: - https://www.peterkovesi.com/matlabfns/ + name, written by Peter Kovesi and available at: + https://codeberg.org/peterkovesi/matlabfns/src/branch/main/Misc/sineramp.m Copyright (c) 1996-2005 Peter Kovesi School of Computer Science & Software Engineering @@ -348,8 +348,8 @@ def circlesineramp( """Generate test image for evaluating cyclic color maps. This function and all documentation is based on the MATLAB function of the same - name, written by Peter Kovesi and available from his website: - https://www.peterkovesi.com/matlabfns/ + name, written by Peter Kovesi and available at: + https://codeberg.org/peterkovesi/matlabfns/src/branch/main/Misc/circlesineramp.m Copyright (c) 1996-2005 Peter Kovesi School of Computer Science & Software Engineering From 81e52a2e1b3b0f18316db2fc0d0358fffa9284ea Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 19 Oct 2026 11:44:48 +0200 Subject: [PATCH 25/32] docs: discrete colormaps get per-color lightness and no sine ramp; fit derivative axis Co-Authored-By: Claude Opus 5.5 --- docs/_gen_catalog.py | 8 +++-- docs/javascripts/cmap.js | 72 +++++++++++++++++++++++++++++++++----- docs/stylesheets/extra.css | 9 +++++ 3 files changed, 79 insertions(+), 10 deletions(-) diff --git a/docs/_gen_catalog.py b/docs/_gen_catalog.py index 838baca18..dbc6e0a58 100644 --- a/docs/_gen_catalog.py +++ b/docs/_gen_catalog.py @@ -155,7 +155,7 @@
{{ bar }} {{ gray }} - +{% if not discrete %}{% endif %}
{% if square %} {% endif %} @@ -172,7 +172,7 @@ ## Perceptual lightness { data-search-exclude } -
+

Lightness (J′) measured in @@ -365,6 +365,9 @@ def _link(other: str | None) -> dict | None: cvd_labels=CVD_LABELS, cvd_cones=CVD_CONES, square=SQUARE_TEST_IMAGES.get(info.category), + # a fixed set of colors: the continuous diagnostics (sine ramp, lightness + # derivative) don't apply + discrete=cm.interpolation == "nearest", data_url=f"../../_data/{quote(Path(page).stem)}.json", prev=_link(prev), next=_link(next_), @@ -403,6 +406,7 @@ def build() -> None: "namespace": info.namespace, "aliases": natsort.natsorted(aliases.get(name.lower(), ())), "monotonic": _is_monotonic(data["normal"]["J"]), + "discrete": Colormap(name).interpolation == "nearest", "path": page.removesuffix(".md"), } ) diff --git a/docs/javascripts/cmap.js b/docs/javascripts/cmap.js index f35d3d9b3..260e1dea5 100644 --- a/docs/javascripts/cmap.js +++ b/docs/javascripts/cmap.js @@ -144,6 +144,30 @@ function paintTestImage(canvas) { paint(canvas, testImageColors.get(canvas), im, r, c); } +// The runs of identical colors in a (discrete) colormap's samples: each with its +// color, its J′ and the x at its middle. +function colorRuns(d) { + const runs = []; + d.color.forEach((c, i) => { + const last = runs.at(-1); + if (last && last.color === c) last.end = i; + else runs.push({ color: c, start: i, end: i }); + }); + const n = Math.max(d.color.length - 1, 1); + return runs.map((r) => ({ + color: `#${r.color}`, + x: (r.start + r.end) / 2 / n, + J: d.J[Math.floor((r.start + r.end) / 2)], + })); +} + +// The smallest 1, 2 or 5 (times a power of 10) that is >= `v`. +function niceCeil(v) { + if (!(v > 0)) return 1; + const p = 10 ** Math.floor(Math.log10(v)); + return [1, 2, 5, 10].map((m) => m * p).find((s) => s >= v); +} + // Paint values in [0, 1] (row-major, `rows` x `cols`) through the LUT `colors`. function paint(canvas, colors, values, rows, cols) { const lut = colors.map(hexToRgb); @@ -306,12 +330,38 @@ function drawChart(canvas, d) { const colors = d.color.map((c) => `#${c}`); const kind = canvas.dataset.chart; - if (kind === "lightness") { + if (kind === "lightness" && "discrete" in canvas.dataset) { + // a discrete colormap: one point per color, and no derivative + const runs = colorRuns(d); + new Chart(canvas, { + type: "scatter", + data: { + datasets: [ + { + label: "J′", + data: runs.map((r) => ({ x: r.x, y: r.J })), + backgroundColor: runs.map((r) => r.color), + tooltipColors: runs.map((r) => r.color), + pointRadius: 10, + pointHoverRadius: 12, + borderWidth: 0, + clip: false, + }, + ], + }, + options: baseOptions({ + y: { min: 0, max: 100, title: { text: "Lightness J′", display: true } }, + }), + }); + } else if (kind === "lightness") { + // symmetric derivative axis, fit to the data (ignoring the most extreme 2%) + const dJ = d.dJ.filter(Number.isFinite).map(Math.abs).sort((a, b) => a - b); + const dJmax = niceCeil(1.25 * (dJ[Math.floor(0.98 * (dJ.length - 1))] ?? 0)); const options = baseOptions({ y: { min: 0, max: 100, title: { text: "Lightness J′", display: true } }, y2: { - min: -500, - max: 500, + min: -dJmax, + max: dJmax, position: "right", grid: { drawOnChartArea: false }, title: { text: "Lightness derivative", display: true }, @@ -329,6 +379,8 @@ function drawChart(canvas, d) { pointRadius: 10, pointHoverRadius: 12, borderWidth: 0, + // (don't cut off the points near the top and bottom) + clip: false, }, { label: "dJ′", @@ -519,11 +571,15 @@ async function initCompare(root) { const bar = document.createElement("canvas"); bar.className = "cmap-compare-bar"; paintBar(bar, d.color); - const sine = document.createElement("canvas"); - sine.className = "cmap-sineramp"; - sine.dataset.testImage = "sineramp"; - row.append(head, bar, sine); - setTestImageColors(sine, d.color); + row.append(head, bar); + // (a sine ramp means nothing for a discrete colormap) + if (!e.discrete) { + const sine = document.createElement("canvas"); + sine.className = "cmap-sineramp"; + sine.dataset.testImage = "sineramp"; + row.append(sine); + setTestImageColors(sine, d.color); + } return row; }) ); diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 5db7b2b66..277d1112e 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -296,6 +296,15 @@ div.doc-contents:not(.first) { height: auto; } +/* discrete colormaps have no sine ramp: put the square test image below the bars */ +.cmap-test-images:has(> .cmap-test-square):not(:has(.cmap-sineramp)) { + grid-template-columns: minmax(0, 1fr); +} + +.cmap-test-images:has(> .cmap-test-square):not(:has(.cmap-sineramp)) .cmap-test-stack { + height: auto; +} + /* too narrow for side by side: square goes below, centered and larger */ @container (width < 560px) { .cmap-test-images:has(> .cmap-test-square) { From c7dbb9e13bab38ce218142b06f56f4e79112c90b Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 19 Oct 2026 11:46:01 +0200 Subject: [PATCH 26/32] fix: report() derivatives no longer start with a padded zero Use np.gradient (same length as x, one-sided at the ends) instead of np.diff padded with a leading 0, and differentiate with respect to x itself. Co-Authored-By: Claude Opus 5.5 --- src/cmap/_util.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/cmap/_util.py b/src/cmap/_util.py index 08b040de7..b4dba85b0 100644 --- a/src/cmap/_util.py +++ b/src/cmap/_util.py @@ -481,13 +481,9 @@ def report( RGB = np.clip(cast("np.ndarray", cspace_convert(RGB, initial_space, "sRGB1")), 0, 1) Jab = cast("np.ndarray", cspace_convert(RGB, "sRGB1", uniform_space)) - local_deltas = np.sqrt(np.sum((Jab[:-1, :] - Jab[1:, :]) ** 2, axis=-1)) - local_deltas = np.insert(local_deltas, 0, 0) # keep length the same - percep_derivs = n * local_deltas # export - - lightness_deltas = np.diff(Jab[:, 0]) - lightness_deltas = np.insert(lightness_deltas, 0, 0) # keep length same - lightness_derivs = n * lightness_deltas # export + # derivatives with respect to x (same length as x, one-sided at the ends) + percep_derivs = np.linalg.norm(np.gradient(Jab, x, axis=0), axis=-1) # export + lightness_derivs = np.gradient(Jab[:, 0], x) # export JchMs = cspace_convert(RGB, "sRGB1", "JChMs") color = [ From 077cf1298ab011ac700ef5e723679a71c879c50c Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 19 Oct 2026 11:49:16 +0200 Subject: [PATCH 27/32] docs: a page per collection, and catalog filters kept in the URL - catalog/collections/: an index of collections, and a page for each with its license, authors and source, and its colormaps - colormap pages link to their collection - catalog filters are read from and written to the URL (?q=&collection=&category=&monotonic=1) Co-Authored-By: Claude Opus 5.5 --- docs/_gen_catalog.py | 110 ++++++++++++++++++++++++++++++++++++--- docs/_macros.py | 23 +++++--- docs/javascripts/cmap.js | 30 +++++++++-- zensical.toml | 2 + 4 files changed, 147 insertions(+), 18 deletions(-) diff --git a/docs/_gen_catalog.py b/docs/_gen_catalog.py index dbc6e0a58..017c593d1 100644 --- a/docs/_gen_catalog.py +++ b/docs/_gen_catalog.py @@ -27,6 +27,7 @@ import numpy as np from cmap import Colormap +from cmap._catalog import DATA_ROOT from cmap._util import report_cvds sys.path.insert(0, str(Path(__file__).parent)) @@ -110,9 +111,9 @@ {% endif %} {{ info }} -| category | license | authors | source | -| --- | --- | --- | --- | -| [{{ category | title }}](index.md) | {{ license }} | {{ authors }} | {{ source }} | +| collection | category | license | authors | source | +| --- | --- | --- | --- | --- | +| [{{ namespace }}](../collections/{{ namespace }}/index.md) | [{{ category | title }}](index.md) | {{ license }} | {{ authors }} | {{ source }} |

@@ -225,6 +226,42 @@ "using it for quantitative data.", } +COLLECTION_PAGE = """\ +--- +title: {title} +description: {description} +render_macros: true +# rendered by overrides/partials/path.html (these pages aren't in the nav) +breadcrumbs: + - {{title: Overview, url: ""}} + - {{title: Colormap Catalog, url: catalog/}} + - {{title: Collections, url: catalog/collections/}} +--- + +# {heading} + +{about} + +{{{{ cmap_catalog(namespace="{namespace}") }}}} +""" + +COLLECTIONS_INDEX = """\ +--- +title: Collections +description: The collections of colormaps in the cmap catalog. +--- + +# Collections + +Every colormap in the catalog belongs to a collection: the part of its name before +the colon, like `bids` in `bids:viridis`. Each collection has its own source and +license. + +| collection | colormaps | license | authors | +| --- | --- | --- | --- | +{rows} +""" + REDIRECT = """ @@ -272,6 +309,12 @@ def _sub(m: re.Match) -> str: return CATALOG_LINK.sub(_sub, info) +def _license_link(license_: str | None) -> str: + if license_ in LICENSE_URL: + return f"[{license_}]({LICENSE_URL[license_]})" + return license_ or "" + + def _md_link(url: str) -> str: return f"[{url}]({url})" if url.startswith("http") else f"`{url}`" @@ -309,7 +352,7 @@ def __call__(self, rel: str, content: str) -> None: path.write_text(content, encoding="utf-8") def prune(self) -> None: - for d in (*CATEGORIES, "_data"): + for d in (*CATEGORIES, "collections", "_data"): for path in sorted((OUT / d).rglob("*"), reverse=True): if path.is_file() and path not in self.written: path.unlink() @@ -340,16 +383,14 @@ def _link(other: str | None) -> dict | None: return None return {"name": other, "href": relative(page, cmap_page(Colormap(other)))} - license_ = info.license - if license_ in LICENSE_URL: - license_ = f"[{license_}]({LICENSE_URL[license_]})" return PAGE.render( name=name, + namespace=info.namespace, description=f"{name}: a {info.category} colormap from {info.namespace}.", aliases=", ".join(f"`{a}`" for a in info.aliases if a != info.name), info=_fix_info_links(info.info, page), category=info.category, - license=license_, + license=_license_link(info.license), authors=", ".join(info.authors), source=_md_link(info.source), css=cm.to_css(max_stops=32, as_hex=True).strip(), @@ -374,6 +415,58 @@ def _link(other: str | None) -> dict | None: ) +def _collections() -> dict[str, list[dict[str, Any]]]: + """Return the catalog's records (collection-level metadata), by namespace.""" + records: dict[str, list[dict[str, Any]]] = {} + for path in sorted(DATA_ROOT.rglob("record.json")): + record = json.loads(path.read_text(encoding="utf-8")) + records.setdefault(record["namespace"], []).append(record) + return records + + +def _authors(record: dict[str, Any]) -> str: + return ", ".join(a for a in record.get("authors") or () if a) + + +def _write_collections(write: _Writer, index: list[dict[str, Any]]) -> None: + """Write a page for each collection, and an index of collections.""" + counts: dict[str, int] = {} + for entry in index: + counts[entry["namespace"]] = counts.get(entry["namespace"], 0) + 1 + + records = _collections() + rows = [] + for ns in natsort.natsorted(counts, alg=natsort.ns.IGNORECASE): + recs = records.get(ns, []) + infos = dict.fromkeys(r["info"] for r in recs if r.get("info")) + table = [ + f"| {_license_link(r.get('license'))} | {_authors(r)} " + f"| {_md_link(r['source']) if r.get('source') else ''} |" + for r in recs + ] + about = "\n\n".join(infos) + if table: + about += "\n\n| license | authors | source |\n| --- | --- | --- |\n" + about += "\n".join(dict.fromkeys(table)) + n = counts[ns] + write( + f"collections/{ns}/index.md", + COLLECTION_PAGE.format( + title=json.dumps(f"{ns} colormaps"), + description=json.dumps(f"The {n} colormaps in the {ns} collection."), + heading=ns, + about=about.strip(), + namespace=ns, + ), + ) + licenses = ", ".join( + dict.fromkeys(_license_link(r.get("license")) for r in recs) + ) + authors = ", ".join(dict.fromkeys(a for r in recs if (a := _authors(r)))) + rows.append(f"| [{ns}]({ns}/index.md) | {n} | {licenses} | {authors} |") + write("collections/index.md", COLLECTIONS_INDEX.format(rows="\n".join(rows))) + + def build() -> None: write = _Writer() by_category: dict[str, list[tuple[str, CatalogItem]]] = {} @@ -418,6 +511,7 @@ def build() -> None: write(page, _render_page(name, info, prev, next_)) write("_data/index.json", json.dumps(index, separators=(",", ":"))) + _write_collections(write, index) _write_redirects(write) write.prune() print(f"Generated {len(index)} colormap pages in {OUT}") diff --git a/docs/_macros.py b/docs/_macros.py index 6b8500d7c..31ced8bd3 100644 --- a/docs/_macros.py +++ b/docs/_macros.py @@ -70,7 +70,9 @@ def cmap_bar( return f'{label}{img}' -def catalog_grid(page: str, category: str | None = None) -> str: +def catalog_grid( + page: str, category: str | None = None, namespace: str | None = None +) -> str: """Return HTML for the filterable grid of catalog colormaps.""" if not CATALOG_INDEX.exists(): raise FileNotFoundError( @@ -83,6 +85,8 @@ def catalog_grid(page: str, category: str | None = None) -> str: ) if category: entries = [e for e in entries if e["category"] == category] + if namespace: + entries = [e for e in entries if e["namespace"] == namespace] items = [] for e in entries: @@ -98,8 +102,8 @@ def catalog_grid(page: str, category: str | None = None) -> str: namespaces = natsort.natsorted({e["namespace"] for e in entries}) ns_options = "".join(f'' for n in namespaces) cat_buttons = "" - if not category: - cats = [c for c in CATEGORIES if any(e["category"] == c for e in entries)] + cats = [c for c in CATEGORIES if any(e["category"] == c for e in entries)] + if not category and len(cats) > 1: cat_buttons = '
' + "".join( f'' @@ -107,12 +111,17 @@ def catalog_grid(page: str, category: str | None = None) -> str: ) cat_buttons += "
" + ns_select = "" + if not namespace: + ns_select = ( + '' + ) controls = ( '
' '' - '' + f"{ns_select}" # (the title is on the text, not the label: clicking a label focuses its # input, and zensical's tooltips also open on focus, so they'd stay open) '
diff --git a/docs/javascripts/cmap.js b/docs/javascripts/cmap.js index e128b274b..dd9ff604a 100644 --- a/docs/javascripts/cmap.js +++ b/docs/javascripts/cmap.js @@ -27,15 +27,31 @@ function fetchData(url) { if (!dataCache.has(url)) { dataCache.set( url, - fetch(url).then((r) => { - if (!r.ok) throw new Error(`${r.status} fetching ${url}`); - return r.json(); - }) + fetch(url) + .then((r) => { + if (!r.ok) throw new Error(`${r.status} fetching ${url}`); + return r.json(); + }) + .catch((err) => { + dataCache.delete(url); // (so that it's retried next time) + throw err; + }) ); } return dataCache.get(url); } +// Replace the contents of `el` with an error message. +function showError(el, message) { + el.replaceChildren(Object.assign(document.createElement("p"), { className: "cmap-error", textContent: message })); +} + +// A colormap's data (one CVD type), for the reversed colormap. +function reverseData(d) { + const reversed = (k, v) => (k === "dJ" ? v.map((x) => (x == null ? x : -x)) : [...v]).reverse(); + return Object.fromEntries(Object.entries(d).map(([k, v]) => [k, reversed(k, v)])); +} + const hexToRgb = (hex) => [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16)); const linspace = (n) => Array.from({ length: n }, (_, i) => (n > 1 ? i / (n - 1) : 0)); @@ -266,7 +282,15 @@ async function initDetail(root) { if (root.dataset.ready) return; root.dataset.ready = "1"; const name = root.dataset.cmapName; - const data = await fetchData(new URL(root.dataset.json, location.href)); + let data; + try { + data = await fetchData(new URL(root.dataset.json, location.href)); + } catch (err) { + const message = document.createElement("div"); + showError(message, `Couldn't load this colormap's data (${err.message})`); + root.prepend(message); + return; + } const bars = [...root.querySelectorAll(".cmap > img")]; const originals = bars.map((img) => img.src); const testImages = [...root.querySelectorAll("canvas[data-test-image]")]; @@ -286,8 +310,11 @@ async function initDetail(root) { for (const canvas of charts) drawChart(canvas, d); } + // (only the charts depend on the color scheme) for (const canvas of charts) { - canvas.addEventListener("cmap:rerender", () => render(currentCvd())); + canvas.addEventListener("cmap:rerender", () => + drawChart(canvas, data[currentCvd()] ?? data.normal) + ); } const currentCvd = () => root.querySelector('.cmap-cvd input:checked')?.value ?? "normal"; root.querySelector(".cmap-cvd").addEventListener("change", () => render(currentCvd())); @@ -565,7 +592,13 @@ async function initCompare(root) { if (root.dataset.ready) return; root.dataset.ready = "1"; const base = new URL(root.dataset.base, location.href); - const index = await fetchData(new URL("index.json", base)); + let index; + try { + index = await fetchData(new URL("index.json", base)); + } catch (err) { + showError(root.querySelector(".cmap-compare-rows"), `Couldn't load the catalog (${err.message})`); + return; + } const byName = new Map(index.map((e) => [e.name.toLowerCase(), e])); for (const e of index) { for (const a of e.aliases) byName.set(a.toLowerCase(), e); @@ -580,15 +613,18 @@ async function initCompare(root) { const chart = root.querySelector("canvas.cmap-chart"); const cvdGroup = root.querySelector(".cmap-cvd"); const params = new URLSearchParams(location.search); - const resolve = (n) => byName.get(n.trim().toLowerCase().replace(/_r$/, "")); - let selected = (params.get("cmaps") ?? "") - .split(",") - .map(resolve) - .filter(Boolean) - .slice(0, COMPARE_MAX); - if (!selected.length) selected = ["bids:viridis", "cmocean:thermal", "crameri:batlow"] - .map(resolve) - .filter(Boolean); + // a colormap name or alias (optionally reversed, with "_r") to + // { name, entry, reversed }, or undefined if it isn't in the catalog + const resolve = (n) => { + const key = n.trim().toLowerCase(); + const reversed = key.endsWith("_r") && !byName.has(key); + const entry = byName.get(reversed ? key.slice(0, -2) : key); + return entry && { name: entry.name + (reversed ? "_r" : ""), entry, reversed }; + }; + let selected = params.has("cmaps") + ? params.get("cmaps").split(",").filter(Boolean).map(resolve).filter(Boolean) + : ["bids:viridis", "cmocean:thermal", "crameri:batlow"].map(resolve); + selected = selected.slice(0, COMPARE_MAX); const cvd = () => cvdGroup.querySelector("input:checked")?.value ?? "normal"; const lineColorsGroup = root.querySelector(".cmap-line-colors"); @@ -600,10 +636,21 @@ async function initCompare(root) { function syncUrl() { const url = new URL(location.href); - url.searchParams.set("cmaps", selected.map((e) => e.name).join(",")); + url.searchParams.delete("cmaps"); + // (":" and "," are fine in a query string, and easier to read unescaped) + const cmaps = selected.map((s) => encodeURIComponent(s.name).replace(/%3A/gi, ":")); + url.search += `${url.search ? "&" : "?"}cmaps=${cmaps.join(",")}`; history.replaceState(history.state, "", url); } + // the catalog data of a selected colormap (reversed, for a reversed colormap) + async function loadData(s) { + const file = `${encodeURIComponent(s.entry.path.split("/").pop())}.json`; + const data = await fetchData(new URL(file, base)); + if (!s.reversed) return data; + return Object.fromEntries(Object.entries(data).map(([k, d]) => [k, reverseData(d)])); + } + // The image section: a sample image, or one of the user's own, shown in grayscale // and through each colormap. const imageEl = root.querySelector(".cmap-image"); @@ -659,7 +706,7 @@ async function initCompare(root) { userImage = { ...(await loadImage(src)), name: file.name }; sampleInput.checked = false; } catch { - userImage = null; + // (whatever was shown before stays shown) imageName.textContent = `Couldn't read ${file.name}`; imageName.hidden = false; return; @@ -713,57 +760,50 @@ async function initCompare(root) { }; document.addEventListener("paste", onPaste); - async function render() { - syncUrl(); - const allData = await Promise.all( - selected.map((e) => fetchData(new URL(`${encodeURIComponent(e.path.split("/").pop())}.json`, base))) - ); - rowsEl.replaceChildren( - ...selected.map((e, i) => { - const d = allData[i][cvd()] ?? allData[i].normal; - const row = document.createElement("div"); - row.className = "cmap-compare-row"; - const head = document.createElement("div"); - head.className = "cmap-compare-head"; - const link = Object.assign(document.createElement("a"), { - href: new URL(`../${e.path}/`, base).href, - textContent: e.name, - }); - const remove = Object.assign(document.createElement("button"), { - type: "button", - className: "cmap-compare-remove", - title: `Remove ${e.name}`, - textContent: "✕", - }); - remove.addEventListener("click", () => { - selected = selected.filter((s) => s !== e); - render(); - }); - head.append(link, remove); - const bar = document.createElement("canvas"); - bar.className = "cmap-compare-bar"; - paintBar(bar, d.color); - row.append(head, bar); - // (a sine ramp means nothing for a discrete colormap) - if (!e.discrete) { - const sine = document.createElement("canvas"); - sine.className = "cmap-sineramp"; - sine.dataset.testImage = "sineramp"; - row.append(sine); - setTestImageColors(sine, d.color); - } - return row; - }) - ); - maps = selected.map((e, i) => ({ name: e.name, data: allData[i] })); - renderImages(); + function row({ name, entry, data }) { + const d = data[cvd()] ?? data.normal; + const el = document.createElement("div"); + el.className = "cmap-compare-row"; + const head = document.createElement("div"); + head.className = "cmap-compare-head"; + const link = Object.assign(document.createElement("a"), { + href: new URL(`../${entry.path}/`, base).href, + textContent: name, + }); + const remove = Object.assign(document.createElement("button"), { + type: "button", + className: "cmap-compare-remove", + title: `Remove ${name}`, + textContent: "✕", + }); + remove.setAttribute("aria-label", `Remove ${name}`); + remove.addEventListener("click", () => { + selected = selected.filter((s) => s.name !== name); + render(); + }); + head.append(link, remove); + const bar = document.createElement("canvas"); + bar.className = "cmap-compare-bar"; + paintBar(bar, d.color); + el.append(head, bar); + // (a sine ramp means nothing for a discrete colormap) + if (!entry.discrete) { + const sine = document.createElement("canvas"); + sine.className = "cmap-sineramp"; + sine.dataset.testImage = "sineramp"; + el.append(sine); + setTestImageColors(sine, d.color); + } + return el; + } + function drawCompareChart() { Chart.getChart(chart)?.destroy(); const options = baseOptions({ y: { min: 0, max: 100, title: { text: "Lightness J′", display: true } }, }); // legend swatches are little dashed lines in each colormap's colors - const swatches = allData.map((data, i) => lineSwatch(lineColors(data, i), COMPARE_DASHES[i])); + const swatches = maps.map(({ data }, i) => lineSwatch(lineColors(data, i), COMPARE_DASHES[i])); options.plugins.legend = { display: true, labels: { @@ -781,12 +821,12 @@ async function initCompare(root) { new Chart(chart, { type: "scatter", data: { - datasets: selected.map((e, i) => { - const d = allData[i][cvd()] ?? allData[i].normal; + datasets: maps.map(({ name, data }, i) => { + const d = data[cvd()] ?? data.normal; const x = linspace(d.color.length); - const colors = lineColors(allData[i], i); + const colors = lineColors(data, i); return { - label: e.name, + label: name, data: points(x, d.J), showLine: true, pointRadius: 0, @@ -810,23 +850,44 @@ async function initCompare(root) { }); } - chart.addEventListener("cmap:rerender", render); + let renderId = 0; + async function render() { + syncUrl(); + const id = ++renderId; + const shown = [...selected]; + let allData; + try { + allData = await Promise.all(shown.map(loadData)); + } catch (err) { + if (id === renderId) showError(rowsEl, `Couldn't load colormap data (${err.message})`); + return; + } + if (id !== renderId) return; // (the selection changed while loading: a newer render wins) + maps = shown.map((s, i) => ({ ...s, data: allData[i] })); + rowsEl.replaceChildren(...maps.map(row)); + renderImages(); + drawCompareChart(); + } + + // (only the chart depends on the color scheme) + chart.addEventListener("cmap:rerender", drawCompareChart); cvdGroup.addEventListener("change", render); - lineColorsGroup.addEventListener("change", render); + lineColorsGroup.addEventListener("change", drawCompareChart); // (a custom validity message blocks submitting the form until it's cleared) input.addEventListener("input", () => input.setCustomValidity("")); root.querySelector(".cmap-compare-form").addEventListener("submit", (ev) => { ev.preventDefault(); - const e = resolve(input.value); - input.setCustomValidity(e ? "" : "Unknown colormap"); + const s = resolve(input.value); + input.setCustomValidity(s ? "" : "Unknown colormap"); input.reportValidity(); - if (!e) return; - if (!selected.includes(e) && selected.length >= COMPARE_MAX) { + if (!s) return; + const isNew = !selected.some((x) => x.name === s.name); + if (isNew && selected.length >= COMPARE_MAX) { input.setCustomValidity(`Up to ${COMPARE_MAX} colormaps: remove one first`); input.reportValidity(); return; } - if (!selected.includes(e)) selected = [...selected, e]; + if (isNew) selected = [...selected, s]; input.value = ""; render(); }); diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 687e9c7d7..3f574b4be 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -228,7 +228,8 @@ div.doc-contents:not(.first) { color: var(--md-default-fg-color--light); } -.cmap-empty { +.cmap-empty, +.md-typeset .cmap-error { color: var(--md-default-fg-color--light); } @@ -347,15 +348,15 @@ div.doc-contents:not(.first) { /* L / M / S: the cone type that each deficiency is missing */ .cmap-cvd-cone { - display: inline-block; - min-width: 1.1em; - margin-right: 0.35em; - font-family: var(--md-code-font-family); - font-weight: 700; - line-height: 1.1em; - text-align: center; - border: 1px solid currentColor; - border-radius: 0.2em; + display: inline-block; + min-width: 1.1em; + margin-right: 0.35em; + font-family: var(--md-code-font-family); + font-weight: 700; + line-height: 1.1em; + text-align: center; + border: 1px solid currentColor; + border-radius: 0.2em; } .cmap-pills input { diff --git a/pyproject.toml b/pyproject.toml index 0c296b656..31cad4d72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,7 @@ dev = [ ] docs = [ 'colorspacious', + 'jinja2', 'mkdocstrings-python', 'natsort', 'zensical',