Coverage for src / tracekit / visualization / annotations.py: 99%
107 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""Enhanced annotation placement with collision detection.
3This module provides intelligent annotation placement with collision avoidance,
4priority-based positioning, and dynamic hiding at different zoom levels.
7Example:
8 >>> from tracekit.visualization.annotations import place_annotations
9 >>> placed = place_annotations(annotations, viewport=(0, 10), density_limit=20)
11References:
12 - Force-directed graph layout (Fruchterman-Reingold)
13 - Greedy placement with priority
14 - Leader line routing algorithms
15"""
17from __future__ import annotations
19from dataclasses import dataclass
21import numpy as np
24@dataclass
25class Annotation:
26 """Annotation specification with position and metadata.
28 Attributes:
29 text: Annotation text
30 x: X coordinate in data units
31 y: Y coordinate in data units
32 bbox_width: Bounding box width in pixels
33 bbox_height: Bounding box height in pixels
34 priority: Priority for placement (0-1, higher is more important)
35 anchor: Preferred anchor position
36 metadata: Additional metadata
37 """
39 text: str
40 x: float
41 y: float
42 bbox_width: float = 60.0
43 bbox_height: float = 20.0
44 priority: float = 0.5
45 anchor: str = "auto"
46 metadata: dict | None = None # type: ignore[type-arg]
48 def __post_init__(self): # type: ignore[no-untyped-def]
49 if self.metadata is None:
50 self.metadata = {}
53@dataclass
54class PlacedAnnotation:
55 """Annotation with optimized placement and leader line.
57 Attributes:
58 annotation: Original annotation
59 display_x: Optimized X position in data units
60 display_y: Optimized Y position in data units
61 visible: Whether annotation is visible at current zoom
62 needs_leader: Whether a leader line is needed
63 leader_points: Points for leader line (if needed)
64 """
66 annotation: Annotation
67 display_x: float
68 display_y: float
69 visible: bool = True
70 needs_leader: bool = False
71 leader_points: list[tuple[float, float]] | None = None
74def place_annotations(
75 annotations: list[Annotation],
76 *,
77 viewport: tuple[float, float] | None = None,
78 density_limit: int = 20,
79 collision_threshold: float = 5.0,
80 max_iterations: int = 50,
81) -> list[PlacedAnnotation]:
82 """Place annotations with collision detection and density limiting.
84 Enhanced version with viewport-aware density limiting and dynamic hiding.
86 Args:
87 annotations: List of annotations to place.
88 viewport: Viewport range (x_min, x_max) for density calculation (None = all visible).
89 density_limit: Maximum annotations per viewport.
90 collision_threshold: Minimum spacing in pixels.
91 max_iterations: Maximum iterations for collision resolution.
93 Returns:
94 List of PlacedAnnotation with optimized positions.
96 Example:
97 >>> annots = [
98 ... Annotation("Peak", 5.0, 1.0, priority=0.9),
99 ... Annotation("Min", 3.0, -0.5, priority=0.7),
100 ... ]
101 >>> placed = place_annotations(annots, density_limit=10)
103 References:
104 VIS-016: Annotation Placement Intelligence (enhanced)
105 """
106 if len(annotations) == 0:
107 return []
109 # Filter by viewport if specified
110 if viewport is not None:
111 x_min, x_max = viewport
112 visible_annots = [a for a in annotations if x_min <= a.x <= x_max]
113 else:
114 visible_annots = annotations
116 # Apply density limiting - keep only top priority annotations
117 if len(visible_annots) > density_limit:
118 # Sort by priority (descending)
119 sorted_annots = sorted(
120 visible_annots,
121 key=lambda a: a.priority,
122 reverse=True,
123 )
124 visible_annots = sorted_annots[:density_limit]
126 # Initialize placed annotations at anchor points
127 placed = []
128 for annot in visible_annots:
129 placed.append(
130 PlacedAnnotation(
131 annotation=annot,
132 display_x=annot.x,
133 display_y=annot.y,
134 visible=True,
135 needs_leader=False,
136 )
137 )
139 # Resolve collisions using iterative adjustment
140 for _iteration in range(max_iterations):
141 moved = False
143 # Check all pairs for collisions
144 for i in range(len(placed)):
145 for j in range(i + 1, len(placed)):
146 if _check_collision(placed[i], placed[j], collision_threshold):
147 # Resolve collision by moving lower-priority annotation
148 if placed[i].annotation.priority >= placed[j].annotation.priority:
149 moved = _move_annotation(placed[j], placed[i], collision_threshold) or moved
150 else:
151 moved = _move_annotation(placed[i], placed[j], collision_threshold) or moved
153 # Converged if nothing moved
154 if not moved:
155 break
157 # Determine which annotations need leader lines
158 leader_threshold = 30.0 # pixels
160 for p in placed:
161 dx = abs(p.display_x - p.annotation.x)
162 dy = abs(p.display_y - p.annotation.y)
163 displacement = np.sqrt(dx**2 + dy**2)
165 if displacement > leader_threshold:
166 p.needs_leader = True
167 p.leader_points = _generate_leader_line(
168 (p.annotation.x, p.annotation.y),
169 (p.display_x, p.display_y),
170 )
172 return placed
175def _check_collision(
176 p1: PlacedAnnotation,
177 p2: PlacedAnnotation,
178 threshold: float,
179) -> bool:
180 """Check if two annotations collide.
182 Args:
183 p1: First annotation
184 p2: Second annotation
185 threshold: Minimum spacing threshold
187 Returns:
188 True if annotations collide
189 """
190 # Bounding box collision detection
191 dx = abs(p2.display_x - p1.display_x)
192 dy = abs(p2.display_y - p1.display_y)
194 # Minimum separation (sum of half-widths + threshold)
195 min_dx = (p1.annotation.bbox_width + p2.annotation.bbox_width) / 2 + threshold
196 min_dy = (p1.annotation.bbox_height + p2.annotation.bbox_height) / 2 + threshold
198 return dx < min_dx and dy < min_dy
201def _move_annotation(
202 to_move: PlacedAnnotation,
203 fixed: PlacedAnnotation,
204 threshold: float,
205) -> bool:
206 """Move annotation away from collision.
208 Args:
209 to_move: Annotation to move
210 fixed: Fixed annotation to move away from
211 threshold: Minimum spacing
213 Returns:
214 True if annotation was moved
215 """
216 dx = to_move.display_x - fixed.display_x
217 dy = to_move.display_y - fixed.display_y
219 distance = np.sqrt(dx**2 + dy**2)
221 if distance < 1e-6:
222 # Randomize if overlapping exactly
223 dx = np.random.randn() * 10
224 dy = np.random.randn() * 10
225 distance = np.sqrt(dx**2 + dy**2)
227 # Required separation
228 min_dx = (to_move.annotation.bbox_width + fixed.annotation.bbox_width) / 2 + threshold
229 min_dy = (to_move.annotation.bbox_height + fixed.annotation.bbox_height) / 2 + threshold
230 min_dist = np.sqrt(min_dx**2 + min_dy**2)
232 # Move away if too close
233 if distance < min_dist: 233 ↛ 246line 233 didn't jump to line 246 because the condition on line 233 was always true
234 # Move proportionally to required distance
235 scale = min_dist / distance
236 new_x = fixed.display_x + dx * scale
237 new_y = fixed.display_y + dy * scale
239 # Apply with damping to avoid oscillation
240 damping = 0.5
241 to_move.display_x += (new_x - to_move.display_x) * damping
242 to_move.display_y += (new_y - to_move.display_y) * damping
244 return True
246 return False
249def _generate_leader_line(
250 anchor: tuple[float, float],
251 label: tuple[float, float],
252) -> list[tuple[float, float]]:
253 """Generate orthogonal leader line from anchor to label.
255 Args:
256 anchor: Anchor point (x, y)
257 label: Label position (x, y)
259 Returns:
260 List of points for leader line
261 """
262 ax, ay = anchor
263 lx, ly = label
265 # L-shaped leader line
266 dx = abs(lx - ax)
267 dy = abs(ly - ay)
269 if dx > dy:
270 # Horizontal-first
271 mid = (lx, ay)
272 else:
273 # Vertical-first
274 mid = (ax, ly)
276 return [anchor, mid, label]
279def filter_by_zoom_level(
280 placed: list[PlacedAnnotation],
281 zoom_range: tuple[float, float],
282 *,
283 min_width_for_display: float = 0.1,
284) -> list[PlacedAnnotation]:
285 """Filter annotations based on zoom level.
287 Hide annotations when zoom range is too large for readability.
289 Args:
290 placed: List of placed annotations.
291 zoom_range: Current zoom range (x_min, x_max).
292 min_width_for_display: Minimum zoom width to display annotations.
294 Returns:
295 Filtered list with visibility updated.
297 Example:
298 >>> # Hide annotations when zoomed out too far
299 >>> filtered = filter_by_zoom_level(placed, (0, 1000), min_width_for_display=1.0)
301 References:
302 VIS-016: Annotation Placement Intelligence (dynamic hiding)
303 """
304 x_min, x_max = zoom_range
305 zoom_width = x_max - x_min
307 result = []
308 for p in placed:
309 # Update visibility based on zoom level
310 if zoom_width < min_width_for_display:
311 p.visible = True
312 else:
313 # Hide if outside viewport or too zoomed out
314 in_viewport = x_min <= p.annotation.x <= x_max
315 p.visible = in_viewport
317 result.append(p)
319 return result
322def create_priority_annotation( # type: ignore[no-untyped-def]
323 text: str,
324 x: float,
325 y: float,
326 *,
327 importance: str = "normal",
328 **kwargs,
329) -> Annotation:
330 """Create annotation with priority based on importance level.
332 Args:
333 text: Annotation text.
334 x: X position in data units.
335 y: Y position in data units.
336 importance: Importance level ("critical", "high", "normal", "low").
337 **kwargs: Additional Annotation parameters.
339 Returns:
340 Annotation with appropriate priority.
342 Example:
343 >>> peak_annot = create_priority_annotation(
344 ... "Critical Peak", 5.0, 1.0, importance="critical"
345 ... )
347 References:
348 VIS-016: Annotation Placement Intelligence (priority-based positioning)
349 """
350 priority_map = {
351 "critical": 1.0,
352 "high": 0.8,
353 "normal": 0.5,
354 "low": 0.2,
355 }
357 priority = priority_map.get(importance, 0.5)
359 return Annotation(
360 text=text,
361 x=x,
362 y=y,
363 priority=priority,
364 **kwargs,
365 )
368__all__ = [
369 "Annotation",
370 "PlacedAnnotation",
371 "create_priority_annotation",
372 "filter_by_zoom_level",
373 "place_annotations",
374]