starplot 0.20.5__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 (98) hide show
  1. starplot/__init__.py +59 -0
  2. starplot/callables.py +176 -0
  3. starplot/cli.py +29 -0
  4. starplot/config.py +80 -0
  5. starplot/coordinates.py +7 -0
  6. starplot/data/__init__.py +22 -0
  7. starplot/data/catalogs.py +356 -0
  8. starplot/data/constellations.py +71 -0
  9. starplot/data/db.py +33 -0
  10. starplot/data/dsos.py +85 -0
  11. starplot/data/ecliptic.py +201 -0
  12. starplot/data/library/constellation_names.parquet +0 -0
  13. starplot/data/library/dso_names.parquet +0 -0
  14. starplot/data/library/readme.md +1 -0
  15. starplot/data/library/star_designations.parquet +0 -0
  16. starplot/data/stars.py +79 -0
  17. starplot/data/translations.py +408 -0
  18. starplot/data/utils.py +35 -0
  19. starplot/geometry.py +319 -0
  20. starplot/mixins.py +360 -0
  21. starplot/models/__init__.py +21 -0
  22. starplot/models/base.py +77 -0
  23. starplot/models/comet.py +302 -0
  24. starplot/models/constellation.py +151 -0
  25. starplot/models/dso.py +317 -0
  26. starplot/models/milky_way.py +30 -0
  27. starplot/models/moon.py +130 -0
  28. starplot/models/objects.py +29 -0
  29. starplot/models/observer.py +125 -0
  30. starplot/models/optics.py +342 -0
  31. starplot/models/planet.py +137 -0
  32. starplot/models/satellite.py +138 -0
  33. starplot/models/star.py +253 -0
  34. starplot/models/sun.py +62 -0
  35. starplot/plots/__init__.py +7 -0
  36. starplot/plots/base.py +1023 -0
  37. starplot/plots/galaxy.py +372 -0
  38. starplot/plots/horizon.py +543 -0
  39. starplot/plots/map.py +515 -0
  40. starplot/plots/optic.py +469 -0
  41. starplot/plots/zenith.py +217 -0
  42. starplot/plotters/__init__.py +9 -0
  43. starplot/plotters/arrow.py +174 -0
  44. starplot/plotters/constellations.py +298 -0
  45. starplot/plotters/debug.py +21 -0
  46. starplot/plotters/dsos.py +294 -0
  47. starplot/plotters/experimental.py +722 -0
  48. starplot/plotters/gradients.py +153 -0
  49. starplot/plotters/legend.py +253 -0
  50. starplot/plotters/milkyway.py +51 -0
  51. starplot/plotters/stars.py +319 -0
  52. starplot/plotters/text.py +802 -0
  53. starplot/profile.py +16 -0
  54. starplot/projections.py +184 -0
  55. starplot/styles/__init__.py +6 -0
  56. starplot/styles/base.py +1344 -0
  57. starplot/styles/ext/antique.yml +175 -0
  58. starplot/styles/ext/blue_dark.yml +163 -0
  59. starplot/styles/ext/blue_gold.yml +147 -0
  60. starplot/styles/ext/blue_light.yml +123 -0
  61. starplot/styles/ext/blue_medium.yml +142 -0
  62. starplot/styles/ext/blue_night.yml +185 -0
  63. starplot/styles/ext/cb_wong.yml +124 -0
  64. starplot/styles/ext/color_print.yml +111 -0
  65. starplot/styles/ext/gradient_presets.yml +158 -0
  66. starplot/styles/ext/grayscale.yml +94 -0
  67. starplot/styles/ext/grayscale_dark.yml +136 -0
  68. starplot/styles/ext/map.yml +12 -0
  69. starplot/styles/ext/nord.yml +158 -0
  70. starplot/styles/ext/optic.yml +20 -0
  71. starplot/styles/ext/publication.yml +8 -0
  72. starplot/styles/extensions.py +129 -0
  73. starplot/styles/fonts-library/gfs-didot/DESCRIPTION.en_us.html +9 -0
  74. starplot/styles/fonts-library/gfs-didot/GFSDidot-Regular.ttf +0 -0
  75. starplot/styles/fonts-library/gfs-didot/METADATA.pb +16 -0
  76. starplot/styles/fonts-library/gfs-didot/OFL.txt +94 -0
  77. starplot/styles/fonts-library/inter/Inter-Bold.ttf +0 -0
  78. starplot/styles/fonts-library/inter/Inter-BoldItalic.ttf +0 -0
  79. starplot/styles/fonts-library/inter/Inter-ExtraBold.ttf +0 -0
  80. starplot/styles/fonts-library/inter/Inter-ExtraLight.ttf +0 -0
  81. starplot/styles/fonts-library/inter/Inter-ExtraLightItalic.ttf +0 -0
  82. starplot/styles/fonts-library/inter/Inter-Italic.ttf +0 -0
  83. starplot/styles/fonts-library/inter/Inter-Light.ttf +0 -0
  84. starplot/styles/fonts-library/inter/Inter-LightItalic.ttf +0 -0
  85. starplot/styles/fonts-library/inter/Inter-Regular.ttf +0 -0
  86. starplot/styles/fonts-library/inter/Inter-SemiBold.ttf +0 -0
  87. starplot/styles/fonts-library/inter/Inter-SemiBoldItalic.ttf +0 -0
  88. starplot/styles/fonts-library/inter/LICENSE.txt +92 -0
  89. starplot/styles/fonts.py +15 -0
  90. starplot/styles/helpers.py +93 -0
  91. starplot/styles/markers.py +308 -0
  92. starplot/utils.py +169 -0
  93. starplot/warnings.py +21 -0
  94. starplot-0.20.5.dist-info/METADATA +146 -0
  95. starplot-0.20.5.dist-info/RECORD +98 -0
  96. starplot-0.20.5.dist-info/WHEEL +4 -0
  97. starplot-0.20.5.dist-info/entry_points.txt +3 -0
  98. starplot-0.20.5.dist-info/licenses/LICENSE +21 -0
