starplot 0.12.5__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.

Files changed (73) hide show
  1. starplot/__init__.py +3 -2
  2. starplot/base.py +408 -95
  3. starplot/callables.py +61 -7
  4. starplot/coordinates.py +6 -0
  5. starplot/data/bayer.py +1532 -3
  6. starplot/data/constellations.py +564 -2
  7. starplot/data/flamsteed.py +2682 -0
  8. starplot/data/library/constellation_borders_inv.gpkg +0 -0
  9. starplot/data/library/constellation_lines_hips.json +3 -1
  10. starplot/data/stars.py +408 -87
  11. starplot/geometry.py +82 -0
  12. starplot/horizon.py +458 -0
  13. starplot/map.py +97 -284
  14. starplot/models/base.py +9 -2
  15. starplot/models/constellation.py +1 -1
  16. starplot/optic.py +32 -14
  17. starplot/plotters/__init__.py +2 -0
  18. starplot/plotters/constellations.py +339 -0
  19. starplot/plotters/dsos.py +5 -1
  20. starplot/plotters/experimental.py +171 -0
  21. starplot/plotters/milkyway.py +41 -0
  22. starplot/plotters/stars.py +143 -13
  23. starplot/styles/base.py +308 -169
  24. starplot/styles/ext/antique.yml +54 -46
  25. starplot/styles/ext/blue_dark.yml +39 -45
  26. starplot/styles/ext/blue_light.yml +49 -30
  27. starplot/styles/ext/blue_medium.yml +53 -50
  28. starplot/styles/ext/cb_wong.yml +16 -7
  29. starplot/styles/ext/grayscale.yml +17 -10
  30. starplot/styles/ext/grayscale_dark.yml +18 -8
  31. starplot/styles/ext/map.yml +10 -7
  32. starplot/styles/ext/nord.yml +38 -38
  33. starplot/styles/ext/optic.yml +7 -5
  34. starplot/styles/fonts-library/gfs-didot/DESCRIPTION.en_us.html +9 -0
  35. starplot/styles/fonts-library/gfs-didot/GFSDidot-Regular.ttf +0 -0
  36. starplot/styles/fonts-library/gfs-didot/METADATA.pb +16 -0
  37. starplot/styles/fonts-library/gfs-didot/OFL.txt +94 -0
  38. starplot/styles/fonts-library/hind/DESCRIPTION.en_us.html +28 -0
  39. starplot/styles/fonts-library/hind/Hind-Bold.ttf +0 -0
  40. starplot/styles/fonts-library/hind/Hind-Light.ttf +0 -0
  41. starplot/styles/fonts-library/hind/Hind-Medium.ttf +0 -0
  42. starplot/styles/fonts-library/hind/Hind-Regular.ttf +0 -0
  43. starplot/styles/fonts-library/hind/Hind-SemiBold.ttf +0 -0
  44. starplot/styles/fonts-library/hind/METADATA.pb +58 -0
  45. starplot/styles/fonts-library/hind/OFL.txt +93 -0
  46. starplot/styles/fonts-library/inter/Inter-Black.ttf +0 -0
  47. starplot/styles/fonts-library/inter/Inter-BlackItalic.ttf +0 -0
  48. starplot/styles/fonts-library/inter/Inter-Bold.ttf +0 -0
  49. starplot/styles/fonts-library/inter/Inter-BoldItalic.ttf +0 -0
  50. starplot/styles/fonts-library/inter/Inter-ExtraBold.ttf +0 -0
  51. starplot/styles/fonts-library/inter/Inter-ExtraBoldItalic.ttf +0 -0
  52. starplot/styles/fonts-library/inter/Inter-ExtraLight.ttf +0 -0
  53. starplot/styles/fonts-library/inter/Inter-ExtraLightItalic.ttf +0 -0
  54. starplot/styles/fonts-library/inter/Inter-Italic.ttf +0 -0
  55. starplot/styles/fonts-library/inter/Inter-Light.ttf +0 -0
  56. starplot/styles/fonts-library/inter/Inter-LightItalic.ttf +0 -0
  57. starplot/styles/fonts-library/inter/Inter-Medium.ttf +0 -0
  58. starplot/styles/fonts-library/inter/Inter-MediumItalic.ttf +0 -0
  59. starplot/styles/fonts-library/inter/Inter-Regular.ttf +0 -0
  60. starplot/styles/fonts-library/inter/Inter-SemiBold.ttf +0 -0
  61. starplot/styles/fonts-library/inter/Inter-SemiBoldItalic.ttf +0 -0
  62. starplot/styles/fonts-library/inter/Inter-Thin.ttf +0 -0
  63. starplot/styles/fonts-library/inter/Inter-ThinItalic.ttf +0 -0
  64. starplot/styles/fonts-library/inter/LICENSE.txt +92 -0
  65. starplot/styles/fonts.py +15 -0
  66. starplot/styles/markers.py +207 -6
  67. starplot/utils.py +19 -0
  68. starplot/warnings.py +16 -0
  69. {starplot-0.12.5.dist-info → starplot-0.14.0.dist-info}/METADATA +12 -12
  70. starplot-0.14.0.dist-info/RECORD +107 -0
  71. starplot-0.12.5.dist-info/RECORD +0 -67
  72. {starplot-0.12.5.dist-info → starplot-0.14.0.dist-info}/LICENSE +0 -0
  73. {starplot-0.12.5.dist-info → starplot-0.14.0.dist-info}/WHEEL +0 -0
