Repository: stefmolin/data-morph

Issue:
Add club target shape
Data Morph already has the heart and the diamond shapes. To complete the set of cards, we need the club and [spade](https://github.com/stefmolin/data-morph/issues/163). This issue is for adding the club.

![image](https://github.com/stefmolin/data-morph/assets/24376333/3119f52f-adfb-46aa-93a5-11cf3148a2b9)

Consult the [shape creation tutorial](https://stefaniemolin.com/data-morph/dev/shape_creation.html) for guidance.

Discussion:
kevin1kevin1k: Thanks for the suggestions! I have applied them.

Patch:
diff --git a/src/data_morph/shapes/factory.py b/src/data_morph/shapes/factory.py
index 2b2db2af..75623e2a 100644
--- a/src/data_morph/shapes/factory.py
+++ b/src/data_morph/shapes/factory.py
@@ -53,6 +53,7 @@ class ShapeFactory:
         'rectangle': polygons.Rectangle,
         'rings': circles.Rings,
         'star': polygons.Star,
+        'club': points.Club,
     }
 
     AVAILABLE_SHAPES: list[str] = sorted(_SHAPE_MAPPING.keys())
diff --git a/src/data_morph/shapes/points.py b/src/data_morph/shapes/points.py
index 158be2a8..aaeaea25 100644
--- a/src/data_morph/shapes/points.py
+++ b/src/data_morph/shapes/points.py
@@ -304,3 +304,84 @@ class Scatter(PointCollection):
             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))


Test patch:
diff --git a/tests/shapes/test_points.py b/tests/shapes/test_points.py
index feb38a47..f156f836 100644
--- a/tests/shapes/test_points.py
+++ b/tests/shapes/test_points.py
@@ -142,3 +142,20 @@ class TestUpParabola(ParabolaTestBase):
     positive_quadratic_term = True
     x_index = 0
     y_index = 1
+
+
+class TestClub(PointsModuleTestBase):
+    """Test the Club class."""
+
+    shape_name = 'club'
+    distance_test_cases = [
+        [(19.639387, 73.783711), 0.0],  # top lobe
+        [(12.730310, 60.295844), 0.0],  # bottom left lobe
+        [(27.630301, 60.920443), 0.0],  # bottom right lobe
+        [(20.304761, 55.933333), 0.0],  # top of stem
+        [(18.8, 57.076666), 0.0],  # left part of stem
+        [(20.933333, 57.823333), 0.0],  # right part of stem
+        [(0, 0), 58.717591],
+        [(20, 50), 5.941155],
+        [(10, 80), 10.288055],
+    ]