starplot 0.15.8__py2.py3-none-any.whl → 0.16.1__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. starplot/__init__.py +7 -2
  2. starplot/base.py +57 -60
  3. starplot/cli.py +3 -3
  4. starplot/config.py +56 -0
  5. starplot/data/__init__.py +5 -5
  6. starplot/data/bigsky.py +3 -3
  7. starplot/data/db.py +2 -2
  8. starplot/data/library/sky.db +0 -0
  9. starplot/geometry.py +48 -0
  10. starplot/horizon.py +194 -90
  11. starplot/map.py +71 -168
  12. starplot/mixins.py +0 -55
  13. starplot/models/dso.py +10 -2
  14. starplot/observer.py +71 -0
  15. starplot/optic.py +61 -26
  16. starplot/plotters/__init__.py +2 -0
  17. starplot/plotters/constellations.py +4 -6
  18. starplot/plotters/dsos.py +3 -2
  19. starplot/plotters/gradients.py +153 -0
  20. starplot/plotters/legend.py +247 -0
  21. starplot/plotters/milkyway.py +8 -5
  22. starplot/plotters/stars.py +5 -3
  23. starplot/projections.py +155 -55
  24. starplot/styles/base.py +98 -22
  25. starplot/styles/ext/antique.yml +0 -1
  26. starplot/styles/ext/blue_dark.yml +0 -1
  27. starplot/styles/ext/blue_gold.yml +60 -52
  28. starplot/styles/ext/blue_light.yml +0 -1
  29. starplot/styles/ext/blue_medium.yml +7 -7
  30. starplot/styles/ext/blue_night.yml +178 -0
  31. starplot/styles/ext/cb_wong.yml +0 -1
  32. starplot/styles/ext/gradient_presets.yml +158 -0
  33. starplot/styles/ext/grayscale.yml +0 -1
  34. starplot/styles/ext/grayscale_dark.yml +0 -1
  35. starplot/styles/ext/nord.yml +0 -1
  36. starplot/styles/extensions.py +90 -0
  37. starplot/zenith.py +174 -0
  38. {starplot-0.15.8.dist-info → starplot-0.16.1.dist-info}/METADATA +18 -11
  39. {starplot-0.15.8.dist-info → starplot-0.16.1.dist-info}/RECORD +42 -36
  40. starplot/settings.py +0 -26
  41. {starplot-0.15.8.dist-info → starplot-0.16.1.dist-info}/WHEEL +0 -0
  42. {starplot-0.15.8.dist-info → starplot-0.16.1.dist-info}/entry_points.txt +0 -0
  43. {starplot-0.15.8.dist-info → starplot-0.16.1.dist-info}/licenses/LICENSE +0 -0
starplot/optic.py CHANGED
@@ -1,4 +1,3 @@
1
- from datetime import datetime
2
1
  from typing import Callable, Mapping
3
2
 
4
3
  import pandas as pd
@@ -13,8 +12,9 @@ from starplot.base import BasePlot, DPI
13
12
  from starplot.data.stars import StarCatalog, STAR_NAMES
14
13
  from starplot.mixins import ExtentMaskMixin
15
14
  from starplot.models import Star
