starplot 0.13.0__py2.py3-none-any.whl → 0.15.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.
Files changed (74) hide show
  1. starplot/__init__.py +5 -2
  2. starplot/base.py +311 -197
  3. starplot/cli.py +33 -0
  4. starplot/coordinates.py +6 -0
  5. starplot/data/__init__.py +6 -24
  6. starplot/data/bigsky.py +58 -40
  7. starplot/data/constellation_lines.py +827 -0
  8. starplot/data/constellation_stars.py +1501 -0
  9. starplot/data/constellations.py +600 -27
  10. starplot/data/db.py +17 -0
  11. starplot/data/dsos.py +24 -141
  12. starplot/data/stars.py +45 -24
  13. starplot/geod.py +0 -6
  14. starplot/geometry.py +181 -0
  15. starplot/horizon.py +302 -231
  16. starplot/map.py +100 -463
  17. starplot/mixins.py +75 -14
  18. starplot/models/__init__.py +1 -1
  19. starplot/models/base.py +18 -129
  20. starplot/models/constellation.py +55 -32
  21. starplot/models/dso.py +132 -67
  22. starplot/models/moon.py +57 -78
  23. starplot/models/planet.py +44 -69
  24. starplot/models/star.py +91 -60
  25. starplot/models/sun.py +32 -53
  26. starplot/optic.py +21 -18
  27. starplot/plotters/__init__.py +2 -0
  28. starplot/plotters/constellations.py +342 -0
  29. starplot/plotters/dsos.py +49 -68
  30. starplot/plotters/experimental.py +171 -0
  31. starplot/plotters/milkyway.py +39 -0
  32. starplot/plotters/stars.py +126 -122
  33. starplot/profile.py +16 -0
  34. starplot/settings.py +26 -0
  35. starplot/styles/__init__.py +2 -0
  36. starplot/styles/base.py +56 -34
  37. starplot/styles/ext/antique.yml +11 -9
  38. starplot/styles/ext/blue_dark.yml +8 -10
  39. starplot/styles/ext/blue_gold.yml +135 -0
  40. starplot/styles/ext/blue_light.yml +14 -12
  41. starplot/styles/ext/blue_medium.yml +23 -20
  42. starplot/styles/ext/cb_wong.yml +9 -7
  43. starplot/styles/ext/grayscale.yml +4 -3
  44. starplot/styles/ext/grayscale_dark.yml +7 -5
  45. starplot/styles/ext/map.yml +9 -6
  46. starplot/styles/ext/nord.yml +7 -7
  47. starplot/styles/ext/optic.yml +1 -1
  48. starplot/styles/extensions.py +1 -0
  49. starplot/utils.py +19 -0
  50. starplot/warnings.py +21 -0
  51. {starplot-0.13.0.dist-info → starplot-0.15.0.dist-info}/METADATA +19 -18
  52. starplot-0.15.0.dist-info/RECORD +97 -0
  53. starplot-0.15.0.dist-info/entry_points.txt +3 -0
  54. starplot/data/bayer.py +0 -3499
  55. starplot/data/flamsteed.py +0 -2682
  56. starplot/data/library/constellation_borders_inv.gpkg +0 -0
  57. starplot/data/library/constellation_lines_hips.json +0 -709
  58. starplot/data/library/constellation_lines_inv.gpkg +0 -0
  59. starplot/data/library/constellations.gpkg +0 -0
  60. starplot/data/library/constellations_hip.fab +0 -88
  61. starplot/data/library/milkyway.gpkg +0 -0
  62. starplot/data/library/milkyway_inv.gpkg +0 -0
  63. starplot/data/library/ongc.gpkg.zip +0 -0
  64. starplot/data/library/stars.bigsky.mag11.parquet +0 -0
  65. starplot/data/library/stars.hipparcos.parquet +0 -0
  66. starplot/data/messier.py +0 -111
  67. starplot/data/prep/__init__.py +0 -0
  68. starplot/data/prep/constellations.py +0 -108
  69. starplot/data/prep/dsos.py +0 -299
  70. starplot/data/prep/utils.py +0 -16
  71. starplot/models/geometry.py +0 -44
  72. starplot-0.13.0.dist-info/RECORD +0 -101
  73. {starplot-0.13.0.dist-info → starplot-0.15.0.dist-info}/LICENSE +0 -0
  74. {starplot-0.13.0.dist-info → starplot-0.15.0.dist-info}/WHEEL +0 -0
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,12 +32,11 @@ 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_360,
37
+ random_point_in_polygon_at_distance,
40
38
  )
