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/.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/.gitignore b/.gitignore
index eeb9f0b..991d3ab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,5 @@ uv.lock
*.zip
release.sh
+
+site
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.
[](https://pepy.tech/projects/dayplot)
-
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..3aef50b 100644
--- a/dayplot/calendar.py
+++ b/dayplot/calendar.py
@@ -1,17 +1,19 @@
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
-import matplotlib
from matplotlib.axes import Axes
from matplotlib.colors import Colormap
import numpy as np
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
@@ -33,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):
@@ -43,7 +154,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 +166,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 "
@@ -68,7 +179,7 @@ def _validate_cmap(cmap: Union[str, LinearSegmentedColormap]):
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]:
@@ -134,35 +245,37 @@ 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,
- boxstyle: Union[str, matplotlib.patches.BoxStyle] = "square",
+ 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",
+ legend_kws: Optional[Dict] = None,
+ less_label: Any = _DEFAULT_LESS_LABEL,
+ more_label: Any = _DEFAULT_MORE_LABEL,
month_grid: bool = False,
month_grid_kws: Dict = {},
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.
@@ -180,9 +293,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.
@@ -194,9 +307,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.
+ colormap is used to determine the fill color intensity of each day's
+ 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
@@ -219,13 +335,16 @@ 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
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.
@@ -243,20 +362,62 @@ 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)
- 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 {}
day_kws = day_kws or {}
legend_labels_kws = legend_labels_kws or {}
+ legend_kws = legend_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]
@@ -269,49 +430,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 = 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 = cmap(norm(count))
+ face_color = validated_cmap(norm(cast(float, count)))
rect = patches.FancyBboxPatch(
xy=(week + 0.35, weekday + 0.35),
@@ -326,7 +509,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 = [
@@ -338,7 +521,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)
@@ -349,7 +532,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)
@@ -359,7 +542,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
@@ -416,13 +599,72 @@ def calendar(
ax.add_patch(patch)
if legend:
+ if is_categorical:
+ 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",
+ }
+ }
+ categorical_legend_kws: dict[str, Any] = dict(
+ 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,
+ )
+ 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"])
+ return rect_patches
+
legend_rects = []
- legend_values = np.linspace(vmin, vmax, legend_bins)
+ 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 = cmap(norm(val))
+ color = validated_cmap(norm(cast(float, val)))
else:
- color = color_for_none if val == 0 else 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(
@@ -443,9 +685,11 @@ def calendar(
if 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(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,
@@ -453,7 +697,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/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/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 @@
-
-
-