diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 43509b3c4..97d792005 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,16 +1,23 @@ 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: read 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 diff --git a/.gitignore b/.gitignore index 5476bf774..3c3558667 100644 --- a/.gitignore +++ b/.gitignore @@ -97,7 +97,7 @@ ENV/ # Rope project settings .ropeproject -# mkdocs documentation +# docs build output /site # mypy @@ -108,7 +108,10 @@ ENV/ cmap/_version.py src/cmap/_version.py -docs/assets/_data/ .ruff_cache/ uv.lock + +# generated docs (see docs/_gen_catalog.py) +docs/catalog/*/ +.cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 78cd743bf..8c5eac923 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.51.1 + 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/.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..6606410fc --- /dev/null +++ b/docs/_gen_catalog.py @@ -0,0 +1,618 @@ +"""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._catalog import DATA_ROOT +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", +} +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": { + "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", + "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), + ("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 }} +# 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 }} + +{% if aliases %}**Aliases**: {{ aliases }} +{% endif %} +{{ info }} + +| collection | category | license | authors | source | +| --- | --- | --- | --- | --- | +| [{{ namespace }}](../collections/{{ namespace }}/index.md) | [{{ 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()) + ``` + +=== "plotly" + + ```python + import plotly.express as px + from cmap import Colormap + + px.imshow(data, color_continuous_scale=Colormap("{{ name }}").to_plotly()) + ``` + +=== "napari" + + ```python + import napari + from cmap import Colormap + + napari.imshow(data, colormap=Colormap("{{ name }}").to_napari()) + napari.run() + ``` + +=== "CSS" + + ```css + {{ css | indent(4) }} + ``` + +=== "More..." + + === "altair" + + ```python + import altair as alt + from cmap import Colormap + + # df: a DataFrame with columns x, y and value + alt.Chart(df).mark_rect().encode( + x="x:O", + y="y:O", + color=alt.Color( + "value:Q", scale=alt.Scale(range=Colormap("{{ name }}").to_altair()) + ), + ) + ``` + + === "bokeh" + + ```python + from bokeh.plotting import figure, show + from cmap import Colormap + + p = figure() + p.image( + image=[data], x=0, y=0, dw=1, dh=1, + color_mapper=Colormap("{{ name }}").to_bokeh(), + ) + show(p) + ``` + + === "vispy" + + ```python + from vispy import app, scene + from cmap import Colormap + + canvas = scene.SceneCanvas(show=True) + view = canvas.central_widget.add_view() + scene.visuals.Image(data, cmap=Colormap("{{ name }}").to_vispy(), parent=view.scene) + view.camera = scene.PanZoomCamera(aspect=1) + view.camera.set_range() + app.run() + ``` + + === "pygfx" + + ```python + import pygfx as gfx + from cmap import Colormap + + image = gfx.Image( + gfx.Geometry(grid=gfx.Texture(data, dim=2)), + gfx.ImageBasicMaterial( + clim=(data.min(), data.max()), map=Colormap("{{ name }}").to_pygfx() + ), + ) + gfx.show(image) + ``` + + === "pyqtgraph" + + ```python + import pyqtgraph as pg + from cmap import Colormap + + view = pg.image(data) + view.setColorMap(Colormap("{{ name }}").to_pyqtgraph()) + pg.exec() + ``` + + === "Earth Engine" + + ```python + import ee + from cmap import Colormap + + ee.Initialize(project="my-project") # your Google Cloud project + elevation = ee.Image("CGIAR/SRTM90_V4") + image = elevation.visualize( + min=0, max=3000, palette=Colormap("{{ name }}").to_gee() + ) + ``` + + See also [usage with external libraries](../../colormaps.md#usage-with-external-visualization-libraries). + +
+ +
+
+
+{{ bar }} +{{ gray }} +{% if not discrete %}{% endif %} +
+{% if square %} +{% endif %}
+
+{% for key, label in cvd_labels.items() %}{% set cone = cvd_cones.get(key) %} +{% endfor %}
+
+ +
+ +[:lucide-git-compare: Compare with other colormaps](../../compare.md?cmaps={{ name | urlencode }}){ .md-button } + +
+ +## Perceptual lightness { data-search-exclude } + +
+ +

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