starplot/base.py CHANGED
@@ -1,22 +1,24 @@
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
6
 
6
7
  import numpy as np
7
8
  import rtree
8
- from adjustText import adjust_text as _adjust_text
9
9
  from matplotlib import patches
10
10
  from matplotlib import pyplot as plt, patheffects
11
11
  from matplotlib.lines import Line2D
12
12
  from pytz import timezone
13
- from shapely import Polygon
13
+ from shapely import Polygon, Point
14
14
 
15
- from starplot import geod, models
15
+ from starplot.coordinates import CoordinateSystem
16
+ from starplot import geod, models, warnings
16
17
  from starplot.data import load, ecliptic
17
18
  from starplot.models.planet import PlanetName, PLANET_LABELS_DEFAULT
18
19
  from starplot.models.moon import MoonPhase
19
20
  from starplot.styles import (
21
+ AnchorPointEnum,
20
22
  PlotStyle,
21
23
  MarkerStyle,
22
24
  ObjectStyle,
@@ -27,8 +29,14 @@ from starplot.styles import (
27
29
  MarkerSymbolEnum,
28
30
  PathStyle,
29
31
  PolygonStyle,
32
+ fonts,
30
33
  )
31
34
  from starplot.styles.helpers import use_style
35
+ from starplot.geometry import (
36
+ unwrap_polygon,
37
+ random_point_in_polygon_at_distance,
38
+ )
39
+
32
40
 
33
41
  LOGGER = logging.getLogger("starplot")
34
42
  LOG_HANDLER = logging.StreamHandler()
@@ -46,35 +54,53 @@ DEFAULT_FOV_STYLE = PolygonStyle(
46
54
 
47
55
  DEFAULT_STYLE = PlotStyle()
48
56
 
57
+ DEFAULT_RESOLUTION = 4096
58
+
59
+ DPI = 100
60
+
49
61
 
50
62
  class BasePlot(ABC):
51
63
  _background_clip_path = None
64
+ _coordinate_system = CoordinateSystem.RA_DEC
52
65
 
53
66
  def __init__(
54
67
  self,
55
68
  dt: datetime = None,
56
69
  ephemeris: str = "de421_2001.bsp",
57
70
  style: PlotStyle = DEFAULT_STYLE,
58
- resolution: int = 2048,
71
+ resolution: int = 4096,
59
72
  hide_colliding_labels: bool = True,
73
+ scale: float = 1.0,
74
+ autoscale: bool = False,
75
+ suppress_warnings: bool = True,
60
76
  *args,
61
77
  **kwargs,
62
78
  ):
63
- px = 1 / plt.rcParams["figure.dpi"] # pixel in inches
64
-
65
- self.pixels_per_point = plt.rcParams["figure.dpi"] / 72
79
+ px = 1 / DPI # plt.rcParams["figure.dpi"] # pixel in inches
80
+ self.pixels_per_point = DPI / 72
66
81
 
67
82
  self.style = style
68
83
  self.figure_size = resolution * px
69
84
  self.resolution = resolution
70
85
  self.hide_colliding_labels = hide_colliding_labels
71
86
 
87
+ self.scale = scale
88
+ self.autoscale = autoscale
89
+ if self.autoscale:
90
+ self.scale = self.resolution / DEFAULT_RESOLUTION
91
+
92
+ if suppress_warnings:
93
+ warnings.suppress()
94
+
72
95
  self.dt = dt or timezone("UTC").localize(datetime.now())
73
96
  self._ephemeris_name = ephemeris
74
97
  self.ephemeris = load(ephemeris)
75
98
 
76
99
  self.labels = []
77
100
  self._labels_rtree = rtree.index.Index()
101
+ self._constellations_rtree = rtree.index.Index()
102
+ self._stars_rtree = rtree.index.Index()
103
+
78
104
  self._background_clip_path = None
79
105
 
80
106
  self._legend = None
@@ -85,13 +111,14 @@ class BasePlot(ABC):
85
111
  self.logger.setLevel(self.log_level)
86
112
 
87
113
  self.text_border = patheffects.withStroke(
88
- linewidth=self.style.text_border_width,
114
+ linewidth=self.style.text_border_width * self.scale,
89
115
  foreground=self.style.text_border_color.as_hex(),
90
116
  )
91
- self._size_multiplier = self.resolution / 3000
92
117
  self.timescale = load.timescale().from_datetime(self.dt)
93
118
 
94
119
  self._objects = models.ObjectList()
120
+ self._labeled_stars = []
121
+ fonts.load()
95
122
 
96
123
  def _plot_kwargs(self) -> dict:
97
124
  return {}
@@ -99,44 +126,80 @@ class BasePlot(ABC):
99
126
  def _prepare_coords(self, ra, dec) -> tuple[float, float]:
100
127
  return ra, dec
101
128
 
102
- def _is_label_collision(self, extent) -> bool:
103
- ix = list(
104
- self._labels_rtree.intersection(
105
- (extent.x0, extent.y0, extent.x1, extent.y1)
106
- )
107
- )
129
+ def _is_label_collision(self, bbox) -> bool:
130
+ ix = list(self._labels_rtree.intersection(bbox))
131
+ return len(ix) > 0
132
+
133
+ def _is_constellation_collision(self, bbox) -> bool:
134
+ ix = list(self._constellations_rtree.intersection(bbox))
135
+ return len(ix) > 0
136
+
137
+ def _is_star_collision(self, bbox) -> bool:
138
+ ix = list(self._stars_rtree.intersection(bbox))
108
139
  return len(ix) > 0
109
140
 
110
- def _is_clipped(self, extent) -> bool:
141
+ def _is_clipped(self, points) -> bool:
142
+ radius = -1.5 * int(self._background_clip_path.get_linewidth())
111
143
  return self._background_clip_path is not None and not all(
112
- self._background_clip_path.contains_points(extent.get_points())
144
+ self._background_clip_path.contains_points(points, radius=radius)
113
145
  )
114
146
 
115
- def _maybe_remove_label(self, label) -> None:
147
+ def _add_label_to_rtree(self, label, extent=None):
148
+ extent = extent or label.get_window_extent(
149
+ renderer=self.fig.canvas.get_renderer()
150
+ )
151
+ self.labels.append(label)
152
+ self._labels_rtree.insert(
153
+ 0, np.array((extent.x0 - 1, extent.y0 - 1, extent.x1 + 1, extent.y1 + 1))
154
+ )
155
+
156
+ def _maybe_remove_label(
157
+ self,
158
+ label,
159
+ remove_on_collision=True,
160
+ remove_on_clipped=True,
161
+ remove_on_constellation_collision=True,
162
+ padding=0,
163
+ ) -> bool:
164
+ """Returns true if the label is removed, else false"""
116
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)
117
178
 
118
179
  if any([np.isnan(c) for c in (extent.x0, extent.y0, extent.x1, extent.y1)]):
119
180
  label.remove()
120
- return
181
+ return True
121
182
 
122
- if any(
123
- [
124
- self._is_clipped(extent),
125
- self.hide_colliding_labels and self._is_label_collision(extent),
126
- ]
183
+ if remove_on_clipped and self._is_clipped(points):
184
+ label.remove()
185
+ return True
186
+
187
+ if remove_on_collision and (
188
+ self._is_label_collision(bbox) or self._is_star_collision(bbox)
127
189
  ):
128
190
  label.remove()
129
- return
191
+ return True
130
192
 
131
- self.labels.append(label)
132
- self._labels_rtree.insert(
133
- 0, np.array((extent.x0, extent.y0, extent.x1, extent.y1))
134
- )
193
+ if remove_on_constellation_collision and self._is_constellation_collision(bbox):
194
+ label.remove()
195
+ return True
196
+
197
+ return False
135
198
 
136
199
  def _add_legend_handle_marker(self, label: str, style: MarkerStyle):
137
200
  if label is not None and label not in self._legend_handles:
138
201
  s = style.matplot_kwargs()
139
- s["markersize"] = self.style.legend.symbol_size * self._size_multiplier
202
+ s["markersize"] = self.style.legend.symbol_size * self.scale
140
203
  self._legend_handles[label] = Line2D(
141
204
  [],
142
205
  [],
@@ -146,35 +209,189 @@ class BasePlot(ABC):
146
209
  label=label,
147
210
  )
148
211
 
149
- def _text(
212
+ def _collision_score(self, label) -> int:
213
+ config = {
214
+ "labels": 1.0, # always fail
215
+ "stars": 0.5,
216
+ "constellations": 0.8,
217
+ "anchors": [
218
+ ("bottom right", 0),
219
+ ("top right", 0.2),
220
+ ("top left", 0.5),
221
+ ],
222
+ "on_fail": "plot",
223
+ }
224
+ extent = label.get_window_extent(renderer=self.fig.canvas.get_renderer())
225
+
226
+ if any(
227
+ [np.isnan(c) for c in (extent.x0, extent.y0, extent.x1, extent.y1)]
228
+ ) or self._is_clipped(extent):
229
+ return 1
230
+
231
+ x_labels = (
232
+ len(
233
+ list(
234
+ self._labels_rtree.intersection(
235
+ (extent.x0, extent.y0, extent.x1, extent.y1)
236
+ )
237
+ )
238
+ )
239
+ * config["labels"]
240
+ )
241
+
242
+ if x_labels >= 1:
243
+ return 1
244
+
245
+ x_constellations = (
246
+ len(
247
+ list(
248
+ self._constellations_rtree.intersection(
249
+ (extent.x0, extent.y0, extent.x1, extent.y1)
250
+ )
251
+ )
252
+ )
253
+ * config["constellations"]
254
+ )
255
+
256
+ if x_constellations >= 1:
257
+ return 1
258
+
259
+ x_stars = (
260
+ len(
261
+ list(
262
+ self._stars_rtree.intersection(
263
+ (extent.x0, extent.y0, extent.x1, extent.y1)
264
+ )
265
+ )
266
+ )
267
+ * config["stars"]
268
+ )
269
+ if x_stars >= 1:
270
+ return 1
271
+
272
+ return sum([x_labels, x_constellations, x_stars]) / 3
273
+
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(
150
287
  self,
151
288
  ra: float,
152
289
  dec: float,
153
290
  text: str,
154
291
  hide_on_collision: bool = True,
155
- *args,
292
+ force: bool = False,
293
+ clip_on: bool = True,
156
294
  **kwargs,
157
- ) -> None:
295
+ ):
158
296
  if not text:
159
- return
297
+ return None
160
298
 
161
299
  x, y = self._prepare_coords(ra, dec)
162
300
  kwargs["path_effects"] = kwargs.get("path_effects", [self.text_border])
163
- label = self.ax.annotate(
164
- text,
165
- (x, y),
166
- *args,
167
- **kwargs,
168
- **self._plot_kwargs(),
301
+
302
+ original_va = kwargs.pop("va", None)
303
+ original_ha = kwargs.pop("ha", None)
304
+ original_offset_x, original_offset_y = kwargs.pop("xytext", (0, 0))
305
+
306
+ anchors = [(original_va, original_ha)]
307
+ for a in self.style.text_anchor_fallbacks:
308
+ d = AnchorPointEnum.from_str(a).as_matplot()
309
+ anchors.append((d["va"], d["ha"]))
310
+
311
+ for va, ha in anchors:
312
+ offset_x, offset_y = original_offset_x, original_offset_y
313
+ if original_ha != ha:
314
+ offset_x *= -1
315
+
316
+ if original_va != va:
317
+ offset_y *= -1
318
+
319
+ if ha == "center":
320
+ offset_x = 0
321
+ offset_y = 0
322
+
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
+ )
329
+
330
+ if force or not removed:
331
+ self._add_label_to_rtree(label)
332
+ return label
333
+
334
+ def _text_area(
335
+ self,
336
+ ra: float,
337
+ dec: float,
338
+ text: str,
339
+ area,
340
+ hide_on_collision: bool = True,
341
+ force: bool = False,
342
+ clip_on: bool = True,
343
+ settings: dict = None,
344
+ **kwargs,
345
+ ) -> None:
346
+ kwargs["path_effects"] = kwargs.get("path_effects", [self.text_border])
347
+
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")
355
+
356
+ areas = (
357
+ [p for p in area.geoms] if "MultiPolygon" == str(area.geom_type) else [area]
169
358
  )
170
- if kwargs.get("clip_on") is False:
171
- return
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
+ )
172
376
 
173
- label.set_clip_on(True)
174
- label.set_clip_path(self._background_clip_path)
377
+ if point is None:
378
+ continue
379
+
380
+ x, y = self._prepare_coords(point.x, point.y)
381
+ label = self._text(x, y, text, **kwargs)
382
+ removed = self._maybe_remove_label(
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,
388
+ )
389
+
390
+ # TODO : remove label if not fully inside area?
175
391
 
176
- if hide_on_collision:
177
- self._maybe_remove_label(label)
392
+ if not removed:
393
+ self._add_label_to_rtree(label)
394
+ return label
178
395
 
179
396
  @use_style(LabelStyle)
180
397
  def text(
@@ -184,6 +401,7 @@ class BasePlot(ABC):
184
401
  dec: float,
185
402
  style: LabelStyle = None,
186
403
  hide_on_collision: bool = True,
404
+ **kwargs,
187
405
  ):
188
406
  """
189
407
  Plots text
@@ -195,16 +413,43 @@ class BasePlot(ABC):
195
413
  style: Styling of the text
196
414
  hide_on_collision: If True, then the text will not be plotted if it collides with another label
197
415
  """
416
+ if not text:
417
+ return
418
+
198
419
  style = style or LabelStyle()
199
- self._text(
200
- ra,
201
- dec,
202
- text,
203
- **style.matplot_kwargs(self._size_multiplier),
204
- hide_on_collision=hide_on_collision,
205
- xytext=(style.offset_x, style.offset_y),
206
- textcoords="offset pixels",
207
- )
420
+
421
+ if style.offset_x == "auto":
422
+ style.offset_x = 0
423
+
424
+ if style.offset_y == "auto":
425
+ style.offset_y = 0
426
+
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
+ )
208
453
 
