diff --git a/docs/examples/map-galaxy.md b/docs/examples/map-galaxy.md
new file mode 100644
index 00000000..1319d9dd
--- /dev/null
+++ b/docs/examples/map-galaxy.md
@@ -0,0 +1,14 @@
+---
+title: Galaxy Plot
+---
+[:octicons-arrow-left-24: Back to Examples](/examples)
+
+# Galaxy Plot {.example-header}
+
+
+
+```python
+--8<-- "examples/map_galaxy.py"
+```
+
+
diff --git a/docs/examples/map-milky-way-stars.md b/docs/examples/map-milky-way-stars.md
index f56712dc..c8bb8408 100644
--- a/docs/examples/map-milky-way-stars.md
+++ b/docs/examples/map-milky-way-stars.md
@@ -7,6 +7,7 @@ title: The Milky Way

+In this example, we first plot all stars with a limiting magnitude of 11, which clearly shows the Milky Way. And then we use [Pillow](https://pillow.readthedocs.io/en/latest/index.html) to apply a median filter, which helps make the Milky Way stand out more in the image.
```python
--8<-- "examples/map_milky_way_stars.py"
diff --git a/docs/examples/map-virgo-cluster.md b/docs/examples/map-virgo-cluster.md
index fee35267..77a191d4 100644
--- a/docs/examples/map-virgo-cluster.md
+++ b/docs/examples/map-virgo-cluster.md
@@ -7,7 +7,7 @@ title: Map of the Virgo Galaxy Cluster

-In this example, we create a custom [CollisionHandler](/reference-collisions/) to ensure _all_ labels are plotted in the very busy area of the Virgo Galaxy Cluster:
+In this example, we create a custom [CollisionHandler](/reference-collisions/) for points to ensure _all_ labels are plotted in the very busy area of the Virgo Galaxy Cluster:
```python
diff --git a/docs/index.md b/docs/index.md
index 277bc898..f6650166 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -16,10 +16,12 @@ Starplot is a Python library for creating star charts and maps of the sky
- β **Zenith Charts** - shows the entire sky at a specific time and place
-- π **Horizon Charts** - shows the horizon at a specific time and place
+- π
**Horizon Charts** - shows the horizon at a specific time and place
- π **Optic Simulations** - shows what you'll see through an optic (e.g. telescope) at a specific time and place
+- π **Galactic Charts** - shows a Mollweide projection in galactic coordinates
+
- πͺ **Planets and Deep Sky Objects (DSOs)** - with support for plotting their true extent
- βοΈ **Comets and Satellites** - easy trajectory plotting
diff --git a/docs/reference-collisions.md b/docs/reference-collisions.md
index e2f1924f..f6ec6ba3 100644
--- a/docs/reference-collisions.md
+++ b/docs/reference-collisions.md
@@ -1,6 +1,12 @@
One of the biggest contributors to the visual quality of a map is labeling, which includes choosing carefully _what_ to label and also choosing good _positions_ for those labels. Obviously, you don't want labels to collide with each other, but there's also a few more subtle things to consider when labeling points and areas on a map. Starplot has a `CollisionHandler` to control some of these things.
-When you create a plot, you can specify the default collision handler that'll be used when plotting text. But, you can also override this default on all functions that plot text (either directly or as a side effect). There's one exception to this though: since constellation labels are area-based labels they have their own default collision handler.
+When you create a plot, you can specify the default collision handler for three different types of labels:
+
+- **Points** (`point_label_handler`) - stars, DSOs, etc
+- **Areas** (`area_label_handler`) - constellations
+- **Paths** (`path_label_handler`) - ecliptic, celestial equator, etc
+
+You can also override these defaults on all functions that plot text. There are three distinct types of collision handlers because it's very common to want different rules for different types of labels. For example, the default area collision handler allows collisions with constellation lines, but the point handler does not.
_See the [Virgo Galaxy Cluster](/examples/map-virgo-cluster/) plot for an example of using a custom collision handler._
@@ -10,6 +16,45 @@ _See the [Virgo Galaxy Cluster](/examples/map-virgo-cluster/) plot for an exampl
The collision handler is a newer feature of Starplot (introduced in version 0.19.0), and will continue to evolve in future versions. As always, if you notice any unexpected behavior with it, please [open an issue on GitHub](https://github.com/steveberardi/starplot/issues).
+## Defaults
+
+Below are the defaults for each type of collision handler. These are the defaults for _all_ plot types.
+
+### Points
+
+```python
+CollisionHandler(
+ attempts=10,
+ anchor_fallbacks=[
+ AnchorPointEnum.BOTTOM_RIGHT,
+ AnchorPointEnum.TOP_LEFT,
+ AnchorPointEnum.TOP_RIGHT,
+ AnchorPointEnum.BOTTOM_LEFT,
+ AnchorPointEnum.BOTTOM_CENTER,
+ AnchorPointEnum.TOP_CENTER,
+ AnchorPointEnum.RIGHT_CENTER,
+ AnchorPointEnum.LEFT_CENTER,
+ ]
+)
+```
+
+### Areas
+
+```python
+CollisionHandler(
+ allow_constellation_line_collisions=True
+)
+```
+
+### Paths
+
+```python
+CollisionHandler(
+ allow_constellation_line_collisions=True
+)
+```
+
+
::: starplot.CollisionHandler
options:
members_order: source
diff --git a/docs/reference-galaxyplot.md b/docs/reference-galaxyplot.md
new file mode 100644
index 00000000..947ef48b
--- /dev/null
+++ b/docs/reference-galaxyplot.md
@@ -0,0 +1,20 @@
+**Galaxy plots will plot everything in galactic coordinates, using a Mollweide projection**. These plots will always plot the entire galactic sphere, since that's how they're most commonly used.
+
+Although they plot everything in galactic coordinates, all functions still expect equatorial coordinates (RA/DEC). This decision was made for two reasons: it seems most astronomical data is presented in equatorial coordinates, and creating a transformation framework in Starplot would be a pretty large project so it'll be reserved for a future version.
+
+Stars on galaxy plots are plotted in their [_astrometric_ positions](reference-positions.md).
+
+!!! tip "New Feature - Feedback wanted!"
+
+ These plots are a newer feature of Starplot (introduced in version 0.20.0), and will continue to evolve in future versions.
+
+ If you notice any unexpected behavior or you think there's a useful feature missing, please [open an issue on GitHub](https://github.com/steveberardi/starplot/issues).
+
+
+::: starplot.GalaxyPlot
+ options:
+ inherited_members: true
+ merge_init_into_class: true
+ show_root_heading: true
+ docstring_section_style: list
+
diff --git a/docs/reference-positions.md b/docs/reference-positions.md
index 991d6270..565167a6 100644
--- a/docs/reference-positions.md
+++ b/docs/reference-positions.md
@@ -12,6 +12,7 @@ Plots that use astrometric positions:
- [MapPlot](reference-mapplot.md)
- [ZenithPlot](reference-zenithplot.md)
+- [GalaxyPlot](reference-galaxyplot.md)
### Apparent Position
diff --git a/docs/tutorial/04.md b/docs/tutorial/04.md
index de041d81..48cbfb86 100644
--- a/docs/tutorial/04.md
+++ b/docs/tutorial/04.md
@@ -14,7 +14,7 @@ title: 4 - Creating a Basic Map | Tutorial
# 4 - Creating a Basic Map
- { width="900" }
+ 
The zenith plot is known as a [perspective projection](https://en.wikipedia.org/wiki/Perspective_(graphical)), which means it depends on a time and place. They're useful for many things in astronomy, but Starplot also lets you create general-purpose maps of the sky that are independent of location.
diff --git a/docs/tutorial/06.md b/docs/tutorial/06.md
index b45874d7..13e82909 100644
--- a/docs/tutorial/06.md
+++ b/docs/tutorial/06.md
@@ -14,7 +14,7 @@ title: 6 - Selecting Objects to Plot | Tutorial
# 6 - Selecting Objects to Plot
- { width="700" }
+ 
When plotting stars, constellations, or deep sky objects (DSOs), you may want to limit the plotted objects by more than just a limiting magnitude. Starplot provides a way to [filter objects by using expressions](/reference-selecting-objects/). This allows you to be very specific about which objects to plot, and it also gives you a way to style objects differently (e.g. if you want to style very bright stars differently than dim stars).
diff --git a/examples/map_canis_major.py b/examples/map_canis_major.py
index 4975ad08..ac36022d 100644
--- a/examples/map_canis_major.py
+++ b/examples/map_canis_major.py
@@ -24,7 +24,7 @@
)
p.line(
geometry=canis_major.border,
- style=p.style.constellation_borders,
+ style__line=p.style.constellation_borders,
)
p.open_clusters(where=[_.magnitude < 9], where_true_size=[False])
p.stars(where=[_.magnitude < 9], where_labels=[_.magnitude < 4], bayer_labels=True)
diff --git a/examples/map_galaxy.py b/examples/map_galaxy.py
new file mode 100644
index 00000000..b677f654
--- /dev/null
+++ b/examples/map_galaxy.py
@@ -0,0 +1,65 @@
+from starplot import _, GalaxyPlot, DSO
+from starplot.styles import PlotStyle, extensions
+from starplot.callables import size_by_magnitude_factory
+
+_sizer = size_by_magnitude_factory(6, 0.03, 12)
+
+style = PlotStyle().extend(
+ extensions.BLUE_NIGHT,
+ extensions.MAP,
+)
+
+p = GalaxyPlot(
+ style=style,
+ resolution=5000,
+ scale=0.83,
+)
+p.gridlines()
+
+p.galactic_equator(num_labels=2)
+p.celestial_equator(num_labels=2)
+p.ecliptic(num_labels=2)
+
+p.milky_way()
+
+p.stars(
+ where=[_.magnitude < 7],
+ where_labels=[False],
+ size_fn=_sizer,
+ style__marker__edge_color="#c5c5c5",
+)
+
+lmc = DSO.get(name="ESO056-115")
+smc = DSO.get(name="NGC0292")
+mc_style = {
+ "font_color": "#acc2e0",
+ "font_size": 42,
+ "font_weight": 700,
+ "border_width": 8,
+ "border_color": "#1e232a",
+}
+
+p.text(
+ "LMC",
+ ra=lmc.ra,
+ dec=lmc.dec,
+ style=mc_style,
+)
+
+p.text(
+ "SMC",
+ ra=smc.ra,
+ dec=smc.dec,
+ style=mc_style,
+)
+
+p.open_clusters(
+ where=[(_.magnitude < 16) | (_.magnitude.isnull())],
+ where_labels=[False],
+ where_true_size=[False],
+)
+p.legend()
+
+p.title("Open Clusters Around the Milky Way", style__font_size=86)
+
+p.export("map_galaxy.png", padding=1)
diff --git a/examples/map_milky_way_stars.py b/examples/map_milky_way_stars.py
index 52f56be1..71b1b1f0 100644
--- a/examples/map_milky_way_stars.py
+++ b/examples/map_milky_way_stars.py
@@ -1,3 +1,5 @@
+from PIL import Image, ImageFilter
+
from starplot import MapPlot, Mollweide, _
from starplot.data.catalogs import BIG_SKY
from starplot.styles import PlotStyle, extensions
@@ -10,38 +12,23 @@
_sizer = size_by_magnitude_factory(6, 0.02, 7)
-
-def alpha(s):
- if s.magnitude > 9:
- return 0.5
- else:
- return 0.9
-
-
p = MapPlot(
projection=Mollweide(),
style=style,
resolution=4800,
)
-
p.stars(
where=[_.magnitude < 11],
where_labels=[False],
size_fn=_sizer,
- alpha_fn=alpha,
- color_fn=color_by_bv,
- catalog=BIG_SKY,
- style__marker__edge_color="#c5c5c5",
-)
-p.stars(
- where=[_.magnitude < 6],
- where_labels=[False],
- size_fn=lambda s: _sizer(s) * 1.5,
- alpha_fn=lambda s: 0.4,
+ alpha_fn=lambda s: 0.95 if s.magnitude < 9 else 0.6,
color_fn=color_by_bv,
catalog=BIG_SKY,
- style__marker__symbol="star_8",
- style__marker__edge_color=None,
+ style__marker__edge_color="#fff",
)
-
p.export("map_milky_way_stars.png", padding=0.1, transparent=True)
+
+# apply a median filter to increase contrast
+with Image.open("map_milky_way_stars.png") as img:
+ filtered = img.filter(ImageFilter.MedianFilter(size=5))
+ filtered.save("map_milky_way_stars.png")
diff --git a/examples/map_orthographic.py b/examples/map_orthographic.py
index 9ceecf47..c0a9968e 100644
--- a/examples/map_orthographic.py
+++ b/examples/map_orthographic.py
@@ -1,5 +1,5 @@
from datetime import datetime
-from pytz import timezone
+from zoneinfo import ZoneInfo
from starplot import MapPlot, Orthographic, Observer, _
from starplot.styles import PlotStyle, extensions
@@ -10,7 +10,7 @@
extensions.MAP,
)
-tz = timezone("America/Los_Angeles")
+tz = ZoneInfo("America/Los_Angeles")
dt = datetime(2024, 10, 19, 21, 00, tzinfo=tz)
observer = Observer(
diff --git a/examples/map_virgo_cluster.py b/examples/map_virgo_cluster.py
index 0f3d86b0..9e114c46 100644
--- a/examples/map_virgo_cluster.py
+++ b/examples/map_virgo_cluster.py
@@ -33,7 +33,7 @@
style=style,
resolution=3000,
scale=1,
- collision_handler=collision_handler,
+ point_label_handler=collision_handler,
)
p.title("Virgo Cluster", style__font_color="hsl(330, 44%, 92%)")
p.stars(where=[_.magnitude < 12], where_labels=[False])
diff --git a/hash_checks/hashlock.yml b/hash_checks/hashlock.yml
index 729b1575..5248796b 100644
--- a/hash_checks/hashlock.yml
+++ b/hash_checks/hashlock.yml
@@ -3,13 +3,13 @@ horizon_base:
filename: /starplot/hash_checks/data/horizon-base.png
phash: c1843fd17e913b86
horizon_gradient_background:
- dhash: f1682072b2a6a294f1682672b2beaa94f168a2b2b29a9ad0
+ dhash: f1682072b2a6a294f1682672b2beaa94f168a6b2b29a9ad0
filename: /starplot/hash_checks/data/horizon-gradient-background.png
phash: 945e62c12bc56ef4
horizon_north_celestial_pole:
dhash: 989aaa888ab2aab09a8a8aba8ab2a2b4928a8aba8aa2aa90
filename: /starplot/hash_checks/data/horizon-north-celestial-pole.png
- phash: d5d17a902ad47ec2
+ phash: d5d13a902ad47ee2
map_allow_all_collisions:
dhash: 663793caa4314b26663793caa4334b26663393caa6334b26
filename: /starplot/hash_checks/data/map-allow-all-collisions.png
@@ -23,7 +23,7 @@ map_coma_berenices_dso_size:
filename: /starplot/hash_checks/data/map-coma-berenices-dso-size.png
phash: 92926d6d7f92124d
map_constellation_clip_path:
- dhash: b20aee96e4781910b34aee96e4781910b20aee96e4781910
+ dhash: b20aee96e4781910b24aee96e4781910b20aee96e4781910
filename: /starplot/hash_checks/data/map-constellation-clip-path.png
phash: 99c41b19ccd98ece
map_custom_stars:
@@ -47,23 +47,23 @@ map_mollweide:
filename: /starplot/hash_checks/data/map-mollweide.png
phash: ebcab698e3349461
map_moon_phase_waxing_crescent:
- dhash: 0008344a4a3408000008204a4a2008008088804a4a808880
+ dhash: 000830484830080000082048482008008088804848808880
filename: /starplot/hash_checks/data/map-moon-phase-waxing-crescent.png
phash: b38ccc3333cccc33
map_orion_base:
- dhash: 18393b3e2e2f656819393b5e2e2365685c19399e2e2b6568
+ dhash: 18393b3e2e2f656819393b5e2e23656a5c19399e2e2b6568
filename: /starplot/hash_checks/data/map-orion-base.png
- phash: bf7952649505b594
+ phash: bff9526495843594
map_orion_extra:
- dhash: 181b397b2d2f6d6d0b1b195b2f23656d5a1b1b9a2b2f6d6d
+ dhash: 181b397b2d2f6d6d4b1b195b2b236d6d521b1b9a2b2b6d6d
filename: /starplot/hash_checks/data/map-orion-extra.png
phash: bf716646859e9096
map_plot_custom_clip_path_virgo:
- dhash: 03032913979c8c0043032d07878c840003032507878e8400
+ dhash: 03032913978c8c0043032d07878c840003032507878e8400
filename: /starplot/hash_checks/data/map-custom-clip-path-virgo.png
- phash: ec6dc9a595929296
+ phash: ec6dc9e594929296
map_plot_limit_by_geometry:
- dhash: a02189a4568c6552a0219184160d6512a02199b4140c6512
+ dhash: a02189a4568c6552a0219184160d6512a02199b4140c6d12
filename: /starplot/hash_checks/data/map-limit-by-geometry.png
phash: b2d88e33c986f349
map_scope_bino_fov:
@@ -71,15 +71,15 @@ map_scope_bino_fov:
filename: /starplot/hash_checks/data/map-scope-bino-fov.png
phash: f3504c72735c4c73
map_stereo_base:
- dhash: 45c37b3192a9b9b245c35b3192a1b99245c35b3092a939b2
+ dhash: 45c37b3192a9b9b245c35b3192a1199245c34b3192a93992
filename: /starplot/hash_checks/data/map-stereo-north-base.png
phash: 8a2f62d2949e4fb4
map_with_planets:
- dhash: 639104e68d5b323a6b9104e68d5b323a2a9104e68d5b3a3b
+ dhash: 639104e68d5b323a6b9104e68d5b323a2a9104e68d5b323b
filename: /starplot/hash_checks/data/map-mercator-planets.png
phash: e8429d89a43f5f52
map_with_planets_gradient:
- dhash: 946eba117224c544946efa1952248544846eaa155224a222
+ dhash: 946eba117224c544946efa195224c544846ea23d522cb222
filename: /starplot/hash_checks/data/map-mercator-planets-gradient.png
phash: 9d2de276db50a02d
map_wrapping:
@@ -95,7 +95,7 @@ optic_clipping:
filename: /starplot/hash_checks/data/optic-clipping.png
phash: 95687a872f9868f2
optic_iss_moon_transit:
- dhash: 70d0ceb0f088f47071d08eb0f48ad47171c48ab6b4aac471
+ dhash: 70d0ceb0f088f47071d08eb4f48ac47171c48ab6b4aac471
filename: /starplot/hash_checks/data/optic-iss-moon-transit.png
phash: c49d33624c9d3367
optic_m45_binoculars:
@@ -121,13 +121,13 @@ optic_m45_scope_gradient:
optic_moon_phase_full:
dhash: 0e334d71714d330e0e334d71714d330e0e334d71714d330e
filename: /starplot/hash_checks/data/optic-moon-phase-full.png
- phash: ff63a06bc5268326
+ phash: ff63a062d5338626
optic_moon_phase_new:
dhash: 0814084d4d0814080814084d4d0814080814084d4d081408
filename: /starplot/hash_checks/data/optic-moon-phase-new.png
- phash: b333cccc3333998c
+ phash: b333cccc333389cc
optic_moon_phase_waxing_crescent:
- dhash: 0e3345495145330e0e3345495145330e0e3345495145330e
+ dhash: 0e334559514d330e0e334559514d330e0e334559514d330e
filename: /starplot/hash_checks/data/optic-moon-phase-waxing-crescent.png
phash: bb26e4999166c699
optic_orion_nebula_refractor:
@@ -147,7 +147,7 @@ optic_wrapping:
filename: /starplot/hash_checks/data/optic-wrapping.png
phash: d1066e1b792ef138
zenith_base:
- dhash: 71f8cc8e8ad4e87171f8cc868ad4e87171f8cc868ad4e871
+ dhash: 71f8cc8eaad4e87171f8cc868ad4e87171f8cc868ed4e871
filename: /starplot/hash_checks/data/zenith-base.png
phash: 90d50b7d2955645f
zenith_chinese:
@@ -155,6 +155,6 @@ zenith_chinese:
filename: /starplot/hash_checks/data/zenith-chinese.png
phash: ed30c1469ecb1bcc
zenith_gradient:
- dhash: 5486133b312b867044863339312b865471cc960f078ecc71
+ dhash: 548613333123867044863339312b865471cc960f078ecc71
filename: /starplot/hash_checks/data/zenith-gradient.png
- phash: e93c94c29acb19cd
+ phash: ed3c84c29acb19cd
diff --git a/hash_checks/map_checks.py b/hash_checks/map_checks.py
index 2c96f857..78835826 100644
--- a/hash_checks/map_checks.py
+++ b/hash_checks/map_checks.py
@@ -594,7 +594,7 @@ def check_map_plot_custom_clip_path_virgo():
(13 * 15, 10),
(13.42 * 15, -11.1613), # Spica
],
- style={
+ style__line={
"color": "red",
"width": 9,
},
@@ -680,7 +680,7 @@ def check_map_allow_all_collisions():
style=STYLE,
resolution=3000,
scale=1.5,
- collision_handler=handler,
+ point_label_handler=handler,
)
p.stars(where=[_.magnitude < 6], bayer_labels=True, flamsteed_labels=True)
p.dsos(where=[_.magnitude < 10], where_true_size=[False])
@@ -706,7 +706,7 @@ def check_map_allow_marker_and_line_collisions():
style=STYLE,
resolution=3000,
scale=1.5,
- collision_handler=handler,
+ point_label_handler=handler,
)
p.constellations()
p.stars(where=[_.magnitude < 8], bayer_labels=True, flamsteed_labels=True)
@@ -756,7 +756,7 @@ def check_map_constellation_clip_path():
p.line(
geometry=constellation.border,
- style=p.style.constellation_borders,
+ style__line=p.style.constellation_borders,
)
for hip1, hip2 in constellation.star_hip_lines:
@@ -767,7 +767,7 @@ def check_map_constellation_clip_path():
(star1.ra, star1.dec),
(star2.ra, star2.dec),
],
- style=p.style.constellation_lines,
+ style__line=p.style.constellation_lines,
)
p.stars(
diff --git a/mkdocs.yml b/mkdocs.yml
index 76c940d2..796a9a17 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -12,6 +12,7 @@ nav:
- ZenithPlot: reference-zenithplot.md
- HorizonPlot: reference-horizonplot.md
- OpticPlot: reference-opticplot.md
+ - GalaxyPlot: reference-galaxyplot.md
- Models: reference-models.md
- Selecting Objects: reference-selecting-objects.md
- Styling: reference-styling.md
diff --git a/scripts/voronoi.py b/scripts/voronoi.py
index 78c2ba81..b19e53c1 100644
--- a/scripts/voronoi.py
+++ b/scripts/voronoi.py
@@ -10,7 +10,6 @@
delaunay_triangles,
distance,
)
-from pytz import timezone
from matplotlib import patches
from starplot import Star, DSO, Constellation
from starplot.styles import PlotStyle, extensions, PolygonStyle
diff --git a/src/starplot/__init__.py b/src/starplot/__init__.py
index 3a4328c9..b49e5a13 100644
--- a/src/starplot/__init__.py
+++ b/src/starplot/__init__.py
@@ -2,13 +2,14 @@
"""Star charts and maps of the sky"""
-__version__ = "0.19.6"
+__version__ = "0.20.0"
from .plots import (
MapPlot,
HorizonPlot,
OpticPlot,
ZenithPlot,
+ GalaxyPlot,
)
from .models import (
DSO,
diff --git a/src/starplot/coordinates.py b/src/starplot/coordinates.py
index 7abe891d..236ad1ab 100644
--- a/src/starplot/coordinates.py
+++ b/src/starplot/coordinates.py
@@ -4,3 +4,4 @@ class CoordinateSystem:
AXES = "axes"
PROJECTED = "projected"
DISPLAY = "display"
+ GALACTIC = "galactic"
diff --git a/src/starplot/data/constellations.py b/src/starplot/data/constellations.py
index fcb8d8ec..56814011 100644
--- a/src/starplot/data/constellations.py
+++ b/src/starplot/data/constellations.py
@@ -1,7 +1,7 @@
from functools import cache
from pathlib import Path
-from ibis import _, row_number
+from ibis import _
from starplot.config import settings
from starplot.data import db
@@ -69,18 +69,3 @@ def load(
c = c.filter(_.pk.isin(pks))
return c
-
-
-def load_borders(extent=None, filters=None):
- filters = filters or []
- con = db.connect()
- c = con.table("constellation_borders")
- c = c.mutate(pk=row_number())
-
- if extent:
- filters.append(_.geometry.intersects(extent))
-
- if filters:
- return c.filter(*filters)
-
- return c
diff --git a/src/starplot/data/db.py b/src/starplot/data/db.py
index bd1fad08..80674cf9 100644
--- a/src/starplot/data/db.py
+++ b/src/starplot/data/db.py
@@ -1,3 +1,5 @@
+from functools import cache
+
from ibis import duckdb
from starplot.config import settings
@@ -11,10 +13,10 @@
}
-def connect():
- path = settings.data_path / "duckdb-extensions"
+@cache
+def _connect(extensions_path):
connection = duckdb.connect()
- connection.raw_sql(f"SET extension_directory = '{str(path)}';")
+ connection.raw_sql(f"SET extension_directory = '{str(extensions_path)}';")
connection.raw_sql("INSTALL spatial;")
connection.load_extension("spatial")
@@ -24,3 +26,8 @@ def connect():
connection.read_parquet(NAME_TABLES[table_name], table_name=table_name)
return connection
+
+
+def connect():
+ path = settings.data_path / "duckdb-extensions"
+ return _connect(extensions_path=path)
diff --git a/src/starplot/geod.py b/src/starplot/geod.py
deleted file mode 100644
index c7f496e6..00000000
--- a/src/starplot/geod.py
+++ /dev/null
@@ -1,96 +0,0 @@
-import math
-
-import pyproj
-import numpy as np
-
-GEOD = pyproj.Geod("+a=6378137 +f=0.0", sphere=True)
-
-
-def distance_m(distance_degrees: float, lat: float = 0, lon: float = 0):
- _, _, distance = GEOD.inv(lon, lat, lon + distance_degrees, lat)
- return distance
-
-
-def away_from_poles(dec):
- # for some reason cartopy does not like plotting things EXACTLY at the poles
- # so, this is a little hack to avoid the bug (or maybe a misconception?) by
- # plotting a tiny bit away from the pole
- if dec == 90:
- dec -= 0.00000001
- if dec == -90:
- dec += 0.00000001
-
- return dec
-
-
-def rectangle(
- center: tuple,
- height_degrees: float,
- width_degrees: float,
- angle: float = 0,
-) -> list:
- ra, dec = center
- dec = away_from_poles(dec)
- angle = 180 - angle
-
- height_m = distance_m(height_degrees)
- width_m = distance_m(width_degrees)
-
- c = math.sqrt((height_m / 2) ** 2 + (width_m / 2) ** 2)
- angle_th = math.atan((height_m / 2) / (width_m / 2))
-
- angle_th = math.degrees(angle_th)
- points = []
-
- p0_lon, p0_lat, _ = GEOD.fwd([ra], [dec], angle + (90 - angle_th), c)
- p1_lon, p1_lat, _ = GEOD.fwd([ra], [dec], angle + (90 + angle_th), c)
- p2_lon, p2_lat, _ = GEOD.fwd([ra], [dec], angle + (270 - angle_th), c)
- p3_lon, p3_lat, _ = GEOD.fwd([ra], [dec], angle + (270 + angle_th), c)
-
- points.append((p0_lon[0], p0_lat[0]))
- points.append((p1_lon[0], p1_lat[0]))
- points.append((p2_lon[0], p2_lat[0]))
- points.append((p3_lon[0], p3_lat[0]))
-
- return points
-
-
-def ellipse(
- center: tuple,
- height_degrees: float,
- width_degrees: float,
- angle: float = 0,
- num_pts: int = 100,
- start_angle: int = 0,
- end_angle: int = 360,
-) -> list:
- ra, dec = center
- dec = away_from_poles(dec)
- angle = 180 - angle
-
- height = distance_m(height_degrees / 2) # b
- width = distance_m(width_degrees / 2) # a
- step_size = (end_angle - start_angle) / num_pts
-
- points = []
- for angle_pt in np.arange(start_angle, end_angle + step_size, step_size):
- radians = math.radians(angle_pt)
- radius_a = (height * width) / math.sqrt(
- height**2 * (math.sin(radians)) ** 2
- + width**2 * (math.cos(radians)) ** 2
- )
- lon, lat, _ = GEOD.fwd([ra], [dec], angle + angle_pt, radius_a)
-
- points.append((lon[0], lat[0]))
-
- return points
-
-
-def circle(center: tuple, radius_degrees: float, num_pts: int = 100) -> list:
- return ellipse(
- center,
- radius_degrees * 2,
- radius_degrees * 2,
- angle=0,
- num_pts=num_pts,
- )
diff --git a/src/starplot/geometry.py b/src/starplot/geometry.py
index 151aa089..c8ca99f6 100644
--- a/src/starplot/geometry.py
+++ b/src/starplot/geometry.py
@@ -1,11 +1,11 @@
import random
import math
-from typing import Union
-from shapely import transform, union_all
-from shapely.geometry import Point, Polygon, MultiPolygon, LineString
+import pyproj
+import numpy as np
-from starplot import geod
+from shapely import union_all
+from shapely.geometry import Point, Polygon, LineString
GLOBAL_EXTENT = Polygon(
[
@@ -17,87 +17,161 @@
]
)
+GEOD = pyproj.Geod("+a=6378137 +f=0.0", sphere=True)
-def circle(center, diameter_degrees, num_pts=100):
- points = geod.ellipse(
- center,
- diameter_degrees,
- diameter_degrees,
- angle=0,
- num_pts=num_pts,
- )
- # points = [
- # (round(24 - utils.lon_to_ra(lon), 4), round(dec, 4)) for lon, dec in points
- # ]
- points = [(round(lon, 4), round(dec, 4)) for lon, dec in points]
- return Polygon(points)
+def distance_m(distance_degrees: float, lat: float = 0, lon: float = 0):
+ _, _, distance = GEOD.inv(lon, lat, lon + distance_degrees, lat)
+ return distance
-def to_24h(geometry: Union[Point, Polygon, MultiPolygon]):
- geometry_type = str(geometry.geom_type)
- if geometry_type == "MultiPolygon":
- polygons = [transform(p, lambda c: c * [1 / 15, 1]) for p in geometry.geoms]
- return MultiPolygon(polygons)
+def away_from_poles(dec):
+ # for some reason cartopy does not like plotting things EXACTLY at the poles
+ # so, this is a little hack to avoid the bug (or maybe a misconception?) by
+ # plotting a tiny bit away from the pole
+ if dec == 90:
+ dec -= 0.00000001
+ if dec == -90:
+ dec += 0.00000001
- return transform(geometry, lambda c: c * [1 / 15, 1])
+ return dec
-def unwrap_polygon(polygon: Polygon) -> Polygon:
- points = list(zip(*polygon.exterior.coords.xy))
- new_points = []
- prev = None
+def rectangle(
+ center: tuple,
+ height_degrees: float,
+ width_degrees: float,
+ angle: float = 0,
+) -> Polygon:
+ """
+ Returns a rectangle polygon on a sphere, with coordinates in degrees.
- for x, y in points:
- if prev is not None and prev > 20 and x < 12:
- x += 24
- elif prev is not None and prev < 12 and x > 20:
- x -= 24
- new_points.append((x, y))
- prev = x
+ If the rectangle crosses the meridian at X=0, then the X coordinates will extend past 360.
- return Polygon(new_points)
+ Args:
+ center: Center of rectangle (x, y) in degrees
+ height_degrees: Height of rectangle in degrees
+ width_degrees: Width of rectangle in degrees
+ angle: Angle to rotate rectangle, in degrees
+ Returns:
+ Polygon of rectangle
+ """
-def unwrap_polygon_360_old(polygon: Polygon) -> Polygon:
- points = list(zip(*polygon.exterior.coords.xy))
- new_points = []
- prev = None
+ ra, dec = center
+ dec = away_from_poles(dec)
+ angle = 180 - angle
+
+ height_m = distance_m(height_degrees)
+ width_m = distance_m(width_degrees)
+
+ distance = math.sqrt((height_m / 2) ** 2 + (width_m / 2) ** 2)
+ angle_th = math.atan((height_m / 2) / (width_m / 2))
+
+ angle_th = math.degrees(angle_th)
+ points = []
+
+ lons, lats, _ = GEOD.fwd(
+ [ra] * 4,
+ [dec] * 4,
+ [
+ angle + (90 - angle_th),
+ angle + (90 + angle_th),
+ angle + (270 - angle_th),
+ angle + (270 + angle_th),
+ ],
+ [distance] * 4,
+ )
+ if min(lons) < 0:
+ lons = [lon + 360 for lon in lons]
- for x, y in points:
- if prev is not None and prev > 300 and x < 180:
- x -= 360
- elif prev is not None and prev < 180 and x > 300:
- x += 360
- new_points.append((x, y))
- prev = x
+ points = list(zip(lons, lats))
+ points = [(round(ra, 4), round(dec, 4)) for ra, dec in points]
+ points.append(points[0])
+ return Polygon(points)
- return Polygon(new_points)
+def ellipse(
+ center: tuple,
+ height_degrees: float,
+ width_degrees: float,
+ angle: float = 0,
+ num_pts: int = 100,
+ start_angle: int = 0,
+ end_angle: int = 360,
+) -> Polygon:
+ """
+ Returns an ellipse polygon on a sphere, with coordinates in degrees.
-def unwrap_polygon_360_inverse(polygon: Polygon) -> Polygon:
- ra, dec = [p for p in polygon.exterior.coords.xy]
+ If the ellipse crosses the meridian at X=0, then the X coordinates will extend past 360.
- if min(ra) < 180 and max(ra) > 300:
- new_ra = [r + 360 if r < 50 else r for r in ra]
- points = list(zip(new_ra, dec))
- return Polygon(points)
+ Args:
+ center: Center of ellipse (x, y) in degrees
+ height_degrees: Height of ellipse in degrees
+ width_degrees: Width of ellipse in degrees
+ angle: Angle to rotate ellipse, in degrees
+ num_pts: Number of evenly-spaced points to generate for the ellipse. At least 100 is recommended to ensure good-looking curves.
+ start_angle: Angle to start drawing the ellipse
+ end_angle: Angle to stop drawing the ellipse
- return polygon
+ Returns:
+ Polygon of ellipse
+ """
+ ra, dec = center
+ dec = away_from_poles(dec)
+ angle = 180 - angle
+
+ height = distance_m(height_degrees / 2) # b
+ width = distance_m(width_degrees / 2) # a
+ step_size = (end_angle - start_angle) / num_pts
+
+ lons = []
+ lats = []
+ points = []
+ for angle_pt in np.arange(start_angle, end_angle + step_size, step_size):
+ radians = math.radians(angle_pt)
+ radius_a = (height * width) / math.sqrt(
+ height**2 * (math.sin(radians)) ** 2
+ + width**2 * (math.cos(radians)) ** 2
+ )
+ lon, lat, _ = GEOD.fwd([ra], [dec], angle + angle_pt, radius_a)
-def unwrap_polygon_360(polygon: Polygon) -> Polygon:
- ra, dec = [p for p in polygon.exterior.coords.xy]
+ lons.append(lon[0])
+ lats.append(lat[0])
- if min(ra) < 180 and max(ra) > 300:
- new_ra = [r - 360 if r > 300 else r for r in ra]
- return Polygon(list(zip(new_ra, dec)))
+ if min(lons) < 0:
+ lons = [lon + 360 for lon in lons]
- return polygon
+ points = list(zip(lons, lats))
+ points = [(round(ra, 4), round(dec, 4)) for ra, dec in points]
+ points.append(points[0])
+ return Polygon(points)
+
+
+def circle(center, diameter_degrees, num_pts=100) -> Polygon:
+ return ellipse(
+ center,
+ diameter_degrees,
+ diameter_degrees,
+ angle=0,
+ num_pts=num_pts,
+ )
def union_at_zero(a: Polygon, b: Polygon) -> Polygon:
- """Returns union of two polygons"""
+ """
+ Returns union of two polygons on a sphere, with coordinates in degrees.
+
+ If the two polygons share a border at the X=0 meridian, then the returned union will have X coordiantes that extend past 360 degrees.
+
+ Args:
+ a: First polygon
+ b: Second polygon
+
+ Returns
+ Polygon union of first and second polygon
+ """
a_ra = list(a.exterior.coords.xy)[0]
b_ra = list(b.exterior.coords.xy)[0]
@@ -160,71 +234,6 @@ def split_polygon_at_zero(polygon: Polygon) -> list[Polygon]:
return [polygon]
-def split_polygon_at_360(polygon: Polygon) -> list[Polygon]:
- """
- Splits a polygon at 360 degrees
-
- Args:
- polygon: Polygon that possibly needs splitting
-
- Returns:
- List of polygons
- """
- ra, _ = [p for p in polygon.exterior.coords.xy]
-
- if max(ra) > 360:
- polygon_1 = polygon.intersection(
- Polygon(
- [
- [0, -90],
- [360, -90],
- [360, 90],
- [0, 90],
- [0, -90],
- ]
- )
- )
-
- polygon_2 = polygon.intersection(
- Polygon(
- [
- [360, -90],
- [720, -90],
- [720, 90],
- [360, 90],
- [360, -90],
- ]
- )
- )
-
- p2_ra, p2_dec = [p for p in polygon_2.exterior.coords.xy]
- p2_new_ra = [ra - 360 for ra in p2_ra]
-
- return [polygon_1, Polygon(list(zip(p2_new_ra, p2_dec)))]
-
- return [polygon]
-
-
-def random_point_in_polygon(
- polygon: Polygon, max_iterations: int = 100, seed: int = None
-) -> Point:
- """Returns a random point inside a shapely polygon"""
- if seed:
- random.seed(seed)
-
- ctr = 0
- x0, y0, x1, y1 = polygon.bounds
-
- while ctr < max_iterations:
- x = random.uniform(x0, x1)
- y = random.uniform(y0, y1)
- point = Point(x, y)
- if polygon.contains(point):
- return point
-
- return None
-
-
def random_point_in_polygon_at_distance(
polygon: Polygon,
origin_point: Point,
@@ -250,47 +259,45 @@ def random_point_in_polygon_at_distance(
return None
-def wrapped_polygon_adjustment_old(polygon: Polygon) -> int:
+def is_wrapped_polygon(polygon: Polygon) -> bool:
if "MultiPolygon" == str(polygon.geom_type):
- return 0
-
- points = list(zip(*polygon.exterior.coords.xy))
- prev = None
+ return False
- for ra, _ in points:
- if prev is not None and prev > 300 and ra < 180:
- return 360
- elif prev is not None and prev < 180 and ra > 300:
- return -360
- prev = ra
+ ra, _ = [p for p in polygon.exterior.coords.xy]
- return 0
+ if min(ra) < 180 and max(ra) > 300:
+ return True
+ return False
-def wrapped_polygon_adjustment(polygon: Polygon) -> int:
- if "MultiPolygon" == str(polygon.geom_type):
- return 0
- ra, _ = [p for p in polygon.exterior.coords.xy]
+def line_segment(start, end, step) -> list[tuple[float, float]]:
+ """Returns coordinates on the line from start to end at the specified step-size"""
+ return LineString([start, end]).segmentize(step).coords
- if min(ra) < 180 and max(ra) > 300:
- return 360
- return 0
+class BaseGeometry:
+ """
+ Wrapper around shapely geometries
-def is_wrapped_polygon(polygon: Polygon) -> bool:
- if "MultiPolygon" == str(polygon.geom_type):
- return False
+ Two types of polygons needed:
+ 1. For intersection testing: needs to be split at zero and restricted to 0-360
+ 2. For plotting: needs to be extended past 360 if applicable
- ra, _ = [p for p in polygon.exterior.coords.xy]
+ TODO:
- if min(ra) < 180 and max(ra) > 300:
- return True
+ Functions
+ - intersects
- return False
+ Properties
+ - centroid
+ - bbox
+ - wkt
+ - wkb
+ """
-def line_segment(start, end, step):
- """Returns coordinates on the line from start to end at the specified step-size"""
- return LineString([start, end]).segmentize(step).coords
+ def intersects(self):
+ """ """
+ pass
diff --git a/src/starplot/mixins.py b/src/starplot/mixins.py
index 7f37f193..766d2b94 100644
--- a/src/starplot/mixins.py
+++ b/src/starplot/mixins.py
@@ -23,30 +23,35 @@ def _extent_mask(self):
]
)
- if self.ra_max <= 360:
+ ra_min = self.ra_min
+ ra_max = self.ra_max
+ dec_min = self.dec_min
+ dec_max = self.dec_max
+
+ if ra_max <= 360:
coords = [
- [self.ra_min, self.dec_min],
- [self.ra_max, self.dec_min],
- [self.ra_max, self.dec_max],
- [self.ra_min, self.dec_max],
- [self.ra_min, self.dec_min],
+ [ra_min, dec_min],
+ [ra_max, dec_min],
+ [ra_max, dec_max],
+ [ra_min, dec_max],
+ [ra_min, dec_min],
]
return Polygon(coords)
else:
coords_1 = [
- [self.ra_min, self.dec_min],
- [360, self.dec_min],
- [360, self.dec_max],
- [self.ra_min, self.dec_max],
- [self.ra_min, self.dec_min],
+ [ra_min, dec_min],
+ [360, dec_min],
+ [360, dec_max],
+ [ra_min, dec_max],
+ [ra_min, dec_min],
]
coords_2 = [
- [0, self.dec_min],
- [(self.ra_max - 360), self.dec_min],
- [(self.ra_max - 360), self.dec_max],
- [0, self.dec_max],
- [0, self.dec_min],
+ [0, dec_min],
+ [(ra_max - 360), dec_min],
+ [(ra_max - 360), dec_max],
+ [0, dec_max],
+ [0, dec_min],
]
return MultiPolygon(
@@ -312,7 +317,7 @@ def _extent_mask(self):
# print(current_polygon_coords)
# print(len(polygon_coords))
- from starplot.geometry import split_polygon_at_360
+ from starplot.geometry import split_polygon_at_zero
# polygons = split_polygon_at_zero(extent)
@@ -322,7 +327,7 @@ def _extent_mask(self):
# extent = MultiPolygon([Polygon(c) for c in polygon_coords])
# extent = Polygon(coords)
extent = convex_hull(MultiPoint(coords))
- polygons = split_polygon_at_360(extent)
+ polygons = split_polygon_at_zero(extent)
pprint(polygons)
@@ -364,17 +369,18 @@ def create_map(self, height_degrees: float, width_degrees: float, *args, **kwarg
Returns:
MapPlot: new instance of a [`MapPlot`][starplot.MapPlot]
"""
- from starplot import MapPlot, geod
+ from starplot import MapPlot, geometry
- ex = geod.rectangle(
+ extent = geometry.rectangle(
center=(self.ra, self.dec),
height_degrees=height_degrees,
width_degrees=width_degrees,
)
- ra_min = ex[0][0]
- ra_max = ex[2][0]
- dec_min = ex[0][1]
- dec_max = ex[2][1]
+ minx, miny, maxx, maxy = extent.bounds
+ ra_min = minx
+ ra_max = maxx
+ dec_min = miny
+ dec_max = maxy
# handle wrapping
if ra_max < ra_min:
diff --git a/src/starplot/plots/__init__.py b/src/starplot/plots/__init__.py
index 6425ca14..89c90b66 100644
--- a/src/starplot/plots/__init__.py
+++ b/src/starplot/plots/__init__.py
@@ -4,3 +4,4 @@
from .horizon import HorizonPlot
from .zenith import ZenithPlot
from .optic import OpticPlot
+from .galaxy import GalaxyPlot
diff --git a/src/starplot/plots/base.py b/src/starplot/plots/base.py
index 63240ff4..bdce778b 100644
--- a/src/starplot/plots/base.py
+++ b/src/starplot/plots/base.py
@@ -11,7 +11,8 @@
from shapely import Polygon, LineString
from starplot.coordinates import CoordinateSystem
-from starplot import geod, models, warnings
+from starplot import models, warnings
+from starplot import geometry as _geometry
from starplot.config import settings as StarplotSettings, SvgTextType
from starplot.data import load, ecliptic
from starplot.data.translations import translate
@@ -24,12 +25,12 @@
MarkerStyle,
ObjectStyle,
LabelStyle,
- LineStyle,
MarkerSymbolEnum,
PathStyle,
PolygonStyle,
GradientDirection,
fonts,
+ AnchorPointEnum,
)
from starplot.plotters.debug import DebugPlotterMixin
from starplot.plotters.text import TextPlotterMixin, CollisionHandler
@@ -72,8 +73,14 @@ class BasePlot(DebugPlotterMixin, TextPlotterMixin, ABC):
The plot's style.
"""
- collision_handler: CollisionHandler
- """Default [collision handler][starplot.CollisionHandler] for the plot."""
+ point_label_handler: CollisionHandler
+ """Default [collision handler][starplot.CollisionHandler] for point labels."""
+
+ area_label_handler: CollisionHandler
+ """Default [collision handler][starplot.CollisionHandler] for area labels."""
+
+ path_label_handler: CollisionHandler
+ """Default [collision handler][starplot.CollisionHandler] for path labels."""
def __init__(
self,
@@ -81,7 +88,9 @@ def __init__(
ephemeris: str = "de421.bsp",
style: PlotStyle = None,
resolution: int = 4096,
- collision_handler: CollisionHandler = None,
+ point_label_handler: CollisionHandler = None,
+ area_label_handler: CollisionHandler = None,
+ path_label_handler: CollisionHandler = None,
scale: float = 1.0,
autoscale: bool = False,
suppress_warnings: bool = True,
@@ -104,7 +113,26 @@ def __init__(
self.style = style or PlotStyle()
self.figure_size = resolution * px
self.resolution = resolution
- self.collision_handler = collision_handler or CollisionHandler()
+
+ self.point_label_handler = point_label_handler or CollisionHandler(
+ attempts=10,
+ anchor_fallbacks=[
+ AnchorPointEnum.BOTTOM_RIGHT,
+ AnchorPointEnum.TOP_LEFT,
+ AnchorPointEnum.TOP_RIGHT,
+ AnchorPointEnum.BOTTOM_LEFT,
+ AnchorPointEnum.BOTTOM_CENTER,
+ AnchorPointEnum.TOP_CENTER,
+ AnchorPointEnum.RIGHT_CENTER,
+ AnchorPointEnum.LEFT_CENTER,
+ ],
+ )
+ self.area_label_handler = area_label_handler or CollisionHandler(
+ allow_constellation_line_collisions=True
+ )
+ self.path_label_handler = path_label_handler or CollisionHandler(
+ allow_constellation_line_collisions=True
+ )
self.scale = scale
self.autoscale = autoscale
@@ -312,7 +340,7 @@ def marker(
ra,
dec,
label_style,
- collision_handler=collision_handler or self.collision_handler,
+ collision_handler=collision_handler or self.point_label_handler,
gid=kwargs.get("gid_label") or "marker-label",
)
@@ -346,7 +374,7 @@ def planets(
)
legend_label = translate(legend_label, self.language)
- handler = collision_handler or self.collision_handler
+ handler = collision_handler or self.point_label_handler
for p in planets:
label = labels.get(p.name)
@@ -417,7 +445,7 @@ def sun(
label = translate(label, self.language)
legend_label = translate(legend_label, self.language)
s.name = label or s.name
- handler = collision_handler or self.collision_handler
+ handler = collision_handler or self.point_label_handler
if not self.in_bounds(s.ra, s.dec):
return
@@ -564,12 +592,13 @@ def rectangle(
angle: Angle of rotation clockwise (degrees)
legend_label: Label for this object in the legend
"""
- points = geod.rectangle(
+ polygon = _geometry.rectangle(
center,
height_degrees,
width_degrees,
angle,
)
+ points = list(zip(*polygon.exterior.coords.xy))
self._polygon(points, style, gid=kwargs.get("gid") or "polygon")
if legend_label is not None:
@@ -606,7 +635,7 @@ def ellipse(
legend_label: Label for this object in the legend
"""
- points = geod.ellipse(
+ polygon = _geometry.ellipse(
center,
height_degrees,
width_degrees,
@@ -615,6 +644,7 @@ def ellipse(
start_angle,
end_angle,
)
+ points = list(zip(*polygon.exterior.coords.xy))
self._polygon(points, style, gid=kwargs.get("gid") or "polygon")
if legend_label is not None:
@@ -658,39 +688,6 @@ def circle(
style=style.to_marker_style(symbol=MarkerSymbolEnum.CIRCLE),
)
- @use_style(LineStyle)
- def line(
- self,
- style: LineStyle,
- coordinates: list[tuple[float, float]] = None,
- geometry: LineString = None,
- **kwargs,
- ):
- """Plots a line
-
- Args:
- coordinates: List of coordinates, e.g. `[(ra, dec), (ra, dec)]`
- geometry: A shapely LineString. If this value is passed, then the `coordinates` kwarg will be ignored.
- style: Style of the line
- """
-
- if coordinates is None and geometry is None:
- raise ValueError("Must pass coordinates or geometry when plotting lines.")
-
- coords = geometry.coords if geometry is not None else coordinates
-
- x, y = zip(*[self._prepare_coords(*p) for p in coords])
-
- self.ax.plot(
- x,
- y,
- clip_on=True,
- clip_path=self._background_clip_path,
- gid=kwargs.get("gid") or "line",
- **style.matplot_kwargs(self.scale),
- **self._plot_kwargs(),
- )
-
@use_style(ObjectStyle, "moon")
def moon(
self,
@@ -709,7 +706,7 @@ def moon(
Args:
style: Styling of the Moon. If None, then the plot's style (specified when creating the plot) will be used
true_size: If True, then the Moon'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.
- show_phase: If True, and if `true_size = True`, then the approximate phase of the moon will be illustrated. The dark side of the moon will be colored with the marker's `edge_color`.
+ show_phase: If True, and if `true_size = True`, then the phase of the moon will be illustrated. The dark side of the moon will be colored with the marker's `edge_color`.
label: How the Moon will be labeled on the plot
legend_label: How the Moon will be labeled in the legend
collision_handler: An instance of [CollisionHandler][starplot.CollisionHandler] that describes what to do on label collisions with other labels, markers, etc. If `None`, then the collision handler of the plot will be used.
@@ -723,7 +720,7 @@ def moon(
label = translate(label, self.language)
legend_label = translate(legend_label, self.language)
m.name = label or m.name
- handler = collision_handler or self.collision_handler
+ handler = collision_handler or self.point_label_handler
if not self.in_bounds(m.ra, m.dec):
return
@@ -785,7 +782,7 @@ def _moon_with_phase(
radius_degrees: float,
style: PolygonStyle,
dark_side_color: str,
- num_pts: int = 100,
+ num_pts: int = 200,
):
"""
Plots the (approximate) moon phase by drawing two half circles and one ellipse in the center,
@@ -793,6 +790,10 @@ def _moon_with_phase(
"""
illuminated_color = style.fill_color
+ ellipse_b_radius_degrees = np.abs(
+ radius_degrees * (2 * self._objects.moon.illumination - 1)
+ )
+
left = style.copy()
right = style.copy()
middle = style.copy()
@@ -859,7 +860,8 @@ def _moon_with_phase(
self.ellipse(
center,
height_degrees=radius_degrees * 2,
- width_degrees=radius_degrees,
+ width_degrees=ellipse_b_radius_degrees * 2,
+ num_pts=num_pts,
style=middle,
gid="moon-marker",
)
@@ -895,11 +897,13 @@ def optic_fov(
style=style,
)
+ @profile
@use_style(PathStyle, "ecliptic")
def ecliptic(
self,
style: PathStyle = None,
label: str = "ECLIPTIC",
+ num_labels: int = 1,
collision_handler: CollisionHandler = None,
):
"""Plots the ecliptic
@@ -907,7 +911,8 @@ def ecliptic(
Args:
style: Styling of the ecliptic. If None, then the plot's style will be used
label: How the ecliptic will be labeled on the plot
- collision_handler: An instance of [CollisionHandler][starplot.CollisionHandler] that describes what to do on label collisions with other labels, markers, etc. If `None`, then the collision handler of the plot will be used.
+ num_labels: Max number of labels to plot along the line
+ collision_handler: An instance of [CollisionHandler][starplot.CollisionHandler] that describes what to do on label collisions with other labels, markers, etc. If `None`, then the plot's `path_label_handler` will be used.
"""
x = []
y = []
@@ -922,34 +927,23 @@ def ecliptic(
if self.in_bounds(ra * 15, dec):
inbounds.append((ra * 15, dec))
- self.ax.plot(
- x,
- y,
- dash_capstyle=style.line.dash_capstyle,
- clip_path=self._background_clip_path,
- gid="ecliptic-line",
- **style.line.matplot_kwargs(self.scale),
- **self._plot_kwargs(),
- )
-
- if label and len(inbounds) > 4:
- label_spacing = int(len(inbounds) / 4)
+ coords = [(ra * 15, dec) for ra, dec in ecliptic.RA_DECS]
- for ra, dec in [inbounds[label_spacing], inbounds[label_spacing * 2]]:
- self.text(
- label,
- ra,
- dec,
- style.label,
- collision_handler=collision_handler or self.collision_handler,
- gid="ecliptic-label",
- )
+ self.line(
+ style=style,
+ label=label.upper(),
+ num_labels=num_labels,
+ collision_handler=collision_handler,
+ coordinates=coords,
+ )
+ @profile
@use_style(PathStyle, "celestial_equator")
def celestial_equator(
self,
style: PathStyle = None,
label: str = "CELESTIAL EQUATOR",
+ num_labels: int = 1,
collision_handler: CollisionHandler = None,
):
"""
@@ -958,37 +952,87 @@ def celestial_equator(
Args:
style: Styling of the celestial equator. If None, then the plot's style will be used
label: How the celestial equator will be labeled on the plot
- collision_handler: An instance of [CollisionHandler][starplot.CollisionHandler] that describes what to do on label collisions with other labels, markers, etc. If `None`, then the collision handler of the plot will be used.
+ num_labels: Max number of labels to plot along the line
+ collision_handler: An instance of [CollisionHandler][starplot.CollisionHandler] that describes what to do on label collisions with other labels, markers, etc. If `None`, then the plot's `path_label_handler` will be used.
"""
- x = []
- y = []
+ label = translate(label, self.language)
+ coords = [(ra, 0) for ra in range(0, 361)]
+ self.line(
+ style=style,
+ label=label.upper(),
+ num_labels=num_labels,
+ collision_handler=collision_handler,
+ coordinates=coords,
+ gid="celestial-equator",
+ )
+
+ @use_style(PathStyle)
+ def line(
+ self,
+ coordinates: list[tuple[float, float]] = None,
+ geometry: LineString = None,
+ style: PathStyle = None,
+ label: str = None,
+ num_labels: int = 2,
+ collision_handler: CollisionHandler = None,
+ **kwargs,
+ ):
+ """Plots a line, with optional labels. Either coordinates OR geometry must be specified.
- # TODO : handle wrapping
+ Args:
- label = translate(label, self.language)
+ coordinates: List of coordinates, e.g. `[(ra, dec), (ra, dec)]`
+ geometry: A shapely LineString. If this value is passed, then the `coordinates` kwarg will be ignored.
+ style: Style of the line
+ label: Label for the line
+ num_labels: Number of labels to plot along the line
+ collision_handler: An instance of [CollisionHandler][starplot.CollisionHandler] that describes what to do on label collisions with other labels, markers, etc. If `None`, then the plot's `path_label_handler` will be used.
- for ra in range(25):
- x0, y0 = self._prepare_coords(ra * 15, 0)
- x.append(x0)
- y.append(y0)
+ """
+
+ if coordinates is None and geometry is None:
+ raise ValueError("Must pass coordinates or geometry when plotting lines.")
+
+ coords = geometry.coords if geometry is not None else coordinates
+ prepared_coords = [self._prepare_coords(*p) for p in coords]
+ x, y = zip(*prepared_coords)
+
+ gid = kwargs.get("gid") or "line"
self.ax.plot(
x,
y,
+ clip_on=True,
clip_path=self._background_clip_path,
- gid="celestial-equator-line",
+ dash_capstyle=style.line.dash_capstyle,
+ gid=gid,
**style.line.matplot_kwargs(self.scale),
**self._plot_kwargs(),
)
- if label:
- label_spacing = (self.ra_max - self.ra_min) / 3
- for ra in np.arange(self.ra_min, self.ra_max, label_spacing):
- self.text(
- label,
- ra,
- 0.25,
- style.label,
- collision_handler=collision_handler or self.collision_handler,
- gid="celestial-equator-label",
- )
+ if not label:
+ return
+
+ prepared_coords = [
+ (x, y) for x, y in prepared_coords if self._in_bounds_xy(x, y)
+ ]
+
+ if not prepared_coords:
+ return
+
+ x, y = zip(*prepared_coords)
+
+ collision_handler = collision_handler or self.path_label_handler
+
+ self._text_line(
+ x,
+ y,
+ label,
+ num_labels=num_labels,
+ collision_handler=collision_handler,
+ min_spacing=0.65,
+ **style.label.matplot_kwargs(self.scale),
+ **self._plot_kwargs(),
+ clip_path=self._background_clip_path,
+ gid=gid,
+ )
diff --git a/src/starplot/plots/galaxy.py b/src/starplot/plots/galaxy.py
new file mode 100644
index 00000000..95779e50
--- /dev/null
+++ b/src/starplot/plots/galaxy.py
@@ -0,0 +1,356 @@
+from functools import cache
+from typing import Callable
+
+import numpy as np
+import astropy.units as u
+from astropy.coordinates import SkyCoord
+from cartopy import crs as ccrs
+from matplotlib import pyplot as plt, patches
+from matplotlib.ticker import FixedLocator, FuncFormatter
+from skyfield.api import Star as SkyfieldStar
+from skyfield.framelib import galactic_frame
+
+from starplot.coordinates import CoordinateSystem
+from starplot.plots.base import BasePlot, DPI
+from starplot.mixins import ExtentMaskMixin
+from starplot.models.observer import Observer
+from starplot.plotters import (
+ ConstellationPlotterMixin,
+ StarPlotterMixin,
+ DsoPlotterMixin,
+ MilkyWayPlotterMixin,
+ GradientBackgroundMixin,
+ LegendPlotterMixin,
+ ArrowPlotterMixin,
+)
+from starplot.plotters.text import CollisionHandler
+from starplot.styles import (
+ PlotStyle,
+ extensions,
+ use_style,
+ PathStyle,
+ GradientDirection,
+)
+from starplot.profile import profile
+
+
+class GalaxyPlot(
+ BasePlot,
+ ExtentMaskMixin,
+ ConstellationPlotterMixin,
+ StarPlotterMixin,
+ DsoPlotterMixin,
+ MilkyWayPlotterMixin,
+ LegendPlotterMixin,
+ GradientBackgroundMixin,
+ ArrowPlotterMixin,
+):
+ """Creates a new galaxy plot.
+
+ Args:
+ center_lon: Central galactic longitude of the Mollweide projection
+ observer: Observer instance which specifies a time and place. Defaults to an observer at epoch J2000
+ ephemeris: Ephemeris to use for calculating planet positions (see [Skyfield's documentation](https://rhodesmill.org/skyfield/planets.html) for details)
+ style: Styling for the plot (colors, sizes, fonts, etc). If `None`, it defaults to `PlotStyle()` with the MAP extension
+ resolution: Size (in pixels) of largest dimension of the map
+ point_label_handler: Default [CollisionHandler][starplot.CollisionHandler] for point labels.
+ area_label_handler: Default [CollisionHandler][starplot.CollisionHandler] for area labels.
+ path_label_handler: Default [CollisionHandler][starplot.CollisionHandler] for path labels.
+ 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.
+ autoscale: If True, then the scale will be automatically set based on resolution
+ suppress_warnings: If True (the default), then all warnings will be suppressed
+
+ Returns:
+ GalaxyPlot: A new instance of a GalaxyPlot
+
+ """
+
+ _coordinate_system = CoordinateSystem.RA_DEC
+ _gradient_direction = GradientDirection.MOLLWEIDE
+
+ def __init__(
+ self,
+ center_lon: float = 0,
+ observer: Observer = None,
+ ephemeris: str = "de421.bsp",
+ style: PlotStyle = None,
+ resolution: int = 4096,
+ point_label_handler: CollisionHandler = None,
+ area_label_handler: CollisionHandler = None,
+ path_label_handler: CollisionHandler = None,
+ scale: float = 1.0,
+ autoscale: bool = False,
+ suppress_warnings: bool = True,
+ *args,
+ **kwargs,
+ ) -> "GalaxyPlot":
+ observer = observer or Observer.at_epoch(2000)
+ style = style or PlotStyle().extend(extensions.MAP)
+
+ super().__init__(
+ observer,
+ ephemeris,
+ style,
+ resolution,
+ point_label_handler=point_label_handler,
+ area_label_handler=area_label_handler,
+ path_label_handler=path_label_handler,
+ scale=scale,
+ autoscale=autoscale,
+ suppress_warnings=suppress_warnings,
+ *args,
+ **kwargs,
+ )
+
+ self.center_lon = center_lon
+ self.logger.debug("Creating GalaxyPlot...")
+ self._geodetic = ccrs.Geodetic()
+ self._plate_carree = ccrs.PlateCarree()
+
+ self._crs = ccrs.CRS(
+ proj4_params=[
+ ("proj", "latlong"),
+ ("axis", "wnu"), # invert
+ ("a", "6378137"),
+ ],
+ globe=ccrs.Globe(ellipse="sphere", flattening=0),
+ )
+
+ self._init_plot()
+ self._calc_position()
+
+ def _prepare_coords(self, ra, dec) -> (float, float):
+ """Converts RA/DEC to galactic coordinates (degrees)"""
+ if ra > 360:
+ ra -= 360
+ if ra < 0:
+ ra += 360
+ point = SkyfieldStar(ra_hours=ra / 15, dec_degrees=dec)
+ lat, lon, _ = self.observe(point).frame_latlon(galactic_frame)
+
+ return lon.degrees, lat.degrees
+
+ def _prepare_star_coords(self, df, limit_by_altaz=True):
+ stars_position = self.observe(SkyfieldStar.from_dataframe(df))
+ lat, lon, _ = stars_position.frame_latlon(galactic_frame)
+ df["x"], df["y"] = (lon.degrees, lat.degrees)
+ return df
+
+ def _plot_kwargs(self) -> dict:
+ return dict(transform=self._crs)
+
+ @cache
+ def in_bounds(self, ra, dec) -> bool:
+ """Determine if a coordinate is within the bounds of the plot.
+
+ Args:
+ ra: Right ascension, in hours (0...24)
+ dec: Declination, in degrees (-90...90)
+
+ Returns:
+ True if the coordinate is in bounds, otherwise False
+ """
+ lon, lat = self._prepare_coords(ra, dec)
+ return self.in_bounds_lonlat(lon, lat)
+
+ def in_bounds_lonlat(self, lon, lat) -> bool:
+ """Determine if a galactic coordinate is within the bounds of the plot.
+
+ Args:
+ lon: Galactic longitude in degrees (0...360)
+ lat: Galactic latitude in degrees (-90...90)
+
+ Returns:
+ True if the coordinate is in bounds, otherwise False
+ """
+ x, y = self._proj.transform_point(lon, lat, self._crs)
+ data_to_axes = self.ax.transData + self.ax.transAxes.inverted()
+ x_axes, y_axes = data_to_axes.transform((x, y))
+ return 0 <= x_axes <= 1 and 0 <= y_axes <= 1
+
+ def _in_bounds_xy(self, x: float, y: float) -> bool:
+ return self.in_bounds_lonlat(x, y)
+
+ def _polygon(self, points, style, **kwargs):
+ super()._polygon(points, style, transform=self._crs, **kwargs)
+
+ def _calc_position(self):
+ self.location = self.ephemeris["earth"]
+ self.observe = self.location.at(self.observer.timescale).observe
+
+ self.ra_min = 0
+ self.ra_max = 360
+ self.dec_min = -90
+ self.dec_max = 90
+
+ self.logger.debug(
+ f"Extent = RA ({self.ra_min:.2f}, {self.ra_max:.2f}) DEC ({self.dec_min:.2f}, {self.dec_max:.2f})"
+ )
+
+ @profile
+ @use_style(PathStyle, "galactic_equator")
+ def galactic_equator(
+ self,
+ style: PathStyle = None,
+ label: str = "GALACTIC EQUATOR",
+ num_labels: int = 1,
+ collision_handler: CollisionHandler = None,
+ ):
+ """
+ Plots the galactic equator
+
+ Args:
+ style: Styling of the galactic equator. If None, then the plot's style will be used
+ label: How the galactic equator will be labeled on the plot
+ num_labels: Max number of labels to plot along the line
+ collision_handler: An instance of [CollisionHandler][starplot.CollisionHandler] that describes what to do on label collisions with other labels, markers, etc. If `None`, then the plot's `path_label_handler` will be used.
+ """
+ lons = np.array([ra for ra in range(0, 361)]) # galactic longitudes
+ lats = np.array([0] * 361) # galactic latitudes
+
+ coords = SkyCoord(l=lons * u.deg, b=lats * u.deg, frame="galactic")
+ coords_eq = coords.icrs
+
+ ra_values = coords_eq.ra.degree
+ dec_values = coords_eq.dec.degree
+
+ radec = list(zip(ra_values, dec_values))
+
+ self.line(
+ label=label,
+ num_labels=num_labels,
+ collision_handler=collision_handler,
+ style=style,
+ coordinates=radec,
+ )
+
+ @use_style(PathStyle, "gridlines")
+ def gridlines(
+ self,
+ style: PathStyle = None,
+ show_labels: list = ["left", "right", "bottom"],
+ lon_locations: list[float] = None,
+ lat_locations: list[float] = None,
+ lon_formatter_fn: Callable[[float], str] = None,
+ lat_formatter_fn: Callable[[float], str] = None,
+ inline: bool = True,
+ ):
+ """
+ Plots gridlines
+
+ Args:
+ style: Styling of the gridlines. If None, then the plot's style (specified when creating the plot) will be used
+ show_labels: List of locations where labels should be shown (options: "left", "right", "top", "bottom")
+ az_locations: List of azimuth locations for the gridlines (in degrees, 0...360). Defaults to every 15 degrees
+ alt_locations: List of altitude locations for the gridlines (in degrees, -90...90). Defaults to every 10 degrees.
+ az_formatter_fn: Callable for creating labels of azimuth gridlines
+ alt_formatter_fn: Callable for creating labels of altitude gridlines
+ 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)
+ 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
+ tick_step: Step size for tick marks
+ """
+ lon_formatter_fn_default = lambda lon: f"{round(lon)}\u00b0 " # noqa: E731
+ lat_formatter_fn_default = lambda lat: f"{round(lat)}\u00b0 " # noqa: E731
+
+ lon_formatter_fn = lon_formatter_fn or lon_formatter_fn_default
+ lat_formatter_fn = lat_formatter_fn or lat_formatter_fn_default
+
+ def lon_formatter(x, pos) -> str:
+ if x < 0:
+ x += 360
+ return lon_formatter_fn(x)
+
+ def lat_formatter(x, pos) -> str:
+ return lat_formatter_fn(x)
+
+ x_locations = (
+ lon_locations
+ if lon_locations is not None
+ else [x for x in range(0, 360, 15)]
+ )
+ x_locations = [x - 180 for x in x_locations]
+ y_locations = (
+ lat_locations
+ if lat_locations is not None
+ else [y for y in range(-90, 90, 10)]
+ )
+
+ label_style_kwargs = style.label.matplot_kwargs(self.scale)
+ label_style_kwargs.pop("va")
+ label_style_kwargs.pop("ha")
+
+ line_style_kwargs = style.line.matplot_kwargs(self.scale)
+ gridlines = self.ax.gridlines(
+ draw_labels=show_labels,
+ x_inline=inline,
+ y_inline=inline,
+ rotate_labels=False,
+ # xpadding=12,
+ # ypadding=12,
+ gid="gridlines",
+ xlocs=FixedLocator(x_locations),
+ xformatter=FuncFormatter(lon_formatter),
+ xlabel_style=label_style_kwargs,
+ ylocs=FixedLocator(y_locations),
+ ylabel_style=label_style_kwargs,
+ yformatter=FuncFormatter(lat_formatter),
+ **line_style_kwargs,
+ )
+ gridlines.set_zorder(style.line.zorder)
+
+ @cache
+ def _to_ax(self, az: float, alt: float) -> tuple[float, float]:
+ """Converts az/alt to axes coordinates"""
+ x, y = self._proj.transform_point(az, alt, self._crs)
+ data_to_axes = self.ax.transData + self.ax.transAxes.inverted()
+ x_axes, y_axes = data_to_axes.transform((x, y))
+ return x_axes, y_axes
+
+ @cache
+ def _ax_to_azalt(self, x: float, y: float) -> tuple[float, float]:
+ trans = self.ax.transAxes + self.ax.transData.inverted()
+ x_projected, y_projected = trans.transform((x, y)) # axes to data
+ az, alt = self._crs.transform_point(x_projected, y_projected, self._proj)
+ return float(az), float(alt)
+
+ def _plot_background_clip_path(self):
+ if self.style.has_gradient_background():
+ background_color = "#ffffff00"
+ self._plot_gradient_background(self.style.background_color)
+ else:
+ background_color = self.style.background_color.as_hex()
+
+ self._background_clip_path = patches.Rectangle(
+ (0, 0),
+ width=1,
+ height=1,
+ facecolor=background_color,
+ linewidth=0,
+ fill=True,
+ zorder=-3_000,
+ transform=self.ax.transAxes,
+ )
+ self.ax.set_facecolor(background_color)
+
+ self.ax.add_patch(self._background_clip_path)
+ self._update_clip_path_polygon()
+
+ def _init_plot(self):
+ self._proj = ccrs.Mollweide(central_longitude=self.center_lon)
+ self._proj.threshold = 100
+ self.fig = plt.figure(
+ figsize=(self.figure_size, self.figure_size),
+ facecolor=self.style.figure_background_color.as_hex(),
+ dpi=DPI,
+ )
+ self.ax = self.fig.add_subplot(1, 1, 1, projection=self._proj)
+ self.fig.subplots_adjust(left=0, right=1, top=1, bottom=0)
+
+ self.ax.xaxis.set_visible(False)
+ self.ax.yaxis.set_visible(False)
+ self.ax.axis("off")
+
+ self.ax.set_global()
+
+ self._fit_to_ax()
+ self._plot_background_clip_path()
diff --git a/src/starplot/plots/horizon.py b/src/starplot/plots/horizon.py
index e7715177..9a654202 100644
--- a/src/starplot/plots/horizon.py
+++ b/src/starplot/plots/horizon.py
@@ -57,14 +57,15 @@ class HorizonPlot(
"""Creates a new horizon plot.
Args:
-
altitude: Tuple of altitude range to plot (min, max)
azimuth: Tuple of azimuth range to plot (min, max)
observer: Observer instance which specifies a time and place. Defaults to `Observer()`
ephemeris: Ephemeris to use for calculating planet positions (see [Skyfield's documentation](https://rhodesmill.org/skyfield/planets.html) for details)
style: Styling for the plot (colors, sizes, fonts, etc). If `None`, it defaults to `PlotStyle()`
resolution: Size (in pixels) of largest dimension of the map
- collision_handler: Default [CollisionHandler][starplot.CollisionHandler] for the plot that describes what to do on label collisions with other labels, markers, etc.
+ point_label_handler: Default [CollisionHandler][starplot.CollisionHandler] for point labels.
+ area_label_handler: Default [CollisionHandler][starplot.CollisionHandler] for area labels.
+ path_label_handler: Default [CollisionHandler][starplot.CollisionHandler] for path labels.
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.
autoscale: If True, then the scale will be automatically set based on resolution
suppress_warnings: If True (the default), then all warnings will be suppressed
@@ -87,7 +88,9 @@ def __init__(
ephemeris: str = "de421.bsp",
style: PlotStyle = None,
resolution: int = 4096,
- collision_handler: CollisionHandler = None,
+ point_label_handler: CollisionHandler = None,
+ area_label_handler: CollisionHandler = None,
+ path_label_handler: CollisionHandler = None,
scale: float = 1.0,
autoscale: bool = False,
suppress_warnings: bool = True,
@@ -102,7 +105,9 @@ def __init__(
ephemeris,
style,
resolution,
- collision_handler=collision_handler,
+ point_label_handler=point_label_handler,
+ area_label_handler=area_label_handler,
+ path_label_handler=path_label_handler,
scale=scale,
autoscale=autoscale,
suppress_warnings=suppress_warnings,
diff --git a/src/starplot/plots/map.py b/src/starplot/plots/map.py
index 1a515aa1..8a2406a8 100644
--- a/src/starplot/plots/map.py
+++ b/src/starplot/plots/map.py
@@ -11,7 +11,7 @@
import numpy as np
from starplot.coordinates import CoordinateSystem
-from starplot import geod
+from starplot import geometry
from starplot.plots.base import BasePlot, DPI
from starplot.mixins import ExtentMaskMixin
from starplot.models.observer import Observer
@@ -60,7 +60,9 @@ class MapPlot(
ephemeris: Ephemeris to use for calculating planet positions (see [Skyfield's documentation](https://rhodesmill.org/skyfield/planets.html) for details)
style: Styling for the plot (colors, sizes, fonts, etc). If `None`, it defaults to `PlotStyle()`
resolution: Size (in pixels) of largest dimension of the map
- collision_handler: Default [CollisionHandler][starplot.CollisionHandler] for the plot that describes what to do on label collisions with other labels, markers, etc.
+ point_label_handler: Default [CollisionHandler][starplot.CollisionHandler] for point labels.
+ area_label_handler: Default [CollisionHandler][starplot.CollisionHandler] for area labels.
+ path_label_handler: Default [CollisionHandler][starplot.CollisionHandler] for path labels.
clip_path: An optional Shapely Polygon that specifies the clip path of the plot -- only objects inside the polygon will be plotted. If `None` (the default), then the clip path will be the extent of the map you specified with the RA/DEC parameters.
scale: Scaling factor that will be applied to all sizes in styles (e.g. font size, marker size, line widths, etc). For example, if you want to make everything 2x bigger, then set the scale to 2. At `scale=1` and `resolution=4096` (the default), all sizes are optimized visually for a map that covers 1-3 constellations. So, if you're creating a plot of a _larger_ extent, then it'd probably be good to decrease the scale (i.e. make everything smaller) -- and _increase_ the scale if you're plotting a very small area.
autoscale: If True, then the scale will be set automatically based on resolution.
@@ -85,7 +87,9 @@ def __init__(
ephemeris: str = "de421.bsp",
style: PlotStyle = None,
resolution: int = 4096,
- collision_handler: CollisionHandler = None,
+ point_label_handler: CollisionHandler = None,
+ area_label_handler: CollisionHandler = None,
+ path_label_handler: CollisionHandler = None,
clip_path: Polygon = None,
scale: float = 1.0,
autoscale: bool = False,
@@ -101,7 +105,9 @@ def __init__(
ephemeris,
style,
resolution,
- collision_handler=collision_handler,
+ point_label_handler=point_label_handler,
+ area_label_handler=area_label_handler,
+ path_label_handler=path_label_handler,
scale=scale,
autoscale=autoscale,
suppress_warnings=suppress_warnings,
@@ -173,6 +179,9 @@ def _latlon_bounds(self):
]
def _adjust_radec_minmax(self):
+ if self._is_global_extent():
+ return
+
# adjust declination to match extent
extent = self.ax.get_extent(crs=self._plate_carree)
self.dec_min = extent[2]
@@ -262,12 +271,13 @@ def horizon(
zenith = observer.from_altaz(alt_degrees=90, az_degrees=0)
ra, dec, _ = zenith.radec()
- points = geod.ellipse(
+ polygon = geometry.ellipse(
center=(ra.hours * 15, dec.degrees),
height_degrees=180,
width_degrees=180,
num_pts=100,
)
+ points = list(zip(*polygon.exterior.coords.xy))
x = []
y = []
diff --git a/src/starplot/plots/optic.py b/src/starplot/plots/optic.py
index ced28523..0fe29afd 100644
--- a/src/starplot/plots/optic.py
+++ b/src/starplot/plots/optic.py
@@ -5,7 +5,7 @@
from skyfield.api import wgs84, Star as SkyfieldStar
-from starplot import callables, geod
+from starplot import callables, geometry
from starplot.coordinates import CoordinateSystem
from starplot.plots.base import BasePlot, DPI
from starplot.data.catalogs import Catalog, BIG_SKY_MAG11
@@ -49,7 +49,9 @@ class OpticPlot(
ephemeris: Ephemeris to use for calculating planet positions (see [Skyfield's documentation](https://rhodesmill.org/skyfield/planets.html) for details)
style: Styling for the plot (colors, sizes, fonts, etc). If `None`, it defaults to `PlotStyle()`
resolution: Size (in pixels) of largest dimension of the map
- collision_handler: Default [CollisionHandler][starplot.CollisionHandler] for the plot that describes what to do on label collisions with other labels, markers, etc.
+ point_label_handler: Default [CollisionHandler][starplot.CollisionHandler] for point labels.
+ area_label_handler: Default [CollisionHandler][starplot.CollisionHandler] for area labels.
+ path_label_handler: Default [CollisionHandler][starplot.CollisionHandler] for path labels.
raise_on_below_horizon: If True, then a ValueError will be raised if the target is below the horizon at the observing time/location
scale: Scaling factor that will be applied to all sizes in styles (e.g. font size, marker size, line widths, etc). For example, if you want to make everything 2x bigger, then set the scale to 2. At `scale=1` and `resolution=4096` (the default), all sizes are optimized visually for a map that covers 1-3 constellations. So, if you're creating a plot of a _larger_ extent, then it'd probably be good to decrease the scale (i.e. make everything smaller) -- and _increase_ the scale if you're plotting a very small area.
autoscale: If True, then the scale will be set automatically based on resolution.
@@ -74,7 +76,9 @@ def __init__(
ephemeris: str = "de421.bsp",
style: PlotStyle = None,
resolution: int = 4096,
- collision_handler: CollisionHandler = None,
+ point_label_handler: CollisionHandler = None,
+ area_label_handler: CollisionHandler = None,
+ path_label_handler: CollisionHandler = None,
raise_on_below_horizon: bool = True,
scale: float = 1.0,
autoscale: bool = False,
@@ -90,7 +94,9 @@ def __init__(
ephemeris,
style,
resolution,
- collision_handler=collision_handler,
+ point_label_handler=point_label_handler,
+ area_label_handler=area_label_handler,
+ path_label_handler=path_label_handler,
scale=scale,
autoscale=autoscale,
suppress_warnings=suppress_warnings,
@@ -188,15 +194,16 @@ def _calc_position(self):
def _adjust_radec_minmax(self):
fov = self.optic.true_fov
- ex = geod.rectangle(
+ extent = geometry.rectangle(
center=(self.ra, self.dec),
height_degrees=fov,
width_degrees=fov,
)
- self.ra_min = ex[0][0]
- self.ra_max = ex[2][0]
- self.dec_min = ex[0][1]
- self.dec_max = ex[2][1]
+ minx, miny, maxx, maxy = extent.bounds
+ self.ra_min = minx
+ self.ra_max = maxx
+ self.dec_min = miny
+ self.dec_max = maxy
if self.ra_max < 0:
self.ra_max += 360
diff --git a/src/starplot/plots/zenith.py b/src/starplot/plots/zenith.py
index 2f1c0dbe..1b070e2d 100644
--- a/src/starplot/plots/zenith.py
+++ b/src/starplot/plots/zenith.py
@@ -25,7 +25,9 @@ class ZenithPlot(MapPlot):
ephemeris: Ephemeris to use for calculating planet positions (see [Skyfield's documentation](https://rhodesmill.org/skyfield/planets.html) for details)
style: Styling for the plot (colors, sizes, fonts, etc). If `None`, it defaults to `PlotStyle()`
resolution: Size (in pixels) of largest dimension of the map
- collision_handler: Default [CollisionHandler][starplot.CollisionHandler] for the plot that describes what to do on label collisions with other labels, markers, etc.
+ point_label_handler: Default [CollisionHandler][starplot.CollisionHandler] for point labels.
+ area_label_handler: Default [CollisionHandler][starplot.CollisionHandler] for area labels.
+ path_label_handler: Default [CollisionHandler][starplot.CollisionHandler] for path labels.
scale: Scaling factor that will be applied to all sizes in styles (e.g. font size, marker size, line widths, etc). For example, if you want to make everything 2x bigger, then set the scale to 2. At `scale=1` and `resolution=4096` (the default), all sizes are optimized visually for a map that covers 1-3 constellations. So, if you're creating a plot of a _larger_ extent, then it'd probably be good to decrease the scale (i.e. make everything smaller) -- and _increase_ the scale if you're plotting a very small area.
autoscale: If True, then the scale will be set automatically based on resolution.
suppress_warnings: If True (the default), then all warnings will be suppressed
@@ -44,7 +46,9 @@ def __init__(
ephemeris: str = "de421.bsp",
style: PlotStyle = None,
resolution: int = 4096,
- collision_handler: CollisionHandler = None,
+ point_label_handler: CollisionHandler = None,
+ area_label_handler: CollisionHandler = None,
+ path_label_handler: CollisionHandler = None,
scale: float = 1.0,
autoscale: bool = False,
suppress_warnings: bool = True,
@@ -68,7 +72,9 @@ def __init__(
ephemeris,
style,
resolution,
- collision_handler=collision_handler,
+ point_label_handler=point_label_handler,
+ area_label_handler=area_label_handler,
+ path_label_handler=path_label_handler,
clip_path=None,
scale=scale,
autoscale=autoscale,
diff --git a/src/starplot/plotters/constellations.py b/src/starplot/plotters/constellations.py
index 690aa017..55268702 100644
--- a/src/starplot/plotters/constellations.py
+++ b/src/starplot/plotters/constellations.py
@@ -286,9 +286,7 @@ def constellation_labels(
collision_handler: An instance of [CollisionHandler][starplot.CollisionHandler] that describes what to do on collisions with other labels, markers, etc. If `None`, then `CollisionHandler(allow_constellation_line_collisions=True)` will be used (**Important: this function does NOT default to the plot's collision handler, since it's the only area-based label function and collisions should be handled differently**).
"""
- collision_handler = collision_handler or CollisionHandler(
- allow_constellation_line_collisions=True
- )
+ collision_handler = collision_handler or self.area_label_handler
hips = []
for c in self.objects.constellations:
diff --git a/src/starplot/plotters/dsos.py b/src/starplot/plotters/dsos.py
index f98421d1..09cb216a 100644
--- a/src/starplot/plotters/dsos.py
+++ b/src/starplot/plotters/dsos.py
@@ -178,7 +178,7 @@ def dsos(
where = where or []
where_labels = where_labels or []
where_true_size = where_true_size or []
- handler = collision_handler or self.collision_handler
+ handler = collision_handler or self.point_label_handler
if legend_labels is None:
legend_labels = {}
@@ -265,7 +265,7 @@ def dsos(
angle=angle or 0,
)
- if label:
+ if label and self.in_bounds(ra, dec):
self.text(
label,
ra,
diff --git a/src/starplot/plotters/stars.py b/src/starplot/plotters/stars.py
index 8fa835f3..9f304432 100644
--- a/src/starplot/plotters/stars.py
+++ b/src/starplot/plotters/stars.py
@@ -195,7 +195,7 @@ def stars(
alpha_fn = alpha_fn or (lambda d: style.marker.alpha)
color_fn = color_fn or (lambda d: color_hex)
- handler = collision_handler or self.collision_handler
+ handler = collision_handler or self.point_label_handler
where = where or []
where_labels = where_labels or []
stars_to_index = []
diff --git a/src/starplot/plotters/text.py b/src/starplot/plotters/text.py
index ddf6b358..8617bbc0 100644
--- a/src/starplot/plotters/text.py
+++ b/src/starplot/plotters/text.py
@@ -99,6 +99,113 @@ def __post_init__(self):
]
+def next_best_position(
+ plotted_positions: list[int],
+ available_positions: list[int],
+ num_labels: int,
+ num_positions: int,
+) -> int:
+ """
+ Returns the next best (evenly spaced) position based on distance from plotted positions
+
+ Assumes original positions are evenly spaced on line
+
+ Args:
+ plotted_positions: List of indices of plotted label positions on the line
+ available_positions: List of available positions to plot labels
+ num_labels: Number of labels to be plotted on the line
+ num_positions: Original number of positions that were available
+
+ Returns:
+ Next best (evenly spaced) position (the index from original list of coordinates)
+ """
+
+ if len(plotted_positions) == 0:
+ return available_positions[len(available_positions) // (num_labels + 1)]
+
+ positions = [0] + sorted(plotted_positions) + [num_positions - 1]
+ diffs = [positions[i] - positions[i - 1] for i in range(1, len(positions))]
+ avg = sum(diffs) / len(diffs)
+
+ # filter out available positions that are too close to plotted positions
+ # (i.e. closer than average distance)
+ possible = []
+ for p in available_positions:
+ min_distance = min([abs(plotted - p) for plotted in plotted_positions])
+ if min_distance >= avg * 0.9:
+ possible.append((min_distance, p))
+
+ if not possible:
+ return None
+
+ possible.sort()
+
+ return possible[0][1]
+
+
+def find_smooth_sections(
+ coordinates, min_length=2, curvature_threshold=0.1
+) -> list[tuple[int, int, float]]:
+ """
+ Find smooth sections of a (i.e. where curvature is low).
+
+ Args:
+ coordinates: line coordinates
+ min_length: minimum number of points for a smooth section
+ curvature_threshold: maximum curvature to consider "smooth"
+
+ Returns:
+ List of (start_idx, end_idx, smoothness_score) tuples
+ """
+ x, y = zip(*coordinates)
+
+ if len(x) < 3:
+ return [(0, len(x) - 1, 1.0)]
+
+ # First derivative
+ dx = np.diff(x)
+ dy = np.diff(y)
+
+ # Second derivative (change in slope)
+ ddx = np.diff(dx)
+ ddy = np.diff(dy)
+
+ # Curvature approximation
+ curvature = np.abs(ddx) + np.abs(ddy)
+
+ # Normalize by typical scale
+ curvature = curvature / (np.median(curvature) + 1e-10)
+
+ # Find smooth regions
+ is_smooth = curvature < curvature_threshold
+
+ # Find contiguous smooth sections with scores
+ smooth_sections = []
+ start = None
+
+ for i in range(len(is_smooth)):
+ if is_smooth[i] and start is None:
+ start = i
+ elif not is_smooth[i] and start is not None:
+ if i - start >= min_length:
+ # Calculate smoothness score (inverse of average curvature)
+ section_curvature = curvature[start:i]
+ smoothness_score = 1.0 / (np.mean(section_curvature) + 1e-10)
+ smooth_sections.append((start, i, smoothness_score))
+ start = None
+
+ # Check last section
+ if start is not None and len(is_smooth) - start >= min_length:
+ section_curvature = curvature[start:]
+ smoothness_score = 1.0 / (np.mean(section_curvature) + 1e-10)
+ smooth_sections.append((start, len(is_smooth), smoothness_score))
+
+ # Sort by smoothness score (descending)
+ smooth_sections.sort(key=lambda s: s[2], reverse=True)
+
+ return smooth_sections if smooth_sections else [(0, len(x) - 1, 1.0)]
+
+
class TextPlotterMixin:
def __init__(self, *args, **kwargs):
self.labels = []
@@ -106,7 +213,6 @@ def __init__(self, *args, **kwargs):
self._constellations_rtree = rtree.index.Index()
self._stars_rtree = rtree.index.Index()
self._markers_rtree = rtree.index.Index()
- self.collision_handler = kwargs.pop("collision_handler", CollisionHandler())
def _is_label_collision(self, bbox: BBox) -> bool:
ix = list(self._labels_rtree.intersection(bbox))
@@ -137,7 +243,7 @@ def _is_clipped_box(self, bbox: BBox) -> bool:
return not self._clip_path_polygon.contains(box(*bbox))
def _get_label_bbox(self, label: Annotation) -> BBox:
- self.fig.draw_without_rendering()
+ # self.fig.draw_without_rendering() # maybe dont need this line after all?
extent = label.get_window_extent(renderer=self.fig.canvas.get_renderer())
result = (
extent.xmin,
@@ -479,6 +585,150 @@ def _text_area(
if is_final_attempt:
return None
+ def _text_line(
+ self,
+ x,
+ y,
+ text: str,
+ num_labels: int = 1,
+ collision_handler: CollisionHandler = None,
+ min_spacing=None,
+ curvature_threshold=0.8,
+ **kwargs,
+ ) -> None:
+ """
+ Plots text labels along a line:
+
+ - Finds smoothest sections and tries those first
+ - Falls back to evenly spaced labels (based on already plotted labels)
+
+ Args:
+ x, y: line data coordinates
+ text: text to plot
+ num_labels: Number of labels to plot
+ collision_handler: Collision handler to use
+ min_spacing: minimum spacing between labels (as fraction of line length). If None, uses 1/(n_labels+1)
+ prefer_center: if True, place labels at center of smooth sections
+ curvature_threshold: threshold for determining smooth sections
+
+ """
+ kwargs.pop("ha", None) # alignment is forced to center of line
+ kwargs.pop("va", None)
+ kwargs.pop("transform", None) # we'll plot in axes coords
+
+ xy = list(zip(x, y))
+ data_xy = [self._proj.transform_point(_x, _y, self._crs) for _x, _y in xy]
+ display_xy = self.ax.transData.transform(data_xy)
+
+ # sort coords by display x value
+ display_xy = display_xy[display_xy[:, 0].argsort()]
+ num_positions = len(display_xy)
+
+ if min_spacing is None:
+ min_spacing = 1.0 / (num_labels + 1)
+
+ min_distance = int(min_spacing * num_positions)
+ smooth_sections = find_smooth_sections(
+ display_xy, min_length=2, curvature_threshold=curvature_threshold
+ )
+
+ smooth_positions = []
+ for section_start, section_end, _ in smooth_sections:
+ section_center = (section_start + section_end) // 2
+
+ too_close = False
+ for pos in smooth_positions:
+ if abs(section_center - pos) < min_distance:
+ too_close = True
+ break
+
+ if not too_close:
+ smooth_positions.append(section_center)
+
+ def plot_label(x0, y0, x1, y1, text):
+ # calculate angle in display coordinates
+ dx_display = x1 - x0
+ dy_display = y1 - y0
+ angle = np.degrees(np.arctan2(dy_display, dx_display))
+
+ # keep text upright
+ if angle > 90:
+ angle -= 180
+ elif angle < -90:
+ angle += 180
+
+ axes_coords = self.ax.transAxes.inverted().transform([(x0, y0)])
+ x_axes, y_axes = axes_coords[0]
+
+ return self.ax.text(
+ x_axes,
+ y_axes,
+ text,
+ rotation=angle,
+ ha="center",
+ va="center",
+ transform=self.ax.transAxes,
+ **kwargs,
+ )
+
+ offset = num_positions // 20 # offset from start/end of line
+ positions = [p for p in range(num_positions) if p not in smooth_positions]
+ positions = positions[offset : -1 * offset]
+ attempts = 0
+ plotted_positions = set()
+
+ while (
+ len(plotted_positions) < num_labels
+ and attempts < collision_handler.attempts
+ and len(positions) > 0
+ ):
+ attempts += 1
+
+ if smooth_positions:
+ pos = smooth_positions.pop()
+ else:
+ pos = next_best_position(
+ plotted_positions, positions, num_labels, num_positions
+ )
+
+ if pos is None:
+ return
+ if pos in positions:
+ positions.remove(pos)
+
+ pos = max(0, min(pos, num_positions - 2))
+ x0, y0 = display_xy[pos]
+ x1, y1 = display_xy[pos + 1]
+ label = plot_label(x0, y0, x1, y1, text)
+ bbox = self._get_label_bbox(label)
+
+ # TODO : find better bbox (that's rotated with text)
+
+ if bbox is None:
+ continue
+
+ is_open = self._is_open_space(
+ bbox,
+ padding=0,
+ allow_clipped=collision_handler.allow_clipped,
+ allow_constellation_collisions=collision_handler.allow_constellation_line_collisions,
+ allow_marker_collisions=collision_handler.allow_marker_collisions,
+ allow_label_collisions=collision_handler.allow_label_collisions,
+ )
+ is_final_attempt = attempts == collision_handler.attempts
+
+ if is_open or (collision_handler.plot_on_fail and is_final_attempt):
+ self._add_label_to_rtree(label, bbox=bbox)
+ plotted_positions.add(pos)
+ if self.debug_text and label:
+ self._debug_bbox(bbox, color="red", width=1)
+
+ elif label is not None:
+ label.remove()
+
+ if is_final_attempt or len(plotted_positions) == num_labels:
+ return
+
@use_style(LabelStyle)
def text(
self,
@@ -497,14 +747,14 @@ def text(
ra: Right ascension of text (0...360)
dec: Declination of text (-90...90)
style: Styling of the text
- collision_handler: An instance of [CollisionHandler][starplot.CollisionHandler] that describes what to do on collisions with other labels, markers, etc. If `None`, then the collision handler of the plot will be used.
+ collision_handler: An instance of [CollisionHandler][starplot.CollisionHandler] that describes what to do on collisions with other labels, markers, etc. If `None`, then the plot's `point_label_handler` will be used.
"""
if not text:
return
style = style.model_copy() # need a copy because we possibly mutate it below
- collision_handler = collision_handler or self.collision_handler
+ collision_handler = collision_handler or self.point_label_handler
if style.offset_x == "auto":
style.offset_x = 0
diff --git a/src/starplot/styles/base.py b/src/starplot/styles/base.py
index be70bcb6..730e7b99 100644
--- a/src/starplot/styles/base.py
+++ b/src/starplot/styles/base.py
@@ -1130,7 +1130,6 @@ class PlotStyle(BaseStyle):
)
"""Styling for gridlines (including Right Ascension / Declination labels). *Only applies to map plots*."""
- # Ecliptic
ecliptic: PathStyle = PathStyle(
line=LineStyle(
color="#777",
@@ -1141,33 +1140,57 @@ class PlotStyle(BaseStyle):
zorder=ZOrderEnum.LAYER_3 - 1,
),
label=LabelStyle(
- font_size=22,
+ font_size=21,
font_color="#777",
font_alpha=1,
+ font_weight=FontWeightEnum.NORMAL,
+ border_width=8,
+ border_color="#000",
zorder=ZOrderEnum.LAYER_3,
),
)
"""Styling for the Ecliptic"""
- # Celestial Equator
celestial_equator: PathStyle = PathStyle(
line=LineStyle(
color="#999",
width=3,
style=LineStyleEnum.DASHED_DOTS,
- alpha=0.65,
+ alpha=1,
zorder=ZOrderEnum.LAYER_3,
),
label=LabelStyle(
- font_size=22,
+ font_size=21,
font_color="#999",
- font_weight=FontWeightEnum.EXTRA_LIGHT,
- font_alpha=0.65,
+ font_weight=FontWeightEnum.NORMAL,
+ font_alpha=1,
+ border_width=8,
+ border_color="#000",
zorder=ZOrderEnum.LAYER_3,
),
)
"""Styling for the Celestial Equator"""
+ galactic_equator: PathStyle = PathStyle(
+ line=LineStyle(
+ color="#999",
+ width=3,
+ style=LineStyleEnum.SOLID,
+ alpha=0.65,
+ zorder=ZOrderEnum.LAYER_3,
+ ),
+ label=LabelStyle(
+ font_size=21,
+ font_color="#7c7c7c",
+ font_weight=FontWeightEnum.NORMAL,
+ font_alpha=1,
+ border_width=8,
+ border_color="#000",
+ zorder=ZOrderEnum.LAYER_3,
+ ),
+ )
+ """Styling for the Galactic Equator"""
+
horizon: PathStyle = PathStyle(
line=LineStyle(
color="#fff",
diff --git a/src/starplot/styles/ext/antique.yml b/src/starplot/styles/ext/antique.yml
index 4216de92..5f1eb68b 100644
--- a/src/starplot/styles/ext/antique.yml
+++ b/src/starplot/styles/ext/antique.yml
@@ -39,14 +39,23 @@ flamsteed_labels:
font_alpha: 0.9
font_color: hsl(60, 3%, 17%)
-
+# Lines
celestial_equator:
label:
font_color: hsl(188, 35%, 56%)
+ border_color: hsl(48, 80%, 96%)
line:
color: hsl(188, 35%, 76%)
alpha: 0.62
+ecliptic:
+ label:
+ font_color: hsl(26, 63%, 50%)
+ border_color: hsl(48, 80%, 96%)
+ line:
+ color: hsl(26, 90%, 62%)
+ alpha: 1
+
# Constellations
constellation_lines:
alpha: 0.3
@@ -61,13 +70,6 @@ constellation_borders:
alpha: 0.8
zorder: -500
-ecliptic:
- label:
- font_color: hsl(26, 63%, 50%)
- line:
- color: hsl(26, 90%, 62%)
- alpha: 1
-
milky_way:
alpha: 0.2
fill_color: hsl(48, 40%, 75%)
diff --git a/src/starplot/styles/ext/blue_dark.yml b/src/starplot/styles/ext/blue_dark.yml
index 19afa3a0..d40d6fdf 100644
--- a/src/starplot/styles/ext/blue_dark.yml
+++ b/src/starplot/styles/ext/blue_dark.yml
@@ -7,6 +7,12 @@ border_bg_color: hsl(209, 50%, 20%)
border_font_color: hsl(209, 56%, 86%)
border_line_color: hsl(209, 38%, 50%)
+title:
+ font_color: hsl(209, 56%, 86%)
+bayer_labels:
+ font_alpha: 0.8
+ font_color: hsl(209, 53%, 82%)
+
horizon:
line:
color: hsl(209, 60%, 13%)
@@ -20,20 +26,18 @@ zenith:
label:
font_color: hsl(209, 20%, 75%)
-title:
- font_color: hsl(209, 56%, 86%)
-bayer_labels:
- font_alpha: 0.8
- font_color: hsl(209, 53%, 82%)
+# Lines
celestial_equator:
label:
font_color: hsl(209, 30%, 80%)
+ border_color: hsl(209, 50%, 24%)
line:
color: hsl(209, 30%, 80%)
ecliptic:
label:
font_color: '#e33b3b'
+ border_color: hsl(209, 50%, 24%)
line:
color: '#e33b3b'
alpha: 1
diff --git a/src/starplot/styles/ext/blue_gold.yml b/src/starplot/styles/ext/blue_gold.yml
index 512d3075..c46b5194 100644
--- a/src/starplot/styles/ext/blue_gold.yml
+++ b/src/starplot/styles/ext/blue_gold.yml
@@ -17,16 +17,18 @@ legend:
celestial_equator:
label:
- font_color: '#2d5ec2'
+ font_color: hsl(220, 76%, 54%)
+ border_color: hsl(208, 23%, 12%)
line:
- color: hsl(220, 62%, 47%)
- alpha: 0.8
+ color: hsl(220, 72%, 52%)
+ alpha: 1
ecliptic:
label:
- font_color: hsl(4, 60%, 54%)
+ font_color: hsl(4, 78%, 54%)
+ border_color: hsl(208, 23%, 12%)
line:
- color: hsl(4, 60%, 54%)
- alpha: 0.9
+ color: hsl(4, 78%, 52%)
+ alpha: 1
horizon:
line:
diff --git a/src/starplot/styles/ext/blue_light.yml b/src/starplot/styles/ext/blue_light.yml
index 7dcf25a8..19b1126d 100644
--- a/src/starplot/styles/ext/blue_light.yml
+++ b/src/starplot/styles/ext/blue_light.yml
@@ -24,12 +24,23 @@ title:
star:
marker:
edge_color: '#fff'
+
celestial_equator:
label:
font_color: '#2d5ec2'
+ border_color: hsl(218, 88%, 99%)
line:
color: '#2d5ec2'
+ecliptic:
+ label:
+ font_color: '#e33b3b'
+ border_color: hsl(218, 88%, 99%)
+ line:
+ color: hsl(359, 98%, 49%)
+ alpha: 1
+ style: [0, [0.14, 2]]
+
# Constellations
constellation_labels:
font_alpha: 0.27
@@ -47,9 +58,9 @@ dso_galaxy:
marker:
alpha: 1
color: hsl(330, 80%, 85%)
- edge_color: hsl(330, 34%, 43%)
+ edge_color: hsl(330, 84%, 20%)
label:
- font_color: hsl(330, 34%, 43%)
+ font_color: hsl(330, 84%, 20%)
dso_nebula: &DSO-NEB
marker:
@@ -98,14 +109,6 @@ dso_nonexistant: *DSO
dso_unknown: *DSO
dso_duplicate: *DSO
-ecliptic:
- label:
- font_color: '#e33b3b'
- line:
- # color: hsl(360, 100%, 50%)
- color: hsl(359, 98%, 49%)
- alpha: 1
- style: [0, [0.14, 2]]
milky_way:
alpha: 0.25
fill_color: hsl(203, 70%, 81%)
diff --git a/src/starplot/styles/ext/blue_medium.yml b/src/starplot/styles/ext/blue_medium.yml
index 107821b8..141bf35c 100644
--- a/src/starplot/styles/ext/blue_medium.yml
+++ b/src/starplot/styles/ext/blue_medium.yml
@@ -29,12 +29,15 @@ constellation_lines:
celestial_equator:
label:
font_color: '#2d5ec2'
+ border_color: hsl(218, 88%, 97%)
line:
color: hsl(220, 52%, 56%)
alpha: 0.8
+
ecliptic:
label:
font_color: hsl(207, 75%, 53%)
+ border_color: hsl(218, 88%, 97%)
line:
color: hsl(207, 75%, 48%)
alpha: 1
@@ -81,12 +84,10 @@ zenith:
dso_galaxy:
marker:
alpha: 1
- # color: hsl(330, 80%, 85%)
- # edge_color: hsl(330, 34%, 33%)
color: hsl(330, 90%, 82%)
- edge_color: hsl(330, 84%, 23%)
+ edge_color: hsl(330, 84%, 20%)
label:
- font_color: hsl(330, 34%, 33%)
+ font_color: hsl(330, 34%, 25%)
dso_nebula: &DSO-NEB
marker:
diff --git a/src/starplot/styles/ext/blue_night.yml b/src/starplot/styles/ext/blue_night.yml
index 30d71dfe..871a198b 100644
--- a/src/starplot/styles/ext/blue_night.yml
+++ b/src/starplot/styles/ext/blue_night.yml
@@ -44,13 +44,22 @@ horizon:
celestial_equator:
label:
- font_color: hsl(211deg 47% 81%)
+ font_color: hsl(211, 47%, 81%)
+ border_color: hsl(210, 29%, 15%)
line:
- color: hsl(211deg 47% 61%)
+ color: hsl(211, 47%, 61%)
+
+galactic_equator:
+ label:
+ font_color: hsl(211, 47%, 91%)
+ border_color: hsl(210, 29%, 15%)
+ line:
+ color: hsl(211, 47%, 91%)
ecliptic:
label:
- font_color: '#e33b3b'
+ font_color: hsl(1, 90%, 60%)
+ border_color: hsl(210, 29%, 15%)
line:
color: hsl(1, 80%, 40%)
alpha: 1
@@ -120,9 +129,9 @@ dso_planetary_nebula: *DSO-NEB
dso_open_cluster: &DSO-OC
marker:
- alpha: 1
- color: hsl(62, 48%, 33%)
- edge_color: hsl(58, 98%, 74%)
+ alpha: 0.9
+ color: hsl(62, 58%, 42%)
+ edge_color: hsl(58, 100%, 64%)
edge_width: 1
label:
font_color: hsl(58, 98%, 58%)
diff --git a/src/starplot/styles/ext/nord.yml b/src/starplot/styles/ext/nord.yml
index baeebec4..2f550a45 100644
--- a/src/starplot/styles/ext/nord.yml
+++ b/src/starplot/styles/ext/nord.yml
@@ -6,6 +6,9 @@ border_bg_color: '#2e3440'
border_font_color: '#a3be8c'
border_line_color: '#a3be8c'
+title:
+ font_color: '#a3be8c'
+
horizon:
line:
color: '#2e3440'
@@ -19,14 +22,20 @@ zenith:
label:
font_color: '#D99CBA'
-title:
- font_color: '#a3be8c'
celestial_equator:
label:
font_color: '#77A67F'
+ border_color: '#4c566a'
line:
color: '#77A67F'
+ecliptic:
+ label:
+ font_color: '#D99CBA'
+ border_color: '#4c566a'
+ line:
+ color: '#D99CBA'
+
# Constellations
constellation_labels:
font_alpha: 0.7
@@ -100,11 +109,6 @@ dso_nonexistant: *DSO
dso_unknown: *DSO
dso_duplicate: *DSO
-ecliptic:
- label:
- font_color: '#D99CBA'
- line:
- color: '#D99CBA'
gridlines:
label:
font_alpha: 0.8
diff --git a/src/starplot/utils.py b/src/starplot/utils.py
index ed8a4125..28c9b6a7 100644
--- a/src/starplot/utils.py
+++ b/src/starplot/utils.py
@@ -1,8 +1,7 @@
import math
-from datetime import datetime
+from datetime import datetime, timezone
import numpy as np
-from pytz import timezone
def in_circle(x, y, center_x=0, center_y=0, radius=0.9) -> bool:
@@ -149,7 +148,7 @@ def azimuth_to_string(azimuth_degrees: int):
def dt_or_now(dt):
- return dt or timezone("UTC").localize(datetime.now())
+ return dt or datetime.now(tz=timezone.utc)
def points_on_line(start, end, num_points=100):
diff --git a/tests/test_geometry.py b/tests/test_geometry.py
new file mode 100644
index 00000000..6a27832a
--- /dev/null
+++ b/tests/test_geometry.py
@@ -0,0 +1,70 @@
+from starplot import geometry
+
+
+def test_square_simple():
+ polygon = geometry.rectangle(
+ center=(200, 0),
+ height_degrees=4,
+ width_degrees=4,
+ )
+ points = list(zip(*polygon.exterior.coords.xy))
+ assert points == [
+ (197.9992, -1.9996),
+ (197.9992, 1.9996),
+ (202.0008, 1.9996),
+ (202.0008, -1.9996),
+ (197.9992, -1.9996),
+ ]
+ assert len(points) == 5
+
+
+def test_rectangle():
+ polygon = geometry.rectangle(
+ center=(50, 0),
+ height_degrees=1,
+ width_degrees=2,
+ )
+ points = list(zip(*polygon.exterior.coords.xy))
+ assert points == [
+ (49.0, -0.5),
+ (49.0, 0.5),
+ (51.0, 0.5),
+ (51.0, -0.5),
+ (49.0, -0.5),
+ ]
+ assert len(points) == 5
+
+
+def test_square_at_meridian():
+ polygon = geometry.rectangle(
+ center=(360, 0),
+ height_degrees=4,
+ width_degrees=4,
+ )
+ points = list(zip(*polygon.exterior.coords.xy))
+ assert points == [
+ (357.9992, -1.9996),
+ (357.9992, 1.9996),
+ (362.0008, 1.9996),
+ (362.0008, -1.9996),
+ (357.9992, -1.9996),
+ ]
+ assert len(points) == 5
+
+
+def test_circle_at_meridian():
+ polygon = geometry.circle(
+ center=(358, 0),
+ diameter_degrees=16,
+ num_pts=4,
+ )
+ points = list(zip(*polygon.exterior.coords.xy))
+ assert points == [
+ (358.0, -8.0),
+ (350.0, 0.0),
+ (358.0, 8.0),
+ (366.0, 0.0),
+ (358.0, -8.0),
+ (358.0, -8.0),
+ ]
+ assert len(points) == 6
diff --git a/tests/test_map.py b/tests/test_map.py
index 85fd12d0..9d67254a 100644
--- a/tests/test_map.py
+++ b/tests/test_map.py
@@ -1,9 +1,7 @@
-from datetime import datetime
+from datetime import datetime, timezone
import pytest
-from pytz import timezone
-
from starplot import Star, MapPlot, Mercator, Miller, Observer, _
@@ -46,7 +44,7 @@ def test_map_objects_list():
def test_map_objects_list_planets():
- dt = timezone("UTC").localize(datetime(2023, 8, 27, 23, 0, 0, 0))
+ dt = datetime(2023, 8, 27, 23, 0, 0, 0, tzinfo=timezone.utc)
p = MapPlot(
projection=Miller(),
diff --git a/tests/test_models.py b/tests/test_models.py
index 2979e137..54821fb1 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -1,9 +1,8 @@
-from datetime import datetime
+from datetime import datetime, timezone
+from zoneinfo import ZoneInfo
import pytest
-from pytz import timezone
-
from starplot import _, DSO, Star, Constellation, Sun, Moon, Planet
@@ -103,7 +102,7 @@ def test_dso_find_duplicate(self):
class TestMoon:
def test_moon_get(self):
- dt = timezone("UTC").localize(datetime(2023, 8, 27, 23, 0, 0, 0))
+ dt = datetime(2023, 8, 27, 23, 0, 0, 0, tzinfo=timezone.utc)
m = Moon.get(dt)
assert m.ra == pytest.approx(292.53617734161276)
assert m.dec == pytest.approx(-26.96492167310071)
@@ -114,14 +113,14 @@ def test_moon_get(self):
assert m.illumination == pytest.approx(0.7553895183806317)
def test_moon_get_new_moon(self):
- dt = timezone("UTC").localize(datetime(2024, 4, 8, 12, 0, 0, 0))
+ dt = datetime(2024, 4, 8, 12, 0, 0, 0, tzinfo=timezone.utc)
m = Moon.get(dt)
assert m.phase_description == "New Moon"
assert m.phase_angle == 356.2894192723546
assert m.illumination == 0.020614337375807645
def test_moon_get_full_moon(self):
- dt = timezone("UTC").localize(datetime(2024, 4, 23, 14, 0, 0, 0))
+ dt = datetime(2024, 4, 23, 14, 0, 0, 0, tzinfo=timezone.utc)
m = Moon.get(dt)
assert m.phase_description == "Full Moon"
assert m.phase_angle == 175.42641200608864
@@ -131,8 +130,8 @@ def test_moon_get_full_moon(self):
class TestSolarEclipse:
def test_total_solar_eclipse(self):
# time of total eclipse in Cleveland, Ohio
- eastern = timezone("US/Eastern")
- dt = eastern.localize(datetime(2024, 4, 8, 15, 13, 47, 0))
+ eastern = ZoneInfo("US/Eastern")
+ dt = datetime(2024, 4, 8, 15, 13, 47, 0, tzinfo=eastern)
lat = 41.482222
lon = -81.669722
@@ -150,7 +149,7 @@ def test_total_solar_eclipse(self):
class TestPlanet:
def test_planet_get(self):
- dt = timezone("UTC").localize(datetime(2024, 4, 7, 21, 0, 0, 0))
+ dt = datetime(2024, 4, 7, 21, 0, 0, 0, tzinfo=timezone.utc)
jupiter = Planet.get("jupiter", dt)
assert jupiter.ra == pytest.approx(46.29005575002272)
assert jupiter.dec == pytest.approx(16.56207889273591)
@@ -158,6 +157,6 @@ def test_planet_get(self):
assert jupiter.apparent_size == 0.009162890626143375
def test_planet_all(self):
- dt = timezone("UTC").localize(datetime(2024, 4, 7, 21, 0, 0, 0))
+ dt = datetime(2024, 4, 7, 21, 0, 0, 0, tzinfo=timezone.utc)
planets = [p for p in Planet.all(dt)]
assert len(planets) == 8