starplot 0.20.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. starplot/__init__.py +59 -0
  2. starplot/callables.py +176 -0
  3. starplot/cli.py +29 -0
  4. starplot/config.py +80 -0
  5. starplot/coordinates.py +7 -0
  6. starplot/data/__init__.py +22 -0
  7. starplot/data/catalogs.py +356 -0
  8. starplot/data/constellations.py +71 -0
  9. starplot/data/db.py +33 -0
  10. starplot/data/dsos.py +85 -0
  11. starplot/data/ecliptic.py +201 -0
  12. starplot/data/library/constellation_names.parquet +0 -0
  13. starplot/data/library/dso_names.parquet +0 -0
  14. starplot/data/library/readme.md +1 -0
  15. starplot/data/library/star_designations.parquet +0 -0
  16. starplot/data/stars.py +79 -0
  17. starplot/data/translations.py +408 -0
  18. starplot/data/utils.py +35 -0
  19. starplot/geometry.py +319 -0
  20. starplot/mixins.py +360 -0
  21. starplot/models/__init__.py +21 -0
  22. starplot/models/base.py +77 -0
  23. starplot/models/comet.py +302 -0
  24. starplot/models/constellation.py +151 -0
  25. starplot/models/dso.py +317 -0
  26. starplot/models/milky_way.py +30 -0
  27. starplot/models/moon.py +130 -0
  28. starplot/models/objects.py +29 -0
  29. starplot/models/observer.py +125 -0
  30. starplot/models/optics.py +342 -0
  31. starplot/models/planet.py +137 -0
  32. starplot/models/satellite.py +138 -0
  33. starplot/models/star.py +253 -0
  34. starplot/models/sun.py +62 -0
  35. starplot/plots/__init__.py +7 -0
  36. starplot/plots/base.py +1023 -0
  37. starplot/plots/galaxy.py +372 -0
  38. starplot/plots/horizon.py +543 -0
  39. starplot/plots/map.py +515 -0
  40. starplot/plots/optic.py +469 -0
  41. starplot/plots/zenith.py +217 -0
  42. starplot/plotters/__init__.py +9 -0
  43. starplot/plotters/arrow.py +174 -0
  44. starplot/plotters/constellations.py +298 -0
  45. starplot/plotters/debug.py +21 -0
  46. starplot/plotters/dsos.py +294 -0
  47. starplot/plotters/experimental.py +722 -0
  48. starplot/plotters/gradients.py +153 -0
  49. starplot/plotters/legend.py +253 -0
  50. starplot/plotters/milkyway.py +51 -0
  51. starplot/plotters/stars.py +319 -0
  52. starplot/plotters/text.py +802 -0
  53. starplot/profile.py +16 -0
  54. starplot/projections.py +184 -0
  55. starplot/styles/__init__.py +6 -0
  56. starplot/styles/base.py +1344 -0
  57. starplot/styles/ext/antique.yml +175 -0
  58. starplot/styles/ext/blue_dark.yml +163 -0
  59. starplot/styles/ext/blue_gold.yml +147 -0
  60. starplot/styles/ext/blue_light.yml +123 -0
  61. starplot/styles/ext/blue_medium.yml +142 -0
  62. starplot/styles/ext/blue_night.yml +185 -0
  63. starplot/styles/ext/cb_wong.yml +124 -0
  64. starplot/styles/ext/color_print.yml +111 -0
  65. starplot/styles/ext/gradient_presets.yml +158 -0
  66. starplot/styles/ext/grayscale.yml +94 -0
  67. starplot/styles/ext/grayscale_dark.yml +136 -0
  68. starplot/styles/ext/map.yml +12 -0
  69. starplot/styles/ext/nord.yml +158 -0
  70. starplot/styles/ext/optic.yml +20 -0
  71. starplot/styles/ext/publication.yml +8 -0
  72. starplot/styles/extensions.py +129 -0
  73. starplot/styles/fonts-library/gfs-didot/DESCRIPTION.en_us.html +9 -0
  74. starplot/styles/fonts-library/gfs-didot/GFSDidot-Regular.ttf +0 -0
  75. starplot/styles/fonts-library/gfs-didot/METADATA.pb +16 -0
  76. starplot/styles/fonts-library/gfs-didot/OFL.txt +94 -0
  77. starplot/styles/fonts-library/inter/Inter-Bold.ttf +0 -0
  78. starplot/styles/fonts-library/inter/Inter-BoldItalic.ttf +0 -0
  79. starplot/styles/fonts-library/inter/Inter-ExtraBold.ttf +0 -0
  80. starplot/styles/fonts-library/inter/Inter-ExtraLight.ttf +0 -0
  81. starplot/styles/fonts-library/inter/Inter-ExtraLightItalic.ttf +0 -0
  82. starplot/styles/fonts-library/inter/Inter-Italic.ttf +0 -0
  83. starplot/styles/fonts-library/inter/Inter-Light.ttf +0 -0
  84. starplot/styles/fonts-library/inter/Inter-LightItalic.ttf +0 -0
  85. starplot/styles/fonts-library/inter/Inter-Regular.ttf +0 -0
  86. starplot/styles/fonts-library/inter/Inter-SemiBold.ttf +0 -0
  87. starplot/styles/fonts-library/inter/Inter-SemiBoldItalic.ttf +0 -0
  88. starplot/styles/fonts-library/inter/LICENSE.txt +92 -0
  89. starplot/styles/fonts.py +15 -0
  90. starplot/styles/helpers.py +93 -0
  91. starplot/styles/markers.py +308 -0
  92. starplot/utils.py +169 -0
  93. starplot/warnings.py +21 -0
  94. starplot-0.20.5.dist-info/METADATA +146 -0
  95. starplot-0.20.5.dist-info/RECORD +98 -0
  96. starplot-0.20.5.dist-info/WHEEL +4 -0
  97. starplot-0.20.5.dist-info/entry_points.txt +3 -0
  98. starplot-0.20.5.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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