starplot 0.20.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- starplot/__init__.py +59 -0
- starplot/callables.py +176 -0
- starplot/cli.py +29 -0
- starplot/config.py +80 -0
- starplot/coordinates.py +7 -0
- starplot/data/__init__.py +22 -0
- starplot/data/catalogs.py +356 -0
- starplot/data/constellations.py +71 -0
- starplot/data/db.py +33 -0
- starplot/data/dsos.py +85 -0
- starplot/data/ecliptic.py +201 -0
- starplot/data/library/constellation_names.parquet +0 -0
- starplot/data/library/dso_names.parquet +0 -0
- starplot/data/library/readme.md +1 -0
- starplot/data/library/star_designations.parquet +0 -0
- starplot/data/stars.py +79 -0
- starplot/data/translations.py +408 -0
- starplot/data/utils.py +35 -0
- starplot/geometry.py +319 -0
- starplot/mixins.py +360 -0
- starplot/models/__init__.py +21 -0
- starplot/models/base.py +77 -0
- starplot/models/comet.py +302 -0
- starplot/models/constellation.py +151 -0
- starplot/models/dso.py +317 -0
- starplot/models/milky_way.py +30 -0
- starplot/models/moon.py +130 -0
- starplot/models/objects.py +29 -0
- starplot/models/observer.py +125 -0
- starplot/models/optics.py +342 -0
- starplot/models/planet.py +137 -0
- starplot/models/satellite.py +138 -0
- starplot/models/star.py +253 -0
- starplot/models/sun.py +62 -0
- starplot/plots/__init__.py +7 -0
- starplot/plots/base.py +1023 -0
- starplot/plots/galaxy.py +372 -0
- starplot/plots/horizon.py +543 -0
- starplot/plots/map.py +515 -0
- starplot/plots/optic.py +469 -0
- starplot/plots/zenith.py +217 -0
- starplot/plotters/__init__.py +9 -0
- starplot/plotters/arrow.py +174 -0
- starplot/plotters/constellations.py +298 -0
- starplot/plotters/debug.py +21 -0
- starplot/plotters/dsos.py +294 -0
- starplot/plotters/experimental.py +722 -0
- starplot/plotters/gradients.py +153 -0
- starplot/plotters/legend.py +253 -0
- starplot/plotters/milkyway.py +51 -0
- starplot/plotters/stars.py +319 -0
- starplot/plotters/text.py +802 -0
- starplot/profile.py +16 -0
- starplot/projections.py +184 -0
- starplot/styles/__init__.py +6 -0
- starplot/styles/base.py +1344 -0
- starplot/styles/ext/antique.yml +175 -0
- starplot/styles/ext/blue_dark.yml +163 -0
- starplot/styles/ext/blue_gold.yml +147 -0
- starplot/styles/ext/blue_light.yml +123 -0
- starplot/styles/ext/blue_medium.yml +142 -0
- starplot/styles/ext/blue_night.yml +185 -0
- starplot/styles/ext/cb_wong.yml +124 -0
- starplot/styles/ext/color_print.yml +111 -0
- starplot/styles/ext/gradient_presets.yml +158 -0
- starplot/styles/ext/grayscale.yml +94 -0
- starplot/styles/ext/grayscale_dark.yml +136 -0
- starplot/styles/ext/map.yml +12 -0
- starplot/styles/ext/nord.yml +158 -0
- starplot/styles/ext/optic.yml +20 -0
- starplot/styles/ext/publication.yml +8 -0
- starplot/styles/extensions.py +129 -0
- starplot/styles/fonts-library/gfs-didot/DESCRIPTION.en_us.html +9 -0
- starplot/styles/fonts-library/gfs-didot/GFSDidot-Regular.ttf +0 -0
- starplot/styles/fonts-library/gfs-didot/METADATA.pb +16 -0
- starplot/styles/fonts-library/gfs-didot/OFL.txt +94 -0
- starplot/styles/fonts-library/inter/Inter-Bold.ttf +0 -0
- starplot/styles/fonts-library/inter/Inter-BoldItalic.ttf +0 -0
- starplot/styles/fonts-library/inter/Inter-ExtraBold.ttf +0 -0
- starplot/styles/fonts-library/inter/Inter-ExtraLight.ttf +0 -0
- starplot/styles/fonts-library/inter/Inter-ExtraLightItalic.ttf +0 -0
- starplot/styles/fonts-library/inter/Inter-Italic.ttf +0 -0
- starplot/styles/fonts-library/inter/Inter-Light.ttf +0 -0
- starplot/styles/fonts-library/inter/Inter-LightItalic.ttf +0 -0
- starplot/styles/fonts-library/inter/Inter-Regular.ttf +0 -0
- starplot/styles/fonts-library/inter/Inter-SemiBold.ttf +0 -0
- starplot/styles/fonts-library/inter/Inter-SemiBoldItalic.ttf +0 -0
- starplot/styles/fonts-library/inter/LICENSE.txt +92 -0
- starplot/styles/fonts.py +15 -0
- starplot/styles/helpers.py +93 -0
- starplot/styles/markers.py +308 -0
- starplot/utils.py +169 -0
- starplot/warnings.py +21 -0
- starplot-0.20.5.dist-info/METADATA +146 -0
- starplot-0.20.5.dist-info/RECORD +98 -0
- starplot-0.20.5.dist-info/WHEEL +4 -0
- starplot-0.20.5.dist-info/entry_points.txt +3 -0
- starplot-0.20.5.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import rtree
|
|
6
|
+
from shapely import Point, box
|
|
7
|
+
from shapely.errors import GEOSException
|
|
8
|
+
from matplotlib.text import Annotation
|
|
9
|
+
|
|
10
|
+
from starplot.config import settings as StarplotSettings, SvgTextType
|
|
11
|
+
from starplot.styles import AnchorPointEnum, LabelStyle
|
|
12
|
+
from starplot.styles.helpers import use_style
|
|
13
|
+
from starplot.geometry import (
|
|
14
|
+
random_point_in_polygon_at_distance,
|
|
15
|
+
union_at_zero,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
"""
|
|
19
|
+
Long term strategy:
|
|
20
|
+
|
|
21
|
+
- plot all markers FIRST (but keep track of labels)
|
|
22
|
+
- on export, find best positions for labels
|
|
23
|
+
- introduce some "priority" for labels (e.g. order by)
|
|
24
|
+
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
BBox = tuple[int, int, int, int]
|
|
28
|
+
"""Tuple of integers representing bounding box (xmin, ymin, xmax, ymax) -- in display coordinates."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def round_away_from_zero(x):
|
|
32
|
+
"""
|
|
33
|
+
Returns ceiling if number is greater than 0, else returns floor
|
|
34
|
+
|
|
35
|
+
round_away_from_zero(5.1) -> 6
|
|
36
|
+
round_away_from_zero(-5.1) -> -6
|
|
37
|
+
|
|
38
|
+
"""
|
|
39
|
+
return math.ceil(x) if x > 0 else math.floor(x)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class CollisionHandler:
|
|
44
|
+
"""
|
|
45
|
+
Dataclass that describes how to handle label collisions with other objects, like text, markers, constellation lines, etc.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
allow_clipped: bool = False
|
|
49
|
+
"""If True, then labels will be plotted if they're clipped (i.e. part of the label is outside the plot area)"""
|
|
50
|
+
|
|
51
|
+
allow_label_collisions: bool = False
|
|
52
|
+
"""If True, then labels will be plotted if they collide with another label"""
|
|
53
|
+
|
|
54
|
+
allow_marker_collisions: bool = False
|
|
55
|
+
"""If True, then labels will be plotted if they collide with another marker"""
|
|
56
|
+
|
|
57
|
+
allow_constellation_line_collisions: bool = False
|
|
58
|
+
"""If True, then labels will be plotted if they collide with a constellation line"""
|
|
59
|
+
|
|
60
|
+
plot_on_fail: bool = False
|
|
61
|
+
"""If True, then labels will be plotted even if no allowed position is found. They will be plotted at their last attempted position."""
|
|
62
|
+
|
|
63
|
+
attempts: int = 500
|
|
64
|
+
"""Max attempts to find a good label position"""
|
|
65
|
+
|
|
66
|
+
seed: int = None
|
|
67
|
+
"""Random seed for randomly generating points"""
|
|
68
|
+
|
|
69
|
+
anchor_fallbacks: list[AnchorPointEnum] = None
|
|
70
|
+
"""
|
|
71
|
+
If a point-based label's preferred anchor point results in a collision, then these fallbacks will be tried in
|
|
72
|
+
sequence until a collision-free position is found.
|
|
73
|
+
|
|
74
|
+
Default:
|
|
75
|
+
```python
|
|
76
|
+
[
|
|
77
|
+
AnchorPointEnum.BOTTOM_RIGHT,
|
|
78
|
+
AnchorPointEnum.TOP_LEFT,
|
|
79
|
+
AnchorPointEnum.TOP_RIGHT,
|
|
80
|
+
AnchorPointEnum.BOTTOM_LEFT,
|
|
81
|
+
AnchorPointEnum.BOTTOM_CENTER,
|
|
82
|
+
AnchorPointEnum.TOP_CENTER,
|
|
83
|
+
AnchorPointEnum.RIGHT_CENTER,
|
|
84
|
+
AnchorPointEnum.LEFT_CENTER,
|
|
85
|
+
]
|
|
86
|
+
```
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __post_init__(self):
|
|
90
|
+
self.anchor_fallbacks = self.anchor_fallbacks or [
|
|
91
|
+
AnchorPointEnum.BOTTOM_RIGHT,
|
|
92
|
+
AnchorPointEnum.TOP_LEFT,
|
|
93
|
+
AnchorPointEnum.TOP_RIGHT,
|
|
94
|
+
AnchorPointEnum.BOTTOM_LEFT,
|
|
95
|
+
AnchorPointEnum.BOTTOM_CENTER,
|
|
96
|
+
AnchorPointEnum.TOP_CENTER,
|
|
97
|
+
AnchorPointEnum.RIGHT_CENTER,
|
|
98
|
+
AnchorPointEnum.LEFT_CENTER,
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def next_best_position(
|
|
103
|
+
plotted_positions: list[int],
|
|
104
|
+
available_positions: list[int],
|
|
105
|
+
num_labels: int,
|
|
106
|
+
num_positions: int,
|
|
107
|
+
) -> int:
|
|
108
|
+
"""
|
|
109
|
+
Returns the next best (evenly spaced) position based on distance from plotted positions
|
|
110
|
+
|
|
111
|
+
Assumes original positions are evenly spaced on line
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
plotted_positions: List of indices of plotted label positions on the line
|
|
115
|
+
available_positions: List of available positions to plot labels
|
|
116
|
+
num_labels: Number of labels to be plotted on the line
|
|
117
|
+
num_positions: Original number of positions that were available
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Next best (evenly spaced) position (the index from original list of coordinates)
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
if len(plotted_positions) == 0:
|
|
124
|
+
return available_positions[len(available_positions) // (num_labels + 1)]
|
|
125
|
+
|
|
126
|
+
positions = [0] + sorted(plotted_positions) + [num_positions - 1]
|
|
127
|
+
diffs = [positions[i] - positions[i - 1] for i in range(1, len(positions))]
|
|
128
|
+
avg = sum(diffs) / len(diffs)
|
|
129
|
+
|
|
130
|
+
# filter out available positions that are too close to plotted positions
|
|
131
|
+
# (i.e. closer than average distance)
|
|
132
|
+
possible = []
|
|
133
|
+
for p in available_positions:
|
|
134
|
+
min_distance = min([abs(plotted - p) for plotted in plotted_positions])
|
|
135
|
+
if min_distance >= avg * 0.9:
|
|
136
|
+
possible.append((min_distance, p))
|
|
137
|
+
|
|
138
|
+
if not possible:
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
possible.sort()
|
|
142
|
+
|
|
143
|
+
return possible[0][1]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def find_smooth_sections(
|
|
147
|
+
coordinates, min_length=2, curvature_threshold=0.1
|
|
148
|
+
) -> list[tuple[int, int, float]]:
|
|
149
|
+
"""
|
|
150
|
+
Find smooth sections of a (i.e. where curvature is low).
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
coordinates: line coordinates
|
|
154
|
+
min_length: minimum number of points for a smooth section
|
|
155
|
+
curvature_threshold: maximum curvature to consider "smooth"
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
List of (start_idx, end_idx, smoothness_score) tuples
|
|
159
|
+
"""
|
|
160
|
+
x, y = zip(*coordinates)
|
|
161
|
+
|
|
162
|
+
if len(x) < 3:
|
|
163
|
+
return [(0, len(x) - 1, 1.0)]
|
|
164
|
+
|
|
165
|
+
# First derivative
|
|
166
|
+
dx = np.diff(x)
|
|
167
|
+
dy = np.diff(y)
|
|
168
|
+
|
|
169
|
+
# Second derivative (change in slope)
|
|
170
|
+
ddx = np.diff(dx)
|
|
171
|
+
ddy = np.diff(dy)
|
|
172
|
+
|
|
173
|
+
# Curvature approximation
|
|
174
|
+
curvature = np.abs(ddx) + np.abs(ddy)
|
|
175
|
+
|
|
176
|
+
# Normalize by typical scale
|
|
177
|
+
curvature = curvature / (np.median(curvature) + 1e-10)
|
|
178
|
+
|
|
179
|
+
# Find smooth regions
|
|
180
|
+
is_smooth = curvature < curvature_threshold
|
|
181
|
+
|
|
182
|
+
# Find contiguous smooth sections with scores
|
|
183
|
+
smooth_sections = []
|
|
184
|
+
start = None
|
|
185
|
+
|
|
186
|
+
for i in range(len(is_smooth)):
|
|
187
|
+
if is_smooth[i] and start is None:
|
|
188
|
+
start = i
|
|
189
|
+
elif not is_smooth[i] and start is not None:
|
|
190
|
+
if i - start >= min_length:
|
|
191
|
+
# Calculate smoothness score (inverse of average curvature)
|
|
192
|
+
section_curvature = curvature[start:i]
|
|
193
|
+
smoothness_score = 1.0 / (np.mean(section_curvature) + 1e-10)
|
|
194
|
+
smooth_sections.append((start, i, smoothness_score))
|
|
195
|
+
start = None
|
|
196
|
+
|
|
197
|
+
# Check last section
|
|
198
|
+
if start is not None and len(is_smooth) - start >= min_length:
|
|
199
|
+
section_curvature = curvature[start:]
|
|
200
|
+
smoothness_score = 1.0 / (np.mean(section_curvature) + 1e-10)
|
|
201
|
+
smooth_sections.append((start, len(is_smooth), smoothness_score))
|
|
202
|
+
|
|
203
|
+
# Sort by smoothness score (descending)
|
|
204
|
+
smooth_sections.sort(key=lambda s: s[2], reverse=True)
|
|
205
|
+
|
|
206
|
+
return smooth_sections if smooth_sections else [(0, len(x) - 1, 1.0)]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class TextPlotterMixin:
|
|
210
|
+
def __init__(self, *args, **kwargs):
|
|
211
|
+
self.labels = []
|
|
212
|
+
self._labels_rtree = rtree.index.Index()
|
|
213
|
+
self._constellations_rtree = rtree.index.Index()
|
|
214
|
+
self._stars_rtree = rtree.index.Index()
|
|
215
|
+
self._markers_rtree = rtree.index.Index()
|
|
216
|
+
|
|
217
|
+
def _is_label_collision(self, bbox: BBox) -> bool:
|
|
218
|
+
ix = list(self._labels_rtree.intersection(bbox))
|
|
219
|
+
return len(ix) > 0
|
|
220
|
+
|
|
221
|
+
def _is_constellation_collision(self, bbox: BBox) -> bool:
|
|
222
|
+
ix = list(self._constellations_rtree.intersection(bbox))
|
|
223
|
+
return len(ix) > 0
|
|
224
|
+
|
|
225
|
+
def _is_star_collision(self, bbox: BBox) -> bool:
|
|
226
|
+
ix = list(self._stars_rtree.intersection(bbox))
|
|
227
|
+
return len(ix) > 0
|
|
228
|
+
|
|
229
|
+
def _is_marker_collision(self, bbox: BBox) -> bool:
|
|
230
|
+
ix = list(self._markers_rtree.intersection(bbox))
|
|
231
|
+
return len(ix) > 0
|
|
232
|
+
|
|
233
|
+
def _is_clipped(self, points) -> bool:
|
|
234
|
+
p = self._clip_path_polygon
|
|
235
|
+
|
|
236
|
+
for x, y in points:
|
|
237
|
+
if not p.contains(Point(x, y)):
|
|
238
|
+
return True
|
|
239
|
+
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
def _is_clipped_box(self, bbox: BBox) -> bool:
|
|
243
|
+
return not self._clip_path_polygon.contains(box(*bbox))
|
|
244
|
+
|
|
245
|
+
def _get_label_bbox(self, label: Annotation) -> BBox:
|
|
246
|
+
# self.fig.draw_without_rendering() # maybe dont need this line after all?
|
|
247
|
+
extent = label.get_window_extent(renderer=self.fig.canvas.get_renderer())
|
|
248
|
+
result = (
|
|
249
|
+
extent.xmin,
|
|
250
|
+
extent.ymin,
|
|
251
|
+
extent.xmax,
|
|
252
|
+
extent.ymax,
|
|
253
|
+
)
|
|
254
|
+
if any([np.isnan(p) for p in result]):
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
return tuple(int(p) for p in result)
|
|
258
|
+
|
|
259
|
+
def _add_label_to_rtree(self, label: Annotation, bbox: BBox = None) -> None:
|
|
260
|
+
"""
|
|
261
|
+
Adds a label to the R-Tree, which is a spatial index for all plotted labels and used for collision detection.
|
|
262
|
+
|
|
263
|
+
If text debugging is enabled, then a white bounding box will be plotted around the label.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
label: Annotation instance returned from matplotlib's annotate() function
|
|
267
|
+
bbox: Tuple of integers representing bounding box (xmin, ymin, xmax, ymax) -- in display coordinates. If None, then bounding box will be obtained from label instance.
|
|
268
|
+
"""
|
|
269
|
+
bbox = bbox or self._get_label_bbox(label)
|
|
270
|
+
|
|
271
|
+
if self.debug_text:
|
|
272
|
+
self._debug_bbox(bbox, color="white", width=1.5)
|
|
273
|
+
|
|
274
|
+
self.labels.append(label)
|
|
275
|
+
self._labels_rtree.insert(0, bbox)
|
|
276
|
+
|
|
277
|
+
def _is_open_space(
|
|
278
|
+
self,
|
|
279
|
+
bbox: BBox,
|
|
280
|
+
padding=0,
|
|
281
|
+
allow_clipped=False,
|
|
282
|
+
allow_label_collisions=False,
|
|
283
|
+
allow_marker_collisions=False,
|
|
284
|
+
allow_constellation_collisions=False,
|
|
285
|
+
) -> bool:
|
|
286
|
+
"""
|
|
287
|
+
Returns true if the bounding box is in an open space, according to the allow_* kwargs.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
bbox: Tuple of integers representing bounding box (xmin, ymin, xmax, ymax) -- in display coordinates.
|
|
291
|
+
"""
|
|
292
|
+
x0, y0, x1, y1 = bbox
|
|
293
|
+
bbox_padded = (
|
|
294
|
+
x0 - padding,
|
|
295
|
+
y0 - padding,
|
|
296
|
+
x1 + padding,
|
|
297
|
+
y1 + padding,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
if any([np.isnan(c) for c in (x0, y0, x1, y1)]):
|
|
301
|
+
return False
|
|
302
|
+
|
|
303
|
+
if not allow_clipped and self._is_clipped_box(bbox_padded):
|
|
304
|
+
return False
|
|
305
|
+
|
|
306
|
+
if not allow_label_collisions and self._is_label_collision(bbox_padded):
|
|
307
|
+
return False
|
|
308
|
+
|
|
309
|
+
if not allow_marker_collisions and (
|
|
310
|
+
self._is_star_collision(bbox_padded)
|
|
311
|
+
or self._is_marker_collision(bbox_padded)
|
|
312
|
+
):
|
|
313
|
+
return False
|
|
314
|
+
|
|
315
|
+
if not allow_constellation_collisions and self._is_constellation_collision(
|
|
316
|
+
bbox_padded
|
|
317
|
+
):
|
|
318
|
+
return False
|
|
319
|
+
|
|
320
|
+
return True
|
|
321
|
+
|
|
322
|
+
def _text(self, x, y, text, **kwargs) -> Annotation:
|
|
323
|
+
"""Plots text at (x, y)"""
|
|
324
|
+
label = self.ax.annotate(
|
|
325
|
+
text,
|
|
326
|
+
(x, y),
|
|
327
|
+
**kwargs,
|
|
328
|
+
**self._plot_kwargs(),
|
|
329
|
+
)
|
|
330
|
+
if kwargs.get("clip_on"):
|
|
331
|
+
label.set_clip_on(True)
|
|
332
|
+
label.set_clip_path(self._background_clip_path)
|
|
333
|
+
return label
|
|
334
|
+
|
|
335
|
+
def _text_point(
|
|
336
|
+
self,
|
|
337
|
+
ra: float,
|
|
338
|
+
dec: float,
|
|
339
|
+
text: str,
|
|
340
|
+
collision_handler: CollisionHandler,
|
|
341
|
+
**kwargs,
|
|
342
|
+
) -> Annotation | None:
|
|
343
|
+
if not text:
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
x, y = self._prepare_coords(ra, dec)
|
|
347
|
+
|
|
348
|
+
if StarplotSettings.svg_text_type == SvgTextType.PATH:
|
|
349
|
+
kwargs["path_effects"] = kwargs.get("path_effects", [self.text_border])
|
|
350
|
+
|
|
351
|
+
original_va = kwargs.pop("va", None)
|
|
352
|
+
original_ha = kwargs.pop("ha", None)
|
|
353
|
+
original_offset_x, original_offset_y = kwargs.pop("xytext", (0, 0))
|
|
354
|
+
|
|
355
|
+
attempts = 0
|
|
356
|
+
height = 0
|
|
357
|
+
width = 0
|
|
358
|
+
|
|
359
|
+
data_xy = self._proj.transform_point(x, y, self._crs)
|
|
360
|
+
display_x, display_y = self.ax.transData.transform(data_xy)
|
|
361
|
+
|
|
362
|
+
anchors = [(original_va, original_ha)]
|
|
363
|
+
for a in collision_handler.anchor_fallbacks:
|
|
364
|
+
d = AnchorPointEnum.from_str(a).as_matplot()
|
|
365
|
+
anchors.append((d["va"], d["ha"]))
|
|
366
|
+
|
|
367
|
+
for va, ha in anchors:
|
|
368
|
+
attempts += 1
|
|
369
|
+
offset_x, offset_y = original_offset_x, original_offset_y
|
|
370
|
+
if original_ha != ha and ha != "center":
|
|
371
|
+
offset_x *= -1
|
|
372
|
+
|
|
373
|
+
if original_va != va:
|
|
374
|
+
offset_y *= -1
|
|
375
|
+
|
|
376
|
+
if ha == "center":
|
|
377
|
+
offset_x = 0
|
|
378
|
+
# offset_y *= 2
|
|
379
|
+
|
|
380
|
+
# if va == "center":
|
|
381
|
+
# offset_x *= 2
|
|
382
|
+
|
|
383
|
+
offset_x = round_away_from_zero(offset_x)
|
|
384
|
+
offset_y = round_away_from_zero(offset_y)
|
|
385
|
+
|
|
386
|
+
if height and width:
|
|
387
|
+
offset_x_px = abs(offset_x * (self.dpi / 72))
|
|
388
|
+
offset_y_px = abs(offset_y * (self.dpi / 72))
|
|
389
|
+
|
|
390
|
+
if ha == "left":
|
|
391
|
+
x0 = int(display_x + offset_x_px)
|
|
392
|
+
x1 = int(display_x + offset_x_px + width)
|
|
393
|
+
elif ha == "right":
|
|
394
|
+
x0 = int(display_x - offset_x_px - width)
|
|
395
|
+
x1 = int(display_x - offset_x_px)
|
|
396
|
+
else:
|
|
397
|
+
x0 = int(display_x - offset_x_px - width / 2)
|
|
398
|
+
x1 = int(display_x + offset_x_px + width / 2)
|
|
399
|
+
|
|
400
|
+
if va == "bottom":
|
|
401
|
+
# TOP
|
|
402
|
+
y0 = int(display_y + offset_y_px)
|
|
403
|
+
y1 = int(display_y + offset_y_px + height)
|
|
404
|
+
elif va == "top":
|
|
405
|
+
# BOTTOM
|
|
406
|
+
y0 = int(display_y - offset_y_px - height)
|
|
407
|
+
y1 = int(display_y - offset_y_px)
|
|
408
|
+
else:
|
|
409
|
+
# CENTER
|
|
410
|
+
y0 = int(display_y - height / 2) + offset_y
|
|
411
|
+
y1 = int(display_y + height / 2) + offset_y
|
|
412
|
+
|
|
413
|
+
bbox = (x0, y0, x1, y1)
|
|
414
|
+
label = None
|
|
415
|
+
|
|
416
|
+
else:
|
|
417
|
+
label = self._text(
|
|
418
|
+
x, y, text, va=va, ha=ha, xytext=(offset_x, offset_y), **kwargs
|
|
419
|
+
)
|
|
420
|
+
bbox = self._get_label_bbox(label)
|
|
421
|
+
|
|
422
|
+
if bbox is None:
|
|
423
|
+
continue
|
|
424
|
+
|
|
425
|
+
height = bbox[3] - bbox[1]
|
|
426
|
+
width = bbox[2] - bbox[0]
|
|
427
|
+
|
|
428
|
+
is_open = self._is_open_space(
|
|
429
|
+
bbox,
|
|
430
|
+
padding=0,
|
|
431
|
+
allow_clipped=collision_handler.allow_clipped,
|
|
432
|
+
allow_constellation_collisions=collision_handler.allow_constellation_line_collisions,
|
|
433
|
+
allow_marker_collisions=collision_handler.allow_marker_collisions,
|
|
434
|
+
allow_label_collisions=collision_handler.allow_label_collisions,
|
|
435
|
+
)
|
|
436
|
+
is_final_attempt = bool(
|
|
437
|
+
(attempts == collision_handler.attempts) or (attempts == len(anchors))
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
if is_open or (collision_handler.plot_on_fail and is_final_attempt):
|
|
441
|
+
label = label or self._text(
|
|
442
|
+
x, y, text, va=va, ha=ha, xytext=(offset_x, offset_y), **kwargs
|
|
443
|
+
)
|
|
444
|
+
self._add_label_to_rtree(label, bbox=bbox)
|
|
445
|
+
return label
|
|
446
|
+
|
|
447
|
+
if label is not None:
|
|
448
|
+
label.remove()
|
|
449
|
+
|
|
450
|
+
if is_final_attempt:
|
|
451
|
+
return None
|
|
452
|
+
|
|
453
|
+
def _text_area(
|
|
454
|
+
self,
|
|
455
|
+
ra: float,
|
|
456
|
+
dec: float,
|
|
457
|
+
text: str,
|
|
458
|
+
area,
|
|
459
|
+
collision_handler: CollisionHandler,
|
|
460
|
+
**kwargs,
|
|
461
|
+
) -> Annotation | None:
|
|
462
|
+
kwargs["va"] = "center"
|
|
463
|
+
kwargs["ha"] = "center"
|
|
464
|
+
|
|
465
|
+
if StarplotSettings.svg_text_type == SvgTextType.PATH:
|
|
466
|
+
kwargs["path_effects"] = kwargs.get("path_effects", [self.text_border])
|
|
467
|
+
|
|
468
|
+
padding = 0
|
|
469
|
+
max_distance = 2_000
|
|
470
|
+
distance_step_size = 2
|
|
471
|
+
attempts = 0
|
|
472
|
+
height = None
|
|
473
|
+
width = None
|
|
474
|
+
bbox = None
|
|
475
|
+
|
|
476
|
+
origin = Point(ra, dec)
|
|
477
|
+
|
|
478
|
+
total_area = (
|
|
479
|
+
area
|
|
480
|
+
if area.geom_type != "MultiPolygon"
|
|
481
|
+
else union_at_zero(area.geoms[0], area.geoms[1])
|
|
482
|
+
)
|
|
483
|
+
original_size = total_area.area
|
|
484
|
+
buffer = -0.05 if original_size < 400 else -1
|
|
485
|
+
|
|
486
|
+
# Intersect with extent
|
|
487
|
+
extent = self._extent_mask()
|
|
488
|
+
|
|
489
|
+
try:
|
|
490
|
+
area = area.intersection(extent)
|
|
491
|
+
except GEOSException:
|
|
492
|
+
# TODO : handle this better
|
|
493
|
+
pass
|
|
494
|
+
|
|
495
|
+
area = (
|
|
496
|
+
area
|
|
497
|
+
if area.geom_type != "MultiPolygon"
|
|
498
|
+
else union_at_zero(area.geoms[0], area.geoms[1])
|
|
499
|
+
)
|
|
500
|
+
area = area.buffer(buffer, cap_style="square", join_style="mitre")
|
|
501
|
+
|
|
502
|
+
if not area.contains(origin) or area.area < (original_size * 0.9):
|
|
503
|
+
origin = area.centroid
|
|
504
|
+
|
|
505
|
+
if self.debug_text and area.is_valid and not origin.is_empty:
|
|
506
|
+
"""Plots marker at origin and polygon of area"""
|
|
507
|
+
self.marker(
|
|
508
|
+
origin.x,
|
|
509
|
+
origin.y,
|
|
510
|
+
style={
|
|
511
|
+
"marker": {
|
|
512
|
+
"symbol": "triangle",
|
|
513
|
+
"color": "red",
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
)
|
|
517
|
+
self.polygon(
|
|
518
|
+
geometry=area,
|
|
519
|
+
style={
|
|
520
|
+
"edge_color": "red",
|
|
521
|
+
"edge_width": 2,
|
|
522
|
+
},
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
for d in range(0, max_distance, distance_step_size):
|
|
526
|
+
attempts += 1
|
|
527
|
+
|
|
528
|
+
if not area.contains(origin):
|
|
529
|
+
continue
|
|
530
|
+
distance = d / 25
|
|
531
|
+
point = random_point_in_polygon_at_distance(
|
|
532
|
+
area,
|
|
533
|
+
origin_point=origin,
|
|
534
|
+
distance=distance,
|
|
535
|
+
max_iterations=10,
|
|
536
|
+
seed=collision_handler.seed,
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
if point is None:
|
|
540
|
+
continue
|
|
541
|
+
|
|
542
|
+
x, y = self._prepare_coords(point.x, point.y)
|
|
543
|
+
|
|
544
|
+
if height and width:
|
|
545
|
+
data_xy = self._proj.transform_point(x, y, self._crs)
|
|
546
|
+
display_x, display_y = self.ax.transData.transform(data_xy)
|
|
547
|
+
bbox = (
|
|
548
|
+
display_x - width / 2,
|
|
549
|
+
display_y - height / 2,
|
|
550
|
+
display_x + width / 2,
|
|
551
|
+
display_y + height / 2,
|
|
552
|
+
)
|
|
553
|
+
label = None
|
|
554
|
+
|
|
555
|
+
else:
|
|
556
|
+
label = self._text(x, y, text, **kwargs)
|
|
557
|
+
bbox = self._get_label_bbox(label)
|
|
558
|
+
|
|
559
|
+
if bbox is None:
|
|
560
|
+
continue
|
|
561
|
+
|
|
562
|
+
height = bbox[3] - bbox[1]
|
|
563
|
+
width = bbox[2] - bbox[0]
|
|
564
|
+
|
|
565
|
+
is_open = self._is_open_space(
|
|
566
|
+
bbox,
|
|
567
|
+
padding=padding,
|
|
568
|
+
allow_clipped=collision_handler.allow_clipped,
|
|
569
|
+
allow_constellation_collisions=collision_handler.allow_constellation_line_collisions,
|
|
570
|
+
allow_marker_collisions=collision_handler.allow_marker_collisions,
|
|
571
|
+
allow_label_collisions=collision_handler.allow_label_collisions,
|
|
572
|
+
)
|
|
573
|
+
is_final_attempt = attempts == collision_handler.attempts
|
|
574
|
+
|
|
575
|
+
# # TODO : remove label if not fully inside area?
|
|
576
|
+
|
|
577
|
+
if is_open or (collision_handler.plot_on_fail and is_final_attempt):
|
|
578
|
+
label = label or self._text(x, y, text, **kwargs)
|
|
579
|
+
self._add_label_to_rtree(label, bbox=bbox)
|
|
580
|
+
return label
|
|
581
|
+
|
|
582
|
+
if label is not None:
|
|
583
|
+
label.remove()
|
|
584
|
+
|
|
585
|
+
if is_final_attempt:
|
|
586
|
+
return None
|
|
587
|
+
|
|
588
|
+
def _text_line(
|
|
589
|
+
self,
|
|
590
|
+
x,
|
|
591
|
+
y,
|
|
592
|
+
text: str,
|
|
593
|
+
num_labels: int = 1,
|
|
594
|
+
collision_handler: CollisionHandler = None,
|
|
595
|
+
min_spacing=None,
|
|
596
|
+
curvature_threshold=0.8,
|
|
597
|
+
**kwargs,
|
|
598
|
+
) -> None:
|
|
599
|
+
"""
|
|
600
|
+
Plots text labels along a line:
|
|
601
|
+
|
|
602
|
+
- Finds smoothest sections and tries those first
|
|
603
|
+
- Falls back to evenly spaced labels (based on already plotted labels)
|
|
604
|
+
|
|
605
|
+
Args:
|
|
606
|
+
x, y: line data coordinates
|
|
607
|
+
text: text to plot
|
|
608
|
+
num_labels: Number of labels to plot
|
|
609
|
+
collision_handler: Collision handler to use
|
|
610
|
+
min_spacing: minimum spacing between labels (as fraction of line length). If None, uses 1/(n_labels+1)
|
|
611
|
+
prefer_center: if True, place labels at center of smooth sections
|
|
612
|
+
curvature_threshold: threshold for determining smooth sections
|
|
613
|
+
|
|
614
|
+
"""
|
|
615
|
+
kwargs.pop("ha", None) # alignment is forced to center of line
|
|
616
|
+
kwargs.pop("va", None)
|
|
617
|
+
kwargs.pop("transform", None) # we'll plot in axes coords
|
|
618
|
+
|
|
619
|
+
xy = list(zip(x, y))
|
|
620
|
+
data_xy = [self._proj.transform_point(_x, _y, self._crs) for _x, _y in xy]
|
|
621
|
+
display_xy = self.ax.transData.transform(data_xy)
|
|
622
|
+
|
|
623
|
+
# sort coords by display x value
|
|
624
|
+
display_xy = display_xy[display_xy[:, 0].argsort()]
|
|
625
|
+
num_positions = len(display_xy)
|
|
626
|
+
|
|
627
|
+
if min_spacing is None:
|
|
628
|
+
min_spacing = 1.0 / (num_labels + 1)
|
|
629
|
+
|
|
630
|
+
min_distance = int(min_spacing * num_positions)
|
|
631
|
+
smooth_sections = find_smooth_sections(
|
|
632
|
+
display_xy, min_length=2, curvature_threshold=curvature_threshold
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
smooth_positions = []
|
|
636
|
+
for section_start, section_end, _ in smooth_sections:
|
|
637
|
+
section_center = (section_start + section_end) // 2
|
|
638
|
+
|
|
639
|
+
too_close = False
|
|
640
|
+
for pos in smooth_positions:
|
|
641
|
+
if abs(section_center - pos) < min_distance:
|
|
642
|
+
too_close = True
|
|
643
|
+
break
|
|
644
|
+
|
|
645
|
+
if not too_close:
|
|
646
|
+
smooth_positions.append(section_center)
|
|
647
|
+
|
|
648
|
+
def plot_label(x0, y0, x1, y1, text):
|
|
649
|
+
# calculate angle in display coordinates
|
|
650
|
+
dx_display = x1 - x0
|
|
651
|
+
dy_display = y1 - y0
|
|
652
|
+
angle = np.degrees(np.arctan2(dy_display, dx_display))
|
|
653
|
+
|
|
654
|
+
# keep text upright
|
|
655
|
+
if angle > 90:
|
|
656
|
+
angle -= 180
|
|
657
|
+
elif angle < -90:
|
|
658
|
+
angle += 180
|
|
659
|
+
|
|
660
|
+
axes_coords = self.ax.transAxes.inverted().transform([(x0, y0)])
|
|
661
|
+
x_axes, y_axes = axes_coords[0]
|
|
662
|
+
|
|
663
|
+
return self.ax.text(
|
|
664
|
+
x_axes,
|
|
665
|
+
y_axes,
|
|
666
|
+
text,
|
|
667
|
+
rotation=angle,
|
|
668
|
+
ha="center",
|
|
669
|
+
va="center",
|
|
670
|
+
transform=self.ax.transAxes,
|
|
671
|
+
**kwargs,
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
offset = num_positions // 20 # offset from start/end of line
|
|
675
|
+
positions = [p for p in range(num_positions) if p not in smooth_positions]
|
|
676
|
+
positions = positions[offset : -1 * offset]
|
|
677
|
+
attempts = 0
|
|
678
|
+
plotted_positions = set()
|
|
679
|
+
|
|
680
|
+
while (
|
|
681
|
+
len(plotted_positions) < num_labels
|
|
682
|
+
and attempts < collision_handler.attempts
|
|
683
|
+
and len(positions) > 0
|
|
684
|
+
):
|
|
685
|
+
attempts += 1
|
|
686
|
+
|
|
687
|
+
if smooth_positions:
|
|
688
|
+
pos = smooth_positions.pop()
|
|
689
|
+
else:
|
|
690
|
+
pos = next_best_position(
|
|
691
|
+
plotted_positions, positions, num_labels, num_positions
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
if pos is None:
|
|
695
|
+
return
|
|
696
|
+
if pos in positions:
|
|
697
|
+
positions.remove(pos)
|
|
698
|
+
|
|
699
|
+
pos = max(0, min(pos, num_positions - 2))
|
|
700
|
+
x0, y0 = display_xy[pos]
|
|
701
|
+
x1, y1 = display_xy[pos + 1]
|
|
702
|
+
label = plot_label(x0, y0, x1, y1, text)
|
|
703
|
+
bbox = self._get_label_bbox(label)
|
|
704
|
+
|
|
705
|
+
# TODO : find better bbox (that's rotated with text)
|
|
706
|
+
|
|
707
|
+
if bbox is None:
|
|
708
|
+
continue
|
|
709
|
+
|
|
710
|
+
is_open = self._is_open_space(
|
|
711
|
+
bbox,
|
|
712
|
+
padding=0,
|
|
713
|
+
allow_clipped=collision_handler.allow_clipped,
|
|
714
|
+
allow_constellation_collisions=collision_handler.allow_constellation_line_collisions,
|
|
715
|
+
allow_marker_collisions=collision_handler.allow_marker_collisions,
|
|
716
|
+
allow_label_collisions=collision_handler.allow_label_collisions,
|
|
717
|
+
)
|
|
718
|
+
is_final_attempt = attempts == collision_handler.attempts
|
|
719
|
+
|
|
720
|
+
if is_open or (collision_handler.plot_on_fail and is_final_attempt):
|
|
721
|
+
self._add_label_to_rtree(label, bbox=bbox)
|
|
722
|
+
plotted_positions.add(pos)
|
|
723
|
+
if self.debug_text and label:
|
|
724
|
+
self._debug_bbox(bbox, color="red", width=1)
|
|
725
|
+
|
|
726
|
+
elif label is not None:
|
|
727
|
+
label.remove()
|
|
728
|
+
|
|
729
|
+
if is_final_attempt or len(plotted_positions) == num_labels:
|
|
730
|
+
return
|
|
731
|
+
|
|
732
|
+
@use_style(LabelStyle)
|
|
733
|
+
def text(
|
|
734
|
+
self,
|
|
735
|
+
text: str,
|
|
736
|
+
ra: float,
|
|
737
|
+
dec: float,
|
|
738
|
+
style: LabelStyle = None,
|
|
739
|
+
collision_handler: CollisionHandler = None,
|
|
740
|
+
**kwargs,
|
|
741
|
+
):
|
|
742
|
+
"""
|
|
743
|
+
Plots text
|
|
744
|
+
|
|
745
|
+
Args:
|
|
746
|
+
text: Text to plot
|
|
747
|
+
ra: Right ascension of text (0...360)
|
|
748
|
+
dec: Declination of text (-90...90)
|
|
749
|
+
style: Styling of the text
|
|
750
|
+
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.
|
|
751
|
+
"""
|
|
752
|
+
if not text:
|
|
753
|
+
return
|
|
754
|
+
|
|
755
|
+
style = style.model_copy() # need a copy because we possibly mutate it below
|
|
756
|
+
|
|
757
|
+
collision_handler = collision_handler or self.point_label_handler
|
|
758
|
+
|
|
759
|
+
if style.offset_x == "auto":
|
|
760
|
+
style.offset_x = 0
|
|
761
|
+
|
|
762
|
+
if style.offset_y == "auto":
|
|
763
|
+
style.offset_y = 0
|
|
764
|
+
|
|
765
|
+
if kwargs.get("area"):
|
|
766
|
+
label = self._text_area(
|
|
767
|
+
ra,
|
|
768
|
+
dec,
|
|
769
|
+
text,
|
|
770
|
+
**style.matplot_kwargs(self.scale),
|
|
771
|
+
area=kwargs.pop("area"),
|
|
772
|
+
collision_handler=collision_handler,
|
|
773
|
+
xycoords="data",
|
|
774
|
+
xytext=(
|
|
775
|
+
style.offset_x * self.scale,
|
|
776
|
+
style.offset_y * self.scale,
|
|
777
|
+
),
|
|
778
|
+
textcoords="offset points",
|
|
779
|
+
**kwargs,
|
|
780
|
+
)
|
|
781
|
+
else:
|
|
782
|
+
label = self._text_point(
|
|
783
|
+
ra,
|
|
784
|
+
dec,
|
|
785
|
+
text,
|
|
786
|
+
**style.matplot_kwargs(self.scale),
|
|
787
|
+
collision_handler=collision_handler,
|
|
788
|
+
xycoords="data",
|
|
789
|
+
xytext=(
|
|
790
|
+
style.offset_x * self.scale,
|
|
791
|
+
style.offset_y * self.scale,
|
|
792
|
+
),
|
|
793
|
+
textcoords="offset points",
|
|
794
|
+
**kwargs,
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
if self.debug_text and label:
|
|
798
|
+
"""Plots RED box around actual position of label"""
|
|
799
|
+
bbox = self._get_label_bbox(label)
|
|
800
|
+
self._debug_bbox(bbox, color="red", width=1)
|
|
801
|
+
|
|
802
|
+
return label
|