209
454
  @property
210
455
  def objects(self) -> models.ObjectList:
@@ -222,7 +467,7 @@ class BasePlot(ABC):
222
467
  text: Title text to plot
223
468
  style: Styling of the title. If None, then the plot's style (specified when creating the plot) will be used
224
469
  """
225
- style_kwargs = style.matplot_kwargs(self._size_multiplier)
470
+ style_kwargs = style.matplot_kwargs(self.scale)
226
471
  style_kwargs.pop("linespacing", None)
227
472
  style_kwargs["pad"] = style.line_spacing
228
473
  self.ax.set_title(text, **style_kwargs)
@@ -265,25 +510,13 @@ class BasePlot(ABC):
265
510
 
266
511
  self._legend = self.ax.legend(
267
512
  handles=self._legend_handles.values(),
268
- **style.matplot_kwargs(size_multiplier=self._size_multiplier),
513
+ **style.matplot_kwargs(self.scale),
269
514
  **bbox_kwargs,
270
515
  ).set_zorder(
271
516
  # zorder is not a valid kwarg to legend(), so we have to set it afterwards
272
517
  style.zorder
273
518
  )
274
519
 
275
- def adjust_text(self, ensure_inside_axes: bool = False, **kwargs) -> None:
276
- """Adjust all the labels to avoid overlapping. This function uses the [adjustText](https://adjusttext.readthedocs.io/) library.
277
-
278
- Args:
279
- ensure_inside_axes: If True, then labels will be forced to stay within the axes
280
- **kwargs: Any keyword arguments to pass through to [adjustText](https://adjusttext.readthedocs.io/en/latest/#adjustText.adjust_text)
281
-
282
- """
283
- _adjust_text(
284
- self.labels, ax=self.ax, ensure_inside_axes=ensure_inside_axes, **kwargs
285
- )
286
-
287
520
  def close_fig(self) -> None:
288
521
  """Closes the underlying matplotlib figure."""
289
522
  if self.fig:
@@ -299,6 +532,7 @@ class BasePlot(ABC):
299
532
  **kwargs: Any keyword arguments to pass through to matplotlib's `savefig` method
300
533
 
301
534
  """
535
+ self.logger.debug("Exporting...")
302
536
  self.fig.savefig(
303
537
  filename,
304
538
  format=format,
@@ -317,6 +551,7 @@ class BasePlot(ABC):
317
551
  label: Optional[str] = None,
318
552
  legend_label: str = None,
319
553
  skip_bounds_check: bool = False,
554
+ **kwargs,
320
555
  ) -> None:
321
556
  """Plots a marker
322
557
 
@@ -335,18 +570,36 @@ class BasePlot(ABC):
335
570
 
336
571
  x, y = self._prepare_coords(ra, dec)
337
572
 
338
- self.ax.plot(
573
+ self.ax.scatter(
339
574
  x,
340
575
  y,
341
- **style.marker.matplot_kwargs(size_multiplier=self._size_multiplier),
576
+ **style.marker.matplot_scatter_kwargs(self.scale),
342
577
  **self._plot_kwargs(),
343
- linestyle="None",
344
578
  clip_on=True,
345
579
  clip_path=self._background_clip_path,
580
+ gid=kwargs.get("gid_marker") or "marker",
346
581
  )
347
582
 
348
583
  if label:
349
- self.text(label, ra, dec, style.label)
584
+ label_style = style.label
585
+ if label_style.offset_x == "auto" or label_style.offset_y == "auto":
586
+ marker_size = ((style.marker.size / self.scale) ** 2) * (
587
+ self.scale**2
588
+ )
589
+
590
+ label_style = label_style.offset_from_marker(
591
+ marker_symbol=style.marker.symbol,
592
+ marker_size=marker_size,
593
+ scale=self.scale,
594
+ )
595
+ self.text(
596
+ label,
597
+ ra,
598
+ dec,
599
+ label_style,
600
+ hide_on_collision=self.hide_colliding_labels,
601
+ gid=kwargs.get("gid_label") or "marker-label",
602
+ )
350
603
 
351
604
  if legend_label is not None:
352
605
  self._add_legend_handle_marker(legend_label, style.marker)
@@ -386,11 +639,14 @@ class BasePlot(ABC):
386
639
  (p.ra, p.dec),
387
640
  p.apparent_size,
388
641
  polygon_style,
642
+ gid="planet-marker",
389
643
  )
390
644
  self._add_legend_handle_marker(legend_label, style.marker)
391
645
 
392
646
  if label:
393
- self.text(label.upper(), p.ra, p.dec, style.label)
647
+ self.text(
648
+ label.upper(), p.ra, p.dec, style.label, gid="planet-label"
649
+ )
394
650
  else:
395
651
  self.marker(
396
652
  ra=p.ra,
@@ -398,6 +654,8 @@ class BasePlot(ABC):
398
654
  style=style,
399
655
  label=label.upper() if label else None,
400
656
  legend_label=legend_label,
657
+ gid_marker="planet-marker",
658
+ gid_label="planet-label",
401
659
  )
402
660
 
403
661
  @use_style(ObjectStyle, "sun")
@@ -416,7 +674,8 @@ class BasePlot(ABC):
416
674
  Args:
417
675
  style: Styling of the Sun. If None, then the plot's style (specified when creating the plot) will be used
418
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.
419
- 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
420
679
  """
421
680
  s = models.Sun.get(
422
681
  dt=self.dt, lat=self.lat, lon=self.lon, ephemeris=self._ephemeris_name
@@ -438,13 +697,14 @@ class BasePlot(ABC):
438
697
  (s.ra, s.dec),
439
698
  s.apparent_size,
440
699
  style=polygon_style,
700
+ gid="sun-marker",
441
701
  )
442
702
 
443
703
  style.marker.symbol = MarkerSymbolEnum.CIRCLE
444
704
  self._add_legend_handle_marker(legend_label, style.marker)
445
705
 
446
706
  if label:
447
- self.text(label, s.ra, s.dec, style.label)
707
+ self.text(label, s.ra, s.dec, style.label, gid="sun-label")
448
708
 
449
709
  else:
450
710
  self.marker(
@@ -453,6 +713,8 @@ class BasePlot(ABC):
453
713
  style=style,
454
714
  label=label,
455
715
  legend_label=legend_label,
716
+ gid_marker="sun-marker",
717
+ gid_label="sun-label",
456
718
  )
457
719
 
458
720
  @abstractmethod
@@ -491,7 +753,7 @@ class BasePlot(ABC):
491
753
  patch = patches.Polygon(
492
754
  points,
493
755
  # closed=False, # needs to be false for circles at poles?
494
- **style.matplot_kwargs(size_multiplier=self._size_multiplier),
756
+ **style.matplot_kwargs(self.scale),
495
757
  **kwargs,
496
758
  clip_on=True,
497
759
  clip_path=self._background_clip_path,
@@ -504,6 +766,8 @@ class BasePlot(ABC):
504
766
  style: PolygonStyle,
505
767
  points: list = None,
506
768
  geometry: Polygon = None,
769
+ legend_label: str = None,
770
+ **kwargs,
507
771
  ):
508
772
  """
509
773
  Plots a polygon.
@@ -511,9 +775,11 @@ class BasePlot(ABC):
511
775
  Must pass in either `points` **or** `geometry` (but not both).
512
776
 
513
777
  Args:
778
+ style: Style of polygon
514
779
  points: List of polygon points `[(ra, dec), ...]` - **must be in counterclockwise order**
515
780
  geometry: A shapely Polygon. If this value is passed, then the `points` kwarg will be ignored.
516
- style: Style of polygon
781
+ legend_label: Label for this object in the legend
782
+
517
783
  """
518
784
  if points is None and geometry is None:
519
785
  raise ValueError("Must pass points or geometry when plotting polygons.")
@@ -522,7 +788,13 @@ class BasePlot(ABC):
522
788
  points = list(zip(*geometry.exterior.coords.xy))
523
789
 
524
790
  _points = [(ra * 15, dec) for ra, dec in points]
525
- self._polygon(_points, style)
791
+ self._polygon(_points, style, gid=kwargs.get("gid") or "polygon")
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
+ )
526
798
 
527
799
  @use_style(PolygonStyle)
528
800
  def rectangle(
@@ -532,7 +804,7 @@ class BasePlot(ABC):
532
804
  width_degrees: float,
533
805
  style: PolygonStyle,
534
806
  angle: float = 0,
535
- *args,
807
+ legend_label: str = None,
536
808
  **kwargs,
537
809
  ):
538
810
  """Plots a rectangle
@@ -541,8 +813,9 @@ class BasePlot(ABC):
541
813
  center: Center of rectangle (ra, dec)
542
814
  height_degrees: Height of rectangle (degrees)
543
815
  width_degrees: Width of rectangle (degrees)
544
- angle: Angle of rotation clockwise (degrees)
545
816
  style: Style of rectangle
817
+ angle: Angle of rotation clockwise (degrees)
818
+ legend_label: Label for this object in the legend
546
819
  """
547
820
  points = geod.rectangle(
548
821
  center,
@@ -550,7 +823,13 @@ class BasePlot(ABC):
550
823
  width_degrees,
551
824
  angle,
552
825
  )
553
- self._polygon(points, style)
826
+ self._polygon(points, style, gid=kwargs.get("gid") or "polygon")
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
+ )
554
833
 
