Files
Just-DataInspector/Utilities/Inc/DataModel.hpp
T
DeepSeek V4.1 Flash 7a6fcd68e2 面板限高与滚动改造 + 边沿统计不再卡在“统计中…” + 组名折起真正收起
一、浮动面板(用户报告:下方空白、滚动时画到面板外、未改造的面板让一切交互卡顿)
1. ⭐ 根因:旧「逐条目」排布没有高度上限 —— 288 条信号时面板 13010px(绘图区 846px)
   ⇒ PanelRectInsideLayer 恒假 ⇒ 面板像素缓存一次都存不进去 ⇒ 每帧整块重画。
   现在 5 个面板全部限高 + 可滚动,旧的 itemized 布局/绘制/交互整体删除。
2. 无按钮面板(中心轴边沿计数)不再预留「按钮行 + pad」⇒ 底部空白 55px → 8.8px(1 个 pad)。
3. ⚠️ 统计文字改用 UiTextClipped:GDI 文本不受 GDI+ SetClip 约束,滚动时画到标题栏之外。
4. 「弱峰调整」改造为与均值滤波同款的分组列表(条目栏 + 右侧复选框 + 展开后的两行滑块
   + 复位按钮 + 底部「构建为新信号」);新增 Lane::weakPeakScroll / SetLaneWeakPeakScroll,
   WeakPeakItem 增加 collapsed。
5. 「边沿计数」保留两行排版,但内容区同样限高、可滚动(新增 Lane::edgeCountScroll)。
6. 鼠标指针:列表式面板的条目栏**只有已勾选**才显示手型(未勾选点击无效果,保持箭头);
   参数行控件只在参数行展开时给手型 / 左右箭头。
7. 内部标志 m_meanDragCenter / m_editMeanCenter(bool)改为 m_meanDragKind / m_editMeanKind
   (EdgePanelKind),以便 MEAN / CENTER / PEAK / COUNT 共用同一套滚动与滑块拖拽。

二、边沿统计“全部统计中…且永不更新”(288 信号合并到同一网格)
1. ⭐ 根因:累积状态上限固定 64 < 信号数 288 ⇒ 新建状态总淘汰未完成的 ⇒ 没有任何状态能扫完。
   新增 EdgeScanStateLimit() = max(64, 全部信号数 × 2 + 16)。
2. 去掉每状态 60000 的固定配额下限(状态一多就把整帧预算耗光),改为 budget / 未完成状态数;
   分母只数未完成状态,短信号扫完后立刻把预算让给长信号。

三、组名折起后“三角已收起但信号行仍在、仍可点选”
1. ⭐ 根因:RequestEdgeScanFrame() 在每个未完成状态里各 Post 一次(单帧最多 288 次),
   把优先级最低的 WM_TIMER 饿死 ⇒ AdvanceGroupAnimations 永不执行 ⇒ 折起动画永不结束。
   修法:每帧最多 Post 一次(m_edgeScanRequestFrame)。
2. 另一道保险:SignalPanel::EnsureRows() 开头推进一次动画 —— 动画进度只依赖挂钟时间,
   不依赖消息能否按时到达。

四、验证(离屏探针 .preflight/p20,全部 SW_HIDE + 合成消息,不占用鼠标/键盘/屏幕)
  p20.exe  9/9:面板限高且像素缓存真的写进去了、288 个统计全部 complete、中心轴面板底部留白、
               组名折起后行集合无该组信号、两组都折起后 signalRows == 0(真实消息 + WM_TIMER)。
  p20c.exe 11/11:弱峰面板限高/可滚动/缓存命中、展开与收起、滚动生效、条目栏手型规则、
               中心轴滚动时标题栏 0 像素变化(A/B 关掉裁剪 ⇒ 805px)、288 信号空闲帧 2.6ms。
  make release / make debug 零 warning。
2026-11-02 10:00:24 +08:00