starplot/plots/base.py ADDED
@@ -0,0 +1,1023 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Dict, Union, Optional
3
+ import logging
4
+
5
+ import numpy as np
6
+ from matplotlib import patches
7
+ from matplotlib import pyplot as plt, patheffects
8
+ from matplotlib.axes import Axes
9
+ from matplotlib.figure import Figure
10
+ from matplotlib.lines import Line2D
11
+ from shapely import Polygon, LineString
12
+
13
+ from starplot.coordinates import CoordinateSystem
14
+ from starplot import models, warnings
15
+ from starplot import geometry as _geometry
16
+ from starplot.config import settings as StarplotSettings, SvgTextType
17
+ from starplot.data import load, ecliptic
18
+ from starplot.data.translations import translate
19
+ from starplot.models.planet import PlanetName, PLANET_LABELS_DEFAULT
20
+ from starplot.models.moon import MoonPhase
21
+ from starplot.models.optics import Optic, Camera
22
+ from starplot.models.observer import Observer
23
+ from starplot.styles import (
24
+ PlotStyle,
25
+ MarkerStyle,
26
+ ObjectStyle,
27
+ LabelStyle,
28
+ MarkerSymbolEnum,
29
+ PathStyle,
30
+ PolygonStyle,
31
+ GradientDirection,
32
+ fonts,
33
+ AnchorPointEnum,
34
+ )
35
+ from starplot.plotters.debug import DebugPlotterMixin
36
+ from starplot.plotters.text import TextPlotterMixin, CollisionHandler
37
+ from starplot.styles.helpers import use_style
38
+ from starplot.profile import profile
39
+
40
+ LOGGER = logging.getLogger("starplot")
41
+ LOG_HANDLER = logging.StreamHandler()
42
+ LOG_FORMATTER = logging.Formatter(
43
+ "\033[1;34m%(name)s\033[0m:[%(levelname)s]: %(message)s"
44
+ )
45
+ LOG_HANDLER.setFormatter(LOG_FORMATTER)
46
+ LOGGER.addHandler(LOG_HANDLER)
47
+
48
+ DEFAULT_RESOLUTION = 4096
49
+
50
+ DPI = 100
51
+
52
+
53
+ class BasePlot(DebugPlotterMixin, TextPlotterMixin, ABC):
54
+ _coordinate_system = CoordinateSystem.RA_DEC
55
+ _gradient_direction: GradientDirection = GradientDirection.LINEAR
56
+
57
+ def __init__(
58
+ self,
59
+ observer: Observer = None,
60
+ ephemeris: str = "de421.bsp",
61
+ style: PlotStyle = None,
62
+ resolution: int = 4096,
63
+ point_label_handler: CollisionHandler = None,
64
+ area_label_handler: CollisionHandler = None,
65
+ path_label_handler: CollisionHandler = None,
66
+ scale: float = 1.0,
67
+ autoscale: bool = False,
68
+ suppress_warnings: bool = True,
69
+ *args,
70
+ **kwargs,
71
+ ):
72
+ super().__init__(*args, **kwargs)
73
+
74
+ self._clip_path_polygon: Polygon = None # clip path in display coordinates
75
+
76
+ self.ax: Axes = None
77
+ """
78
+ The underlying [Matplotlib axes](https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.html#matplotlib.axes.Axes) that everything is plotted on.
79
+
80
+ **Important**: Most Starplot plotting functions also specify a transform based on the plot's projection when plotting things on the Matplotlib Axes instance, so use this property at your own risk!
81
+ """
82
+
83
+ self.fig: Figure = None
84
+ """
85
+ The underlying [Matplotlib figure](https://matplotlib.org/stable/api/_as_gen/matplotlib.figure.Figure.html#matplotlib.figure.Figure) that the axes is drawn on.
86
+ """
87
+
88
+ if StarplotSettings.svg_text_type == SvgTextType.PATH:
89
+ plt.rcParams["svg.fonttype"] = "path"
90
+ else:
91
+ plt.rcParams["svg.fonttype"] = "none"
92
+
93
+ px = 1 / DPI # pixel in inches
94
+ self.pixels_per_point = DPI / 72
95
+ self.dpi = DPI
96
+
97
+ self.language = StarplotSettings.language
98
+
99
+ self.style = style or PlotStyle()
100
+ """The plot's style."""
101
+
102
+ self.figure_size = resolution * px
103
+ self.resolution = resolution
104
+
105
+ self.point_label_handler = point_label_handler or CollisionHandler(
106
+ attempts=10,
107
+ anchor_fallbacks=[
108
+ AnchorPointEnum.BOTTOM_RIGHT,
109
+ AnchorPointEnum.TOP_LEFT,
110
+ AnchorPointEnum.TOP_RIGHT,
111
+ AnchorPointEnum.BOTTOM_LEFT,
112
+ AnchorPointEnum.BOTTOM_CENTER,
113
+ AnchorPointEnum.TOP_CENTER,
114
+ AnchorPointEnum.RIGHT_CENTER,
115
+ AnchorPointEnum.LEFT_CENTER,
116
+ ],
117
+ )
118
+ """Default [collision handler][starplot.CollisionHandler] for point labels."""
119
+
120
+ self.area_label_handler = area_label_handler or CollisionHandler(
121
+ allow_constellation_line_collisions=True
122
+ )
123
+ """Default [collision handler][starplot.CollisionHandler] for area labels."""
124
+
125
+ self.path_label_handler = path_label_handler or CollisionHandler(
126
+ allow_constellation_line_collisions=True
127
+ )
128
+ """Default [collision handler][starplot.CollisionHandler] for path labels."""
129
+
130
+ self.scale = scale
131
+ self.autoscale = autoscale
132
+ if self.autoscale:
133
+ self.scale = self.resolution / DEFAULT_RESOLUTION
134
+
135
+ self.scale *= 1.28
136
+
137
+ if suppress_warnings:
138
+ warnings.suppress()
139
+
140
+ self.observer = observer or Observer()
141
+ self.ephemeris_name = ephemeris
142
+ self.ephemeris = load(ephemeris)
143
+ self.earth = self.ephemeris["earth"]
144
+
145
+ self._background_clip_path = None
146
+
147
+ self._legend = None
148
+ self._legend_handles = {}
149
+
150
+ self.debug = StarplotSettings.debug or bool(kwargs.get("debug"))
151
+ self.debug_text = StarplotSettings.debug or bool(kwargs.get("debug_text"))
152
+ self.log_level = logging.DEBUG if self.debug else logging.ERROR
153
+ self.logger = LOGGER
154
+ self.logger.setLevel(self.log_level)
155
+
156
+ self.text_border = patheffects.withStroke(
157
+ linewidth=self.style.text_border_width * self.scale,
158
+ foreground=self.style.text_border_color.as_hex(),
159
+ )
160
+
161
+ self._objects = models.ObjectList()
162
+ self._labeled_stars = []
163
+ fonts.load()
164
+
165
+ def _plot_kwargs(self) -> dict:
166
+ return {}
167
+
168
+ def _prepare_coords(self, ra, dec) -> tuple[float, float]:
169
+ return ra, dec
170
+
171
+ def _prepare_coords_many(self, coordinates: list, epoch_year: float = 2000) -> list:
172
+ return coordinates
173
+
174
+ def _update_clip_path_polygon(self, buffer=8):
175
+ self.fig.draw_without_rendering()
176
+ coords = self._background_clip_path.get_verts()
177
+ self._clip_path_polygon = Polygon(coords).buffer(-1 * buffer)
178
+
179
+ # if self.debug_text:
180
+ # patch = patches.Polygon(
181
+ # Polygon(coords).buffer(-1 * buffer).exterior.coords,
182
+ # fill=False,
183
+ # facecolor="none",
184
+ # edgecolor="red",
185
+ # linewidth=4,
186
+ # zorder=5_000,
187
+ # transform=None,
188
+ # )
189
+ # self.ax.add_patch(patch)
190
+
191
+ def _add_legend_handle_marker(self, label: str, style: MarkerStyle):
192
+ if label is not None and label not in self._legend_handles:
193
+ s = style.matplot_kwargs()
194
+ s["markersize"] = self.style.legend.symbol_size * self.scale
195
+ self._legend_handles[label] = Line2D(
196
+ [],
197
+ [],
198
+ **s,
199
+ **self._plot_kwargs(),
200
+ linestyle="None",
201
+ label=label,
202
+ )
203
+
204
+ def _fit_to_ax(self) -> None:
205
+ self.fig.draw_without_rendering()
206
+ bbox = self.ax.get_window_extent().transformed(
207
+ self.fig.dpi_scale_trans.inverted()
208
+ )
209
+ width, height = bbox.width, bbox.height
210
+ self.fig.set_size_inches(width, height)
211
+
212
+ @property
213
+ def magnitude_range(self) -> tuple[float, float]:
214
+ """
215
+ Range of magnitude for all plotted stars, as a tuple (min, max)
216
+ """
217
+ mags = [s.magnitude for s in self.objects.stars]
218
+ return (min(mags), max(mags))
219
+
220
+ @property
221
+ def objects(self) -> models.ObjectList:
222
+ """
223
+ Returns an [`ObjectList`][starplot.models.ObjectList] that contains various lists of sky objects that have been plotted.
224
+ """
225
+ return self._objects
226
+
227
+ @use_style(LabelStyle, "title")
228
+ def title(self, text: str, style: LabelStyle = None):
229
+ """
230
+ Plots a title at the top of the plot
231
+
232
+ Args:
233
+ text: Title text to plot
234
+ style: Styling of the title. If None, then the plot's style (specified when creating the plot) will be used
235
+ """
236
+ style_kwargs = style.matplot_kwargs(self.scale)
237
+ style_kwargs.pop("linespacing", None)
238
+ style_kwargs["pad"] = style.line_spacing
239
+ self.ax.set_title(text, **style_kwargs)
240
+
241
+ def close_fig(self) -> None:
242
+ """Closes the underlying matplotlib figure."""
243
+ if self.fig:
244
+ plt.close(self.fig)
245
+
246
+ @profile
247
+ def export(self, filename: str, padding: float = 0, **kwargs):
248
+ """Exports the plot to an image file.
249
+
250
+ Args:
251
+ filename: Filename of exported file (the format will be inferred from the extension)
252
+ padding: Padding (in inches) around the image
253
+ **kwargs: Any keyword arguments to pass through to matplotlib's `savefig` method
254
+
255
+ """
256
+ self.logger.debug("Exporting...")
257
+ self.fig.savefig(
258
+ filename,
259
+ bbox_inches="tight",
260
+ pad_inches=padding * self.scale,
261
+ dpi=DPI,
262
+ **kwargs,
263
+ )
264
+
265
+ @use_style(ObjectStyle)
266
+ def marker(
267
+ self,
268
+ ra: float,
269
+ dec: float,
270
+ style: Union[dict, ObjectStyle],
271
+ label: Optional[str] = None,
272
+ legend_label: str = None,
273
+ skip_bounds_check: bool = False,
274
+ collision_handler: CollisionHandler = None,
275
+ **kwargs,
276
+ ) -> None:
277
+ """Plots a marker
278
+
279
+ Args:
280
+ ra: Right ascension of the marker
281
+ dec: Declination of the marker
282
+ label: Label for the marker
283
+ style: Styling for the marker
284
+ legend_label: How to label the marker in the legend. If `None`, then the marker will not be added to the legend
285
+ skip_bounds_check: If True, then don't check the marker coordinates to ensure they're within the bounds of the plot. If you're plotting many markers, setting this to True can speed up plotting time.
286
+ collision_handler: An instance of [CollisionHandler][starplot.CollisionHandler] that describes what to do on label collisions with other labels, markers, etc. If `None`, then the collision handler of the plot will be used.
287
+
288
+ """
289
+
290
+ if not skip_bounds_check and not self.in_bounds(ra, dec):
291
+ return
292
+
293
+ # Plot marker
294
+ x, y = self._prepare_coords(ra, dec)
295
+ style_kwargs = style.marker.matplot_scatter_kwargs(self.scale)
296
+ result = self.ax.scatter(
297
+ x,
298
+ y,
299
+ **style_kwargs,
300
+ **self._plot_kwargs(),
301
+ clip_on=True,
302
+ clip_path=self._background_clip_path,
303
+ gid=kwargs.get("gid_marker") or "marker",
304
+ )
305
+
306
+ # Add to spatial index
307
+ data_xy = self._proj.transform_point(x, y, self._crs)
308
+ display_x, display_y = self.ax.transData.transform(data_xy)
309
+ if display_x > 0 and display_y > 0:
310
+ radius = style_kwargs.get("s", 1) ** 0.5 / 5
311
+ bbox = np.array(
312
+ (
313
+ display_x - radius,
314
+ display_y - radius,
315
+ display_x + radius,
316
+ display_y + radius,
317
+ )
318
+ )
319
+ self._markers_rtree.insert(0, bbox, None)
320
+
321
+ # Plot label
322
+ if label:
323
+ label_style = style.label
324
+ if label_style.offset_x == "auto" or label_style.offset_y == "auto":
325
+ marker_size = ((style.marker.size / self.scale) ** 2) * (
326
+ self.scale**2
327
+ )
328
+
329
+ label_style = label_style.offset_from_marker(
330
+ marker_symbol=style.marker.symbol,
331
+ marker_size=marker_size,
332
+ scale=self.scale,
333
+ )
334
+ self.text(
335
+ label,
336
+ ra,
337
+ dec,
338
+ label_style,
339
+ collision_handler=collision_handler or self.point_label_handler,
340
+ gid=kwargs.get("gid_label") or "marker-label",
341
+ )
342
+
343
+ if legend_label is not None:
344
+ self._legend_handles[legend_label] = result
345
+
346
+ @use_style(ObjectStyle, "planets")
347
+ def planets(
348
+ self,
349
+ style: ObjectStyle = None,
350
+ true_size: bool = False,
351
+ labels: Dict[PlanetName, str] = PLANET_LABELS_DEFAULT,
352
+ legend_label: str = "Planet",
353
+ collision_handler: CollisionHandler = None,
354
+ ) -> None:
355
+ """
356
+ Plots the planets, at their _apparent_ RA/DEC (based on the observer you defined).
357
+
358
+ Args:
359
+ style: Styling of the planets. If None, then the plot's style (specified when creating the plot) will be used
360
+ true_size: If True, then each planet's true apparent size in the sky will be plotted. If False, then the style's marker size will be used.
361
+ labels: How the planets will be labeled on the plot and legend. If not specified, then the planet's name will be used (see [`Planet`][starplot.models.planet.PlanetName])
362
+ legend_label: How to label the planets in the legend. If `None`, then the planets will not be added to the legend
363
+ collision_handler: An instance of [CollisionHandler][starplot.CollisionHandler] that describes what to do on label collisions with other labels, markers, etc. If `None`, then the collision handler of the plot will be used.
364
+ """
365
+ labels = labels or {}
366
+ planets = models.Planet.all(self.observer, self.ephemeris_name)
367
+
368
+ legend_label = translate(legend_label, self.language)
369
+ handler = collision_handler or self.point_label_handler
370
+
371
+ for p in planets:
372
+ label = labels.get(p.name)
373
+ label = translate(label, self.language)
374
+
375
+ if self.in_bounds(p.ra, p.dec):
376
+ self._objects.planets.append(p)
377
+
378
+ if true_size:
379
+ polygon_style = style.marker.to_polygon_style()
380
+ polygon_style.edge_color = None
381
+ self.circle(
382
+ center=(p.ra, p.dec),
383
+ radius_degrees=p.apparent_size / 2,
384
+ style=polygon_style,
385
+ gid="planet-marker",
386
+ )
387
+ self._add_legend_handle_marker(legend_label, style.marker)
388
+
389
+ if label:
390
+ self.text(
391
+ label.upper(),
392
+ p.ra,
393
+ p.dec,
394
+ style.label,
395
+ collision_handler=handler,
396
+ gid="planet-label",
397
+ )
398
+ else:
399
+ self.marker(
400
+ ra=p.ra,
401
+ dec=p.dec,
402
+ style=style,
403
+ label=label.upper() if label else None,
404
+ legend_label=legend_label,
405
+ collision_handler=handler,
406
+ gid_marker="planet-marker",
407
+ gid_label="planet-label",
408
+ )
409
+
410
+ @use_style(ObjectStyle, "sun")
411
+ def sun(
412
+ self,
413
+ style: ObjectStyle = None,
414
+ true_size: bool = False,
415
+ label: str = "Sun",
416
+ legend_label: str = "Sun",
417
+ collision_handler: CollisionHandler = None,
418
+ ) -> None:
419
+ """
420
+ Plots the Sun, at its _apparent_ RA/DEC (based on the observer you defined).
421
+
422
+ Args:
423
+ style: Styling of the Sun. If None, then the plot's style (specified when creating the plot) will be used
424
+ true_size: If True, then the Sun's true apparent size in the sky will be plotted as a circle (the marker style's symbol will be ignored). If False, then the style's marker size will be used.
425
+ label: How the Sun will be labeled on the plot
426
+ legend_label: How the sun will be labeled in the legend
427
+ collision_handler: An instance of [CollisionHandler][starplot.CollisionHandler] that describes what to do on label collisions with other labels, markers, etc. If `None`, then the collision handler of the plot will be used.
428
+ """
429
+ s = models.Sun.get(
430
+ observer=self.observer,
431
+ ephemeris=self.ephemeris_name,
432
+ )
433
+ label = translate(label, self.language)
434
+ legend_label = translate(legend_label, self.language)
435
+ s.name = label or s.name
436
+ handler = collision_handler or self.point_label_handler
437
+
438
+ if not self.in_bounds(s.ra, s.dec):
439
+ return
440
+
441
+ self._objects.sun = s
442
+
443
+ if true_size:
444
+ polygon_style = style.marker.to_polygon_style()
445
+
446
+ # hide the edge because it can interfere with the true size
447
+ polygon_style.edge_color = None
448
+
449
+ self.circle(
450
+ center=(s.ra, s.dec),
451
+ radius_degrees=s.apparent_size / 2,
452
+ style=polygon_style,
453
+ gid="sun-marker",
454
+ num_pts=200,
455
+ )
456
+
457
+ style.marker.symbol = MarkerSymbolEnum.CIRCLE
458
+ self._add_legend_handle_marker(legend_label, style.marker)
459
+
460
+ if label:
461
+ self.text(
462
+ label,
463
+ s.ra,
464
+ s.dec,
465
+ style.label,
466
+ collision_handler=handler,
467
+ gid="sun-label",
468
+ )
469
+
470
+ else:
471
+ self.marker(
472
+ ra=s.ra,
473
+ dec=s.dec,
474
+ style=style,
475
+ label=label,
476
+ legend_label=legend_label,
477
+ collision_handler=handler,
478
+ gid_marker="sun-marker",
479
+ gid_label="sun-label",
480
+ )
481
+
482
+ @abstractmethod
483
+ def in_bounds(self, ra: float, dec: float) -> bool:
484
+ """Determine if a coordinate is within the bounds of the plot.
485
+
486
+ Args:
487
+ ra: Right ascension
488
+ dec: Declination
489
+
490
+ Returns:
491
+ bool: True if the coordinate is in bounds, otherwise False
492
+
493
+ """
494
+ raise NotImplementedError
495
+
496
+ @abstractmethod
497
+ def _in_bounds_xy(self, x: float, y: float) -> bool:
498
+ """
499
+ Determine if a data / projected coordinate is within the non-clipped bounds of the plot.
500
+
501
+ This should be extremely precise.
502
+
503
+ Args:
504
+ x: X coordinate
505
+ y: Y coordinate
506
+
507
+ Returns:
508
+ bool: True if the coordinate is in bounds, otherwise False
509
+ """
510
+ raise NotImplementedError
511
+
512
+ def _polygon(self, points: list, style: PolygonStyle, **kwargs):
513
+ # points = [self._prepare_coords(*p) for p in points]
514
+ points = self._prepare_coords_many(points)
515
+ patch = patches.Polygon(
516
+ points,
517
+ # closed=False, # needs to be false for circles at poles?
518
+ **style.matplot_kwargs(self.scale),
519
+ **kwargs,
520
+ # clip_on=True,
521
+ # clip_path=self._background_clip_path,
522
+ )
523
+ self.ax.add_patch(patch)
524
+ # Need to set clip path AFTER adding patch
525
+ patch.set_clip_on(True)
526
+ patch.set_clip_path(self._background_clip_path)
527
+
528
+ @use_style(PolygonStyle)
529
+ def polygon(
530
+ self,
531
+ style: PolygonStyle,
532
+ points: list = None,
533
+ geometry: Polygon = None,
534
+ legend_label: str = None,
535
+ **kwargs,
536
+ ):
537
+ """
538
+ Plots a polygon.
539
+
540
+ Must pass in either `points` **or** `geometry` (but not both).
541
+
542
+ Args:
543
+ style: Style of polygon
544
+ points: List of polygon points `[(ra, dec), ...]` - **must be in counterclockwise order**
545
+ geometry: A shapely Polygon. If this value is passed, then the `points` kwarg will be ignored.
546
+ legend_label: Label for this object in the legend
547
+
548
+ """
549
+ if points is None and geometry is None:
550
+ raise ValueError("Must pass points or geometry when plotting polygons.")
551
+
552
+ if geometry is not None:
553
+ points = list(zip(*geometry.exterior.coords.xy))
554
+
555
+ self._polygon(points, style, gid=kwargs.get("gid") or "polygon", **kwargs)
556
+
557
+ if legend_label is not None:
558
+ self._add_legend_handle_marker(
559
+ legend_label,
560
+ style=style.to_marker_style(symbol=MarkerSymbolEnum.SQUARE),
561
+ )
562
+
563
+ @use_style(PolygonStyle)
564
+ def rectangle(
565
+ self,
566
+ center: tuple,
567
+ height_degrees: float,
568
+ width_degrees: float,
569
+ style: PolygonStyle,
570
+ angle: float = 0,
571
+ legend_label: str = None,
572
+ **kwargs,
573
+ ):
574
+ """Plots a rectangle
575
+
576
+ Args:
577
+ center: Center of rectangle (ra, dec)
578
+ height_degrees: Height of rectangle (degrees)
579
+ width_degrees: Width of rectangle (degrees)
580
+ style: Style of rectangle
581
+ angle: Angle of rotation clockwise (degrees)
582
+ legend_label: Label for this object in the legend
583
+ """
584
+ polygon = _geometry.rectangle(
585
+ center,
586
+ height_degrees,
587
+ width_degrees,
588
+ angle,
589
+ )
590
+ points = list(zip(*polygon.exterior.coords.xy))
591
+ self._polygon(points, style, gid=kwargs.get("gid") or "polygon")
592
+
593
+ if legend_label is not None:
594
+ self._add_legend_handle_marker(
595
+ legend_label,
596
+ style=style.to_marker_style(symbol=MarkerSymbolEnum.SQUARE),
597
+ )
598
+
599
+ @use_style(PolygonStyle)
600
+ def ellipse(
601
+ self,
602
+ center: tuple,
603
+ height_degrees: float,
604
+ width_degrees: float,
605
+ style: PolygonStyle,
606
+ angle: float = 0,
607
+ num_pts: int = 100,
608
+ start_angle: int = 0,
609
+ end_angle: int = 360,
610
+ legend_label: str = None,
611
+ **kwargs,
612
+ ):
613
+ """Plots an ellipse
614
+
615
+ Args:
616
+ center: Center of ellipse (ra, dec)
617
+ height_degrees: Height of ellipse (degrees)
618
+ width_degrees: Width of ellipse (degrees)
619
+ style: Style of ellipse
620
+ angle: Angle of rotation clockwise (degrees)
621
+ num_pts: Number of points to calculate for the ellipse polygon
622
+ start_angle: Angle to start at
623
+ end_angle: Angle to end at
624
+ legend_label: Label for this object in the legend
625
+ """
626
+
627
+ polygon = _geometry.ellipse(
628
+ center,
629
+ height_degrees,
630
+ width_degrees,
631
+ angle,
632
+ num_pts,
633
+ start_angle,
634
+ end_angle,
635
+ )
636
+ points = list(zip(*polygon.exterior.coords.xy))
637
+ self._polygon(points, style, gid=kwargs.get("gid") or "polygon")
638
+
639
+ if legend_label is not None:
640
+ self._add_legend_handle_marker(
641
+ legend_label,
642
+ style=style.to_marker_style(symbol=MarkerSymbolEnum.ELLIPSE),
643
+ )
644
+
645
+ @use_style(PolygonStyle)
646
+ def circle(
647
+ self,
648
+ center: tuple,
649
+ radius_degrees: float,
650
+ style: PolygonStyle,
651
+ num_pts: int = 100,
652
+ legend_label: str = None,
653
+ **kwargs,
654
+ ):
655
+ """Plots a circle
656
+
657
+ Args:
658
+ center: Center of circle (ra, dec)
659
+ radius_degrees: Radius of circle (degrees)
660
+ style: Style of circle
661
+ num_pts: Number of points to calculate for the circle polygon
662
+ legend_label: Label for this object in the legend
663
+ """
664
+ self.ellipse(
665
+ center,
666
+ radius_degrees * 2,
667
+ radius_degrees * 2,
668
+ style=style,
669
+ angle=0,
670
+ num_pts=num_pts,
671
+ gid=kwargs.get("gid") or "polygon",
672
+ )
673
+
674
+ if legend_label is not None:
675
+ self._add_legend_handle_marker(
676
+ legend_label,
677
+ style=style.to_marker_style(symbol=MarkerSymbolEnum.CIRCLE),
678
+ )
679
+
680
+ @use_style(ObjectStyle, "moon")
681
+ def moon(
682
+ self,
683
+ style: ObjectStyle = None,
684
+ true_size: bool = False,
685
+ show_phase: bool = False,
686
+ label: str = "Moon",
687
+ legend_label: str = "Moon",
688
+ collision_handler: CollisionHandler = None,
689
+ ) -> None:
690
+ """
691
+ Plots the Moon, at its _apparent_ RA/DEC (based on the observer you defined).
692
+
693
+ Args:
694
+ style: Styling of the Moon. If None, then the plot's style (specified when creating the plot) will be used
695
+ true_size: If True, then the Moon's true apparent size in the sky will be plotted as a circle (the marker style's symbol will be ignored). If False, then the style's marker size will be used.
696
+ show_phase: If True, and if `true_size = True`, then the phase of the moon will be illustrated. The dark side of the moon will be colored with the marker's `edge_color`.
697
+ label: How the Moon will be labeled on the plot
698
+ legend_label: How the Moon will be labeled in the legend
699
+ collision_handler: An instance of [CollisionHandler][starplot.CollisionHandler] that describes what to do on label collisions with other labels, markers, etc. If `None`, then the collision handler of the plot will be used.
700
+ """
701
+ m = models.Moon.get(
702
+ observer=self.observer,
703
+ ephemeris=self.ephemeris_name,
704
+ )
705
+ label = translate(label, self.language)
706
+ legend_label = translate(legend_label, self.language)
707
+ m.name = label or m.name
708
+ handler = collision_handler or self.point_label_handler
709
+
710
+ if not self.in_bounds(m.ra, m.dec):
711
+ return
712
+
713
+ self._objects.moon = m
714
+
715
+ if true_size:
716
+ # convert to PolygonStyle because we'll plot the true size as a polygon
717
+ polygon_style = style.marker.to_polygon_style()
718
+
719
+ # hide the edge because it can interfere with the true size
720
+ polygon_style.edge_color = None
721
+
722
+ if show_phase:
723
+ self._moon_with_phase(
724
+ moon_phase=m.phase_description,
725
+ center=(m.ra, m.dec),
726
+ radius_degrees=m.apparent_size / 2,
727
+ style=polygon_style,
728
+ dark_side_color=style.marker.edge_color,
729
+ )
730
+ else:
731
+ self.circle(
732
+ center=(m.ra, m.dec),
733
+ radius_degrees=m.apparent_size / 2,
734
+ style=polygon_style,
735
+ gid="moon-marker",
736
+ )
737
+
738
+ style.marker.symbol = MarkerSymbolEnum.CIRCLE
739
+ self._add_legend_handle_marker(legend_label, style.marker)
740
+
741
+ if label:
742
+ self.text(
743
+ label,
744
+ m.ra,
745
+ m.dec,
746
+ style.label,
747
+ collision_handler=handler,
748
+ gid="moon-label",
749
+ )
750
+
751
+ else:
752
+ self.marker(
753
+ ra=m.ra,
754
+ dec=m.dec,
755
+ style=style,
756
+ label=label,
757
+ legend_label=legend_label,
758
+ collision_handler=handler,
759
+ gid_marker="moon-marker",
760
+ gid_label="moon-label",
761
+ )
762
+
763
+ def _moon_with_phase(
764
+ self,
765
+ moon_phase: MoonPhase,
766
+ center: tuple,
767
+ radius_degrees: float,
768
+ style: PolygonStyle,
769
+ dark_side_color: str,
770
+ num_pts: int = 200,
771
+ ):
772
+ """
773
+ Plots the (approximate) moon phase by drawing two half circles and one ellipse in the center,
774
+ and then determining the color of each of the three shapes by the moon phase.
775
+ """
776
+ illuminated_color = style.fill_color
777
+
778
+ ellipse_b_radius_degrees = np.abs(
779
+ radius_degrees * (2 * self._objects.moon.illumination - 1)
780
+ )
781
+
782
+ left = style.copy()
783
+ right = style.copy()
784
+ middle = style.copy()
785
+
786
+ if moon_phase == MoonPhase.WAXING_CRESCENT:
787
+ left.fill_color = illuminated_color
788
+ middle.fill_color = dark_side_color
789
+ right.fill_color = dark_side_color
790
+
791
+ elif moon_phase == MoonPhase.FIRST_QUARTER:
792
+ left.fill_color = illuminated_color
793
+ middle.alpha = 0
794
+ right.fill_color = dark_side_color
795
+
796
+ elif moon_phase == MoonPhase.WAXING_GIBBOUS:
797
+ left.fill_color = illuminated_color
798
+ middle.fill_color = illuminated_color
799
+ right.fill_color = dark_side_color
800
+
801
+ elif moon_phase == MoonPhase.FULL_MOON:
802
+ left.fill_color = middle.fill_color = right.fill_color = illuminated_color
803
+
804
+ elif moon_phase == MoonPhase.WANING_GIBBOUS:
805
+ left.fill_color = dark_side_color
806
+ middle.fill_color = illuminated_color
807
+ right.fill_color = illuminated_color
808
+
809
+ elif moon_phase == MoonPhase.LAST_QUARTER:
810
+ left.fill_color = dark_side_color
811
+ middle.alpha = 0
812
+ right.fill_color = illuminated_color
813
+
814
+ elif moon_phase == MoonPhase.WANING_CRESCENT:
815
+ left.fill_color = dark_side_color
816
+ middle.fill_color = dark_side_color
817
+ right.fill_color = illuminated_color
818
+
819
+ else:
820
+ left.fill_color = middle.fill_color = right.fill_color = dark_side_color
821
+
822
+ # Plot left side
823
+ self.ellipse(
824
+ center,
825
+ height_degrees=radius_degrees * 2,
826
+ width_degrees=radius_degrees * 2,
827
+ style=left,
828
+ num_pts=num_pts,
829
+ angle=0,
830
+ end_angle=180, # plot as a semicircle
831
+ gid="moon-marker",
832
+ )
833
+ # Plot right side
834
+ self.ellipse(
835
+ center,
836
+ height_degrees=radius_degrees * 2,
837
+ width_degrees=radius_degrees * 2,
838
+ style=right,
839
+ num_pts=num_pts,
840
+ angle=180,
841
+ end_angle=180, # plot as a semicircle
842
+ gid="moon-marker",
843
+ )
844
+ # Plot middle
845
+ self.ellipse(
846
+ center,
847
+ height_degrees=radius_degrees * 2,
848
+ width_degrees=ellipse_b_radius_degrees * 2,
849
+ num_pts=num_pts,
850
+ style=middle,
851
+ gid="moon-marker",
852
+ )
853
+
854
+ @use_style(PolygonStyle, "optic_fov")
855
+ def optic_fov(
856
+ self,
857
+ ra: float,
858
+ dec: float,
859
+ optic: Optic,
860
+ style: PolygonStyle = None,
861
+ ):
862
+ """Draws a polygon representing the field of view for an optic, centered at a specific point.
863
+
864
+ Args:
865
+ ra: Right ascension of the center of view
866
+ dec: Declination of the center of view
867
+ optic: Instance of an [Optic][starplot.models.Optic]
868
+ style: style of the polygon
869
+ """
870
+ if isinstance(optic, Camera):
871
+ self.rectangle(
872
+ center=(ra, dec),
873
+ height_degrees=optic.true_fov_y,
874
+ width_degrees=optic.true_fov_x,
875
+ angle=optic.rotation,
876
+ style=style,
877
+ )
878
+ else:
879
+ self.circle(
880
+ center=(ra, dec),
881
+ radius_degrees=optic.true_fov / 2,
882
+ style=style,
883
+ )
884
+
885
+ @profile
886
+ @use_style(PathStyle, "ecliptic")
887
+ def ecliptic(
888
+ self,
889
+ style: PathStyle = None,
890
+ label: str = "ECLIPTIC",
891
+ num_labels: int = 1,
892
+ collision_handler: CollisionHandler = None,
893
+ ):
894
+ """Plots the ecliptic
895
+
896
+ Args:
897
+ style: Styling of the ecliptic. If None, then the plot's style will be used
898
+ label: How the ecliptic will be labeled on the plot
899
+ num_labels: Max number of labels to plot along the line
900
+ collision_handler: An instance of [CollisionHandler][starplot.CollisionHandler] that describes what to do on label collisions with other labels, markers, etc. If `None`, then the plot's `path_label_handler` will be used.
901
+ """
902
+ x = []
903
+ y = []
904
+ inbounds = []
905
+
906
+ label = translate(label, self.language)
907
+
908
+ for ra, dec in ecliptic.RA_DECS:
909
+ x0, y0 = self._prepare_coords(ra * 15, dec)
910
+ x.append(x0)
911
+ y.append(y0)
912
+ if self.in_bounds(ra * 15, dec):
913
+ inbounds.append((ra * 15, dec))
914
+
915
+ coords = [(ra * 15, dec) for ra, dec in ecliptic.RA_DECS]
916
+
917
+ self.line(
918
+ style=style,
919
+ label=label.upper(),
920
+ num_labels=num_labels,
921
+ collision_handler=collision_handler,
922
+ coordinates=coords,
923
+ )
924
+
925
+ @profile
926
+ @use_style(PathStyle, "celestial_equator")
927
+ def celestial_equator(
928
+ self,
929
+ style: PathStyle = None,
930
+ label: str = "CELESTIAL EQUATOR",
931
+ num_labels: int = 1,
932
+ collision_handler: CollisionHandler = None,
933
+ ):
934
+ """
935
+ Plots the celestial equator
936
+
937
+ Args:
938
+ style: Styling of the celestial equator. If None, then the plot's style will be used
939
+ label: How the celestial equator will be labeled on the plot
940
+ num_labels: Max number of labels to plot along the line
941
+ collision_handler: An instance of [CollisionHandler][starplot.CollisionHandler] that describes what to do on label collisions with other labels, markers, etc. If `None`, then the plot's `path_label_handler` will be used.
942
+ """
943
+ label = translate(label, self.language)
944
+ coords = [(ra, 0) for ra in range(0, 361)]
945
+ self.line(
946
+ style=style,
947
+ label=label.upper(),
948
+ num_labels=num_labels,
949
+ collision_handler=collision_handler,
950
+ coordinates=coords,
951
+ gid="celestial-equator",
952
+ )
953
+
954
+ @use_style(PathStyle)
955
+ def line(
956
+ self,
957
+ coordinates: list[tuple[float, float]] = None,
958
+ geometry: LineString = None,
959
+ style: PathStyle = None,
960
+ label: str = None,
961
+ num_labels: int = 2,
962
+ collision_handler: CollisionHandler = None,
963
+ **kwargs,
964
+ ):
965
+ """Plots a line, with optional labels. Either coordinates OR geometry must be specified.
966
+
967
+ Args:
968
+
969
+ coordinates: List of coordinates, e.g. `[(ra, dec), (ra, dec)]`
970
+ geometry: A shapely LineString. If this value is passed, then the `coordinates` kwarg will be ignored.
971
+ style: Style of the line
972
+ label: Label for the line
973
+ num_labels: Number of labels to plot along the line
974
+ collision_handler: An instance of [CollisionHandler][starplot.CollisionHandler] that describes what to do on label collisions with other labels, markers, etc. If `None`, then the plot's `path_label_handler` will be used.
975
+
976
+ """
977
+
978
+ if coordinates is None and geometry is None:
979
+ raise ValueError("Must pass coordinates or geometry when plotting lines.")
980
+
981
+ coords = geometry.coords if geometry is not None else coordinates
982
+ prepared_coords = [self._prepare_coords(*p) for p in coords]
983
+ x, y = zip(*prepared_coords)
984
+
985
+ gid = kwargs.get("gid") or "line"
986
+
987
+ self.ax.plot(
988
+ x,
989
+ y,
990
+ clip_on=True,
991
+ clip_path=self._background_clip_path,
992
+ dash_capstyle=style.line.dash_capstyle,
993
+ gid=gid,
994
+ **style.line.matplot_kwargs(self.scale),
995
+ **self._plot_kwargs(),
996
+ )
997
+
998
+ if not label:
999
+ return
1000
+
1001
+ prepared_coords = [
1002
+ (x, y) for x, y in prepared_coords if self._in_bounds_xy(x, y)
1003
+ ]
1004
+
1005
+ if not prepared_coords:
1006
+ return
1007
+
1008
+ x, y = zip(*prepared_coords)
1009
+
1010
+ collision_handler = collision_handler or self.path_label_handler
1011
+
1012
+ self._text_line(
1013
+ x,
1014
+ y,
1015
+ label,
1016
+ num_labels=num_labels,
1017
+ collision_handler=collision_handler,
1018
+ min_spacing=0.65,
1019
+ **style.label.matplot_kwargs(self.scale),
1020
+ **self._plot_kwargs(),
1021
+ clip_path=self._background_clip_path,
1022
+ gid=gid,
1023
+ )