39
+ from starplot.profile import profile
41
40
 
42
41
  LOGGER = logging.getLogger("starplot")
43
42
  LOG_HANDLER = logging.StreamHandler()
@@ -49,7 +48,11 @@ LOGGER.addHandler(LOG_HANDLER)
49
48
 
50
49
 
51
50
  DEFAULT_FOV_STYLE = PolygonStyle(
52
- fill_color=None, edge_color="red", line_style="dashed", edge_width=4, zorder=1000
51
+ fill_color=None,
52
+ edge_color="red",
53
+ line_style=[1, [2, 3]],
54
+ edge_width=3,
55
+ zorder=-1000,
53
56
  )
54
57
  """Default style for plotting scope and bino field of view circles"""
55
58
 
@@ -62,6 +65,8 @@ DPI = 100
62
65
 
63
66
  class BasePlot(ABC):
64
67
  _background_clip_path = None
68
+ _clip_path_polygon: Polygon = None # clip path in display coordinates
69
+ _coordinate_system = CoordinateSystem.RA_DEC
65
70
 
66
71
  def __init__(
67
72
  self,
@@ -72,11 +77,11 @@ class BasePlot(ABC):
72
77
  hide_colliding_labels: bool = True,
73
78
  scale: float = 1.0,
74
79
  autoscale: bool = False,
80
+ suppress_warnings: bool = True,
75
81
  *args,
76
82
  **kwargs,
77
83
  ):
78
84
  px = 1 / DPI # plt.rcParams["figure.dpi"] # pixel in inches
79
-
80
85
  self.pixels_per_point = DPI / 72
81
86
 
82
87
  self.style = style
@@ -89,16 +94,19 @@ class BasePlot(ABC):
89
94
  if self.autoscale:
90
95
  self.scale = self.resolution / DEFAULT_RESOLUTION
91
96
 
97
+ if suppress_warnings:
98
+ warnings.suppress()
99
+
92
100
  self.dt = dt or timezone("UTC").localize(datetime.now())
93
101
  self._ephemeris_name = ephemeris
94
102
  self.ephemeris = load(ephemeris)
103
+ self.earth = self.ephemeris["earth"]
95
104
 
96
105
  self.labels = []
97
106
  self._labels_rtree = rtree.index.Index()
98
-
99
- # self.labels = []
100
107
  self._constellations_rtree = rtree.index.Index()
101
108
  self._stars_rtree = rtree.index.Index()
109
+ self._markers_rtree = rtree.index.Index()
102
110
 
103
111
  self._background_clip_path = None
104
112
 
@@ -125,32 +133,34 @@ class BasePlot(ABC):
125
133
  def _prepare_coords(self, ra, dec) -> tuple[float, float]:
126
134
  return ra, dec
127
135
 
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
- )
136
+ def _update_clip_path_polygon(self, buffer=8):
137
+ coords = self._background_clip_path.get_verts()
138
+ self._clip_path_polygon = Polygon(coords).buffer(-1 * buffer)
139
+
140
+ def _is_label_collision(self, bbox) -> bool:
141
+ ix = list(self._labels_rtree.intersection(bbox))
134
142
  return len(ix) > 0
135
143
 
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
- )
144
+ def _is_constellation_collision(self, bbox) -> bool:
145
+ ix = list(self._constellations_rtree.intersection(bbox))
142
146
  return len(ix) > 0
