diff --git a/doc/api.rst b/doc/api.rst index 576c22464b..0963b6dbd2 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -29,13 +29,13 @@ Mark objects Bar Bars Dot + Dots Interval Path Paths Line Lines Ribbon - Scatter Stat objects ~~~~~~~~~~~~ diff --git a/doc/nextgen/demo.ipynb b/doc/nextgen/demo.ipynb index 649cd2c19b..08fde40e61 100644 --- a/doc/nextgen/demo.ipynb +++ b/doc/nextgen/demo.ipynb @@ -93,7 +93,7 @@ "metadata": {}, "outputs": [], "source": [ - "so.Plot(tips, x=\"total_bill\", y=\"tip\").add(so.Scatter())" + "so.Plot(tips, x=\"total_bill\", y=\"tip\").add(so.Dots())" ] }, { @@ -111,7 +111,7 @@ "metadata": {}, "outputs": [], "source": [ - "so.Plot(tips).add(so.Scatter(), x=\"total_bill\", y=\"tip\")" + "so.Plot(tips).add(so.Dots(), x=\"total_bill\", y=\"tip\")" ] }, { @@ -131,8 +131,8 @@ "source": [ "(\n", " so.Plot(tips, x=\"total_bill\", y=\"tip\")\n", - " .add(so.Scatter(color=\".6\"), data=tips.query(\"size != 2\"))\n", - " .add(so.Scatter(), data=tips.query(\"size == 2\"))\n", + " .add(so.Dots(color=\".6\"), data=tips.query(\"size != 2\"))\n", + " .add(so.Dots(), data=tips.query(\"size == 2\"))\n", ")" ] }, @@ -153,7 +153,7 @@ "source": [ "(\n", " so.Plot(tips.to_dict(), x=\"total_bill\")\n", - " .add(so.Scatter(), y=tips[\"tip\"].to_numpy())\n", + " .add(so.Dots(), y=tips[\"tip\"].to_numpy())\n", ")" ] }, @@ -172,7 +172,7 @@ "metadata": {}, "outputs": [], "source": [ - "so.Plot(tips, x=\"total_bill\", y=\"tip\", color=\"time\").add(so.Scatter())" + "so.Plot(tips, x=\"total_bill\", y=\"tip\", color=\"time\").add(so.Dots())" ] }, { @@ -192,7 +192,7 @@ "source": [ "(\n", " so.Plot(tips, x=\"total_bill\", y=\"tip\", color=\"day\", fill=\"time\")\n", - " .add(so.Scatter(fillalpha=.8))\n", + " .add(so.Dots(fillalpha=.8))\n", ")" ] }, @@ -376,7 +376,7 @@ "\n", "### Overplotting resolution: the Move\n", "\n", - "Existing seaborn functions have parameters that allow adjustments for overplotting, such as `dodge=` in several categorical functions, `jitter=` in several functions based on scatterplots, and the `multiple=` parameter in distribution functions. In the new interface, those adjustments are abstracted away from the particular visual representation into the concept of a `Move`:" + "Existing seaborn functions have parameters that allow adjustments for overplotting, such as `dodge=` in several categorical functions, `jitter=` in several functions based on scatter plots, and the `multiple=` parameter in distribution functions. In the new interface, those adjustments are abstracted away from the particular visual representation into the concept of a `Move`:" ] }, { @@ -523,7 +523,7 @@ "(\n", " so.Plot(planets, x=\"mass\", y=\"distance\")\n", " .scale(x=\"log\", y=\"log\")\n", - " .add(so.Scatter())\n", + " .add(so.Dots())\n", ")" ] }, @@ -545,7 +545,7 @@ "(\n", " so.Plot(planets, x=\"mass\", y=\"distance\", color=\"orbital_period\")\n", " .scale(x=\"log\", y=\"log\", color=\"rocket\")\n", - " .add(so.Scatter())\n", + " .add(so.Dots())\n", ")" ] }, @@ -571,7 +571,7 @@ " y=so.Continuous(transform=\"log\").tick(at=[3, 10, 30, 100, 300]),\n", " color=so.Continuous(\"rocket\", transform=\"log\"),\n", " )\n", - " .add(so.Scatter())\n", + " .add(so.Dots())\n", ")" ] }, @@ -596,7 +596,7 @@ " y=\"log\",\n", " color=so.Nominal([\"b\", \"g\"], order=[\"Radial Velocity\", \"Transit\"])\n", " )\n", - " .add(so.Scatter())\n", + " .add(so.Dots())\n", ")" ] }, @@ -618,7 +618,7 @@ "(\n", " so.Plot(planets, x=\"distance\", y=\"orbital_period\", pointsize=\"mass\")\n", " .scale(x=\"log\", y=\"log\", pointsize=None)\n", - " .add(so.Scatter())\n", + " .add(so.Dots())\n", ")" ] }, @@ -693,7 +693,7 @@ "(\n", " so.Plot(tips, x=\"total_bill\", y=\"tip\")\n", " .facet(\"time\", order=[\"Dinner\", \"Lunch\"])\n", - " .add(so.Scatter())\n", + " .add(so.Dots())\n", ")" ] }, @@ -715,8 +715,8 @@ "(\n", " so.Plot(tips, x=\"total_bill\", y=\"tip\")\n", " .facet(col=\"day\")\n", - " .add(so.Scatter(color=\".75\"), col=None)\n", - " .add(so.Scatter(), color=\"day\")\n", + " .add(so.Dots(color=\".75\"), col=None)\n", + " .add(so.Dots(), color=\"day\")\n", " .configure(figsize=(7, 3))\n", ")" ] @@ -971,7 +971,7 @@ "(\n", " so.Plot(tips, x=\"total_bill\", y=\"tip\")\n", " .on(ax)\n", - " .add(so.Scatter())\n", + " .add(so.Dots())\n", ")" ] }, @@ -994,7 +994,7 @@ "(\n", " so.Plot(tips, x=\"total_bill\", y=\"tip\")\n", " .on(f)\n", - " .add(so.Scatter())\n", + " .add(so.Dots())\n", " .facet(\"time\")\n", ")" ] @@ -1018,14 +1018,14 @@ "sf1, sf2 = f.subfigures(1, 2)\n", "(\n", " so.Plot(tips, x=\"total_bill\", y=\"tip\", color=\"day\")\n", - " .add(so.Scatter())\n", + " .add(so.Dots())\n", " .on(sf1)\n", " .plot()\n", ")\n", "(\n", " so.Plot(tips, x=\"total_bill\", y=\"tip\", color=\"day\")\n", " .facet(\"day\", wrap=2)\n", - " .add(so.Scatter())\n", + " .add(so.Dots())\n", " .on(sf2)\n", " .plot()\n", ")" @@ -1056,7 +1056,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.9" + "version": "3.9.13" } }, "nbformat": 4, diff --git a/doc/nextgen/index.ipynb b/doc/nextgen/index.ipynb index 3e7cdbadcb..6b95991dba 100644 --- a/doc/nextgen/index.ipynb +++ b/doc/nextgen/index.ipynb @@ -30,7 +30,7 @@ " color=\"smoker\", marker=\"smoker\", pointsize=\"size\",\n", " )\n", " .facet(\"time\")\n", - " .add(so.Scatter())\n", + " .add(so.Dots())\n", " .configure(figsize=(7, 4))\n", ")" ] @@ -104,7 +104,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.9" + "version": "3.9.13" } }, "nbformat": 4, diff --git a/seaborn/_core/properties.py b/seaborn/_core/properties.py index 8a6a4a6523..173fd45efb 100644 --- a/seaborn/_core/properties.py +++ b/seaborn/_core/properties.py @@ -259,9 +259,6 @@ def mapping(x): class PointSize(IntervalProperty): """Size (diameter) of a point mark, in points, with scaling by area.""" _default_range = 2, 8 # TODO use rcparams? - # TODO N.B. both Scatter and Dot use this but have different expected sizes - # Is that something we need to handle? Or assume Dot size rarely scaled? - # Also will Line marks have a PointSize property? def _forward(self, values): """Square native values to implement linear scaling of point area.""" diff --git a/seaborn/_marks/scatter.py b/seaborn/_marks/dot.py similarity index 90% rename from seaborn/_marks/scatter.py rename to seaborn/_marks/dot.py index 2579113a3e..98f84ffd00 100644 --- a/seaborn/_marks/scatter.py +++ b/seaborn/_marks/dot.py @@ -24,19 +24,7 @@ @dataclass -class Scatter(Mark): - """ - A point mark defined by strokes with optional fills. - """ - # TODO retype marker as MappableMarker - marker: MappableString = Mappable(rc="scatter.marker", grouping=False) - stroke: MappableFloat = Mappable(.75, grouping=False) # TODO rcParam? - pointsize: MappableFloat = Mappable(3, grouping=False) # TODO rcParam? - color: MappableColor = Mappable("C0", grouping=False) - alpha: MappableFloat = Mappable(1, grouping=False) # TODO auto alpha? - fill: MappableBool = Mappable(True, grouping=False) - fillcolor: MappableColor = Mappable(depend="color", grouping=False) - fillalpha: MappableFloat = Mappable(.2, grouping=False) +class DotBase(Mark): def _resolve_paths(self, data): @@ -60,28 +48,14 @@ def _resolve_properties(self, data, scales): resolved = resolve_properties(self, data, scales) resolved["path"] = self._resolve_paths(resolved) + resolved["size"] = resolved["pointsize"] ** 2 - if isinstance(data, dict): # TODO need a better way to check + if isinstance(data, dict): # Properties for single dot filled_marker = resolved["marker"].is_filled() else: filled_marker = [m.is_filled() for m in resolved["marker"]] - resolved["linewidth"] = resolved["stroke"] resolved["fill"] = resolved["fill"] * filled_marker - resolved["size"] = resolved["pointsize"] ** 2 - - resolved["edgecolor"] = resolve_color(self, data, "", scales) - resolved["facecolor"] = resolve_color(self, data, "fill", scales) - - # Because only Dot, and not Scatter, has an edgestyle - resolved.setdefault("edgestyle", (0, None)) - - fc = resolved["facecolor"] - if isinstance(fc, tuple): - resolved["facecolor"] = fc[0], fc[1], fc[2], fc[3] * resolved["fill"] - else: - fc[:, 3] = fc[:, 3] * resolved["fill"] # TODO Is inplace mod a problem? - resolved["facecolor"] = fc return resolved @@ -129,33 +103,31 @@ def _legend_artist( ) -# TODO change this to depend on ScatterBase? @dataclass -class Dot(Scatter): +class Dot(DotBase): """ - A point mark defined by shape with optional edges. + A mark suitable for dot plots or less-dense scatterplots. """ marker: MappableString = Mappable("o", grouping=False) + pointsize: MappableFloat = Mappable(6, grouping=False) # TODO rcParam? + stroke: MappableFloat = Mappable(.75, grouping=False) # TODO rcParam? color: MappableColor = Mappable("C0", grouping=False) alpha: MappableFloat = Mappable(1, grouping=False) fill: MappableBool = Mappable(True, grouping=False) edgecolor: MappableColor = Mappable(depend="color", grouping=False) edgealpha: MappableFloat = Mappable(depend="alpha", grouping=False) - pointsize: MappableFloat = Mappable(6, grouping=False) # TODO rcParam? edgewidth: MappableFloat = Mappable(.5, grouping=False) # TODO rcParam? edgestyle: MappableStyle = Mappable("-", grouping=False) def _resolve_properties(self, data, scales): - # TODO this is maybe a little hacky, is there a better abstraction? - resolved = super()._resolve_properties(data, scales) + resolved = super()._resolve_properties(data, scales) filled = resolved["fill"] main_stroke = resolved["stroke"] edge_stroke = resolved["edgewidth"] resolved["linewidth"] = np.where(filled, edge_stroke, main_stroke) - # Overwrite the colors that the super class set main_color = resolve_color(self, data, "", scales) edge_color = resolve_color(self, data, "edge", scales) @@ -166,9 +138,43 @@ def _resolve_properties(self, data, scales): filled = np.squeeze(filled) if isinstance(main_color, tuple): + # TODO handle this in resolve_color main_color = tuple([*main_color[:3], main_color[3] * filled]) else: main_color = np.c_[main_color[:, :3], main_color[:, 3] * filled] resolved["facecolor"] = main_color return resolved + + +@dataclass +class Dots(DotBase): + """ + A dot mark defined by strokes to better handle overplotting. + """ + # TODO retype marker as MappableMarker + marker: MappableString = Mappable(rc="scatter.marker", grouping=False) + pointsize: MappableFloat = Mappable(3, grouping=False) # TODO rcParam? + stroke: MappableFloat = Mappable(.75, grouping=False) # TODO rcParam? + color: MappableColor = Mappable("C0", grouping=False) + alpha: MappableFloat = Mappable(1, grouping=False) # TODO auto alpha? + fill: MappableBool = Mappable(True, grouping=False) + fillcolor: MappableColor = Mappable(depend="color", grouping=False) + fillalpha: MappableFloat = Mappable(.2, grouping=False) + + def _resolve_properties(self, data, scales): + + resolved = super()._resolve_properties(data, scales) + resolved["linewidth"] = resolved.pop("stroke") + resolved["facecolor"] = resolve_color(self, data, "fill", scales) + resolved["edgecolor"] = resolve_color(self, data, "", scales) + resolved.setdefault("edgestyle", (0, None)) + + fc = resolved["facecolor"] + if isinstance(fc, tuple): + resolved["facecolor"] = fc[0], fc[1], fc[2], fc[3] * resolved["fill"] + else: + fc[:, 3] = fc[:, 3] * resolved["fill"] # TODO Is inplace mod a problem? + resolved["facecolor"] = fc + + return resolved diff --git a/seaborn/objects.py b/seaborn/objects.py index ca385a8f66..5bcf7e7ff5 100644 --- a/seaborn/objects.py +++ b/seaborn/objects.py @@ -32,7 +32,7 @@ from seaborn._marks.area import Area, Ribbon # noqa: F401 from seaborn._marks.bar import Bar, Bars # noqa: F401 from seaborn._marks.line import Line, Lines, Path, Paths, Interval # noqa: F401 -from seaborn._marks.scatter import Dot, Scatter # noqa: F401 +from seaborn._marks.dot import Dot, Dots # noqa: F401 from seaborn._stats.base import Stat # noqa: F401 from seaborn._stats.aggregation import Agg, Est # noqa: F401 diff --git a/tests/_marks/test_scatter.py b/tests/_marks/test_dot.py similarity index 87% rename from tests/_marks/test_scatter.py rename to tests/_marks/test_dot.py index 95dcbf42ad..3b2cbbb110 100644 --- a/tests/_marks/test_scatter.py +++ b/tests/_marks/test_dot.py @@ -3,11 +3,18 @@ import pytest from numpy.testing import assert_array_equal +from seaborn.palettes import color_palette from seaborn._core.plot import Plot -from seaborn._marks.scatter import Dot, Scatter +from seaborn._marks.dot import Dot, Dots -class ScatterBase: +@pytest.fixture(autouse=True) +def default_palette(): + with color_palette("deep"): + yield + + +class DotBase: def check_offsets(self, points, x, y): @@ -23,13 +30,67 @@ def check_colors(self, part, points, colors, alpha=None): assert_array_equal(getter(), rgba) -class TestScatter(ScatterBase): +class TestDot(DotBase): + + def test_simple(self): + + x = [1, 2, 3] + y = [4, 5, 2] + p = Plot(x=x, y=y).add(Dot()).plot() + ax = p._figure.axes[0] + points, = ax.collections + self.check_offsets(points, x, y) + self.check_colors("face", points, ["C0"] * 3, 1) + self.check_colors("edge", points, ["C0"] * 3, 1) + + def test_filled_unfilled_mix(self): + + x = [1, 2] + y = [4, 5] + marker = ["a", "b"] + shapes = ["o", "x"] + + mark = Dot(edgecolor="k", stroke=2, edgewidth=1) + p = Plot(x=x, y=y).add(mark, marker=marker).scale(marker=shapes).plot() + ax = p._figure.axes[0] + points, = ax.collections + self.check_offsets(points, x, y) + self.check_colors("face", points, ["C0", to_rgba("C0", 0)], None) + self.check_colors("edge", points, ["k", "C0"], 1) + + expected = [mark.edgewidth, mark.stroke] + assert_array_equal(points.get_linewidths(), expected) + + def test_missing_coordinate_data(self): + + x = [1, float("nan"), 3] + y = [5, 3, 4] + + p = Plot(x=x, y=y).add(Dot()).plot() + ax = p._figure.axes[0] + points, = ax.collections + self.check_offsets(points, [1, 3], [5, 4]) + + @pytest.mark.parametrize("prop", ["color", "fill", "marker", "pointsize"]) + def test_missing_semantic_data(self, prop): + + x = [1, 2, 3] + y = [5, 3, 4] + z = ["a", float("nan"), "b"] + + p = Plot(x=x, y=y, **{prop: z}).add(Dot()).plot() + ax = p._figure.axes[0] + points, = ax.collections + self.check_offsets(points, [1, 3], [5, 4]) + + +class TestDots(DotBase): def test_simple(self): x = [1, 2, 3] y = [4, 5, 2] - p = Plot(x=x, y=y).add(Scatter()).plot() + p = Plot(x=x, y=y).add(Dots()).plot() ax = p._figure.axes[0] points, = ax.collections self.check_offsets(points, x, y) @@ -40,7 +101,7 @@ def test_color_direct(self): x = [1, 2, 3] y = [4, 5, 2] - p = Plot(x=x, y=y).add(Scatter(color="g")).plot() + p = Plot(x=x, y=y).add(Dots(color="g")).plot() ax = p._figure.axes[0] points, = ax.collections self.check_offsets(points, x, y) @@ -52,7 +113,7 @@ def test_color_mapped(self): x = [1, 2, 3] y = [4, 5, 2] c = ["a", "b", "a"] - p = Plot(x=x, y=y, color=c).add(Scatter()).plot() + p = Plot(x=x, y=y, color=c).add(Dots()).plot() ax = p._figure.axes[0] points, = ax.collections self.check_offsets(points, x, y) @@ -64,7 +125,7 @@ def test_fill(self): x = [1, 2, 3] y = [4, 5, 2] c = ["a", "b", "a"] - p = Plot(x=x, y=y, color=c).add(Scatter(fill=False)).plot() + p = Plot(x=x, y=y, color=c).add(Dots(fill=False)).plot() ax = p._figure.axes[0] points, = ax.collections self.check_offsets(points, x, y) @@ -76,7 +137,7 @@ def test_pointsize(self): x = [1, 2, 3] y = [4, 5, 2] s = 3 - p = Plot(x=x, y=y).add(Scatter(pointsize=s)).plot() + p = Plot(x=x, y=y).add(Dots(pointsize=s)).plot() ax = p._figure.axes[0] points, = ax.collections self.check_offsets(points, x, y) @@ -87,7 +148,7 @@ def test_stroke(self): x = [1, 2, 3] y = [4, 5, 2] s = 3 - p = Plot(x=x, y=y).add(Scatter(stroke=s)).plot() + p = Plot(x=x, y=y).add(Dots(stroke=s)).plot() ax = p._figure.axes[0] points, = ax.collections self.check_offsets(points, x, y) @@ -100,7 +161,7 @@ def test_filled_unfilled_mix(self): marker = ["a", "b"] shapes = ["o", "x"] - mark = Scatter(stroke=2) + mark = Dots(stroke=2) p = Plot(x=x, y=y).add(mark, marker=marker).scale(marker=shapes).plot() ax = p._figure.axes[0] points, = ax.collections @@ -108,57 +169,3 @@ def test_filled_unfilled_mix(self): self.check_colors("face", points, [to_rgba("C0", .2), to_rgba("C0", 0)], None) self.check_colors("edge", points, ["C0", "C0"], 1) assert_array_equal(points.get_linewidths(), [mark.stroke] * 2) - - -class TestDot(ScatterBase): - - def test_simple(self): - - x = [1, 2, 3] - y = [4, 5, 2] - p = Plot(x=x, y=y).add(Dot()).plot() - ax = p._figure.axes[0] - points, = ax.collections - self.check_offsets(points, x, y) - self.check_colors("face", points, ["C0"] * 3, 1) - self.check_colors("edge", points, ["C0"] * 3, 1) - - def test_filled_unfilled_mix(self): - - x = [1, 2] - y = [4, 5] - marker = ["a", "b"] - shapes = ["o", "x"] - - mark = Dot(edgecolor="k", stroke=2, edgewidth=1) - p = Plot(x=x, y=y).add(mark, marker=marker).scale(marker=shapes).plot() - ax = p._figure.axes[0] - points, = ax.collections - self.check_offsets(points, x, y) - self.check_colors("face", points, ["C0", to_rgba("C0", 0)], None) - self.check_colors("edge", points, ["k", "C0"], 1) - - expected = [mark.edgewidth, mark.stroke] - assert_array_equal(points.get_linewidths(), expected) - - def test_missing_coordinate_data(self): - - x = [1, float("nan"), 3] - y = [5, 3, 4] - - p = Plot(x=x, y=y).add(Dot()).plot() - ax = p._figure.axes[0] - points, = ax.collections - self.check_offsets(points, [1, 3], [5, 4]) - - @pytest.mark.parametrize("prop", ["color", "fill", "marker", "pointsize"]) - def test_missing_semantic_data(self, prop): - - x = [1, 2, 3] - y = [5, 3, 4] - z = ["a", float("nan"), "b"] - - p = Plot(x=x, y=y, **{prop: z}).add(Dot()).plot() - ax = p._figure.axes[0] - points, = ax.collections - self.check_offsets(points, [1, 3], [5, 4])