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. [![PyPI Downloads](https://static.pepy.tech/badge/dayplot)](https://pepy.tech/projects/dayplot) -![Coverage](coverage-badge.svg)
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 @@ - - - - - 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 diff --git a/docs/tuto/categorical-data.md b/docs/tuto/categorical-data.md new file mode 100644 index 0000000..251e2b6 --- /dev/null +++ b/docs/tuto/categorical-data.md @@ -0,0 +1,174 @@ +### 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 + +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", + 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="20-26" +# 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, + ax=ax, +) +``` + +You can also pass a list of colors. Colors are assigned in the order categories first appear in `values`. + +```py hl_lines="18" +# 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"], + colors=["#16a34a", "#9333ea", "#2563eb", "#dc2626", "#f97316"], + start_date="2024-01-01", + end_date="2024-12-31", + 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`. +Categorical legends use Matplotlib's regular legend layout, which works better with longer labels. + +```py hl_lines="28" +# 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_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/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/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..9a5a0bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,43 +2,53 @@ 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", + "pyrefly>=1.1.1", + "pytest>=8.3.5", + "ruff>=0.11.13", + "ty==0.0.56", + "types-markdown>=3.10.2.20260518", + "types-requests>=2.33.0.20260518", + "zensical>=0.0.46", ] [build-system] requires = [ - "setuptools", - "setuptools-scm", + "setuptools", + "setuptools-scm", ] build-backend = "setuptools.build_meta" @@ -48,15 +58,12 @@ 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.pyrefly] +project-includes = ["dayplot"] + +[tool.uv.sources] +dayplot = { workspace = true } diff --git a/tests/test_main.py b/tests/test_main.py index 3aa4e72..47b937c 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,209 @@ 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 = 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 legend.get_texts()] == [ + "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, + ) + + 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") + + +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)] + 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", []]) diff --git a/zensical.toml b/zensical.toml new file mode 100644 index 0000000..5ae98c3 --- /dev/null +++ b/zensical.toml @@ -0,0 +1,140 @@ +[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/categorical-data.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"]