feat: replay chart with zoomable viewpor

This commit is contained in:
jack
2026-07-10 20:54:24 -07:00
parent 4bb855a940
commit fed4d9b590
5 changed files with 645 additions and 22 deletions
@@ -21,7 +21,9 @@ Usage from QML::
from __future__ import annotations
from PySide6.QtCore import Property, QRectF, Qt, Signal
import math
from PySide6.QtCore import Property, QRectF, Qt, Signal, Slot
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen
from PySide6.QtQml import QmlElement
from PySide6.QtQuick import QQuickPaintedItem
@@ -32,6 +34,47 @@ QML_IMPORT_NAME = "ISC.Wafer"
QML_IMPORT_MAJOR_VERSION = 1
def viewport_stats(
series: list[list[float]],
names: list[str],
start: int,
end: int,
) -> tuple[float, float, float, str, str]:
"""Aggregate min/max/avg across every sensor's points in `[start, end]`.
Mirrors PopupChartForm.cs's `recalc_stats`: one running min/max/avg over
every visible point from every series, plus which sensor achieved each
extreme. Non-numeric values are skipped. No points in range -> zeros and
empty sensor names.
"""
vmin = math.inf
vmax = -math.inf
total = 0.0
count = 0
min_sensor = ""
max_sensor = ""
for i, s in enumerate(series):
name = names[i] if i < len(names) else ""
lo = max(0, start)
hi = min(len(s) - 1, end)
for j in range(lo, hi + 1):
try:
v = float(s[j])
except (TypeError, ValueError):
continue
if v < vmin:
vmin = v
min_sensor = name
if v > vmax:
vmax = v
max_sensor = name
total += v
count += 1
if count == 0:
return (0.0, 0.0, 0.0, "", "")
return (vmin, vmax, total / count, min_sensor, max_sensor)
@QmlElement
class GraphQuickItem(QQuickPaintedItem):
"""Painted line chart; driven by series data passed via QML property bindings."""
@@ -42,6 +85,8 @@ class GraphQuickItem(QQuickPaintedItem):
xLabelChanged = Signal()
yLabelChanged = Signal()
colorsChanged = Signal()
viewportChanged = Signal()
viewStatsChanged = Signal()
def __init__(self, parent=None) -> None:
super().__init__(parent)
@@ -74,6 +119,18 @@ class GraphQuickItem(QQuickPaintedItem):
self._max_y: float = 150.0
self._padding: dict[str, int] = {"left": 60, "right": 20, "top": 30, "bottom": 50}
# Viewport (replay chart zoom/pan): defaults reproduce "whole series",
# so the Graph tab (which never sets these) is unaffected. See
# docs/adr/0004-graphquickitem-viewport-for-replay-chart.md.
self._view_start: int = 0
self._view_end: int = -1
self._show_min_max_markers: bool = False
self._view_min: float = 0.0
self._view_max: float = 0.0
self._view_avg: float = 0.0
self._view_min_sensor: str = ""
self._view_max_sensor: str = ""
# ── Qt properties (QML-bindable) ──────────────────────────────────────────
@Property("QVariantList", notify=seriesDataChanged)
@@ -83,7 +140,16 @@ class GraphQuickItem(QQuickPaintedItem):
@seriesData.setter # type: ignore[no-redef]
def seriesData(self, val: list) -> None:
self._series_data = [list(s) for s in (val or [])]
# New data resets the viewport to the full range -- matches
# PopupChartForm.cs's SetData(), which resets AxisX.Minimum/Maximum
# before RecalculateAxesScale(). Without this, a stale viewport left
# over from a previous (longer) series can point entirely past the
# new data -- every per-series slice becomes empty and the chart
# silently renders nothing instead of the new data.
self._view_start = 0
self._view_end = -1
self._auto_range()
self._recompute_view_stats()
self.seriesDataChanged.emit()
self.update()
@@ -94,9 +160,130 @@ class GraphQuickItem(QQuickPaintedItem):
@sensorNames.setter # type: ignore[no-redef]
def sensorNames(self, val: list) -> None:
self._sensor_names = list(val or [])
self._recompute_view_stats()
self.sensorNamesChanged.emit()
self.update()
# ── Viewport properties (replay chart zoom/pan) ────────────────────────
@Property(int, notify=viewportChanged)
def viewStartIndex(self) -> int:
return self._view_start
@viewStartIndex.setter # type: ignore[no-redef]
def viewStartIndex(self, val: int) -> None:
v = max(0, int(val))
if v == self._view_start:
return
self._view_start = v
self._on_viewport_changed()
@Property(int, notify=viewportChanged)
def viewEndIndex(self) -> int:
return self._view_end
@viewEndIndex.setter # type: ignore[no-redef]
def viewEndIndex(self, val: int) -> None:
v = int(val)
if v == self._view_end:
return
self._view_end = v
self._on_viewport_changed()
@Property(bool, notify=viewportChanged)
def showMinMaxMarkers(self) -> bool:
return self._show_min_max_markers
@showMinMaxMarkers.setter # type: ignore[no-redef]
def showMinMaxMarkers(self, val: bool) -> None:
v = bool(val)
if v == self._show_min_max_markers:
return
self._show_min_max_markers = v
self.viewportChanged.emit()
self.update()
@Property(float, notify=viewStatsChanged)
def viewMin(self) -> float:
return self._view_min
@Property(float, notify=viewStatsChanged)
def viewMax(self) -> float:
return self._view_max
@Property(float, notify=viewStatsChanged)
def viewAvg(self) -> float:
return self._view_avg
@Property(str, notify=viewStatsChanged)
def viewMinSensor(self) -> str:
return self._view_min_sensor
@Property(str, notify=viewStatsChanged)
def viewMaxSensor(self) -> str:
return self._view_max_sensor
# ── Zoom/pan slots (replay chart interaction; QML MouseArea calls these
# with fractions derived from wheel/drag pixel deltas) ───────────────
@Slot(float, float)
def zoomAtFraction(self, frac: float, factor: float) -> None:
"""Zoom the viewport around a fractional X position within it.
`frac` in [0, 1] is where within the current viewport to zoom around;
`factor < 1` narrows the window (zoom in), `factor > 1` widens it
(zoom out). Clamped to data bounds and a minimum 1-index window.
"""
max_len = max((len(s) for s in self._series_data), default=0)
if max_len < 2:
return
start = float(self._view_start)
end = float(self._resolved_view_end())
width = max(1.0, end - start)
f = max(0.0, min(1.0, frac))
center = start + f * width
new_width = max(1.0, min(float(max_len - 1), width * max(0.01, factor)))
new_start = center - f * new_width
new_end = new_start + new_width
self._set_viewport_clamped(new_start, new_end, max_len)
@Slot(float)
def panByFraction(self, delta_frac: float) -> None:
"""Shift the viewport by `delta_frac * window_width` indices."""
max_len = max((len(s) for s in self._series_data), default=0)
if max_len < 2:
return
start = float(self._view_start)
end = float(self._resolved_view_end())
width = max(1.0, end - start)
shift = delta_frac * width
self._set_viewport_clamped(start + shift, end + shift, max_len)
@Slot()
def resetZoom(self) -> None:
"""Restore the full-range viewport sentinel (0, -1)."""
self._set_viewport(0, -1)
def _set_viewport_clamped(self, new_start: float, new_end: float, max_len: int) -> None:
if new_start < 0.0:
new_end -= new_start
new_start = 0.0
if new_end > max_len - 1:
new_start -= new_end - (max_len - 1)
new_end = float(max_len - 1)
new_start = max(0.0, new_start)
self._set_viewport(int(round(new_start)), int(round(new_end)))
def _set_viewport(self, start: int, end: int) -> None:
"""Set the viewport bounds directly (not through the QML-facing
property setters, so this stays a plain attribute write for mypy)."""
new_start = max(0, start)
if new_start == self._view_start and end == self._view_end:
return
self._view_start = new_start
self._view_end = end
self._on_viewport_changed()
@Property(str, notify=titleChanged)
def title(self) -> str:
return self._title
@@ -194,13 +381,38 @@ class GraphQuickItem(QQuickPaintedItem):
# ── internal ──────────────────────────────────────────────────────────────
def _resolved_view_end(self) -> int:
"""`viewEndIndex` with the -1 ("last index") sentinel resolved."""
max_len = max((len(s) for s in self._series_data), default=0)
if self._view_end < 0 or self._view_end > max_len - 1:
return max_len - 1
return self._view_end
def _on_viewport_changed(self) -> None:
self._auto_range()
self._recompute_view_stats()
self.viewportChanged.emit()
self.update()
def _recompute_view_stats(self) -> None:
end = self._resolved_view_end()
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(
self._series_data, self._sensor_names, self._view_start, end
)
self._view_min, self._view_max, self._view_avg = vmin, vmax, avg
self._view_min_sensor, self._view_max_sensor = min_sensor, max_sensor
self.viewStatsChanged.emit()
def _auto_range(self) -> None:
"""Set Y range to cover all data with 10% padding."""
"""Set Y range to cover the current viewport's data with 10% padding."""
start = max(0, self._view_start)
end = self._resolved_view_end()
all_vals: list[float] = []
for series in self._series_data:
for v in series:
hi = min(len(series) - 1, end)
for i in range(start, hi + 1):
try:
all_vals.append(float(v))
all_vals.append(float(series[i]))
except (ValueError, TypeError):
pass
if not all_vals:
@@ -319,18 +531,23 @@ class GraphQuickItem(QQuickPaintedItem):
self._x_label,
)
# --- X-axis ticks ---
num_points = max(len(s) for s in self._series_data) if self._series_data else 0
if num_points > 1:
n_x_ticks = min(num_points, max(2, int(plot_w // 80)))
x_tick_step = (num_points - 1) / max(1, n_x_ticks - 1)
# --- Viewport slice (replay chart zoom/pan; full range by default,
# so unset viewport reproduces the pre-viewport rendering) ---
view_start = max(0, self._view_start)
view_end = self._resolved_view_end()
view_len = max(1, view_end - view_start + 1)
# --- X-axis ticks (labelled in original, non-sliced index space) ---
if view_len > 1:
n_x_ticks = min(view_len, max(2, int(plot_w // 80)))
x_tick_step = (view_len - 1) / max(1, n_x_ticks - 1)
for i in range(n_x_ticks):
idx = int(round(i * x_tick_step))
x_px = index_to_pixel(idx, num_points, plot_left, plot_w)
rel_idx = int(round(i * x_tick_step))
x_px = index_to_pixel(rel_idx, view_len, plot_left, plot_w)
painter.setPen(axis_pen)
painter.drawText(
int(x_px - 12), int(plot_top + plot_h + 14),
str(idx + 1),
str(view_start + rel_idx + 1),
)
# --- Axes boundary ---
@@ -339,28 +556,36 @@ class GraphQuickItem(QQuickPaintedItem):
painter.drawLine(int(plot_left), int(plot_top), int(plot_left), int(plot_top + plot_h))
painter.drawLine(int(plot_left), int(plot_top + plot_h), int(plot_left + plot_w), int(plot_top + plot_h))
# --- Figure out max series length ---
max_len = max(len(s) for s in self._series_data) if self._series_data else 0
if max_len < 1:
return
# --- Draw each series ---
# --- Draw each series (sliced to the current viewport) ---
min_pt: tuple[float, float] | None = None
max_pt: tuple[float, float] | None = None
min_seen = math.inf
max_seen = -math.inf
for si, series in enumerate(self._series_data):
if len(series) < 1:
hi = min(len(series) - 1, view_end)
sliced = series[view_start:hi + 1] if view_start <= hi else []
if not sliced:
continue
color = self._series_colors[si % len(self._series_colors)]
pen = QPen(color, 2)
painter.setPen(pen)
pts: list[tuple[float, float]] = []
for i, val in enumerate(series):
for i, val in enumerate(sliced):
try:
v = float(val)
except (ValueError, TypeError):
continue
x_px = index_to_pixel(i, max_len, plot_left, plot_w)
x_px = index_to_pixel(i, view_len, plot_left, plot_w)
y_px = value_to_pixel(v, self._min_y, self._max_y, plot_top, plot_h)
pts.append((x_px, y_px))
if self._show_min_max_markers:
if v < min_seen:
min_seen = v
min_pt = (x_px, y_px)
if v > max_seen:
max_seen = v
max_pt = (x_px, y_px)
if len(pts) < 2:
if len(pts) == 1:
@@ -373,6 +598,19 @@ class GraphQuickItem(QQuickPaintedItem):
int(pts[i + 1][0]), int(pts[i + 1][1]),
)
# --- Min/max markers (replay chart only; PopupChartForm.cs parity) ---
if self._show_min_max_markers:
marker_font = QFont()
marker_font.setPixelSize(14)
marker_font.setBold(True)
painter.setFont(marker_font)
if max_pt is not None:
painter.setPen(QPen(QColor("#FF5757")))
painter.drawText(int(max_pt[0] - 6), int(max_pt[1] - 8), "")
if min_pt is not None:
painter.setPen(QPen(QColor("#42A5F5")))
painter.drawText(int(min_pt[0] - 6), int(min_pt[1] + 18), "")
# --- Legend ---
if self._show_legend and self._sensor_names:
legend_font = QFont()