feat: 支持多视图标签页、按文件分组的多文件信号管理与网格随勾选自动收敛

数据层与视图配置解耦:新增 DataSet(多文件只读数据层)与 TabConfig(每标签页独立配置),
以 SignalRef{fileIndex,signalIndex} 标识全局信号,替换原有单文件 SignalSet。

- 标签页:新增 TabBar 组件,支持新建/切换/关闭标签页;每页独立保存信号勾选、
  颜色/线宽/显示方式、网格划分、视图范围与时间光标,互不影响。
- 网格收敛:可见性变更统一经 TabConfig 的 Set*Visible 接口并同步重算网格,
  取消勾选后对应的空网格立即回收(修复此前留下空轨道的问题)。
- 多文件分组:左侧面板改为「文件分组 + 信号」树形列表,分组可折叠、可整组勾选,
  分组复选框支持全选/部分/未选三态;新加载文件的信号默认未勾选,由用户自行选择。
- 来源标识:信号行、样式编辑区标题、波形图例与光标读数在跨文件时标注来源文件名。
- 交互补充:面板滚动条支持点击跳转与拖拽,切换标签页自动滚动到已选信号。
- 工具栏精简为「打开 CSV/适应视图/放大/缩小/合并网格/每信号一格/全选信号/清空勾选」,
  按钮宽度改为按实际文本像素测量;打开对话框支持多选文件,拖放支持批量载入。
- 新增 TestData/[2].CSV 作为跨文件对比测试数据。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-09-29 15:48:04 +08:00
1 parent 132b305544
commit 9c6c374cce
21 files changed
+4086 -1461

No files matched your search

