Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 90 additions & 11 deletions src/phosphor/channel_plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ def __init__(
# _buffer is set by the subclass before calling _init_rendering()
self._buffer = None

# Trace colours, indexed by *absolute channel* so a channel keeps its
# colour as it scrolls -- see _channel_color. Subclasses override this
# with a configured palette before building graphics.
self._palette = list(CHANNEL_COLORS)

# Overlays, created on demand. Kept as None until asked for so a plot
# that never wants them pays nothing.
self._label_overlay: ChannelLabelOverlay | None = None
Expand Down Expand Up @@ -277,7 +282,7 @@ def _sync_overlays(self) -> None:
self._label_overlay.set_view(
getattr(buf, "channel_offset", 0),
getattr(buf, "n_visible", 0),
getattr(buf, "channel_order", "top_down") == "top_down",
self._is_top_down(),
y0,
slope,
z_scale,
Expand Down Expand Up @@ -330,14 +335,89 @@ def _on_wheel_event(self, event) -> None:
self._on_ctrl_scroll(delta)
elif "Shift" in getattr(event, "modifiers", ()):
# Shift+scroll → amplitude zoom
factor = 1.1 if delta > 0 else 0.9
self._zoom_amplitude(factor)
self._zoom_amplitude(1.1 if self._shift_wheel_delta(event) > 0 else 0.9)
else:
# Unmodified scroll → channel scroll
buf = self._buffer
step = 1 if delta < 0 else -1
buf.set_channel_offset(buf.channel_offset + step)
self._update_range_label()
step = self._channel_scroll_step(delta)
if step:
buf = self._buffer
buf.set_channel_offset(buf.channel_offset + step)
self._update_range_label()

# ------------------------------------------------------------------
# Row <-> channel mapping
# ------------------------------------------------------------------
#
# A buffer lays its visible rows out along world-y, and "row 0" is the
# bottom of the canvas because that is what a plain arange of z-offsets
# produces. ``channel_order="top_down"`` reverses which channel lands on
# which row, so anything converting between a screen position and a channel
# has to go through here -- reading it off as ``channel_offset + row``
# silently inverts the answer whenever top_down is in force, which is the
# sweep's default.

def _is_top_down(self) -> bool:
"""Whether the first visible channel is drawn at the top of the canvas.

A buffer that does not declare an order (the spectrum) offsets its rows
with a plain arange, so *absent* means bottom-up rather than the sweep's
default.
"""
return getattr(self._buffer, "channel_order", "bottom_up") == "top_down"

def _channel_at_row(self, row: int) -> int:
"""Absolute channel drawn at *row*, counting up from the canvas bottom."""
if self._is_top_down():
row = self._buffer.n_visible - 1 - row
return self._buffer.channel_offset + row

def _row_of_channel(self, channel: int) -> int:
"""Row a channel is drawn at, counting up from the canvas bottom."""
row = channel - self._buffer.channel_offset
if self._is_top_down():
row = self._buffer.n_visible - 1 - row
return row

def _channel_color(self, channel: int) -> tuple[float, float, float]:
"""Palette colour for an absolute channel index, as RGB.

Keyed to the channel rather than the on-screen row so a trace keeps its
colour while the window scrolls past it -- the point of a colour here
is to let the eye follow one channel, which a colour that belongs to
the row actively defeats.
"""
return tuple(self._palette[channel % len(self._palette)][:3])

@staticmethod
def _shift_wheel_delta(event) -> float:
"""Wheel motion for Shift+scroll, from whichever axis it arrived on.

Holding Shift makes the OS report a mouse wheel as *horizontal*
scrolling -- the convention that scrolls a document sideways -- so the
motion arrives in dx with dy pinned at 0. Reading dy alone then makes
every notch look negative and amplitude only ever zooms out. A trackpad
reports both axes natively and is unaffected, which is why this shows
up on a mouse only.
"""
dy = getattr(event, "dy", 0.0) or 0.0
return dy if dy else (getattr(event, "dx", 0.0) or 0.0)

@staticmethod
def _channel_scroll_step(delta: float) -> int:
"""Channel-offset change for one wheel notch, 0 for no vertical motion.

Scrolling down moves the window down the channel list, so the traces
travel with the fingers the way a document does. Split out from the
handler so the direction can be tested without a canvas, since it is
the kind of thing that is obvious in use and invisible in review.

A horizontal trackpad swipe arrives as a wheel event carrying dx with
dy at 0, which is why 0 has to mean *stay*: taking it as a direction
makes sideways scrolling walk the channel window.
"""
if delta == 0:
return 0
return 1 if delta > 0 else -1

def _on_pointer_move_event(self, event) -> None:
self._handle_mouse_move(event)
Expand Down Expand Up @@ -431,14 +511,13 @@ def _handle_mouse_move(self, event) -> None:
best_dist = dist
best_idx = i

ch_index = best_idx
abs_ch = buf.channel_offset + ch_index
abs_ch = self._channel_at_row(best_idx)

labels = self._channel_labels
label = labels[abs_ch] if labels and abs_ch < len(labels) else f"Ch {abs_ch}"

rgba = CHANNEL_COLORS[ch_index % len(CHANNEL_COLORS)]
hex_color = f"#{int(rgba[0] * 255):02x}{int(rgba[1] * 255):02x}{int(rgba[2] * 255):02x}"
rgb = self._channel_color(abs_ch)
hex_color = f"#{int(rgb[0] * 255):02x}{int(rgb[1] * 255):02x}{int(rgb[2] * 255):02x}"
html = f'<span style="color:{hex_color}">\u25a0</span> {label}'
from PySide6.QtCore import QPoint

Expand Down
6 changes: 3 additions & 3 deletions src/phosphor/spectrum_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from PySide6.QtWidgets import QWidget

from .channel_plot import ChannelPlotWidget
from .constants import CHANNEL_COLORS, DEFAULT_N_VISIBLE
from .constants import DEFAULT_N_VISIBLE
from .spectrum_buffer import SpectrumBuffer
from .x_axis import XAxisWidget

Expand Down Expand Up @@ -118,8 +118,8 @@ def _setup_graphics(self) -> None:
buf = self.spectrum_buffer
data = buf.get_multiline_data(self._display_freq_max)

n_vis = buf.n_visible
colors = [CHANNEL_COLORS[i % len(CHANNEL_COLORS)][:3] for i in range(n_vis)]
offset = buf.channel_offset
colors = [self._channel_color(offset + i) for i in range(buf.n_visible)]

self._multi_line = subplot.add_multi_line(
data,
Expand Down
83 changes: 59 additions & 24 deletions src/phosphor/sweep_buffer.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,16 @@ def _allocate(self):
self.n_columns = min(self._configured_n_columns, self.total_raw_samples)
self.samples_per_column = self.total_raw_samples / self.n_columns

raw_shape = (self.total_raw_samples, self.n_visible)
# Every channel is buffered, not just the visible window. Scrolling is
# then a change of which slice is drawn, with no reallocation and
# nothing lost -- the alternative is that a channel scrolled into view
# has no history, because it was discarded on arrival.
raw_shape = (self.total_raw_samples, self.n_channels)
if self.envelope:
raw_shape += (2,)
self.raw_buffer = np.zeros(raw_shape, dtype=np.float32)
self.display_mins = np.zeros((self.n_columns, self.n_visible), dtype=np.float32)
self.display_maxs = np.zeros((self.n_columns, self.n_visible), dtype=np.float32)
self.display_mins = np.zeros((self.n_columns, self.n_channels), dtype=np.float32)
self.display_maxs = np.zeros((self.n_columns, self.n_channels), dtype=np.float32)

self.write_pos = 0
self.sweep_col = 0
Expand Down Expand Up @@ -149,13 +153,7 @@ def push_data(self, data: np.ndarray, timestamps=None) -> None:
elif n_ch > self.n_channels:
data = data[:, : self.n_channels]

# Select visible channels
end_ch = min(self.channel_offset + self.n_visible, self.n_channels)
vis_data = data[:, self.channel_offset : end_ch].astype(np.float32)
if vis_data.shape[1] < self.n_visible:
pad = [(0, 0)] * vis_data.ndim
pad[1] = (0, self.n_visible - vis_data.shape[1])
vis_data = np.pad(vis_data, pad)
vis_data = data.astype(np.float32, copy=False)

# Truncate if more data than one full sweep
if n_samples > self.total_raw_samples:
Expand Down Expand Up @@ -215,19 +213,35 @@ def set_n_channels(self, n: int) -> None:
self._allocate()

def set_channel_offset(self, offset: int) -> None:
"""Scroll the visible window. Cheap: no reallocation, nothing lost.

Every channel is already buffered, so this only changes which slice is
drawn. The graphic keeps its shape -- ``n_visible`` rows either way --
so there is no version bump and no rebuild; the next frame simply
redraws every column from the new window.
"""
with self._lock:
offset = max(0, min(offset, self.n_channels - self.n_visible))
if offset != self.channel_offset:
self.channel_offset = offset
self._allocate()
self._mark_all_dirty()

def set_n_visible(self, n: int) -> None:
"""Change how many channels are drawn, keeping their history.

The stored data is untouched -- only the height of the window over it
changes. The multiline graphic does change shape, so the version is
bumped to have it rebuilt, but that is a GPU-side rebuild rather than a
data reset: the traces reappear already populated.
"""
with self._lock:
n = max(1, min(n, self.n_channels))
if n != self.n_visible:
self.n_visible = n
self.channel_offset = min(self.channel_offset, self.n_channels - self.n_visible)
self._allocate()
self._ch_mid = np.zeros((self.n_visible, 1), dtype=np.float32)
self._mark_all_dirty()
self._version += 1

def set_display_dur(self, dur: float) -> None:
with self._lock:
Expand Down Expand Up @@ -261,14 +275,32 @@ def set_envelope(self, envelope: bool) -> None:
def version(self) -> int:
return self._version

def _mark_all_dirty(self) -> None:
"""Force a full redraw on the next frame. Call while holding ``_lock``."""
self._dirty_start = 0
self._dirty_end = self.n_columns - 1

def _visible(self) -> slice:
"""The channel slice currently on screen.

Storage spans every channel; this is the window drawn from it.
Normalization and midpoints use it too, so the amplitude scale follows
what the user can see rather than channels off-screen.
"""
return slice(self.channel_offset, self.channel_offset + self.n_visible)

def _compute_y_scale(self) -> float:
"""Compute normalization scale from current buffer data.

Uses the max absolute value across all display columns/channels so
that normalized data fits within ±0.5, matching MultiLine z_offset_scale separation.
Must be called while holding ``_lock``.
"""
max_abs = max(float(np.abs(self.display_mins).max()), float(np.abs(self.display_maxs).max()))
vis = self._visible()
max_abs = max(
float(np.abs(self.display_mins[:, vis]).max()),
float(np.abs(self.display_maxs[:, vis]).max()),
)
return 0.5 / max(max_abs, 1e-12)

def _compute_ch_mid(self, scale: float) -> np.ndarray:
Expand All @@ -279,15 +311,19 @@ def _compute_ch_mid(self, scale: float) -> np.ndarray:
translating channels as the user changes ``amplitude_scale``.
Must be called while holding ``_lock``.
"""
ch_min = self.display_mins.min(axis=0)
ch_max = self.display_maxs.max(axis=0)
vis = self._visible()
ch_min = self.display_mins[:, vis].min(axis=0)
ch_max = self.display_maxs[:, vis].max(axis=0)
return (((ch_min + ch_max) / 2) * scale).astype(np.float32).reshape(-1, 1)

def _build_multiline_array(self, mins, maxs, col_indices, scale) -> np.ndarray:
"""Build a ``[n_visible, 2*n_cols, 3]`` array from min/max slices.

Must be called while holding ``_lock``.
"""
vis = self._visible()
mins = mins[:, vis]
maxs = maxs[:, vis]
n_cols = mins.shape[0]
out = np.zeros((self.n_visible, 2 * n_cols, 3), dtype=np.float32)
col_x = col_indices.astype(np.float32) / max(self.n_columns - 1, 1) * self.display_dur
Expand Down Expand Up @@ -325,9 +361,7 @@ def set_amplitude_scale(self, scale: float) -> None:
if scale == self._amplitude_scale:
return
self._amplitude_scale = scale
# Force a full rebuild on the next animation frame.
self._dirty_start = 0
self._dirty_end = self.n_columns - 1
self._mark_all_dirty()

def set_channel_order(self, order: str) -> None:
if order not in ("top_down", "bottom_up"):
Expand All @@ -336,8 +370,7 @@ def set_channel_order(self, order: str) -> None:
if order == self.channel_order:
return
self.channel_order = order
self._dirty_start = 0
self._dirty_end = self.n_columns - 1
self._mark_all_dirty()

def get_multiline_data(self) -> np.ndarray:
"""Full data shaped ``[n_visible, 2*n_columns, 3]`` for fastplotlib MultiLineGraphic.
Expand Down Expand Up @@ -489,8 +522,10 @@ def _resize_display_dur(self, new_dur: float) -> None:
# New write position: same total-sample count, different modulus
new_write_pos = self._samples_since_alloc % new_total

# Create new (zeroed) buffer
new_raw = np.zeros((new_total, self.n_visible), dtype=np.float32)
# Create new (zeroed) buffer. Shape derived from the existing one so it
# keeps both the full channel width and, in envelope mode, the trailing
# (min, max) pair -- writing n_visible here silently mis-sized it.
new_raw = np.zeros((new_total,) + self.raw_buffer.shape[1:], dtype=np.float32)

# Copy data preserving sample ages
available = min(self._samples_since_alloc, old_total)
Expand Down Expand Up @@ -523,8 +558,8 @@ def _resize_display_dur(self, new_dur: float) -> None:
self.sweep_col = self._col_for_pos(new_write_pos)

# Recompute display columns from new raw data
self.display_mins = np.zeros((new_n_columns, self.n_visible), dtype=np.float32)
self.display_maxs = np.zeros((new_n_columns, self.n_visible), dtype=np.float32)
self.display_mins = np.zeros((new_n_columns, self.n_channels), dtype=np.float32)
self.display_maxs = np.zeros((new_n_columns, self.n_channels), dtype=np.float32)
self._recompute_columns(0, new_n_columns)

self._dirty_start = None
Expand Down
Loading
Loading