Repository: stefmolin/data-morph

Issue:
Add spade target shape
Add a new target shape for the spade:

![image](https://github.com/stefmolin/data-morph/assets/24376333/139804d6-0348-4666-ab68-f5c5536378b9)


Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/stefmolin/data-morph/pull/225?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stefanie+Molin) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 98.43%. Comparing base [(`51b8d26`)](https://app.codecov.io/gh/stefmolin/data-morph/commit/51b8d26b386382f16b00a8b5c2ae454d9700c3de?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stefanie+Molin) to head [(`42d40ed`)](https://app.codecov.io/gh/stefmolin/data-morph/commit/42d40edc6b1074c7436d06578e63287a71d99e1a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stefanie+Molin).
> Report is 22 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


[![Impacted file tree graph](https://app.codecov.io/gh/stefmolin/data-morph/pull/225/graphs/tree.svg?width=650&height=150&src=pr&token=3SEEG9SZQO&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stefanie+Molin)](https://app.codecov.io/gh/stefmolin/data-morph/pull/225?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stefanie+Molin)

```diff
@@            Coverage Diff             @@
##             main     #225      +/-   ##
==========================================
+ Coverage   98.42%   98.43%   +0.01%     
==========================================
  Files          43       43              
  Lines        1775     1788      +13     
  Branches      358      357       -1     
==========================================
+ Hits         1747     1760      +13     
  Misses         25       25              
  Partials        3        3              
```

| [Files with missing lines](https://app.codecov.io/gh/stefmolin/data-morph/pull/225?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stefanie+Molin) | Coverage Δ | |
|---|---|---|
| [src/data\_morph/shapes/factory.py](https://app.codecov.io/gh/stefmolin/data-morph/pull/225?src=pr&el=tree&filepath=src%2Fdata_morph%2Fshapes%2Ffactory.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stefanie+Molin#diff-c3JjL2RhdGFfbW9ycGgvc2hhcGVzL2ZhY3RvcnkucHk=) | `100.00% <ø> (ø)` | |
| [src/data\_morph/shapes/points.py](https://app.codecov.io/gh/stefmolin/data-morph/pull/225?src=pr&el=tree&filepath=src%2Fdata_morph%2Fshapes%2Fpoints.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stefanie+Molin#diff-c3JjL2RhdGFfbW9ycGgvc2hhcGVzL3BvaW50cy5weQ==) | `100.00% <100.00%> (ø)` | |

... and [1 file with indirect coverage changes](https://app.codecov.io/gh/stefmolin/data-morph/pull/225/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stefanie+Molin)

</details>

Patch:
diff --git a/src/data_morph/shapes/factory.py b/src/data_morph/shapes/factory.py
index 75623e2a..94c15b2c 100644
--- a/src/data_morph/shapes/factory.py
+++ b/src/data_morph/shapes/factory.py
@@ -54,6 +54,7 @@ class ShapeFactory:
         'rings': circles.Rings,
         'star': polygons.Star,
         'club': points.Club,
+        'spade': points.Spade,
     }
 
     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 aaeaea25..d4013d9d 100644
--- a/src/data_morph/shapes/points.py
+++ b/src/data_morph/shapes/points.py
@@ -385,3 +385,62 @@ class Club(PointCollection):
         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))


Test patch:
diff --git a/tests/shapes/test_points.py b/tests/shapes/test_points.py
index f156f836..b5275314 100644
--- a/tests/shapes/test_points.py
+++ b/tests/shapes/test_points.py
@@ -159,3 +159,17 @@ class TestClub(PointsModuleTestBase):
         [(20, 50), 5.941155],
         [(10, 80), 10.288055],
     ]
+
+
+class TestSpade(PointsModuleTestBase):
+    """Test the Spade class."""
+
+    shape_name = 'spade'
+    distance_test_cases = [
+        [(19.97189615, 75.43271708), 0],
+        [(23.75, 55), 0],
+        [(11.42685318, 59.11304904), 0],
+        [(20, 75), 0.2037185],
+        [(0, 0), 57.350348],
+        [(10, 80), 10.968080],
+    ]