Files
Just-DataInspector/Utilities/Inc/DataModel.hpp
T
DeepSeek V4.1 Flash fc77b0262f 「调整」行:系数/偏移 按钮 + 偏移调整(Y 轴/时间轴)二级面板;结算顺序加入偏移;派生信号继承调整变换
按钮与面板:
- 「系数」按钮改名为「系数/偏移」;点击展开的二级面板在原「系数调整」输入框下方新增「偏移调整」:
  与「系数调整」同一套绘制(标题 + 标题栏右侧的还原图标),下面两行分别是「Y轴」「时间轴」,
  每行左侧说明文字、右侧内联输入框(整行数值框)。
- 「Y轴」默认 0.00,非编辑态**始终带正负号**(+0.00 / -12.50),小数不足两位时补两位、更高精度保留更多位。
- 「时间轴」默认 0.00,非编辑态显示与双光标差值同一套复合单位文本(`Δt:500ms`、`Δt:1h 1min 2s`);
  进入编辑态时显示以**秒**为单位的数值,并在输入框最右侧提示单位 `s`。
- 「偏移调整」的还原图标把两个偏移恢复为默认值 0;「系数调整」的还原图标仍只重置系数。
- 面板/输入框的编辑初值与显示文本分离(`MenuItem::editValue` / `editSuffix`),
  非编辑态(`Δt:…`)与编辑态(`3662`)不一致时也能正确回显。

语义与结算顺序:
- 结算顺序变为 **系数 → 绝对值 → 镜像 → 偏移**(前三步不变)。
- Y 偏移:给绘制值加常数(波形上下移动);时间偏移:采样绘制在 `t + offsetTime` 处
  (例如 t=1s、偏移 -0.5s → 画在 0.5s)。
- ⚠️ Y 偏移以**未镜像空间**为准:垂直镜像开关变化时同步取反偏移量,
  使绘制等价于「先加上偏移再镜像」(开关来回切换可逆)。
- 绘制链路全部跟随偏移:`BuildPolyline`(逐点描边 / 像素列包络 / 跨列边界插值 / 粗粒度分组层,
  保序的 Y 偏移直接加在分组极值上)、派生预览曲线、光标取值读数(含 Δt)、
  可见区间 Y 轴自适应、整段自适应的区间映射;折线缓存键、场景指纹、样式快照与图层指纹都纳入两个偏移。

派生信号(实验性功能「构建为新信号」):
- 修复「预览是调整后的波形、构建出来却按原始状态绘制」:新信号**继承源信号的调整变换**
  (系数/绝对值/两个镜像/两个偏移)——网格预览绘制的是 `变换(滤波结果)`,
  而构建出的信号只存了滤波结果,因此必须把变换一并继承;
  时间轴偏移**只能**靠继承(派生信号与源信号共用同一文件时间轴,无法把时间平移烘进数据)。

离屏验证(不干扰用户):
- ⚠️ harness 现在在每次注入鼠标消息后**立即释放本窗口的鼠标捕获**
  (宿主 WM_LBUTTONDOWN 会 SetCapture,隐藏窗口同样会把用户的真实鼠标输入吸走);
  后续消息本来就是直接投递,无需捕获。
- 新增 [AP]:按钮标签不裁切、面板 5 行顺序与「偏移调整」位于系数输入框下方、
  Y轴/时间轴行的标签与输入框同行且默认值/单位提示正确、时间轴编辑态显示秒值并在最右侧提示 `s`、
  Y 偏移提交/显示正负号/波形整体平移量、时间偏移提交/Δt 复合单位(含 3662 → Δt:1h 1min 2s)/
  编辑态为秒、**按"偏移若干个采样点"构造的精确几何验证**(采样被画到移位后的像素位置、且不再画在原处)、
  读数跟随、两个还原图标、垂直镜像对 Y 偏移取反(可逆)、
  以及派生信号继承变换;[AL] 补充「系数→绝对值→镜像→偏移」的顺序与区间映射(含 Y 偏移)。
- 合计 645/645 通过;make clean && make all 零警告。
2026-10-13 17:56:00 +08:00

