Coverage for src / tracekit / visualization / layout.py: 88%

111 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 23:04 +0000

1"""Visualization layout functions for multi-channel plots and annotation placement. 

2 

3This module provides intelligent layout algorithms for stacking multiple 

4channels and optimizing annotation placement with collision avoidance. 

5 

6 

7Example: 

8 >>> from tracekit.visualization.layout import layout_stacked_channels 

9 >>> layout = layout_stacked_channels(n_channels=4, figsize=(10, 8)) 

10 >>> print(f"Channel heights: {layout['heights']}") 

11 

12References: 

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

14 - Constrained layout solver for equal spacing 

15""" 

16 

17from __future__ import annotations 

18 

19from dataclasses import dataclass 

20from typing import TYPE_CHECKING 

21 

22import numpy as np 

23 

24if TYPE_CHECKING: 

25 from numpy.typing import NDArray 

26 

27 

28@dataclass 

29class ChannelLayout: 

30 """Layout specification for stacked channels. 

31 

32 Attributes: 

33 n_channels: Number of channels to stack 

34 heights: Array of subplot heights (normalized 0-1) 

35 gaps: Array of gap sizes between channels (normalized 0-1) 

36 y_positions: Array of Y positions for each channel (normalized 0-1) 

37 shared_x: Whether channels share X-axis 

38 figsize: Figure size (width, height) in inches 

39 """ 

40 

41 n_channels: int 

42 heights: NDArray[np.float64] 

43 gaps: NDArray[np.float64] 

44 y_positions: NDArray[np.float64] 

45 shared_x: bool 

46 figsize: tuple[float, float] 

47 

48 

49@dataclass 

50class Annotation: 

51 """Annotation specification with position and bounding box. 

52 

53 Attributes: 

54 text: Annotation text 

55 x: X coordinate in data units 

56 y: Y coordinate in data units 

57 bbox_width: Bounding box width in display units 

58 bbox_height: Bounding box height in display units 

59 priority: Priority for placement (0-1, higher is more important) 

60 anchor: Preferred anchor position ("top", "bottom", "left", "right", "auto") 

61 """ 

62 

63 text: str 

64 x: float 

65 y: float 

66 bbox_width: float = 50.0 

67 bbox_height: float = 20.0 

68 priority: float = 0.5 

69 anchor: str = "auto" 

70 

71 

72@dataclass 

73class PlacedAnnotation: 

74 """Annotation with optimized placement. 

75 

76 Attributes: 

77 annotation: Original annotation 

78 display_x: Optimized X position in display units 

79 display_y: Optimized Y position in display units 

80 needs_leader: Whether a leader line is needed 

81 leader_points: Points for leader line (if needed) 

82 """ 

83 

84 annotation: Annotation 

85 display_x: float 

86 display_y: float 

87 needs_leader: bool 

88 leader_points: list[tuple[float, float]] | None = None 

89 

90 

91def layout_stacked_channels( 

92 n_channels: int, 

93 *, 

94 figsize: tuple[float, float] = (10, 8), 

95 gap_ratio: float = 0.1, 

96 shared_x: bool = True, 

97) -> ChannelLayout: 

98 """Calculate equal vertical spacing for stacked multi-channel plots. 

99 

100 Implements constrained layout solver for equal spacing with configurable 

101 gaps between channels, ensuring proper vertical alignment. 

102 

103 Args: 

104 n_channels: Number of channels to stack. 

105 figsize: Figure size (width, height) in inches. 

106 gap_ratio: Ratio of gap to channel height (default 0.1 = 10%). 

107 shared_x: Whether channels share X-axis (affects bottom margin). 

108 

109 Returns: 

110 ChannelLayout with heights, gaps, and positions. 

111 

112 Raises: 

113 ValueError: If n_channels < 1 or gap_ratio invalid. 

114 

115 Example: 

116 >>> layout = layout_stacked_channels(n_channels=3, gap_ratio=0.1) 

117 >>> print(f"Channel 0 position: {layout.y_positions[0]:.3f}") 

118 

119 References: 

120 VIS-015: Multi-Channel Stack Optimization 

121 """ 

122 if n_channels < 1: 

123 raise ValueError("n_channels must be >= 1") 

124 

125 if gap_ratio < 0 or gap_ratio > 1: 

126 raise ValueError(f"gap_ratio must be in [0, 1], got {gap_ratio}") 