143
147
 
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
- )
148
+ def _is_star_collision(self, bbox) -> bool:
149
+ ix = list(self._stars_rtree.intersection(bbox))
148
150
  return len(ix) > 0
149
151
 
150
- def _is_clipped(self, extent) -> bool:
151
- return self._background_clip_path is not None and not all(
152
- self._background_clip_path.contains_points(extent.get_points())
153
- )
152
+ def _is_marker_collision(self, bbox) -> bool:
153
+ ix = list(self._markers_rtree.intersection(bbox))
154
+ return len(ix) > 0
155
+
156
+ def _is_clipped(self, points) -> bool:
157
+ p = self._clip_path_polygon
158
+
159
+ for x, y in points:
160
+ if not p.contains(Point(x, y)):
161
+ return True
162
+
163
+ return False
154
164
 
155
165
  def _add_label_to_rtree(self, label, extent=None):
156
166
  extent = extent or label.get_window_extent(
@@ -158,31 +168,94 @@ class BasePlot(ABC):
158
168
  )
159
169
  self.labels.append(label)
160
170
  self._labels_rtree.insert(
161
- 0, np.array((extent.x0, extent.y0, extent.x1, extent.y1))
171
+ 0, np.array((extent.x0 - 1, extent.y0 - 1, extent.x1 + 1, extent.y1 + 1))
162
172
  )
163
173
 
174
+ def _is_open_space(
175
+ self,
176
+ bbox: tuple[float, float, float, float],
177
+ padding=0,
178
+ avoid_clipped=True,
179
+ avoid_label_collisions=True,
180
+ avoid_marker_collisions=True,
181
+ avoid_constellation_collision=True,
182
+ ) -> bool:
183
+ """
184
+ Returns true if the boox covers an open space (i.e. no collisions)
185
+
186
+ Args:
187
+ bbox: 4-element tuple of lower left and upper right coordinates
188
+ """
189
+ x0, y0, x1, y1 = bbox
190
+ points = [(x0, y0), (x1, y1)]
191
+ bbox = (
192
+ x0 - padding,
193
+ y0 - padding,
194
+ x1 + padding,
195
+ y1 + padding,
196
+ )
197
+
198
+ if any([np.isnan(c) for c in (x0, y0, x1, y1)]):
199
+ return False
200
+
201
+ if avoid_clipped and self._is_clipped(points):
202
+ return False
203
+
204
+ if avoid_label_collisions and self._is_label_collision(bbox):
205
+ return False
206
+
207
+ if avoid_marker_collisions and (
208
+ self._is_star_collision(bbox) or self._is_marker_collision(bbox)
209
+ ):
210
+ return False
211
+
212
+ if avoid_constellation_collision and self._is_constellation_collision(bbox):
213
+ return False
214
+
215
+ return True
216
+
217
+ def _get_label_bbox(self, label):
218
+ extent = label.get_window_extent(renderer=self.fig.canvas.get_renderer())
219
+ return (extent.x0, extent.y0, extent.x1, extent.y1)
220
+
164
221
  def _maybe_remove_label(
165
- self, label, remove_on_collision=True, remove_on_clipped=True
222
+ self,
223
+ label,
224
+ remove_on_collision=True,
225
+ remove_on_clipped=True,
226
+ remove_on_constellation_collision=True,
227
+ padding=0,
166
228
  ) -> bool:
167
229
  """Returns true if the label is removed, else false"""
168
230
  extent = label.get_window_extent(renderer=self.fig.canvas.get_renderer())
231
+ bbox = (
232
+ extent.x0 - padding,
233
+ extent.y0 - padding,
234
+ extent.x1 + padding,
235
+ extent.y1 + padding,
236
+ )
237
+ points = [(extent.x0, extent.y0), (extent.x1, extent.y1)]
169
238
 
170
239
  if any([np.isnan(c) for c in (extent.x0, extent.y0, extent.x1, extent.y1)]):
171
240
  label.remove()
172
241
  return True
173
242
 
