#ifndef __DATA_MODEL_HPP__ #define __DATA_MODEL_HPP__ #include #include #include #include #include #include #include #include #include #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(style.gain); } if (style.absolute) { value = (value < 0.0) ? -value : value; } if (style.mirrorVertical) { value = -value; } if (style.offsetY != 0.0f) { value += static_cast(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(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(style.offsetY); low += offset; high += offset; } } /** * @brief 一条时间轴(时间向量 + 它的取值范围与单调性)。 * * 时间轴**独立于信号**:同一分组内多条信号可以共用同一条时间轴(最常见的 CSV 单时间轴文件、 * MF4 里同一通道组内的全部通道),也可以各自使用不同的时间轴(多 `time` 列的 CSV、 * MF4 的不同通道组)。 */ struct TimeAxis { std::wstring name; /**< 时间列名(源文件中的原样名称,如 `time`)。 */ std::vector 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 values; /**< 采样值,缺失处为 NaN。 */ double minValue = 0.0; /**< 有限采样值最小值。 */ double maxValue = 0.0; /**< 有限采样值最大值。 */ std::size_t finiteCount = 0; /**< 有限采样点数量。 */ /** * @brief 有效采样点很少时的**全部有效点**(按时间升序;有效点很多时为空)。 * * 仅当 `finiteCount <= kSparseTimesLimit` 时在加载/插入信号时就地填充,用于: * * 光标吸附:直接二分得到“视界内有几个数据点”“离光标最近的数据点”; * * 单点信号的初始视图:Centering 需要那个点的时刻与取值。 * ⚠️ 不要在鼠标移动时改成扫描整条采样数组来找有效点(千万级信号承受不住)。 * * 说明:`sparseTimes.size() == 1` 即“只有一个数据点的信号”。 */ std::vector sparseTimes; /** @brief `sparseTimes` 对应的取值(同序)。 */ std::vector sparseValues; /** * @brief 粗粒度包络层:每 coarseStride 个采样一组的 min/max。 * * 大文件(千万级采样)的折线包络构建是 O(可见采样数) 的,每次视图变化都要重算, * 实测 1770 万行 × 5 信号需要约 200ms/帧,拖拽时严重掉帧。 * 有了这一层(额外内存 ≈ 采样数的 0.8%),构建包络时只需读取“每列覆盖到的分组”, * 读取量降到约 1/5000,且**绘制结果与全量扫描完全一致**(分组内已存真实极值)。 * 分组内没有有效值时存 ±inf,读取时跳过。 */ std::vector coarseMin; /**< 分组最小值。 */ std::vector coarseMax; /**< 分组最大值。 */ std::size_t coarseStride = 0; /**< 分组的采样跨度;0 表示未构建。 */ SignalStyle style; /**< 显示外观(全局共享,跨标签页同步)。 */ }; /** * @brief 一个**信号分组**:一次加载产生的全部信号,以及它们使用的若干条时间轴。 * * ⚠️ 「分组」是应用内的逻辑容器,**不是文件**:加载完成后二者已无关系, * 因此这里**不保存文件路径**(路径只在加载那一刻用于错误提示,之后毫无用处)。 * 分组的默认名称取源文件名(便于用户辨认来源),用户可以重命名。 * * 一个分组可以包含**多条时间轴**(CSV 的多个 `time` 列、MF4 的多个通道组), * 每条信号通过 `SignalData::axisIndex` 指向自己那条。 */ struct SignalGroup { std::wstring name; /**< 分组名(加载时取源文件名、含扩展名;用户可重命名)。 */ std::vector axes; /**< 该分组使用的时间轴(至少一条)。 */ std::vector 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& 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 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 「自由标记」中一个**固定**的数据点。 * * 用户开启「自由标记」后,网格内会跟随鼠标出现一条纵向光标;左键单击一次就把当时 * 就近的那个数据点固定下来。固定点按信号分组显示在浮动面板里, * 同一信号内的条目按**时间从左到右**排列(面板里从上到下)。 * * ⚠️ `time` 存的是**显示时刻**(= 画在 `TimeToX(time)` 处), * 与 `TabConfig::CursorTime()` 同一口径;采样查询要用 * `SampleDisplayedSignalAt`(它内部会减掉 `style.offsetTime`)。 */ struct FreeMarkerItem { SignalRef ref; /**< 目标信号。 */ double time = 0.0; /**< 显示时刻(秒)。 */ }; std::wstring name; /**< 网格名称。 */ std::vector 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 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 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 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 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 「自由标记」面板里被**收起**的信号分组(点击分组栏切换)。 * * 只保存被收起的信号引用:不在列表里 = 展开(默认)。收起后该分组只显示 * 分组栏(三角朝右),下属的固定点条目不显示。 */ std::vector freeMarkerCollapsedGroups; /** * @brief 「自由标记」面板里被**选中**的条目(`freeMarkerItems` 下标,-1 = 未选中)。 * * 单击面板里的条目的数据区域即选中(该行高亮);它与网格内的固定点一一对应, * 因此增删固定点后需要重新校正(见 `RemoveFreeMarker*`)。 */ int freeMarkerSelected = -1; /** * @brief 「自由标记」面板六列的**实测内容宽度**(设备像素,0 = 还没量过)。 * * 用户 2026-10-21 明确要求:面板里的序号 / 取值 / 枚举文本 / 增量 / 时刻 / 间隔 * **不得出现省略号(…)**。因此列宽不能再用固定值:绘制该面板时顺便把每格 * 真实文本量一遍,取最大值存到这里;布局(`EdgePanelWidth` / `EdgePanelLayoutFor`) * 用它与缺省列宽 `kFreeMarkerColumnWidths` 的较大者,于是列宽自然贴合内容。 * ⚠️ 存的是**设备像素**(已经乘过 DPI 缩放),与字体直接对应。 */ std::array 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(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& values, int radius, std::vector& 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& values, int radius, int iterations, std::vector& out, const std::atomic* 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& values, double amount, int iterations, std::vector& out, const std::atomic* 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& Lanes() const { return m_lanes; } /** * @brief 获取网格集合(可写)。 * @return 网格集合。 */ std::vector& 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> m_visible; /**< 信号可见性,按 [分组][信号] 索引(本页独立)。 */ std::vector m_collapsed; /**< 分组折叠状态,按分组下标。 */ /** * @brief 新出现的(首次见到的)分组是否默认折起。 * * 用户新建的标签页把它设为 true(信号列表默认紧凑);启动时的首个标签页保持 false, * 这样加载完文件就能直接勾选信号。 */ bool m_groupsCollapsedByDefault = false; std::vector 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__