Reference-line manager, relative time axis, axis-aware ref-line UX

Reference lines:
- Side-panel cluster (ref_line_widget.py): list of lines with Add/Edit/Remove/
  Clear and a properties dialog (value, colour, line style, tag)
- Lines own their state as RefLineProps (held by MainWindow), drawn with a
  triangle drag-handle; dragging writes the value back and refreshes the list
- Replaces the old centre-only add-and-drag-the-whole-line approach

Comparison:
- Time-axis mode selector: Absolute (seconds) vs Relative (% of each track's own
  length), so a long track and a short one line up by song position. Pure view
  transform (plotspec.apply_x_mode), no recompute
- Overlaid different-length tracks now autorange their x union in absolute mode
  instead of clipping to the first track's span

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Mikkeli Matlock
2026-06-14 00:51:33 +09:00
parent b400551321
commit a45bd4ba9b
7 changed files with 365 additions and 83 deletions
+56
View File
@@ -102,6 +102,25 @@ class PlotSpec:
return self.heatmap is not None
@dataclass
class RefLineProps:
"""A user-defined horizontal reference line.
Owned by the GUI controller and passed to the renderer, which draws it as a
draggable line and writes `value` back on drag. Persists across redraws; the
GUI clears the set when the metric changes (the value axis units change).
"""
value: float
color: str = "#444444"
style: str = "dash" # 'solid' | 'dash' | 'dot'
label: str = "" # tag shown on the line; falls back to the value
# X-axis modes for comparison.
X_ABSOLUTE = "absolute" # time in seconds (native)
X_RELATIVE = "relative" # 0-100% of each track's own length
@dataclass
class ViewState:
"""User-controlled, recompute-free view options.
@@ -112,6 +131,7 @@ class ViewState:
"""
y_log: Optional[bool] = None
x_log: Optional[bool] = None
x_mode: str = X_ABSOLUTE
def resolve_y_log(self, default: bool) -> bool:
return self.y_log if self.y_log is not None else default
@@ -120,5 +140,41 @@ class ViewState:
return self.x_log if self.x_log is not None else default
def apply_x_mode(spec: PlotSpec, mode: str) -> PlotSpec:
"""Rewrite a spec's x-axis to relative position (0-100%) in place, if asked.
Each dataset is normalised to *its own* span, so tracks of different lengths
line up by song position — the point of relative mode. A pure view transform:
it reassigns the x arrays (cached data is left untouched) and relabels the
axis. No-op for absolute mode.
"""
if mode != X_RELATIVE:
return spec
xs = [c.x for c in spec.curves] + [b.x for b in spec.bands]
if spec.heatmap is not None:
xs.append(spec.heatmap.x)
xs = [x for x in xs if len(x)]
if not xs:
return spec
lo = min(float(x[0]) for x in xs)
hi = max(float(x[-1]) for x in xs)
span = (hi - lo) or 1.0
def rel(x):
return (x - lo) / span * 100.0
for c in spec.curves:
c.x = rel(c.x)
for b in spec.bands:
b.x = rel(b.x)
if spec.heatmap is not None:
spec.heatmap.x = rel(spec.heatmap.x)
spec.axes.x_label = "Position (%)"
spec.axes.x_range = (0.0, 100.0)
return spec
# A neutral default reused wherever a caller hasn't supplied view options.
DEFAULT_VIEW = ViewState()