From 888951c8a3bcaa6d9d6807d3bc6c3aa1460104af Mon Sep 17 00:00:00 2001 From: Hanjin Liu Date: Sun, 25 Feb 2024 22:28:35 +0900 Subject: [PATCH 1/3] update tests, bug fixes for tests --- pyproject.toml | 2 +- tests/test_canvas.py | 33 +++++++++++++--- tests/test_categorical.py | 4 +- whitecanvas/__init__.py | 4 +- whitecanvas/backend/bokeh/_base.py | 10 ++++- whitecanvas/backend/pyqtgraph/markers.py | 12 ++++-- whitecanvas/backend/vispy/markers.py | 10 +++++ whitecanvas/canvas/_base.py | 35 ++++++++--------- whitecanvas/canvas/dataframe/_base.py | 14 ++++++- whitecanvas/canvas/dataframe/_one_cat.py | 40 +++++++++++++------- whitecanvas/canvas/dataframe/_stacked_cat.py | 27 ++++++------- whitecanvas/layers/_mixin.py | 30 +++++++++++---- whitecanvas/layers/_primitive/markers.py | 9 +++-- whitecanvas/layers/group/boxplot.py | 20 +++++----- whitecanvas/layers/group/labeled.py | 6 +++ whitecanvas/layers/tabular/_box_like.py | 17 +++++---- 16 files changed, 184 insertions(+), 89 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 11438c23..718f06f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -208,7 +208,7 @@ omit = [ ] [tool.coverage.paths] -whitecanvas = ["whitecanvas", "*/whitecanvas/whitecanvas"] +whitecanvas = ["*/whitecanvas/whitecanvas"] tests = ["tests", "*/whitecanvas/tests"] [tool.coverage.report] diff --git a/tests/test_canvas.py b/tests/test_canvas.py index de13690b..b51661b0 100644 --- a/tests/test_canvas.py +++ b/tests/test_canvas.py @@ -1,3 +1,5 @@ +from pathlib import Path +import tempfile import numpy as np from numpy.testing import assert_allclose @@ -77,9 +79,7 @@ def test_grid(backend: str): def test_grid_nonuniform(backend: str): - cgrid = wc.new_grid( - [2, 1], [2, 1], backend=backend - ).link_x().link_y() + cgrid = wc.new_grid([2, 1], [2, 1], backend=backend, size=(100, 100)).link_x().link_y() c00 = cgrid.add_canvas(0, 0) c01 = cgrid.add_canvas(0, 1) c10 = cgrid.add_canvas(1, 0) @@ -103,7 +103,7 @@ def test_grid_nonuniform(backend: str): assert len(c11.layers) == 1 def test_vgrid_hgrid(backend: str): - cgrid = wc.new_col(2, backend=backend).link_x().link_y() + cgrid = wc.new_col(2, backend=backend, size=(100, 100)).link_x().link_y() c0 = cgrid.add_canvas(0) c1 = cgrid.add_canvas(1) @@ -116,7 +116,7 @@ def test_vgrid_hgrid(backend: str): assert len(c0.layers) == 1 assert len(c1.layers) == 1 - cgrid = wc.new_row(2, backend=backend).link_x().link_y() + cgrid = wc.new_row(2, backend=backend, size=(100, 100)).link_x().link_y() c0 = cgrid.add_canvas(0) c1 = cgrid.add_canvas(1) @@ -142,7 +142,7 @@ def test_unlink(backend: str): def test_jointgrid(backend: str): rng = np.random.default_rng(0) - joint = wc.new_jointgrid(backend=backend).with_hist().with_kde().with_rug() + joint = wc.new_jointgrid(backend=backend, size=(100, 100)).with_hist().with_kde().with_rug() joint.add_markers(rng.random(100), rng.random(100), color="red") def test_legend(backend: str): @@ -158,3 +158,24 @@ def test_legend(backend: str): canvas.add_line([3, 4, 5], [4, 5, 4], name="plot+err").with_markers().with_xerr([1, 1, 1]) canvas.add_markers([3, 4, 5], [5, 6, 5], name="markers+err+err").with_stem() canvas.add_legend(location="bottom_right") + +def test_animation(): + from whitecanvas.animation import Animation + + canvas = new_canvas(backend="matplotlib") + anim = Animation(canvas) + x = np.linspace(0, 2 * np.pi, 100) + line = canvas.add_line(x, np.sin(x + 0), name="line") + for i in anim.iter_range(3): + line.set_data(x, np.sin(x + i * np.pi / 3)) + with tempfile.TemporaryDirectory() as tmpdir: + anim.save(Path(tmpdir) / "test.gif") + assert anim.asarray().ndim == 4 + +def test_multidim(): + canvas = new_canvas(backend="matplotlib") + x = np.arange(5) + ys = [x, x ** 2, x ** 3] + canvas.dims.add_line(x, ys) + img = np.zeros((3, 5, 5)) + canvas.dims.add_image(img) diff --git a/tests/test_categorical.py b/tests/test_categorical.py index ded32aac..1bef3b31 100644 --- a/tests/test_categorical.py +++ b/tests/test_categorical.py @@ -45,8 +45,10 @@ def test_cat_plots(backend: str, orient: str): cat_plt = canvas.cat_y(df, "y", "label") cat_plt.add_stripplot(color="c") cat_plt.add_swarmplot(color="c") - cat_plt.add_boxplot(color="c") + cat_plt.add_boxplot(color="c").with_outliers(ratio=0.5) cat_plt.add_violinplot(color="c").with_rug() + cat_plt.add_violinplot(color="c").with_outliers(ratio=0.5) + cat_plt.add_violinplot(color="c").with_box() cat_plt.add_pointplot(color="c").err_by_se() cat_plt.add_barplot(color="c") if backend == "plotly": diff --git a/whitecanvas/__init__.py b/whitecanvas/__init__.py index 4bd9e324..c7c91e8d 100644 --- a/whitecanvas/__init__.py +++ b/whitecanvas/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.2.4" +__version__ = "0.2.5" from whitecanvas import theme from whitecanvas.canvas import link_axes @@ -23,7 +23,7 @@ ] -def __getattr__(name: str): +def __getattr__(name: str): # pragma: no cover import warnings if name in ("grid", "grid_nonuniform"): diff --git a/whitecanvas/backend/bokeh/_base.py b/whitecanvas/backend/bokeh/_base.py index 0b569a22..56c54dc9 100644 --- a/whitecanvas/backend/bokeh/_base.py +++ b/whitecanvas/backend/bokeh/_base.py @@ -36,7 +36,10 @@ def _plt_set_visible(self, visible: bool): ##### HasFace protocol ##### def _plt_get_face_color(self) -> NDArray[np.float32]: - return np.stack([arr_color(c) for c in self._data.data["face_color"]], axis=0) + colors = [arr_color(c) for c in self._data.data["face_color"]] + if len(colors) == 0: + return np.zeros((0, 4), dtype=np.float32) + return np.stack(colors, axis=0) def _plt_set_face_color(self, color: NDArray[np.float32]): if color.ndim == 1: @@ -75,7 +78,10 @@ def _plt_set_edge_style(self, style: LineStyle | list[LineStyle]): self._data.data["style"] = val def _plt_get_edge_color(self) -> NDArray[np.float32]: - return np.stack([arr_color(c) for c in self._data.data["edge_color"]], axis=0) + colors = [arr_color(c) for c in self._data.data["edge_color"]] + if len(colors) == 0: + return np.zeros((0, 4), dtype=np.float32) + return np.stack(colors, axis=0) def _plt_set_edge_color(self, color: NDArray[np.float32]): if color.ndim == 1: diff --git a/whitecanvas/backend/pyqtgraph/markers.py b/whitecanvas/backend/pyqtgraph/markers.py index 0318c14b..cf7719ed 100644 --- a/whitecanvas/backend/pyqtgraph/markers.py +++ b/whitecanvas/backend/pyqtgraph/markers.py @@ -87,8 +87,11 @@ def _get_brush(self) -> list[QtGui.QBrush]: return brushes def _plt_get_face_color(self) -> NDArray[np.float32]: + brushes = self._get_brush() + if len(brushes) == 0: + return np.zeros((0, 4), dtype=np.float32) return np.array( - [brush.color().getRgbF() for brush in self._get_brush()], dtype=np.float32 + [brush.color().getRgbF() for brush in brushes], dtype=np.float32 ) def _plt_set_face_color(self, color: NDArray[np.float32]): @@ -118,9 +121,10 @@ def _get_pen(self) -> list[QtGui.QPen]: return pens def _plt_get_edge_color(self) -> NDArray[np.float32]: - return np.array( - [pen.color().getRgbF() for pen in self._get_pen()], dtype=np.float32 - ) + pens = self._get_pen() + if len(pens) == 0: + return np.zeros((0, 4), dtype=np.float32) + return np.array([pen.color().getRgbF() for pen in pens], dtype=np.float32) def _plt_set_edge_color(self, color: NDArray[np.float32]): color = as_color_array(color, len(self.data["x"])) diff --git a/whitecanvas/backend/vispy/markers.py b/whitecanvas/backend/vispy/markers.py index 88e7408b..2f7f4dbf 100644 --- a/whitecanvas/backend/vispy/markers.py +++ b/whitecanvas/backend/vispy/markers.py @@ -50,6 +50,8 @@ def _plt_set_data(self, xdata, ydata): ##### HasSymbol protocol ##### def _plt_get_symbol(self) -> Symbol: + if self._data["a_position"].shape[0] == 0: + return Symbol.CIRCLE sym = self.symbol[0] if sym == "clobber": return Symbol.TRIANGLE_LEFT @@ -76,6 +78,8 @@ def _plt_get_symbol_size(self) -> NDArray[np.floating]: def _plt_set_symbol_size(self, size: float | NDArray[np.floating]): if is_real_number(size): size = np.full(self._plt_get_ndata(), size) + if size.shape[0] == 0: + return self.set_data( pos=self._data["a_position"], size=size, @@ -91,6 +95,8 @@ def _plt_get_face_color(self) -> NDArray[np.float32]: def _plt_set_face_color(self, color: NDArray[np.float32]): color = as_color_array(color, self._plt_get_ndata()) + if color.shape[0] == 0: + return self.set_data( pos=self._data["a_position"], size=self._plt_get_symbol_size(), @@ -108,6 +114,8 @@ def _plt_get_edge_color(self) -> NDArray[np.float32]: def _plt_set_edge_color(self, color: NDArray[np.float32]): color = as_color_array(color, self._plt_get_ndata()) + if color.shape[0] == 0: + return self.set_data( pos=self._data["a_position"], size=self._plt_get_symbol_size(), @@ -123,6 +131,8 @@ def _plt_get_edge_width(self) -> NDArray[np.floating]: def _plt_set_edge_width(self, width: float): if isinstance(width, float): width = np.full(self._plt_get_ndata(), width) + if width.shape[0] == 0: + return self.set_data( pos=self._data["a_position"], size=self._plt_get_symbol_size(), diff --git a/whitecanvas/canvas/_base.py b/whitecanvas/canvas/_base.py index 0814bbd3..34387652 100644 --- a/whitecanvas/canvas/_base.py +++ b/whitecanvas/canvas/_base.py @@ -356,23 +356,14 @@ def update_font( New font family. """ if size is not _void: - self.title.size = size - self.x.label.size = size - self.y.label.size = size - self.x.ticks.size = size - self.y.ticks.size = size + self.title.size = self.x.label.size = self.y.label.size = size + self.x.ticks.size = self.y.ticks.size = size if family is not _void: - self.title.family = family - self.x.label.family = family - self.y.label.family = family - self.x.ticks.family = family - self.y.ticks.family = family + self.title.family = self.x.label.family = self.y.label.family = family + self.x.ticks.family = self.y.ticks.family = family if color is not _void: - self.title.color = color - self.x.label.color = color - self.y.label.color = color - self.x.ticks.color = color - self.y.ticks.color = color + self.title.color = self.x.label.color = self.y.label.color = color + self.x.ticks.color = self.y.ticks.color = color return self def cat( @@ -416,6 +407,7 @@ def cat_x( y: str | None = None, *, update_labels: bool = True, + numeric_axis: bool = False, ) -> _df.XCatPlotter[Self, _DF]: """ Categorize input data for plotting with x-axis as a categorical axis. @@ -431,13 +423,17 @@ def cat_x( Name of the column that will be used for the y-axis. Must be numerical. update_labels : bool, default True If True, update the x/y labels to the corresponding names. + numeric_axis : bool, default False + If True, the x-axis will be treated as a numerical axis. For example, if + categories are [2, 4, 8], the x coordinates will be mapped to [0, 1, 2] by + default, but if this option is True, the x coordinates will be [2, 4, 8]. Returns ------- XCatPlotter Plotter object. """ - return _df.XCatPlotter(self, data, x, y, update_labels) + return _df.XCatPlotter(self, data, x, y, update_labels, numeric=numeric_axis) def cat_y( self, @@ -446,6 +442,7 @@ def cat_y( y: str | Sequence[str] | None = None, *, update_labels: bool = True, + numeric_axis: bool = False, ) -> _df.YCatPlotter[Self, _DF]: """ Categorize input data for plotting with y-axis as a categorical axis. @@ -461,13 +458,17 @@ def cat_y( Name of the column(s) that will be used for the y-axis. Must be categorical. update_labels : bool, default True If True, update the x/y labels to the corresponding names. + numeric_axis : bool, default False + If True, the x-axis will be treated as a numerical axis. For example, if + categories are [2, 4, 8], the y coordinates will be mapped to [0, 1, 2] by + default, but if this option is True, the y coordinates will be [2, 4, 8]. Returns ------- YCatPlotter Plotter object """ - return _df.YCatPlotter(self, data, y, x, update_labels) + return _df.YCatPlotter(self, data, y, x, update_labels, numeric=numeric_axis) def cat_xy( self, diff --git a/whitecanvas/canvas/dataframe/_base.py b/whitecanvas/canvas/dataframe/_base.py index 254fa46b..ca12bdc2 100644 --- a/whitecanvas/canvas/dataframe/_base.py +++ b/whitecanvas/canvas/dataframe/_base.py @@ -55,10 +55,12 @@ def __init__( self, df: DataFrameWrapper[_DF], offsets: tuple[str, ...], + numeric: bool = False, ): self._df = df self._offsets = offsets self._cat_map_cache = {} + self._numeric = numeric @property def df(self) -> DataFrameWrapper[_DF]: @@ -96,7 +98,10 @@ def iter_arrays( f"by={by!r}" ) indices = [by.index(d) for d in self._offsets] - _map = self.category_map(self._offsets) + if self._numeric: + _map = NumericMap() + else: + _map = self.category_map(self._offsets) if not dodge: for sl, group in self._df.group_by(by): key = tuple(sl[i] for i in indices) @@ -107,6 +112,8 @@ def iter_arrays( f"offsets and dodge must be disjoint, got offsets={self._offsets!r}" f" and dodge={dodge!r}" ) + if self._numeric: + raise ValueError("dodge is not supported for numeric data.") inv_indices = [by.index(d) for d in dodge] _res_map = self.category_map(dodge) _nres = len(_res_map) @@ -169,3 +176,8 @@ def zoom_factor(self, dodge: tuple[str, ...] | None = None) -> float: def categories(self) -> list[tuple]: return list(self.category_map(self._offsets).keys()) + + +class NumericMap: + def __getitem__(self, key: tuple[float]) -> float: + return key[0] diff --git a/whitecanvas/canvas/dataframe/_one_cat.py b/whitecanvas/canvas/dataframe/_one_cat.py index 1d6de2e7..91a0c6ec 100644 --- a/whitecanvas/canvas/dataframe/_one_cat.py +++ b/whitecanvas/canvas/dataframe/_one_cat.py @@ -93,6 +93,7 @@ def __init__( offset: str | tuple[str, ...] | None, value: str | None, update_labels: bool = False, + numeric: bool = False, ): super().__init__(canvas, df) if isinstance(offset, str): @@ -105,28 +106,40 @@ def __init__( "Category column(s) must be specified by a string or a sequence " f"of strings, got {offset!r}." ) - # check dtype - for col in offset: - arr = self._df[col] - if arr.dtype.kind in "fc": + if numeric and offset is not None: + if len(offset) != 1: raise ValueError( - f"Column {col!r} cannot be interpreted as a categorical column " + "Numerical axis with multiple categories is not supported." + ) + arr = self._df[offset[0]] + if arr.dtype.kind not in "iuf": + raise ValueError( + f"Column {offset[0]!r} cannot be interpreted as a numerical axis " f"(dtype={arr.dtype!r})." ) + # check dtype? + # for col in offset: + # arr = self._df[col] + # if arr.dtype.kind in "fc": + # raise ValueError( + # f"Column {col!r} cannot be interpreted as a categorical column " + # f"(dtype={arr.dtype!r})." + # ) self._offset: tuple[str, ...] = offset - self._cat_iter = CatIterator(self._df, offset) + self._cat_iter = CatIterator(self._df, offset, numeric=numeric) self._value = value self._update_labels = update_labels if update_labels: if value is not None: self._update_axis_labels(value) - pos, label = self._cat_iter.axis_ticks() - if self._orient.is_vertical: - canvas.x.ticks.set_labels(pos, label) - canvas.x.lim = (np.min(pos) - 0.5, np.max(pos) + 0.5) - else: - canvas.y.ticks.set_labels(pos, label) - canvas.y.lim = (np.min(pos) - 0.5, np.max(pos) + 0.5) + if not numeric: + pos, label = self._cat_iter.axis_ticks() + if self._orient.is_vertical: + canvas.x.ticks.set_labels(pos, label) + canvas.x.lim = (np.min(pos) - 0.5, np.max(pos) + 0.5) + else: + canvas.y.ticks.set_labels(pos, label) + canvas.y.lim = (np.min(pos) - 0.5, np.max(pos) + 0.5) def __repr__(self) -> str: return ( @@ -184,6 +197,7 @@ def stack( orient=self._orient, stackby=by, update_labels=self._update_labels, + numeric=self._cat_iter._numeric, ) def melt( diff --git a/whitecanvas/canvas/dataframe/_stacked_cat.py b/whitecanvas/canvas/dataframe/_stacked_cat.py index c6bab99c..521bf663 100644 --- a/whitecanvas/canvas/dataframe/_stacked_cat.py +++ b/whitecanvas/canvas/dataframe/_stacked_cat.py @@ -5,7 +5,7 @@ from whitecanvas.canvas.dataframe._base import BaseCatPlotter, CatIterator from whitecanvas.layers import tabular as _lt from whitecanvas.layers.tabular import _jitter, _shared -from whitecanvas.types import ColorType, Hatch, Orientation, Symbol +from whitecanvas.types import ColorType, Hatch, Orientation if TYPE_CHECKING: from whitecanvas.canvas._base import CanvasBase @@ -26,6 +26,7 @@ def __init__( orient: Orientation, stackby: str | tuple[str, ...] | None = None, update_labels: bool = False, + numeric: bool = False, ): super().__init__(canvas, df) if isinstance(stackby, str): @@ -40,7 +41,7 @@ def __init__( ) stackby = tuple(stackby) self._offset: tuple[str, ...] = offset - self._cat_iter = CatIterator(self._df, offset) + self._cat_iter = CatIterator(self._df, offset, numeric=numeric) self._value = value self._stackby = stackby self._orient = orient @@ -111,14 +112,14 @@ def add_area( layer.update_color(canvas._color_palette.next()) return canvas.add_layer(layer) - def add_stem( - self, - *, - color: NStr | None = None, - symbol: Symbol = Symbol.CIRCLE, - size: float | None = None, - width: float | None = None, - style: NStr | None = None, - name: str | None = None, - ): - ... + # def add_stem( + # self, + # *, + # color: NStr | None = None, + # symbol: Symbol = Symbol.CIRCLE, + # size: float | None = None, + # width: float | None = None, + # style: NStr | None = None, + # name: str | None = None, + # ): + # ... diff --git a/whitecanvas/layers/_mixin.py b/whitecanvas/layers/_mixin.py index 2696cdbd..08633df7 100644 --- a/whitecanvas/layers/_mixin.py +++ b/whitecanvas/layers/_mixin.py @@ -396,7 +396,10 @@ class ConstEdge(SinglePropertyEdgeBase): @property def color(self) -> NDArray[np.floating]: - return self._layer._backend._plt_get_edge_color()[0] + colors = self._layer._backend._plt_get_edge_color() + if len(colors) > 0: + return colors[0] + return np.array([0, 0, 0, 0], dtype=np.float32) @color.setter def color(self, value: ColorType): @@ -407,7 +410,10 @@ def color(self, value: ColorType): @property def style(self) -> LineStyle: """Edge style.""" - return self._layer._backend._plt_get_edge_style()[0] + styles = self._layer._backend._plt_get_edge_style() + if len(styles) > 0: + return styles[0] + return LineStyle.SOLID @style.setter def style(self, value: str | LineStyle): @@ -418,7 +424,10 @@ def style(self, value: str | LineStyle): @property def width(self) -> float: """Edge width.""" - return self._layer._backend._plt_get_edge_width()[0] + widths = self._layer._backend._plt_get_edge_width() + if len(widths) > 0: + return widths[0] + return 0.0 @width.setter def width(self, value: float): @@ -449,7 +458,8 @@ def color(self) -> NDArray[np.floating]: @color.setter def color(self, color): col = as_color_array(color, self._layer.ndata) - self._layer._backend._plt_set_face_color(col) + if self._layer.ndata > 0: + self._layer._backend._plt_set_face_color(col) self.events.color.emit(col) @property @@ -465,7 +475,8 @@ def hatch(self, hatch: str | Hatch | Iterable[str | Hatch]): pass else: hatch = [Hatch(p) for p in hatch] - self._layer._backend._plt_set_face_hatch(hatch) + if self._layer.ndata > 0: + self._layer._backend._plt_set_face_hatch(hatch) self.events.hatch.emit(hatch) @@ -478,7 +489,8 @@ def color(self) -> NDArray[np.floating]: @color.setter def color(self, color): col = as_color_array(color, self._layer.ndata) - self._layer._backend._plt_set_edge_color(col) + if self._layer.ndata > 0: + self._layer._backend._plt_set_edge_color(col) self.events.color.emit(col) @property @@ -497,7 +509,8 @@ def width(self, width: float | Iterable[float]): ) else: width = float(width) - self._layer._backend._plt_set_edge_width(width) + if self._layer.ndata > 0: + self._layer._backend._plt_set_edge_width(width) self.events.width.emit(width) @property @@ -513,7 +526,8 @@ def style(self, style: str | LineStyle | Iterable[str | LineStyle]): pass else: style = [LineStyle(s) for s in style] - self._layer._backend._plt_set_edge_style(style) + if self._layer.ndata > 0: + self._layer._backend._plt_set_edge_style(style) self.events.style.emit(style) diff --git a/whitecanvas/layers/_primitive/markers.py b/whitecanvas/layers/_primitive/markers.py index b823bf17..d59fe5cc 100644 --- a/whitecanvas/layers/_primitive/markers.py +++ b/whitecanvas/layers/_primitive/markers.py @@ -171,7 +171,8 @@ def symbol(self) -> Symbol: @symbol.setter def symbol(self, symbol: str | Symbol): sym = Symbol(symbol) - self._backend._plt_set_symbol(sym) + if self.ndata > 0: + self._backend._plt_set_symbol(sym) self.events.symbol.emit(sym) @property @@ -188,6 +189,7 @@ def size(self) -> _Size: @size.setter def size(self, size: _Size): """Set marker size""" + ndata = self.ndata if not isinstance(size, (float, int, np.number)): if not self._size_is_array: raise ValueError( @@ -195,12 +197,13 @@ def size(self, size: _Size): "set multiple sizes." ) size = as_array_1d(size) - if size.size != self.ndata: + if size.size != ndata: raise ValueError( f"Expected `size` to have the same size as the layer data size " f"({self.ndata}), got {size.size}." ) - self._backend._plt_set_symbol_size(size) + if ndata > 0: + self._backend._plt_set_symbol_size(size) self.events.size.emit(size) def update( diff --git a/whitecanvas/layers/group/boxplot.py b/whitecanvas/layers/group/boxplot.py index 17c83484..2dbcc48f 100644 --- a/whitecanvas/layers/group/boxplot.py +++ b/whitecanvas/layers/group/boxplot.py @@ -177,7 +177,7 @@ def _update_data(self, agg_arr: NDArray[np.number]): x, agg_arr[0], agg_arr[1], agg_arr[3], agg_arr[4], self._capsize ) medsegs = [ - [(x0 - extent / 2, y0), (x0 + extent / 2, y0)] + np.array([(x0 - extent / 2, y0), (x0 + extent / 2, y0)]) for x0, y0 in zip(x, agg_arr[2]) ] else: @@ -185,7 +185,7 @@ def _update_data(self, agg_arr: NDArray[np.number]): x, agg_arr[0], agg_arr[1], agg_arr[3], agg_arr[4], self._capsize ) medsegs = [ - [(x0, y0 - extent / 2), (x0, y0 + extent / 2)] + np.array([(x0, y0 - extent / 2), (x0, y0 + extent / 2)]) for x0, y0 in zip(x, agg_arr[2]) ] self.whiskers.data = segs @@ -238,12 +238,12 @@ def _xyy_to_segments( v1 = np.stack([x, y1], axis=1) v2 = np.stack([x, y2], axis=1) v3 = np.stack([x, y3], axis=1) - segments_0 = [[s0, s1] for s0, s1 in zip(v0, v1)] - segments_1 = [[s2, s3] for s2, s3 in zip(v2, v3)] + segments_0 = [np.stack([s0, s1]) for s0, s1 in zip(v0, v1)] + segments_1 = [np.stack([s2, s3]) for s2, s3 in zip(v2, v3)] if capsize > 0: _c = np.array([capsize / 2, 0]) - cap0 = [[s0 - _c, s0 + _c] for s0 in v0] - cap1 = [[s3 - _c, s3 + _c] for s3 in v3] + cap0 = [np.stack([s0 - _c, s0 + _c]) for s0 in v0] + cap1 = [np.stack([s3 - _c, s3 + _c]) for s3 in v3] else: cap0 = [] cap1 = [] @@ -269,13 +269,13 @@ def _yxx_to_segments( v1 = np.stack([x1, y], axis=1) v2 = np.stack([x2, y], axis=1) v3 = np.stack([x3, y], axis=1) - segments_0 = [[s0, s1] for s0, s1 in zip(v0, v1)] - segments_1 = [[s2, s3] for s2, s3 in zip(v2, v3)] + segments_0 = [np.stack([s0, s1]) for s0, s1 in zip(v0, v1)] + segments_1 = [np.stack([s2, s3]) for s2, s3 in zip(v2, v3)] if capsize > 0: _c = np.array([0, capsize / 2]) - cap0 = [[s0 - _c, s0 + _c] for s0 in v0] - cap1 = [[s3 - _c, s3 + _c] for s3 in v3] + cap0 = [np.stack([s0 - _c, s0 + _c]) for s0 in v0] + cap1 = [np.stack([s3 - _c, s3 + _c]) for s3 in v3] else: cap0 = [] cap1 = [] diff --git a/whitecanvas/layers/group/labeled.py b/whitecanvas/layers/group/labeled.py index 6af461b6..ff339f77 100644 --- a/whitecanvas/layers/group/labeled.py +++ b/whitecanvas/layers/group/labeled.py @@ -329,6 +329,7 @@ class LabeledBars( _mixin.AbstractFaceEdgeMixin["PlotFace", "PlotEdge"], Generic[_NFace, _NEdge], ): + _ATTACH_TO_AXIS = True events: RichContainerEvents _events_class = RichContainerEvents @@ -350,6 +351,11 @@ def bars(self) -> Bars[_NFace, _NEdge]: """The bars layer.""" return self._children[0] + @property + def orient(self) -> Orientation: + """The orientation of the bars.""" + return self.bars.orient + def _main_object_layer(self): return self.bars diff --git a/whitecanvas/layers/tabular/_box_like.py b/whitecanvas/layers/tabular/_box_like.py index 90feaeb6..c909f8c0 100644 --- a/whitecanvas/layers/tabular/_box_like.py +++ b/whitecanvas/layers/tabular/_box_like.py @@ -266,6 +266,7 @@ def __init__( self._offsets = cat.offsets self._value = value self._dodge = dodge + self._numeric = cat._numeric self.with_hover_template("\n".join(f"{k}: {{{k}!r}}" for k in self._splitby)) @property @@ -421,7 +422,6 @@ def with_outliers( Random seed for the jitter (same effect as the `seed` argument of the `add_stripplot` method). """ - from whitecanvas.canvas.dataframe._base import CatIterator from whitecanvas.layers.tabular import DFMarkerGroups canvas = self._canvas_ref() @@ -432,7 +432,7 @@ def with_outliers( is_edge_only = self._is_edge_only() # category iterator is used to calculate positions and indices - _cat_self = CatIterator(self._source, offsets=self._offsets) + _cat_self = self._make_cat_iterator() _pos_map = _cat_self.prep_position_map(self._splitby, self._dodge) _extent = _cat_self.zoom_factor(self._dodge) * extent @@ -495,7 +495,6 @@ def with_strip( seed : int, optional Random seed for the jitter. """ - from whitecanvas.canvas.dataframe._base import CatIterator from whitecanvas.layers.tabular import DFMarkerGroups canvas = self._canvas_ref() @@ -509,7 +508,7 @@ def with_strip( color = Color(color) # category iterator is used to calculate positions and indices - _cat_self = CatIterator(self._source, offsets=self._offsets) + _cat_self = self._make_cat_iterator() _pos_map = _cat_self.prep_position_map(self._splitby, self._dodge) _extent = _cat_self.zoom_factor(self._dodge) * extent df = self._source @@ -550,7 +549,6 @@ def with_swarm( sort : bool, default False If True, the markers will be sorted by the value. """ - from whitecanvas.canvas.dataframe._base import CatIterator from whitecanvas.layers.tabular import DFMarkerGroups canvas = self._canvas_ref() @@ -564,7 +562,7 @@ def with_swarm( color = Color(color) # category iterator is used to calculate positions and indices - _cat_self = CatIterator(self._source, offsets=self._offsets) + _cat_self = self._make_cat_iterator() _pos_map = _cat_self.prep_position_map(self._splitby, self._dodge) _extent = _cat_self.zoom_factor(self._dodge) * extent df = self._source @@ -609,7 +607,7 @@ def _as_legend_item(self) -> _legend.LegendItemCollection: def _make_cat_iterator(self) -> CatIterator[_DF]: from whitecanvas.canvas.dataframe._base import CatIterator - return CatIterator(self._source, offsets=self._offsets) + return CatIterator(self._source, offsets=self._offsets, numeric=self._numeric) def _is_edge_only(self) -> bool: return np.all(self.base.face.alpha < 1e-6) @@ -649,6 +647,7 @@ def __init__( self._offsets = cat.offsets self._value = value self._dodge = dodge + self._numeric = cat._numeric @property def orient(self) -> Orientation: @@ -724,7 +723,7 @@ def with_outliers( is_edge_only = np.all(self.base.boxes.face.alpha < 1e-6) # category iterator is used to calculate positions and indices - _cat_self = CatIterator(self._source, offsets=self._offsets) + _cat_self = CatIterator(self._source, self._offsets, self._numeric) _pos_map = _cat_self.prep_position_map(self._splitby, self._dodge) _extent = _cat_self.zoom_factor(self._dodge) * extent @@ -894,6 +893,7 @@ def __init__( _BoxLikeMixin.__init__(self, categories, _splitby, color_by, hatch_by) base.with_edge(color=theme.get_theme().foreground_color) self._orient = orient + self._numeric = cat._numeric @property def orient(self) -> Orientation: @@ -975,6 +975,7 @@ def __init__( _BoxLikeMixin.__init__(self, categories, _splitby, color_by, hatch_by) base.with_edge(color=theme.get_theme().foreground_color) self._orient = orient + self._numeric = cat._numeric @property def orient(self) -> Orientation: From 1add3c859ee1f30cfd14485503dfb7845dec2bcb Mon Sep 17 00:00:00 2001 From: Hanjin Liu Date: Sun, 25 Feb 2024 23:03:29 +0900 Subject: [PATCH 2/3] more tests --- tests/_utils.py | 13 +++++++++++++ tests/test_categorical.py | 20 +++++++++++--------- whitecanvas/backend/bokeh/markers.py | 20 ++++++++++++++++++-- whitecanvas/backend/matplotlib/canvas.py | 6 +++++- whitecanvas/canvas/dataframe/_one_cat.py | 1 - 5 files changed, 47 insertions(+), 13 deletions(-) diff --git a/tests/_utils.py b/tests/_utils.py index 29dd1b2f..63f1a2bb 100644 --- a/tests/_utils.py +++ b/tests/_utils.py @@ -1,3 +1,5 @@ +from contextlib import contextmanager +import warnings from cmap import Color def assert_color_equal(a, b): @@ -18,3 +20,14 @@ def assert_color_array_equal(arr, b): ok = all([a == b for a, b in zip(cols, other)]) if not ok: raise AssertionError(f"Color {arr} != {b}") + +@contextmanager +def filter_warning(backend: str, choices: "str | list[str]"): + if isinstance(choices, str): + choices = [choices] + if backend in choices: + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + yield + else: + yield diff --git a/tests/test_categorical.py b/tests/test_categorical.py index 1bef3b31..269dd3e6 100644 --- a/tests/test_categorical.py +++ b/tests/test_categorical.py @@ -2,7 +2,7 @@ import numpy as np from whitecanvas import new_canvas -from ._utils import assert_color_array_equal +from ._utils import assert_color_array_equal, filter_warning import pytest def test_cat(backend: str): @@ -46,17 +46,16 @@ def test_cat_plots(backend: str, orient: str): cat_plt.add_stripplot(color="c") cat_plt.add_swarmplot(color="c") cat_plt.add_boxplot(color="c").with_outliers(ratio=0.5) + with filter_warning(backend, "plotly"): + cat_plt.add_boxplot(color="c").as_edge_only() cat_plt.add_violinplot(color="c").with_rug() cat_plt.add_violinplot(color="c").with_outliers(ratio=0.5) cat_plt.add_violinplot(color="c").with_box() - cat_plt.add_pointplot(color="c").err_by_se() - cat_plt.add_barplot(color="c") - if backend == "plotly": - # NOTE: plotly does not support multiple colors for rugplot - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - cat_plt.add_rugplot(color="c").scale_by_density() - else: + cat_plt.add_violinplot(color="c").as_edge_only().with_strip() + cat_plt.add_violinplot(color="c").with_swarm() + cat_plt.add_pointplot(color="c").err_by_se().err_by_sd().err_by_quantile().est_by_mean().est_by_median() + cat_plt.add_barplot(color="c").err_by_se().err_by_sd().err_by_quantile().est_by_mean().est_by_median() + with filter_warning(backend, "plotly"): cat_plt.add_rugplot(color="c").scale_by_density() def test_markers(backend: str): @@ -141,3 +140,6 @@ def test_catx_legend(backend: str): _c.add_pointplot(color="label").err_by_se() _c.add_barplot(color="label") canvas.add_legend() + +def test_numeric_axis(backend: str): + ... diff --git a/whitecanvas/backend/bokeh/markers.py b/whitecanvas/backend/bokeh/markers.py index 042a240f..6f726104 100644 --- a/whitecanvas/backend/bokeh/markers.py +++ b/whitecanvas/backend/bokeh/markers.py @@ -48,8 +48,24 @@ def __init__(self, xdata, ydata): def _plt_get_data(self): return self._data.data["x"], self._data.data["y"] - def _plt_set_data(self, xdata, ydata): - self._data.data = {"x": xdata, "y": ydata} + def _plt_set_data(self, xdata: NDArray[np.number], ydata: NDArray[np.number]): + ndata = self._data.data["x"].size + cur_data = self._data.data.copy() + cur_data["x"] = xdata + cur_data["y"] = ydata + cols_to_update = [ + "sizes", "face_color", "edge_color", "width", "pattern", "style", + "hovertexts" + ] # fmt: skip + if xdata.size < ndata: + for key in cols_to_update: + cur_data[key] = cur_data[key][: xdata.size] + elif xdata.size > ndata: + for key in cols_to_update: + cur_data[key] = np.concatenate( + [cur_data[key], np.full(xdata.size - ndata, cur_data[key][-1])] + ) + self._data.data = cur_data def _plt_get_symbol(self) -> Symbol: sym = self._model.marker diff --git a/whitecanvas/backend/matplotlib/canvas.py b/whitecanvas/backend/matplotlib/canvas.py index 1b94f66f..71de5144 100644 --- a/whitecanvas/backend/matplotlib/canvas.py +++ b/whitecanvas/backend/matplotlib/canvas.py @@ -1,5 +1,6 @@ from __future__ import annotations +import warnings from timeit import default_timer from typing import Callable @@ -408,7 +409,10 @@ def _plt_screenshot(self): def _plt_set_figsize(self, width: int, height: int): dpi = self._fig.get_dpi() self._fig.set_size_inches(width / dpi, height / dpi) - self._fig.tight_layout() + with warnings.catch_warnings(): + # if the size is small, tight_layout may raise a warning + warnings.simplefilter("ignore") + self._fig.tight_layout() def _plt_set_spacings(self, wspace: float, hspace: float): dpi = self._fig.get_dpi() diff --git a/whitecanvas/canvas/dataframe/_one_cat.py b/whitecanvas/canvas/dataframe/_one_cat.py index 91a0c6ec..f195bf41 100644 --- a/whitecanvas/canvas/dataframe/_one_cat.py +++ b/whitecanvas/canvas/dataframe/_one_cat.py @@ -274,7 +274,6 @@ def add_violinplot( shape : str, default "both" Shape of the violins. Can be "both", "left", or "right". - Returns ------- DFViolinPlot From 87c0473a54777da21e62549fc25a052e322e6eee Mon Sep 17 00:00:00 2001 From: Hanjin Liu Date: Sun, 25 Feb 2024 23:24:41 +0900 Subject: [PATCH 3/3] update cdoc --- pyproject.toml | 1 + tests/test_categorical.py | 28 +++++- whitecanvas/canvas/dataframe/_base.py | 6 +- whitecanvas/canvas/dataframe/_one_cat.py | 108 ++++++++++++++++++----- 4 files changed, 118 insertions(+), 25 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 718f06f3..ab61dce6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ testing = [ "pytest", "pytest-qt", "pytest-cov", + "imageio", "qtpy>=2.4.1", "pyqt5>=5.15.4", "ipywidgets>=8.0.0", diff --git a/tests/test_categorical.py b/tests/test_categorical.py index 269dd3e6..ac2576a8 100644 --- a/tests/test_categorical.py +++ b/tests/test_categorical.py @@ -141,5 +141,29 @@ def test_catx_legend(backend: str): _c.add_barplot(color="label") canvas.add_legend() -def test_numeric_axis(backend: str): - ... +@pytest.mark.parametrize("orient", ["v", "h"]) +def test_numeric_axis(backend: str, orient: str): + canvas = new_canvas(backend=backend) + df = { + "y": np.arange(30), + "label": np.repeat([2, 5, 6], 10), + "c": ["P", "Q"] * 15, + } + if orient == "v": + cat_plt = canvas.cat_x(df, "label", "y", numeric_axis=True) + else: + cat_plt = canvas.cat_y(df, "y", "label", numeric_axis=True) + cat_plt.add_stripplot(color="c") + cat_plt.add_swarmplot(color="c") + cat_plt.add_boxplot(color="c").with_outliers(ratio=0.5) + with filter_warning(backend, "plotly"): + cat_plt.add_boxplot(color="c").as_edge_only() + cat_plt.add_violinplot(color="c").with_rug() + cat_plt.add_violinplot(color="c").with_outliers(ratio=0.5) + cat_plt.add_violinplot(color="c").with_box() + cat_plt.add_violinplot(color="c").as_edge_only().with_strip() + cat_plt.add_violinplot(color="c").with_swarm() + cat_plt.add_pointplot(color="c").err_by_se().err_by_sd().err_by_quantile().est_by_mean().est_by_median() + cat_plt.add_barplot(color="c").err_by_se().err_by_sd().err_by_quantile().est_by_mean().est_by_median() + with filter_warning(backend, "plotly"): + cat_plt.add_rugplot(color="c").scale_by_density() diff --git a/whitecanvas/canvas/dataframe/_base.py b/whitecanvas/canvas/dataframe/_base.py index ca12bdc2..13037adf 100644 --- a/whitecanvas/canvas/dataframe/_base.py +++ b/whitecanvas/canvas/dataframe/_base.py @@ -113,11 +113,13 @@ def iter_arrays( f" and dodge={dodge!r}" ) if self._numeric: - raise ValueError("dodge is not supported for numeric data.") + _pos = list(self.prep_position_map(self._offsets, dodge=False).values()) + _width = np.diff(np.sort(_pos)).min() * 0.8 + else: + _width = 0.8 inv_indices = [by.index(d) for d in dodge] _res_map = self.category_map(dodge) _nres = len(_res_map) - _width = 0.8 dmax = (_nres - 1) / 2 / _nres * _width dd = np.linspace(-dmax, dmax, _nres) for sl, group in self._df.group_by(by): diff --git a/whitecanvas/canvas/dataframe/_one_cat.py b/whitecanvas/canvas/dataframe/_one_cat.py index f195bf41..3ea9821d 100644 --- a/whitecanvas/canvas/dataframe/_one_cat.py +++ b/whitecanvas/canvas/dataframe/_one_cat.py @@ -259,7 +259,9 @@ def add_violinplot( >>> canvas.cat_x(df, x="species", y="weight").add_violinplot() >>> ### Color by column "region" with dodging. - >>> canvas.cat_x(df, "region", "weight").add_violinplot(dodge=True) + >>> canvas.cat_x(df, x="species", y="weight").add_violinplot( + ... color="region", dodge=True, + ... ) Parameters ---------- @@ -269,6 +271,9 @@ def add_violinplot( Column name(s) for coloring the lines. Must be categorical. hatch : str or sequence of str, optional Column name(s) for hatches. Must be categorical. + dodge : str, sequence of str or bool, optional + Column name(s) for dodging. If True, column name(s) for splitting the data + will all be used for dodging to avoid overlap. extent : float, default 0.8 Width of the violins. Usually in range (0, 1]. shape : str, default "both" @@ -291,10 +296,10 @@ def add_violinplot( def add_boxplot( self, *, + name: str | None = None, color: NStr | None = None, hatch: NStr | None = None, dodge: NStr | bool = True, - name: str | None = None, capsize: float = 0.1, extent: float = 0.8, ) -> _lt.DFBoxPlot[_DF]: @@ -305,16 +310,21 @@ def add_boxplot( >>> canvas.cat_x(df, x="species", y="weight").add_boxplot() >>> ### Color by column "region" with dodging. - >>> canvas.cat_x(df, "region", "weight").add_boxplot(dodge=True) + >>> canvas.cat_x(df, x="species", y="weight").add_boxplot( + ... color="region", dodge=True, + ... ) Parameters ---------- + name : str, optional + Name of the layer. color : str or sequence of str, optional Column name(s) for coloring the lines. Must be categorical. hatch : str or sequence of str, optional Column name(s) for hatches. Must be categorical. - name : str, optional - Name of the layer. + dodge : str, sequence of str or bool, optional + Column name(s) for dodging. If True, column name(s) for splitting the data + will all be used for dodging to avoid overlap. capsize : float, default 0.1 Length of the caps as a fraction of the width of the box. extent : float, default 0.8 @@ -337,10 +347,10 @@ def add_boxplot( def add_pointplot( self, *, + name: str | None = None, color: NStr | None = None, hatch: NStr | None = None, dodge: NStr | bool = True, - name: str | None = None, capsize: float = 0.1, ) -> _lt.DFPointPlot[_DF]: """ @@ -350,7 +360,9 @@ def add_pointplot( >>> canvas.cat_x(df, x="species", y="weight").add_pointplot() >>> ### Color by column "region" with dodging. - >>> canvas.cat_x(df, "region", "weight").add_pointplot(dodge=True) + >>> canvas.cat_x(df, x="species", y="weight").add_pointplot( + ... color="region", dodge=True, + ... ) The default estimator and errors are mean and standard deviation. To change them, use `est_by_*` and `err_by_*` methods. @@ -360,12 +372,15 @@ def add_pointplot( Parameters ---------- + name : str, optional + Name of the layer. color : str or sequence of str, optional Column name(s) for coloring the lines. Must be categorical. hatch : str or sequence of str, optional Column name(s) for hatches. Must be categorical. - name : str, optional - Name of the layer. + dodge : str, sequence of str or bool, optional + Column name(s) for dodging. If True, column name(s) for splitting the data + will all be used for dodging to avoid overlap. capsize : float, default 0.1 Length of the caps as a fraction of the width of the box. @@ -386,10 +401,10 @@ def add_pointplot( def add_barplot( self, *, + name: str | None = None, color: NStr | None = None, hatch: NStr | None = None, dodge: NStr | bool = True, - name: str | None = None, capsize: float = 0.1, extent: float = 0.8, ) -> _lt.DFBarPlot[_DF]: @@ -400,7 +415,9 @@ def add_barplot( >>> canvas.cat_x(df, x="species", y="weight").add_barplot() >>> ### Color by column "region" with dodging. - >>> canvas.cat_x(df, "region", "weight").add_barplot(dodge=True) + >>> canvas.cat_x(df, x="species", y="weight").add_barplot( + ... color="region", dodge=True + ... ) The default estimator and errors are mean and standard deviation. To change them, use `est_by_*` and `err_by_*` methods. @@ -410,12 +427,15 @@ def add_barplot( Parameters ---------- + name : str, optional + Name of the layer. color : str or sequence of str, optional Column name(s) for coloring the lines. Must be categorical. hatch : str or sequence of str, optional Column name(s) for hatches. Must be categorical. - name : str, optional - Name of the layer. + dodge : str, sequence of str or bool, optional + Column name(s) for dodging. If True, column name(s) for splitting the data + will all be used for dodging to avoid overlap. capsize : float, default 0.1 Length of the caps as a fraction of the width of the box. extent : float, default 0.8 @@ -445,12 +465,12 @@ def _post_add_boxlike(self, layer: _BoxLikeMixin, color): def add_stripplot( self, *, + name: str | None = None, color: NStr | None = None, hatch: NStr | None = None, symbol: NStr | None = None, size: str | None = None, dodge: NStr | bool = False, - name: str | None = None, extent: float = 0.5, seed: int | None = 0, ) -> _lt.DFMarkerGroups[_DF]: @@ -461,10 +481,14 @@ def add_stripplot( >>> canvas.cat_x(df, x="species", y="weight").add_stripplot() >>> ### Color by column "region" with dodging. - >>> canvas.cat_x(df, "region", "weight").add_stripplot(dodge=True) + >>> canvas.cat_x(df, x="species", y="weight").add_stripplot( + ... color="region", dodge=True + ... ) Parameters ---------- + name : str, optional + Name of the layer. color : str or sequence of str, optional Column name(s) for coloring the lines. Must be categorical. hatch : str or sequence of str, optional @@ -473,8 +497,9 @@ def add_stripplot( Column name(s) for symbols. Must be categorical. size : str, optional Column name for marker size. Must be numerical. - name : str, optional - Name of the layer. + dodge : str, sequence of str or bool, optional + Column name(s) for dodging. If True, column name(s) for splitting the data + will all be used for dodging to avoid overlap. extent : float, default 0.5 Width of the violins. Usually in range (0, 1]. seed : int, optional @@ -528,12 +553,12 @@ def add_markers( def add_swarmplot( self, *, + name: str | None = None, color: NStr | None = None, hatch: NStr | None = None, symbol: NStr | None = None, size: str | None = None, dodge: NStr | bool = False, - name: str | None = None, extent: float = 0.8, sort: bool = False, ) -> _lt.DFMarkerGroups[_DF]: @@ -544,10 +569,14 @@ def add_swarmplot( >>> canvas.cat_x(df, x="species", y="weight").add_swarmplot() >>> ### Color by column "region" with dodging. - >>> canvas.cat_x(df, "region", "weight").add_swarmplot(dodge=True) + >>> canvas.cat_x(df, x="species", y="weight").add_swarmplot( + ... color="region", dodge=True + ... ) Parameters ---------- + name : str, optional + Name of the layer. color : str or sequence of str, optional Column name(s) for coloring the lines. Must be categorical. hatch : str or sequence of str, optional @@ -556,8 +585,9 @@ def add_swarmplot( Column name(s) for symbols. Must be categorical. size : str, optional Column name for marker size. Must be numerical. - name : str, optional - Name of the layer. + dodge : str, sequence of str or bool, optional + Column name(s) for dodging. If True, column name(s) for splitting the data + will all be used for dodging to avoid overlap. extent : float, default 0.8 Width of the violins. Usually in range (0, 1]. sort : bool, default False @@ -606,6 +636,42 @@ def add_rugplot( dodge: NStr | bool = True, extent: float = 0.8, ) -> _lt.DFRugGroups[_DF]: + """ + Add a categorical rug plot. + + >>> ### Use "species" column as categories and "weight" column as values. + >>> canvas.cat_x(df, x="species", y="weight").add_rugplot() + + >>> ### Color by column "region" with dodging. + >>> canvas.cat_x(df, x="species", y="weight").add_rugplot( + ... color="region", dodge=True + ... ) + + Parameters + ---------- + name : str, optional + Name of the layer. + color : str or sequence of str, optional + Column name(s) for coloring the lines. Must be categorical. + hatch : str or sequence of str, optional + Column name(s) for hatches. Must be categorical. + symbol : str or sequence of str, optional + Column name(s) for symbols. Must be categorical. + size : str, optional + Column name for marker size. Must be numerical. + dodge : str, sequence of str or bool, optional + Column name(s) for dodging. If True, column name(s) for splitting the data + will all be used for dodging to avoid overlap. + extent : float, default 0.8 + Width of the violins. Usually in range (0, 1]. + sort : bool, default False + Whether to sort the data by value. + + Returns + ------- + DFMarkerGroups + Marker collection layer. + """ canvas = self._canvas() width = theme._default("line.width", width)