starplot 0.15.8__py2.py3-none-any.whl → 0.16.1__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.
- starplot/__init__.py +7 -2
- starplot/base.py +57 -60
- starplot/cli.py +3 -3
- starplot/config.py +56 -0
- starplot/data/__init__.py +5 -5
- starplot/data/bigsky.py +3 -3
- starplot/data/db.py +2 -2
- starplot/data/library/sky.db +0 -0
- starplot/geometry.py +48 -0
- starplot/horizon.py +194 -90
- starplot/map.py +71 -168
- starplot/mixins.py +0 -55
- starplot/models/dso.py +10 -2
- starplot/observer.py +71 -0
- starplot/optic.py +61 -26
- starplot/plotters/__init__.py +2 -0
- starplot/plotters/constellations.py +4 -6
- starplot/plotters/dsos.py +3 -2
- starplot/plotters/gradients.py +153 -0
- starplot/plotters/legend.py +247 -0
- starplot/plotters/milkyway.py +8 -5
- starplot/plotters/stars.py +5 -3
- starplot/projections.py +155 -55
- starplot/styles/base.py +98 -22
- starplot/styles/ext/antique.yml +0 -1
- starplot/styles/ext/blue_dark.yml +0 -1
- starplot/styles/ext/blue_gold.yml +60 -52
- starplot/styles/ext/blue_light.yml +0 -1
- starplot/styles/ext/blue_medium.yml +7 -7
- starplot/styles/ext/blue_night.yml +178 -0
- starplot/styles/ext/cb_wong.yml +0 -1
- starplot/styles/ext/gradient_presets.yml +158 -0
- starplot/styles/ext/grayscale.yml +0 -1
- starplot/styles/ext/grayscale_dark.yml +0 -1
- starplot/styles/ext/nord.yml +0 -1
- starplot/styles/extensions.py +90 -0
- starplot/zenith.py +174 -0
- {starplot-0.15.8.dist-info → starplot-0.16.1.dist-info}/METADATA +18 -11
- {starplot-0.15.8.dist-info → starplot-0.16.1.dist-info}/RECORD +42 -36
- starplot/settings.py +0 -26
- {starplot-0.15.8.dist-info → starplot-0.16.1.dist-info}/WHEEL +0 -0
- {starplot-0.15.8.dist-info → starplot-0.16.1.dist-info}/entry_points.txt +0 -0
- {starplot-0.15.8.dist-info → starplot-0.16.1.dist-info}/licenses/LICENSE +0 -0
starplot/optic.py
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
from datetime import datetime
|
|
2
1
|
from typing import Callable, Mapping
|
|
3
2
|
|
|
4
3
|
import pandas as pd
|
|
@@ -13,8 +12,9 @@ from starplot.base import BasePlot, DPI
|
|
|
13
12
|
from starplot.data.stars import StarCatalog, STAR_NAMES
|
|
14
13
|
from starplot.mixins import ExtentMaskMixin
|
|
15
14
|
from starplot.models import Star
|
|
16
|
-
from starplot.
|
|
17
|
-
from starplot.
|
|
15
|
+
from starplot.observer import Observer
|
|
16
|
+
from starplot.optics import Optic, Camera
|
|
17
|
+
from starplot.plotters import StarPlotterMixin, DsoPlotterMixin, GradientBackgroundMixin
|
|
18
18
|
from starplot.styles import (
|
|
19
19
|
PlotStyle,
|
|
20
20
|
ObjectStyle,
|
|
@@ -22,6 +22,7 @@ from starplot.styles import (
|
|
|
22
22
|
extensions,
|
|
23
23
|
use_style,
|
|
24
24
|
ZOrderEnum,
|
|
25
|
+
GradientDirection,
|
|
25
26
|
)
|
|
26
27
|
from starplot.utils import azimuth_to_string
|
|
27
28
|
|
|
@@ -30,7 +31,13 @@ pd.options.mode.chained_assignment = None # default='warn'
|
|
|
30
31
|
DEFAULT_OPTIC_STYLE = PlotStyle().extend(extensions.OPTIC)
|
|
31
32
|
|
|
32
33
|
|
|
33
|
-
class OpticPlot(
|
|
34
|
+
class OpticPlot(
|
|
35
|
+
BasePlot,
|
|
36
|
+
ExtentMaskMixin,
|
|
37
|
+
StarPlotterMixin,
|
|
38
|
+
DsoPlotterMixin,
|
|
39
|
+
GradientBackgroundMixin,
|
|
40
|
+
):
|
|
34
41
|
"""Creates a new optic plot.
|
|
35
42
|
|
|
36
43
|
Args:
|
|
@@ -55,17 +62,16 @@ class OpticPlot(BasePlot, ExtentMaskMixin, StarPlotterMixin, DsoPlotterMixin):
|
|
|
55
62
|
"""
|
|
56
63
|
|
|
57
64
|
_coordinate_system = CoordinateSystem.AZ_ALT
|
|
65
|
+
_gradient_direction = GradientDirection.RADIAL
|
|
58
66
|
|
|
59
67
|
FIELD_OF_VIEW_MAX = 9.0
|
|
60
68
|
|
|
61
69
|
def __init__(
|
|
62
70
|
self,
|
|
63
|
-
optic: Optic,
|
|
64
71
|
ra: float,
|
|
65
72
|
dec: float,
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
dt: datetime = None,
|
|
73
|
+
optic: Optic,
|
|
74
|
+
observer: Observer = Observer(),
|
|
69
75
|
ephemeris: str = "de421_2001.bsp",
|
|
70
76
|
style: PlotStyle = DEFAULT_OPTIC_STYLE,
|
|
71
77
|
resolution: int = 4096,
|
|
@@ -78,7 +84,7 @@ class OpticPlot(BasePlot, ExtentMaskMixin, StarPlotterMixin, DsoPlotterMixin):
|
|
|
78
84
|
**kwargs,
|
|
79
85
|
) -> "OpticPlot":
|
|
80
86
|
super().__init__(
|
|
81
|
-
|
|
87
|
+
observer,
|
|
82
88
|
ephemeris,
|
|
83
89
|
style,
|
|
84
90
|
resolution,
|
|
@@ -90,10 +96,12 @@ class OpticPlot(BasePlot, ExtentMaskMixin, StarPlotterMixin, DsoPlotterMixin):
|
|
|
90
96
|
**kwargs,
|
|
91
97
|
)
|
|
92
98
|
self.logger.debug("Creating OpticPlot...")
|
|
99
|
+
|
|
100
|
+
if isinstance(optic, Camera) and style.has_gradient_background():
|
|
101
|
+
raise ValueError("Gradient backgrounds are not yet supported for cameras.")
|
|
102
|
+
|
|
93
103
|
self.ra = ra
|
|
94
104
|
self.dec = dec
|
|
95
|
-
self.lat = lat
|
|
96
|
-
self.lon = lon
|
|
97
105
|
self.raise_on_below_horizon = raise_on_below_horizon
|
|
98
106
|
|
|
99
107
|
self.optic = optic
|
|
@@ -112,6 +120,16 @@ class OpticPlot(BasePlot, ExtentMaskMixin, StarPlotterMixin, DsoPlotterMixin):
|
|
|
112
120
|
self._adjust_radec_minmax()
|
|
113
121
|
self._init_plot()
|
|
114
122
|
|
|
123
|
+
@property
|
|
124
|
+
def alt(self):
|
|
125
|
+
"""Altitude of target (degrees)"""
|
|
126
|
+
return self.pos_alt.degrees
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def az(self):
|
|
130
|
+
"""Azimuth of target (degrees)"""
|
|
131
|
+
return self.pos_az.degrees
|
|
132
|
+
|
|
115
133
|
def _prepare_coords(self, ra, dec) -> (float, float):
|
|
116
134
|
"""Converts RA/DEC to AZ/ALT"""
|
|
117
135
|
point = SkyfieldStar(ra_hours=ra / 15, dec_degrees=dec)
|
|
@@ -155,9 +173,9 @@ class OpticPlot(BasePlot, ExtentMaskMixin, StarPlotterMixin, DsoPlotterMixin):
|
|
|
155
173
|
def _calc_position(self):
|
|
156
174
|
earth = self.ephemeris["earth"]
|
|
157
175
|
|
|
158
|
-
self.location = earth + wgs84.latlon(self.lat, self.lon)
|
|
176
|
+
self.location = earth + wgs84.latlon(self.observer.lat, self.observer.lon)
|
|
159
177
|
self.star = SkyfieldStar(ra_hours=self.ra / 15, dec_degrees=self.dec)
|
|
160
|
-
self.observe = self.location.at(self.timescale).observe
|
|
178
|
+
self.observe = self.location.at(self.observer.timescale).observe
|
|
161
179
|
self.position = self.observe(self.star)
|
|
162
180
|
|
|
163
181
|
self.pos_apparent = self.position.apparent()
|
|
@@ -285,7 +303,11 @@ class OpticPlot(BasePlot, ExtentMaskMixin, StarPlotterMixin, DsoPlotterMixin):
|
|
|
285
303
|
self.ax
|
|
286
304
|
) # apply transform again because new xy limits will undo the transform
|
|
287
305
|
|
|
288
|
-
dt_str =
|
|
306
|
+
dt_str = (
|
|
307
|
+
self.observer.dt.strftime("%m/%d/%Y @ %H:%M:%S")
|
|
308
|
+
+ " "
|
|
309
|
+
+ self.observer.dt.tzname()
|
|
310
|
+
)
|
|
289
311
|
font_size = style.font_size * self.scale
|
|
290
312
|
|
|
291
313
|
column_labels = [
|
|
@@ -298,7 +320,7 @@ class OpticPlot(BasePlot, ExtentMaskMixin, StarPlotterMixin, DsoPlotterMixin):
|
|
|
298
320
|
values = [
|
|
299
321
|
f"{self.pos_alt.degrees:.0f}\N{DEGREE SIGN} / {self.pos_az.degrees:.0f}\N{DEGREE SIGN} ({azimuth_to_string(self.pos_az.degrees)})",
|
|
300
322
|
f"{(self.ra / 15):.2f}h / {self.dec:.2f}\N{DEGREE SIGN}",
|
|
301
|
-
f"{self.lat:.2f}\N{DEGREE SIGN}, {self.lon:.2f}\N{DEGREE SIGN}",
|
|
323
|
+
f"{self.observer.lat:.2f}\N{DEGREE SIGN}, {self.observer.lon:.2f}\N{DEGREE SIGN}",
|
|
302
324
|
dt_str,
|
|
303
325
|
str(self.optic),
|
|
304
326
|
]
|
|
@@ -331,35 +353,45 @@ class OpticPlot(BasePlot, ExtentMaskMixin, StarPlotterMixin, DsoPlotterMixin):
|
|
|
331
353
|
x = 0
|
|
332
354
|
y = 0
|
|
333
355
|
|
|
356
|
+
if self.style.has_gradient_background():
|
|
357
|
+
background_color = "#ffffff00"
|
|
358
|
+
# self._plot_gradient_background(self.style.background_color)
|
|
359
|
+
else:
|
|
360
|
+
background_color = self.style.background_color.as_hex()
|
|
361
|
+
|
|
334
362
|
# Background of Viewable Area
|
|
335
363
|
self._background_clip_path = self.optic.patch(
|
|
336
364
|
x,
|
|
337
365
|
y,
|
|
338
|
-
facecolor=
|
|
366
|
+
facecolor=background_color,
|
|
339
367
|
linewidth=0,
|
|
340
368
|
fill=True,
|
|
341
369
|
zorder=ZOrderEnum.LAYER_1,
|
|
342
370
|
)
|
|
371
|
+
self.ax.set_facecolor(background_color)
|
|
343
372
|
self.ax.add_patch(self._background_clip_path)
|
|
344
373
|
self._update_clip_path_polygon()
|
|
345
374
|
|
|
346
375
|
# Inner Border
|
|
347
|
-
inner_border = self.optic.patch(
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
)
|
|
355
|
-
self.ax.add_patch(inner_border)
|
|
376
|
+
# inner_border = self.optic.patch(
|
|
377
|
+
# x,
|
|
378
|
+
# y,
|
|
379
|
+
# linewidth=2 * self.scale,
|
|
380
|
+
# edgecolor=self.style.border_line_color.as_hex(),
|
|
381
|
+
# fill=False,
|
|
382
|
+
# zorder=ZOrderEnum.LAYER_5 + 100,
|
|
383
|
+
# )
|
|
384
|
+
# self.ax.add_patch(inner_border)
|
|
385
|
+
|
|
386
|
+
if self.style.has_gradient_background():
|
|
387
|
+
self._plot_gradient_background(self.style.background_color)
|
|
356
388
|
|
|
357
389
|
# Outer border
|
|
358
390
|
outer_border = self.optic.patch(
|
|
359
391
|
x,
|
|
360
392
|
y,
|
|
361
393
|
padding=0.05,
|
|
362
|
-
linewidth=
|
|
394
|
+
linewidth=25 * self.scale,
|
|
363
395
|
edgecolor=self.style.border_bg_color.as_hex(),
|
|
364
396
|
fill=False,
|
|
365
397
|
zorder=ZOrderEnum.LAYER_5,
|
|
@@ -395,3 +427,6 @@ class OpticPlot(BasePlot, ExtentMaskMixin, StarPlotterMixin, DsoPlotterMixin):
|
|
|
395
427
|
self.ax.set_ylim(-1.06 * self.optic.ylim, 1.06 * self.optic.ylim)
|
|
396
428
|
self.optic.transform(self.ax)
|
|
397
429
|
self._plot_border()
|
|
430
|
+
|
|
431
|
+
# if self.gradient_preset:
|
|
432
|
+
# self.apply_gradient_background(self.gradient_preset)
|
starplot/plotters/__init__.py
CHANGED
|
@@ -2,3 +2,5 @@ from .constellations import ConstellationPlotterMixin # noqa: F401
|
|
|
2
2
|
from .stars import StarPlotterMixin # noqa: F401
|
|
3
3
|
from .dsos import DsoPlotterMixin # noqa: F401
|
|
4
4
|
from .milkyway import MilkyWayPlotterMixin # noqa: F401
|
|
5
|
+
from .legend import LegendPlotterMixin # noqa: F401
|
|
6
|
+
from .gradients import GradientBackgroundMixin # noqa: F401
|
|
@@ -16,7 +16,7 @@ from starplot.data.constellations import (
|
|
|
16
16
|
from starplot.data.constellation_stars import CONSTELLATION_HIPS
|
|
17
17
|
from starplot.models import Star
|
|
18
18
|
from starplot.models.constellation import from_tuple as constellation_from_tuple
|
|
19
|
-
from starplot.projections import
|
|
19
|
+
from starplot.projections import Mercator, Miller
|
|
20
20
|
from starplot.profile import profile
|
|
21
21
|
from starplot.styles import PathStyle, LineStyle, LabelStyle
|
|
22
22
|
from starplot.styles.helpers import use_style
|
|
@@ -88,10 +88,8 @@ class ConstellationPlotterMixin:
|
|
|
88
88
|
if constellations_df.empty:
|
|
89
89
|
return
|
|
90
90
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
Projection.MILLER,
|
|
94
|
-
]:
|
|
91
|
+
projection = getattr(self, "projection", None)
|
|
92
|
+
if isinstance(projection, Mercator) or isinstance(projection, Miller):
|
|
95
93
|
transform = self._plate_carree
|
|
96
94
|
else:
|
|
97
95
|
transform = self._geodetic
|
|
@@ -238,7 +236,7 @@ class ConstellationPlotterMixin:
|
|
|
238
236
|
geometries = [line.geometry for line in borders_df.itertuples()]
|
|
239
237
|
|
|
240
238
|
for ls in geometries:
|
|
241
|
-
if ls.length <
|
|
239
|
+
if ls.length < 360:
|
|
242
240
|
ls = ls.segmentize(1)
|
|
243
241
|
|
|
244
242
|
xy = [c for c in ls.coords]
|
starplot/plotters/dsos.py
CHANGED
|
@@ -222,6 +222,8 @@ class DsoPlotterMixin:
|
|
|
222
222
|
if label:
|
|
223
223
|
self.text(label, ra, dec, style.label, gid=f"dso-{d.type}-label")
|
|
224
224
|
|
|
225
|
+
self._add_legend_handle_marker(legend_label, style.marker)
|
|
226
|
+
|
|
225
227
|
else:
|
|
226
228
|
# if no major axis, then just plot as a marker
|
|
227
229
|
self.marker(
|
|
@@ -229,11 +231,10 @@ class DsoPlotterMixin:
|
|
|
229
231
|
dec=dec,
|
|
230
232
|
style=style,
|
|
231
233
|
label=label,
|
|
234
|
+
legend_label=legend_label,
|
|
232
235
|
skip_bounds_check=True,
|
|
233
236
|
gid_marker=f"dso-{d.type}-marker",
|
|
234
237
|
gid_label=f"dso-{d.type}-label",
|
|
235
238
|
)
|
|
236
239
|
|
|
237
240
|
self._objects.dsos.append(_dso)
|
|
238
|
-
|
|
239
|
-
self._add_legend_handle_marker(legend_label, style.marker)
|
|
@@ -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,247 @@
|
|
|
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.models.star import Star
|
|
9
|
+
from starplot.styles import (
|
|
10
|
+
MarkerStyle,
|
|
11
|
+
LegendLocationEnum,
|
|
12
|
+
LegendStyle,
|
|
13
|
+
)
|
|
14
|
+
from starplot.styles.helpers import use_style
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LegendPlotterMixin:
|
|
18
|
+
def _create_legend(self, handles, labels, title, style, set_anchor: bool = False):
|
|
19
|
+
style_kwargs = style.matplot_kwargs(self.scale)
|
|
20
|
+
|
|
21
|
+
target = self.ax
|
|
22
|
+
|
|
23
|
+
if style.location.startswith("outside"):
|
|
24
|
+
target = self.fig
|
|
25
|
+
|
|
26
|
+
style_kwargs["borderaxespad"] = -1 * style.padding
|
|
27
|
+
|
|
28
|
+
legend = Legend(
|
|
29
|
+
target,
|
|
30
|
+
handles=handles,
|
|
31
|
+
labels=labels,
|
|
32
|
+
title=title,
|
|
33
|
+
**style_kwargs,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
legend.set_zorder(
|
|
37
|
+
# zorder is not a valid kwarg to legend(), so we have to set it afterwards
|
|
38
|
+
style.zorder
|
|
39
|
+
)
|
|
40
|
+
legend.get_title().set_color(style.font_color.as_hex())
|
|
41
|
+
|
|
42
|
+
if not set_anchor:
|
|
43
|
+
return legend
|
|
44
|
+
|
|
45
|
+
display_to_axes_transform = self.ax.transAxes.inverted()
|
|
46
|
+
origin_x, origin_y = self.ax.transAxes.transform((0, 0))
|
|
47
|
+
padding_x, padding_y = display_to_axes_transform.transform(
|
|
48
|
+
(origin_x + style.padding_x / 2, origin_y + style.padding_y / 2)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if style.location.startswith("outside"):
|
|
52
|
+
extent = legend.get_window_extent(renderer=self.fig.canvas.get_renderer())
|
|
53
|
+
min_x, min_y = display_to_axes_transform.transform(extent.min)
|
|
54
|
+
max_x, max_y = display_to_axes_transform.transform(extent.max)
|
|
55
|
+
|
|
56
|
+
baseline, _ = display_to_axes_transform.transform(
|
|
57
|
+
(origin_x + 200, origin_y + style.padding_y / 2)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
padding_x += baseline
|
|
61
|
+
width = max_x - min_x + padding_x
|
|
62
|
+
# height = max_y - min_y
|
|
63
|
+
# top_x, top_y = display_to_figure_transform.transform(self.ax.transAxes.transform((1, 1)))
|
|
64
|
+
|
|
65
|
+
bbox = {
|
|
66
|
+
LegendLocationEnum.OUTSIDE_TOP_RIGHT: (1 + width, 1 - padding_y),
|
|
67
|
+
LegendLocationEnum.OUTSIDE_TOP_LEFT: (-1 * width, 1 - padding_y),
|
|
68
|
+
LegendLocationEnum.OUTSIDE_BOTTOM_RIGHT: (1 + width, padding_y),
|
|
69
|
+
LegendLocationEnum.OUTSIDE_BOTTOM_LEFT: (-1 * width, padding_y),
|
|
70
|
+
}.get(style.location)
|
|
71
|
+
|
|
72
|
+
else:
|
|
73
|
+
bbox = {
|
|
74
|
+
LegendLocationEnum.INSIDE_TOP_LEFT: (padding_x, 1 - padding_y),
|
|
75
|
+
LegendLocationEnum.INSIDE_TOP_RIGHT: (1 - padding_x, 1 - padding_y),
|
|
76
|
+
LegendLocationEnum.INSIDE_TOP: (0.5, 1 - padding_y),
|
|
77
|
+
LegendLocationEnum.INSIDE_BOTTOM_LEFT: (padding_x, padding_y),
|
|
78
|
+
LegendLocationEnum.INSIDE_BOTTOM_RIGHT: (1 - padding_x, padding_y),
|
|
79
|
+
LegendLocationEnum.INSIDE_BOTTOM: (0.5, padding_y),
|
|
80
|
+
}.get(style.location)
|
|
81
|
+
|
|
82
|
+
legend.set_bbox_to_anchor(
|
|
83
|
+
bbox=bbox,
|
|
84
|
+
transform=self.ax.transAxes,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return legend
|
|
88
|
+
|
|
89
|
+
@use_style(LegendStyle, "legend")
|
|
90
|
+
def legend(self, title: str = "Legend", style: LegendStyle = None):
|
|
91
|
+
"""
|
|
92
|
+
Plots the legend.
|
|
93
|
+
|
|
94
|
+
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.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
title: Title of the legend, which will be plotted at the top
|
|
98
|
+
style: Styling of the legend. If None, then the plot's style (specified when creating the plot) will be used
|
|
99
|
+
"""
|
|
100
|
+
if not self._legend_handles:
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
if self._legend:
|
|
104
|
+
self._legend.remove()
|
|
105
|
+
|
|
106
|
+
target = self.ax
|
|
107
|
+
|
|
108
|
+
if style.location.startswith("outside"):
|
|
109
|
+
target = self.fig
|
|
110
|
+
|
|
111
|
+
legend = self._create_legend(
|
|
112
|
+
handles=self._legend_handles.values(),
|
|
113
|
+
labels=self._legend_handles.keys(),
|
|
114
|
+
title=title,
|
|
115
|
+
style=style,
|
|
116
|
+
set_anchor=True,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
target.add_artist(legend)
|
|
120
|
+
|
|
121
|
+
self._legend = legend
|
|
122
|
+
self._legend_target = target
|
|
123
|
+
|
|
124
|
+
def _add_to_legend(self, legend):
|
|
125
|
+
if not self._legend:
|
|
126
|
+
self.legend()
|
|
127
|
+
|
|
128
|
+
target = self._legend_target
|
|
129
|
+
|
|
130
|
+
legend_base = self._legend.get_children()[0]
|
|
131
|
+
legend_2 = legend.get_children()[0]
|
|
132
|
+
|
|
133
|
+
# empty legend for padding
|
|
134
|
+
empty = Legend(
|
|
135
|
+
target,
|
|
136
|
+
handles=[],
|
|
137
|
+
labels=[],
|
|
138
|
+
title="",
|
|
139
|
+
# **style_kwargs,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# add empty legend for padding between legend and scale
|
|
143
|
+
legend_base.get_children().extend(empty.get_children()[0].get_children())
|
|
144
|
+
|
|
145
|
+
legend_base.get_children().extend(legend_2.get_children())
|
|
146
|
+
|
|
147
|
+
legend_base.get_children().extend(empty.get_children()[0].get_children())
|
|
148
|
+
|
|
149
|
+
# target.add_artist(self._legend)
|
|
150
|
+
|
|
151
|
+
@use_style(LegendStyle, "legend")
|
|
152
|
+
def star_magnitude_scale(
|
|
153
|
+
self,
|
|
154
|
+
title: str = "Star Magnitude",
|
|
155
|
+
style: LegendStyle = None,
|
|
156
|
+
size_fn: Callable[[Star], float] = callables.size_by_magnitude,
|
|
157
|
+
label_fn: Callable[float, str] = lambda m: str(m),
|
|
158
|
+
start: float = -1,
|
|
159
|
+
stop: float = 9,
|
|
160
|
+
step: float = 1,
|
|
161
|
+
add_to_legend: bool = False,
|
|
162
|
+
):
|
|
163
|
+
"""
|
|
164
|
+
Plots a magnitude scale for stars.
|
|
165
|
+
|
|
166
|
+
!!! example "Experimental"
|
|
167
|
+
|
|
168
|
+
This is currently an "experimental" feature, which means it's likely to be changed and improved in upcoming versions of Starplot.
|
|
169
|
+
It also means the feature likely has limitations.
|
|
170
|
+
|
|
171
|
+
**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!**
|
|
172
|
+
|
|
173
|
+
!!! note "Current Limitations"
|
|
174
|
+
- Only supports size functions that determine size based on magnitude only
|
|
175
|
+
- Only supports default marker for stars (point)
|
|
176
|
+
- Labels can only be plotted to the right of the marker
|
|
177
|
+
- Does not automatically determine the magnitude range of the stars you already plotted
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
title: Title of the legend, which will be plotted at the top
|
|
181
|
+
style: Styling of the magnitude scale. If None, then the plot's `legend` style will be used
|
|
182
|
+
size_fn: Size function for the star markers
|
|
183
|
+
label_fn: Function to determine the label for each magnitude
|
|
184
|
+
start: Starting magnitude
|
|
185
|
+
stop: Stop point (exclusive)
|
|
186
|
+
step: Step-size of each scale entry (i.e. how much to increment each step)
|
|
187
|
+
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)
|
|
188
|
+
"""
|
|
189
|
+
target = self.ax
|
|
190
|
+
|
|
191
|
+
if style.location.startswith("outside"):
|
|
192
|
+
target = self.fig
|
|
193
|
+
|
|
194
|
+
def scale(
|
|
195
|
+
size_fn,
|
|
196
|
+
style: MarkerStyle,
|
|
197
|
+
label_fn,
|
|
198
|
+
start: float,
|
|
199
|
+
stop: float,
|
|
200
|
+
step: float = 1,
|
|
201
|
+
):
|
|
202
|
+
for mag in np.arange(start, stop, step):
|
|
203
|
+
s = style.matplot_kwargs()
|
|
204
|
+
s["markersize"] = (
|
|
205
|
+
size_fn(Star(ra=0, dec=0, magnitude=mag)) ** 0.5
|
|
206
|
+
) * self.scale
|
|
207
|
+
label = label_fn(mag)
|
|
208
|
+
yield Line2D(
|
|
209
|
+
[],
|
|
210
|
+
[],
|
|
211
|
+
**s,
|
|
212
|
+
linestyle="None",
|
|
213
|
+
label=label,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
handles = [
|
|
217
|
+
h
|
|
218
|
+
for h in scale(
|
|
219
|
+
size_fn=size_fn,
|
|
220
|
+
style=self.style.star.marker,
|
|
221
|
+
label_fn=label_fn,
|
|
222
|
+
start=start,
|
|
223
|
+
stop=stop,
|
|
224
|
+
step=step,
|
|
225
|
+
)
|
|
226
|
+
]
|
|
227
|
+
labels = [str(m) for m in np.arange(start, stop, step)]
|
|
228
|
+
|
|
229
|
+
scale = self._create_legend(
|
|
230
|
+
handles=handles,
|
|
231
|
+
labels=labels,
|
|
232
|
+
title=title,
|
|
233
|
+
style=style,
|
|
234
|
+
set_anchor=True if not add_to_legend else False,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
if add_to_legend:
|
|
238
|
+
self._add_to_legend(scale)
|
|
239
|
+
else:
|
|
240
|
+
target.add_artist(scale)
|
|
241
|
+
|
|
242
|
+
# for text in magnitude_scale.get_texts():
|
|
243
|
+
# text.set_ha("center") # horizontal alignment of text item
|
|
244
|
+
# text.set_x(-85) # x-position
|
|
245
|
+
# text.set_y(-90) # y-position
|
|
246
|
+
|
|
247
|
+
# self.ax.add_artist(magnitude_scale)
|
starplot/plotters/milkyway.py
CHANGED
|
@@ -3,7 +3,7 @@ from shapely.ops import unary_union
|
|
|
3
3
|
from starplot.data import db
|
|
4
4
|
from starplot.styles import PolygonStyle
|
|
5
5
|
from starplot.styles.helpers import use_style
|
|
6
|
-
from starplot.geometry import
|
|
6
|
+
from starplot.geometry import split_polygon_at_zero
|
|
7
7
|
from starplot.profile import profile
|
|
8
8
|
|
|
9
9
|
|
|
@@ -23,9 +23,12 @@ class MilkyWayPlotterMixin:
|
|
|
23
23
|
extent = self._extent_mask()
|
|
24
24
|
result = mw.filter(mw.geometry.intersects(extent)).to_pandas()
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
)
|
|
26
|
+
polygons = []
|
|
27
|
+
|
|
28
|
+
for row in result.itertuples():
|
|
29
|
+
polygons.extend(split_polygon_at_zero(row.geometry))
|
|
30
|
+
|
|
31
|
+
mw_union = unary_union(polygons)
|
|
29
32
|
|
|
30
33
|
if mw_union.geom_type == "MultiPolygon":
|
|
31
34
|
polygons = mw_union.geoms
|
|
@@ -34,6 +37,6 @@ class MilkyWayPlotterMixin:
|
|
|
34
37
|
|
|
35
38
|
for p in polygons:
|
|
36
39
|
self.polygon(
|
|
37
|
-
geometry=p,
|
|
40
|
+
geometry=p.buffer(0.001),
|
|
38
41
|
style=style,
|
|
39
42
|
)
|