174
- if remove_on_clipped and self._is_clipped(extent):
243
+ if remove_on_clipped and self._is_clipped(points):
175
244
  label.remove()
176
245
  return True
177
246
 
178
247
  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)
248
+ self._is_label_collision(bbox)
249
+ or self._is_star_collision(bbox)
250
+ or self._is_marker_collision(bbox)
182
251
  ):
183
252
  label.remove()
184
253
  return True
185
254
 
255
+ if remove_on_constellation_collision and self._is_constellation_collision(bbox):
256
+ label.remove()
257
+ return True
258
+
186
259
  return False
187
260
 
188
261
  def _add_legend_handle_marker(self, label: str, style: MarkerStyle):
@@ -260,70 +333,44 @@ class BasePlot(ABC):
260
333
 
261
334
  return sum([x_labels, x_constellations, x_stars]) / 3
262
335
 
263
- def _text_experimental(
336
+ def _text(self, x, y, text, **kwargs):
337
+ label = self.ax.annotate(
338
+ text,
339
+ (x, y),
340
+ **kwargs,
341
+ **self._plot_kwargs(),
342
+ )
343
+ if kwargs.get("clip_on"):
344
+ label.set_clip_on(True)
345
+ label.set_clip_path(self._background_clip_path)
346
+ return label
347
+
348
+ def _text_point(
264
349
  self,
265
350
  ra: float,
266
351
  dec: float,
267
352
  text: str,
268
353
  hide_on_collision: bool = True,
269
- auto_anchor: bool = True,
270
- *args,
354
+ force: bool = False,
355
+ clip_on: bool = True,
271
356
  **kwargs,
272
- ) -> None:
357
+ ):
273
358
  if not text:
274
- return
359
+ return None
275
360
 
276
361
  x, y = self._prepare_coords(ra, dec)
277
362
  kwargs["path_effects"] = kwargs.get("path_effects", [self.text_border])
278
- clip_on = kwargs.get("clip_on") or True
279
363
 
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
-
319
- collision_scores = []
320
364
  original_va = kwargs.pop("va", None)
321
365
  original_ha = kwargs.pop("ha", None)
322
366
  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):
367
+
368
+ anchors = [(original_va, original_ha)]
369
+ for a in self.style.text_anchor_fallbacks:
325
370
  d = AnchorPointEnum.from_str(a).as_matplot()
326
- va, ha = d["va"], d["ha"]
371
+ anchors.append((d["va"], d["ha"]))
372
+
373
+ for va, ha in anchors:
327
374
  offset_x, offset_y = original_offset_x, original_offset_y
328
375
  if original_ha != ha:
329
376
  offset_x *= -1
@@ -335,100 +382,112 @@ class BasePlot(ABC):
335
382
  offset_x = 0
336
383
  offset_y = 0
337
384
 
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
385
+ label = self._text(
386
+ x, y, text, **kwargs, va=va, ha=ha, xytext=(offset_x, offset_y)
387
+ )
388
+ removed = self._maybe_remove_label(
389
+ label, remove_on_collision=hide_on_collision, remove_on_clipped=clip_on
390
+ )
348
391
 
