网格菜单重构、大文件异步加载与内存/解析优化、修复应用图标
网格右键菜单: - 菜单顺序改为: 网格 N / Y 轴最大值 / Y 轴最小值 / 选区 Y 轴自适应 / --分隔线-- / Y 轴自适应 / 时间轴自适应 / 全部自适应 (恢复自适应移到分隔线以下、时间轴自适应上方) - Y 轴最大值/最小值改为菜单内联输入框:点击就地编辑、回车提交,菜单不关闭, 处理方式与拾色器的 RGB 内联编辑一致;提交后按真实夹取结果回写菜单显示值, 并同步刷新自适应项的文案/对勾/可用状态 - 新增"选区 Y 轴自适应":只按当前显示时间段内的数据做幅值自适应 (时间单调时二分定位区间,非单调时全扫并按区间过滤) 大文件加载: - 加载改为工作线程执行(解析 + 按列重组),主线程抽送消息并播放过渡动画 (旋转弧线 + 进度条 + 文件名 + 百分比 + 按 Esc 取消),界面不再整块卡死 - 加载完成后主线程只做一次 O(1) 的移动入队,不会产生可见卡顿 - 新增 DataModel::BuildDataFile / DataSet::AddPreparedFile:不依赖 DataSet, 因此可以安全地在线程上运行 内存与解析效率: - CsvTable 由"行优先二维数组"改为"列优先":百万行文件原先会产生上百万次独立堆分配 (每行还有 vector 控制块与容量对齐开销),现在每列一整块连续内存 - 解析改为流式分块读取(4MB),不再把整个文件读入内存,也没有体积上限 (原先超过 512MB 直接拒绝);实测 51MB 文件峰值内存约 126MB(数据本身 113MB) - BuildDataFile 直接把整列移动进 SignalData(零拷贝),不再逐元素复制 - TryParseDouble 增加十进制快速路径:ucrt strtod 实测约 335ns/次,手写路径约 13ns (快 25 倍);非十进制写法(十六进制浮点、inf/nan、带尾部杂字符)原样交回 strtod, 输入接受语义完全不变(实测与 strtod 的差异不超过 1 ULP) → 640MB 测试文件加载时间由约 40s 降到 2s 以内 交互流畅性: - 拖拽(平移/框选/拖动信号)期间关闭波形抗锯齿并压缩顶点预算(2 像素一列包络), 松开后立即恢复全质量绘制;实测 BIG_DATA 10 信号全显示的帧耗时由 105~168ms 降到 75~90ms(其中折线绘制 55ms→23ms) - 折线包络抽稀改为"列边界单调推进"的二分,避免每列在全区间二分造成缓存缺失 - 未对波形构建引入多线程:构建已由几何键缓存消除,且拖拽降级后仅约 1~2ms/帧, 并行化收益不足以抵消线程与同步的复杂度(加载路径的多线程收益明确,故只在该处引入) 图标: - 窗口类与 WM_SETICON 原先使用系统默认图标 IDI_APPLICATION,导致标题栏、 任务栏与 Alt+Tab 缩略图都显示成通用应用图标;改为按 SM_CXICON/SM_CXSMICON 从应用的 IDI_APP_ICON 资源加载(多尺寸 ICO 会挑选最接近的一帧) 其他: - PopupMenu 新增 EDIT 项类型(菜单内联输入框)与 UpdateItemValue/UpdateItemLabel
This commit is contained in:
1 parent
d6a81839bb
commit
45b44eae57
11 files changed
+1637
-222
No files matched your search
+199
-103
@@ -22,56 +22,76 @@ namespace jdi
|
||||
};
|
||||
|
||||
const double kMissingValue = std::numeric_limits<double>::quiet_NaN(); /**< 缺失值占位。 */
|
||||
const std::size_t kReadChunkBytes = 4u * 1024u * 1024u; /**< 流式读取块大小。 */
|
||||
|
||||
/**
|
||||
* @brief 一次性将整个文件读入内存。
|
||||
* @param [IN] filePath 文件路径。
|
||||
* @param [OUT] buffer 文件内容输出。
|
||||
* @param [OUT] error 失败原因描述。
|
||||
* @return 读取成功返回 true。
|
||||
* @brief 按比例预留的初始行容量。
|
||||
*
|
||||
* 先按文件体积估算一个行数,既能避免边解析边扩容(百万行时复制代价可观),
|
||||
* 又不会因为估得过大而浪费内存(超估时 vector 只是容量偏大,数据本身不变)。
|
||||
*/
|
||||
bool ReadWholeFile(const std::wstring& filePath, std::string& buffer, std::wstring& error)
|
||||
std::size_t EstimateRowCapacity(unsigned long long fileBytes)
|
||||
{
|
||||
FILE* file = _wfopen(filePath.c_str(), L"rb");
|
||||
// 典型 CSV 每行约 40-200 字节;取下界 64 字节估算行数,宁可小估也不要多占。
|
||||
const unsigned long long estimate = fileBytes / 64ull;
|
||||
if (estimate > 4096ull * 1024ull)
|
||||
{
|
||||
return static_cast<std::size_t>(4096ull * 1024ull);
|
||||
}
|
||||
return static_cast<std::size_t>(estimate);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 打开文件并查询其体积。
|
||||
* @param [IN] filePath 文件路径。
|
||||
* @param [OUT] file 文件句柄输出(需调用方关闭)。
|
||||
* @param [OUT] fileSize 文件字节数输出。
|
||||
* @param [OUT] error 失败原因描述。
|
||||
* @return 打开成功返回 true。
|
||||
*/
|
||||
bool OpenFile(const std::wstring& filePath, FILE*& file, unsigned long long& fileSize,
|
||||
std::wstring& error)
|
||||
{
|
||||
file = _wfopen(filePath.c_str(), L"rb");
|
||||
if (file == nullptr)
|
||||
{
|
||||
error = L"无法打开文件,请确认路径与访问权限。";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (std::fseek(file, 0, SEEK_END) != 0)
|
||||
// 使用 64 位定位接口,避免 long 在部分平台下只有 32 位而无法处理大文件。
|
||||
if (_fseeki64(file, 0, SEEK_END) != 0)
|
||||
{
|
||||
std::fclose(file);
|
||||
file = nullptr;
|
||||
error = L"无法定位文件末尾。";
|
||||
return false;
|
||||
}
|
||||
|
||||
const long fileSize = std::ftell(file);
|
||||
if (fileSize < 0)
|
||||
const long long size = _ftelli64(file);
|
||||
if (size < 0)
|
||||
{
|
||||
std::fclose(file);
|
||||
file = nullptr;
|
||||
error = L"无法获取文件长度。";
|
||||
return false;
|
||||
}
|
||||
if (fileSize == 0)
|
||||
if (size == 0)
|
||||
{
|
||||
std::fclose(file);
|
||||
file = nullptr;
|
||||
error = L"文件内容为空。";
|
||||
return false;
|
||||
}
|
||||
if (fileSize > (512L * 1024L * 1024L))
|
||||
if (_fseeki64(file, 0, SEEK_SET) != 0)
|
||||
{
|
||||
std::fclose(file);
|
||||
error = L"文件体积超过 512 MB,暂不支持。";
|
||||
file = nullptr;
|
||||
error = L"无法回到文件开头。";
|
||||
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);
|
||||
fileSize = static_cast<unsigned long long>(size);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -197,82 +217,54 @@ namespace jdi
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 在一行内查找首个非空行的终止位置。
|
||||
* @param [IN] begin 文本起始指针。
|
||||
* @param [IN] end 文本结束指针(不包含)。
|
||||
* @return 该行结束指针(不含换行符)。
|
||||
* @brief 解析状态:跨读取块保存进度。
|
||||
*/
|
||||
const char* FindLineEnd(const char* begin, const char* end)
|
||||
struct ParseState
|
||||
{
|
||||
const char* p = begin;
|
||||
while (p < end && *p != '\n')
|
||||
{
|
||||
++p;
|
||||
}
|
||||
const char* lineEnd = p;
|
||||
while (lineEnd > begin && (lineEnd[-1] == '\r' || lineEnd[-1] == '\n'))
|
||||
std::string pending; /**< 尚未处理完的尾部字节(不完整行)。 */
|
||||
std::vector<FieldSpan> fields; /**< 字段区间复用缓冲。 */
|
||||
std::vector<double> rowValues; /**< 行数值复用缓冲(列优先存储时的中转)。 */
|
||||
bool headerReady = false; /**< 是否已解析表头。 */
|
||||
unsigned long long bytesConsumed = 0; /**< 已处理字节数(用于进度)。 */
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 处理一块完整行文本(不含换行符)。
|
||||
* @param [IN,OUT] table 解析结果。
|
||||
* @param [IN,OUT] state 解析状态。
|
||||
* @param [IN] begin 行起始指针。
|
||||
* @param [IN] end 行结束指针(不包含)。
|
||||
* @return 解析可以继续返回 true;表头非法需要中止时返回 false。
|
||||
*/
|
||||
bool HandleLine(CsvTable& table, ParseState& state, const char* begin, const char* end)
|
||||
{
|
||||
const char* lineEnd = end;
|
||||
while (lineEnd > begin && lineEnd[-1] == '\r')
|
||||
{
|
||||
--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'))
|
||||
const char* scan = begin;
|
||||
while (scan < lineEnd && (*scan == ' ' || *scan == '\t'))
|
||||
{
|
||||
++scan;
|
||||
}
|
||||
const bool lineIsEmpty = (scan >= lineEnd);
|
||||
|
||||
if (!headerReady && lineIsEmpty)
|
||||
if (!state.headerReady)
|
||||
{
|
||||
cursor = (rawEnd < limit) ? (rawEnd + 1) : limit;
|
||||
continue;
|
||||
}
|
||||
if (lineIsEmpty)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!headerReady)
|
||||
{
|
||||
table.delimiter = DetectDelimiter(cursor, lineEnd);
|
||||
SplitFields(cursor, lineEnd, static_cast<char>(table.delimiter), fields);
|
||||
table.delimiter = DetectDelimiter(begin, lineEnd);
|
||||
SplitFields(begin, lineEnd, static_cast<char>(table.delimiter), state.fields);
|
||||
|
||||
table.headers.clear();
|
||||
table.headers.reserve(fields.size());
|
||||
for (const FieldSpan& span : fields)
|
||||
table.headers.reserve(state.fields.size());
|
||||
for (const FieldSpan& span : state.fields)
|
||||
{
|
||||
std::wstring text = FieldToText(span);
|
||||
if (text.empty())
|
||||
@@ -284,60 +276,164 @@ namespace jdi
|
||||
|
||||
if (table.headers.empty())
|
||||
{
|
||||
error = L"表头为空,无法解析列名。";
|
||||
return false;
|
||||
}
|
||||
|
||||
headerReady = true;
|
||||
cursor = (rawEnd < limit) ? (rawEnd + 1) : limit;
|
||||
continue;
|
||||
// 列优先存储:一次性为每列预留容量,避免逐行扩容(百万行时复制代价可观)。
|
||||
const std::size_t capacity = EstimateRowCapacity(table.fileBytes);
|
||||
table.columns.assign(table.headers.size(), std::vector<double>());
|
||||
for (std::vector<double>& column : table.columns)
|
||||
{
|
||||
if (capacity > 0)
|
||||
{
|
||||
column.reserve(capacity);
|
||||
}
|
||||
}
|
||||
state.rowValues.assign(table.headers.size(), kMissingValue);
|
||||
|
||||
state.headerReady = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
SplitFields(cursor, lineEnd, static_cast<char>(table.delimiter), fields);
|
||||
SplitFields(begin, lineEnd, static_cast<char>(table.delimiter), state.fields);
|
||||
|
||||
// 空行与注释行(以 # 或 % 开头)不参与数据统计。
|
||||
if (ShouldSkipLine(fields.empty() ? FieldSpan() : fields.front(), lineIsEmpty))
|
||||
if (ShouldSkipLine(state.fields.empty() ? FieldSpan() : state.fields.front(), lineIsEmpty))
|
||||
{
|
||||
cursor = (rawEnd < limit) ? (rawEnd + 1) : limit;
|
||||
continue;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (fields.size() != table.headers.size())
|
||||
if (state.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)
|
||||
for (std::size_t column = 0; column < state.rowValues.size(); ++column)
|
||||
{
|
||||
if (column >= fields.size())
|
||||
{
|
||||
row.push_back(kMissingValue);
|
||||
continue;
|
||||
}
|
||||
double value = kMissingValue;
|
||||
if (TryParseDouble(fields[column].begin, fields[column].end, value))
|
||||
if (column < state.fields.size() &&
|
||||
TryParseDouble(state.fields[column].begin, state.fields[column].end, value))
|
||||
{
|
||||
rowHasValue = true;
|
||||
}
|
||||
row.push_back(value);
|
||||
state.rowValues[column] = value;
|
||||
}
|
||||
|
||||
// 整行都没有有效数值时该行直接丢弃(与旧行为一致):
|
||||
// 列优先存储要求每列等长,因此必须整行一起跳过,不能只补某一列。
|
||||
if (!rowHasValue)
|
||||
{
|
||||
cursor = (rawEnd < limit) ? (rawEnd + 1) : limit;
|
||||
continue;
|
||||
return true;
|
||||
}
|
||||
|
||||
table.rows.push_back(std::move(row));
|
||||
for (std::size_t column = 0; column < state.rowValues.size(); ++column)
|
||||
{
|
||||
table.columns[column].push_back(state.rowValues[column]);
|
||||
}
|
||||
++table.rowCount;
|
||||
++table.sourceRowCount;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
cursor = (rawEnd < limit) ? (rawEnd + 1) : limit;
|
||||
bool LoadCsvTable(const std::wstring& filePath, CsvTable& table, std::wstring& error,
|
||||
const CsvProgress& progress)
|
||||
{
|
||||
table = CsvTable();
|
||||
error.clear();
|
||||
|
||||
FILE* file = nullptr;
|
||||
unsigned long long fileSize = 0;
|
||||
if (!OpenFile(filePath, file, fileSize, error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
table.fileBytes = fileSize;
|
||||
|
||||
ParseState state;
|
||||
std::string staging;
|
||||
staging.resize(kReadChunkBytes);
|
||||
|
||||
bool cancelled = false;
|
||||
bool fatal = false;
|
||||
|
||||
while (!cancelled && !fatal)
|
||||
{
|
||||
const size_t readCount = std::fread(&staging[0], 1, staging.size(), file);
|
||||
if (readCount == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
state.bytesConsumed += readCount;
|
||||
|
||||
state.pending.append(staging.data(), readCount);
|
||||
|
||||
// 首个块处理前剥离 UTF-8 BOM,避免首列名被污染(BOM 必然落在第一块内)。
|
||||
if (!state.headerReady && state.pending.size() >= 3 &&
|
||||
static_cast<unsigned char>(state.pending[0]) == 0xEF &&
|
||||
static_cast<unsigned char>(state.pending[1]) == 0xBB &&
|
||||
static_cast<unsigned char>(state.pending[2]) == 0xBF)
|
||||
{
|
||||
state.pending.erase(0, 3);
|
||||
}
|
||||
|
||||
// 处理 pending 中所有以换行结束的完整行。
|
||||
std::size_t lineStart = 0;
|
||||
while (true)
|
||||
{
|
||||
const std::size_t newline = state.pending.find('\n', lineStart);
|
||||
if (newline == std::string::npos)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (!HandleLine(table, state, state.pending.data() + lineStart,
|
||||
state.pending.data() + newline))
|
||||
{
|
||||
fatal = true;
|
||||
break;
|
||||
}
|
||||
lineStart = newline + 1;
|
||||
}
|
||||
if (fatal)
|
||||
{
|
||||
break;
|
||||
}
|
||||
state.pending.erase(0, lineStart);
|
||||
|
||||
if (progress)
|
||||
{
|
||||
const double ratio = (fileSize > 0)
|
||||
? static_cast<double>(state.bytesConsumed) /
|
||||
static_cast<double>(fileSize)
|
||||
: 0.0;
|
||||
if (!progress(ratio > 1.0 ? 1.0 : ratio))
|
||||
{
|
||||
cancelled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理文件末尾没有换行符的最后一行。
|
||||
if (!cancelled && !fatal && !state.pending.empty())
|
||||
{
|
||||
if (!HandleLine(table, state, state.pending.data(), state.pending.data() + state.pending.size()))
|
||||
{
|
||||
fatal = true;
|
||||
}
|
||||
}
|
||||
std::fclose(file);
|
||||
|
||||
if (cancelled)
|
||||
{
|
||||
error = L"已取消加载。";
|
||||
return false;
|
||||
}
|
||||
if (table.headers.empty())
|
||||
{
|
||||
error = fatal ? L"表头为空,无法解析列名。" : L"未从文件中解析出有效数据行。";
|
||||
return false;
|
||||
}
|
||||
if (!table.HasData())
|
||||
{
|
||||
error = L"未从文件中解析出有效数据行。";
|
||||
|
||||
Reference in new issue
Block a user