+494
View File
@@ -0,0 +1,494 @@
#ifndef __DATA_MODEL_HPP__
#define __DATA_MODEL_HPP__
#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, /**< 同时绘制插值直线与采样点散点。 */
STEP = 3 /**< 阶梯保持(零阶保持)连线。 */
};
/**
* @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 单条信号的元数据与采样值(纯数据,不含显示配置)。
*/
struct SignalData
{
std::wstring name; /**< 显示名称(已剥离 "[n]" 序号前缀)。 */
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 一个已加载的数据文件及其自有时间轴。
*/
struct DataFile
{
std::wstring filePath; /**< 文件完整路径。 */
std::wstring fileName; /**< 文件名称。 */
std::wstring timeColumnName; /**< 时间列列名。 */
std::vector<double> time; /**< 时间轴数值。 */
std::vector<SignalData> signals; /**< 该文件包含的信号。 */
double minTime = 0.0; /**< 时间轴最小值。 */
double maxTime = 0.0; /**< 时间轴最大值。 */
bool timeMonotonic = true; /**< 时间轴是否单调不减。 */
};
/**
* @brief 数据集合:统一管理全部已加载文件,与视图配置无关。
*/
class DataSet
{
public:
/**
* @brief 清空全部已加载文件。
*/
void Clear();
/**
* @brief 依据 CSV 解析结果追加一个数据文件。
* @param [IN] table CSV 解析结果。
* @param [IN] filePath 源文件路径。
* @param [OUT] error 失败原因描述(成功时清空)。
* @param [OUT] fileIndex 新文件下标,失败时为 -1。
* @return 追加成功返回 true。
*/
bool AddFromTable(const CsvTable& table, const std::wstring& filePath, std::wstring& error, int& fileIndex);
/**
* @brief 移除指定下标的文件。
* @param [IN] fileIndex 文件下标。
* @return 移除成功返回 true。
*/
bool RemoveFile(int fileIndex);
/**
* @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] ref 信号引用。
* @return 信号数据指针,引用无效时返回 nullptr。
*/
const SignalData* Resolve(const SignalRef& ref) const;
/**
* @brief 按路径查找已加载文件下标。
* @param [IN] filePath 文件完整路径。
* @return 文件下标,未找到返回 -1。
*/
int IndexOfFile(const std::wstring& filePath) const;
/**
* @brief 计算全部文件时间范围的并集下限。
* @return 时间下限。
*/
double MinTime() const;
/**
* @brief 计算全部文件时间范围的并集上限。
* @return 时间上限。
*/
double MaxTime() const;
private:
std::vector<DataFile> m_files; /**< 已加载文件集合。 */
};
/**
* @brief 单个信号的显示配置(属于某个标签页)。
*/
struct SignalStyle
{
bool visible = false; /**< 是否参与绘制。默认关闭,由用户自行勾选。 */
std::uint32_t color = 0xFF268BD2; /**< 波形颜色,格式 0xAARRGGBB。 */
float lineWidth = 1.6f; /**< 线宽(逻辑像素,未乘 DPI 缩放)。 */
DrawMode mode = DrawMode::LINE; /**< 绘制方式。 */
};
/**
* @brief 一个波形网格(纵向泳道),可容纳多条信号叠加显示。
*/
struct Lane
{
std::wstring name; /**< 网格名称。 */
std::vector<SignalRef> signals; /**< 该网格包含的信号(按加入顺序)。 */
bool autoRange = true; /**< 是否依据信号取值范围自动计算 Y 轴。 */
double yMin = -1.0; /**< 手动 Y 轴下限。 */
double yMax = 1.0; /**< 手动 Y 轴上限。 */
};
/**
* @brief 计算某条信号在给定时刻的取值。
* @param [IN] file 信号所属文件。
* @param [IN] signal 目标信号。
* @param [IN] time 采样时刻。
* @param [IN] interpolate 是否在相邻采样点之间做线性插值。
* @param [OUT] value 输出采样值,缺失时为 NaN。
* @return 时间点落在数据范围内且取到有效值返回 true。
*/
bool SampleFileAt(const DataFile& file, const SignalData& signal, double time, bool interpolate, double& value);
/**
* @brief 在指定文件的时间轴上查找与给定时刻最接近的采样点。
* @param [IN] file 目标文件。
* @param [IN] time 采样时刻。
* @return 采样点下标,文件为空时返回 -1。
*/
int FindNearestSample(const DataFile& file, double time);
/**
* @brief 单个标签页的独立视图配置(信号可见性、样式、网格划分与视图范围)。
*/
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] ref 信号引用。
* @return 配置指针,引用无效时返回 nullptr。
*/
const SignalStyle* Style(const SignalRef& ref) const;
/**
* @brief 获取指定信号的显示配置(可写)。
* @param [IN] ref 信号引用。
* @return 配置指针,引用无效时返回 nullptr。
*/
SignalStyle* Style(const SignalRef& ref);
/**
* @brief 判断指定信号是否可见。
* @param [IN] ref 信号引用。
* @return 可见返回 true。
*/
bool IsVisible(const SignalRef& ref) const;
/**
* @brief 设置指定信号的可见性,并同步网格划分。
* @param [IN] dataSet 数据集合。
* @param [IN] ref 信号引用。
* @param [IN] visible 目标可见状态。
*/
void SetVisible(const DataSet& dataSet, const SignalRef& ref, bool visible);
/**
* @brief 切换指定信号的可见性,并同步网格划分。
* @param [IN] dataSet 数据集合。
* @param [IN] ref 信号引用。
*/
void ToggleVisible(const DataSet& dataSet, const SignalRef& ref);
/**
* @brief 设置某个文件全部信号的可见性,并同步网格划分。
* @param [IN] dataSet 数据集合。
* @param [IN] fileIndex 文件下标。
* @param [IN] visible 目标可见状态。
*/
void SetFileVisible(const DataSet& dataSet, int fileIndex, bool visible);
/**
* @brief 设置全部信号可见性,并同步网格划分。
* @param [IN] dataSet 数据集合。
* @param [IN] visible 目标可见状态。
*/
void SetAllVisible(const DataSet& dataSet, bool visible);
/**
* @brief 仅显示指定信号,其余全部隐藏。
* @param [IN] dataSet 数据集合。
* @param [IN] ref 需要保持可见的信号。
*/
void SoloSignal(const DataSet& dataSet, const SignalRef& ref);
/**
* @brief 判断某文件是否存在可见信号。
* @param [IN] dataSet 数据集合。
* @param [IN] fileIndex 文件下标。
* @return 存在可见信号返回 true。
*/
bool FileHasVisible(const DataSet& dataSet, int fileIndex) const;
/**
* @brief 判断某文件是否全部信号可见。
* @param [IN] dataSet 数据集合。
* @param [IN] fileIndex 文件下标。
* @return 全部可见且至少有一条信号返回 true。
*/
bool FileAllVisible(const DataSet& dataSet, 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] dataSet 数据集合。
* @param [IN] mode 目标策略。
*/
void SetMode(const DataSet& dataSet, LaneMode mode);
/**
* @brief 依据当前可见性与策略重新生成网格划分。
* @param [IN] dataSet 数据集合。
*/
void SyncLanes(const DataSet& dataSet);
/**
* @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 查询某文件的分组是否处于折叠状态。
* @param [IN] fileIndex 文件下标。
* @return 折叠状态。
*/
bool IsCollapsed(int fileIndex) const;
/**
* @brief 切换某文件分组的折叠状态。
* @param [IN] fileIndex 文件下标。
*/
void ToggleCollapsed(int fileIndex);
/**
* @brief 获取当前选中的信号引用。
* @return 信号引用。
*/
SignalRef Selected() const { return m_selected; }
/**
* @brief 设置当前选中的信号。
* @param [IN] ref 信号引用,无效值表示取消选择。
*/
void SetSelected(const SignalRef& ref) { m_selected = ref; }
/**
* @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 时间光标是否可见(可读写)。
bool& CursorVisible() { return m_cursorVisible; }
/// @brief 时间光标是否可见(只读)。
bool CursorVisible() const { return m_cursorVisible; }
/// @brief 时间光标是否已固定(可读写)。
bool& CursorPinned() { return m_cursorPinned; }
/// @brief 时间光标是否已固定(只读)。
bool CursorPinned() const { return m_cursorPinned; }
/// @brief 时间光标位置(可读写)。
double& CursorTime() { return m_cursorTime; }
/// @brief 时间光标位置(只读)。
double CursorTime() const { return m_cursorTime; }
private:
/**
* @brief 为新增信号补默认样式(含调色板取色)。
* @param [IN] dataSet 数据集合。
* @param [IN] fileIndex 文件下标。
* @param [IN] signalIndex 信号下标。
*/
void EnsureStyle(const DataSet& dataSet, int fileIndex, int signalIndex);
std::wstring m_name; /**< 标签页名称。 */
std::vector<std::vector<SignalStyle>> m_styles; /**< 显示配置,按 [文件][信号] 索引。 */
std::vector<bool> m_collapsed; /**< 文件分组折叠状态,按文件索引。 */
std::vector<Lane> m_lanes; /**< 网格划分。 */
LaneMode m_laneMode = LaneMode::PER_SIGNAL; /**< 网格划分策略。 */
SignalRef m_selected; /**< 当前选中信号。 */
double m_viewMin = 0.0; /**< 视图左边界时间。 */
double m_viewMax = 1.0; /**< 视图右边界时间。 */
bool m_viewInitialized = false; /**< 视图范围是否已初始化。 */
float m_laneScroll = 0.0f; /**< 网格纵向滚动偏移量。 */
bool m_cursorVisible = false; /**< 时间光标是否可见。 */
bool m_cursorPinned = false; /**< 时间光标是否已固定。 */
double m_cursorTime = 0.0; /**< 时间光标位置。 */
};
} // namespace jdi
#endif // __DATA_MODEL_HPP__
+8
View File
@@ -148,6 +148,14 @@ namespace jdi
*/
float UiTextWidth(gd::Graphics& g, const std::wstring& text, HFONT font);
/**
* @brief 测量单行文本的像素宽度(无需 GDI+ 上下文,可在布局阶段调用)。
* @param [IN] font 字体句柄。
* @param [IN] text 文本内容。
* @return 文本像素宽度。
*/
float UiMeasureTextWidth(HFONT font, const std::wstring& text);
/**
* @brief 在超出可用宽度时对文本做尾部省略截断。
* @param [IN,OUT] g 绘图上下文。
-212
View File
@@ -1,212 +0,0 @@
#ifndef __SIGNAL_SET_HPP__
#define __SIGNAL_SET_HPP__
#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, /**< 同时绘制插值直线与采样点散点。 */
STEP = 3 /**< 阶梯保持(零阶保持)连线。 */
};
/**
* @brief 将绘制方式转换为中文显示名称。
* @param [IN] mode 绘制方式。
* @return 显示名称。
*/
const wchar_t* DrawModeName(DrawMode mode);
/**
* @brief 单条信号(一列数据及其显示属性)。
*/
struct Signal
{
std::wstring name; /**< 显示名称(已剥离 "[n]" 序号前缀)。 */
std::wstring rawHeader; /**< CSV 原始列名。 */
std::wstring unit; /**< 从列名括号中提取的单位,可能为空。 */
std::vector<double> values; /**< 采样值,缺失处为 NaN。 */
bool visible = true; /**< 是否参与绘制。 */
std::uint32_t color = 0xFF268BD2; /**< 波形颜色,格式 0xAARRGGBB。 */
float lineWidth = 1.6f; /**< 线宽(逻辑像素,未乘 DPI 缩放)。 */
DrawMode mode = DrawMode::LINE; /**< 绘制方式。 */
double minValue = 0.0; /**< 有限采样值最小值。 */
double maxValue = 0.0; /**< 有限采样值最大值。 */
std::size_t finiteCount = 0; /**< 有限采样点数量。 */
};
/**
* @brief 一个波形网格(纵向泳道),可容纳多条信号叠加显示。
*/
struct Lane
{
std::wstring name; /**< 网格名称。 */
std::vector<int> signalIndices; /**< 该网格包含的信号下标(按加入顺序)。 */
bool autoRange = true; /**< 是否依据信号取值范围自动计算 Y 轴。 */
double yMin = -1.0; /**< 手动 Y 轴下限。 */
double yMax = 1.0; /**< 手动 Y 轴上限。 */
};
/**
* @brief 仿真数据集合:时间轴 + 多条信号 + 网格划分。
*/
class SignalSet
{
public:
SignalSet();
/**
* @brief 清空全部数据与显示状态。
*/
void Clear();
/**
* @brief 依据 CSV 解析结果构建数据集(自动识别时间列与信号列)。
* @param [IN] table CSV 解析结果。
* @param [IN] filePath 源文件路径,用于界面展示。
* @param [OUT] error 失败原因描述。
* @return 构建成功返回 true。
*/
bool BuildFromTable(const CsvTable& table, const std::wstring& filePath, std::wstring& error);
/**
* @brief 判断数据集是否已加载数据。
* @return 已加载返回 true。
*/
bool IsLoaded() const { return !m_signals.empty(); }
/// @brief 信号集合(可读写)。
std::vector<Signal>& Signals() { return m_signals; }
/// @brief 信号集合(只读)。
const std::vector<Signal>& Signals() const { return m_signals; }
/// @brief 网格集合(可读写)。
std::vector<Lane>& Lanes() { return m_lanes; }
/// @brief 网格集合(只读)。
const std::vector<Lane>& Lanes() const { return m_lanes; }
/// @brief 时间轴数值(只读)。
const std::vector<double>& Time() const { return m_time; }
/// @brief 时间轴列名(只读)。
const std::wstring& TimeColumnName() const { return m_timeColumnName; }
/// @brief 源文件路径(只读)。
const std::wstring& FilePath() const { return m_filePath; }
/// @brief 源文件名称(只读)。
const std::wstring& FileName() const { return m_fileName; }
/// @brief 采样点数量(只读)。
std::size_t SampleCount() const { return m_time.size(); }
/// @brief 时间轴最小值(只读)。
double MinTime() const { return m_minTime; }
/// @brief 时间轴最大值(只读)。
double MaxTime() const { return m_maxTime; }
/// @brief 时间轴是否单调不减(只读)。
bool TimeMonotonic() const { return m_timeMonotonic; }
/// @brief 当前选中信号下标,-1 表示未选中(只读)。
int SelectedSignal() const { return m_selectedSignal; }
/**
* @brief 设置当前选中信号。
* @param [IN] index 信号下标,传入越界值等价于取消选择。
*/
void SetSelectedSignal(int index);
/**
* @brief 查询某信号所属网格下标。
* @param [IN] signalIndex 信号下标。
* @return 网格下标,未找到返回 -1。
*/
int LaneOfSignal(int signalIndex) const;
/**
* @brief 将信号移动到指定网格,必要时创建新网格并回收空网格。
* @param [IN] signalIndex 信号下标。
* @param [IN] laneIndex 目标网格下标,传入负值表示新建网格。
* @return 移动后该信号所在的网格下标。
*/
int MoveSignalToLane(int signalIndex, int laneIndex);
/**
* @brief 将所有可见信号合并到同一个网格中。
*/
void MergeIntoSingleLane();
/**
* @brief 为每条信号创建独立网格。
*/
void SplitIntoLanes();
/**
* @brief 设置全部信号的可见状态。
* @param [IN] visible 目标可见状态。
*/
void SetAllVisible(bool visible);
/**
* @brief 仅显示指定信号,其余全部隐藏。
* @param [IN] signalIndex 需要保持可见的信号下标。
*/
void SoloSignal(int signalIndex);
/**
* @brief 计算某网格的 Y 轴显示范围。
* @param [IN] laneIndex 网格下标。
* @param [OUT] yMin 输出下限。
* @param [OUT] yMax 输出上限。
*/
void ComputeLaneRange(int laneIndex, double& yMin, double& yMax) const;
/**
* @brief 在当前时间轴上采样指定信号的值。
* @param [IN] signalIndex 信号下标。
* @param [IN] time 采样时刻。
* @param [IN] interpolate 是否在相邻采样点之间做线性插值。
* @param [OUT] value 输出采样值,缺失时为 NaN。
* @return 时间点落在数据范围内且取到有效值返回 true。
*/
bool SampleAt(int signalIndex, double time, bool interpolate, double& value) const;
/**
* @brief 在当前数据集中查找时间最为接近的采样点下标。
* @param [IN] time 采样时刻。
* @return 采样点下标,数据集为空时返回 -1。
*/
int FindNearestSample(double time) const;
private:
/**
* @brief 移除没有包含任何信号的网格(至少保留一个)。
*/
void PruneEmptyLanes();
/**
* @brief 为新增信号分配默认颜色。
* @param [IN] index 信号序号。
* @return 0xAARRGGBB 颜色值。
*/
std::uint32_t PickDefaultColor(std::size_t index) const;
std::vector<double> m_time; /**< 时间轴数值。 */
std::vector<Signal> m_signals; /**< 信号集合。 */
std::vector<Lane> m_lanes; /**< 网格集合。 */
std::wstring m_timeColumnName; /**< 时间列列名。 */
std::wstring m_filePath; /**< 源文件完整路径。 */
std::wstring m_fileName; /**< 源文件名称。 */
double m_minTime = 0.0; /**< 时间轴最小值。 */
double m_maxTime = 0.0; /**< 时间轴最大值。 */
bool m_timeMonotonic = true; /**< 时间轴是否单调不减。 */
int m_selectedSignal = -1; /**< 当前选中信号下标。 */
};
} // namespace jdi
#endif // __SIGNAL_SET_HPP__
File diff suppressed because it is too large. Load diff
+22
View File
@@ -236,6 +236,28 @@ namespace jdi
return static_cast<float>(size.cx);
}
float UiMeasureTextWidth(HFONT font, const std::wstring& text)
{
if (text.empty() || font == nullptr)
{
return 0.0f;
}
HDC hdc = GetDC(nullptr);
if (hdc == nullptr)
{
return 0.0f;
}
HGDIOBJ oldFont = SelectObject(hdc, font);
SIZE size = {};
GetTextExtentPoint32W(hdc, text.c_str(), static_cast<int>(text.size()), &size);
SelectObject(hdc, oldFont);
ReleaseDC(nullptr, hdc);
return static_cast<float>(size.cx);
}
std::wstring UiEllipsize(gd::Graphics& g, const std::wstring& text, HFONT font, float maxWidth)
{
if (text.empty() || font == nullptr || maxWidth <= 0.0f)
-612
View File
@@ -1,612 +0,0 @@
#include "SignalSet.hpp"
#include "Theme.hpp"
#include "Utf8Util.hpp"
#include <algorithm>
#include <cmath>
#include <cwctype>
#include <limits>
namespace jdi
{
namespace
{
const double kMissing = std::numeric_limits<double>::quiet_NaN(); /**< 缺失值占位。 */
const double kDefaultLineWidth = 1.6; /**< 默认线宽。 */
/**
* @brief 判断列名是否为时间轴候选列。
* @param [IN] header 原始列名。
* @return 可能为时间轴返回 true。
*/
bool LooksLikeTimeHeader(const std::wstring& header)
{
const std::wstring text = ToLowerAscii(Trim(header));
if (text.empty())
{
return false;
}
if (text == L"t" || text == L"time" || text == L"timestamp" || text == L"simulation time" ||
text == L"时间" || text == L"时间轴")
{
return true;
}
return ContainsInsensitive(text, L"time") || ContainsInsensitive(text, L"时间");
}
/**
* @brief 剥离列名中的 "[n]" 序号前缀,得到显示名称。
* @param [IN] header 原始列名。
* @return 显示名称。
*/
std::wstring MakeDisplayName(const std::wstring& header)
{
const std::wstring text = Trim(header);
if (text.size() >= 3 && text[0] == L'[')
{
const std::size_t close = text.find(L']');
if (close != std::wstring::npos && close > 1 && (close + 1) < text.size())
{
bool numeric = true;
for (std::size_t i = 1; i < close; ++i)
{
if (!iswdigit(text[i]))
{
numeric = false;
break;
}
}
if (numeric)
{
const std::wstring rest = Trim(text.substr(close + 1));
if (!rest.empty())
{
return rest;
}
}
}
}
return text.empty() ? header : text;
}
/**
* @brief 从列名末尾的括号中提取单位(仅接受形如 mA / V / deg/s 的短标记)。
* @param [IN] header 原始列名。
* @return 单位文本,无法识别时返回空字符串。
*/
std::wstring ExtractUnit(const std::wstring& header)
{
const std::wstring text = Trim(header);
if (text.size() < 3 || text.back() != L')')
{
return std::wstring();
}
const std::size_t open = text.rfind(L'(');
if (open == std::wstring::npos || open + 2 >= text.size())
{
return std::wstring();
}
const std::wstring inner = Trim(text.substr(open + 1, text.size() - open - 2));
if (inner.empty() || inner.size() > 10)
{
return std::wstring();
}
for (const wchar_t c : inner)
{
const bool allowed = iswalpha(c) || iswdigit(c) || c == L'%' || c == L'/' || c == L'^' ||
c == L'-' || c == L'.' || c == L'\u00B0' || c == L'\u00B5' ||
c == L'\u03A9' || c == L'\u00B2' || c == L'\u00B3' || c == L'\u00B7';
if (!allowed)
{
return std::wstring();
}
}
return inner;
}
/**
* @brief 为网格范围补充留白,避免波形紧贴边界。
* @param [IN, OUT] yMin 范围下限。
* @param [IN, OUT] yMax 范围上限。
*/
void PadRange(double& yMin, double& yMax)
{
if (!std::isfinite(yMin) || !std::isfinite(yMax))
{
yMin = 0.0;
yMax = 1.0;
return;
}
const double span = yMax - yMin;
if (span <= 0.0)
{
const double magnitude = std::max(1.0, std::fabs(yMin) * 0.1);
yMin -= magnitude;
yMax += magnitude;
return;
}
const double pad = span * 0.06;
yMin -= pad;
yMax += pad;
}
}
const wchar_t* DrawModeName(DrawMode mode)
{
switch (mode)
{
case DrawMode::LINE:
return L"线段";
case DrawMode::SCATTER:
return L"散点";
case DrawMode::LINE_SCATTER:
return L"点线";
case DrawMode::STEP:
return L"阶梯";
default:
return L"线段";
}
}
SignalSet::SignalSet() = default;
void SignalSet::Clear()
{
m_time.clear();
m_signals.clear();
m_lanes.clear();
m_timeColumnName.clear();
m_filePath.clear();
m_fileName.clear();
m_minTime = 0.0;
m_maxTime = 0.0;
m_timeMonotonic = true;
m_selectedSignal = -1;
}
bool SignalSet::BuildFromTable(const CsvTable& table, const std::wstring& filePath, std::wstring& error)
{
Clear();
error.clear();
if (!table.HasData())
{
error = L"数据表为空。";
return false;
}
const std::size_t columnCount = table.headers.size();
const std::size_t rowCount = table.rows.size();
// 1. 识别时间轴列:优先取列名匹配的列,否则回退到第一列。
int timeColumn = -1;
for (std::size_t column = 0; column < columnCount; ++column)
{
if (LooksLikeTimeHeader(table.headers[column]))
{
timeColumn = static_cast<int>(column);
break;
}
}
if (timeColumn < 0)
{
timeColumn = 0;
}
m_time.resize(rowCount);
std::size_t finiteTimeCount = 0;
bool monotonic = true;
double previousTime = 0.0;
bool hasPrevious = false;
for (std::size_t row = 0; row < rowCount; ++row)
{
const double value = table.rows[row][static_cast<std::size_t>(timeColumn)];
m_time[row] = value;
if (std::isfinite(value))
{
++finiteTimeCount;
if (hasPrevious && value < previousTime)
{
monotonic = false;
}
previousTime = value;
hasPrevious = true;
}
}
if (finiteTimeCount < 2)
{
error = L"时间轴列(" + table.headers[static_cast<std::size_t>(timeColumn)] + L")有效数值不足。";
return false;
}
m_timeColumnName = table.headers[static_cast<std::size_t>(timeColumn)];
m_timeMonotonic = monotonic;
m_filePath = filePath;
const std::size_t slashPos = filePath.find_last_of(L"\\/");
m_fileName = (slashPos == std::wstring::npos) ? filePath : filePath.substr(slashPos + 1);
// 2. 构建信号列。
m_signals.reserve(columnCount);
for (std::size_t column = 0; column < columnCount; ++column)
{
if (static_cast<int>(column) == timeColumn)
{
continue;
}
Signal signal;
signal.rawHeader = table.headers[column];
signal.name = MakeDisplayName(signal.rawHeader);
signal.unit = ExtractUnit(signal.rawHeader);
signal.color = PickDefaultColor(m_signals.size());
signal.lineWidth = static_cast<float>(kDefaultLineWidth);
signal.mode = DrawMode::LINE;
signal.visible = true;
signal.values.resize(rowCount);
double minValue = std::numeric_limits<double>::infinity();
double maxValue = -std::numeric_limits<double>::infinity();
std::size_t finiteCount = 0;
for (std::size_t row = 0; row < rowCount; ++row)
{
const double value = table.rows[row][column];
signal.values[row] = value;
if (std::isfinite(value))
{
++finiteCount;
minValue = std::min(minValue, value);
maxValue = std::max(maxValue, value);
}
}
if (finiteCount == 0)
{
signal.minValue = 0.0;
signal.maxValue = 0.0;
}
else
{
signal.minValue = minValue;
signal.maxValue = maxValue;
}
signal.finiteCount = finiteCount;
m_signals.push_back(std::move(signal));
}
if (m_signals.empty())
{
error = L"除时间轴外未发现任何数据列。";
Clear();
return false;
}
// 3. 统计时间范围。
double tMin = std::numeric_limits<double>::infinity();
double tMax = -std::numeric_limits<double>::infinity();
for (const double value : m_time)
{
if (std::isfinite(value))
{
tMin = std::min(tMin, value);
tMax = std::max(tMax, value);
}
}
m_minTime = tMin;
m_maxTime = tMax;
// 4. 默认每个信号独占一个网格,便于区分量纲差异较大的数据。
m_lanes.clear();
m_lanes.resize(m_signals.size());
for (std::size_t index = 0; index < m_signals.size(); ++index)
{
m_lanes[index].name = L"网格 " + std::to_wstring(index + 1);
m_lanes[index].signalIndices.push_back(static_cast<int>(index));
m_lanes[index].autoRange = true;
}
m_selectedSignal = m_signals.empty() ? -1 : 0;
return true;
}
void SignalSet::SetSelectedSignal(int index)
{
if (index < 0 || index >= static_cast<int>(m_signals.size()))
{
m_selectedSignal = -1;
return;
}
m_selectedSignal = index;
}
int SignalSet::LaneOfSignal(int signalIndex) const
{
for (std::size_t lane = 0; lane < m_lanes.size(); ++lane)
{
const std::vector<int>& indices = m_lanes[lane].signalIndices;
if (std::find(indices.begin(), indices.end(), signalIndex) != indices.end())
{
return static_cast<int>(lane);
}
}
return -1;
}
int SignalSet::MoveSignalToLane(int signalIndex, int laneIndex)
{
if (signalIndex < 0 || signalIndex >= static_cast<int>(m_signals.size()))
{
return -1;
}
for (Lane& lane : m_lanes)
{
std::vector<int>& indices = lane.signalIndices;
indices.erase(std::remove(indices.begin(), indices.end(), signalIndex), indices.end());
}
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
Lane lane;
lane.name = L"网格 " + std::to_wstring(m_lanes.size() + 1);
lane.autoRange = true;
lane.signalIndices.push_back(signalIndex);
m_lanes.push_back(std::move(lane));
}
else
{
m_lanes[static_cast<std::size_t>(laneIndex)].signalIndices.push_back(signalIndex);
}
PruneEmptyLanes();
return LaneOfSignal(signalIndex);
}
void SignalSet::MergeIntoSingleLane()
{
m_lanes.clear();
Lane lane;
lane.name = L"网格 1";
lane.autoRange = true;
lane.signalIndices.reserve(m_signals.size());
for (std::size_t index = 0; index < m_signals.size(); ++index)
{
lane.signalIndices.push_back(static_cast<int>(index));
}
m_lanes.push_back(std::move(lane));
}
void SignalSet::SplitIntoLanes()
{
m_lanes.clear();
m_lanes.resize(m_signals.size());
for (std::size_t index = 0; index < m_signals.size(); ++index)
{
Lane& lane = m_lanes[index];
lane.name = L"网格 " + std::to_wstring(index + 1);
lane.autoRange = true;
lane.signalIndices.push_back(static_cast<int>(index));
}
}
void SignalSet::SetAllVisible(bool visible)
{
for (Signal& signal : m_signals)
{
signal.visible = visible;
}
}
void SignalSet::SoloSignal(int signalIndex)
{
for (std::size_t index = 0; index < m_signals.size(); ++index)
{
m_signals[index].visible = (static_cast<int>(index) == signalIndex);
}
}
void SignalSet::ComputeLaneRange(int laneIndex, double& yMin, double& yMax) const
{
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
yMin = 0.0;
yMax = 1.0;
return;
}
const Lane& lane = m_lanes[static_cast<std::size_t>(laneIndex)];
if (!lane.autoRange)
{
yMin = lane.yMin;
yMax = lane.yMax;
if (!(yMax > yMin))
{
yMin = 0.0;
yMax = 1.0;
}
return;
}
double low = std::numeric_limits<double>::infinity();
double high = -std::numeric_limits<double>::infinity();
for (const int signalIndex : lane.signalIndices)
{
if (signalIndex < 0 || signalIndex >= static_cast<int>(m_signals.size()))
{
continue;
}
const Signal& signal = m_signals[static_cast<std::size_t>(signalIndex)];
if (!signal.visible || signal.finiteCount == 0)
{
continue;
}
low = std::min(low, signal.minValue);
high = std::max(high, signal.maxValue);
}
if (!std::isfinite(low) || !std::isfinite(high))
{
low = 0.0;
high = 1.0;
}
PadRange(low, high);
yMin = low;
yMax = high;
}
int SignalSet::FindNearestSample(double time) const
{
const std::size_t count = m_time.size();
if (count == 0)
{
return -1;
}
if (m_timeMonotonic)
{
const std::vector<double>::const_iterator lower =
std::lower_bound(m_time.begin(), m_time.end(), time);
if (lower == m_time.begin())
{
return 0;
}
if (lower == m_time.end())
{
return static_cast<int>(count - 1);
}
const std::size_t rightIndex = static_cast<std::size_t>(lower - m_time.begin());
const std::size_t leftIndex = rightIndex - 1;
const double leftDistance = std::fabs(time - m_time[leftIndex]);
const double rightDistance = std::fabs(m_time[rightIndex] - time);
return (leftDistance <= rightDistance) ? static_cast<int>(leftIndex) : static_cast<int>(rightIndex);
}
int best = 0;
double bestDistance = std::numeric_limits<double>::infinity();
for (std::size_t index = 0; index < count; ++index)
{
if (!std::isfinite(m_time[index]))
{
continue;
}
const double distance = std::fabs(m_time[index] - time);
if (distance < bestDistance)
{
bestDistance = distance;
best = static_cast<int>(index);
}
}
return best;
}
bool SignalSet::SampleAt(int signalIndex, double time, bool interpolate, double& value) const
{
value = kMissing;
if (signalIndex < 0 || signalIndex >= static_cast<int>(m_signals.size()))
{
return false;
}
if (m_time.empty() || time < m_minTime || time > m_maxTime)
{
return false;
}
const std::vector<double>& samples = m_signals[static_cast<std::size_t>(signalIndex)].values;
if (!m_timeMonotonic)
{
const int nearest = FindNearestSample(time);
if (nearest < 0)
{
return false;
}
value = samples[static_cast<std::size_t>(nearest)];
return std::isfinite(value);
}
const std::vector<double>::const_iterator lower =
std::lower_bound(m_time.begin(), m_time.end(), time);
const std::size_t rightIndex = static_cast<std::size_t>(lower - m_time.begin());
if (rightIndex == 0)
{
value = samples[0];
return std::isfinite(value);
}
if (rightIndex >= m_time.size())
{
value = samples.back();
return std::isfinite(value);
}
const std::size_t leftIndex = rightIndex - 1;
const double t0 = m_time[leftIndex];
const double t1 = m_time[rightIndex];
const double v0 = samples[leftIndex];
const double v1 = samples[rightIndex];
if (!interpolate || !std::isfinite(v0) || !std::isfinite(v1) || !(t1 > t0))
{
const double leftDistance = std::fabs(time - t0);
const double rightDistance = std::fabs(t1 - time);
value = (leftDistance <= rightDistance) ? v0 : v1;
return std::isfinite(value);
}
const double ratio = (time - t0) / (t1 - t0);
value = v0 + (v1 - v0) * ratio;
return true;
}
void SignalSet::PruneEmptyLanes()
{
if (m_lanes.size() <= 1)
{
return;
}
std::vector<Lane> kept;
kept.reserve(m_lanes.size());
for (Lane& lane : m_lanes)
{
if (!lane.signalIndices.empty())
{
kept.push_back(std::move(lane));
}
}
if (kept.empty())
{
return;
}
m_lanes = std::move(kept);
for (std::size_t index = 0; index < m_lanes.size(); ++index)
{
m_lanes[index].name = L"网格 " + std::to_wstring(index + 1);
}
}
std::uint32_t SignalSet::PickDefaultColor(std::size_t index) const
{
int paletteCount = 0;
const std::uint32_t* palette = SignalPalette(paletteCount);
if (palette == nullptr || paletteCount <= 0)
{
return 0xFF268BD2;
}
return palette[index % static_cast<std::size_t>(paletteCount)];
}
} // namespace jdi