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 {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user