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
@@ -0,0 +1,543 @@
1
+ import math
2
+
3
+ from functools import cache
4
+ from typing import Callable
5
+
6
+ import pandas as pd
7
+
8
+ from cartopy import crs as ccrs
9
+ from matplotlib import pyplot as plt, patches
10
+ from matplotlib.ticker import FixedLocator, FuncFormatter
11
+ from skyfield.api import Star as SkyfieldStar
12
+ from shapely import Polygon, MultiPolygon
13
+ from starplot.coordinates import CoordinateSystem
14
+ from starplot.plots.base import BasePlot, DPI
15
+ from starplot.mixins import ExtentMaskMixin
16
+ from starplot.models.observer import Observer
17
+ from starplot.plotters import (
18
+ ConstellationPlotterMixin,
19
+ StarPlotterMixin,
20
+ DsoPlotterMixin,
21
+ MilkyWayPlotterMixin,
22
+ GradientBackgroundMixin,
23
+ LegendPlotterMixin,
24
+ ArrowPlotterMixin,
25
+ )
26
+ from starplot.plotters.text import CollisionHandler
27
+ from starplot.styles import (
28
+ PlotStyle,
29
+ extensions,
30
+ use_style,
31
+ PathStyle,
32
+ GradientDirection,
33
+ )
34
+
35
+ DEFAULT_HORIZON_LABELS = {
36
+ 0: "N",
37
+ 45: "NE",
38
+ 90: "E",
39
+ 135: "SE",
40
+ 180: "S",
41
+ 225: "SW",
42
+ 270: "W",
43
+ 315: "NW",
44
+ }
45
+
46
+
47
+ class HorizonPlot(
48
+ BasePlot,
49
+ ExtentMaskMixin,
50
+ # HorizonExtentMaskMixin,
51
+ ConstellationPlotterMixin,
52
+ StarPlotterMixin,
53
+ DsoPlotterMixin,
54
+ MilkyWayPlotterMixin,
55
+ GradientBackgroundMixin,
56
+ LegendPlotterMixin,
57
+ ArrowPlotterMixin,
58
+ ):
59
+ """Creates a new horizon plot.
60
+
61
+ Args:
62
+ altitude: Tuple of altitude range to plot (min, max)
63
+ azimuth: Tuple of azimuth range to plot (min, max)
64
+ observer: Observer instance which specifies a time and place. Defaults to `Observer()`
65
+ ephemeris: Ephemeris to use for calculating planet positions (see [Skyfield's documentation](https://rhodesmill.org/skyfield/planets.html) for details)
66
+ style: Styling for the plot (colors, sizes, fonts, etc). If `None`, it defaults to `PlotStyle()`
67
+ resolution: Size (in pixels) of largest dimension of the map
68
+ point_label_handler: Default [CollisionHandler][starplot.CollisionHandler] for point labels.
69
+ area_label_handler: Default [CollisionHandler][starplot.CollisionHandler] for area labels.
70
+ path_label_handler: Default [CollisionHandler][starplot.CollisionHandler] for path labels.
71
+ scale: Scaling factor that will be applied to all relevant sizes in styles (e.g. font size, marker size, line widths, etc). For example, if you want to make everything 2x bigger, then set scale to 2.
72
+ autoscale: If True, then the scale will be automatically set based on resolution
73
+ suppress_warnings: If True (the default), then all warnings will be suppressed
74
+
75
+ Returns:
76
+ HorizonPlot: A new instance of an HorizonPlot
77
+
78
+ """
79
+
80
+ _coordinate_system = CoordinateSystem.AZ_ALT
81
+ _gradient_direction = GradientDirection.LINEAR
82
+
83
+ FIELD_OF_VIEW_MAX = 9.0
84
+
85
+ def __init__(
86
+ self,
87
+ altitude: tuple[float, float],
88
+ azimuth: tuple[float, float],
89
+ observer: Observer = None,
90
+ ephemeris: str = "de421.bsp",
91
+ style: PlotStyle = None,
92
+ resolution: int = 4096,
93
+ point_label_handler: CollisionHandler = None,
94
+ area_label_handler: CollisionHandler = None,
95
+ path_label_handler: CollisionHandler = None,
96
+ scale: float = 1.0,
97
+ autoscale: bool = False,
98
+ suppress_warnings: bool = True,
99
+ *args,
100
+ **kwargs,
101
+ ) -> "HorizonPlot":
102
+ observer = observer or Observer()
103
+ style = style or PlotStyle().extend(extensions.MAP)
104
+
105
+ super().__init__(
106
+ observer,
107
+ ephemeris,
108
+ style,
109
+ resolution,
110
+ point_label_handler=point_label_handler,
111
+ area_label_handler=area_label_handler,
112
+ path_label_handler=path_label_handler,
113
+ scale=scale,
114
+ autoscale=autoscale,
115
+ suppress_warnings=suppress_warnings,
116
+ *args,
117
+ **kwargs,
118
+ )
119
+
120
+ if azimuth[0] >= azimuth[1]:
121
+ raise ValueError("Azimuth min must be less than max")
122
+ if azimuth[1] - azimuth[0] > 180:
123
+ raise ValueError("Azimuth range cannot be greater than 180 degrees")
124
+
125
+ if altitude[0] >= altitude[1]:
126
+ raise ValueError("Altitude min must be less than max")
127
+ if altitude[1] - altitude[0] > 90:
128
+ raise ValueError("Altitude range cannot be greater than 90 degrees")
129
+
130
+ self.logger.debug("Creating HorizonPlot...")
131
+ self.alt = altitude
132
+ self.az = azimuth
133
+ self._alt = altitude
134
+ self._az = azimuth
135
+ self.center_alt = sum(altitude) / 2
136
+ self.center_az = sum(azimuth) / 2
137
+
138
+ self._geodetic = ccrs.Geodetic()
139
+ self._plate_carree = ccrs.PlateCarree()
140
+ self._crs = ccrs.CRS(
141
+ proj4_params=[
142
+ ("proj", "latlong"),
143
+ ("a", "6378137"),
144
+ ],
145
+ globe=ccrs.Globe(ellipse="sphere", flattening=0),
146
+ )
147
+
148
+ self._init_plot()
149
+
150
+ self.altaz_mask = self._extent_mask_altaz()
151
+ self.logger.debug(f"Extent = AZ ({self.az}) ALT ({self.alt})")
152
+
153
+ self._calc_position()
154
+
155
+ @cache
156
+ def _prepare_coords(self, ra, dec) -> (float, float):
157
+ """Converts RA/DEC to AZ/ALT"""
158
+ if ra > 360:
159
+ ra -= 360
160
+ if ra < 0:
161
+ ra += 360
162
+
163
+ return self.observer._apparent(
164
+ obj=SkyfieldStar(ra_hours=ra / 15, dec_degrees=dec),
165
+ ephemeris=self.ephemeris_name,
166
+ )
167
+
168
+ def _prepare_coords_many(
169
+ self, coordinates: list, epoch_year: float = 2000
170
+ ) -> (float, float):
171
+ """Converts RA/DEC to AZ/ALT"""
172
+ df = pd.DataFrame(coordinates, columns=["ra", "dec"])
173
+ df["ra_hours"], df["dec_degrees"] = (df.ra / 15, df.dec)
174
+ df["epoch_year"] = epoch_year
175
+
176
+ sf_star = SkyfieldStar.from_dataframe(df)
177
+
178
+ df["x"], df["y"] = self.observer._apparent(
179
+ obj=sf_star,
180
+ ephemeris=self.ephemeris_name,
181
+ )
182
+
183
+ return list(zip(df["x"], df["y"]))
184
+
185
+ def _prepare_star_coords(self, df, limit_by_altaz=True):
186
+ df["x"], df["y"] = self.observer._apparent(
187
+ obj=SkyfieldStar.from_dataframe(df),
188
+ ephemeris=self.ephemeris_name,
189
+ )
190
+
191
+ # if limit_by_altaz:
192
+ # extent = self._extent_mask_altaz()
193
+ # df["_geometry_az_alt"] = gpd.points_from_xy(df.x, df.y)
194
+ # df = df[df["_geometry_az_alt"].intersects(extent)]
195
+
196
+ return df
197
+
198
+ def _calc_position(self):
199
+ self.observe = self.observer.observe(self.ephemeris_name)
200
+
201
+ self.ra_min = 0
202
+ self.ra_max = 360
203
+ self.dec_min = self.observer.lat - 90
204
+ self.dec_max = self.observer.lat + 90
205
+
206
+ self.logger.debug(
207
+ f"Extent = RA ({self.ra_min:.2f}, {self.ra_max:.2f}) DEC ({self.dec_min:.2f}, {self.dec_max:.2f})"
208
+ )
209
+
210
+ def _plot_kwargs(self) -> dict:
211
+ return dict(transform=self._crs)
212
+
213
+ @cache
214
+ def in_bounds(self, ra, dec) -> bool:
215
+ """Determine if a coordinate is within the bounds of the plot.
216
+
217
+ Args:
218
+ ra: Right ascension, in hours (0...24)
219
+ dec: Declination, in degrees (-90...90)
220
+
221
+ Returns:
222
+ True if the coordinate is in bounds, otherwise False
223
+ """
224
+ az, alt = self._prepare_coords(ra, dec)
225
+ return self.in_bounds_altaz(alt, az)
226
+
227
+ def in_bounds_altaz(self, alt, az, scale: float = 1) -> bool:
228
+ """Determine if a coordinate is within the bounds of the plot.
229
+
230
+ Args:
231
+ alt: Altitude angle in degrees (0...90)
232
+ az: Azimuth angle in degrees (0...360)
233
+
234
+ Returns:
235
+ True if the coordinate is in bounds, otherwise False
236
+ """
237
+ # return self.altaz_mask.contains(Point(az, alt))
238
+ x, y = self._to_ax(az, alt)
239
+ return 0 <= x <= 1 and 0 <= y <= 1
240
+
241
+ def _in_bounds_xy(self, x: float, y: float) -> bool:
242
+ return self.in_bounds_altaz(y, x) # alt = y, az = x
243
+
244
+ def _polygon(self, points, style, **kwargs):
245
+ super()._polygon(points, style, transform=self._crs, **kwargs)
246
+
247
+ @cache
248
+ def _extent_mask_altaz(self):
249
+ """
250
+ Returns shapely geometry objects of the alt/az extent
251
+
252
+ If the extent crosses North cardinal direction, then a MultiPolygon will be returned
253
+ """
254
+ extent = list(self.ax.get_extent(crs=self._plate_carree))
255
+ alt_min, alt_max = extent[2], extent[3]
256
+ az_min, az_max = extent[0], extent[1]
257
+
258
+ az_ul, _ = self._ax_to_azalt(0, 1)
259
+ az_ur, _ = self._ax_to_azalt(1, 1)
260
+
261
+ if az_ul < 0:
262
+ az_ul += 360
263
+
264
+ if az_ur < 0:
265
+ az_ur += 360
266
+
267
+ az_min = min(self.az[0], self.az[1], az_ul, az_ur)
268
+ az_max = max(self.az[0], self.az[1], az_ul, az_ur)
269
+
270
+ if az_min < 0:
271
+ az_min += 360
272
+ if az_max < 0:
273
+ az_max += 360
274
+
275
+ if az_min >= az_max:
276
+ az_max += 360
277
+
278
+ self.az = (az_min, az_max)
279
+ self.alt = (alt_min, alt_max)
280
+
281
+ if az_max <= 360:
282
+ coords = [
283
+ [az_min, alt_min],
284
+ [az_max, alt_min],
285
+ [az_max, alt_max],
286
+ [az_min, alt_max],
287
+ [az_min, alt_min],
288
+ ]
289
+ return Polygon(coords)
290
+
291
+ else:
292
+ coords_1 = [
293
+ [az_min, alt_min],
294
+ [360, alt_min],
295
+ [360, alt_max],
296
+ [az_min, alt_max],
297
+ [az_min, alt_min],
298
+ ]
299
+ coords_2 = [
300
+ [0, alt_min],
301
+ [az_max - 360, alt_min],
302
+ [az_max - 360, alt_max],
303
+ [0, alt_max],
304
+ [0, alt_min],
305
+ ]
306
+
307
+ return MultiPolygon(
308
+ [
309
+ Polygon(coords_1),
310
+ Polygon(coords_2),
311
+ ]
312
+ )
313
+
314
+ @use_style(PathStyle, "horizon")
315
+ def horizon(
316
+ self,
317
+ style: PathStyle = None,
318
+ labels: dict[int, str] = DEFAULT_HORIZON_LABELS,
319
+ ):
320
+ """
321
+ Plots rectangle for horizon that shows cardinal directions and azimuth labels.
322
+
323
+ Args:
324
+ style: Style of the horizon path. If None, then the plot's style definition will be used.
325
+ labels: Dictionary that maps azimuth values (0...360) to their cardinal direction labels (e.g. "N"). Default is to label each 45deg direction (e.g. "N", "NE", "E", etc)
326
+ """
327
+ patch_y = -0.11 * self.scale
328
+ bottom = patches.Polygon(
329
+ [
330
+ (0, -0.04 * self.scale),
331
+ (1, -0.04 * self.scale),
332
+ (1, patch_y),
333
+ (0, patch_y),
334
+ (0, -0.04 * self.scale),
335
+ ],
336
+ color=style.line.color.as_hex(),
337
+ transform=self.ax.transAxes,
338
+ clip_on=False,
339
+ )
340
+ self.ax.add_patch(bottom)
341
+
342
+ for az, label in labels.items():
343
+ az = int(az)
344
+ x, _ = self._to_ax(az, self.alt[0])
345
+ if x <= 0.03 or x >= 0.97 or math.isnan(x):
346
+ continue
347
+
348
+ self.ax.annotate(
349
+ label,
350
+ (x, patch_y + 0.027),
351
+ xycoords=self.ax.transAxes,
352
+ xytext=(
353
+ style.label.offset_x * self.scale,
354
+ style.label.offset_y * self.scale,
355
+ ),
356
+ textcoords="offset points",
357
+ **style.label.matplot_kwargs(self.scale),
358
+ clip_on=False,
359
+ )
360
+
361
+ @use_style(PathStyle, "gridlines")
362
+ def gridlines(
363
+ self,
364
+ style: PathStyle = None,
365
+ show_labels: list = ["left", "right", "bottom"],
366
+ az_locations: list[float] = None,
367
+ alt_locations: list[float] = None,
368
+ az_formatter_fn: Callable[[float], str] = None,
369
+ alt_formatter_fn: Callable[[float], str] = None,
370
+ divider_line: bool = True,
371
+ show_ticks: bool = True,
372
+ tick_step: int = 5,
373
+ ):
374
+ """
375
+ Plots gridlines
376
+
377
+ Args:
378
+ style: Styling of the gridlines. If None, then the plot's style (specified when creating the plot) will be used
379
+ show_labels: List of locations where labels should be shown (options: "left", "right", "top", "bottom")
380
+ az_locations: List of azimuth locations for the gridlines (in degrees, 0...360). Defaults to every 15 degrees
381
+ alt_locations: List of altitude locations for the gridlines (in degrees, -90...90). Defaults to every 10 degrees.
382
+ az_formatter_fn: Callable for creating labels of azimuth gridlines
383
+ alt_formatter_fn: Callable for creating labels of altitude gridlines
384
+ divider_line: If True, then a divider line will be plotted below the azimuth labels on the bottom of the plot (this is helpful when also plotting the horizon)
385
+ show_ticks: If True, then tick marks will be plotted on the horizon path for every `tick_step` degree that is not also a degree label
386
+ tick_step: Step size for tick marks
387
+ """
388
+ az_formatter_fn_default = lambda az: f"{round(az)}\u00b0 " # noqa: E731
389
+ alt_formatter_fn_default = lambda alt: f"{round(alt)}\u00b0 " # noqa: E731
390
+
391
+ az_formatter_fn = az_formatter_fn or az_formatter_fn_default
392
+ alt_formatter_fn = alt_formatter_fn or alt_formatter_fn_default
393
+
394
+ def az_formatter(x, pos) -> str:
395
+ if x < 0:
396
+ x += 360
397
+ return az_formatter_fn(x)
398
+
399
+ def alt_formatter(x, pos) -> str:
400
+ return alt_formatter_fn(x)
401
+
402
+ x_locations = az_locations or [x for x in range(0, 360, 15)]
403
+ x_locations = [x - 180 for x in x_locations]
404
+ y_locations = alt_locations or [d for d in range(-90, 90, 10)]
405
+
406
+ label_style_kwargs = style.label.matplot_kwargs(self.scale)
407
+ label_style_kwargs.pop("va")
408
+ label_style_kwargs.pop("ha")
409
+
410
+ line_style_kwargs = style.line.matplot_kwargs(self.scale)
411
+ gridlines = self.ax.gridlines(
412
+ draw_labels=show_labels,
413
+ x_inline=False,
414
+ y_inline=False,
415
+ rotate_labels=False,
416
+ xpadding=12,
417
+ ypadding=12,
418
+ gid="gridlines",
419
+ xlocs=FixedLocator(x_locations),
420
+ xformatter=FuncFormatter(az_formatter),
421
+ xlabel_style=label_style_kwargs,
422
+ ylocs=FixedLocator(y_locations),
423
+ ylabel_style=label_style_kwargs,
424
+ yformatter=FuncFormatter(alt_formatter),
425
+ **line_style_kwargs,
426
+ )
427
+ gridlines.set_zorder(style.line.zorder)
428
+
429
+ if show_labels:
430
+ self._axis_labels = True
431
+
432
+ # gridlines.xlocator = FixedLocator(x_locations)
433
+ # gridlines.xformatter = FuncFormatter(az_formatter)
434
+ # gridlines.xlabel_style = label_style_kwargs
435
+
436
+ # gridlines.ylocator = FixedLocator(y_locations)
437
+ # gridlines.yformatter = FuncFormatter(alt_formatter)
438
+ # gridlines.ylabel_style = label_style_kwargs
439
+ # print(gridlines.label_artists)
440
+ # for label in gridlines.label_artists:
441
+ # label.set_zorder(style.label.zorder)
442
+
443
+ if divider_line:
444
+ self.ax.plot(
445
+ [0, 1],
446
+ [-0.04 * self.scale, -0.04 * self.scale],
447
+ lw=1,
448
+ color=style.label.font_color.as_hex(),
449
+ clip_on=False,
450
+ transform=self.ax.transAxes,
451
+ )
452
+
453
+ if not show_ticks or len(x_locations) < 2:
454
+ return
455
+
456
+ # sort x locations so we iterate in order
457
+ x_locations_sorted = sorted(x_locations)
458
+ for i, az in enumerate(x_locations_sorted[1:], start=1):
459
+ prev_az = x_locations_sorted[i - 1]
460
+
461
+ # start at az label location + tick step cause we only want ticks between labels
462
+ for az_tick in range(prev_az + tick_step, az, tick_step):
463
+ a = int(az_tick)
464
+ if a >= 360:
465
+ a -= 360
466
+ x, _ = self._to_ax(a, self.alt[0])
467
+
468
+ if x <= 0.03 or x >= 0.97 or math.isnan(x):
469
+ continue
470
+
471
+ self.ax.annotate(
472
+ "|",
473
+ (x, -0.011 * self.scale),
474
+ xycoords=self.ax.transAxes,
475
+ **style.label.matplot_kwargs(self.scale / 2),
476
+ )
477
+
478
+ @cache
479
+ def _to_ax(self, az: float, alt: float) -> tuple[float, float]:
480
+ """Converts az/alt to axes coordinates"""
481
+ x, y = self._proj.transform_point(az, alt, self._crs)
482
+ data_to_axes = self.ax.transData + self.ax.transAxes.inverted()
483
+ x_axes, y_axes = data_to_axes.transform((x, y))
484
+ return x_axes, y_axes
485
+
486
+ @cache
487
+ def _ax_to_azalt(self, x: float, y: float) -> tuple[float, float]:
488
+ trans = self.ax.transAxes + self.ax.transData.inverted()
489
+ x_projected, y_projected = trans.transform((x, y)) # axes to data
490
+ az, alt = self._crs.transform_point(x_projected, y_projected, self._proj)
491
+ return float(az), float(alt)
492
+
493
+ def _plot_background_clip_path(self):
494
+ if self.style.has_gradient_background():
495
+ background_color = "#ffffff00"
496
+ self._plot_gradient_background(self.style.background_color)
497
+ else:
498
+ background_color = self.style.background_color.as_hex()
499
+
500
+ self._background_clip_path = patches.Rectangle(
501
+ (0, 0),
502
+ width=1,
503
+ height=1,
504
+ facecolor=background_color,
505
+ linewidth=0,
506
+ fill=True,
507
+ zorder=-3_000,
508
+ transform=self.ax.transAxes,
509
+ )
510
+ self.ax.set_facecolor(background_color)
511
+
512
+ self.ax.add_patch(self._background_clip_path)
513
+ self._update_clip_path_polygon()
514
+
515
+ def _init_plot(self):
516
+ self._proj = ccrs.LambertAzimuthalEqualArea(
517
+ central_longitude=sum(self.az) / 2,
518
+ central_latitude=0,
519
+ )
520
+ self._proj.threshold = 100
521
+ self.fig = plt.figure(
522
+ figsize=(self.figure_size, self.figure_size),
523
+ facecolor=self.style.figure_background_color.as_hex(),
524
+ # layout="constrained",
525
+ dpi=DPI,
526
+ )
527
+ self.ax = self.fig.add_subplot(1, 1, 1, projection=self._proj)
528
+ self.fig.subplots_adjust(left=0, right=1, top=1, bottom=0)
529
+
530
+ self.ax.xaxis.set_visible(False)
531
+ self.ax.yaxis.set_visible(False)
532
+ self.ax.axis("off")
533
+
534
+ bounds = [
535
+ self.az[0],
536
+ self.az[1],
537
+ self.alt[0],
538
+ self.alt[1],
539
+ ]
540
+
541
+ self.ax.set_extent(bounds, crs=ccrs.PlateCarree())
542
+ self._fit_to_ax()
543
+ self._plot_background_clip_path()