+ +## RGB components { data-search-exclude } + +
+ +## Hue & saturation { data-search-exclude } + +
+ +--- + + +""" +) + +CATEGORY_PAGE = """\ +--- +title: {title} colormaps +render_macros: true +--- + +# {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.", +} + +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 = """ + + + +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 _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}`" + + +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, "collections", "_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)))} + + 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_link(info.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, 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, + 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_), + ) + + +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]]] = {} + 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, intro=CATEGORY_INTROS[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"]), + "discrete": Colormap(name).interpolation == "nearest", + "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_collections(write, index) + _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..8c5b31687 --- /dev/null +++ b/docs/_macros.py @@ -0,0 +1,205 @@ +"""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]) + # (in a link, the label already names it: an alt would be read out twice) + alt = "" if link else f"{html.escape(name)} colormap" + img = ( + f'{alt}' + ) + 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, namespace: 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 = 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] + if namespace: + entries = [e for e in entries if e["namespace"] == namespace] + + 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 = "" + 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'' + for c in ("all", *cats) + ) + 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) + '' + f"{cat_buttons}" + f'{len(items)} colormaps' + "
" + ) + return ( + f'
{controls}' + f'
{"".join(items)}
' + '
' + ) + + +COLORBOX = ( + '
' + "{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 (aliases share a box).""" + names: dict[Any, list[str]] = {} + for name, c in NAME_TO_RGB.items(): + 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: + # 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(), + aliases=also if aliases else "", + ) + ) + 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_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, namespace: str | None = None) -> str: + return catalog_grid(_page(), category, namespace) + + 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..927b7c37f 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] @@ -16,43 +20,25 @@ 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" 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..ab9ccf123 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. @@ -22,35 +26,35 @@ 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` - 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 ✅" + !!!note "Matplotlib names" Any valid matplotlib colormap key that could be used in `matplotlib.colormaps[...]` is also a valid `cmap` colormap name. - 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,35 +93,34 @@ 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: + ```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.]]) }} + {{ 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), @@ -130,36 +133,73 @@ 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) ``` - {{ 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 +### 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 @@ -344,7 +384,7 @@ external visualization libraries. To that end, `cmap.Colormap` provides Colormap("viridis").to_pygfx() ``` - Returns an instance of `pygfx.Texture`. + Returns an instance of `pygfx.TextureMap`. - [plotly](https://plotly.com/python/) @@ -397,8 +437,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 @@ -409,14 +452,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.5019607843137255,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): @@ -424,30 +473,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 edc98de0c..753585acd 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. @@ -36,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: @@ -81,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 @@ -121,12 +125,12 @@ 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.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) @@ -162,7 +166,8 @@ 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 +# colors are cached, so equal colors are the same object +trq is Color((64, 224, 208)) # True ``` !!!tip @@ -209,8 +214,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 @@ -223,13 +228,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): @@ -237,18 +245,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 @@ -269,4 +267,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..0982b2b11 --- /dev/null +++ b/docs/compare.md @@ -0,0 +1,65 @@ +--- +title: Compare colormaps +description: Compare colormaps side by side, including perceptual lightness and simulated color vision deficiencies. +--- + +# Compare colormaps + +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://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. +Below that, each colormap is applied to a sample image, or to one of your own, +and the chart at the bottom shows the perceptual lightness of each colormap. + +The URL updates as you go, so you can share a comparison. + +
+ +
+
+ + + +
+
+ + + + +
+
+ +## Colorbars + +
+ +## Image + +
+
+ +
+ + +
+ + + +Or drop or paste an image here. It's converted to grayscale and stays in your browser: nothing is uploaded. +
+
+

Sample: a cat in crumpled paper, with fine detail (fur, whiskers) and smooth shading (paper). It has no meaningful middle value: for diverging colormaps, use Corsica.