349
- if collision < 1:
350
- collision_scores.append((collision, pt_kwargs))
392
+ if force or not removed:
393
+ self._add_label_to_rtree(label)
394
+ return label
351
395
 
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(
396
+ def _text_area(
364
397
  self,
365
398
  ra: float,
366
399
  dec: float,
367
400
  text: str,
401
+ area,
368
402
  hide_on_collision: bool = True,
369
403
  force: bool = False,
370
404
  clip_on: bool = True,
371
- *args,
405
+ settings: dict = None,
372
406
  **kwargs,
373
407
  ) -> None:
374
- if not text:
375
- return
376
-
377
- x, y = self._prepare_coords(ra, dec)
378
408
  kwargs["path_effects"] = kwargs.get("path_effects", [self.text_border])
379
-
380
- def plot_text(**kwargs):
381
- label = self.ax.annotate(
382
- text,
383
- (x, y),
384
- *args,
385
- **kwargs,
386
- **self._plot_kwargs(),
409
+ kwargs["va"] = "center"
410
+ kwargs["ha"] = "center"
411
+
412
+ avoid_constellation_lines = settings.get("avoid_constellation_lines", False)
413
+ padding = settings.get("label_padding", 3)
414
+ settings.get("buffer", 0.1)
415
+ max_distance = settings.get("max_distance", 300)
416
+ distance_step_size = settings.get("distance_step_size", 1)
417
+ point_iterations = settings.get("point_generation_max_iterations", 500)
418
+ random_seed = settings.get("seed")
419
+
420
+ attempts = 0
421
+ height = None
422
+ width = None
423
+ bbox = None
424
+ areas = (
425
+ [p for p in area.geoms] if "MultiPolygon" == str(area.geom_type) else [area]
426
+ )
427
+ new_areas = []
428
+ origin = Point(ra, dec)
429
+
430
+ for a in areas:
431
+ unwrapped = unwrap_polygon_360(a)
432
+ # new_buffer = unwrapped.area / 10 * -1 * buffer * self.scale
433
+ # new_buffer = -1 * buffer * self.scale
434
+ # new_poly = unwrapped.buffer(new_buffer)
435
+ new_areas.append(unwrapped)
436
+
437
+ for d in range(0, max_distance, distance_step_size):
438
+ distance = d / 20
439
+ poly = randrange(len(new_areas))
440
+ point = random_point_in_polygon_at_distance(
441
+ new_areas[poly],
442
+ origin_point=origin,
443
+ distance=distance,
444
+ max_iterations=point_iterations,
445
+ seed=random_seed,
387
446
  )
388
- if clip_on:
389
- label.set_clip_on(True)
390
- label.set_clip_path(self._background_clip_path)
391
- return label
392
447
 
393
- label = plot_text(**kwargs)
448
+ if point is None:
449
+ continue
394
450
 
395
- if force:
396
- return
451
+ x, y = self._prepare_coords(point.x, point.y)
397
452
 
398
- removed = self._maybe_remove_label(
399
- label, remove_on_collision=hide_on_collision, remove_on_clipped=clip_on
400
- )
453
+ if height and width:
454
+ data_xy = self._proj.transform_point(x, y, self._crs)
455
+ display_x, display_y = self.ax.transData.transform(data_xy)
456
+ bbox = (
457
+ display_x - width / 2,
458
+ display_y - height / 2,
459
+ display_x + width / 2,
460
+ display_y + height / 2,
461
+ )
462
+ label = None
401
463
 
402
- if not removed:
403
- self._add_label_to_rtree(label)
404
- return
464
+ else:
465
+ label = self._text(x, y, text, **kwargs)
466
+ bbox = self._get_label_bbox(label)
467
+ height = bbox[3] - bbox[1]
468
+ width = bbox[2] - bbox[0]
469
+
470
+ is_open = self._is_open_space(
471
+ bbox,
472
+ padding=padding,
473
+ avoid_clipped=clip_on,
474
+ avoid_constellation_collision=avoid_constellation_lines,
475
+ avoid_marker_collisions=hide_on_collision,
476
+ avoid_label_collisions=hide_on_collision,
477
+ )
405
478
 
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
479
+ # # TODO : remove label if not fully inside area?
416
480
 
417
- if original_va != va:
418
- offset_y *= -1
419
-
420
- if ha == "center":
421
- offset_x = 0
422
- offset_y = 0
481
+ attempts += 1
423
482
 
424
- label = plot_text(**kwargs, va=va, ha=ha, xytext=(offset_x, offset_y))
425
- removed = self._maybe_remove_label(
426
- label, remove_on_collision=hide_on_collision, remove_on_clipped=clip_on
427
- )
483
+ if is_open and label is None:
484
+ label = self._text(x, y, text, **kwargs)
428
485
 
429
- if not removed:
486
+ if is_open:
430
487
  self._add_label_to_rtree(label)
431
- break
488
+ return label
489
+ elif label is not None:
490
+ label.remove()
432
491
 
433
492
  @use_style(LabelStyle)
434
493
  def text(
@@ -438,7 +497,6 @@ class BasePlot(ABC):
438
497
  dec: float,
439
498
  style: LabelStyle = None,
440
499
  hide_on_collision: bool = True,
441
- *args,
442
500
  **kwargs,
443
501
  ):
444
502
  """
@@ -451,7 +509,7 @@ class BasePlot(ABC):
451
509
  style: Styling of the text
452
510
  hide_on_collision: If True, then the text will not be plotted if it collides with another label
453
511
  """
454
- if not self.in_bounds(ra, dec):
512
+ if not text:
455
513
  return
456
514
 
457
515
  style = style or LabelStyle()
@@ -462,17 +520,32 @@ class BasePlot(ABC):
462
520
  if style.offset_y == "auto":
463
521
  style.offset_y = 0
464
522
 
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
- )
523
+ if kwargs.get("area"):
524
+ return self._text_area(
525
+ ra,
526
+ dec,
527
+ text,
528
+ **style.matplot_kwargs(self.scale),
529
+ area=kwargs.pop("area"),
530
+ hide_on_collision=hide_on_collision,
531
+ xycoords="data",
532
+ xytext=(style.offset_x * self.scale, style.offset_y * self.scale),
533
+ textcoords="offset points",
534
+ settings=kwargs.pop("auto_adjust_settings"),
535
+ **kwargs,
536
+ )
537
+ else:
538
+ return self._text_point(
539
+ ra,
540
+ dec,
541
+ text,
542
+ **style.matplot_kwargs(self.scale),
543
+ hide_on_collision=hide_on_collision,
544
+ xycoords="data",
545
+ xytext=(style.offset_x * self.scale, style.offset_y * self.scale),
546
+ textcoords="offset points",
547
+ **kwargs,
548
+ )
476
549
 
477
550
  @property
478
551
  def objects(self) -> models.ObjectList:
@@ -540,23 +613,12 @@ class BasePlot(ABC):
540
613
  style.zorder
541
614
  )
