feat: 搭建 Just DataInspector 波形数据查看器基础功能

基于 Win32 API + GDI+ 从零实现仿真数据检查器,用于替代 Simulink.sdi.view 查看带时间轴的 CSV 数据。

- 新增 Utilities 层:UTF-8/宽字符与数值格式化工具、CSV 解析器(自动识别分隔符与引号)、
  信号数据集模型(时间轴识别、信号名解析、取值采样)、Solarised Light 主题色板与绘图基础工具
- 新增 Toolbar 组件:打开 CSV、适应窗口、适应时间、放大、缩小、合并网格、每信号一格
- 新增 SignalPanel 组件:信号列表(可见性开关、颜色标识、所属网格标记)、样式编辑区
  (颜色、线宽、线段/散点/点线/阶梯显示方式、所属网格),支持右键上下文菜单与滚轮滚动
- 新增 PlotView 组件:多网格波形绘制、自适应时间轴与幅值轴、网格线、信号图例、
  时间光标与实时数值读数、纵向滚动条
- 新增 Application 组件:DPI 感知(Per-Monitor V2)、双缓冲绘制、区域布局与分隔条拖拽、
  鼠标与键盘消息分发、文件拖放、状态栏提示
- 交互能力:滚轮缩放、Shift+滚轮平移、Ctrl+滚轮缩放幅值、拖拽平移、幅值轴纵向缩放、
  右键框选缩放、单击固定/取消时间光标、双击自适应、方向键平移与滚动
- 补齐 Makefile 的源文件与头文件索引,并新增 gdiplus、comdlg32 链接库
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-09-29 15:05:29 +08:00
commit 132b305544
22 files changed
+7146

No files matched your search

