From 8dfcb8332ee18ca6788c2210393df2bda83ee2ff Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Sat, 14 Feb 2026 12:23:56 -0800 Subject: [PATCH 01/32] cleanup and tests --- Makefile | 2 +- src/starplot/geod.py | 25 +++++++++-------- tests/test_geometry.py | 62 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 12 deletions(-) create mode 100644 tests/test_geometry.py diff --git a/Makefile b/Makefile index adafe328..17d96083 100644 --- a/Makefile +++ b/Makefile @@ -35,7 +35,7 @@ format: $(DOCKER_RUN) "python -m black src/ tests/ scripts/ examples/ hash_checks/ tutorial/ data/ $(ARGS)" test: - $(DOCKER_RUN) "python -m pytest --cov=src/ --cov-report=term --cov-report=html ." + $(DOCKER_RUN) "python -m pytest $(ARGS) --cov=src/ --cov-report=term --cov-report=html ." check-hashes: $(DOCKER_RUN) "python hash_checks/hashio.py check" diff --git a/src/starplot/geod.py b/src/starplot/geod.py index c7f496e6..485af146 100644 --- a/src/starplot/geod.py +++ b/src/starplot/geod.py @@ -36,23 +36,26 @@ def rectangle( height_m = distance_m(height_degrees) width_m = distance_m(width_degrees) - c = math.sqrt((height_m / 2) ** 2 + (width_m / 2) ** 2) + 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 = [] - 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])) + 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, + ) + points = list(zip(lons, lats)) - return points + return [(round(ra, 4), round(dec, 4)) for ra, dec in points] def ellipse( diff --git a/tests/test_geometry.py b/tests/test_geometry.py new file mode 100644 index 00000000..6d190b38 --- /dev/null +++ b/tests/test_geometry.py @@ -0,0 +1,62 @@ +from starplot import geod + + +def test_square_simple(): + result = geod.rectangle( + center=(200, 0), + height_degrees=4, + width_degrees=4, + ) + assert result == [ + (-162.0008, -1.9996), + (-162.0008, 1.9996), + (-157.9992, 1.9996), + (-157.9992, -1.9996), + ] + assert len(result) == 4 + + +def test_rectangle(): + result = geod.rectangle( + center=(50, 0), + height_degrees=1, + width_degrees=2, + ) + assert result == [ + (49.0, -0.5), + (49.0, 0.5), + (51.0, 0.5), + (51.0, -0.5), + ] + assert len(result) == 4 + + +def test_square_at_meridian(): + result = geod.rectangle( + center=(360, 0), + height_degrees=4, + width_degrees=4, + ) + assert result == [ + (-2.0008, -1.9996), + (-2.0008, 1.9996), + (2.0008, 1.9996), + (2.0008, -1.9996), + ] + assert len(result) == 4 + + +def test_circle_at_meridian(): + result = geod.circle( + center=(358, 0), + radius_degrees=8, + num_pts=4, + ) + assert result == [ + (-2.0, -8.0), + (-10.0, 0.0), + (-2.0, 8.0), + (6.0, 0.0), + (-2.0, -8.0), + ] + assert len(result) == 5 From 6cf93f7ef59e304931db7c8c679716cb8c65702e Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Sat, 14 Feb 2026 13:17:32 -0800 Subject: [PATCH 02/32] cleanup geometry --- hash_checks/hashlock.yml | 2 +- src/starplot/geod.py | 99 -------------------- src/starplot/geometry.py | 180 +++++++++++++++++++++++++++--------- src/starplot/mixins.py | 13 +-- src/starplot/plots/base.py | 9 +- src/starplot/plots/map.py | 5 +- src/starplot/plots/optic.py | 13 +-- tests/test_geometry.py | 62 +++++++------ 8 files changed, 196 insertions(+), 187 deletions(-) delete mode 100644 src/starplot/geod.py diff --git a/hash_checks/hashlock.yml b/hash_checks/hashlock.yml index 729b1575..90a6b483 100644 --- a/hash_checks/hashlock.yml +++ b/hash_checks/hashlock.yml @@ -47,7 +47,7 @@ map_mollweide: filename: /starplot/hash_checks/data/map-mollweide.png phash: ebcab698e3349461 map_moon_phase_waxing_crescent: - dhash: 0008344a4a3408000008204a4a2008008088804a4a808880 + dhash: 000830484830080000082048482008008088804c4c808880 filename: /starplot/hash_checks/data/map-moon-phase-waxing-crescent.png phash: b38ccc3333cccc33 map_orion_base: diff --git a/src/starplot/geod.py b/src/starplot/geod.py deleted file mode 100644 index 485af146..00000000 --- a/src/starplot/geod.py +++ /dev/null @@ -1,99 +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) - - 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, - ) - points = list(zip(lons, lats)) - - return [(round(ra, 4), round(dec, 4)) for ra, dec in 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..f0bbcfb0 100644 --- a/src/starplot/geometry.py +++ b/src/starplot/geometry.py @@ -2,11 +2,12 @@ import math from typing import Union +import pyproj +import numpy as np + from shapely import transform, union_all from shapely.geometry import Point, Polygon, MultiPolygon, LineString -from starplot import geod - GLOBAL_EXTENT = Polygon( [ [0, -90], @@ -17,20 +18,146 @@ ] ) +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, +) -> Polygon: + """ + Returns a rectangle polygon on a sphere, with coordinates in degrees. + + If the rectangle crosses the meridian at X=0, then the X coordinates will extend past 360. + + 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 + """ + + 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] + + 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 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. + + If the ellipse crosses the meridian at X=0, then the X coordinates will extend past 360. + + 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 + + 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) + + lons.append(lon[0]) + lats.append(lat[0]) + + if min(lons) < 0: + lons = [lon + 360 for lon in lons] -def circle(center, diameter_degrees, num_pts=100): - points = geod.ellipse( + 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, ) - # 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 to_24h(geometry: Union[Point, Polygon, MultiPolygon]): @@ -59,22 +186,6 @@ def unwrap_polygon(polygon: Polygon) -> Polygon: return Polygon(new_points) -def unwrap_polygon_360_old(polygon: Polygon) -> Polygon: - points = list(zip(*polygon.exterior.coords.xy)) - new_points = [] - prev = None - - 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 - - return Polygon(new_points) - - def unwrap_polygon_360_inverse(polygon: Polygon) -> Polygon: ra, dec = [p for p in polygon.exterior.coords.xy] @@ -250,23 +361,6 @@ def random_point_in_polygon_at_distance( return None -def wrapped_polygon_adjustment_old(polygon: Polygon) -> int: - if "MultiPolygon" == str(polygon.geom_type): - return 0 - - points = list(zip(*polygon.exterior.coords.xy)) - prev = None - - 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 - - return 0 - - def wrapped_polygon_adjustment(polygon: Polygon) -> int: if "MultiPolygon" == str(polygon.geom_type): return 0 @@ -291,6 +385,6 @@ def is_wrapped_polygon(polygon: Polygon) -> bool: return False -def line_segment(start, end, step): +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 diff --git a/src/starplot/mixins.py b/src/starplot/mixins.py index 7f37f193..a8fb8c03 100644 --- a/src/starplot/mixins.py +++ b/src/starplot/mixins.py @@ -364,17 +364,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/base.py b/src/starplot/plots/base.py index 63240ff4..73b60665 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 @@ -564,12 +565,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 +608,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 +617,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: diff --git a/src/starplot/plots/map.py b/src/starplot/plots/map.py index 1a515aa1..d8b27f61 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 @@ -262,12 +262,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..e3163c95 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 @@ -188,15 +188,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/tests/test_geometry.py b/tests/test_geometry.py index 6d190b38..6a27832a 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -1,62 +1,70 @@ -from starplot import geod +from starplot import geometry def test_square_simple(): - result = geod.rectangle( + polygon = geometry.rectangle( center=(200, 0), height_degrees=4, width_degrees=4, ) - assert result == [ - (-162.0008, -1.9996), - (-162.0008, 1.9996), - (-157.9992, 1.9996), - (-157.9992, -1.9996), + 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(result) == 4 + assert len(points) == 5 def test_rectangle(): - result = geod.rectangle( + polygon = geometry.rectangle( center=(50, 0), height_degrees=1, width_degrees=2, ) - assert result == [ + 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(result) == 4 + assert len(points) == 5 def test_square_at_meridian(): - result = geod.rectangle( + polygon = geometry.rectangle( center=(360, 0), height_degrees=4, width_degrees=4, ) - assert result == [ - (-2.0008, -1.9996), - (-2.0008, 1.9996), - (2.0008, 1.9996), - (2.0008, -1.9996), + 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(result) == 4 + assert len(points) == 5 def test_circle_at_meridian(): - result = geod.circle( + polygon = geometry.circle( center=(358, 0), - radius_degrees=8, + diameter_degrees=16, num_pts=4, ) - assert result == [ - (-2.0, -8.0), - (-10.0, 0.0), - (-2.0, 8.0), - (6.0, 0.0), - (-2.0, -8.0), + 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(result) == 5 + assert len(points) == 6 From 1a639e931c5b94d4f1e8c4bf5a6b7d3216634dab Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Sat, 14 Feb 2026 13:39:22 -0800 Subject: [PATCH 03/32] remove unused code --- src/starplot/geometry.py | 138 ++++----------------------------------- src/starplot/mixins.py | 4 +- 2 files changed, 14 insertions(+), 128 deletions(-) diff --git a/src/starplot/geometry.py b/src/starplot/geometry.py index f0bbcfb0..3ccab175 100644 --- a/src/starplot/geometry.py +++ b/src/starplot/geometry.py @@ -1,12 +1,11 @@ import random import math -from typing import Union import pyproj import numpy as np -from shapely import transform, union_all -from shapely.geometry import Point, Polygon, MultiPolygon, LineString +from shapely import union_all +from shapely.geometry import Point, Polygon, LineString GLOBAL_EXTENT = Polygon( [ @@ -160,55 +159,19 @@ def circle(center, diameter_degrees, num_pts=100) -> Polygon: ) -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) - - return transform(geometry, lambda c: c * [1 / 15, 1]) - - -def unwrap_polygon(polygon: Polygon) -> Polygon: - points = list(zip(*polygon.exterior.coords.xy)) - new_points = [] - prev = None - - 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 - - return Polygon(new_points) - - -def unwrap_polygon_360_inverse(polygon: Polygon) -> Polygon: - ra, dec = [p for p in polygon.exterior.coords.xy] - - 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) - - return polygon - - -def unwrap_polygon_360(polygon: Polygon) -> Polygon: - ra, dec = [p for p in polygon.exterior.coords.xy] - - 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))) +def union_at_zero(a: Polygon, b: Polygon) -> Polygon: + """ + Returns union of two polygons on a sphere, with coordinates in degrees. - return polygon + 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 -def union_at_zero(a: Polygon, b: Polygon) -> Polygon: - """Returns union of two polygons""" + Returns + Polygon union of first and second polygon + """ a_ra = list(a.exterior.coords.xy)[0] b_ra = list(b.exterior.coords.xy)[0] @@ -271,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, @@ -361,18 +259,6 @@ def random_point_in_polygon_at_distance( return None -def wrapped_polygon_adjustment(polygon: Polygon) -> int: - if "MultiPolygon" == str(polygon.geom_type): - return 0 - - ra, _ = [p for p in polygon.exterior.coords.xy] - - if min(ra) < 180 and max(ra) > 300: - return 360 - - return 0 - - def is_wrapped_polygon(polygon: Polygon) -> bool: if "MultiPolygon" == str(polygon.geom_type): return False diff --git a/src/starplot/mixins.py b/src/starplot/mixins.py index a8fb8c03..df318934 100644 --- a/src/starplot/mixins.py +++ b/src/starplot/mixins.py @@ -312,7 +312,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 +322,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) From 69abc389cac9b1f7193e218053b541ee84df23c4 Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Sun, 15 Feb 2026 05:48:16 -0800 Subject: [PATCH 04/32] notes --- src/starplot/geometry.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/starplot/geometry.py b/src/starplot/geometry.py index 3ccab175..8893a5ba 100644 --- a/src/starplot/geometry.py +++ b/src/starplot/geometry.py @@ -274,3 +274,22 @@ def is_wrapped_polygon(polygon: Polygon) -> bool: 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 + + + +class BaseGeometry: + + """ + Wrapper around shapely geometries + + 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 + + """ + def intersects(self): + """ + + + """ + pass From 078b350159860c6fe4772d9bebe046d906a3d509 Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Sun, 15 Feb 2026 07:56:15 -0800 Subject: [PATCH 05/32] notes --- src/starplot/geometry.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/starplot/geometry.py b/src/starplot/geometry.py index 8893a5ba..5c07953d 100644 --- a/src/starplot/geometry.py +++ b/src/starplot/geometry.py @@ -286,6 +286,17 @@ class BaseGeometry: 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 + TODO: + + Functions + - intersects + + Properties + - centroid + - bbox + - wkt + - wkb + """ def intersects(self): """ From 0dad34a91ef73a5b08a90281bfbef044795ced00 Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Mon, 16 Feb 2026 05:40:18 -0800 Subject: [PATCH 06/32] poc --- .gitignore | 1 + Makefile | 4 +- src/starplot/__init__.py | 1 + src/starplot/coordinates.py | 1 + src/starplot/mixins.py | 37 ++-- src/starplot/plots/__init__.py | 1 + src/starplot/plots/galaxy.py | 363 +++++++++++++++++++++++++++++++++ src/starplot/plots/map.py | 3 + src/starplot/plotters/text.py | 2 +- src/starplot/styles/base.py | 20 +- 10 files changed, 412 insertions(+), 21 deletions(-) create mode 100644 src/starplot/plots/galaxy.py diff --git a/.gitignore b/.gitignore index bf30eb90..a947b862 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ bigsky*.parquet hash_checks/data/*.png hash_checks/results.html +.tmp/ temp* temp.py temp/ diff --git a/Makefile b/Makefile index adafe328..87e2d266 100644 --- a/Makefile +++ b/Makefile @@ -65,10 +65,10 @@ examples: tutorial: $(DOCKER_RUN) "cd tutorial && python build.py" -profile: DR_ARGS=-it -p 8080:8080 +profile: DR_ARGS=-it -p 8081:8081 profile: $(DOCKER_RUN) "python -m cProfile -o temp/results.prof scripts/scratchpad.py && \ - snakeviz -s -p 8080 -H 0.0.0.0 temp/results.prof" + snakeviz -s -p 8081 -H 0.0.0.0 temp/results.prof" # builds ALL data files and then database: db: diff --git a/src/starplot/__init__.py b/src/starplot/__init__.py index 3a4328c9..0b25beb9 100644 --- a/src/starplot/__init__.py +++ b/src/starplot/__init__.py @@ -9,6 +9,7 @@ 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/mixins.py b/src/starplot/mixins.py index 7f37f193..32e870d5 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( 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/galaxy.py b/src/starplot/plots/galaxy.py new file mode 100644 index 00000000..1d339ed5 --- /dev/null +++ b/src/starplot/plots/galaxy.py @@ -0,0 +1,363 @@ +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, +) + + +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 `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. + 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, + collision_handler: CollisionHandler = None, + scale: float = 1.0, + autoscale: bool = False, + suppress_warnings: bool = True, + *args, + **kwargs, + ) -> "GalaxyPlot": + observer = observer or Observer() + style = style or PlotStyle().extend(extensions.MAP) + + super().__init__( + observer, + ephemeris, + style, + resolution, + collision_handler=collision_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})" + ) + + @use_style(PathStyle, "galactic_equator") + def galactic_equator( + self, + style: PathStyle = None, + label: str = "GALACTIC EQUATOR", + 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 + 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. + """ + 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( + style=style.line, + coordinates=radec, + ) + + if not label: + return + + label_spacing = int(len(radec) / 4) + + for ra, dec in radec[label_spacing::label_spacing]: + self.text( + label, + ra, + dec, + style.label, + collision_handler=collision_handler or self.collision_handler, + gid="galactic-equator-label", + ) + + @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/map.py b/src/starplot/plots/map.py index 1a515aa1..6413b4de 100644 --- a/src/starplot/plots/map.py +++ b/src/starplot/plots/map.py @@ -173,6 +173,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] diff --git a/src/starplot/plotters/text.py b/src/starplot/plotters/text.py index ddf6b358..ce62df96 100644 --- a/src/starplot/plotters/text.py +++ b/src/starplot/plotters/text.py @@ -137,7 +137,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, diff --git a/src/starplot/styles/base.py b/src/starplot/styles/base.py index be70bcb6..e9dc2b02 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", @@ -1149,7 +1148,6 @@ class PlotStyle(BaseStyle): ) """Styling for the Ecliptic""" - # Celestial Equator celestial_equator: PathStyle = PathStyle( line=LineStyle( color="#999", @@ -1168,6 +1166,24 @@ class PlotStyle(BaseStyle): ) """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=18, + font_color="#7c7c7c", + font_weight=FontWeightEnum.NORMAL, + font_alpha=1, + zorder=ZOrderEnum.LAYER_3, + ), + ) + """Styling for the Galactic Equator""" + horizon: PathStyle = PathStyle( line=LineStyle( color="#fff", From 76d6f81ab562ea1ac25a4e1f12e219a391f19848 Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Mon, 16 Feb 2026 06:16:35 -0800 Subject: [PATCH 07/32] docs --- docs/changelog.md | 6 ++++++ docs/coming-soon.md | 11 ++++++----- docs/reference-galaxyplot.md | 18 ++++++++++++++++++ mkdocs.yml | 1 + src/starplot/plots/galaxy.py | 7 +------ src/starplot/plots/horizon.py | 1 - 6 files changed, 32 insertions(+), 12 deletions(-) create mode 100644 docs/reference-galaxyplot.md diff --git a/docs/changelog.md b/docs/changelog.md index 3fc18d96..c9b15442 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,4 +1,10 @@ +## v0.20.x + +- Adds a `GalaxyPlot` for plotting in galactic coordinates + + ## v0.19.x +[Documentation](https://archives.starplot.dev/0.19.6/) - Adds a `CollisionHandler` for defining what to do when labels collide with something - Introduces catalogs for the Milky Way and constellation borders diff --git a/docs/coming-soon.md b/docs/coming-soon.md index 49007063..0415cdd8 100644 --- a/docs/coming-soon.md +++ b/docs/coming-soon.md @@ -1,30 +1,31 @@ - + - + - + diff --git a/docs/reference-galaxyplot.md b/docs/reference-galaxyplot.md new file mode 100644 index 00000000..b670a3ca --- /dev/null +++ b/docs/reference-galaxyplot.md @@ -0,0 +1,18 @@ +**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. + +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/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/src/starplot/plots/galaxy.py b/src/starplot/plots/galaxy.py index 1d339ed5..affde4b7 100644 --- a/src/starplot/plots/galaxy.py +++ b/src/starplot/plots/galaxy.py @@ -47,7 +47,6 @@ class GalaxyPlot( """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 `Observer()` ephemeris: Ephemeris to use for calculating planet positions (see [Skyfield's documentation](https://rhodesmill.org/skyfield/planets.html) for details) @@ -127,11 +126,7 @@ def _prepare_coords(self, ra, dec) -> (float, float): 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, - ) + df["x"], df["y"] = (lon.degrees, lat.degrees) return df def _plot_kwargs(self) -> dict: diff --git a/src/starplot/plots/horizon.py b/src/starplot/plots/horizon.py index e7715177..184ada21 100644 --- a/src/starplot/plots/horizon.py +++ b/src/starplot/plots/horizon.py @@ -57,7 +57,6 @@ 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()` From 81b4ab1da31957412180cd649f98a97940f9ef92 Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Mon, 16 Feb 2026 11:28:40 -0800 Subject: [PATCH 08/32] baseline line labels --- src/starplot/plots/base.py | 93 ++++++++++- src/starplot/plots/galaxy.py | 23 +-- src/starplot/plotters/text.py | 200 ++++++++++++++++++++++++ src/starplot/styles/base.py | 19 ++- src/starplot/styles/ext/blue_light.yml | 19 ++- src/starplot/styles/ext/blue_medium.yml | 3 + src/starplot/styles/ext/blue_night.yml | 19 ++- 7 files changed, 336 insertions(+), 40 deletions(-) diff --git a/src/starplot/plots/base.py b/src/starplot/plots/base.py index 63240ff4..03e68736 100644 --- a/src/starplot/plots/base.py +++ b/src/starplot/plots/base.py @@ -895,6 +895,7 @@ def optic_fov( style=style, ) + @profile @use_style(PathStyle, "ecliptic") def ecliptic( self, @@ -922,6 +923,16 @@ def ecliptic( if self.in_bounds(ra * 15, dec): inbounds.append((ra * 15, dec)) + coords = [(ra * 15, dec) for ra, dec in ecliptic.RA_DECS] + + self.line_label( + style=style, + label=label.upper(), + collision_handler=collision_handler or self.collision_handler, + coordinates=coords, + ) + return + self.ax.plot( x, y, @@ -945,6 +956,7 @@ def ecliptic( gid="ecliptic-label", ) + @profile @use_style(PathStyle, "celestial_equator") def celestial_equator( self, @@ -960,13 +972,21 @@ def celestial_equator( 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. """ + label = translate(label, self.language) + coords = [(ra, 0) for ra in range(0, 361)] + coords.reverse() # TODO : solve this on the text_line function instead + self.line_label( + style=style, + label=label.upper(), + collision_handler=collision_handler or self.collision_handler, + coordinates=coords, + gid="celestial-equator", + ) + return + x = [] y = [] - # TODO : handle wrapping - - label = translate(label, self.language) - for ra in range(25): x0, y0 = self._prepare_coords(ra * 15, 0) x.append(x0) @@ -992,3 +1012,68 @@ def celestial_equator( collision_handler=collision_handler or self.collision_handler, gid="celestial-equator-label", ) + + @use_style(PathStyle) + def line_label( + self, + style: PathStyle, + label: str = None, + collision_handler: CollisionHandler = None, + num_labels: int = 2, + coordinates: list[tuple[float, float]] = None, + geometry: LineString = None, + **kwargs, + ): + """Plots a line, with optional labels. Either coordinates OR geometry must be specified. + + Args: + style: Style of the line + label: Label for the line + num_labels: Number of labels to plot along the line + 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. + + """ + + 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, + dash_capstyle=style.line.dash_capstyle, + gid=gid, + **style.line.matplot_kwargs(self.scale), + **self._plot_kwargs(), + ) + + 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) + + + self._text_line( + x, + y, + [label] * num_labels, + collision_handler=collision_handler or self.collision_handler, + min_spacing=0.75, + **style.label.matplot_kwargs(self.scale), + **self._plot_kwargs(), + clip_path=self._background_clip_path, + ) diff --git a/src/starplot/plots/galaxy.py b/src/starplot/plots/galaxy.py index affde4b7..52f757b8 100644 --- a/src/starplot/plots/galaxy.py +++ b/src/starplot/plots/galaxy.py @@ -31,6 +31,7 @@ PathStyle, GradientDirection, ) +from starplot.profile import profile class GalaxyPlot( @@ -180,6 +181,7 @@ def _calc_position(self): 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, @@ -206,26 +208,13 @@ def galactic_equator( radec = list(zip(ra_values, dec_values)) - self.line( - style=style.line, + self.line_label( + label=label, + collision_handler=collision_handler or self.collision_handler, + style=style, coordinates=radec, ) - if not label: - return - - label_spacing = int(len(radec) / 4) - - for ra, dec in radec[label_spacing::label_spacing]: - self.text( - label, - ra, - dec, - style.label, - collision_handler=collision_handler or self.collision_handler, - gid="galactic-equator-label", - ) - @use_style(PathStyle, "gridlines") def gridlines( self, diff --git a/src/starplot/plotters/text.py b/src/starplot/plotters/text.py index ce62df96..af913885 100644 --- a/src/starplot/plotters/text.py +++ b/src/starplot/plotters/text.py @@ -99,6 +99,68 @@ def __post_init__(self): ] + +def find_smooth_sections(x, y, min_length=5, curvature_threshold=0.1): + """ + Find sections of the line where curvature is low (smooth). + + Args: + x, y: 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 + """ + 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 = [] @@ -479,6 +541,144 @@ def _text_area( if is_final_attempt: return None + def _text_line( + self, + x, + y, + labels, + collision_handler: CollisionHandler, + min_spacing=None, + prefer_center=True, + curvature_threshold=0.8, + **kwargs, + ): + """ + Plot multiple labels on the smoothest sections of the line. + + Args: + x, y: line coordinates + labels: list of label strings to plot + 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 + + Returns: + List of indices where labels were placed + """ + + # TODO : + # - add collision handling + # - make extra positions more intelligent (e.g. find position far from already selected) + + n_labels = len(labels) + + 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) + + display_x, display_y = zip(*display_xy) + x, y = zip(*data_xy) + + if min_spacing is None: + min_spacing = 1.0 / (n_labels + 1) + + min_distance = int(min_spacing * len(x)) + + # Find all smooth sections + smooth_sections = find_smooth_sections( + display_x, display_y, min_length=1, curvature_threshold=curvature_threshold + ) + print("SMOOTH = ", len(smooth_sections)) + + # Select top N sections, ensuring they don't overlap + selected_positions = [] + used_sections = [] + + for section_start, section_end, score in smooth_sections: + # Calculate center of this section + if prefer_center: + section_center = (section_start + section_end) // 2 + else: + section_center = section_start + + # Check if this section is too close to already selected positions + too_close = False + for pos in selected_positions: + if abs(section_center - pos) < min_distance: + too_close = True + break + + if not too_close: + selected_positions.append(section_center) + used_sections.append((section_start, section_end, score)) + + # if len(selected_positions) >= n_labels: + # break + + print("SELECTED = ", len(selected_positions)) + + # If we don't have enough positions, add more based on spacing + if len(selected_positions) < n_labels: + # Fall back to evenly spaced positions + + # TODO : this should pick farthest + for i in range(len(selected_positions), n_labels): + pos = int((i + 1) * len(x) / (n_labels + 1)) + selected_positions.append(pos) + + chunk_size = len(x) // 4 + evenly_spaced = [i for i in range(chunk_size, len(x), chunk_size)] + # Sort positions by x coordinate for consistent ordering + selected_positions.sort() + + selected_positions.extend(evenly_spaced) + + print("SELECTED = ", len(selected_positions)) + print(selected_positions) + + plotted_label_count = 0 + + # Plot labels at selected positions + label_positions = [] + for i, (pos, label) in enumerate(zip(selected_positions[:n_labels], labels)): + idx = max(0, min(pos, len(x) - 2)) + + x_pos = x[idx] + y_pos = y[idx] + + # calculate angle in display coordinates + points_data = np.array([[x[idx], y[idx]], [x[idx + 1], y[idx + 1]]]) + points_display = self.ax.transData.transform(points_data) + dx_display = points_display[1, 0] - points_display[0, 0] + dy_display = points_display[1, 1] - points_display[0, 1] + angle = np.degrees(np.arctan2(dy_display, dx_display)) + + # Keep text upright + if angle > 90: + angle -= 180 + elif angle < -90: + angle += 180 + + kwargs.pop("ha", None) + kwargs.pop("va", None) + kwargs.pop("transform", None) # we're plotting in display coords + + self.ax.text( + x_pos, + y_pos, + label, + rotation=angle, + ha="center", + va="center", + **kwargs, + ) + + label_positions.append(idx) + + return label_positions + @use_style(LabelStyle) def text( self, diff --git a/src/starplot/styles/base.py b/src/starplot/styles/base.py index e9dc2b02..730e7b99 100644 --- a/src/starplot/styles/base.py +++ b/src/starplot/styles/base.py @@ -1140,9 +1140,12 @@ 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, ), ) @@ -1153,14 +1156,16 @@ class PlotStyle(BaseStyle): 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, ), ) @@ -1175,10 +1180,12 @@ class PlotStyle(BaseStyle): zorder=ZOrderEnum.LAYER_3, ), label=LabelStyle( - font_size=18, + font_size=21, font_color="#7c7c7c", font_weight=FontWeightEnum.NORMAL, font_alpha=1, + border_width=8, + border_color="#000", zorder=ZOrderEnum.LAYER_3, ), ) diff --git a/src/starplot/styles/ext/blue_light.yml b/src/starplot/styles/ext/blue_light.yml index 7dcf25a8..bc1ea23e 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 @@ -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..d4d7312b 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 diff --git a/src/starplot/styles/ext/blue_night.yml b/src/starplot/styles/ext/blue_night.yml index 30d71dfe..1cc686f8 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 @@ -121,8 +130,8 @@ dso_planetary_nebula: *DSO-NEB dso_open_cluster: &DSO-OC marker: alpha: 1 - color: hsl(62, 48%, 33%) - edge_color: hsl(58, 98%, 74%) + color: hsl(62, 48%, 36%) + edge_color: hsl(58, 100%, 60%) edge_width: 1 label: font_color: hsl(58, 98%, 58%) From 5642c37776d42afaa5c203f9644866557e07e044 Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Tue, 17 Feb 2026 06:33:47 -0800 Subject: [PATCH 09/32] always find next best --- docs/changelog.md | 1 + src/starplot/plots/base.py | 29 ++-- src/starplot/plotters/text.py | 233 ++++++++++++++++++++------ src/starplot/styles/ext/antique.yml | 18 +- src/starplot/styles/ext/blue_dark.yml | 14 +- src/starplot/styles/ext/blue_gold.yml | 2 + src/starplot/styles/ext/nord.yml | 18 +- 7 files changed, 234 insertions(+), 81 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index c9b15442..3a03c512 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,6 +1,7 @@ ## v0.20.x - Adds a `GalaxyPlot` for plotting in galactic coordinates +- Adds label support to lines with automatic angle adjustment ## v0.19.x diff --git a/src/starplot/plots/base.py b/src/starplot/plots/base.py index 03e68736..3b3d25c3 100644 --- a/src/starplot/plots/base.py +++ b/src/starplot/plots/base.py @@ -902,6 +902,7 @@ def ecliptic( style: PathStyle = None, label: str = "ECLIPTIC", collision_handler: CollisionHandler = None, + num_labels: int = 2, ): """Plots the ecliptic @@ -928,6 +929,7 @@ def ecliptic( self.line_label( style=style, label=label.upper(), + num_labels=num_labels, collision_handler=collision_handler or self.collision_handler, coordinates=coords, ) @@ -963,6 +965,7 @@ def celestial_equator( style: PathStyle = None, label: str = "CELESTIAL EQUATOR", collision_handler: CollisionHandler = None, + num_labels: int = 2, ): """ Plots the celestial equator @@ -974,16 +977,16 @@ def celestial_equator( """ label = translate(label, self.language) coords = [(ra, 0) for ra in range(0, 361)] - coords.reverse() # TODO : solve this on the text_line function instead self.line_label( style=style, label=label.upper(), + num_labels=num_labels, collision_handler=collision_handler or self.collision_handler, coordinates=coords, gid="celestial-equator", ) return - + x = [] y = [] @@ -1032,19 +1035,18 @@ def line_label( num_labels: Number of labels to plot along the line 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. - + """ 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" - + gid = kwargs.get("gid") or "line" + self.ax.plot( x, y, @@ -1058,22 +1060,25 @@ def line_label( if not label: return - - prepared_coords = [(x, y) for x, y in prepared_coords if self._in_bounds_xy(x, y)] + + 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) + x, y = zip(*prepared_coords) self._text_line( x, y, - [label] * num_labels, + label, + num_labels=num_labels, collision_handler=collision_handler or self.collision_handler, - min_spacing=0.75, + 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/plotters/text.py b/src/starplot/plotters/text.py index af913885..92a92662 100644 --- a/src/starplot/plotters/text.py +++ b/src/starplot/plotters/text.py @@ -99,6 +99,51 @@ def __post_init__(self): ] +def sort_by_evenness_with_existing( + x, y, existing_positions, candidate_positions, n_new_labels +): + """ + Sort candidates considering both ideal spacing and existing label positions. + """ + # Calculate what the spacing should be with all labels + total_labels = len(existing_positions) + n_new_labels + + # For each candidate, calculate how it improves overall spacing + def calculate_spacing_quality(new_positions): + """ + Calculate spacing quality metric. + Lower = better (more even spacing). + """ + all_positions = sorted(existing_positions + list(new_positions)) + + # Calculate gaps between consecutive labels + gaps = [ + all_positions[i + 1] - all_positions[i] + for i in range(len(all_positions) - 1) + ] + + # Ideal gap + ideal_gap = len(x) / len(all_positions) + + # Calculate variance from ideal gap + variance = sum((gap - ideal_gap) ** 2 for gap in gaps) + + return variance + + # Score each candidate based on how much it improves spacing + candidate_scores = [] + + for candidate in candidate_positions: + # Calculate quality if we add this candidate + score = calculate_spacing_quality([candidate]) + candidate_scores.append((candidate, score)) + + # Sort by score (lower = better) + candidate_scores.sort(key=lambda x: x[1]) + + # Return just the positions + return [pos for pos, score in candidate_scores] + def find_smooth_sections(x, y, min_length=5, curvature_threshold=0.1): """ @@ -159,8 +204,6 @@ def find_smooth_sections(x, y, min_length=5, curvature_threshold=0.1): return smooth_sections if smooth_sections else [(0, len(x) - 1, 1.0)] - - class TextPlotterMixin: def __init__(self, *args, **kwargs): self.labels = [] @@ -545,8 +588,9 @@ def _text_line( self, x, y, - labels, - collision_handler: CollisionHandler, + text: str, + num_labels: int = 2, + collision_handler: CollisionHandler = None, min_spacing=None, prefer_center=True, curvature_threshold=0.8, @@ -567,30 +611,26 @@ def _text_line( List of indices where labels were placed """ - # TODO : - # - add collision handling - # - make extra positions more intelligent (e.g. find position far from already selected) - - n_labels = len(labels) - 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()] + display_x, display_y = zip(*display_xy) x, y = zip(*data_xy) - + if min_spacing is None: - min_spacing = 1.0 / (n_labels + 1) + min_spacing = 1.0 / (num_labels + 1) - min_distance = int(min_spacing * len(x)) + min_distance = int(min_spacing * len(display_x)) # Find all smooth sections smooth_sections = find_smooth_sections( display_x, display_y, min_length=1, curvature_threshold=curvature_threshold ) - print("SMOOTH = ", len(smooth_sections)) + # print("SMOOTH = ", len(smooth_sections)) # Select top N sections, ensuring they don't overlap selected_positions = [] @@ -617,42 +657,45 @@ def _text_line( # if len(selected_positions) >= n_labels: # break - print("SELECTED = ", len(selected_positions)) + # print("SELECTED = ", len(selected_positions)) + # selected_positions = [] # If we don't have enough positions, add more based on spacing - if len(selected_positions) < n_labels: - # Fall back to evenly spaced positions + # if len(selected_positions) < num_labels: + # # Fall back to evenly spaced positions + # for i in range(len(selected_positions), num_labels): + # pos = int((i + 1) * len(display_x) / (num_labels + 1)) + # selected_positions.append(pos) - # TODO : this should pick farthest - for i in range(len(selected_positions), n_labels): - pos = int((i + 1) * len(x) / (n_labels + 1)) - selected_positions.append(pos) + """ + + 1. create list of evenly spaced indices + 2. try selected positions first + 3. if not plotted number of labels, then try evenly spaced + 4. sort evenly spaced by distance from plotted + """ - chunk_size = len(x) // 4 - evenly_spaced = [i for i in range(chunk_size, len(x), chunk_size)] # Sort positions by x coordinate for consistent ordering - selected_positions.sort() - - selected_positions.extend(evenly_spaced) + # selected_positions.sort() - print("SELECTED = ", len(selected_positions)) - print(selected_positions) + # chunk_size = max(1, len(display_x) // (num_labels + 1)) + # selected_positions = [i for i in range(chunk_size, len(display_x) - chunk_size, chunk_size)] - plotted_label_count = 0 + # selected_positions = [] - # Plot labels at selected positions - label_positions = [] - for i, (pos, label) in enumerate(zip(selected_positions[:n_labels], labels)): - idx = max(0, min(pos, len(x) - 2)) + attempts = 0 + plotted_positions = [] - x_pos = x[idx] - y_pos = y[idx] + kwargs.pop("ha", None) + kwargs.pop("va", None) + kwargs.pop("transform", None) # we're plotting in axes coords + def plot_label(x0, y0, x1, y1, text): # calculate angle in display coordinates - points_data = np.array([[x[idx], y[idx]], [x[idx + 1], y[idx + 1]]]) - points_display = self.ax.transData.transform(points_data) - dx_display = points_display[1, 0] - points_display[0, 0] - dy_display = points_display[1, 1] - points_display[0, 1] + # points_data = np.array([[x0, y0], [x1, y1]]) + # points_display = self.ax.transData.transform(points_data) + dx_display = x1 - x0 + dy_display = y1 - y0 angle = np.degrees(np.arctan2(dy_display, dx_display)) # Keep text upright @@ -661,23 +704,115 @@ def _text_line( elif angle < -90: angle += 180 - kwargs.pop("ha", None) - kwargs.pop("va", None) - kwargs.pop("transform", None) # we're plotting in display coords + axes_coords = self.ax.transAxes.inverted().transform([(x0, y0)]) + x_axes, y_axes = axes_coords[0] - self.ax.text( - x_pos, - y_pos, - label, + return self.ax.text( + x_axes, + y_axes, + text, rotation=angle, ha="center", va="center", + transform=self.ax.transAxes, **kwargs, ) - label_positions.append(idx) + for pos in selected_positions: + attempts += 1 + + idx = max(0, min(pos, len(display_x) - 2)) + x0 = display_x[idx] + y0 = display_y[idx] + x1 = display_x[idx + 1] + y1 = display_y[idx + 1] + + label = plot_label(x0, y0, x1, y1, text) + bbox = self._get_label_bbox(label) + + 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.append(idx) + 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 + + # find and try more positions + chunk_size = max(1, len(display_x) // 10) + evenly_spaced = [ + i for i in range(chunk_size, len(display_x) - chunk_size, chunk_size) + ] + evenly_spaced = evenly_spaced[1:-1] + + # print(chunk_size, [int(display_x[i]) for i in plotted_positions], [int(display_x[i]) for i in more_positions]) + + while ( + len(plotted_positions) < num_labels + and attempts < collision_handler.attempts + and len(evenly_spaced) > 0 + ): + attempts += 1 + + if plotted_positions: + evenly_spaced = sort_by_evenness_with_existing( + display_x, + display_y, + plotted_positions, + evenly_spaced, + len(evenly_spaced), + ) + + pos = evenly_spaced.pop(0) + + idx = max(0, min(pos, len(display_x) - 2)) + x0 = display_x[idx] + y0 = display_y[idx] + x1 = display_x[idx + 1] + y1 = display_y[idx + 1] + + label = plot_label(x0, y0, x1, y1, text) + bbox = self._get_label_bbox(label) + + 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.append(idx) + + elif label is not None: + label.remove() - return label_positions + if is_final_attempt or len(plotted_positions) == num_labels: + return @use_style(LabelStyle) def text( 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..e9c86944 100644 --- a/src/starplot/styles/ext/blue_gold.yml +++ b/src/starplot/styles/ext/blue_gold.yml @@ -18,12 +18,14 @@ legend: celestial_equator: label: font_color: '#2d5ec2' + border_color: rgb(46, 61, 74) line: color: hsl(220, 62%, 47%) alpha: 0.8 ecliptic: label: font_color: hsl(4, 60%, 54%) + border_color: rgb(46, 61, 74) line: color: hsl(4, 60%, 54%) alpha: 0.9 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 From 1401e80c93f88f88bb4e4a8fd5147cad26c25052 Mon Sep 17 00:00:00 2001 From: Pascual Marcone Date: Tue, 17 Feb 2026 11:48:43 -0300 Subject: [PATCH 10/32] Continuous moon phase illumination (#230) * added continuous moon phase illumination * changed default num_pts to 200 to minimize graphical glitch --- src/starplot/plots/base.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/starplot/plots/base.py b/src/starplot/plots/base.py index 63240ff4..f8b4d688 100644 --- a/src/starplot/plots/base.py +++ b/src/starplot/plots/base.py @@ -785,7 +785,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 +793,8 @@ 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 +861,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", ) From cd60da6fe4a04ff905ab09725e5505e57daf5d68 Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Tue, 17 Feb 2026 07:16:45 -0800 Subject: [PATCH 11/32] clean up --- src/starplot/plots/base.py | 60 +-------------- src/starplot/plots/galaxy.py | 3 + src/starplot/plotters/text.py | 105 +++++--------------------- src/starplot/styles/ext/blue_gold.yml | 8 +- 4 files changed, 30 insertions(+), 146 deletions(-) diff --git a/src/starplot/plots/base.py b/src/starplot/plots/base.py index 3b3d25c3..6ca89391 100644 --- a/src/starplot/plots/base.py +++ b/src/starplot/plots/base.py @@ -901,14 +901,15 @@ def ecliptic( self, style: PathStyle = None, label: str = "ECLIPTIC", - collision_handler: CollisionHandler = None, num_labels: int = 2, + collision_handler: CollisionHandler = None, ): """Plots the 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 + 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 collision handler of the plot will be used. """ x = [] @@ -933,30 +934,6 @@ def ecliptic( collision_handler=collision_handler or self.collision_handler, coordinates=coords, ) - return - - 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) - - 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", - ) @profile @use_style(PathStyle, "celestial_equator") @@ -964,8 +941,8 @@ def celestial_equator( self, style: PathStyle = None, label: str = "CELESTIAL EQUATOR", - collision_handler: CollisionHandler = None, num_labels: int = 2, + collision_handler: CollisionHandler = None, ): """ Plots the celestial equator @@ -973,6 +950,7 @@ 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 + 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 collision handler of the plot will be used. """ label = translate(label, self.language) @@ -985,36 +963,6 @@ def celestial_equator( coordinates=coords, gid="celestial-equator", ) - return - - x = [] - y = [] - - for ra in range(25): - x0, y0 = self._prepare_coords(ra * 15, 0) - x.append(x0) - y.append(y0) - - self.ax.plot( - x, - y, - clip_path=self._background_clip_path, - gid="celestial-equator-line", - **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", - ) @use_style(PathStyle) def line_label( diff --git a/src/starplot/plots/galaxy.py b/src/starplot/plots/galaxy.py index 52f757b8..2f9e7eb0 100644 --- a/src/starplot/plots/galaxy.py +++ b/src/starplot/plots/galaxy.py @@ -187,6 +187,7 @@ def galactic_equator( self, style: PathStyle = None, label: str = "GALACTIC EQUATOR", + num_labels: int = 2, collision_handler: CollisionHandler = None, ): """ @@ -195,6 +196,7 @@ def 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 collision handler of the plot will be used. """ lons = np.array([ra for ra in range(0, 361)]) # galactic longitudes @@ -210,6 +212,7 @@ def galactic_equator( self.line_label( label=label, + num_labels=num_labels, collision_handler=collision_handler or self.collision_handler, style=style, coordinates=radec, diff --git a/src/starplot/plotters/text.py b/src/starplot/plotters/text.py index 92a92662..bf9a62f7 100644 --- a/src/starplot/plotters/text.py +++ b/src/starplot/plotters/text.py @@ -595,20 +595,22 @@ def _text_line( prefer_center=True, curvature_threshold=0.8, **kwargs, - ): + ) -> None: """ - Plot multiple labels on the smoothest sections of the line. + 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 coordinates - labels: list of label strings to plot - min_spacing: minimum spacing between labels (as fraction of line length) - If None, uses 1/(n_labels+1) + 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 - Returns: - List of indices where labels were placed """ xy = list(zip(x, y)) @@ -630,7 +632,6 @@ def _text_line( smooth_sections = find_smooth_sections( display_x, display_y, min_length=1, curvature_threshold=curvature_threshold ) - # print("SMOOTH = ", len(smooth_sections)) # Select top N sections, ensuring they don't overlap selected_positions = [] @@ -654,35 +655,6 @@ def _text_line( selected_positions.append(section_center) used_sections.append((section_start, section_end, score)) - # if len(selected_positions) >= n_labels: - # break - - # print("SELECTED = ", len(selected_positions)) - - # selected_positions = [] - # If we don't have enough positions, add more based on spacing - # if len(selected_positions) < num_labels: - # # Fall back to evenly spaced positions - # for i in range(len(selected_positions), num_labels): - # pos = int((i + 1) * len(display_x) / (num_labels + 1)) - # selected_positions.append(pos) - - """ - - 1. create list of evenly spaced indices - 2. try selected positions first - 3. if not plotted number of labels, then try evenly spaced - 4. sort evenly spaced by distance from plotted - """ - - # Sort positions by x coordinate for consistent ordering - # selected_positions.sort() - - # chunk_size = max(1, len(display_x) // (num_labels + 1)) - # selected_positions = [i for i in range(chunk_size, len(display_x) - chunk_size, chunk_size)] - - # selected_positions = [] - attempts = 0 plotted_positions = [] @@ -692,13 +664,11 @@ def _text_line( def plot_label(x0, y0, x1, y1, text): # calculate angle in display coordinates - # points_data = np.array([[x0, y0], [x1, y1]]) - # points_display = self.ax.transData.transform(points_data) dx_display = x1 - x0 dy_display = y1 - y0 angle = np.degrees(np.arctan2(dy_display, dx_display)) - # Keep text upright + # keep text upright if angle > 90: angle -= 180 elif angle < -90: @@ -718,69 +688,30 @@ def plot_label(x0, y0, x1, y1, text): **kwargs, ) - for pos in selected_positions: - attempts += 1 - - idx = max(0, min(pos, len(display_x) - 2)) - x0 = display_x[idx] - y0 = display_y[idx] - x1 = display_x[idx + 1] - y1 = display_y[idx + 1] - - label = plot_label(x0, y0, x1, y1, text) - bbox = self._get_label_bbox(label) - - 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.append(idx) - 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 - - # find and try more positions chunk_size = max(1, len(display_x) // 10) evenly_spaced = [ i for i in range(chunk_size, len(display_x) - chunk_size, chunk_size) ] evenly_spaced = evenly_spaced[1:-1] - - # print(chunk_size, [int(display_x[i]) for i in plotted_positions], [int(display_x[i]) for i in more_positions]) + positions = selected_positions + evenly_spaced while ( len(plotted_positions) < num_labels and attempts < collision_handler.attempts - and len(evenly_spaced) > 0 + and len(positions) > 0 ): attempts += 1 if plotted_positions: - evenly_spaced = sort_by_evenness_with_existing( + positions = sort_by_evenness_with_existing( display_x, display_y, plotted_positions, - evenly_spaced, - len(evenly_spaced), + positions, + len(positions), ) - pos = evenly_spaced.pop(0) + pos = positions.pop(0) idx = max(0, min(pos, len(display_x) - 2)) x0 = display_x[idx] @@ -807,6 +738,8 @@ def plot_label(x0, y0, x1, y1, text): if is_open or (collision_handler.plot_on_fail and is_final_attempt): self._add_label_to_rtree(label, bbox=bbox) plotted_positions.append(idx) + if self.debug_text and label: + self._debug_bbox(bbox, color="red", width=1) elif label is not None: label.remove() diff --git a/src/starplot/styles/ext/blue_gold.yml b/src/starplot/styles/ext/blue_gold.yml index e9c86944..cf480051 100644 --- a/src/starplot/styles/ext/blue_gold.yml +++ b/src/starplot/styles/ext/blue_gold.yml @@ -21,14 +21,14 @@ celestial_equator: border_color: rgb(46, 61, 74) line: color: hsl(220, 62%, 47%) - alpha: 0.8 + alpha: 1 ecliptic: label: - font_color: hsl(4, 60%, 54%) + font_color: hsl(4, 78%, 60%) border_color: rgb(46, 61, 74) line: - color: hsl(4, 60%, 54%) - alpha: 0.9 + color: hsl(4, 78%, 57%) + alpha: 1 horizon: line: From 5d4882f5534dae18db6b416c7ff60fa846d60a3c Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Thu, 19 Feb 2026 07:09:26 -0800 Subject: [PATCH 12/32] refactor and cleanup --- src/starplot/plotters/text.py | 124 +++++++++++++++++----------------- 1 file changed, 62 insertions(+), 62 deletions(-) diff --git a/src/starplot/plotters/text.py b/src/starplot/plotters/text.py index bf9a62f7..6dab9b88 100644 --- a/src/starplot/plotters/text.py +++ b/src/starplot/plotters/text.py @@ -99,50 +99,48 @@ def __post_init__(self): ] -def sort_by_evenness_with_existing( - x, y, existing_positions, candidate_positions, n_new_labels -): +def next_best_position( + plotted_positions: list[int], + available_positions: list[int], + num_labels: int, + num_positions: int, +) -> int: """ - Sort candidates considering both ideal spacing and existing label positions. - """ - # Calculate what the spacing should be with all labels - total_labels = len(existing_positions) + n_new_labels + Returns the next best (evenly spaced) position based on distance from plotted positions - # For each candidate, calculate how it improves overall spacing - def calculate_spacing_quality(new_positions): - """ - Calculate spacing quality metric. - Lower = better (more even spacing). - """ - all_positions = sorted(existing_positions + list(new_positions)) + Assumes original positions are evenly spaced on line - # Calculate gaps between consecutive labels - gaps = [ - all_positions[i + 1] - all_positions[i] - for i in range(len(all_positions) - 1) - ] + 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 - # Ideal gap - ideal_gap = len(x) / len(all_positions) + Returns: + Next best (evenly spaced) position (the index from original list of coordinates) + """ - # Calculate variance from ideal gap - variance = sum((gap - ideal_gap) ** 2 for gap in gaps) + if len(plotted_positions) == 0: + return available_positions[len(available_positions) // (num_labels + 1)] - return variance + 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) - # Score each candidate based on how much it improves spacing - candidate_scores = [] + # 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)) - for candidate in candidate_positions: - # Calculate quality if we add this candidate - score = calculate_spacing_quality([candidate]) - candidate_scores.append((candidate, score)) + if not possible: + return None - # Sort by score (lower = better) - candidate_scores.sort(key=lambda x: x[1]) + possible.sort() - # Return just the positions - return [pos for pos, score in candidate_scores] + return possible[0][1] def find_smooth_sections(x, y, min_length=5, curvature_threshold=0.1): @@ -613,13 +611,16 @@ def _text_line( """ + 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()] - display_x, display_y = zip(*display_xy) x, y = zip(*data_xy) @@ -634,7 +635,7 @@ def _text_line( ) # Select top N sections, ensuring they don't overlap - selected_positions = [] + smooth_positions = [] used_sections = [] for section_start, section_end, score in smooth_sections: @@ -646,22 +647,15 @@ def _text_line( # Check if this section is too close to already selected positions too_close = False - for pos in selected_positions: + for pos in smooth_positions: if abs(section_center - pos) < min_distance: too_close = True break if not too_close: - selected_positions.append(section_center) + smooth_positions.append(section_center) used_sections.append((section_start, section_end, score)) - attempts = 0 - plotted_positions = [] - - kwargs.pop("ha", None) - kwargs.pop("va", None) - kwargs.pop("transform", None) # we're plotting in axes coords - def plot_label(x0, y0, x1, y1, text): # calculate angle in display coordinates dx_display = x1 - x0 @@ -688,12 +682,12 @@ def plot_label(x0, y0, x1, y1, text): **kwargs, ) - chunk_size = max(1, len(display_x) // 10) - evenly_spaced = [ - i for i in range(chunk_size, len(display_x) - chunk_size, chunk_size) + offset = len(display_x) // 20 # offset from start/end of line + positions = [p for p in range(len(display_x)) if p not in smooth_positions][ + offset : -1 * offset ] - evenly_spaced = evenly_spaced[1:-1] - positions = selected_positions + evenly_spaced + attempts = 0 + plotted_positions = set() while ( len(plotted_positions) < num_labels @@ -702,26 +696,32 @@ def plot_label(x0, y0, x1, y1, text): ): attempts += 1 - if plotted_positions: - positions = sort_by_evenness_with_existing( - display_x, - display_y, + if smooth_positions: + pos = smooth_positions.pop() + else: + pos = next_best_position( plotted_positions, positions, - len(positions), + num_labels, + len(display_x), ) - pos = positions.pop(0) + if pos is None: + return + if pos in positions: + positions.remove(pos) - idx = max(0, min(pos, len(display_x) - 2)) - x0 = display_x[idx] - y0 = display_y[idx] - x1 = display_x[idx + 1] - y1 = display_y[idx + 1] + pos = max(0, min(pos, len(display_x) - 2)) + x0 = display_x[pos] + y0 = display_y[pos] + x1 = display_x[pos + 1] + y1 = display_y[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 @@ -737,7 +737,7 @@ def plot_label(x0, y0, x1, y1, text): if is_open or (collision_handler.plot_on_fail and is_final_attempt): self._add_label_to_rtree(label, bbox=bbox) - plotted_positions.append(idx) + plotted_positions.add(pos) if self.debug_text and label: self._debug_bbox(bbox, color="red", width=1) From f79dbc868180677084484e0aee5c80efefeace8d Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Sat, 21 Feb 2026 07:11:36 -0800 Subject: [PATCH 13/32] fixes --- docs/examples/map-milky-way-stars.md | 1 + examples/map_milky_way_stars.py | 31 +++++++------------------ src/starplot/plotters/dsos.py | 2 +- src/starplot/plotters/text.py | 4 ++++ src/starplot/styles/ext/blue_light.yml | 4 ++-- src/starplot/styles/ext/blue_medium.yml | 6 ++--- 6 files changed, 19 insertions(+), 29 deletions(-) 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 ![map-milky-way-stars](/images/examples/map_milky_way_stars.png) +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/examples/map_milky_way_stars.py b/examples/map_milky_way_stars.py index 52f56be1..2a2b6c7f 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 and 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/src/starplot/plotters/dsos.py b/src/starplot/plotters/dsos.py index f98421d1..524a9eaa 100644 --- a/src/starplot/plotters/dsos.py +++ b/src/starplot/plotters/dsos.py @@ -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/text.py b/src/starplot/plotters/text.py index 6dab9b88..097904e4 100644 --- a/src/starplot/plotters/text.py +++ b/src/starplot/plotters/text.py @@ -610,6 +610,10 @@ def _text_line( curvature_threshold: threshold for determining smooth sections """ + collision_handler = CollisionHandler( + allow_constellation_line_collisions=True, + # allow_marker_collisions=True, + ) kwargs.pop("ha", None) # alignment is forced to center of line kwargs.pop("va", None) diff --git a/src/starplot/styles/ext/blue_light.yml b/src/starplot/styles/ext/blue_light.yml index bc1ea23e..19b1126d 100644 --- a/src/starplot/styles/ext/blue_light.yml +++ b/src/starplot/styles/ext/blue_light.yml @@ -58,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: diff --git a/src/starplot/styles/ext/blue_medium.yml b/src/starplot/styles/ext/blue_medium.yml index d4d7312b..141bf35c 100644 --- a/src/starplot/styles/ext/blue_medium.yml +++ b/src/starplot/styles/ext/blue_medium.yml @@ -84,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: From 329f0f7cfb0fd2934211ed45e0e093cb08c5f4ad Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Sat, 21 Feb 2026 07:19:50 -0800 Subject: [PATCH 14/32] lock --- hash_checks/hashlock.yml | 32 +++++++++++++-------------- src/starplot/styles/ext/blue_gold.yml | 12 +++++----- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/hash_checks/hashlock.yml b/hash_checks/hashlock.yml index 729b1575..5af55bfb 100644 --- a/hash_checks/hashlock.yml +++ b/hash_checks/hashlock.yml @@ -1,15 +1,15 @@ horizon_base: - dhash: 8e868a8e868a82b08686ce8e869a92b08ec6c68e869ad270 + dhash: 8e869a8e868a82b08686ca8e869a92b08ec6c68e869ad270 filename: /starplot/hash_checks/data/horizon-base.png phash: c1843fd17e913b86 horizon_gradient_background: - dhash: f1682072b2a6a294f1682672b2beaa94f168a2b2b29a9ad0 + dhash: f1682072b2a6a294f1682272b2beaa94f168a2b2b29a9ad0 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 @@ -51,19 +51,19 @@ map_moon_phase_waxing_crescent: filename: /starplot/hash_checks/data/map-moon-phase-waxing-crescent.png phash: b38ccc3333cccc33 map_orion_base: - dhash: 18393b3e2e2f656819393b5e2e2365685c19399e2e2b6568 + dhash: 18393b3e2d2f656819393b5e2e23656a5c19399e2e2b6568 filename: /starplot/hash_checks/data/map-orion-base.png - phash: bf7952649505b594 + phash: bff9526495853194 map_orion_extra: - dhash: 181b397b2d2f6d6d0b1b195b2f23656d5a1b1b9a2b2f6d6d + dhash: 181b397b2d2f6d6d5b1b195b29236d6d521b1b9a2b2b6d6d 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,17 +71,17 @@ 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: 639104e68d5b323a6a9104e68d5b323a2a9105e68d5b323b filename: /starplot/hash_checks/data/map-mercator-planets.png phash: e8429d89a43f5f52 map_with_planets_gradient: - dhash: 946eba117224c544946efa1952248544846eaa155224a222 + dhash: 956eba1972248544946efa1952248544846eaa155214b222 filename: /starplot/hash_checks/data/map-mercator-planets-gradient.png - phash: 9d2de276db50a02d + phash: bd25e276db50a02d map_wrapping: dhash: 0ee8c32d8b2947490ee8c32d8b2947490ee8c32d8b294749 filename: /starplot/hash_checks/data/map-wrapping.png @@ -147,7 +147,7 @@ optic_wrapping: filename: /starplot/hash_checks/data/optic-wrapping.png phash: d1066e1b792ef138 zenith_base: - dhash: 71f8cc8e8ad4e87171f8cc868ad4e87171f8cc868ad4e871 + dhash: 71f8cc8e8ad4e87171f8cc868ad4e87171f8cc868ed4e871 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/src/starplot/styles/ext/blue_gold.yml b/src/starplot/styles/ext/blue_gold.yml index cf480051..c46b5194 100644 --- a/src/starplot/styles/ext/blue_gold.yml +++ b/src/starplot/styles/ext/blue_gold.yml @@ -17,17 +17,17 @@ legend: celestial_equator: label: - font_color: '#2d5ec2' - border_color: rgb(46, 61, 74) + font_color: hsl(220, 76%, 54%) + border_color: hsl(208, 23%, 12%) line: - color: hsl(220, 62%, 47%) + color: hsl(220, 72%, 52%) alpha: 1 ecliptic: label: - font_color: hsl(4, 78%, 60%) - border_color: rgb(46, 61, 74) + font_color: hsl(4, 78%, 54%) + border_color: hsl(208, 23%, 12%) line: - color: hsl(4, 78%, 57%) + color: hsl(4, 78%, 52%) alpha: 1 horizon: From 4d1599ee017085a61da5e6dd4939ac21d904a9ef Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Sat, 21 Feb 2026 07:28:25 -0800 Subject: [PATCH 15/32] examples and docs --- docs/examples.md | 8 ++++- docs/examples/map-galaxy.md | 14 ++++++++ examples/map_galaxy.py | 65 ++++++++++++++++++++++++++++++++++++ src/starplot/plots/galaxy.py | 6 ++-- 4 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 docs/examples/map-galaxy.md create mode 100644 examples/map_galaxy.py diff --git a/docs/examples.md b/docs/examples.md index c618a488..8ebdc8b3 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -90,11 +90,17 @@