1615 lines
67 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 <array>
#include <limits>
#include <string>
#include <atomic>
#include <vector>
#include "DataLoader.hpp"
#include "DataTable.hpp"
namespace jdi
{
/**
* @brief 波形绘制方式。
*/
enum class DrawMode : int
{
LINE = 0, /**< 相邻采样点之间以插值直线连接。 */
SCATTER = 1, /**< 仅绘制采样点散点。 */
LINE_SCATTER = 2 /**< 同时绘制插值直线与采样点散点(界面文案「点线」)。 */
};
/**
* @brief 样式允许的线宽范围(逻辑像素)。
*
* 信号面板的线宽滑块与所有线宽写入路径都以此为唯一依据;
* 稀疏信号(见 `kSparseSignalSampleLimit`)的初始线宽取其中的较大值。
*/
constexpr float kSignalLineWidthMin = 0.5f;
constexpr float kSignalLineWidthMax = 10.0f;
/**
* @brief **只含一个有效采样点**的信号的初始线宽(取样式允许的最大线宽)。
*
* 单个数据点的初始绘制方式是「散点」,半径随线宽线性增长;取最大值才能看清那个点。
* ⚠️ 用户 2026-10-16 要求:初始绘制方式由「点线」改为「**散点**」,
* 同时信号面板「样式」行的「线段」/「点线」两个按钮对该信号置灰。
* ⚠️ 曾经还对「有效采样点 2…16」的信号也设了「点线 + 2px」的默认样式,
* 该需求已**作废**(用户 2026-10-15 明确取消):放大后自动切「点线」的机制
* 已经覆盖了那个场景,无需再改默认样式。
*/
constexpr float kSinglePointSignalLineWidth = kSignalLineWidthMax;
/**
* @brief 普通信号的**默认线宽**(逻辑像素):新建信号时写入,也是样式区「复位」按钮要恢复的值。
*
* ⚠️ 只有这一个来源:`BuildSignalGroup` 的初始化与信号面板线宽复位都用它,
* 否则改了一处就会与"新建信号"的观感不一致。
*/
constexpr float kDefaultSignalLineWidth = 1.0f;
/**
* @brief 按有效采样点数取该信号**默认的线宽**(复位按钮与新建信号共用同一判定)。
* @param [IN] finiteCount 有效采样点数。
* @return 默认线宽(单数据点信号 = 最大线宽,其余 = `kDefaultSignalLineWidth`)。
*/
inline float DefaultSignalLineWidth(std::size_t finiteCount)
{
return (finiteCount == 1) ? kSinglePointSignalLineWidth : kDefaultSignalLineWidth;
}
/**
* @brief 「稀疏信号」缓存有效采样点时刻/取值的上限(有效点不超过该值才缓存)。
*
* 光标吸附需要“当前视界内有多少个数据点”以及“离光标最近的那个数据点”。
* 对有效点很少的信号(典型:单点信号、只有零星采样的列)事先把它们的时刻与取值缓存下来,
* 判定与查找都退化成二分 → 不必在鼠标移动时扫描整条采样数组(千万级信号无法承受)。
* 1024 个有效点 × 16 字节 ≈ 16 KB/条,可忽略。
*/
constexpr std::size_t kSparseTimesLimit = 1024;
/** @brief 网格 Y 轴自适应时为幅值区间补充的留白比例(相对区间跨度)。 */
constexpr double kLaneRangePadRatio = 0.06;
/**
* @brief 为幅值区间补充留白 —— **网格 Y 轴自适应的唯一留白规则**。
*
* ⚠️ 必须只有这一份实现:整段自适应(`TabConfig::ComputeLaneRange`)与
* 「选区 Y 轴自适应」(`PlotView::AutoRangeLaneToView`)都要调用它。
* 过去两处各自硬编码(6% / 5%),于是「点过适应视图后再打开 Y 轴自适应」会因为
* 留白不同而算出**不一样的区间** → 网格被无意义地重绘一次;反向(关掉自适应再点适应视图)
* 同样会变。统一留白后这两种操作在视界未变时都是**像素级无变化**。
*
* 区间退化(恒定值 / 单点信号)时给一个可读的跨度 `max(1, |值|×0.1)`,
* 而不是“几乎为 0”的区间(那会让幅值网格线挤成一团、数据点也看不出位置)。
*
* @param [IN,OUT] low 区间下限。 @param [IN,OUT] high 区间上限。
*/
inline void PadValueRange(double& low, double& high)
{
if (!std::isfinite(low) || !std::isfinite(high))
{
low = 0.0;
high = 1.0;
return;
}
if (low > high)
{
std::swap(low, high);
}
const double span = high - low;
if (span <= 0.0)
{
const double magnitude = std::max(1.0, std::fabs(low) * 0.1);
low -= magnitude;
high += magnitude;
return;
}
const double pad = span * kLaneRangePadRatio;
low -= pad;
high += pad;
}
/**
* @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 groupIndex = -1; /**< 所属分组下标。 */
int signalIndex = -1; /**< 组内信号下标。 */
/**
* @brief 判断引用是否指向有效位置。
* @return 两个下标均非负返回 true。
*/
bool IsValid() const { return groupIndex >= 0 && signalIndex >= 0; }
/**
* @brief 相等比较。
* @param [IN] other 另一引用。
* @return 指向同一信号返回 true。
*/
bool operator==(const SignalRef& other) const
{
return groupIndex == other.groupIndex && 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 「放大到采样点稀疏时自动启用点线」的运行时状态。
*
* 视界放大到采样点间距超过半个绘图区宽度时(此时光标也会吸附数据点),
* 线段模式会退化成「看不出数据点在哪」,因此自动切到「点线」给用户提示;
* 视界回退到阈值之外(如适应视图)时自动恢复**原来的**绘制方式。
*
* * `autoScatterActive` —— 当前绘制方式是否由该机制自动设置的;
* * `autoScatterSavedMode` —— 自动启用**之前**的绘制方式(退出时恢复它);
* * `autoScatterUserOverride` —— 自动启用期间用户**手动**改过绘制方式:
* 此时退出不再恢复(用户的选择优先,用户明确要求)。
*/
bool autoScatterActive = false;
DrawMode autoScatterSavedMode = DrawMode::LINE;
bool autoScatterUserOverride = false;
};
/**
* @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 一条时间轴(时间向量 + 它的取值范围与单调性)。
*
* 时间轴**独立于信号**:同一分组内多条信号可以共用同一条时间轴(最常见的 CSV 单时间轴文件、
* MF4 里同一通道组内的全部通道),也可以各自使用不同的时间轴(多 `time` 列的 CSV、
* MF4 的不同通道组)。
*/
struct TimeAxis
{
std::wstring name; /**< 时间列名(源文件中的原样名称,如 `time`)。 */
std::vector<double> time; /**< 时间数值,缺失处为 NaN。 */
double minTime = 0.0; /**< 有限时间最小值。 */
double maxTime = 0.0; /**< 有限时间最大值。 */
bool monotonic = true; /**< 时间是否单调不减(决定能否用下标区间做二分)。 */
std::size_t finiteCount = 0; /**< 有效时间点数量。 */
/** @brief 时间点数量。 */
std::size_t Count() const { return time.size(); }
};
/**
* @brief 单条信号的元数据与采样值(纯数据,不含可见性配置)。
*
* ⚠️ 信号**只有一个名称字段** `name` —— 它就是界面展示用的名字,也是用户重命名后写入的地方
*(加载时取列名 / 通道名,之后不再保留“原始名称”的第二份副本:应用从不靠名称定位信号,
* 定位一律走 `SignalRef` 下标)。
*
* ⚠️ 信号的时间轴由 `axisIndex` 指向所属分组的时间轴列表,
* 因此采样值本身不需要、也不应该携带时间信息。
*/
struct SignalData
{
std::wstring name; /**< 信号名称(加载时取列名 / 通道名;用户可在界面上重命名)。 */
std::size_t axisIndex = 0; /**< 所属时间轴在分组内的下标。 */
/**
* @brief 取值 → 文本的枚举表(源格式自带时才有,例如 MDF4 `cc_type = 7`)。
*
* ⚠️ 这**不是**单位、也不是注释:它只回答「这个数值代表什么状态」。
* 空表示该信号没有枚举信息,界面必须与以前完全一致(不加任何尾缀)。
* 启用光标时,取值标注才在数值之后追加 ` = <枚举文本>`。
*/
SignalEnumText enumText;
std::vector<double> values; /**< 采样值,缺失处为 NaN。 */
double minValue = 0.0; /**< 有限采样值最小值。 */
double maxValue = 0.0; /**< 有限采样值最大值。 */
std::size_t finiteCount = 0; /**< 有限采样点数量。 */
/**
* @brief 有效采样点很少时的**全部有效点**(按时间升序;有效点很多时为空)。
*
* 仅当 `finiteCount <= kSparseTimesLimit` 时在加载/插入信号时就地填充,用于:
* * 光标吸附:直接二分得到“视界内有几个数据点”“离光标最近的数据点”;
* * 单点信号的初始视图:Centering 需要那个点的时刻与取值。
* ⚠️ 不要在鼠标移动时改成扫描整条采样数组来找有效点(千万级信号承受不住)。
*
* 说明:`sparseTimes.size() == 1` 即“只有一个数据点的信号”。
*/
std::vector<double> sparseTimes;
/** @brief `sparseTimes` 对应的取值(同序)。 */
std::vector<double> sparseValues;
/**
* @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 一个**信号分组**:一次加载产生的全部信号,以及它们使用的若干条时间轴。
*
* ⚠️ 「分组」是应用内的逻辑容器,**不是文件**:加载完成后二者已无关系,
* 因此这里**不保存文件路径**(路径只在加载那一刻用于错误提示,之后毫无用处)。
* 分组的默认名称取源文件名(便于用户辨认来源),用户可以重命名。
*
* 一个分组可以包含**多条时间轴**(CSV 的多个 `time` 列、MF4 的多个通道组),
* 每条信号通过 `SignalData::axisIndex` 指向自己那条。
*/
struct SignalGroup
{
std::wstring name; /**< 分组名(加载时取源文件名、含扩展名;用户可重命名)。 */
std::vector<TimeAxis> axes; /**< 该分组使用的时间轴(至少一条)。 */
std::vector<SignalData> signals; /**< 该分组包含的信号。 */
/**
* @brief 取某条信号所属的时间轴(下标越界时回退到第一条,保证永不返回悬空引用)。
* @param [IN] signal 目标信号。
* @return 时间轴引用;分组没有任何时间轴时返回内部静态空时间轴。
*/
const TimeAxis& AxisOf(const SignalData& signal) const
{
return AxisAt(signal.axisIndex);
}
/**
* @brief 按下标取时间轴(越界时回退到第一条)。
* @param [IN] index 时间轴下标。
* @return 时间轴引用。
*/
const TimeAxis& AxisAt(std::size_t index) const
{
static const TimeAxis kEmptyAxis;
if (axes.empty())
{
return kEmptyAxis;
}
return axes[(index < axes.size()) ? index : 0];
}
/**
* @brief 计算本分组全部时间轴的时间范围并集。
* @param [OUT] low 下限。 @param [OUT] high 上限。
* @return 至少有一条时间轴有有效时间返回 true。
*/
bool TimeRange(double& low, double& high) const
{
low = 0.0;
high = 0.0;
bool valid = false;
for (const TimeAxis& axis : axes)
{
if (axis.finiteCount == 0)
{
continue;
}
if (!valid)
{
low = axis.minTime;
high = axis.maxTime;
valid = true;
}
else
{
low = std::min(low, axis.minTime);
high = std::max(high, axis.maxTime);
}
}
return valid;
}
};
/**
* @brief 由一个**格式无关**的加载结果构建应用内的信号分组。
*
* 职责划分(新增文件格式时只需要写一个加载器,本函数完全不用改):
* * 加载器:文件 → `LoadedSource`(若干「时间轴块」,纯格子,无应用语义);
* * 本函数:`LoadedSource` → `SignalGroup`(时间轴**去重合并**、信号命名、
* 调色板取色、统计与粗粒度包络层)。
*
* 时间轴去重:内容完全相同(长度与全部数值逐位相同)的时间轴只保留一条,
* 于是 CSV 里多个同刻度的 `time` 列、MF4 里多个同基线的通道组不会重复占内存,
* 也不会让信号各自持有实质上相同的轴。
*
* 该函数**不访问 DataSet**(只依赖传入数据与调色板基准序号),因此可以在工作线程上执行:
* 大文件的解析与列重组占整个加载耗时的大头,放到工作线程后主线程只需做一次 O(1) 的
* 移动入队,加载过程中的界面不会卡住。
*
* @param [IN,OUT] source 加载结果(列会被**移动**进信号,零拷贝)。
* @param [IN] paletteBase 调色板起始序号(通常传入当前已有信号数)。
* @param [OUT] group 构建结果。
* @param [OUT] error 失败原因描述(成功时清空)。
* @param [IN] progress 进度回调(0-1),返回 false 表示取消,可为空。
* @return 构建成功返回 true。
*/
bool BuildSignalGroup(LoadedSource&& source, std::size_t paletteBase, SignalGroup& group,
std::wstring& error, const LoadProgress& progress = LoadProgress());
/**
* @brief 数据集合:统一管理全部已加载的**信号分组**,与视图配置无关。
*/
class DataSet
{
public:
/**
* @brief 追加一个已构建好的信号分组(仅做一次移动,不拷贝采样数据)。
* @param [IN,OUT] group 信号分组(内部缓冲会被移走)。
* @param [OUT] groupIndex 新分组下标。
* @return 追加成功返回 true。
*/
bool AddGroup(SignalGroup&& group, int& groupIndex);
/**
* @brief 在指定分组的指定信号**下方**插入一条新信号(派生信号)。
*
* 新信号会占据 `afterSignalIndex + 1` 的位置,该位置及其后的信号下标整体后移一位;
* 调用方必须紧接着对每个标签页调用 `TabConfig::RemapAfterSignalInsert()` 修正引用。
* 采样值会被就地统计(min/max/finiteCount 与粗粒度包络层),显示外观按「全局信号序号」
* 取调色板颜色(与普通新信号一致)。
*
* @param [IN] groupIndex 目标分组下标。
* @param [IN] afterSignalIndex 插入位置的上一条信号下标。
* @param [IN,OUT] signal 待插入的信号(values 会被统计,其余字段原样使用)。
* @param [OUT] newSignalIndex 新信号在本组内的下标。
* @return 插入成功返回 true。
*/
bool InsertDerivedSignal(int groupIndex, int afterSignalIndex, SignalData&& signal,
int& newSignalIndex);
/**
* @brief 移除指定下标的分组,并立即释放其占用的全部采样内存。
* @param [IN] groupIndex 分组下标。
* @return 移除成功返回 true。
*/
bool RemoveGroup(int groupIndex);
/**
* @brief 从某个分组中移除一条信号,并立即释放它的采样内存。
*
* 该信号之后的所有信号下标整体前移一位,因此调用方必须紧接着对每个标签页调用
* `TabConfig::RemapAfterSignalRemoval()`,并用 `RemapSelectionAfterSignalRemoval()`
* 修正共享选中项。使用者一旦确认不再需要该信号即可调用,本调用后其采样缓冲立即析构。
*
* @param [IN] groupIndex 信号所属分组下标。
* @param [IN] signalIndex 组内信号下标。
* @return 移除成功返回 true。
*/
bool RemoveSignal(int groupIndex, int signalIndex);
/**
* @brief 在数据层移除某条信号之前,按旧下标修正选中引用。
* @param [IN] groupIndex 信号所属分组下标。
* @param [IN] signalIndex 即将被移除的信号下标。
*/
void RemapSelectionAfterSignalRemoval(int groupIndex, int signalIndex);
/**
* @brief 判断数据集合是否为空。
* @return 未加载任何分组返回 true。
*/
bool Empty() const { return m_groups.empty(); }
/**
* @brief 获取分组数量。
* @return 分组数量。
*/
std::size_t GroupCount() const { return m_groups.size(); }
/**
* @brief 获取信号总数。
* @return 信号总数。
*/
std::size_t SignalCount() const;
/**
* @brief 获取全部分组(只读)。
* @return 分组集合。
*/
const std::vector<SignalGroup>& Groups() const { return m_groups; }
/**
* @brief 按下标获取分组。
* @param [IN] groupIndex 分组下标。
* @return 分组指针,下标非法时返回 nullptr。
*/
const SignalGroup* GroupAt(int groupIndex) const;
/**
* @brief 重命名某条信号(写入它的**唯一**名称字段 `SignalData::name`)。
* @param [IN] groupIndex 分组下标。
* @param [IN] signalIndex 组内信号下标。
* @param [IN] name 新名称;空串(或仅空白)忽略,避免把名称抹空。
* @return 名称确实发生变化返回 true。
*/
bool RenameSignal(int groupIndex, int signalIndex, const std::wstring& name);
/**
* @brief 重命名某个分组(写入它的**唯一**名称字段 `SignalGroup::name`)。
* @param [IN] groupIndex 分组下标。
* @param [IN] name 新名称;空串(或仅空白)忽略,避免把名称抹空。
* @return 名称确实发生变化返回 true。
*/
bool RenameGroup(int groupIndex, const std::wstring& name);
/**
* @brief 解析信号引用。
* @param [IN] ref 信号引用。
* @return 信号数据指针,引用无效时返回 nullptr。
*/
const SignalData* Resolve(const SignalRef& ref) const;
/**
* @brief 取某条信号所属的时间轴。
* @param [IN] ref 信号引用。
* @return 时间轴指针,引用无效时返回 nullptr。
*/
const TimeAxis* Axis(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] groupIndex 即将被移除的分组下标。
*/
void RemapSelectionAfterGroupRemoval(int groupIndex);
/**
* @brief 校验选中引用是否仍指向存在的信号,失效则取消选中。
* @return 选中状态发生变化返回 true。
*/
bool ValidateSelection();
private:
std::vector<SignalGroup> m_groups; /**< 已加载信号分组集合。 */
SignalRef m_selected; /**< 当前选中信号(跨标签页共享)。 */
};
/**
* @brief 一个实验性功能浮动面板的 UI 状态(展开开关 + 左上角位置)。
*
* 四类实验性功能(边沿计数 / 边沿计数(中心轴)/ 均值滤波 / 弱峰调整)各自持有一个,
* 因此 `Lane` 里不必再重复 12 个(4×3)同形字段 —— 这属于「同一含义被拆成多份」的冗余。
* 面板的**每信号参数**放在 `Lane::*Items` 里,与这里的位置/展开状态分开。
*/
struct LanePanelState
{
bool open = true; /**< 功能面板是否显示(功能关闭时置 false)。 */
/**
* @brief 面板是否被**折起**(只显示标题栏)。
*
* 与 `open` 的区别:`open == false` 表示该功能本身没有开启(面板完全不存在);
* `collapsed == true` 表示功能仍开启、但内容被收起,只留标题栏(点击标题栏左侧的
* 三角指示器切换,双击标题栏也可展开)。
*/
bool collapsed = false;
double x = -1.0; /**< 面板左上角横坐标(客户区像素;<0 表示自动定位)。 */
double y = -1.0; /**< 面板左上角纵坐标(<0 表示自动定位)。 */
};
/**
* @brief 一个波形网格(纵向泳道),可容纳多条信号叠加显示。
*/
struct Lane
{
/**
* @brief 「均值滤波」中某个信号自己的参数。
*
* 仅参与列表中的信号才会被勾选预览;参数按信号保存,
* 因此同一条网格内不同信号可以使用不同的均值点数与迭代次数。
* 「边沿计数(中心轴)」的平滑参数与它**完全同构**(同一套命名与作用),
* 见 `EdgeCenterItem`。
*/
struct MeanFilterItem
{
SignalRef ref; /**< 目标信号。 */
int points = 5; /**< 均值点数 X(滑平半径,窗口 = 2X+1)。 */
int iterations = 1; /**< 迭代次数 N(重复滤波次数,至少 1)。 */
/**
* @brief 「均值点数」滑块的**上限**(用户可点击滑块右侧数值改成其它正整数)。
*
* 滑块量程 = `1 … maxPoints`,`points` 恒被夹在该区间内。
*/
int maxPoints = 50;
/** @brief 「迭代次数」滑块的**上限**(同上,量程 = `1 … maxIterations`)。 */
int maxIterations = 10;
/**
* @brief 参数行是否被**收起**(只保留信号色叠底的标题栏)。
*
* 仅「均值滤波」面板使用:勾选后标题栏左侧出现展开三角,单击它切换收起/展开。
* 未勾选时没有参数行,本字段无意义。
*/
bool collapsed = false;
};
/** 中心轴边沿计数的平滑参数:与均值滤波共用同一结构与同一套语义。 */
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)。 */
/**
* @brief 参数行是否被**收起**(只保留信号色叠底的条目栏)。
*
* 「弱峰调整」面板自 2026-11-02 起与「均值滤波 / 中心轴边沿计数」同一套可滚动列表:
* 勾选后条目栏左侧出现展开三角,单击它切换收起 / 展开。未勾选时本字段无意义。
*/
bool collapsed = false;
};
/**
* @brief 「自由标记」中一个**固定**的数据点。
*
* 用户开启「自由标记」后,网格内会跟随鼠标出现一条纵向光标;左键单击一次就把当时
* 就近的那个数据点固定下来。固定点按信号分组显示在浮动面板里,
* 同一信号内的条目按**时间从左到右**排列(面板里从上到下)。
*
* ⚠️ `time` 存的是**显示时刻**(= 画在 `TimeToX(time)` 处),
* 与 `TabConfig::CursorTime()` 同一口径;采样查询要用
* `SampleDisplayedSignalAt`(它内部会减掉 `style.offsetTime`)。
*/
struct FreeMarkerItem
{
SignalRef ref; /**< 目标信号。 */
double time = 0.0; /**< 显示时刻(秒)。 */
};
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;
/** 边沿计数浮动面板的 UI 状态(展开 + 位置)。 */
LanePanelState edgePanel;
/**
* @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;
}
/** 中心轴边沿计数浮动面板的 UI 状态(展开 + 位置)。 */
LanePanelState edgeCenterPanel;
/**
* @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;
}
/** 均值滤波浮动面板的 UI 状态(展开 + 位置)。 */
LanePanelState meanFilterPanel;
/**
* @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;
}
/** 弱峰调整浮动面板的 UI 状态(展开 + 位置)。 */
LanePanelState weakPeakPanel;
/**
* @brief 「实验性功能 → 自由标记」开关(功能总开关)。
*
* 开启后本网格参与自由标记:鼠标**贴近信号曲线**(判据 = 到**画出来的折线**的垂直距离 ≤
* `kSignalHitTolerance`,与常规触发曲线高亮同源)时标注离鼠标最近的数据点,
* 左键单击把该数据点固定下来(固定点带 ✕ 可单独移除;浮动面板按信号分组,
* 每组一行**表头** + 五列数据:标记值 / 枚举文本 / 增量 / 时间点 / 时间间隔)。
* 默认**不**显示网格私有纵向光标。
*/
bool freeMarker = false;
/**
* @brief 「自由标记」的**光标模式**(浮动面板底部「自由标记点选取」切换按钮)。
*
* `false`(默认):鼠标就是**普通鼠标** —— 不显示任何取值预览、不画纵向光标,
* 按下/拖动完全走常规语义(平移 / 框选 / 拖信号),**不会**固定数据点。
* `true`:进入「自由标记点选取」—— 网格内始终跟随鼠标画一条**仅限本网格**的纵向光标
*(上下各一个三角把手、不显示时间读数),鼠标接触到信号曲线时标注离鼠标最近的数据点,
* 左键单击把该数据点固定下来(此时左键**不再**用于平移/框选/拖信号)。
*/
bool freeMarkerCursor = false;
/**
* @brief 固定点的数值显示是否附带**括号内增量**(浮动面板的「显示增量」开关)。
*
* 默认 `false`:胶囊只显示当前值。⚠️ 只影响**已固定点**的显示,
* 不影响「自由标记点选取」模式下跟随鼠标的预览胶囊,也不影响面板统计列。
*/
bool freeMarkerShowDelta = false;
/**
* @brief 固定点的数值显示是否附带**枚举文本**(浮动面板的「显示枚举文本」开关)。
*
* 默认 `false`:胶囊只显示当前值。⚠️ 只影响**已固定点**的显示,
* 不影响跟随鼠标的预览胶囊,也不影响面板统计列(那一列只看有没有枚举表)。
*/
bool freeMarkerShowEnum = false;
/**
* @brief 已固定的自由标记点(**未排序**,按加入顺序)。
*
* 面板内的显示顺序是按「网格内信号顺序 → 该信号内按时间升序」临时计算的,
* 这样删除某一项不会打乱其它项的下标。同一信号同一时刻不会被重复加入。
*/
std::vector<FreeMarkerItem> freeMarkerItems;
/** 取某信号在某时刻的固定点下标(未找到返回 -1)。 */
int FindFreeMarker(const SignalRef& ref, double time) const;
/**
* @brief 加入一个固定点(同一信号同一时刻已存在时不重复加入)。
* @return 新加入返回 true。
*/
bool AddFreeMarker(const SignalRef& ref, double time);
/** 移除某信号在某时刻的固定点(按时刻容差匹配)。 */
void RemoveFreeMarker(const SignalRef& ref, double time);
/** 移除某个下标对应的固定点。 */
void RemoveFreeMarkerAt(std::size_t index);
/** 移除某信号的**全部**固定点。 */
void RemoveFreeMarkersOf(const SignalRef& ref);
/** 某信号是否已有固定点。 */
bool HasFreeMarkers(const SignalRef& ref) const
{
for (const FreeMarkerItem& item : freeMarkerItems)
{
if (item.ref == ref)
{
return true;
}
}
return false;
}
/** 自由标记浮动面板的 UI 状态(展开/折起 + 位置)。 */
LanePanelState freeMarkerPanel;
/**
* @brief 网格内固定数据点(纵向细线 / 数据点 / 取值胶囊 / 行尾 ✕)的显示不透明度。
*
* 0 = 完全透明(看不见),1 = 完全不透明(默认)。由浮动面板底部**总是显示**的
* 「标记透明度」滑动条驱动(与「自由标记点选取」是否开启无关);只影响**本网格**的
* 固定点显示内容,不影响面板自身的像素与鼠标交互。
*/
float freeMarkerOpacity = 1.0f;
/**
* @brief 「自由标记」面板内容的滚动偏移(客户区像素,0 = 顶部)。
*
* 面板内容(信号分组栏 + 固定点条目)超出可视高度时出现滚动条;
* 保持在这个范围之外的内容会被裁剪。面板底部的按钮与「标记透明度」
* 滑动条**不参与**滚动(它们固定在面板底部)。
*/
float freeMarkerScroll = 0.0f;
/**
* @brief 「均值滤波」面板内容的滚动偏移(客户区像素,0 = 顶部)。
*
* 面板内容(每个信号的"信号色叠底标题栏 + 参数滑块行")超出可视高度时右侧出现滚动条;
* 底部的「构建为新信号」按钮**不参与**滚动(固定在面板底部)。
* 上百个信号合并到同一网格时它是必备的(否则面板会被顶出绘图区)。
*/
float meanFilterScroll = 0.0f;
/**
* @brief 「中心轴边沿计数」面板内容的滚动偏移(客户区像素,0 = 顶部)。
*
* 与 `meanFilterScroll` 完全同一套语义:面板自 2026-11-02 起改为**可滚动的分组列表**
*(信号色叠底条目栏 + 右侧复选框 + 展开后的统计行与两行参数滑块),
* 内容超出可视高度时右侧出现滚动条(否则上百个信号会把面板顶出绘图区)。
*/
float edgeCenterScroll = 0.0f;
/**
* @brief 「弱峰调整」面板内容的滚动偏移(客户区像素,0 = 顶部)。
*
* 与 `meanFilterScroll` / `edgeCenterScroll` 完全同一套语义:面板自 2026-11-02 起
* 改为**可滚动的分组列表**(信号色叠底条目栏 + 右侧复选框 + 展开后的两行滑块),
* 底部「构建为新信号」按钮固定不滚动。
*/
float weakPeakScroll = 0.0f;
/**
* @brief 「边沿计数」面板内容的滚动偏移(客户区像素,0 = 顶部)。
*
* 该面板仍是「每信号两行(名称 / 上升沿·下降沿)」的旧式排版,但**内容可以滚动**:
* 上百个信号合并到同一网格时内容高达上万像素,若不限高,面板会远超绘图区
* ⇒ 面板像素缓存一次都存不进去 ⇒ 每一帧都要整块重画(用户 2026-11-02 报告
* “开启该功能后所有交互都非常卡顿”)。
*/
float edgeCountScroll = 0.0f;
/**
* @brief 「自由标记」面板里被**收起**的信号分组(点击分组栏切换)。
*
* 只保存被收起的信号引用:不在列表里 = 展开(默认)。收起后该分组只显示
* 分组栏(三角朝右),下属的固定点条目不显示。
*/
std::vector<SignalRef> freeMarkerCollapsedGroups;
/**
* @brief 「自由标记」面板里被**选中**的条目(`freeMarkerItems` 下标,-1 = 未选中)。
*
* 单击面板里的条目的数据区域即选中(该行高亮);它与网格内的固定点一一对应,
* 因此增删固定点后需要重新校正(见 `RemoveFreeMarker*`)。
*/
int freeMarkerSelected = -1;
/**
* @brief 「自由标记」面板六列的**实测内容宽度**(设备像素,0 = 还没量过)。
*
* 用户 2026-10-21 明确要求:面板里的序号 / 取值 / 枚举文本 / 增量 / 时刻 / 间隔
* **不得出现省略号(…)**。因此列宽不能再用固定值:绘制该面板时顺便把每格
* 真实文本量一遍,取最大值存到这里;布局(`EdgePanelWidth` / `EdgePanelLayoutFor`)
* 用它与缺省列宽 `kFreeMarkerColumnWidths` 的较大者,于是列宽自然贴合内容。
* ⚠️ 存的是**设备像素**(已经乘过 DPI 缩放),与字体直接对应。
*/
std::array<float, 6> freeMarkerColumnWidths{};
/** 某信号分组在「自由标记」面板里是否被收起。 */
bool FreeMarkerGroupCollapsed(const SignalRef& ref) const
{
for (const SignalRef& entry : freeMarkerCollapsedGroups)
{
if (entry == ref)
{
return true;
}
}
return false;
}
/** 切换某信号分组在「自由标记」面板里的收起状态,返回切换后的状态。 */
bool ToggleFreeMarkerGroupCollapsed(const SignalRef& ref)
{
for (std::size_t index = 0; index < freeMarkerCollapsedGroups.size(); ++index)
{
if (freeMarkerCollapsedGroups[index] == ref)
{
freeMarkerCollapsedGroups.erase(
freeMarkerCollapsedGroups.begin() + static_cast<std::ptrdiff_t>(index));
return false;
}
}
freeMarkerCollapsedGroups.push_back(ref);
return true;
}
};
/**
* @brief 从内存中的信号采样点取某一时刻的取值。
*
* 加载完成后全部采样值已在内存中,本函数只查询内存数据,与源文件无关。
*
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @param [IN] time 采样时刻。
* @param [IN] interpolate 是否在相邻采样点之间做线性插值。
* @param [OUT] value 输出采样值,缺失时为 NaN。
* @return 时间点落在数据范围内且取到有效值返回 true。
*/
bool SampleSignalAt(const TimeAxis& axis, const SignalData& signal, double time, bool interpolate,
double& value);
/**
* @brief 取某一时刻**画面上显示**的取值(含水平镜像的"数据反序"与时间偏移)。
*
* 与 `SampleSignalAt` 的区别:
* * 水平镜像时用 `SourceIndexFor` 把下标映射到源采样(头尾对调);
* * 时间偏移时先按 `time - style.offsetTime` 求出源采样时刻(因为绘制在 `t + offsetTime`)。
* 因此返回的正是当前绘制出来的那条曲线在该时刻的取值 —— 光标读数、数据点标注都必须用它,
* 否则读数会与看到的波形不一致。
* ⚠️ 系数 / 绝对值 / 垂直镜像 / `offsetY` 不在这里处理,由 `ApplyDrawTransform` 负责。
*
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @param [IN] style 显示外观(只用 `mirrorHorizontal` 与 `offsetTime`)。
* @param [IN] time 采样时刻。
* @param [IN] interpolate 是否在相邻采样点之间做线性插值。
* @param [OUT] value 输出采样值,缺失时为 NaN。
* @return 时间点落在数据范围内且取到有效值返回 true。
*/
bool SampleDisplayedSignalAt(const TimeAxis& axis, 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 相同)。
* @param [IN] cancel 可选的取消标志(非 nullptr 且为 true 时**尽快退出**)。
* 用于「过时任务立即清退」:拖动参数滑块时旧参数的计算不再需要,
* 越早退出就能越早把工作缓冲(千万级采样时可达数百 MB)归还。
* @param [IN] throttle 是否在阶与阶之间让出 CPU(低内存设备上的“少量多次”模式)。
*/
void ComputeMovingAverageIterated(const std::vector<double>& values, int radius, int iterations,
std::vector<float>& out,
const std::atomic<bool>* cancel = nullptr,
bool throttle = false);
/**
* @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)。
* @param [IN] cancel 可选的取消标志(非 nullptr 且为 true 时**尽快退出**)。
* 拖动滑块时旧参数的计算会被新参数取代,越早退出越早释放工作缓冲。
* @param [IN] throttle 是否在阶与阶之间让出 CPU(低内存设备上的“少量多次”模式)。
*/
void ComputeWeakPeakAdjust(const std::vector<double>& values, double amount, int iterations,
std::vector<float>& out,
const std::atomic<bool>* cancel = nullptr,
bool throttle = false);
/**
* @brief 在给定时间轴上查找与给定时刻最接近的采样点。
* @param [IN] axis 目标时间轴。
* @param [IN] time 采样时刻。
* @return 采样点下标,时间轴为空时返回 -1。
*/
int FindNearestSample(const TimeAxis& axis, 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] groupIndex 即将被移除的分组下标。
* @note 调用顺序必须为:各标签页 RemapAfterGroupRemoval → 数据层 RemoveGroup → 各标签页 SyncToDataSet。
*/
void RemapAfterGroupRemoval(int groupIndex);
/**
* @brief 在数据层移除某条信号之前,按旧下标修正本配置(不重建网格)。
*
* 会删除该信号的可见性记录与网格归属,并把同一分组内位于其后的引用下标前移一位。
* 若某个网格因此变空则一并移除(与“取消勾选后空网格消失”的策略一致)。
*
* @param [IN] groupIndex 信号所属分组下标。
* @param [IN] signalIndex 即将被移除的信号下标。
* @note 调用顺序必须为:各标签页 RemapAfterSignalRemoval → 数据层修正选中 →
* 数据层 RemoveSignal → 各标签页 SyncToDataSet。
*/
void RemapAfterSignalRemoval(int groupIndex, 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] groupIndex 分组下标。
* @param [IN] visible 目标可见状态。
*/
void SetGroupVisible(int groupIndex, bool visible);
/**
* @brief 设置全部信号可见性,并同步网格划分。
* @param [IN] visible 目标可见状态。
*/
void SetAllVisible(bool visible);
/**
* @brief 仅显示指定信号,其余全部隐藏。
* @param [IN] ref 需要保持可见的信号。
*/
void SoloSignal(const SignalRef& ref);
/**
* @brief 判断某分组是否存在可见信号。
* @param [IN] groupIndex 分组下标。
* @return 存在可见信号返回 true。
*/
bool GroupHasVisible(int groupIndex) const;
/**
* @brief 判断某分组是否全部信号可见。
* @param [IN] groupIndex 分组下标。
* @return 全部可见且至少有一条信号返回 true。
*/
bool GroupAllVisible(int groupIndex) 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] groupIndex 发生插入的分组下标。
* @param [IN] insertPos 新信号的下标。
*/
void RemapAfterSignalInsert(int groupIndex, 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] groupIndex 分组下标。
* @return 折叠状态。
*/
bool IsCollapsed(int groupIndex) const;
/**
* @brief 切换某分组的折叠状态。
* @param [IN] groupIndex 分组下标。
*/
void ToggleCollapsed(int groupIndex);
/**
* @brief 设置「新出现的分组是否默认折起」。
*
* 只影响**之后**新出现的分组(例如新加载的文件);已存在的分组保持用户当前的选择。
* 用户新建的标签页会把它设为 true,使信号列表默认紧凑。
*
* @param [IN] collapsed 是否默认折起。
*/
void SetGroupsCollapsedByDefault(bool collapsed) { m_groupsCollapsedByDefault = collapsed; }
/**
* @brief 把当前所有分组设为折叠或展开。
* @param [IN] collapsed true = 全部折起,false = 全部展开。
*/
void SetAllCollapsed(bool collapsed)
{
std::fill(m_collapsed.begin(), m_collapsed.end(), collapsed);
}
/**
* @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 计算全部可见信号覆盖的**显示时间**范围。
*
* ⚠️ 返回的是"画出来的位置",即每条信号的 `axis.minTime/maxTime` 已叠加它自己的
* `SignalStyle::offsetTime`(采样画在 `t + offsetTime` 处)。视界要覆盖的正是这个范围,
* 少了偏移就会出现"设置时间轴偏移后点适应视图,曲线跑到视界之外"。
*
* @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; /**< 分组折叠状态,按分组下标。 */
/**
* @brief 新出现的(首次见到的)分组是否默认折起。
*
* 用户新建的标签页把它设为 true(信号列表默认紧凑);启动时的首个标签页保持 false,
* 这样加载完文件就能直接勾选信号。
*/
bool m_groupsCollapsedByDefault = false;
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__