From fa07cbeafa62f83804ce5714e6b2c809d0987bda Mon Sep 17 00:00:00 2001 From: Barbier--Darnal Joseph Date: Thu, 2 Jul 2026 10:04:07 +0200 Subject: [PATCH 1/9] reorder pyproject.toml --- .pre-commit-config.yaml | 20 -------------- README.md | 1 - prek.toml | 24 +++++++++++++++++ pyproject.toml | 60 ++++++++++++++++++++--------------------- 4 files changed, 54 insertions(+), 51 deletions(-) delete mode 100644 .pre-commit-config.yaml create mode 100644 prek.toml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 98838dc..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,20 +0,0 @@ -repos: - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.1 - hooks: - - id: ruff - types_or: [python, pyi] - args: [--fix] - - id: ruff-format - types_or: [python, pyi] - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 - hooks: - - id: check-yaml - - id: trailing-whitespace - - id: end-of-file-fixer - - id: check-merge-conflict - - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.28.2 - hooks: - - id: check-github-workflows diff --git a/README.md b/README.md index a6c8652..d1eb034 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,6 @@ A simple-to-use Python library to build **calendar heatmaps** with ease. It's built on top of **matplotlib** and leverages it to access high customization possibilities. [![PyPI Downloads](https://static.pepy.tech/badge/dayplot)](https://pepy.tech/projects/dayplot) -![Coverage](coverage-badge.svg)
diff --git a/prek.toml b/prek.toml new file mode 100644 index 0000000..2e7a15e --- /dev/null +++ b/prek.toml @@ -0,0 +1,24 @@ +[[repos]] +repo = "https://github.com/pre-commit/pre-commit-hooks" +rev = "v6.0.0" +hooks = [ + { id = "check-yaml" }, + { id = "end-of-file-fixer", exclude = "^ninejs/static/.*\\.min\\.js$" }, + { id = "trailing-whitespace", exclude = "^ninejs/static/.*\\.min\\.js$" }, + { id = "check-added-large-files", args = ["--maxkb=6500"] }, +] + +[[repos]] +repo = "https://github.com/astral-sh/ruff-pre-commit" +rev = "v0.11.7" +hooks = [ + { id = "ruff", types_or = ["python", "pyi"], args = ["--fix"] }, + { id = "ruff-format", types_or = ["python", "pyi"] }, +] + +[[repos]] +repo = "https://github.com/python-jsonschema/check-jsonschema" +rev = "0.28.2" +hooks = [ + { id = "check-github-workflows", args = ["--verbose"] }, +] diff --git a/pyproject.toml b/pyproject.toml index d5cf4c3..8ddfe37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,43 +2,49 @@ name = "dayplot" version = "0.5.1" description = "Calendar heatmaps with matplotlib" +readme = "README.md" +requires-python = ">=3.10" license = "MIT" license-files = ["LICENSE"] -keywords = ["matplotlib", "calendar", "heatmap", "github", "plot"] authors = [ - { name = "Joseph Barbier", email = "joseph.barbierdarnal@gmail.com" }, + { name = "Joseph Barbier", email = "joseph.barbierdarnal@gmail.com" }, ] -readme = "README.md" -requires-python = ">=3.10" +keywords = ["calendar", "github", "heatmap", "matplotlib", "plot"] dependencies = [ - "matplotlib", - "narwhals>=1.0.0", + "matplotlib", + "narwhals>=1.0.0", ] +[project.urls] +Documentation = "https://y-sunflower.github.io/dayplot/reference/calendar/" +Homepage = "https://y-sunflower.github.io/dayplot/" +Issues = "https://github.com/y-sunflower/dayplot/issues" +Repository = "https://github.com/y-sunflower/dayplot" + [project.optional-dependencies] data = ["requests"] [dependency-groups] dev = [ - "dotenv>=0.9.9", - "pre-commit>=4.2.0", - "pytest>=8.3.5", - "ruff>=0.11.13", - "mkdocs-matplotlib>=0.10.1", - "mkdocs-material>=9.6.9", - "mkdocstrings-python>=1.16.5", - "coverage>=7.9.1", - "genbadge[coverage]>=1.1.2", - "polars>=1.31.0", - "ty>=0.0.1a20", - "pandas>=2.3.0", - "plotjs>=0.0.9", + "coverage>=7.9.1", + "dotenv>=0.9.9", + "genbadge[coverage]>=1.1.2", + "mkdocs-material>=9.6.9", + "mkdocs-matplotlib>=0.10.1", + "mkdocstrings-python>=1.16.5", + "pandas>=2.3.0", + "plotjs>=0.0.9", + "polars>=1.31.0", + "prek>=0.4.6", + "pytest>=8.3.5", + "ruff>=0.11.13", + "ty>=0.0.1a20", ] [build-system] requires = [ - "setuptools", - "setuptools-scm", + "setuptools", + "setuptools-scm", ] build-backend = "setuptools.build_meta" @@ -48,15 +54,9 @@ packages = ["dayplot"] [tool.setuptools.package-data] mypkg = ["sample.csv"] -[tool.uv.sources] -dayplot = { workspace = true } - -[project.urls] -Homepage = "https://y-sunflower.github.io/dayplot/" -Issues = "https://github.com/y-sunflower/dayplot/issues" -Documentation = "https://y-sunflower.github.io/dayplot/reference/calendar/" -Repository = "https://github.com/y-sunflower/dayplot" - [tool.ty.src] include = ["dayplot"] exclude = ["tests"] + +[tool.uv.sources] +dayplot = { workspace = true } From e0f7359e07f07c5b9d5992f1026dd6f27af8b209 Mon Sep 17 00:00:00 2001 From: Barbier--Darnal Joseph Date: Thu, 2 Jul 2026 10:26:15 +0200 Subject: [PATCH 2/9] fix type issues + migrate to zensical --- .github/workflows/doc.yaml | 32 +++++++++ .github/workflows/site.yaml | 49 ------------- .github/workflows/type.yaml | 10 ++- .gitignore | 2 + dayplot/_docs_matplotlib.py | 67 +++++++++++++++++ dayplot/calendar.py | 23 +++--- dayplot/github.py | 7 +- dayplot/utils.py | 4 +- justfile | 10 ++- mkdocs.yml | 74 ------------------- pyproject.toml | 7 ++ zensical.toml | 139 ++++++++++++++++++++++++++++++++++++ 12 files changed, 279 insertions(+), 145 deletions(-) create mode 100644 .github/workflows/doc.yaml delete mode 100644 .github/workflows/site.yaml create mode 100644 dayplot/_docs_matplotlib.py delete mode 100644 mkdocs.yml create mode 100644 zensical.toml diff --git a/.github/workflows/doc.yaml b/.github/workflows/doc.yaml new file mode 100644 index 0000000..f17da26 --- /dev/null +++ b/.github/workflows/doc.yaml @@ -0,0 +1,32 @@ +name: Documentation +on: + push: + branches: + - main +permissions: + contents: write +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: 3.x + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Install the project + run: uv sync --all-groups + + - run: uv run zensical build --clean + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_branch: gh-pages + publish_dir: ./site diff --git a/.github/workflows/site.yaml b/.github/workflows/site.yaml deleted file mode 100644 index 6b9d2eb..0000000 --- a/.github/workflows/site.yaml +++ /dev/null @@ -1,49 +0,0 @@ -name: ci - -on: - push: - branches: [main] - -permissions: - contents: write - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Configure Git Credentials - run: | - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - - uses: actions/setup-python@v5 - with: - python-version: 3.x - - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - - uses: actions/cache@v4 - with: - key: mkdocs-material-${{ env.cache_id }} - path: .cache - restore-keys: | - mkdocs-material- - - - name: Install uv - uses: astral-sh/setup-uv@v5 - - - name: Enable caching - uses: astral-sh/setup-uv@v5 - with: - enable-cache: true - - - name: Install the project - run: uv sync --all-extras --dev - - - name: Setup Environment - run: | - echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" > .env - cat .env - - - name: Deploy MkDocs - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: PYTHONPATH=$(pwd) uv run mkdocs gh-deploy --force diff --git a/.github/workflows/type.yaml b/.github/workflows/type.yaml index 969a901..bf2a554 100644 --- a/.github/workflows/type.yaml +++ b/.github/workflows/type.yaml @@ -1,9 +1,10 @@ name: Check Static Typing on: + push: + branches: [main] pull_request: - branches: - - main + branches: [main] jobs: check-formatting: @@ -21,5 +22,8 @@ jobs: - name: Install the project run: uv sync --all-groups - - name: Run tests + - name: ty run: uv run ty check + + - name: pyrefly + run: uv run pyrefly check diff --git a/.gitignore b/.gitignore index eeb9f0b..991d3ab 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ uv.lock *.zip release.sh + +site diff --git a/dayplot/_docs_matplotlib.py b/dayplot/_docs_matplotlib.py new file mode 100644 index 0000000..0e58754 --- /dev/null +++ b/dayplot/_docs_matplotlib.py @@ -0,0 +1,67 @@ +import base64 +import tempfile +from typing import Any + +import matplotlib +from bs4 import BeautifulSoup, Tag +from markdown import Markdown +from markdown.extensions import Extension +from markdown.postprocessors import Postprocessor +from mkdocs_matplotlib.plugin import ( + HIDECODE_SWITCH, + HIDEOUTPUT_SWITCH, + RENDER_SWITCH, + _rendered_image_to_dir, +) + + +class MatplotlibRenderPostprocessor(Postprocessor): + def run(self, text: str) -> str: + soup = BeautifulSoup(text, features="html.parser") + global_namespace: dict[str, Any] = {} + local_namespace: dict[str, Any] = {} + + for code_tag in soup.find_all("code"): + parent_code_tag = code_tag.parent + if not isinstance(parent_code_tag, Tag) or parent_code_tag.name != "pre": + continue + + raw_code = code_tag.text + code_lines = raw_code.splitlines() + if RENDER_SWITCH not in code_lines: + continue + + is_hidecode = HIDECODE_SWITCH in code_lines + is_hideoutput = HIDEOUTPUT_SWITCH in code_lines + + matplotlib.use("Agg", force=True) + temp_file = tempfile.NamedTemporaryFile(suffix=".svg").name + is_empty = _rendered_image_to_dir( + temp_file, raw_code, global_namespace, local_namespace + ) + + if not is_hideoutput and not is_empty: + with open(temp_file, "rb") as file: + encoded = base64.b64encode(file.read()).decode("ascii") + img_tag = soup.new_tag( + "img", + src="data:image/svg+xml;base64," + encoded, + ) + parent_code_tag.insert_after(img_tag) + img_tag.wrap(soup.new_tag("center")) + + if is_hidecode: + parent_code_tag.decompose() + + return str(soup) + + +class MatplotlibRenderExtension(Extension): + def extendMarkdown(self, md: Markdown) -> None: + md.postprocessors.register( + MatplotlibRenderPostprocessor(md), "matplotlib_render", 5 + ) + + +def makeExtension(**kwargs: Any) -> MatplotlibRenderExtension: + return MatplotlibRenderExtension(**kwargs) diff --git a/dayplot/calendar.py b/dayplot/calendar.py index ac8c56f..cb8d41d 100644 --- a/dayplot/calendar.py +++ b/dayplot/calendar.py @@ -2,7 +2,6 @@ import matplotlib.patches as patches from matplotlib.path import Path from matplotlib.colors import LinearSegmentedColormap, Normalize, TwoSlopeNorm -import matplotlib from matplotlib.axes import Axes from matplotlib.colors import Colormap import numpy as np @@ -43,7 +42,7 @@ def _validate_inputs(boxstyle, dates, values): raise ValueError( f"Invalid `boxstyle` value. Must be in {IMPLEMENTED_BOXSTYLE}" ) - elif not isinstance(boxstyle, matplotlib.patches.BoxStyle): + elif not isinstance(boxstyle, patches.BoxStyle): raise ValueError( f"`boxstyle` must either be a string or a `matplotlib.patches.BoxStyle`, not {boxstyle}" ) @@ -55,9 +54,9 @@ def _validate_inputs(boxstyle, dates, values): raise ValueError("`dates` and `values` cannot be empty.") -def _validate_cmap(cmap: Union[str, LinearSegmentedColormap]): +def _validate_cmap(cmap: Union[str, LinearSegmentedColormap]) -> Colormap: if isinstance(cmap, str): - cmap: Colormap = plt.get_cmap(cmap) + return plt.get_cmap(cmap) elif not isinstance(cmap, LinearSegmentedColormap): raise ValueError( "Invalid `cmap` input. It must be either a valid matplotlib colormap string " @@ -149,7 +148,7 @@ def calendar( vmin: Optional[float] = None, vmax: Optional[float] = None, vcenter: Optional[float] = None, - boxstyle: Union[str, matplotlib.patches.BoxStyle] = "square", + boxstyle: Union[str, patches.BoxStyle] = "square", legend: bool = False, legend_bins: int = 4, legend_labels: Optional[Union[List, Literal["auto"]]] = None, @@ -162,7 +161,7 @@ def calendar( clip_on: bool = False, ax: Optional[Axes] = None, **kwargs: Any, -) -> List[matplotlib.patches.Rectangle]: +) -> List[patches.FancyBboxPatch]: """ Create a calendar heatmap (GitHub-style) from input dates and values, supporting both positive and negative values via a suitable colormap scale. @@ -195,7 +194,7 @@ def calendar( edgecolor: Color of the edges for each day's rectangle. edgewidth: Line width for the edges of each day's rectangle. cmap: A valid Matplotlib colormap name or a LinearSegmentedColormap instance. The - colormap is used to determine the fill color intensity of each day's + colormap is used to determine the fill color intensity of each day's cell based on its value. week_starts_on: The starting day of the week, which can be specified as a string ("Sunday", "Monday", ..., "Saturday"). @@ -246,7 +245,7 @@ def calendar( The function aggregates multiple entries for the same date by summing their values. """ _validate_inputs(boxstyle, dates, values) - cmap = _validate_cmap(cmap) + validated_cmap = _validate_cmap(cmap) cal = Calendar([*day_name].index(week_starts_on)) month_kws = month_kws or {} @@ -304,14 +303,14 @@ def calendar( "argument contains negative values.", UserWarning, ) - face_color = cmap(norm(count)) + face_color = validated_cmap(norm(count)) elif not is_diverging: if count == 0: if color_for_none is None: color_for_none = "#e8e8e8" face_color = color_for_none else: - face_color = cmap(norm(count)) + face_color = validated_cmap(norm(count)) rect = patches.FancyBboxPatch( xy=(week + 0.35, weekday + 0.35), @@ -420,9 +419,9 @@ def calendar( legend_values = np.linspace(vmin, vmax, legend_bins) for i, val in enumerate(legend_values): if is_diverging: - color = cmap(norm(val)) + color = validated_cmap(norm(val)) else: - color = color_for_none if val == 0 else cmap(norm(val)) + color = color_for_none if val == 0 else validated_cmap(norm(val)) legend_xloc = total_weeks - len(legend_values) + i rect = patches.FancyBboxPatch( diff --git a/dayplot/github.py b/dayplot/github.py index 5eda271..88170a1 100644 --- a/dayplot/github.py +++ b/dayplot/github.py @@ -1,4 +1,7 @@ +from typing import Any + import narwhals as nw +from narwhals.typing import EagerAllowed def fetch_github_contrib( @@ -6,8 +9,8 @@ def fetch_github_contrib( github_token: str, start_date: str, end_date: str, - backend: str = "pandas", -): + backend: EagerAllowed = "pandas", +) -> Any: """ Fetches GitHub contributions for a given user and date range. It requires `requests` and `pandas` to be installed. diff --git a/dayplot/utils.py b/dayplot/utils.py index 3702508..dd131a7 100644 --- a/dayplot/utils.py +++ b/dayplot/utils.py @@ -1,7 +1,7 @@ import os import narwhals as nw from narwhals.typing import IntoDataFrame -from typing import Union, Literal +from typing import Any, Union, Literal import calendar from datetime import date, datetime, timedelta from typing import Generator @@ -12,7 +12,7 @@ def load_dataset( backend: Literal["pandas", "polars", "pyarrow", "modin", "cudf"] = "pandas", - **kwargs, + **kwargs: Any, ) -> IntoDataFrame: """ Load a simple dataset with fake daily data. This function is a simple wrapper diff --git a/justfile b/justfile index e8f65f8..0aa9c1f 100644 --- a/justfile +++ b/justfile @@ -1,5 +1,9 @@ -preview: - uv run mkdocs serve - test: uv run pytest + +doc: + uv run zensical serve + +type: + uv run ty check + uv run pyrefly check diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index d367484..0000000 --- a/mkdocs.yml +++ /dev/null @@ -1,74 +0,0 @@ -site_name: dayplot -site_url: https://example.com -repo_url: https://github.com/y-sunflower/dayplot - -theme: - name: material - custom_dir: overrides - features: - - content.code.copy - - navigation.path - - content.footnote.tooltips - - navigation.expand - icon: - repo: fontawesome/brands/github - -extra: - analytics: - provider: google - property: G-MLP58TWW3R - -plugins: - - mkdocs_matplotlib - - search - - mkdocstrings: - default_handler: python - handlers: - python: - options: - show_source: false - show_root_heading: true - heading_level: 3 - -nav: - - Home: index.md - - Contributing: contributing.md - - Guides: - - tuto/built-in-styles.md - - tuto/basic-styling.md - - tuto/month-grid.md - - tuto/negative-values.md - - tuto/boxstyle.md - - tuto/legend.md - - tuto/interactive.md - - tuto/combine-charts.md - - tuto/fetch-github-contribs.md - - tuto/advanced.md - - Reference: - - reference/calendar.md - - reference/fetch_github_contrib.md - - reference/load_dataset.md - -extra_css: - - stylesheets/style.css - -markdown_extensions: - - pymdownx.tabbed: - alternate_style: true - - pymdownx.highlight: - anchor_linenums: true - line_spans: __span - pygments_lang_class: true - - pymdownx.inlinehilite - - pymdownx.snippets - - pymdownx.superfences - - attr_list - - md_in_html - - pymdownx.blocks.caption - - admonition - - pymdownx.details - - pymdownx.critic - - pymdownx.caret - - pymdownx.keys - - pymdownx.mark - - pymdownx.tilde diff --git a/pyproject.toml b/pyproject.toml index 8ddfe37..a3e0aaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,9 +36,13 @@ dev = [ "plotjs>=0.0.9", "polars>=1.31.0", "prek>=0.4.6", + "pyrefly>=1.1.1", "pytest>=8.3.5", "ruff>=0.11.13", "ty>=0.0.1a20", + "types-markdown>=3.10.2.20260518", + "types-requests>=2.33.0.20260518", + "zensical>=0.0.46", ] [build-system] @@ -58,5 +62,8 @@ mypkg = ["sample.csv"] include = ["dayplot"] exclude = ["tests"] +[tool.pyrefly] +project-includes = ["dayplot"] + [tool.uv.sources] dayplot = { workspace = true } diff --git a/zensical.toml b/zensical.toml new file mode 100644 index 0000000..df1486e --- /dev/null +++ b/zensical.toml @@ -0,0 +1,139 @@ +[project] +site_name = "dayplot" +repo_url = "https://github.com/y-sunflower/dayplot" +site_url = "https://y-sunflower.github.io/dayplot/" +site_description = "Calendar heatmaps with matplotlib" +site_author = "Joseph Barbier" +extra_css = ["stylesheets/style.css"] +nav = [ + { "Home" = "index.md" }, + { "Contributing" = "contributing.md" }, + { "Guides" = [ + "tuto/built-in-styles.md", + "tuto/basic-styling.md", + "tuto/month-grid.md", + "tuto/negative-values.md", + "tuto/boxstyle.md", + "tuto/legend.md", + "tuto/interactive.md", + "tuto/combine-charts.md", + "tuto/fetch-github-contribs.md", + "tuto/advanced.md", + ] }, + { "Reference" = [ + "reference/calendar.md", + "reference/fetch_github_contrib.md", + "reference/load_dataset.md", + ] }, +] + +[project.theme] +language = "en" +custom_dir = "overrides" +features = [ + "announce.dismiss", + "content.code.annotate", + "content.code.copy", + "content.footnote.tooltips", + "content.tabs.link", + "content.tooltips", + "navigation.expand", + "navigation.footer", + "navigation.indexes", + "navigation.instant", + "navigation.instant.prefetch", + "navigation.path", + "navigation.sections", + "navigation.top", + "navigation.tracking", + "search.highlight", +] + +[[project.theme.palette]] +scheme = "default" +toggle.icon = "lucide/sun" +toggle.name = "Switch to dark mode" + +[[project.theme.palette]] +scheme = "slate" +toggle.icon = "lucide/moon" +toggle.name = "Switch to light mode" + +[project.theme.icon] +repo = "fontawesome/brands/github" + +[project.extra.analytics] +provider = "google" +property = "G-MLP58TWW3R" + +[project.plugins.mkdocstrings.handlers.python] +paths = ["."] + +[project.plugins.mkdocstrings.handlers.python.options] +show_source = false +show_root_heading = true +heading_level = 3 + +[project.markdown_extensions.abbr] + +[project.markdown_extensions.admonition] + +[project.markdown_extensions.attr_list] + +[project.markdown_extensions.def_list] + +[project.markdown_extensions.footnotes] + +[project.markdown_extensions.md_in_html] + +[project.markdown_extensions.toc] +permalink = true + +[project.markdown_extensions."pymdownx.arithmatex"] +generic = true + +[project.markdown_extensions."pymdownx.betterem"] + +[project.markdown_extensions."pymdownx.blocks.caption"] + +[project.markdown_extensions."pymdownx.caret"] + +[project.markdown_extensions."pymdownx.critic"] + +[project.markdown_extensions."pymdownx.details"] + +[project.markdown_extensions."pymdownx.emoji"] +emoji_generator = "zensical.extensions.emoji.to_svg" +emoji_index = "zensical.extensions.emoji.twemoji" + +[project.markdown_extensions."pymdownx.highlight"] +anchor_linenums = true +line_spans = "__span" +pygments_lang_class = true + +[project.markdown_extensions."pymdownx.inlinehilite"] + +[project.markdown_extensions."pymdownx.keys"] + +[project.markdown_extensions."pymdownx.magiclink"] + +[project.markdown_extensions."pymdownx.mark"] + +[project.markdown_extensions."pymdownx.smartsymbols"] + +[project.markdown_extensions."pymdownx.snippets"] + +[[project.markdown_extensions."pymdownx.superfences".custom_fences]] +name = "mermaid" +class = "mermaid" + +[project.markdown_extensions."pymdownx.tabbed"] +alternate_style = true +combine_header_slug = true + +[project.markdown_extensions."pymdownx.tasklist"] +custom_checkbox = true + +[project.markdown_extensions."pymdownx.tilde"] + +[project.markdown_extensions."dayplot._docs_matplotlib"] From fa5109afab0fd042017337acf7cc02b9bf1992b9 Mon Sep 17 00:00:00 2001 From: Barbier--Darnal Joseph Date: Thu, 2 Jul 2026 14:33:41 +0200 Subject: [PATCH 3/9] fix type checking + test on 3.14 --- .github/workflows/unit-test.yaml | 2 +- dayplot/calendar.py | 14 ++++++++------ pyproject.toml | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/unit-test.yaml b/.github/workflows/unit-test.yaml index ec0b679..f33bf54 100644 --- a/.github/workflows/unit-test.yaml +++ b/.github/workflows/unit-test.yaml @@ -11,7 +11,7 @@ jobs: strategy: matrix: - python-version: [3.9, 3.13] + python-version: [3.9, 3.14] steps: - name: Checkout code diff --git a/dayplot/calendar.py b/dayplot/calendar.py index cb8d41d..e8fb5e4 100644 --- a/dayplot/calendar.py +++ b/dayplot/calendar.py @@ -325,7 +325,7 @@ def calendar( ax.add_patch(rect) rect_patches.append(rect) - month_text_style = dict(ha="left", va="top", size=10) + month_text_style: dict[str, Any] = dict(ha="left", va="top", size=10) month_text_style.update(month_kws) month_starts = [ @@ -337,7 +337,7 @@ def calendar( week_of_month + 0.1, 7 + month_y_margin, m_start.strftime("%b"), - **month_text_style, # type: ignore[invalid-argument-type] + **month_text_style, ) ax.spines[["top", "right", "left", "bottom"]].set_visible(False) @@ -348,7 +348,7 @@ def calendar( ax.invert_yaxis() ax.set_aspect("equal") - day_text_style = dict( + day_text_style: dict[str, Any] = dict( transform=ax.get_yaxis_transform(), ha="left", va="center", size=10 ) day_text_style.update(day_kws) @@ -358,7 +358,7 @@ def calendar( labels = [day_abbr[(cal.firstweekday + i) % 7] for i in range(7)] for y_tick, day_label in zip(ticks, labels): - ax.text(-day_x_margin, y_tick, day_label, **day_text_style) # type: ignore[invalid-argument-type] + ax.text(-day_x_margin, y_tick, day_label, **day_text_style) if month_grid: # vertical grid around data within each months @@ -444,7 +444,9 @@ def calendar( else: legend_label = str(legend_labels[i]) - legend_labels_style = dict(size=7, ha="center", va="bottom") + legend_labels_style: dict[str, Any] = dict( + size=7, ha="center", va="bottom" + ) legend_labels_style.update(legend_labels_kws) ax.annotate( legend_label, @@ -452,7 +454,7 @@ def calendar( xycoords=rect, xytext=(0, 1), textcoords="offset points", - **legend_labels_style, # type: ignore[invalid-argument-type] + **legend_labels_style, ) ax.annotate( diff --git a/pyproject.toml b/pyproject.toml index a3e0aaf..9a5a0bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dev = [ "pyrefly>=1.1.1", "pytest>=8.3.5", "ruff>=0.11.13", - "ty>=0.0.1a20", + "ty==0.0.56", "types-markdown>=3.10.2.20260518", "types-requests>=2.33.0.20260518", "zensical>=0.0.46", From d737e73ebd59d10a00056f2921ddbe251abcdd1c Mon Sep 17 00:00:00 2001 From: Barbier--Darnal Joseph Date: Thu, 2 Jul 2026 15:01:51 +0200 Subject: [PATCH 4/9] add support for categorical variables --- dayplot/calendar.py | 331 +++++++++++++++++++++++++++++++++++--------- tests/test_main.py | 142 ++++++++++++++++++- 2 files changed, 404 insertions(+), 69 deletions(-) diff --git a/dayplot/calendar.py b/dayplot/calendar.py index e8fb5e4..ae87112 100644 --- a/dayplot/calendar.py +++ b/dayplot/calendar.py @@ -1,5 +1,6 @@ import matplotlib.pyplot as plt import matplotlib.patches as patches +import matplotlib.colors as mcolors from matplotlib.path import Path from matplotlib.colors import LinearSegmentedColormap, Normalize, TwoSlopeNorm from matplotlib.axes import Axes @@ -8,9 +9,11 @@ from calendar import Calendar, day_name, day_abbr from collections import defaultdict +from collections.abc import Mapping, Sequence from datetime import date, datetime, timedelta from itertools import chain -from typing import List, Union, Optional, Dict, Any, Literal +from numbers import Real +from typing import List, Union, Optional, Dict, Any, Literal, cast import warnings from dayplot.utils import _parse_date, date_range, relative_date_add @@ -32,6 +35,115 @@ "darrow", ] +_MISSING = object() + + +class _DefaultArg: + def __init__(self, value): + self.value = value + + def __repr__(self): + return repr(self.value) + + +_DEFAULT_CMAP = _DefaultArg("Greens") +_DEFAULT_VMIN = _DefaultArg(None) +_DEFAULT_VMAX = _DefaultArg(None) +_DEFAULT_VCENTER = _DefaultArg(None) +_DEFAULT_LEGEND_BINS = _DefaultArg(4) +_DEFAULT_LESS_LABEL = _DefaultArg("Less") +_DEFAULT_MORE_LABEL = _DefaultArg("More") + + +def _is_numeric_value(value: Any) -> bool: + return isinstance(value, Real) and not isinstance(value, bool) + + +def _is_numeric_values(values) -> bool: + return all(_is_numeric_value(value) for value in values) + + +def _unique_values_in_order(values) -> list[Any]: + unique_values = [] + seen = set() + for value in values: + if value not in seen: + seen.add(value) + unique_values.append(value) + return unique_values + + +def _validate_categorical_arguments( + cmap: Any, + vmin: Any, + vmax: Any, + vcenter: Any, + legend_bins: Any, + less_label: Any, + more_label: Any, +) -> None: + invalid_args = [] + if cmap is not _DEFAULT_CMAP: + invalid_args.append("cmap") + if vmin is not _DEFAULT_VMIN: + invalid_args.append("vmin") + if vmax is not _DEFAULT_VMAX: + invalid_args.append("vmax") + if vcenter is not _DEFAULT_VCENTER: + invalid_args.append("vcenter") + if legend_bins is not _DEFAULT_LEGEND_BINS: + invalid_args.append("legend_bins") + if less_label is not _DEFAULT_LESS_LABEL: + invalid_args.append("less_label") + if more_label is not _DEFAULT_MORE_LABEL: + invalid_args.append("more_label") + + if invalid_args: + invalid_args_text = ", ".join(f"`{arg}`" for arg in invalid_args) + raise ValueError( + f"{invalid_args_text} cannot be used when `values` contains categorical data." + ) + + +def _validate_colors(colors: Any, categories: list[Any]) -> dict[Any, Any]: + if colors is None: + tab10 = plt.get_cmap("tab10") + colors = [tab10(i) for i in range(10)] + + if isinstance(colors, Mapping): + missing_categories = [ + category for category in categories if category not in colors + ] + if missing_categories: + missing_text = ", ".join(repr(category) for category in missing_categories) + raise ValueError( + "`colors` is missing colors for the following categories: " + f"{missing_text}." + ) + + color_map = {category: colors[category] for category in categories} + elif isinstance(colors, Sequence) and not isinstance(colors, str): + if len(colors) < len(categories): + raise ValueError( + "`colors` must contain at least as many colors as observed categories." + ) + + color_map = dict(zip(categories, colors)) + else: + raise ValueError( + "`colors` must be a mapping of category to color or a sequence of colors." + ) + + for category, color in color_map.items(): + try: + mcolors.to_rgba(color) + except ValueError as exc: + raise ValueError( + f"Invalid color {color!r} for category {category!r}." + ) from exc + + return color_map + def _validate_inputs(boxstyle, dates, values): if isinstance(boxstyle, str): @@ -67,7 +179,7 @@ def _validate_cmap(cmap: Union[str, LinearSegmentedColormap]) -> Colormap: def _get_start_and_end_dates( - date_counts: dict[date, float], + date_counts: dict[date, Any], start_date: Union[datetime, str, date, None], end_date: Union[datetime, str, date, None], ) -> tuple[date, date]: @@ -133,29 +245,30 @@ def calendar_week(cal: Calendar, date: date) -> list[date]: def calendar( dates: List[Union[date, datetime, str]], - values: List[Union[int, float]], + values: List[Any], start_date: Optional[Union[date, datetime, str]] = None, end_date: Optional[Union[date, datetime, str]] = None, color_for_none: Optional[str] = None, edgecolor: str = "black", edgewidth: float = 0.0, - cmap: Union[str, LinearSegmentedColormap] = "Greens", + colors: Optional[Union[Dict[Any, Any], List[Any]]] = None, + cmap: Any = _DEFAULT_CMAP, week_starts_on: str = "Sunday", month_kws: Optional[Dict] = None, day_kws: Optional[Dict] = None, day_x_margin: float = 0.02, month_y_margin: float = 0.4, - vmin: Optional[float] = None, - vmax: Optional[float] = None, - vcenter: Optional[float] = None, + vmin: Any = _DEFAULT_VMIN, + vmax: Any = _DEFAULT_VMAX, + vcenter: Any = _DEFAULT_VCENTER, boxstyle: Union[str, patches.BoxStyle] = "square", legend: bool = False, - legend_bins: int = 4, + legend_bins: Any = _DEFAULT_LEGEND_BINS, legend_labels: Optional[Union[List, Literal["auto"]]] = None, legend_labels_precision: Optional[int] = None, legend_labels_kws: Optional[Dict] = None, - less_label: str = "Less", - more_label: str = "More", + less_label: Any = _DEFAULT_LESS_LABEL, + more_label: Any = _DEFAULT_MORE_LABEL, month_grid: bool = False, month_grid_kws: Dict = {}, clip_on: bool = False, @@ -179,9 +292,9 @@ def calendar( Args: dates: A list of date-like objects (e.g., datetime.date, datetime.datetime, or strings in "YYYY-MM-DD" format). Must have the same length as values. - values: A list of numeric values corresponding to each date in dates. These - values represent contributions or counts for each day and must have the same - length as dates. + values: A list of numeric or categorical values corresponding to each date in + dates. Numeric values are aggregated by summing duplicate dates. Categorical + values use the last value for duplicate dates. start_date: The earliest date to display on the chart. Can be a date, datetime, or a string in "YYYY-MM-DD" format. If not provided, the minimum date found in `dates` will be used. @@ -193,9 +306,12 @@ def calendar( has negative values. edgecolor: Color of the edges for each day's rectangle. edgewidth: Line width for the edges of each day's rectangle. + colors: Colors to use when `values` contains categorical data. Can be a mapping + of category to color or a list of colors assigned in first-appearance order. + If None, categorical data uses the Matplotlib "tab10" palette. cmap: A valid Matplotlib colormap name or a LinearSegmentedColormap instance. The colormap is used to determine the fill color intensity of each day's - cell based on its value. + cell based on its value. This only applies to numeric values. week_starts_on: The starting day of the week, which can be specified as a string ("Sunday", "Monday", ..., "Saturday"). month_kws: Additional keyword arguments passed to the matplotlib.axes.Axes.text function @@ -218,9 +334,10 @@ def calendar( boxstyle: The style of each box. This will be passed to `matplotlib.patches.FancyBboxPatch`. Available values are: "square", "circle", "ellipse", "larrow" legend: Whether to display a legend for the color scale. - legend_bins: Number of boxes/steps to display in the legend. + legend_bins: Number of boxes/steps to display in the numeric legend. legend_labels: Labels for the legend boxes. Can be a list of strings or "auto" - to generate labels from the data values. + to generate labels from the data values. For categorical legends, None and + "auto" display category labels, and a list overrides them in category order. legend_labels_precision: Number of decimal places to round legend labels when `legend_labels="auto"`. legend_labels_kws: Additional keyword arguments passed to Axes.annotate function when @@ -242,10 +359,39 @@ def calendar( A list of `matplotlib.patches.FancyBboxPatch` (one for each cell). Notes: - The function aggregates multiple entries for the same date by summing their values. + The function aggregates multiple numeric entries for the same date by summing + their values. For categorical data, the last entry for a date is used. """ _validate_inputs(boxstyle, dates, values) - validated_cmap = _validate_cmap(cmap) + is_categorical = not _is_numeric_values(values) + + if is_categorical: + _validate_categorical_arguments( + cmap, vmin, vmax, vcenter, legend_bins, less_label, more_label + ) + validated_cmap = _validate_cmap(_DEFAULT_CMAP.value) + else: + cmap = _DEFAULT_CMAP.value if cmap is _DEFAULT_CMAP else cmap + vmin = _DEFAULT_VMIN.value if vmin is _DEFAULT_VMIN else vmin + vmax = _DEFAULT_VMAX.value if vmax is _DEFAULT_VMAX else vmax + vcenter = _DEFAULT_VCENTER.value if vcenter is _DEFAULT_VCENTER else vcenter + legend_bins = ( + _DEFAULT_LEGEND_BINS.value + if legend_bins is _DEFAULT_LEGEND_BINS + else legend_bins + ) + less_label = ( + _DEFAULT_LESS_LABEL.value + if less_label is _DEFAULT_LESS_LABEL + else less_label + ) + more_label = ( + _DEFAULT_MORE_LABEL.value + if more_label is _DEFAULT_MORE_LABEL + else more_label + ) + validated_cmap = _validate_cmap(cmap) + cal = Calendar([*day_name].index(week_starts_on)) month_kws = month_kws or {} @@ -253,9 +399,21 @@ def calendar( legend_labels_kws = legend_labels_kws or {} ax = ax or plt.gca() - date_counts: defaultdict[date, float] = defaultdict(float) - for d, v in zip(dates, values): - date_counts[_parse_date(d)] += v + category_order: list[Any] = [] + categories: list[Any] = [] + color_map: dict[Any, Any] = {} + is_diverging = False + norm: Any = None + + if is_categorical: + date_counts = {} + category_order = _unique_values_in_order(values) + for d, v in zip(dates, values): + date_counts[_parse_date(d)] = v + else: + date_counts = defaultdict(float) + for d, v in zip(dates, values): + date_counts[_parse_date(d)] += cast(float, v) start_date, end_date = _get_start_and_end_dates(date_counts, start_date, end_date) cal_start_date = calendar_week(cal, start_date)[0] @@ -268,49 +426,71 @@ def calendar( for d in full_range: week_index = (d - cal_start_date).days // 7 day_of_week = (d.weekday() - cal.firstweekday) % 7 - count = date_counts.get(d, 0) + count = date_counts.get(d, _MISSING if is_categorical else 0) data_for_plot.append((week_index, day_of_week, count)) total_weeks = (cal_end_date - cal_start_date).days // 7 + 1 - all_counts = np.array(list(date_counts.values())) - min_count, max_count = all_counts.min(), all_counts.max() + if is_categorical: + observed_categories = { + count for _, _, count in data_for_plot if count is not _MISSING + } + categories = [ + category for category in category_order if category in observed_categories + ] + color_map = _validate_colors(colors, categories) + else: + all_counts = np.array(list(date_counts.values())) + min_count, max_count = all_counts.min(), all_counts.max() - if vmin is None: - vmin = min_count - if vmax is None: - vmax = max_count if max_count != 0 else 1 + if vmin is None: + vmin = min_count + if vmax is None: + vmax = max_count if max_count != 0 else 1 - if vcenter is not None: - is_diverging = True - norm = TwoSlopeNorm(vmin=vmin, vcenter=vcenter, vmax=vmax) - else: - # If we have both negative and positive values, use a diverging - # scale with a center of 0. Otherwise, use a simple Normalize. - if min_count < 0 < max_count: + if vcenter is not None: is_diverging = True - norm = TwoSlopeNorm(vmin=vmin, vcenter=0, vmax=vmax) + norm = TwoSlopeNorm( + vmin=cast(float, vmin), + vcenter=cast(float, vcenter), + vmax=cast(float, vmax), + ) else: - is_diverging = False - norm = Normalize(vmin=vmin, vmax=vmax) + # If we have both negative and positive values, use a diverging + # scale with a center of 0. Otherwise, use a simple Normalize. + if min_count < 0 < max_count: + is_diverging = True + norm = TwoSlopeNorm( + vmin=cast(float, vmin), vcenter=0, vmax=cast(float, vmax) + ) + else: + is_diverging = False + norm = Normalize(vmin=cast(float, vmin), vmax=cast(float, vmax)) rect_patches = [] for week, weekday, count in data_for_plot: - if is_diverging: + if is_categorical: + if count is _MISSING: + if color_for_none is None: + color_for_none = "#e8e8e8" + face_color = color_for_none + else: + face_color = color_map[count] + elif is_diverging: if color_for_none is not None: warnings.warn( "`color_for_none` argument is ignored when `values` " "argument contains negative values.", UserWarning, ) - face_color = validated_cmap(norm(count)) + face_color = validated_cmap(norm(cast(float, count))) elif not is_diverging: if count == 0: if color_for_none is None: color_for_none = "#e8e8e8" face_color = color_for_none else: - face_color = validated_cmap(norm(count)) + face_color = validated_cmap(norm(cast(float, count))) rect = patches.FancyBboxPatch( xy=(week + 0.35, weekday + 0.35), @@ -416,12 +596,24 @@ def calendar( if legend: legend_rects = [] - legend_values = np.linspace(vmin, vmax, legend_bins) + if is_categorical: + legend_values = categories + else: + legend_values = np.linspace( + cast(float, vmin), cast(float, vmax), cast(int, legend_bins) + ) + for i, val in enumerate(legend_values): - if is_diverging: - color = validated_cmap(norm(val)) + if is_categorical: + color = color_map[val] + elif is_diverging: + color = validated_cmap(norm(cast(float, val))) else: - color = color_for_none if val == 0 else validated_cmap(norm(val)) + color = ( + color_for_none + if val == 0 + else validated_cmap(norm(cast(float, val))) + ) legend_xloc = total_weeks - len(legend_values) + i rect = patches.FancyBboxPatch( @@ -438,11 +630,13 @@ def calendar( ax.add_patch(rect) legend_rects.append(rect) - if legend_labels is not None: - if legend_labels == "auto": + if is_categorical or legend_labels is not None: + if is_categorical and legend_labels in (None, "auto"): + legend_label = str(val) + elif legend_labels == "auto": legend_label = round(val, ndigits=legend_labels_precision) else: - legend_label = str(legend_labels[i]) + legend_label = str(cast(List, legend_labels)[i]) legend_labels_style: dict[str, Any] = dict( size=7, ha="center", va="bottom" @@ -457,25 +651,26 @@ def calendar( **legend_labels_style, ) - ax.annotate( - less_label, - xy=(0, 0.5), - xycoords=legend_rects[0], - xytext=(-5, 0), - textcoords="offset points", - va="center", - ha="right", - size=8, - ) - ax.annotate( - more_label, - xy=(1, 0.5), - xycoords=legend_rects[-1], - xytext=(5, 0), - textcoords="offset points", - va="center", - ha="left", - size=8, - ) + if not is_categorical: + ax.annotate( + less_label, + xy=(0, 0.5), + xycoords=legend_rects[0], + xytext=(-5, 0), + textcoords="offset points", + va="center", + ha="right", + size=8, + ) + ax.annotate( + more_label, + xy=(1, 0.5), + xycoords=legend_rects[-1], + xytext=(5, 0), + textcoords="offset points", + va="center", + ha="left", + size=8, + ) return rect_patches diff --git a/tests/test_main.py b/tests/test_main.py index 3aa4e72..2253bc1 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -3,7 +3,7 @@ import matplotlib from matplotlib.patches import PathPatch import matplotlib.pyplot as plt -from matplotlib.colors import LinearSegmentedColormap +from matplotlib.colors import LinearSegmentedColormap, to_rgba from datetime import datetime, timedelta import string @@ -213,6 +213,146 @@ def test_diffrerent_df_backends(backend): assert len(patches) == 7 +def test_calendar_categorical_default_colors(): + """Test categorical values use the default tab10 colors.""" + dates = [datetime(2024, 1, 1) + timedelta(days=i) for i in range(3)] + values = ["work", "rest", "work"] + fig, ax = plt.subplots() + + patches = calendar(dates, values, ax=ax) + tab10 = plt.get_cmap("tab10").colors + + assert patches[0].get_facecolor() == to_rgba(tab10[0]) + assert patches[1].get_facecolor() == to_rgba(tab10[1]) + assert patches[2].get_facecolor() == to_rgba(tab10[0]) + + plt.close("all") + + +def test_calendar_categorical_dict_colors(): + """Test categorical values can use an explicit category-color mapping.""" + dates = [datetime(2024, 1, 1) + timedelta(days=i) for i in range(3)] + values = ["work", "rest", "work"] + fig, ax = plt.subplots() + + patches = calendar( + dates, + values, + colors={"work": "#0f766e", "rest": "#f59e0b"}, + ax=ax, + ) + + assert patches[0].get_facecolor() == to_rgba("#0f766e") + assert patches[1].get_facecolor() == to_rgba("#f59e0b") + assert patches[2].get_facecolor() == to_rgba("#0f766e") + + plt.close("all") + + +def test_calendar_categorical_list_colors_and_legend_order(): + """Test list colors are assigned and shown in first-appearance order.""" + dates = [datetime(2024, 1, 1) + timedelta(days=i) for i in range(3)] + values = ["low", "high", "medium"] + fig, ax = plt.subplots() + + rects = calendar( + dates, + values, + colors=["#ef4444", "#22c55e", "#3b82f6"], + legend=True, + ax=ax, + ) + + assert rects[0].get_facecolor() == to_rgba("#ef4444") + assert rects[1].get_facecolor() == to_rgba("#22c55e") + assert rects[2].get_facecolor() == to_rgba("#3b82f6") + + legend_rects = ax.patches[len(rects) :] + assert [rect.get_facecolor() for rect in legend_rects] == [ + to_rgba("#ef4444"), + to_rgba("#22c55e"), + to_rgba("#3b82f6"), + ] + assert [text.get_text() for text in ax.texts[-3:]] == ["low", "high", "medium"] + + plt.close("all") + + +def test_calendar_categorical_duplicate_dates_last_wins(): + """Test duplicate categorical dates use the last value.""" + dates = [datetime(2024, 1, 1), datetime(2024, 1, 1)] + values = ["work", "rest"] + fig, ax = plt.subplots() + + patches = calendar( + dates, + values, + colors={"work": "#0f766e", "rest": "#f59e0b"}, + ax=ax, + ) + + assert len(patches) == 1 + assert patches[0].get_facecolor() == to_rgba("#f59e0b") + + plt.close("all") + + +def test_calendar_categorical_custom_legend_labels(): + """Test categorical legend labels can be overridden.""" + dates = [datetime(2024, 1, 1) + timedelta(days=i) for i in range(2)] + values = ["work", "rest"] + fig, ax = plt.subplots() + + calendar( + dates, + values, + colors={"work": "#0f766e", "rest": "#f59e0b"}, + legend=True, + legend_labels=["Working", "Resting"], + ax=ax, + ) + + assert [text.get_text() for text in ax.texts[-2:]] == ["Working", "Resting"] + + plt.close("all") + + +def test_calendar_categorical_colors_too_short(): + """Test categorical list colors must cover all observed categories.""" + dates = [datetime(2024, 1, 1) + timedelta(days=i) for i in range(2)] + values = ["work", "rest"] + fig, ax = plt.subplots() + + with pytest.raises(ValueError, match="at least as many colors"): + calendar(dates, values, colors=["#0f766e"], ax=ax) + + plt.close("all") + + +@pytest.mark.parametrize( + "kwargs", + [ + {"cmap": "Reds"}, + {"vmin": 0}, + {"vmax": 10}, + {"vcenter": 5}, + {"legend_bins": 3}, + {"less_label": "Low"}, + {"more_label": "High"}, + ], +) +def test_calendar_categorical_rejects_numeric_only_arguments(kwargs): + """Test numeric-only arguments raise for categorical values.""" + dates = [datetime(2024, 1, 1)] + values = ["work"] + fig, ax = plt.subplots() + + with pytest.raises(ValueError, match="categorical data"): + calendar(dates, values, ax=ax, **kwargs) + + plt.close("all") + + @pytest.mark.parametrize("legend", [True, False]) @pytest.mark.parametrize("legend_bins", [2, 4, 10]) @pytest.mark.parametrize("legend_labels", [None, "auto", []]) From b96b4c11049f34dd99863bf4d9bb4ccb8b2be122 Mon Sep 17 00:00:00 2001 From: Barbier--Darnal Joseph Date: Thu, 2 Jul 2026 15:05:15 +0200 Subject: [PATCH 5/9] add guide example for categorical charts --- docs/tuto/categorical-data.md | 141 ++++++++++++++++++++++++++++++++++ zensical.toml | 1 + 2 files changed, 142 insertions(+) create mode 100644 docs/tuto/categorical-data.md diff --git a/docs/tuto/categorical-data.md b/docs/tuto/categorical-data.md new file mode 100644 index 0000000..eedb61f --- /dev/null +++ b/docs/tuto/categorical-data.md @@ -0,0 +1,141 @@ +### Plot categorical values + +`dayplot` automatically switches to categorical colors when `values` contains non-numeric data. + +In this mode, each category gets its own color instead of being mapped through a colormap. + +```py +# mkdocs: render +import matplotlib.pyplot as plt +import dayplot as dp + +dates = [ + "2024-01-01", "2024-01-02", "2024-01-03", "2024-01-04", "2024-01-05", + "2024-01-08", "2024-01-09", "2024-01-10", "2024-01-11", "2024-01-12", + "2024-01-15", "2024-01-16", "2024-01-17", "2024-01-18", "2024-01-19", +] +values = [ + "office", "office", "remote", "remote", "off", + "office", "remote", "office", "remote", "off", + "remote", "office", "office", "remote", "off", +] + +fig, ax = plt.subplots(figsize=(10, 3)) +dp.calendar( + dates=dates, + values=values, + start_date="2024-01-01", + end_date="2024-01-21", + legend=True, + ax=ax, +) +``` + +### Use your own colors + +Use the `colors` argument to control the color for each category. + +The safest option is to pass a dictionary, where each category is explicitly associated with a color. + +```py hl_lines="24 25 26 27 28" +# mkdocs: render +import matplotlib.pyplot as plt +import dayplot as dp + +dates = [ + "2024-01-01", "2024-01-02", "2024-01-03", "2024-01-04", "2024-01-05", + "2024-01-08", "2024-01-09", "2024-01-10", "2024-01-11", "2024-01-12", + "2024-01-15", "2024-01-16", "2024-01-17", "2024-01-18", "2024-01-19", +] +values = [ + "office", "office", "remote", "remote", "off", + "office", "remote", "office", "remote", "off", + "remote", "office", "office", "remote", "off", +] + +fig, ax = plt.subplots(figsize=(10, 3)) +dp.calendar( + dates=dates, + values=values, + start_date="2024-01-01", + end_date="2024-01-21", + colors={ + "office": "#2563eb", + "remote": "#16a34a", + "off": "#f97316", + }, + legend=True, + ax=ax, +) +``` + +You can also pass a list of colors. Colors are assigned in the order categories first appear in `values`. + +```py hl_lines="21" +# mkdocs: render +import matplotlib.pyplot as plt +import dayplot as dp + +dates = [ + "2024-01-01", "2024-01-02", "2024-01-03", "2024-01-04", "2024-01-05", + "2024-01-08", "2024-01-09", "2024-01-10", "2024-01-11", "2024-01-12", + "2024-01-15", "2024-01-16", "2024-01-17", "2024-01-18", "2024-01-19", +] +values = [ + "office", "office", "remote", "remote", "off", + "office", "remote", "office", "remote", "off", + "remote", "office", "office", "remote", "off", +] + +fig, ax = plt.subplots(figsize=(10, 3)) +dp.calendar( + dates=dates, + values=values, + colors=["#2563eb", "#16a34a", "#f97316"], + start_date="2024-01-01", + end_date="2024-01-21", + legend=True, + ax=ax, +) +``` + +### Customize the legend labels + +For categorical data, `legend=True` displays one box per category. + +By default, the labels are the category names. You can override them with `legend_labels`. + +```py hl_lines="30" +# mkdocs: render +import matplotlib.pyplot as plt +import dayplot as dp + +dates = [ + "2024-01-01", "2024-01-02", "2024-01-03", "2024-01-04", "2024-01-05", + "2024-01-08", "2024-01-09", "2024-01-10", "2024-01-11", "2024-01-12", + "2024-01-15", "2024-01-16", "2024-01-17", "2024-01-18", "2024-01-19", +] +values = [ + "office", "office", "remote", "remote", "off", + "office", "remote", "office", "remote", "off", + "remote", "office", "office", "remote", "off", +] + +fig, ax = plt.subplots(figsize=(10, 3)) +dp.calendar( + dates=dates, + values=values, + start_date="2024-01-01", + end_date="2024-01-21", + colors={ + "office": "#2563eb", + "remote": "#16a34a", + "off": "#f97316", + }, + legend=True, + legend_labels=["Office", "Remote", "Day off"], + ax=ax, +) +``` + +

diff --git a/zensical.toml b/zensical.toml index df1486e..5ae98c3 100644 --- a/zensical.toml +++ b/zensical.toml @@ -11,6 +11,7 @@ nav = [ { "Guides" = [ "tuto/built-in-styles.md", "tuto/basic-styling.md", + "tuto/categorical-data.md", "tuto/month-grid.md", "tuto/negative-values.md", "tuto/boxstyle.md", From 0a47d13699dbe6a037e9a665dbaeda233ecb77ee Mon Sep 17 00:00:00 2001 From: Barbier--Darnal Joseph Date: Thu, 2 Jul 2026 15:09:15 +0200 Subject: [PATCH 6/9] improve categorical data example --- docs/tuto/categorical-data.md | 138 +++++++++++++++++----------------- 1 file changed, 67 insertions(+), 71 deletions(-) diff --git a/docs/tuto/categorical-data.md b/docs/tuto/categorical-data.md index eedb61f..7cdf265 100644 --- a/docs/tuto/categorical-data.md +++ b/docs/tuto/categorical-data.md @@ -9,23 +9,21 @@ In this mode, each category gets its own color instead of being mapped through a import matplotlib.pyplot as plt import dayplot as dp -dates = [ - "2024-01-01", "2024-01-02", "2024-01-03", "2024-01-04", "2024-01-05", - "2024-01-08", "2024-01-09", "2024-01-10", "2024-01-11", "2024-01-12", - "2024-01-15", "2024-01-16", "2024-01-17", "2024-01-18", "2024-01-19", -] -values = [ - "office", "office", "remote", "remote", "off", - "office", "remote", "office", "remote", "off", - "remote", "office", "office", "remote", "off", -] - -fig, ax = plt.subplots(figsize=(10, 3)) +df = dp.load_dataset() +df["activity"] = df["values"].map({ + 1: "Focus", + 2: "Meeting", + 3: "Writing", + 4: "Review", + 5: "Admin", +}) + +fig, ax = plt.subplots(figsize=(16, 4)) dp.calendar( - dates=dates, - values=values, + dates=df["dates"], + values=df["activity"], start_date="2024-01-01", - end_date="2024-01-21", + end_date="2024-12-31", legend=True, ax=ax, ) @@ -37,32 +35,32 @@ Use the `colors` argument to control the color for each category. The safest option is to pass a dictionary, where each category is explicitly associated with a color. -```py hl_lines="24 25 26 27 28" +```py hl_lines="18 19 20 21 22 23 24" # mkdocs: render import matplotlib.pyplot as plt import dayplot as dp -dates = [ - "2024-01-01", "2024-01-02", "2024-01-03", "2024-01-04", "2024-01-05", - "2024-01-08", "2024-01-09", "2024-01-10", "2024-01-11", "2024-01-12", - "2024-01-15", "2024-01-16", "2024-01-17", "2024-01-18", "2024-01-19", -] -values = [ - "office", "office", "remote", "remote", "off", - "office", "remote", "office", "remote", "off", - "remote", "office", "office", "remote", "off", -] - -fig, ax = plt.subplots(figsize=(10, 3)) +df = dp.load_dataset() +df["activity"] = df["values"].map({ + 1: "Focus", + 2: "Meeting", + 3: "Writing", + 4: "Review", + 5: "Admin", +}) + +fig, ax = plt.subplots(figsize=(16, 4)) dp.calendar( - dates=dates, - values=values, + dates=df["dates"], + values=df["activity"], start_date="2024-01-01", - end_date="2024-01-21", + end_date="2024-12-31", colors={ - "office": "#2563eb", - "remote": "#16a34a", - "off": "#f97316", + "Focus": "#2563eb", + "Meeting": "#f97316", + "Writing": "#16a34a", + "Review": "#9333ea", + "Admin": "#dc2626", }, legend=True, ax=ax, @@ -71,29 +69,27 @@ dp.calendar( You can also pass a list of colors. Colors are assigned in the order categories first appear in `values`. -```py hl_lines="21" +```py hl_lines="18" # mkdocs: render import matplotlib.pyplot as plt import dayplot as dp -dates = [ - "2024-01-01", "2024-01-02", "2024-01-03", "2024-01-04", "2024-01-05", - "2024-01-08", "2024-01-09", "2024-01-10", "2024-01-11", "2024-01-12", - "2024-01-15", "2024-01-16", "2024-01-17", "2024-01-18", "2024-01-19", -] -values = [ - "office", "office", "remote", "remote", "off", - "office", "remote", "office", "remote", "off", - "remote", "office", "office", "remote", "off", -] - -fig, ax = plt.subplots(figsize=(10, 3)) +df = dp.load_dataset() +df["activity"] = df["values"].map({ + 1: "Focus", + 2: "Meeting", + 3: "Writing", + 4: "Review", + 5: "Admin", +}) + +fig, ax = plt.subplots(figsize=(16, 4)) dp.calendar( - dates=dates, - values=values, - colors=["#2563eb", "#16a34a", "#f97316"], + dates=df["dates"], + values=df["activity"], + colors=["#16a34a", "#9333ea", "#2563eb", "#dc2626", "#f97316"], start_date="2024-01-01", - end_date="2024-01-21", + end_date="2024-12-31", legend=True, ax=ax, ) @@ -105,35 +101,35 @@ For categorical data, `legend=True` displays one box per category. By default, the labels are the category names. You can override them with `legend_labels`. -```py hl_lines="30" +```py hl_lines="27" # mkdocs: render import matplotlib.pyplot as plt import dayplot as dp -dates = [ - "2024-01-01", "2024-01-02", "2024-01-03", "2024-01-04", "2024-01-05", - "2024-01-08", "2024-01-09", "2024-01-10", "2024-01-11", "2024-01-12", - "2024-01-15", "2024-01-16", "2024-01-17", "2024-01-18", "2024-01-19", -] -values = [ - "office", "office", "remote", "remote", "off", - "office", "remote", "office", "remote", "off", - "remote", "office", "office", "remote", "off", -] - -fig, ax = plt.subplots(figsize=(10, 3)) +df = dp.load_dataset() +df["activity"] = df["values"].map({ + 1: "Focus", + 2: "Meeting", + 3: "Writing", + 4: "Review", + 5: "Admin", +}) + +fig, ax = plt.subplots(figsize=(16, 4)) dp.calendar( - dates=dates, - values=values, + dates=df["dates"], + values=df["activity"], start_date="2024-01-01", - end_date="2024-01-21", + end_date="2024-12-31", colors={ - "office": "#2563eb", - "remote": "#16a34a", - "off": "#f97316", + "Focus": "#2563eb", + "Meeting": "#f97316", + "Writing": "#16a34a", + "Review": "#9333ea", + "Admin": "#dc2626", }, legend=True, - legend_labels=["Office", "Remote", "Day off"], + legend_labels=["Writing day", "Review day", "Focus day", "Admin day", "Meeting day"], ax=ax, ) ``` From 93c085ed93591c61f9e9b90206a347e04578cabc Mon Sep 17 00:00:00 2001 From: Barbier--Darnal Joseph Date: Thu, 2 Jul 2026 15:35:42 +0200 Subject: [PATCH 7/9] remove plotjs example --- docs/img/interactive.html | 4252 ------------------------------------- docs/index.md | 27 - 2 files changed, 4279 deletions(-) delete mode 100644 docs/img/interactive.html diff --git a/docs/img/interactive.html b/docs/img/interactive.html deleted file mode 100644 index d482954..0000000 --- a/docs/img/interactive.html +++ /dev/null @@ -1,4252 +0,0 @@ - - - - - Made with plotjs - - - - - - -
- - - - - - - - 2026-02-17T21:08:58.559852 - image/svg+xml - - - Matplotlib v3.10.8, https://matplotlib.org/ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - - - - diff --git a/docs/index.md b/docs/index.md index 13cdfc2..f87847f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -161,33 +161,6 @@ A simple-to-use Python library to build **calendar heatmaps** with ease. It's bu ) ``` -You can also make `dayplot` **interactive** thanks to [plotjs](https://y-sunflower.github.io/plotjs/): - -```py hl_lines="3 15" -import matplotlib.pyplot as plt -import dayplot as dp -from plotjs import PlotJS - -df = dp.load_dataset() - -fig, ax = plt.subplots(figsize=(15, 6)) -dp.calendar( - dates=df["dates"], - values=df["values"], - start_date="2024-01-01", - end_date="2024-12-31", - ax=ax, -) -PlotJS(fig).add_tooltip(labels=df["values"]).save("docs/img/interactive.html") -``` - -
- -
- [See more examples](./tuto/basic-styling.md) ## Installation From 2a30c8e1f58065d9d078a27e192be99cbdb00c27 Mon Sep 17 00:00:00 2001 From: Barbier--Darnal Joseph Date: Thu, 2 Jul 2026 15:46:59 +0200 Subject: [PATCH 8/9] fix legend on categorical data --- dayplot/calendar.py | 108 +++++++++++++++++++++++----------- docs/tuto/categorical-data.md | 5 +- tests/test_main.py | 46 +++++++++++++-- 3 files changed, 119 insertions(+), 40 deletions(-) diff --git a/dayplot/calendar.py b/dayplot/calendar.py index ae87112..fe41e66 100644 --- a/dayplot/calendar.py +++ b/dayplot/calendar.py @@ -595,18 +595,61 @@ def calendar( ax.add_patch(patch) if legend: - legend_rects = [] if is_categorical: - legend_values = categories - else: - legend_values = np.linspace( - cast(float, vmin), cast(float, vmax), cast(int, legend_bins) - ) + legend_handles = [] + legend_label_values = [] + for i, category in enumerate(categories): + if legend_labels in (None, "auto"): + legend_label = str(category) + else: + legend_label = str(cast(List, legend_labels)[i]) + + legend_handles.append( + patches.Patch( + facecolor=color_map[category], + edgecolor=edgecolor, + label=legend_label, + ) + ) + legend_label_values.append(legend_label) + + if legend_handles: + legend_text_props = { + key: value + for key, value in legend_labels_kws.items() + if key + not in { + "color", + "ha", + "horizontalalignment", + "va", + "verticalalignment", + } + } + legend_artist = ax.legend( + handles=legend_handles, + labels=legend_label_values, + loc="upper center", + bbox_to_anchor=(0.5, -0.08), + ncol=min(len(categories), 3), + frameon=False, + handlelength=1, + handletextpad=0.5, + columnspacing=1.2, + prop=legend_text_props or None, + ) + if "color" in legend_labels_kws: + for text in legend_artist.get_texts(): + text.set_color(legend_labels_kws["color"]) + return rect_patches + + legend_rects = [] + legend_values = np.linspace( + cast(float, vmin), cast(float, vmax), cast(int, legend_bins) + ) for i, val in enumerate(legend_values): - if is_categorical: - color = color_map[val] - elif is_diverging: + if is_diverging: color = validated_cmap(norm(cast(float, val))) else: color = ( @@ -630,10 +673,8 @@ def calendar( ax.add_patch(rect) legend_rects.append(rect) - if is_categorical or legend_labels is not None: - if is_categorical and legend_labels in (None, "auto"): - legend_label = str(val) - elif legend_labels == "auto": + if legend_labels is not None: + if legend_labels == "auto": legend_label = round(val, ndigits=legend_labels_precision) else: legend_label = str(cast(List, legend_labels)[i]) @@ -651,26 +692,25 @@ def calendar( **legend_labels_style, ) - if not is_categorical: - ax.annotate( - less_label, - xy=(0, 0.5), - xycoords=legend_rects[0], - xytext=(-5, 0), - textcoords="offset points", - va="center", - ha="right", - size=8, - ) - ax.annotate( - more_label, - xy=(1, 0.5), - xycoords=legend_rects[-1], - xytext=(5, 0), - textcoords="offset points", - va="center", - ha="left", - size=8, - ) + ax.annotate( + less_label, + xy=(0, 0.5), + xycoords=legend_rects[0], + xytext=(-5, 0), + textcoords="offset points", + va="center", + ha="right", + size=8, + ) + ax.annotate( + more_label, + xy=(1, 0.5), + xycoords=legend_rects[-1], + xytext=(5, 0), + textcoords="offset points", + va="center", + ha="left", + size=8, + ) return rect_patches diff --git a/docs/tuto/categorical-data.md b/docs/tuto/categorical-data.md index 7cdf265..d6a1eec 100644 --- a/docs/tuto/categorical-data.md +++ b/docs/tuto/categorical-data.md @@ -35,7 +35,7 @@ Use the `colors` argument to control the color for each category. The safest option is to pass a dictionary, where each category is explicitly associated with a color. -```py hl_lines="18 19 20 21 22 23 24" +```py hl_lines="20-26" # mkdocs: render import matplotlib.pyplot as plt import dayplot as dp @@ -100,6 +100,7 @@ dp.calendar( For categorical data, `legend=True` displays one box per category. By default, the labels are the category names. You can override them with `legend_labels`. +Categorical legends use Matplotlib's regular legend layout, which works better with longer labels. ```py hl_lines="27" # mkdocs: render @@ -129,7 +130,7 @@ dp.calendar( "Admin": "#dc2626", }, legend=True, - legend_labels=["Writing day", "Review day", "Focus day", "Admin day", "Meeting day"], + legend_labels_kws={"size": 16, "ncol": 5}, ax=ax, ) ``` diff --git a/tests/test_main.py b/tests/test_main.py index 2253bc1..97c5e4f 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -267,13 +267,19 @@ def test_calendar_categorical_list_colors_and_legend_order(): assert rects[1].get_facecolor() == to_rgba("#22c55e") assert rects[2].get_facecolor() == to_rgba("#3b82f6") - legend_rects = ax.patches[len(rects) :] - assert [rect.get_facecolor() for rect in legend_rects] == [ + legend = ax.get_legend() + assert legend is not None + assert len(ax.patches) == len(rects) + assert [handle.get_facecolor() for handle in legend.legend_handles] == [ to_rgba("#ef4444"), to_rgba("#22c55e"), to_rgba("#3b82f6"), ] - assert [text.get_text() for text in ax.texts[-3:]] == ["low", "high", "medium"] + assert [text.get_text() for text in legend.get_texts()] == [ + "low", + "high", + "medium", + ] plt.close("all") @@ -312,7 +318,39 @@ def test_calendar_categorical_custom_legend_labels(): ax=ax, ) - assert [text.get_text() for text in ax.texts[-2:]] == ["Working", "Resting"] + legend = ax.get_legend() + assert legend is not None + assert [text.get_text() for text in legend.get_texts()] == [ + "Working", + "Resting", + ] + + plt.close("all") + + +def test_calendar_categorical_legend_with_long_labels(): + """Test categorical legends use Matplotlib's layout for long labels.""" + dates = [datetime(2024, 1, 1) + timedelta(days=i) for i in range(4)] + values = [ + "Customer support", + "Deep strategy work", + "Documentation review", + "Release coordination", + ] + fig, ax = plt.subplots() + + calendar( + dates, + values, + colors=["#0f766e", "#f59e0b", "#2563eb", "#dc2626"], + legend=True, + ax=ax, + ) + + legend = ax.get_legend() + assert legend is not None + assert [text.get_text() for text in legend.get_texts()] == values + assert len(ax.patches) == len(values) plt.close("all") From 622800d3fdf2e3390046326c1829be736d551405 Mon Sep 17 00:00:00 2001 From: Barbier--Darnal Joseph Date: Thu, 2 Jul 2026 15:50:31 +0200 Subject: [PATCH 9/9] better legend customization for categorical data --- dayplot/calendar.py | 14 +++++++++--- docs/tuto/categorical-data.md | 40 +++++++++++++++++++++++++++++++++-- tests/test_main.py | 25 ++++++++++++++++++++++ 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/dayplot/calendar.py b/dayplot/calendar.py index fe41e66..3aef50b 100644 --- a/dayplot/calendar.py +++ b/dayplot/calendar.py @@ -267,6 +267,7 @@ def calendar( legend_labels: Optional[Union[List, Literal["auto"]]] = None, legend_labels_precision: Optional[int] = None, legend_labels_kws: Optional[Dict] = None, + legend_kws: Optional[Dict] = None, less_label: Any = _DEFAULT_LESS_LABEL, more_label: Any = _DEFAULT_MORE_LABEL, month_grid: bool = False, @@ -342,6 +343,8 @@ def calendar( `legend_labels="auto"`. legend_labels_kws: Additional keyword arguments passed to Axes.annotate function when rendering legend labels. + legend_kws: Additional keyword arguments passed to Axes.legend when rendering + categorical legends. less_label: Left label used for the legend. more_label: Right label used for the legend. month_grid: Whether to draw bounding boxes around each month. @@ -397,6 +400,7 @@ def calendar( month_kws = month_kws or {} day_kws = day_kws or {} legend_labels_kws = legend_labels_kws or {} + legend_kws = legend_kws or {} ax = ax or plt.gca() category_order: list[Any] = [] @@ -626,9 +630,7 @@ def calendar( "verticalalignment", } } - legend_artist = ax.legend( - handles=legend_handles, - labels=legend_label_values, + categorical_legend_kws: dict[str, Any] = dict( loc="upper center", bbox_to_anchor=(0.5, -0.08), ncol=min(len(categories), 3), @@ -638,6 +640,12 @@ def calendar( columnspacing=1.2, prop=legend_text_props or None, ) + categorical_legend_kws.update(legend_kws) + legend_artist = ax.legend( + handles=legend_handles, + labels=legend_label_values, + **categorical_legend_kws, + ) if "color" in legend_labels_kws: for text in legend_artist.get_texts(): text.set_color(legend_labels_kws["color"]) diff --git a/docs/tuto/categorical-data.md b/docs/tuto/categorical-data.md index d6a1eec..251e2b6 100644 --- a/docs/tuto/categorical-data.md +++ b/docs/tuto/categorical-data.md @@ -102,7 +102,7 @@ For categorical data, `legend=True` displays one box per category. By default, the labels are the category names. You can override them with `legend_labels`. Categorical legends use Matplotlib's regular legend layout, which works better with longer labels. -```py hl_lines="27" +```py hl_lines="28" # mkdocs: render import matplotlib.pyplot as plt import dayplot as dp @@ -130,7 +130,43 @@ dp.calendar( "Admin": "#dc2626", }, legend=True, - legend_labels_kws={"size": 16, "ncol": 5}, + legend_labels_kws={"size": 16, "color": "red"}, + ax=ax, +) +``` + +You can also use `legend_kws` to control the legend layout: + +```py hl_lines="28 29" +# mkdocs: render +import matplotlib.pyplot as plt +import dayplot as dp + +df = dp.load_dataset() +df["activity"] = df["values"].map({ + 1: "Focus", + 2: "Meeting", + 3: "Writing", + 4: "Review", + 5: "Admin", +}) + +fig, ax = plt.subplots(figsize=(16, 4)) +dp.calendar( + dates=df["dates"], + values=df["activity"], + start_date="2024-01-01", + end_date="2024-12-31", + colors={ + "Focus": "#2563eb", + "Meeting": "#f97316", + "Writing": "#16a34a", + "Review": "#9333ea", + "Admin": "#dc2626", + }, + legend=True, + legend_kws={"ncol": 5, "bbox_to_anchor": (0.5, -0.12)}, + legend_labels_kws={"size": 16}, ax=ax, ) ``` diff --git a/tests/test_main.py b/tests/test_main.py index 97c5e4f..47b937c 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -355,6 +355,31 @@ def test_calendar_categorical_legend_with_long_labels(): plt.close("all") +def test_calendar_categorical_legend_kws(): + """Test categorical legend layout can be customized with legend_kws.""" + dates = [datetime(2024, 1, 1) + timedelta(days=i) for i in range(3)] + values = ["low", "high", "medium"] + fig, ax = plt.subplots() + + rects = calendar( + dates, + values, + colors=["#ef4444", "#22c55e", "#3b82f6"], + legend=True, + legend_kws={"ncol": 1, "frameon": True, "loc": "lower center"}, + ax=ax, + ) + + legend = ax.get_legend() + assert legend is not None + assert len(ax.patches) == len(rects) + assert legend.get_frame_on() + assert legend._ncols == 1 + assert legend._loc == 8 + + plt.close("all") + + def test_calendar_categorical_colors_too_short(): """Test categorical list colors must cover all observed categories.""" dates = [datetime(2024, 1, 1) + timedelta(days=i) for i in range(2)]