+ +
+ +## Lightness + +
+ + +
+
+ +
diff --git a/docs/faq.md b/docs/faq.md index 84cbf1b35..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. @@ -67,6 +66,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/images/cat.jpg b/docs/images/cat.jpg new file mode 100644 index 000000000..22d97919a Binary files /dev/null and b/docs/images/cat.jpg differ diff --git a/docs/images/corsica.png b/docs/images/corsica.png new file mode 100644 index 000000000..16fe37c83 Binary files /dev/null and b/docs/images/corsica.png differ 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 new file mode 100644 index 000000000..dd9ff604a --- /dev/null +++ b/docs/javascripts/cmap.js @@ -0,0 +1,895 @@ +// 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 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) { + url = String(url); // (URL objects are compared by identity) + if (!dataCache.has(url)) { + dataCache.set( + url, + 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)); + +// 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 }; +} + +// 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://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; + 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 }; +} + +// 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, 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) { + 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); +} + +// 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); + 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++) { + 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); + } + 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"); // (absent on collection pages) + 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"; + + // the filters are kept in the URL, so that a filtered view can be linked to: + // ?q=&collection=&category=&monotonic=1 + const params = new URLSearchParams(location.search); + text.value = params.get("q") ?? ""; + if (ns && [...ns.options].some((o) => o.value === params.get("collection"))) { + ns.value = params.get("collection"); + } + mono.checked = params.get("monotonic") === "1"; + const requested = catButtons.find((b) => b.dataset.category === params.get("category")); + if (requested) category = requested.dataset.category; + catButtons.forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.category === category))); + + function syncUrl() { + const url = new URL(location.href); + const set = (key, value) => + value ? url.searchParams.set(key, value) : url.searchParams.delete(key); + set("q", text.value.trim()); + set("collection", ns?.value); + set("category", category === "all" ? "" : category); + set("monotonic", mono.checked ? "1" : ""); + history.replaceState(history.state, "", url); + } + + 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; + syncUrl(); + } + + 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; + 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]")]; + const charts = [...document.querySelectorAll(`canvas.cmap-chart[data-cmap-name="${name}"]`)]; + + 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)); + } + for (const canvas of testImages) setTestImageColors(canvas, d.color); + 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", () => + drawChart(canvas, data[currentCvd()] ?? data.normal) + ); + } + 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 }, + 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 }) => + `${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 }; + }, + labelPointStyle: () => ({ pointStyle: "rectRounded", rotation: 0 }), + }, + }, + }, + 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" && "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: -dJmax, + max: dJmax, + 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, + tooltipColors: colors, + pointRadius: 10, + pointHoverRadius: 12, + borderWidth: 0, + // (don't cut off the points near the top and bottom) + clip: false, + }, + { + label: "dJ′", + data: points(x, d.dJ), + showLine: true, + pointRadius: 0, + borderColor: "#88888866", + tooltipColors: Array(x.length).fill("#888888"), + 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: { usePointStyle: true, pointStyle: "line", 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; +// 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; +} + +// user images are scaled down to at most this many pixels on their long edge +const IMAGE_MAX = 768; +const GRAY = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0").repeat(3)); + +// The image at `src` as values in [0, 1] (NaN where transparent). +async function loadImage(src) { + // (an decodes anything the browser can show, SVG included) + const img = new Image(); + img.src = src; + await img.decode(); + const { naturalWidth: width, naturalHeight: height } = img; + if (!width || !height) throw new Error(`${src} has no size`); + const scale = Math.min(1, IMAGE_MAX / Math.max(width, height)); + const cols = Math.max(1, Math.round(width * scale)); + const rows = Math.max(1, Math.round(height * scale)); + const canvas = Object.assign(document.createElement("canvas"), { width: cols, height: rows }); + const ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0, cols, rows); + const px = ctx.getImageData(0, 0, cols, rows).data; + const im = new Float32Array(rows * cols); + for (let i = 0; i < im.length; i++) { + const [r, g, b, a] = px.subarray(i * 4, i * 4 + 4); + // (luma of the encoded values, so that a grayscale image keeps its own values) + im[i] = a ? (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 : NaN; + } + return { im, rows, cols, width, height }; +} + +// `image` with its 1st to 99th percentile stretched to [0, 1]. +function stretchContrast(image) { + const sorted = image.im.filter((v) => !Number.isNaN(v)).sort(); + if (!sorted.length) return image; + const lo = sorted[Math.floor(0.01 * (sorted.length - 1))]; + const hi = sorted[Math.ceil(0.99 * (sorted.length - 1))]; + if (!(hi > lo)) return image; + const im = image.im.map((v) => Math.min(1, Math.max(0, (v - lo) / (hi - lo)))); + return { ...image, im }; +} + +async function initCompare(root) { + if (root.dataset.ready) return; + root.dataset.ready = "1"; + const base = new URL(root.dataset.base, location.href); + 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); + } + + 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); + // 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"); + // 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); + 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"); + const imageGrid = imageEl.querySelector(".cmap-image-grid"); + const imageName = imageEl.querySelector(".cmap-image-name"); + const imageFile = imageEl.querySelector("input[type=file]"); + const imageReset = imageEl.querySelector(".cmap-image-reset"); + const imageCredits = imageEl.querySelectorAll(".cmap-image-credit"); + const sampleGroup = imageEl.querySelector(".cmap-image-samples"); + const stretch = imageEl.querySelector(".cmap-image-stretch"); + const samples = new Map(); // sample name -> Promise of its image + let sampleInput = sampleGroup.querySelector("input:checked"); + let sample = null; // (the image of `sampleInput`, once loaded) + let userImage = null; + let maps = []; // the colormaps as last rendered: [{ name, data }] + + function renderImages() { + // (samples are never stretched: Corsica's middle value is sea level) + const image = !userImage + ? sample + : stretch.querySelector("input").checked + ? (userImage.stretched ??= stretchContrast(userImage)) + : userImage; + if (userImage) { + imageName.textContent = `${userImage.name} (${userImage.width}×${userImage.height})`; + } + imageName.hidden = imageReset.hidden = stretch.hidden = !userImage; + for (const p of imageCredits) { + p.hidden = !!userImage || p.dataset.sample !== sampleInput.value; + } + if (!image) return; + const aspect = image.cols / image.rows; + imageGrid.dataset.shape = aspect > 1.5 ? "wide" : aspect < 0.75 ? "tall" : "square"; + const tile = (name, colors) => { + const el = document.createElement("div"); + el.className = "cmap-image-tile"; + const canvas = document.createElement("canvas"); + paint(canvas, colors, image.im, image.rows, image.cols); + const label = Object.assign(document.createElement("span"), { textContent: name }); + el.append(canvas, label); + return el; + }; + imageGrid.replaceChildren( + tile("grayscale", GRAY), + ...maps.map(({ name, data }) => tile(name, (data[cvd()] ?? data.normal).color)) + ); + } + + async function setImage(file) { + if (!file?.type.startsWith("image/")) return; + const src = URL.createObjectURL(file); + try { + userImage = { ...(await loadImage(src)), name: file.name }; + sampleInput.checked = false; + } catch { + // (whatever was shown before stays shown) + imageName.textContent = `Couldn't read ${file.name}`; + imageName.hidden = false; + return; + } finally { + URL.revokeObjectURL(src); + } + renderImages(); + } + + async function showSample(input) { + sampleInput = input; + input.checked = true; + userImage = null; + renderImages(); + if (!samples.has(input.value)) { + samples.set(input.value, loadImage(new URL(input.dataset.image, location.href))); + } + const image = await samples.get(input.value); + if (sampleInput !== input || userImage) return; // (something else was picked since) + sample = image; + renderImages(); + } + showSample(sampleInput); + + sampleGroup.addEventListener("change", (ev) => showSample(ev.target)); + imageFile.addEventListener("change", () => { + setImage(imageFile.files[0]); + imageFile.value = ""; // (so that choosing the same file again still fires) + }); + imageReset.addEventListener("click", () => showSample(sampleInput)); + stretch.addEventListener("change", renderImages); + imageEl.addEventListener("dragover", (ev) => { + ev.preventDefault(); + imageEl.classList.add("is-dragging"); + }); + imageEl.addEventListener("dragleave", (ev) => { + if (!imageEl.contains(ev.relatedTarget)) imageEl.classList.remove("is-dragging"); + }); + imageEl.addEventListener("drop", (ev) => { + ev.preventDefault(); + imageEl.classList.remove("is-dragging"); + setImage(ev.dataTransfer.files[0]); + }); + // pasting an image anywhere on the page (until we navigate away from it) + const onPaste = (ev) => { + if (!root.isConnected) return document.removeEventListener("paste", onPaste); + const file = [...ev.clipboardData.files].find((f) => f.type.startsWith("image/")); + if (!file) return; + ev.preventDefault(); + setImage(file); + }; + document.addEventListener("paste", onPaste); + + 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 = maps.map(({ data }, i) => lineSwatch(lineColors(data, i), COMPARE_DASHES[i])); + options.plugins.legend = { + display: true, + labels: { + color: themeColors().fg, + usePointStyle: true, + pointStyleWidth: 40, + 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: { + datasets: maps.map(({ name, data }, i) => { + const d = data[cvd()] ?? data.normal; + const x = linspace(d.color.length); + const colors = lineColors(data, i); + return { + label: name, + data: points(x, d.J), + showLine: true, + pointRadius: 0, + borderWidth: COMPARE_LINE_WIDTH, + borderCapStyle: "round", + 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); + }, + }; + }), + }, + options, + }); + } + + 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", 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 s = resolve(input.value); + input.setCustomValidity(s ? "" : "Unknown colormap"); + input.reportValidity(); + 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 (isNew) selected = [...selected, s]; + 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/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 14a061686..3f574b4be 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -1,224 +1,612 @@ -.md-typeset .admonition, -.md-typeset details { - border-width: 0; - border-left-width: 4px; +/* ---------------------------------------------------------------------------- + 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-typeset .doc-children { - font-size: 0.7rem; +.md-header__source, +.md-header [for="__search"] { + mix-blend-mode: difference; } -/* name of a method */ -code.highlight span.n:first-child { - color: red; +html body div.ethical-sidebar, +html body div.ethical-footer { + display: none !important; } -code.highlight span.n:not(:first-child) { - color: #666; + +/* ---------------------------------------------------------------------------- + API docs (mkdocstrings) + ------------------------------------------------------------------------- */ + +/* 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 */ + mask: url('data:image/svg+xml,') + center / contain no-repeat; + content: ""; + display: inline-block; + width: 0.75em; + height: 0.75em; + margin-left: 0.15em; + vertical-align: 0.05em; + background-color: currentColor; } -/* Indentation. */ 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); } -/* 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: " "; +/* ---------------------------------------------------------------------------- + Colorbars ({{ cmap(...) }} macros and the catalog grid) + ------------------------------------------------------------------------- */ - display: inline-block; - position: relative; - top: 0.1em; - margin-left: 0.2em; - margin-right: 0.1em; +.md-typeset .cmap { + position: relative; + display: block; + margin: 0 0 2px; + line-height: 0; +} - height: 1em; - width: 1em; - border-radius: 100%; - background-color: var(--md-typeset-a-color); +.md-typeset .cmap[hidden] { + display: none; } -a.autorefs-external:hover::after { - background-color: var(--md-accent-fg-color); + +.md-typeset .cmap img { + display: block; + width: 100%; + height: 32px; } -.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% - ); +.md-typeset .cmap.grayscale img { + filter: grayscale(100%); +} + +/* example bars under their code, aligned with it */ +.md-typeset .cmap-expr { + 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 */ +.md-typeset .cmap-name { + position: absolute; + top: 50%; + left: 0.6rem; + z-index: 1; + padding: 0.15em 0.4em; + font-size: 0.65rem; + font-weight: 500; + line-height: 1; + color: white; + background: rgb(0 0 0 / 0.65); + border-radius: 0.25em; + transform: translateY(-50%); + pointer-events: none; } -nav.md-header__inner { - min-height: 72px; +/* grow only the bar (not the label) on hover */ +.md-typeset a.cmap img { + transition: + transform 0.1s, + box-shadow 0.1s; } -nav .md-header__source { - mix-blend-mode: difference; +.md-typeset a.cmap:hover, +.md-typeset a.cmap:focus-visible { + z-index: 1; } -nav > label.md-header__button[for="__search"] { - mix-blend-mode: difference; +.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); } -div.cmap { - position: relative; - margin: 0; +/* ---------------------------------------------------------------------------- + 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; } -div.cmap-bar { - height: 30px; + +/* on small screens, a sticky filter bar would cover too much of the page */ +@media (max-width: 600px) { + .cmap-filter { + position: static; + } } -div.cmap-expr > .cmap-bar { - height: 24px; +.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; } -div.cmap.grayscale > .cmap-bar, div.cmap.grayscale > img { - filter: grayscale(100%); +.cmap-filter input[type="search"], +.cmap-filter select { + box-sizing: border-box; + height: 1.9rem; } -a div.cmap-bar:hover { - cursor: pointer; - filter: drop-shadow(0 0 0.4rem gray); +.cmap-filter input[type="search"] { + flex: 1 1 12rem; } -div.cmap-name { - position: absolute; - vertical-align: middle; - line-height: 30px; /* same as cmap-bar */ - margin: 0 0.8rem; - font-weight: bold; - font-size: 0.75rem; - text-shadow: #000 0 1px 2px; - color: white; - mix-blend-mode: difference; +/* 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; } -div.cmap:hover .cmap-name { - color: red; + +.cmap-filter input:focus, +.cmap-filter select:focus, +.cmap-compare-input:focus { + outline: none; + border-color: var(--md-accent-fg-color); } -.container { - overflow: hidden; +.cmap-filter-mono { + display: inline-flex; + gap: 0.3rem; + align-items: center; + cursor: pointer; } -.filterDiv { - width: 100%; - display: none; /* Hidden by default */ +.cmap-filter-cats { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; } -/* The "show" class is added to the filtered elements */ -.show { - display: block; +.cmap-filter-cats button, +.cmap-pills label { + padding: 0.25rem 0.7rem; + font: inherit; + color: var(--md-default-fg-color); + cursor: pointer; + background: var(--md-default-fg-color--lightest); + border-radius: 1rem; } -/* Style the buttons */ -.btn { - border: none; - outline: none; - padding: 8px 16px; - background-color: #f1f1f1; - cursor: pointer; +.cmap-filter-cats button[aria-pressed="true"], +.cmap-pills label:has(input:checked) { + color: var(--md-accent-bg-color); + /* (darkened a little, for white text) */ + background: color-mix(in srgb, var(--md-accent-fg-color) 85%, black); } -body[data-md-color-scheme="slate"] .btn { - background-color: #414352; - color: white; + +.cmap-filter-count { + margin-left: auto; + color: var(--md-default-fg-color--light); } -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-empty, +.md-typeset .cmap-error { + color: var(--md-default-fg-color--light); } -/* Add a light grey background on mouse-over */ -.btn:hover { - background-color: #ddd; +/* ---------------------------------------------------------------------------- + Colormap detail page + ------------------------------------------------------------------------- */ + +.md-typeset .cmap-detail .cmap img { + height: 40px; } -body[data-md-color-scheme="slate"] .btn:hover { - background-color: #a3a5b1; + +.md-typeset .cmap-detail .cmap.grayscale img { + height: 30px; } -/* Add a dark background to the active button */ -.btn.active { - background-color: #666; - color: white; +/* (the page title already names the colormap) */ +.md-typeset .cmap-detail .cmap-name { + display: none; } -body[data-md-color-scheme="slate"] .btn.active { - background-color: #a0a3b7; - color: black; + +/* 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; } -.colorlist { - display: flex; - flex-wrap: wrap; - justify-content: center; - font-size: 0.7rem; +.cmap-test-images { + display: grid; + gap: 0.8rem; } -.colorbox { - width: 7rem; - height: 3.8rem; - padding-top: 0.22rem; - margin: 0.2rem; - text-align: center; + +.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: 96px; +} + +.cmap-test-square { + 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; +} + +/* 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; +} + +.cmap-test-images:not(:has(.cmap-sineramp)) .cmap-test-square { + justify-self: center; +} + +/* 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-pills { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + padding: 0; + margin: 0.8rem 0; + font-size: 0.65rem; + 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-pills input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.cmap-pills 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; + +/* (the links are wrapped in a

