starplot 0.13.0__py2.py3-none-any.whl → 0.14.0__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.

Potentially problematic release.


This version of starplot might be problematic. Click here for more details.

starplot/__init__.py CHANGED
@@ -1,9 +1,11 @@
1
1
  """Star charts and maps of the sky"""
2
2
 
3
- __version__ = "0.13.0"
3
+ __version__ = "0.14.0"
4
4
 
5
5
  from .base import BasePlot # noqa: F401
6
6
  from .map import MapPlot, Projection # noqa: F401
7
+ from .horizon import HorizonPlot # noqa: F401
8
+ from .optic import OpticPlot # noqa: F401
7
9
  from .models import (
8
10
  DSO, # noqa: F401
9
11
  Star, # noqa: F401
@@ -13,5 +15,4 @@ from .models import (
13
15
  Sun, # noqa: F401
14
16
  ObjectList, # noqa: F401
15
17
  )
16
- from .optic import OpticPlot # noqa: F401
17
18
  from .styles import * # noqa: F401 F403
starplot/base.py CHANGED
@@ -1,19 +1,19 @@
1
1
  from abc import ABC, abstractmethod
2
2
  from datetime import datetime
3
3
  from typing import Dict, Union, Optional
4
+ from random import randrange
4
5
  import logging
5
- import warnings
6
6
 
7
7
  import numpy as np
8
8
  import rtree
9
- from adjustText import adjust_text as _adjust_text
10
9
  from matplotlib import patches
11
10
  from matplotlib import pyplot as plt, patheffects
12
11
  from matplotlib.lines import Line2D
13
12
  from pytz import timezone
14
- from shapely import Polygon
13
+ from shapely import Polygon, Point
15
14
 
16
- from starplot import geod, models
15
+ from starplot.coordinates import CoordinateSystem
16
+ from starplot import geod, models, warnings
17
17
  from starplot.data import load, ecliptic
18
18
  from starplot.models.planet import PlanetName, PLANET_LABELS_DEFAULT
19
19
  from starplot.models.moon import MoonPhase
@@ -32,13 +32,12 @@ from starplot.styles import (
32
32
  fonts,
33
33
  )
34
34
  from starplot.styles.helpers import use_style
35
-
36
- # ignore noisy matplotlib warnings
37
- warnings.filterwarnings(
38
- "ignore",
39
- message="Setting the 'color' property will override the edgecolor or facecolor properties",
35
+ from starplot.geometry import (
36
+ unwrap_polygon,
37
+ random_point_in_polygon_at_distance,
40
38
  )
41
39
 
40
+
42
41
  LOGGER = logging.getLogger("starplot")
43
42
  LOG_HANDLER = logging.StreamHandler()
44
43
  LOG_FORMATTER = logging.Formatter(
@@ -62,6 +61,7 @@ DPI = 100
62
61
 
63
62
  class BasePlot(ABC):
64
63
  _background_clip_path = None
64
+ _coordinate_system = CoordinateSystem.RA_DEC
65
65
 
66
66
  def __init__(
67
67
  self,
@@ -72,11 +72,11 @@ class BasePlot(ABC):
72
72
  hide_colliding_labels: bool = True,
73
73
  scale: float = 1.0,
74
74
  autoscale: bool = False,
75
+ suppress_warnings: bool = True,
75
76
  *args,
76
77
  **kwargs,
77
78
  ):
78
79
  px = 1 / DPI # plt.rcParams["figure.dpi"] # pixel in inches
79
-
80
80
  self.pixels_per_point = DPI / 72
81
81
 
82
82
  self.style = style
@@ -89,14 +89,15 @@ class BasePlot(ABC):
89
89
  if self.autoscale:
90
90
  self.scale = self.resolution / DEFAULT_RESOLUTION
91
91
 
92
+ if suppress_warnings:
93
+ warnings.suppress()
94
+
92
95
  self.dt = dt or timezone("UTC").localize(datetime.now())
93
96
  self._ephemeris_name = ephemeris
94
97
  self.ephemeris = load(ephemeris)
95
98
 
96
99
  self.labels = []
97
100
  self._labels_rtree = rtree.index.Index()
98
-
99
- # self.labels = []
100
101
  self._constellations_rtree = rtree.index.Index()
101
102
  self._stars_rtree = rtree.index.Index()
102
103
 
@@ -125,31 +126,22 @@ class BasePlot(ABC):
125
126
  def _prepare_coords(self, ra, dec) -> tuple[float, float]:
126
127
  return ra, dec
127
128
 
128
- def _is_label_collision(self, extent) -> bool:
129
- ix = list(
130
- self._labels_rtree.intersection(
131
- (extent.x0, extent.y0, extent.x1, extent.y1)
132
- )
133
- )
129
+ def _is_label_collision(self, bbox) -> bool:
130
+ ix = list(self._labels_rtree.intersection(bbox))
134
131
  return len(ix) > 0
135
132
 
136
- def _is_object_collision(self, extent) -> bool:
137
- ix = list(
138
- self._constellations_rtree.intersection(
139
- (extent.x0, extent.y0, extent.x1, extent.y1)
140
- )
141
- )
133
+ def _is_constellation_collision(self, bbox) -> bool:
134
+ ix = list(self._constellations_rtree.intersection(bbox))
142
135
  return len(ix) > 0
143
136
 
144
- def _is_star_collision(self, extent) -> bool:
145
- ix = list(
146
- self._stars_rtree.intersection((extent.x0, extent.y0, extent.x1, extent.y1))
147
- )
137
+ def _is_star_collision(self, bbox) -> bool:
138
+ ix = list(self._stars_rtree.intersection(bbox))
148
139
  return len(ix) > 0
149
140
 
150
- def _is_clipped(self, extent) -> bool:
141
+ def _is_clipped(self, points) -> bool:
142
+ radius = -1.5 * int(self._background_clip_path.get_linewidth())
151
143
  return self._background_clip_path is not None and not all(
152
- self._background_clip_path.contains_points(extent.get_points())
144
+ self._background_clip_path.contains_points(points, radius=radius)
153
145
  )
154
146
 
155
147
  def _add_label_to_rtree(self, label, extent=None):
@@ -158,31 +150,50 @@ class BasePlot(ABC):
158
150
  )
159
151
  self.labels.append(label)
160
152
  self._labels_rtree.insert(
161
- 0, np.array((extent.x0, extent.y0, extent.x1, extent.y1))
153
+ 0, np.array((extent.x0 - 1, extent.y0 - 1, extent.x1 + 1, extent.y1 + 1))
162
154
  )
163
155
 
164
156
  def _maybe_remove_label(
165
- self, label, remove_on_collision=True, remove_on_clipped=True
157
+ self,
158
+ label,
159
+ remove_on_collision=True,
160
+ remove_on_clipped=True,
161
+ remove_on_constellation_collision=True,
162
+ padding=0,
166
163
  ) -> bool:
167
164
  """Returns true if the label is removed, else false"""
168
165
  extent = label.get_window_extent(renderer=self.fig.canvas.get_renderer())
166
+ bbox = (
167
+ extent.x0 - padding,
168
+ extent.y0 - padding,
169
+ extent.x1 + padding,
170
+ extent.y1 + padding,
171
+ )
172
+ points = [(extent.x0, extent.y0), (extent.x1, extent.y1)]
173
+
174
+ # if label.get_text() == "CANIS MAJOR":
175
+ # print(bbox)
176
+ # if label.get_text() == "Electra":
177
+ # print(bbox)
169
178
 
170
179
  if any([np.isnan(c) for c in (extent.x0, extent.y0, extent.x1, extent.y1)]):
171
180
  label.remove()
172
181
  return True
173
182
 
174
- if remove_on_clipped and self._is_clipped(extent):
183
+ if remove_on_clipped and self._is_clipped(points):
175
184
  label.remove()
176
185
  return True
177
186
 
178
187
  if remove_on_collision and (
179
- self._is_label_collision(extent)
180
- or self._is_object_collision(extent)
181
- # or self._is_star_collision(extent)
188
+ self._is_label_collision(bbox) or self._is_star_collision(bbox)
182
189
  ):
183
190
  label.remove()
184
191
  return True
185
192
 
193
+ if remove_on_constellation_collision and self._is_constellation_collision(bbox):
194
+ label.remove()
195
+ return True
196
+
186
197
  return False
187
198
 
188
199
  def _add_legend_handle_marker(self, label: str, style: MarkerStyle):
@@ -260,70 +271,44 @@ class BasePlot(ABC):
260
271
 
261
272
  return sum([x_labels, x_constellations, x_stars]) / 3
262
273
 
263
- def _text_experimental(
274
+ def _text(self, x, y, text, **kwargs):
275
+ label = self.ax.annotate(
276
+ text,
277
+ (x, y),
278
+ **kwargs,
279
+ **self._plot_kwargs(),
280
+ )
281
+ if kwargs.get("clip_on"):
282
+ label.set_clip_on(True)
283
+ label.set_clip_path(self._background_clip_path)
284
+ return label
285
+
286
+ def _text_point(
264
287
  self,
265
288
  ra: float,
266
289
  dec: float,
267
290
  text: str,
268
291
  hide_on_collision: bool = True,
269
- auto_anchor: bool = True,
270
- *args,
292
+ force: bool = False,
293
+ clip_on: bool = True,
271
294
  **kwargs,
272
- ) -> None:
295
+ ):
273
296
  if not text:
274
- return
297
+ return None
275
298
 
276
299
  x, y = self._prepare_coords(ra, dec)
277
300
  kwargs["path_effects"] = kwargs.get("path_effects", [self.text_border])
278
- clip_on = kwargs.get("clip_on") or True
279
-
280
- def plot_text(**kwargs):
281
- label = self.ax.annotate(
282
- text,
283
- (x, y),
284
- *args,
285
- **kwargs,
286
- **self._plot_kwargs(),
287
- )
288
- if clip_on:
289
- label.set_clip_on(True)
290
- label.set_clip_path(self._background_clip_path)
291
- return label
292
-
293
- def add_label(label):
294
- extent = label.get_window_extent(renderer=self.fig.canvas.get_renderer())
295
- self.labels.append(label)
296
- self._labels_rtree.insert(
297
- 0, np.array((extent.x0, extent.y0, extent.x1, extent.y1))
298
- )
299
-
300
- label = plot_text(**kwargs)
301
-
302
- if not clip_on:
303
- add_label(label)
304
- return
305
-
306
- if not hide_on_collision and not auto_anchor:
307
- add_label(label)
308
- return
309
-
310
- # removed = self._maybe_remove_label(label)
311
- collision = self._collision_score(label)
312
-
313
- if collision == 0:
314
- add_label(label)
315
- return
316
-
317
- label.remove()
318
301
 
319
- collision_scores = []
320
302
  original_va = kwargs.pop("va", None)
321
303
  original_ha = kwargs.pop("ha", None)
322
304
  original_offset_x, original_offset_y = kwargs.pop("xytext", (0, 0))
323
- anchor_fallbacks = self.style.text_anchor_fallbacks
324
- for i, a in enumerate(anchor_fallbacks):
305
+
306
+ anchors = [(original_va, original_ha)]
307
+ for a in self.style.text_anchor_fallbacks:
325
308
  d = AnchorPointEnum.from_str(a).as_matplot()
326
- va, ha = d["va"], d["ha"]
309
+ anchors.append((d["va"], d["ha"]))
310
+
311
+ for va, ha in anchors:
327
312
  offset_x, offset_y = original_offset_x, original_offset_y
328
313
  if original_ha != ha:
329
314
  offset_x *= -1
@@ -335,100 +320,78 @@ class BasePlot(ABC):
335
320
  offset_x = 0
336
321
  offset_y = 0
337
322
 
338
- pt_kwargs = dict(**kwargs, va=va, ha=ha, xytext=(offset_x, offset_y))
339
- label = plot_text(**pt_kwargs)
340
-
341
- # if not hide_on_collision and i == len(anchor_fallbacks) - 1:
342
- # break
343
-
344
- collision = self._collision_score(label)
345
- if collision == 0:
346
- add_label(label)
347
- return
323
+ label = self._text(
324
+ x, y, text, **kwargs, va=va, ha=ha, xytext=(offset_x, offset_y)
325
+ )
326
+ removed = self._maybe_remove_label(
327
+ label, remove_on_collision=hide_on_collision, remove_on_clipped=clip_on
328
+ )
348
329
 
349
- if collision < 1:
350
- collision_scores.append((collision, pt_kwargs))
330
+ if force or not removed:
331
+ self._add_label_to_rtree(label)
332
+ return label
351
333
 
352
- label.remove()
353
- # removed = self._maybe_remove_label(label)
354
- # if not removed:
355
- # break
356
- if len(collision_scores) > 0:
357
- best = sorted(collision_scores, key=lambda c: c[0])[0]
358
- # return
359
- if best[0] < 1:
360
- label = plot_text(**best[1])
361
- add_label(label)
362
-
363
- def _text(
334
+ def _text_area(
364
335
  self,
365
336
  ra: float,
366
337
  dec: float,
367
338
  text: str,
339
+ area,
368
340
  hide_on_collision: bool = True,
369
341
  force: bool = False,
370
342
  clip_on: bool = True,
371
- *args,
343
+ settings: dict = None,
372
344
  **kwargs,
373
345
  ) -> None:
374
- if not text:
375
- return
376
-
377
- x, y = self._prepare_coords(ra, dec)
378
346
  kwargs["path_effects"] = kwargs.get("path_effects", [self.text_border])
379
347
 
380
- def plot_text(**kwargs):
381
- label = self.ax.annotate(
382
- text,
383
- (x, y),
384
- *args,
385
- **kwargs,
386
- **self._plot_kwargs(),
387
- )
388
- if clip_on:
389
- label.set_clip_on(True)
390
- label.set_clip_path(self._background_clip_path)
391
- return label
392
-
393
- label = plot_text(**kwargs)
394
-
395
- if force:
396
- return
348
+ avoid_constellation_lines = settings.get("avoid_constellation_lines", False)
349
+ padding = settings.get("label_padding", 3)
350
+ buffer = settings.get("buffer", 0.1)
351
+ max_distance = settings.get("max_distance", 300)
352
+ distance_step_size = settings.get("distance_step_size", 1)
353
+ point_iterations = settings.get("point_generation_max_iterations", 500)
354
+ random_seed = settings.get("seed")
397
355
 
398
- removed = self._maybe_remove_label(
399
- label, remove_on_collision=hide_on_collision, remove_on_clipped=clip_on
356
+ areas = (
357
+ [p for p in area.geoms] if "MultiPolygon" == str(area.geom_type) else [area]
400
358
  )
359
+ new_areas = []
360
+
361
+ for a in areas:
362
+ unwrapped = unwrap_polygon(a)
363
+ buffer = unwrapped.area / 10 * -1 * buffer * self.scale
364
+ new_areas.append(unwrapped.buffer(buffer))
365
+
366
+ for d in range(0, max_distance, distance_step_size):
367
+ distance = d / 10
368
+ poly = randrange(len(new_areas))
369
+ point = random_point_in_polygon_at_distance(
370
+ new_areas[poly],
371
+ Point(ra, dec),
372
+ distance,
373
+ max_iterations=point_iterations,
374
+ seed=random_seed,
375
+ )
401
376
 
402
- if not removed:
403
- self._add_label_to_rtree(label)
404
- return
405
-
406
- original_va = kwargs.pop("va", None)
407
- original_ha = kwargs.pop("ha", None)
408
- original_offset_x, original_offset_y = kwargs.pop("xytext", (0, 0))
409
- anchor_fallbacks = self.style.text_anchor_fallbacks
410
- for i, a in enumerate(anchor_fallbacks):
411
- d = AnchorPointEnum.from_str(a).as_matplot()
412
- va, ha = d["va"], d["ha"]
413
- offset_x, offset_y = original_offset_x, original_offset_y
414
- if original_ha != ha:
415
- offset_x *= -1
416
-
417
- if original_va != va:
418
- offset_y *= -1
419
-
420
- if ha == "center":
421
- offset_x = 0
422
- offset_y = 0
377
+ if point is None:
378
+ continue
423
379
 
424
- label = plot_text(**kwargs, va=va, ha=ha, xytext=(offset_x, offset_y))
380
+ x, y = self._prepare_coords(point.x, point.y)
381
+ label = self._text(x, y, text, **kwargs)
425
382
  removed = self._maybe_remove_label(
426
- label, remove_on_collision=hide_on_collision, remove_on_clipped=clip_on
383
+ label,
384
+ remove_on_collision=hide_on_collision,
385
+ remove_on_clipped=clip_on,
386
+ remove_on_constellation_collision=avoid_constellation_lines,
387
+ padding=padding,
427
388
  )
428
389
 
390
+ # TODO : remove label if not fully inside area?
391
+
429
392
  if not removed:
430
393
  self._add_label_to_rtree(label)
431
- break
394
+ return label
432
395
 
433
396
  @use_style(LabelStyle)
434
397
  def text(
@@ -438,7 +401,6 @@ class BasePlot(ABC):
438
401
  dec: float,
439
402
  style: LabelStyle = None,
440
403
  hide_on_collision: bool = True,
441
- *args,
442
404
  **kwargs,
443
405
  ):
444
406
  """
@@ -451,7 +413,7 @@ class BasePlot(ABC):
451
413
  style: Styling of the text
452
414
  hide_on_collision: If True, then the text will not be plotted if it collides with another label
453
415
  """
454
- if not self.in_bounds(ra, dec):
416
+ if not text:
455
417
  return
456
418
 
457
419
  style = style or LabelStyle()
@@ -462,17 +424,32 @@ class BasePlot(ABC):
462
424
  if style.offset_y == "auto":
463
425
  style.offset_y = 0
464
426
 
465
- self._text(
466
- ra,
467
- dec,
468
- text,
469
- **style.matplot_kwargs(self.scale),
470
- hide_on_collision=hide_on_collision,
471
- xycoords="data",
472
- xytext=(style.offset_x * self.scale, style.offset_y * self.scale),
473
- textcoords="offset points",
474
- **kwargs,
475
- )
427
+ if kwargs.get("area"):
428
+ return self._text_area(
429
+ ra,
430
+ dec,
431
+ text,
432
+ **style.matplot_kwargs(self.scale),
433
+ area=kwargs.pop("area"),
434
+ hide_on_collision=hide_on_collision,
435
+ xycoords="data",
436
+ xytext=(style.offset_x * self.scale, style.offset_y * self.scale),
437
+ textcoords="offset points",
438
+ settings=kwargs.pop("auto_adjust_settings"),
439
+ **kwargs,
440
+ )
441
+ else:
442
+ return self._text_point(
443
+ ra,
444
+ dec,
445
+ text,
446
+ **style.matplot_kwargs(self.scale),
447
+ hide_on_collision=hide_on_collision,
448
+ xycoords="data",
449
+ xytext=(style.offset_x * self.scale, style.offset_y * self.scale),
450
+ textcoords="offset points",
451
+ **kwargs,
452
+ )
476
453
 
477
454
  @property
478
455
  def objects(self) -> models.ObjectList:
@@ -540,18 +517,6 @@ class BasePlot(ABC):
540
517
  style.zorder
541
518
  )
542
519
 
543
- def adjust_text(self, ensure_inside_axes: bool = False, **kwargs) -> None:
544
- """Adjust all the labels to avoid overlapping. This function uses the [adjustText](https://adjusttext.readthedocs.io/) library.
545
-
546
- Args:
547
- ensure_inside_axes: If True, then labels will be forced to stay within the axes
548
- **kwargs: Any keyword arguments to pass through to [adjustText](https://adjusttext.readthedocs.io/en/latest/#adjustText.adjust_text)
549
-
550
- """
551
- _adjust_text(
552
- self.labels, ax=self.ax, ensure_inside_axes=ensure_inside_axes, **kwargs
553
- )
554
-
555
520
  def close_fig(self) -> None:
556
521
  """Closes the underlying matplotlib figure."""
557
522
  if self.fig:
@@ -567,6 +532,7 @@ class BasePlot(ABC):
567
532
  **kwargs: Any keyword arguments to pass through to matplotlib's `savefig` method
568
533
 
569
534
  """
535
+ self.logger.debug("Exporting...")
570
536
  self.fig.savefig(
571
537
  filename,
572
538
  format=format,
@@ -708,7 +674,8 @@ class BasePlot(ABC):
708
674
  Args:
709
675
  style: Styling of the Sun. If None, then the plot's style (specified when creating the plot) will be used
710
676
  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.
711
- label: How the Sun will be labeled on the plot and legend
677
+ label: How the Sun will be labeled on the plot
678
+ legend_label: How the sun will be labeled in the legend
712
679
  """
713
680
  s = models.Sun.get(
714
681
  dt=self.dt, lat=self.lat, lon=self.lon, ephemeris=self._ephemeris_name
@@ -799,6 +766,7 @@ class BasePlot(ABC):
799
766
  style: PolygonStyle,
800
767
  points: list = None,
801
768
  geometry: Polygon = None,
769
+ legend_label: str = None,
802
770
  **kwargs,
803
771
  ):
804
772
  """
@@ -807,9 +775,11 @@ class BasePlot(ABC):
807
775
  Must pass in either `points` **or** `geometry` (but not both).
808
776
 
809
777
  Args:
778
+ style: Style of polygon
810
779
  points: List of polygon points `[(ra, dec), ...]` - **must be in counterclockwise order**
811
780
  geometry: A shapely Polygon. If this value is passed, then the `points` kwarg will be ignored.
812
- style: Style of polygon
781
+ legend_label: Label for this object in the legend
782
+
813
783
  """
814
784
  if points is None and geometry is None:
815
785
  raise ValueError("Must pass points or geometry when plotting polygons.")
@@ -820,6 +790,12 @@ class BasePlot(ABC):
820
790
  _points = [(ra * 15, dec) for ra, dec in points]
821
791
  self._polygon(_points, style, gid=kwargs.get("gid") or "polygon")
822
792
 
793
+ if legend_label is not None:
794
+ self._add_legend_handle_marker(
795
+ legend_label,
796
+ style=style.to_marker_style(symbol=MarkerSymbolEnum.SQUARE),
797
+ )
798
+
823
799
  @use_style(PolygonStyle)
824
800
  def rectangle(
825
801
  self,
@@ -828,6 +804,7 @@ class BasePlot(ABC):
828
804
  width_degrees: float,
829
805
  style: PolygonStyle,
830
806
  angle: float = 0,
807
+ legend_label: str = None,
831
808
  **kwargs,
832
809
  ):
833
810
  """Plots a rectangle
@@ -836,8 +813,9 @@ class BasePlot(ABC):
836
813
  center: Center of rectangle (ra, dec)
837
814
  height_degrees: Height of rectangle (degrees)
838
815
  width_degrees: Width of rectangle (degrees)
839
- angle: Angle of rotation clockwise (degrees)
840
816
  style: Style of rectangle
817
+ angle: Angle of rotation clockwise (degrees)
818
+ legend_label: Label for this object in the legend
841
819
  """
842
820
  points = geod.rectangle(
843
821
  center,
@@ -847,6 +825,12 @@ class BasePlot(ABC):
847
825
  )
848
826
  self._polygon(points, style, gid=kwargs.get("gid") or "polygon")
849
827
 
828
+ if legend_label is not None:
829
+ self._add_legend_handle_marker(
830
+ legend_label,
831
+ style=style.to_marker_style(symbol=MarkerSymbolEnum.SQUARE),
832
+ )
833
+
850
834
  @use_style(PolygonStyle)
851
835
  def ellipse(
852
836
  self,
@@ -858,6 +842,7 @@ class BasePlot(ABC):
858
842
  num_pts: int = 100,
859
843
  start_angle: int = 0,
860
844
  end_angle: int = 360,
845
+ legend_label: str = None,
861
846
  **kwargs,
862
847
  ):
863
848
  """Plots an ellipse
@@ -869,6 +854,9 @@ class BasePlot(ABC):
869
854
  style: Style of ellipse
870
855
  angle: Angle of rotation clockwise (degrees)
871
856
  num_pts: Number of points to calculate for the ellipse polygon
857
+ start_angle: Angle to start at
858
+ end_angle: Angle to end at
859
+ legend_label: Label for this object in the legend
872
860
  """
873
861
 
874
862
  points = geod.ellipse(
@@ -882,6 +870,12 @@ class BasePlot(ABC):
882
870
  )
883
871
  self._polygon(points, style, gid=kwargs.get("gid") or "polygon")
884
872
 
873
+ if legend_label is not None:
874
+ self._add_legend_handle_marker(
875
+ legend_label,
876
+ style=style.to_marker_style(symbol=MarkerSymbolEnum.ELLIPSE),
877
+ )
878
+
885
879
  @use_style(PolygonStyle)
886
880
  def circle(
887
881
  self,
@@ -889,6 +883,7 @@ class BasePlot(ABC):
889
883
  radius_degrees: float,
890
884
  style: PolygonStyle,
891
885
  num_pts: int = 100,
886
+ legend_label: str = None,
892
887
  **kwargs,
893
888
  ):
894
889
  """Plots a circle
@@ -898,6 +893,7 @@ class BasePlot(ABC):
898
893
  radius_degrees: Radius of circle (degrees)
899
894
  style: Style of circle
900
895
  num_pts: Number of points to calculate for the circle polygon
896
+ legend_label: Label for this object in the legend
901
897
  """
902
898
  self.ellipse(
903
899
  center,
@@ -909,6 +905,12 @@ class BasePlot(ABC):
909
905
  gid=kwargs.get("gid") or "polygon",
910
906
  )
911
907
 
908
+ if legend_label is not None:
909
+ self._add_legend_handle_marker(
910
+ legend_label,
911
+ style=style.to_marker_style(symbol=MarkerSymbolEnum.CIRCLE),
912
+ )
913
+
912
914
  @use_style(LineStyle)
913
915
  def line(self, coordinates: list[tuple[float, float]], style: LineStyle, **kwargs):
914
916
  """Plots a line
@@ -0,0 +1,6 @@
1
+ class CoordinateSystem:
2
+ RA_DEC = "radec"
3
+ AZ_ALT = "azalt"
4
+ AXES = "axes"
5
+ PROJECTED = "projected"
6
+ DISPLAY = "display"