diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0da08462..99b26719 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,7 +25,7 @@ repos: exclude: (\.(svg|png|pdf)$)|(CODE_OF_CONDUCT.md) - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.1 + rev: v0.8.0 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix, --show-fixes] @@ -44,7 +44,7 @@ repos: files: tests/.* - repo: https://github.com/tox-dev/pyproject-fmt - rev: v2.4.3 + rev: v2.5.0 hooks: - id: pyproject-fmt args: [--keep-full-version, --no-print-diff] diff --git a/docs/tutorials/shape-creation.rst b/docs/tutorials/shape-creation.rst index 95dd877f..e388a153 100644 --- a/docs/tutorials/shape-creation.rst +++ b/docs/tutorials/shape-creation.rst @@ -82,10 +82,14 @@ Register the shape For the ``data-morph`` CLI to find your shape, you need to register it with the :class:`.ShapeFactory`: -1. Add your shape class to the appropriate file inside the ``src/data_morph/shapes/`` - directory. Note that the filenames correspond to the type of shape (*e.g.*, use - ``src/data_morph/shapes/points.py`` for a new shape inheriting from :class:`.PointCollection`). -2. Add an entry to the ``ShapeFactory._SHAPE_MAPPING`` dictionary in +1. Add your shape class to the appropriate module inside the ``src/data_morph/shapes/`` + directory. Note that these correspond to the type of shape (*e.g.*, use + ``src/data_morph/shapes/points/.py`` for a new shape inheriting from + :class:`.PointCollection`). +2. Add your shape to ``__all__`` in that module's ``__init__.py`` (*e.g.*, use + ``src/data_morph/shapes/points/__init__.py`` for a new shape inheriting from + :class:`.PointCollection`). +3. Add an entry to the ``ShapeFactory._SHAPE_MAPPING`` dictionary in ``src/data_morph/shapes/factory.py``. Test out the shape diff --git a/src/data_morph/shapes/factory.py b/src/data_morph/shapes/factory.py index ca8be28d..ca2f1d23 100644 --- a/src/data_morph/shapes/factory.py +++ b/src/data_morph/shapes/factory.py @@ -9,8 +9,31 @@ from ..data.dataset import Dataset from ..plotting.style import plot_with_custom_style -from . import circles, lines, points, polygons from .bases.shape import Shape +from .circles import Bullseye, Circle, Rings +from .lines import ( + Diamond, + HighLines, + HorizontalLines, + Rectangle, + SlantDownLines, + SlantUpLines, + Star, + VerticalLines, + WideLines, + XLines, +) +from .points import ( + Club, + DotsGrid, + DownParabola, + Heart, + LeftParabola, + RightParabola, + Scatter, + Spade, + UpParabola, +) class ShapeFactory: @@ -34,28 +57,28 @@ class ShapeFactory: """ _SHAPE_MAPPING: dict = { - 'bullseye': circles.Bullseye, - 'circle': circles.Circle, - 'high_lines': lines.HighLines, - 'h_lines': lines.HorizontalLines, - 'slant_down': lines.SlantDownLines, - 'slant_up': lines.SlantUpLines, - 'v_lines': lines.VerticalLines, - 'wide_lines': lines.WideLines, - 'x': lines.XLines, - 'dots': points.DotsGrid, - 'down_parab': points.DownParabola, - 'heart': points.Heart, - 'left_parab': points.LeftParabola, - 'scatter': points.Scatter, - 'right_parab': points.RightParabola, - 'up_parab': points.UpParabola, - 'diamond': polygons.Diamond, - 'rectangle': polygons.Rectangle, - 'rings': circles.Rings, - 'star': polygons.Star, - 'club': points.Club, - 'spade': points.Spade, + 'bullseye': Bullseye, + 'circle': Circle, + 'high_lines': HighLines, + 'h_lines': HorizontalLines, + 'slant_down': SlantDownLines, + 'slant_up': SlantUpLines, + 'v_lines': VerticalLines, + 'wide_lines': WideLines, + 'x': XLines, + 'dots': DotsGrid, + 'down_parab': DownParabola, + 'heart': Heart, + 'left_parab': LeftParabola, + 'scatter': Scatter, + 'right_parab': RightParabola, + 'up_parab': UpParabola, + 'diamond': Diamond, + 'rectangle': Rectangle, + 'rings': Rings, + 'star': Star, + 'club': Club, + 'spade': Spade, } AVAILABLE_SHAPES: list[str] = sorted(_SHAPE_MAPPING.keys()) diff --git a/src/data_morph/shapes/lines.py b/src/data_morph/shapes/lines.py deleted file mode 100644 index 0c89581b..00000000 --- a/src/data_morph/shapes/lines.py +++ /dev/null @@ -1,268 +0,0 @@ -"""Shapes that are lines of lines.""" - -import numpy as np - -from ..data.dataset import Dataset -from .bases.line_collection import LineCollection - - -class HighLines(LineCollection): - """ - Class for the high lines shape. - - .. plot:: - :scale: 75 - :caption: - This shape is generated using the panda dataset. - - from data_morph.data.loader import DataLoader - from data_morph.shapes.lines import HighLines - - _ = HighLines(DataLoader.load_dataset('panda')).plot() - - Parameters - ---------- - dataset : Dataset - The starting dataset to morph into other shapes. - """ - - def __init__(self, dataset: Dataset) -> None: - x_bounds = dataset.data_bounds.x_bounds - y_bounds = dataset.data_bounds.y_bounds - - offset = y_bounds.range / 5 - lower = y_bounds[0] + offset - upper = y_bounds[1] - offset - - super().__init__( - [[x_bounds[0], lower], [x_bounds[1], lower]], - [[x_bounds[0], upper], [x_bounds[1], upper]], - ) - - def __str__(self) -> str: - return 'high_lines' - - -class HorizontalLines(LineCollection): - """ - Class for the horizontal lines shape. - - .. plot:: - :scale: 75 - :caption: - This shape is generated using the panda dataset. - - from data_morph.data.loader import DataLoader - from data_morph.shapes.lines import HorizontalLines - - _ = HorizontalLines(DataLoader.load_dataset('panda')).plot() - - Parameters - ---------- - dataset : Dataset - The starting dataset to morph into other shapes. - """ - - def __init__(self, dataset: Dataset) -> None: - x_bounds = dataset.data_bounds.x_bounds - y_bounds = dataset.data_bounds.y_bounds - - super().__init__( - *[ - [[x_bounds[0], y], [x_bounds[1], y]] - for y in np.linspace(y_bounds[0], y_bounds[1], 5) - ] - ) - - def __str__(self) -> str: - return 'h_lines' - - -class SlantDownLines(LineCollection): - """ - Class for the slant down lines shape. - - .. plot:: - :scale: 75 - :caption: - This shape is generated using the panda dataset. - - from data_morph.data.loader import DataLoader - from data_morph.shapes.lines import SlantDownLines - - _ = SlantDownLines(DataLoader.load_dataset('panda')).plot() - - Parameters - ---------- - dataset : Dataset - The starting dataset to morph into other shapes. - """ - - def __init__(self, dataset: Dataset) -> None: - x_bounds = dataset.morph_bounds.x_bounds - y_bounds = dataset.morph_bounds.y_bounds - - xmin, xmax = x_bounds - xmid = xmin + x_bounds.range / 2 - x_offset = (xmid - xmin) / 2 - - ymin, ymax = y_bounds - ymid = ymin + y_bounds.range / 2 - y_offset = (ymid - ymin) / 2 - - super().__init__( - [[xmin, ymid], [xmid, ymin]], - [[xmin, ymid + y_offset], [xmid + x_offset, ymin]], - [[xmin, ymax], [xmax, ymin]], - [[xmin + x_offset, ymax], [xmax, ymin + y_offset]], - [[xmid, ymax], [xmax, ymid]], - ) - - def __str__(self) -> str: - return 'slant_down' - - -class SlantUpLines(LineCollection): - """ - Class for the slant up lines shape. - - .. plot:: - :scale: 75 - :caption: - This shape is generated using the panda dataset. - - from data_morph.data.loader import DataLoader - from data_morph.shapes.lines import SlantUpLines - - _ = SlantUpLines(DataLoader.load_dataset('panda')).plot() - - Parameters - ---------- - dataset : Dataset - The starting dataset to morph into other shapes. - """ - - def __init__(self, dataset: Dataset) -> None: - x_bounds = dataset.morph_bounds.x_bounds - y_bounds = dataset.morph_bounds.y_bounds - - xmin, xmax = x_bounds - xmid = xmin + x_bounds.range / 2 - x_offset = (xmid - xmin) / 2 - - ymin, ymax = y_bounds - ymid = ymin + y_bounds.range / 2 - y_offset = (ymid - ymin) / 2 - - super().__init__( - [[xmin, ymid], [xmid, ymax]], - [[xmin, ymin + y_offset], [xmid + x_offset, ymax]], - [[xmin, ymin], [xmax, ymax]], - [[xmin + x_offset, ymin], [xmax, ymid + y_offset]], - [[xmid, ymin], [xmax, ymid]], - ) - - def __str__(self) -> str: - return 'slant_up' - - -class VerticalLines(LineCollection): - """ - Class for the vertical lines shape. - - .. plot:: - :scale: 75 - :caption: - This shape is generated using the panda dataset. - - from data_morph.data.loader import DataLoader - from data_morph.shapes.lines import VerticalLines - - _ = VerticalLines(DataLoader.load_dataset('panda')).plot() - - Parameters - ---------- - dataset : Dataset - The starting dataset to morph into other shapes. - """ - - def __init__(self, dataset: Dataset) -> None: - x_bounds = dataset.data_bounds.x_bounds - y_bounds = dataset.data_bounds.y_bounds - - super().__init__( - *[ - [[x, y_bounds[0]], [x, y_bounds[1]]] - for x in np.linspace(x_bounds[0], x_bounds[1], 5) - ] - ) - - def __str__(self) -> str: - return 'v_lines' - - -class WideLines(LineCollection): - """ - Class for the wide lines shape. - - .. plot:: - :scale: 75 - :caption: - This shape is generated using the panda dataset. - - from data_morph.data.loader import DataLoader - from data_morph.shapes.lines import WideLines - - _ = WideLines(DataLoader.load_dataset('panda')).plot() - - Parameters - ---------- - dataset : Dataset - The starting dataset to morph into other shapes. - """ - - def __init__(self, dataset: Dataset) -> None: - x_bounds = dataset.data_bounds.x_bounds - y_bounds = dataset.data_bounds.y_bounds - - offset = x_bounds.range / 5 - lower = x_bounds[0] + offset - upper = x_bounds[1] - offset - - super().__init__( - [[lower, y_bounds[0]], [lower, y_bounds[1]]], - [[upper, y_bounds[0]], [upper, y_bounds[1]]], - ) - - def __str__(self) -> str: - return 'wide_lines' - - -class XLines(LineCollection): - """ - Class for the X shape consisting of two crossing, perpendicular lines. - - .. plot:: - :scale: 75 - :caption: - This shape is generated using the panda dataset. - - from data_morph.data.loader import DataLoader - from data_morph.shapes.lines import XLines - - _ = XLines(DataLoader.load_dataset('panda')).plot() - - Parameters - ---------- - dataset : Dataset - The starting dataset to morph into other shapes. - """ - - def __init__(self, dataset: Dataset) -> None: - xmin, xmax = dataset.morph_bounds.x_bounds - ymin, ymax = dataset.morph_bounds.y_bounds - - super().__init__([[xmin, ymin], [xmax, ymax]], [[xmin, ymax], [xmax, ymin]]) - - def __str__(self) -> str: - return 'x' diff --git a/src/data_morph/shapes/lines/__init__.py b/src/data_morph/shapes/lines/__init__.py new file mode 100644 index 00000000..d1229fe7 --- /dev/null +++ b/src/data_morph/shapes/lines/__init__.py @@ -0,0 +1,25 @@ +"""Shapes made up of lines.""" + +from .diamond import Diamond +from .high_lines import HighLines +from .horizontal_lines import HorizontalLines +from .rectangle import Rectangle +from .slant_down import SlantDownLines +from .slant_up import SlantUpLines +from .star import Star +from .vertical_lines import VerticalLines +from .wide_lines import WideLines +from .x_lines import XLines + +__all__ = [ + 'Diamond', + 'HighLines', + 'HorizontalLines', + 'Rectangle', + 'SlantDownLines', + 'SlantUpLines', + 'Star', + 'VerticalLines', + 'WideLines', + 'XLines', +] diff --git a/src/data_morph/shapes/lines/diamond.py b/src/data_morph/shapes/lines/diamond.py new file mode 100644 index 00000000..9d92c617 --- /dev/null +++ b/src/data_morph/shapes/lines/diamond.py @@ -0,0 +1,40 @@ +"""Diamond shape.""" + +from ...data.dataset import Dataset +from ..bases.line_collection import LineCollection + + +class Diamond(LineCollection): + """ + Class for the diamond shape. + + .. plot:: + :scale: 75 + :caption: + This shape is generated using the panda dataset. + + import matplotlib.pyplot as plt + from data_morph.data.loader import DataLoader + from data_morph.shapes.polygons import Diamond + + _ = Diamond(DataLoader.load_dataset('panda')).plot() + + Parameters + ---------- + dataset : Dataset + The starting dataset to morph into other shapes. + """ + + def __init__(self, dataset: Dataset) -> None: + xmin, xmax = dataset.df.x.quantile([0.05, 0.95]) + ymin, ymax = dataset.df.y.quantile([0.05, 0.95]) + + xmid = (xmax + xmin) / 2 + ymid = (ymax + ymin) / 2 + + super().__init__( + [[xmin, ymid], [xmid, ymax]], + [[xmid, ymax], [xmax, ymid]], + [[xmax, ymid], [xmid, ymin]], + [[xmid, ymin], [xmin, ymid]], + ) diff --git a/src/data_morph/shapes/lines/high_lines.py b/src/data_morph/shapes/lines/high_lines.py new file mode 100644 index 00000000..37e62e2f --- /dev/null +++ b/src/data_morph/shapes/lines/high_lines.py @@ -0,0 +1,41 @@ +"""High lines shape.""" + +from ...data.dataset import Dataset +from ..bases.line_collection import LineCollection + + +class HighLines(LineCollection): + """ + Class for the high lines shape. + + .. plot:: + :scale: 75 + :caption: + This shape is generated using the panda dataset. + + from data_morph.data.loader import DataLoader + from data_morph.shapes.lines import HighLines + + _ = HighLines(DataLoader.load_dataset('panda')).plot() + + Parameters + ---------- + dataset : Dataset + The starting dataset to morph into other shapes. + """ + + def __init__(self, dataset: Dataset) -> None: + x_bounds = dataset.data_bounds.x_bounds + y_bounds = dataset.data_bounds.y_bounds + + offset = y_bounds.range / 5 + lower = y_bounds[0] + offset + upper = y_bounds[1] - offset + + super().__init__( + [[x_bounds[0], lower], [x_bounds[1], lower]], + [[x_bounds[0], upper], [x_bounds[1], upper]], + ) + + def __str__(self) -> str: + return 'high_lines' diff --git a/src/data_morph/shapes/lines/horizontal_lines.py b/src/data_morph/shapes/lines/horizontal_lines.py new file mode 100644 index 00000000..bcc8213a --- /dev/null +++ b/src/data_morph/shapes/lines/horizontal_lines.py @@ -0,0 +1,41 @@ +"""Horizontal lines shape.""" + +import numpy as np + +from ...data.dataset import Dataset +from ..bases.line_collection import LineCollection + + +class HorizontalLines(LineCollection): + """ + Class for the horizontal lines shape. + + .. plot:: + :scale: 75 + :caption: + This shape is generated using the panda dataset. + + from data_morph.data.loader import DataLoader + from data_morph.shapes.lines import HorizontalLines + + _ = HorizontalLines(DataLoader.load_dataset('panda')).plot() + + Parameters + ---------- + dataset : Dataset + The starting dataset to morph into other shapes. + """ + + def __init__(self, dataset: Dataset) -> None: + x_bounds = dataset.data_bounds.x_bounds + y_bounds = dataset.data_bounds.y_bounds + + super().__init__( + *[ + [[x_bounds[0], y], [x_bounds[1], y]] + for y in np.linspace(y_bounds[0], y_bounds[1], 5) + ] + ) + + def __str__(self) -> str: + return 'h_lines' diff --git a/src/data_morph/shapes/lines/rectangle.py b/src/data_morph/shapes/lines/rectangle.py new file mode 100644 index 00000000..7a5d90f5 --- /dev/null +++ b/src/data_morph/shapes/lines/rectangle.py @@ -0,0 +1,37 @@ +"""Rectangle shape.""" + +from ...data.dataset import Dataset +from ..bases.line_collection import LineCollection + + +class Rectangle(LineCollection): + """ + Class for the rectangle shape. + + .. plot:: + :scale: 75 + :caption: + This shape is generated using the panda dataset. + + import matplotlib.pyplot as plt + from data_morph.data.loader import DataLoader + from data_morph.shapes.polygons import Rectangle + + _ = Rectangle(DataLoader.load_dataset('panda')).plot() + + Parameters + ---------- + dataset : Dataset + The starting dataset to morph into other shapes. + """ + + def __init__(self, dataset: Dataset) -> None: + xmin, xmax = dataset.df.x.quantile([0.1, 0.9]) + ymin, ymax = dataset.df.y.quantile([0.1, 0.9]) + + super().__init__( + [[xmin, ymin], [xmin, ymax]], + [[xmin, ymin], [xmax, ymin]], + [[xmax, ymin], [xmax, ymax]], + [[xmin, ymax], [xmax, ymax]], + ) diff --git a/src/data_morph/shapes/lines/slant_down.py b/src/data_morph/shapes/lines/slant_down.py new file mode 100644 index 00000000..7bb2991a --- /dev/null +++ b/src/data_morph/shapes/lines/slant_down.py @@ -0,0 +1,48 @@ +"""Slant down lines shape.""" + +from ...data.dataset import Dataset +from ..bases.line_collection import LineCollection + + +class SlantDownLines(LineCollection): + """ + Class for the slant down lines shape. + + .. plot:: + :scale: 75 + :caption: + This shape is generated using the panda dataset. + + from data_morph.data.loader import DataLoader + from data_morph.shapes.lines import SlantDownLines + + _ = SlantDownLines(DataLoader.load_dataset('panda')).plot() + + Parameters + ---------- + dataset : Dataset + The starting dataset to morph into other shapes. + """ + + def __init__(self, dataset: Dataset) -> None: + x_bounds = dataset.morph_bounds.x_bounds + y_bounds = dataset.morph_bounds.y_bounds + + xmin, xmax = x_bounds + xmid = xmin + x_bounds.range / 2 + x_offset = (xmid - xmin) / 2 + + ymin, ymax = y_bounds + ymid = ymin + y_bounds.range / 2 + y_offset = (ymid - ymin) / 2 + + super().__init__( + [[xmin, ymid], [xmid, ymin]], + [[xmin, ymid + y_offset], [xmid + x_offset, ymin]], + [[xmin, ymax], [xmax, ymin]], + [[xmin + x_offset, ymax], [xmax, ymin + y_offset]], + [[xmid, ymax], [xmax, ymid]], + ) + + def __str__(self) -> str: + return 'slant_down' diff --git a/src/data_morph/shapes/lines/slant_up.py b/src/data_morph/shapes/lines/slant_up.py new file mode 100644 index 00000000..76465523 --- /dev/null +++ b/src/data_morph/shapes/lines/slant_up.py @@ -0,0 +1,48 @@ +"""Slant up lines shape.""" + +from ...data.dataset import Dataset +from ..bases.line_collection import LineCollection + + +class SlantUpLines(LineCollection): + """ + Class for the slant up lines shape. + + .. plot:: + :scale: 75 + :caption: + This shape is generated using the panda dataset. + + from data_morph.data.loader import DataLoader + from data_morph.shapes.lines import SlantUpLines + + _ = SlantUpLines(DataLoader.load_dataset('panda')).plot() + + Parameters + ---------- + dataset : Dataset + The starting dataset to morph into other shapes. + """ + + def __init__(self, dataset: Dataset) -> None: + x_bounds = dataset.morph_bounds.x_bounds + y_bounds = dataset.morph_bounds.y_bounds + + xmin, xmax = x_bounds + xmid = xmin + x_bounds.range / 2 + x_offset = (xmid - xmin) / 2 + + ymin, ymax = y_bounds + ymid = ymin + y_bounds.range / 2 + y_offset = (ymid - ymin) / 2 + + super().__init__( + [[xmin, ymid], [xmid, ymax]], + [[xmin, ymin + y_offset], [xmid + x_offset, ymax]], + [[xmin, ymin], [xmax, ymax]], + [[xmin + x_offset, ymin], [xmax, ymid + y_offset]], + [[xmid, ymin], [xmax, ymid]], + ) + + def __str__(self) -> str: + return 'slant_up' diff --git a/src/data_morph/shapes/lines/star.py b/src/data_morph/shapes/lines/star.py new file mode 100644 index 00000000..be7ce305 --- /dev/null +++ b/src/data_morph/shapes/lines/star.py @@ -0,0 +1,55 @@ +"""Star shape.""" + +from ...data.dataset import Dataset +from ..bases.line_collection import LineCollection + + +class Star(LineCollection): + """ + Class for the star shape. + + .. plot:: + :scale: 75 + :caption: + This shape is generated using the panda dataset. + + import matplotlib.pyplot as plt + from data_morph.data.loader import DataLoader + from data_morph.shapes.polygons import Star + + _ = Star(DataLoader.load_dataset('panda')).plot() + + Parameters + ---------- + dataset : Dataset + The starting dataset to morph into other shapes. + """ + + def __init__(self, dataset: Dataset) -> None: + bounds = dataset.data_bounds.clone() + bounds.align_aspect_ratio() + + x_bounds = bounds.x_bounds + y_bounds = bounds.y_bounds + + xmin, xmax = x_bounds + ymin, ymax = y_bounds + + x_range = x_bounds.range + y_range = y_bounds.range + + pts = [ + [xmin, ymin + y_range * 0.625], + [xmin + x_range * 0.375, ymin + y_range * 0.625], + [xmin + x_range * 0.5, ymax], + [xmin + x_range * 0.625, ymin + y_range * 0.625], + [xmax, ymin + y_range * 0.625], + [xmin + x_range * 0.6875, ymin + y_range * 0.375], + [xmin + x_range * 0.8125, ymin], + [xmin + x_range * 0.5, ymin + y_range * 0.25], + [xmin + x_range * 0.1875, ymin], + [xmin + x_range * 0.3125, ymin + y_range * 0.375], + [xmin, ymin + y_range * 0.625], + ] + + super().__init__(*list(zip(pts[:-1], pts[1:]))) diff --git a/src/data_morph/shapes/lines/vertical_lines.py b/src/data_morph/shapes/lines/vertical_lines.py new file mode 100644 index 00000000..23b239d0 --- /dev/null +++ b/src/data_morph/shapes/lines/vertical_lines.py @@ -0,0 +1,41 @@ +"""Vertical lines shape.""" + +import numpy as np + +from ...data.dataset import Dataset +from ..bases.line_collection import LineCollection + + +class VerticalLines(LineCollection): + """ + Class for the vertical lines shape. + + .. plot:: + :scale: 75 + :caption: + This shape is generated using the panda dataset. + + from data_morph.data.loader import DataLoader + from data_morph.shapes.lines import VerticalLines + + _ = VerticalLines(DataLoader.load_dataset('panda')).plot() + + Parameters + ---------- + dataset : Dataset + The starting dataset to morph into other shapes. + """ + + def __init__(self, dataset: Dataset) -> None: + x_bounds = dataset.data_bounds.x_bounds + y_bounds = dataset.data_bounds.y_bounds + + super().__init__( + *[ + [[x, y_bounds[0]], [x, y_bounds[1]]] + for x in np.linspace(x_bounds[0], x_bounds[1], 5) + ] + ) + + def __str__(self) -> str: + return 'v_lines' diff --git a/src/data_morph/shapes/lines/wide_lines.py b/src/data_morph/shapes/lines/wide_lines.py new file mode 100644 index 00000000..36cbe2a7 --- /dev/null +++ b/src/data_morph/shapes/lines/wide_lines.py @@ -0,0 +1,41 @@ +"""Wide lines shape.""" + +from ...data.dataset import Dataset +from ..bases.line_collection import LineCollection + + +class WideLines(LineCollection): + """ + Class for the wide lines shape. + + .. plot:: + :scale: 75 + :caption: + This shape is generated using the panda dataset. + + from data_morph.data.loader import DataLoader + from data_morph.shapes.lines import WideLines + + _ = WideLines(DataLoader.load_dataset('panda')).plot() + + Parameters + ---------- + dataset : Dataset + The starting dataset to morph into other shapes. + """ + + def __init__(self, dataset: Dataset) -> None: + x_bounds = dataset.data_bounds.x_bounds + y_bounds = dataset.data_bounds.y_bounds + + offset = x_bounds.range / 5 + lower = x_bounds[0] + offset + upper = x_bounds[1] - offset + + super().__init__( + [[lower, y_bounds[0]], [lower, y_bounds[1]]], + [[upper, y_bounds[0]], [upper, y_bounds[1]]], + ) + + def __str__(self) -> str: + return 'wide_lines' diff --git a/src/data_morph/shapes/lines/x_lines.py b/src/data_morph/shapes/lines/x_lines.py new file mode 100644 index 00000000..14b367cb --- /dev/null +++ b/src/data_morph/shapes/lines/x_lines.py @@ -0,0 +1,34 @@ +"""X lines shape.""" + +from ...data.dataset import Dataset +from ..bases.line_collection import LineCollection + + +class XLines(LineCollection): + """ + Class for the X shape consisting of two crossing, perpendicular lines. + + .. plot:: + :scale: 75 + :caption: + This shape is generated using the panda dataset. + + from data_morph.data.loader import DataLoader + from data_morph.shapes.lines import XLines + + _ = XLines(DataLoader.load_dataset('panda')).plot() + + Parameters + ---------- + dataset : Dataset + The starting dataset to morph into other shapes. + """ + + def __init__(self, dataset: Dataset) -> None: + xmin, xmax = dataset.morph_bounds.x_bounds + ymin, ymax = dataset.morph_bounds.y_bounds + + super().__init__([[xmin, ymin], [xmax, ymax]], [[xmin, ymax], [xmax, ymin]]) + + def __str__(self) -> str: + return 'x' diff --git a/src/data_morph/shapes/points.py b/src/data_morph/shapes/points.py deleted file mode 100644 index d4013d9d..00000000 --- a/src/data_morph/shapes/points.py +++ /dev/null @@ -1,446 +0,0 @@ -"""Shapes that are composed of points.""" - -import itertools -from numbers import Number - -import numpy as np - -from ..data.dataset import Dataset -from .bases.point_collection import PointCollection - - -class DotsGrid(PointCollection): - """ - Class representing a 3x3 grid of dots. - - .. plot:: - :scale: 75 - :caption: - This shape is generated using the panda dataset. - - from data_morph.data.loader import DataLoader - from data_morph.shapes.points import DotsGrid - - _ = DotsGrid(DataLoader.load_dataset('panda')).plot() - - Parameters - ---------- - dataset : Dataset - The starting dataset to morph into other shapes. - """ - - def __init__(self, dataset: Dataset) -> None: - xlow, xhigh = dataset.df.x.quantile([0.05, 0.95]).tolist() - ylow, yhigh = dataset.df.y.quantile([0.05, 0.95]).tolist() - - xmid = (xhigh + xlow) / 2 - ymid = (yhigh + ylow) / 2 - - super().__init__( - *list(itertools.product([xlow, xmid, xhigh], [ylow, ymid, yhigh])) - ) - - def __str__(self) -> str: - return 'dots' - - -class DownParabola(PointCollection): - """ - Class for the down parabola shape. - - .. plot:: - :scale: 75 - :caption: - This shape is generated using the panda dataset. - - from data_morph.data.loader import DataLoader - from data_morph.shapes.points import DownParabola - - _ = DownParabola(DataLoader.load_dataset('panda')).plot() - - Parameters - ---------- - dataset : Dataset - The starting dataset to morph into other shapes. - """ - - def __init__(self, dataset: Dataset) -> None: - x_bounds = dataset.data_bounds.x_bounds - xmin, xmax = x_bounds - xmid = xmax - x_bounds.range / 2 - - x_offset = x_bounds.range / 10 - xmin += x_offset - xmax -= x_offset - - ymin, ymax = dataset.data_bounds.y_bounds - - poly = np.polynomial.Polynomial.fit([xmin, xmid, xmax], [ymin, ymax, ymin], 2) - - super().__init__(*np.stack(poly.linspace(), axis=1)) - - def __str__(self) -> str: - return 'down_parab' - - -class Heart(PointCollection): - """ - Class for the heart shape. - - .. plot:: - :scale: 75 - :caption: - This shape is generated using the panda dataset. - - from data_morph.data.loader import DataLoader - from data_morph.shapes.points import Heart - - _ = Heart(DataLoader.load_dataset('panda')).plot() - - Parameters - ---------- - dataset : Dataset - The starting dataset to morph into other shapes. - - Notes - ----- - The formula for the heart shape is inspired by - `Heart Curve `_: - - Weisstein, Eric W. "Heart Curve." From `MathWorld `_ - --A Wolfram Web Resource. https://mathworld.wolfram.com/HeartCurve.html - """ - - def __init__(self, dataset: Dataset) -> None: - x_bounds = dataset.data_bounds.x_bounds - y_bounds = dataset.data_bounds.y_bounds - - x_shift = sum(x_bounds) / 2 - y_shift = sum(y_bounds) / 2 - - t = np.linspace(-3, 3, num=80) - - x = 16 * np.sin(t) ** 3 - y = 13 * np.cos(t) - 5 * np.cos(2 * t) - 2 * np.cos(3 * t) - np.cos(4 * t) - - # scale by the half the widest width of the heart - scale_factor = (x_bounds[1] - x_shift) / 16 - - super().__init__( - *np.stack([x * scale_factor + x_shift, y * scale_factor + y_shift], axis=1) - ) - - -class LeftParabola(PointCollection): - """ - Class for the left parabola shape. - - .. plot:: - :scale: 75 - :caption: - This shape is generated using the panda dataset. - - from data_morph.data.loader import DataLoader - from data_morph.shapes.points import LeftParabola - - _ = LeftParabola(DataLoader.load_dataset('panda')).plot() - - Parameters - ---------- - dataset : Dataset - The starting dataset to morph into other shapes. - """ - - def __init__(self, dataset: Dataset) -> None: - y_bounds = dataset.data_bounds.y_bounds - ymin, ymax = y_bounds - ymid = ymax - y_bounds.range / 2 - - y_offset = y_bounds.range / 10 - ymin += y_offset - ymax -= y_offset - - xmin, xmax = dataset.data_bounds.x_bounds - - poly = np.polynomial.Polynomial.fit([ymin, ymid, ymax], [xmin, xmax, xmin], 2) - - super().__init__(*np.stack(poly.linspace()[::-1], axis=1)) - - def __str__(self) -> str: - return 'left_parab' - - -class RightParabola(PointCollection): - """ - Class for the right parabola shape. - - .. plot:: - :scale: 75 - :caption: - This shape is generated using the panda dataset. - - from data_morph.data.loader import DataLoader - from data_morph.shapes.points import RightParabola - - _ = RightParabola(DataLoader.load_dataset('panda')).plot() - - Parameters - ---------- - dataset : Dataset - The starting dataset to morph into other shapes. - """ - - def __init__(self, dataset: Dataset) -> None: - y_bounds = dataset.data_bounds.y_bounds - ymin, ymax = y_bounds - ymid = ymax - y_bounds.range / 2 - - y_offset = y_bounds.range / 10 - ymin += y_offset - ymax -= y_offset - - xmin, xmax = dataset.data_bounds.x_bounds - - poly = np.polynomial.Polynomial.fit([ymin, ymid, ymax], [xmax, xmin, xmax], 2) - - super().__init__(*np.stack(poly.linspace()[::-1], axis=1)) - - def __str__(self) -> str: - return 'right_parab' - - -class UpParabola(PointCollection): - """ - Class for the up parabola shape. - - .. plot:: - :scale: 75 - :caption: - This shape is generated using the panda dataset. - - from data_morph.data.loader import DataLoader - from data_morph.shapes.points import UpParabola - - _ = UpParabola(DataLoader.load_dataset('panda')).plot() - - Parameters - ---------- - dataset : Dataset - The starting dataset to morph into other shapes. - """ - - def __init__(self, dataset: Dataset) -> None: - x_bounds = dataset.data_bounds.x_bounds - xmin, xmax = x_bounds - xmid = xmax - x_bounds.range / 2 - - x_offset = x_bounds.range / 10 - xmin += x_offset - xmax -= x_offset - - ymin, ymax = dataset.data_bounds.y_bounds - - poly = np.polynomial.Polynomial.fit([xmin, xmid, xmax], [ymax, ymin, ymax], 2) - - super().__init__(*np.stack(poly.linspace(), axis=1)) - - def __str__(self) -> str: - return 'up_parab' - - -class Scatter(PointCollection): - """ - Class for the scatter shape: a cloud of randomly-scattered points. - - .. plot:: - :scale: 75 - :caption: - This shape is generated using the panda dataset. - - from data_morph.data.loader import DataLoader - from data_morph.shapes.points import Scatter - - _ = Scatter(DataLoader.load_dataset('panda')).plot() - - Parameters - ---------- - dataset : Dataset - The starting dataset to morph into other shapes. - """ - - def __init__(self, dataset: Dataset) -> None: - rng = np.random.default_rng(1) - center = (dataset.df.x.mean(), dataset.df.y.mean()) - points = [center] - max_radius = max(dataset.df.x.std(), dataset.df.y.std()) - for radius in np.linspace(max_radius // 5, max_radius, num=5): - for angle in np.linspace(0, 360, num=50, endpoint=False): - points.append( - ( - center[0] - + np.cos(angle) * radius - + rng.standard_normal() * max_radius, - center[1] - + np.sin(angle) * radius - + rng.standard_normal() * max_radius, - ) - ) - super().__init__(*points) - - self._alpha = 0.4 - - def distance(self, x: Number, y: Number) -> int: - """ - No-op that allows returns 0 so that all perturbations are accepted. - - Parameters - ---------- - x, y : int or float - Coordinates of a point in 2D space. - - Returns - ------- - int - Always returns 0 to allow for scattering of the points. - """ - return 0 - - -class Club(PointCollection): - """ - Class for the club shape. - - .. plot:: - :scale: 75 - :caption: - This shape is generated using the panda dataset. - - from data_morph.data.loader import DataLoader - from data_morph.shapes.points import Club - - _ = Club(DataLoader.load_dataset('panda')).plot() - - Parameters - ---------- - dataset : Dataset - The starting dataset to morph into other shapes. - """ - - def __init__(self, dataset: Dataset) -> None: - x_bounds = dataset.data_bounds.x_bounds - y_bounds = dataset.data_bounds.y_bounds - - x_shift = sum(x_bounds) / 2 - y_shift = sum(y_bounds) / 2 - scale_factor = min(x_bounds.range, y_bounds.range) / 75 - - # params for lobes - radius = 15 * scale_factor - top_lobe_y_offset = 18 * scale_factor - bottom_lobes_x_offset = 15 * scale_factor - bottom_lobes_y_offset = 9 * scale_factor - - t = np.linspace(0, (2 - 1 / 3) * np.pi, num=30) - - # top lobe - angle_offset = -1 / 3 * np.pi - x_top = radius * np.cos(t + angle_offset) - y_top = radius * np.sin(t + angle_offset) + top_lobe_y_offset - - # bottom left lobe - angle_offset = 1 / 3 * np.pi - x_bottom_left = radius * np.cos(t + angle_offset) - bottom_lobes_x_offset - y_bottom_left = radius * np.sin(t + angle_offset) - bottom_lobes_y_offset - - # bottom right lobe - angle_offset = np.pi - x_bottom_right = radius * np.cos(t + angle_offset) + bottom_lobes_x_offset - y_bottom_right = radius * np.sin(t + angle_offset) - bottom_lobes_y_offset - - x_lobes = [x_top, x_bottom_left, x_bottom_right] - y_lobes = [y_top, y_bottom_left, y_bottom_right] - - # params for the stem - stem_x_offset = 8 * scale_factor - stem_y_offset = 34 * scale_factor - stem_scaler = 0.35 / scale_factor - stem_x_pad = 1.5 * scale_factor - - # stem bottom - x_line = np.linspace(-stem_x_offset, stem_x_offset, num=8) - y_line = np.repeat(-stem_y_offset, 8) - - # left part of the stem - x_left = np.linspace(-(stem_x_offset - stem_x_pad), -stem_x_pad, num=6) - y_left = stem_scaler * np.power(x_left + stem_x_offset, 2) - stem_y_offset - - # right part of the stem - x_right = np.linspace(stem_x_pad, stem_x_offset - stem_x_pad, num=6) - y_right = stem_scaler * np.power(x_right - stem_x_offset, 2) - stem_y_offset - - x_stem = [x_line, x_left, x_right] - y_stem = [y_line, y_left, y_right] - - xs = x_shift + np.concatenate(x_lobes + x_stem) - ys = y_shift + np.concatenate(y_lobes + y_stem) - - super().__init__(*np.stack([xs, ys], axis=1)) - - -class Spade(PointCollection): - """ - Class for the spade shape. - - .. plot:: - :scale: 75 - :caption: - This shape is generated using the panda dataset. - - from data_morph.data.loader import DataLoader - from data_morph.shapes.points import Spade - - _ = Spade(DataLoader.load_dataset('panda')).plot() - - Parameters - ---------- - dataset : Dataset - The starting dataset to morph into other shapes. - """ - - def __init__(self, dataset: Dataset) -> None: - x_bounds = dataset.data_bounds.x_bounds - y_bounds = dataset.data_bounds.y_bounds - - x_shift = sum(x_bounds) / 2 - y_shift = sum(y_bounds) / 2 - - # graph upside-down heart - heart_points = Heart(dataset).points - heart_points[:, 1] = -heart_points[:, 1] + 2 * y_shift - - # line base - line_x = np.linspace(-6, 6, num=12) - line_y = np.repeat(-16, 12) - - # left wing - left_x = np.linspace(-6, 0, num=12) - left_y = 0.278 * np.power(left_x + 6, 2) - 16 - - # right wing - right_x = np.linspace(0, 6, num=12) - right_y = 0.278 * np.power(right_x - 6, 2) - 16 - - # shift and scale the base and wing - base_x = np.concatenate((line_x, left_x, right_x), axis=0) - base_y = np.concatenate((line_y, left_y, right_y), axis=0) - - # scale by the half the widest width of the spade - scale_factor = (x_bounds[1] - x_shift) / 16 - base_x = base_x * scale_factor + x_shift - base_y = base_y * scale_factor + y_shift - - # combine the base and the upside-down heart - x = np.concatenate((heart_points[:, 0], base_x), axis=0) - y = np.concatenate((heart_points[:, 1], base_y), axis=0) - - super().__init__(*np.stack([x, y], axis=1)) diff --git a/src/data_morph/shapes/points/__init__.py b/src/data_morph/shapes/points/__init__.py new file mode 100644 index 00000000..8609f4d4 --- /dev/null +++ b/src/data_morph/shapes/points/__init__.py @@ -0,0 +1,20 @@ +"""Shapes made up of points.""" + +from .club import Club +from .dots_grid import DotsGrid +from .heart import Heart +from .parabola import DownParabola, LeftParabola, RightParabola, UpParabola +from .scatter import Scatter +from .spade import Spade + +__all__ = [ + 'Club', + 'DotsGrid', + 'DownParabola', + 'Heart', + 'LeftParabola', + 'RightParabola', + 'Scatter', + 'Spade', + 'UpParabola', +] diff --git a/src/data_morph/shapes/points/club.py b/src/data_morph/shapes/points/club.py new file mode 100644 index 00000000..1ce579fb --- /dev/null +++ b/src/data_morph/shapes/points/club.py @@ -0,0 +1,154 @@ +"""Club shape.""" + +from numbers import Number + +import numpy as np + +from ...data.dataset import Dataset +from ..bases.point_collection import PointCollection + + +class Club(PointCollection): + """ + Class for the club shape. + + .. plot:: + :scale: 75 + :caption: + This shape is generated using the panda dataset. + + from data_morph.data.loader import DataLoader + from data_morph.shapes.points import Club + + _ = Club(DataLoader.load_dataset('panda')).plot() + + Parameters + ---------- + dataset : Dataset + The starting dataset to morph into other shapes. + """ + + def __init__(self, dataset: Dataset) -> None: + x_bounds = dataset.data_bounds.x_bounds + y_bounds = dataset.data_bounds.y_bounds + + x_shift = sum(x_bounds) / 2 + y_shift = sum(y_bounds) / 2 + scale_factor = min(x_bounds.range, y_bounds.range) / 75 + + x_lobes, y_lobes = self._get_lobes(scale_factor) + x_stem, y_stem = self._get_stem(scale_factor) + + xs = x_shift + np.concatenate(x_lobes + x_stem) + ys = y_shift + np.concatenate(y_lobes + y_stem) + + super().__init__(*np.stack([xs, ys], axis=1)) + + @staticmethod + def _get_arc( + r: Number, + t: np.ndarray, + angle_offset: np.float64, + x_offset: Number, + y_offset: Number, + ) -> tuple[np.ndarray, np.ndarray]: + """ + Get arc of a circle. + + Parameters + ---------- + r : Number + The radius of the circle. + t : numpy.ndarray + The values to sample at in radians. + angle_offset : numpy.float64 + Angle at which to start the arc in radians. + x_offset : Number + A constant value to shift the *x* coordinates by. + y_offset : Number + A constant value to shift the *y* coordinates by. + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray] + The *x* and *y* coordinates for the arc. + """ + x = r * np.cos(t + angle_offset) + x_offset + y = r * np.sin(t + angle_offset) + y_offset + return x, y + + @classmethod + def _get_lobes( + cls, scale_factor: Number + ) -> tuple[list[np.ndarray], list[np.ndarray]]: + """ + Get the lobes of the club. + + Parameters + ---------- + scale_factor : Number + The factor to scale up/down the radius of the arcs used to calculate the lobes. + + Returns + ------- + tuple[list[numpy.ndarray], list[numpy.ndarray]] + The *x* and *y* coordinates for the lobes. + """ + radius = 15 * scale_factor + top_lobe_y_offset = 18 * scale_factor + bottom_lobes_x_offset = 15 * scale_factor + bottom_lobes_y_offset = 9 * scale_factor + + t = np.linspace(0, (2 - 1 / 3) * np.pi, num=30) + + x_top, y_top = cls._get_arc(radius, t, -np.pi / 3, 0, top_lobe_y_offset) + x_bottom_left, y_bottom_left = cls._get_arc( + radius, t, np.pi / 3, -bottom_lobes_x_offset, -bottom_lobes_y_offset + ) + x_bottom_right, y_bottom_right = cls._get_arc( + radius, t, np.pi, bottom_lobes_x_offset, -bottom_lobes_y_offset + ) + + x_lobes = [x_top, x_bottom_left, x_bottom_right] + y_lobes = [y_top, y_bottom_left, y_bottom_right] + + return x_lobes, y_lobes + + @classmethod + def _get_stem( + cls, scale_factor: Number + ) -> tuple[list[np.ndarray], list[np.ndarray]]: + """ + Get the stem of the club. + + Parameters + ---------- + scale_factor : Number + The factor to scale up/down the stem. + + Returns + ------- + tuple[list[numpy.ndarray], list[numpy.ndarray]] + The *x* and *y* coordinates for the stem. + """ + stem_x_offset = 8 * scale_factor + stem_y_offset = 34 * scale_factor + stem_scaler = 0.35 / scale_factor + stem_x_pad = 1.5 * scale_factor + + # stem bottom + x_line = np.linspace(-stem_x_offset, stem_x_offset, num=8) + y_line = np.repeat(-stem_y_offset, 8) + + # left part of the stem + x_left = np.linspace(-(stem_x_offset - stem_x_pad), -stem_x_pad, num=6) + y_left = stem_scaler * np.power(x_left + stem_x_offset, 2) - stem_y_offset + + # right part of the stem + x_right = np.linspace(stem_x_pad, stem_x_offset - stem_x_pad, num=6) + y_right = stem_scaler * np.power(x_right - stem_x_offset, 2) - stem_y_offset + + x_stem = [x_line, x_left, x_right] + y_stem = [y_line, y_left, y_right] + + return x_stem, y_stem diff --git a/src/data_morph/shapes/points/dots_grid.py b/src/data_morph/shapes/points/dots_grid.py new file mode 100644 index 00000000..467a67ce --- /dev/null +++ b/src/data_morph/shapes/points/dots_grid.py @@ -0,0 +1,41 @@ +"""Dots grid shape.""" + +import itertools + +from ...data.dataset import Dataset +from ..bases.point_collection import PointCollection + + +class DotsGrid(PointCollection): + """ + Class representing a 3x3 grid of dots. + + .. plot:: + :scale: 75 + :caption: + This shape is generated using the panda dataset. + + from data_morph.data.loader import DataLoader + from data_morph.shapes.points import DotsGrid + + _ = DotsGrid(DataLoader.load_dataset('panda')).plot() + + Parameters + ---------- + dataset : Dataset + The starting dataset to morph into other shapes. + """ + + def __init__(self, dataset: Dataset) -> None: + xlow, xhigh = dataset.df.x.quantile([0.05, 0.95]).tolist() + ylow, yhigh = dataset.df.y.quantile([0.05, 0.95]).tolist() + + xmid = (xhigh + xlow) / 2 + ymid = (yhigh + ylow) / 2 + + super().__init__( + *list(itertools.product([xlow, xmid, xhigh], [ylow, ymid, yhigh])) + ) + + def __str__(self) -> str: + return 'dots' diff --git a/src/data_morph/shapes/points/heart.py b/src/data_morph/shapes/points/heart.py new file mode 100644 index 00000000..c8db9382 --- /dev/null +++ b/src/data_morph/shapes/points/heart.py @@ -0,0 +1,54 @@ +"""Heart shape.""" + +import numpy as np + +from ...data.dataset import Dataset +from ..bases.point_collection import PointCollection + + +class Heart(PointCollection): + """ + Class for the heart shape. + + .. plot:: + :scale: 75 + :caption: + This shape is generated using the panda dataset. + + from data_morph.data.loader import DataLoader + from data_morph.shapes.points import Heart + + _ = Heart(DataLoader.load_dataset('panda')).plot() + + Parameters + ---------- + dataset : Dataset + The starting dataset to morph into other shapes. + + Notes + ----- + The formula for the heart shape is inspired by + `Heart Curve `_: + + Weisstein, Eric W. "Heart Curve." From `MathWorld `_ + --A Wolfram Web Resource. https://mathworld.wolfram.com/HeartCurve.html + """ + + def __init__(self, dataset: Dataset) -> None: + x_bounds = dataset.data_bounds.x_bounds + y_bounds = dataset.data_bounds.y_bounds + + x_shift = sum(x_bounds) / 2 + y_shift = sum(y_bounds) / 2 + + t = np.linspace(-3, 3, num=80) + + x = 16 * np.sin(t) ** 3 + y = 13 * np.cos(t) - 5 * np.cos(2 * t) - 2 * np.cos(3 * t) - np.cos(4 * t) + + # scale by the half the widest width of the heart + scale_factor = (x_bounds[1] - x_shift) / 16 + + super().__init__( + *np.stack([x * scale_factor + x_shift, y * scale_factor + y_shift], axis=1) + ) diff --git a/src/data_morph/shapes/points/parabola.py b/src/data_morph/shapes/points/parabola.py new file mode 100644 index 00000000..dea94a99 --- /dev/null +++ b/src/data_morph/shapes/points/parabola.py @@ -0,0 +1,162 @@ +"""Parabola shapes.""" + +import numpy as np + +from ...data.dataset import Dataset +from ..bases.point_collection import PointCollection + + +class DownParabola(PointCollection): + """ + Class for the down parabola shape. + + .. plot:: + :scale: 75 + :caption: + This shape is generated using the panda dataset. + + from data_morph.data.loader import DataLoader + from data_morph.shapes.points import DownParabola + + _ = DownParabola(DataLoader.load_dataset('panda')).plot() + + Parameters + ---------- + dataset : Dataset + The starting dataset to morph into other shapes. + """ + + def __init__(self, dataset: Dataset) -> None: + x_bounds = dataset.data_bounds.x_bounds + xmin, xmax = x_bounds + xmid = xmax - x_bounds.range / 2 + + x_offset = x_bounds.range / 10 + xmin += x_offset + xmax -= x_offset + + ymin, ymax = dataset.data_bounds.y_bounds + + poly = np.polynomial.Polynomial.fit([xmin, xmid, xmax], [ymin, ymax, ymin], 2) + + super().__init__(*np.stack(poly.linspace(), axis=1)) + + def __str__(self) -> str: + return 'down_parab' + + +class LeftParabola(PointCollection): + """ + Class for the left parabola shape. + + .. plot:: + :scale: 75 + :caption: + This shape is generated using the panda dataset. + + from data_morph.data.loader import DataLoader + from data_morph.shapes.points import LeftParabola + + _ = LeftParabola(DataLoader.load_dataset('panda')).plot() + + Parameters + ---------- + dataset : Dataset + The starting dataset to morph into other shapes. + """ + + def __init__(self, dataset: Dataset) -> None: + y_bounds = dataset.data_bounds.y_bounds + ymin, ymax = y_bounds + ymid = ymax - y_bounds.range / 2 + + y_offset = y_bounds.range / 10 + ymin += y_offset + ymax -= y_offset + + xmin, xmax = dataset.data_bounds.x_bounds + + poly = np.polynomial.Polynomial.fit([ymin, ymid, ymax], [xmin, xmax, xmin], 2) + + super().__init__(*np.stack(poly.linspace()[::-1], axis=1)) + + def __str__(self) -> str: + return 'left_parab' + + +class RightParabola(PointCollection): + """ + Class for the right parabola shape. + + .. plot:: + :scale: 75 + :caption: + This shape is generated using the panda dataset. + + from data_morph.data.loader import DataLoader + from data_morph.shapes.points import RightParabola + + _ = RightParabola(DataLoader.load_dataset('panda')).plot() + + Parameters + ---------- + dataset : Dataset + The starting dataset to morph into other shapes. + """ + + def __init__(self, dataset: Dataset) -> None: + y_bounds = dataset.data_bounds.y_bounds + ymin, ymax = y_bounds + ymid = ymax - y_bounds.range / 2 + + y_offset = y_bounds.range / 10 + ymin += y_offset + ymax -= y_offset + + xmin, xmax = dataset.data_bounds.x_bounds + + poly = np.polynomial.Polynomial.fit([ymin, ymid, ymax], [xmax, xmin, xmax], 2) + + super().__init__(*np.stack(poly.linspace()[::-1], axis=1)) + + def __str__(self) -> str: + return 'right_parab' + + +class UpParabola(PointCollection): + """ + Class for the up parabola shape. + + .. plot:: + :scale: 75 + :caption: + This shape is generated using the panda dataset. + + from data_morph.data.loader import DataLoader + from data_morph.shapes.points import UpParabola + + _ = UpParabola(DataLoader.load_dataset('panda')).plot() + + Parameters + ---------- + dataset : Dataset + The starting dataset to morph into other shapes. + """ + + def __init__(self, dataset: Dataset) -> None: + x_bounds = dataset.data_bounds.x_bounds + xmin, xmax = x_bounds + xmid = xmax - x_bounds.range / 2 + + x_offset = x_bounds.range / 10 + xmin += x_offset + xmax -= x_offset + + ymin, ymax = dataset.data_bounds.y_bounds + + poly = np.polynomial.Polynomial.fit([xmin, xmid, xmax], [ymax, ymin, ymax], 2) + + super().__init__(*np.stack(poly.linspace(), axis=1)) + + def __str__(self) -> str: + return 'up_parab' diff --git a/src/data_morph/shapes/points/scatter.py b/src/data_morph/shapes/points/scatter.py new file mode 100644 index 00000000..a8f6ab11 --- /dev/null +++ b/src/data_morph/shapes/points/scatter.py @@ -0,0 +1,66 @@ +"""Scatter shape.""" + +from numbers import Number + +import numpy as np + +from ...data.dataset import Dataset +from ..bases.point_collection import PointCollection + + +class Scatter(PointCollection): + """ + Class for the scatter shape: a cloud of randomly-scattered points. + + .. plot:: + :scale: 75 + :caption: + This shape is generated using the panda dataset. + + from data_morph.data.loader import DataLoader + from data_morph.shapes.points import Scatter + + _ = Scatter(DataLoader.load_dataset('panda')).plot() + + Parameters + ---------- + dataset : Dataset + The starting dataset to morph into other shapes. + """ + + def __init__(self, dataset: Dataset) -> None: + rng = np.random.default_rng(1) + center = (dataset.df.x.mean(), dataset.df.y.mean()) + points = [center] + max_radius = max(dataset.df.x.std(), dataset.df.y.std()) + for radius in np.linspace(max_radius // 5, max_radius, num=5): + for angle in np.linspace(0, 360, num=50, endpoint=False): + points.append( + ( + center[0] + + np.cos(angle) * radius + + rng.standard_normal() * max_radius, + center[1] + + np.sin(angle) * radius + + rng.standard_normal() * max_radius, + ) + ) + super().__init__(*points) + + self._alpha = 0.4 + + def distance(self, x: Number, y: Number) -> int: + """ + No-op that allows returns 0 so that all perturbations are accepted. + + Parameters + ---------- + x, y : int or float + Coordinates of a point in 2D space. + + Returns + ------- + int + Always returns 0 to allow for scattering of the points. + """ + return 0 diff --git a/src/data_morph/shapes/points/spade.py b/src/data_morph/shapes/points/spade.py new file mode 100644 index 00000000..5acdefff --- /dev/null +++ b/src/data_morph/shapes/points/spade.py @@ -0,0 +1,119 @@ +"""Spade shape.""" + +from numbers import Number + +import numpy as np + +from ...data.dataset import Dataset +from ..bases.point_collection import PointCollection +from .heart import Heart + + +class Spade(PointCollection): + """ + Class for the spade shape. + + .. plot:: + :scale: 75 + :caption: + This shape is generated using the panda dataset. + + from data_morph.data.loader import DataLoader + from data_morph.shapes.points import Spade + + _ = Spade(DataLoader.load_dataset('panda')).plot() + + Parameters + ---------- + dataset : Dataset + The starting dataset to morph into other shapes. + """ + + def __init__(self, dataset: Dataset) -> None: + x_bounds = dataset.data_bounds.x_bounds + y_bounds = dataset.data_bounds.y_bounds + + x_shift = sum(x_bounds) / 2 + y_shift = sum(y_bounds) / 2 + + # upside-down heart + heart_points = self._get_inverted_heart(dataset, y_shift) + + # base of the spade + base_x, base_y = self._get_base(x_bounds[1], x_shift, y_shift) + + # combine all points + x = np.concatenate((heart_points[:, 0], base_x), axis=0) + y = np.concatenate((heart_points[:, 1], base_y), axis=0) + + super().__init__(*np.stack([x, y], axis=1)) + + @staticmethod + def _get_inverted_heart(dataset: Dataset, y_shift: Number) -> np.ndarray: + """ + Get points for an inverted heart. + + Parameters + ---------- + dataset : Dataset + The starting dataset to morph into other shapes. + y_shift : Number + The constant value to shift the *y* up/down by. + + Returns + ------- + numpy.ndarray + The points for the upside-down heart. + + See Also + -------- + Heart : This shape is reused to calculate the spade. + """ + heart_points = Heart(dataset).points + heart_points[:, 1] = -heart_points[:, 1] + 2 * y_shift + return heart_points + + @staticmethod + def _get_base( + xmax: Number, x_shift: Number, y_shift: Number + ) -> tuple[np.ndarray, np.ndarray]: + """ + Get the base of the spade. + + Parameters + ---------- + xmax : Number + The maximum *x* value for the shape. + x_shift : Number + The constant value to shift the *x* left/right by. + y_shift : Number + The constant value to shift the *y* up/down by. + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray] + The *x* and *y* coordinates for the base of the spade. + """ + # line base + line_x = np.linspace(-6, 6, num=12) + line_y = np.repeat(-16, 12) + + # left wing + left_x = np.linspace(-6, 0, num=12) + left_y = 0.278 * np.power(left_x + 6, 2) - 16 + + # right wing + right_x = np.linspace(0, 6, num=12) + right_y = 0.278 * np.power(right_x - 6, 2) - 16 + + # shift and scale the base and wing + base_x = np.concatenate((line_x, left_x, right_x), axis=0) + base_y = np.concatenate((line_y, left_y, right_y), axis=0) + + # scale by the half the widest width of the spade + scale_factor = (xmax - x_shift) / 16 + + base_x = base_x * scale_factor + x_shift + base_y = base_y * scale_factor + y_shift + + return base_x, base_y diff --git a/src/data_morph/shapes/polygons.py b/src/data_morph/shapes/polygons.py deleted file mode 100644 index 44141bbb..00000000 --- a/src/data_morph/shapes/polygons.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Polygon shapes made from lines.""" - -from ..data.dataset import Dataset -from .bases.line_collection import LineCollection - - -class Diamond(LineCollection): - """ - Class for the diamond shape. - - .. plot:: - :scale: 75 - :caption: - This shape is generated using the panda dataset. - - import matplotlib.pyplot as plt - from data_morph.data.loader import DataLoader - from data_morph.shapes.polygons import Diamond - - _ = Diamond(DataLoader.load_dataset('panda')).plot() - - Parameters - ---------- - dataset : Dataset - The starting dataset to morph into other shapes. - """ - - def __init__(self, dataset: Dataset) -> None: - xmin, xmax = dataset.df.x.quantile([0.05, 0.95]) - ymin, ymax = dataset.df.y.quantile([0.05, 0.95]) - - xmid = (xmax + xmin) / 2 - ymid = (ymax + ymin) / 2 - - super().__init__( - [[xmin, ymid], [xmid, ymax]], - [[xmid, ymax], [xmax, ymid]], - [[xmax, ymid], [xmid, ymin]], - [[xmid, ymin], [xmin, ymid]], - ) - - -class Rectangle(LineCollection): - """ - Class for the rectangle shape. - - .. plot:: - :scale: 75 - :caption: - This shape is generated using the panda dataset. - - import matplotlib.pyplot as plt - from data_morph.data.loader import DataLoader - from data_morph.shapes.polygons import Rectangle - - _ = Rectangle(DataLoader.load_dataset('panda')).plot() - - Parameters - ---------- - dataset : Dataset - The starting dataset to morph into other shapes. - """ - - def __init__(self, dataset: Dataset) -> None: - xmin, xmax = dataset.df.x.quantile([0.1, 0.9]) - ymin, ymax = dataset.df.y.quantile([0.1, 0.9]) - - super().__init__( - [[xmin, ymin], [xmin, ymax]], - [[xmin, ymin], [xmax, ymin]], - [[xmax, ymin], [xmax, ymax]], - [[xmin, ymax], [xmax, ymax]], - ) - - -class Star(LineCollection): - """ - Class for the star shape. - - .. plot:: - :scale: 75 - :caption: - This shape is generated using the panda dataset. - - import matplotlib.pyplot as plt - from data_morph.data.loader import DataLoader - from data_morph.shapes.polygons import Star - - _ = Star(DataLoader.load_dataset('panda')).plot() - - Parameters - ---------- - dataset : Dataset - The starting dataset to morph into other shapes. - """ - - def __init__(self, dataset: Dataset) -> None: - bounds = dataset.data_bounds.clone() - bounds.align_aspect_ratio() - - x_bounds = bounds.x_bounds - y_bounds = bounds.y_bounds - - xmin, xmax = x_bounds - ymin, ymax = y_bounds - - x_range = x_bounds.range - y_range = y_bounds.range - - pts = [ - [xmin, ymin + y_range * 0.625], - [xmin + x_range * 0.375, ymin + y_range * 0.625], - [xmin + x_range * 0.5, ymax], - [xmin + x_range * 0.625, ymin + y_range * 0.625], - [xmax, ymin + y_range * 0.625], - [xmin + x_range * 0.6875, ymin + y_range * 0.375], - [xmin + x_range * 0.8125, ymin], - [xmin + x_range * 0.5, ymin + y_range * 0.25], - [xmin + x_range * 0.1875, ymin], - [xmin + x_range * 0.3125, ymin + y_range * 0.375], - [xmin, ymin + y_range * 0.625], - ] - - super().__init__(*list(zip(pts[:-1], pts[1:])))