1118 lines
45 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 __DATA_MODEL_HPP__
#define __DATA_MODEL_HPP__
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
#include "CsvTable.hpp"
namespace jdi
{
/**
* @brief 波形绘制方式。
*/
enum class DrawMode : int
{
LINE = 0, /**< 相邻采样点之间以插值直线连接。 */
SCATTER = 1, /**< 仅绘制采样点散点。 */
LINE_SCATTER = 2 /**< 同时绘制插值直线与采样点散点。 */
};
/**
* @brief 绘图区拖拽缩放模式(由工具栏按钮切换)。
*/
enum class PlotZoomMode : int
{
NONE = 0, /**< 常规模式:拖拽平移时间轴,光标为箭头。 */
SELECT = 1 /**< 选区放大:横向拖拽放大时间轴,纵向拖拽放大当前网格的幅值范围。 */
};
/**
* @brief 时间光标模式(由工具栏「光标」按钮三态循环切换)。
*/
enum class PlotCursorMode : int
{
NONE = 0, /**< 不显示光标。 */
SINGLE = 1, /**< 单光标。 */
DOUBLE = 2 /**< 双光标(第二条用于与第一条对比取值)。 */
};
/**
* @brief 将绘制方式转换为中文显示名称。
* @param [IN] mode 绘制方式。
* @return 显示名称。
*/
const wchar_t* DrawModeName(DrawMode mode);
/**
* @brief 网格划分策略:决定信号可见性与网格数量的联动方式。
*/
enum class LaneMode : int
{
PER_SIGNAL = 0, /**< 每条可见信号独占一个网格(随可见性自动增删)。 */
MERGED = 1, /**< 全部可见信号叠加在同一网格。 */
CUSTOM = 2 /**< 由用户手动指定归属;可见性变化时仅清理空网格。 */
};
/**
* @brief 全局信号引用:文件下标 + 文件内信号下标。
*/
struct SignalRef
{
int fileIndex = -1; /**< 所属文件下标。 */
int signalIndex = -1; /**< 文件内信号下标。 */
/**
* @brief 判断引用是否指向有效位置。
* @return 两个下标均非负返回 true。
*/
bool IsValid() const { return fileIndex >= 0 && signalIndex >= 0; }
/**
* @brief 相等比较。
* @param [IN] other 另一引用。
* @return 指向同一信号返回 true。
*/
bool operator==(const SignalRef& other) const
{
return fileIndex == other.fileIndex && signalIndex == other.signalIndex;
}
/**
* @brief 不等比较。
* @param [IN] other 另一引用。
* @return 指向不同信号返回 true。
*/
bool operator!=(const SignalRef& other) const { return !(*this == other); }
};
/**
* @brief 单个信号的显示外观(颜色/透明度/线宽/绘制方式/数值变换)。
*
* 外观属于信号自身而非某个标签页——同一信号在所有标签页中必须显示效果一致,
* 因此在任一视图里修改颜色/线宽/透明度/样式,其它视图会自动同步。
* 信号是否可见则属于各标签页自己的配置(见 `TabConfig`)。
*/
struct SignalStyle
{
std::uint32_t color = 0xFF268BD2; /**< 波形颜色,格式 0xAARRGGBB。 */
std::uint32_t customColor = 0xFF6C71C4; /**< 自定义颜色记忆值(切换预设色后仍保留)。 */
float opacity = 1.0f; /**< 不透明度,取值 0.0-1.0,默认 1.0。 */
float lineWidth = 1.0f; /**< 线宽(逻辑像素,未乘 DPI 缩放)。 */
DrawMode mode = DrawMode::LINE; /**< 绘制方式。 */
/**
* 绘制前的变换(依次作用,见 `ApplyDrawTransform`):
* 1. `gain` 系数:采样值乘以该系数(默认 1,即不改变);
* 2. `absolute` 绝对值:|value|;
* 3. 镜像:`mirrorVertical` 按 Y = 0 上下镜像(value → −value),
* `mirrorHorizontal` 为**数据反序**(把采样序列头尾对调后绘制,即水平翻转);
* 4. 偏移:`offsetY` 给绘制值加上一个常数(波形整体上下移动),
* `offsetTime` 把采样沿时间轴平移(绘制在 `t + offsetTime` 处)。
*
* ⚠️ 结算顺序固定为「系数 → 绝对值 → 镜像 → 偏移」(用户明确要求)。
* 其中镜像的两个方向互相正交(垂直改**值**、水平改**取值下标**),偏移同理
*(`offsetY` 改值、`offsetTime` 改**绘制时刻**)。
* ⚠️ `offsetY` 以**未镜像空间**为准:启用/取消垂直镜像时会同步取反(见
* `SignalStyle::offsetY` 的使用处),使绘制等价于「先加上偏移再镜像」。
*/
float gain = 1.0f; /**< 系数,默认 1.0(不改变)。 */
bool absolute = false; /**< 是否对绘制值取绝对值,默认关闭。 */
bool mirrorVertical = false; /**< 是否按 Y=0 上下镜像(值取反),默认关闭。 */
bool mirrorHorizontal = false; /**< 是否水平镜像(数据反序绘制),默认关闭。 */
float offsetY = 0.0f; /**< Y 轴偏移(叠加到绘制值上),默认 0。 */
double offsetTime = 0.0; /**< 时间轴偏移(秒,绘制在 t + offsetTime 处),默认 0。 */
/** @brief 镜像功能的任一分量是否启用(决定「镜像」按钮的激活态)。 */
bool Mirrored() const { return mirrorVertical || mirrorHorizontal; }
/**
* @brief 是否存在任何**偏移**(决定「系数/偏移」按钮的激活态之一)。
* @return Y 或时间偏移不为 0 返回 true。
*/
bool Offset() const
{
return std::fabs(offsetY) > 1.0e-9f || std::fabs(offsetTime) > 1.0e-15;
}
};
/**
* @brief 把原始采样值变换为**绘制用值**。
*
* 顺序固定为「系数 → 绝对值 → 垂直镜像 → Y 偏移」,例如系数 2、原始值 1.5 →
* 2×1.5 = 3;取绝对值仍为 3;垂直镜像则 −3;Y 偏移 +1 得 −2。
* NaN 经乘/取绝对值/取反/加偏移后仍为 NaN(保持断线语义)。
*
* ⚠️ 水平镜像与时间偏移**不在这里处理**:它们分别改变“取值下标”与“绘制时刻”,
* 必须在遍历采样时处理(见 `SourceIndexFor` 与 `offsetTime` 的使用处)。
*
* @param [IN] style 信号外观。
* @param [IN] value 原始采样值。
* @return 绘制用值。
*/
inline double ApplyDrawTransform(const SignalStyle& style, double value)
{
if (style.gain != 1.0f)
{
value *= static_cast<double>(style.gain);
}
if (style.absolute)
{
value = (value < 0.0) ? -value : value;
}
if (style.mirrorVertical)
{
value = -value;
}
if (style.offsetY != 0.0f)
{
value += static_cast<double>(style.offsetY);
}
return value;
}
/**
* @brief 把遍历下标映射为**源采样下标**(处理水平镜像的"数据反序")。
*
* 水平镜像 = 头尾对调:遍历下标 `index` 取源下标 `count - 1 - index`。
* 关闭时原样返回,因此该函数在有/无镜像时都是 O(1)、无分支代价可忽略。
*
* @param [IN] style 信号外观。
* @param [IN] count 采样点数。
* @param [IN] index 遍历下标。
* @return 源采样下标。
*/
inline std::size_t SourceIndexFor(const SignalStyle& style, std::size_t count, std::size_t index)
{
if (!style.mirrorHorizontal || count == 0)
{
return index;
}
return (count - 1) - index;
}
/**
* @brief 判断数值变换是否**保持大小顺序**(正系数、不取绝对值、不垂直镜像)。
*
* 只有保序时,粗粒度包络层(每组的 min/max)才能直接按系数缩放后继续使用;
* 取绝对值 / 垂直镜像会把最小值与最大值互换,此时必须回退到逐采样点变换。
*
* @param [IN] style 信号外观。
* @return 变换单调递增返回 true。
*/
inline bool DrawTransformOrderPreserving(const SignalStyle& style)
{
return (style.gain > 0.0f) && !style.absolute && !style.mirrorVertical;
}
/**
* @brief 把**数值区间**按绘制变换映射为变换后的区间(用于整段 Y 轴自适应)。
*
* 各步作用在区间端点上是精确的(系数为线性、绝对值为 |x|、镜像与偏移为线性),
* 因此 `[minValue, maxValue]` 映射后就是真实的数据范围,无需扫描采样点。
* ⚠️ 水平镜像与时间偏移都不改变值的范围,因此不影响本函数。
*
* @param [IN] style 信号外观。
* @param [IN,OUT] low 区间下限,返回变换后的下限。
* @param [IN,OUT] high 区间上限,返回变换后的上限。
*/
inline void TransformValueRange(const SignalStyle& style, double& low, double& high)
{
if (low > high)
{
std::swap(low, high);
}
const double gain = static_cast<double>(style.gain);
if (gain != 1.0)
{
low *= gain;
high *= gain;
if (low > high)
{
std::swap(low, high);
}
}
if (style.absolute)
{
// |x| 在 [low, high] 上的取值范围:跳 0 时从 0 起,否则从较近的一端起。
const double first = std::fabs(low);
const double second = std::fabs(high);
const bool spansZero = (low <= 0.0) && (high >= 0.0);
low = spansZero ? 0.0 : std::min(first, second);
high = std::max(first, second);
}
if (style.mirrorVertical)
{
const double flipped = -low;
low = -high;
high = flipped;
}
if (style.offsetY != 0.0f)
{
const double offset = static_cast<double>(style.offsetY);
low += offset;
high += offset;
}
}
/**
* @brief 单条信号的元数据与采样值(纯数据,不含可见性配置)。
*/
struct SignalData
{
std::wstring name; /**< 名称(取自文件列名,完全保留原样)。 */
std::wstring displayName; /**< 用户重命名的显示名;为空时使用 name。 */
std::wstring rawHeader; /**< 原始列名。 */
std::wstring unit; /**< 从列名括号中提取的单位,可能为空。 */
std::vector<double> values; /**< 采样值,缺失处为 NaN。 */
double minValue = 0.0; /**< 有限采样值最小值。 */
double maxValue = 0.0; /**< 有限采样值最大值。 */
std::size_t finiteCount = 0; /**< 有限采样点数量。 */
/**
* @brief 粗粒度包络层:每 coarseStride 个采样一组的 min/max。
*
* 大文件(千万级采样)的折线包络构建是 O(可见采样数) 的,每次视图变化都要重算,
* 实测 1770 万行 × 5 信号需要约 200ms/帧,拖拽时严重掉帧。
* 有了这一层(额外内存 ≈ 采样数的 0.8%),构建包络时只需读取“每列覆盖到的分组”,
* 读取量降到约 1/5000,且**绘制结果与全量扫描完全一致**(分组内已存真实极值)。
* 分组内没有有效值时存 ±inf,读取时跳过。
*/
std::vector<double> coarseMin; /**< 分组最小值。 */
std::vector<double> coarseMax; /**< 分组最大值。 */
std::size_t coarseStride = 0; /**< 分组的采样跨度;0 表示未构建。 */
SignalStyle style; /**< 显示外观(全局共享,跨标签页同步)。 */
/**
* @brief 获取用于界面展示的名称。
* @return 用户重命名后的显示名,未重命名时返回文件中的原始名称。
*/
const std::wstring& Title() const { return displayName.empty() ? name : displayName; }
};
/**
* @brief 一个已加载的数据文件及其自有时间轴。
*/
struct DataFile
{
std::wstring filePath; /**< 文件完整路径(仅用于状态提示)。 */
std::wstring fileName; /**< 文件名称。 */
std::wstring displayName; /**< 用户重命名的分组名;为空时使用 fileName。 */
std::wstring timeColumnName; /**< 时间列列名。 */
std::vector<double> time; /**< 时间轴数值。 */
std::vector<SignalData> signals; /**< 该文件包含的信号。 */
double minTime = 0.0; /**< 时间轴最小值。 */
double maxTime = 0.0; /**< 时间轴最大值。 */
bool timeMonotonic = true; /**< 时间轴是否单调不减。 */
/**
* @brief 获取用于界面展示的分组名。
* @return 用户重命名后的显示名,未重命名时返回文件名。
*/
const std::wstring& Title() const { return displayName.empty() ? fileName : displayName; }
};
/**
* @brief 由 CSV 解析结果构建可入库的数据文件。
*
* 该函数**不访问 DataSet**(只依赖传入的 CsvTable 与调色板基准序号),
* 因此可以在工作线程上执行:大文件的解析与列重组占整个加载耗时的大头,
* 放到工作线程后主线程只需做一次 O(1) 的移动入队,加载过程中的界面不会卡住。
*
* table 按右值传入:每列会被**移动**进对应信号的 values(零拷贝)。
*
* @param [IN,OUT] table CSV 解析结果(列会被移走)。
* @param [IN] filePath 源文件路径。
* @param [IN] paletteBase 调色板起始序号(通常传入当前已有信号数)。
* @param [OUT] file 构建结果。
* @param [OUT] error 失败原因描述(成功时清空)。
* @param [IN] progress 进度回调(0-1),返回 false 表示取消,可为空。
* @return 构建成功返回 true。
*/
bool BuildDataFile(CsvTable&& table, const std::wstring& filePath, std::size_t paletteBase, DataFile& file,
std::wstring& error, const CsvProgress& progress = CsvProgress());
/**
* @brief 数据集合:统一管理全部已加载文件,与视图配置无关。
*/
class DataSet
{
public:
/**
* @brief 追加一个已构建好的数据文件(仅做一次移动,不拷贝采样数据)。
* @param [IN,OUT] file 数据文件(内部缓冲会被移走)。
* @param [OUT] fileIndex 新文件下标。
* @return 追加成功返回 true。
*/
bool AddDataFile(DataFile&& file, int& fileIndex);
/**
* @brief 在指定文件的指定信号**下方**插入一条新信号(派生信号)。
*
* 新信号会占据 `afterSignalIndex + 1` 的位置,该位置及其后的信号下标整体后移一位;
* 调用方必须紧接着对每个标签页调用 `TabConfig::RemapAfterSignalInsert()` 修正引用。
* 采样值会被就地统计(min/max/finiteCount 与粗粒度包络层),显示外观按「全局信号序号」
* 取调色板颜色(与普通新信号一致)。
*
* @param [IN] fileIndex 目标文件下标。
* @param [IN] afterSignalIndex 插入位置的上一条信号下标。
* @param [IN,OUT] signal 待插入的信号(values 会被统计,其余字段原样使用)。
* @param [OUT] newSignalIndex 新信号在本文件内的下标。
* @return 插入成功返回 true。
*/
bool InsertDerivedSignal(int fileIndex, int afterSignalIndex, SignalData&& signal, int& newSignalIndex);
/**
* @brief 移除指定下标的文件,并立即释放其占用的全部采样内存。
* @param [IN] fileIndex 文件下标。
* @return 移除成功返回 true。
*/
bool RemoveFile(int fileIndex);
/**
* @brief 从某个分组中移除一条信号,并立即释放它的采样内存。
*
* 该信号之后的所有信号下标整体前移一位,因此调用方必须紧接着对每个标签页调用
* `TabConfig::RemapAfterSignalRemoval()`,并用 `RemapSelectionAfterSignalRemoval()`
* 修正共享选中项。使用者一旦确认不再需要该信号即可调用,本调用后其采样缓冲立即析构。
*
* @param [IN] fileIndex 信号所属文件下标。
* @param [IN] signalIndex 文件内信号下标。
* @return 移除成功返回 true。
*/
bool RemoveSignal(int fileIndex, int signalIndex);
/**
* @brief 在数据层移除某条信号之前,按旧下标修正选中引用。
* @param [IN] fileIndex 信号所属文件下标。
* @param [IN] signalIndex 即将被移除的信号下标。
*/
void RemapSelectionAfterSignalRemoval(int fileIndex, int signalIndex);
/**
* @brief 判断数据集合是否为空。
* @return 未加载任何文件返回 true。
*/
bool Empty() const { return m_files.empty(); }
/**
* @brief 获取文件数量。
* @return 文件数量。
*/
std::size_t FileCount() const { return m_files.size(); }
/**
* @brief 获取信号总数。
* @return 信号总数。
*/
std::size_t SignalCount() const;
/**
* @brief 获取全部文件(只读)。
* @return 文件集合。
*/
const std::vector<DataFile>& Files() const { return m_files; }
/**
* @brief 按下标获取文件。
* @param [IN] fileIndex 文件下标。
* @return 文件指针,下标非法时返回 nullptr。
*/
const DataFile* FileAt(int fileIndex) const;
/**
* @brief 修改某个信号在界面上的显示名称。
* @param [IN] fileIndex 文件下标。
* @param [IN] signalIndex 文件内信号下标。
* @param [IN] name 新的显示名;传入空串表示恢复使用原始列名。
* @return 修改成功返回 true。
*/
bool SetSignalDisplayName(int fileIndex, int signalIndex, const std::wstring& name);
/**
* @brief 修改某个文件分组的显示名称。
* @param [IN] fileIndex 文件下标。
* @param [IN] name 新的分组名;传入空串表示恢复使用文件名。
* @return 修改成功返回 true。
*/
bool SetFileDisplayName(int fileIndex, const std::wstring& name);
/**
* @brief 解析信号引用。
* @param [IN] ref 信号引用。
* @return 信号数据指针,引用无效时返回 nullptr。
*/
const SignalData* Resolve(const SignalRef& ref) const;
/**
* @brief 获取指定信号的显示外观(只读)。
* @param [IN] ref 信号引用。
* @return 外观指针,引用无效时返回 nullptr。
*/
const SignalStyle* Style(const SignalRef& ref) const;
/**
* @brief 获取指定信号的显示外观(可写)。
* @param [IN] ref 信号引用。
* @return 外观指针,引用无效时返回 nullptr。
*/
SignalStyle* Style(const SignalRef& ref);
/**
* @brief 计算全部文件时间范围的并集下限。
* @return 时间下限。
*/
double MinTime() const;
/**
* @brief 计算全部文件时间范围的并集上限。
* @return 时间上限。
*/
double MaxTime() const;
/**
* @brief 获取当前选中的信号(全标签页共享)。
* @return 信号引用。
*/
const SignalRef& Selected() const { return m_selected; }
/**
* @brief 设置当前选中的信号(全标签页共享)。
* @param [IN] ref 信号引用;传入无效值表示取消选择。
*/
void SetSelected(const SignalRef& ref) { m_selected = ref; }
/**
* @brief 在数据层移除某文件之前,按旧下标修正选中引用。
* @param [IN] fileIndex 即将被移除的文件下标。
*/
void RemapSelectionAfterFileRemoval(int fileIndex);
/**
* @brief 校验选中引用是否仍指向存在的信号,失效则取消选中。
* @return 选中状态发生变化返回 true。
*/
bool ValidateSelection();
private:
std::vector<DataFile> m_files; /**< 已加载文件集合。 */
SignalRef m_selected; /**< 当前选中信号(跨标签页共享)。 */
};
/**
* @brief 一个波形网格(纵向泳道),可容纳多条信号叠加显示。
*/
struct Lane
{
/**
* @brief 「均值滤波」中某个信号自己的参数。
*
* 仅参与列表中的信号才会被勾选预览;参数按信号保存,
* 因此同一条网格内不同信号可以使用不同的均值点数与迭代次数。
* 「边沿计数(中心轴)」的平滑参数与它**完全同构**(同一套命名与作用),
* 见 `EdgeCenterItem`。
*/
struct MeanFilterItem
{
SignalRef ref; /**< 目标信号。 */
int points = 5; /**< 均值点数 X(滑平半径,窗口 = 2X+1)。 */
int iterations = 1; /**< 迭代次数 N(重复滤波次数,至少 1)。 */
};
/** 中心轴边沿计数的平滑参数:与均值滤波共用同一结构与同一套语义。 */
using EdgeCenterItem = MeanFilterItem;
/**
* @brief 「弱峰调整」中某个信号自己的参数条目。
*
* 与「均值滤波」完全一致:**每个被勾选的信号各自持有参数**
*(调整幅度 -400% – +400% + 迭代次数 N),因此同一网格内不同信号可以使用不同强度。
*
* ⚠️ `iterations` 的**上限由用户自己设定**(`maxIterations`):面板上「迭代次数 N」
* 滑块的量程就是 `1 … maxIterations`,点击滑块右侧的数值可以把该上限改成其它正整数。
*/
struct WeakPeakItem
{
SignalRef ref; /**< 目标信号。 */
int amountPercent = 0; /**< 调整幅度(-400–400,默认 0 = 不改动)。 */
int iterations = 1; /**< 迭代次数 N(1 … `maxIterations`)。 */
int maxIterations = 10; /**< 「迭代次数 N」滑块的上限(用户可在面板上修改,恒 ≥ 1)。 */
};
std::wstring name; /**< 网格名称。 */
std::vector<SignalRef> signals; /**< 该网格包含的信号(按加入顺序)。 */
bool autoRange = true; /**< 是否依据信号取值范围自动计算 Y 轴。 */
double yMin = -1.0; /**< 手动 Y 轴下限。 */
double yMax = 1.0; /**< 手动 Y 轴上限。 */
/**
* @brief 「选区 Y 轴自适应」开关。
*
* 开启后每次对该网格做时间轴选区放大,都会自动把本网格的 Y 轴匹配到新的可见时间区间;
* 用户手动调整幅值(拖拽平移、Y 轴选区放大、直接输入上下限)后会自动关闭。
*/
bool fitYToSelection = false;
/**
* @brief 「实验性功能 → 边沿计数」开关。
*
* 开启后本网格显示一条横向光标(可上下拖动),并在浮动面板中列出该网格各信号的
* 上升沿 / 下降沿数量。与 `fitYToSelection` 一样属于网格自身状态,重建网格时按信号继承。
*/
bool edgeCount = false;
/** 横向光标所在的幅值(数据单位)。 */
double edgeValue = 0.0;
/** `edgeValue` 是否已经初始化(首次开启时取可见幅值范围的中点)。 */
bool edgeValueValid = false;
/** 边沿统计浮动面板是否展开(点击面板上的 ✕ 后置 false)。 */
bool edgePanelOpen = true;
/** 统计面板左上角位置(客户区像素;<0 表示自动定位到绘图区右侧)。 */
double edgePanelX = -1.0;
/** 统计面板左上角纵坐标(<0 表示自动定位)。 */
double edgePanelY = -1.0;
/**
* @brief 「实验性功能 → 边沿计数(中心轴)」开关。
*
* 开启后为本网格内**被勾选**的信号额外绘制一条均值滤波曲线(与「实验性功能 → 均值滤波」
* 用的是同一套参数:均值点数 X + 迭代次数 N),并按“信号穿越该滤波曲线”统计上/下边沿。
* 这条曲线只是叠加显示,不会作为信号加入左侧列表。
*/
bool edgeCenter = false;
/**
* @brief 参与中心轴边沿计数的信号及其平滑参数(只包含被勾选的信号)。
*
* 参数语义与 `meanFilterItems` **完全一致**(`points` = 均值点数 X,
* `iterations` = 迭代次数 N),因此两条曲线的数值逐位相同。
*/
std::vector<EdgeCenterItem> edgeCenterItems;
/** 取某个信号的中心轴平滑参数(未勾选时返回空指针)。 */
const EdgeCenterItem* FindEdgeCenterItem(const SignalRef& ref) const;
/** 取得或创建某信号的中心轴平滑参数(用于勾选)。 */
EdgeCenterItem& EnsureEdgeCenterItem(const SignalRef& ref);
/** 取消某信号的中心轴边沿计数勾选。 */
void RemoveEdgeCenterItem(const SignalRef& ref);
/** 取某信号的中心轴边沿计数勾选状态。 */
bool EdgeCenterEnabled(const SignalRef& ref) const
{
return FindEdgeCenterItem(ref) != nullptr;
}
/** 中心轴统计浮动面板是否展开。 */
bool edgeCenterPanelOpen = true;
/** 中心轴统计面板左上角位置(客户区像素;<0 表示自动定位)。 */
double edgeCenterPanelX = -1.0;
/** 中心轴统计面板左上角纵坐标(<0 表示自动定位)。 */
double edgeCenterPanelY = -1.0;
/**
* @brief 「实验性功能 → 均值滤波」开关。
*
* 开启后为本网格内**被勾选**的信号额外绘制一条多次迭代滑动平均曲线(按各自的均值点数 X
* 重复 N 阶),并在浮动面板中提供每个信号自己的参数与「构建为新信号」按钮。
* 与其它实验性功能一样属于网格自身状态,重建网格时按信号继承。
*/
bool meanFilter = false;
/**
* @brief 参与均值滤波预览的信号及其参数(只包含被勾选的信号)。
*
* 顺序与信号在网格内的顺序不一定一致,绘制时按网格内信号顺序派发。
*/
std::vector<MeanFilterItem> meanFilterItems;
/** 取某个信号的均值滤波参数(未勾选时返回空指针)。 */
const MeanFilterItem* FindMeanFilterItem(const SignalRef& ref) const;
/** 取得或创建某信号的均值滤波参数(用于勾选)。 */
MeanFilterItem& EnsureMeanFilterItem(const SignalRef& ref);
/** 取消某信号的均值滤波勾选。 */
void RemoveMeanFilterItem(const SignalRef& ref);
/** 取某信号的均值滤波勾选状态。 */
bool MeanFilterEnabled(const SignalRef& ref) const
{
return FindMeanFilterItem(ref) != nullptr;
}
/** 均值滤波浮动面板是否展开。 */
bool meanFilterPanelOpen = true;
/** 均值滤波面板左上角位置(客户区像素;<0 表示自动定位)。 */
double meanFilterPanelX = -1.0;
/** 均值滤波面板左上角纵坐标(<0 表示自动定位)。 */
double meanFilterPanelY = -1.0;
/**
* @brief 「实验性功能 → 弱峰调整」开关。
*
* 开启后为本网格内**被勾选**的信号额外绘制一条「弱峰调整」曲线:
* 识别纹波的波峰/波谷,把“比左右同类极值更弱”的波峰上拉、波谷下拉,
* 并按各信号自己的「增强等级」在左右两个同类极值之间取目标值,使其恢复与周边振幅。
* 这条曲线只是叠加显示,不会作为信号加入左侧列表。
*/
bool weakPeak = false;
/**
* @brief 参与弱峰调整预览的信号及其**各自的参数**(只包含被勾选的信号)。
*
* 参数(调整幅度 -400% – +400% + 迭代次数 N)按信号保存,与「均值滤波」的实现方式一致:
* 同一网格内不同信号可以使用不同的增强强度,也可以分别「构建为新信号」。
*/
std::vector<WeakPeakItem> weakPeakItems;
/** 取某个信号的弱峰调整参数(未勾选时返回空指针)。 */
const WeakPeakItem* FindWeakPeakItem(const SignalRef& ref) const;
/** 取得或创建某信号的弱峰调整参数(用于勾选)。 */
WeakPeakItem& EnsureWeakPeakItem(const SignalRef& ref);
/** 取消某信号的弱峰调整勾选。 */
void RemoveWeakPeakItem(const SignalRef& ref);
/** 取某信号的弱峰调整勾选状态。 */
bool WeakPeakEnabled(const SignalRef& ref) const
{
return FindWeakPeakItem(ref) != nullptr;
}
/** 弱峰调整浮动面板是否展开。 */
bool weakPeakPanelOpen = true;
/** 弱峰调整面板左上角位置(客户区像素;<0 表示自动定位)。 */
double weakPeakPanelX = -1.0;
/** 弱峰调整面板左上角纵坐标(<0 表示自动定位)。 */
double weakPeakPanelY = -1.0;
};
/**
* @brief 从内存中的信号采样点取某一时刻的取值。
*
* 加载完成后全部采样值已在内存中,本函数只查询内存数据,与源文件无关。
*
* @param [IN] file 信号所属的已加载数据分组(提供时间轴)。
* @param [IN] signal 目标信号。
* @param [IN] time 采样时刻。
* @param [IN] interpolate 是否在相邻采样点之间做线性插值。
* @param [OUT] value 输出采样值,缺失时为 NaN。
* @return 时间点落在数据范围内且取到有效值返回 true。
*/
bool SampleSignalAt(const DataFile& file, const SignalData& signal, double time, bool interpolate,
double& value);
/**
* @brief 取某一时刻**画面上显示**的取值(含水平镜像的"数据反序"与时间偏移)。
*
* 与 `SampleSignalAt` 的区别:
* * 水平镜像时用 `SourceIndexFor` 把下标映射到源采样(头尾对调);
* * 时间偏移时先按 `time - style.offsetTime` 求出源采样时刻(因为绘制在 `t + offsetTime`)。
* 因此返回的正是当前绘制出来的那条曲线在该时刻的取值 —— 光标读数、数据点标注都必须用它,
* 否则读数会与看到的波形不一致。
* ⚠️ 系数 / 绝对值 / 垂直镜像 / `offsetY` 不在这里处理,由 `ApplyDrawTransform` 负责。
*
* @param [IN] file 信号所属的已加载数据分组(提供时间轴)。
* @param [IN] signal 目标信号。
* @param [IN] style 显示外观(只用 `mirrorHorizontal`)。
* @param [IN] time 采样时刻。
* @param [IN] interpolate 是否在相邻采样点之间做线性插值。
* @param [OUT] value 输出采样值,缺失时为 NaN。
* @return 时间点落在数据范围内且取到有效值返回 true。
*/
bool SampleDisplayedSignalAt(const DataFile& file, const SignalData& signal,
const SignalStyle& style, double time, bool interpolate, double& value);
/**
* @brief 计算 N 点滑动平均(窗口 = [i-N, i+N],边界按实际窗口长度)。
*
* 与「边沿计数(中心轴)」的中心平均值曲线**完全同一算法**:
* NaN 不参与平均;中心点本身为 NaN 时结果也为 NaN;窗口内无有效值时为 NaN。
*
* @param [IN] values 原始采样值。
* @param [IN] radius 平滑半径 N(0 表示原样返回)。
* @param [OUT] out 输出数组(长度与 values 相同)。
*/
void ComputeMovingAverage(const std::vector<double>& values, int radius, std::vector<float>& out);
/**
* @brief 计算多次迭代滑动平均:把 N 点滑动平均重复应用 `iterations` 次。
*
* 每次迭代以上一次的结果为输入(NaN 不参与平均),因此迭代次数越高曲线越平滑。
* `iterations <= 1` 时与 `ComputeMovingAverage` 完全一致。
*
* @param [IN] values 原始采样值。
* @param [IN] radius 滑平半径 N(0 表示原样返回)。
* @param [IN] iterations 迭代次数(至少为 1)。
* @param [OUT] out 输出数组(长度与 values 相同)。
*/
void ComputeMovingAverageIterated(const std::vector<double>& values, int radius, int iterations,
std::vector<float>& out);
/**
* @brief 「弱峰调整」:把比周边更弱的纹波波峰/波谷恢复,或(负幅度时)把弱峰削弱。
*
* 本算法**不做任何前置滤波**:需要平滑时用户可以先用「均值滤波」构建一条
* 滤波后的信号,再对它做弱峰调整。极值点直接在输入采样上识别。
*
* 「调整幅度」滑动条的量程是 **-400% – +400%**,它按"梯度"同时驱动**三组识别规则**:
* 越宽松的规则参与得越晚,同一滑动条位置上的幅度越小:
* | 滑动条 | 第一类(严格) | 第二类(或) | 第三类(宽松) |
* | --- | --- | --- | --- |
* | 0 – 100% | 0 – 100% | — | — |
* | 100 – 200% | 100 – 200% | 0 – 100% | — |
* | 200 – 300% | 200%(上限) | 100 – 200% | 0 – 100% |
* | 300 – 400% | 200%(上限) | 200%(上限) | 100 – 200% |
* (负值完全对称;某个极值若同时满足多组规则,取**幅度绝对值最大**的那一组。)
*
* 单阶处理(全 O(n)):
* 1. **识别趋势与极值**:扫描单调段,方向发生翻转处的上一个采样点即为
* 波峰(先升后降)或波谷(先降后升);波峰与波谷交替出现。
* 2. **三组识别规则**(以波峰 k 为例;`L` = 左侧相邻第一个波峰、`l` = 左侧相邻第一个波谷,
* `r` = 右侧相邻第一个波谷、`R` = 右侧相邻第一个波峰):
* * 第一类:`v < (L+l)/2` **且** `v < (r+R)/2`(两侧均值都要满足);
* * 第二类:`v < (L+l)/2` **或** `v < (r+R)/2`(放宽为"或");
* * 第三类:`v < L` **且** `v < R`(只与左右同类极值比较)。
* 波谷把"低于"换成"高于"、把"且/或"的上下界互换即可。
* 3. **目标值**(每一组的幅度 `a` 都在 -2 – +2,对应其自身的 -200% – +200%):
* * `0 – +100%`:当前值 → 左右**同类**极值中**较温和**的一侧
* (波峰取较低的波峰、波谷取较高的波谷);
* * `+100 – +200%`:该侧 → 左右同类极值的**均值**;
* * `0 – -100%`:当前值 → 相邻**异类**极值中**较近**的一个
* (波峰取较高的波谷、波谷取较低的波峰);
* * `-100 – -200%`:该侧 → 相邻异类极值的**均值**。
* 削弱**不会反向增强**(目标被夹在"不越过当前值"的一侧)。
* 4. **平滑偏移**:极值点本身取其目标值,两侧的数据点按相邻两个极值点的偏移量
* 平滑过渡(平滑步进,两端导数为 0),使波形整体趋势不变、只在弱峰附近被推回。
*
* 不属于任何一类规则点的极值偏移量恒为 0,因此输出与输入逐位相同。
*
* @param [IN] values 原始采样值。
* @param [IN] amount 调整幅度(-4 – +4,对应 -400% – +400%,超出范围会被夹取;0 = 完全不动)。
* @param [IN] iterations 迭代次数 N(重复应用次数,至少为 1)。
* @param [OUT] out 输出数组(长度与 values 相同;原值为 NaN 处保持 NaN)。
*/
void ComputeWeakPeakAdjust(const std::vector<double>& values, double amount, int iterations,
std::vector<float>& out);
/**
* @brief 在指定文件的时间轴上查找与给定时刻最接近的采样点。
* @param [IN] file 目标文件。
* @param [IN] time 采样时刻。
* @return 采样点下标,文件为空时返回 -1。
*/
int FindNearestSample(const DataFile& file, double time);
/**
* @brief 单个标签页的独立视图配置(信号可见性、网格划分与视图范围)。
*
* 仅保存「本页看到哪些信号」与视图状态;信号的显示外观(颜色/线宽/透明度/样式)
* 保存在 `DataSet` 中并被所有标签页共享,因此同一个信号在各视图中的显示效果一致。
*/
class TabConfig
{
public:
TabConfig();
/**
* @brief 获取标签页名称。
* @return 名称。
*/
const std::wstring& Name() const { return m_name; }
/**
* @brief 设置标签页名称。
* @param [IN] name 新名称。
*/
void SetName(const std::wstring& name) { m_name = name; }
/**
* @brief 依据数据集合调整配置容量(新增文件补默认配置,移除文件清理配置)。
* @param [IN] dataSet 数据集合。
*/
void SyncToDataSet(const DataSet& dataSet);
/**
* @brief 在数据层移除某文件之前,按旧下标修正本配置(不重建网格)。
* @param [IN] fileIndex 即将被移除的文件下标。
* @note 调用顺序必须为:各标签页 RemapAfterFileRemoval → 数据层 RemoveFile → 各标签页 SyncToDataSet。
*/
void RemapAfterFileRemoval(int fileIndex);
/**
* @brief 在数据层移除某条信号之前,按旧下标修正本配置(不重建网格)。
*
* 会删除该信号的可见性记录与网格归属,并把同一文件内位于其后的引用下标前移一位。
* 若某个网格因此变空则一并移除(与“取消勾选后空网格消失”的策略一致)。
*
* @param [IN] fileIndex 信号所属文件下标。
* @param [IN] signalIndex 即将被移除的信号下标。
* @note 调用顺序必须为:各标签页 RemapAfterSignalRemoval → 数据层修正选中 →
* 数据层 RemoveSignal → 各标签页 SyncToDataSet。
*/
void RemapAfterSignalRemoval(int fileIndex, int signalIndex);
/**
* @brief 判断指定信号是否可见。
* @param [IN] ref 信号引用。
* @return 可见返回 true。
*/
bool IsVisible(const SignalRef& ref) const;
/**
* @brief 设置指定信号的可见性,并同步网格划分。
* @param [IN] ref 信号引用。
* @param [IN] visible 目标可见状态。
*/
void SetVisible(const SignalRef& ref, bool visible);
/**
* @brief 切换指定信号的可见性,并同步网格划分。
* @param [IN] ref 信号引用。
*/
void ToggleVisible(const SignalRef& ref);
/**
* @brief 设置某个文件全部信号的可见性,并同步网格划分。
* @param [IN] fileIndex 文件下标。
* @param [IN] visible 目标可见状态。
*/
void SetFileVisible(int fileIndex, bool visible);
/**
* @brief 设置全部信号可见性,并同步网格划分。
* @param [IN] visible 目标可见状态。
*/
void SetAllVisible(bool visible);
/**
* @brief 仅显示指定信号,其余全部隐藏。
* @param [IN] ref 需要保持可见的信号。
*/
void SoloSignal(const SignalRef& ref);
/**
* @brief 判断某文件是否存在可见信号。
* @param [IN] fileIndex 文件下标。
* @return 存在可见信号返回 true。
*/
bool FileHasVisible(int fileIndex) const;
/**
* @brief 判断某文件是否全部信号可见。
* @param [IN] fileIndex 文件下标。
* @return 全部可见且至少有一条信号返回 true。
*/
bool FileAllVisible(int fileIndex) const;
/**
* @brief 判断是否存在任何可见信号。
* @return 存在返回 true。
*/
bool AnyVisible() const;
/**
* @brief 统计可见信号数量。
* @return 可见信号数量。
*/
int VisibleCount() const;
/**
* @brief 获取网格划分策略。
* @return 策略枚举。
*/
LaneMode Mode() const { return m_laneMode; }
/**
* @brief 设置网格划分策略并同步网格。
* @param [IN] mode 目标策略。
*/
void SetMode(LaneMode mode);
/**
* @brief 依据当前可见性与策略重新生成网格划分。
*/
void SyncLanes();
/**
* @brief 获取网格集合(只读)。
* @return 网格集合。
*/
const std::vector<Lane>& Lanes() const { return m_lanes; }
/**
* @brief 获取网格集合(可写)。
* @return 网格集合。
*/
std::vector<Lane>& Lanes() { return m_lanes; }
/**
* @brief 查询某信号所属网格下标。
* @param [IN] ref 信号引用。
* @return 网格下标,未找到返回 -1。
*/
int LaneOfSignal(const SignalRef& ref) const;
/**
* @brief 将信号移动到指定网格,并切换为自定义划分策略。
* @param [IN] dataSet 数据集合。
* @param [IN] ref 信号引用。
* @param [IN] laneIndex 目标网格下标,传入负值表示新建网格。
* @return 移动后该信号所在的网格下标。
*/
int MoveSignalToLane(const DataSet& dataSet, const SignalRef& ref, int laneIndex);
/**
* @brief 「拆分当前网格」:把指定网格内的信号按个拆分成独立网格。
*
* 新网格插入在原网格的**下方依次排列**(原位置 + 1 起),原本在其下方的网格
* 顺次后移。由于网格编号(GX)完全由位置推导,拆分后所有引用该编号的地方
* (左侧列表徽标、图例徽标、面板标题、右键菜单标题)会自动保持一致。
*
* @param [IN] laneIndex 待拆分的网格下标。
* @return 拆分后该网格内第一条信号所在的网格下标;无法拆分(不存在或只有 1 条信号)返回 -1。
*/
int SplitLane(int laneIndex);
/** * @brief 在数据层插入新信号之后,修正本页引用该文件的信号下标。
*
* 数据层在下标 `insertPos` 处插入了一条信号,因此本页可见性表需插入一列,
* 且所有指向该文件、下标不小于 `insertPos` 的信号引用都要后移一位。
*
* @param [IN] fileIndex 发生插入的文件下标。
* @param [IN] insertPos 新信号的下标。
*/
void RemapAfterSignalInsert(int fileIndex, int insertPos);
/**
* @brief 把某信号所在的网格移动到另一信号所在网格的下方(紧邻其后)。
*
* 用于「均值滤波 → 构建为新信号」:新信号所在的网格应紧跟在原信号网格的下方。
* 移动成功后会切换为自定义划分策略,避免后续重建网格时顺序被复原。
*
* @param [IN] moved 被移动网格内的任一信号。
* @param [IN] anchor 锚点信号(其所在网格之后即为目标位置)。
* @return 发生移动返回 true。
*/
bool MoveLaneOfSignalBelow(const SignalRef& moved, const SignalRef& anchor);
/**
* @brief 把某个网格整体移动到新的位置(用于拖拽 GX 编号调整网格次序)。
*
* `target` 是**插入位置**(0..laneCount):0 表示移到最上方,laneCount 表示移到最下方。
* 移动后会切换到自定义划分策略并按新位置重新编号全部网格,
* 因此所有以 GX 编号关联的界面(信号列表徽标、图例徽标、菜单、浮动面板)自动同步。
*
* @param [IN] from 被移动的网格下标。
* @param [IN] target 插入位置(0..网格数)。
* @return 实际发生了移动返回 true。
*/
bool MoveLane(int from, int target);
/**
* @brief 按当前位置统一重命名全部网格(GX 编号由位置推导,须保持一致)。
*/
void RenameLanes();
/** * @brief 查询某文件的分组是否处于折叠状态。
* @param [IN] fileIndex 文件下标。
* @return 折叠状态。
*/
bool IsCollapsed(int fileIndex) const;
/**
* @brief 切换某文件分组的折叠状态。
* @param [IN] fileIndex 文件下标。
*/
void ToggleCollapsed(int fileIndex);
/**
* @brief 计算某网格的 Y 轴显示范围。
* @param [IN] dataSet 数据集合。
* @param [IN] laneIndex 网格下标。
* @param [OUT] yMin 输出下限。
* @param [OUT] yMax 输出上限。
*/
void ComputeLaneRange(const DataSet& dataSet, int laneIndex, double& yMin, double& yMax) const;
/**
* @brief 计算全部可见信号覆盖的时间范围。
* @param [IN] dataSet 数据集合。
* @param [OUT] minTime 输出下限。
* @param [OUT] maxTime 输出上限。
* @return 存在可见信号返回 true。
*/
bool ComputeVisibleTimeBounds(const DataSet& dataSet, double& minTime, double& maxTime) const;
/// @brief 视图左边界时间(可读写)。
double& ViewMin() { return m_viewMin; }
/// @brief 视图左边界时间(只读)。
double ViewMin() const { return m_viewMin; }
/// @brief 视图右边界时间(可读写)。
double& ViewMax() { return m_viewMax; }
/// @brief 视图右边界时间(只读)。
double ViewMax() const { return m_viewMax; }
/// @brief 视图范围是否已初始化(可读写)。
bool& ViewInitialized() { return m_viewInitialized; }
/// @brief 网格纵向滚动偏移量(可读写)。
float& LaneScroll() { return m_laneScroll; }
/// @brief 网格纵向滚动偏移量(只读)。
float LaneScroll() const { return m_laneScroll; }
/// @brief 时间光标模式(可读写)。
PlotCursorMode& CursorMode() { return m_cursorMode; }
/// @brief 时间光标模式(只读)。
PlotCursorMode CursorMode() const { return m_cursorMode; }
/// @brief 是否启用了时间光标(单/双光标均为启用,只读)。
bool CursorEnabled() const { return m_cursorMode != PlotCursorMode::NONE; }
/// @brief 光标 1(左侧)的时间位置(可读写)。
double& CursorTime() { return m_cursorTime; }
/// @brief 光标 1(左侧)的时间位置(只读)。
double CursorTime() const { return m_cursorTime; }
/// @brief 光标 2 的时间位置(可读写,仅双光标模式使用)。
double& CursorTimeAlt() { return m_cursorTimeAlt; }
/// @brief 光标 2 的时间位置(只读)。
double CursorTimeAlt() const { return m_cursorTimeAlt; }
/// @brief 绘图区拖拽缩放模式(可读写)。
PlotZoomMode& ZoomMode() { return m_zoomMode; }
/// @brief 绘图区拖拽缩放模式(只读)。
PlotZoomMode ZoomMode() const { return m_zoomMode; }
private:
std::wstring m_name; /**< 标签页名称。 */
std::vector<std::vector<bool>> m_visible; /**< 信号可见性,按 [文件][信号] 索引(本页独立)。 */
std::vector<bool> m_collapsed; /**< 文件分组折叠状态,按文件索引。 */
std::vector<Lane> m_lanes; /**< 网格划分。 */
LaneMode m_laneMode = LaneMode::PER_SIGNAL; /**< 网格划分策略。 */
double m_viewMin = 0.0; /**< 视图左边界时间。 */
double m_viewMax = 1.0; /**< 视图右边界时间。 */
bool m_viewInitialized = false; /**< 视图范围是否已初始化。 */
float m_laneScroll = 0.0f; /**< 网格纵向滚动偏移量。 */
PlotCursorMode m_cursorMode = PlotCursorMode::NONE; /**< 时间光标模式(无/单/双)。 */
double m_cursorTime = 0.0; /**< 光标 1(左侧)的时间位置。 */
double m_cursorTimeAlt = 0.0; /**< 光标 2 的时间位置(仅双光标模式有效)。 */
PlotZoomMode m_zoomMode = PlotZoomMode::NONE; /**< 绘图区拖拽缩放模式。 */
};
} // namespace jdi
#endif // __DATA_MODEL_HPP__