555
834
  @use_style(PolygonStyle)
556
835
  def ellipse(
@@ -563,6 +842,8 @@ class BasePlot(ABC):
563
842
  num_pts: int = 100,
564
843
  start_angle: int = 0,
565
844
  end_angle: int = 360,
845
+ legend_label: str = None,
846
+ **kwargs,
566
847
  ):
567
848
  """Plots an ellipse
568
849
 
@@ -573,6 +854,9 @@ class BasePlot(ABC):
573
854
  style: Style of ellipse
574
855
  angle: Angle of rotation clockwise (degrees)
575
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
576
860
  """
577
861
 
578
862
  points = geod.ellipse(
@@ -584,7 +868,13 @@ class BasePlot(ABC):
584
868
  start_angle,
585
869
  end_angle,
586
870
  )
587
- self._polygon(points, style)
871
+ self._polygon(points, style, gid=kwargs.get("gid") or "polygon")
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
+ )
588
878
 
589
879
  @use_style(PolygonStyle)
590
880
  def circle(
@@ -593,6 +883,8 @@ class BasePlot(ABC):
593
883
  radius_degrees: float,
594
884
  style: PolygonStyle,
595
885
  num_pts: int = 100,
886
+ legend_label: str = None,
887
+ **kwargs,
596
888
  ):