by markdown) */ +.md-typeset .cmap-pager > p { + display: flex; + justify-content: space-between; +} + +.md-typeset .cmap-pager a:only-child:last-child { + margin-left: auto; +} + +/* ---------------------------------------------------------------------------- + Compare page + ------------------------------------------------------------------------- */ + +/* the colormap input and CVD pills stay in reach while scrolling the page */ +.cmap-compare-controls { + position: sticky; + top: 2.4rem; + z-index: 2; + padding-top: 0.6rem; + background: var(--md-default-bg-color); +} + +.md-typeset .cmap-compare-controls .cmap-cvd { + margin: 0.6rem 0 0; + padding-bottom: 0.6rem; +} + +/* (so that linked headings aren't hidden under the sticky controls) */ +.md-typeset .cmap-compare h2 { + scroll-margin-top: 9rem; +} + +/* (as for the catalog filter, too much of a small screen) */ +@media (max-width: 600px) { + .cmap-compare-controls { + position: static; + } + + .md-typeset .cmap-compare h2 { + scroll-margin-top: 3rem; + } +} + +.cmap-compare-form { + display: flex; + 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; +} + +.cmap-compare-head { + display: flex; + justify-content: space-between; + align-items: center; + font-weight: 700; +} + +.cmap-compare-remove { + color: var(--md-default-fg-color--light); + cursor: pointer; +} + +.cmap-compare-remove:hover { + color: var(--md-accent-fg-color); +} + +.cmap-compare-bar { + display: block; + width: 100%; + height: 32px; + margin-bottom: 2px; +} + +.cmap-image-drop { + display: flex; + flex-wrap: wrap; + gap: 0.4rem 0.8rem; + align-items: center; + padding: 0.6rem 0.8rem; + font-size: 0.7rem; + border: 1px dashed var(--md-default-fg-color--lighter); + border-radius: 0.3rem; + transition: + border-color 0.1s, + background-color 0.1s; +} + +.cmap-image.is-dragging .cmap-image-drop { + background: var(--md-accent-fg-color--transparent); + border-color: var(--md-accent-fg-color); +} + +.md-typeset .cmap-image-pick { + padding: 0.25rem 0.8rem; + margin: 0; + font-size: inherit; +} + +.md-typeset .cmap-image-samples { + margin: 0; +} + +.cmap-image-name { + font-weight: 700; + overflow-wrap: anywhere; +} + +.cmap-image-reset { + color: var(--md-default-fg-color--light); + cursor: pointer; +} + +.cmap-image-reset:hover { + color: var(--md-accent-fg-color); +} + +.cmap-image-stretch { + display: inline-flex; + gap: 0.3rem; + align-items: center; + margin-left: auto; + cursor: pointer; } -.radio-group { - display: flex; - gap: 1rem; - margin-bottom: 1rem; +.cmap-image-stretch[hidden] { + display: none; } -.radio-option { - position: relative; - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0.5em; - border-radius: 0.5em; - cursor: pointer; - transition: background-color 0.2s, box-shadow 0.2s; +.cmap-image-hint { + flex-basis: 100%; + color: var(--md-default-fg-color--light); } -.radio-option input[type="radio"] { - appearance: none; - position: absolute; - opacity: 0; - pointer-events: none; +/* small multiples: fewer, larger tiles for wide images, more for tall ones */ +.cmap-image-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.8rem 0.6rem; + margin-top: 0.8rem; } -.radio-option .material-icons { - font-size: 1rem; - color: var(--md-default-fg-color); - transition: color 0.2s; +.cmap-image-grid[data-shape="wide"] { + grid-template-columns: repeat(2, minmax(0, 1fr)); } -.radio-option input[type="radio"]:checked ~ .material-icons, -.radio-option input[type="radio"]:checked ~ .radio-label { - color: var(--md-accent-fg-color); +.cmap-image-grid[data-shape="tall"] { + grid-template-columns: repeat(4, minmax(0, 1fr)); } +@media (max-width: 600px) { + .cmap-image-grid, + .cmap-image-grid[data-shape="tall"] { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .cmap-image-grid[data-shape="wide"] { + grid-template-columns: minmax(0, 1fr); + } +} + +.cmap-image-tile canvas { + display: block; + width: 100%; + height: auto; + border-radius: 0.15rem; +} + +.md-typeset .cmap-image-credit { + font-size: 0.65rem; + color: var(--md-default-fg-color--light); +} + +.cmap-image-tile span { + display: block; + margin-top: 0.2rem; + font-size: 0.65rem; + color: var(--md-default-fg-color--light); +} + +/* ---------------------------------------------------------------------------- + Named colors (colors.md) + ------------------------------------------------------------------------- */ + +.colorlist { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(7.5rem, 1fr)); + gap: 0.4rem; + font-size: 0.65rem; +} + +.colorbox { + 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); +} -.radio-option:hover { - background-color: var(--md-default-bg-color--lighter); - box-shadow: 0 0 0 2px var(--md-accent-fg-color); +.md-typeset .colorbox code { + padding: 0; + font-size: inherit; + color: inherit; + background: none; } -.radio-option .radio-label { - font-size: 0.65rem; - color: var(--md-default-fg-color); +.colorbox-aliases { + font-style: italic; + opacity: 0.8; } 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..31cad4d72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,17 +62,11 @@ dev = [ "pyqt6", ] docs = [ - 'colorcet', 'colorspacious', - 'imageio', - 'mkdocs-gen-files', - 'mkdocs-literate-nav', - 'mkdocs-api-autonav', - 'mkdocs-material', - 'mkdocs-minify-plugin', - 'mkdocs', + 'jinja2', 'mkdocstrings-python', 'natsort', + 'zensical', ] [tool.uv.sources] @@ -193,6 +187,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", @@ -200,7 +207,7 @@ ignore = [ "tests/**/*", "setup.py", "docs/**/*", - "mkdocs.yml", + "zensical.toml", ".readthedocs.yaml", ] 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/src/cmap/_catalog.py b/src/cmap/_catalog.py index c1c160a2a..cc4e9ba16 100644 --- a/src/cmap/_catalog.py +++ b/src/cmap/_catalog.py @@ -249,10 +249,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..b4dba85b0 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 @@ -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 = [ @@ -577,7 +573,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/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 diff --git a/zensical.toml b/zensical.toml new file mode 100644 index 000000000..0796e62be --- /dev/null +++ b/zensical.toml @@ -0,0 +1,157 @@ +# 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/en/stable/" +repo_name = "pyapp-kit/cmap" +repo_url = "https://github.com/pyapp-kit/cmap" + +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__/ +overrides/ +""" +# colormap pages are reached from the catalog / category pages and search +not_in_nav = """ +catalog/*/*.md +catalog/collections/*/index.md +""" + +# rebuild when docstrings change +watch = ["src"] + +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" }, + { "Collections" = "catalog/collections/index.md" }, + ] }, + { "Compare" = "compare.md" }, + { "FAQ" = "faq.md" }, +] + +[project.theme] +language = "en" +favicon = "images/favicon.svg" +custom_dir = "docs/overrides" +features = [ + "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" + +# dark by default (the first palette is used until the user picks one) +[[project.theme.palette]] +scheme = "slate" +primary = "pink" +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" + +[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.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 +js_files = ["javascripts/cmap.js"] +css_files = ["stylesheets/extra.css"] + +[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 +# 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 +separate_signature = true +show_symbol_type_heading = true +show_symbol_type_toc = 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 }