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,153 @@
1
+ import numpy as np
2
+ from matplotlib.colors import LinearSegmentedColormap
3
+ from starplot.profile import profile
4
+ from starplot.styles import GradientDirection
5
+
6
+
7
+ class GradientBackgroundMixin:
8
+ """
9
+ Mixin class to handle adding gradients to plots.
10
+
11
+ Handles a variety of projections and can be inherited by HorizonPlot, OpticPlot,
12
+ and MapPlot for vertical, radial, and mollweide gradients. However, some more
13
+ obscure projections may throw errors if attempting to plot a gradient with them.
14
+ """
15
+
16
+ @profile
17
+ def _plot_gradient_background(
18
+ self, gradient_preset: list[tuple[float, str]]
19
+ ) -> None:
20
+ """
21
+ Adds a gradient background to the plot, beneath the GeoAxes.
22
+ The background_color of the map must be set as a RGBA value with full
23
+ transparency (e.g. #ffffff00) for this function to render the desired
24
+ result.
25
+
26
+ Args:
27
+ gradient_preset: A list of tuples (e.g. [(0.0, '#000000'), (1.0, '#000080')])
28
+ where each tuple contains a position value [0-1] and a color
29
+ value to describe the range of colors in the gradient.
30
+ """
31
+ direction = self._gradient_direction
32
+ reverse = True if direction == GradientDirection.RADIAL else False
33
+ cmap = self._create_colormap(gradient_preset, reverse=reverse)
34
+ background_ax = self._create_background_ax()
35
+ background_ax.set_axis_off()
36
+ self._background_ax = background_ax
37
+
38
+ X, Y, gradient = self._create_gradient_arrays()
39
+
40
+ # Radial specific axes adjustments
41
+ if self._gradient_direction == GradientDirection.RADIAL:
42
+ background_ax.set_ylim(Y.min(), Y.max() * 1.06)
43
+
44
+ # if getattr(self, "optic", None):
45
+ # self._camera_optic_transform(background_ax)
46
+ # Camera specific axes adjustments
47
+ # if gradient_shape == "camera":
48
+ # self._camera_optic_transform(background_ax)
49
+
50
+ # Render gradient
51
+ background_ax.pcolormesh(
52
+ X,
53
+ Y,
54
+ gradient,
55
+ cmap=cmap,
56
+ shading="gouraud",
57
+ rasterized=True,
58
+ zorder=0,
59
+ clip_path=self._background_clip_path,
60
+ )
61
+
62
+ # Set plot in self.ax's zorder to 1 so it appears above the gradient
63
+ self.ax.zorder = 1
64
+
65
+ # Event driven function so background_ax matches GeoAxes when plotted
66
+ self.ax.figure.canvas.mpl_connect(
67
+ "draw_event",
68
+ lambda event: background_ax.set_position(self.ax.get_position()),
69
+ )
70
+
71
+ def _create_colormap(
72
+ self, gradient_preset: list[tuple[float, str]], reverse: bool = False
73
+ ) -> LinearSegmentedColormap:
74
+ """Creates a matplotlib colormap from a gradient preset."""
75
+ positions, colors = zip(*gradient_preset)
76
+
77
+ if self._gradient_direction == GradientDirection.RADIAL:
78
+ positions = [p / 2 for p in positions]
79
+ positions[-1] = 1
80
+
81
+ from pydantic.color import Color
82
+
83
+ colors = [Color(c).as_hex() for c in colors]
84
+
85
+ cmap = LinearSegmentedColormap.from_list(
86
+ "custom_gradient", list(zip(positions, colors)), N=750
87
+ )
88
+ return cmap.reversed() if reverse else cmap
89
+
90
+ def _create_background_ax(self):
91
+ """Adds a set of axes to take the gradient image."""
92
+ bbox = self.ax.get_position()
93
+ projection = None
94
+
95
+ if self._gradient_direction == GradientDirection.RADIAL:
96
+ projection = "polar"
97
+ elif self._gradient_direction == GradientDirection.MOLLWEIDE:
98
+ projection = "mollweide"
99
+
100
+ return self.ax.figure.add_axes(bbox, zorder=0, projection=projection)
101
+
102
+ def _create_gradient_arrays(self):
103
+ """Creates arrays for the gradient placement and the gradient meshgrid."""
104
+ # Radial gradient
105
+ if self._gradient_direction == GradientDirection.RADIAL:
106
+ rad = np.linspace(0, 1, 50)
107
+ azm = np.linspace(0, 2 * np.pi, 100)
108
+ Y, X = np.meshgrid(rad, azm)
109
+ gradient = Y**2.0
110
+ return X, Y, gradient
111
+
112
+ # Mollweide gradient
113
+ if self._gradient_direction == GradientDirection.MOLLWEIDE:
114
+ return self._create_mollweide_gradient()
115
+
116
+ # Default Vertical Gradient
117
+ x_array = np.linspace(0, 1, 2)
118
+ y_array = np.linspace(0, 1, 750)
119
+ X, Y = np.meshgrid(x_array, y_array)
120
+ gradient = np.linspace(0, 1, 750).reshape(-1, 1)
121
+ gradient = np.repeat(gradient, 2, axis=1)
122
+ return X, Y, gradient
123
+
124
+ def _create_mollweide_gradient(self):
125
+ """Generate meshgrid and gradient for a mollweide projection."""
126
+ x = np.linspace(-np.pi, np.pi, 250)
127
+ y = np.linspace(-np.pi / 2, np.pi / 2, 250)
128
+ X, Y = np.meshgrid(x, y)
129
+ # Rotation matrix (ICRS → Galactic)
130
+ R = np.array(
131
+ [
132
+ [-0.0548755604162154, -0.8734370902348850, -0.4838350155487132],
133
+ [0.4941094278755837, -0.4448296299600112, 0.7469822444972189],
134
+ [-0.8676661490190047, -0.1980763734312015, 0.4559837761750669],
135
+ ]
136
+ )
137
+ # Equatorial unit vectors
138
+ cos_y = np.cos(Y)
139
+ eq = np.stack(
140
+ [cos_y * np.cos(X), cos_y * np.sin(X) * -1, np.sin(Y) * -1], axis=-1
141
+ )
142
+ # Rotate into Galactic coords
143
+ gal = eq @ R.T
144
+ # Gradient follows galactic latitude
145
+ gradient = np.arcsin(gal[..., 2])
146
+ return X, Y, gradient
147
+
148
+ def _camera_optic_transform(self, background_ax) -> None:
149
+ """Apply camera-specific axes transformations for the gradient."""
150
+ background_ax.set_xlim(-0.11, 1.11)
151
+ background_ax.set_ylim(-0.07, 1.07)
152
+ if self.optic.rotation == 0:
153
+ return
@@ -0,0 +1,253 @@
1
+ from typing import Callable
2
+
3
+ import numpy as np
4
+ from matplotlib.legend import Legend
5
+ from matplotlib.lines import Line2D
6
+
7
+ from starplot import callables
8
+ from starplot.data.translations import translate
9
+ from starplot.models.star import Star
10
+ from starplot.styles import (
11
+ MarkerStyle,
12
+ LegendLocationEnum,
13
+ LegendStyle,
14
+ )
15
+ from starplot.styles.helpers import use_style
16
+
17
+
18
+ class LegendPlotterMixin:
19
+ def _create_legend(self, handles, labels, title, style, set_anchor: bool = False):
20
+ style_kwargs = style.matplot_kwargs(self.scale)
21
+
22
+ target = self.ax
23
+
24
+ if style.location.startswith("outside"):
25
+ target = self.fig
26
+
27
+ style_kwargs["borderaxespad"] = -1 * style.padding
28
+
29
+ legend = Legend(
30
+ target,
31
+ handles=handles,
32
+ labels=labels,
33
+ title=title,
34
+ **style_kwargs,
35
+ )
36
+
37
+ legend.set_zorder(
38
+ # zorder is not a valid kwarg to legend(), so we have to set it afterwards
39
+ style.zorder
40
+ )
41
+ legend.get_title().set_color(style.font_color.as_hex())
42
+
43
+ if not set_anchor:
44
+ return legend
45
+
46
+ display_to_axes_transform = self.ax.transAxes.inverted()
47
+ origin_x, origin_y = self.ax.transAxes.transform((0, 0))
48
+ padding_x, padding_y = display_to_axes_transform.transform(
49
+ (origin_x + style.padding_x / 2, origin_y + style.padding_y / 2)
50
+ )
51
+
52
+ if style.location.startswith("outside"):
53
+ extent = legend.get_window_extent(renderer=self.fig.canvas.get_renderer())
54
+ min_x, min_y = display_to_axes_transform.transform(extent.min)
55
+ max_x, max_y = display_to_axes_transform.transform(extent.max)
56
+
57
+ baseline, _ = display_to_axes_transform.transform(
58
+ (origin_x + 200, origin_y + style.padding_y / 2)
59
+ )
60
+
61
+ padding_x += baseline
62
+ width = max_x - min_x + padding_x
63
+ # height = max_y - min_y
64
+ # top_x, top_y = display_to_figure_transform.transform(self.ax.transAxes.transform((1, 1)))
65
+
66
+ bbox = {
67
+ LegendLocationEnum.OUTSIDE_TOP_RIGHT: (1 + width, 1 - padding_y),
68
+ LegendLocationEnum.OUTSIDE_TOP_LEFT: (-1 * width, 1 - padding_y),
69
+ LegendLocationEnum.OUTSIDE_BOTTOM_RIGHT: (1 + width, padding_y),
70
+ LegendLocationEnum.OUTSIDE_BOTTOM_LEFT: (-1 * width, padding_y),
71
+ }.get(style.location)
72
+
73
+ else:
74
+ bbox = {
75
+ LegendLocationEnum.INSIDE_TOP_LEFT: (padding_x, 1 - padding_y),
76
+ LegendLocationEnum.INSIDE_TOP_RIGHT: (1 - padding_x, 1 - padding_y),
77
+ LegendLocationEnum.INSIDE_TOP: (0.5, 1 - padding_y),
78
+ LegendLocationEnum.INSIDE_BOTTOM_LEFT: (padding_x, padding_y),
79
+ LegendLocationEnum.INSIDE_BOTTOM_RIGHT: (1 - padding_x, padding_y),
80
+ LegendLocationEnum.INSIDE_BOTTOM: (0.5, padding_y),
81
+ }.get(style.location)
82
+
83
+ legend.set_bbox_to_anchor(
84
+ bbox=bbox,
85
+ transform=self.ax.transAxes,
86
+ )
87
+
88
+ return legend
89
+
90
+ @use_style(LegendStyle, "legend")
91
+ def legend(self, title: str = "Legend", style: LegendStyle = None):
92
+ """
93
+ Plots the legend.
94
+
95
+ If the legend is already plotted, then it'll be removed first and then plotted again. So, it's safe to call this function multiple times if you need to 'refresh' the legend.
96
+
97
+ Args:
98
+ title: Title of the legend, which will be plotted at the top
99
+ style: Styling of the legend. If None, then the plot's style (specified when creating the plot) will be used
100
+ """
101
+ if not self._legend_handles:
102
+ return
103
+
104
+ if self._legend:
105
+ self._legend.remove()
106
+
107
+ target = self.ax
108
+
109
+ title = translate(title, self.language)
110
+
111
+ if style.location.startswith("outside"):
112
+ target = self.fig
113
+
114
+ legend = self._create_legend(
115
+ handles=self._legend_handles.values(),
116
+ labels=self._legend_handles.keys(),
117
+ title=title,
118
+ style=style,
119
+ set_anchor=True,
120
+ )
121
+
122
+ target.add_artist(legend)
123
+
124
+ self._legend = legend
125
+ self._legend_target = target
126
+
127
+ def _add_to_legend(self, legend):
128
+ if not self._legend:
129
+ self.legend()
130
+
131
+ target = self._legend_target
132
+
133
+ legend_base = self._legend.get_children()[0]
134
+ legend_2 = legend.get_children()[0]
135
+
136
+ # empty legend for padding
137
+ empty = Legend(
138
+ target,
139
+ handles=[],
140
+ labels=[],
141
+ title="",
142
+ # **style_kwargs,
143
+ )
144
+
145
+ # add empty legend for padding between legend and scale
146
+ legend_base.get_children().extend(empty.get_children()[0].get_children())
147
+
148
+ legend_base.get_children().extend(legend_2.get_children())
149
+
150
+ legend_base.get_children().extend(empty.get_children()[0].get_children())
151
+
152
+ # target.add_artist(self._legend)
153
+
154
+ @use_style(LegendStyle, "legend")
155
+ def star_magnitude_scale(
156
+ self,
157
+ title: str = "Star Magnitude",
158
+ style: LegendStyle = None,
159
+ size_fn: Callable[[Star], float] = callables.size_by_magnitude,
160
+ label_fn: Callable[float, str] = lambda m: str(m),
161
+ start: float = -1,
162
+ stop: float = 9,
163
+ step: float = 1,
164
+ add_to_legend: bool = False,
165
+ ):
166
+ """
167
+ Plots a magnitude scale for stars.
168
+
169
+ !!! example "Experimental"
170
+
171
+ This is currently an "experimental" feature, which means it's likely to be changed and improved in upcoming versions of Starplot.
172
+ It also means the feature likely has limitations.
173
+
174
+ **Help us improve this feature by submitting feedback on [GitHub (open an issue)](https://github.com/steveberardi/starplot/issues) or chat with us on [Discord](https://discord.gg/WewJJjshFu). Thanks!**
175
+
176
+ !!! note "Current Limitations"
177
+ - Only supports size functions that determine size based on magnitude only
178
+ - Only supports default marker for stars (point)
179
+ - Labels can only be plotted to the right of the marker
180
+ - Does not automatically determine the magnitude range of the stars you already plotted
181
+
182
+ Args:
183
+ title: Title of the legend, which will be plotted at the top
184
+ style: Styling of the magnitude scale. If None, then the plot's `legend` style will be used
185
+ size_fn: Size function for the star markers
186
+ label_fn: Function to determine the label for each magnitude
187
+ start: Starting magnitude
188
+ stop: Stop point (exclusive)
189
+ step: Step-size of each scale entry (i.e. how much to increment each step)
190
+ add_to_legend: If True, the scale will be added to the bottom of the legend (and if the legend isn't already plotted, then it'll plot the legend)
191
+ """
192
+ target = self.ax
193
+
194
+ if style.location.startswith("outside"):
195
+ target = self.fig
196
+
197
+ title = translate(title, self.language)
198
+
199
+ def scale(
200
+ size_fn,
201
+ style: MarkerStyle,
202
+ label_fn,
203
+ start: float,
204
+ stop: float,
205
+ step: float = 1,
206
+ ):
207
+ for mag in np.arange(start, stop, step):
208
+ s = style.matplot_kwargs()
209
+ s["markersize"] = (
210
+ size_fn(Star(pk=1, ra=0, dec=0, magnitude=mag, geometry=None))
211
+ ** 0.5
212
+ ) * self.scale
213
+ label = label_fn(mag)
214
+ yield Line2D(
215
+ [],
216
+ [],
217
+ **s,
218
+ linestyle="None",
219
+ label=label,
220
+ )
221
+
222
+ handles = [
223
+ h
224
+ for h in scale(
225
+ size_fn=size_fn,
226
+ style=self.style.star.marker,
227
+ label_fn=label_fn,
228
+ start=start,
229
+ stop=stop,
230
+ step=step,
231
+ )
232
+ ]
233
+ labels = [str(m) for m in np.arange(start, stop, step)]
234
+
235
+ scale = self._create_legend(
236
+ handles=handles,
237
+ labels=labels,
238
+ title=title,
239
+ style=style,
240
+ set_anchor=True if not add_to_legend else False,
241
+ )
242
+
243
+ if add_to_legend:
244
+ self._add_to_legend(scale)
245
+ else:
246
+ target.add_artist(scale)
247
+
248
+ # for text in magnitude_scale.get_texts():
249
+ # text.set_ha("center") # horizontal alignment of text item
250
+ # text.set_x(-85) # x-position
251
+ # text.set_y(-90) # y-position
252
+
253
+ # self.ax.add_artist(magnitude_scale)
@@ -0,0 +1,51 @@
1
+ from shapely.ops import unary_union
2
+
3
+ from ibis import _
4
+
5
+ from starplot.data import db
6
+ from starplot.data.catalogs import Catalog, MILKY_WAY
7
+ from starplot.styles import PolygonStyle
8
+ from starplot.styles.helpers import use_style
9
+ from starplot.geometry import split_polygon_at_zero
10
+ from starplot.profile import profile
11
+ from starplot.models.milky_way import from_tuple
12
+
13
+
14
+ class MilkyWayPlotterMixin:
15
+ @profile
16
+ @use_style(PolygonStyle, "milky_way")
17
+ def milky_way(self, style: PolygonStyle = None, catalog: Catalog = MILKY_WAY):
18
+ """
19
+ Plots the Milky Way
20
+
21
+ Args:
22
+ style: Styling of the Milky Way. If None, then the plot's style (specified when creating the plot) will be used
23
+ catalog: Catalog to use for Milky Way polygons
24
+ """
25
+ con = db.connect()
26
+ mw = catalog._load(connection=con, table_name="milky_way")
27
+ mw = mw.mutate(
28
+ geometry=_.geometry.cast("geometry"), # cast WKB to geometry type
29
+ )
30
+
31
+ extent = self._extent_mask()
32
+ df = mw.filter(_.geometry.intersects(extent)).to_pandas()
33
+
34
+ milky_ways = [from_tuple(m) for m in df.itertuples()]
35
+
36
+ polygons = []
37
+ for milky_way in milky_ways:
38
+ polygons.extend(split_polygon_at_zero(milky_way.geometry))
39
+
40
+ mw_union = unary_union(polygons)
41
+
42
+ if mw_union.geom_type == "MultiPolygon":
43
+ polygons = mw_union.geoms
44
+ else:
45
+ polygons = [mw_union]
46
+
47
+ for p in polygons:
48
+ self.polygon(
49
+ geometry=p.buffer(0.001),
50
+ style=style,
51
+ )