feat: replay chart with zoomable viewpor
This commit is contained in:
@@ -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 {
|
Item {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
visible: transportBar.hasContent
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,3 +23,4 @@ EditableScrubStat 1.0 EditableScrubStat.qml
|
|||||||
ComparisonChartCard 1.0 ComparisonChartCard.qml
|
ComparisonChartCard 1.0 ComparisonChartCard.qml
|
||||||
OverlapMapCard 1.0 OverlapMapCard.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 __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.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen
|
||||||
from PySide6.QtQml import QmlElement
|
from PySide6.QtQml import QmlElement
|
||||||
from PySide6.QtQuick import QQuickPaintedItem
|
from PySide6.QtQuick import QQuickPaintedItem
|
||||||
@@ -32,6 +34,47 @@ QML_IMPORT_NAME = "ISC.Wafer"
|
|||||||
QML_IMPORT_MAJOR_VERSION = 1
|
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
|
@QmlElement
|
||||||
class GraphQuickItem(QQuickPaintedItem):
|
class GraphQuickItem(QQuickPaintedItem):
|
||||||
"""Painted line chart; driven by series data passed via QML property bindings."""
|
"""Painted line chart; driven by series data passed via QML property bindings."""
|
||||||
@@ -42,6 +85,8 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
xLabelChanged = Signal()
|
xLabelChanged = Signal()
|
||||||
yLabelChanged = Signal()
|
yLabelChanged = Signal()
|
||||||
colorsChanged = Signal()
|
colorsChanged = Signal()
|
||||||
|
viewportChanged = Signal()
|
||||||
|
viewStatsChanged = Signal()
|
||||||
|
|
||||||
def __init__(self, parent=None) -> None:
|
def __init__(self, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -74,6 +119,18 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
self._max_y: float = 150.0
|
self._max_y: float = 150.0
|
||||||
self._padding: dict[str, int] = {"left": 60, "right": 20, "top": 30, "bottom": 50}
|
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) ──────────────────────────────────────────
|
# ── Qt properties (QML-bindable) ──────────────────────────────────────────
|
||||||
|
|
||||||
@Property("QVariantList", notify=seriesDataChanged)
|
@Property("QVariantList", notify=seriesDataChanged)
|
||||||
@@ -83,7 +140,16 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
@seriesData.setter # type: ignore[no-redef]
|
@seriesData.setter # type: ignore[no-redef]
|
||||||
def seriesData(self, val: list) -> None:
|
def seriesData(self, val: list) -> None:
|
||||||
self._series_data = [list(s) for s in (val or [])]
|
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._auto_range()
|
||||||
|
self._recompute_view_stats()
|
||||||
self.seriesDataChanged.emit()
|
self.seriesDataChanged.emit()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
@@ -94,9 +160,130 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
@sensorNames.setter # type: ignore[no-redef]
|
@sensorNames.setter # type: ignore[no-redef]
|
||||||
def sensorNames(self, val: list) -> None:
|
def sensorNames(self, val: list) -> None:
|
||||||
self._sensor_names = list(val or [])
|
self._sensor_names = list(val or [])
|
||||||
|
self._recompute_view_stats()
|
||||||
self.sensorNamesChanged.emit()
|
self.sensorNamesChanged.emit()
|
||||||
self.update()
|
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)
|
@Property(str, notify=titleChanged)
|
||||||
def title(self) -> str:
|
def title(self) -> str:
|
||||||
return self._title
|
return self._title
|
||||||
@@ -194,13 +381,38 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
|
|
||||||
# ── internal ──────────────────────────────────────────────────────────────
|
# ── 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:
|
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] = []
|
all_vals: list[float] = []
|
||||||
for series in self._series_data:
|
for series in self._series_data:
|
||||||
for v in series:
|
hi = min(len(series) - 1, end)
|
||||||
|
for i in range(start, hi + 1):
|
||||||
try:
|
try:
|
||||||
all_vals.append(float(v))
|
all_vals.append(float(series[i]))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
if not all_vals:
|
if not all_vals:
|
||||||
@@ -319,18 +531,23 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
self._x_label,
|
self._x_label,
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- X-axis ticks ---
|
# --- Viewport slice (replay chart zoom/pan; full range by default,
|
||||||
num_points = max(len(s) for s in self._series_data) if self._series_data else 0
|
# so unset viewport reproduces the pre-viewport rendering) ---
|
||||||
if num_points > 1:
|
view_start = max(0, self._view_start)
|
||||||
n_x_ticks = min(num_points, max(2, int(plot_w // 80)))
|
view_end = self._resolved_view_end()
|
||||||
x_tick_step = (num_points - 1) / max(1, n_x_ticks - 1)
|
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):
|
for i in range(n_x_ticks):
|
||||||
idx = int(round(i * x_tick_step))
|
rel_idx = int(round(i * x_tick_step))
|
||||||
x_px = index_to_pixel(idx, num_points, plot_left, plot_w)
|
x_px = index_to_pixel(rel_idx, view_len, plot_left, plot_w)
|
||||||
painter.setPen(axis_pen)
|
painter.setPen(axis_pen)
|
||||||
painter.drawText(
|
painter.drawText(
|
||||||
int(x_px - 12), int(plot_top + plot_h + 14),
|
int(x_px - 12), int(plot_top + plot_h + 14),
|
||||||
str(idx + 1),
|
str(view_start + rel_idx + 1),
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Axes boundary ---
|
# --- 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), 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))
|
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 ---
|
# --- Draw each series (sliced to the current viewport) ---
|
||||||
max_len = max(len(s) for s in self._series_data) if self._series_data else 0
|
min_pt: tuple[float, float] | None = None
|
||||||
if max_len < 1:
|
max_pt: tuple[float, float] | None = None
|
||||||
return
|
min_seen = math.inf
|
||||||
|
max_seen = -math.inf
|
||||||
# --- Draw each series ---
|
|
||||||
for si, series in enumerate(self._series_data):
|
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
|
continue
|
||||||
color = self._series_colors[si % len(self._series_colors)]
|
color = self._series_colors[si % len(self._series_colors)]
|
||||||
pen = QPen(color, 2)
|
pen = QPen(color, 2)
|
||||||
painter.setPen(pen)
|
painter.setPen(pen)
|
||||||
|
|
||||||
pts: list[tuple[float, float]] = []
|
pts: list[tuple[float, float]] = []
|
||||||
for i, val in enumerate(series):
|
for i, val in enumerate(sliced):
|
||||||
try:
|
try:
|
||||||
v = float(val)
|
v = float(val)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
continue
|
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)
|
y_px = value_to_pixel(v, self._min_y, self._max_y, plot_top, plot_h)
|
||||||
pts.append((x_px, y_px))
|
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) < 2:
|
||||||
if len(pts) == 1:
|
if len(pts) == 1:
|
||||||
@@ -373,6 +598,19 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
int(pts[i + 1][0]), int(pts[i + 1][1]),
|
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 ---
|
# --- Legend ---
|
||||||
if self._show_legend and self._sensor_names:
|
if self._show_legend and self._sensor_names:
|
||||||
legend_font = QFont()
|
legend_font = QFont()
|
||||||
|
|||||||
@@ -59,3 +59,231 @@ def test_non_numeric_values_are_skipped_not_crashing():
|
|||||||
|
|
||||||
assert item._min_y == pytest.approx(149.9)
|
assert item._min_y == pytest.approx(149.9)
|
||||||
assert item._max_y == pytest.approx(151.1)
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user