Files
Just-DataInspector/Components/PlotView/Inc/PlotView.hpp
T
DeepSeek V4.1 Flash 24706c906a feat(UI): 布局边界改为四角粗/四边细包角 + 全元素覆盖 + 几何同源(RowRect O(1))
## 1. 边界观感:四角粗、四边细

用户反馈"边框就是实心矩形、粗细不一,效果不佳"。旧实现是四条等宽实心矩形
(层级 0 用 2px、其余 1px),同一元素的角与边粗细不一致,父子矩形共边时还会叠成更粗的一条。

新规则:
- 四条边固定 1 设备像素(四条独立实心矩形;不用 DrawRectangle,笔宽奇偶会偏半像素);
- 四个角额外加粗:层级 0 = 4px、其余 3px,沿水平/垂直各延伸 cornerLen(≤14px 且 ≤ 短边一半);
- 层级 > 0 整体内缩 1px,父子矩形共边不再叠粗;
- 绝不填充(几十层嵌套填充会把内容糊住);
- 整像素对齐 + PixelOffsetModeNone + 关抗锯齿。

四角粗不只是观感:多条边重合时靠角的形状分辨层次(父=青蓝大包角、子=同位置品红小包角)。

## 2. 协议:组件"收集 box",宿主统一绘制

UiComponent::CollectDebugBoxes(UiDebugBoxList&)  默认只登记 Area()
UiWidget::CollectDebugBoxes(boxes, depth)        递归整棵控件树
Application::PaintLayoutBounds                   收集后按 depth 升序一次性描边

刻意保持 box 为**值类型**而不是让它继承控件基类:PlotView 的 4 层位图缓存、
SignalPanel 的 192 槽行位图缓存都要求"绘制由组件自己负责",几何进继承体系会与
缓存键/失效体系打架。box 不参与绘制,因此能让绘制/命中/调试共用同一份几何。

## 3. 覆盖补齐(此前大量元素被裹在外层单位里)

- Toolbar:区域 / 每个按钮 / **按钮内容(文字或齿轮图标,新增 UiButtonWidget::ContentRect())**
- TabBar:区域 / 标签条 / 新建按钮 / 每个 ✕
- SignalPanel:4 分区 + 筛选输入框/清空✕/全部显示 + 滚动轨道与滑块
  + 样式区 5 行及其 12 色块/自定义/3 模式/3 调整/线宽与透明度轨道和数值
  + 目标行/名称/GX 徽标 + **每个可见行**的折起三角/复选框/色块/名称/GX 徽标/✕
  (组行另有组名/计数)+ 内联编辑框
- PlotView:区域 / 每个网格 / GX 徽标 / 装饰层矩形 / 图例托底+色块+文本
  / **5 类浮动面板全部 20+ 字段**(标题/✕/折起三角/徽标/编辑行/按钮/内容区/滚动轨道与滑块
  /透明度轨道与数值/两个开关/各行复选框与数值与滑轨与复位与名称与✕/分组栏/表头/固定点行)
- PopupMenu:浮层 / 每个条目 / 可滚动可视区 / 轨道 / 滑块 / 二级子菜单与条目
- ColorPickerPopup:面板 / 标题 / 预览 / 色号 / 色域 / 色相条 / 3 条滑轨与数值 / 输入框

标签增加**尺寸门槛**(< 56×16 逻辑像素不画):复选框/色块/✕ 紧邻,逐个小框贴字会糊成一片。

## 4. 设置按钮配色 + 齿轮图标

- ToolbarAction::SETTINGS 改为 primary = true,与「导入文件」同为强调色主按钮。
- 齿轮几何重做:齿占最外一圈(0.66R→1.0R)、外环中心线 0.54R/笔宽 0.24R、轴孔 0.17R。
  旧版让齿从环中心线伸出,齿被环的描边吃掉 ⇒ 看起来只剩一个"甜甜圈"。

## 5. 几何同源与性能

- RowRect(row) 原本每次调用都从头累加行高(O(row)),而画一行要调它 **5 次**
  (✕/徽标/名称/组计数/折起三角各一次)⇒ 200 行只见 30 行时每帧上万次加法,
  命中测试(每次鼠标移动)同样走这条路。
  新增 m_rowTop 前缀和(由 UpdateAnimGeometry() 统一重建)⇒ **O(1)**;
  长度与行集合不一致时自动退回逐行累加(正确性优先)。
