generated from hulk/gd32f4xx_template_cmake_vscode
GUI v0.2.13: chart Y-axis range control (right-edge dual-handle slider)
- TemperatureChart gains a right-edge slider on a FIXED -20..120C rail (user-specified): top handle = upper bound, bottom handle = lower bound, drag middle band = pan keeping span, click free rail = snap that bound. First drag engages manual mode (yManualEngaged -> sync button state + config); release quantizes to 0.1C via yRangeChanged, min span 0.5C. Bound values during drag must come from live display range, not the stale paint cache (two drags without repaint made pan jump the range). - Auto/manual toggle moved into the card header (was a wrapped second row); pause/clear merged into header too; legend moved to top-left. - Value labels next to handles only visible on hover/drag (constant labels covered the curve per user feedback). - Out-of-range curves clipped back to the plot area (setClipRect); current-value marker suppressed outside the manual range. - Draft textbox rows from v0.2.10 superseded; versions v0.2.10..v0.2.13. - Fixed "no data yet" hint never disappearing: set_source cached a stale _has_data bool once at build time while data flows through the same list references; now evaluated live at paint time. - config: y_axis key persists mode/bounds; invalid stored values fall back to auto. selftest settings isolated to a temp dir (user json with stored manual state broke the fresh-default assertions). - selftest_gui: slider hit/drag/clamp/pan/clipping regression + hint gating; 83 checks all pass.
This commit is contained in:
+17
-1
@@ -8,7 +8,13 @@ FilmForge 热压模具温控板(GD32F405, RS485 数据口)的桌面上位机
|
|||||||
|
|
||||||
- **实时温度曲线**(自绘 QPainter 控件,无第三方图表依赖):实测温度折线 +
|
- **实时温度曲线**(自绘 QPainter 控件,无第三方图表依赖):实测温度折线 +
|
||||||
目标温度橙色阶梯线同图对照(闭环监视),NTC 开路/短路处曲线断开并打故障标记;
|
目标温度橙色阶梯线同图对照(闭环监视),NTC 开路/短路处曲线断开并打故障标记;
|
||||||
时间窗 2/5/15/60 分钟/全部,暂停/恢复,窗口统计(最小/最大/平均)。
|
时间窗 2/5/15/60 分钟/全部,暂停/恢复,窗口统计(最小/最大/平均);
|
||||||
|
Y 轴(温度轴)量程可选**自动**(随可见数据自适应)或**手动**——直接拖动
|
||||||
|
图表右缘滑条(固定参考域 -20~120℃,把手位置=℃ 读数):上把手=上限、
|
||||||
|
下把手=下限、拖动把手中段上下=整段平移并把跨度保持不变、点击轨道空白直接
|
||||||
|
把对应边界设到该处;首次任意拖拽自动切入手动("Y轴自动/手动"按钮同步),
|
||||||
|
把手数值标签仅悬停/拖拽时显示(不常驻遮挡曲线),曲线越界时裁剪回绘图区;
|
||||||
|
模式与量程记忆在 `filmforge_gui.json`。
|
||||||
- **温度数据**:温度监控统一走固件 M2 周期上报帧(M2S<ms> 开启后主动推送入曲线);
|
- **温度数据**:温度监控统一走固件 M2 周期上报帧(M2S<ms> 开启后主动推送入曲线);
|
||||||
M1 仅作手动单点查询(命令区按钮/预设)。
|
M1 仅作手动单点查询(命令区按钮/预设)。
|
||||||
- **控制面板**:目标温度 M4S<n>(0~200 整数℃,滑条+输入框)、加热 M3S1/M3S0
|
- **控制面板**:目标温度 M4S<n>(0~200 整数℃,滑条+输入框)、加热 M3S1/M3S0
|
||||||
@@ -92,6 +98,16 @@ python publish_gitea.py release/FilmForge-GUI-v<版本>-win64.zip --replace --in
|
|||||||
直接 `setStyleSheet` 会在切主题时被库重刷(DLPilot 同款坑);标题字号走 QFont。
|
直接 `setStyleSheet` 会在切主题时被库重刷(DLPilot 同款坑);标题字号走 QFont。
|
||||||
- 图表刷新双节流:数据到达即重绘(≤10Hz)+ 0.5s 时间轴推进;“全部”窗口超 4000 点
|
- 图表刷新双节流:数据到达即重绘(≤10Hz)+ 0.5s 时间轴推进;“全部”窗口超 4000 点
|
||||||
按步进抽稀(NaN 断口保留)。
|
按步进抽稀(NaN 断口保留)。
|
||||||
|
- **是否有数据由绘制时实时判定**(`paintEvent` 直接看数据列表引用):图表只在
|
||||||
|
构建时调用一次 `set_source`,旧版在这里缓存过 `_has_data` 布尔——数据经引用
|
||||||
|
追加后永不刷新,“暂无数据”提示文字在有数据后仍绘在曲线上(实测截图暴露)。
|
||||||
|
手动 Y 轴模式下曲线/目标线**裁剪回绘图区**(`setClipRect`),当前值标记
|
||||||
|
超出量程时不画(实时读数卡片仍可见)。v0.2.10。
|
||||||
|
- **Y 轴滑条的两套坐标系别混用**:把手像素位置在**参考域**(全量数据范围
|
||||||
|
±15%,`_y_ref`)上映射,与"显示量程"是两回事;拖拽中的界值必须取**实时
|
||||||
|
显示范围**(manual=`_y_manual`,auto=当前自适应值),不能取绘制缓存
|
||||||
|
`_cached_layout` 的陈旧范围——连续两次拖拽之间没有重绘,实测曾把 pan 的
|
||||||
|
量程整段拖跳(v0.2.11)。
|
||||||
|
|
||||||
## 已知事项
|
## 已知事项
|
||||||
|
|
||||||
|
|||||||
+344
-23
@@ -45,8 +45,8 @@ if getattr(sys, "frozen", False) and os.name == "nt":
|
|||||||
except (AttributeError, OSError):
|
except (AttributeError, OSError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
from PySide6.QtCore import QPointF, QRectF, Qt, QTimer
|
from PySide6.QtCore import QPointF, QRectF, Qt, QTimer, Signal
|
||||||
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPainterPath, QPen, QPalette
|
from PySide6.QtGui import QColor, QCursor, QFont, QIcon, QPainter, QPainterPath, QPen, QPalette
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QApplication, QHBoxLayout, QLabel, QMainWindow, QMessageBox, QPlainTextEdit,
|
QApplication, QHBoxLayout, QLabel, QMainWindow, QMessageBox, QPlainTextEdit,
|
||||||
QScrollArea, QSplitter, QVBoxLayout, QWidget,
|
QScrollArea, QSplitter, QVBoxLayout, QWidget,
|
||||||
@@ -60,7 +60,7 @@ import config
|
|||||||
import filmforge_rs485 as ff
|
import filmforge_rs485 as ff
|
||||||
|
|
||||||
APP_NAME = "FilmForge"
|
APP_NAME = "FilmForge"
|
||||||
APP_VERSION = "0.2.9"
|
APP_VERSION = "0.2.13"
|
||||||
REPO_URL = "https://gitea.hulk.wang/hulk/FilmForge"
|
REPO_URL = "https://gitea.hulk.wang/hulk/FilmForge"
|
||||||
MONO = QFont("Consolas", 10)
|
MONO = QFont("Consolas", 10)
|
||||||
CHIP_IDLE = "#666666"
|
CHIP_IDLE = "#666666"
|
||||||
@@ -77,6 +77,13 @@ CHART_COLORS_LIGHT = dict(bg="#ffffff", grid="#e3e8ee", text="#5a6572",
|
|||||||
measured="#0f6cbd", target="#d96f1a", fault=CHIP_ERR)
|
measured="#0f6cbd", target="#d96f1a", fault=CHIP_ERR)
|
||||||
|
|
||||||
X_TICK_STEPS = (1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600)
|
X_TICK_STEPS = (1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600)
|
||||||
|
RIGHT_MARGIN = 12 # 主绘图区右缘留白(px)
|
||||||
|
GUTTER_W = 18 # 右侧 Y 轴滑条栏宽(px)
|
||||||
|
HANDLE_H = 9 # 滑条把手厚(px)
|
||||||
|
HANDLE_HIT = 12 # 把手命中半径(px)
|
||||||
|
MIN_Y_SPAN = 0.5 # 手动 Y 量程最小间距(℃)
|
||||||
|
Y_RAIL_LO = -20.0 # 滑条固定参考域(℃, 用户拍板): 把手位置=℃ 读数
|
||||||
|
Y_RAIL_HI = 120.0 # (覆盖工艺全程 0~120 与冷启动低温, 不随数据漂移)
|
||||||
DANGER_QSS = ("PushButton, PushButton:hover, PushButton:pressed, PushButton:disabled "
|
DANGER_QSS = ("PushButton, PushButton:hover, PushButton:pressed, PushButton:disabled "
|
||||||
"{ background-color: #D9534F; color: white; border: none; }")
|
"{ background-color: #D9534F; color: white; border: none; }")
|
||||||
|
|
||||||
@@ -84,10 +91,16 @@ DANGER_QSS = ("PushButton, PushButton:hover, PushButton:pressed, PushButton:disa
|
|||||||
class TemperatureChart(QWidget):
|
class TemperatureChart(QWidget):
|
||||||
"""自绘滚动温度曲线(QWidget + QPainter, 不引第三方图表库)。
|
"""自绘滚动温度曲线(QWidget + QPainter, 不引第三方图表库)。
|
||||||
|
|
||||||
数据由 App 持有, 经 set_source() 传入引用; 视窗/冻结/配色独立设置。
|
数据由 App 持有, 经 set_source() 传入引用; 视窗/冻结/配色/Y 轴模式独立设置。
|
||||||
曲线语义: 实测=折线(NTC 故障处 NaN 断开+底部红标), 目标=阶梯横线(橙虚线)。
|
曲线语义: 实测=折线(NTC 故障处 NaN 断开+底部红标), 目标=阶梯横线(橙虚线)。
|
||||||
|
Y 轴(auto)=按可见数据自适应; Y 轴(manual)=右缘滑条双把手固定量程
|
||||||
|
(上把手=上限/下把手=下限/拖中带=整段平移, 曲线裁剪回绘图区)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
yManualEngaged = Signal() # 首次拖拽滑条: 从 auto 切入 manual
|
||||||
|
yRangeChanged = Signal(float, float) # 松手: 量化 0.1℃ 后的范围
|
||||||
|
MIN_Y_SPAN = MIN_Y_SPAN # 手动量程最小间距(℃, 供测试断言)
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setMinimumHeight(300)
|
self.setMinimumHeight(300)
|
||||||
@@ -98,13 +111,24 @@ class TemperatureChart(QWidget):
|
|||||||
self._window_sec = config.DEFAULT_WINDOW_SEC
|
self._window_sec = config.DEFAULT_WINDOW_SEC
|
||||||
self._frozen_at = None # None=实时跟随; 否则冻结该时刻
|
self._frozen_at = None # None=实时跟随; 否则冻结该时刻
|
||||||
self._colors = dict(CHART_COLORS_DARK)
|
self._colors = dict(CHART_COLORS_DARK)
|
||||||
self._has_data = False
|
self._y_mode = "auto" # 'auto'=按可见数据自适应; 'manual'=固定范围
|
||||||
|
self._y_manual = None # 手动范围 (ymin, ymax); None=未设
|
||||||
|
self._y_drag = None # 拖拽态: dict(kind/y0/lo/hi/h/ref) 或 None
|
||||||
|
self._drag_cursor = False # 拖拽中 override 光标(保证 release 还原)
|
||||||
|
self._y_hover = False # 鼠标在滑条栏上: 值标签仅此时显示(不常驻遮曲线)
|
||||||
|
self._paint_area = None # 最近绘制的布局缓存: 命中/拖拽复用免重算
|
||||||
|
self._paint_range = (-5.0, 55.0)
|
||||||
|
self._paint_ref = None
|
||||||
|
self.setMouseTracking(True) # 悬停把手换光标(mouseMove 无按键也来)
|
||||||
|
self.last_auto_range = (-5.0, 55.0) # 最近绘制的自适应范围(切手动时预填)
|
||||||
|
|
||||||
# ---- 数据/状态注入 ----
|
# ---- 数据/状态注入 ----
|
||||||
def set_source(self, samples_t, samples_v, target_hist, faults):
|
def set_source(self, samples_t, samples_v, target_hist, faults):
|
||||||
|
"""注入数据列表引用(原地变更可见)。仅在构建时调用一次: 是否有数据
|
||||||
|
必须由绘制时实时判定, 旧版在这里缓存 _has_data 布尔, 数据经引用
|
||||||
|
追加后永不刷新 → "暂无数据"提示文字不消失(实测踩坑)。"""
|
||||||
self._samples_t, self._samples_v = samples_t, samples_v
|
self._samples_t, self._samples_v = samples_t, samples_v
|
||||||
self._target_hist, self._faults = target_hist, faults
|
self._target_hist, self._faults = target_hist, faults
|
||||||
self._has_data = bool(samples_t) or bool(faults)
|
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def set_window(self, sec):
|
def set_window(self, sec):
|
||||||
@@ -119,6 +143,18 @@ class TemperatureChart(QWidget):
|
|||||||
self._colors = dict(colors)
|
self._colors = dict(colors)
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
|
def set_y_mode(self, mode):
|
||||||
|
self._y_mode = mode if mode in ("auto", "manual") else "auto"
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def set_y_manual(self, lo, hi):
|
||||||
|
lo, hi = float(lo), float(hi)
|
||||||
|
if not lo < hi:
|
||||||
|
return False
|
||||||
|
self._y_manual = (lo, hi)
|
||||||
|
self.update()
|
||||||
|
return True
|
||||||
|
|
||||||
# ---- 视窗 ----
|
# ---- 视窗 ----
|
||||||
def _view(self):
|
def _view(self):
|
||||||
t1 = self._frozen_at if self._frozen_at is not None else time.time()
|
t1 = self._frozen_at if self._frozen_at is not None else time.time()
|
||||||
@@ -186,28 +222,249 @@ class TemperatureChart(QWidget):
|
|||||||
return pts
|
return pts
|
||||||
|
|
||||||
# ---- 绘制 ----
|
# ---- 绘制 ----
|
||||||
|
def _layout(self):
|
||||||
|
"""一次算齐布局(转换区/S 轴范围), paint 与滑条命中/拖拽共用, 保证一致"""
|
||||||
|
area = QRectF(self.rect()).adjusted(52, 10, -(RIGHT_MARGIN + GUTTER_W), -26)
|
||||||
|
t0, t1 = self._view()
|
||||||
|
vis_t, vis_v = self._visible(t0, t1)
|
||||||
|
tgt = self._target_points(t0, t1)
|
||||||
|
ymin, ymax = self._resolve_y_range(vis_v, tgt)
|
||||||
|
return area, t0, t1, vis_t, vis_v, tgt, ymin, ymax
|
||||||
|
|
||||||
def paintEvent(self, _event):
|
def paintEvent(self, _event):
|
||||||
p = QPainter(self)
|
p = QPainter(self)
|
||||||
c = self._colors
|
c = self._colors
|
||||||
p.fillRect(self.rect(), QColor(c["bg"]))
|
p.fillRect(self.rect(), QColor(c["bg"]))
|
||||||
area = QRectF(self.rect()).adjusted(52, 10, -12, -26)
|
area, t0, t1, vis_t, vis_v, tgt, ymin, ymax = self._layout()
|
||||||
t0, t1 = self._view()
|
self._paint_area = area
|
||||||
vis_t, vis_v = self._visible(t0, t1)
|
self._paint_range = (ymin, ymax)
|
||||||
tgt = self._target_points(t0, t1)
|
self._paint_ref = self._y_ref()
|
||||||
ymin, ymax = self._y_range(vis_v, tgt)
|
|
||||||
sy = self._scale_y(area, ymin, ymax)
|
sy = self._scale_y(area, ymin, ymax)
|
||||||
sx = self._scale_x(area, t0, t1)
|
sx = self._scale_x(area, t0, t1)
|
||||||
|
|
||||||
self._draw_grid(p, area, t0, t1, ymin, ymax, sx, sy)
|
self._draw_grid(p, area, t0, t1, ymin, ymax, sx, sy)
|
||||||
|
p.save()
|
||||||
|
p.setClipRect(area) # 手动范围下数据可越界, 裁剪回绘图区
|
||||||
self._draw_target(p, tgt, sx, sy)
|
self._draw_target(p, tgt, sx, sy)
|
||||||
self._draw_measured(p, vis_t, vis_v, sx, sy)
|
self._draw_measured(p, vis_t, vis_v, sx, sy)
|
||||||
|
p.restore()
|
||||||
|
self._draw_marker(p, area, vis_t, vis_v, sx, sy)
|
||||||
self._draw_faults(p, area, t0, t1)
|
self._draw_faults(p, area, t0, t1)
|
||||||
self._draw_legend(p)
|
self._draw_y_gutter(p, area, ymin, ymax)
|
||||||
if not self._has_data:
|
self._draw_legend(p, area)
|
||||||
|
if not (self._samples_t or self._faults): # 实时判定: 数据到位即消失
|
||||||
p.setPen(QColor(c["text"]))
|
p.setPen(QColor(c["text"]))
|
||||||
p.drawText(self.rect(), Qt.AlignCenter,
|
p.drawText(self.rect(), Qt.AlignCenter,
|
||||||
"暂无数据 — 连接设备后开启周期上报(M2S<ms>)即可实时推送; 单点查询用 M1")
|
"暂无数据 — 连接设备后开启周期上报(M2S<ms>)即可实时推送; 单点查询用 M1")
|
||||||
|
|
||||||
|
def _resolve_y_range(self, vis_v, tgt_pts):
|
||||||
|
"""本次绘制使用的 Y 轴范围: manual 模式用固定值, 否则按可见数据自适应;
|
||||||
|
空数据时自适应值是缺省 (-5,55), 不得覆盖 last_auto_range(切手动预填用)"""
|
||||||
|
auto = self._y_range(vis_v, tgt_pts)
|
||||||
|
if self._y_mode == "manual" and self._y_manual is not None:
|
||||||
|
return self._y_manual
|
||||||
|
pts = tgt_pts or []
|
||||||
|
if [v for v in vis_v if v == v] or [v for _t, v in pts]:
|
||||||
|
self.last_auto_range = auto
|
||||||
|
return auto
|
||||||
|
|
||||||
|
# ---- 右缘 Y 轴滑条(手动量程) ----
|
||||||
|
def _y_ref(self):
|
||||||
|
"""滑条固定参考域(用户拍板 -20~120℃): 把手像素位置直接对应 ℃ 读数,
|
||||||
|
空数据/冷机不再出现随数据漂移的误导位置"""
|
||||||
|
return Y_RAIL_LO, Y_RAIL_HI
|
||||||
|
|
||||||
|
def _gutter(self):
|
||||||
|
w = self.width()
|
||||||
|
return w - RIGHT_MARGIN - GUTTER_W, w - RIGHT_MARGIN
|
||||||
|
|
||||||
|
def _cached_layout(self):
|
||||||
|
"""命中/拖拽用绘制缓存; 从未绘制过(如测试直接调)则补算一次"""
|
||||||
|
if self._paint_area is None:
|
||||||
|
area, _t0, _t1, _vt, _vv, _tgt, ymin, ymax = self._layout()
|
||||||
|
self._paint_area = area
|
||||||
|
self._paint_range = (ymin, ymax)
|
||||||
|
self._paint_ref = self._y_ref()
|
||||||
|
return self._paint_area, self._paint_range, self._paint_ref
|
||||||
|
|
||||||
|
def _rail_px(self, area, ref, v):
|
||||||
|
ref_lo, ref_hi = ref
|
||||||
|
v = min(max(v, ref_lo), ref_hi)
|
||||||
|
return area.bottom() - (v - ref_lo) / max(ref_hi - ref_lo, 1e-9) * area.height()
|
||||||
|
|
||||||
|
def _rail_value(self, area, ref, y):
|
||||||
|
ref_lo, ref_hi = ref
|
||||||
|
frac = min(max((area.bottom() - y) / max(area.height(), 1e-6), 0.0), 1.0)
|
||||||
|
return ref_lo + frac * (ref_hi - ref_lo)
|
||||||
|
|
||||||
|
def _gutter_hit(self, x, y):
|
||||||
|
"""右栏命中: 就近把手优先, 轨道空白区视为就近边界点击, 上下把手间为整段平移"""
|
||||||
|
x0, x1 = self._gutter()
|
||||||
|
if not x0 - 3 <= x <= x1 + 3:
|
||||||
|
return None
|
||||||
|
area, (lo, hi), ref = self._cached_layout()
|
||||||
|
y_hi = self._rail_px(area, ref, hi)
|
||||||
|
y_lo = self._rail_px(area, ref, lo)
|
||||||
|
d_hi, d_lo = abs(y - y_hi), abs(y - y_lo)
|
||||||
|
if min(d_hi, d_lo) <= HANDLE_HIT:
|
||||||
|
return "max" if d_hi <= d_lo else "min"
|
||||||
|
if y_hi <= y <= y_lo:
|
||||||
|
return "win"
|
||||||
|
return "max" if y < y_hi else "min"
|
||||||
|
|
||||||
|
def start_y_drag(self, kind, y):
|
||||||
|
"""按下滑条(测试可直接调): auto 模式以当前显示范围起坡并切手动。
|
||||||
|
几何(绘图区/参考域)可用绘制缓存, 界值必须取实时显示值——拖拽间存在
|
||||||
|
未重绘的域间修改(实测: pan 用陈旧界值把量程拖跳)。
|
||||||
|
kind: 'min'(下把手)=下限 / 'max'(上把手)=上限 / 'win'=整段平移"""
|
||||||
|
area, rng, ref = self._cached_layout()
|
||||||
|
if self._y_mode != "manual":
|
||||||
|
lo, hi = rng
|
||||||
|
self._y_manual = (lo, hi)
|
||||||
|
self._y_mode = "manual"
|
||||||
|
self.yManualEngaged.emit()
|
||||||
|
else:
|
||||||
|
lo, hi = self._y_manual
|
||||||
|
self._y_drag = {"kind": kind, "y0": y, "lo": lo, "hi": hi,
|
||||||
|
"h": area.height(), "ref": ref}
|
||||||
|
if not self._drag_cursor:
|
||||||
|
QApplication.setOverrideCursor(QCursor(Qt.SizeVerCursor))
|
||||||
|
self._drag_cursor = True
|
||||||
|
self.update()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def update_y_drag(self, y):
|
||||||
|
"""拖拽中: 手把直接跟鼠标(参考域映射+钳位), 中带按参考跨度平移"""
|
||||||
|
d = self._y_drag
|
||||||
|
if d is None:
|
||||||
|
return False
|
||||||
|
area, _rng, _ref_unused = self._cached_layout()
|
||||||
|
ref = d["ref"]
|
||||||
|
ref_span = max(ref[1] - ref[0], 1e-9)
|
||||||
|
h = max(d["h"], 1e-6)
|
||||||
|
lo, hi = d["lo"], d["hi"]
|
||||||
|
if d["kind"] == "max":
|
||||||
|
hi = max(self._rail_value(area, ref, y), lo + MIN_Y_SPAN)
|
||||||
|
elif d["kind"] == "min":
|
||||||
|
lo = min(self._rail_value(area, ref, y), hi - MIN_Y_SPAN)
|
||||||
|
else:
|
||||||
|
delta = -(y - d["y0"]) / h * ref_span
|
||||||
|
delta = min(max(delta, ref[0] - lo), ref[1] - hi)
|
||||||
|
lo, hi = lo + delta, hi + delta
|
||||||
|
self._y_manual = (lo, hi)
|
||||||
|
self.update()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def end_y_drag(self):
|
||||||
|
"""松手: 量化 0.1℃ 并通知(防御四舍五入挤掉间距)"""
|
||||||
|
if self._y_drag is None:
|
||||||
|
return False
|
||||||
|
lo, hi = self._y_manual
|
||||||
|
lo, hi = round(lo, 1), round(hi + 1e-9, 1)
|
||||||
|
if not lo < hi:
|
||||||
|
hi = lo + 0.1
|
||||||
|
self._y_manual = (lo, hi)
|
||||||
|
self._y_drag = None
|
||||||
|
self.update()
|
||||||
|
self.yRangeChanged.emit(lo, hi)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def mousePressEvent(self, event):
|
||||||
|
pos = event.position()
|
||||||
|
kind = self._gutter_hit(pos.x(), pos.y())
|
||||||
|
if kind is not None:
|
||||||
|
self.start_y_drag(kind, pos.y())
|
||||||
|
event.accept()
|
||||||
|
else:
|
||||||
|
event.ignore()
|
||||||
|
|
||||||
|
def mouseMoveEvent(self, event):
|
||||||
|
pos = event.position()
|
||||||
|
if self._y_drag is not None:
|
||||||
|
self.update_y_drag(pos.y())
|
||||||
|
self._set_y_hover(True)
|
||||||
|
return
|
||||||
|
hit = self._gutter_hit(pos.x(), pos.y()) is not None
|
||||||
|
self._set_y_hover(hit) # 值标签仅悬停/拖拽时显示
|
||||||
|
if hit:
|
||||||
|
self.setCursor(Qt.SizeVerCursor)
|
||||||
|
else:
|
||||||
|
self.unsetCursor()
|
||||||
|
|
||||||
|
def mouseReleaseEvent(self, event):
|
||||||
|
pos = event.position()
|
||||||
|
if self._y_drag is not None:
|
||||||
|
self.end_y_drag()
|
||||||
|
if self._drag_cursor:
|
||||||
|
QApplication.restoreOverrideCursor()
|
||||||
|
self._drag_cursor = False
|
||||||
|
self.update_y_drag_hover(pos)
|
||||||
|
event.ignore()
|
||||||
|
|
||||||
|
def update_y_drag_hover(self, pos):
|
||||||
|
hit = self._gutter_hit(pos.x(), pos.y()) is not None
|
||||||
|
self._set_y_hover(hit)
|
||||||
|
if hit:
|
||||||
|
self.setCursor(Qt.SizeVerCursor)
|
||||||
|
else:
|
||||||
|
self.unsetCursor()
|
||||||
|
|
||||||
|
def _set_y_hover(self, on):
|
||||||
|
"""滑条悬停态翻转才重绘(悬停/拖拽期间才画把手值标签)"""
|
||||||
|
if self._y_hover == on:
|
||||||
|
return
|
||||||
|
self._y_hover = on
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def leaveEvent(self, _event):
|
||||||
|
if self._y_drag is None:
|
||||||
|
self.unsetCursor()
|
||||||
|
self._set_y_hover(False) # 悬停态途中拖拽不退出(松手后按位置重算)
|
||||||
|
|
||||||
|
def _draw_y_gutter(self, p, area, ymin, ymax):
|
||||||
|
"""右缘滑条栏: 轨道 + 手动量程带 + 双把手。值标签仅鼠标悬停/拖拽时显示
|
||||||
|
(常驻会遮挡曲线, 用户标注); auto 模式把手半透明——直接按住即可切手动。"""
|
||||||
|
c = self._colors
|
||||||
|
accent = QColor(c["measured"])
|
||||||
|
manual = self._y_mode == "manual"
|
||||||
|
show_pills = self._y_hover or self._y_drag is not None
|
||||||
|
ref = self._y_ref()
|
||||||
|
x0, x1 = self._gutter()
|
||||||
|
x_c = (x0 + x1) / 2
|
||||||
|
y_hi = self._rail_px(area, ref, ymax)
|
||||||
|
y_lo = self._rail_px(area, ref, ymin)
|
||||||
|
|
||||||
|
p.setPen(QPen(QColor(c["grid"]), 1))
|
||||||
|
p.drawLine(QPointF(x_c, area.top()), QPointF(x_c, area.bottom()))
|
||||||
|
if manual and y_hi + 2 < y_lo:
|
||||||
|
band = QColor(accent)
|
||||||
|
band.setAlpha(38)
|
||||||
|
p.setPen(Qt.NoPen)
|
||||||
|
p.setBrush(band)
|
||||||
|
p.drawRect(QRectF(x_c - 2, y_hi, 4, y_lo - y_hi))
|
||||||
|
|
||||||
|
for y_px, label in ((y_hi, f"{ymax:.1f}"), (y_lo, f"{ymin:.1f}")):
|
||||||
|
bar = QColor(accent)
|
||||||
|
if not manual:
|
||||||
|
bar.setAlpha(110)
|
||||||
|
p.setPen(Qt.NoPen)
|
||||||
|
p.setBrush(bar)
|
||||||
|
p.drawRoundedRect(QRectF(x_c - 7, y_px - HANDLE_H / 2, 14, HANDLE_H), 3, 3)
|
||||||
|
p.setPen(QPen(QColor(c["bg"]), 1))
|
||||||
|
for dy in (-1.5, 1.5):
|
||||||
|
p.drawLine(QPointF(x_c - 4, y_px + dy), QPointF(x_c + 4, y_px + dy))
|
||||||
|
if not show_pills:
|
||||||
|
continue
|
||||||
|
pill = QRectF(area.right() - 41, y_px - 8, 38, 16)
|
||||||
|
p.setBrush(QColor(c["bg"]))
|
||||||
|
p.setPen(QPen(QColor(c["grid"]), 1))
|
||||||
|
p.drawRoundedRect(pill, 4, 4)
|
||||||
|
font = self.font()
|
||||||
|
font.setPointSizeF(7.5)
|
||||||
|
p.setFont(font)
|
||||||
|
p.setPen(QColor(c["measured"]))
|
||||||
|
p.drawText(pill, Qt.AlignCenter, label)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _y_range(vis_v, tgt_pts):
|
def _y_range(vis_v, tgt_pts):
|
||||||
vals = [v for v in vis_v if v == v] + [v for _t, v in tgt_pts]
|
vals = [v for v in vis_v if v == v] + [v for _t, v in tgt_pts]
|
||||||
@@ -312,13 +569,18 @@ class TemperatureChart(QWidget):
|
|||||||
path.lineTo(x, y)
|
path.lineTo(x, y)
|
||||||
p.drawPath(path)
|
p.drawPath(path)
|
||||||
|
|
||||||
last_t = last_v = None # 当前值标记: 视窗内最后一个有效样本
|
def _draw_marker(self, p, area, ts, vs, sx, sy):
|
||||||
|
"""当前值标记: 视窗内最后一个有效样本。超出当前 Y 范围(手动模式)不画。"""
|
||||||
|
last_t = last_v = None
|
||||||
for t, v in zip(reversed(ts), reversed(vs)):
|
for t, v in zip(reversed(ts), reversed(vs)):
|
||||||
if v == v:
|
if v == v:
|
||||||
last_t, last_v = t, v
|
last_t, last_v = t, v
|
||||||
break
|
break
|
||||||
if last_t is not None:
|
if last_t is None:
|
||||||
|
return
|
||||||
x, y = sx(last_t), sy(last_v)
|
x, y = sx(last_t), sy(last_v)
|
||||||
|
if not area.top() <= y <= area.bottom():
|
||||||
|
return
|
||||||
p.setPen(Qt.NoPen)
|
p.setPen(Qt.NoPen)
|
||||||
p.setBrush(QColor(self._colors["measured"]))
|
p.setBrush(QColor(self._colors["measured"]))
|
||||||
p.drawEllipse(QPointF(x, y), 3.2, 3.2)
|
p.drawEllipse(QPointF(x, y), 3.2, 3.2)
|
||||||
@@ -327,7 +589,10 @@ class TemperatureChart(QWidget):
|
|||||||
font.setPointSizeF(8.5)
|
font.setPointSizeF(8.5)
|
||||||
font.setBold(True)
|
font.setBold(True)
|
||||||
p.setFont(font)
|
p.setFont(font)
|
||||||
p.drawText(QPointF(min(x + 6, self.width() - 54), max(y - 6, 14)),
|
# 文字通常画在点上方; 贴顶边放不下时换到点下方, 再钳回绘图区内
|
||||||
|
ty = y - 6 if y - 14 >= area.top() else y + 12
|
||||||
|
ty = min(max(ty, area.top() + 9), area.bottom() - 2)
|
||||||
|
p.drawText(QPointF(min(x + 6, area.right() - 44), ty),
|
||||||
f"{last_v:.1f}℃")
|
f"{last_v:.1f}℃")
|
||||||
|
|
||||||
def _draw_faults(self, p, area, t0, t1):
|
def _draw_faults(self, p, area, t0, t1):
|
||||||
@@ -349,12 +614,13 @@ class TemperatureChart(QWidget):
|
|||||||
last_x = x
|
last_x = x
|
||||||
labeled += 1
|
labeled += 1
|
||||||
|
|
||||||
def _draw_legend(self, p):
|
def _draw_legend(self, p, area):
|
||||||
c = self._colors
|
c = self._colors
|
||||||
font = self.font()
|
font = self.font()
|
||||||
font.setPointSizeF(8.5)
|
font.setPointSizeF(8.5)
|
||||||
p.setFont(font)
|
p.setFont(font)
|
||||||
x = self.width() - 150
|
# 图例放绘图区左上: 右侧被滑条把手/值标签占用(用户标注)
|
||||||
|
x = area.left() + 6
|
||||||
y = 16
|
y = 16
|
||||||
for color, label in ((c["measured"], "实测"), (c["target"], "目标")):
|
for color, label in ((c["measured"], "实测"), (c["target"], "目标")):
|
||||||
p.setPen(Qt.NoPen)
|
p.setPen(Qt.NoPen)
|
||||||
@@ -550,15 +816,36 @@ class App(QMainWindow):
|
|||||||
win_combo.setCurrentIndex(1) # 先设默认再连信号, 避免触发时 chart 未建
|
win_combo.setCurrentIndex(1) # 先设默认再连信号, 避免触发时 chart 未建
|
||||||
win_combo.currentIndexChanged.connect(self._on_window)
|
win_combo.currentIndexChanged.connect(self._on_window)
|
||||||
self.cbo_window = win_combo
|
self.cbo_window = win_combo
|
||||||
row = QHBoxLayout()
|
|
||||||
|
card = CardWidget(self)
|
||||||
|
body = QVBoxLayout(card)
|
||||||
|
body.setContentsMargins(14, 12, 14, 14)
|
||||||
|
body.setSpacing(8)
|
||||||
|
# ---- 标题行 = [标题][暂停][清空] ... [Y轴手动][窗口] 一行放齐:
|
||||||
|
# 暂停/清空并入标题行、Y 轴按钮不再单独换行(用户标注: 两个按钮组都
|
||||||
|
# 悬在半空布局脱节)。不走 self._card 的通用头, 需要多 trailing 控件。
|
||||||
|
header = QHBoxLayout()
|
||||||
|
header.addWidget(StrongBodyLabel("实时温度曲线", card))
|
||||||
|
header.addSpacing(8)
|
||||||
self.btn_pause = self._button("暂停", self._toggle_pause)
|
self.btn_pause = self._button("暂停", self._toggle_pause)
|
||||||
|
header.addWidget(self.btn_pause)
|
||||||
self.btn_clear = self._button("清空", self._clear_plot)
|
self.btn_clear = self._button("清空", self._clear_plot)
|
||||||
row.addWidget(self.btn_pause)
|
header.addWidget(self.btn_clear)
|
||||||
row.addWidget(self.btn_clear)
|
header.addStretch(1)
|
||||||
card, body = self._card("实时温度曲线", trailing=win_combo)
|
# ---- Y 轴(温度轴)量程: 自动适配 / 图表右缘滑条手动拖拽 ----
|
||||||
body.addLayout(row)
|
self.btn_ymode = PushButton("Y轴自动", card)
|
||||||
|
self.btn_ymode.setCheckable(True)
|
||||||
|
self.btn_ymode.setChecked(True) # 默认自动; 先就位默认态再连信号
|
||||||
|
self.btn_ymode.setToolTip("勾选=随曲线数据自适应量程; 取消勾选或直接拖动"
|
||||||
|
"图表右缘滑条=固定量程, 再勾选恢复自动")
|
||||||
|
self.btn_ymode.toggled.connect(self._on_ymode)
|
||||||
|
header.addWidget(self.btn_ymode)
|
||||||
|
header.addWidget(win_combo)
|
||||||
|
body.addLayout(header)
|
||||||
self.chart = TemperatureChart(card)
|
self.chart = TemperatureChart(card)
|
||||||
self.chart.set_source(self.samples_t, self.samples_v, self.target_hist, self.faults)
|
self.chart.set_source(self.samples_t, self.samples_v, self.target_hist, self.faults)
|
||||||
|
self.chart.yManualEngaged.connect(self._on_y_drag_engaged)
|
||||||
|
self.chart.yRangeChanged.connect(self._on_y_range_changed)
|
||||||
body.addWidget(self.chart, 1)
|
body.addWidget(self.chart, 1)
|
||||||
layout.addWidget(card, 2)
|
layout.addWidget(card, 2)
|
||||||
|
|
||||||
@@ -719,6 +1006,17 @@ class App(QMainWindow):
|
|||||||
self.cbo_window.setCurrentIndex(widx)
|
self.cbo_window.setCurrentIndex(widx)
|
||||||
self.chart.set_window(config.WINDOW_SEC)
|
self.chart.set_window(config.WINDOW_SEC)
|
||||||
self.cbo_theme.setCurrentText(config.THEME)
|
self.cbo_theme.setCurrentText(config.THEME)
|
||||||
|
# ---- Y 轴量程记忆恢复: 存有合法手动值且上次为手动时才复用, 否则回自动 ----
|
||||||
|
stored = (config.Y_MIN is not None and config.Y_MAX is not None
|
||||||
|
and config.Y_MIN < config.Y_MAX)
|
||||||
|
manual = stored and config.Y_MODE == "manual"
|
||||||
|
self.btn_ymode.blockSignals(True)
|
||||||
|
self.btn_ymode.setChecked(not manual)
|
||||||
|
self.btn_ymode.blockSignals(False)
|
||||||
|
self.btn_ymode.setText("Y轴手动" if manual else "Y轴自动")
|
||||||
|
if manual:
|
||||||
|
self.chart.set_y_manual(config.Y_MIN, config.Y_MAX)
|
||||||
|
self.chart.set_y_mode("manual")
|
||||||
|
|
||||||
# ================= 主题 =================
|
# ================= 主题 =================
|
||||||
def _set_theme(self, value):
|
def _set_theme(self, value):
|
||||||
@@ -1233,6 +1531,29 @@ class App(QMainWindow):
|
|||||||
config.save_settings()
|
config.save_settings()
|
||||||
self.chart.set_window(sec)
|
self.chart.set_window(sec)
|
||||||
|
|
||||||
|
# ---- Y 轴(温度轴)量程 ----
|
||||||
|
def _on_ymode(self, checked):
|
||||||
|
"""勾选=随数据自适应量程; 取消勾选=固定量程(右缘滑条拖拽)"""
|
||||||
|
manual = not checked
|
||||||
|
self.chart.set_y_mode("manual" if manual else "auto")
|
||||||
|
self.btn_ymode.setText("Y轴手动" if manual else "Y轴自动")
|
||||||
|
config.Y_MODE = "manual" if manual else "auto"
|
||||||
|
config.save_settings()
|
||||||
|
|
||||||
|
def _on_y_drag_engaged(self):
|
||||||
|
"""滑条首次拖拽: 图表内部已切手动, 这里同步按钮态并落设置"""
|
||||||
|
self.btn_ymode.blockSignals(True)
|
||||||
|
self.btn_ymode.setChecked(False)
|
||||||
|
self.btn_ymode.blockSignals(False)
|
||||||
|
self.btn_ymode.setText("Y轴手动")
|
||||||
|
config.Y_MODE = "manual"
|
||||||
|
config.save_settings()
|
||||||
|
|
||||||
|
def _on_y_range_changed(self, lo, hi):
|
||||||
|
"""滑条松手: 图表已量化 0.1℃, 直接范围记忆"""
|
||||||
|
config.Y_MIN, config.Y_MAX = lo, hi
|
||||||
|
config.save_settings()
|
||||||
|
|
||||||
def _toggle_pause(self):
|
def _toggle_pause(self):
|
||||||
if self._frozen_at is None:
|
if self._frozen_at is None:
|
||||||
self._frozen_at = time.time()
|
self._frozen_at = time.time()
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ SERIAL_CFG = {'port': '', 'baud': filmforge_rs485.DEFAULT_BAUD,
|
|||||||
REPORT_MS = 500 # 上报间隔输入框默认值
|
REPORT_MS = 500 # 上报间隔输入框默认值
|
||||||
WINDOW_SEC = DEFAULT_WINDOW_SEC
|
WINDOW_SEC = DEFAULT_WINDOW_SEC
|
||||||
THEME = 'System'
|
THEME = 'System'
|
||||||
|
Y_MODE = 'auto' # 曲线 Y 轴(温度轴): 'auto'=自适应 / 'manual'=固定范围
|
||||||
|
Y_MIN = None # 手动 Y 轴上下限(℃), 仅 Y_MODE='manual' 且合法时生效
|
||||||
|
Y_MAX = None
|
||||||
|
|
||||||
|
|
||||||
def load_settings():
|
def load_settings():
|
||||||
@@ -68,6 +71,19 @@ def load_settings():
|
|||||||
theme = str(data.get('theme') or '')
|
theme = str(data.get('theme') or '')
|
||||||
if theme in ('Dark', 'Light', 'System'):
|
if theme in ('Dark', 'Light', 'System'):
|
||||||
THEME = theme
|
THEME = theme
|
||||||
|
ax = data.get('y_axis')
|
||||||
|
if isinstance(ax, dict):
|
||||||
|
mode = str(ax.get('mode') or '')
|
||||||
|
if mode in ('auto', 'manual'):
|
||||||
|
globals()['Y_MODE'] = mode
|
||||||
|
try:
|
||||||
|
globals()['Y_MIN'] = round(float(ax.get('min')), 2)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
globals()['Y_MAX'] = round(float(ax.get('max')), 2)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def save_settings():
|
def save_settings():
|
||||||
@@ -77,6 +93,7 @@ def save_settings():
|
|||||||
'report_ms': REPORT_MS,
|
'report_ms': REPORT_MS,
|
||||||
'window_sec': WINDOW_SEC,
|
'window_sec': WINDOW_SEC,
|
||||||
'theme': THEME,
|
'theme': THEME,
|
||||||
|
'y_axis': {'mode': Y_MODE, 'min': Y_MIN, 'max': Y_MAX},
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
with open(SETTINGS_FILE, 'w', encoding='utf-8') as f:
|
with open(SETTINGS_FILE, 'w', encoding='utf-8') as f:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import math
|
|||||||
import os
|
import os
|
||||||
from collections import deque
|
from collections import deque
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
@@ -15,9 +16,16 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
import filmforge_rs485 as ff
|
import filmforge_rs485 as ff
|
||||||
|
import config
|
||||||
|
from PySide6.QtCore import QRectF
|
||||||
from PySide6.QtGui import QImage, QPainter
|
from PySide6.QtGui import QImage, QPainter
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
# 设置文件指向临时目录: 隔离用户真实 filmforge_gui.json(用户可能展存着手动
|
||||||
|
# Y 轴等状态), 测试一律从"首次启动默认"出发, 残留写入不留真实配置盘
|
||||||
|
_tmpdir = tempfile.mkdtemp(prefix='filmforge_selftest_')
|
||||||
|
config.SETTINGS_FILE = os.path.join(_tmpdir, 'settings.json')
|
||||||
|
|
||||||
fails = []
|
fails = []
|
||||||
|
|
||||||
|
|
||||||
@@ -73,6 +81,18 @@ def count_pixels(img, rgb, tol=30):
|
|||||||
return n
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def count_in(img, x0, x1, y0, y1, rgb, tol=30):
|
||||||
|
"""区域内逐像素计数(stride 1): 用于条带级断言(如越界裁剪)与空态提示"""
|
||||||
|
n = 0
|
||||||
|
for y in range(y0, y1):
|
||||||
|
for x in range(x0, x1):
|
||||||
|
c = img.pixelColor(x, y)
|
||||||
|
if (abs(c.red() - rgb[0]) < tol and abs(c.green() - rgb[1]) < tol
|
||||||
|
and abs(c.blue() - rgb[2]) < tol):
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
measured = (0, 200, 215) # Dark 主题实测曲线色 #00c8d7
|
measured = (0, 200, 215) # Dark 主题实测曲线色 #00c8d7
|
||||||
target = (232, 131, 58) # 目标线 #E8833A
|
target = (232, 131, 58) # 目标线 #E8833A
|
||||||
fault = (217, 83, 79) # 故障 #D9534F
|
fault = (217, 83, 79) # 故障 #D9534F
|
||||||
@@ -279,5 +299,126 @@ ts, _vs = c._visible(0, 99)
|
|||||||
check('实时切片完整', ts, [1.0, 2.0, 3.0, 4.0])
|
check('实时切片完整', ts, [1.0, 2.0, 3.0, 4.0])
|
||||||
c._samples_t, c._samples_v = window.samples_t, window.samples_v
|
c._samples_t, c._samples_v = window.samples_t, window.samples_v
|
||||||
|
|
||||||
|
# 8) v0.2.11 回归: 右缘双把手滑条拖拽量程 + 空态提示实时消失。
|
||||||
|
# (提示不消失根因: 旧版 set_source 构建期缓存 _has_data 布尔, 数据经引用追加
|
||||||
|
# 后永不刷新 → 改绘制时实时判定; 本段同时覆盖滑条命中/拖拽/钳位/平移/裁剪)
|
||||||
|
c = window.chart
|
||||||
|
check('Y 轴初始自动', c._y_mode, 'auto')
|
||||||
|
check('Y 轴按钮默认自动', window.btn_ymode.isChecked()
|
||||||
|
and window.btn_ymode.text() == 'Y轴自动', True)
|
||||||
|
|
||||||
|
engaged, ranges = [], []
|
||||||
|
c.yManualEngaged.connect(lambda: engaged.append(1))
|
||||||
|
c.yRangeChanged.connect(lambda lo, hi: ranges.append((lo, hi)))
|
||||||
|
|
||||||
|
c.update()
|
||||||
|
app.processEvents() # 同步一次绘制: 命中缓存与当前数据一致
|
||||||
|
area, _t0, _t1, _vt, _vv, _tgt, ymin0, ymax0 = c._layout()
|
||||||
|
gx0, gx1 = c._gutter()
|
||||||
|
ref0 = c._y_ref()
|
||||||
|
y_hi_px = c._rail_px(area, ref0, ymax0)
|
||||||
|
y_lo_px = c._rail_px(area, ref0, ymin0)
|
||||||
|
check('把手命中(上把手)', c._gutter_hit(gx0 + 6, y_hi_px), 'max')
|
||||||
|
check('把手命中(下把手)', c._gutter_hit(gx0 + 6, y_lo_px), 'min')
|
||||||
|
check('轨道空白亦视为边界', c._gutter_hit(gx0 + 6, area.bottom() - 3), 'min')
|
||||||
|
check('栏外不命中', c._gutter_hit(10, 100) is None, True)
|
||||||
|
|
||||||
|
c.start_y_drag('min', y_lo_px)
|
||||||
|
check('拖拽即切手动', c._y_mode, 'manual')
|
||||||
|
check('手动信号已发', engaged, [1])
|
||||||
|
# 滑条域固定 -20~120 后拖拽映射恒定(0.53C/px 量级), 断言用目标值反算像素,
|
||||||
|
# 不再依赖域宽: 拖到 ymin0+30 对应位置 → 下限抬升 30℃
|
||||||
|
c.update_y_drag(c._rail_px(area, ref0, ymin0 + 30.0))
|
||||||
|
lo1, hi1 = c._y_manual
|
||||||
|
check('下限随拖拽抬升', lo1 > ymin0 + 1e-9, True)
|
||||||
|
check('上限不受牵动', abs(hi1 - ymax0) < 1e-9, True)
|
||||||
|
check('松手生效', c.end_y_drag(), True)
|
||||||
|
lo_e, hi_e = ranges[-1]
|
||||||
|
check('范围量化 0.1', abs(lo_e * 10 - round(lo_e * 10)) < 1e-6
|
||||||
|
and abs(hi_e * 10 - round(hi_e * 10)) < 1e-6, True)
|
||||||
|
|
||||||
|
c.start_y_drag('min', y_lo_px) # 极端上拉 → 钳到 上限-最小间距
|
||||||
|
c.update_y_drag(area.top() - 500)
|
||||||
|
check('最小间距约束',
|
||||||
|
c._y_manual[1] - c._y_manual[0] >= c.MIN_Y_SPAN - 1e-9, True)
|
||||||
|
c.end_y_drag()
|
||||||
|
|
||||||
|
lo_prev, hi_prev = c._y_manual[0], c._y_manual[1] # 中带平移: 整段同移
|
||||||
|
c.start_y_drag('win', (y_hi_px + y_lo_px) / 2)
|
||||||
|
c.update_y_drag(area.bottom() + 30)
|
||||||
|
c.end_y_drag()
|
||||||
|
lo_pan, hi_pan = c._y_manual
|
||||||
|
# 松手量化 0.1℃ 各自取整, 跨度允许 ±0.05 偏差(两段半界)
|
||||||
|
check('平移不改跨度(量化容差)', abs((hi_pan - lo_pan) - (hi_prev - lo_prev)) < 0.12, True)
|
||||||
|
check('平移已生效', lo_pan < lo_prev - 1e-9, True)
|
||||||
|
check('上下限同步移动', abs((lo_prev - lo_pan) - (hi_prev - hi_pan)) < 0.12, True)
|
||||||
|
check('范围通知已发 3 次', len(ranges), 3)
|
||||||
|
|
||||||
|
window.btn_ymode.setChecked(True) # 按钮勾回自动
|
||||||
|
check('切回自动', c._y_mode, 'auto')
|
||||||
|
check('按钮文本回自动', window.btn_ymode.text(), 'Y轴自动')
|
||||||
|
|
||||||
|
# 确定形状的像素断言: 手动 [28,80], 数据(~19~60℃)出界部分必被裁剪回绘图区
|
||||||
|
c.set_y_manual(28.0, 80.0)
|
||||||
|
_hx = c._colors['measured'].lstrip('#')
|
||||||
|
measured_now = (int(_hx[0:2], 16), int(_hx[2:4], 16), int(_hx[4:6], 16))
|
||||||
|
window.chart.update()
|
||||||
|
img = QImage(window.chart.size(), QImage.Format_ARGB32)
|
||||||
|
window.chart.render(img)
|
||||||
|
area = QRectF(window.chart.rect()).adjusted(
|
||||||
|
52, 10, -(app_fluent.RIGHT_MARGIN + app_fluent.GUTTER_W), -26)
|
||||||
|
x0, x1 = int(area.left()) + 2, int(area.right()) - 2
|
||||||
|
check('手动范围下曲线仍绘制', count_pixels(img, measured_now) > 0, True)
|
||||||
|
check('裁剪: 上条带无曲线像素',
|
||||||
|
count_in(img, x0, x1, 0, int(area.top()) - 1, measured_now), 0)
|
||||||
|
check('裁剪: 下条带无曲线像素',
|
||||||
|
count_in(img, x0, x1, int(area.bottom()), window.chart.height(), measured_now), 0)
|
||||||
|
c.set_y_mode('auto')
|
||||||
|
|
||||||
|
hint = (201, 201, 201) # 提示文字色 #c9c9c9
|
||||||
|
c2 = app_fluent.TemperatureChart()
|
||||||
|
c2.resize(640, 320)
|
||||||
|
lst_t, lst_v = [], []
|
||||||
|
c2.set_source(lst_t, lst_v, [], []) # 模拟构建期一次性注入(空)
|
||||||
|
img2 = QImage(c2.size(), QImage.Format_ARGB32)
|
||||||
|
c2.render(img2)
|
||||||
|
ax0, ax1 = 60, c2.width() - 20
|
||||||
|
ax_top, ax_bot = 30, c2.height() - 30
|
||||||
|
n_hint0 = count_in(img2, ax0, ax1, ax_top, ax_bot, hint)
|
||||||
|
lst_t.append(time.time()) # 经同一引用追加数据(复现旧 bug 路径)
|
||||||
|
lst_v.append(50.0)
|
||||||
|
c2.update()
|
||||||
|
img3 = QImage(c2.size(), QImage.Format_ARGB32)
|
||||||
|
c2.render(img3)
|
||||||
|
n_hint1 = count_in(img3, ax0, ax1, ax_top, ax_bot, hint)
|
||||||
|
print(f'提示像素: 空={n_hint0} 有数据={n_hint1}')
|
||||||
|
check('空态提示已绘出', n_hint0 > 50, True)
|
||||||
|
check('数据到位后提示消失(实时判定)', n_hint1, 0)
|
||||||
|
|
||||||
|
# 8b) 值标签悬停门控: 非悬停不画(不常驻遮挡曲线, 用户标注), 悬停/拖拽才显示
|
||||||
|
lt2, lv2 = [time.time() - 100], [35.0]
|
||||||
|
c2.set_source(lt2, lv2, [], [])
|
||||||
|
c2.set_y_manual(20.0, 60.0)
|
||||||
|
c2.set_y_mode('manual')
|
||||||
|
c2.update()
|
||||||
|
img4 = QImage(c2.size(), QImage.Format_ARGB32)
|
||||||
|
c2.render(img4)
|
||||||
|
zx0, zx1 = c2.width() - 71, c2.width() - 31 # 把手值标签区(绘图区右缘)
|
||||||
|
zy0, zy1 = 110, 230 # 覆盖上下两个把手 y 位
|
||||||
|
n_pill0 = count_in(img4, zx0, zx1, zy0, zy1, (0, 200, 215))
|
||||||
|
c2._set_y_hover(True)
|
||||||
|
c2.update()
|
||||||
|
img5 = QImage(c2.size(), QImage.Format_ARGB32)
|
||||||
|
c2.render(img5)
|
||||||
|
n_pill1 = count_in(img5, zx0, zx1, zy0, zy1, (0, 200, 215))
|
||||||
|
print(f'值标签像素: 未悬停={n_pill0} 悬停={n_pill1}')
|
||||||
|
check('未悬停时值标签隐藏(不遮曲线)', n_pill0, 0)
|
||||||
|
check('悬停/拖拽时值标签显示', n_pill1 > 20, True)
|
||||||
|
c2._set_y_hover(False)
|
||||||
|
|
||||||
|
# 还原模块配置, 避免测试残留写入用户 filmforge_gui.json
|
||||||
|
config.Y_MODE, config.Y_MIN, config.Y_MAX = 'auto', None, None
|
||||||
|
config.save_settings()
|
||||||
|
|
||||||
print('RESULT:', 'FAIL:' + ','.join(fails) if fails else 'ALL PASS')
|
print('RESULT:', 'FAIL:' + ','.join(fails) if fails else 'ALL PASS')
|
||||||
sys.exit(1 if fails else 0)
|
sys.exit(1 if fails else 0)
|
||||||
|
|||||||
Reference in New Issue
Block a user