597
889
  """Plots a circle
598
890
 
@@ -601,6 +893,7 @@ class BasePlot(ABC):
601
893
  radius_degrees: Radius of circle (degrees)
602
894
  style: Style of circle
603
895
  num_pts: Number of points to calculate for the circle polygon
896
+ legend_label: Label for this object in the legend
604
897
  """
605
898
  self.ellipse(
606
899
  center,
@@ -609,10 +902,17 @@ class BasePlot(ABC):
609
902
  style=style,
610
903
  angle=0,
611
904
  num_pts=num_pts,
905
+ gid=kwargs.get("gid") or "polygon",
612
906
  )
613
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
+
614
914
  @use_style(LineStyle)
615
- def line(self, coordinates: list[tuple[float, float]], style: LineStyle):
915
+ def line(self, coordinates: list[tuple[float, float]], style: LineStyle, **kwargs):
616
916
  """Plots a line
617
917
 
618
918
  Args:
@@ -626,7 +926,8 @@ class BasePlot(ABC):
626
926
  y,
627
927
  clip_on=True,
628
928
  clip_path=self._background_clip_path,
629
- **style.matplot_kwargs(self._size_multiplier),
929
+ gid=kwargs.get("gid") or "line",
930
+ **style.matplot_kwargs(self.scale),
630
931
  **self._plot_kwargs(),
631
932
  )
632
933
 
@@ -680,13 +981,14 @@ class BasePlot(ABC):
680
981
  (m.ra, m.dec),
681
982
  m.apparent_size,
682
983
  style=polygon_style,
984
+ gid="moon-marker",
683
985
  )
684
986
 
685
987
  style.marker.symbol = MarkerSymbolEnum.CIRCLE
686
988
  self._add_legend_handle_marker(legend_label, style.marker)
687
989
 
688
990
  if label:
689
- self.text(label, m.ra, m.dec, style.label)
991
+ self.text(label, m.ra, m.dec, style.label, gid="moon-label")
690
992
 
691
993
  else:
692
994
  self.marker(
@@ -695,6 +997,8 @@ class BasePlot(ABC):
695
997
  style=style,
696
998
  label=label,
697
999
  legend_label=legend_label,
1000
+ gid_marker="moon-marker",
1001
+ gid_label="moon-label",
698
1002
  )
699
1003
 
700
1004
  def _moon_with_phase(
@@ -761,6 +1065,7 @@ class BasePlot(ABC):
761
1065
  num_pts=num_pts,
762
1066
  angle=0,
763
1067
  end_angle=180, # plot as a semicircle
1068
+ gid="moon-marker",
764
1069
  )
765
1070
  # Plot right side
766
1071
  self.ellipse(
@@ -771,6 +1076,7 @@ class BasePlot(ABC):
771
1076
  num_pts=num_pts,
772
1077
  angle=180,
773
1078
  end_angle=180, # plot as a semicircle
1079
+ gid="moon-marker",
774
1080
  )
775
1081
  # Plot middle
776
1082
  self.ellipse(
@@ -778,6 +1084,7 @@ class BasePlot(ABC):
778
1084
  radius_degrees * 2,
779
1085
  radius_degrees,
780
1086
  style=middle,
1087
+ gid="moon-marker",
781
1088
  )
782
1089
 
783
1090
  def _fov_circle(
@@ -860,17 +1167,16 @@ class BasePlot(ABC):
860
1167
  y,
861
1168
  dash_capstyle=style.line.dash_capstyle,
862
1169
  clip_path=self._background_clip_path,
863
- **style.line.matplot_kwargs(self._size_multiplier),
1170
+ gid="ecliptic-line",
1171
+ **style.line.matplot_kwargs(self.scale),
864
1172
  **self._plot_kwargs(),
865
1173
  )
866
1174
 
867
- if label:
868
- if len(inbounds) > 4:
869
- label_spacing = int(len(inbounds) / 3) or 1
1175
+ if label and len(inbounds) > 4:
1176
+ label_spacing = int(len(inbounds) / 4)
870
1177
 
871
- for i in range(0, len(inbounds), label_spacing):
872
- ra, dec = inbounds[i]
873
- self.text(label, ra, dec, style.label)
1178
+ for ra, dec in [inbounds[label_spacing], inbounds[label_spacing * 2]]:
1179
+ self.text(label, ra, dec, style.label, gid="ecliptic-label")
874
1180
 
875
1181
  @use_style(PathStyle, "celestial_equator")
876
1182
  def celestial_equator(
@@ -897,11 +1203,18 @@ class BasePlot(ABC):
897
1203
  x,
898
1204
  y,
899
1205
  clip_path=self._background_clip_path,
900
- **style.line.matplot_kwargs(self._size_multiplier),
1206
+ gid="celestial-equator-line",
1207
+ **style.line.matplot_kwargs(self.scale),
901
1208
  **self._plot_kwargs(),
902
1209
  )
903
1210
 
904
1211
  if label:
905
1212
  label_spacing = (self.ra_max - self.ra_min) / 3
906
1213
  for ra in np.arange(self.ra_min, self.ra_max, label_spacing):
907
- self.text(label, ra, 0.25, style.label)
1214
+ self.text(
1215
+ label,
1216
+ ra,
1217
+ 0.25,
1218
+ style.label,
1219
+ gid="celestial-equator-label",
1220
+ )