- ContentOffsetBeforeRow 同样改用前缀和。
- PanelBounds() 收敛 6 处重复的 (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area。

## 6. 验证

- make release / make debug 零 error 零 warning;TargetCheck.exe 910,336 B(889 KiB < 900 KiB)。
- .preflight/lbcheck:21 / 21 PASS(含 bounds-off-restores-pixels:关闭后逐像素回到开启前)。
- Tools/rendercheck:「工具栏带以下」区域哈希 8/8 与上一轮**逐一相同**
  (证明本轮改动只落在工具栏带内);FUNC 断言 7/7 PASS。
- Tools/uicheck:69 passed / 0 failed。

## 7. 文档

Documents/UI_Refactor_Plan.md / UI_Refactor_Progress.md / UI_Refactor_Report.md 追加第七阶段;
Documents/agent_mem.md 同步变更日志与「2026-10-28 第九轮」硬规则与踩坑记录。
2026-10-28 16:28:57 +08:00

4048 lines
192 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#ifndef __PLOT_VIEW_HPP__
#define __PLOT_VIEW_HPP__
#include <windows.h>
#include <objidl.h>
#include <gdiplus.h>
#include <array>
#include <atomic>
#include <condition_variable>
#include <deque>
#include <map>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <utility>
#include <vector>
#include "DataModel.hpp"
#include "PaintUtil.hpp"
#include "Theme.hpp"
#include "UiComponent.hpp"
namespace jdi
{
class UiDebugBoxList;
/**
* @brief 后台派生曲线计算完成后由 `PlotView` 投递给宿主的自定义消息。
*
* 宿主收到后重绘绘图区即可:绘制线程会在下一帧取到刚算好的曲线(双缓冲切换),
* 同时静态场景缓存会被作废以纳入新曲线。`WM_APP + 3` 与宿主其它自定义消息错开。
*/
constexpr UINT kMessagePlotDerivedReady = WM_APP + 3;
/**
* @brief 「可用内存严重不足,已暂停派生曲线计算」的通知消息。
*
* 只有当**连一条预览曲线本身都装不下**时才发出(`DerivedMemoryAllows`),
* 宿主收到后应给出一次可见的告警(本项目用 `SetStatusMessage(..., true)` 弹一次提示框)
* 并让界面继续可用 —— 绝不会因为内存不足而崩溃。`WM_APP + 4` 与其它自定义消息错开。
*/
constexpr UINT kMessagePlotLowMemory = WM_APP + 4;
/**
* @brief 绘图区拖拽交互模式。
*/
enum class PlotDragMode : int
{
NONE = 0, /**< 未拖拽。 */
PAN, /**< 常规模式:水平平移时间轴 + 纵向偏移当前网格幅值。 */
RUBBER_SELECT, /**< 选区放大:按拖拽方向决定横向(时间轴)或纵向(幅值轴)选区。 */
SCROLL_LANES, /**< 拖动网格滚动条。 */
CURSOR_MOVE, /**< 拖拽时间光标。 */
CURSOR_SPAN_MOVE, /**< 拖拽双光标差值标签:两条光标整体平移,间隔不变。 */
MOVE_SIGNAL, /**< 拖拽信号曲线到其它网格。 */
EDGE_MOVE, /**< 拖动「边沿计数」的横向光标(上下移动幅值)。 */
EDGE_PANEL_MOVE, /**< 拖动边沿计数浮动面板。 */
PEAK_LEVEL, /**< 拖动「弱峰调整」某信号的幅度滑块(-400% – +400%)。 */
PEAK_ITERATIONS, /**< 拖动「弱峰调整」某信号的迭代次数滑块(1 … 该信号自定义上限)。 */
LANE_REORDER, /**< 拖动网格图例中的 GX 编号徽标:上下调整网格次序。 */
MARKER_OPACITY, /**< 拖动「自由标记」面板的「标记透明度」滑块。 */
MARKER_SCROLL /**< 拖动「自由标记」面板内容的滚动条滑块。 */
};
/**
* @brief 选区放大拖拽的轴向(由首次明显位移的方向决定)。
*/
enum class PlotRubberAxis : int
{
UNDECIDED = 0, /**< 尚未确定(拖拽刚刚开始)。 */
TIME = 1, /**< 横向拖拽:放大时间轴。 */
VALUE = 2 /**< 纵向拖拽:放大当前网格的幅值轴。 */
};
/**
* @brief 绘图区内联数值输入的目标。
*/
enum class PlotEditTarget : int
{
NONE = 0, /**< 未处于输入状态。 */
CURSOR_TIME, /**< 编辑时间光标位置。 */
CURSOR_SPAN, /**< 编辑双光标之间的时间差(前方光标不动,后方光标随之移动)。 */
LANE_Y_MAX, /**< 编辑某网格的 Y 轴最大值。 */
LANE_Y_MIN, /**< 编辑某网格的 Y 轴最小值。 */
SIGNAL_NAME, /**< 编辑图例中某信号的显示名称。 */
EDGE_CENTER_POINTS, /**< 编辑「中心轴边沿计数」的均值点数 X。 */
EDGE_CENTER_ITERATIONS, /**< 编辑「中心轴边沿计数」的迭代次数 N。 */
MEAN_FILTER_POINTS, /**< 编辑「均值滤波」的均值点数 X。 */
MEAN_FILTER_ITERATIONS, /**< 编辑「均值滤波」的迭代次数 N。 */
PEAK_ITERATIONS_MAX, /**< 编辑「弱峰调整」某信号「迭代次数 N」滑块的**上限**(正整数)。 */
FREE_MARKER_TIME, /**< 编辑「自由标记」某固定点的**自身时间**(秒)。 */
FREE_MARKER_DELTA /**< 编辑「自由标记」某固定点相对前一固定点的**时间间隔**(秒)。 */
};
/**
* @brief 绘图区右键菜单命令标识(与信号面板的命令空间相互独立)。
*/
enum PlotMenuCommand : int
{
PLOT_CMD_AUTO_FIT_ALL = 1, /**< 全局切换全部网格的「选区 Y 轴自适应」。 */
PLOT_CMD_LANE_Y_MAX = 2, /**< 输入该网格 Y 轴最大值。 */
PLOT_CMD_LANE_Y_MIN = 3, /**< 输入该网格 Y 轴最小值。 */
PLOT_CMD_FIT_ALL = 4, /**< 全部网格与时间轴自适应。 */
PLOT_CMD_LANE_FIT_TIME = 5, /**< 时间轴自适应到本网格信号范围(按网格时间轴展开)。 */
PLOT_CMD_LANE_AUTO_VIEW = 6, /**< 切换本网格的「选区 Y 轴自适应」开关。 */
PLOT_CMD_LANE_EDGE_COUNT = 7, /**< 切换本网格的「实验性功能 → 边沿计数」开关。 */
PLOT_CMD_LANE_EDGE_CENTER = 8, /**< 切换本网格的「实验性功能 → 边沿计数(中心轴)」开关。 */
PLOT_CMD_LANE_SPLIT = 9, /**< 拆分当前网格:把网格内信号按个拆成独立网格并顺次下移编号。 */
PLOT_CMD_LANE_MEAN_FILTER = 10, /**< 切换本网格的「实验性功能 → 均值滤波」开关。 */
PLOT_CMD_LANE_WEAK_PEAK = 11, /**< 切换本网格的「实验性功能 → 弱峰调整」开关。 */
PLOT_CMD_LANE_FREE_MARKER = 12 /**< 切换本网格的「实验性功能 → 自由标记」开关。 */
};
/**
* @brief 实验性功能浮动面板的种类。
*/
enum class EdgePanelKind : int
{
COUNT = 0, /**< 边沿计数。 */
CENTER = 1, /**< 边沿计数(中心轴)。 */
MEAN = 2, /**< 均值滤波。 */
PEAK = 3, /**< 弱峰调整。 */
MARK = 4 /**< 自由标记。 */
};
/**
* @brief 派生(叠加预览)曲线的种类。
*
* 「中心轴边沿计数」与「均值滤波」共用 `MEAN_FILTER`(同一套参数与同一条曲线)。
*/
enum class DerivedCurveKind : int
{
MEAN_FILTER = 0, /**< 多次迭代均值滤波。 */
WEAK_PEAK = 1 /**< 弱峰调整。 */
};
/**
* @brief 波形绘图区:网格、时间轴、光标、缩放、自适应与信号拖拽。
*
* 绘图区同时依赖数据层(DataSet,只读)与当前标签页配置(TabConfig,可写),
* 视图范围、网格滚动与时间光标均保存在标签页配置中,因此各标签页互不干扰。
*/
class PlotView : public UiComponent
{
public:
PlotView();
/**
* @brief 析构:释放拖拽静态场景缓存的离屏位图。
*/
~PlotView();
/**
* @brief 设置宿主窗口句柄(用于剪贴板所有者)。
* @param [IN] host 宿主窗口句柄,允许为空。
*/
void SetHostWindow(HWND host) { m_host = host; }
/**
* @brief 已渲染的「信号图层」区域数量(仅供验证 / 诊断)。
*
* 每次把一个网格的基底(整块或滚动细条)画进图层都会 +1。计数长期不增长即说明
* 平移期间确实在复用已有像素(只做坐标偏移),没有重新绘制波形。
* @return 累计完成的图层区域数量。
*/
std::uint64_t BackgroundLayerRenderCount() const
{
return m_layerRenderCount.load(std::memory_order_relaxed);
}
/**
* @brief 绑定字体与主题资源。
* @param [IN] fonts 字体集合,需在视图生命周期内保持有效。
* @param [IN] theme 主题配色,需在视图生命周期内保持有效。
*/
void Setup(const UiFonts* fonts, const Theme* theme);
/**
* @brief 绑定数据层与当前标签页配置。
* @param [IN] dataSet 数据集合,允许为空。
* @param [IN] tab 当前标签页配置,允许为空。
*/
void SetContext(DataSet* dataSet, TabConfig* tab);
/**
* @brief 设置 DPI 缩放比例。
* @param [IN] scale 缩放比例(1.0 对应 96 DPI)。
*/
void SetScale(float scale) override;
/**
* @brief 按给定区域重新排布绘图区。
* @param [IN] area 可用区域。
*/
void Layout(const gd::RectF& area) override;
/** @brief 绘图区占用的整块区域(与 `Layout` 同源)。 */
gd::RectF Area() const override { return m_area; }
/**
* @brief 绘制绘图区。
* @param [IN,OUT] g 绘图上下文。
*/
void Paint(gd::Graphics& g) override;
/**
* @brief 收集绘图区区域、**每个网格**、GX 徽标、图例条目与浮动面板的布局边界(调试用)。
*
* 只上报“布局结构”,**不上报**曲线折线、取值胶囊、悬停提示这类随数据/鼠标逐帧变化的
* 瞬态几何 —— 它们每帧都不同,叠上去只是噪声。
*
* @param [IN,OUT] boxes 收集器。
*/
void CollectDebugBoxes(UiDebugBoxList& boxes) override;
/**
* @brief 处理鼠标移动(含拖拽)。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 需要重绘返回 true。
*/
bool OnMouseMove(int x, int y);
/**
* @brief 处理鼠标左键按下。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 需要重绘返回 true。
*/
bool OnLButtonDown(int x, int y);
/**
* @brief 处理鼠标左键抬起。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 需要重绘返回 true。
*/
bool OnLButtonUp(int x, int y);
/**
* @brief 处理鼠标右键按下(**只处理浮动面板**)。
*
* 浮动面板压在波形之上,属最上层元素:右键落在面板上必须被面板消费,
* 不得穿透到下面的网格(否则会在面板上弹出网格的 Y 轴菜单)。
*
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 命中浮动面板(已消费该次右键)返回 true;否则返回 false,由宿主继续处理。
*/
bool OnRButtonDown(int x, int y);
/**
* @brief 处理鼠标中键按下:与左键在常规模式下一致,进入自由平移(水平平移时间轴 +
* 纵向偏移当前网格幅值)。**任何缩放模式下都可用**,用于在不改变当前模式的
* 前提下自由查看区域。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 需要重绘返回 true。
*/
bool OnMButtonDown(int x, int y);
/**
* @brief 处理鼠标中键抬起:结束中键平移。
* @return 需要重绘返回 true。
*/
bool OnMButtonUp();
/**
* @brief 处理鼠标滚轮(仅用于平移与网格滚动,不再承担缩放)。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @param [IN] delta 滚轮增量。
* @param [IN] shift 是否按下 Shift(水平平移时间轴)。
* @return 需要重绘返回 true。
*/
bool OnMouseWheel(int delta, bool shift);
/**
* @brief 处理鼠标双击(命中时间光标时进入精确时间输入)。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 需要重绘返回 true。
*/
bool OnDoubleClick(int x, int y);
/**
* @brief 双击落点是否应**当作普通「按下」**处理(该位置没有双击语义)。
*
* ⚠️ 窗口类带 `CS_DBLCLKS`:快速连点同一个控件时,第二次(及之后)按下会被 Windows
* 合成为 `WM_LBUTTONDBLCLK`。若宿主把它交给 `OnDoubleClick`,而该位置本来只有
* 「按下 / 单击」语义(浮动面板上的复选框与按钮、滚动条、GX 徽标…),那一次按下就
* **完全丢失** —— 用户看到的正是“连点好几下、状态只切换了几次”(不跟手)。
*
* ✓ 本函数为这些位置返回 true,宿主改走 `OnLButtonDown`(与单击完全相同的路径)。
* 只有真正有双击语义的位置(时间光标线 / 读数标签 / 图例条目名 / 绘图区空白处
* 的自适应)返回 false。
*
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 应当作普通按下返回 true。
*/
bool DoubleClickIsPlainPress(int x, int y) const;
/**
* @brief 处理鼠标离开绘图区。
* @return 需要重绘返回 true。
*/
bool OnMouseLeave() override;
/**
* @brief 将时间轴自适应到指定网格内信号的时间范围。
* @param [IN] laneIndex 网格下标。
* @return 调整成功返回 true。
*/
bool FitTimeToLane(int laneIndex);
/**
* @brief 「拆分当前网格」:把该网格内的信号按个拆分成独立网格,新网格依次排在其下方。
* @param [IN] laneIndex 网格下标。
* @return 拆分成功返回 true。
*/
bool SplitLane(int laneIndex);
/**
* @brief 查询某网格内的信号数量(用于菜单项可用状态判定)。
* @param [IN] laneIndex 网格下标。
* @return 信号数量,网格不存在时返回 0。
*/
std::size_t LaneSignalCount(int laneIndex) const;
/**
* @brief 依据当前显示时间段内的数据对该网格做 Y 轴自适应(“选区 Y 轴自适应”)。
*
* 与普通自适应(用整段数据的极值)不同:这里只看时间轴当前可见区间内的采样点,
* 因此放大到某段局部波形后可以快速把该段的幅值铺满整个网格。
*
* @param [IN] laneIndex 网格下标。
* @return 调整成功返回 true(区间内无有效数据时返回 false)。
*/
bool AutoRangeLaneToView(int laneIndex);
/**
* @brief 对全部开启了「选区 Y 轴自适应」的网格做一次 Y 轴选区自适应。
*
* 时间轴选区放大后调用:只有开关开启的网格才自动匹配新的可见时间区间。
*/
void ApplySelectionAutoFit();
/**
* @brief 网格结构变化后,若「全局 Y 轴自适应」处于开启状态则重新做一次全局自适应。
*
* 用户要求(2026-10-15):
* * 新勾选信号产生的**新网格**要按**当前视界**自适应(新网格继承开关由
* `RebuildLanes` 负责,这里负责“立即算一次”);
* * 多信号网格内**取消显示**某条信号、或把网格**拆分**之后,剩余/新生的网格
* 都要重新收敛到当前视界(原来的幅值区间是按旧信号集合算的)。
*
* 判据与工具栏「Y 轴自适应」一致 = `AllLanesAutoFitToSelection()`(全部网格都开启)。
*/
void ReapplyGlobalAutoFitIfEnabled();
/**
* @brief 依据「每小格数据点数」自动启用/恢复「点线」绘制方式(即“放大自动点线”)。
*
* 用户 2026-10-17 **重新定义判据**(推翻此前的“一屏内数据点少于 N×4”):
* 按**缩放后的横向时间轴网格**把小格切出来,**只要存在任何一个小格内的数据点数
* 少于 `kSparseMinPointsPerCell`(=5)** 就自动切到「点线」提示用户数据点的位置
*(见 `MinorCellSparse`);一旦不再满足(适应视图、或把视界从稀疏区移到稠密区)
* 自动恢复**原来的**绘制方式。
*
* ⚠️ 自动启用期间若用户**手动**改了绘制方式(`SignalStyle::autoScatterUserOverride`
* 由本函数自动识别:自动状态下 mode 不等于 `LINE_SCATTER` 即说明是用户改的),
* 退出时**不再**恢复,用户的选择优先。
*
* @return 有任何信号的绘制方式被改变返回 true(调用方需作废缓存)。
*/
bool UpdateAutoScatterStyles();
/**
* @brief 网格底衬**一横排的小格数量 N**(与 `PaintLane` 画出来的网格同源)。
*
* 时间刻度用 `NiceStep(span, kTimeTickTarget)` 定主刻度、再五等分 ⇒
* 不管缩放等级如何,一横排总是差不多同样多的小格。
*
* @return 小格数量(无法判定时返回 0)。
*/
int GridMinorCellCount() const;
/**
* @brief 判断某信号在当前视界下是否**按小格判定为稀疏**。
*
* 用户 2026-10-17 定义的判据(**推翻**此前的“一屏内数据点 < N×4”):
* 按缩放后的**横向时间轴网格**把小格切出来(`GridMinorCellCount()`),
* **只要存在任何一个小格内的有效数据点少于 `kSparseMinPointsPerCell`(=5)** 就返回 true。
*
* ⚠️ **这一个判定同时驱动两处**(用户 2026-10-17 明确要求“同样的状态判定”):
* * 「放大自动切点线」(`UpdateAutoScatterStyles`);
* * 「光标吸附」(`SnapTimeToSample`)—— 判定**完全不看绘制方式**,
* 因此即使用户手动改过显示样式,该吸附的信号照样吸附。
*
* 判据随视界实时重算 ⇒ 疏密变化的信号在稀疏区自动吸附、稠密区自动自由移动。
* ⚠️ 额外要求视界内**至少有一个可见数据点**:数据点全在视界之外的信号既没有可吸附的
* 对象、也没有可提示的内容(否则它会被判成“小格全空”而白切一次「点线」)。
* ⚠️ 可见性一律按**显示时间**(`采样时刻 + timeShift`)判断 —— `timeShift` 就是该信号的
* `SignalStyle::offsetTime`(存在时间轴偏移时不能拿源时刻去比视界)。
*
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @param [IN] timeShift 该信号的显示时间偏移(`offsetTime`)。
* @return 存在少于 `kSparseMinPointsPerCell` 个数据点的小格返回 true。
*/
bool MinorCellSparse(const TimeAxis& axis, const SignalData& signal, double timeShift) const;
/**
* @brief 判断某信号在给定可见下标区间内是否存在**落在视界内**的有效采样点。
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @param [IN] begin 可见下标区间起点。 @param [IN] end 终点(不含)。
* @param [IN] timeShift 该信号的显示时间偏移(`offsetTime`)。
* @return 存在返回 true。
*/
bool HasVisibleSample(const TimeAxis& axis, const SignalData& signal, std::size_t begin,
std::size_t end, double timeShift) const;
/**
* @brief 找某信号**可见区间内**离给定时刻最近的有效采样点。
*
* 稀疏信号(有效点已缓存)用二分;一般信号从最近下标向两侧**有界**探测
*(NaN 只出现在缺少数据的区段)。视界之外的采样点不是合法落点,一律排除。
*
* ⚠️ `time` 与输出的 `candidate` 都是**显示时间**(画面上的时刻):内部按 `timeShift`
* 换算回源空间查找、再把结果加回去,因此存在「调整 → 时间轴偏移」时也能吸到
* **画面上**的数据点,而不是历史位置。
*
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @param [IN] time 目标时刻(显示时间)。
* @param [IN] timeShift 该信号的显示时间偏移(`offsetTime`)。
* @param [OUT] candidate 最近的可见有效采样点时刻(显示时间)。
* @return 找到返回 true。
*/
bool NearestSnapSample(const TimeAxis& axis, const SignalData& signal, double time,
double timeShift, double& candidate) const;
/**
* @brief 取某时刻附近的**局部采样间距**(时间单位,用于限制吸附半径)。
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @param [IN] time 目标时刻(显示时间)。
* @param [IN] timeShift 该信号的显示时间偏移(`offsetTime`)。
* @param [OUT] spacing 相邻采样点的时间间隔(无法判定时为 0)。
*/
void LocalSampleSpacing(const TimeAxis& axis, const SignalData& signal, double time,
double timeShift, double& spacing) const;
/**
* @brief 把时间吸附到距今最近的数据采样点。
*
* 判定与半径(用户 2026-10-17 修订):
* * **判定只针对鼠标下方所在的网格**:该网格内的信号各自按 `MinorCellSparse`
* (“任一小格内数据点少于 5 个”)判定,**符合条件的信号的数据点全部参与吸附**;
* * 网格内**既有符合条件也有不符合条件的信号**时:只吸附符合条件那条信号的数据点
* (用户明确要求);
* * 网格内**没有**符合条件的信号时:光标完全自由移动(不吸附);
* * 判定**不看绘制方式** ⇒ 即使用户手动改了显示样式,该吸附的信号照样吸附;
* * 吸附半径 = **一个网格小格的宽度**(与判定同源),且**不超过半个局部采样间距**
* ⇒ 数据点很稀疏(一屏只有一两个点)时光标在点与点之间仍可自由移动,
* 而不是被“死死吸住”(吸附只是提示性响应,不是卡住光标)。
*
* @param [IN] time 原始时间。
* @param [OUT] snapped 吸附后的时间。
* @return 成功吸附返回 true。
*/
bool SnapTimeToSample(double time, double& snapped) const;
/**
* @brief 判断坐标是否落在绘图区范围内。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 落在范围内返回 true。
*/
bool HitTest(int x, int y) const override;
/**
* @brief 查询当前鼠标位置应使用的系统光标。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 系统光标句柄,未命中特殊区域返回 nullptr。
*/
HCURSOR QueryCursor(int x, int y) const;
/**
* @brief `UiComponent` 协议的入口(转调上面的 const 版本)。
*
* `UiComponent` 声明的是非 const 版本(`SignalPanel` 的命中需要惰性重建行集合),
* 而绘图区的实现天然是 const;这里做一次薄转发,两个重载保持行为完全一致。
*/
HCURSOR QueryCursor(int x, int y) override
{
return static_cast<const PlotView*>(this)->QueryCursor(x, y);
}
/**
* @brief 「适应视图」:时间轴与幅值轴同时自适应到可见信号范围。
*
* 边界规则(用户 2026-10-15 第五轮**重新定义**,取代此前"左右各留一小格"的旧做法):
* * 常规的全局适应后,若某条边界(时间轴的左 / 右、每个网格幅值的上 / 下)被一个
* **单值信号的数据点**顶住,则该边界再向外让出**该散点的半径**,使它**完整地
* 显示在网格内**;边界若不是单值信号的数据点,则边界就落在这个信号点上
* (时间轴不留额外余量,幅值仍走 `PadValueRange` 的统一留白)。
* * 唯一特例:**所有可见信号都是单值数据点、且时刻完全相同**时,时间轴以该时刻
* 居中并缩放到"一大格 = 1s",每个网格的幅值区间保证其数据点都能完整画出。
*
* ⚠️ 没有任何可见信号时**不置位** `Tab().ViewInitialized()`:用户先连点几次
* 「适应视图」后再勾选第一个信号,初次自适应仍必须发生。
*/
void FitAll();
/**
* @brief 一个**可见信号**在「适应视图」里提供的边界贡献(见 `FitAll` 的边界规则)。
*/
struct FitContribution
{
SignalRef ref; /**< 信号引用。 */
double minTime = 0.0; /**< 时间轴下限(源时刻,与 `ComputeVisibleTimeBounds` 同源)。 */
double maxTime = 0.0; /**< 时间轴上限。 */
bool singlePoint = false; /**< 是否只有一个有效数据点(画出来就是一个圆点)。 */
double pointTime = 0.0; /**< 单值时:数据点的源时刻。 */
double pointValue = 0.0; /**< 单值时:**绘制变换后**的取值。 */
float dotRadius = 0.0f; /**< 单值时:散点半径(设备像素)。 */
};
/**
* @brief 汇总全部可见信号的 `FitContribution`(按网格分组)。
* @param [OUT] perLane 与 `Tab().Lanes()` 同序的分组结果。
* @return 可见信号总数(0 表示当前没有任何可见信号)。
*/
std::size_t CollectFitContributions(std::vector<std::vector<FitContribution>>& perLane) const;
/**
* @brief 当前布局下**单个网格的高度**(设备像素)。
*
* 与 `BuildLaneVisuals()` 用同一公式(而不是读 `m_lanes[i].rect.Height`):
* `FitAll` 可能在网格数量刚变化、布局尚未重建时被调用,公式给出"将要生效"的值。
* @return 网格高度;无网格时返回 0。
*/
float FitLaneHeight() const;
/**
* @brief 若该网格的**幅值上/下边界**被单值散点顶住,把区间向外让出散点半径。
*
* 只在散点**会被裁切**时才改动区间("散点本来就完整可见"不动它,保持自动自适应的
* 语义);改动时把 `autoRange` 置 false 并写入显式区间。
*/
void ApplySinglePointValueMargins(int laneIndex, const std::vector<FitContribution>& items,
float laneHeight);
/**
* @brief 「所有可见数据点都是单值信号、且时刻完全相同」时的自适应。
*
* 时间轴以 `sharedTime` 居中、**一大格 = 1s**;每个网格的幅值区间保证其数据点
* 完整显示(网格内只有一个取值时以跨度 1 居中)。
*/
void FitSinglePointCluster(const std::vector<std::vector<FitContribution>>& perLane,
double sharedTime);
/**
* @brief 取某信号**唯一的有效数据点**(时刻 + 原始取值)。
*
* 优先用加载时缓存的稀疏点;没有缓存时逐个采样找(单点信号的采样数组必然很短)。
*
* @param [IN] signal 目标信号。
* @param [IN] axis 该信号使用的时间轴。
* @param [OUT] time 数据点时刻。 @param [OUT] value 数据点原始取值。
* @return 取到有效点返回 true。
*/
bool SinglePointOfSignal(const SignalData& signal, const TimeAxis& axis, double& time,
double& value) const;
/**
* @brief 以给定倍率缩放时间轴(以视图中心为锚点)。
* @param [IN] factor 缩放倍率,大于 1 表示放大。
*/
void ZoomTimeStep(double factor);
/**
* @brief 按当前视图跨度的比例平移时间轴。
* @param [IN] fraction 平移比例,正值向右(时间增大)平移。
*/
void PanTimeByFraction(double fraction);
/**
* @brief 按网格内容高度的比例纵向滚动网格。
* @param [IN] fraction 滚动比例,正值向下滚动。
*/
void ScrollLanesByFraction(double fraction);
/**
* @brief 提示浮层是否仍在显示(含淡出阶段)。
*
* 宿主可在返回 true 时保持定时重绘,使提示能"停留后淡出"。
*
* @return 需要继续重绘返回 true。
*/
bool HintNeedsAnimation() const;
/**
* @brief 以**非模态**方式向用户提示一条信息(绘图区底部浮层,停留至下一条提示替换)。
*
* 宿主(`Application`)把不宜用模态对话框打断的操作反馈(例如加载/导出错误)
* 交给本接口显示即可,也便于在无交互(隐藏窗口)场景下保持可用。
*
* @param [IN] text 提示文本;传空串则立即清除提示。
*/
void ShowPersistentHint(const std::wstring& text);
/**
* @brief 清除已启用的时间光标。
*/
void DisableCursor();
/**
* @brief 放弃当前正在进行的拖拽(缩放选区/平移/信号拖动),不改变视图范围。
* @param [IN] showHint 是否同时显示“已放弃选区”提示。
* @return 确实取消了某个拖拽返回 true。
*/
bool CancelDrag(bool showHint = false);
/**
* @brief 判断当前是否处于「选区放大」的框选过程中(供宿主处理右键放弃)。
* @return 正在框选返回 true。
*/
bool IsRubberSelecting() const { return m_dragMode == PlotDragMode::RUBBER_SELECT; }
/**
* @brief 退出全部缩放模式(选区放大),使工具栏缩放按钮回到未选中态。
* @return 之前处于缩放模式返回 true。
*/
bool ExitZoomModes();
/**
* @brief 使全部派生结果失效:折线顶点、边沿统计、中心平均值曲线与静态场景缓存。
*
* 数据层结构变化(加载/移除分组、信号增删)后信号引用与分组地址都可能改变,
* 必须整体失效,避免旧几何键与新的信号意外重合而取到过期结果。
*/
void InvalidateDerivedCaches();
/**
* @brief 释放本组件持有的全部绘制资源(位图 / 后台线程)。
*
* ⚠️ **必须在 `GdiplusShutdown` 之前调用**:`PlotView` 持有 `gd::Bitmap`(场景/图层/
* 装饰/面板/光晕,以及并行图层 worker 各自的位图),而 `Application` 是进程级单例,
* 其成员要等到 `WinMain` 返回后才析构 —— 那时 GDI+ 已关闭,位图析构会直接访问违例
* (与拾色器当年那个 0xC0000005 同类)。`~PlotView()` 也会调一次(幂等)。
*/
void ReleaseGraphicsResources();
/**
* @brief 暂停后台派生曲线计算并作废其结果。
*
* 后台线程会读取采样数据,因此**任何会改变数据层结构(增删分组/信号、插入派生信号,
* 进而移动 `SignalGroup`/`SignalData` 存储)的操作之前**都必须先调用本函数:它会等待正在
* 进行的计算结束、清空待算队列与缓存,从而杜绝后台线程读取已释放内存。
* 之后的绘制会按需重新启动后台线程(惰性)。
*/
void PauseDerivedAsync();
/**
* @brief 当前标签页是否还有任何「带预览曲线」的实验性功能处于开启状态。
* @return 至少一个网格开启了弱峰调整 / 均值滤波 / 边沿计数(中心轴)返回 true。
*/
bool AnyDerivedPreviewEnabled() const;
/**
* @brief 若已无任何预览曲线功能,立即释放派生曲线的全部数据。
*
* 释放内容包括:后台线程池(**join 之后各线程的 `thread_local` 工作缓冲才析构、
* 内存才归还**)、待算队列、已算好的曲线条目与均值/弱峰曲线缓存。
*
* ⚠️ 这是"关掉预览后内存不下降"的修复:一条整段(千万级采样)曲线的中间缓冲
* 可达数百 MB,而 `thread_local` 只在线程退出时释放 —— 过去只有"
* 关闭整个分组"(会 `PauseDerivedAsync`)才会真正还内存。
*/
void TrimDerivedCurveDataIfIdle();
/**
* @brief 三态循环切换时间光标:无 → 单光标 → 双光标 → 无。
* @return 状态发生变化返回 true。
*/
bool CycleCursorMode();
/**
* @brief 当绘图区从「没有任何可见信号」变为「有信号」时,按当前光标模式**重新按规则摆好光标**。
*
* 用户 2026-10-17 要求:光标工具处于激活状态(单 / 双光标)且当前**没有任何信号被勾选绘制**时,
* 下一个信号首次出现时应按“光标出现的规则”重新出现一次,而不是停留在之前的
* 残留位置(那时的视界与数据都是空的,残留位置已无意义)。
*
* 规则与 `CycleCursorMode()` 的“首次出现”完全同源:单光标置于视图中心;
* 双光标的第一条置于视图中心、第二条紧邻第一条并保证落在可见范围内。
*
* @return 确实重新摆放了光标返回 true(光标工具未激活时返回 false)。
*/
bool ResetCursorPlacementOnFreshContent();
/**
* @brief 判断时间光标是否已启用(单/双光标均为启用)。
* @return 启用返回 true。
*/
bool CursorEnabled() const;
/**
* @brief 获取当前时间光标模式。
* @return 光标模式。
*/
PlotCursorMode CursorMode() const;
/**
* @brief 获取光标 1 对应的时间。
* @return 光标 1 时间。
*/
double CursorTime() const;
/**
* @brief 切换拖拽缩放模式(再次调用同一模式则取消)。
* @param [IN] mode 目标模式。
* @return 状态发生变化返回 true。
*/
bool ToggleZoomMode(PlotZoomMode mode);
/**
* @brief 获取当前拖拽缩放模式。
* @return 模式枚举。
*/
PlotZoomMode CurrentZoomMode() const;
/**
* @brief 查询坐标命中的网格下标(用于右键菜单)。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 网格下标,未命中返回 -1。
*/
int LaneIndexAt(int x, int y) const;
/**
* @brief 判断某网格是否开启了「选区 Y 轴自适应」开关(菜单勾选标记)。
* @param [IN] laneIndex 网格下标。
* @return 开启返回 true。
*/
bool LaneAutoFitToSelection(int laneIndex) const;
/**
* @brief 判断是否全部网格都开启了「选区 Y 轴自适应」(用于全局菜单项与工具栏按钮)。
* @return 存在网格且全部开启返回 true。
*/
bool AllLanesAutoFitToSelection() const;
/**
* @brief 切换某网格的「选区 Y 轴自适应」开关。
* @param [IN] laneIndex 网格下标。
* @return 切换后的状态。
*/
bool ToggleLaneAutoFitToSelection(int laneIndex);
/**
* @brief 让某网格的「选区 Y 轴自适应」开关立即生效一次(打开时调用)。
* @param [IN] laneIndex 网格下标。
*/
void ApplyAutoFitToSelectionNow(int laneIndex);
/**
* @brief 对全部网格的「选区 Y 轴自适应」开关立即生效一次(全局打开时调用)。
*/
void ApplyAllAutoFitToSelectionNow();
/**
* @brief 一次性设置全部网格的「选区 Y 轴自适应」开关(全局菜单项 / 工具栏按钮)。
* @param [IN] enabled 目标状态。
* @param [IN] applyNow 打开时是否立即按当前视图执行一次 Y 轴自适应。
* @return 任一网格状态发生变化返回 true。
*/
bool SetAllLanesAutoFitToSelection(bool enabled, bool applyNow = true);
/**
* @brief 判断某网格是否开启了「实验性功能 → 边沿计数」(菜单勾选标记)。
* @param [IN] laneIndex 网格下标。
* @return 开启返回 true。
*/
bool LaneEdgeCount(int laneIndex) const;
/**
* @brief 设置某网格的「边沿计数」开关。
* @param [IN] laneIndex 网格下标。
* @param [IN] enabled 目标状态。
* @return 状态发生变化返回 true。
*/
bool SetLaneEdgeCount(int laneIndex, bool enabled);
/**
* @brief 切换某网格的「边沿计数」开关(开启时自动初始化横向光标位置与面板)。
* @param [IN] laneIndex 网格下标。
* @return 切换后的状态。
*/
bool ToggleLaneEdgeCount(int laneIndex);
/**
* @brief 判断某网格是否开启了「实验性功能 → 边沿计数(中心轴)」。
* @param [IN] laneIndex 网格下标。
* @return 开启返回 true。
*/
bool LaneEdgeCenterCount(int laneIndex) const;
/**
* @brief 设置某网格的「边沿计数(中心轴)」开关。
* @param [IN] laneIndex 网格下标。
* @param [IN] enabled 目标状态。
* @return 状态发生变化返回 true。
*/
bool SetLaneEdgeCenterCount(int laneIndex, bool enabled);
/**
* @brief 切换某网格的「边沿计数(中心轴)」开关。
* @param [IN] laneIndex 网格下标。
* @return 切换后的状态。
*/
bool ToggleLaneEdgeCenterCount(int laneIndex);
/**
* @brief 判断某网格是否开启了「实验性功能 → 均值滤波」。
* @param [IN] laneIndex 网格下标。
* @return 开启返回 true。
*/
bool LaneMeanFilter(int laneIndex) const;
/**
* @brief 设置某网格的「均值滤波」开关。
* @param [IN] laneIndex 网格下标。
* @param [IN] enabled 目标状态。
* @return 状态发生变化返回 true。
*/
bool SetLaneMeanFilter(int laneIndex, bool enabled);
/**
* @brief 切换某网格的「均值滤波」开关。
* @param [IN] laneIndex 网格下标。
* @return 切换后的状态。
*/
bool ToggleLaneMeanFilter(int laneIndex);
/**
* @brief 「均值滤波 → 构建为新信号」的单个请求(一个被勾选的信号 + 自己的参数)。
*/
struct MeanFilterBuild
{
int groupIndex = -1; /**< 信号所属分组下标。 */
int signalIndex = -1; /**< 组内信号下标。 */
int points = 5; /**< 均值点数 X(滑平半径)。 */
int iterations = 1; /**< 迭代次数 N(至少 1)。 */
};
/**
* @brief 取出「构建为新信号」请求(由面板底部按钮触发)。
*
* 请求只描述「哪一网格、哪些被勾选的信号及其参数」,真正的信号构建与插入由宿主
* (Application)完成——数据层属于 DataSet,绘图区不能直接修改它。
*
* @param [OUT] laneIndex 触发该请求的网格下标。
* @param [OUT] items 需要构建的信号及其参数(按信号在网格内的顺序)。
* @return 存在待处理请求返回 true(取出后清空)。
*/
bool TakePendingMeanFilterBuild(int& laneIndex, std::vector<MeanFilterBuild>& items);
/**
* @brief 「实验性功能 → 弱峰调整」的「构建为新信号」单个请求
* (一个被勾选的信号 + **它自己的**增强等级与迭代次数,与均值滤波同构)。
*/
struct WeakPeakBuild
{
int groupIndex = -1; /**< 信号所属分组下标。 */
int signalIndex = -1; /**< 组内信号下标。 */
int amountPercent = 0; /**< 弱峰调整幅度(-400–400,默认 0 = 不改动)。 */
int iterations = 1; /**< 迭代次数 N(1–10)。 */
};
/**
* @brief 取出「弱峰调整 → 构建为新信号」请求(由面板底部按钮触发)。
* @param [OUT] laneIndex 触发该请求的网格下标。
* @param [OUT] items 需要构建的信号及其参数(按信号在网格内的顺序)。
* @return 存在待处理请求返回 true(取出后清空)。
*/
bool TakePendingWeakPeakBuild(int& laneIndex, std::vector<WeakPeakBuild>& items);
/**
* @brief 切换某网格的「弱峰调整」开关。
* @param [IN] laneIndex 网格下标。
* @param [IN] enabled 是否开启。
* @return 状态发生变化返回 true。
*/
bool SetLaneWeakPeak(int laneIndex, bool enabled);
/**
* @brief 查询某网格的「弱峰调整」开关。
* @param [IN] laneIndex 网格下标。
* @return 已开启返回 true。
*/
bool LaneWeakPeak(int laneIndex) const;
/**
* @brief 切换某网格的「弱峰调整」开关。
* @param [IN] laneIndex 网格下标。
* @return 切换后的状态。
*/
bool ToggleLaneWeakPeak(int laneIndex);
/**
* @brief 获取弱峰调整曲线(按「信号 + 增强等级 + 迭代次数」缓存)。
* @param [IN] signal 目标信号。
* @param [IN] amountPercent 弱峰调整幅度(-400–400,整数百分比)。
* @param [IN] iterations 迭代次数 N(1–10)。
* @return 曲线数值数组(长度与 `signal.values` 相同;缺失处为 NaN)。
*/
const std::vector<float>& WeakPeakCurveFor(const SignalData& signal, int amountPercent,
int iterations) const;
/**
* @brief 取某个时刻的弱峰调整曲线取值(用于纵向光标的取值标注)。
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @param [IN] amountPercent 弱峰调整幅度(-400–400)。
* @param [IN] iterations 迭代次数 N(1–10)。
* @param [IN] time 采样时刻。
* @param [OUT] value 输出取值。
* @return 取到有效值返回 true。
*/
bool WeakPeakValueAt(const TimeAxis& axis, const SignalData& signal, int amountPercent, int iterations,
double time, double& value) const;
/**
* @brief 切换某个信号在「弱峰调整」面板中的预览勾选状态。
* @param [IN] laneIndex 网格下标。
* @param [IN] ref 目标信号。
* @return 切换后的勾选状态。
*/
bool ToggleLaneWeakPeakSignal(int laneIndex, const SignalRef& ref);
/**
* @brief 查询某个信号在「弱峰调整」面板中的预览勾选状态。
* @param [IN] laneIndex 网格下标。
* @param [IN] ref 目标信号。
* @return 已勾选返回 true。
*/
bool LaneWeakPeakSignalEnabled(int laneIndex, const SignalRef& ref) const;
/**
* @brief 切换某网格的「自由标记」开关。
*
* 开启后该网格内跟随鼠标显示一条仅限本网格的纵向光标(上下各一个三角把手、
* 不显示时间读数),光标穿过的信号中**离鼠标最近**的那个会被标注取值;
* 左键原地单击即把该数据点固定下来。关闭时**保留**已固定的点(用户可再次开启
* 继续查看),需要清空请用面板上的 ✕。
*
* @param [IN] laneIndex 网格下标。
* @param [IN] enabled 是否开启。
* @return 状态发生变化返回 true。
*/
bool SetLaneFreeMarker(int laneIndex, bool enabled);
/**
* @brief 查询某网格的「自由标记」开关。
* @param [IN] laneIndex 网格下标。
* @return 已开启返回 true。
*/
bool LaneFreeMarker(int laneIndex) const;
/**
* @brief 切换某网格的「自由标记」开关。
* @param [IN] laneIndex 网格下标。
* @return 切换后的状态。
*/
bool ToggleLaneFreeMarker(int laneIndex);
/**
* @brief 设置某网格是否处于「自由标记点选取」(浮动面板底部的切换按钮)。
*
* 关闭(默认)= **普通鼠标**:不预览数据点、不画纵向光标、单击不固定任何点,
* 按下/拖动/单击完全是常规语义(平移 / 框选 / 拖信号)。
* 开启时网格内始终跟随鼠标画一条纵向光标,并在贴近曲线时预览/固定数据点。
*
* @param [IN] laneIndex 网格下标。
* @param [IN] enabled 是否启用「自由标记点选取」。
* @return 状态发生变化返回 true。
*/
bool SetLaneFreeMarkerCursor(int laneIndex, bool enabled);
/**
* @brief 查询某网格是否开启了「自由标记点选取」。
* @param [IN] laneIndex 网格下标。
* @return 启用返回 true(该网格未开启自由标记时恒为 false)。
*/
bool LaneFreeMarkerCursor(int laneIndex) const;
/**
* @brief 切换某网格的「自由标记点选取」。
* @param [IN] laneIndex 网格下标。
* @return 切换后的状态。
*/
bool ToggleLaneFreeMarkerCursor(int laneIndex);
/**
* @brief 设置某个网格「自由标记」固定点显示是否附带**括号内增量**。
*
* 只影响已固定点的取值胶囊(面板的「显示增量」开关),
* 不影响跟随鼠标的预览胶囊与统计列。
* @param [IN] laneIndex 网格下标。
* @param [IN] enabled 是否显示增量。
* @return 状态发生变化返回 true。
*/
bool SetLaneFreeMarkerShowDelta(int laneIndex, bool enabled);
/**
* @brief 设置某个网格「自由标记」固定点显示是否附带**枚举文本**。
*
* 只影响已固定点的取值胶囊(面板的「显示枚举文本」开关),
* 不影响跟随鼠标的预览胶囊与统计列。
* @param [IN] laneIndex 网格下标。
* @param [IN] enabled 是否显示枚举文本。
* @return 状态发生变化返回 true。
*/
bool SetLaneFreeMarkerShowEnum(int laneIndex, bool enabled);
/** 查询某网格固定点是否显示增量。 */
bool LaneFreeMarkerShowDelta(int laneIndex) const;
/** 查询某网格固定点是否显示枚举文本。 */
bool LaneFreeMarkerShowEnum(int laneIndex) const;
/**
* @brief 设置某网格「自由标记」固定点的**显示不透明度**(0–1)。
*
* 只影响网格内固定数据点的显示内容(数据点 / 取值胶囊 / ✕),
* 面板自身的像素与交互不受影响。
*/
bool SetLaneFreeMarkerOpacity(int laneIndex, float opacity);
/**
* @brief 设置某网格「自由标记」面板内容的滚动偏移(客户区像素,0 = 顶部)。
* @return 偏移真的发生变化返回 true。
*/
bool SetLaneFreeMarkerScroll(int laneIndex, float scroll);
/**
* @brief 切换某网格「自由标记」面板里某个信号分组的收起状态。
* @return 切换后为「已收起」返回 true。
*/
bool ToggleLaneFreeMarkerGroup(int laneIndex, const SignalRef& ref);
/**
* @brief 选中「自由标记」面板里的某个条目(`-1` = 取消选中)。
*
* 选中的行在面板里高亮;它与网格内的固定点一一对应。
* @param [IN] laneIndex 网格下标。
* @param [IN] itemIndex `Lane::freeMarkerItems` 下标(越界会被夹成“取消选中”)。
* @return 选中状态发生变化返回 true。
*/
bool SelectLaneFreeMarker(int laneIndex, int itemIndex);
/**
* @brief 切换某个实验性功能浮动面板的**折起**状态(只显示标题栏)。
* @param [IN] laneIndex 网格下标。
* @param [IN] kind 面板种类。
* @param [IN] collapsed 是否折起。
* @return 状态发生变化返回 true。
*/
bool SetLanePanelCollapsed(int laneIndex, EdgePanelKind kind, bool collapsed);
/**
* @brief 查询某个实验性功能浮动面板是否处于折起状态。
*/
bool LanePanelCollapsed(int laneIndex, EdgePanelKind kind) const;
/**
* @brief 切换某个实验性功能浮动面板的折起状态。
* @return 切换后的状态(true = 已折起)。
*/
bool ToggleLanePanelCollapsed(int laneIndex, EdgePanelKind kind);
/**
* @brief 直接把文本解析为网格 Y 轴上限/下限并应用(供菜单内联输入框提交使用)。
* @param [IN] laneIndex 网格下标。
* @param [IN] editMax true 写上限,false 写下限。
* @param [IN] text 输入文本。
* @return 解析并应用成功返回 true。
*/
bool ApplyLaneValueText(int laneIndex, bool editMax, const std::wstring& text);
/**
* @brief 查询某网格当前的 Y 轴显示范围。
* @param [IN] laneIndex 网格下标。
* @param [OUT] yMin 输出下限。
* @param [OUT] yMax 输出上限。
* @return 网格下标合法返回 true。
*/
bool LaneRange(int laneIndex, double& yMin, double& yMax) const;
/**
* @brief 判断是否处于任意内联数值输入状态。
* @return 处于编辑态返回 true。
*/
bool IsEditing() const override { return m_editTarget != PlotEditTarget::NONE; }
/**
* @brief 取出并清除待应用的信号重命名请求。
* @param [OUT] ref 被重命名的信号引用。
* @param [OUT] name 新的显示名称。
* @return 存在待处理请求返回 true。
* @note 绘图区只持有只读数据层,重命名需由宿主窗口落到 DataSet 上。
*/
bool TakePendingRename(SignalRef& ref, std::wstring& name);
/**
* @brief 设置「外部悬停信号」(信号列表 / 样式区「目标」行悬停到某条信号时调用)。
*
* 用户 2026-10-15 要求:
* * 鼠标**在网格里移动不再触发**网格变色高亮(旧行为已取消);
* * 改为**在信号列表里悬停某条信号**时,若该信号已勾选绘制到某个网格,
* 则该网格变色高亮,并且该信号曲线本身也高亮;鼠标移开即恢复。
*
* ⚠️ 与 `m_hoverSignal`(鼠标在绘图区内直接命中的信号)**分开保存**:绘图区的
* `OnMouseLeave` 会清掉后者,而列表悬停时鼠标恰恰不在绘图区内。
*
* @param [IN] ref 悬停的信号;无效引用表示取消外部悬停。
*/
void SetExternalHoverSignal(const SignalRef& ref);
/**
* @brief 当前外部悬停的信号(供验证 / 宿主查询)。
* @return 悬停的信号引用,无则无效。
*/
const SignalRef& ExternalHoverSignal() const { return m_externalHoverSignal; }
/**
* @brief 取出并清除「请把信号列表滚动到该信号」的联动请求。
*
* 用户 2026-10-15 要求:在绘图区触发联动(点击图例 / 点击信号曲线等把某条信号选为
* 当前目标)时,信号选择区域要**平滑滚动**到该信号并完整显示它 ——
* 信号很多时(如 MF4 的 124 条)否则找不见选中的那一条。
*
* @param [OUT] ref 需要滚动到的信号。
* @return 存在待处理的请求返回 true。
*/
bool TakePanelScrollRequest(SignalRef& ref);
/**
* @brief 把指定网格**平滑滚动**到完整可见(带动画,网格不在可视区时自动调节滚动条)。
*
* 用户 2026-10-16 要求:信号很多时(例如把 MF4 的上百条信号各自放进独立网格)
* 每个网格只占绘图区的一小段,绝大多数网格都在可视区之外 —— 在信号列表里点选一条
* 信号后,用户无法从画面上的高亮看出它究竟在哪个网格。因此点选信号时:
* * 目标网格在可视区**上方** ⇒ 向上滚动直到它的上边界进入绘图区上沿;
* * 目标网格在可视区**下方** ⇒ 向下滚动直到它的下边界进入绘图区下沿;
* * 已经完整可见 ⇒ 不动(避免无意义地改变视图)。
*
* ⚠️ 过渡必须是**带动画的平滑滚动**(与信号列表的“滚动到目标信号”同源:ease-out cubic、
* 时长 `clamp(距离×0.35ms, 130, 300)`),不是直接把视界瞬移过去 —— 用户 2026-10-16 明确要求。
* 动画由宿主 16ms 定时器驱动(`AdvanceLaneScrollAnimation`)。
*
* ⚠️ 只在**点击**信号时调用(悬停只做高亮联动,绝不改变视图)。
*
* @param [IN] laneIndex 网格下标。
* @return 开始了滚动(动画或已到位)返回 true;已完整可见则返回 false。
*/
bool ScrollLaneIntoView(int laneIndex);
/**
* @brief 推进一步网格滚动动画(由宿主定时器驱动)。
* @return 动画仍在进行返回 true(宿主应保持定时重绘)。
*/
bool AdvanceLaneScrollAnimation();
/** @brief 网格滚动动画是否正在进行。 */
bool LaneScrollAnimationActive() const { return m_laneScrollAnimActive; }
/**
* @brief 把网格滚动**平滑地**回到最上方(供右下角浮动按钮调用)。
* @return 开始了动画(或已在上方)返回 true。
*/
bool ScrollLanesToTop();
/**
* @brief 右下角「回到最上方」浮动按钮是否可见(含淡入淡出过程中的可见性)。
* @return 可见返回 true。
*/
bool BackToTopVisible() const { return m_backToTopAlpha > 0.01f; }
/**
* @brief 推进一步「回到最上方」按钮的淡入/淡出过渡(由宿主定时器驱动)。
* @return 仍在过渡中返回 true(宿主应保持定时重绘)。
*/
bool AdvanceBackToTopAnimation();
/** @brief 「回到最上方」按钮是否正在淡入/淡出。 */
bool BackToTopAnimationActive() const { return m_backToTopAnimating; }
/**
* @brief 取消网格滚动动画(用户自己滚动网格时调用,避免动画与手动滚动抢滚动位置)。
*/
void CancelLaneScrollAnimation() { m_laneScrollAnimActive = false; }
/**
* @brief 处理编辑态下的字符输入。
* @param [IN] character 输入字符。
* @return 界面状态发生变化返回 true。
*/
bool OnEditChar(wchar_t character);
/**
* @brief 处理编辑态下的按键。
* @param [IN] key 虚拟键码。
* @return 界面状态发生变化返回 true。
*/
bool OnEditKey(WPARAM key);
/**
* @brief 编辑态下按下鼠标右键:取消选区并把插入符移到文本末尾。
* @return 发生改变返回 true;不在编辑态返回 false。
*/
bool CollapseEditSelectionToEnd();
/**
* @brief 编辑态下在输入框内按下鼠标左键:定位插入符并开始拖拽框选。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 命中输入框返回 true。
*/
bool OnEditMouseDown(int x, int y);
/**
* @brief 编辑态下拖拽鼠标:扩展选区。
* @param [IN] x 客户区横坐标。
* @return 选区发生变化返回 true。
*/
bool OnEditMouseMove(int x);
/**
* @brief 编辑态下松开鼠标左键:结束拖拽框选。
* @return 之前处于拖拽中返回 true。
*/
bool OnEditMouseUp();
/**
* @brief 重新生成网格几何(可见性或网格划分变化后调用)。
*/
void RefreshLanes();
/**
* @brief 设置「前台正在播放 UI 动画」标志(信号列表滚动 / 分组折起展开动画)。
*
* ⚠️ 为 true 时**暂停网格图层预热**(`WarmLaneLayersStep`):预热每帧要重画 1 个网格图层,
* 而单个巨型信号(2500 万采样)的图层重建实测 **10~30ms** —— 它会直接把 60fps 的
* 动画打成一帧一跳(用户 2026-10-18 报告:信号列表只有文字,滚动/展开却明显卡顿)。
* 动画结束后预热会自然继续(待重画队列不丢),因此只是"推迟",不改变最终画面。
* @param [IN] busy 是否正在播放动画。
*/
void SetAnimationBusy(bool busy) { m_animationBusy = busy; }
private:
/** @brief 前台是否正在播放 UI 动画(见 `SetAnimationBusy`)。 */
bool m_animationBusy = false;
public:
/**
* @brief 单个网格的绘制信息。
*/
struct LaneVisual
{
gd::RectF rect; /**< 网格波形区域。 */
double yMin = 0.0; /**< 幅值下限。 */
double yMax = 1.0; /**< 幅值上限。 */
};
/**
* @brief 已解析的信号视图:数据与显示配置的组合。
*/
struct ResolvedSignal
{
const SignalData* data = nullptr; /**< 信号数据(含全局共享的显示外观)。 */
const SignalStyle* style = nullptr; /**< 显示外观。 */
const SignalGroup* group = nullptr; /**< 信号所属分组(仅用于分组级信息)。 */
const TimeAxis* axis = nullptr; /**< 该信号使用的时间轴(采样值 ↔ 时刻的唯一依据)。 */
bool visible = false; /**< 在当前标签页中是否可见。 */
};
/**
* @brief 获取当前生效的标签页配置(未绑定时返回内部占位对象)。
* @return 标签页配置引用。
*/
TabConfig& Tab() { return (m_tab != nullptr) ? *m_tab : m_localTab; }
/**
* @brief 获取当前生效的标签页配置(只读)。
* @return 标签页配置引用。
*/
const TabConfig& Tab() const { return (m_tab != nullptr) ? *m_tab : m_localTab; }
/**
* @brief 判断是否存在可绘制内容(已加载数据且至少有一条可见信号)。
* @return 可绘制返回 true。
*/
bool HasDrawableContent() const;
/**
* @brief 计算当前视图应使用的时间范围。
* @param [OUT] low 时间下限。
* @param [OUT] high 时间上限。
*/
void TimeBounds(double& low, double& high) const;
/**
* @brief 解析信号引用,得到数据与显示配置。
* @param [IN] ref 信号引用。
* @return 解析结果,引用无效时各指针为空。
*/
ResolvedSignal ResolveSignal(const SignalRef& ref) const;
/**
* @brief 判断某网格内的可见信号是否来自多个分组。
* @param [IN] laneIndex 网格下标。
* @return 跨越多个分组返回 true。
*/
bool LaneSpansMultipleGroups(int laneIndex) const;
/**
* @brief 获取当前选中信号(跨标签页共享,切换视图后选中项保持一致)。
* @return 信号引用。
*/
const SignalRef& SelectedRef() const;
/**
* @brief 设置当前选中信号(跨标签页共享)。
* @param [IN] ref 信号引用。
*/
void SelectSignal(const SignalRef& ref);
/**
* @brief 重新计算各网格的布局与幅值范围。
*/
void BuildLaneVisuals();
/**
* @brief 限制网格滚动偏移量。
*/
void ClampLaneScroll();
/**
* @brief 将时间映射为绘图区横坐标。
* @param [IN] time 时间值。
* @return 横坐标。
*/
float TimeToX(double time) const;
/**
* @brief 将绘图区横坐标还原为时间。
* @param [IN] x 横坐标。
* @return 时间值。
*/
double XToTime(float x) const;
/**
* @brief 将幅值映射为纵坐标。
* @param [IN] value 幅值。
* @param [IN] lane 网格绘制信息。
* @return 纵坐标。
*/
float ValueToY(double value, const LaneVisual& lane) const;
/**
* @brief 将纵坐标还原为幅值。
* @param [IN] y 纵坐标。
* @param [IN] lane 网格绘制信息。
* @return 幅值。
*/
double YToValue(float y, const LaneVisual& lane) const;
/**
* @brief 查询指定纵坐标落在哪个网格内。
* @param [IN] y 纵坐标。
* @return 网格下标,未命中返回 -1。
*/
int LaneAtY(int y) const;
/**
* @brief 限制时间视图范围,避免过度平移到数据之外。
*/
void ClampTimeView();
/**
* @brief 以指定锚点缩放时间轴。
* @param [IN] factor 缩放倍率,大于 1 表示放大。
* @param [IN] anchorTime 锚点时间。
*/
void ZoomTimeAt(double factor, double anchorTime);
/**
* @brief 将时间轴缩放到指定的时间区间(拖拽选区释放时调用)。
* @param [IN] x0 选区起点横坐标。
* @param [IN] x1 选区终点横坐标。
* @return 产生了有效缩放返回 true。
*/
bool ApplyTimeSelection(float x0, float x1);
/**
* @brief 将某个网格的幅值轴缩放到指定区间(拖拽选区释放时调用)。
* @param [IN] laneIndex 目标网格下标。
* @param [IN] y0 选区起点纵坐标。
* @param [IN] y1 选区终点纵坐标。
* @return 产生了有效缩放返回 true。
*/
bool ApplyValueSelection(int laneIndex, float y0, float y1);
/**
* @brief 平移某网格的幅值轴(偏移波形显示位置)。
* @param [IN] laneIndex 目标网格下标。
* @param [IN] deltaValue 幅值偏移量。
*/
void ShiftLaneRange(int laneIndex, double deltaValue);
/**
* @brief 计算指定时间光标当前的横坐标。
* @param [IN] which 光标序号(1 或 2)。
* @return 横坐标。
*/
float CursorX(int which) const;
/**
* @brief 计算指定光标在屏幕上的**显示**横坐标(滞留在可视区外时贴到网格边缘)。
* @param [IN] which 光标序号(1 或 2)。
* @return 显示横坐标。
*/
float CursorDisplayX(int which) const;
/**
* @brief 计算指定光标时间读数标签的矩形。
* @param [IN] which 光标序号(1 或 2)。
* @return 标签矩形,位于绘图区底部(不遮挡网格内数据点标注)。
*/
gd::RectF CursorLabelRect(int which) const;
/**
* @brief 判断给定横坐标是否落在某条时间光标附近(允许拖拽的容差范围)。
* @param [IN] x 横坐标。
* @param [OUT] which 命中的光标序号(1 或 2),未命中时为 0。
* @return 命中返回 true。
*/
bool IsOnCursorLine(int x, int& which) const;
/**
* @brief 将两条光标的时间夹取到当前可见时间范围内。
*
* ⚠️ **仅在拖拽光标时使用**。视图变化(平移 / 缩放 / 自适应 / 滚轮 / 方向键)**一律不**调用:
* 光标是一个固定的**时间位置**,视界移动时它必须留在原处(可以滑出可视区),
* 此时仅把它的取值/时间读数贴在网格边缘显示。
*
* @param [IN] which 要夹取的光标序号(1 或 2)。
*/
void ClampCursorToView(int which);
/**
* @brief 计算当前数据下**最小可用的时间跨度**(时间视界的下限)。
*
* 下限由两件事共同决定:
* * 数据本身:`数据跨度 × 1e-9`(与滚轮缩放一致)——小于它就是把视界缩进采样间隔内部;
* * `double` 的可表示精度:视界宽度小于 `ULP(时间) × 画布宽度` 时,
* 「像素 → 时间」的映射会退化(相邻像素映射到同一个 double),
* 鼠标每动一下只能落在少数几个可表示的时间值上,
* 表现为**光标跟不上鼠标、一拖就跨几百像素或直接贴到视界边缘**。
*
* @param [IN] low 视界下限(用于估算时间量级)。
* @param [IN] high 视界上限。
* @return 最小可用跨度。
*/
double MinimumTimeSpan(double low, double high) const;
/**
* @brief **时间视界的唯一写入入口**(所有不变量的守门人)。
*
* 负责:拒绝非有限值、纠正左右颠倒、把跨度抬到 `MinimumTimeSpan` 之上、
* 以及最后按数据范围做边界收敛。
*
* ⚠️ 缩放(滚轮 / 选区放大)、平移、适应视图都必须经过这里:
* 过去每个路径各自维护“跨度下限”,新增的路径(如选区放大)漏掉了这件事,
* 于是“能一直放大到视界只有几个 ULP”这类问题反复出现。
*
* @param [IN] low 新的视界下限。
* @param [IN] high 新的视界上限。
*/
void SetTimeView(double low, double high);
/**
* @brief **光标时间位置的统一写入入口**(拖拽光标时必须经由此处)。
*
* 负责:拒绝非有限值、按 `SnapTimeToSample` 吸附加视界内采样点、
* 以及把结果夹回可见区间。三步缺一不可——光标必须在**可见区间内**且与鼠标位置对应,
* 否则用户看到的是“拖拽时光标自己飞到视界边缘”。
*
* @param [IN] which 光标序号(1 或 2)。
* @param [IN] time 目标时间(鼠标位置换算)。
*/
void PlaceCursorTime(int which, double time);
/**
* @brief 计算双光标之间的时间差(绝对值)。
* @return 时间差;非双光标模式返回 0。
*/
double CursorSpan() const;
/**
* @brief 判断指定光标是否滞留在可视区之外(时间读数被钉在网格边缘)。
* @param [IN] which 光标序号(1 或 2)。
* @param [OUT] offRight true 表示偏出**右**侧视界,false 表示偏出左侧。
* @return 越界返回 true;光标在视界内返回 false。
*/
bool CursorOffViewSide(int which, bool& offRight) const;
/**
* @brief 在读数胶囊上方绘制"数据在视界之外"的方向三角指示。
* @param [IN] labelRect 胶囊矩形(三角与其水平居中对齐)。
* @param [IN] offRight true 表示指向右侧(光标在右视界之外)。
* @param [IN] color 三角颜色(与胶囊同色)。
*/
void PaintCursorOffViewTip(gd::Graphics& g, const gd::RectF& labelRect, bool offRight,
std::uint32_t color);
/**
* @brief 计算双光标差值标签的矩形(时间轴上方、两条时间读数的中间位置)。
* @param [OUT] insideAxis 为 true 时表示两条光标过近,标签落在主时间轴内部。
* @return 标签矩形;非双光标模式返回空矩形。
*/
gd::RectF CursorSpanRect(bool& insideAxis) const;
/**
* @brief 获取指定光标对应的显示颜色。
* @param [IN] which 光标序号(1 或 2)。
* @return 颜色。
*/
std::uint32_t CursorColor() const;
/**
* @brief 设置并显示一条临时提示(在绘图区底部以浮层方式绘制,短暂停留后淡出)。
* @param [IN] text 提示文本;传空串则立即清除提示。
*/
void SetHint(const std::wstring& text);
/**
* @brief 设置一条"粘滞"提示:直到 `ReleaseHint()` 为止不会淡出。
*
* 用于"拖动过程中维持显示"的场景(例如选区拖拽时的右键/ESC 说明)。
*
* @param [IN] text 提示文本。
*/
void SetHintSticky(const std::wstring& text);
/**
* @brief 结束粘滞状态,并从这里开始重新计时(停留后淡出)。
*/
void ReleaseHint();
/**
* @brief 判断坐标是否落在某条光标的**时间读数标签**上。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @param [OUT] which 命中的光标序号(1 或 2),未命中时为 0。
* @return 命中返回 true。
*/
bool IsOnCursorTimeLabel(int x, int y, int& which) const;
/**
* @brief 判断坐标是否落在双光标差值标签上。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 命中返回 true。
*/
bool IsOnCursorSpanLabel(int x, int y) const;
/**
* @brief 画文字时给底色胶囊加一层细描边,提升与背景/曲线的对比度。
* @param [IN] rect 胶囊矩形。
* @param [IN] radius 圆角半径。
* @param [IN] outlineColor 描边色(由 `LabelInkFor` 与文字色一并算出的反相色)。
*/
void DrawLabelOutline(gd::Graphics& g, const gd::RectF& rect, float radius,
std::uint32_t outlineColor);
/**
* @brief 精确检测鼠标位置是否命中某条可见信号曲线。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 命中的信号引用,未命中返回无效引用。
*/
SignalRef HitTestSignal(int x, int y) const;
/**
* @brief 计算当前内联输入框的矩形(依编辑目标而定)。
* @return 输入框矩形,未处于编辑态时返回空矩形。
*/
gd::RectF EditFieldRect() const;
/**
* @brief 以给定目标与初始文本进入内联输入状态。
* @param [IN] target 编辑目标。
* @param [IN] laneIndex 目标网格下标(光标编辑时忽略)。
* @param [IN] initialText 初始文本。
* @return 进入成功返回 true。
*/
bool BeginEdit(PlotEditTarget target, int laneIndex, const std::wstring& initialText,
const gd::RectF& anchorRect = gd::RectF());
/**
* @brief 结束内联输入。
* @param [IN] commit 是否提交当前输入。
* @return 状态发生变化返回 true。
*/
bool EndEdit(bool commit);
/**
* @brief 构建单条信号在当前视图下的折线点序列。
* @param [IN] resolved 已解析的信号视图。
* @param [IN] lane 所属网格绘制信息。
* @param [OUT] points 输出点序列。
* @param [OUT] breaks 输出每段折线的起始下标(用于处理缺失值断线)。
*/
void BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane,
std::vector<gd::PointF>& points, std::vector<std::size_t>& breaks,
std::vector<gd::PointF>& band, std::vector<std::size_t>& bandBreaks,
bool& bandMode, bool& bandSpiky) const;
/**
* @brief 绘制单个网格。
* @param [IN,OUT] g 绘图上下文。
* @param [IN] laneIndex 网格下标。
* @param [IN] lane 网格绘制信息。
* @param [IN] timeTicks 时间刻度序列。
*/
void PaintLane(gd::Graphics& g, int laneIndex, const LaneVisual& lane,
const std::vector<double>& timeTicks);
/**
* @brief 绘制某网格的全部**派生预览曲线**(均值滤波 / 中心轴 / 弱峰调整),叠加在原信号之上。
*
* 从 `PaintLane` 抽出,供「宽缓冲区」前台路径复用:宽缓冲只存"基底"(网格/波形),
* 派生曲线每帧作为叠加层单独绘制,因此拖派生参数滑块时无需重画波形。
*
* @param [IN,OUT] g 绘图上下文。
* @param [IN] laneIndex 网格下标。
* @param [IN] lane 网格绘制信息。
*/
void PaintLaneDerivedOverlays(gd::Graphics& g, int laneIndex, const LaneVisual& lane) const;
/**
* @brief 绘制底部时间轴。
* @param [IN,OUT] g 绘图上下文。
* @param [IN] timeTicks 时间刻度序列。
* @param [IN] timeStep 时间刻度步长。
*/
void PaintTimeAxis(gd::Graphics& g, const std::vector<double>& timeTicks, double timeStep);
/**
* @brief 绘制时间光标、选区及其读数。
* @param [IN,OUT] g 绘图上下文。
* @param [IN] lanes 网格绘制信息集合。
*/
void PaintCursor(gd::Graphics& g, const std::vector<LaneVisual>& lanes);
/**
* @brief 绘制「边沿计数」的横向光标(含两端把手、左侧幅值读数)与统计浮动面板。
* @param [IN,OUT] g 绘图上下文。
* @param [IN] lanes 网格绘制信息集合。
*/
void PaintEdgeCursor(gd::Graphics& g, const std::vector<LaneVisual>& lanes);
/**
* @brief 确保面板图层缓存位图可用(尺寸不符时重建)。
* @return 可用返回 true。
*/
bool EnsurePanelCache();
/** @brief 释放面板图层缓存位图。 */
void ReleasePanelCache();
/**
* @brief 计算「全局」面板缓存键。
*
* 它涵盖所有面板共同依赖的状态(几何、缩放、主题、光标、选区、悬停、统计区间等);
* 一旦变化,所有面板缓存一律作废(整层重画)。
*/
std::uint64_t PanelGlobalKey() const;
/**
* @brief 判断坐标是否命中某网格的「边沿计数」横向光标线。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @param [OUT] laneIndex 命中的网格下标。
* @return 命中返回 true。
*/
bool IsOnEdgeCursorLine(int x, int y, int& laneIndex) const;
/**
* @brief 实验性功能浮动面板的几何布局(三类面板共用同一套样式)。
*/
struct EdgePanelLayout
{
gd::RectF panel; /**< 面板整体矩形。 */
gd::RectF title; /**< 标题栏(**唯一可拖拽区域**)。 */
gd::RectF close; /**< ✕ 关闭按钮。 */
/**
* @brief 标题栏左侧的「展开 / 折起」三角指示器(三角 + 后方一条横线)。
*
* 单击它切换面板的折起状态;折起后面板只剩标题栏(内容区完全省略)。
* 它位于「GX」编号徽标的**左边**。
*/
gd::RectF disclosure;
/** 面板当前是否处于折起状态(只显示标题栏)。 */
bool collapsed = false;
/**
* @brief 标题栏左侧的「GX」编号徽标。
*
* 它是**绘制与命中的唯一几何来源**:悬停提示、手型光标、以及
* “点击把该网格滚动到视界内”都依赖它(用户 2026-10-19 要求)。
* 面板未展开时为空矩形。
*/
gd::RectF badge;
gd::RectF edit; /**< 面板底部编辑行(无编辑内容时宽度为 0)。 */
gd::RectF button; /**< 面板底部按钮(无按钮时宽度为 0)。 */
int rows = 0; /**< 统计行数(每条可见信号一行)。 */
/**
* @brief 「逐条目」面板的矩形(MEAN / CENTER / PEAK 面板共用同一套排布)。
*
* 索引与 `rowRefs` 一一对应(即网格内可见信号的顺序):
* `rowChecks` 为第一排右侧的复选框;`rowPoints` / `rowIterations` 为**输入框**
*(MEAN / CENTER 使用);`rowStats` 为第三排的统计结果(中心轴边沿计数专用)。
* 未勾选的条目对应的矩形均为空。
*/
std::vector<gd::RectF> rowChecks;
std::vector<gd::RectF> rowPoints;
std::vector<gd::RectF> rowIterations;
std::vector<gd::RectF> rowStats;
/**
* @brief 「弱峰调整」每个被勾选信号自己的两行**滑块**(与 `rowRefs` 同序)。
*
* `rowLevelTracks/rowLevelValues` = 增强等级滑块与它右侧的数值文本;
* `rowIterTracks/rowIterValues` = 迭代次数滑块与它右侧的数值文本
*(**点值数值文本可以把滑块上限改为别的正整数**)。
* 未勾选或非 PEAK 面板时对应元素为空矩形(数组也可能为空)。
*/
std::vector<gd::RectF> rowLevelTracks;
std::vector<gd::RectF> rowLevelValues;
std::vector<gd::RectF> rowIterTracks;
std::vector<gd::RectF> rowIterValues;
/**
* @brief 「弱峰调整」每行滑块右侧的**复位按钮**矩形(与 `rowLevelTracks` 同序)。
*
* 点击把该行参数恢复为默认值(幅度 0 / 迭代次数 1 且上限回到初始上限)。
* 未勾选或非 PEAK 面板时对应元素为空矩形(数组也可能为空)。
*/
std::vector<gd::RectF> rowLevelResets;
std::vector<gd::RectF> rowIterResets;
std::vector<SignalRef> rowRefs;
/**
* @brief 「自由标记」面板的一行(一个固定点)。
*
* `itemIndex` 是 `Lane::freeMarkerItems` 的下标(绘制文本、增删都通过它定位,
* 不依赖面板里的显示顺序);`rect` 是整行矩形(悬停/双击编辑时间用),
* `close` 是该行末尾的 ✕ 按钮。
*/
struct MarkerRow
{
std::size_t itemIndex = 0;
/**
* @brief 该固定点在**本分组内**的序号(从 1 开始,组内按时间升序)。
*
* 面板最左侧「序号」列直接显示它;按分组重置(每组 = 一个信号)。
*/
std::size_t serial = 0;
gd::RectF rect;
gd::RectF close;
};
/** 「自由标记」:每个固定点一行(按信号分组、组内按时间升序展开后的顺序)。 */
std::vector<MarkerRow> markerRows;
/**
* @brief 「自由标记」的一个**信号分组栏**(以信号色为托底底色)。
*
* 左侧 = 展开/收起三角 + 信号名;右侧 = 标记点个数(`N 标记点`)+ ✕
*(清除该信号全部固定点)。单击分组栏(✕ 除外)切换收起状态。
* 只有至少有一个固定点的信号才会有分组栏。
*/
struct MarkerGroup
{
SignalRef ref;
gd::RectF header; /**< 分组栏托底矩形。 */
/**
* @brief 分组栏下方的一行**列表头**(取值 / 枚举文本 / 增量 / 时间点 / 时间间隔)。
*
* 与条目列共用 `markerColumns` 的列边界 ⇒ 表头与数据严格对齐。
* 分组被收起(或还没登记)时为空矩形。
* ⚠️ 滚动时它可能与分组栏一起**悬浮**在内容区顶部(吸顶),
* 因此它的 Y 不一定是“分组栏 Y + 行高”。
*/
gd::RectF tableHeader;
gd::RectF triangle; /**< 展开/收起三角的命中矩形。 */
gd::RectF close; /**< 右侧✕的命中矩形。 */
bool collapsed = false;
/**
* @brief 该分组栏是否处于**吸顶**(滚动时被钉在内容区顶部)。
*
* 吸顶时分组栏与表头会盖住正在向上滚的条目 ⇒ 绘制侧必须把它们画在**条目之后**
* (否则会被条目覆盖),交互侧也已把这些被盖住的条目从 `markerRows` 里剔除。
*/
bool pinned = false;
};
/** 「自由标记」:信号分组栏(顺序与面板显示顺序一致)。 */
std::vector<MarkerGroup> markerGroups;
/**
* @brief 「自由标记」面板的**可滚动内容区**(分组栏 + 固定点条目)。
*
* 内容高度超过它时出现上下滚动条(不参与滚动的按钮与透明度滑块在它下方)。
*/
gd::RectF content;
/** 「自由标记」面板的**内容总高度**(含被滚动出去的条目),用于换算滚动比例。 */
float markerContentHeight = 0.0f;
gd::RectF scrollTrack; /**< 内容区右侧的滚动条轨道(不需要滚动时为空矩形)。 */
gd::RectF scrollThumb; /**< 滚动条滑块。 */
gd::RectF opacityTrack; /**< 「标记透明度」滑动条轨道(总是显示,与选取模式无关)。 */
gd::RectF opacityValue; /**< 「标记透明度」数值文本矩形。 */
/**
* @brief 「显示增量」开关按钮(位于「自由标记点选取」下方那一行的左半)。
*
* 它只控制**已固定点**的取值胶囊是否附带括号内增量,
* 不影响跟随鼠标的预览胶囊,也不影响下面的统计列。
*/
gd::RectF showDelta;
/** @brief 「显示枚举文本」开关按钮(同一行的右半)。 */
gd::RectF showEnum;
/**
* @brief 「自由标记」:每个信号名右侧的「清除该信号全部固定点」✕。
*
* 与 `rowRefs` 一一对应;该信号没有任何固定点时为空矩形。
*/
std::vector<gd::RectF> rowNameCloses;
/**
* @brief 「自由标记」:每个信号**名称行**的矩形(与 `rowRefs` 一一对应)。
*
* 绘制只读这个矩形(与 `rowNameCloses` 同一套横坐标),人为主观反推容易与布局漂移。
*/
std::vector<gd::RectF> rowNameRects;
/**
* @brief 「自由标记」面板的**六列**文本区
* (① 序号 ② 标记值 ③ 枚举文本 ④ 增量 ⑤ 时间点 ⑥ 时间间隔)。
*
* 每行都在这六列的**同一横坐标**上左对齐输出,因此同一信息在纵向上严格对齐;
* 相邻列之间画一条竖线作为分隔。
* 列宽取「缺省下限」「实测内容宽度」「列标题宽度」三者的较大者;
* 当整个面板里没有任何枚举文本时,③ 枚举列的宽度为 0(整列缺省)。
*/
struct MarkerColumn
{
float left = 0.0f;
float width = 0.0f;
};
std::array<MarkerColumn, 6> markerColumns{};
/** 面板里是否存在枚举文本(为 false 时 ③ 列整列缺省)。 */
bool markerHasEnumColumn = false;
/** 六列的语义下标(`markerColumns` 的索引)。 */
enum MarkerColumnIndex
{
MARKER_COL_SERIAL = 0,
MARKER_COL_VALUE = 1,
MARKER_COL_ENUM = 2,
MARKER_COL_DELTA = 3,
MARKER_COL_TIME = 4,
MARKER_COL_DT = 5
};
};
/**
* @brief 计算某个实验性功能面板的布局。
* @param [IN] laneIndex 网格下标。
* @param [IN] kind 面板种类。
* @return 布局;面板未展开时各项均为空矩形。
*/
EdgePanelLayout EdgePanelLayoutFor(int laneIndex, EdgePanelKind kind) const;
/**
* @brief 「自由标记」跟随光标的解析结果(每次鼠标移动/每帧重算,绘制与命中共用)。
*
* 它描述「鼠标此刻所在的、开启了自由标记的网格」里应当标注的那个数据点:
* 该网格内所有可见信号中**离鼠标最近**的那一个。
*
* 两种情形由 `Lane::freeMarkerCursor` 决定:
* * **未开启「自由标记点选取」**:**没有结果**(`valid == false`)—— 鼠标就是普通鼠标,
* 不预览数据点、不画纵向光标、单击也不固定任何点(用户 2026-10-23 要求);
* * **已开启「自由标记点选取」**:网格内始终 `valid == true`(画纵向光标),但只有鼠标
* **贴近某条信号曲线**时才有可固定的数据点(`ref` 有效)。⚠️ 判据 = 鼠标到**画出来的折线**
* 的垂直距离 ≤ `kSignalHitTolerance`(与「常规状态下触发信号曲线高亮」**完全同一范围**;
* 因此阶跃的竖直跳变段也能探测到)。命中后标注时刻会被吸附到最近的**采样时刻**
* (固定点必须落在真实数据点上),但“已吸附”圆环只在命中「自动点线」稀疏判据时画。
*/
struct FreeMarkerProbe
{
bool valid = false; /**< 是否有结果(开启选取模式后恒真;`ref` 才是可固定的数据点)。 */
bool cursorMode = false; /**< 该网格是否处于「自由标记点选取」(决定是否画纵向光标)。 */
int laneIndex = -1; /**< 该网格下标。 */
double time = 0.0; /**< 标注时刻(显示时刻;吸附时 = 采样时刻)。 */
float x = 0.0f; /**< 该时刻的屏幕横坐标。 */
SignalRef ref; /**< 就近信号(网格内没有可用信号时无效)。 */
double value = 0.0; /**< 已叠加「调整」变换的取值(用于标注与增量)。 */
double enumValue = 0.0; /**< 采样原值(用于查枚举表)。 */
float y = 0.0f; /**< 数据点的屏幕纵坐标。 */
bool snapped = false; /**< 是否吸附到了采样点(需要给出吸附提示)。 */
};
/**
* @brief 解析「自由标记」跟随光标应当标注的数据点。
* @param [IN] mouseX 鼠标客户区横坐标。
* @param [IN] mouseY 鼠标客户区纵坐标。
* @return 解析结果;鼠标不在任何开启该功能的网格内时 `valid == false`。
*/
FreeMarkerProbe ProbeFreeMarker(int mouseX, int mouseY) const;
/**
* @brief 绘制「自由标记」:跟随光标 + 全部已固定点(含各自的 ✕)。
*
* 同时重建 `m_freeMarkerHits`(固定点 ✕ 的命中矩形),供鼠标悬停与点击使用。
*/
void PaintFreeMarker(gd::Graphics& g, const std::vector<LaneVisual>& lanes);
/** @brief 一个固定点 ✕ 的命中矩形(绘制时重建,命中测试时读取)。 */
struct FreeMarkerHit
{
int laneIndex = -1;
std::size_t itemIndex = 0;
gd::RectF close;
};
/**
* @brief 「自由标记」某个固定点的**内容**(取值与各种文本)。
*
* 这些值与**视界无关**(只取决于采样数据与信号样式),因此单独缓存:
* 平移视界、拖动时间光标时不需要重新采样/重新格式化。
* 面板的五列与网格内的取值胶囊共用这一份内容。
*/
struct FreeMarkerContentEntry
{
std::size_t itemIndex = 0; /**< `Lane::freeMarkerItems` 下标。 */
bool valid = false; /**< 取值是否有效(无效时全部文本为 `N/A`)。 */
double value = 0.0; /**< 绘制用取值(用于算胶囊/圆点的纵向位置)。 */
std::wstring capsuleText; /**< 网格内取值胶囊的文本(`值(±增量) = 枚举`)。 */
std::wstring valueText; /**< 面板:取值列。 */
std::wstring enumText; /**< 面板:枚举列(可能是空串)。 */
std::wstring deltaText; /**< 面板:增量列。 */
std::wstring timeText; /**< 面板:时刻列。 */
std::wstring deltaTimeText; /**< 面板:间隔(Δt)列。 */
std::uint32_t color = 0; /**< 信号色(信号样式里的颜色,已乘不透明度)。 */
};
/** 一个网格的「自由标记」内容缓存(`key` 只含与视界无关的输入)。 */
struct FreeMarkerContentCache
{
std::uint64_t key = 0;
std::vector<FreeMarkerContentEntry> entries;
};
/**
* @brief 取某个网格的「自由标记」内容列表(必要时重建,**与视界无关**)。
*
* ⚠️ 性能硬约束:大量固定点时,逐帧重新 `SampleDisplayedSignalAt` + 拼字符串
* 是“拖动全局光标/移动视界变卡”的主因。内容只依赖数据与样式,因此缓存起来;
* 命中缓存时每帧只做几何与绘制。
*/
const std::vector<FreeMarkerContentEntry>& FreeMarkerContents(int laneIndex) const;
/** 按网格下标的「自由标记」内容缓存(`mutable`:只在绘制时按需重建)。 */
mutable std::vector<FreeMarkerContentCache> m_freeMarkerContent;
/**
* @brief 计算某个网格里「第 index 个固定点」的标注几何(绘制与命中共源)。
*
* @param [IN] laneIndex 网格下标。
* @param [IN] itemIndex `Lane::freeMarkerItems` 下标。
* @param [OUT] label 取值胶囊矩形(**不含** ✕)。
* @param [OUT] close ✕ 按钮矩形。
* @param [OUT] text 取值文本(`值(±增量) = 枚举`,绘制侧直接复用,避免重复求解)。
* @return 该固定点可显示(在视界内、取值有效)返回 true。
*/
bool FreeMarkerLabelRects(int laneIndex, std::size_t itemIndex, gd::RectF& label,
gd::RectF& close, std::wstring& text) const;
/**
* @brief 画一个「取值胶囊」(信号色底 + 高对比度文字 + 可选 ✕ 按钮)。
*
* 网格内的固定点与跟随鼠标的预览点共用这一段(两者外观规则完全相同)。
*
* @param [IN,OUT] g 绘图上下文。
* @param [IN] label 胶囊矩形。
* @param [IN] text 文本。
* @param [IN] color 信号色。
* @param [IN] close ✕ 按钮矩形(宽度为 0 表示不画)。
* @param [IN] closeHot ✕ 是否处于悬停态。
* @param [IN] alphaScale 整体不透明度系数(1 = 不透明;固定点受「标记透明度」控制)。
* @param [IN] vectorText 强制用 GDI+(`DrawString`)画文字,即使完全不透明。
* ⚠️ GDI `DrawTextW`(`UiText`)**不认 GDI+ 裁剪区**;胶囊压在网格的 GX 标签 /
* 图例上时必须走 GDI+,否则文字会“穿透”到图例上方(用户 2026-10-27 要求标记点
* 显示在 GX 标签与图例之下)。正常情形仍用 `UiText`(与既有像素保持一致)。
*/
void PaintFreeMarkerCapsule(gd::Graphics& g, const gd::RectF& label, const std::wstring& text,
std::uint32_t color, const gd::RectF& close, bool closeHot,
float alphaScale = 1.0f, bool vectorText = false);
/**
* @brief 计算某个固定点相对**同信号**前一个固定点的增量与其时间间隔。
*
* @param [IN] lane 目标网格。
* @param [IN] itemIndex `Lane::freeMarkerItems` 下标。
* @param [IN] value 当前点的取值(已叠加「调整」变换)。
* @param [OUT] hasPrev 是否存在前一个同信号固定点。
* @param [OUT] delta 取值增量(当前 − 前一个)。
* @param [OUT] timeDelta 时间间隔(当前 − 前一个,恒为正)。
* @return 当前点取值有效返回 true。
*/
bool FreeMarkerDelta(const Lane& lane, std::size_t itemIndex, double value, bool& hasPrev,
double& delta, double& timeDelta) const;
/**
* @brief 计算某个固定点的取值(已叠加「调整」变换)。
* @param [IN] lane 目标网格。
* @param [IN] item 固定点。
* @param [OUT] value 输出取值(已含界面变换)。
* @param [OUT] enumValue 输出采样原值(用于查枚举表)。
* @return 取到有效值返回 true。
*/
bool FreeMarkerValue(const Lane& lane, const Lane::FreeMarkerItem& item, double& value,
double& enumValue) const;
/**
* @brief 在面板布局里找出某个信号对应的「逐条目」行下标。
* @param [IN] layout 面板布局。
* @param [IN] ref 目标信号。
* @return 行下标(与 `rowRefs` 同序);未找到返回 -1。
*/
int PeakRowIndexOf(const EdgePanelLayout& layout, const SignalRef& ref) const;
/**
* @brief 计算单个面板的缓存键(全局键之外,面板自身的内容)。
* @param [IN] laneIndex 面板所属网格。
* @param [IN] kind 面板种类。
* @param [IN] layout 面板布局。
* @return 缓存键。
*/
std::uint64_t PanelSlotKey(int laneIndex, EdgePanelKind kind,
const EdgePanelLayout& layout) const;
/**
* @brief 判断某个矩形是否完全落在面板图层位图之内。
* @param [IN] rect 目标矩形(客户区坐标)。
* @return 完全落在图层内返回 true。
*/
bool PanelRectInsideLayer(const gd::RectF& rect) const;
/**
* @brief 获取多阶(迭代)均值滤波曲线,结果带缓存。
*
* 语义:以「均值点数 X」作滑平半径,对输入重复做 `iterations` 次滑动平均。
* 「实验性功能 → 均值滤波」与「中心轴边沿计数」共用本函数,二者曲线因此逐位一致。
*
* @param [IN] signal 目标信号。
* @param [IN] points 均值点数 X(滑平半径)。
* @param [IN] iterations 迭代次数 N(至少 1)。
* @return 曲线数值数组(长度与 `signal.values` 相同;缺失处为 NaN)。
*/
const std::vector<float>& MeanFilterCurveFor(const SignalData& signal, int points,
int iterations) const;
/**
* @brief 取某个时刻的多次迭代均值滤波曲线取值(用于纵向光标的取值标注)。
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @param [IN] points 均值点数 X。
* @param [IN] iterations 迭代次数 N。
* @param [IN] time 采样时刻。
* @param [OUT] value 输出取值。
* @return 取到有效值返回 true。
*/
bool MeanFilterValueAt(const TimeAxis& axis, const SignalData& signal, int points, int iterations,
double time, double& value) const;
/**
* @brief 切换某个信号在「均值滤波」面板中的预览勾选状态。
* @param [IN] laneIndex 网格下标。
* @param [IN] ref 目标信号。
* @return 切换后的勾选状态。
*/
bool ToggleLaneMeanFilterSignal(int laneIndex, const SignalRef& ref);
/**
* @brief 查询某个信号在「均值滤波」面板中的预览勾选状态。
* @param [IN] laneIndex 网格下标。
* @param [IN] ref 目标信号。
* @return 已勾选返回 true。
*/
bool LaneMeanFilterSignalEnabled(int laneIndex, const SignalRef& ref) const;
/**
* @brief 统计信号穿越“均值滤波曲线”的上/下边沿数量,结果带缓存。
*
* 曲线由「均值点数 X + 迭代次数 N」确定,与「实验性功能 → 均值滤波」的预览曲线逐位一致。
*
* ⚠️ 判定对象是**画出来的两条曲线**:信号与中心曲线都要先按 `style` 做水平镜像映射、
* 再套用 `ApplyDrawTransform`(系数 → 绝对值 → 垂直镜像 → Y 偏移)。
*
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @param [IN] style 该信号的外观(含水平镜像与「调整」变换)。
* @param [IN] points 均值点数 X。
* @param [IN] iterations 迭代次数 N。
* @param [IN] hasRange 是否只统计某个时间区间(双光标模式下为光标区间)。
* @param [IN] rangeMin 区间左端点时间。
* @param [IN] rangeMax 区间右端点时间。
* @param [OUT] rising 上升沿数量(信号从下往上越过均值滤波曲线)。
* @param [OUT] falling 下降沿数量(信号从上往下穿过均值滤波曲线)。
* @param [OUT] complete 是否为完整精确值(false = 仍在推进,界面显示“…”)。
*/
void EdgeCenterCountsFor(const TimeAxis& axis, const SignalData& signal, const SignalStyle& style,
int points, int iterations, bool hasRange, double rangeMin,
double rangeMax, std::size_t& rising, std::size_t& falling,
bool& complete) const;
/**
* @brief 切换某个信号在「中心轴边沿计数」面板中的勾选状态。
* @param [IN] laneIndex 网格下标。
* @param [IN] ref 目标信号。
* @return 切换后的勾选状态。
*/
bool ToggleLaneEdgeCenterSignal(int laneIndex, const SignalRef& ref);
/**
* @brief 查询某个信号在「中心轴边沿计数」面板中的勾选状态。
* @param [IN] laneIndex 网格下标。
* @param [IN] ref 目标信号。
* @return 已勾选返回 true。
*/
bool LaneEdgeCenterSignalEnabled(int laneIndex, const SignalRef& ref) const;
/**
* @brief 绘制一段派生曲线(中心轴均值滤波 / 多次迭代均值滤波共用同一实现)。
* @param [IN,OUT] g 绘图上下文。
* @param [IN] resolved 信号视图。
* @param [IN] lane 网格绘制信息。
* @param [IN] curve 曲线数值数组(缺失处为 NaN)。
* @param [IN] curveBegin `curve[0]` 对应的采样下标。
*/
void PaintDerivedCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane,
const std::vector<float>& curve, std::size_t curveBegin) const;
/**
* @brief 取得某个采样区间内的派生曲线(带缓存)。
*
* ⚠️ **性能关键**:派生曲线只用于“看得见的那一段”,而整条信号可能有数千万个采样点
* (VERY_BIG_DATA 为 2576 万)。若每次都重算整条曲线(实测弱峰调整 ~800ms/条),
* 拖动等级/迭代次数滑块时每帧要花几秒钟,交互完全卡死。
* 本函数只计算“所需区间 ± 保护带”,并按(信号 + 参数 + 对齐后的区间)缓存:
* 实测单条降到 1~3ms,滑块拖动与平移因此恢复流畅。
*
* 保护带保证可见区间内的取值与“整条曲线”一致:均值滤波的核是有限宽度,
* 弱峰调整的修正沿相邻极值向内传播(每迭代一次传一个极值),
* 因此只要求保护带足够宽(见 `kDerivedCurveGuard` 系列常量)。
*
* @param [IN] kind 曲线种类。
* @param [IN] signal 目标信号。
* @param [IN] points 均值点数 X(仅 `MEAN_FILTER` 使用)。
* @param [IN] iterations 迭代次数 N。
* @param [IN] amountPercent 弱峰调整幅度(仅 `WEAK_PEAK` 使用,-400–400)。
* @param [IN] begin 需要的采样区间起点(含)。
* @param [IN] end 需要的采样区间终点(不含)。
* @param [OUT] spanBegin 返回数组对应的首个采样下标。
* @param [IN] allowAsyncCompute 是否允许「后台异步计算」:窗口很大时**不在绘制线程里计算**,
* 而是交后台线程算,本帧先用已算好的旧曲线顶替(双缓冲),保证交互始终跟手。
* 仅供**预览绘制**传 true;需要精确取值的统计(中心轴边沿计数、取值胶囊)
* 传 false,走同步精确计算。
* @return 曲线数组指针(覆盖 `[spanBegin, spanBegin + size())`);暂无可显示曲线时返回 nullptr。
*/
const std::vector<float>* CurveSpanFor(DerivedCurveKind kind, const SignalData& signal,
int points, int iterations, int amountPercent,
std::size_t begin, std::size_t end,
std::size_t& spanBegin,
bool allowAsyncCompute = false) const;
/**
* @brief 派生曲线缓存的键(决定一条派生曲线数值的全部输入)。
*
* 数据层在两次结构变化之间是只读的,因此 `signal` 指针在此期间稳定;
* 任何结构变化(增删分组/信号、插入派生信号、切换标签页)前都会暂停后台线程并清空缓存
*(见 `PauseDerivedAsync`),因此不会出现悬空读取。
*/
struct CurveKey
{
const SignalData* signal = nullptr; /**< 目标信号。 */
int kind = 0; /**< `DerivedCurveKind`。 */
int points = 5; /**< 均值点数 X。 */
int iterations = 1; /**< 迭代次数 N。 */
int amountPercent = 0; /**< 弱峰调整幅度(-400–400)。 */
bool operator==(const CurveKey& other) const
{
return signal == other.signal && kind == other.kind && points == other.points &&
iterations == other.iterations && amountPercent == other.amountPercent;
}
};
/**
* @brief 一条已算好的派生曲线窗口(发布后**不可变**)。
*
* 用 `shared_ptr<const CurveEntry>` 持有:绘制线程取到后钉在 `m_curvePins` 中,整帧都引用
* 同一块内存;后台线程只**追加**新条目、从不修改已发布条目,因此「显示旧缓冲、后台算新缓冲」
* 的双缓冲模型天然无数据竞争。
*/
struct CurveEntry
{
CurveKey key; /**< 曲线键。 */
std::size_t begin = 0; /**< 已计算窗口起点(采样下标)。 */
std::size_t end = 0; /**< 已计算窗口终点(不含)。 */
std::uint64_t serial = 0; /**< 发布序号(越大越新,用于选出最新覆盖项)。 */
std::vector<float> values; /**< 曲线数值(长度 = end - begin)。 */
};
using CurveEntryPtr = std::shared_ptr<const CurveEntry>;
/** @brief 后台派生曲线计算任务(`windowBegin/End` 已含保护带)。 */
struct CurveJob
{
CurveKey key; /**< 曲线键。 */
std::size_t windowBegin = 0; /**< 计算窗口起点(含保护带)。 */
std::size_t windowEnd = 0; /**< 计算窗口终点(不含)。 */
std::uint64_t generation = 0; /**< 入队时的数据代次(代次变化即丢弃结果)。 */
std::uint64_t requestSerial = 0; /**< 入队时的“最新请求序号”(被更新请求超越则本任务已过时)。 */
bool huge = false; /**< 是否为“大窗口”任务(低内存时串行执行并降低并发)。 */
int hugeLimit = 1; /**< 允许同时运行的“大窗口”任务数(内存充足时 2,紧张时 1)。 */
};
/**
* @brief 计算派生曲线「含保护带」的实际计算窗口(同步与后台共用同一公式)。
* @param [IN] kind 曲线种类(均值滤波的保护带需覆盖滤波半径)。
* @param [IN] alignedBegin 对齐后的需要区间起点。
* @param [IN] alignedEnd 对齐后的需要区间终点(不含)。
* @param [IN] points 均值点数 X。
* @param [IN] iterations 迭代次数 N。
* @param [IN] count 采样点总数。
* @param [OUT] begin 计算窗口起点。
* @param [OUT] end 计算窗口终点(不含)。
*/
void DerivedGuardWindow(DerivedCurveKind kind, std::size_t alignedBegin, std::size_t alignedEnd,
int points, int iterations, std::size_t count, std::size_t& begin,
std::size_t& end) const;
/**
* @brief 取得覆盖 `[begin, end]` 的最新已算好条目并钉住(防止本帧被回收)。
* @param [IN] key 曲线键。
* @param [IN] begin 需要区间起点。
* @param [IN] end 需要区间终点(不含)。
* @param [OUT] spanBegin 命中条目的窗口起点。
* @return 命中条目,未命中返回 nullptr。
*/
const CurveEntry* AcquireCoveringCurve(const CurveKey& key, std::size_t begin, std::size_t end,
std::size_t& spanBegin) const;
/**
* @brief 取得该曲线键下最新的已算好条目并钉住(双缓冲的「旧缓冲」)。
*
* ⚠️ **只按「信号 + 曲线种类」匹配,忽略参数**:拖参数滑块时每帧参数都在变,
* 若要求键完全相同就永远找不到上一帧的曲线,本帧便无曲线可画 → 预览曲线**闪烁**。
* 旧曲线只是参数略旧,按采样下标映射位置依然正确,先顶上再等后台算好新曲线。
*
* @param [IN] key 曲线键(只用其 `signal` 与 `kind`)。
* @param [IN] begin 需要区间起点(用于优先挑「覆盖该区间」的条目)。
* @param [IN] end 需要区间终点(不含)。
* @param [OUT] spanBegin 命中条目的窗口起点。
* @return 命中条目,无则返回 nullptr。
*/
const CurveEntry* AcquireNewestCurve(const CurveKey& key, std::size_t begin, std::size_t end,
std::size_t& spanBegin) const;
/**
* @brief 发布一条新算好的曲线窗口并钉住。
* @param [IN] key 曲线键。
* @param [IN] begin 窗口起点。
* @param [IN] end 窗口终点(不含)。
* @param [IN] values 曲线数值(移动入条目)。
* @param [OUT] spanBegin 发布条目的窗口起点。
* @return 发布条目的只读指针。
*/
const CurveEntry* PublishCurve(const CurveKey& key, std::size_t begin, std::size_t end,
std::vector<float>&& values, std::size_t& spanBegin) const;
/**
* @brief 把一个派生曲线计算任务排入后台队列(同一曲线键只保留一个在算任务)。
* @param [IN] key 曲线键。
* @param [IN] alignedBegin 对齐后的需要区间起点。
* @param [IN] alignedEnd 对齐后的需要区间终点(不含)。
* @param [IN] count 采样点总数。
*/
void ScheduleCurveJob(const CurveKey& key, std::size_t alignedBegin, std::size_t alignedEnd,
std::size_t count) const;
/** @brief 惰性启动后台计算线程。 */
void EnsureCurveWorker() const;
/** @brief 停止并回收后台计算线程(等待在算任务结束)。 */
void StopCurveWorker() const;
/** @brief 后台计算线程主体。 */
void CurveWorkerLoop() const;
/**
* @brief 判断一条任务是否已经「过时」(同一信号+曲线种类已有更新的请求)。
*
* 拖动参数滑块时参数每帧都在变,旧参数的任务再算下去没有意义;工作线程每阶之间
* 与开始前都用它检查一次,一旦过时**立即退出**(尽早归还工作缓冲)。
*
* @param [IN] key 曲线键。
* @param [IN] requestSerial 任务入队时的请求序号。
* @return 已过时返回 true。
*/
bool CurveJobIsStale(const CurveKey& key, std::uint64_t requestSerial) const;
/**
* @brief 计算「要装下这条派生曲线」所需的最小内存(字节)。
* @param [IN] samples 采样点数。
* @return 最小内存字节数(输出曲线本体 + 内部工作缓冲的经验值)。
*/
static std::size_t DerivedMinimumBytes(std::size_t samples);
/**
* @brief 查询当前可用物理内存(字节);查询失败返回极大值(视为“内存充足”)。
* @return 可用物理内存字节数。
*/
static std::size_t AvailablePhysicalBytes();
/**
* @brief 判断现在是否适合开始一条「整段信号」的派生曲线计算。
*
* * 可用内存 ≥ 最小需求的 4 倍 → 正常(并行、不节流,尽可能快);
* * 可用内存在最小需求 ~ 4 倍之间 → **低内存模式**(串行 + 节流,少量多次慢慢算完);
* * 可用内存 < 最小需求 → **严重不足**:不开始计算,并(仅一次)通知宿主告警。
*
* @param [IN] samples 采样点数。
* @param [OUT] throttle 输出是否应节流(低内存模式)。
* @return 允许开始计算返回 true。
*/
bool DerivedMemoryAllows(std::size_t samples, bool& throttle) const;
/** @brief 告知宿主“可用内存严重不足,已暂停派生曲线计算”(同一轮只提示一次)。 */
void NotifyDerivedLowMemory() const;
/** @brief 清理已被更新条目覆盖或超出内存预算的缓存条目(调用方须持有 `m_curveMutex`)。 */
void PruneCurveEntriesLocked() const;
/**
* @brief 计算当前视图对应的采样下标区间(时间单调时二分定位,否则为整段)。
* @param [IN] axis 目标时间轴。
* @param [IN] count 可用采样点数。
* @param [OUT] begin 区间起点(含)。
* @param [OUT] end 区间终点(不含)。
* @param [IN] timeShift 信号的时间偏移:采样画在 `t + timeShift` 处,因此可见采样对应的
* 源时刻区间为 `[ViewMin - timeShift, ViewMax - timeShift]`(偏移 0 时同视界)。
*/
void VisibleIndexRange(const TimeAxis& axis, std::size_t count, std::size_t& begin,
std::size_t& end, double timeShift = 0.0) const;
/**
* @brief 绘制某条信号的多次迭代均值滤波曲线(与中心平均值曲线同一配色派生规则)。
* @param [IN,OUT] g 绘图上下文。
* @param [IN] resolved 信号视图。
* @param [IN] lane 网格绘制信息。
* @param [IN] points 均值点数 X(滑平半径)。
* @param [IN] iterations 迭代次数 N(至少 1)。
*/
void PaintMeanFilterCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane,
int points, int iterations) const;
/**
* @brief 绘制某条信号的弱峰调整曲线(与均值滤波曲线同一配色派生规则)。
* @param [IN,OUT] g 绘图上下文。
* @param [IN] resolved 信号视图。
* @param [IN] lane 网格绘制信息。
* @param [IN] amountPercent 弱峰调整幅度(-400–400)。
* @param [IN] iterations 迭代次数 N(1–10)。
*/
void PaintWeakPeakCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane,
int amountPercent, int iterations) const;
/**
* @brief 数据点标记的位置与"越界方向"(用于决定画圆点还是贴边三角)。
*/
struct DataMarker
{
float x = 0.0f; /**< 标记锚点横坐标(已夹在可视区内)。 */
float y = 0.0f; /**< 标记锚点纵坐标(已夹在可视区内)。 */
bool offLeft = false; /**< 数据点位于左侧视界之外。 */
bool offRight = false; /**< 数据点位于右侧视界之外。 */
bool offUp = false; /**< 数据点位于上方视界之外。 */
bool offDown = false; /**< 数据点位于下方视界之外。 */
};
/**
* @brief 计算数据点标记:把越界的数据点"钉"在网格可见边缘,并记录越界方向。
* @param [IN] rawX 数据点的原始横坐标。
* @param [IN] rawY 数据点的原始纵坐标。
* @param [IN] pinX 横向贴边位置(光标在视界外时的钉住横坐标)。
* @param [IN] clipTop 网格可见区上边界。
* @param [IN] clipBottom 网格可见区下边界。
* @param [IN] topReserve 上边界额外需要预留的高度(例如时间光标的顶部三角把手,
* 避免数据点三角压住光标把手)。默认 0。
* @return 标记几何。
*/
DataMarker MakeDataMarker(float rawX, float rawY, float pinX, float clipTop,
float clipBottom, float topReserve = 0.0f) const;
/**
* @brief 绘制数据点标记:视界内为圆点,越界时在对应边缘绘制指示方向的三角。
* @param [IN,OUT] g 绘图上下文。
* @param [IN] marker 标记几何。
* @param [IN] radius 圆点半径。
* @param [IN] fillColor 前景色(圆点/三角内部)。
* @param [IN] ringColor 描边色(圆环/三角外框)。
*/
void PaintDataMarker(gd::Graphics& g, const DataMarker& marker, float radius,
std::uint32_t fillColor, std::uint32_t ringColor) const;
/**
* @brief 放置一个光标取值胶囊:先夹到网格可见范围内,再向下(必要时向上)避让
* 已占用的标注矩形,最后登记到 `m_cursorLabelRects`。
* @param [IN,OUT] rect 胶囊矩形(Y 会被调整)。
* @param [IN] preferredTop 期望的顶边纵坐标(避让前)。
* @param [IN] textHeight 胶囊高度。
* @param [IN] clipTop 网格可见区上边界。
* @param [IN] clipBottom 网格可见区下边界。
*/
void PlaceCursorLabel(gd::RectF& rect, float preferredTop, float textHeight, float clipTop,
float clipBottom);
/**
* @brief 判断数据点圆点是否被「光标时间读数 / Δt 面板」盖住。
*
* 光标的时间读数与 Δt 胶囊绘制在绘图区底部、且在取值标注**之后**绘制,
* 因此靠近底部的数据点圆点会被它们完全遮住(用户 2026-10-19 报告“看不清数据点在哪”)。
* 判定为“盖住”时调用方应把圆点改成**向下的三角**(与数据点在视界下方同一语义)。
*
* @param [IN] marker 数据点标记。
* @param [IN] radius 圆点半径。
* @param [OUT] hit 遮住它的面板矩形(用于把三角贴在面板上沿)。
* @return 被盖住返回 true。
*/
bool MarkerCoveredByCursorLabel(const DataMarker& marker, float radius, gd::RectF& hit) const;
/**
* @brief 若数据点圆点被底部时间读数/Δt 面板盖住,把它改成**向下的三角**。
*
* 语义与“数据点位于视界下方”一致(用户 2026-10-19 要求):圆点既然看不见,
* 就用贴面板上沿的向下三角告知“这里有个数据点、它在下侧”。
* 已经是越界三角的标记不做任何修改。
*
* @param [IN,OUT] marker 标记几何。
* @param [IN] radius 圆点半径。
* @param [IN] clipTop 网格可见区上边界(三角不会越过它)。
*/
void CoverHiddenMarker(DataMarker& marker, float radius, float clipTop) const;
/**
* @brief 统计某信号穿越横向光标幅值的上/下边沿数量,结果带缓存。
*
* 判定定义(用户明确要求):把信号**绘制出来的曲线**与横向光标所在的幅值比较,
* 曲线从**下往上**穿过该幅值 → 上升沿;从**上往下**穿过 → 下降沿。
*
* ⚠️ 因此判定值必须是**画出来的值**:先按 `style` 做水平镜像映射,再套用
* `ApplyDrawTransform`(系数 → 绝对值 → 垂直镜像 → Y 偏移)。
* 只比较原始采样值会导致「应用过调整的信号数不到任何边沿」。
* ⚠️ 时间区间**之外**的采样一律不参与计数(只有两个端点都在区间内的采样对才计入)。
*
* ⚠️ **本函数绝不长时间阻塞 UI 线程**(千万级信号整段扫描实测 ≈115ms/条,
* 横向光标每一帧都会换阈值 → 拖动时每帧都要扫,5 条信号就是 ≈575ms/帧):
* 扫描按「逐块累积」进行,**每帧**只消耗 `kEdgeScanPairsPerFrame` 个采样对的预算
*(且**每个状态各自持有配额**,多条信号不会互相饿死),未扫完时 `complete` 返回 false
*(界面显示上一个完整结果 + “…”)。
* 阈值变化(横向光标拖动中)就地把累积状态重置(不新建条目),且当帧不展示部分值
*(`justReset`),避免“错误且不更新的数字”。
*
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @param [IN] style 该信号的外观(含水平镜像与「调整」变换)。
* @param [IN] edgeValue 横向光标所在的幅值。
* @param [IN] hasRange 是否只统计某个时间区间(双光标模式下为光标区间)。
* @param [IN] rangeMin 区间左端点时间。
* @param [IN] rangeMax 区间右端点时间。
* @param [OUT] rising 上升沿数量(从下往上穿越)。
* @param [OUT] falling 下降沿数量(从上往下穿越)。
* @param [OUT] complete 结果是否为该阈值的**完整**精确值(false = 仍在推进,当前是临时值)。
*/
void EdgeCountsFor(const TimeAxis& axis, const SignalData& signal, const SignalStyle& style,
double edgeValue, bool hasRange, double rangeMin, double rangeMax,
std::size_t& rising, std::size_t& falling, bool& complete) const;
/**
* @brief 计算实验性功能面板所需的宽度(排版与绘制共用,保证文字完整不被省略)。
* @param [IN] laneIndex 网格下标。
* @param [IN] kind 面板种类。
* @return 面板宽度(像素)。
*/
float EdgePanelWidth(int laneIndex, EdgePanelKind kind) const;
/**
* @brief 判断边沿统计是否限定在双光标区间内(双光标模式)。
* @return 限定在光标区间内返回 true。
*/
bool EdgeRangeActive() const;
/**
* @brief 边沿统计时间区间的左端点(取两条光标的较小时间)。
* @return 区间左端点。
*/
double EdgeRangeMin() const;
/**
* @brief 边沿统计时间区间的右端点(取两条光标的较大时间)。
* @return 区间右端点。
*/
double EdgeRangeMax() const;
/**
* @brief 双光标模式下面板标题的尾缀文本(“(光标区间内)”)。
* @return 尾缀文本,非双光标模式返回空串。
*/
std::wstring EdgeRangeSuffix() const;
/**
* @brief 绘制拖动中的缩放选区。
* @param [IN,OUT] g 绘图上下文。
*/
void PaintRubberBand(gd::Graphics& g);
/**
* @brief 绘制内联数值输入框(时间光标或网格 Y 轴上下限)。
* @param [IN,OUT] g 绘图上下文。
*/
void PaintEditField(gd::Graphics& g);
/**
* @brief 绘制信号悬停提示与拖拽虚影。
* @param [IN,OUT] g 绘图上下文。
*/
void PaintSignalInteraction(gd::Graphics& g);
/**
* @brief 在绘图区底部绘制临时提示条(如“已放弃当前选区”)。
* @param [IN,OUT] g 绘图上下文。
*/
void PaintHint(gd::Graphics& g);
/**
* @brief 绘制网格纵向滚动条。
* @param [IN,OUT] g 绘图上下文。
*/
void PaintScrollBar(gd::Graphics& g);
/**
* @brief 计算右下角「回到最上方」浮动按钮的矩形(客户区坐标)。
* @return 按钮矩形;当前不该显示时返回空矩形(`m_backToTopAlpha` 为 0 时仍返回几何,
* 便于命中测试与过渡动画)。
*/
gd::RectF BackToTopRect() const;
/**
* @brief 绘制右下角「回到最上方」浮动按钮(含按 `m_backToTopAlpha` 的淡入淡出)。
* @param [IN,OUT] g 绘图上下文。
*/
void PaintBackToTop(gd::Graphics& g);
/**
* @brief 依据当前滚动位置刷新「回到最上方」按钮的目标可见性(需要时启动过渡)。
* @return 视觉状态发生变化返回 true。
*/
bool UpdateBackToTopTarget();
/**
* @brief 绘制网格标题(信号图例)。
* @param [IN,OUT] g 绘图上下文。
* @param [IN] laneIndex 网格下标。
* @param [IN] lane 网格绘制信息。
* @param [IN] visibleTop 网格在绘图区内的可见上边界。
* @param [IN] visibleBottom 网格在绘图区内的可见下边界。
*/
void PaintLaneLegend(gd::Graphics& g, int laneIndex, const LaneVisual& lane, float visibleTop,
float visibleBottom);
/**
* @brief 判断给定矩形是否完整落在可见纵向区间内。
* @param [IN] rect 待测矩形。
* @param [IN] visibleTop 可见区上边界。
* @param [IN] visibleBottom 可见区下边界。
* @return 完整可见返回 true。
* @note GDI 文本绘制不会遵循 GDI+ 的裁剪区,必须显式判定,否则滚动时文字会溢出到工具栏。
*/
static bool IsFullyVisible(const gd::RectF& rect, float visibleTop, float visibleBottom);
/**
* @brief 绘制空状态提示文本。
* @param [IN,OUT] g 绘图上下文。
*/
void PaintEmptyState(gd::Graphics& g);
const UiFonts* m_fonts = nullptr; /**< 字体资源。 */
const Theme* m_theme = nullptr; /**< 主题资源。 */
const DataSet* m_dataSet = nullptr; /**< 数据集合(只读访问)。 */
DataSet* m_dataSetWritable = nullptr; /**< 数据集合(选中状态写入)。 */
TabConfig* m_tab = nullptr; /**< 当前标签页配置。 */
TabConfig m_localTab; /**< 未绑定标签页时使用的占位配置。 */
HWND m_host = nullptr; /**< 剪贴板所有者窗口句柄。 */
float m_scale = 1.0f; /**< DPI 缩放比例。 */
gd::RectF m_area; /**< 绘图区整体区域。 */
gd::RectF m_plotRect; /**< 波形绘制区域。 */
gd::RectF m_timeAxisRect; /**< 时间轴区域。 */
gd::RectF m_scrollBarRect; /**< 网格滚动条区域。 */
float m_labelBandWidth = 66.0f; /**< 网格内侧纵轴标尺带宽度(逻辑像素)。 */
float m_timeAxisHeight = 32.0f; /**< 时间轴高度(逻辑像素)。 */
/** @brief 网格最小高度(**逻辑像素**,使用处乘 `m_scale`)。用户 2026-10-19 要求为 81(原 54 的 1.5 倍)。 */
float m_minLaneHeight = 81.0f;
float m_laneContentHeight = 0.0f; /**< 网格内容总高度。 */
bool m_scrollBarVisible = false; /**< 是否需要显示滚动条。 */
/** 网格滚动(“点选信号 ⇒ 把目标网格滚入可视区”)的平滑过渡状态。 */
bool m_laneScrollAnimActive = false; /**< 动画是否正在进行。 */
float m_laneScrollAnimFrom = 0.0f; /**< 动画起始滚动偏移。 */
float m_laneScrollAnimTo = 0.0f; /**< 动画目标滚动偏移。 */
std::uint64_t m_laneScrollAnimStartMs = 0; /**< 动画起始时刻(`GetTickCount64()`)。 */
std::uint32_t m_laneScrollAnimDurationMs = 0; /**< 动画总时长(毫秒)。 */
/**
* @brief 右下角「回到最上方」浮动按钮(只在网格需要滚动条、且已向下滚动时出现)。
*
* 用户 2026-10-16 要求:参考长文档网页的常见做法,用一个向上箭头的浮动按钮;
* 仅在向下滚动时出现、回到上方时消失,并且**出现/消失都要有过渡动画**(不能突变)。
*/
float m_backToTopAlpha = 0.0f; /**< 当前不透明度(0 = 完全隐藏)。 */
bool m_backToTopFadeIn = false; /**< 当前过渡方向(true = 正在淡入)。 */
bool m_backToTopAnimating = false; /**< 是否正在淡入/淡出。 */
bool m_backToTopHover = false; /**< 鼠标是否悬停在按钮上。 */
bool m_backToTopPressed = false; /**< 是否正在按钮上按住左键。 */
std::uint64_t m_backToTopAnimStartMs = 0; /**< 本段过渡的起始时刻。 */
PlotDragMode m_dragMode = PlotDragMode::NONE; /**< 当前拖拽模式。 */
PlotRubberAxis m_rubberAxis = PlotRubberAxis::UNDECIDED; /**< 选区放大拖拽的轴向。 */
int m_dragLane = -1; /**< 拖拽所作用的网格下标。 */
bool m_dragMoved = false; /**< 拖拽过程中是否发生了位移。 */
int m_dragStartX = 0; /**< 拖拽起点横坐标。 */
int m_dragStartY = 0; /**< 拖拽起点纵坐标。 */
int m_dragLastX = 0; /**< 上一次拖拽横坐标。 */
int m_dragLastY = 0; /**< 上一次拖拽纵坐标。 */
gd::RectF m_rubberRect; /**< 拖拽选区矩形。 */
SignalRef m_hoverSignal; /**< 鼠标悬停命中的信号(曲线高亮)。 */
/**
* @brief 「外部悬停信号」:信号列表 / 样式区「目标」行悬停到某条信号时的联动高亮源。
*
* 与 `m_hoverSignal` 分开(绘图区的 `OnMouseLeave` 会清掉后者,而列表悬停时
* 鼠标并不在绘图区内);二者的高亮效果相同:网格变色 + 曲线光晕。
*/
SignalRef m_externalHoverSignal;
/**
* @brief 待宿主处理的「把信号列表滚动到该信号」请求(见 `TakePanelScrollRequest`)。
*
* 绘图区自身无法访问信号面板,因此把请求记在这里,由宿主在鼠标消息处理后取走。
*/
SignalRef m_pendingPanelScroll;
bool m_pendingPanelScrollFlag = false;
SignalRef m_hoverLegend; /**< 鼠标悬停命中的图例条目(半高亮提示,不触发曲线拖拽提示)。 */
SignalRef m_dragSignal; /**< 正在被拖拽的信号。 */
int m_dragTargetLane = -1; /**< 拖拽信号时的目标网格下标。 */
int m_dragMouseX = 0; /**< 拖拽过程中鼠标的当前横坐标(虚影跟随)。 */
int m_dragMouseY = 0; /**< 拖拽过程中鼠标的当前纵坐标(虚影跟随)。 */
bool m_cursorHover = false; /**< 鼠标是否悬停在某条时间光标上。 */
int m_hoverCursor = 0; /**< 悬停/拖拽中的光标序号(1 或 2,0 表示无)。 */
int m_activeCursor = 1; /**< 最近操作过的光标:内容重叠时它绘制在上层。 */
int m_editCursor = 1; /**< 内联输入所作用的 Time 光标序号。 */
int m_spanDragStartX = 0; /**< 差值标签拖拽起点横坐标。 */
double m_spanDragTime1 = 0.0; /**< 差值标签拖拽起始时两条光标的时间。 */
double m_spanDragTime2 = 0.0;
int m_cursorGrabOffset = 0; /**< 从光标时间标签上抓起时的横向偏移(像素)。 */
PlotEditTarget m_editTarget = PlotEditTarget::NONE; /**< 当前内联输入目标。 */
TextEdit m_edit; /**< 输入框编辑模型(光标/选区/剪贴板)。 */
int m_editLane = -1; /**< 内联输入所作用的网格下标。 */
SignalRef m_editMeanRef; /**< 均值滤波参数编辑所作用的信号。 */
/** 网格拖拽重排状态(拖动图例中的 GX 徽标)。 */
int m_laneDragFrom = -1; /**< 被拖拽的网格下标,-1 表示未拖拽。 */
int m_laneDragTarget = -1; /**< 当前插入位置(0..网格数)。 */
int m_laneDragMouseY = 0; /**< 最近一次鼠标纵坐标(被拖网格自由跟随)。 */
float m_laneDragGrabOffset = 0.0f; /**< 抓取时鼠标相对网格顶边的偏移。 */
SignalRef m_editSignal; /**< 内联输入所作用的信号(图例改名)。 */
gd::RectF m_editFieldRect; /**< 本次编辑输入框的矩形(用于点外提交判定)。 */
SignalRef m_pendingRenameRef; /**< 待宿主窗口应用的信号重命名引用。 */
std::wstring m_pendingRenameName; /**< 待宿主窗口应用的信号新名称。 */
bool m_pendingRename = false; /**< 是否存在待应用的重命名请求。 */
int m_pendingMeanFilterLane = -1; /**< 待构建的均值滤波信号所属网格下标(-1 表示无请求)。 */
int m_pendingWeakPeakLane = -1; /**< 待构建的弱峰调整信号所属网格下标(-1 表示无请求)。 */
std::vector<MeanFilterBuild> m_pendingMeanFilterItems; /**< 待构建的信号及其参数。 */
std::vector<WeakPeakBuild> m_pendingWeakPeakItems; /**< 待构建的弱峰调整信号及其参数。 */
bool m_mouseOverPanel = false; /**< 上一帧鼠标是否落在某个实验性功能面板上。 */
int m_peakDragLane = -1; /**< 正在拖动弱峰滑块的面板所属网格。 */
SignalRef m_peakDragRef; /**< 正在拖动弱峰滑块的**信号**(每个信号一套参数)。 */
/**
* @brief 正在编辑「迭代次数 N 上限」时,输入框内灰色提示用的**当前上限值**。
*
* 输入框以空内容开始,空内容时显示这个值作为提示;进入编辑时记录,提交后清零。
*/
int m_peakEditCurrentMax = 0;
/**
* @brief 本帧是否使用「快速绘制」(交互期间关闭波形与派生曲线的抗锯齿)。
*
* 用户明确要求"先用低分辨率,绘制完成后再用高分辨率替换":抗锯齿是单帧最贵的
* 一项(同一条包络带实测 27.6ms → 3.2ms),而它的几何完全不变,
* 因此拖拽/拖动滑块期间关闭它,松手后由宿主整帧失效自动恢复全质量绘制。
*/
bool m_fastFrame = false;
/**
* @brief 实验性功能浮动面板的 ✕ 处于"已按下、等待抬起确认"状态。
*
* 关闭类 ✕ 统一改为左键**抬起**时生效:按下只记录目标,抬起时指针仍在该 ✕ 上才真正
* 关闭对应功能;按住后移开再松开视为放弃。
*/
bool m_pendingPanelClose = false; /**< 是否有待确认的面板关闭请求。 */
int m_pendingPanelCloseLane = -1; /**< 待关闭面板所属网格下标。 */
EdgePanelKind m_pendingPanelCloseKind = EdgePanelKind::COUNT; /**< 待关闭面板的功能类型。 */
int m_hoverLane = -1; /**< 当前悬停网格下标。 */
/**
* @brief 取本次绘制应该高亮的网格下标(-1 表示无)。
*
* 来源两处:① 鼠标悬停在实验性功能浮动面板上(该面板所属网格);
* ② 外部悬停信号(信号列表 / 样式区)且它已绘制到某个网格时,高亮该网格。
* ⚠️ 鼠标在网格里移动**不再**触发高亮(用户 2026-10-15 要求取消旧行为)。
*/
int EffectiveHoverLane() const;
/**
* @brief 上一帧鼠标是否悬停在某个网格的“GX”编号徽标上(是则记录网格下标,否则 -1)。
*
* 徽标本体绘制在静态场景 / 装饰缓存里(不随鼠标变化),悬停高亮是**贴图之后的叠加层**
*(见 PaintLegendHighlight)。鼠标在同一个网格的徽标范围内移动时 `m_hoverLane` 不变,
* 因此必须单独记住这个状态才能触发重绘与取消高亮。
*/
int m_hoverBadge = -1;
/**
* @brief 上一帧鼠标是否悬停在某个浮动面板标题栏的「GX」徽标上(是则记录网格下标 / 面板种类)。
*
* 徽标本体绘制在面板**像素图层**里(缓存键只在鼠标位于面板内时才混入坐标),
* 因此需要单独记录状态才能让高亮跟着鼠标进出而刷新。
* ⚠️ 用户 2026-10-21 要求:徽标悬停**不改变鼠标形状**、也**不弹提示气泡**。
*/
int m_hoverPanelBadgeLane = -1;
int m_hoverPanelBadgeKind = -1;
/** 「弱峰调整」每行滑块右侧的复位控件是否处于悬停态(用于“进入/离开”时只提示一次)。 */
bool m_peakResetHover = false;
// ---------------------------------------------------------------- 自由标记
/** 最近一次解析出的「自由标记」跟随数据点(绘制与命中共用)。 */
FreeMarkerProbe m_freeMarker;
/**
* @brief 上一帧鼠标悬停在「自由标记」面板的哪个**条目行**上(-1 = 没有)。
*
* 与 `m_freeMarkerPanelHoverLane/Item`(只表示 ✕ 的悬停)**分开**:
* 悬停在整行上时要把网格内对应的固定点整体高亮(取值胶囊 + 数据点),
* 但**不能**因此把该行的 ✕ 画成 danger 色(那是“准备删除”的语义)。
*/
int m_freeMarkerRowHoverLane = -1;
std::size_t m_freeMarkerRowHoverItem = static_cast<std::size_t>(-1);
/**
* @brief 上一次绘制时登记的固定点 ✕ 命中矩形。
*
* 固定点 ✕ 的位置由「取值文本宽度」决定(文本又取决于数值),因此无法在命中测试里
* 重新推算 —— 改为绘制时登记、命中时读取(与 `m_legendHits` 同一套路)。
*/
mutable std::vector<FreeMarkerHit> m_freeMarkerHits;
/** 网格内固定点 ✕ 的悬停态:`m_freeMarkerHoverItem` 为 `SIZE_MAX` 表示没有悬停。 */
int m_freeMarkerHoverLane = -1;
std::size_t m_freeMarkerHoverItem = static_cast<std::size_t>(-1);
/** 面板内 ✕ 的悬停态:信号名 ✕(`itemIndex == SIZE_MAX`)或某一固定点行 ✕。 */
int m_freeMarkerPanelHoverLane = -1;
std::size_t m_freeMarkerPanelHoverItem = static_cast<std::size_t>(-1);
SignalRef m_freeMarkerPanelHoverSignal;
/**
* @brief 本次按下是否**可能**固定一个数据点(抬起时若"原地未拖动"就真正固定)。
*
* 自由标记网格 + 选区放大模式共存时,单击 = 固定数据点、按下后拖动 = 框选,
* 因此按下时不能立即固定,必须等抬起时判断是否发生过位移。
*/
bool m_freeMarkerPendingFix = false;
int m_freeMarkerPendingLane = -1;
/** 正在编辑的固定点(`m_editLane` 网格内的 `freeMarkerItems` 下标)。 */
std::size_t m_editMarkerIndex = static_cast<std::size_t>(-1);
/** 「标记透明度」滑块拖拽的目标网格(-1 = 未拖拽)。 */
int m_markerOpacityDragLane = -1;
/** 面板内容滚动条拖拽的目标网格(-1 = 未拖拽)。 */
int m_markerScrollDragLane = -1;
/** 按下滑块时鼠标相对滚动条滑块上沿的偏移(避免滑块跳动)。 */
float m_markerScrollGrabOffset = 0.0f;
/** 「自由标记」面板:分组栏三角的悬停态(用于给出可点击提示)。 */
int m_freeMarkerTriangleHoverLane = -1;
SignalRef m_freeMarkerTriangleHoverSignal;
int m_mouseX = 0; /**< 最近一次鼠标横坐标(用于悬停提示定位)。 */
int m_mouseY = 0; /**< 最近一次鼠标纵坐标(用于悬停提示定位)。 */
bool m_spanHover = false; /**< 鼠标是否悬停在双光标差值标签上。 */
std::wstring m_hintText; /**< 临时提示文本(空表示不显示)。 */
std::uint64_t m_hintShownAt = 0; /**< 提示出现的时刻(毫秒,GetTickCount64)。 */
bool m_hintSticky = false; /**< 提示是否处于粘滞状态(拖动期间维持显示,不淡出)。 */
gd::RectF m_cursorSpanRect; /**< 最近一次绘制时差值标签的矩形(用于命中拖拽/双击)。 */
std::vector<gd::RectF> m_cursorLabelRects; /**< 本次绘制已占用的取值标注矩形(用于避让重叠)。 */
std::vector<LaneVisual> m_lanes; /**< 当前网格绘制信息集合。 */
/**
* @brief 「边沿统计」的累积状态(普通边沿计数与中心轴边沿计数**共用**)。
*
* 千万级信号整段扫描一次就要 ≈115ms(5 条信号同一网格 = 575ms/帧,界面完全失去响应),
* 因此统计改为「逐块累积 + 每 tick 有限预算推进」(与 `RenderPendingLaneRebuilds(1)`
* 同一套"宁可晚一点、绝不卡住前台"的思路):
* * 采样对 `k`(采样 `k-1 → k`)按 `kEdgeBlockSamples` 分块,每块各存上升/下降沿数;
* * `scannedPair` = 下一个待扫描的采样对,[1, scannedPair) 已扫完;
* * 查询区间时内部整块用块和相加、两端边界块就地精确扫描(≤ 2 块);
* * 未扫完时**主动请宿主再送一帧**(`RequestEdgeScanFrame`),因此**不需要移动鼠标**
* 也会持续推进(修掉"面板一直显示统计中,把鼠标移到面板上才更新")。
*
* 键 = 「时间轴 + 信号 + 种类 + 样式指纹 + 参数 +(仅非单调时间轴)区间」;
* 阈值/参数变化时就地重新开始(不新建条目,避免拖动时状态爆炸)。
*/
struct EdgeScanState
{
const TimeAxis* axis = nullptr; /**< 该信号使用的时间轴(统计身份 = 时间轴 + 信号)。 */
const SignalData* signal = nullptr; /**< 目标信号。 */
bool centre = false; /**< false = 穿越横向光标阈值;true = 穿越中心平均值曲线。 */
std::uint64_t styleKey = 0; /**< 「调整」变换 + 水平镜像的指纹。 */
int points = 0; /**< 中心轴:均值点数 X(普通计数恒为 0)。 */
int iterations = 1; /**< 中心轴:迭代次数 N。 */
bool hasRange = false; /**< 是否把区间计入键(仅非单调时间轴需要)。 */
double rangeMin = 0.0; /**< 计入键的区间左端。 */
double rangeMax = 0.0; /**< 计入键的区间右端。 */
double threshold = 0.0; /**< 当前阈值(普通计数)。 */
std::vector<std::size_t> blockRising; /**< 每块的上升沿数量(已扫到的部分)。 */
std::vector<std::size_t> blockFalling; /**< 每块的下降沿数量(已扫到的部分)。 */
std::size_t scannedPair = 1; /**< 下一个待扫描的采样对([1, scannedPair) 已扫完)。 */
std::size_t rising = 0; /**< 已扫部分的上升沿总数。 */
std::size_t falling = 0; /**< 已扫部分的下降沿总数。 */
bool complete = false; /**< 整条信号是否已扫完。 */
bool justReset = false; /**< 本 tick 刚因阈值/参数变化重新开始(不展示部分值)。 */
/**
* @brief 本状态**上一次推进所在的帧序号**与当时已消耗的配额。
*
* 每个状态**每帧各自持有配额**(而不是所有状态共享一个总额):旧实现下推进总是
* 从列表第一个状态扫起,5 条信号同一网格时只有第一条有进展、其余长期显示“统计中…”
*(用户报告“很多项都停在了统计中不更新”)。
*/
std::uint64_t pumpedFrame = 0; /**< 最近一次推进的帧序号(与 `m_edgeScanFrame` 比较)。 */
std::size_t pumpedPairs = 0; /**< 本帧已消耗的采样对配额(新帧开始时重置)。 */
/**
* @brief 中心轴扫描**就地计算**的一小段均值滤波曲线(源下标空间)。
*
* ⚠️ 不要改为依赖全局曲线缓存(`AcquireCoveringCurve`/`CurveSpanFor` 的整条曲线):
* 千万级信号的整条曲线是 ~103MB/条,多条信号超出曲线缓存预算后条目会被淘汰 →
* 该信号被显示成“统计中…”并从头重扫(**偶发**,取决于当时缓存里有多少条目),
* 而且几条信号的统计会通过缓存互相牵连(用户报告:“改一个信号的参数,
* 其它几个信号也重新统计”)。
* 本地区间只需「当前扫描块 ± 滤波半径」(设 `points * iterations` 即可精确),
* 内存只有几十 KB,且完全与其它信号/其它曲线解耦。
*/
std::vector<float> localCurve; /**< 本地均滤波取值(长度 = localEnd - localBegin)。 */
std::size_t localBegin = 0; /**< 本地区间起点(源采样下标,含)。 */
std::size_t localEnd = 0; /**< 本地区间终点(源采样下标,不含)。 */
bool localValid = false; /**< 本地区间是否可用。 */
};
mutable std::vector<EdgeScanState> m_edgeScans; /**< 边沿统计累积状态(纯缓存)。 */
mutable std::size_t m_edgeScanBudget = 0; /**< 本帧剩余可扫描的采样对数量(跨状态共享,限制单帧总开销)。 */
/**
* @brief 帧序号(`Paint` 每帧递增)。
*
* ⚠️ 预算必须**按帧**重置,不能按 `GetTickCount64()` 分桶:系统时钟粒度约 15.6ms,
* 帧率一高(`PostMessage` 自持重绘会把它拉高)就会在同一 tick 内连画多帧而不推进,
* 用户看到的就是“按住光标不动时统计卡住、要动鼠标才更新”。
*/
mutable std::uint64_t m_edgeScanFrame = 0;
/** 诊断计数:中心轴本地曲线计算失败次数(内存不足等)。0 = 一切正常。 */
mutable std::size_t m_edgeScanLocalCurveFails = 0;
/**
* @brief 边沿统计的统一实现(普通边沿计数与中心轴边沿计数共用)。
*
* 判定基于**画出来的曲线**:先按 `style` 做水平镜像映射,再套用 `ApplyDrawTransform`;
* 中心轴时两侧(信号与中心曲线)都套用同一套变换。
* 时间区间**之外**的采样一律不参与计数(只有两个端点都在区间内的采样对才计入)。
*
* @param [IN] axis 该信号使用的时间轴。 @param [IN] signal 目标信号。
* @param [IN] style 信号外观。 @param [IN] centre 是否为中心轴判定。
* @param [IN] threshold 阈值(`centre = false` 时使用)。
* @param [IN] points 均值点数 X(`centre = true` 时使用)。
* @param [IN] iterations 迭代次数 N(`centre = true` 时使用)。
* @note 中心轴所需的小段均值滤波曲线由本函数**就地计算**,
* 不依赖 `CurveSpanFor` 的整条曲线缓存(见 `EdgeScanState::localCurve`)。
* @param [IN] hasRange 是否只统计某个时间区间。
* @param [IN] rangeMin 区间左端。 @param [IN] rangeMax 区间右端。
* @param [OUT] rising 上升沿数量。 @param [OUT] falling 下降沿数量。
* @param [OUT] complete 是否为完整精确值(false = 仍在推进)。
*/
void EdgeScanFor(const TimeAxis& axis, const SignalData& signal, const SignalStyle& style,
bool centre, double threshold, int points, int iterations, bool hasRange,
double rangeMin, double rangeMax, std::size_t& rising, std::size_t& falling,
bool& complete) const;
/** @brief 边沿统计未完成时请宿主再送一帧(无需鼠标移动也能持续推进)。 */
void RequestEdgeScanFrame() const;
/**
* @brief **定向**作废边沿统计状态(参数变化时只影响被改动的那个信号)。
*
* ⚠️ 绝不要用 `m_edgeScans.clear()` 代替:那会把**所有**信号的累积状态都丢掉,
* 于是两个浮动面板里的每一个统计值都会从头重新扫描
*(用户报告:“改变一个信号的中心轴参数,其它未改变参数的统计也重新计算了”)。
* 其它信号的键没有变化,它们的块和依然有效,必须保留。
*
* @param [IN] signal 参数被改动的信号(nullptr 时不做任何事)。
* @param [IN] centreOnly true = 只作废该信号的中心轴统计(改中心轴参数时用)。
*/
void InvalidateEdgeScansFor(const SignalData* signal, bool centreOnly);
/** 多次迭代均值滤波曲线缓存项(按「信号指针 + 均值点数 + 迭代次数」标识)。 */
struct MeanCurveEntry
{
const SignalData* signal = nullptr;
int points = 5;
int iterations = 1;
std::vector<float> values; /**< 与 signal->values 等长;缺失处为 NaN。 */
};
mutable std::deque<MeanCurveEntry> m_meanCurveCache;
/** 弱峰调整曲线缓存项(按「信号指针 + 调整幅度百分比 + 迭代次数」标识)。 */
struct WeakPeakCurveEntry
{
const SignalData* signal = nullptr;
int amountPercent = 0; /**< 弱峰调整幅度(-400–400,默认 0 = 不改动)。 */
int iterations = 1; /**< 迭代次数 N(1–10)。 */
std::vector<float> values; /**< 与 signal->values 等长;缺失处为 NaN。 */
};
mutable std::deque<WeakPeakCurveEntry> m_weakPeakCurveCache;
// ---- 异步派生曲线计算(后台线程 + 双缓冲)----
// 设计:预览曲线(均值滤波 / 弱峰调整)在窗口很大时**不在绘制线程里计算**,而是交给后台
// 线程;绘制线程本帧先用上一条已算好的曲线顶替(双缓冲),后台算完后通过
// `kMessagePlotDerivedReady` 通知宿主重绘,从而切换为最新曲线。这样滑块 / 光标 / 视界 /
// 缩放等交互的反馈始终跟手,不会被计算拖住,同时把重计算放到后台充分利用多核。
mutable std::mutex m_curveMutex; /**< 保护任务队列与已算好条目。 */
mutable std::condition_variable m_curveCv; /**< 唤醒后台线程。 */
mutable std::vector<std::thread> m_curveWorkers; /**< 后台计算线程池(惰性启动,多核并行)。 */
mutable bool m_curveWorkerStop = false; /**< 请求后台线程退出。 */
mutable std::uint64_t m_curveGeneration = 0; /**< 数据代次(结构变化时自增)。 */
mutable std::uint64_t m_curveSerial = 0; /**< 已发布条目序号。 */
mutable std::deque<CurveJob> m_curveJobs; /**< 待计算任务。 */
mutable std::deque<CurveEntryPtr> m_curveEntries; /**< 已算好条目(双缓冲的「就绪缓冲」)。 */
mutable std::vector<CurveEntryPtr> m_curvePins; /**< 本帧正在使用的条目(阻止其被回收)。 */
mutable std::atomic<bool> m_curveSceneDirty{false}; /**< 后台发布了新条目 → 需重建静态场景缓存。 */
/**
* @brief 「每个(信号 + 曲线种类)的最新请求序号」。
*
* 拖动参数滑块时同一 (信号, 种类) 会被反复请求;已入队/正在计算的任务一旦不是最新请求,
* 其参数已被取代 → 工作线程看到后**立即退出并归还缓冲**(“过时任务立即清退”)。
* 值随每次 `ScheduleCurveJob` 单调递增,因此可以表达“比谁新”。
*/
mutable std::map<std::pair<const SignalData*, int>, std::uint64_t> m_curveLatest;
mutable std::atomic<std::uint64_t> m_curveRequestSerial{0}; /**< 全局请求序号发生器。 */
mutable int m_curveHugeActive = 0; /**< 正在执行的“大窗口”任务数(最多 1,低内存时保证串行)。 */
/**
* @brief 正在计算的曲线键集合(由工作线程在计算期间增删)。
*
* 有了它,`ScheduleCurveJob` 才能做到**幂等**:统计侧(边沿计数)会在“曲线还没算好”时
* 每帧请求一次整条曲线的计算;若每次都当作新请求,就会把上一份正在算的任务判为过时,
* 于是永远算不完(请求 → 作废 → 重请求 的死循环)。
* 用**集合**而不是单个键:线程池可能同时算多条不同曲线。
*/
mutable std::vector<CurveKey> m_curveRunningKeys;
mutable std::atomic<bool> m_curveCancelAll{false}; /**< 请求全部在算任务立即退出(结构变化 / 关闭预览)。 */
mutable bool m_curveLowMemoryWarned = false; /**< 「可用内存严重不足」是否已告警(避免反复弹窗)。 */
// 「边沿计数」横向光标 / 面板的拖拽状态。
int m_edgeDragLane = -1; /**< 正在拖动的横向光标所属网格。 */
int m_edgePanelDragLane = -1; /**< 正在拖动的统计面板所属网格。 */
EdgePanelKind m_edgePanelDragKind = EdgePanelKind::COUNT; /**< 正在拖动的面板种类。 */
gd::PointF m_edgePanelDragOffset; /**< 抓取面板时的相对偏移(保持不跳变)。 */
/**
* @brief 图例条目的命中信息(用于点选与双击改名),同时记录"悬停高亮"重绘内容所需
* 的全部几何与颜色。
*
* 悬停高亮画在**装饰缓存贴图之后**(随鼠标变化,不能进缓存),且托底必须**不透明**
*(半透明叠加会让底下的旧像素透出来)。为了在"不透明托底"上仍能看到色块与文字,
* 这里把绘制时用到的色块矩形 / 文本矩形 / 颜色也一并记下来,供悬停时原样重绘。
*/
struct LegendHit
{
SignalRef ref; /**< 对应信号。 */
gd::RectF rect; /**< 条目托底矩形(同时是命中区域)。 */
float radius = 0.0f; /**< 托底圆角半径(悬停高亮重绘时使用)。 */
gd::RectF swatch; /**< 色块矩形。 */
gd::RectF text; /**< 文本矩形。 */
std::wstring label; /**< 已经过省略处理的显示文本。 */
std::uint32_t swatchColor = 0; /**< 色块颜色。 */
float swatchOpacity = 1.0f; /**< 色块不透明度。 */
std::uint32_t textColor = 0; /**< 文本颜色。 */
};
std::vector<LegendHit> m_legendHits; /**< 最近一次绘制时记录的图例条目矩形。 */
/**
* @brief 折线顶点缓存条目。
*
* 折线抽稀是 O(采样点数) 的(百万级采样约 2~3ms/条),
* 而绘制、悬停命中检测每帧都要用到同一批顶点。
* 用“构建折线时读取的全部输入”作为键缓存顶点,
* 视图/网格几何未变化时可直接复用,避免重复扫描全量采样。
*/
struct PolylineCacheEntry
{
SignalRef ref; /**< 信号引用。 */
const SignalGroup* group = nullptr; /**< 所属分组(缓存键的一部分)。 */
std::size_t sampleCount = 0; /**< 采样点数(数据变化时改变)。 */
double viewMin = 0.0; /**< 时间轴显示下界。 */
double viewMax = 0.0; /**< 时间轴显示上界。 */
float plotX = 0.0f; /**< 绘图区左边界。 */
float plotWidth = 0.0f; /**< 绘图区宽度。 */
float laneY = 0.0f; /**< 网格上边界。 */
float laneHeight = 0.0f; /**< 网格高度。 */
double laneYMin = 0.0; /**< 网格幅值下界。 */
double laneYMax = 0.0; /**< 网格幅值上界。 */
float gain = 1.0f; /**< 绘制前的系数。 */
bool absolute = false; /**< 绘制前是否取绝对值。 */
bool mirrorVertical = false; /**< 绘制前是否按 Y=0 上下镜像。 */
bool mirrorHorizontal = false; /**< 是否水平镜像(取值下标反序)。 */
float offsetY = 0.0f; /**< Y 轴偏移。 */
double offsetTime = 0.0; /**< 时间轴偏移(秒)。 */
/**
* @brief 线宽(逻辑像素)。
*
* ⚠️ **必须进缓存键**:`BuildPolyline` 会把线宽**烘进包络带几何**
* (`strokeHalf = max(1, 线宽) * scale * 0.5`,逐列上下包络各向外扩这么多),
* 且「梳状」判定也依赖扩展后的列跨度。旧实现漏了这一项 ⇒ 改变线宽时
* 稠密信号会命中旧条目、一直按**旧线宽**绘制(用户 2026-10-16 报告:
* 拖动线宽滑块时信号线宽不变,松开后再移动鼠标又变回去)。
*/
float lineWidth = 1.0f;
std::vector<gd::PointF> points; /**< 折线顶点。 */
std::vector<std::size_t> breaks; /**< 每段折线在 points 中的起始下标。 */
/**
* @brief 是否处于「稠密包络」模式(采样点密度超过像素分辨率)。
*
* 此时 `points` 是每列 min/max 交替的锯齿,逐段描边的光栅化开销极大
* (实测每网格约 14ms);改用下面的「包络带」一次填充后约 1.7ms,
* 且保留抗锯齿,静态与拖拽帧的观感完全一致。
*/
bool bandMode = false;
/**
* @brief 该包络带是否为「梳状」(相邻列的上下包络几乎不相交)。
*
* 振荡周期与像素列宽相当时(例如编码器方波在 2ms/大格下约 3 个采样点一列),
* 逐列包络会在"全高"与"极窄"之间交替。这种形状下 GDI+ 的抗锯齿扫描线代价暴涨
* (实测同一条 2448 顶点的路径:27.6ms vs 无抗锯齿 3.2ms),
* 而抗锯齿对它的平滑作用几乎看不出来 —— 因此绘制时改用无抗锯齿填充。
* ⚠️ 几何本身仍然是**精确**的(不做任何加宽),否则波形会被"糊"成实心块。
*/
bool bandSpiky = false;
/** 包络带顶点:每段为「上包络正向 + 下包络反向」,闭合后即为该段的填充多边形。 */
std::vector<gd::PointF> band;
/** 每段包络带在 `band` 中的起始下标。 */
std::vector<std::size_t> bandBreaks;
};
/**
* @brief 取得(必要时重建)指定信号在当前几何下的折线顶点。
* @param [IN] ref 信号引用(缓存键的一部分)。
* @param [IN] resolved 已解析的信号。
* @param [IN] lane 网格绘制信息。
* @return 缓存条目指针,无法构建时返回 nullptr。
*/
const PolylineCacheEntry* AcquirePolyline(const SignalRef& ref, const ResolvedSignal& resolved,
const LaneVisual& lane) const;
mutable std::deque<PolylineCacheEntry> m_polylineCache; /**< 折线顶点缓存(按几何键复用)。 */
/**
* @brief 图层**横向补条**渲染期间的折线裁剪范围(省掉整段折线的重建与描边)。
*
* 横向补条只覆盖图层位图的**一条窄竖带**(`m_area` 的 x 区间),但折线原本会按
* `m_plotRect`(整条宽窗口 = 2× 视界)构建整段顶点并交给 GDI+ 描边 —— 即使只画
* 一条窄带,`DrawLines`/`FillPath` 仍要逐段提交,实测在"逐点描边"区间单帧高达
* **13ms**(这正是"深放大下中键拖拽非常卡顿"的根因)。
* 因此补条渲染时把折线的采样区间按条带的 x 范围裁剪(两侧各留少量像素余量以避免
* 接缝),只构建并绘制落在条带内的那一段顶点。条带之外的像素由已有图层像素提供,
* 因此结果仍与整块重画**逐像素一致**。
*/
bool m_stripRenderActive = false;
float m_stripX0 = 0.0f; /**< 条带左边界(与 `m_plotRect` 同坐标系)。 */
float m_stripX1 = 0.0f; /**< 条带右边界。 */
/** 条带渲染专用的折线结果(**不进缓存** —— 它只覆盖条带区间,不能当作整段折线复用)。 */
mutable PolylineCacheEntry m_stripPolylineEntry;
/**
* @brief 静态场景缓存的几何/内容键。
*
* 只要这组参数完全一致,"背景 + 全部网格 + 时间轴"的渲染结果就逐像素相同,
* 可以直接复用离屏位图,无需重算百万级采样的包络与绘制。
* 这使鼠标悬停、拖拽选区等"只改一小块叠加层"的交互不再重绘波形:
* 大文件下整幅重绘约 80ms,命中缓存时只需 1~2ms 贴图。
*/
struct SceneCacheKey
{
double viewMin = 0.0; /**< 时间轴下界。 */
double viewMax = 0.0; /**< 时间轴上界。 */
float laneScroll = 0.0f; /**< 网格滚动偏移。 */
int laneCount = 0; /**< 网格数量。 */
float areaX = 0.0f; /**< 绘图区左上角。 */
float areaY = 0.0f;
float areaW = 0.0f; /**< 绘图区尺寸。 */
float areaH = 0.0f;
float scale = 1.0f; /**< DPI 缩放。 */
int haloGroup = -1; /**< 已绘入缓存的拖拽光晕信号(无则 -1)。 */
int haloSignal = -1;
std::uint64_t contentHash = 0; /**< 信号集合/显示样式/网格幅值等内容指纹。 */
};
/**
* @brief 构造当前场景的缓存键。
* @return 缓存键。
*/
SceneCacheKey MakeSceneCacheKey() const;
/**
* @brief 判断当前缓存键是否与已捕获的场景一致。
* @return 可直接贴图返回 true。
*/
bool SceneCacheMatches() const;
/**
* @brief **单个网格**的内容指纹(用于该网格自己的「信号图层」)。
*
* 图层位图里只含该网格的内容(底色 + 网格线 + 波形),位图高度 = 该网格高度、
* 内部以 `y=0` 为网格顶边,因此该指纹只需要「网格高度 + 幅值范围 + 本网格各信号的
* 可见性与外观 + 图例文案」。
*
* ⚠️ 这**必须**是逐网格的:早期版本用全局内容指纹,于是一个网格的幅值变化
* 会同时作废**全部**网格的图层(拖拽时纵向分量让 5 个网格排队重画 → 图层内容
* 相互不一致 = 错误绘制;且每帧都入队 → 永远补不完)。
*
* @param [IN] laneIndex 网格下标。
* @return 指纹值。
*/
std::uint64_t LaneLayerHash(int laneIndex) const;
/**
* @brief 计算场景内容指纹(信号集合、显示样式、网格幅值、图例文案)。
*
* 视图几何以外的任何内容变化(改颜色/线宽/透明度/样式、重命名、增删信号)
* 都会改变该指纹,从而让静态场景缓存自动失效,无需在各处手工调用失效接口。
*
* @param [IN] includeStyles 是否把逐信号外观样式(颜色/不透明度/线宽/模式)计入指纹。
* @param [IN] includeDerived 是否把派生预览曲线的开关与参数计入指纹。
* @param [IN] includeLaneRange 是否把各网格的**幅值范围**(`yMin/yMax`)计入指纹。
* ⚠️ **拖拽平移时的纵向分量**会每帧小幅修改被拖网格的幅值范围;若它参与
* “宽基底可用性”判据,每一帧都会退化成整幅重绘(质量下降 + 卡顿)。
* 因此宽基底判据用 `includeLaneRange = false` 的几何指纹,
* 幅值变化改由**逐网格图层自己的指纹**处理(只有该网格需要重画)。
* @param [IN] includeLaneScroll 是否把**网格纵向滚动量**与各网格的屏幕纵向位置
* (`LaneScroll()` / `lane.rect.Y`)计入指纹。
* ⚠️ 静态场景缓存**必须**计入(缓存位图的内容取决于它们);
* 但“宽基底可用性”判据**不能**计入:网格滚动只改变各网格的**贴图落点**,
* 图层内容完全不变(`BlitWideBuffer` 本来就按 `lane.rect` 逐网格贴图)——
* 若计入,则“网格滚动到可视区 / 回到最上方”的每一帧都会退化成整幅重绘
* (用户 2026-10-17 报告:滚动动画卡顿)。
* @return 指纹值。
*/
std::uint64_t SceneContentHash(bool includeStyles = true, bool includeDerived = true,
bool includeLaneRange = true,
bool includeLaneScroll = true) const;
/**
* @brief 场景内容中**不含逐信号外观样式**的指纹。
*
* 只包含网格结构、视图几何、信号集合与可见性、图例文案、选中项等
* "变化后必须整幅重建"的内容;逐信号的 `color / opacity / lineWidth / mode`
* 一律不参与,**各网格的幅值范围(`yMin/yMax`)也不参与**(由逐网格图层指纹负责)。
*
* 用途:外观样式变化时(典型场景是拖动自定义颜色的滑块)波形几何完全不变,
* 只要该指纹一致,就可以只重画"颜色变了的那个网格"再写回缓存,
* 而不必整幅重绘全部网格(VERY_BIG_DATA 实测整幅 30ms+、单网格约 4ms)。
*
* @return 指纹值。
*/
std::uint64_t SceneGeometryHash() const;
/**
* @brief 装饰层(图例 + Y 轴刻度)的指纹:含外观样式,但**不含派生曲线参数**。
*
* 拖弱峰调整等级/迭代次数滑块时,图例与 Y 轴刻度不变,仅网格内预览曲线变化;
* 因此装饰层缓存不应因参数变化而失效(否则拖滑块每帧都要重绘图例与刻度)。
*
* @return 指纹值。
*/
std::uint64_t SceneDecorHash() const;
/**
* @brief 静态场景缓存中"网格内信号外观样式"的快照条目。
*/
struct SceneStyleEntry
{
std::uint32_t color = 0; /**< 信号色(未叠加不透明度)。 */
float opacity = 1.0f; /**< 不透明度。 */
float lineWidth = 1.0f; /**< 线宽。 */
int mode = 0; /**< 绘制模式。 */
float gain = 1.0f; /**< 系数。 */
bool absolute = false; /**< 是否取绝对值。 */
bool mirrorVertical = false; /**< 是否按 Y=0 上下镜像。 */
bool mirrorHorizontal = false; /**< 是否水平镜像(数据反序)。 */
float offsetY = 0.0f; /**< Y 轴偏移。 */
double offsetTime = 0.0; /**< 时间轴偏移(秒)。 */
/** @brief 相等比较(用于判断"逐信号外观样式是否变化")。 */
bool operator==(const SceneStyleEntry& other) const
{
return color == other.color && opacity == other.opacity &&
lineWidth == other.lineWidth && mode == other.mode &&
gain == other.gain && absolute == other.absolute &&
mirrorVertical == other.mirrorVertical &&
mirrorHorizontal == other.mirrorHorizontal && offsetY == other.offsetY &&
offsetTime == other.offsetTime;
}
};
/**
* @brief 单个网格内「叠加预览曲线」的状态(开关 + 每条被勾选信号的参数)。
*
* 它**只影响网格内容的绘制**(不影响网格结构/视图几何),因此不进入
* `SceneGeometryHash`,而是与外观样式一起作为“局部重画”的判据:
* 拖动弱峰调整的等级/迭代次数滑块时,只需重画受影响的网格,
* 不必整幅重绘(也不会把静态场景缓存整体作废)。
*/
struct SceneDerivedEntry
{
int edgeCenter = 0; /**< 中心轴边沿计数开关。 */
int meanFilter = 0; /**< 均值滤波开关。 */
int weakPeak = 0; /**< 弱峰调整开关。 */
/** 各条目展开为 [分组, 信号, 参数…],按原顺序(稳定)。 */
std::vector<int> edgeCenterItems;
std::vector<int> meanFilterItems;
std::vector<int> weakPeakItems;
};
/**
* @brief 采集当前各网格内可见信号的外观样式快照。
* @param [OUT] out 采集结果(按网格分组)。
*/
void CollectSceneStyles(std::vector<std::vector<SceneStyleEntry>>& out) const;
/**
* @brief 采集当前各网格的「叠加预览曲线」状态快照。
* @param [OUT] out 采集结果(按网格分组)。
*/
void CollectSceneDerived(std::vector<SceneDerivedEntry>& out) const;
/**
* @brief 判断当前相对缓存是否"只有外观样式发生了变化"。
*
* 成立条件:`SceneGeometryHash()` 与缓存一致(网格结构、视图几何、信号集合
* 与文字都没变),且至少有一个网格内信号的外观样式不同。
*
* @param [OUT] lanes 外观发生变化的网格下标(升序)。
* @return 可以走"局部重画"快路径返回 true。
*/
bool FindStyleOnlyLanes(std::vector<int>& lanes) const;
/**
* @brief 只重画指定网格并把结果写回静态场景缓存。
*
* 必须在 `BlitSceneCache()` 之后调用:贴图已经完成,这里把受影响网格的
* "底色 + 网格线 + 波形 + 标尺 + 图例"重画到画布上,再整块拷回缓存位图,
* 使缓存内容与当前样式一致(下一帧即可重新走"完全命中"快路径)。
*
* @param [IN,OUT] g 绘图上下文。
* @param [IN] lanes 需要重画的网格下标。
*/
void RepaintSceneCachePatch(gd::Graphics& g, const std::vector<int>& lanes);
/**
* @brief 当前应当绘入静态场景缓存的信号光晕(仅拖拽中的信号)。
*
* 拖拽信号的光晕在整个拖拽过程中不变,可以进入缓存;
* 悬停光晕随鼠标移动,必须画在缓存之外。
*
* @return 信号引用,无则返回无效引用。
*/
SignalRef SceneHaloRef() const;
/**
* @brief 绘制静态场景缓存之外的"悬停高亮"(悬停网格底色 + 悬停信号光晕)。
* @param [IN,OUT] g 绘图上下文。
*/
void PaintHoverHighlight(gd::Graphics& g);
/**
* @brief 绘制图例条目的悬停半高亮提示(在静态场景贴图之后绘制)。
* @param [IN,OUT] g 绘图上下文。
*/
void PaintLegendHighlight(gd::Graphics& g);
/**
* @brief 绘制某条信号的悬停/拖拽光晕(按缓存的折线模式选择最省的画法)。
* @param [IN,OUT] g 绘图上下文。
* @param [IN] resolved 已解析的信号。
* @param [IN] lane 该信号所在网格(单点信号需要它把数据点换算到屏幕坐标)。
* @param [IN] cached 该信号的折线缓存项(包络带模式下会走带外扩填充)。
*/
void PaintSignalHaloFor(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane,
const PolylineCacheEntry& cached);
/**
* @brief 绘制**悬停**光晕:把整条光晕渲染进一张离屏位图并缓存,命中时只贴图。
*
* ⚠️ 悬停光晕必须**沿整条曲线**发光(用户 2026-10-18 第二轮报告:上一版只画
* 鼠标左右 ±72px 的一段,在满幅方波上看起来就是一个**长方形色块**,像选区框)。
* 而整条光晕的直接绘制代价很高(稠密包络带填充实测 ~14ms/帧,加粗折线描边更是
* 200ms+/帧)。折中方案:光晕的几何只取决于「信号 + 视图 + 网格几何 + 样式」,
* **与鼠标位置无关**,因此渲染一次进离屏位图后每帧只贴图(~0.5ms),
* 并随视图/几何/样式变化自动失效重建。
*
* @param [IN,OUT] g 绘图上下文(调用方已设置绘图区裁剪)。
* @param [IN] resolved 已解析的信号。
* @param [IN] lane 该信号所在网格。
* @param [IN] cached 该信号的折线缓存项。
*/
void PaintSignalHaloCached(gd::Graphics& g, const ResolvedSignal& resolved,
const LaneVisual& lane, const PolylineCacheEntry& cached);
/**
* @brief 悬停光晕的离屏缓存(见 `PaintSignalHaloCached`)。
*
* 键 = 决定光晕几何的全部输入(与 `AcquirePolyline` 的键同源),
* 唯独**不含网格的屏幕纵向位置**:滚动网格时只需按新位置贴同一张位图。
*/
struct HaloLayerCache
{
bool valid = false; /**< 位图内容是否有效。 */
SignalRef ref; /**< 信号引用。 */
const SignalGroup* group = nullptr; /**< 所属分组。 */
std::size_t sampleCount = 0; /**< 采样点数。 */
double viewMin = 0.0; /**< 时间轴显示下界。 */
double viewMax = 0.0; /**< 时间轴显示上界。 */
float plotX = 0.0f; /**< 绘图区左边界。 */
float plotWidth = 0.0f; /**< 绘图区宽度。 */
float laneHeight = 0.0f; /**< 网格高度。 */
double laneYMin = 0.0; /**< 网格幅值下界。 */
double laneYMax = 0.0; /**< 网格幅值上界。 */
float lineWidth = 1.0f; /**< 线宽。 */
DrawMode mode = DrawMode::LINE; /**< 绘制方式。 */
float gain = 1.0f; /**< 绘制系数。 */
bool absolute = false; /**< 是否绝对值。 */
bool mirrorVertical = false; /**< 是否垂直镜像。 */
bool mirrorHorizontal = false; /**< 是否水平镜像。 */
float offsetY = 0.0f; /**< Y 偏移。 */
double offsetTime = 0.0; /**< 时间偏移。 */
int originX = 0; /**< 位图左上角(屏幕 x)。 */
int originY = 0; /**< 位图左上角(屏幕 y)。 */
std::unique_ptr<gd::Bitmap> bitmap; /**< 32bpp ARGB 光晕图层(直通 alpha)。 */
/**
* @brief 预乘 alpha 的 DIB 副本(每帧真正贴出去的那张)。
*
* ⚠️ 每帧贴图必须走 GDI `AlphaBlend` + `AC_SRC_ALPHA`:
* `gd::Graphics::DrawImage` 贴一张 32bpp 半透明位图实测约 **6ms**(每帧都付),
* 而 `AlphaBlend` 对同一张图约 **0.3ms**。
* `AlphaBlend` 要求源位图的 RGB **已按 alpha 预乘**,因此重建时把
* `bitmap` 拷进这个 DIB 并顺带做预乘(只付一次)。
*/
HDC dc = nullptr;
HBITMAP dib = nullptr;
void* bits = nullptr;
int dibWidth = 0;
int dibHeight = 0;
};
mutable HaloLayerCache m_haloLayer;
/** @brief 释放悬停光晕的离屏位图(析构 / 重建尺寸时使用)。 */
void ReleaseHaloLayer();
/**
* @brief 把 `m_haloLayer.bitmap` 拷进预乘 alpha 的 DIB(必要时先创建/重建 DIB)。
* @param [IN] width 需要的位图宽度。
* @param [IN] height 需要的位图高度。
* @return DIB 可用返回 true。
*/
bool UploadHaloLayer(int width, int height);
/**
* @brief 绘制某条信号的悬停/拖拽光晕(折线描边版本)。
*
* ⚠️ 只有**单个有效数据点**的信号没有折线可描边(其默认样式是「点线」),
* 此时改为围绕那个数据点画一圈强调色圆环 —— 否则悬停时完全没有视觉反馈。
*
* @param [IN,OUT] g 绘图上下文。
* @param [IN] resolved 已解析的信号。
* @param [IN] lane 该信号所在网格。
* @param [IN] points 折线顶点。
* @param [IN] breaks 每段折线的起始下标。
*/
void PaintSignalHalo(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane,
const std::vector<gd::PointF>& points, const std::vector<std::size_t>& breaks);
/**
* @brief 单点信号那个数据点绘制时的半径(与散点绘制同源)。
* @param [IN] resolved 已解析的信号。
* @return 半径(像素)。
*/
float SinglePointDotRadius(const ResolvedSignal& resolved) const;
/**
* @brief 取某信号**唯一数据点**在屏幕上的位置(含时间偏移与「调整」变换)。
* @param [IN] resolved 已解析的信号(要求 `finiteCount == 1`)。
* @param [IN] lane 该信号所在网格。
* @param [OUT] out 屏幕坐标。
* @return 取到有效位置返回 true。
*/
bool SinglePointScreenPos(const ResolvedSignal& resolved, const LaneVisual& lane,
gd::PointF& out) const;
/**
* @brief 计算某网格图例中 GX 编号徽标的矩形(绘制与命中测试共用,保证一致)。
* @param [IN] laneIndex 网格下标。
* @return 徽标矩形;网格不可见时返回空矩形。
*/
gd::RectF LaneBadgeRect(int laneIndex) const;
/**
* @brief 查询坐标命中的网格编号徽标(用于发起网格次序拖拽)。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 命中的网格下标,未命中返回 -1。
*/
int LaneBadgeAtPoint(int x, int y) const;
/**
* @brief 网格拖拽重排时某条网格的显示位移(像素)。
*
* 被拖拽的网格自由跟随鼠标;它路径上的其它网格整体上/下让位一格。
* @param [IN] laneIndex 网格下标。
* @return 纵向位移。
*/
float LaneDragOffset(int laneIndex) const;
/**
* @brief 网格拖拽重排:按鼠标位置计算插入位置(0..网格数)。
* @param [IN] mouseY 鼠标客户区纵坐标。
* @return 插入位置。
*/
int LaneDragTargetAt(int mouseY) const;
/**
* @brief 绘制网格拖拽重排过程中的内容(逐网格按位移贴图 + 目标位置高亮)。
* @param [IN,OUT] g 绘图上下文。
*/
void PaintLaneReorder(gd::Graphics& g);
/**
* @brief 结束网格拖拽重排:若目标位置与原位置不同则真正重排并同步编号。
*/
void FinishLaneReorder();
/**
* @brief 确保离屏缓存位图存在且尺寸匹配。
* @param [IN] width 位图宽度(像素)。
* @param [IN] height 位图高度(像素)。
* @return 可用返回 true。
*/
bool EnsureSceneCache(int width, int height);
/**
* @brief 把当前画布内容(绘图区底色 + 全部网格 + 时间轴)拷贝进离屏缓存。
*
* 缓存区域严格限定为绘图区(m_area)。悬停与拖拽产生的内容一律不进入缓存,
* 因此在任何交互过程中都可以安全复用,只贴图而不再重绘波形。
*
* @param [IN,OUT] g 当前绘图上下文(必须未设置裁剪区)。
* @return 拷贝成功返回 true。
*/
bool CaptureSceneCache(gd::Graphics& g);
/**
* @brief 把离屏缓存贴回画布。
* @param [IN,OUT] g 绘图上下文。
* @return 命中缓存并贴图成功返回 true。
*/
bool BlitSceneCache(gd::Graphics& g);
/**
* @brief 丢弃缓存标记(下次绘制会重新捕获)。
*/
void InvalidateSceneCache();
/**
* @brief 释放离屏缓存占用的 GDI 对象。
*/
void ReleaseSceneCache();
HBITMAP m_sceneBitmap = nullptr; /**< 离屏缓存位图。 */
HDC m_sceneDc = nullptr; /**< 离屏缓存设备上下文。 */
HGDIOBJ m_sceneOldBitmap = nullptr; /**< 离屏 DC 原选位图。 */
int m_sceneWidth = 0; /**< 离屏位图宽度。 */
int m_sceneHeight = 0; /**< 离屏位图高度。 */
int m_sceneX = 0; /**< 缓存对应的客户区横坐标(绘图区左边界)。 */
int m_sceneY = 0; /**< 缓存对应的客户区纵坐标(绘图区上边界)。 */
bool m_sceneValid = false; /**< 缓存内容是否有效。 */
SceneCacheKey m_sceneKey; /**< 缓存对应的场景参数。 */
/** 缓存捕获时"不含逐信号外观样式"的内容指纹(用于识别仅样式变化)。 */
std::uint64_t m_sceneGeometryHash = 0;
/** 缓存捕获时各网格内信号的外观样式快照(按网格分组,与 m_sceneStyles 同序)。 */
std::vector<std::vector<SceneStyleEntry>> m_sceneStyles;
/** 缓存捕获时的“叠加预览曲线”状态快照(用于判断只有曲线参数变化)。 */
std::vector<SceneDerivedEntry> m_sceneDerived;
/**
* @brief 静态场景缓存里存的是否为「快速帧」(交互期间关掉了抗锯齿)。
*
* 为 true 时 `SceneCacheMatches()` 直接判不命中,强制重画一次全质量版本 ——
* 这就是用户要求的“先用低分辨率、绘制完成后再用高分辨率替换”。
*/
bool m_sceneDegraded = false;
// ---- 「装饰层」像素缓存(Y 轴刻度胶囊 + GX 编号 + 图例)----
// 装饰只取决于网格布局 / 幅值范围 / 信号外观与图例文案,与时间视界无关;但它
// **不适合**放进「宽基底」:平移是从基底里截取子矩形,烘进基底的装饰会跟着一起
// 移动 → 在网格左缘之外留下一排旧图例/刻度(重影)。因此单独缓存一层的像素。
//
// ⚠️ **本缓存带真 Alpha**(32bpp DIB + `AlphaBlend`),而不是"从后备缓冲区抓一块不透明
// 矩形":装饰是**圆角**图形,抓矩形会把圆角之外(属于波形)的像素也一并烘进缓存,
// 平移贴回时露出"圆角矩形外围多出一圈直角白边 / 旧波形像素残留"(用户报告)。
// 现在改为:**把装饰单独绘制进一张透明 DIB**(圆角之外保持全透明),贴回时用
// `AlphaBlend` 合成 ⇒ 圆角之外透出的是**当前**波形,既干净又可跨视界复用。
//
// ⚠️ 关键不变量:**每一帧要显示装饰时,都用 `m_decorKey == SceneDecorHash()` 判定缓存
// 是否同源**;不同源就必须先 `RenderDecorCache()` 再贴回。
/**
* @brief 确保装饰缓存位图尺寸正确(不足则重建为 32bpp 的 DIB 段)。
* @param [IN] width 位图宽度。 @param [IN] height 位图高度。
* @return 可用返回 true。
*/
bool EnsureDecorCache(int width, int height);
/** @brief 释放装饰缓存位图并作废缓存。 */
void ReleaseDecorCache();
/**
* @brief 把装饰(Y 轴胶囊 + GX 编号 + 图例)**单独绘制**进透明的装饰缓存位图。
*
* 位图覆盖范围 = 客户区原点到绘图区右下角(`m_area` 的右下角),因此绘制坐标与
* 正常绘制**完全一致**(GDI 文本不认 GDI+ 变换,必须避免任何坐标平移)。
* 结束后做一次 alpha 修补:GDI 文本会把写入像素的 alpha 清成 0,需要还原为不透明。
* @return 成功返回 true。
*/
bool RenderDecorCache();
/**
* @brief 把装饰缓存里的装饰按 `AlphaBlend` 贴回后备缓冲区。
* @param [IN,OUT] g 绘图上下文。 @return 贴图成功返回 true。
*/
bool BlitDecorCache(gd::Graphics& g);
/**
* @brief 取 `AlphaBlend` 函数指针(`gdi32!GdiAlphaBlend`,必要时回退到 `msimg32!AlphaBlend`)。
*
* 走 `GetProcAddress` 而不是链接库:本项目的 Makefile 只链接 gdi32 等固定几个库,
* 且两台协作设备的工具链不同,动态解析可避免改动构建脚本、也避免引入新的链接依赖。
* @return 可用返回函数指针,否则返回 nullptr。
*/
static void* ResolveAlphaBlend();
mutable HDC m_decorDc = nullptr; /**< 装饰缓存设备上下文。 */
mutable HBITMAP m_decorBitmap = nullptr; /**< 装饰缓存位图。 */
mutable HGDIOBJ m_decorOldBitmap = nullptr; /**< 装饰 DC 原选位图。 */
mutable void* m_decorBits = nullptr; /**< 位图像素(32bpp DIB 段,可写 alpha)。 */
mutable int m_decorW = 0; /**< 装饰缓存宽度。 */
mutable int m_decorH = 0; /**< 装饰缓存高度。 */
mutable bool m_decorValid = false; /**< 装饰缓存内容是否有效。 */
mutable std::uint64_t m_decorKey = 0; /**< 装饰缓存对应的内容指纹。 */
mutable bool m_useDecorCache = false; /**< 本帧是否跳过绘制装饰、改用缓存贴回。 */
/** 本帧需要捕获的装饰矩形(各网格的 Y 轴胶囊 + GX 编号 + 图例托底)。 */
mutable std::vector<gd::RectF> m_decorRects;
/**
* @brief 缓存里那批像素对应的装饰矩形(与像素同时生成)。
*
* ⚠️ 贴回必须用这一份,**不能**用当前的 `m_decorRects`:
* 命中缓存时各网格会跳过绘制、根本不会重建矩形列表(为空),
* 若用当前列表就会贴错位置或一个都不贴(表现为装饰消失 / 黑块 / 错位)。
*/
mutable std::vector<gd::RectF> m_decorCapturedRects;
// ---- 每网格「信号图层」(前台同步维护;交互帧只做贴图 / 坐标偏移) ----
//
// 平移只改变绘制坐标、不改变画面内容。因此**每个网格各自维护一块离屏位图**
// (宽度 = 视界宽 + 左右各半个视界的余量),称为该网格的「信号图层」。
// 图层只含基底(底色 + 网格线 + 波形),**不含装饰**(Y 轴刻度 / 图例)——
// 装饰锚定在网格左缘、不随时间视界移动,若烘进图层会被平移带走(重影),
// 因此装饰由前台单独绘制(见 `m_decorCapturedRects` 那套缓存)。
//
// ⚠️ **图层一律在 UI 线程内同步维护**(`Paint` 全程持有 `m_renderMutex`),
// 因为渲染图层会临时改写 `m_area` / `m_plotRect` / `Tab().ViewMin()/ViewMax()` /
// 各网格矩形 —— 这些状态**同时被 UI 线程的消息处理无锁读取**
// (`OnMouseMove` 平移读视界、命中测试读网格矩形)。若把图层渲染搬到后台线程,
// 后台的"保存 → 改写 → 恢复"会**覆盖用户正在进行的平移**(视界被弹回原位),
// 命中测试也会读到错位的网格 → 表现为「拖动时视界跳回 / 波形抖动 / 绘制错乱」。
// 这正是先前"后台图层线程"被移除的原因:**不要把图层渲染移回后台**。
//
// 每个图层记录自己的时间映射(`LaneLayer::wideMin`):不同时刻渲染出的图层锚点
// 并不相同,滚动与贴图一律**逐网格**按各自的 `wideMin` 计算,**绝不共用一个全局锚点**
// (共用全局锚点会让各网格被平移不同的真实距离 → 网格之间时间错位)。
/**
* @brief 单个网格的「信号图层」(一块只含该网格基底的离屏位图)。
*/
struct LaneLayer
{
HDC dc = nullptr; /**< 离屏 DC。 */
HBITMAP bitmap = nullptr; /**< 离屏位图。 */
HGDIOBJ oldBitmap = nullptr; /**< DC 原选位图。 */
int width = 0; /**< 位图宽度(含左右各半屏余量)。 */
int height = 0; /**< 位图高度(含上下各半个网格高的余量)。 */
int marginY = 0; /**< 位图在**可见网格上方**预留的像素行数(纵向预渲染余量)。 */
double wideMin = 0.0; /**< 位图 x=0 对应的时间。 */
double span = 0.0; /**< 渲染时的视界跨度(决定像素/时间)。 */
/**
* @brief 渲染时的**纵向锚点**:该网格的 `yMax`(可见窗口顶行对应的数值)。
*
* 与 `wideMin` 同理,平移(纵向是幅值平移)时只移动**贴图偏移**、不重画像素:
* 需要贴到屏幕顶行的位图行 = `marginY + round((anchorYMax - 当前 yMax) * pxPerValue)`。
*/
double anchorYMax = 0.0;
double pxPerValue = 0.0; /**< 位图纵向像素 / 数值单位(= 可见网格高 / 幅值跨度)。 */
std::uint64_t key = 0; /**< 内容指纹(含跨度、网格几何与外观)。 */
bool valid = false; /**< 是否有可显示的像素。 */
};
/** @brief 确保某网格图层位图尺寸正确(不足则重建 DC/位图)。 */
bool EnsureLaneLayer(LaneLayer& layer, int width, int height);
/** @brief 释放单个图层位图。 */
void ReleaseLaneLayer(LaneLayer& layer);
/** @brief 按当前视界与内容计算某网格图层的内容指纹。 */
std::uint64_t LaneLayerKey(int laneIndex) const;
/**
* @brief 把某网格的基底渲染进指定图层(同步,调用者需持有 `m_renderMutex`)。
* @param [IN] laneIndex 网格下标。
* @param [IN,OUT] layer 目标图层(会被写入尺寸 / 时间映射 / 指纹)。
* @param [IN] stripX 只渲染位图的这一段 x 区间(滚动时补画细条用)。
* @param [IN] stripW 该区间的宽度;传 0 表示横向整块。
* @param [IN] stripY 只渲染位图的这一段 y 区间(纵向滚动补条用)。
* @param [IN] stripH 该区间的高度;传 0 表示纵向整块。
* @param [IN] wideMin 位图 `x=0` 对应的时间。
* **必须与调用方的意图一致**:整块重画传 `LayerDefaultWideMin()`(以当前视界居中);
* 滚动补细条则传 `layer.wideMin`(与该图层已有像素同一映射,否则会留下接缝)。
* @param [IN] anchorYMax 本次渲染的像素所对应的**纵向锚点**(= 该网格的 `yMax`)。
* ⚠️ **必须与"本次改动的那部分像素"一致**:
* - 整块重画 / 纵向补条(整宽):整块图层都对齐到锚点 → 传**当前** `yMax`
* (纵向补条则传 `layer.anchorYMax` 滚动后的值,与已滚动的像素同一映射);
* - **横向补细条(整高)**:只改一条竖带,其余像素仍在**原锚点**上,
* 因此必须传 `layer.anchorYMax`(**绝不能传当前 `yMax`**)—— 否则贴图偏移被
* 静默清零、而像素并未上移,整个网格的波形会纵向错位(用户报告的
* "垂直拖动后再横向平移出现纵向跳变/绘制错误")。
* @return 渲染成功返回 true。
*/
bool RenderLaneLayerRegion(int laneIndex, LaneLayer& layer, int stripX, int stripW, int stripY,
int stripH, double wideMin, double anchorYMax);
/**
* @brief 前台补画各网格的**分界线**(网格顶部那条较重的横线)。
*
* ⚠️ 分界线锚定在网格的**屏幕位置**(`lane.rect.Y`),幅值平移时它**不跟着移动**,
* 因此不能烘进"纵向可平移"的图层里(否则纵向拖动时它会跟着波形跑)。
* 整幅重绘由 `PaintLane` 直接绘制;宽基底快路径(贴图层像素)必须在这里补画一次。
*/
void PaintLaneDividers(gd::Graphics& g) const;
/** @brief 「以当前视界居中」的默认图层映射(位图 `x=0` 对应的时间)。 */
double LayerDefaultWideMin() const;
/** @brief 图层位图的横向余量(左右各半屏 → 可横向平移 ±半屏)。 */
int LayerMarginX() const;
/**
* @brief 图层位图的纵向余量(上下各半个网格高 → 可纵向平移 ±半个网格高)。
*
* ⚠️ 纵向余量让「拖拽平移的纵向分量」(幅值平移)与横向平移一样**只改贴图偏移**,
* 不必每帧重画整块图层 —— 这正是「左右平移流畅、上下平移卡顿」的根因所在。
* 余量越大可连续拖拽的范围越大,但每个横向补条 / 整块重画的代价也按余量线性增长
*(因此取「半个网格高」:一整屏的额外内容已足够,且不拖慢已经流畅的横向平移)。
*/
int LayerMarginY(float laneHeight) const;
/**
* @brief 以当前视界为中心重建全部网格图层(同步;跨度变化 / 首帧 / 图层缺失时使用)。
* @return 全部成功返回 true。
*/
bool RebuildWideBuffer();
/**
* @brief 让各图层"滚动"以跟随视界:逐网格整数像素自贴图平移 + 只补画新暴露的细条。
*
* ⚠️ **本函数绝不做"整块重画"**:内容指纹过期 / 锚点离视界太远的网格只被排进
* `m_layerRebuildPending`(随后由 `RenderPendingLaneRebuilds` 按预算补画),本帧仍按
* 现有像素做整数像素平移,因此**前台线程永远不会因为"图层没准备好"而卡顿**。
*/
void RollWideBuffer();
/**
* @brief 逐个网格截取图层子矩形贴图到后备缓冲区(O(1) 位图搬运)。
*
* 图层只覆盖"视界 ± 半屏":若当前视界有一部分落在图层之外,这里**只贴有像素的那一段**
* (其余留由底色填充,下一帧补齐),而不是跳过整块 —— 这正是"未绘制的部分先暂时不管"。
* @param [IN,OUT] g 绘图上下文。
* @return 至少贴了若干像素返回 true。
*/
bool BlitWideBuffer(gd::Graphics& g, const std::vector<double>& timeTicks);
/**
* @brief 为单个网格绘制**底衬**(网格底色 + 横向/竖向网格线 + 网格分界线)。
*
* 不含波形、派生预览曲线、Y 轴刻度与图例 —— 这些要么代价高、要么属于装饰层。
* 用途:宽基底快路径中本帧拿不到图层像素的网格用它兜底,保证**网格底衬这种
* 廉价绘制永远不会空白/闪烁**(用户 2026-10-17 明确要求)。
* @param [IN,OUT] g 绘图上下文。
* @param [IN] laneIndex 网格下标。
* @param [IN] timeTicks 当前视界的时间主刻度。
* @return 绘制了内容返回 true。
*/
bool PaintLaneGridBacking(gd::Graphics& g, int laneIndex, const std::vector<double>& timeTicks);
/**
* @brief 全部图层是否**结构可用**(数量 / 位图尺寸 / 视界跨度都与当前状态一致)。
*
* 结构不可用意味着"像素的时间映射已经不对",只能整批重建(跨度变化 / 布局变化 / 首帧);
* 内容指纹是否最新由 `LaneLayerReady()` 逐个判断。
* @return 可以直接滚动 + 贴图层像素返回 true。
*/
bool LayersStructurallyReady() const;
/**
* @brief 图层数组是否已经**与当前网格数量对齐**(首次绘制 / 增删网格后为 false)。
*
* ⚠️ 只有它返回 false 时才允许**同步**整批重建(否则没有可拉伸的旧像素可占位)。
* 跨度变化(缩放)**不**走同步重建:改为"占位拉伸 + 按预算逐个补齐"。
* @return 数量对齐返回 true。
*/
bool LayersAllocated() const;
/**
* @brief 某个网格的图层是否**完全就绪**(结构可用且内容指纹与当前状态一致)。
* @param [IN] laneIndex 网格下标。
* @return 就绪返回 true。
*/
bool LaneLayerReady(int laneIndex) const;
/** @brief 全部网格图层是否都完全就绪(用于判断能否做"结算"整幅精确帧)。 */
bool LanesAllReadyForView() const;
/**
* @brief 该网格是否**在屏幕可视区内**(其纵向带与绘图区相交)。
*
* ⚠️ 只有可视网格的图层才影响画面。整批重建时必须**优先补画可视网格**,
* 否则(上百个网格时)队列按下标顺序推进,可视网格可能要等上百帧才轮到,
* 期间画面一直停留在旧的兜底状态(用户报告:需要等一会才正确加载)。
* @param [IN] laneIndex 网格下标。
* @return 可视返回 true。
*/
bool LaneVisuallyRelevant(int laneIndex) const;
/**
* @brief 在「可视」基础上再多算 `prefetchScreens` 个绘图区高度的**预取带**。
*
* ⚠️ 预取带只用于决定「是否还要请求下一帧去补图层」(`AnyVisibleLanePending` / 预热):
* 网格滚动动画很可能在几百毫秒内扫过一整个屏高,若只预建「严格可见」的网格,
* 动画扫进去的那几帧会落在一堆**尚未建过图层**的网格上 ⇒ 每帧都要靠底衬兜底
* 画十几份网格(实测 ~8ms/帧)⇒ 动画明显卡顿(用户 2026-10-17 报告)。
* 预取带让静置期间就把附近网格的图层建好。
* ⚠️ **「结算」不能带预取带**(它要求的是“当前可见区域像素精确”)。
* @param [IN] laneIndex 网格下标。
* @param [IN] prefetchScreens 预取带宽度(倍绘图区高度,0 = 严格可见)。
* @return 可视(或在预取带内)返回 true。
*/
bool LaneVisuallyRelevant(int laneIndex, int prefetchScreens) const;
/** @brief 全部**可视**网格图层是否都已就绪(用于判断能否做结算的整幅精确帧)。 */
bool LanesVisiblyReadyForView() const;
/**
* @brief 待重画队列里是否还有**可视**网格。
*
* ⚠️⚠️ 用它(而不是“队列非空”)来决定“是否要请求宿主再送一帧”:
* 上百个网格时队列里会长期残留大量**离屏**网格,若按“队列非空”每帧都请求重绘,
* 就会形成持续 1 秒以上、每帧一次的高频重绘(用户在“显示全部信号 → 折起分组”
* 时正好会撞上),把动画的 `WM_TIMER`(优先级低于投递消息)饿死 ⇒ 动画卡住 / 闪烁。
* 离屏网格改为**机会式**预热:任何一帧(悬停、动画、滚动…)顺手补一个即可。
* @return 仍有可视网格待重画返回 true。
*/
bool AnyVisibleLanePending() const;
/**
* @brief 「图层预热」是否允许再请求一帧(**限速**)。
*
* ⚠️⚠️ 背景:待重画队列在“全部信号各自一块网格”的场景下可能有上百个**离屏**网格。
* 若“队列非空就每帧都请求重绘”,会持续一秒以上地高频重绘,而 `WM_TIMER` 的优先级
* **低于投递消息** ⇒ 动画定时器被饿死(实测 `frames=124 / 动画 tick=1`)。
* 若反过来“离屏的一律不问”,则用户快速滚动到那些网格时它们**还没建过图层**,
* 每帧要靠底衬兜底画十几份网格(实测 ~8ms/帧)⇒ 动画卡顿
*(用户 2026-10-17 报告:MF4 + VERY_BIG_DATA 时点选最后一个信号,滚动明显卡)。
*
* ✓ 折中:以约 `kPrefetchMinIntervalMs` 的间隔**慢速**继续请求,让整批图层在静置期间
* 逐步建好(对 130 个网格大约几秒),期间不会把动画的定时器挤出预算。
*
* @return 允许请求返回 true(并记录本次时刻)。
*/
bool PrefetchStepAllowed();
/** @brief 把某网格排进"待重画"队列(去重;视界/跨度后续变化时会在渲染时再次校验)。 */
void QueueLaneRebuild(int laneIndex);
/**
* @brief 按预算补画"待重画"的网格图层(**本帧最多 `budget` 个**)。
*
* 这是"前台不做整批重绘"的关键:即使 5 个网格都需要重画,也一帧只画一个,
* 其余留到后续帧(缺的部分留空 / 用旧像素)。调用方若发现队列非空应请求再次重绘。
* @param [IN] budget 本次最多重画的网格数量。
* @return 队列中仍有待重画网格返回 true。
*/
bool RenderPendingLaneRebuilds(int budget);
/**
* @brief **空闲预热**:静置期间用既有预算机制**每帧至多补画 1 个网格图层**。
*
* 图层不存在(刚加载 / 网格数量变化)或还是旧跨度时,若等到用户**第一次**
* 平移 / 选区放大才去建,`Paint` 会在那一帧里**同步重建全部网格图层**
*(5 个网格实测 30~80ms)—— 这就是"选区放大的第一帧特别卡"的根因
*(该帧其余工作只有约 16ms 的占位拉伸)。
*
* 这里只在**视界没有变化**(命中静态场景缓存 ⇒ 本帧视图与缓存同源)且**不在拖拽中**时
* 调用一次,把整批重建的代价摊到若干静止帧上;任何交互帧都不再承担它。
*/
void WarmLaneLayersStep();
/**
* @brief 预建「宽缓冲基底」:只为**尚未建立 / 已失效**的网格补图层。
*
* 用户 2026-10-17 报告:每次开/关一个实验性功能(以及关闭浮动面板)都会卡一下。
* 根因是旧实现直接 `RebuildWideBuffer()` —— 它**同步重渲染全部网格的图层**:
* “全部信号各自一块网格”时可达上百个网格、其中可能含着千万级采样的巨型信号,
* 整批重建要**几百毫秒**。
*
* ✓ 现在:只有「图层数组与网格数量不符」(首次绘制 / 增删网格)才同步整批建立;
* 其余只把缺失 / 过期的网格登记进待重画队列,由既有预算机制逐帧补齐。
* 这已经足够达成原本的目的(避免“首次拖动参数滑块时的一次性重建尖峰”):
* 拖动滑块时视界在变 ⇒ 本就不做重建、直接用现有基底。
*/
void EnsureLaneLayersPrefetched();
/** @brief 使全部图层失效。 */
void InvalidateWideBuffer(); /** @brief 释放全部图层位图。 */
void ReleaseWideBuffer();
/**
* @brief **并行**重建多个网格的信号图层(多核;用于"视界已停"时一次性补齐所有过期网格)。
*
* ⚠️ 并行化方式:为每条工作线程准备一个**隔离的 `PlotView`**(它把 `m_tab` 置空、
* 用自带的 `m_localTab` 副本),因此 `RenderLaneLayerRegion` 改写 `m_area` / `m_plotRect` /
* 视界 / 网格矩形时**只作用于该线程自己的副本**,不会与 UI 线程或其它工作线程互相覆盖
*(这正是 2026-10-12 移除"后台图层线程"的根因;用隔离上下文即可安全并行)。
* 合成只需各线程把自己那块位图交给主线程(`std::swap` 交换 `m_layerFront`)。
* @param [IN] laneIndices 要重建的网格下标(各自独立)。
* @param [IN] wideMin 图层位图 `x=0` 对应的时间(各网格一致,即"以当前视界居中")。
*/
void RebuildLaneLayersParallel(const std::vector<int>& laneIndices, double wideMin);
/** @brief 创建(或按需扩容)并行图层渲染的工作线程上下文。 */
void EnsureLayerWorkers();
/** @brief 释放并行图层渲染的工作线程上下文与暂存图层。 */
void ReleaseLayerWorkers();
/** 并行图层渲染的工作上下文(每线程一个,惰性创建;每个都有自己的 `m_workerTab`)。 */
std::vector<std::unique_ptr<PlotView>> m_layerWorkers;
int m_layerWorkerCount = 0;
/**
* @brief 工作线程实例**自有的**标签页配置(`m_tab` 指向它)。
*
* 工作线程的 `RenderLaneLayerRegion` 会改写视界 / 网格矩形;让它改在自己这份副本上,
* 就不会碰到 UI 线程共享的 `TabConfig`(安全并行的前提)。
* ⚠️ `m_tab` **必须非空** —— `BuildLaneVisuals()` 在 `m_tab == nullptr` 时会清空 `m_lanes`。
*/
TabConfig m_workerTab;
std::vector<LaneLayer> m_layerFront; /**< 各网格的「信号图层」(与 `m_lanes` 同序)。 */
/** 待重画的网格下标(去重;`RollWideBuffer` 登记,`RenderPendingLaneRebuilds` 消费)。 */
std::vector<int> m_layerRebuildPending;
/**
* @brief 图层元数据的互斥(当前图层只在 UI 线程维护,故它实际不承担跨线程保护;
* 保留是为了给 `BlitWideBuffer()` 这类 `const` 读取路径一个明确的一致性边界)。
*/
mutable std::mutex m_layerMutex;
std::recursive_mutex m_renderMutex; /**< 串行化"改写渲染状态"的绘制(整帧 / 单网格图层);可重入。 */
/** 已渲染的图层区域数量(原子,仅用于验证 / 诊断)。 */
mutable std::atomic<std::uint64_t> m_layerRenderCount{0};
bool m_renderWide = false; /**< 正在渲染信号图层(重入 `PaintLane` 时跳过装饰)。 */
/**
* @brief 本次 `PaintLane` 只绘制「网格底衬」(底色 + 网格线 + 网格分界线)。
*
* 用于宽基底快路径的兜底:本帧拿不到图层像素的网格必须**立刻**画出正确的网格底衬,
* 否则该网格会显示成一块空白(用户 2026-10-17 报告「网格叠底消失」)。
* 底色/网格线的绘制代价极低,因此这条兜底路径可以**每帧**执行。
*/
bool m_renderGridOnly = false;
/**
* @brief 渲染信号图层时,横向网格线(幅值刻度)的「参考跨度 / 参考高度」。
*
* ⚠️ **必须是前台可见网格的值**,而不是图层画布的(含纵向余量的)值:
* 图层的幅值刻度必须以该跨度为基准取「整齐步长」,否则画布里的网格线密度与
* 整幅渲染不一致 —— 贴图后拖动时网格线会跳变。步长一致时刻度落在同一个
* 绝对值格点上,于是纵向平移就是纯粹的像素平移(逐像素同源)。
*/
double m_wideTickSpan = 0.0;
float m_wideTickHeight = 0.0f;
/**
* @brief 渲染「信号图层(宽基底)」时使用的时间主刻步长(秒);0 = 按当前视界自行推算。
*
* ⚠️ **必须由 `RenderLaneLayerRegion` 显式传入(前台视界跨度算出的步长)**:
* 图层画布比前台宽约一倍(左右各半屏),而 `Tab().ViewMin/ViewMax` 在图层的临场渲染里
* 被改成了**宽窗口**区间 —— 若 `PaintLane` 直接拿这个区间去 `NiceStep`,算出的主刻步长会
* 比前台大(往往正好 2 倍),于是图层里的竖向网格线(含五等分辅助线)**落在与前台不同的位置**、
* 并且“与主刻重合则跳过”的判据也错位 → 用户看到网格线**错位/部分整条丢失**、
* 且在“拖拽帧(用图层)”与“结算帧(整幅重绘)”之间来回切换时**闪烁**。
* 与纵向的 `m_wideTickSpan/m_wideTickHeight` 是同一套思路(那两处已经做对了)。
*/
double m_renderTimeStep = 0.0;
/**
* @brief 本次 `PaintLane` 只绘制「装饰」(Y 轴刻度胶囊 + 图例 + 网格分界线)。
*
* 宽缓冲区只存「基底」(底色 + 网格线 + 波形 + 派生曲线),**不含装饰**:
* 因为装饰锚定在网格左缘、随视界横向移动,若烘进基底,平移截取时会产生重影。
* 因此前台每帧用本标志重画一次装饰(复用与整幅渲染**完全相同**的绘制代码,
* 保证逐像素一致),而不是去贴回任何缓存像素。
*/
bool m_renderDecorOnly = false;
/** 上一次走"宽缓冲快路径"时的内容指纹(含视界)。当前帧指纹与之相同 → 视界已停止变化,
* 此时改走一次整幅重绘("结算"),保证最终呈现的是**像素精确**的全质量帧。 */
std::uint64_t m_wideLastKey = 0;
bool m_wideLastValid = false;
/**
* @brief 上一次为「图层预热」请求宿主重绘的时刻(`GetTickCount64()`)。
*
* 用来给预热**限速**(见 `PrefetchStepAllowed`):既保证离屏网格最终都会建好图层,
* 又不会高频请求重绘把动画的 `WM_TIMER` 饿死。
*/
mutable std::uint64_t m_lastPrefetchRequestMs = 0;
/**
* @brief 实验性功能浮动面板的像素图层项。
*
* 面板被完整绘制进 `m_panelDc`;只要 `key` 不变,下一帧就直接按
* `[x0, y0, x1, y1)` 贴图,不再重复绘制(15 个面板全画约 33ms,贴图 <1ms)。
*/
struct PanelSlot
{
int laneIndex = -1; /**< 所属网格。 */
int kind = 0; /**< `EdgePanelKind`。 */
std::uint64_t key = 0; /**< 面板自身的缓存键。 */
int x0 = 0; /**< 已缓存矩形(含阴影外扩),左。 */
int y0 = 0; /**< 上。 */
int x1 = 0; /**< 右(不含)。 */
int y1 = 0; /**< 下(不含)。 */
};
HBITMAP m_panelBitmap = nullptr; /**< 面板图层位图(原点与客户区一致)。 */
HDC m_panelDc = nullptr; /**< 面板图层设备上下文。 */
HGDIOBJ m_panelOldBitmap = nullptr; /**< 面板图层 DC 原选位图。 */
int m_panelWidth = 0; /**< 面板图层位图宽度。 */
int m_panelHeight = 0; /**< 面板图层位图高度。 */
bool m_panelValid = false; /**< 图层内容是否有效。 */
std::uint64_t m_panelGlobalKey = 0; /**< 上一层对应的全局键。 */
std::vector<PanelSlot> m_panelSlots; /**< 上一层缓存的面板(本帧比对用)。 */
/**
* @brief 浮动面板的**层序**(从下到上)。
*
* 面板允许互相重叠,因此必须有明确的层序:
* * **绘制**按此顺序从下往上;**命中测试**从后往前(最上层优先)。
* * 点击某个面板会把它移到队尾(置顶),因此重叠时总能操作到"用户想要"的那一个
* (否则上层面板的 ✕ / 标题栏会被下层控件的命中判定抢走)。
* * 新出现的面板追加到队尾(在最上层),不会被已有面板盖住。
*/
struct PanelRef
{
int laneIndex = -1; /**< 所属网格。 */
int kind = 0; /**< `EdgePanelKind` 的整型值。 */
bool operator==(const PanelRef& other) const
{
return laneIndex == other.laneIndex && kind == other.kind;
}
};
/** 同步 `m_panelOrder` 与当前"可见的面板集合"(新增的追加到最上层;消失的移除)。 */
void SyncPanelOrder() const;
/** @brief 把某个面板移到最上层(点击面板时调用)。 */
void BringPanelToFront(int laneIndex, int kind);
/**
* @brief 从最上层往下找出包含该点的面板。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @param [OUT] laneIndex 命中面板所属网格。
* @param [OUT] kind 命中面板的种类(`EdgePanelKind`)。
* @return 命中返回 true。
*/
bool TopPanelAtPoint(float x, float y, int& laneIndex, int& kind) const;
mutable std::vector<PanelRef> m_panelOrder; /**< 浮动面板层序(队尾 = 最上层;mutable 供 const 光标查询同步层序)。 */
/**
* @brief 查询坐标命中的图例条目。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 命中的信号引用,未命中返回无效引用。
*/
SignalRef LegendAtPoint(int x, int y) const;
};
} // namespace jdi
#endif // __PLOT_VIEW_HPP__