From fed4d9b590e70f35c7164006d5b28825e854a65c Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 10 Jul 2026 20:54:24 -0700 Subject: [PATCH] feat: replay chart with zoomable viewpor --- src/pygui/ISC/Tabs/WaferMapTab.qml | 20 ++ src/pygui/ISC/Tabs/components/ReplayChart.qml | 136 +++++++++ src/pygui/ISC/Tabs/components/qmldir | 3 +- .../backend/visualization/graph_quick_item.py | 280 ++++++++++++++++-- tests/test_graph_quick_item.py | 228 ++++++++++++++ 5 files changed, 645 insertions(+), 22 deletions(-) create mode 100644 src/pygui/ISC/Tabs/components/ReplayChart.qml diff --git a/src/pygui/ISC/Tabs/WaferMapTab.qml b/src/pygui/ISC/Tabs/WaferMapTab.qml index c5c1ad9..f6c1420 100644 --- a/src/pygui/ISC/Tabs/WaferMapTab.qml +++ b/src/pygui/ISC/Tabs/WaferMapTab.qml @@ -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 diff --git a/src/pygui/ISC/Tabs/components/ReplayChart.qml b/src/pygui/ISC/Tabs/components/ReplayChart.qml new file mode 100644 index 0000000..4a3fc22 --- /dev/null +++ b/src/pygui/ISC/Tabs/components/ReplayChart.qml @@ -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() + } + } + } +} diff --git a/src/pygui/ISC/Tabs/components/qmldir b/src/pygui/ISC/Tabs/components/qmldir index 6c7a233..54ae126 100644 --- a/src/pygui/ISC/Tabs/components/qmldir +++ b/src/pygui/ISC/Tabs/components/qmldir @@ -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 \ No newline at end of file +ComparisonSidePanel 1.0 ComparisonSidePanel.qml +ReplayChart 1.0 ReplayChart.qml \ No newline at end of file diff --git a/src/pygui/backend/visualization/graph_quick_item.py b/src/pygui/backend/visualization/graph_quick_item.py index 67db9bf..d833247 100644 --- a/src/pygui/backend/visualization/graph_quick_item.py +++ b/src/pygui/backend/visualization/graph_quick_item.py @@ -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() diff --git a/tests/test_graph_quick_item.py b/tests/test_graph_quick_item.py index 2dc4233..08a0cb4 100644 --- a/tests/test_graph_quick_item.py +++ b/tests/test_graph_quick_item.py @@ -59,3 +59,231 @@ def test_non_numeric_values_are_skipped_not_crashing(): assert item._min_y == pytest.approx(149.9) assert item._max_y == pytest.approx(151.1) + + +# ---- viewport_stats (replay chart min/max/avg readouts) ---- +# Independently hand-computed against PopupChartForm.cs's recalc_stats +# semantics: aggregate across every sensor's points in [start, end], plus +# which sensor achieved each extreme. + + +def test_viewport_stats_full_range_aggregate(): + from pygui.backend.visualization.graph_quick_item import viewport_stats + + series = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]] + names = ["Sensor 1", "Sensor 2"] + + vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(series, names, 0, 2) + + assert vmin == pytest.approx(147.0) + assert vmax == pytest.approx(152.0) + assert avg == pytest.approx(149.25) + assert min_sensor == "Sensor 2" + assert max_sensor == "Sensor 1" + + +def test_viewport_stats_restricted_to_subrange(): + from pygui.backend.visualization.graph_quick_item import viewport_stats + + series = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]] + names = ["Sensor 1", "Sensor 2"] + + vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(series, names, 0, 1) + + assert vmin == pytest.approx(147.5) + assert vmax == pytest.approx(151.0) + assert avg == pytest.approx(149.125) + assert min_sensor == "Sensor 2" + assert max_sensor == "Sensor 1" + + +def test_viewport_stats_empty_series_returns_zeros(): + from pygui.backend.visualization.graph_quick_item import viewport_stats + + assert viewport_stats([], [], 0, -1) == (0.0, 0.0, 0.0, "", "") + + +def test_viewport_stats_skips_non_numeric(): + from pygui.backend.visualization.graph_quick_item import viewport_stats + + series = [[150.0, "bad", 151.0]] + names = ["Sensor 1"] + + vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(series, names, 0, 2) + + assert vmin == pytest.approx(150.0) + assert vmax == pytest.approx(151.0) + assert avg == pytest.approx(150.5) + assert min_sensor == "Sensor 1" + assert max_sensor == "Sensor 1" + + +# ---- GraphQuickItem viewport properties (replay chart) ---- +# Default viewport (0, -1) means "whole series" so the already-shipped Graph +# tab (which never sets these) is unaffected — see docs/adr/0004. + + +def test_viewport_defaults_to_whole_range_and_markers_off(): + from pygui.backend.visualization.graph_quick_item import GraphQuickItem + + item = GraphQuickItem() + + assert item.viewStartIndex == 0 + assert item.viewEndIndex == -1 + assert item.showMinMaxMarkers is False + + +def test_view_stats_reflect_default_full_range_viewport(): + from pygui.backend.visualization.graph_quick_item import GraphQuickItem + + item = GraphQuickItem() + item.sensorNames = ["Sensor 1", "Sensor 2"] + item.seriesData = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]] + + assert item.viewMin == pytest.approx(147.0) + assert item.viewMax == pytest.approx(152.0) + assert item.viewAvg == pytest.approx(149.25) + assert item.viewMinSensor == "Sensor 2" + assert item.viewMaxSensor == "Sensor 1" + + +def test_view_stats_restrict_to_narrowed_viewport(): + from pygui.backend.visualization.graph_quick_item import GraphQuickItem + + item = GraphQuickItem() + item.sensorNames = ["Sensor 1", "Sensor 2"] + item.seriesData = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]] + + item.viewStartIndex = 0 + item.viewEndIndex = 1 + + assert item.viewMin == pytest.approx(147.5) + assert item.viewMax == pytest.approx(151.0) + assert item.viewAvg == pytest.approx(149.125) + + +def test_auto_range_y_restricted_to_narrowed_viewport(): + from pygui.backend.visualization.graph_quick_item import GraphQuickItem + + item = GraphQuickItem() + item.seriesData = [[150.0, 100.0], [200.0, 120.0]] + # Full-range Y (pre-existing behavior): min=100, max=200 -> [90, 210] + assert item._min_y == pytest.approx(90.0) + assert item._max_y == pytest.approx(210.0) + + # Narrow the viewport to index 0 only: min=150, max=200, range=50, 10% pad + item.viewStartIndex = 0 + item.viewEndIndex = 0 + + assert item._min_y == pytest.approx(145.0) + assert item._max_y == pytest.approx(205.0) + + +# ---- zoomAtFraction / panByFraction / resetZoom (replay chart interaction) ---- +# 11-point series (indices 0..10) chosen so the zoom/pan arithmetic lands on +# whole numbers -- avoids rounding ambiguity in the independent hand trace. + + +def _eleven_point_item(): + from pygui.backend.visualization.graph_quick_item import GraphQuickItem + + item = GraphQuickItem() + item.seriesData = [[float(i) for i in range(11)]] + return item + + +def test_zoom_in_centers_on_fraction(): + item = _eleven_point_item() + + # Full range is (0, 10), width 10. Zooming to 40% width centered at the + # midpoint (frac=0.5): center=5, new_width=4 -> new_start=3, new_end=7. + item.zoomAtFraction(0.5, 0.4) + + assert item.viewStartIndex == 3 + assert item.viewEndIndex == 7 + + +def test_zoom_out_clamps_to_data_bounds(): + item = _eleven_point_item() + item.viewStartIndex = 3 + item.viewEndIndex = 7 + + # width=4, zoom out 3x centered at midpoint (frac=0.5): requested + # new_width=12 exceeds the data's max width of 10, so it clamps to the + # full range (0, 10). + item.zoomAtFraction(0.5, 3.0) + + assert item.viewStartIndex == 0 + assert item.viewEndIndex == 10 + + +def test_pan_shifts_viewport_by_fraction_of_width(): + item = _eleven_point_item() + item.viewStartIndex = 4 + item.viewEndIndex = 8 + + # width=4, pan by 50% of width (2 indices): (4,8) -> (6,10) + item.panByFraction(0.5) + + assert item.viewStartIndex == 6 + assert item.viewEndIndex == 10 + + +def test_pan_clamps_at_right_edge_preserving_width(): + item = _eleven_point_item() + item.viewStartIndex = 4 + item.viewEndIndex = 8 + + # width=4, pan by 100% of width (4 indices) would be (8,12); index 12 is + # out of bounds (max index 10), so the whole window shifts back by 2 to + # stay in bounds while keeping width=4: (6,10). + item.panByFraction(1.0) + + assert item.viewStartIndex == 6 + assert item.viewEndIndex == 10 + + +def test_reset_zoom_restores_full_range_sentinel(): + item = _eleven_point_item() + item.viewStartIndex = 3 + item.viewEndIndex = 7 + + item.resetZoom() + + assert item.viewStartIndex == 0 + assert item.viewEndIndex == -1 + + +def test_zoom_noop_when_fewer_than_two_points(): + from pygui.backend.visualization.graph_quick_item import GraphQuickItem + + item = GraphQuickItem() + item.seriesData = [[42.0]] + + item.zoomAtFraction(0.5, 0.5) + + assert item.viewStartIndex == 0 + assert item.viewEndIndex == -1 + + +def test_loading_new_series_while_zoomed_resets_viewport_to_full_range(): + """Regression: zooming near the tail of a long run, then loading a new + (shorter) file, left the stale viewport pointing past the new data -- + every per-series slice became empty and the chart went blank with a + stale Y-range and zeroed-out min/max/avg readouts. New data must reset + the viewport to the full range (PopupChartForm.cs's SetData() parity). + """ + from pygui.backend.visualization.graph_quick_item import GraphQuickItem + + item = GraphQuickItem() + item.seriesData = [[float(i) for i in range(20)]] + item.viewStartIndex = 15 + item.viewEndIndex = 19 + + item.seriesData = [[100.0, 101.0, 102.0, 103.0, 104.0]] + + assert item.viewStartIndex == 0 + assert item.viewEndIndex == -1 + assert item.viewMin == pytest.approx(100.0) + assert item.viewMax == pytest.approx(104.0) + assert item.viewAvg == pytest.approx(102.0)