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
+20
View File
@@ -182,6 +182,26 @@ Item {
}
}
Rectangle {
id: replayChartPane
Layout.fillWidth: true
Layout.fillHeight: false
Layout.preferredHeight: 260
Layout.minimumHeight: 180
visible: streamController.mode !== "live" && streamController.loadedFile !== ""
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusMd
ReplayChart {
anchors.fill: parent
anchors.margins: 8
sensorNames: streamController.graphSensorNames ? streamController.graphSensorNames.split(",") : []
seriesData: JSON.parse(streamController.graphSeriesJson || "[]")
}
}
Item {
Layout.fillWidth: true
visible: transportBar.hasContent
@@ -0,0 +1,136 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ISC
import ISC.Wafer
// ===== Replay Chart =====
// Zoomable whole-run multi-sensor chart (C# parity: PopupChartForm.cs).
// Embedded inline in WaferMapTab's replay slot rather than a separate popup
// window. Mouse wheel zooms around the cursor, drag pans; the strip below
// shows the aggregate min/max/avg over the *visible* range (not the whole
// run), matching PopupChartForm's recalc_stats. Cursor/playhead tracking is
// out of scope — see specs/plans/2026-07-10-popup-chart.md and
// docs/adr/0004-graphquickitem-viewport-for-replay-chart.md.
Item {
id: root
property alias sensorNames: chart.sensorNames
property alias seriesData: chart.seriesData
ColumnLayout {
anchors.fill: parent
spacing: 8
Item {
Layout.fillWidth: true
Layout.fillHeight: true
GraphQuickItem {
id: chart
anchors.fill: parent
showMinMaxMarkers: true
title: "Replay Chart"
xLabel: "Measurement Interval"
yLabel: "Temperature (°C)"
}
MouseArea {
id: interaction
anchors.fill: parent
acceptedButtons: Qt.LeftButton
property real lastX: 0
onWheel: function(wheel) {
var frac = Math.max(0, Math.min(1, wheel.x / width));
var factor = wheel.angleDelta.y > 0 ? 0.8 : 1.25;
chart.zoomAtFraction(frac, factor);
wheel.accepted = true;
}
onPressed: function(mouse) {
lastX = mouse.x;
}
onPositionChanged: function(mouse) {
if (pressed && width > 0) {
chart.panByFraction(-(mouse.x - lastX) / width);
lastX = mouse.x;
}
}
}
}
// ── Min/Max/Avg readout strip + Reset Zoom ─────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 16
Label {
text: "Min:"
color: Theme.sensorLow
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
}
Label {
text: chart.viewMin.toFixed(2) + (chart.viewMinSensor ? " (" + chart.viewMinSensor + ")" : "")
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontSm
}
Label {
text: "Max:"
color: Theme.sensorHigh
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
}
Label {
text: chart.viewMax.toFixed(2) + (chart.viewMaxSensor ? " (" + chart.viewMaxSensor + ")" : "")
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontSm
}
Label {
text: "Avg:"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
}
Label {
text: chart.viewAvg.toFixed(2)
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontSm
}
Item { Layout.fillWidth: true }
Button {
id: resetZoomBtn
implicitHeight: 32
hoverEnabled: true
text: "Reset Zoom"
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
background: Rectangle {
radius: Theme.radiusSm
color: resetZoomBtn.pressed ? Theme.transportButtonHover : Theme.transportButtonBg
border.color: Theme.sideBorder
border.width: 1
}
contentItem: Text {
text: resetZoomBtn.text
color: Theme.headingColor
font: resetZoomBtn.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: chart.resetZoom()
}
}
}
}
+2 -1
View File
@@ -22,4 +22,5 @@ PanelSlider 1.0 PanelSlider.qml
EditableScrubStat 1.0 EditableScrubStat.qml
ComparisonChartCard 1.0 ComparisonChartCard.qml
OverlapMapCard 1.0 OverlapMapCard.qml
ComparisonSidePanel 1.0 ComparisonSidePanel.qml
ComparisonSidePanel 1.0 ComparisonSidePanel.qml
ReplayChart 1.0 ReplayChart.qml
@@ -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()