Refactor/interactive plotting #1
@@ -57,9 +57,18 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
|
||||
|
||||
#### `plot_control_widget.py`
|
||||
- Metric selector dropdown driven by the `metrics.METRICS` registry
|
||||
- Log-frequency toggle (view-state; recompute-free, currently honoured by the
|
||||
spectrogram) and the `Refresh Plot` button
|
||||
- Compare/overlay is *not* here — it is driven by the file-list checkboxes
|
||||
- Log-frequency toggle and a time-axis mode selector — Absolute (seconds) vs
|
||||
Relative (% of each track's own length) — both view-state, recompute-free
|
||||
- `Refresh Plot` button. Compare/overlay membership is the file-list checkboxes;
|
||||
reference lines have their own cluster
|
||||
|
||||
#### `ref_line_widget.py`
|
||||
- `RefLineControlWidget`: side-panel list of custom reference lines with
|
||||
Add / Edit… / Remove / Clear; a pure view over the `RefLineProps` list the
|
||||
main window owns, emitting intents
|
||||
- `RefLineDialog`: edits one line's value, colour, line style, and tag
|
||||
- The plot draws each line with a triangle drag-handle; dragging writes the new
|
||||
value back into the shared `RefLineProps` and refreshes the list
|
||||
|
||||
#### `metrics.py`
|
||||
- Pluggable `Metric` ABC: `compute(audio_file) -> data` (heavy, worker thread,
|
||||
@@ -114,11 +123,13 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
|
||||
(pyqtgraph ViewBox); log/linear frequency toggle. Scroll zooms both axes;
|
||||
**Ctrl+scroll** zooms time only, **Shift+scroll** zooms the value axis only
|
||||
(`_AxisZoomViewBox`); scrolling over an axis also zooms just that axis
|
||||
- **Custom reference lines**: "Add ref line" drops a draggable horizontal marker
|
||||
on any metric (e.g. an eyeballed effective average); lines persist across
|
||||
redraws/overlay changes and are cleared automatically when the metric changes
|
||||
- **Time-axis mode**: Absolute (seconds) or Relative (% of each track's own
|
||||
length), so tracks of very different durations line up by song position
|
||||
- **Custom reference lines**: side-panel list (Add/Edit/Remove/Clear) of draggable
|
||||
horizontal markers with value/colour/style/tag; dragged via a triangle handle.
|
||||
Persist across redraws/overlay changes; cleared when the metric changes
|
||||
- **Font control**: Unified font selector with size control
|
||||
- **Plot control**: Metric selector + log-frequency toggle + ref-line add/clear
|
||||
- **Plot control**: Metric selector + log-frequency toggle + time-axis mode
|
||||
+ refresh-plot button
|
||||
- **Analysis display**: Real-time visualization with metadata panels
|
||||
- **Modular architecture**: Self-contained widgets for easy layout management
|
||||
|
||||
@@ -11,8 +11,9 @@ Interaction notes:
|
||||
- Plain scroll zooms both axes; Ctrl+scroll zooms time only; Shift+scroll zooms
|
||||
the value axis only (see `_AxisZoomViewBox`). Scrolling directly over an axis
|
||||
also zooms just that axis (pyqtgraph default).
|
||||
- User reference lines (`add_user_line`) are draggable, survive redraws within a
|
||||
metric, and are cleared by the GUI when the metric changes (units change).
|
||||
- Reference lines (`set_reference_lines`) are draggable via a triangle handle,
|
||||
survive redraws, and write their position back into the GUI-owned RefLineProps;
|
||||
the GUI clears them when the metric changes (units change).
|
||||
|
||||
Why the spectrogram is special: pyqtgraph's ImageItem is affine-only, so it does
|
||||
not follow a log-scaled axis. Log frequency is therefore realised by resampling
|
||||
@@ -24,9 +25,9 @@ import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from scipy.interpolate import interp1d
|
||||
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
|
||||
from plotspec import PlotSpec, ViewState, DEFAULT_VIEW
|
||||
from plotspec import PlotSpec, ViewState, DEFAULT_VIEW, RefLineProps
|
||||
|
||||
# White canvas / black ink to match the previous matplotlib aesthetic.
|
||||
pg.setConfigOption("background", "w")
|
||||
@@ -45,9 +46,6 @@ _PEN_STYLE = {"solid": Qt.SolidLine, "dash": Qt.DashLine, "dot": Qt.DotLine}
|
||||
# "Nice" frequencies to label on a log frequency axis, in Hz.
|
||||
_LOG_FREQ_TICKS = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000]
|
||||
|
||||
# Colour for user-added reference lines (neutral so it reads on any metric).
|
||||
_USER_LINE_COLOR = "#444444"
|
||||
|
||||
|
||||
def dataset_color(index: int) -> str:
|
||||
"""Stable dataset colour for a given index (e.g. a file's row in the list)."""
|
||||
@@ -83,9 +81,40 @@ class _AxisZoomViewBox(pg.ViewBox):
|
||||
super().wheelEvent(ev, axis=axis)
|
||||
|
||||
|
||||
class _RefLine(pg.InfiniteLine):
|
||||
"""A draggable horizontal reference line bound to a RefLineProps.
|
||||
|
||||
Carries a triangle grab-handle at the left edge and writes its position back
|
||||
into the props on drag, notifying the widget so the side-panel list refreshes.
|
||||
"""
|
||||
|
||||
def __init__(self, index: int, props: RefLineProps, on_moved):
|
||||
pen = pg.mkPen(props.color, width=1.4,
|
||||
style=_PEN_STYLE.get(props.style, Qt.DashLine))
|
||||
super().__init__(
|
||||
pos=props.value, angle=0, movable=True, pen=pen,
|
||||
label=props.label or "{value:.2f}",
|
||||
labelOpts={"position": 0.06, "color": props.color,
|
||||
"fill": (255, 255, 255, 180)},
|
||||
)
|
||||
self._index = index
|
||||
self._props = props
|
||||
self._on_moved = on_moved
|
||||
self.addMarker("|>", position=0.0, size=12) # triangle handle at the start
|
||||
self.sigPositionChangeFinished.connect(self._commit)
|
||||
|
||||
def _commit(self):
|
||||
self._props.value = float(self.value())
|
||||
self._on_moved(self._index)
|
||||
|
||||
|
||||
class AudioVisualizationWidget(QWidget):
|
||||
"""Persistent interactive plot. Call `show_specs` to (re)draw."""
|
||||
|
||||
# Emitted (with the line's index) when a reference line is dragged, so the
|
||||
# side-panel list can refresh its displayed value.
|
||||
referenceLineMoved = pyqtSignal(int)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
layout = QVBoxLayout(self)
|
||||
@@ -101,10 +130,10 @@ class AudioVisualizationWidget(QWidget):
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
self._colorbar = None
|
||||
# User reference lines persist by value across redraws; the items are rebuilt
|
||||
# each render. Cleared by the GUI on metric change (units change).
|
||||
self._user_line_values: list[float] = []
|
||||
self._user_lines: list[pg.InfiniteLine] = []
|
||||
# Reference lines are owned by the GUI controller (RefLineProps objects) and
|
||||
# passed in via set_reference_lines; the line items are rebuilt each render.
|
||||
self._ref_props: list[RefLineProps] = []
|
||||
self._ref_lines: list[_RefLine] = []
|
||||
self._show_empty()
|
||||
|
||||
# ---- public API ---------------------------------------------------------
|
||||
@@ -131,8 +160,8 @@ class AudioVisualizationWidget(QWidget):
|
||||
self._render_heatmap(spec, view)
|
||||
if len(specs) > 1:
|
||||
self.set_status(f"{spec.title or label}: spectrogram shows one track at a time")
|
||||
self._apply_axes(base_axes, log_y_image_handled=True)
|
||||
self._draw_user_lines()
|
||||
self._apply_axes(base_axes, single=True, log_y_image_handled=True)
|
||||
self._draw_ref_lines()
|
||||
return
|
||||
|
||||
single = len(specs) == 1
|
||||
@@ -150,21 +179,18 @@ class AudioVisualizationWidget(QWidget):
|
||||
for note in spec.annotations:
|
||||
self._legend_note(prefix + note)
|
||||
|
||||
self._apply_axes(base_axes)
|
||||
self._draw_user_lines()
|
||||
self._apply_axes(base_axes, single=single)
|
||||
self._draw_ref_lines()
|
||||
|
||||
def add_user_line(self, value: float | None = None):
|
||||
"""Add a draggable horizontal reference line at `value` (default: view centre)."""
|
||||
if value is None:
|
||||
(_, _), (y0, y1) = self.plot.viewRange()
|
||||
value = (y0 + y1) / 2.0
|
||||
self._user_line_values.append(float(value))
|
||||
self._draw_user_lines()
|
||||
def set_reference_lines(self, props: list[RefLineProps]):
|
||||
"""Set the reference-line set (RefLineProps owned by the GUI) and redraw them."""
|
||||
self._ref_props = props
|
||||
self._draw_ref_lines()
|
||||
|
||||
def clear_user_lines(self):
|
||||
"""Remove all user reference lines (called when the metric changes)."""
|
||||
self._user_line_values.clear()
|
||||
self._remove_user_line_items()
|
||||
def current_view_center_y(self) -> float:
|
||||
"""Mid-point of the current y view — a sane default position for a new line."""
|
||||
(_, _), (y0, y1) = self.plot.viewRange()
|
||||
return (y0 + y1) / 2.0
|
||||
|
||||
def set_status(self, message: str):
|
||||
self.status_label.setText(message)
|
||||
@@ -261,11 +287,16 @@ class AudioVisualizationWidget(QWidget):
|
||||
self._colorbar.setImageItem(img)
|
||||
self.glw.addItem(self._colorbar, row=0, col=1)
|
||||
|
||||
def _apply_axes(self, axes, log_y_image_handled: bool = False):
|
||||
def _apply_axes(self, axes, single: bool, log_y_image_handled: bool = False):
|
||||
self.plot.setLabel("bottom", axes.x_label)
|
||||
self.plot.setLabel("left", axes.y_label)
|
||||
if axes.x_range:
|
||||
# Frame x exactly only for a single dataset; overlaid tracks of different
|
||||
# lengths (absolute mode) should autorange to their union rather than clip to
|
||||
# the first one's span. In relative mode every spec is 0-100, so either works.
|
||||
if axes.x_range and single:
|
||||
self.plot.setXRange(*axes.x_range, padding=0)
|
||||
elif not single:
|
||||
self.plot.enableAutoRange(axis=pg.ViewBox.XAxis)
|
||||
if axes.y_range and not log_y_image_handled:
|
||||
self.plot.setYRange(*axes.y_range, padding=0)
|
||||
if not log_y_image_handled:
|
||||
@@ -274,29 +305,18 @@ class AudioVisualizationWidget(QWidget):
|
||||
|
||||
# ---- user reference lines -----------------------------------------------
|
||||
|
||||
def _draw_user_lines(self):
|
||||
"""(Re)create draggable lines from the stored values, preserving positions."""
|
||||
self._remove_user_line_items()
|
||||
for idx in range(len(self._user_line_values)):
|
||||
line = pg.InfiniteLine(
|
||||
pos=self._user_line_values[idx], angle=0, movable=True,
|
||||
pen=pg.mkPen(_USER_LINE_COLOR, width=1.2, style=Qt.DashLine),
|
||||
label="{value:.2f}",
|
||||
labelOpts={"position": 0.05, "color": _USER_LINE_COLOR,
|
||||
"fill": (255, 255, 255, 180)},
|
||||
)
|
||||
line.sigPositionChanged.connect(lambda ln, i=idx: self._on_user_line_moved(i, ln))
|
||||
def _draw_ref_lines(self):
|
||||
"""(Re)create draggable lines from the current RefLineProps set."""
|
||||
self._remove_ref_line_items()
|
||||
for idx, props in enumerate(self._ref_props):
|
||||
line = _RefLine(idx, props, on_moved=self.referenceLineMoved.emit)
|
||||
self.plot.addItem(line)
|
||||
self._user_lines.append(line)
|
||||
self._ref_lines.append(line)
|
||||
|
||||
def _on_user_line_moved(self, index: int, line: pg.InfiniteLine):
|
||||
if 0 <= index < len(self._user_line_values):
|
||||
self._user_line_values[index] = float(line.value())
|
||||
|
||||
def _remove_user_line_items(self):
|
||||
for line in self._user_lines:
|
||||
def _remove_ref_line_items(self):
|
||||
for line in self._ref_lines:
|
||||
self.plot.removeItem(line)
|
||||
self._user_lines.clear()
|
||||
self._ref_lines.clear()
|
||||
|
||||
# ---- legend / lifecycle -------------------------------------------------
|
||||
|
||||
@@ -307,7 +327,7 @@ class AudioVisualizationWidget(QWidget):
|
||||
self.legend.addItem(pg.PlotDataItem(pen=None), text)
|
||||
|
||||
def _reset_plot(self):
|
||||
self._remove_user_line_items() # cleared from scene; values persist for redraw
|
||||
self._remove_ref_line_items() # cleared from scene; props persist for redraw
|
||||
self.plot.clear()
|
||||
if self._colorbar is not None:
|
||||
try:
|
||||
|
||||
@@ -12,7 +12,9 @@ from logger_setup import setup_logging, parse_log_args
|
||||
from font_manager import initialize_fonts, get_font_manager
|
||||
from font_control_widget import FontControlWidget
|
||||
from plot_control_widget import PlotControlWidget
|
||||
from ref_line_widget import RefLineControlWidget, RefLineDialog
|
||||
from metrics import METRICS
|
||||
from plotspec import RefLineProps, apply_x_mode
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
@@ -24,6 +26,8 @@ class MainWindow(QMainWindow):
|
||||
self.analysis_manager = AnalysisResultsManager()
|
||||
# Guards programmatic list mutations from triggering re-render storms.
|
||||
self._suppress_list_signals = False
|
||||
# Custom reference lines, owned here and pushed to the plot each render.
|
||||
self.ref_lines: list[RefLineProps] = []
|
||||
self.initUI()
|
||||
self.connect_signals()
|
||||
|
||||
@@ -74,10 +78,16 @@ class MainWindow(QMainWindow):
|
||||
self.plot_control.metricChanged.connect(self.on_metric_changed)
|
||||
self.plot_control.viewChanged.connect(self.on_view_changed)
|
||||
self.plot_control.plotRefreshRequested.connect(self.on_plot_refresh_requested)
|
||||
self.plot_control.addReferenceLineRequested.connect(self.on_add_reference_line)
|
||||
self.plot_control.clearReferenceLinesRequested.connect(self.on_clear_reference_lines)
|
||||
layout.addWidget(self.plot_control)
|
||||
|
||||
# Reference-line management cluster (list + add/edit/remove/clear).
|
||||
self.ref_line_control = RefLineControlWidget()
|
||||
self.ref_line_control.addRequested.connect(self.on_add_reference_line)
|
||||
self.ref_line_control.editRequested.connect(self.on_edit_reference_line)
|
||||
self.ref_line_control.removeRequested.connect(self.on_remove_reference_line)
|
||||
self.ref_line_control.clearRequested.connect(self.on_clear_reference_lines)
|
||||
layout.addWidget(self.ref_line_control)
|
||||
|
||||
# File list. Each item carries a checkbox: the checked set is the overlay
|
||||
# set drawn on the graph; the highlighted item drives the metadata panel.
|
||||
self.file_list_label = QLabel("Analyzed Files (tick to overlay):")
|
||||
@@ -116,6 +126,7 @@ class MainWindow(QMainWindow):
|
||||
self.analysis_manager.metricComputeStarted.connect(self.on_metric_compute_started)
|
||||
self.analysis_manager.metricReady.connect(self.on_metric_ready)
|
||||
self.analysis_manager.metricComputeError.connect(self.on_metric_compute_error)
|
||||
self.visualization_widget.referenceLineMoved.connect(self.on_reference_line_moved)
|
||||
|
||||
def dragEnterEvent(self, event):
|
||||
"""Handle drag enter event for file drops."""
|
||||
@@ -223,16 +234,47 @@ class MainWindow(QMainWindow):
|
||||
self.logger.info(f"Metric changed via GUI: {metric_id}")
|
||||
# The value axis units change with the metric, so custom reference lines
|
||||
# placed against the old metric no longer mean anything — drop them.
|
||||
self.visualization_widget.clear_user_lines()
|
||||
self.ref_lines.clear()
|
||||
self._sync_ref_lines()
|
||||
self._refresh_view()
|
||||
|
||||
def on_add_reference_line(self):
|
||||
"""Drop a draggable reference line on the current plot."""
|
||||
self.visualization_widget.add_user_line()
|
||||
"""Add a reference line at the current view centre, then edit it."""
|
||||
value = self.visualization_widget.current_view_center_y()
|
||||
props = RefLineProps(value=round(value, 2))
|
||||
self.ref_lines.append(props)
|
||||
self._sync_ref_lines()
|
||||
# Open the editor immediately so colour/tag/value can be set right away.
|
||||
self.on_edit_reference_line(len(self.ref_lines) - 1)
|
||||
|
||||
def on_edit_reference_line(self, index: int):
|
||||
"""Open the properties dialog for a reference line."""
|
||||
if not (0 <= index < len(self.ref_lines)):
|
||||
return
|
||||
dialog = RefLineDialog(self, self.ref_lines[index])
|
||||
if dialog.exec_():
|
||||
self.ref_lines[index] = dialog.result_props()
|
||||
self._sync_ref_lines()
|
||||
|
||||
def on_remove_reference_line(self, index: int):
|
||||
"""Delete a reference line."""
|
||||
if 0 <= index < len(self.ref_lines):
|
||||
del self.ref_lines[index]
|
||||
self._sync_ref_lines()
|
||||
|
||||
def on_clear_reference_lines(self):
|
||||
"""Remove all custom reference lines."""
|
||||
self.visualization_widget.clear_user_lines()
|
||||
self.ref_lines.clear()
|
||||
self._sync_ref_lines()
|
||||
|
||||
def on_reference_line_moved(self, index: int):
|
||||
"""A line was dragged on the plot — its value is already updated; refresh list."""
|
||||
self.ref_line_control.set_lines(self.ref_lines)
|
||||
|
||||
def _sync_ref_lines(self):
|
||||
"""Push the current reference-line set to both the list view and the plot."""
|
||||
self.ref_line_control.set_lines(self.ref_lines)
|
||||
self.visualization_widget.set_reference_lines(self.ref_lines)
|
||||
|
||||
def on_view_changed(self):
|
||||
"""Called when a view-scale toggle (lin/log) changes. Recompute-free redraw."""
|
||||
@@ -320,7 +362,8 @@ class MainWindow(QMainWindow):
|
||||
# Colour is keyed to the file's row, not its position in the overlay
|
||||
# subset, so a song keeps its colour as others are ticked/unticked.
|
||||
color = dataset_color(self._row_index(path))
|
||||
specs.append((label, metric.build_spec(data, view), color))
|
||||
spec = apply_x_mode(metric.build_spec(data, view), view.x_mode)
|
||||
specs.append((label, spec, color))
|
||||
|
||||
if specs:
|
||||
self.visualization_widget.show_specs(specs, view)
|
||||
|
||||
+18
-20
@@ -13,21 +13,20 @@ from PyQt5.QtWidgets import (
|
||||
from PyQt5.QtCore import pyqtSignal
|
||||
|
||||
from metrics import METRICS, DEFAULT_METRIC_ID
|
||||
from plotspec import ViewState
|
||||
from plotspec import ViewState, X_ABSOLUTE, X_RELATIVE
|
||||
|
||||
|
||||
class PlotControlWidget(QWidget):
|
||||
"""Metric selector, view-scale toggle, and manual plot refresh.
|
||||
"""Metric selector, view-scale/x-mode toggles, and manual plot refresh.
|
||||
|
||||
Overlay/compare is driven by the file-list checkboxes, not here — this cluster
|
||||
only governs *what* metric and *how* its axes are scaled.
|
||||
Overlay/compare membership is driven by the file-list checkboxes and reference
|
||||
lines by their own cluster; this one governs *what* metric and *how* its axes
|
||||
are scaled (lin/log frequency) and laid out (absolute vs relative time).
|
||||
"""
|
||||
|
||||
metricChanged = pyqtSignal(str) # metric_id
|
||||
viewChanged = pyqtSignal() # view-state (scale) changed
|
||||
viewChanged = pyqtSignal() # view-state (scale / x-mode) changed
|
||||
plotRefreshRequested = pyqtSignal()
|
||||
addReferenceLineRequested = pyqtSignal()
|
||||
clearReferenceLinesRequested = pyqtSignal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
@@ -58,18 +57,14 @@ class PlotControlWidget(QWidget):
|
||||
self.log_freq_check.toggled.connect(lambda _: self.viewChanged.emit())
|
||||
group_layout.addWidget(self.log_freq_check)
|
||||
|
||||
# Custom reference lines: drop a draggable horizontal marker (e.g. an
|
||||
# eyeballed effective average) onto whatever metric is showing.
|
||||
ref_row = QHBoxLayout()
|
||||
self.add_ref_button = QPushButton("Add ref line")
|
||||
self.add_ref_button.setToolTip("Drop a draggable horizontal reference line")
|
||||
self.add_ref_button.clicked.connect(self.addReferenceLineRequested.emit)
|
||||
ref_row.addWidget(self.add_ref_button)
|
||||
self.clear_ref_button = QPushButton("Clear")
|
||||
self.clear_ref_button.setToolTip("Remove all custom reference lines")
|
||||
self.clear_ref_button.clicked.connect(self.clearReferenceLinesRequested.emit)
|
||||
ref_row.addWidget(self.clear_ref_button)
|
||||
group_layout.addLayout(ref_row)
|
||||
# Time axis: absolute seconds vs relative % of each track's own length, so
|
||||
# tracks of very different durations line up by song position when overlaid.
|
||||
group_layout.addWidget(QLabel("Time axis:"))
|
||||
self.x_mode_combo = QComboBox()
|
||||
self.x_mode_combo.addItem("Absolute (seconds)", X_ABSOLUTE)
|
||||
self.x_mode_combo.addItem("Relative (%)", X_RELATIVE)
|
||||
self.x_mode_combo.currentIndexChanged.connect(lambda _: self.viewChanged.emit())
|
||||
group_layout.addWidget(self.x_mode_combo)
|
||||
|
||||
button_row = QHBoxLayout()
|
||||
self.refresh_button = QPushButton("Refresh Plot")
|
||||
@@ -90,4 +85,7 @@ class PlotControlWidget(QWidget):
|
||||
return self.metric_combo.currentData() or DEFAULT_METRIC_ID
|
||||
|
||||
def current_view_state(self) -> ViewState:
|
||||
return ViewState(y_log=self.log_freq_check.isChecked())
|
||||
return ViewState(
|
||||
y_log=self.log_freq_check.isChecked(),
|
||||
x_mode=self.x_mode_combo.currentData(),
|
||||
)
|
||||
|
||||
+56
@@ -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()
|
||||
|
||||
@@ -33,6 +33,7 @@ py-modules = [
|
||||
"font_manager",
|
||||
"font_control_widget",
|
||||
"plot_control_widget",
|
||||
"ref_line_widget",
|
||||
"logger_setup",
|
||||
"setup_fonts",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
Reference-line management: a side-panel list of custom horizontal markers plus a
|
||||
properties dialog.
|
||||
|
||||
`RefLineControlWidget` is a pure view over a list of `RefLineProps` owned by the
|
||||
main window: it renders the list and emits intents (add / edit / remove / clear).
|
||||
`RefLineDialog` edits one line's value, colour, line style, and tag.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from PyQt5.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QListWidget, QPushButton,
|
||||
QDialog, QFormLayout, QDoubleSpinBox, QComboBox, QLineEdit, QColorDialog,
|
||||
QDialogButtonBox,
|
||||
)
|
||||
from PyQt5.QtGui import QColor
|
||||
from PyQt5.QtCore import pyqtSignal
|
||||
|
||||
from plotspec import RefLineProps
|
||||
|
||||
|
||||
_STYLE_CHOICES = [("Solid", "solid"), ("Dashed", "dash"), ("Dotted", "dot")]
|
||||
|
||||
|
||||
class RefLineDialog(QDialog):
|
||||
"""Edit one reference line's properties. Read the result via `result_props`."""
|
||||
|
||||
def __init__(self, parent, props: RefLineProps):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Reference line")
|
||||
self._color = props.color
|
||||
|
||||
form = QFormLayout(self)
|
||||
|
||||
self.value_spin = QDoubleSpinBox()
|
||||
self.value_spin.setRange(-1e6, 1e6)
|
||||
self.value_spin.setDecimals(2)
|
||||
self.value_spin.setValue(props.value)
|
||||
form.addRow("Value:", self.value_spin)
|
||||
|
||||
self.color_button = QPushButton()
|
||||
self.color_button.clicked.connect(self._pick_color)
|
||||
self._refresh_color_button()
|
||||
form.addRow("Colour:", self.color_button)
|
||||
|
||||
self.style_combo = QComboBox()
|
||||
for label, key in _STYLE_CHOICES:
|
||||
self.style_combo.addItem(label, key)
|
||||
idx = self.style_combo.findData(props.style)
|
||||
if idx >= 0:
|
||||
self.style_combo.setCurrentIndex(idx)
|
||||
form.addRow("Line style:", self.style_combo)
|
||||
|
||||
self.label_edit = QLineEdit(props.label)
|
||||
self.label_edit.setPlaceholderText("(optional tag)")
|
||||
form.addRow("Tag:", self.label_edit)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
form.addRow(buttons)
|
||||
|
||||
def _pick_color(self):
|
||||
chosen = QColorDialog.getColor(QColor(self._color), self, "Reference line colour")
|
||||
if chosen.isValid():
|
||||
self._color = chosen.name()
|
||||
self._refresh_color_button()
|
||||
|
||||
def _refresh_color_button(self):
|
||||
self.color_button.setText(self._color)
|
||||
# Show the colour as the button's background for a quick read.
|
||||
self.color_button.setStyleSheet(f"background-color: {self._color};")
|
||||
|
||||
def result_props(self) -> RefLineProps:
|
||||
return RefLineProps(
|
||||
value=float(self.value_spin.value()),
|
||||
color=self._color,
|
||||
style=self.style_combo.currentData(),
|
||||
label=self.label_edit.text().strip(),
|
||||
)
|
||||
|
||||
|
||||
class RefLineControlWidget(QWidget):
|
||||
"""List of reference lines with Add / Edit / Remove / Clear controls."""
|
||||
|
||||
addRequested = pyqtSignal()
|
||||
editRequested = pyqtSignal(int)
|
||||
removeRequested = pyqtSignal(int)
|
||||
clearRequested = pyqtSignal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.initUI()
|
||||
|
||||
def initUI(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(5, 5, 5, 5)
|
||||
|
||||
group_box = QGroupBox("Reference lines")
|
||||
group_layout = QVBoxLayout(group_box)
|
||||
|
||||
self.line_list = QListWidget()
|
||||
self.line_list.setMaximumHeight(110)
|
||||
self.line_list.itemDoubleClicked.connect(self._on_double_click)
|
||||
group_layout.addWidget(self.line_list)
|
||||
|
||||
row = QHBoxLayout()
|
||||
self.add_button = QPushButton("Add")
|
||||
self.add_button.clicked.connect(self.addRequested.emit)
|
||||
row.addWidget(self.add_button)
|
||||
|
||||
self.edit_button = QPushButton("Edit…")
|
||||
self.edit_button.clicked.connect(self._emit_edit)
|
||||
row.addWidget(self.edit_button)
|
||||
|
||||
self.remove_button = QPushButton("Remove")
|
||||
self.remove_button.clicked.connect(self._emit_remove)
|
||||
row.addWidget(self.remove_button)
|
||||
|
||||
self.clear_button = QPushButton("Clear")
|
||||
self.clear_button.clicked.connect(self.clearRequested.emit)
|
||||
row.addWidget(self.clear_button)
|
||||
|
||||
group_layout.addLayout(row)
|
||||
layout.addWidget(group_box)
|
||||
|
||||
def set_lines(self, lines: list[RefLineProps]):
|
||||
"""Repopulate the list display from the current props (preserving selection)."""
|
||||
current = self.line_list.currentRow()
|
||||
self.line_list.clear()
|
||||
for p in lines:
|
||||
tag = f" {p.label}" if p.label else ""
|
||||
self.line_list.addItem(f"{p.value:.2f}{tag}")
|
||||
if 0 <= current < self.line_list.count():
|
||||
self.line_list.setCurrentRow(current)
|
||||
|
||||
def _selected_row(self) -> int:
|
||||
return self.line_list.currentRow()
|
||||
|
||||
def _emit_edit(self):
|
||||
row = self._selected_row()
|
||||
if row >= 0:
|
||||
self.editRequested.emit(row)
|
||||
|
||||
def _emit_remove(self):
|
||||
row = self._selected_row()
|
||||
if row >= 0:
|
||||
self.removeRequested.emit(row)
|
||||
|
||||
def _on_double_click(self, _item):
|
||||
self._emit_edit()
|
||||
Reference in New Issue
Block a user