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
« 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.
3This module provides intelligent layout algorithms for stacking multiple
4channels and optimizing annotation placement with collision avoidance.
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']}")
12References:
13 - Force-directed graph layout (Fruchterman-Reingold)
14 - Constrained layout solver for equal spacing
15"""
17from __future__ import annotations
19from dataclasses import dataclass
20from typing import TYPE_CHECKING
22import numpy as np
24if TYPE_CHECKING:
25 from numpy.typing import NDArray
28@dataclass
29class ChannelLayout:
30 """Layout specification for stacked channels.
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 """
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]
49@dataclass
50class Annotation:
51 """Annotation specification with position and bounding box.
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 """
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"
72@dataclass
73class PlacedAnnotation:
74 """Annotation with optimized placement.
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 """
84 annotation: Annotation
85 display_x: float
86 display_y: float
87 needs_leader: bool
88 leader_points: list[tuple[float, float]] | None = None
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.
100 Implements constrained layout solver for equal spacing with configurable
101 gaps between channels, ensuring proper vertical alignment.
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).
109 Returns:
110 ChannelLayout with heights, gaps, and positions.
112 Raises:
113 ValueError: If n_channels < 1 or gap_ratio invalid.
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}")
119 References:
120 VIS-015: Multi-Channel Stack Optimization
121 """
122 if n_channels < 1:
123 raise ValueError("n_channels must be >= 1")
125 if gap_ratio < 0 or gap_ratio > 1:
126 raise ValueError(f"gap_ratio must be in [0, 1], got {gap_ratio}")
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
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
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([])
147 # Calculate Y positions (from bottom)
148 y_positions = np.zeros(n_channels, dtype=np.float64)
149 current_y = bottom_margin
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
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 )
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.
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.
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.
191 Returns:
192 List of PlacedAnnotation with optimized positions.
194 Raises:
195 ValueError: If annotations list is empty.
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}")
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")
209 # Convert annotations to display coordinates
210 # For now, assume data coordinates are normalized to display units
211 placed = []
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 )
225 # Apply force-directed layout to resolve overlaps
226 for _iteration in range(max_iterations):
227 moved = False
229 # Calculate forces between all pairs
230 for i in range(len(placed)):
231 fx = 0.0
232 fy = 0.0
234 for j in range(len(placed)):
235 if i == j:
236 continue
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
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
248 # Minimum separation (sum of half-widths + spacing)
249 min_dx = (w1 + w2) / 2 + min_spacing
250 min_dy = (h1 + h2) / 2 + min_spacing
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
262 # Repulsion inversely proportional to distance
263 force = repulsion_strength / distance
265 # Apply force in direction away from overlap
266 fx -= force * dx / distance
267 fy -= force * dy / distance
269 # Apply forces with damping (priority affects inertia)
270 damping = 0.5
271 priority_factor = 1.0 - placed[i].annotation.priority
273 # Higher priority annotations move less
274 step_size = damping * priority_factor
276 new_x = placed[i].display_x + fx * step_size
277 new_y = placed[i].display_y + fy * step_size
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)
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
294 # Converged if nothing moved
295 if not moved:
296 break
298 # Determine which annotations need leader lines
299 # (displaced beyond threshold from original position)
300 leader_threshold = 20.0 # pixels
302 for i, p in enumerate(placed):
303 anchor_x = p.annotation.x
304 anchor_y = p.annotation.y
306 displacement = np.sqrt((p.display_x - anchor_x) ** 2 + (p.display_y - anchor_y) ** 2)
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 )
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 )
323 return placed
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.
332 Args:
333 anchor: Anchor point (x, y)
334 label: Label position (x, y)
336 Returns:
337 List of points for leader line
338 """
339 ax, ay = anchor
340 lx, ly = label
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
346 dx = abs(lx - ax)
347 dy = abs(ly - ay)
349 if dx > dy:
350 # Horizontal-first
351 mid = (lx, ay)
352 else:
353 # Vertical-first
354 mid = (ax, ly)
356 return [anchor, mid, label]
359__all__ = [
360 "Annotation",
361 "ChannelLayout",
362 "PlacedAnnotation",
363 "layout_stacked_channels",
364 "optimize_annotation_placement",
365]