542
615
 
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
616
  def close_fig(self) -> None:
556
617
  """Closes the underlying matplotlib figure."""
557
618
  if self.fig:
558
619
  plt.close(self.fig)
559
620
 
621
+ @profile
560
622
  def export(self, filename: str, format: str = "png", padding: float = 0, **kwargs):
561
623
  """Exports the plot to an image file.
562
624
 
@@ -567,6 +629,7 @@ class BasePlot(ABC):
567
629
  **kwargs: Any keyword arguments to pass through to matplotlib's `savefig` method
568
630
 
569
631
  """
632
+ self.logger.debug("Exporting...")
570
633
  self.fig.savefig(
571
634
  filename,
572
635
  format=format,
@@ -602,18 +665,35 @@ class BasePlot(ABC):
602
665
  if not skip_bounds_check and not self.in_bounds(ra, dec):
603
666
  return
604
667
 
668
+ # Plot marker
605
669
  x, y = self._prepare_coords(ra, dec)
606
-
670
+ style_kwargs = style.marker.matplot_scatter_kwargs(self.scale)
607
671
  self.ax.scatter(
608
672
  x,
609
673
  y,
610
- **style.marker.matplot_scatter_kwargs(self.scale),
674
+ **style_kwargs,
611
675
  **self._plot_kwargs(),
612
676
  clip_on=True,
613
677
  clip_path=self._background_clip_path,
614
678
  gid=kwargs.get("gid_marker") or "marker",
615
679
  )
616
680
 
681
+ # Add to spatial index
682
+ data_xy = self._proj.transform_point(x, y, self._crs)
683
+ display_x, display_y = self.ax.transData.transform(data_xy)
684
+ if display_x > 0 and display_y > 0:
685
+ radius = style_kwargs.get("s", 1) ** 0.5 / 5
686
+ bbox = np.array(
687
+ (
688
+ display_x - radius,
689
+ display_y - radius,
690
+ display_x + radius,
691
+ display_y + radius,
692
+ )
693
+ )
694
+ self._markers_rtree.insert(0, bbox, None)
695
+
696
+ # Plot label
617
697
  if label:
618
698
  label_style = style.label
619
699
  if label_style.offset_x == "auto" or label_style.offset_y == "auto":
@@ -708,7 +788,8 @@ class BasePlot(ABC):
708
788
  Args:
709
789
  style: Styling of the Sun. If None, then the plot's style (specified when creating the plot) will be used
710
790
  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
791
+ label: How the Sun will be labeled on the plot
792
+ legend_label: How the sun will be labeled in the legend
712
793
  """
713
794
  s = models.Sun.get(
714
795
  dt=self.dt, lat=self.lat, lon=self.lon, ephemeris=self._ephemeris_name
@@ -781,7 +862,6 @@ class BasePlot(ABC):
781
862
  raise NotImplementedError
782
863
 
783
864
  def _polygon(self, points: list, style: PolygonStyle, **kwargs):
784
- points = [geod.to_radec(p) for p in points]
785
865
  points = [self._prepare_coords(*p) for p in points]
786
866
  patch = patches.Polygon(
787
867
  points,
@@ -799,6 +879,7 @@ class BasePlot(ABC):
799
879
  style: PolygonStyle,
800
880
  points: list = None,
801
881
  geometry: Polygon = None,
882
+ legend_label: str = None,
802
883
  **kwargs,
803
884
  ):
804
885
  """
@@ -807,9 +888,11 @@ class BasePlot(ABC):
807
888
  Must pass in either `points` **or** `geometry` (but not both).
808
889
 
809
890
  Args:
891
+ style: Style of polygon
810
892
  points: List of polygon points `[(ra, dec), ...]` - **must be in counterclockwise order**
811
893
  geometry: A shapely Polygon. If this value is passed, then the `points` kwarg will be ignored.
812
- style: Style of polygon
894
+ legend_label: Label for this object in the legend
895
+
813
896
  """
814
897
  if points is None and geometry is None:
815
898
  raise ValueError("Must pass points or geometry when plotting polygons.")
@@ -817,8 +900,13 @@ class BasePlot(ABC):
817
900
  if geometry is not None:
818
901
  points = list(zip(*geometry.exterior.coords.xy))
819
902
 
820
- _points = [(ra * 15, dec) for ra, dec in points]
821
- self._polygon(_points, style, gid=kwargs.get("gid") or "polygon")
903
+ self._polygon(points, style, gid=kwargs.get("gid") or "polygon")
904
+
905
+ if legend_label is not None:
906
+ self._add_legend_handle_marker(
907
+ legend_label,
908
+ style=style.to_marker_style(symbol=MarkerSymbolEnum.SQUARE),
909
+ )
822
910
 
823
911
  @use_style(PolygonStyle)
824
912
  def rectangle(
@@ -828,6 +916,7 @@ class BasePlot(ABC):
828
916
  width_degrees: float,
829
917
  style: PolygonStyle,
830
918
  angle: float = 0,
919
+ legend_label: str = None,
831
920
  **kwargs,
832
921
  ):
833
922
  """Plots a rectangle
@@ -836,8 +925,9 @@ class BasePlot(ABC):
836
925
  center: Center of rectangle (ra, dec)
837
926
  height_degrees: Height of rectangle (degrees)
838
927
  width_degrees: Width of rectangle (degrees)
839
- angle: Angle of rotation clockwise (degrees)
840
928
  style: Style of rectangle
929
+ angle: Angle of rotation clockwise (degrees)
930
+ legend_label: Label for this object in the legend
841
931
  """
842
932
  points = geod.rectangle(
843
933
  center,
@@ -847,6 +937,12 @@ class BasePlot(ABC):
847
937
  )
848
938
  self._polygon(points, style, gid=kwargs.get("gid") or "polygon")
849
939
 
940
+ if legend_label is not None:
941
+ self._add_legend_handle_marker(
942
+ legend_label,
943
+ style=style.to_marker_style(symbol=MarkerSymbolEnum.SQUARE),
944
+ )
945
+
850
946
  @use_style(PolygonStyle)
851
947
  def ellipse(
852
948
  self,
@@ -858,6 +954,7 @@ class BasePlot(ABC):
858
954
  num_pts: int = 100,
859
955
  start_angle: int = 0,
860
956
  end_angle: int = 360,
957
+ legend_label: str = None,
861
958
  **kwargs,
862
959
  ):
863
960
  """Plots an ellipse
@@ -869,6 +966,9 @@ class BasePlot(ABC):
869
966
  style: Style of ellipse
870
967
  angle: Angle of rotation clockwise (degrees)
871
968
  num_pts: Number of points to calculate for the ellipse polygon
969
+ start_angle: Angle to start at
970
+ end_angle: Angle to end at
971
+ legend_label: Label for this object in the legend
872
972
  """
873
973
 
874
974
  points = geod.ellipse(
@@ -882,6 +982,12 @@ class BasePlot(ABC):
882
982
  )
883
983
  self._polygon(points, style, gid=kwargs.get("gid") or "polygon")
884
984
 
985
+ if legend_label is not None:
986
+ self._add_legend_handle_marker(
987
+ legend_label,
988
+ style=style.to_marker_style(symbol=MarkerSymbolEnum.ELLIPSE),
989
+ )
990
+
885
991
  @use_style(PolygonStyle)
886
992
  def circle(
887
993
  self,
@@ -889,6 +995,7 @@ class BasePlot(ABC):
889
995
  radius_degrees: float,
890
996
  style: PolygonStyle,
891
997
  num_pts: int = 100,
998
+ legend_label: str = None,
892
999
  **kwargs,
893
1000
  ):
894
1001
  """Plots a circle
@@ -898,6 +1005,7 @@ class BasePlot(ABC):
898
1005
  radius_degrees: Radius of circle (degrees)
899
1006
  style: Style of circle
900
1007
  num_pts: Number of points to calculate for the circle polygon
1008
+ legend_label: Label for this object in the legend
901
1009
  """
902
1010
  self.ellipse(
903
1011
  center,
@@ -909,6 +1017,12 @@ class BasePlot(ABC):
909
1017
  gid=kwargs.get("gid") or "polygon",
910
1018
  )
911
1019
 
1020
+ if legend_label is not None:
1021
+ self._add_legend_handle_marker(
1022
+ legend_label,
1023
+ style=style.to_marker_style(symbol=MarkerSymbolEnum.CIRCLE),
1024
+ )
1025
+
912
1026
  @use_style(LineStyle)
913
1027
  def line(self, coordinates: list[tuple[float, float]], style: LineStyle, **kwargs):
914
1028
  """Plots a line
@@ -1154,11 +1268,11 @@ class BasePlot(ABC):
1154
1268
  inbounds = []
1155
1269
 
1156
1270
  for ra, dec in ecliptic.RA_DECS:
1157
- x0, y0 = self._prepare_coords(ra, dec)
1271
+ x0, y0 = self._prepare_coords(ra * 15, dec)
1158
1272
  x.append(x0)
1159
1273
  y.append(y0)
1160
- if self.in_bounds(ra, dec):
1161
- inbounds.append((ra, dec))
1274
+ if self.in_bounds(ra * 15, dec):
1275
+ inbounds.append((ra * 15, dec))
1162
1276
 
1163
1277
  self.ax.plot(
1164
1278
  x,
@@ -1193,7 +1307,7 @@ class BasePlot(ABC):
1193
1307
  # TODO : handle wrapping
1194
1308
 
1195
1309
  for ra in range(25):
1196
- x0, y0 = self._prepare_coords(ra, 0)
1310
+ x0, y0 = self._prepare_coords(ra * 15, 0)
1197
1311
  x.append(x0)
1198
1312
  y.append(y0)
1199
1313