127 

128 # Total available height (normalized to 1.0) 

129 # Reserve space for margins 

130 top_margin = 0.05 

131 bottom_margin = 0.1 if shared_x else 0.05 

132 available_height = 1.0 - top_margin - bottom_margin 

133 

134 # Calculate channel height with gaps 

135 # Total height = n_channels * h + (n_channels - 1) * gap 

136 # where gap = gap_ratio * h 

137 # Solving: available_height = n_channels * h + (n_channels - 1) * gap_ratio * h 

138 # = h * (n_channels + (n_channels - 1) * gap_ratio) 

139 denominator = n_channels + (n_channels - 1) * gap_ratio 

140 channel_height = available_height / denominator 

141 gap_height = channel_height * gap_ratio 

142 

143 # Calculate heights and gaps arrays 

144 heights = np.full(n_channels, channel_height, dtype=np.float64) 

145 gaps = np.full(n_channels - 1, gap_height, dtype=np.float64) if n_channels > 1 else np.array([]) 

146 

147 # Calculate Y positions (from bottom) 

148 y_positions = np.zeros(n_channels, dtype=np.float64) 

149 current_y = bottom_margin 

150 

151 for i in range(n_channels): 

152 # Channels are indexed from bottom to top 

153 y_positions[i] = current_y 

154 current_y += channel_height 

155 if i < n_channels - 1: 

156 current_y += gap_height 

157 

158 return ChannelLayout( 

159 n_channels=n_channels, 

160 heights=heights, 

161 gaps=gaps, 

162 y_positions=y_positions, 

163 shared_x=shared_x, 

164 figsize=figsize, 

165 ) 

166 

167 

168def optimize_annotation_placement( 

169 annotations: list[Annotation], 

170 *, 

171 display_width: float = 800.0, 

172 display_height: float = 600.0, 

173 max_iterations: int = 100, 

174 repulsion_strength: float = 10.0, 

175 min_spacing: float = 5.0, 

176) -> list[PlacedAnnotation]: 

177 """Optimize annotation placement with collision avoidance. 

178 

179 Uses force-directed layout algorithm to separate overlapping labels 

180 with repulsive forces. Generates leader lines when labels must be 

181 displaced from anchor points. 

182 

183 Args: 

184 annotations: List of annotations to place. 

185 display_width: Display area width in pixels. 

186 display_height: Display area height in pixels. 

187 max_iterations: Maximum iterations for force-directed layout. 

188 repulsion_strength: Strength of repulsive force between overlapping labels. 

189 min_spacing: Minimum spacing between annotations in pixels. 

190 

191 Returns: 

192 List of PlacedAnnotation with optimized positions. 

193 

194 Raises: 

195 ValueError: If annotations list is empty. 

196 

197 Example: 

198 >>> annots = [Annotation("Peak", 0.5, 1.0, priority=0.9)] 

199 >>> placed = optimize_annotation_placement(annots) 

200 >>> print(f"Needs leader: {placed[0].needs_leader}") 

201 

202 References: 

203 VIS-016: Annotation Placement Intelligence 

204 Force-directed graph layout (Fruchterman-Reingold) 

205 """ 

206 if len(annotations) == 0: 

207 raise ValueError("annotations list cannot be empty") 

208 

209 # Convert annotations to display coordinates 

210 # For now, assume data coordinates are normalized to display units 

211 placed = [] 

212 

213 for annot in annotations: 

214 # Initial placement at anchor point 

215 placed.append( 

216 PlacedAnnotation( 

217 annotation=annot, 

218 display_x=annot.x, 

219 display_y=annot.y, 

220 needs_leader=False, 

221 leader_points=None, 

222 ) 

223 ) 

224 

225 # Apply force-directed layout to resolve overlaps 

226 for _iteration in range(max_iterations): 

227 moved = False 

228 

229 # Calculate forces between all pairs 

230 for i in range(len(placed)): 

231 fx = 0.0 

232 fy = 0.0 

233 

234 for j in range(len(placed)): 

235 if i == j: 

236 continue 

237 

238 # Check for bounding box overlap 

239 dx = placed[j].display_x - placed[i].display_x 

240 dy = placed[j].display_y - placed[i].display_y 

241 

242 # Bounding box sizes 

243 w1 = placed[i].annotation.bbox_width 

