From ab08bb508fa2bcc7fa3c2ae2fce8b9eaaf31829e Mon Sep 17 00:00:00 2001 From: Hanjin Liu Date: Sat, 31 Aug 2024 22:49:59 +0900 Subject: [PATCH 1/6] fix legend item --- whitecanvas/layers/group/_collections.py | 27 +++++++++++------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/whitecanvas/layers/group/_collections.py b/whitecanvas/layers/group/_collections.py index a2146c01..894fb92d 100644 --- a/whitecanvas/layers/group/_collections.py +++ b/whitecanvas/layers/group/_collections.py @@ -79,6 +79,12 @@ def _connect_canvas(self, canvas: Canvas): def _disconnect_canvas(self, canvas: Canvas): return super()._disconnect_canvas(canvas) + def _as_legend_item(self): + """Use the first layer as the main legend item.""" + if len(self._children) == 0: + return _legend.EmptyLegendItem() + return self._children[0]._as_legend_item() + class LayerTuple(LayerContainer, Sequence[Layer]): def __getitem__(self, key: int | str) -> Layer: @@ -141,16 +147,10 @@ def insert(self, n: int, layer: _L): self._ordering_indices.insert(n, len(self._ordering_indices)) return None - def _as_legend_item(self): - """Use the first layer as the main legend item.""" - if len(self) == 0: - return _legend.EmptyLegendItem() - return self[0]._as_legend_item() - + # fmt: off if TYPE_CHECKING: - - def iter_children(self) -> Iterator[_L]: - ... + def iter_children(self) -> Iterator[_L]: ... + # fmt: on _L0 = TypeVar("_L0", bound=Layer) @@ -159,16 +159,13 @@ def iter_children(self) -> Iterator[_L]: class MainAndOtherLayers(LayerTuple, Generic[_L0, _L1]): @overload - def __getitem__(self, n: Literal[0]) -> _L0: - ... + def __getitem__(self, n: Literal[0]) -> _L0: ... @overload - def __getitem__(self, n: Literal[0]) -> _L1: - ... + def __getitem__(self, n: Literal[0]) -> _L1: ... @overload - def __getitem__(self, n: int) -> Layer: - ... + def __getitem__(self, n: int) -> Layer: ... def __getitem__(self, n): """The n-th layer.""" From 988eaf57776b94a305c21306f92095f64ceb8ac2 Mon Sep 17 00:00:00 2001 From: Hanjin Liu Date: Sat, 31 Aug 2024 23:12:51 +0900 Subject: [PATCH 2/6] impl name filter --- whitecanvas/canvas/_base.py | 8 +- whitecanvas/canvas/_joint.py | 10 +- whitecanvas/layers/_primitive/line.py | 127 ++++++++++++++++++++++++++ whitecanvas/utils/predicate.py | 8 ++ whitecanvas/utils/type_check.py | 2 + 5 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 whitecanvas/utils/predicate.py diff --git a/whitecanvas/canvas/_base.py b/whitecanvas/canvas/_base.py index 9d80a638..8107889e 100644 --- a/whitecanvas/canvas/_base.py +++ b/whitecanvas/canvas/_base.py @@ -52,6 +52,7 @@ Symbol, ) from whitecanvas.utils.normalize import as_array_1d, normalize_xy +from whitecanvas.utils.predicate import not_starts_with_underscore from whitecanvas.utils.type_check import is_real_number if TYPE_CHECKING: @@ -99,7 +100,7 @@ def _draw_canvas(self): self._canvas()._plt_draw() self.events.drawn.emit() - def _coerce_name(self, name: str | None, default: str = "data") -> str: + def _coerce_name(self, name: str | None, default: str = "_data") -> str: if name is None: basename = default name = f"{default}-0" @@ -743,6 +744,7 @@ def add_legend( *, location: Location | LocationStr = "top_right", title: str | None = None, + name_filter: Callable[[str], bool] = not_starts_with_underscore, ): """ Add legend items to the canvas. @@ -779,6 +781,8 @@ def add_legend( ``` title : str, optional If given, title label will be added as the first legend item. + name_filter : callable, default not_starts_with_underscore + A callable that returns True if the name should be included in the legend. """ if layers is None: layers = list(self.layers) @@ -788,6 +792,8 @@ def add_legend( items = list[tuple[str, _legend.LegendItem]]() for layer in layers: + if not name_filter(layer.name): + continue if isinstance(layer, str): items.append((layer, _legend.TitleItem())) elif isinstance(layer, _l.Layer): diff --git a/whitecanvas/canvas/_joint.py b/whitecanvas/canvas/_joint.py index aebb2c0d..ed99709a 100644 --- a/whitecanvas/canvas/_joint.py +++ b/whitecanvas/canvas/_joint.py @@ -3,6 +3,7 @@ from abc import ABC, abstractmethod from typing import ( TYPE_CHECKING, + Callable, Iterator, Literal, Sequence, @@ -31,6 +32,7 @@ OrientationLike, Symbol, ) +from whitecanvas.utils.predicate import not_starts_with_underscore if TYPE_CHECKING: from typing_extensions import Self @@ -164,13 +166,15 @@ def _link_marginal_to_main(self, layer: _l.Layer, main: _l.Layer) -> None: def add_legend( self, layers: Sequence[str | _l.Layer] | None = None, - location: Location | LocationStr = "top_right", *, + location: Location | LocationStr = "top_right", title: str | None = None, + name_filter: Callable[[str], bool] = not_starts_with_underscore, ): """Add legend to the main canvas.""" - self.main_canvas.add_legend(layers, location=location, title=title) - return None + return self.main_canvas.add_legend( + layers, location=location, title=title, name_filter=name_filter + ) def add_markers( self, diff --git a/whitecanvas/layers/_primitive/line.py b/whitecanvas/layers/_primitive/line.py index 8cd083ea..cc5fb350 100644 --- a/whitecanvas/layers/_primitive/line.py +++ b/whitecanvas/layers/_primitive/line.py @@ -245,6 +245,26 @@ def with_xerr( antialias: bool | _Void = _void, capsize: float = 0, ) -> _lg.LabeledLine[Self]: + """ + Add error bars parallel to the x-axis. + + Parameters + ---------- + err : array-like + The error values. + err_high : array-like, optional + The higher error values. If not given, use `err`. + color : color-like, optional + The error bar color. Use the line color by default. + width : float, optional + The error bar line width. Use the line width by default. + style : str, optional + The error bar line style. Use the line style by default. + antialias : bool, optional + Whether to use antialiasing. Use the line antialias by default. + capsize : float, default 0 + The cap size of the error bars. + """ from whitecanvas.layers._primitive import Errorbars from whitecanvas.layers.group import LabeledLine @@ -279,6 +299,26 @@ def with_yerr( antialias: bool | _Void = _void, capsize: float = 0, ) -> _lg.LabeledLine[Self]: + """ + Add error bars parallel to the y-axis. + + Parameters + ---------- + err : array-like + The error values. + err_high : array-like, optional + The higher error values. If not given, use `err`. + color : color-like, optional + The error bar color. Use the line color by default. + width : float, optional + The error bar line width. Use the line width by default. + style : str, optional + The error bar line style. Use the line style by default. + antialias : bool, optional + Whether to use antialiasing. Use the line antialias by default. + capsize : float, default 0 + The cap size of the error bars. + """ from whitecanvas.layers._primitive import Errorbars from whitecanvas.layers.group import LabeledLine @@ -311,6 +351,22 @@ def with_xband( alpha: float = 0.3, hatch: str | Hatch = Hatch.SOLID, ) -> _lg.LineBand[Self]: + """ + Add a x-oriented band region attached to the line. + + Parameters + ---------- + err : array-like + The error values. + err_high : array-like, optional + The higher error values. If not given, use `err`. + color : color-like, optional + The band color. Use the line color by default. + alpha : float, optional + The band alpha value. + hatch : str or Hatch, optional + The band hatch pattern. + """ from whitecanvas.layers._primitive import Band from whitecanvas.layers.group import LineBand @@ -338,6 +394,22 @@ def with_yband( alpha: float = 0.3, hatch: str | Hatch = Hatch.SOLID, ) -> _lg.LineBand[Self]: + """ + Add a y-oriented band region attached to the line. + + Parameters + ---------- + err : array-like + The error values. + err_high : array-like, optional + The higher error values. If not given, use `err`. + color : color-like, optional + The band color. Use the line color by default. + alpha : float, optional + The band alpha value. + hatch : str or Hatch, optional + The band hatch pattern. + """ from whitecanvas.layers._primitive import Band from whitecanvas.layers.group import LineBand @@ -364,11 +436,27 @@ def with_xfill( alpha: float = 0.3, hatch: str | Hatch = Hatch.SOLID, ) -> _lg.LineBand[Self]: + """ + Fill the region between line and the y-axis (x-direction). + + Parameters + ---------- + bottom : float, default 0.0 + The bottom value of the fill region. + color : color-like, optional + The fill color. Use the line color by default. + alpha : float, optional + The fill alpha value. + hatch : str or Hatch, optional + The fill hatch pattern. + """ from whitecanvas.layers._primitive import Band from whitecanvas.layers.group import LineBand if color is _void: color = self.color + if not is_real_number(bottom): + raise TypeError(f"Expected `bottom` to be a number, got {type(bottom)}") data = self.data x0 = np.full((data.x.size,), bottom) band = Band( @@ -387,11 +475,27 @@ def with_yfill( alpha: float = 0.3, hatch: str | Hatch = Hatch.SOLID, ) -> _lg.LineBand[Self]: + """ + Fill the region between line and the x-axis (y-direction). + + Parameters + ---------- + bottom : float, default 0.0 + The bottom value of the fill region. + color : color-like, optional + The fill color. Use the line color by default. + alpha : float, optional + The fill alpha value. + hatch : str or Hatch, optional + The fill hatch pattern. + """ from whitecanvas.layers._primitive import Band from whitecanvas.layers.group import LineBand if color is _void: color = self.color + if not is_real_number(bottom): + raise TypeError(f"Expected `bottom` to be a number, got {type(bottom)}") data = self.data y0 = np.full((data.y.size,), bottom) band = Band( @@ -448,6 +552,29 @@ def with_text( anchor: str | Alignment = Alignment.BOTTOM_LEFT, family: str | None = None, ) -> _lg.LabeledLine: + """ + Add texts to each data point. + + Parameters + ---------- + strings : list of str + Texts to be added. + color : ColorType, default "black" + Color of the text. + size : float, optional + Font size., by default 12 + rotation : float, default 0.0 + Text rotation angle in degree. + anchor : str or Alignment, default Alignment.BOTTOM_LEFT + Anchor point of the text. + family : str, optional + Font family. + + Returns + ------- + LabeledLine + The labeled line layer. + """ from whitecanvas.layers import Errorbars from whitecanvas.layers.group import LabeledLine diff --git a/whitecanvas/utils/predicate.py b/whitecanvas/utils/predicate.py new file mode 100644 index 00000000..33b3078c --- /dev/null +++ b/whitecanvas/utils/predicate.py @@ -0,0 +1,8 @@ +from __future__ import annotations + + +def not_starts_with_underscore(name: str) -> bool: + return not name.startswith("_") + + +not_starts_with_underscore.__repr__ = lambda: "" diff --git a/whitecanvas/utils/type_check.py b/whitecanvas/utils/type_check.py index 3bc72535..062a34b8 100644 --- a/whitecanvas/utils/type_check.py +++ b/whitecanvas/utils/type_check.py @@ -14,10 +14,12 @@ def is_not_array(x) -> bool: + """True if x is not an array.""" return np.isscalar(x) or isinstance(x, Enum) def is_real_number(x) -> TypeGuard[float]: + """True if x is a real number.""" return isinstance(x, (int, float, Real, np.number)) From a15393927d09df7bd4c4edfece952a355f3c4c45 Mon Sep 17 00:00:00 2001 From: Hanjin Liu Date: Sat, 31 Aug 2024 23:50:16 +0900 Subject: [PATCH 3/6] fix legend title position --- whitecanvas/backend/matplotlib/canvas.py | 8 +++++++- whitecanvas/canvas/_base.py | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/whitecanvas/backend/matplotlib/canvas.py b/whitecanvas/backend/matplotlib/canvas.py index d8c4afaf..74e0eae9 100644 --- a/whitecanvas/backend/matplotlib/canvas.py +++ b/whitecanvas/backend/matplotlib/canvas.py @@ -309,10 +309,16 @@ def _plt_make_legend( if artists: loc, bbox_to_anchor = _LEGEND_LOC_MAP[anchor] font_size = self._plt_get_xticks()._plt_get_size() - self._axes.legend( + leg = self._axes.legend( artists, names, loc=loc, bbox_to_anchor=bbox_to_anchor, prop={"size": font_size}, ) # fmt: skip + for item, text in zip(leg.legend_handles, leg.texts): + if isinstance(item, plt.Rectangle): + extent = item.get_window_extent(leg.figure.canvas.get_renderer()) + width = extent.width + text.set_fontweight("bold") + text.set_position((-1.5 * width, 0)) if anchor.is_side: self._axes.figure.tight_layout() diff --git a/whitecanvas/canvas/_base.py b/whitecanvas/canvas/_base.py index 8107889e..f6eeb347 100644 --- a/whitecanvas/canvas/_base.py +++ b/whitecanvas/canvas/_base.py @@ -792,11 +792,11 @@ def add_legend( items = list[tuple[str, _legend.LegendItem]]() for layer in layers: - if not name_filter(layer.name): - continue if isinstance(layer, str): items.append((layer, _legend.TitleItem())) elif isinstance(layer, _l.Layer): + if not name_filter(layer.name): + continue items.append((layer.name, layer._as_legend_item())) else: raise TypeError(f"Expected a list of layer or str, got {type(layer)}.") From 1d5a015e2ef189d03cb6fbe6f5bd2bd7ecb211db Mon Sep 17 00:00:00 2001 From: Hanjin Liu Date: Sat, 31 Aug 2024 23:55:28 +0900 Subject: [PATCH 4/6] minor updates --- tests/test_canvas.py | 2 +- whitecanvas/__init__.py | 2 +- whitecanvas/utils/predicate.py | 3 --- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/test_canvas.py b/tests/test_canvas.py index 6b9456d7..071ca78f 100644 --- a/tests/test_canvas.py +++ b/tests/test_canvas.py @@ -222,7 +222,7 @@ def test_legend(backend: str): if backend == "vispy": pytest.skip("vispy does not support legend") canvas = new_canvas(backend=backend) - canvas.add_line([0, 1, 2], [0, 1, 2], name="line") + canvas.add_line([0, 1, 2], [0, 1, 2]) canvas.add_markers([0, 1, 2], [0, 1, 2], name="markers") canvas.add_bars([0, 1, 2], [0, 1, 2], name="bars") canvas.add_line([3, 4, 5], [1, 2, 1], name="plot").with_markers() diff --git a/whitecanvas/__init__.py b/whitecanvas/__init__.py index 99bbe728..653613be 100644 --- a/whitecanvas/__init__.py +++ b/whitecanvas/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.3.1" +__version__ = "0.3.2" from whitecanvas import theme from whitecanvas.canvas import link_axes diff --git a/whitecanvas/utils/predicate.py b/whitecanvas/utils/predicate.py index 33b3078c..d5d2a657 100644 --- a/whitecanvas/utils/predicate.py +++ b/whitecanvas/utils/predicate.py @@ -3,6 +3,3 @@ def not_starts_with_underscore(name: str) -> bool: return not name.startswith("_") - - -not_starts_with_underscore.__repr__ = lambda: "" From 87bec9f151b7e46cfe2add6f0aa03deca452db40 Mon Sep 17 00:00:00 2001 From: Hanjin Liu Date: Sun, 8 Sep 2024 07:14:40 +0900 Subject: [PATCH 5/6] update mkdocs versions --- pyproject.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 49f45748..9b11c001 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,11 +72,11 @@ testing = [ docs = [ "mkdocs", - "mkdocs-autorefs==0.5.0", - "mkdocs-material==9.5.2", + "mkdocs-autorefs==1.0.1", + "mkdocs-material==9.5.23", "mkdocs-material-extensions==1.3.1", - "mkdocstrings==0.24.0", - "mkdocstrings-python==1.7.5", + "mkdocstrings==0.25.2", + "mkdocstrings-python==1.10.8", "mkdocs-gen-files", "matplotlib>=3.8.2", "imageio>=2.9.0", From 87babb7087ea9ef40c2a8fd6815b44016a4b56a3 Mon Sep 17 00:00:00 2001 From: Hanjin Liu Date: Sun, 8 Sep 2024 07:41:45 +0900 Subject: [PATCH 6/6] fix warnings --- tests/test_logics.py | 8 ++++ whitecanvas/backend/pyqtgraph/_labels.py | 2 +- whitecanvas/canvas/_namespaces.py | 4 +- whitecanvas/layers/_mixin.py | 4 +- whitecanvas/layers/_primitive/line.py | 51 ++++++++++++++++-------- 5 files changed, 48 insertions(+), 21 deletions(-) diff --git a/tests/test_logics.py b/tests/test_logics.py index 2720483d..6f76bf5b 100644 --- a/tests/test_logics.py +++ b/tests/test_logics.py @@ -45,6 +45,14 @@ def test_kde(): layer.band_width layer.band_width = 0.5 +def test_line_with_methods(): + canvas = new_canvas(backend="mock") + line = canvas.add_line([1, 2], [4, 5]) + with pytest.raises(TypeError): + line.with_xband([1, 1], alpha=[1, 2]) + line.with_yfill([0, 1]) + line.with_yfill(0.1, alpha=[1, 2]) + def test_hover_template(): canvas = new_canvas(backend="mock") layer = canvas.add_markers([1, 2, 3], [4, 5, 6]) diff --git a/whitecanvas/backend/pyqtgraph/_labels.py b/whitecanvas/backend/pyqtgraph/_labels.py index a6530229..1f81eacf 100644 --- a/whitecanvas/backend/pyqtgraph/_labels.py +++ b/whitecanvas/backend/pyqtgraph/_labels.py @@ -92,7 +92,7 @@ def _plt_set_text(self, text: str): self._get_axis().setLabel(text, **self._css) def _plt_get_color(self): - return np.array(Color(self._css["color"])) + return np.fromiter(Color(self._css["color"]), dtype=np.float32) def _plt_set_color(self, color): css = self._css.copy() diff --git a/whitecanvas/canvas/_namespaces.py b/whitecanvas/canvas/_namespaces.py index a3c2a04f..9a03d098 100644 --- a/whitecanvas/canvas/_namespaces.py +++ b/whitecanvas/canvas/_namespaces.py @@ -122,7 +122,7 @@ def color(self): @color.setter def color(self, color): - self._get_object()._plt_set_color(np.array(Color(color))) + self._get_object()._plt_set_color(np.fromiter(Color(color), dtype=np.float32)) @property def size(self) -> float: @@ -333,7 +333,7 @@ def color(self): @color.setter def color(self, color): - self._get_object()._plt_set_color(np.array(Color(color))) + self._get_object()._plt_set_color(np.fromiter(Color(color), dtype=np.float32)) self._draw_canvas() @property diff --git a/whitecanvas/layers/_mixin.py b/whitecanvas/layers/_mixin.py index a9617c1d..5c48569a 100644 --- a/whitecanvas/layers/_mixin.py +++ b/whitecanvas/layers/_mixin.py @@ -722,7 +722,7 @@ def _make_sure_hatch_visible(self): _is_no_width = self.edge.width == 0 if isinstance(self._edge_namespace, MultiEdge): if np.any(_is_no_width): - ec = np.array(get_theme().foreground_color, dtype=np.float32) + ec = np.fromiter(get_theme().foreground_color, dtype=np.float32) self.edge.width = np.where(_is_no_width, 1, self.edge.width) ec_old = self.edge.color ec_old[_is_no_width] = ec[np.newaxis] @@ -848,7 +848,7 @@ def __init__(self): def _make_sure_hatch_visible(self): _is_no_width = self.edge.width == 0 if np.any(_is_no_width): - ec = np.array(get_theme().foreground_color, dtype=np.float32) + ec = np.fromiter(get_theme().foreground_color, dtype=np.float32) self.edge.width = np.where(_is_no_width, 1, self.edge.width) ec_old = self.edge.color ec_old[_is_no_width] = ec[np.newaxis] diff --git a/whitecanvas/layers/_primitive/line.py b/whitecanvas/layers/_primitive/line.py index cc5fb350..d7d42e31 100644 --- a/whitecanvas/layers/_primitive/line.py +++ b/whitecanvas/layers/_primitive/line.py @@ -370,10 +370,7 @@ def with_xband( from whitecanvas.layers._primitive import Band from whitecanvas.layers.group import LineBand - if err_high is None: - err_high = err - if color is _void: - color = self.color + err, err_high, color, alpha = _norm_band_args(self, err, err_high, color, alpha) data = self.data _x, _y0 = self._data_to_backend_data(XYData(data.y, data.x - err)) _, _y1 = self._data_to_backend_data(XYData(data.y, data.x + err_high)) @@ -413,10 +410,7 @@ def with_yband( from whitecanvas.layers._primitive import Band from whitecanvas.layers.group import LineBand - if err_high is None: - err_high = err - if color is _void: - color = self.color + err, err_high, color, alpha = _norm_band_args(self, err, err_high, color, alpha) data = self.data _x, _y0 = self._data_to_backend_data(XYData(data.x, data.y - err)) _, _y1 = self._data_to_backend_data(XYData(data.x, data.y + err_high)) @@ -453,10 +447,7 @@ def with_xfill( from whitecanvas.layers._primitive import Band from whitecanvas.layers.group import LineBand - if color is _void: - color = self.color - if not is_real_number(bottom): - raise TypeError(f"Expected `bottom` to be a number, got {type(bottom)}") + bottom, color, alpha = _norm_fill_args(self, bottom, color, alpha) data = self.data x0 = np.full((data.x.size,), bottom) band = Band( @@ -492,10 +483,7 @@ def with_yfill( from whitecanvas.layers._primitive import Band from whitecanvas.layers.group import LineBand - if color is _void: - color = self.color - if not is_real_number(bottom): - raise TypeError(f"Expected `bottom` to be a number, got {type(bottom)}") + bottom, color, alpha = _norm_fill_args(self, bottom, color, alpha) data = self.data y0 = np.full((data.y.size,), bottom) band = Band( @@ -507,6 +495,37 @@ def with_yfill( return LineBand(self, band, name=old_name) +def _norm_band_args( + self: _SingleLine, + err: ArrayLike1D, + err_high: ArrayLike1D | None, + color: ColorType | _Void, + alpha: float, +) -> tuple[ArrayLike1D, ArrayLike1D, ColorType, float]: + if err_high is None: + err_high = err + if color is _void: + color = self.color + if not is_real_number(alpha): + raise TypeError(f"Expected `alpha` to be a number, got {type(alpha)}") + return err, err_high, color, alpha + + +def _norm_fill_args( + self: _SingleLine, + bottom: float, + color: ColorType | _Void, + alpha: float, +) -> tuple[float, ColorType, float]: + if color is _void: + color = self.color + if not is_real_number(bottom): + raise TypeError(f"Expected `bottom` to be a number, got {type(bottom)}") + if not is_real_number(alpha): + raise TypeError(f"Expected `alpha` to be a number, got {type(alpha)}") + return bottom, color, alpha + + class Line(_SingleLine): _backend_class_name = "MonoLine" events: LineLayerEvents