diff --git a/tests/test_categorical.py b/tests/test_categorical.py index 724aea0..80b0613 100644 --- a/tests/test_categorical.py +++ b/tests/test_categorical.py @@ -83,6 +83,19 @@ def test_cat_plots(backend: str, orient: str): cat_plt.add_heatmap_hist(bins=4, color="c").copy() read_canvas(canvas.write_json()) +def test_single_point(): + import warnings + + warnings.filterwarnings("error") # to make sure std is not called with single point + canvas = new_canvas(backend="mock") + df = {"cat": ["a", "b", "b", "c", "c", "c"], "val": np.arange(6)} + cat_plt = canvas.cat_x(df, "cat", "val") + cat_plt.add_barplot() + cat_plt.add_boxplot() + cat_plt.add_heatmap_hist() + cat_plt.add_pointplot() + cat_plt.add_violinplot() + def test_cat_plots_with_sequential_color(): df = { "y": np.arange(30), diff --git a/whitecanvas/backend/matplotlib/canvas.py b/whitecanvas/backend/matplotlib/canvas.py index cb3a7cf..8d82311 100644 --- a/whitecanvas/backend/matplotlib/canvas.py +++ b/whitecanvas/backend/matplotlib/canvas.py @@ -417,6 +417,7 @@ def _plt_show(self): if _is_inline(): from IPython.display import display + self._fig.tight_layout() plt.close(self._fig) display(self._fig) else: diff --git a/whitecanvas/layers/group/band_collection.py b/whitecanvas/layers/group/band_collection.py index 69830df..96977d9 100644 --- a/whitecanvas/layers/group/band_collection.py +++ b/whitecanvas/layers/group/band_collection.py @@ -199,12 +199,18 @@ def _convert_data( xyy_values: list[XYYData] = [] for offset, values in zip(x, data): arr = as_array_1d(values) - kde = gaussian_kde(arr, bw_method=kde_band_width) - - sigma = np.sqrt(kde.covariance[0, 0]) - pad = sigma * 2.5 - x_ = np.linspace(arr.min() - pad, arr.max() + pad, 100) - y = kde(x_) + if arr.size > 1: + kde = gaussian_kde(arr, bw_method=kde_band_width) + sigma = np.sqrt(kde.covariance[0, 0]) + pad = sigma * 2.5 + x_ = np.linspace(arr.min() - pad, arr.max() + pad, 100) + y = kde(x_) + elif arr.size == 1: + x_ = np.array([arr[0]]) + y = np.array([1.0]) + else: + x_ = np.array([]) + y = np.array([]) if shape in ("both", "left"): y0 = -y + offset else: diff --git a/whitecanvas/layers/group/boxplot.py b/whitecanvas/layers/group/boxplot.py index 5046398..2fb9244 100644 --- a/whitecanvas/layers/group/boxplot.py +++ b/whitecanvas/layers/group/boxplot.py @@ -26,12 +26,9 @@ LineStyle, Orientation, OrientationLike, - _Void, ) from whitecanvas.utils.normalize import as_any_1d_array, as_color_array -_void = _Void() - class BoxPlot(LayerContainer, AbstractFaceEdgeMixin["BoxFace", "BoxEdge"]): """ diff --git a/whitecanvas/layers/group/labeled.py b/whitecanvas/layers/group/labeled.py index d2a4d88..6e80877 100644 --- a/whitecanvas/layers/group/labeled.py +++ b/whitecanvas/layers/group/labeled.py @@ -301,16 +301,20 @@ def _as_legend_item(self) -> _legend.MarkerErrorLegendItem: return _legend.MarkerErrorLegendItem(markers, xerr, yerr) -def _init_mean_sd(x, data, color): +def _init_mean_sd(x, data: list[ArrayLike1D], color): x, data = check_array_input(x, data) color = as_color_array(color, len(x)) est_data = [] err_data = [] - for sub_data in data: + for each in data: + sub_data = np.asarray(each) _mean = np.mean(sub_data) - _sd = np.std(sub_data, ddof=1) + if sub_data.size == 1: + _sd = 0 + else: + _sd = np.std(sub_data, ddof=1) est_data.append(_mean) err_data.append(_sd) diff --git a/whitecanvas/layers/tabular/_box_like.py b/whitecanvas/layers/tabular/_box_like.py index eec19d5..46a2bd2 100644 --- a/whitecanvas/layers/tabular/_box_like.py +++ b/whitecanvas/layers/tabular/_box_like.py @@ -271,7 +271,6 @@ def __init__( @classmethod def from_dict(cls, d: dict[str, Any], backend: Backend | str | None = None) -> Self: - """Create a DFViolinPlot from a dictionary.""" from whitecanvas.canvas.dataframe._base import CatIterator base = d["base"] @@ -793,8 +792,7 @@ def with_outliers( high = q3 + ratio * iqr # upper bound of inliers is_inlier = (low <= arr) & (arr <= high) inliers = arr[is_inlier] - agg_values[0, idx_cat] = inliers.min() - agg_values[4, idx_cat] = inliers.max() + agg_values[:, idx_cat] = np.quantile(inliers, [0, 0.25, 0.5, 0.75, 1.0]) outliers = arr[~is_inlier] for _cat, _s in zip(sl, self._splitby): df_outliers[_s].extend([_cat] * outliers.size) @@ -847,7 +845,7 @@ class _EstimatorWrapper(_BoxLikeWrapper[_L, _DF]): def est_by_mean(self) -> Self: """Set estimator to mean.""" - def est_func(x): + def est_func(x: np.ndarray): return np.mean(x) return self._update_estimate(est_func) @@ -855,7 +853,7 @@ def est_func(x): def est_by_median(self) -> Self: """Set estimator to median.""" - def est_func(x): + def est_func(x: np.ndarray): return np.median(x) return self._update_estimate(est_func) @@ -863,8 +861,10 @@ def est_func(x): def err_by_sd(self, scale: float = 1.0, *, ddof: int = 1) -> Self: """Set error to standard deviation.""" - def err_func(x): + def err_func(x: np.ndarray): _mean = np.mean(x) + if x.size <= ddof: + return _mean, _mean _sd = np.std(x, ddof=ddof) * scale return _mean - _sd, _mean + _sd @@ -873,8 +873,10 @@ def err_func(x): def err_by_se(self, scale: float = 1.0, *, ddof: int = 1) -> Self: """Set error to standard error.""" - def err_func(x): + def err_func(x: np.ndarray): _mean = np.mean(x) + if x.size <= ddof: + return _mean, _mean _er = np.std(x, ddof=ddof) / np.sqrt(len(x)) * scale return _mean - _er, _mean + _er