The Big Dipper

-
  • + +
  • + + Open Clusters around the Milky Way +

    Galaxy Plot

    +
  • 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} + +![map-galaxy](/images/examples/map_galaxy.png) + +```python +--8<-- "examples/map_galaxy.py" +``` + + diff --git a/examples/map_galaxy.py b/examples/map_galaxy.py new file mode 100644 index 00000000..5057e144 --- /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() +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/src/starplot/plots/galaxy.py b/src/starplot/plots/galaxy.py index 2f9e7eb0..448a44c3 100644 --- a/src/starplot/plots/galaxy.py +++ b/src/starplot/plots/galaxy.py @@ -49,9 +49,9 @@ class GalaxyPlot( Args: center_lon: Central galactic longitude of the Mollweide projection - observer: Observer instance which specifies a time and place. Defaults to `Observer()` + 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()` + 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 collision_handler: Default [CollisionHandler][starplot.CollisionHandler] for the plot that describes what to do on label collisions with other labels, markers, etc. 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. @@ -80,7 +80,7 @@ def __init__( *args, **kwargs, ) -> "GalaxyPlot": - observer = observer or Observer() + observer = observer or Observer.at_epoch(2000) style = style or PlotStyle().extend(extensions.MAP) super().__init__( From 0cfed451956897913555140c6553d748d3e8e1ca Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Sat, 21 Feb 2026 07:31:46 -0800 Subject: [PATCH 16/32] moon --- docs/changelog.md | 1 + src/starplot/plots/base.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index 3a03c512..4873c31d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,7 @@ - Adds a `GalaxyPlot` for plotting in galactic coordinates - Adds label support to lines with automatic angle adjustment +- Plots the Moon's precise phase / illumination when `show_phase=True` ## v0.19.x diff --git a/src/starplot/plots/base.py b/src/starplot/plots/base.py index 793c8b46..34337a13 100644 --- a/src/starplot/plots/base.py +++ b/src/starplot/plots/base.py @@ -709,7 +709,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. From 42a542e94a548f78b8269524ac9126caa15cb74b Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Sat, 21 Feb 2026 08:21:42 -0800 Subject: [PATCH 17/32] remove pytz --- examples/map_orthographic.py | 4 ++-- scripts/voronoi.py | 1 - src/starplot/plots/base.py | 4 +++- src/starplot/utils.py | 5 ++--- tests/test_map.py | 6 ++---- tests/test_models.py | 19 +++++++++---------- 6 files changed, 18 insertions(+), 21 deletions(-) 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/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/plots/base.py b/src/starplot/plots/base.py index 34337a13..70153924 100644 --- a/src/starplot/plots/base.py +++ b/src/starplot/plots/base.py @@ -793,7 +793,9 @@ def _moon_with_phase( """ illuminated_color = style.fill_color - ellipse_b_radius_degrees = np.abs(radius_degrees * (2 * self._objects.moon.illumination - 1)) + ellipse_b_radius_degrees = np.abs( + radius_degrees * (2 * self._objects.moon.illumination - 1) + ) left = style.copy() right = style.copy() 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_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 From 6ad803b9c43bdedf33ce4ae05edf3b165213b334 Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Sat, 21 Feb 2026 11:30:06 -0800 Subject: [PATCH 18/32] docs --- docs/coming-soon.md | 1 - docs/tutorial/04.md | 2 +- docs/tutorial/06.md | 2 +- src/starplot/styles/ext/blue_night.yml | 6 +++--- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/coming-soon.md b/docs/coming-soon.md index 0415cdd8..616d29f4 100644 --- a/docs/coming-soon.md +++ b/docs/coming-soon.md @@ -14,7 +14,6 @@
  • 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
    - ![Tutorial - Map Plot](/images/tutorial/tutorial_04.png){ width="900" } + ![Tutorial - Map Plot](/images/tutorial/tutorial_04.png)
    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
    - ![Tutorial - Selecting Objects](/images/tutorial/tutorial_06.png){ width="700" } + ![Tutorial - Selecting Objects](/images/tutorial/tutorial_06.png)
    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/src/starplot/styles/ext/blue_night.yml b/src/starplot/styles/ext/blue_night.yml index 1cc686f8..871a198b 100644 --- a/src/starplot/styles/ext/blue_night.yml +++ b/src/starplot/styles/ext/blue_night.yml @@ -129,9 +129,9 @@ dso_planetary_nebula: *DSO-NEB dso_open_cluster: &DSO-OC marker: - alpha: 1 - color: hsl(62, 48%, 36%) - edge_color: hsl(58, 100%, 60%) + alpha: 0.9 + color: hsl(62, 58%, 42%) + edge_color: hsl(58, 100%, 64%) edge_width: 1 label: font_color: hsl(58, 98%, 58%) From 15e19219546cc0c2ba264c854d1ad42a664d308b Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Sat, 21 Feb 2026 11:47:22 -0800 Subject: [PATCH 19/32] lock --- hash_checks/hashlock.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/hash_checks/hashlock.yml b/hash_checks/hashlock.yml index 5af55bfb..690e737a 100644 --- a/hash_checks/hashlock.yml +++ b/hash_checks/hashlock.yml @@ -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: From 0843666f8ae9aa1093762b529badef0084d83310 Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Sat, 21 Feb 2026 11:49:59 -0800 Subject: [PATCH 20/32] version --- src/starplot/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/starplot/__init__.py b/src/starplot/__init__.py index 0b25beb9..b49e5a13 100644 --- a/src/starplot/__init__.py +++ b/src/starplot/__init__.py @@ -2,7 +2,7 @@ """Star charts and maps of the sky""" -__version__ = "0.19.6" +__version__ = "0.20.0" from .plots import ( MapPlot, From 78e076ad98ee662780dd069c9627a027350e88d1 Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Sat, 21 Feb 2026 12:36:36 -0800 Subject: [PATCH 21/32] clean up --- hash_checks/hashlock.yml | 18 ++++++------ src/starplot/plots/base.py | 4 +-- src/starplot/plots/galaxy.py | 2 +- src/starplot/plotters/text.py | 54 ++++++++++++----------------------- 4 files changed, 30 insertions(+), 48 deletions(-) diff --git a/hash_checks/hashlock.yml b/hash_checks/hashlock.yml index 690e737a..ebf5ca4f 100644 --- a/hash_checks/hashlock.yml +++ b/hash_checks/hashlock.yml @@ -1,9 +1,9 @@ horizon_base: - dhash: 8e869a8e868a82b08686ca8e869a92b08ec6c68e869ad270 + dhash: 8e868a8e868a82b08686ce8e869a92b08ec6c68e869ad270 filename: /starplot/hash_checks/data/horizon-base.png phash: c1843fd17e913b86 horizon_gradient_background: - dhash: f1682072b2a6a294f1682272b2beaa94f168a2b2b29a9ad0 + dhash: f1682072b2a6a294f1682672b2beaa94f168a6b2b29a9ad0 filename: /starplot/hash_checks/data/horizon-gradient-background.png phash: 945e62c12bc56ef4 horizon_north_celestial_pole: @@ -51,11 +51,11 @@ map_moon_phase_waxing_crescent: filename: /starplot/hash_checks/data/map-moon-phase-waxing-crescent.png phash: b38ccc3333cccc33 map_orion_base: - dhash: 18393b3e2d2f656819393b5e2e23656a5c19399e2e2b6568 + dhash: 18393b3e2e2f656819393b5e2e23656a5c19399e2e2b6568 filename: /starplot/hash_checks/data/map-orion-base.png - phash: bff9526495853194 + phash: bff9526495843594 map_orion_extra: - dhash: 181b397b2d2f6d6d5b1b195b29236d6d521b1b9a2b2b6d6d + dhash: 181b397b2d2f6d6d4b1b195b2b236d6d521b1b9a2b2b6d6d filename: /starplot/hash_checks/data/map-orion-extra.png phash: bf716646859e9096 map_plot_custom_clip_path_virgo: @@ -75,13 +75,13 @@ map_stereo_base: filename: /starplot/hash_checks/data/map-stereo-north-base.png phash: 8a2f62d2949e4fb4 map_with_planets: - dhash: 639104e68d5b323a6a9104e68d5b323a2a9105e68d5b323b + dhash: 639104e68d5b323a6b9104e68d5b323a2a9104e68d5b323b filename: /starplot/hash_checks/data/map-mercator-planets.png phash: e8429d89a43f5f52 map_with_planets_gradient: - dhash: 956eba1972248544946efa1952248544846eaa155214b222 + dhash: 946eba117224c544946efa195224c544846ea23d522cb222 filename: /starplot/hash_checks/data/map-mercator-planets-gradient.png - phash: bd25e276db50a02d + phash: 9d2de276db50a02d map_wrapping: dhash: 0ee8c32d8b2947490ee8c32d8b2947490ee8c32d8b294749 filename: /starplot/hash_checks/data/map-wrapping.png @@ -147,7 +147,7 @@ optic_wrapping: filename: /starplot/hash_checks/data/optic-wrapping.png phash: d1066e1b792ef138 zenith_base: - dhash: 71f8cc8e8ad4e87171f8cc868ad4e87171f8cc868ed4e871 + dhash: 71f8cc8eaad4e87171f8cc868ad4e87171f8cc868ed4e871 filename: /starplot/hash_checks/data/zenith-base.png phash: 90d50b7d2955645f zenith_chinese: diff --git a/src/starplot/plots/base.py b/src/starplot/plots/base.py index 70153924..3db44170 100644 --- a/src/starplot/plots/base.py +++ b/src/starplot/plots/base.py @@ -906,7 +906,7 @@ def ecliptic( self, style: PathStyle = None, label: str = "ECLIPTIC", - num_labels: int = 2, + num_labels: int = 1, collision_handler: CollisionHandler = None, ): """Plots the ecliptic @@ -946,7 +946,7 @@ def celestial_equator( self, style: PathStyle = None, label: str = "CELESTIAL EQUATOR", - num_labels: int = 2, + num_labels: int = 1, collision_handler: CollisionHandler = None, ): """ diff --git a/src/starplot/plots/galaxy.py b/src/starplot/plots/galaxy.py index 448a44c3..6dd67e8d 100644 --- a/src/starplot/plots/galaxy.py +++ b/src/starplot/plots/galaxy.py @@ -187,7 +187,7 @@ def galactic_equator( self, style: PathStyle = None, label: str = "GALACTIC EQUATOR", - num_labels: int = 2, + num_labels: int = 1, collision_handler: CollisionHandler = None, ): """ diff --git a/src/starplot/plotters/text.py b/src/starplot/plotters/text.py index 097904e4..7c9dd9d2 100644 --- a/src/starplot/plotters/text.py +++ b/src/starplot/plotters/text.py @@ -143,18 +143,20 @@ def next_best_position( return possible[0][1] -def find_smooth_sections(x, y, min_length=5, curvature_threshold=0.1): +def find_smooth_sections(coordinates, min_length=2, curvature_threshold=0.1) -> list[tuple[int, int, float]]: """ - Find sections of the line where curvature is low (smooth). + Find smooth sections of a (i.e. where curvature is low). Args: - x, y: line coordinates + 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)] @@ -587,10 +589,9 @@ def _text_line( x, y, text: str, - num_labels: int = 2, + num_labels: int = 1, collision_handler: CollisionHandler = None, min_spacing=None, - prefer_center=True, curvature_threshold=0.8, **kwargs, ) -> None: @@ -625,31 +626,20 @@ def _text_line( # sort coords by display x value display_xy = display_xy[display_xy[:, 0].argsort()] - display_x, display_y = zip(*display_xy) - x, y = zip(*data_xy) + num_positions = len(display_xy) if min_spacing is None: min_spacing = 1.0 / (num_labels + 1) - min_distance = int(min_spacing * len(display_x)) - - # Find all smooth sections + min_distance = int(min_spacing * num_positions) smooth_sections = find_smooth_sections( - display_x, display_y, min_length=1, curvature_threshold=curvature_threshold + display_xy, min_length=2, curvature_threshold=curvature_threshold ) - # Select top N sections, ensuring they don't overlap smooth_positions = [] - used_sections = [] - - for section_start, section_end, score in smooth_sections: - # Calculate center of this section - if prefer_center: - section_center = (section_start + section_end) // 2 - else: - section_center = section_start + for section_start, section_end, _ in smooth_sections: + section_center = (section_start + section_end) // 2 - # Check if this section is too close to already selected positions too_close = False for pos in smooth_positions: if abs(section_center - pos) < min_distance: @@ -658,7 +648,6 @@ def _text_line( if not too_close: smooth_positions.append(section_center) - used_sections.append((section_start, section_end, score)) def plot_label(x0, y0, x1, y1, text): # calculate angle in display coordinates @@ -686,10 +675,9 @@ def plot_label(x0, y0, x1, y1, text): **kwargs, ) - offset = len(display_x) // 20 # offset from start/end of line - positions = [p for p in range(len(display_x)) if p not in smooth_positions][ - offset : -1 * offset - ] + 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() @@ -704,10 +692,7 @@ def plot_label(x0, y0, x1, y1, text): pos = smooth_positions.pop() else: pos = next_best_position( - plotted_positions, - positions, - num_labels, - len(display_x), + plotted_positions, positions, num_labels, num_positions ) if pos is None: @@ -715,12 +700,9 @@ def plot_label(x0, y0, x1, y1, text): if pos in positions: positions.remove(pos) - pos = max(0, min(pos, len(display_x) - 2)) - x0 = display_x[pos] - y0 = display_y[pos] - x1 = display_x[pos + 1] - y1 = display_y[pos + 1] - + 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) From 150b2c7da6f1ecdda6d27e34adaa7899edffffe8 Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Sat, 21 Feb 2026 12:41:14 -0800 Subject: [PATCH 22/32] format --- src/starplot/plotters/text.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/starplot/plotters/text.py b/src/starplot/plotters/text.py index 7c9dd9d2..57a46963 100644 --- a/src/starplot/plotters/text.py +++ b/src/starplot/plotters/text.py @@ -143,7 +143,9 @@ def next_best_position( return possible[0][1] -def find_smooth_sections(coordinates, min_length=2, curvature_threshold=0.1) -> list[tuple[int, int, float]]: +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). From 8b1dbd031dbdcb8035e52837fbe8da9f77cb8c28 Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Sat, 21 Feb 2026 12:47:28 -0800 Subject: [PATCH 23/32] docs --- README.md | 3 ++- docs/index.md | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3de76450..55d13589 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,9 @@ - πŸ—ΊοΈ **Maps** - including 10+ customizable projections - ⭐ **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 - 🎨 **Custom Styles** - for all objects and with 8+ built-in themes 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 From dcd195307ad1afd4730cad3370e3c8e606294443 Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Sat, 21 Feb 2026 12:54:17 -0800 Subject: [PATCH 24/32] geometry --- hash_checks/hashlock.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hash_checks/hashlock.yml b/hash_checks/hashlock.yml index 1f63f2bd..0f035a4b 100644 --- a/hash_checks/hashlock.yml +++ b/hash_checks/hashlock.yml @@ -47,7 +47,7 @@ map_mollweide: filename: /starplot/hash_checks/data/map-mollweide.png phash: ebcab698e3349461 map_moon_phase_waxing_crescent: - dhash: 000830484830080000082048482008008088804c4c808880 + dhash: 000830484830080000082048482008008088804848808880 filename: /starplot/hash_checks/data/map-moon-phase-waxing-crescent.png phash: b38ccc3333cccc33 map_orion_base: From fcd4a725a7624d7db1e30106893833aeb7084c7d Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Sat, 21 Feb 2026 12:55:53 -0800 Subject: [PATCH 25/32] format --- src/starplot/geometry.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/starplot/geometry.py b/src/starplot/geometry.py index 5c07953d..c8ca99f6 100644 --- a/src/starplot/geometry.py +++ b/src/starplot/geometry.py @@ -276,7 +276,6 @@ def line_segment(start, end, step) -> list[tuple[float, float]]: return LineString([start, end]).segmentize(step).coords - class BaseGeometry: """ @@ -285,12 +284,12 @@ class BaseGeometry: 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 - + TODO: Functions - intersects - + Properties - centroid - bbox @@ -298,9 +297,7 @@ class BaseGeometry: - wkb """ - def intersects(self): - """ - - """ + def intersects(self): + """ """ pass From d014ee8436d447d6c4f410cbd88efdc47a56fa3e Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Sun, 22 Feb 2026 12:20:15 -0800 Subject: [PATCH 26/32] label type handlers --- docs/reference-galaxyplot.md | 2 + examples/map_canis_major.py | 2 +- examples/map_galaxy.py | 2 +- examples/map_virgo_cluster.py | 2 +- hash_checks/hashlock.yml | 2 +- hash_checks/map_checks.py | 10 +-- src/starplot/plots/base.py | 97 +++++++++++-------------- src/starplot/plots/galaxy.py | 6 +- src/starplot/plotters/constellations.py | 4 +- src/starplot/plotters/dsos.py | 2 +- src/starplot/plotters/stars.py | 2 +- src/starplot/plotters/text.py | 10 +-- 12 files changed, 60 insertions(+), 81 deletions(-) diff --git a/docs/reference-galaxyplot.md b/docs/reference-galaxyplot.md index b670a3ca..947ef48b 100644 --- a/docs/reference-galaxyplot.md +++ b/docs/reference-galaxyplot.md @@ -1,5 +1,7 @@ **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!" 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 index 5057e144..b677f654 100644 --- a/examples/map_galaxy.py +++ b/examples/map_galaxy.py @@ -16,7 +16,7 @@ ) p.gridlines() -p.galactic_equator() +p.galactic_equator(num_labels=2) p.celestial_equator(num_labels=2) p.ecliptic(num_labels=2) 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 0f035a4b..5248796b 100644 --- a/hash_checks/hashlock.yml +++ b/hash_checks/hashlock.yml @@ -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: 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/src/starplot/plots/base.py b/src/starplot/plots/base.py index bccb4386..36f3ecb4 100644 --- a/src/starplot/plots/base.py +++ b/src/starplot/plots/base.py @@ -25,7 +25,6 @@ MarkerStyle, ObjectStyle, LabelStyle, - LineStyle, MarkerSymbolEnum, PathStyle, PolygonStyle, @@ -73,8 +72,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, @@ -82,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, scale: float = 1.0, autoscale: bool = False, suppress_warnings: bool = True, @@ -105,7 +112,14 @@ 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() + 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 @@ -313,7 +327,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", ) @@ -347,7 +361,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) @@ -418,7 +432,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 @@ -661,39 +675,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, @@ -726,7 +707,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 @@ -918,7 +899,7 @@ def ecliptic( 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 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 collision handler of the plot will be used. + 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 = [] @@ -935,11 +916,11 @@ def ecliptic( coords = [(ra * 15, dec) for ra, dec in ecliptic.RA_DECS] - self.line_label( + self.line( style=style, label=label.upper(), num_labels=num_labels, - collision_handler=collision_handler or self.collision_handler, + collision_handler=collision_handler, coordinates=coords, ) @@ -959,38 +940,40 @@ def celestial_equator( 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 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 collision handler of the plot will be used. + 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. """ label = translate(label, self.language) coords = [(ra, 0) for ra in range(0, 361)] - self.line_label( + self.line( style=style, label=label.upper(), num_labels=num_labels, - collision_handler=collision_handler or self.collision_handler, + collision_handler=collision_handler, coordinates=coords, gid="celestial-equator", ) @use_style(PathStyle) - def line_label( + def line( self, - style: PathStyle, - label: str = None, - collision_handler: CollisionHandler = None, - num_labels: int = 2, 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. 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 label: Label for the line num_labels: Number of labels to plot along the line - 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. + 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. """ @@ -1026,12 +1009,14 @@ def line_label( 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 or self.collision_handler, + collision_handler=collision_handler, min_spacing=0.65, **style.label.matplot_kwargs(self.scale), **self._plot_kwargs(), diff --git a/src/starplot/plots/galaxy.py b/src/starplot/plots/galaxy.py index 6dd67e8d..16f44aa3 100644 --- a/src/starplot/plots/galaxy.py +++ b/src/starplot/plots/galaxy.py @@ -197,7 +197,7 @@ def galactic_equator( 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 collision handler of the plot will be used. + 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 @@ -210,10 +210,10 @@ def galactic_equator( radec = list(zip(ra_values, dec_values)) - self.line_label( + self.line( label=label, num_labels=num_labels, - collision_handler=collision_handler or self.collision_handler, + collision_handler=collision_handler, style=style, coordinates=radec, ) 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 524a9eaa..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 = {} 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 57a46963..8617bbc0 100644 --- a/src/starplot/plotters/text.py +++ b/src/starplot/plotters/text.py @@ -213,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)) @@ -613,11 +612,6 @@ def _text_line( curvature_threshold: threshold for determining smooth sections """ - collision_handler = CollisionHandler( - allow_constellation_line_collisions=True, - # allow_marker_collisions=True, - ) - 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 @@ -753,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 From 36ab8f4fd00685fdca9309a3d8456de35d29c35d Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Sun, 22 Feb 2026 12:54:33 -0800 Subject: [PATCH 27/32] docs --- docs/examples/map-virgo-cluster.md | 2 +- docs/reference-collisions.md | 45 +++++++++++++++++++++++++++++- src/starplot/plots/base.py | 15 +++++++++- 3 files changed, 59 insertions(+), 3 deletions(-) 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 ![map-virgo-galaxy-cluster](/images/examples/map_virgo_cluster.png) -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/reference-collisions.md b/docs/reference-collisions.md index e2f1924f..dd3838af 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 - stars, DSOs, etc +- Areas - constellations +- Paths - ecliptic, celestial equator, etc + +You can also override these defaults on all functions that plot text (either directly or as a side effect). There are intentionally three distinct types of collision handlers because it's very common to apply different rules for different types of labels. For example, the default area collision handler allows collisions with constellation lines. _See the [Virgo Galaxy Cluster](/examples/map-virgo-cluster/) plot for an example of using a custom collision handler._ @@ -10,6 +16,43 @@ _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 + +### 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/src/starplot/plots/base.py b/src/starplot/plots/base.py index 36f3ecb4..bdce778b 100644 --- a/src/starplot/plots/base.py +++ b/src/starplot/plots/base.py @@ -30,6 +30,7 @@ PolygonStyle, GradientDirection, fonts, + AnchorPointEnum, ) from starplot.plotters.debug import DebugPlotterMixin from starplot.plotters.text import TextPlotterMixin, CollisionHandler @@ -113,7 +114,19 @@ def __init__( self.figure_size = resolution * px self.resolution = resolution - self.point_label_handler = point_label_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 ) From 02118fad278dbcb737e11e5554ae2e796a2866f8 Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Mon, 23 Feb 2026 07:38:12 -0800 Subject: [PATCH 28/32] kwargs --- docs/reference-collisions.md | 10 ++++++---- src/starplot/plots/galaxy.py | 12 +++++++++--- src/starplot/plots/horizon.py | 12 +++++++++--- src/starplot/plots/map.py | 12 +++++++++--- src/starplot/plots/optic.py | 12 +++++++++--- src/starplot/plots/zenith.py | 12 +++++++++--- 6 files changed, 51 insertions(+), 19 deletions(-) diff --git a/docs/reference-collisions.md b/docs/reference-collisions.md index dd3838af..f6ec6ba3 100644 --- a/docs/reference-collisions.md +++ b/docs/reference-collisions.md @@ -2,11 +2,11 @@ One of the biggest contributors to the visual quality of a map is labeling, whic When you create a plot, you can specify the default collision handler for three different types of labels: -- Points - stars, DSOs, etc -- Areas - constellations -- Paths - ecliptic, celestial equator, etc +- **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 (either directly or as a side effect). There are intentionally three distinct types of collision handlers because it's very common to apply different rules for different types of labels. For example, the default area collision handler allows collisions with constellation lines. +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._ @@ -18,6 +18,8 @@ _See the [Virgo Galaxy Cluster](/examples/map-virgo-cluster/) plot for an exampl ## Defaults +Below are the defaults for each type of collision handler. These are the defaults for _all_ plot types. + ### Points ```python diff --git a/src/starplot/plots/galaxy.py b/src/starplot/plots/galaxy.py index 16f44aa3..95779e50 100644 --- a/src/starplot/plots/galaxy.py +++ b/src/starplot/plots/galaxy.py @@ -53,7 +53,9 @@ class GalaxyPlot( 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 - 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 @@ -73,7 +75,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, @@ -88,7 +92,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/horizon.py b/src/starplot/plots/horizon.py index 184ada21..9a654202 100644 --- a/src/starplot/plots/horizon.py +++ b/src/starplot/plots/horizon.py @@ -63,7 +63,9 @@ class HorizonPlot( 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 @@ -86,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, @@ -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, diff --git a/src/starplot/plots/map.py b/src/starplot/plots/map.py index b7212735..8a2406a8 100644 --- a/src/starplot/plots/map.py +++ b/src/starplot/plots/map.py @@ -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, diff --git a/src/starplot/plots/optic.py b/src/starplot/plots/optic.py index e3163c95..0fe29afd 100644 --- a/src/starplot/plots/optic.py +++ b/src/starplot/plots/optic.py @@ -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, 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, From 2534c26f4b16f9958dccd3543d953ead486c6695 Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Wed, 25 Feb 2026 05:52:11 -0800 Subject: [PATCH 29/32] clean up --- docs/reference-positions.md | 1 + examples/map_milky_way_stars.py | 2 +- src/starplot/data/constellations.py | 15 --------------- src/starplot/data/db.py | 4 +++- 4 files changed, 5 insertions(+), 17 deletions(-) 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/examples/map_milky_way_stars.py b/examples/map_milky_way_stars.py index 2a2b6c7f..71b1b1f0 100644 --- a/examples/map_milky_way_stars.py +++ b/examples/map_milky_way_stars.py @@ -28,7 +28,7 @@ ) p.export("map_milky_way_stars.png", padding=0.1, transparent=True) -# apply a median filter and increase contrast +# 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/src/starplot/data/constellations.py b/src/starplot/data/constellations.py index fcb8d8ec..f6d1486b 100644 --- a/src/starplot/data/constellations.py +++ b/src/starplot/data/constellations.py @@ -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..9fde9a25 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 @@ -10,7 +12,7 @@ "dso_names": DataFiles.DSO_NAMES, } - +@cache def connect(): path = settings.data_path / "duckdb-extensions" connection = duckdb.connect() From a8d1f6e11ce7bb9a0ab5cb01e6ce1e13fa7a051a Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Wed, 25 Feb 2026 06:01:47 -0800 Subject: [PATCH 30/32] format --- src/starplot/data/constellations.py | 2 +- src/starplot/data/db.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/starplot/data/constellations.py b/src/starplot/data/constellations.py index f6d1486b..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 diff --git a/src/starplot/data/db.py b/src/starplot/data/db.py index 9fde9a25..cf8bff49 100644 --- a/src/starplot/data/db.py +++ b/src/starplot/data/db.py @@ -12,6 +12,7 @@ "dso_names": DataFiles.DSO_NAMES, } + @cache def connect(): path = settings.data_path / "duckdb-extensions" From 36e57fce3c6852cb9c681f4382fd0c0948f71674 Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Thu, 26 Feb 2026 07:11:11 -0800 Subject: [PATCH 31/32] handle settings change --- src/starplot/data/db.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/starplot/data/db.py b/src/starplot/data/db.py index cf8bff49..80674cf9 100644 --- a/src/starplot/data/db.py +++ b/src/starplot/data/db.py @@ -14,10 +14,9 @@ @cache -def connect(): - path = settings.data_path / "duckdb-extensions" +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") @@ -27,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) From 9d81e53b49da66b30fba500b801cbb32b8a2b866 Mon Sep 17 00:00:00 2001 From: Steve Berardi Date: Fri, 27 Feb 2026 06:57:38 -0800 Subject: [PATCH 32/32] docs --- CITATION.cff | 4 ++-- docs/changelog.md | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 0086abd8..6527803c 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -20,5 +20,5 @@ keywords: - graphs - plotting license: MIT -version: 0.19.6 -date-released: '2026-02-15' +version: 0.20.0 +date-released: '2026-02-28' diff --git a/docs/changelog.md b/docs/changelog.md index 4873c31d..a6f10f52 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,7 @@ - Adds a `GalaxyPlot` for plotting in galactic coordinates - Adds label support to lines with automatic angle adjustment - Plots the Moon's precise phase / illumination when `show_phase=True` +- Separate collision handlers for points, areas, and paths ## v0.19.x
    v0.20v0.21

    ⭐ Next Release ⭐

      -
    • Support for more coordinate systems
    • +
    • Plot definition files
    • +
    • CLI for creating plots
    v0.21v0.22
      +
    • Improved line labeling
    • TBD
    v0.22+v0.23+
    • Planet moons
    • Area-based labeling
    • -
    • Improved line labeling
    • Optimized vector graphics backend
    v0.22
      -
    • Improved line labeling
    • TBD