+349
View File
@@ -0,0 +1,349 @@
#include "CsvTable.hpp"
#include "Utf8Util.hpp"
#include <windows.h>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <limits>
namespace jdi
{
namespace
{
/** @brief 单个字段在原始缓冲中的位置区间。 */
struct FieldSpan
{
const char* begin = nullptr; /**< 字段起始指针。 */
const char* end = nullptr; /**< 字段结束指针(不包含)。 */
};
const double kMissingValue = std::numeric_limits<double>::quiet_NaN(); /**< 缺失值占位。 */
/**
* @brief 一次性将整个文件读入内存。
* @param [IN] filePath 文件路径。
* @param [OUT] buffer 文件内容输出。
* @param [OUT] error 失败原因描述。
* @return 读取成功返回 true。
*/
bool ReadWholeFile(const std::wstring& filePath, std::string& buffer, std::wstring& error)
{
FILE* file = _wfopen(filePath.c_str(), L"rb");
if (file == nullptr)
{
error = L"无法打开文件,请确认路径与访问权限。";
return false;
}
if (std::fseek(file, 0, SEEK_END) != 0)
{
std::fclose(file);
error = L"无法定位文件末尾。";
return false;
}
const long fileSize = std::ftell(file);
if (fileSize < 0)
{
std::fclose(file);
error = L"无法获取文件长度。";
return false;
}
if (fileSize == 0)
{
std::fclose(file);
error = L"文件内容为空。";
return false;
}
if (fileSize > (512L * 1024L * 1024L))
{
std::fclose(file);
error = L"文件体积超过 512 MB,暂不支持。";
return false;
}
std::fseek(file, 0, SEEK_SET);
buffer.resize(static_cast<size_t>(fileSize));
const size_t readCount = std::fread(&buffer[0], 1, buffer.size(), file);
std::fclose(file);
buffer.resize(readCount);
return true;
}
/**
* @brief 统计一行中某个候选分隔符出现的次数。
* @param [IN] begin 行起始指针。
* @param [IN] end 行结束指针(不包含)。
* @param [IN] candidate 候选分隔符。
* @return 出现次数。
*/
int CountDelimiter(const char* begin, const char* end, char candidate)
{
int count = 0;
bool inQuote = false;
for (const char* p = begin; p < end; ++p)
{
if (*p == '"')
{
inQuote = !inQuote;
}
else if (*p == candidate && !inQuote)
{
++count;
}
}
return count;
}
/**
* @brief 依据表头行推断分隔符。
* @param [IN] begin 表头行起始指针。
* @param [IN] end 表头行结束指针(不包含)。
* @return 推断出的分隔符。
*/
char DetectDelimiter(const char* begin, const char* end)
{
static const char kCandidates[] = {',', ';', '\t', '|'};
char best = ',';
int bestCount = 0;
for (const char candidate : kCandidates)
{
const int count = CountDelimiter(begin, end, candidate);
if (count > bestCount)
{
bestCount = count;
best = candidate;
}
}
return best;
}
/**
* @brief 将一行文本按分隔符拆分为多个字段(支持双引号包裹与转义)。
* @param [IN] begin 行起始指针。
* @param [IN] end 行结束指针(不包含)。
* @param [IN] delimiter 分隔符。
* @param [OUT] fields 字段区间输出。
*/
void SplitFields(const char* begin, const char* end, char delimiter, std::vector<FieldSpan>& fields)
{
fields.clear();
const char* fieldBegin = begin;
bool inQuote = false;
for (const char* p = begin; p < end; ++p)
{
const char c = *p;
if (c == '"')
{
if (inQuote && (p + 1) < end && p[1] == '"')
{
++p;
continue;
}
inQuote = !inQuote;
}
else if (c == delimiter && !inQuote)
{
FieldSpan span;
span.begin = fieldBegin;
span.end = p;
fields.push_back(span);
fieldBegin = p + 1;
}
}
FieldSpan span;
span.begin = fieldBegin;
span.end = end;
fields.push_back(span);
}
/**
* @brief 将字段区间转换为字符串并去除引号与空白。
* @param [IN] span 字段区间。
* @return 规范化后的字段文本。
*/
std::wstring FieldToText(const FieldSpan& span)
{
std::wstring text = Utf8ToWide(span.begin, static_cast<int>(span.end - span.begin));
return StripQuotes(text);
}
/**
* @brief 判断字段是否应当被整行跳过(空行或注释行)。
* @param [IN] span 首字段区间。
* @param [IN] lineIsEmpty 该行是否为纯空白行。
* @return 需要跳过返回 true。
*/
bool ShouldSkipLine(const FieldSpan& span, bool lineIsEmpty)
{
if (lineIsEmpty)
{
return true;
}
if (span.begin == nullptr || span.begin >= span.end)
{
return false;
}
const char first = span.begin[0];
return first == '#' || first == '%';
}
/**
* @brief 在一行内查找首个非空行的终止位置。
* @param [IN] begin 文本起始指针。
* @param [IN] end 文本结束指针(不包含)。
* @return 该行结束指针(不含换行符)。
*/
const char* FindLineEnd(const char* begin, const char* end)
{
const char* p = begin;
while (p < end && *p != '\n')
{
++p;
}
const char* lineEnd = p;
while (lineEnd > begin && (lineEnd[-1] == '\r' || lineEnd[-1] == '\n'))
{
--lineEnd;
}
return lineEnd;
}
}
bool LoadCsvTable(const std::wstring& filePath, CsvTable& table, std::wstring& error)
{
table = CsvTable();
error.clear();
std::string buffer;
if (!ReadWholeFile(filePath, buffer, error))
{
return false;
}
const char* data = buffer.data();
size_t length = buffer.size();
// 跳过 UTF-8 BOM,避免首列名被污染。
if (length >= 3 && static_cast<unsigned char>(data[0]) == 0xEF &&
static_cast<unsigned char>(data[1]) == 0xBB && static_cast<unsigned char>(data[2]) == 0xBF)
{
data += 3;
length -= 3;
}
const char* cursor = data;
const char* limit = data + length;
std::vector<FieldSpan> fields;
bool headerReady = false;
while (cursor < limit)
{
const char* rawEnd = FindLineEnd(cursor, limit);
const char* lineEnd = rawEnd;
// 跳过纯空白行,但不产生数据行。
const char* scan = cursor;
while (scan < lineEnd && (*scan == ' ' || *scan == '\t' || *scan == '\r'))
{
++scan;
}
const bool lineIsEmpty = (scan >= lineEnd);
if (!headerReady && lineIsEmpty)
{
cursor = (rawEnd < limit) ? (rawEnd + 1) : limit;
continue;
}
if (!headerReady)
{
table.delimiter = DetectDelimiter(cursor, lineEnd);
SplitFields(cursor, lineEnd, static_cast<char>(table.delimiter), fields);
table.headers.clear();
table.headers.reserve(fields.size());
for (const FieldSpan& span : fields)
{
std::wstring text = FieldToText(span);
if (text.empty())
{
text = L"Column" + std::to_wstring(table.headers.size() + 1);
}
table.headers.push_back(text);
}
if (table.headers.empty())
{
error = L"表头为空,无法解析列名。";
return false;
}
headerReady = true;
cursor = (rawEnd < limit) ? (rawEnd + 1) : limit;
continue;
}
SplitFields(cursor, lineEnd, static_cast<char>(table.delimiter), fields);
// 空行与注释行(以 # 或 % 开头)不参与数据统计。
if (ShouldSkipLine(fields.empty() ? FieldSpan() : fields.front(), lineIsEmpty))
{
cursor = (rawEnd < limit) ? (rawEnd + 1) : limit;
continue;
}
if (fields.size() != table.headers.size())
{
++table.skippedRowCount;
}
std::vector<double> row;
row.reserve(table.headers.size());
bool rowHasValue = false;
for (size_t column = 0; column < table.headers.size(); ++column)
{
if (column >= fields.size())
{
row.push_back(kMissingValue);
continue;
}
double value = kMissingValue;
if (TryParseDouble(fields[column].begin, fields[column].end, value))
{
rowHasValue = true;
}
row.push_back(value);
}
if (!rowHasValue)
{
cursor = (rawEnd < limit) ? (rawEnd + 1) : limit;
continue;
}
table.rows.push_back(std::move(row));
++table.sourceRowCount;
cursor = (rawEnd < limit) ? (rawEnd + 1) : limit;
}
if (!table.HasData())
{
error = L"未从文件中解析出有效数据行。";
return false;
}
return true;
}
} // namespace jdi