16
- from starplot.optics import Optic
17
- from starplot.plotters import StarPlotterMixin, DsoPlotterMixin
15
+ from starplot.observer import Observer
16
+ from starplot.optics import Optic, Camera
17
+ from starplot.plotters import StarPlotterMixin, DsoPlotterMixin, GradientBackgroundMixin
18
18
  from starplot.styles import (
19
19
  PlotStyle,
20
20
  ObjectStyle,
@@ -22,6 +22,7 @@ from starplot.styles import (
22
22
  extensions,
23
23
  use_style,
24
24
  ZOrderEnum,
25
+ GradientDirection,
25
26
  )
26
27
  from starplot.utils import azimuth_to_string
27
28
 
@@ -30,7 +31,13 @@ pd.options.mode.chained_assignment = None # default='warn'
30
31
  DEFAULT_OPTIC_STYLE = PlotStyle().extend(extensions.OPTIC)
31
32
 
32
33
 
33
- class OpticPlot(BasePlot, ExtentMaskMixin, StarPlotterMixin, DsoPlotterMixin):
34
+ class OpticPlot(
35
+ BasePlot,
36
+ ExtentMaskMixin,
37
+ StarPlotterMixin,
38
+ DsoPlotterMixin,
39
+ GradientBackgroundMixin,
40
+ ):
34
41
  """Creates a new optic plot.
35
42
 
36
43
  Args:
@@ -55,17 +62,16 @@ class OpticPlot(BasePlot, ExtentMaskMixin, StarPlotterMixin, DsoPlotterMixin):
55
62
  """
56
63
 
57
64
  _coordinate_system = CoordinateSystem.AZ_ALT
65
+ _gradient_direction = GradientDirection.RADIAL
58
66
 
59
67
  FIELD_OF_VIEW_MAX = 9.0
60
68
 
61
69
  def __init__(
62
70
  self,
63
- optic: Optic,
64
71
  ra: float,
65
72
  dec: float,
66
- lat: float,
67
- lon: float,
68
- dt: datetime = None,
73
+ optic: Optic,
74
+ observer: Observer = Observer(),
69
75
  ephemeris: str = "de421_2001.bsp",
70
76
  style: PlotStyle = DEFAULT_OPTIC_STYLE,
71
77
  resolution: int = 4096,
@@ -78,7 +84,7 @@ class OpticPlot(BasePlot, ExtentMaskMixin, StarPlotterMixin, DsoPlotterMixin):
78
84
  **kwargs,
79
85
  ) -> "OpticPlot":
80
86
  super().__init__(
81
- dt,
87
+ observer,
82
88
  ephemeris,
83
89
  style,
84
90
  resolution,
@@ -90,10 +96,12 @@ class OpticPlot(BasePlot, ExtentMaskMixin, StarPlotterMixin, DsoPlotterMixin):
90
96
  **kwargs,
91
97
  )
92
98
  self.logger.debug("Creating OpticPlot...")
99
+
100
+ if isinstance(optic, Camera) and style.has_gradient_background():
101
+ raise ValueError("Gradient backgrounds are not yet supported for cameras.")
102
+
93
103
  self.ra = ra
94
104
  self.dec = dec
95
- self.lat = lat
96
- self.lon = lon
97
105
  self.raise_on_below_horizon = raise_on_below_horizon
98
106
 
99
107
  self.optic = optic
@@ -112,6 +120,16 @@ class OpticPlot(BasePlot, ExtentMaskMixin, StarPlotterMixin, DsoPlotterMixin):
112
120
  self._adjust_radec_minmax()
113
121
  self._init_plot()
114
122
 
123
+ @property
124
+ def alt(self):
125
+ """Altitude of target (degrees)"""
126
+ return self.pos_alt.degrees
127
+
128
+ @property
129
+ def az(self):
130
+ """Azimuth of target (degrees)"""
131
+ return self.pos_az.degrees
132
+
115
133
  def _prepare_coords(self, ra, dec) -> (float, float):
116
134
  """Converts RA/DEC to AZ/ALT"""
117
135
  point = SkyfieldStar(ra_hours=ra / 15, dec_degrees=dec)
@@ -155,9 +173,9 @@ class OpticPlot(BasePlot, ExtentMaskMixin, StarPlotterMixin, DsoPlotterMixin):
155
173
  def _calc_position(self):
156
174
  earth = self.ephemeris["earth"]
157
175
 
158
- self.location = earth + wgs84.latlon(self.lat, self.lon)
176
+ self.location = earth + wgs84.latlon(self.observer.lat, self.observer.lon)
159
177
  self.star = SkyfieldStar(ra_hours=self.ra / 15, dec_degrees=self.dec)
160
- self.observe = self.location.at(self.timescale).observe
178
+ self.observe = self.location.at(self.observer.timescale).observe
161
179
  self.position = self.observe(self.star)
162
180
 
163
181
  self.pos_apparent = self.position.apparent()
@@ -285,7 +303,11 @@ class OpticPlot(BasePlot, ExtentMaskMixin, StarPlotterMixin, DsoPlotterMixin):
285
303
  self.ax
286
304
  ) # apply transform again because new xy limits will undo the transform
