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

1"""Enhanced annotation placement with collision detection. 

2 

3This module provides intelligent annotation placement with collision avoidance, 

4priority-based positioning, and dynamic hiding at different zoom levels. 

5 

6 

7Example: 

8 >>> from tracekit.visualization.annotations import place_annotations 

9 >>> placed = place_annotations(annotations, viewport=(0, 10), density_limit=20) 

10 

11References: 

12 - Force-directed graph layout (Fruchterman-Reingold) 

13 - Greedy placement with priority 

14 - Leader line routing algorithms 

15""" 

16 

17from __future__ import annotations 

18 

19from dataclasses import dataclass 

20 

21import numpy as np 

22 

23 

24@dataclass 

25class Annotation: 

26 """Annotation specification with position and metadata. 

27 

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 """ 

38 

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] 

47 

48 def __post_init__(self): # type: ignore[no-untyped-def] 

49 if self.metadata is None: 

50 self.metadata = {} 

51 

52 

53@dataclass 

54class PlacedAnnotation: 

55 """Annotation with optimized placement and leader line. 

56 

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 """ 

65 

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 

72 

73 

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. 

83 

84 Enhanced version with viewport-aware density limiting and dynamic hiding. 

85 

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. 

92 

93 Returns: 

94 List of PlacedAnnotation with optimized positions. 

95 

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) 

102 

103 References: 

104 VIS-016: Annotation Placement Intelligence (enhanced) 

105 """ 

106 if len(annotations) == 0: 

107 return [] 

108 

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 

115 

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] 

125 

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 ) 

138 

139 # Resolve collisions using iterative adjustment 

140 for _iteration in range(max_iterations): 

141 moved = False 

142 

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 

152 

153 # Converged if nothing moved 

154 if not moved: 

155 break 

156 

157 # Determine which annotations need leader lines 

158 leader_threshold = 30.0 # pixels 

159 

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) 

164 

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 ) 

171 

172 return placed 

173 

174 

175def _check_collision( 

176 p1: PlacedAnnotation, 

177 p2: PlacedAnnotation, 

178 threshold: float, 

179) -> bool: 

180 """Check if two annotations collide. 

181 

182 Args: 

183 p1: First annotation 

184 p2: Second annotation 

185 threshold: Minimum spacing threshold 

186 

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) 

193 

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 

197 

198 return dx < min_dx and dy < min_dy 

199 

200 

201def _move_annotation( 

202 to_move: PlacedAnnotation, 

203 fixed: PlacedAnnotation, 

204 threshold: float, 

205) -> bool: 

206 """Move annotation away from collision. 

207 

208 Args: 

209 to_move: Annotation to move 

210 fixed: Fixed annotation to move away from 

211 threshold: Minimum spacing 

212 

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 

218 

219 distance = np.sqrt(dx**2 + dy**2) 

220 

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) 

226 

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) 

231 

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 

238 

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 

243 

244 return True 

245 

246 return False 

247 

248 

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. 

254 

255 Args: 

256 anchor: Anchor point (x, y) 

257 label: Label position (x, y) 

258 

259 Returns: 

260 List of points for leader line 

261 """ 

262 ax, ay = anchor 

263 lx, ly = label 

264 

265 # L-shaped leader line 

266 dx = abs(lx - ax) 

267 dy = abs(ly - ay) 

268 

269 if dx > dy: 

270 # Horizontal-first 

271 mid = (lx, ay) 

272 else: 

273 # Vertical-first 

274 mid = (ax, ly) 

275 

276 return [anchor, mid, label] 

277 

278 

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. 

286 

287 Hide annotations when zoom range is too large for readability. 

288 

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. 

293 

294 Returns: 

295 Filtered list with visibility updated. 

296 

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) 

300 

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 

306 

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 

316 

317 result.append(p) 

318 

319 return result 

320 

321 

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. 

331 

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. 

338 

339 Returns: 

340 Annotation with appropriate priority. 

341 

342 Example: 

343 >>> peak_annot = create_priority_annotation( 

344 ... "Critical Peak", 5.0, 1.0, importance="critical" 

345 ... ) 

346 

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 } 

356 

357 priority = priority_map.get(importance, 0.5) 

358 

359 return Annotation( 

360 text=text, 

361 x=x, 

362 y=y, 

363 priority=priority, 

364 **kwargs, 

365 ) 

366 

367 

368__all__ = [ 

369 "Annotation", 

370 "PlacedAnnotation", 

371 "create_priority_annotation", 

372 "filter_by_zoom_level", 

373 "place_annotations", 

374]