244 h1 = placed[i].annotation.bbox_height 

245 w2 = placed[j].annotation.bbox_width 

246 h2 = placed[j].annotation.bbox_height 

247 

248 # Minimum separation (sum of half-widths + spacing) 

249 min_dx = (w1 + w2) / 2 + min_spacing 

250 min_dy = (h1 + h2) / 2 + min_spacing 

251 

252 # Check if overlapping 

253 if abs(dx) < min_dx and abs(dy) < min_dy: 253 ↛ 234line 253 didn't jump to line 234 because the condition on line 253 was always true

254 # Calculate repulsive force 

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

256 if distance < 1e-6: 256 ↛ 258line 256 didn't jump to line 258 because the condition on line 256 was never true

257 # Avoid division by zero 

258 distance = 1e-6 

259 dx = np.random.randn() * 0.1 

260 dy = np.random.randn() * 0.1 

261 

262 # Repulsion inversely proportional to distance 

263 force = repulsion_strength / distance 

264 

265 # Apply force in direction away from overlap 

266 fx -= force * dx / distance 

267 fy -= force * dy / distance 

268 

269 # Apply forces with damping (priority affects inertia) 

270 damping = 0.5 

271 priority_factor = 1.0 - placed[i].annotation.priority 

272 

273 # Higher priority annotations move less 

274 step_size = damping * priority_factor 

275 

276 new_x = placed[i].display_x + fx * step_size 

277 new_y = placed[i].display_y + fy * step_size 

278 

279 # Clamp to display bounds 

280 new_x = np.clip(new_x, 0, display_width) 

281 new_y = np.clip(new_y, 0, display_height) 

282 

283 # Update if moved significantly 

284 if abs(new_x - placed[i].display_x) > 0.1 or abs(new_y - placed[i].display_y) > 0.1: 

285 placed[i] = PlacedAnnotation( 

286 annotation=placed[i].annotation, 

287 display_x=new_x, 

288 display_y=new_y, 

289 needs_leader=False, 

290 leader_points=None, 

291 ) 

292 moved = True 

293 

294 # Converged if nothing moved 

295 if not moved: 

296 break 

297 

298 # Determine which annotations need leader lines 

299 # (displaced beyond threshold from original position) 

300 leader_threshold = 20.0 # pixels 

301 

302 for i, p in enumerate(placed): 

303 anchor_x = p.annotation.x 

304 anchor_y = p.annotation.y 

305 

306 displacement = np.sqrt((p.display_x - anchor_x) ** 2 + (p.display_y - anchor_y) ** 2) 

307 

308 if displacement > leader_threshold: 308 ↛ 310line 308 didn't jump to line 310 because the condition on line 308 was never true

309 # Generate simple orthogonal leader line 

310 leader_points = _generate_leader_line( 

311 (anchor_x, anchor_y), 

312 (p.display_x, p.display_y), 

313 ) 

314 

315 placed[i] = PlacedAnnotation( 

316 annotation=p.annotation, 

317 display_x=p.display_x, 

318 display_y=p.display_y, 

319 needs_leader=True, 

320 leader_points=leader_points, 

321 ) 

322 

323 return placed 

324 

325 

326def _generate_leader_line( 

327 anchor: tuple[float, float], 

328 label: tuple[float, float], 

329) -> list[tuple[float, float]]: 

330 """Generate orthogonal leader line from anchor to label. 

331 

332 Args: 

333 anchor: Anchor point (x, y) 

334 label: Label position (x, y) 

335 

336 Returns: 

337 List of points for leader line 

338 """ 

339 ax, ay = anchor 

340 lx, ly = label 

341 

342 # Simple L-shaped leader: anchor -> midpoint -> label 

343 # Choose horizontal-then-vertical or vertical-then-horizontal 

344 # based on which dimension has larger displacement 

345 

346 dx = abs(lx - ax) 

347 dy = abs(ly - ay) 

348 

349 if dx > dy: 

350 # Horizontal-first 

351 mid = (lx, ay) 

352 else: 

353 # Vertical-first 

354 mid = (ax, ly) 

355 

356 return [anchor, mid, label] 

357 

358 

359__all__ = [ 

360 "Annotation", 

361 "ChannelLayout", 

362 "PlacedAnnotation", 

363 "layout_stacked_channels", 

364 "optimize_annotation_placement", 

365]