287
305
 
288
- dt_str = self.dt.strftime("%m/%d/%Y @ %H:%M:%S") + " " + self.dt.tzname()
306
+ dt_str = (
307
+ self.observer.dt.strftime("%m/%d/%Y @ %H:%M:%S")
308
+ + " "
309
+ + self.observer.dt.tzname()
310
+ )
289
311
  font_size = style.font_size * self.scale
290
312
 
291
313
  column_labels = [
@@ -298,7 +320,7 @@ class OpticPlot(BasePlot, ExtentMaskMixin, StarPlotterMixin, DsoPlotterMixin):
298
320
  values = [
299
321
  f"{self.pos_alt.degrees:.0f}\N{DEGREE SIGN} / {self.pos_az.degrees:.0f}\N{DEGREE SIGN} ({azimuth_to_string(self.pos_az.degrees)})",
300
322
  f"{(self.ra / 15):.2f}h / {self.dec:.2f}\N{DEGREE SIGN}",
301
- f"{self.lat:.2f}\N{DEGREE SIGN}, {self.lon:.2f}\N{DEGREE SIGN}",
323
+ f"{self.observer.lat:.2f}\N{DEGREE SIGN}, {self.observer.lon:.2f}\N{DEGREE SIGN}",
302
324
  dt_str,
303
325
  str(self.optic),
304
326
  ]
@@ -331,35 +353,45 @@ class OpticPlot(BasePlot, ExtentMaskMixin, StarPlotterMixin, DsoPlotterMixin):
331
353
  x = 0
332
354
  y = 0
333
355
 
356
+ if self.style.has_gradient_background():
357
+ background_color = "#ffffff00"
358
+ # self._plot_gradient_background(self.style.background_color)
359
+ else:
360
+ background_color = self.style.background_color.as_hex()
361
+
334
362
  # Background of Viewable Area
335
363
  self._background_clip_path = self.optic.patch(
336
364
  x,
337
365
  y,
338
- facecolor=self.style.background_color.as_hex(),
366
+ facecolor=background_color,
339
367
  linewidth=0,
340
368
  fill=True,
341
369
  zorder=ZOrderEnum.LAYER_1,
342
370
  )
371
+ self.ax.set_facecolor(background_color)
343
372
  self.ax.add_patch(self._background_clip_path)
344
373
  self._update_clip_path_polygon()
345
374
 
346
375
  # Inner Border
347
- inner_border = self.optic.patch(
348
- x,
349
- y,
350
- linewidth=2 * self.scale,
351
- edgecolor=self.style.border_line_color.as_hex(),
352
- fill=False,
353
- zorder=ZOrderEnum.LAYER_5 + 100,
354
- )
355
- self.ax.add_patch(inner_border)
376
+ # inner_border = self.optic.patch(
377
+ # x,
378
+ # y,
379
+ # linewidth=2 * self.scale,
380
+ # edgecolor=self.style.border_line_color.as_hex(),
381
+ # fill=False,
382
+ # zorder=ZOrderEnum.LAYER_5 + 100,
383
+ # )
384
+ # self.ax.add_patch(inner_border)
385
+
386
+ if self.style.has_gradient_background():
387
+ self._plot_gradient_background(self.style.background_color)
356
388
 
357
389
  # Outer border
358
390
  outer_border = self.optic.patch(
359
391
  x,
360
392
  y,
361
393
  padding=0.05,
362
- linewidth=20 * self.scale,
394
+ linewidth=25 * self.scale,
363
395
  edgecolor=self.style.border_bg_color.as_hex(),
364
396
  fill=False,
365
397
  zorder=ZOrderEnum.LAYER_5,
@@ -395,3 +427,6 @@ class OpticPlot(BasePlot, ExtentMaskMixin, StarPlotterMixin, DsoPlotterMixin):
395
427
  self.ax.set_ylim(-1.06 * self.optic.ylim, 1.06 * self.optic.ylim)
396
428
  self.optic.transform(self.ax)
397
429
  self._plot_border()
430
+
431
+ # if self.gradient_preset:
432
+ # self.apply_gradient_background(self.gradient_preset)
@@ -2,3 +2,5 @@ from .constellations import ConstellationPlotterMixin # noqa: F401
2
2
  from .stars import StarPlotterMixin # noqa: F401
3
3
  from .dsos import DsoPlotterMixin # noqa: F401
4
4
  from .milkyway import MilkyWayPlotterMixin # noqa: F401
5
+ from .legend import LegendPlotterMixin # noqa: F401
6
+ from .gradients import GradientBackgroundMixin # noqa: F401
@@ -16,7 +16,7 @@ from starplot.data.constellations import (
16
16
  from starplot.data.constellation_stars import CONSTELLATION_HIPS
17
17
  from starplot.models import Star
18
18
  from starplot.models.constellation import from_tuple as constellation_from_tuple
19
- from starplot.projections import Projection
19
+ from starplot.projections import Mercator, Miller
20
20
  from starplot.profile import profile
21
21
  from starplot.styles import PathStyle, LineStyle, LabelStyle
22
22
  from starplot.styles.helpers import use_style
@@ -88,10 +88,8 @@ class ConstellationPlotterMixin:
88
88
  if constellations_df.empty:
89
89
  return
90
90
 
91
- if getattr(self, "projection", None) in [
92
- Projection.MERCATOR,
93
- Projection.MILLER,
94
- ]:
91
+ projection = getattr(self, "projection", None)
92
+ if isinstance(projection, Mercator) or isinstance(projection, Miller):
95
93
  transform = self._plate_carree
96
94
  else:
97
95
  transform = self._geodetic
@@ -238,7 +236,7 @@ class ConstellationPlotterMixin:
238
236
  geometries = [line.geometry for line in borders_df.itertuples()]
239
237
 
240
238
  for ls in geometries:
241
- if ls.length < 80:
239
+ if ls.length < 360:
242
240
  ls = ls.segmentize(1)
243
241
 
244
242
  xy = [c for c in ls.coords]
starplot/plotters/dsos.py CHANGED
@@ -222,6 +222,8 @@ class DsoPlotterMixin:
222
222
  if label:
223
223
  self.text(label, ra, dec, style.label, gid=f"dso-{d.type}-label")
224
224
 
225
+ self._add_legend_handle_marker(legend_label, style.marker)
226
+
225
227
  else:
226
228
  # if no major axis, then just plot as a marker
227
229
  self.marker(
@@ -229,11 +231,10 @@ class DsoPlotterMixin:
229
231
  dec=dec,
230
232
  style=style,
231
233
  label=label,
234
+ legend_label=legend_label,
232
235
  skip_bounds_check=True,
233
236
  gid_marker=f"dso-{d.type}-marker",
234
237
  gid_label=f"dso-{d.type}-label",
235
238
  )
236
239
 
237
240
  self._objects.dsos.append(_dso)
238
-
239
- self._add_legend_handle_marker(legend_label, style.marker)
@@ -0,0 +1,153 @@
1
+ import numpy as np
2
+ from matplotlib.colors import LinearSegmentedColormap
3
+ from starplot.profile import profile
4
+ from starplot.styles import GradientDirection
5
+
6
+
7
+ class GradientBackgroundMixin:
8
+ """
9
+ Mixin class to handle adding gradients to plots.
10
+
11
+ Handles a variety of projections and can be inherited by HorizonPlot, OpticPlot,
12
+ and MapPlot for vertical, radial, and mollweide gradients. However, some more
13
+ obscure projections may throw errors if attempting to plot a gradient with them.
14
+ """
15
+
16
+ @profile
17
+ def _plot_gradient_background(
18
+ self, gradient_preset: list[tuple[float, str]]
19
+ ) -> None:
20
+ """
21
+ Adds a gradient background to the plot, beneath the GeoAxes.
22
+ The background_color of the map must be set as a RGBA value with full
23
+ transparency (e.g. #ffffff00) for this function to render the desired
24
+ result.
25
+
26
+ Args:
27
+ gradient_preset: A list of tuples (e.g. [(0.0, '#000000'), (1.0, '#000080')])
28
+ where each tuple contains a position value [0-1] and a color
29
+ value to describe the range of colors in the gradient.
30
+ """
31
+ direction = self._gradient_direction
32
+ reverse = True if direction == GradientDirection.RADIAL else False
33
+ cmap = self._create_colormap(gradient_preset, reverse=reverse)
34
+ background_ax = self._create_background_ax()
35
+ background_ax.set_axis_off()
36
+ self._background_ax = background_ax
37
+
38
+ X, Y, gradient = self._create_gradient_arrays()
39
+
40
+ # Radial specific axes adjustments
41
+ if self._gradient_direction == GradientDirection.RADIAL:
42
+ background_ax.set_ylim(Y.min(), Y.max() * 1.06)
43
+
44
+ # if getattr(self, "optic", None):
45
+ # self._camera_optic_transform(background_ax)
46
+ # Camera specific axes adjustments
47
+ # if gradient_shape == "camera":
48
+ # self._camera_optic_transform(background_ax)
49
+
50
+ # Render gradient
51
+ background_ax.pcolormesh(
52
+ X,
53
+ Y,
54
+ gradient,
55
+ cmap=cmap,
56
+ shading="gouraud",
57
+ rasterized=True,
58
+ zorder=0,
59
+ clip_path=self._background_clip_path,
60
+ )
61
+
62
+ # Set plot in self.ax's zorder to 1 so it appears above the gradient
63
+ self.ax.zorder = 1
64
+
65
+ # Event driven function so background_ax matches GeoAxes when plotted
66
+ self.ax.figure.canvas.mpl_connect(
67
+ "draw_event",
68
+ lambda event: background_ax.set_position(self.ax.get_position()),
69
+ )
70
+
71
+ def _create_colormap(
72
+ self, gradient_preset: list[tuple[float, str]], reverse: bool = False
73
+ ) -> LinearSegmentedColormap:
74
+ """Creates a matplotlib colormap from a gradient preset."""
75
+ positions, colors = zip(*gradient_preset)
76
+
77
+ if self._gradient_direction == GradientDirection.RADIAL:
78
+ positions = [p / 2 for p in positions]
79
+ positions[-1] = 1
80
+
81
+ from pydantic.color import Color
82
+
83
+ colors = [Color(c).as_hex() for c in colors]
84
+
85
+ cmap = LinearSegmentedColormap.from_list(
86
+ "custom_gradient", list(zip(positions, colors)), N=750
87
+ )
88
+ return cmap.reversed() if reverse else cmap
89
+
90
+ def _create_background_ax(self):
91
+ """Adds a set of axes to take the gradient image."""
92
+ bbox = self.ax.get_position()
93
+ projection = None
94
+
95
+ if self._gradient_direction == GradientDirection.RADIAL:
96
+ projection = "polar"
97
+ elif self._gradient_direction == GradientDirection.MOLLWEIDE:
98
+ projection = "mollweide"
99
+
100
+ return self.ax.figure.add_axes(bbox, zorder=0, projection=projection)
101
+
102
+ def _create_gradient_arrays(self):
103
+ """Creates arrays for the gradient placement and the gradient meshgrid."""
104
+ # Radial gradient
105
+ if self._gradient_direction == GradientDirection.RADIAL:
106
+ rad = np.linspace(0, 1, 50)
107
+ azm = np.linspace(0, 2 * np.pi, 100)
108
+ Y, X = np.meshgrid(rad, azm)
109
+ gradient = Y**2.0
110
+ return X, Y, gradient
111
+
112
+ # Mollweide gradient
113
+ if self._gradient_direction == GradientDirection.MOLLWEIDE:
114
+ return self._create_mollweide_gradient()
115
+
116
+ # Default Vertical Gradient
117
+ x_array = np.linspace(0, 1, 2)
118
+ y_array = np.linspace(0, 1, 750)
119
+ X, Y = np.meshgrid(x_array, y_array)
120
+ gradient = np.linspace(0, 1, 750).reshape(-1, 1)
121
+ gradient = np.repeat(gradient, 2, axis=1)
122
+ return X, Y, gradient
123
+
124
+ def _create_mollweide_gradient(self):
125
+ """Generate meshgrid and gradient for a mollweide projection."""
126
+ x = np.linspace(-np.pi, np.pi, 250)
127
+ y = np.linspace(-np.pi / 2, np.pi / 2, 250)
128
+ X, Y = np.meshgrid(x, y)
129
+ # Rotation matrix (ICRS → Galactic)
130
+ R = np.array(
131
+ [
132
+ [-0.0548755604162154, -0.8734370902348850, -0.4838350155487132],
133
+ [0.4941094278755837, -0.4448296299600112, 0.7469822444972189],
134
+ [-0.8676661490190047, -0.1980763734312015, 0.4559837761750669],
135
+ ]
136
+ )
137
+ # Equatorial unit vectors
138
+ cos_y = np.cos(Y)
139
+ eq = np.stack(
140
+ [cos_y * np.cos(X), cos_y * np.sin(X) * -1, np.sin(Y) * -1], axis=-1
141
+ )
142
+ # Rotate into Galactic coords
143
+ gal = eq @ R.T
144
+ # Gradient follows galactic latitude
145
+ gradient = np.arcsin(gal[..., 2])
146
+ return X, Y, gradient
147
+
148
+ def _camera_optic_transform(self, background_ax) -> None:
149
+ """Apply camera-specific axes transformations for the gradient."""
150
+ background_ax.set_xlim(-0.11, 1.11)
151
+ background_ax.set_ylim(-0.07, 1.07)
152
+ if self.optic.rotation == 0:
153
+ return
@@ -0,0 +1,247 @@
1
+ from typing import Callable
2
+
3
+ import numpy as np
4
+ from matplotlib.legend import Legend
5
+ from matplotlib.lines import Line2D
6
+
7
+ from starplot import callables
8
+ from starplot.models.star import Star
9
+ from starplot.styles import (
10
+ MarkerStyle,
11
+ LegendLocationEnum,
12
+ LegendStyle,
13
+ )
14
+ from starplot.styles.helpers import use_style
15
+
16
+
17
+ class LegendPlotterMixin:
18
+ def _create_legend(self, handles, labels, title, style, set_anchor: bool = False):
19
+ style_kwargs = style.matplot_kwargs(self.scale)
20
+
21
+ target = self.ax
22
+
23
+ if style.location.startswith("outside"):
24
+ target = self.fig
25
+
26
+ style_kwargs["borderaxespad"] = -1 * style.padding
27
+
28
+ legend = Legend(
29
+ target,
30
+ handles=handles,
31
+ labels=labels,
32
+ title=title,
33
+ **style_kwargs,
34
+ )
35
+
36
+ legend.set_zorder(
37
+ # zorder is not a valid kwarg to legend(), so we have to set it afterwards
38
+ style.zorder
39
+ )
40
+ legend.get_title().set_color(style.font_color.as_hex())
41
+
42
+ if not set_anchor:
43
+ return legend
44
+
45
+ display_to_axes_transform = self.ax.transAxes.inverted()
46
+ origin_x, origin_y = self.ax.transAxes.transform((0, 0))
47
+ padding_x, padding_y = display_to_axes_transform.transform(
48
+ (origin_x + style.padding_x / 2, origin_y + style.padding_y / 2)
49
+ )
50
+
51
+ if style.location.startswith("outside"):
52
+ extent = legend.get_window_extent(renderer=self.fig.canvas.get_renderer())
53
+ min_x, min_y = display_to_axes_transform.transform(extent.min)
54
+ max_x, max_y = display_to_axes_transform.transform(extent.max)
55
+
56
+ baseline, _ = display_to_axes_transform.transform(
57
+ (origin_x + 200, origin_y + style.padding_y / 2)
58
+ )
59
+
60
+ padding_x += baseline
61
+ width = max_x - min_x + padding_x
62
+ # height = max_y - min_y
63
+ # top_x, top_y = display_to_figure_transform.transform(self.ax.transAxes.transform((1, 1)))
64
+
65
+ bbox = {
66
+ LegendLocationEnum.OUTSIDE_TOP_RIGHT: (1 + width, 1 - padding_y),
67
+ LegendLocationEnum.OUTSIDE_TOP_LEFT: (-1 * width, 1 - padding_y),
68
+ LegendLocationEnum.OUTSIDE_BOTTOM_RIGHT: (1 + width, padding_y),
69
+ LegendLocationEnum.OUTSIDE_BOTTOM_LEFT: (-1 * width, padding_y),
70
+ }.get(style.location)
71
+
72
+ else:
73
+ bbox = {
74
+ LegendLocationEnum.INSIDE_TOP_LEFT: (padding_x, 1 - padding_y),
75
+ LegendLocationEnum.INSIDE_TOP_RIGHT: (1 - padding_x, 1 - padding_y),
76
+ LegendLocationEnum.INSIDE_TOP: (0.5, 1 - padding_y),
77
+ LegendLocationEnum.INSIDE_BOTTOM_LEFT: (padding_x, padding_y),
78
+ LegendLocationEnum.INSIDE_BOTTOM_RIGHT: (1 - padding_x, padding_y),
79
+ LegendLocationEnum.INSIDE_BOTTOM: (0.5, padding_y),
80
+ }.get(style.location)
81
+
82
+ legend.set_bbox_to_anchor(
83
+ bbox=bbox,
84
+ transform=self.ax.transAxes,
85
+ )
86
+
87
+ return legend
88
+
89
+ @use_style(LegendStyle, "legend")
90
+ def legend(self, title: str = "Legend", style: LegendStyle = None):
91
+ """
92
+ Plots the legend.
93
+
94
+ If the legend is already plotted, then it'll be removed first and then plotted again. So, it's safe to call this function multiple times if you need to 'refresh' the legend.
95
+
96
+ Args:
97
+ title: Title of the legend, which will be plotted at the top
98
+ style: Styling of the legend. If None, then the plot's style (specified when creating the plot) will be used
99
+ """
100
+ if not self._legend_handles:
101
+ return
102
+
103
+ if self._legend:
104
+ self._legend.remove()
105
+
106
+ target = self.ax
107
+
108
+ if style.location.startswith("outside"):
109
+ target = self.fig
110
+
111
+ legend = self._create_legend(
112
+ handles=self._legend_handles.values(),
113
+ labels=self._legend_handles.keys(),
114
+ title=title,
115
+ style=style,
116
+ set_anchor=True,
117
+ )
118
+
119
+ target.add_artist(legend)
120
+
121
+ self._legend = legend
122
+ self._legend_target = target
123
+
124
+ def _add_to_legend(self, legend):
125
+ if not self._legend:
126
+ self.legend()
127
+
128
+ target = self._legend_target
129
+
130
+ legend_base = self._legend.get_children()[0]
131
+ legend_2 = legend.get_children()[0]
132
+
133
+ # empty legend for padding
134
+ empty = Legend(
135
+ target,
136
+ handles=[],
137
+ labels=[],
138
+ title="",
139
+ # **style_kwargs,
140
+ )
141
+
142
+ # add empty legend for padding between legend and scale
143
+ legend_base.get_children().extend(empty.get_children()[0].get_children())
144
+
145
+ legend_base.get_children().extend(legend_2.get_children())
146
+
147
+ legend_base.get_children().extend(empty.get_children()[0].get_children())
148
+
149
+ # target.add_artist(self._legend)
150
+
151
+ @use_style(LegendStyle, "legend")
152
+ def star_magnitude_scale(
153
+ self,
154
+ title: str = "Star Magnitude",
155
+ style: LegendStyle = None,
156
+ size_fn: Callable[[Star], float] = callables.size_by_magnitude,
157
+ label_fn: Callable[float, str] = lambda m: str(m),
158
+ start: float = -1,
159
+ stop: float = 9,
160
+ step: float = 1,
161
+ add_to_legend: bool = False,
162
+ ):
163
+ """
164
+ Plots a magnitude scale for stars.
165
+
166
+ !!! example "Experimental"
167
+
168
+ This is currently an "experimental" feature, which means it's likely to be changed and improved in upcoming versions of Starplot.
169
+ It also means the feature likely has limitations.
170
+
171
+ **Help us improve this feature by submitting feedback on [GitHub (open an issue)](https://github.com/steveberardi/starplot/issues) or chat with us on [Discord](https://discord.gg/WewJJjshFu). Thanks!**
172
+
173
+ !!! note "Current Limitations"
174
+ - Only supports size functions that determine size based on magnitude only
175
+ - Only supports default marker for stars (point)
176
+ - Labels can only be plotted to the right of the marker
177
+ - Does not automatically determine the magnitude range of the stars you already plotted
178
+
179
+ Args:
180
+ title: Title of the legend, which will be plotted at the top
181
+ style: Styling of the magnitude scale. If None, then the plot's `legend` style will be used
182
+ size_fn: Size function for the star markers
183
+ label_fn: Function to determine the label for each magnitude
184
+ start: Starting magnitude
185
+ stop: Stop point (exclusive)
186
+ step: Step-size of each scale entry (i.e. how much to increment each step)
187
+ add_to_legend: If True, the scale will be added to the bottom of the legend (and if the legend isn't already plotted, then it'll plot the legend)
188
+ """
189
+ target = self.ax
190
+
191
+ if style.location.startswith("outside"):
192
+ target = self.fig
193
+
194
+ def scale(
195
+ size_fn,
196
+ style: MarkerStyle,
197
+ label_fn,
198
+ start: float,
199
+ stop: float,
200
+ step: float = 1,
201
+ ):
202
+ for mag in np.arange(start, stop, step):
203
+ s = style.matplot_kwargs()
204
+ s["markersize"] = (
205
+ size_fn(Star(ra=0, dec=0, magnitude=mag)) ** 0.5
206
+ ) * self.scale
207
+ label = label_fn(mag)
208
+ yield Line2D(
209
+ [],
210
+ [],
211
+ **s,
212
+ linestyle="None",
213
+ label=label,
214
+ )
215
+
216
+ handles = [
217
+ h
218
+ for h in scale(
219
+ size_fn=size_fn,
220
+ style=self.style.star.marker,
221
+ label_fn=label_fn,
222
+ start=start,
223
+ stop=stop,
224
+ step=step,
225
+ )
226
+ ]
227
+ labels = [str(m) for m in np.arange(start, stop, step)]
228
+
229
+ scale = self._create_legend(
230
+ handles=handles,
231
+ labels=labels,
232
+ title=title,
233
+ style=style,
234
+ set_anchor=True if not add_to_legend else False,
235
+ )
236
+
237
+ if add_to_legend:
238
+ self._add_to_legend(scale)
239
+ else:
240
+ target.add_artist(scale)
241
+
242
+ # for text in magnitude_scale.get_texts():
243
+ # text.set_ha("center") # horizontal alignment of text item
244
+ # text.set_x(-85) # x-position
245
+ # text.set_y(-90) # y-position
246
+
247
+ # self.ax.add_artist(magnitude_scale)
@@ -3,7 +3,7 @@ from shapely.ops import unary_union
3
3
  from starplot.data import db
4
4
  from starplot.styles import PolygonStyle
5
5
  from starplot.styles.helpers import use_style
6
- from starplot.geometry import unwrap_polygon_360
6
+ from starplot.geometry import split_polygon_at_zero
7
7
  from starplot.profile import profile
8
8
 
9
9
 
@@ -23,9 +23,12 @@ class MilkyWayPlotterMixin:
23
23
  extent = self._extent_mask()
24
24
  result = mw.filter(mw.geometry.intersects(extent)).to_pandas()
25
25
 
26
- mw_union = unary_union(
27
- [unwrap_polygon_360(row.geometry) for row in result.itertuples()]
28
- )
26
+ polygons = []
27
+
28
+ for row in result.itertuples():
29
+ polygons.extend(split_polygon_at_zero(row.geometry))
30
+
31
+ mw_union = unary_union(polygons)
29
32
 
30
33
  if mw_union.geom_type == "MultiPolygon":
31
34
  polygons = mw_union.geoms
@@ -34,6 +37,6 @@ class MilkyWayPlotterMixin:
34
37
 
35
38
  for p in polygons:
36
39
  self.polygon(
37
- geometry=p,
40
+ geometry=p.buffer(0.001),
38
41
  style=style,
39
42
  )