数据层大重构:文件→分组 / 时间轴一等公民 / 加载器抽象 + CSV 多时间轴 + MF4 支持

一、数据模型解耦(详见 Documents/refactor_report_2026-10-15.md 与 agent_mem §9.27)
1. DataFile → SignalGroup:删除 filePath / fileName / time / timeColumnName / timeMonotonic /
   minTime / maxTime —— 「分组」是应用内的逻辑容器,加载完成后与磁盘文件再无关系。
2. 新增 TimeAxis(name / time / minTime / maxTime / monotonic / finiteCount),一个分组可含 1..N 条;
   SignalData 新增 axisIndex 指向所属时间轴,采样值与时间的对应关系自包含。
3. SignalData 删除冗余的 rawHeader(name 的副本)与 unit(由列名猜出、全仓无人使用);
   名字只保留 name(源名称)+ displayName(用户重命名),Title() 统一展示。
4. SignalRef::fileIndex → groupIndex;DataSet:AddGroup/RemoveGroup/GroupAt/GroupCount/Groups()/
   SetGroupDisplayName/RemapSelectionAfterGroupRemoval + 新增 Axis(ref);
   TabConfig:SetGroupVisible/GroupHasVisible/GroupAllVisible/RemapAfterGroupRemoval +
   RowKind::GROUP / CMD_GROUP_*。
5. 采样与统计 API 首参由 const DataFile& 改为 const TimeAxis&:
   SampleSignalAt / SampleDisplayedSignalAt / FindNearestSample(Utilities)与
   SampleSpacingPixels / VisibleIndexRange / EdgeIndexRange / MeanFilterValueAt / WeakPeakValueAt /
   EdgeCountsFor / EdgeCenterCountsFor / EdgeScanFor(PlotView)。
6. 视图层:ResolvedSignal = { data, style, group, axis, visible };PolylineCacheEntry::file→group;
   EdgeScanState::file→axis(统计身份 = 时间轴 + 信号);
   ComputeVisibleTimeBounds 只统计可见信号真正用到的时间轴。

二、加载层抽象(格式与模型彻底解耦)
- 新增 Utilities/Inc/DataTable.hpp:DataTable(通用列优先表格)+ LoadProgress/Info +
  TableBlock(一个时间轴块)+ LoadedSource(源文件全部块)。
- 新增 Utilities/Inc|Src/DataLoader.*:唯一入口 LoadSignalSource 按扩展名分发 +
  IsSupportedSignalFile + SignalFileDialogFilter。
- 新增 Utilities/Inc|Src/CsvLoader.*:CSV 解析 + 多时间轴切分 + 各轴按自己的时间列裁剪。
- 新增 Utilities/Inc|Src/Mf4Loader.*:ASAM MDF v4 解析。
- 删除 Utilities/Inc/CsvTable.hpp 与 Utilities/Src/CsvTable.cpp。
- 唯一装配点 BuildSignalGroup(LoadedSource&&, paletteBase, group, error, progress):
  时间轴去重合并 + 信号命名 + 调色板取色 + 统计与粗粒度包络;新增格式只需写一个加载器。

三、CSV 多时间轴(修正用户报告的缺陷)
- 只有名为 t/time/timestamp/simulation time/时间/时间轴 的列是时间轴(判据收紧,
  不再用「包含 time」以免把 timeout 误判成时间轴)。
- 每个时间列开启一个新块,其后直到下一个时间列之前的列都属于该块;各块按自己的时间列裁剪
  ⇒ 支持各轴长度不同(Multi_Timeline.CSV 实测切出 3 条轴,长度 7/4/7);
  **不再把其它 time 列当成信号显示**。
- 完全没有时间列时保留旧行为(第一列当时间轴,单轴)。

四、MF4 支持(新功能)
- 加载后新建一个分组、组名 = 文件名,文件内全部信号归入该分组。
- 每个通道组(CG)是一条时间轴,内容相同的时间轴自动合并(实测 27 个 CG → 3 条轴 / 124 条信号)。
- 实现 MDF v4 块解析(HD/DG/CG/CN/CC/DT)、1–64 位整数(任意位偏移、含符号扩展)与
  32/64 位 IEEE 浮点解码、无效位(inval bit)处理、线性换算 phys = cc_val[0] + cc_val[1]*raw。
- TX 文本块同时兼容规范写法(U32 长度 + UTF-16/UTF-8)与 CANape 的「无长度前缀 NUL 结尾裸文本」。
- 未实现(跳过该组):压缩块 ##DZ/##HL/##SD、##DL 数据列表、字符串/MIME 通道。

五、顺带修复
- Application::Initialize:窗口类已注册(ERROR_CLASS_ALREADY_EXISTS)时复用而不是弹模态错误框
  (旧实现让同一进程第二次构造 Application 永久阻塞);Shutdown 注销窗口类并销毁窗口。
- UI 文案统一到「分组」:选中该分组全部信号 / 取消该分组全部信号 / 关闭该分组(从工作区移除)/
  请关闭部分分组或信号后重试。/ 尚未加载数据,请打开数据文件 / 打开数据文件以查看波形 /
  (可同时加载多个文件,各自成为一个分组);对话框过滤器增加 *.mf4/*.mdf。

六、验证(全部离屏:BELOW_NORMAL 优先级 + SW_HIDE 窗口 + 只注入合成消息)
- 新增 .preflight/perf/datacheck_main.cpp:69/69 通过(单轴 CSV 回归、多时间轴 CSV、MF4 组名/信号数/
  时间轴/主时间轴 ns→s/setSpeed 首样本与原始字节人工核对、DataSet 不变量、派生信号沿用源轴、
  MF4 集成 124 信号并一格 60~68ms/帧)。
- 新增 .preflight/perf/edgecheck_main.cpp(ASCII-only,替代被编码破坏的 panelcheck):46/46 通过
  (「调整」符号规则走真实菜单路径、边沿统计按帧推进/不饿死/逐位一致/无鼠标输入也完成、
  面板两列等宽与信号名限宽、弱峰复位控件)。
- 既有 .preflight/perf/verify_main.cpp(已适配新 API):47/47,另有 2 条本机未同步的旧规则断言
  (derived offsetTime kept / mouse in lane1 lower half),与本轮无关。
- make clean && make all 零警告。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-15 03:48:58 +08:00
1 parent fba40ee1a2
commit 6eedbe3c5d
18 files changed
+2545 -817

No files matched your search

+6 -6
View File
@@ -183,9 +183,9 @@ namespace jdi
/**
* @brief 从工作区移除指定文件并修正各标签页配置。
* @param [IN] fileIndex 文件下标。
* @param [IN] groupIndex 文件下标。
*/
void RemoveFile(int fileIndex);
void RemoveGroup(int groupIndex);
/**
* @brief 从工作区移除某分组中的一条信号,并立即释放其采样内存。
@@ -193,10 +193,10 @@ namespace jdi
* 用户点击信号行左侧的 ✕ 时调用:该信号一旦被确认不再需要,就应从数据层删除
* 并释放内存(而非只是隐藏)。若它是所属分组中最后一条信号,则连同分组一起移除。
*
* @param [IN] fileIndex 信号所属文件下标。
* @param [IN] groupIndex 信号所属文件下标。
* @param [IN] signalIndex 文件内信号下标。
*/
void RemoveSignal(int fileIndex, int signalIndex);
void RemoveSignal(int groupIndex, int signalIndex);
/**
* @brief 处理标签栏交互结果。
@@ -259,7 +259,7 @@ namespace jdi
*/
struct DerivedSignalRequest
{
int fileIndex = -1; /**< 原信号所属文件下标。 */
int groupIndex = -1; /**< 原信号所属文件下标。 */
int signalIndex = -1; /**< 原信号下标。 */
int points = 5; /**< 多次迭代均值滤波:均值点数 X(滑平半径)。 */
int iterations = 1; /**< 多次迭代均值滤波:迭代次数 N(至少 1)。 */
@@ -330,7 +330,7 @@ namespace jdi
* @param [OUT] error 失败原因描述。
* @return 加载成功返回 true;用户取消或失败返回 false。
*/
bool LoadFileAsync(const std::wstring& path, DataFile& file, std::wstring& error);
bool LoadFileAsync(const std::wstring& path, SignalGroup& file, std::wstring& error);
/**
* @brief 绘制加载过渡动画遮罩(在 Render 的最后一步调用,覆盖整个客户区)。
+93 -65
View File
@@ -10,7 +10,7 @@
#include <shellapi.h>
#include <windowsx.h>
#include "CsvTable.hpp"
#include "DataLoader.hpp"
#include "ResourceIds.h"
#include "Utf8Util.hpp"
@@ -43,7 +43,7 @@ namespace jdi
const float kMaxPanelWidth = 400.0f; /**< 面板最大宽度(逻辑像素)。 */
const int kMinClientWidth = 860; /**< 窗口最小客户区宽度。 */
const int kMinClientHeight = 540; /**< 窗口最小客户区高度。 */
const UINT kMessageRemoveFile = WM_APP + 1; /**< 面板请求移除文件的自定义消息。 */
const UINT kMessageRemoveGroup = WM_APP + 1; /**< 面板请求移除文件的自定义消息。 */
const UINT kMessageRemoveSignal = WM_APP + 2; /**< 面板请求移除单个信号的自定义消息。 */
const UINT_PTR kHintTimerId = 1; /**< 提示浮层淡出用的定时器 ID。 */
const UINT kHintTimerPeriodMs = 33; /**< 提示浮层淡出的重绘间隔(约 30fps)。 */
@@ -177,11 +177,11 @@ namespace jdi
std::atomic<unsigned long long> totalBytes{0}; /**< 文件总字节数(0 表示未知)。 */
bool ok = false; /**< 加载是否成功。 */
std::wstring error; /**< 失败原因。 */
DataFile file; /**< 构建结果。 */
SignalGroup group; /**< 构建结果。 */
};
/**
* @brief 工作线程主体:解析 CSV 并构建数据文件(不接触 DataSet)。
* @brief 工作线程主体:按扩展名选择加载器读入文件并构建信号分组(不接触 DataSet)。
* @param [IN,OUT] state 共享状态。
* @param [IN] path 文件路径。
* @param [IN] paletteBase 调色板起始序号。
@@ -189,7 +189,7 @@ namespace jdi
void RunAsyncLoad(AsyncLoadState* state, std::wstring path, std::size_t paletteBase)
{
// 解析阶段占总进度的 0-75%,列重组与统计占 75-100%。
CsvProgress parseProgress = [state](const CsvProgressInfo& info)
LoadProgress parseProgress = [state](const LoadProgressInfo& info)
{
state->progress.store(static_cast<float>(info.ratio * 0.75), std::memory_order_relaxed);
if (info.hasBytes)
@@ -200,31 +200,31 @@ namespace jdi
return !state->cancelled.load(std::memory_order_relaxed);
};
CsvTable table;
LoadedSource source;
std::wstring error;
if (!LoadCsvTable(path, table, error, parseProgress))
if (!LoadSignalSource(path, source, error, parseProgress))
{
state->error = error;
state->done.store(true, std::memory_order_release);
return;
}
CsvProgress buildProgress = [state](const CsvProgressInfo& info)
LoadProgress buildProgress = [state](const LoadProgressInfo& info)
{
state->progress.store(static_cast<float>(0.75 + info.ratio * 0.25),
std::memory_order_relaxed);
return !state->cancelled.load(std::memory_order_relaxed);
};
DataFile file;
if (!BuildDataFile(std::move(table), path, paletteBase, file, error, buildProgress))
SignalGroup group;
if (!BuildSignalGroup(std::move(source), paletteBase, group, error, buildProgress))
{
state->error = error;
state->done.store(true, std::memory_order_release);
return;
}
state->file = std::move(file);
state->group = std::move(group);
state->ok = true;
state->progress.store(1.0f, std::memory_order_relaxed);
state->done.store(true, std::memory_order_release);
@@ -357,7 +357,10 @@ namespace jdi
windowClass.hIcon = LoadAppIcon(GetSystemMetrics(SM_CXICON), GetSystemMetrics(SM_CYICON));
windowClass.hIconSm = LoadAppIcon(GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON));
if (RegisterClassExW(&windowClass) == 0)
// ⚠️ 同一个进程里再次创建 `Application`(离屏测试、单进程多次启动)时窗口类已经注册,
// 这时**不是错误**:直接复用已注册的类即可。旧实现把它当致命错误弹模态框,
// 会让调用方永远阻塞在对话框上。
if (RegisterClassExW(&windowClass) == 0 && GetLastError() != ERROR_CLASS_ALREADY_EXISTS)
{
MessageBoxW(nullptr, L"窗口类注册失败。", kWindowTitle, MB_ICONERROR | MB_OK);
return false;
@@ -439,6 +442,15 @@ namespace jdi
gd::GdiplusShutdown(m_gdiplusToken);
m_gdiplusReady = false;
}
// 注销窗口类:否则同一进程里再次创建 `Application` 时会命中
// `ERROR_CLASS_ALREADY_EXISTS`(离屏测试需要多次构造/销毁)。
if (m_hwnd != nullptr)
{
DestroyWindow(m_hwnd);
m_hwnd = nullptr;
}
UnregisterClassW(kWindowClassName, m_instance);
}
void Application::ApplyDpi(UINT dpi)
@@ -655,48 +667,48 @@ namespace jdi
}
}
void Application::RemoveFile(int fileIndex)
void Application::RemoveGroup(int groupIndex)
{
const DataFile* file = m_dataSet.FileAt(fileIndex);
if (file == nullptr)
const SignalGroup* group = m_dataSet.GroupAt(groupIndex);
if (group == nullptr)
{
return;
}
const std::wstring name = file->fileName;
const std::wstring name = group->name;
// 移除文件会释放大量采样内存:先暂停后台派生曲线计算,避免其读取已释放内存。
m_plot.PauseDerivedAsync();
// 先按旧下标修正各标签页配置与共享选中项,再从数据层移除,最后统一对齐。
for (TabConfig& tab : m_tabs)
{
tab.RemapAfterFileRemoval(fileIndex);
tab.RemapAfterGroupRemoval(groupIndex);
}
m_dataSet.RemapSelectionAfterFileRemoval(fileIndex);
m_dataSet.RemoveFile(fileIndex);
m_dataSet.RemapSelectionAfterGroupRemoval(groupIndex);
m_dataSet.RemoveGroup(groupIndex);
SyncTabsToDataSet();
SetStatusMessage(L"已从工作区移除:" + name, false);
UpdateWindowTitle();
}
void Application::RemoveSignal(int fileIndex, int signalIndex)
void Application::RemoveSignal(int groupIndex, int signalIndex)
{
SignalRef target;
target.fileIndex = fileIndex;
target.groupIndex = groupIndex;
target.signalIndex = signalIndex;
const SignalData* signal = m_dataSet.Resolve(target);
const DataFile* file = m_dataSet.FileAt(fileIndex);
if (signal == nullptr || file == nullptr)
const SignalGroup* group = m_dataSet.GroupAt(groupIndex);
if (signal == nullptr || group == nullptr)
{
return;
}
const std::wstring name = signal->Title();
if (file->signals.size() <= 1)
if (group->signals.size() <= 1)
{
// 组内只剩这一条信号:直接移除整个分组,避免在列表里留下一个空分组。
RemoveFile(fileIndex);
RemoveGroup(groupIndex);
return;
}
@@ -705,10 +717,10 @@ namespace jdi
// 先按旧下标修正各标签页配置与共享选中项,再从数据层移除(释放采样内存),最后对齐。
for (TabConfig& tab : m_tabs)
{
tab.RemapAfterSignalRemoval(fileIndex, signalIndex);
tab.RemapAfterSignalRemoval(groupIndex, signalIndex);
}
m_dataSet.RemapSelectionAfterSignalRemoval(fileIndex, signalIndex);
m_dataSet.RemoveSignal(fileIndex, signalIndex);
m_dataSet.RemapSelectionAfterSignalRemoval(groupIndex, signalIndex);
m_dataSet.RemoveSignal(groupIndex, signalIndex);
SyncTabsToDataSet();
SetStatusMessage(L"已从工作区移除信号:" + name, false);
@@ -967,10 +979,10 @@ namespace jdi
OPENFILENAMEW dialog = {};
dialog.lStructSize = sizeof(dialog);
dialog.hwndOwner = m_hwnd;
dialog.lpstrFilter = L"CSV 数据文件 (*.csv)\0*.csv\0文本数据文件 (*.txt)\0*.txt\0所有文件 (*.*)\0*.*\0\0";
dialog.lpstrFilter = SignalFileDialogFilter();
dialog.lpstrFile = buffer.data();
dialog.nMaxFile = static_cast<DWORD>(buffer.size());
dialog.lpstrTitle = L"打开仿真数据文件(可多选)";
dialog.lpstrTitle = L"打开测量数据文件(可多选)";
dialog.lpstrDefExt = L"csv";
dialog.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_EXPLORER | OFN_NOCHANGEDIR |
OFN_ALLOWMULTISELECT;
@@ -1021,9 +1033,9 @@ namespace jdi
}
// 大文件在工作线程解析并构建,主线程播放加载过渡动画,界面不再整块卡住。
DataFile file;
SignalGroup group;
std::wstring error;
if (!LoadFileAsync(path, file, error))
if (!LoadFileAsync(path, group, error))
{
if (!error.empty())
{
@@ -1033,30 +1045,45 @@ namespace jdi
return false;
}
int fileIndex = -1;
// 插入新文件会移动 DataFile/SignalData 的存储位置,必须先暂停后台派生曲线计算,
int groupIndex = -1;
// 插入新分组会移动 SignalGroup/SignalData 的存储位置,必须先暂停后台派生曲线计算,
// 否则后台线程可能正在读取即将被移动的采样数据。
m_plot.PauseDerivedAsync();
// 此处只做一次指针/缓冲移动,不拷贝任何采样数据,因此不会造成可见卡顿。
if (!m_dataSet.AddDataFile(std::move(file), fileIndex))
if (!m_dataSet.AddGroup(std::move(group), groupIndex))
{
SetStatusMessage(L"解析失败:数据为空。", true);
InvalidateRect(m_hwnd, nullptr, FALSE);
return false;
}
// 新文件的信号在全部标签页中默认均为未勾选状态,由用户自行决定查看内容。
// 新分组的信号在全部标签页中默认均为未勾选状态,由用户自行决定查看内容。
SyncTabsToDataSet();
UpdateWindowTitle();
const DataFile* loaded = m_dataSet.FileAt(fileIndex);
const SignalGroup* loaded = m_dataSet.GroupAt(groupIndex);
if (loaded != nullptr)
{
std::wstring message = L"已加载 组" + std::to_wstring(fileIndex + 1) + L"|" + loaded->Title() +
L"(" + FormatCount(static_cast<unsigned long long>(loaded->time.size())) +
// 行数取各时间轴的**最大值**(多时间轴文件的各轴长度可能不同)。
std::size_t rowCount = 0;
bool anyNonMonotonic = false;
std::wstring timeNames;
for (const TimeAxis& axis : loaded->axes)
{
rowCount = std::max(rowCount, axis.Count());
anyNonMonotonic = anyNonMonotonic || !axis.monotonic;
if (!timeNames.empty())
{
timeNames += L"、";
}
timeNames += axis.name;
}
std::wstring message = L"已加载 组" + std::to_wstring(groupIndex + 1) + L"|" + loaded->Title() +
L"(" + FormatCount(static_cast<unsigned long long>(rowCount)) +
L" 行采样," + std::to_wstring(loaded->signals.size()) +
L" 条信号,时间列:" + loaded->timeColumnName + L")";
if (!loaded->timeMonotonic)
L" 条信号," + std::to_wstring(loaded->axes.size()) + L" 条时间轴:" +
timeNames + L")";
if (anyNonMonotonic)
{
message += L" 提示:时间列非单调递增,光标取值使用最近邻策略。";
}
@@ -1065,7 +1092,7 @@ namespace jdi
return true;
}
bool Application::LoadFileAsync(const std::wstring& path, DataFile& file, std::wstring& error)
bool Application::LoadFileAsync(const std::wstring& path, SignalGroup& group, std::wstring& error)
{
error.clear();
@@ -1138,7 +1165,7 @@ namespace jdi
return false;
}
file = std::move(state.file);
group = std::move(state.group);
return true;
}
@@ -1549,7 +1576,7 @@ namespace jdi
return false;
}
if (!m_dataSet.SetSignalDisplayName(ref.fileIndex, ref.signalIndex, name))
if (!m_dataSet.SetSignalDisplayName(ref.groupIndex, ref.signalIndex, name))
{
return false;
}
@@ -1563,13 +1590,14 @@ namespace jdi
bool Application::BuildDerivedSignal(const DerivedSignalRequest& request, bool makeVisible)
{
const DataFile* file = m_dataSet.FileAt(request.fileIndex);
if (file == nullptr || request.signalIndex < 0 ||
request.signalIndex >= static_cast<int>(file->signals.size()))
const SignalGroup* group = m_dataSet.GroupAt(request.groupIndex);
if (group == nullptr || request.signalIndex < 0 ||
request.signalIndex >= static_cast<int>(group->signals.size()))
{
return false;
}
const SignalData& source = file->signals[static_cast<std::size_t>(request.signalIndex)];
const SignalData& source = group->signals[static_cast<std::size_t>(request.signalIndex)];
const TimeAxis& axis = group->AxisOf(source);
const std::wstring baseName = source.Title();
// 0. 先把源信号的「调整」变换快照下来(下面 `InsertDerivedSignal` 会移动信号存储,
@@ -1590,7 +1618,7 @@ namespace jdi
bool hasSourceStyle = false;
{
SignalRef sourceRef;
sourceRef.fileIndex = request.fileIndex;
sourceRef.groupIndex = request.groupIndex;
sourceRef.signalIndex = request.signalIndex;
if (const SignalStyle* sourceStyle = m_dataSet.Style(sourceRef))
{
@@ -1619,8 +1647,8 @@ namespace jdi
}
// 2. 组装新信号(外观一律用默认值:变换已经烘进采样数据,不再是"继承的指纹")。
derived.rawHeader = derived.name;
derived.unit = source.unit;
// ⚠️ 新信号**沿用源信号的时间轴**(`axisIndex`):派生只改采样值,不改时间基准。
derived.axisIndex = source.axisIndex;
derived.values.assign(derived_values.begin(), derived_values.end());
// 2b. 把源信号的「调整」变换**烘进派生采样值**:
@@ -1660,9 +1688,9 @@ namespace jdi
// ⚠️ 时间轴非单调时无法按下标单调推进取样(只能整表扫描),此时退回"继承
// `offsetTime`"(见 3b):两条路径的**显示效果相同**,只是偏移留在样式里。
bool timeShiftBaked = false;
if (hasSourceStyle && sourceTransform.offsetTime != 0.0 && file->timeMonotonic)
if (hasSourceStyle && sourceTransform.offsetTime != 0.0 && axis.monotonic)
{
const std::vector<double>& times = file->time;
const std::vector<double>& times = axis.time;
const std::size_t total = derived.values.size();
if (total > 1 && times.size() >= total)
{
@@ -1698,14 +1726,14 @@ namespace jdi
int newIndex = -1;
// 插入派生信号会移动该文件的信号存储:先暂停后台计算,避免其读取已移动的数据。
m_plot.PauseDerivedAsync();
if (!m_dataSet.InsertDerivedSignal(request.fileIndex, request.signalIndex, std::move(derived),
if (!m_dataSet.InsertDerivedSignal(request.groupIndex, request.signalIndex, std::move(derived),
newIndex))
{
return false;
}
for (TabConfig& tab : m_tabs)
{
tab.RemapAfterSignalInsert(request.fileIndex, newIndex);
tab.RemapAfterSignalInsert(request.groupIndex, newIndex);
}
// 3b. 时间轴偏移正常情况下已烘进采样值(见 2c),新信号的「调整」全部保持默认。
@@ -1713,7 +1741,7 @@ namespace jdi
// `offsetTime` 带在新信号样式上 —— 无论如何都要让**显示**与预览一致。
if (hasSourceStyle && sourceTransform.offsetTime != 0.0 && !timeShiftBaked)
{
if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{request.fileIndex, newIndex}))
if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{request.groupIndex, newIndex}))
{
derivedStyle->offsetTime = sourceTransform.offsetTime;
}
@@ -1723,12 +1751,12 @@ namespace jdi
if (makeVisible && !m_tabs.empty())
{
SignalRef derivedRef;
derivedRef.fileIndex = request.fileIndex;
derivedRef.groupIndex = request.groupIndex;
derivedRef.signalIndex = newIndex;
ActiveTab().SetVisible(derivedRef, true);
SignalRef sourceRef;
sourceRef.fileIndex = request.fileIndex;
sourceRef.groupIndex = request.groupIndex;
sourceRef.signalIndex = request.signalIndex;
ActiveTab().MoveLaneOfSignalBelow(derivedRef, sourceRef);
}
@@ -1751,7 +1779,7 @@ namespace jdi
for (const PlotView::MeanFilterBuild& item : items)
{
DerivedSignalRequest request;
request.fileIndex = item.fileIndex;
request.groupIndex = item.groupIndex;
request.signalIndex = item.signalIndex;
request.points = item.points;
request.iterations = item.iterations;
@@ -1768,7 +1796,7 @@ namespace jdi
for (const PlotView::WeakPeakBuild& item : items)
{
DerivedSignalRequest request;
request.fileIndex = item.fileIndex;
request.groupIndex = item.groupIndex;
request.signalIndex = item.signalIndex;
request.amountPercent = item.amountPercent;
request.iterations = item.iterations;
@@ -1791,9 +1819,9 @@ namespace jdi
std::sort(requests.begin(), requests.end(),
[](const DerivedSignalRequest& a, const DerivedSignalRequest& b)
{
if (a.fileIndex != b.fileIndex)
if (a.groupIndex != b.groupIndex)
{
return a.fileIndex < b.fileIndex;
return a.groupIndex < b.groupIndex;
}
return a.signalIndex > b.signalIndex;
});
@@ -2364,7 +2392,7 @@ namespace jdi
// 可用内存**严重不足**(连一条预览曲线本身都装不下):绘图区已放弃本次派生计算,
// 这里给出一次明确的告警。界面保持可用,绝不会因为内存不足而崩溃。
SetStatusMessage(L"可用内存不足,已暂停派生预览曲线与相关统计的计算。\n"
L"请关闭部分文件分组或信号后重试。",
L"请关闭部分分组或信号后重试。",
true);
InvalidatePlotRect(hwnd, m_plotRect);
return 0;
@@ -2945,9 +2973,9 @@ namespace jdi
m_menu.Close();
InvalidateRect(hwnd, nullptr, FALSE);
return 0;
case kMessageRemoveFile:
case kMessageRemoveGroup:
{
RemoveFile(static_cast<int>(wParam));
RemoveGroup(static_cast<int>(wParam));
return 0;
}
case kMessageRemoveSignal:
+20 -19
View File
@@ -322,7 +322,7 @@ namespace jdi
* @param [OUT] pixels 间隔换算到屏幕上的像素宽度。
* @return 计算成功返回 true。
*/
bool SampleSpacingPixels(const DataFile& file, double time, double& pixels) const;
bool SampleSpacingPixels(const TimeAxis& axis, double time, double& pixels) const;
/**
* @brief 判断坐标是否落在绘图区范围内。
@@ -408,7 +408,7 @@ namespace jdi
* @brief 暂停后台派生曲线计算并作废其结果。
*
* 后台线程会读取采样数据,因此**任何会改变数据层结构(增删文件/信号、插入派生信号,
* 进而移动 `DataFile`/`SignalData` 存储)的操作之前**都必须先调用本函数:它会等待正在
* 进而移动 `SignalGroup`/`SignalData` 存储)的操作之前**都必须先调用本函数:它会等待正在
* 进行的计算结束、清空待算队列与缓存,从而杜绝后台线程读取已释放内存。
* 之后的绘制会按需重新启动后台线程(惰性)。
*/
@@ -587,7 +587,7 @@ namespace jdi
*/
struct MeanFilterBuild
{
int fileIndex = -1; /**< 信号所属文件下标。 */
int groupIndex = -1; /**< 信号所属分组下标。 */
int signalIndex = -1; /**< 文件内信号下标。 */
int points = 5; /**< 均值点数 X(滑平半径)。 */
int iterations = 1; /**< 迭代次数 N(至少 1)。 */
@@ -611,7 +611,7 @@ namespace jdi
*/
struct WeakPeakBuild
{
int fileIndex = -1; /**< 信号所属文件下标。 */
int groupIndex = -1; /**< 信号所属分组下标。 */
int signalIndex = -1; /**< 文件内信号下标。 */
int amountPercent = 0; /**< 弱峰调整幅度(-400–400,默认 0 = 不改动)。 */
int iterations = 1; /**< 迭代次数 N(1–10)。 */
@@ -659,7 +659,7 @@ namespace jdi
/**
* @brief 取某个时刻的弱峰调整曲线取值(用于纵向光标的取值标注)。
* @param [IN] file 信号所属文件。
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @param [IN] amountPercent 弱峰调整幅度(-400–400)。
* @param [IN] iterations 迭代次数 N(1–10)。
@@ -667,7 +667,7 @@ namespace jdi
* @param [OUT] value 输出取值。
* @return 取到有效值返回 true。
*/
bool WeakPeakValueAt(const DataFile& file, const SignalData& signal, int amountPercent, int iterations,
bool WeakPeakValueAt(const TimeAxis& axis, const SignalData& signal, int amountPercent, int iterations,
double time, double& value) const;
/**
@@ -783,7 +783,8 @@ namespace jdi
{
const SignalData* data = nullptr; /**< 信号数据(含全局共享的显示外观)。 */
const SignalStyle* style = nullptr; /**< 显示外观。 */
const DataFile* file = nullptr; /**< 所属文件。 */
const SignalGroup* group = nullptr; /**< 信号所属分组(仅用于分组级信息)。 */
const TimeAxis* axis = nullptr; /**< 该信号使用的时间轴(采样值 ↔ 时刻的唯一依据)。 */
bool visible = false; /**< 在当前标签页中是否可见。 */
};
@@ -1294,7 +1295,7 @@ namespace jdi
/**
* @brief 取某个时刻的多次迭代均值滤波曲线取值(用于纵向光标的取值标注)。
* @param [IN] file 信号所属文件。
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @param [IN] points 均值点数 X。
* @param [IN] iterations 迭代次数 N。
@@ -1302,7 +1303,7 @@ namespace jdi
* @param [OUT] value 输出取值。
* @return 取到有效值返回 true。
*/
bool MeanFilterValueAt(const DataFile& file, const SignalData& signal, int points, int iterations,
bool MeanFilterValueAt(const TimeAxis& axis, const SignalData& signal, int points, int iterations,
double time, double& value) const;
/**
@@ -1329,7 +1330,7 @@ namespace jdi
* ⚠️ 判定对象是**画出来的两条曲线**:信号与中心曲线都要先按 `style` 做水平镜像映射、
* 再套用 `ApplyDrawTransform`(系数 → 绝对值 → 垂直镜像 → Y 偏移)。
*
* @param [IN] file 信号所属文件。
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @param [IN] style 该信号的外观(含水平镜像与「调整」变换)。
* @param [IN] points 均值点数 X。
@@ -1341,7 +1342,7 @@ namespace jdi
* @param [OUT] falling 下降沿数量(信号从上往下穿过均值滤波曲线)。
* @param [OUT] complete 是否为完整精确值(false = 仍在推进,界面显示“…”)。
*/
void EdgeCenterCountsFor(const DataFile& file, const SignalData& signal, const SignalStyle& style,
void EdgeCenterCountsFor(const TimeAxis& axis, const SignalData& signal, const SignalStyle& style,
int points, int iterations, bool hasRange, double rangeMin,
double rangeMax, std::size_t& rising, std::size_t& falling,
bool& complete) const;
@@ -1583,7 +1584,7 @@ namespace jdi
* @param [IN] timeShift 信号的时间偏移:采样画在 `t + timeShift` 处,因此可见采样对应的
* 源时刻区间为 `[ViewMin - timeShift, ViewMax - timeShift]`(偏移 0 时同视界)。
*/
void VisibleIndexRange(const DataFile& file, std::size_t count, std::size_t& begin,
void VisibleIndexRange(const TimeAxis& axis, std::size_t count, std::size_t& begin,
std::size_t& end, double timeShift = 0.0) const;
/**
@@ -1677,7 +1678,7 @@ namespace jdi
* 阈值变化(横向光标拖动中)就地把累积状态重置(不新建条目),且当帧不展示部分值
*(`justReset`),避免“错误且不更新的数字”。
*
* @param [IN] file 信号所属文件。
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @param [IN] style 该信号的外观(含水平镜像与「调整」变换)。
* @param [IN] edgeValue 横向光标所在的幅值。
@@ -1688,7 +1689,7 @@ namespace jdi
* @param [OUT] falling 下降沿数量(从上往下穿越)。
* @param [OUT] complete 结果是否为该阈值的**完整**精确值(false = 仍在推进,当前是临时值)。
*/
void EdgeCountsFor(const DataFile& file, const SignalData& signal, const SignalStyle& style,
void EdgeCountsFor(const TimeAxis& axis, const SignalData& signal, const SignalStyle& style,
double edgeValue, bool hasRange, double rangeMin, double rangeMax,
std::size_t& rising, std::size_t& falling, bool& complete) const;
@@ -1905,12 +1906,12 @@ namespace jdi
* * 未扫完时**主动请宿主再送一帧**(`RequestEdgeScanFrame`),因此**不需要移动鼠标**
* 也会持续推进(修掉"面板一直显示统计中,把鼠标移到面板上才更新")。
*
* 键 = 「文件 + 信号 + 种类 + 样式指纹 + 参数 +(仅非单调时间轴)区间」;
* 键 = 「时间轴 + 信号 + 种类 + 样式指纹 + 参数 +(仅非单调时间轴)区间」;
* 阈值/参数变化时就地重新开始(不新建条目,避免拖动时状态爆炸)。
*/
struct EdgeScanState
{
const DataFile* file = nullptr; /**< 所属数据分组。 */
const TimeAxis* axis = nullptr; /**< 该信号使用的时间轴(统计身份 = 时间轴 + 信号)。 */
const SignalData* signal = nullptr; /**< 目标信号。 */
bool centre = false; /**< false = 穿越横向光标阈值;true = 穿越中心平均值曲线。 */
std::uint64_t styleKey = 0; /**< 「调整」变换 + 水平镜像的指纹。 */
@@ -1972,7 +1973,7 @@ namespace jdi
* 中心轴时两侧(信号与中心曲线)都套用同一套变换。
* 时间区间**之外**的采样一律不参与计数(只有两个端点都在区间内的采样对才计入)。
*
* @param [IN] file 信号所属文件。 @param [IN] signal 目标信号。
* @param [IN] axis 该信号使用的时间轴。 @param [IN] signal 目标信号。
* @param [IN] style 信号外观。 @param [IN] centre 是否为中心轴判定。
* @param [IN] threshold 阈值(`centre = false` 时使用)。
* @param [IN] points 均值点数 X(`centre = true` 时使用)。
@@ -1984,7 +1985,7 @@ namespace jdi
* @param [OUT] rising 上升沿数量。 @param [OUT] falling 下降沿数量。
* @param [OUT] complete 是否为完整精确值(false = 仍在推进)。
*/
void EdgeScanFor(const DataFile& file, const SignalData& signal, const SignalStyle& style,
void EdgeScanFor(const TimeAxis& axis, const SignalData& signal, const SignalStyle& style,
bool centre, double threshold, int points, int iterations, bool hasRange,
double rangeMin, double rangeMax, std::size_t& rising, std::size_t& falling,
bool& complete) const;
@@ -2102,7 +2103,7 @@ namespace jdi
struct PolylineCacheEntry
{
SignalRef ref; /**< 信号引用。 */
const DataFile* file = nullptr; /**< 所属文件(内容不可变)。 */
const SignalGroup* group = nullptr; /**< 所属分组(缓存键的一部分)。 */
std::size_t sampleCount = 0; /**< 采样点数(数据变化时改变)。 */
double viewMin = 0.0; /**< 时间轴显示下界。 */
double viewMax = 0.0; /**< 时间轴显示上界。 */
+125 -119
View File
@@ -1,4 +1,4 @@
#include "PlotView.hpp"
#include "PlotView.hpp"
#include <algorithm>
#include <cmath>
@@ -504,7 +504,7 @@ namespace jdi
* @param [OUT] end 输出区间终点(不含)。
* @return 计数是否完全由该下标区间决定(时间轴单调时为真)。
*/
bool EdgeIndexRange(const DataFile& file, std::size_t count, bool hasRange, double rangeMin,
bool EdgeIndexRange(const TimeAxis& axis, std::size_t count, bool hasRange, double rangeMin,
double rangeMax, std::size_t& begin, std::size_t& end)
{
begin = 0;
@@ -513,13 +513,13 @@ namespace jdi
{
return true;
}
if (!file.timeMonotonic)
if (!axis.monotonic)
{
// 时间轴非单调时无法用下标区间表达统计范围(需逐点按时间过滤)。
return false;
}
const std::vector<double>& times = file.time;
const std::vector<double>& times = axis.time;
const std::size_t timeCount = std::min(times.size(), count);
begin = static_cast<std::size_t>(
std::lower_bound(times.begin(), times.begin() + static_cast<std::ptrdiff_t>(timeCount),
@@ -920,7 +920,8 @@ namespace jdi
return result;
}
result.data = m_dataSet->Resolve(ref);
result.file = m_dataSet->FileAt(ref.fileIndex);
result.group = m_dataSet->GroupAt(ref.groupIndex);
result.axis = m_dataSet->Axis(ref);
result.style = m_dataSet->Style(ref);
result.visible = (m_tab != nullptr) && m_tab->IsVisible(ref);
return result;
@@ -1011,9 +1012,9 @@ namespace jdi
}
if (firstFile < 0)
{
firstFile = ref.fileIndex;
firstFile = ref.groupIndex;
}
else if (ref.fileIndex != firstFile)
else if (ref.groupIndex != firstFile)
{
return true;
}
@@ -1582,13 +1583,13 @@ namespace jdi
double high = -std::numeric_limits<double>::infinity();
for (const SignalRef& ref : lanes[static_cast<std::size_t>(laneIndex)].signals)
{
const DataFile* file = m_dataSet->FileAt(ref.fileIndex);
if (file == nullptr)
const TimeAxis* axis = m_dataSet->Axis(ref);
if (axis == nullptr || axis->finiteCount == 0)
{
continue;
}
low = std::min(low, file->minTime);
high = std::max(high, file->maxTime);
low = std::min(low, axis->minTime);
high = std::max(high, axis->maxTime);
}
if (!std::isfinite(low) || !std::isfinite(high) || !(high > low))
@@ -1644,10 +1645,10 @@ namespace jdi
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].signals.size();
}
bool PlotView::SampleSpacingPixels(const DataFile& file, double time, double& pixels) const
bool PlotView::SampleSpacingPixels(const TimeAxis& axis, double time, double& pixels) const
{ pixels = 0.0;
const std::vector<double>& samples = file.time;
const std::vector<double>& samples = axis.time;
if (samples.size() < 2 || m_plotRect.Width <= 1.0f)
{
return false;
@@ -1659,7 +1660,7 @@ namespace jdi
return false;
}
const int index = FindNearestSample(file, time);
const int index = FindNearestSample(axis, time);
if (index < 0)
{
return false;
@@ -1703,37 +1704,42 @@ namespace jdi
double bestDistance = std::numeric_limits<double>::infinity();
double bestTime = 0.0;
for (const DataFile& file : m_dataSet->Files())
for (const SignalGroup& group : m_dataSet->Groups())
{
// 只有采样点在屏幕上足够稀疏时才吸附,否则缩小视图后光标会“跳格”。
double pixels = 0.0;
if (!SampleSpacingPixels(file, time, pixels) || pixels < minPixels || pixels > maxPixels)
// 一个分组可能有多条时间轴(多 `time` 列 CSV / MF4 多通道组),逐条尝试。
for (const TimeAxis& groupAxis : group.axes)
{
continue;
}
double pixels = 0.0;
if (!SampleSpacingPixels(groupAxis, time, pixels) || pixels < minPixels ||
pixels > maxPixels)
{
continue;
}
const int index = FindNearestSample(file, time);
if (index < 0)
{
continue;
}
const double candidate = file.time[static_cast<std::size_t>(index)];
if (!std::isfinite(candidate))
{
continue;
}
// 只吸附**当前可见区间内**的采样点:视界之外的采样点不是合法的落点。
if (candidate < viewMin || candidate > viewMax)
{
continue;
}
const int index = FindNearestSample(groupAxis, time);
if (index < 0)
{
continue;
}
const double candidate = groupAxis.time[static_cast<std::size_t>(index)];
if (!std::isfinite(candidate))
{
continue;
}
// 只吸附**当前可见区间内**的采样点:视界之外的采样点不是合法的落点。
if (candidate < viewMin || candidate > viewMax)
{
continue;
}
const double distance = std::fabs(candidate - time);
if (distance < bestDistance)
{
bestDistance = distance;
bestTime = candidate;
found = true;
const double distance = std::fabs(candidate - time);
if (distance < bestDistance)
{
bestDistance = distance;
bestTime = candidate;
found = true;
}
}
}
@@ -2372,10 +2378,10 @@ namespace jdi
return true;
}
bool PlotView::MeanFilterValueAt(const DataFile& file, const SignalData& signal, int points, int iterations,
bool PlotView::MeanFilterValueAt(const TimeAxis& axis, const SignalData& signal, int points, int iterations,
double time, double& value) const
{
const int index = FindNearestSample(file, time);
const int index = FindNearestSample(axis, time);
if (index < 0)
{
return false;
@@ -2402,17 +2408,17 @@ namespace jdi
return true;
}
void PlotView::VisibleIndexRange(const DataFile& file, std::size_t count, std::size_t& begin,
void PlotView::VisibleIndexRange(const TimeAxis& axis, std::size_t count, std::size_t& begin,
std::size_t& end, double timeShift) const
{
begin = 0;
end = count;
if (!file.timeMonotonic || count == 0)
if (!axis.monotonic || count == 0)
{
return;
}
// 时间偏移:采样画在 `t + timeShift` 处,因此落在当前视界内的采样其源时刻区间要整体左移。
const std::vector<double>& time = file.time;
const std::vector<double>& time = axis.time;
begin = static_cast<std::size_t>(
std::lower_bound(time.begin(), time.end(), Tab().ViewMin() - timeShift) - time.begin());
if (begin > 0)
@@ -3053,10 +3059,10 @@ namespace jdi
return m_weakPeakCurveCache.back().values;
}
bool PlotView::WeakPeakValueAt(const DataFile& file, const SignalData& signal, int amountPercent,
bool PlotView::WeakPeakValueAt(const TimeAxis& axis, const SignalData& signal, int amountPercent,
int iterations, double time, double& value) const
{
const int index = FindNearestSample(file, time);
const int index = FindNearestSample(axis, time);
if (index < 0)
{
return false;
@@ -3115,7 +3121,7 @@ namespace jdi
m_edgeScans.end());
}
void PlotView::EdgeScanFor(const DataFile& file, const SignalData& signal, const SignalStyle& style,
void PlotView::EdgeScanFor(const TimeAxis& axis, const SignalData& signal, const SignalStyle& style,
bool centre, double threshold, int points, int iterations, bool hasRange,
double rangeMin, double rangeMax, std::size_t& rising, std::size_t& falling,
bool& complete) const
@@ -3130,7 +3136,7 @@ namespace jdi
complete = true;
return;
}
const bool monotonic = file.timeMonotonic;
const bool monotonic = axis.monotonic;
// 非单调时间轴无法用「采样对下标区间」表达统计范围,只能逐点按时间过滤 →
// 此时区间必须进键(否则会混用别的区间结果)。
const bool rangeInKey = hasRange && !monotonic;
@@ -3149,7 +3155,7 @@ namespace jdi
{
std::size_t indexBegin = 0;
std::size_t indexEnd = 0;
EdgeIndexRange(file, count, hasRange, rangeMin, rangeMax, indexBegin, indexEnd);
EdgeIndexRange(axis, count, hasRange, rangeMin, rangeMax, indexBegin, indexEnd);
pairFirst = (indexBegin + 1 < count) ? (indexBegin + 1) : count;
pairLast = (indexEnd > 0) ? (indexEnd - 1) : 0;
if (pairLast < pairFirst)
@@ -3163,7 +3169,7 @@ namespace jdi
EdgeScanState* state = nullptr;
for (EdgeScanState& candidate : m_edgeScans)
{
if (candidate.file == &file && candidate.signal == &signal && candidate.centre == centre &&
if (candidate.axis == &axis && candidate.signal == &signal && candidate.centre == centre &&
candidate.styleKey == styleKey && candidate.points == safePoints &&
candidate.iterations == safeIterations && candidate.hasRange == rangeInKey &&
(!rangeInKey || (candidate.rangeMin == rangeMin && candidate.rangeMax == rangeMax)))
@@ -3197,7 +3203,7 @@ namespace jdi
m_edgeScans.erase(m_edgeScans.begin() + static_cast<std::ptrdiff_t>(victim));
}
EdgeScanState created;
created.file = &file;
created.axis = &axis;
created.signal = &signal;
created.centre = centre;
created.styleKey = styleKey;
@@ -3314,9 +3320,9 @@ namespace jdi
{
return nan;
}
if (filterByTime && index < file.time.size())
if (filterByTime && index < axis.time.size())
{
const double time = file.time[index];
const double time = axis.time[index];
if (time < rangeMin || time > rangeMax)
{
return nan;
@@ -3539,22 +3545,22 @@ namespace jdi
}
}
void PlotView::EdgeCountsFor(const DataFile& file, const SignalData& signal, const SignalStyle& style,
void PlotView::EdgeCountsFor(const TimeAxis& axis, const SignalData& signal, const SignalStyle& style,
double edgeValue, bool hasRange, double rangeMin, double rangeMax,
std::size_t& rising, std::size_t& falling, bool& complete) const
{
EdgeScanFor(file, signal, style, false, edgeValue, 0, 1, hasRange, rangeMin, rangeMax, rising,
EdgeScanFor(axis, signal, style, false, edgeValue, 0, 1, hasRange, rangeMin, rangeMax, rising,
falling, complete);
}
void PlotView::EdgeCenterCountsFor(const DataFile& file, const SignalData& signal,
void PlotView::EdgeCenterCountsFor(const TimeAxis& axis, const SignalData& signal,
const SignalStyle& style, int points, int iterations,
bool hasRange, double rangeMin, double rangeMax,
std::size_t& rising, std::size_t& falling, bool& complete) const
{
// 与普通边沿计数完全共用同一套「逐块累积 + 每 tick 预算推进」机制;
// 区别只在判定基准(信号 − 中心平均值曲线)与所需曲线(均值滤波,需整条)。
EdgeScanFor(file, signal, style, true, 0.0, points, iterations, hasRange, rangeMin, rangeMax,
EdgeScanFor(axis, signal, style, true, 0.0, points, iterations, hasRange, rangeMin, rangeMax,
rising, falling, complete);
}
@@ -3621,13 +3627,13 @@ namespace jdi
{
continue;
}
EdgeCenterCountsFor(*resolved.file, *resolved.data, *resolved.style, item->points,
EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, item->points,
item->iterations, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(),
rising, falling, complete);
}
else
{
EdgeCountsFor(*resolved.file, *resolved.data, *resolved.style, lane.edgeValue,
EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, lane.edgeValue,
EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete);
}
// 宽度按实际要显示的文本测量(含未算完时的“…”),否则推进过程中数字变长会被截断。
@@ -3812,13 +3818,13 @@ namespace jdi
continue;
}
const SignalData* signal = m_dataSet->Resolve(ref);
const DataFile* file = m_dataSet->FileAt(ref.fileIndex);
if (signal == nullptr || file == nullptr)
const TimeAxis* axis = m_dataSet->Axis(ref);
if (signal == nullptr || axis == nullptr)
{
continue;
}
const std::size_t count = std::min(file->time.size(), signal->values.size());
const std::size_t count = std::min(axis->time.size(), signal->values.size());
const SignalStyle* style = m_dataSet->Style(ref);
// 时间偏移:可见采样对应的源时刻区间要整体左移(画在 t + shift 处)。
const double timeShift = (style != nullptr) ? style->offsetTime : 0.0;
@@ -3826,17 +3832,17 @@ namespace jdi
const double sourceMax = viewMax - timeShift;
std::size_t begin = 0;
std::size_t end = count;
if (file->timeMonotonic)
if (axis->monotonic)
{
// 时间单调时直接二分定位区间,百万级采样也只需扫过可见段。
begin = static_cast<std::size_t>(
std::lower_bound(file->time.begin(), file->time.end(), sourceMin) - file->time.begin());
std::lower_bound(axis->time.begin(), axis->time.end(), sourceMin) - axis->time.begin());
if (begin > 0)
{
--begin;
}
end = static_cast<std::size_t>(
std::lower_bound(file->time.begin(), file->time.end(), sourceMax) - file->time.begin());
std::lower_bound(axis->time.begin(), axis->time.end(), sourceMax) - axis->time.begin());
if (end < count)
{
++end;
@@ -3857,7 +3863,7 @@ namespace jdi
// `ComputeStats` 预存的 min/max 完全一致。工具栏「Y 轴自适应」开关在全视图下
// 会一次性对全部网格做这件事 —— 逐点扫描 2580 万 × 5 是秒级卡顿(实测 1200ms),
// 这里改为 O(1)。
if (file->timeMonotonic && begin == 0 && end == count && !mirrored && !transform &&
if (axis->monotonic && begin == 0 && end == count && !mirrored && !transform &&
signal->finiteCount > 0)
{
low = std::min(low, signal->minValue);
@@ -3870,9 +3876,9 @@ namespace jdi
// 无变换:与既有实现完全一致的紧凑循环(无逐点分支,便于向量化)。
for (std::size_t index = begin; index < end; ++index)
{
if (!file->timeMonotonic)
if (!axis->monotonic)
{
const double t = file->time[index];
const double t = axis->time[index];
if (!std::isfinite(t) || t < sourceMin || t > sourceMax)
{
continue;
@@ -3895,9 +3901,9 @@ namespace jdi
// 水平镜像时画面上的第 index 个位置取源下标 count-1-index 的值。
for (std::size_t index = begin; index < end; ++index)
{
if (!file->timeMonotonic)
if (!axis->monotonic)
{
const double t = file->time[index];
const double t = axis->time[index];
if (!std::isfinite(t) || t < sourceMin || t > sourceMax)
{
continue;
@@ -4145,7 +4151,7 @@ namespace jdi
// 否则每次鼠标移动都要遍历上万个顶点(悬停卡顿的来源之一)。
std::size_t windowFirst = 0;
std::size_t windowLast = points.size();
if (resolved.file->timeMonotonic)
if (resolved.axis->monotonic)
{
const auto byX = [](const gd::PointF& point, float limit) { return point.X < limit; };
windowFirst = static_cast<std::size_t>(
@@ -5674,7 +5680,7 @@ namespace jdi
continue;
}
WeakPeakBuild build;
build.fileIndex = ref.fileIndex;
build.groupIndex = ref.groupIndex;
build.signalIndex = ref.signalIndex;
const int peakAmount =
std::max(kWeakPeakAmountMin, std::min(kWeakPeakAmountMax, item->amountPercent));
@@ -5718,7 +5724,7 @@ namespace jdi
continue;
}
MeanFilterBuild build;
build.fileIndex = ref.fileIndex;
build.groupIndex = ref.groupIndex;
build.signalIndex = ref.signalIndex;
build.points = item->points;
build.iterations = item->iterations;
@@ -6094,7 +6100,7 @@ namespace jdi
const ResolvedSignal& resolved,
const LaneVisual& lane) const
{
if (resolved.data == nullptr || resolved.file == nullptr)
if (resolved.data == nullptr || resolved.group == nullptr)
{
return nullptr;
}
@@ -6113,7 +6119,7 @@ namespace jdi
// 键 = 构建折线时实际读取的全部输入:
// 信号(引用 + 文件内容 + 采样点数)、时间轴显示范围、绘图区横向几何、网格纵向几何。
// 这些值全部相同时,BuildPolyline 的输出必然完全一致,可直接复用。
const std::size_t sampleCount = std::min(resolved.file->time.size(), resolved.data->values.size());
const std::size_t sampleCount = std::min(resolved.axis->time.size(), resolved.data->values.size());
const double viewMin = Tab().ViewMin();
const double viewMax = Tab().ViewMax();
// 绘制前的数值变换(系数/绝对值/镜像/偏移)会改变顶点,必须进缓存键。
@@ -6126,7 +6132,7 @@ namespace jdi
for (const PolylineCacheEntry& entry : m_polylineCache)
{
if (entry.ref == ref && entry.file == resolved.file && entry.sampleCount == sampleCount &&
if (entry.ref == ref && entry.group == resolved.group && entry.sampleCount == sampleCount &&
entry.viewMin == viewMin && entry.viewMax == viewMax && entry.plotX == m_plotRect.X &&
entry.plotWidth == m_plotRect.Width && entry.laneY == lane.rect.Y &&
entry.laneHeight == lane.rect.Height && entry.laneYMin == lane.yMin &&
@@ -6147,7 +6153,7 @@ namespace jdi
m_polylineCache.push_back(PolylineCacheEntry());
PolylineCacheEntry& entry = m_polylineCache.back();
entry.ref = ref;
entry.file = resolved.file;
entry.group = resolved.group;
entry.sampleCount = sampleCount;
entry.viewMin = viewMin;
entry.viewMax = viewMax;
@@ -6182,12 +6188,12 @@ namespace jdi
bandMode = false;
bandSpiky = false;
if (resolved.data == nullptr || resolved.file == nullptr)
if (resolved.data == nullptr || resolved.group == nullptr)
{
return;
}
const std::vector<double>& time = resolved.file->time;
const std::vector<double>& time = resolved.axis->time;
const std::vector<double>& values = resolved.data->values;
const std::size_t count = std::min(time.size(), values.size());
if (count == 0 || m_plotRect.Width <= 1.0f)
@@ -6207,7 +6213,7 @@ namespace jdi
std::size_t begin = 0;
std::size_t end = count;
if (resolved.file->timeMonotonic)
if (resolved.axis->monotonic)
{
const std::vector<double>::const_iterator first = std::lower_bound(time.begin(), time.end(), sourceMin);
begin = static_cast<std::size_t>(first - time.begin());
@@ -6259,12 +6265,12 @@ namespace jdi
// 把阈值降到 1 采样/像素后,1~2 采样/像素区间走包络带(逐列 min/max 与折线在该密度下
// 几乎等价,且**保留抗锯齿**),彻底消除这一区间的病态卡顿。
const bool useEnvelopeBand = (visibleCount > static_cast<std::size_t>(m_plotRect.Width)) &&
resolved.file->timeMonotonic && (m_plotRect.Width > 1.0f);
resolved.axis->monotonic && (m_plotRect.Width > 1.0f);
// 逐点描边路径若处于"横向补条"渲染:把采样区间裁剪到条带对应的 x 范围
//(两侧各留少量像素余量以避免接缝与抗锯齿溢出)。逐点描边路径必然 `visibleCount <= budget`
//(否则走包络),此时 `stride == 1`,裁剪后条带内的顶点序列与整块渲染完全一致 → 像素不变。
if (!useEnvelopeBand && m_stripRenderActive && resolved.file->timeMonotonic &&
if (!useEnvelopeBand && m_stripRenderActive && resolved.axis->monotonic &&
m_stripX1 > m_stripX0)
{
const float marginPx =
@@ -6581,11 +6587,11 @@ namespace jdi
std::wstring message;
if (m_dataSet == nullptr || m_dataSet->Empty())
{
message = L"打开 CSV 文件以查看仿真数据波形";
message = L"打开数据文件以查看波形";
}
else
{
message = L"请在左侧勾选需要查看的信号(可同时加载多个文件进行对比)";
message = L"请在左侧勾选需要查看的信号(可同时加载多个文件,各自成为一个分组)";
}
UiText(g, message, m_fonts->ui, m_area, m_theme->textMuted, TextAlign::CENTER);
}
@@ -7111,7 +7117,7 @@ namespace jdi
key.scale = m_scale;
const SignalRef halo = SceneHaloRef();
key.haloFile = halo.fileIndex;
key.haloFile = halo.groupIndex;
key.haloSignal = halo.signalIndex;
key.contentHash = SceneContentHash();
return key;
@@ -7195,7 +7201,7 @@ namespace jdi
{
const SignalRef selected = SelectedRef();
mix(selected.IsValid() ? 1ull : 0ull);
mix(static_cast<std::uint64_t>(selected.fileIndex));
mix(static_cast<std::uint64_t>(selected.groupIndex));
mix(static_cast<std::uint64_t>(selected.signalIndex));
}
@@ -7225,7 +7231,7 @@ namespace jdi
mix(static_cast<std::uint64_t>(Tab().Lanes()[laneIndex].edgeCenterItems.size()));
for (const Lane::EdgeCenterItem& item : Tab().Lanes()[laneIndex].edgeCenterItems)
{
mix(static_cast<std::uint64_t>(item.ref.fileIndex));
mix(static_cast<std::uint64_t>(item.ref.groupIndex));
mix(static_cast<std::uint64_t>(item.ref.signalIndex));
mix(static_cast<std::uint64_t>(item.points));
mix(static_cast<std::uint64_t>(item.iterations));
@@ -7234,7 +7240,7 @@ namespace jdi
mix(static_cast<std::uint64_t>(Tab().Lanes()[laneIndex].meanFilterItems.size()));
for (const Lane::MeanFilterItem& item : Tab().Lanes()[laneIndex].meanFilterItems)
{
mix(static_cast<std::uint64_t>(item.ref.fileIndex));
mix(static_cast<std::uint64_t>(item.ref.groupIndex));
mix(static_cast<std::uint64_t>(item.ref.signalIndex));
mix(static_cast<std::uint64_t>(item.points));
mix(static_cast<std::uint64_t>(item.iterations));
@@ -7244,7 +7250,7 @@ namespace jdi
for (const Lane::WeakPeakItem& item : Tab().Lanes()[laneIndex].weakPeakItems)
{
// 逐信号参数(等级 + 迭代次数)都参与指纹:改任一条的参数都会重画。
mix(static_cast<std::uint64_t>(item.ref.fileIndex));
mix(static_cast<std::uint64_t>(item.ref.groupIndex));
mix(static_cast<std::uint64_t>(item.ref.signalIndex));
mix(static_cast<std::uint64_t>(item.amountPercent));
mix(static_cast<std::uint64_t>(item.iterations));
@@ -7254,7 +7260,7 @@ namespace jdi
for (const SignalRef& ref : signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
mix(static_cast<std::uint64_t>(ref.fileIndex));
mix(static_cast<std::uint64_t>(ref.groupIndex));
mix(static_cast<std::uint64_t>(ref.signalIndex));
mix(resolved.visible ? 1ull : 0ull);
if (resolved.data == nullptr || resolved.style == nullptr)
@@ -7281,9 +7287,9 @@ namespace jdi
}
// 图例文案参与绘制,重命名必须让缓存失效。
mixText(resolved.data->Title());
if (resolved.file != nullptr)
if (resolved.group != nullptr)
{
mixText(resolved.file->Title());
mixText(resolved.group->Title());
}
}
}
@@ -7425,7 +7431,7 @@ namespace jdi
target.reserve(items.size() * 4);
for (const auto& item : items)
{
target.push_back(item.ref.fileIndex);
target.push_back(item.ref.groupIndex);
target.push_back(item.ref.signalIndex);
// 均值滤波/中心轴取 points,弱峰调整取 amountPercent(由 `valueOf` 决定)。
target.push_back(static_cast<int>(valueOf(item)));
@@ -8290,7 +8296,7 @@ namespace jdi
for (const SignalRef& ref : signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
mix(static_cast<std::uint64_t>(ref.fileIndex));
mix(static_cast<std::uint64_t>(ref.groupIndex));
mix(static_cast<std::uint64_t>(ref.signalIndex));
mix(resolved.visible ? 1ull : 0ull);
if (resolved.data == nullptr || resolved.style == nullptr)
@@ -9945,9 +9951,9 @@ namespace jdi
}
++visibleCount;
std::wstring label = BuildLegendText(*resolved.data);
if (multiFile && resolved.file != nullptr)
if (multiFile && resolved.group != nullptr)
{
label += L" · " + resolved.file->Title();
label += L" · " + resolved.group->Title();
}
const float maxTextWidth = kLegendMaxTextWidth * m_scale;
const float fullTextWidth = UiMeasureTextWidth(m_fonts->uiTiny, label);
@@ -10130,20 +10136,20 @@ namespace jdi
UiDrawSolidLines(g, axisClip, axisSegment, 1, axisColor);
}
// 时间轴列名(取自首个可见信号所属文件),贴左侧绘制。
// 时间轴列名(取自首个可见信号所属分组的第一条时间轴),贴左侧绘制。
std::wstring axisName = L"时间";
if (m_dataSet != nullptr && m_tab != nullptr)
{
for (std::size_t fileIndex = 0; fileIndex < m_dataSet->FileCount(); ++fileIndex)
for (std::size_t groupIndex = 0; groupIndex < m_dataSet->GroupCount(); ++groupIndex)
{
if (!m_tab->FileHasVisible(static_cast<int>(fileIndex)))
if (!m_tab->GroupHasVisible(static_cast<int>(groupIndex)))
{
continue;
}
const DataFile* file = m_dataSet->FileAt(static_cast<int>(fileIndex));
if (file != nullptr && !file->timeColumnName.empty())
const SignalGroup* group = m_dataSet->GroupAt(static_cast<int>(groupIndex));
if (group != nullptr && !group->axes.empty() && !group->axes.front().name.empty())
{
axisName = file->timeColumnName;
axisName = group->axes.front().name;
// CSV 中该列通常直接叫 "time",这里补上时间单位秒便于阅读;
// 若列名自带单位描述(以右括号结尾)则不重复追加。
if (axisName.back() != L')')
@@ -10457,14 +10463,14 @@ namespace jdi
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.style == nullptr ||
resolved.file == nullptr || !resolved.visible)
resolved.axis == nullptr || !resolved.visible)
{
continue;
}
const bool interpolate = (resolved.style->mode != DrawMode::SCATTER);
double value = 0.0;
if (!SampleDisplayedSignalAt(*resolved.file, *resolved.data, *resolved.style, time,
if (!SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style, time,
interpolate, value))
{
continue;
@@ -10491,7 +10497,7 @@ namespace jdi
if (showDelta)
{
double otherValue = 0.0;
if (SampleDisplayedSignalAt(*resolved.file, *resolved.data, *resolved.style, otherTime,
if (SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style, otherTime,
interpolate, otherValue))
{
otherValue = ApplyDrawTransform(*resolved.style, otherValue);
@@ -10561,13 +10567,13 @@ namespace jdi
int iterations, int amountPercent,
const std::wstring& suffix)
{
const int sampleIndex = FindNearestSample(*resolved.file, time);
const int sampleIndex = FindNearestSample(*resolved.axis, time);
if (sampleIndex < 0)
{
return;
}
const int otherIndex =
showDelta ? FindNearestSample(*resolved.file, otherTime) : -1;
showDelta ? FindNearestSample(*resolved.axis, otherTime) : -1;
// 本函数**只需要光标所在采样点处的派生取值**(以及双光标另一端的取值),
// 因此把请求窗口收窄到「需要的那几个下标」即可,均值滤波半径造成的取值
// 依赖由 `CurveSpanFor` 自身的保护带负责覆盖。
@@ -11374,12 +11380,12 @@ namespace jdi
const LaneVisual& lane, const std::vector<float>& curve,
std::size_t curveBegin) const
{
if (resolved.data == nullptr || resolved.file == nullptr || resolved.style == nullptr)
if (resolved.data == nullptr || resolved.axis == nullptr || resolved.style == nullptr)
{
return;
}
const std::vector<double>& time = resolved.file->time;
const std::vector<double>& time = resolved.axis->time;
const std::size_t count = std::min(time.size(), resolved.data->values.size());
if (count == 0 || m_plotRect.Width <= 1.0f || curve.empty())
{
@@ -11388,7 +11394,7 @@ namespace jdi
std::size_t begin = 0;
std::size_t end = 0;
VisibleIndexRange(*resolved.file, count, begin, end, resolved.style->offsetTime);
VisibleIndexRange(*resolved.axis, count, begin, end, resolved.style->offsetTime);
if (end <= begin)
{
return;
@@ -11501,14 +11507,14 @@ namespace jdi
void PlotView::PaintMeanFilterCurve(gd::Graphics& g, const ResolvedSignal& resolved,
const LaneVisual& lane, int points, int iterations) const
{
if (resolved.data == nullptr || resolved.file == nullptr || resolved.style == nullptr)
if (resolved.data == nullptr || resolved.axis == nullptr || resolved.style == nullptr)
{
return;
}
// 只取「可见区间 ± 保护带」的窗口(见 `CurveSpanFor`):拖参数滑块不会重算整条曲线。
// 与「边沿计数(中心轴)」共用同一段绘制逻辑
//(同色相高对比度的派生配色 + 同透明度),只是曲线本身按多次迭代滤波计算。
const std::size_t count = std::min(resolved.file->time.size(), resolved.data->values.size());
const std::size_t count = std::min(resolved.axis->time.size(), resolved.data->values.size());
if (count == 0)
{
return;
@@ -11531,11 +11537,11 @@ namespace jdi
void PlotView::PaintWeakPeakCurve(gd::Graphics& g, const ResolvedSignal& resolved,
const LaneVisual& lane, int amountPercent, int iterations) const
{
if (resolved.data == nullptr || resolved.file == nullptr || resolved.style == nullptr)
if (resolved.data == nullptr || resolved.axis == nullptr || resolved.style == nullptr)
{
return;
}
const std::size_t count = std::min(resolved.file->time.size(), resolved.data->values.size());
const std::size_t count = std::min(resolved.axis->time.size(), resolved.data->values.size());
if (count == 0)
{
return;
@@ -11661,7 +11667,7 @@ namespace jdi
const auto mixRef = [&mix](const SignalRef& ref)
{
mix(ref.IsValid() ? 1ull : 0ull);
mix(static_cast<std::uint64_t>(ref.fileIndex));
mix(static_cast<std::uint64_t>(ref.groupIndex));
mix(static_cast<std::uint64_t>(ref.signalIndex));
};
@@ -11798,7 +11804,7 @@ namespace jdi
for (const SignalRef& ref : layout.rowRefs)
{
mix(ref.IsValid() ? 1ull : 0ull);
mix(static_cast<std::uint64_t>(ref.fileIndex));
mix(static_cast<std::uint64_t>(ref.groupIndex));
mix(static_cast<std::uint64_t>(ref.signalIndex));
// 勾选状态与参数直接决定行文本与行高。
mix(lane.MeanFilterEnabled(ref) ? 1ull : 0ull);
@@ -12339,7 +12345,7 @@ namespace jdi
std::size_t rising = 0;
std::size_t falling = 0;
bool statsComplete = false;
EdgeCenterCountsFor(*resolved.file, *resolved.data, *resolved.style,
EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style,
statsPoints, statsOrder, EdgeRangeActive(),
EdgeRangeMin(), EdgeRangeMax(), rising, falling,
statsComplete);
@@ -12374,7 +12380,7 @@ namespace jdi
std::size_t rising = 0;
std::size_t falling = 0;
bool complete = true;
EdgeCountsFor(*resolved.file, *resolved.data, *resolved.style, tabLane.edgeValue,
EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, tabLane.edgeValue,
EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling,
complete);
paintCounts(gd::RectF(panel.X + pad, rowY + rowHeight, textWidth, rowHeight), rising,
+7 -7
View File
@@ -28,9 +28,9 @@ namespace jdi
CMD_SIGNAL_SOLO = 2, /**< 仅显示该信号。 */
CMD_SIGNAL_SHOW_ALL = 3, /**< 显示全部信号。 */
CMD_SIGNAL_HIDE_ALL = 4, /**< 隐藏全部信号。 */
CMD_FILE_SHOW_ALL = 10, /**< 选中该文件全部信号。 */
CMD_FILE_HIDE_ALL = 11, /**< 取消该文件全部信号。 */
CMD_FILE_REMOVE = 12, /**< 从工作区移除该文件。 */
CMD_GROUP_SHOW_ALL = 10, /**< 选中该文件全部信号。 */
CMD_GROUP_HIDE_ALL = 11, /**< 取消该文件全部信号。 */
CMD_GROUP_REMOVE = 12, /**< 从工作区移除该文件。 */
CMD_COLOR_BASE = 100, /**< 预设颜色基准(配合色块下标)。 */
CMD_MODE_BASE = 200, /**< 绘制样式基准(配合下标)。 */
CMD_LANE_BASE = 300, /**< 网格归属基准(配合下标)。 */
@@ -315,7 +315,7 @@ namespace jdi
*/
enum class RowKind : int
{
FILE = 0, /**< 文件分组标题行。 */
GROUP = 0, /**< 文件分组标题行。 */
SIGNAL = 1 /**< 信号行。 */
};
@@ -359,7 +359,7 @@ namespace jdi
struct ListRow
{
RowKind kind = RowKind::SIGNAL; /**< 行类型。 */
int fileIndex = -1; /**< 所属文件下标。 */
int groupIndex = -1; /**< 所属文件下标。 */
int signalIndex = -1; /**< 信号下标(仅信号行有效)。 */
float height = 0.0f; /**< 行高(像素)。 */
};
@@ -478,11 +478,11 @@ namespace jdi
/**
* @brief 弹出文件分组右键菜单。
* @param [IN] fileIndex 目标文件下标。
* @param [IN] groupIndex 目标文件下标。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
*/
void OpenFileMenu(int fileIndex, int x, int y);
void OpenFileMenu(int groupIndex, int x, int y);
/**
* @brief 弹出网格归属菜单(由信号行的网格徽标触发)。
+63 -63
View File
@@ -448,17 +448,17 @@ namespace jdi
const float signalHeight = kSignalRowHeight * m_scale;
const bool filtering = !m_filterText.empty();
const std::vector<DataFile>& files = m_dataSet->Files();
for (std::size_t fileIndex = 0; fileIndex < files.size(); ++fileIndex)
const std::vector<SignalGroup>& files = m_dataSet->Groups();
for (std::size_t groupIndex = 0; groupIndex < files.size(); ++groupIndex)
{
// 筛选时先挑出本组内匹配的信号;整组都没有匹配则**不显示该组**。
std::vector<int> matched;
if (filtering)
{
const std::size_t signalCount = files[fileIndex].signals.size();
const std::size_t signalCount = files[groupIndex].signals.size();
for (std::size_t signalIndex = 0; signalIndex < signalCount; ++signalIndex)
{
if (FilterMatchesSignal(files[fileIndex].signals[signalIndex]))
if (FilterMatchesSignal(files[groupIndex].signals[signalIndex]))
{
matched.push_back(static_cast<int>(signalIndex));
}
@@ -470,13 +470,13 @@ namespace jdi
}
ListRow fileRow;
fileRow.kind = RowKind::FILE;
fileRow.fileIndex = static_cast<int>(fileIndex);
fileRow.kind = RowKind::GROUP;
fileRow.groupIndex = static_cast<int>(groupIndex);
fileRow.height = fileHeight;
m_rows.push_back(fileRow);
// 筛选结果**默认展开**(忽略折叠状态),便于直接看到匹配项。
if (!filtering && m_tab->IsCollapsed(static_cast<int>(fileIndex)))
if (!filtering && m_tab->IsCollapsed(static_cast<int>(groupIndex)))
{
continue;
}
@@ -487,7 +487,7 @@ namespace jdi
{
ListRow signalRow;
signalRow.kind = RowKind::SIGNAL;
signalRow.fileIndex = static_cast<int>(fileIndex);
signalRow.groupIndex = static_cast<int>(groupIndex);
signalRow.signalIndex = signalIndex;
signalRow.height = signalHeight;
m_rows.push_back(signalRow);
@@ -495,12 +495,12 @@ namespace jdi
continue;
}
const std::size_t signalCount = files[fileIndex].signals.size();
const std::size_t signalCount = files[groupIndex].signals.size();
for (std::size_t signalIndex = 0; signalIndex < signalCount; ++signalIndex)
{
ListRow signalRow;
signalRow.kind = RowKind::SIGNAL;
signalRow.fileIndex = static_cast<int>(fileIndex);
signalRow.groupIndex = static_cast<int>(groupIndex);
signalRow.signalIndex = static_cast<int>(signalIndex);
signalRow.height = signalHeight;
m_rows.push_back(signalRow);
@@ -639,7 +639,7 @@ namespace jdi
{
const int rowIndex = static_cast<int>(index);
const gd::RectF rect = RowRect(rowIndex);
const bool isFile = (row.kind == RowKind::FILE);
const bool isFile = (row.kind == RowKind::GROUP);
const float baseX = rect.X + kPadding * m_scale + (isFile ? 0.0f : kIndent * m_scale);
const gd::RectF disclosureRect(baseX, rect.Y, kDisclosureWidth * m_scale, rect.Height);
@@ -668,7 +668,7 @@ namespace jdi
}
else
{
const SignalRef ref{row.fileIndex, row.signalIndex};
const SignalRef ref{row.groupIndex, row.signalIndex};
const bool badgeVisible = (m_tab != nullptr) && m_tab->IsVisible(ref);
// ✕ 命中优先于复选框:两者紧邻,顺序保证不会互相抢命中。
@@ -708,7 +708,7 @@ namespace jdi
for (std::size_t index = 0; index < m_rows.size(); ++index)
{
const ListRow& row = m_rows[index];
if (row.kind == RowKind::SIGNAL && row.fileIndex == ref.fileIndex &&
if (row.kind == RowKind::SIGNAL && row.groupIndex == ref.groupIndex &&
row.signalIndex == ref.signalIndex)
{
if (top < m_scrollOffset)
@@ -968,7 +968,7 @@ namespace jdi
if (m_dataSet == nullptr || m_dataSet->Empty())
{
UiText(g, L"尚未加载数据,请打开 CSV 文件", m_fonts->uiSmall, m_listRect, m_theme->textMuted,
UiText(g, L"尚未加载数据,请打开数据文件", m_fonts->uiSmall, m_listRect, m_theme->textMuted,
TextAlign::CENTER);
}
else if (m_rows.empty() && !m_filterText.empty())
@@ -987,7 +987,7 @@ namespace jdi
{
continue;
}
if (m_rows[static_cast<std::size_t>(row)].kind == RowKind::FILE)
if (m_rows[static_cast<std::size_t>(row)].kind == RowKind::GROUP)
{
PaintFileRow(g, row);
}
@@ -1025,8 +1025,8 @@ namespace jdi
}
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
const DataFile* file = m_dataSet->FileAt(item.fileIndex);
if (file == nullptr)
const SignalGroup* group = m_dataSet->GroupAt(item.groupIndex);
if (group == nullptr)
{
return;
}
@@ -1042,7 +1042,7 @@ namespace jdi
const float baseX = rect.X + kPadding * m_scale;
const float centerY = rect.Y + rect.Height * 0.5f;
const bool collapsed = m_tab->IsCollapsed(item.fileIndex);
const bool collapsed = m_tab->IsCollapsed(item.groupIndex);
// 折叠三角。
{
@@ -1057,15 +1057,15 @@ namespace jdi
// 分组复选框(三态:全选 / 部分 / 未选)。
const gd::RectF checkRect(baseX + kDisclosureWidth * m_scale, centerY - kCheckSize * m_scale * 0.5f,
kCheckSize * m_scale, kCheckSize * m_scale);
const bool allVisible = m_tab->FileAllVisible(item.fileIndex);
const bool anyVisible = m_tab->FileHasVisible(item.fileIndex);
const bool allVisible = m_tab->GroupAllVisible(item.groupIndex);
const bool anyVisible = m_tab->GroupHasVisible(item.groupIndex);
UiDrawCheckBox(g, checkRect, allVisible ? 1 : (anyVisible ? 2 : 0), 3.0f * m_scale);
const gd::RectF closeRect = FileCloseRect(row);
// 分组标题:左侧为“组X”标签,右侧以绘制的竖线分隔分组名(不使用字符),再显示可重命名的名称。
const std::wstring groupLabel = L"组" + std::to_wstring(item.fileIndex + 1);
const std::wstring stats = std::to_wstring(file->signals.size()) + L" 项";
const std::wstring groupLabel = L"组" + std::to_wstring(item.groupIndex + 1);
const std::wstring stats = std::to_wstring(group->signals.size()) + L" 项";
const float groupWidth = FileGroupLabelWidth(row);
const float statsWidth = UiMeasureTextWidth(m_fonts->uiSmall, stats);
const float statsRight = closeRect.X - 6.0f * m_scale;
@@ -1091,7 +1091,7 @@ namespace jdi
}
else
{
UiText(g, UiEllipsize(file->Title(), m_fonts->uiBold, nameWidth), m_fonts->uiBold,
UiText(g, UiEllipsize(group->Title(), m_fonts->uiBold, nameWidth), m_fonts->uiBold,
gd::RectF(nameAreaLeft, rect.Y, nameWidth, rect.Height), m_theme->textStrong,
TextAlign::LEFT);
}
@@ -1116,7 +1116,7 @@ namespace jdi
}
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
const SignalRef ref{item.fileIndex, item.signalIndex};
const SignalRef ref{item.groupIndex, item.signalIndex};
const SignalData* signal = m_dataSet->Resolve(ref);
const SignalStyle* style = StyleOf(ref);
if (signal == nullptr || style == nullptr)
@@ -1451,7 +1451,7 @@ namespace jdi
}
const gd::RectF rect = RowRect(row);
const bool isFile = (m_rows[static_cast<std::size_t>(row)].kind == RowKind::FILE);
const bool isFile = (m_rows[static_cast<std::size_t>(row)].kind == RowKind::GROUP);
const float baseX = rect.X + kPadding * m_scale + (isFile ? 0.0f : kIndent * m_scale);
const float checkRight = baseX + kDisclosureWidth * m_scale + kCheckSize * m_scale;
@@ -1468,7 +1468,7 @@ namespace jdi
{
left = checkRight + 6.0f * m_scale + kSwatchSize * m_scale + 6.0f * m_scale;
// 行尾的可用右界:有网格徽标时让位给徽标,否则可直接延伸到行尾 ✕ 之前。
const SignalRef ref{m_rows[static_cast<std::size_t>(row)].fileIndex,
const SignalRef ref{m_rows[static_cast<std::size_t>(row)].groupIndex,
m_rows[static_cast<std::size_t>(row)].signalIndex};
const bool badgeVisible = (m_tab != nullptr) && m_tab->IsVisible(ref) &&
(m_tab->LaneOfSignal(ref) >= 0);
@@ -1489,7 +1489,7 @@ namespace jdi
{
return 0.0f;
}
const std::wstring label = L"组" + std::to_wstring(m_rows[static_cast<std::size_t>(row)].fileIndex + 1);
const std::wstring label = L"组" + std::to_wstring(m_rows[static_cast<std::size_t>(row)].groupIndex + 1);
return UiMeasureTextWidth(m_fonts->uiBold, label);
}
@@ -1534,7 +1534,7 @@ namespace jdi
return false;
}
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
const SignalData* signal = m_dataSet->Resolve(SignalRef{item.fileIndex, item.signalIndex});
const SignalData* signal = m_dataSet->Resolve(SignalRef{item.groupIndex, item.signalIndex});
if (signal == nullptr)
{
return false;
@@ -1548,12 +1548,12 @@ namespace jdi
{
return false;
}
const DataFile* file = m_dataSet->FileAt(m_rows[static_cast<std::size_t>(row)].fileIndex);
if (file == nullptr)
const SignalGroup* group = m_dataSet->GroupAt(m_rows[static_cast<std::size_t>(row)].groupIndex);
if (group == nullptr)
{
return false;
}
initial = file->Title();
initial = group->Title();
break;
}
default:
@@ -1638,7 +1638,7 @@ namespace jdi
break;
}
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
m_dataSetWritable->SetSignalDisplayName(item.fileIndex, item.signalIndex, trimmed);
m_dataSetWritable->SetSignalDisplayName(item.groupIndex, item.signalIndex, trimmed);
break;
}
case StyleEditField::FILE_NAME:
@@ -1647,7 +1647,7 @@ namespace jdi
{
break;
}
m_dataSetWritable->SetFileDisplayName(m_rows[static_cast<std::size_t>(row)].fileIndex, trimmed);
m_dataSetWritable->SetGroupDisplayName(m_rows[static_cast<std::size_t>(row)].groupIndex, trimmed);
break;
}
default:
@@ -2458,12 +2458,12 @@ namespace jdi
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
if (item.kind == RowKind::FILE)
if (item.kind == RowKind::GROUP)
{
if (part == RowPart::CHECK)
{
const bool allVisible = m_tab->FileAllVisible(item.fileIndex);
m_tab->SetFileVisible(item.fileIndex, !allVisible);
const bool allVisible = m_tab->GroupAllVisible(item.groupIndex);
m_tab->SetGroupVisible(item.groupIndex, !allVisible);
m_rowsDirty = true;
return true;
}
@@ -2475,12 +2475,12 @@ namespace jdi
m_pendingCloseRow = row;
return true;
}
m_tab->ToggleCollapsed(item.fileIndex);
m_tab->ToggleCollapsed(item.groupIndex);
m_rowsDirty = true;
return true;
}
const SignalRef ref{item.fileIndex, item.signalIndex};
const SignalRef ref{item.groupIndex, item.signalIndex};
// ✕:用户明确表示不再需要该信号 → 交由宿主从数据层移除并释放其采样内存。
// 必须在 SelectSignal 之前处理,避免选中一条即将被删除的信号;
@@ -2542,12 +2542,12 @@ namespace jdi
{
if (pendingPart == RowPart::CLOSE)
{
PostMessageW(m_host, WM_APP + 1, static_cast<WPARAM>(item.fileIndex), 0);
PostMessageW(m_host, WM_APP + 1, static_cast<WPARAM>(item.groupIndex), 0);
}
else
{
// wParam = 文件下标,lParam = 文件内信号下标(见 Application 的消息处理)。
PostMessageW(m_host, WM_APP + 2, static_cast<WPARAM>(item.fileIndex),
PostMessageW(m_host, WM_APP + 2, static_cast<WPARAM>(item.groupIndex),
static_cast<LPARAM>(item.signalIndex));
}
}
@@ -2616,7 +2616,7 @@ namespace jdi
if (row >= 0 && part == RowPart::LANE_BADGE)
{
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
const SignalRef ref{item.fileIndex, item.signalIndex};
const SignalRef ref{item.groupIndex, item.signalIndex};
if (ref == m_pendingLaneRef)
{
const gd::RectF badge = SignalBadgeRect(row);
@@ -2660,11 +2660,11 @@ namespace jdi
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
// 分组行:双击折叠三角用于折叠,双击名称区域进入重命名。
if (item.kind == RowKind::FILE)
if (item.kind == RowKind::GROUP)
{
if (part == RowPart::DISCLOSURE)
{
m_tab->ToggleCollapsed(item.fileIndex);
m_tab->ToggleCollapsed(item.groupIndex);
m_rowsDirty = true;
return true;
}
@@ -2683,7 +2683,7 @@ namespace jdi
return false;
}
const SignalRef ref{item.fileIndex, item.signalIndex};
const SignalRef ref{item.groupIndex, item.signalIndex};
SelectSignal(ref);
m_rowsDirty = true;
return BeginEdit(StyleEditField::SIGNAL_NAME, row);
@@ -2704,13 +2704,13 @@ namespace jdi
}
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
if (item.kind == RowKind::FILE)
if (item.kind == RowKind::GROUP)
{
OpenFileMenu(item.fileIndex, x, y);
OpenFileMenu(item.groupIndex, x, y);
return true;
}
const SignalRef ref{item.fileIndex, item.signalIndex};
const SignalRef ref{item.groupIndex, item.signalIndex};
SelectSignal(ref);
OpenSignalMenu(ref, x, y);
return true;
@@ -2825,37 +2825,37 @@ namespace jdi
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items);
}
void SignalPanel::OpenFileMenu(int fileIndex, int x, int y)
void SignalPanel::OpenFileMenu(int groupIndex, int x, int y)
{
if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr)
{
return;
}
const DataFile* file = m_dataSet->FileAt(fileIndex);
if (file == nullptr)
const SignalGroup* group = m_dataSet->GroupAt(groupIndex);
if (group == nullptr)
{
return;
}
m_menuTargetFile = fileIndex;
m_menuTargetFile = groupIndex;
m_menuTargetSignal = SignalRef();
std::vector<MenuItem> items;
MenuItem header;
header.kind = MenuItemKind::HEADER;
header.label = file->Title();
header.label = group->Title();
items.push_back(header);
MenuItem showAll;
showAll.label = L"选中该文件全部信号";
showAll.id = CMD_FILE_SHOW_ALL;
showAll.label = L"选中该分组全部信号";
showAll.id = CMD_GROUP_SHOW_ALL;
items.push_back(showAll);
MenuItem hideAll;
hideAll.label = L"取消该文件全部信号";
hideAll.id = CMD_FILE_HIDE_ALL;
hideAll.label = L"取消该分组全部信号";
hideAll.id = CMD_GROUP_HIDE_ALL;
items.push_back(hideAll);
MenuItem separator;
@@ -2863,8 +2863,8 @@ namespace jdi
items.push_back(separator);
MenuItem remove;
remove.label = L"关闭该文件(从工作区移除)";
remove.id = CMD_FILE_REMOVE;
remove.label = L"关闭该分组(从工作区移除)";
remove.id = CMD_GROUP_REMOVE;
items.push_back(remove);
const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
@@ -3086,23 +3086,23 @@ namespace jdi
m_rowsDirty = true;
needRelayout = true;
break;
case CMD_FILE_SHOW_ALL:
case CMD_GROUP_SHOW_ALL:
if (m_menuTargetFile >= 0)
{
m_tab->SetFileVisible(m_menuTargetFile, true);
m_tab->SetGroupVisible(m_menuTargetFile, true);
m_rowsDirty = true;
needRelayout = true;
}
break;
case CMD_FILE_HIDE_ALL:
case CMD_GROUP_HIDE_ALL:
if (m_menuTargetFile >= 0)
{
m_tab->SetFileVisible(m_menuTargetFile, false);
m_tab->SetGroupVisible(m_menuTargetFile, false);
m_rowsDirty = true;
needRelayout = true;
}
break;
case CMD_FILE_REMOVE:
case CMD_GROUP_REMOVE:
if (m_menuTargetFile >= 0 && m_host != nullptr)
{
PostMessageW(m_host, WM_APP + 1, static_cast<WPARAM>(m_menuTargetFile), 0);
+119
View File
@@ -1357,6 +1357,102 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
---
### 9.27 数据层大重构:文件→分组 / 时间轴一等公民 / 加载器抽象 + CSV 多时间轴 + MF4 支持(本轮)
> 完整审计与改动清单见 `Documents/refactor_report_2026-10-15.md`。本轮**推翻并替换**了 §12 里
> 「DataFile = 一个文件及其自有时间轴」的旧模型;所有界面行为与显示保持与旧版一致。
#### 一、模型:`DataFile` → `SignalGroup`,时间轴独立成 `TimeAxis`
* **新增 `TimeAxis`**:`{ name, time, minTime, maxTime, monotonic, finiteCount }`。
* **`SignalData`**:删除 `rawHeader`(`name` 的副本)与 `unit`(由列名猜出、全仓无人使用);
新增 **`axisIndex`** 指向所属时间轴 ⇒ 采样值与时间的对应关系**自包含**,不再需要「文件」。
名字只剩两个且职责明确:`name`(源名称,永不改写)/ `displayName`(用户重命名)。
* **`SignalGroup`(原 `DataFile`)**:`{ name(默认=源文件名), displayName, axes[], signals[] }`;
**删除** `filePath` / `fileName` / `time` / `timeColumnName` / `timeMonotonic` / `minTime` / `maxTime`。
新增 `AxisOf(signal)` / `AxisAt(i)` / `TimeRange(low, high)`(多轴并集)。
* **`SignalRef::fileIndex` → `groupIndex`**;`DataSet`:`AddGroup` / `RemoveGroup` / `GroupAt` / `GroupCount` /
`Groups()` / `SetGroupDisplayName` / `RemapSelectionAfterGroupRemoval` + 新增 **`Axis(ref)`**。
* `TabConfig`:`SetGroupVisible` / `GroupHasVisible` / `GroupAllVisible` / `RemapAfterGroupRemoval`;
`RowKind::GROUP`、`CMD_GROUP_*`。
* **采样/统计 API 首参由 `const DataFile&` 改为 `const TimeAxis&`**:
`SampleSignalAt` / `SampleDisplayedSignalAt` / `FindNearestSample`(Utilities)与
`SampleSpacingPixels` / `VisibleIndexRange` / `EdgeIndexRange` / `MeanFilterValueAt` / `WeakPeakValueAt` /
`EdgeCountsFor` / `EdgeCenterCountsFor` / `EdgeScanFor`(PlotView)。
* 视图层:`ResolvedSignal` 改为 `{ data, style, group, axis, visible }`;
`PolylineCacheEntry::file → group`;`EdgeScanState::file → axis`(统计身份 = 时间轴 + 信号)。
* `TabConfig::ComputeVisibleTimeBounds` 只统计**可见信号真正用到的时间轴**。
#### 二、加载层:格式与模型彻底解耦(新文件)
| 文件 | 职责 |
| --- | --- |
| `Utilities/Inc/DataTable.hpp` | `DataTable`(通用列优先表格)、`LoadProgressInfo`/`LoadProgress`、`TableBlock`(一个时间轴块)、`LoadedSource`(源文件全部块) |
| `Utilities/Inc/DataLoader.hpp` + `.cpp` | **唯一入口** `LoadSignalSource(path,…)`:按扩展名分发(`.csv/.txt` → CSV;`.mf4/.mdf` → MDF4;其它按文本尝试)+ `IsSupportedSignalFile` + `SignalFileDialogFilter` |
| `Utilities/Inc/CsvLoader.hpp` + `.cpp` | CSV 解析 + 多时间轴切分 + 各轴按自己的时间列裁剪 |
| `Utilities/Inc/Mf4Loader.hpp` + `.cpp` | ASAM MDF v4 解析 |
| 删除 | `Utilities/Inc/CsvTable.hpp`、`Utilities/Src/CsvTable.cpp` |
* **唯一装配点** `BuildSignalGroup(LoadedSource&&, paletteBase, group, error, progress)`:与格式无关;
负责**时间轴去重合并**(内容逐位相同只保留一条)、信号命名、调色板取色、统计与粗粒度包络。
⚠️ **新增文件格式时只写一个加载器**,数据模型与全部界面代码**不用改**。
#### 三、CSV 多时间轴(修正用户报告的缺陷)
* 规则:**只有名为 `time` 的列是时间轴**;每个时间列开启新块,其后直到下一个时间列之前的列都属于该块。
* 各块按**自己的时间列**裁剪 ⇒ 支持各轴长度不同(实测 `Multi_Timeline.CSV` → 3 条轴,长度 **7/4/7**);
**不再把其它 `time` 列当作信号显示**。
* 完全没有 `time` 列时保留旧行为(第一列当时间轴,单轴)。
* `LooksLikeTimeHeader` 判据收紧为 `t`/`time`/`timestamp`/`simulation time`/`时间`/`时间轴`
(旧判据「包含 time」会把 `timeout` 误判成时间轴并切出空块)。
#### 四、MF4 支持(实测格式要点,务必保留)
* 加载后**新建一个分组、组名 = 文件名**,文件内全部信号归入该分组(用户要求)。
* **每个通道组(CG)是一条时间轴**;内容相同的时间轴自动合并(实测 27 个 CG → **3 条轴**、**124 条信号**)。
因此 MF4 分组天然是多时间轴的,`SignalData::axisIndex` 让每条信号各归其轴。
* 块布局(MDF 4.10 / CANape 16 实测):
* 块头 24 字节 = `id[4] + reserved[4] + block_len(U64) + link_count(U64)`,随后是 `link_count` 个
**8 字节绝对文件偏移**(0 = 空),数据段紧随其后。DT 可以 0 链接。
* `HD[0] = 第一个 DG`;`DG[1] = 第一个 CG`、`DG[2] = 数据块 DT`;
`CG[1] = 第一个 CN`、`CG[2] = tx_name`、数据段 `+8 = cycle_count`;
**记录长度 = DT 数据段字节数 ÷ cycle_count**(实测 114/110)。
* `CN`:`[2] = 名称 TX`、`[4] = 换算 CC`;数据段 `+0 type`、`+1 sync_type`(**1 = 时间主通道**)、
`+2 data_type`、`+3 bit_offset`、`+4 byte_offset(U32,**字节**)`、`+8 bit_count`、`+12 flags`
(bit0 整列无效 / bit1 有无效位)、`+16 invalid_bit_pos`。
**绝对位偏移 = byte_offset × 8 + bit_offset**。
* `data_type`:0/1 无符号(LE/BE)、2/3 有符号、4/5 IEEE 浮点、≥6 字符串/字节数组(跳过)。
* `CC`:`+0 type`、`+6 val_count`、`+8 phy_range_min`、`+16 phy_range_max`、`+24 cc_val[]`。
**换算公式(实测确认)= `phys = cc_val[0] + cc_val[1] × raw`**(本文件时间主通道是
「double 纳秒 + 线性 1e-9」⇒ 解出 `[0.024320125, 42.3918] s`,与 CC 的物理范围逐位吻合,可自检)。
* **TX 文本块**:规范是「U32 长度 + UTF-16」(4.1 起也可 UTF-8 字节数),
但 **CANape 写的是无长度前缀的 NUL 结尾裸文本** ⇒ 必须先按规范、失败再按裸文本回退,
否则所有通道名都会变成空(本轮踩过)。
* 未实现(遇到则跳过该组):压缩块 `##DZ`/`##HL`/`##SD`、`##DL` 数据列表、字符串/MIME 通道。
#### 五、顺带修复的健壮性缺陷
* `Application::Initialize`:`RegisterClassExW` 返回 `ERROR_CLASS_ALREADY_EXISTS` 时**复用**已注册的类
(旧实现当作致命错误弹**模态**框 ⇒ 同进程第二次构造 `Application` 会**永久阻塞**);
`Shutdown` 现在注销窗口类并销毁窗口。
* UI 文案统一到「分组」:`选中该分组全部信号` / `取消该分组全部信号` / `关闭该分组(从工作区移除)` /
`请关闭部分分组或信号后重试。` / `尚未加载数据,请打开数据文件` / `打开数据文件以查看波形` /
`(可同时加载多个文件,各自成为一个分组)`;对话框过滤器含 `*.mf4;*.mdf`。
#### 六、验证(全部离屏,`BELOW_NORMAL` + `SW_HIDE`,不注入真实鼠标键盘)
* 新增 `.preflight/perf/datacheck_main.cpp`:**69/69 通过** —— 单轴 CSV 回归、多时间轴 CSV(3 轴 / 7-4-7 /
不再出现 `time` 信号)、MF4(组名 / 124 信号 / 3 轴 / ns→s / `setSpeed` 首样本与原始字节人工核对)、
`DataSet` 不变量、派生信号沿用源轴、**MF4 集成**(124 信号并一格渲染 67.5ms/帧)。
* 新增 `.preflight/perf/edgecheck_main.cpp`(ASCII-only,替代上轮被编码破坏的 `panelcheck`):
**46/46 通过** —— 「调整」符号规则(真实菜单路径)、边沿统计按帧推进 / 不饿死 / 逐位一致 / 无鼠标输入也完成、
面板两列等宽与名称限宽、弱峰复位控件。
* 既有 `.preflight/perf/verify_main.cpp`(已适配新 API):**47/47**,另有 2 条**本机未同步的旧规则断言**
(`derived offsetTime kept`、`mouse in lane1 lower half`)——属本机 `.preflight` 版本滞后,与本轮无关。
* `make release` 零警告。
---
## 10. 变更日志(本文件维护记录)
| 日期 | 设备/模型 | 摘要 |
@@ -1396,6 +1492,7 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
| 2026-10-14 | DeepSeek-V4.1-Flash(**起源设备**) | **新标签页分组默认折起 + GX 徽标悬停不改光标 + 标题栏 RAM 改用「私有工作集」+ 边沿计数改为「逐块累积 + 每 tick 预算推进」(详见 §9.22)**。① `NewTab(bool collapseGroups)`(首个标签页 false、用户新建 true)+ `TabConfig::SetGroupsCollapsedByDefault/SetAllCollapsed`;`SyncToDataSet` 用该默认值初始化**新出现的**分组。② `QueryCursor` 的 GX 徽标分支由 `IDC_HAND` 改为 `nullptr`(悬停不改形状,仅拖拽时 `IDC_SIZENS`)。③ **RAM 口径修正**:原取 `WorkingSetSize`(总工作集,含映射 DLL/共享页与已释放未回收的驻留页)→ 改为 `PROCESS_MEMORY_COUNTERS_EX2::PrivateWorkingSetSize`(与任务管理器「内存」列一致;失败退回 `PrivateUsage` → `WorkingSetSize`)。实测加载后 117.55/109.96 MiB、关闭分组后 15.44/7.62 MiB —— **无泄漏,内存完整归还**。④ **边沿计数性能**:改前 25.8M 采样 ≈**115ms/条/帧**(5 条同网格 = 575ms/帧,用户报告"开启即卡顿、之后拖不动");改为按 `kEdgeBlockSamples=8192` 分块累积、每 tick 预算 `kEdgeScanPairsPerTick=1.2M`、区间用块和相加(区间不进键)、拖动横向光标期间不推进并显示上一个完整值 + `complete=false`(`EdgeCountsText` 显示"统计中…/…");实测首帧 1.7ms、单次最坏 1.9ms、拖动中 10 次查询 0.11ms、最终结果与参考实现逐位一致。⑤ **内存排查报告**(用户要求,第 4 项只排查未改动):弱峰调整工作缓冲实测 **≈34 B/采样**(极值表 `PeakExtremum` 24 B/极值为主导),**4 条工作线程并发 +412 MiB / 3M 采样** ⇒ 25.8M 采样峰值 ≈**3.5GB**(+数据 1.03GB +曲线缓存 ≤768MB ⇒ 4~5GB,与用户 4.4GB 观测吻合);**全仓无 `try`/`catch`** ⇒ 低内存时 `bad_alloc` 逸出线程入口 ⇒ `std::terminate()` 崩溃(无优雅降级机制)。验证:离屏 `uitest` **738/738 通过**(新增 `[AR]` 边沿计数预算/精确性/拖动期 O(1)、`[AS]` 标题栏 RAM 口径),`make release` 零警告。 |
| 2026-10-14 | DeepSeek-V4.1-Flash(**起源设备**) | **边沿计数改用「绘制值」+ 派生信号烘入时间偏移 + 图例托底统一为 GX 徽标款 + 调整二级面板向上展开(详见 §9.21)**。① **边沿计数恒为 0**:`EdgeCountsFor`/`EdgeCenterCountsFor` 只比原始采样值,而阈值是屏幕上的值 → 「系数 50」的信号统计恒为 0;修法:判定值改为 `ApplyDrawTransform(style, values[SourceIndexFor(...)])`(中心轴两侧同套变换;水平镜像时曲线窗口也要先映射下标);缓存新增 **`styleKey`**(否则改完「调整」仍命中旧计数)。② **区间边界点被误计**:`EdgeIndexRange` 左端“多取一个采样点”的外扩**已删除**,收紧为“**两个端点都在区间内**才计入”(光标恰落在越过阈值的点 B 上时 B 不再被算作上升沿)。③ **`offsetTime` 也烘进派生采样值**(**推翻 §9.19 的“唯一例外”**):按 `time[i]-offsetTime` 取最近采样点重采样(单调时间轴用只前进的游标,O(n);越界置 NaN),新信号样式**全默认**;非单调时间轴退回到把 `offsetTime` 带在样式上。④ **图例托底 = GX 徽标同款**(同高 / 圆角 = 高度一半 / 同配色 / 水平内边距 = 胶囊半径),文本改用新增字体 **`uiTiny`(10 逻辑 px)**;`kLegendPlateRadius` 已删。⑤ **`PopupMenu::Open(..., expandUp=true)`**(锤点为**左下角**、向上展开,`m_buildExpandUp` 供 `RebuildLayout` 复用)+ 「系数/偏移」「镜像」面板锤在按钮左上角再上移 `kAdjustMenuGap`。验证:`.preflight/uitest` 新增 `[AR]`(五组断言全部与**独立参考实现**逐位比对,含 A/B:“按原始采样值算会得到 7/6 个边沿”);**并同步修正 harness 里两处旧规则断言**(`[AP]` 派生信号继承 → 默认样式 + 烘值;`[AN]` 图例带无托底 → 装饰矩形逐块且暴露列可见底层内容);合计 **722/722 通过**,`make release` 零警告。 |
| 2026-10-15 | DeepSeek-V4.1-Flash(**另一台设备**) | **「调整」数值符号规则 + 边沿统计按帧推进(含首帧公平分摊)+ 浮动面板排版与弱峰复位控件(详见 §9.26)**。① **符号**:Y 轴偏移 0 不再显示 `+0.00`;系数非 0 正值带 `+`(编辑态初始文本仍无符号);`Δt:` 后按正负带符号(`Δt:+5s 60ms` / `Δt:-5s 60ms` / `Δt:0s`)。② **边沿统计卡住**根因:预算按 `GetTickCount64()` 分桶(系统粒度 15.6ms)而 `PostMessage` 自持重绘把帧率拉高 ⇒ 同一 tick 内多帧完全不推进 → 改为 **`Paint` 开头按帧重置**;并给**每个状态独立每帧配额**(`share = max(6, m_edgeScans.size())`,避免同帧新建状态被第一个独占整额)→ 实测首帧 5 个状态全部推进、**9 帧内全部扫完且不注入任何鼠标消息**、与参考实现逐位一致。③ **面板排版**:信号名宽度上限 `kEdgePanelNameMaxWidth=150`(300 字符名字只多 44px,旧实现多 5100px);统计信息**两列等宽**(`2×max(列宽)+间距`)⇒ 多信号“上升沿/下降沿”列起点固定、垂直对齐;未完成时**两个数字后都带 `…`**(旧实现只跟在“下降沿”后)。④ **弱峰调整**:每行滑块右侧新增**复位控件**(复用还原图标,悬停高亮 + `IDC_HAND` + 提示;点击恢复幅度 0 / 迭代 1 且上限回 10);上限输入框提示改为“输入上限(当前N)”;滑块标签去掉多余的 `%` / `N`。验证:新增离屏探针 `panelcheck` **46/46 通过**(全部走真实菜单/点击路径),既有 `verify` 47/47(另 2 条为本机未同步的旧规则断言),`make release` 零警告。 |
| 2026-10-15 | DeepSeek-V4.1-Flash(**另一台设备**) | **数据层大重构(文件→分组 / 时间轴一等公民 / 加载器抽象)+ CSV 多时间轴 + MF4 支持(详见 §9.27 与 `Documents/refactor_report_2026-10-15.md`)**。① **解耦**:`DataFile`→**`SignalGroup`**(删除 `filePath`/`fileName`/单一 `time`/`timeColumnName`/`timeMonotonic`/`minTime`/`maxTime`),新增 **`TimeAxis`**(1..N 条/组)与 `SignalData::axisIndex`,`SignalData` 删除冗余的 `rawHeader` 与**全仓无人使用的 `unit`**;`SignalRef::fileIndex`→`groupIndex`;`DataSet`/`TabConfig` 全套改名并新增 `Axis(ref)`;采样/统计 API(`SampleSignalAt`/`FindNearestSample`/`VisibleIndexRange`/`EdgeIndexRange`/`EdgeCountsFor`/`EdgeCenterCountsFor`/`EdgeScanFor`/`MeanFilterValueAt`/`WeakPeakValueAt`/`SampleSpacingPixels`)**首参由 `const DataFile&` 改为 `const TimeAxis&`**;`ResolvedSignal` 携带 `{group, axis}`。② **加载器抽象**:新增 `DataTable.hpp`(`DataTable`/`LoadProgress`/`TableBlock`/`LoadedSource`)、`DataLoader.*`(唯一入口 `LoadSignalSource` 按扩展名分发)、`CsvLoader.*`、`Mf4Loader.*`,删除 `CsvTable.*`;唯一装配点 `BuildSignalGroup` 与格式无关(新增格式只写加载器)。③ **CSV 多时间轴**:每个 `time` 列开启新块、各块按自己的时间列裁剪 ⇒ 支持各轴不等长(实测 3 条轴 7/4/7),**不再把其它 `time` 列当成信号**;时间轴判据收紧。④ **MF4 支持**:加载后新建分组、组名=文件名,全文件信号归入该组;每个通道组(CG)一条时间轴并自动去重(实测 27 CG → 3 轴 / 124 信号);实现 MDF v4 块解析(DG/CG/CN/CC/DT)、位级整数与 32/64 位浮点解码、线性换算 `phys = cc_val[0] + cc_val[1]*raw`、无效位处理;**`##TX` 兼容规范的「长度+UTF-16」与 CANape 的「裸 NUL 结尾文本」**(否则通道名全空)。⑤ 顺带修复:`Application::Initialize` 在窗口类已注册时不再弹模态框(旧实现导致同进程二次构造永久阻塞),`Shutdown` 注销窗口类;UI 文案统一到「分组」,对话框支持 `*.mf4/*.mdf`。验证:新增 `datacheck` **69/69**、`edgecheck` **46/46**(替代被编码破坏的 `panelcheck`)、既有 `verify` **47/47**(另 2 条本机旧规则断言)、`make release` 零警告。 |
---
@@ -1561,6 +1658,28 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
`rowIterResets` **必须进 `PanelSlotKey`**(否则悬停高亮不会重绘);两行按钮共用同一 X。
⚠️ 滑块标签不得带多余的 `%` / `N`(但 MEAN / CENTER 参数输入框的 `迭代次数 N` 保持不变);
迭代次数上限输入框的空内容提示为 `输入上限(当前N)`。
49. ⚠️ **数据模型里禁止出现「文件」概念**(§9.27 重构后的硬约束):
分组 `SignalGroup` **不保存路径/文件名**(用户明确要求:加载后与源文件再无关系);
时间轴是独立的一等公民 `TimeAxis`,信号通过 `SignalData::axisIndex` 指向它;
引用用 `SignalRef::groupIndex`(不是 `fileIndex`)。
✓ 需要「时间 + 采样值」的函数一律只接 `const TimeAxis&` + `const SignalData&`,
**不要**再接 `const DataFile&`/`const SignalGroup&`(那是旧的过度耦合,已全部改掉)。
50. ⚠️ **新增文件格式只需加一个加载器**:实现 `Load*Source(path, LoadedSource&, error, progress)`,
在 `DataLoader.cpp` 按扩展名登记即可;数据模型(`BuildSignalGroup`)与全部界面代码**不得**因新格式而改动。
`LoadedSource` = 若干「时间轴块」(`TableBlock` = 一个时间列 + 同轴数据列),
时间轴去重、命名、取色、统计都在 `BuildSignalGroup` 里完成。
51. ⚠️ **CSV 多时间轴语义**:只有列名为 `t`/`time`/`timestamp`/`simulation time`/`时间`/`时间轴` 的列是时间轴
(判据**不得**放宽为「包含 time」,否则 `timeout` 之类会被误判并切出空块);每个时间列开启新块,
各块按**自己的时间列**裁剪 ⇒ 允许各轴长度不同。没有任何时间列时才回退「第一列是时间轴」。
52. ⚠️ **MF4(MDF v4)解析要点**:块头 24 字节 + N 个 8 字节文件偏移链接;
`DG[1] = CG`、`DG[2] = DT`、`CG[1] = CN`、`CG[2] = tx_name`、`CG data+8 = cycle_count`、
**记录长度 = DT 载荷 ÷ cycle_count**;`CN[2] = 名称`、`CN[4] = CC`,数据段
`+1 sync_type`(1 = 时间主通道)、`+2 data_type`、`+3 bit_offset`、`+4 byte_offset(字节)`、
`+8 bit_count`、`+12 flags`、`+16 invalid_bit_pos`;**绝对位偏移 = byte_offset*8 + bit_offset**;
`CC` 的 `+24 cc_val[]`,**换算 = `cc_val[0] + cc_val[1]*raw`**(用 CC 的 phy_range 自检)。
⚠️ **`##TX` 文本块必须兼容两种写法**:规范的「U32 长度 + UTF-16/UTF-8」**与** CANape 的
「无长度前缀的 NUL 结尾裸文本」;只按规范解析会得到空通道名。
压缩块(`##DZ`/`##HL`/`##SD`)、`##DL` 数据列表、字符串/MIME 通道**未实现**(跳过该组)。
### 11.3 离屏验证方法学(本机偏好,强烈建议沿用)
+245
View File
@@ -0,0 +1,245 @@
# 数据层审计与重构报告(2026-10-15)
> 本轮任务:① 审计代码库的命名/参数/注释/数据结构;② 在审计基础上做一次解耦重构(大刀阔斧,
> 但**保持全部交互与显示不变**);③ 完善 CSV(多时间轴 / 各轴不等长)并新增 MF4 支持。
>
> 结论:**数据层与加载层被重写为「分组 / 时间轴 / 信号」三段式结构**,文件格式与数据模型彻底解耦;
> CSV 多时间轴与 MF4 均已支持;三套离屏测试(69 + 46 + 47 条断言)通过;`make release` 零警告。
---
## 一、审计发现的问题(按严重度)
### 1.1 「文件」概念渗进了数据模型的核心(最严重)
| 位置 | 问题 |
| --- | --- |
| `DataFile`(结构体名) | 名字把「一次加载产生的信号集合」和「磁盘上的文件」绑死;实际应用语义是**信号分组**。 |
| `DataFile::filePath` / `fileName` | 加载完成后应用与文件再无关系(文件可移动/删除/改名),这两个字段在运行期**没有任何用途**(`filePath` 只在加载消息里出现一次)。 |
| `DataFile::time` / `timeColumnName` / `timeMonotonic` / `minTime` / `maxTime` | 把「时间轴」焊死在分组上 ⇒ **一个分组只能有一条时间轴**,与 CSV 多 `time` 列、MF4 多通道组的现实不符。 |
| `SignalRef::fileIndex` | 引用语义被写成「文件下标」,全仓 223 处;后来者会以为信号与文件有绑定关系。 |
| `EdgeCountsFor(const DataFile& file, const SignalData& signal, …)` | **典型文不对题**:函数只需要「时间 + 采样值」,却要求调用方提供整个「文件」;这也是用户指出的重点。 |
| `SampleSignalAt(const DataFile& file, …)`、`FindNearestSample(const DataFile& file, …)`、`VisibleIndexRange(const DataFile& file, …)`、`EdgeIndexRange(const DataFile& file, …)`、`MeanFilterValueAt`、`WeakPeakValueAt` | 同上一类:参数语义过宽,时间轴与信号被迫通过「文件」间接耦合。 |
| `ResolvedSignal::file` | 视图层的解析结果也携带「文件」指针,导致采样/命中/包络/边沿统计处处 `resolved.file->time`。 |
### 1.2 `SignalData` 的冗余字段(用户指出的重点)
| 字段 | 问题 | 结论 |
| --- | --- | --- |
| `name` | 源名称(列名/通道名)。 | **保留**,语义明确:加载后原样不变。 |
| `displayName` | 用户重命名。 | **保留**(`Title()` 优先返回它)。 |
| `rawHeader` | `name` 的副本(构建时 `rawHeader = headers[i]; name = Trim(rawHeader)`),派生信号里又 `rawHeader = derived.name`。 | **删除**(纯冗余,两个字段表达同一件事)。 |
| `unit` | 由列名括号里猜出来的单位,全仓只有 2 处写、0 处读(从未显示、从未参与计算)。 | **删除**(属于「未实现的元数据」残留;用户明确说单位元数据暂不支持)。 |
| 时间信息 | 缺失 ⇒ 只能靠「文件」找时间轴。 | **新增 `axisIndex`**:信号指向所属时间轴,采样值与时间的对应关系自包含。 |
### 1.3 加载层与格式耦合
| 位置 | 问题 |
| --- | --- |
| `CsvTable`(结构体) | 「从文件读出来的原始表格」这个名字把通用中转结构钉死在 CSV 上;后续每加一种格式都要再抄一遍类似结构。 |
| `CsvProgress` / `CsvProgressInfo` | 名字里的 `Csv` 让「加载进度」这类通用概念只服务于 CSV。 |
| `BuildDataFile(CsvTable&&, path, …)` | 建组函数直接吃 CSV 解析结果;MF4(或任何其它格式)都无法复用,只能复制一份逻辑。 |
| `bool BuildDataFile(...)` 里的「识别时间轴列 / 回退第一列」逻辑 | 与「多时间轴」需求正面冲突:只认**第一个**时间列,且要求所有列等长。 |
| `.preflight` 之外无任何「格式 → 数据」的装配点 | 新增格式要在 `Application::RunAsyncLoad` 里改流程。 |
### 1.4 过期 / 文不对题的注释
| 位置 | 问题 |
| --- | --- |
| `LooksLikeTimeHeader` | 判据是「列名含 time/时间」⇒ 会把 `timeout`、`TimeStampExtra` 之类误判成时间轴(多时间轴下会**切出空块**)。 |
| `ExtractUnit` | 解析出来的单位无人使用(见 1.2)。 |
| 大量 `@param [IN] file 信号所属文件` | 参数已改语义后注释未跟(本轮全部改为 `axis 该信号使用的时间轴`)。 |
| UI 文案 | `选中该文件全部信号` / `取消该文件全部信号` / `关闭该文件(从工作区移除)` / `请关闭部分文件分组或信号` 等,把「分组」说成「文件」。 |
| 空状态文案 | `尚未加载数据,请打开 CSV 文件` / `打开 CSV 文件以查看仿真数据波形`:应用已不只支持 CSV。 |
### 1.5 健壮性缺陷(审计中顺带发现)
| 位置 | 问题 |
| --- | --- |
| `Application::Initialize` | `RegisterClassExW` 失败即弹**模态**错误框。同一进程内第二次构造 `Application` 必然命中 `ERROR_CLASS_ALREADY_EXISTS` ⇒ 调用方**永久阻塞**(本轮离屏测试就被它卡住)。`Shutdown` 也从不注销窗口类。 |
| `Application::LoadFile` 失败路径 | 会弹模态对话框(离屏/自动化场景是雷区)。已在测试中绕开;建议后续把「错误提示」与「模态阻塞」解耦。 |
| MDF 文本块解析 | `##TX` 在 MDF 规范里是「长度 + UTF-16/UTF-8」,但 CANape 16 会写**无长度前缀的裸 ASCII**(见 §三),只按规范解析会得到空名称。 |
---
## 二、重构内容(改动清单)
### 2.1 数据模型(`Utilities/Inc/DataModel.hpp`、`Utilities/Src/DataModel.cpp`)
**新增 `TimeAxis`(时间轴成为一等公民)**
```cpp
struct TimeAxis {
std::wstring name; // 时间列名(源文件原样)
std::vector<double> time;
double minTime, maxTime;
bool monotonic;
std::size_t finiteCount;
std::size_t Count() const;
};
```
**`SignalData` 精简 + 指向时间轴**
```cpp
struct SignalData {
std::wstring name; // 源名称(原样保留、永不修改)
std::wstring displayName; // 用户重命名(空 = 用 name)
std::size_t axisIndex = 0; // 所属时间轴(分组内下标) ← 新增
std::vector<double> values;
double minValue, maxValue; std::size_t finiteCount;
std::vector<double> coarseMin, coarseMax; std::size_t coarseStride;
SignalStyle style;
const std::wstring& Title() const;
};
// 删除:rawHeader、unit
```
**`DataFile` → `SignalGroup`(分组 = 若干时间轴 + 若干信号)**
```cpp
struct SignalGroup {
std::wstring name; // 默认取源文件名(便于辨认来源)
std::wstring displayName; // 用户重命名
std::vector<TimeAxis> axes; // 1..N 条时间轴 ← 取代单一 time
std::vector<SignalData> signals;
const std::wstring& Title() const;
const TimeAxis& AxisOf(const SignalData&) const;
const TimeAxis& AxisAt(std::size_t) const;
bool TimeRange(double& low, double& high) const; // 全部轴的并集
};
// 删除:filePath、fileName、time、timeColumnName、timeMonotonic、minTime、maxTime
```
**引用与集合**
* `SignalRef::fileIndex` → **`groupIndex`**(含注释:「分组是应用内的逻辑容器,与磁盘文件没有任何关系」)。
* `DataSet`:`AddDataFile→AddGroup`、`RemoveFile→RemoveGroup`、`FileAt→GroupAt`、`FileCount→GroupCount`、
`Files()→Groups()`、`SetFileDisplayName→SetGroupDisplayName`、
`RemapSelectionAfterFileRemoval→RemapSelectionAfterGroupRemoval`;新增 **`Axis(ref)`**(解析信号→时间轴)。
* `TabConfig`:`SetFileVisible→SetGroupVisible`、`FileHasVisible→GroupHasVisible`、`FileAllVisible→GroupAllVisible`、
`RemapAfterFileRemoval→RemapAfterGroupRemoval`、`RowKind::FILE→RowKind::GROUP`、`CMD_FILE_*→CMD_GROUP_*`。
**采样与统计 API 去掉「文件」**
```cpp
bool SampleSignalAt(const TimeAxis&, const SignalData&, double time, bool interpolate, double&);
bool SampleDisplayedSignalAt(const TimeAxis&, const SignalData&, const SignalStyle&, …);
int FindNearestSample(const TimeAxis&, double time);
// PlotView:SampleSpacingPixels / VisibleIndexRange / EdgeIndexRange /
// MeanFilterValueAt / WeakPeakValueAt / EdgeCountsFor /
// EdgeCenterCountsFor / EdgeScanFor —— 首参全部由 const DataFile& 改为 const TimeAxis&
```
**视图层**
* `ResolvedSignal`:`const DataFile* file` → **`const SignalGroup* group` + `const TimeAxis* axis`**;
所有 `resolved.file->time` 改为 `resolved.axis->time`,`resolved.file->Title()` 保留走 `group`。
* `PolylineCacheEntry::file` → `group`;`EdgeScanState::file` → **`axis`**(统计身份 = 时间轴 + 信号)。
* `TabConfig::ComputeVisibleTimeBounds`:只统计**可见信号真正用到的时间轴**(多轴分组下避免拉出过宽范围)。
* 时间轴刻度列名取「首个可见信号所属分组的第一条时间轴」。
### 2.2 加载层(新文件,格式与模型解耦)
```
Utilities/Inc/DataTable.hpp DataTable(通用列优先表格)+ LoadProgress/Info
+ TableBlock(一个时间轴块)+ LoadedSource(源文件的全部块)
Utilities/Inc/DataLoader.hpp LoadSignalSource(path,…) 唯一入口 + IsSupportedSignalFile + 对话框过滤器
Utilities/Src/DataLoader.cpp 按扩展名分发(.csv/.txt → CSV;.mf4/.mdf → MDF4;其它按文本尝试)
Utilities/Inc/CsvLoader.hpp LoadCsvSource
Utilities/Src/CsvLoader.cpp CSV 解析 + 多时间轴切分 + 各轴按自己的时间列裁剪
Utilities/Inc/Mf4Loader.hpp LoadMf4Source
Utilities/Src/Mf4Loader.cpp ASAM MDF v4 解析(见 §三)
删除:Utilities/Inc/CsvTable.hpp、Utilities/Src/CsvTable.cpp
```
**唯一装配点**:`BuildSignalGroup(LoadedSource&&, paletteBase, SignalGroup&, error, progress)`
—— 与格式无关,负责:时间轴**去重合并**(内容逐位相同只保留一条)、信号命名、调色板取色、统计与粗粒度包络。
新增格式时**只需写一个加载器**,数据模型与全部界面代码不用改。
### 2.3 CSV:多时间轴 + 各轴不等长
* 规则(与 MATLAB/Simulink 说明一致):**只有名为 `time` 的列是时间轴**;每个时间列开启一个新块,
它之后直到下一个时间列之前的列都属于该块。
* 各块按**自己的时间列**裁剪(较短的时间轴在数据区提前结束,其余位置为空)⇒ 支持
`TestData/Multi_Timeline.CSV`(`time,data1,time,data2,data3,time,data4`,实测切出 3 条轴,长度 7/4/7)。
* 修复用户报告的缺陷:**不再把其它 `time` 列当成信号显示**。
* 保留旧行为:完全没有 `time` 列时退化为「第一列是时间轴」的单轴文件。
* 判据收紧:`LooksLikeTimeHeader` 只接受 `t` / `time` / `timestamp` / `simulation time` / `时间` / `时间轴`
(不再用「包含 time」这种会误判 `timeout` 的规则)。
### 2.4 MF4:新增支持
* 加载后**新建一个分组、组名 = 文件名**,文件内的全部信号都归入该分组(用户要求的行为)。
* 每个**通道组(CG)**是独立时间轴:内容相同的时间轴自动合并
(实测该文件 27 个 CG → **3 条时间轴**、124 条信号,避免 25 条重复的单样本轴)。
* 只解码绘制需要的部分:字符串/字节数组通道、压缩块(DZ/HZ)、MIME 通道一律跳过;
非数值通道不产生信号。
### 2.5 UI 文案与健壮性
* 文案统一到「分组」:`选中该分组全部信号`、`取消该分组全部信号`、`关闭该分组(从工作区移除)`、
`请关闭部分分组或信号后重试。`、`尚未加载数据,请打开数据文件`、`打开数据文件以查看波形`、
`(可同时加载多个文件,各自成为一个分组)`;打开对话框标题改为「打开测量数据文件」并支持 `*.mf4/*.mdf`。
* `Application::Initialize`:`RegisterClassExW` 返回 `ERROR_CLASS_ALREADY_EXISTS` 时**复用**已注册的类
(不再弹模态错误框);`Shutdown` 注销窗口类并销毁窗口。⇒ 同一进程可多次构造/销毁 `Application`。
---
## 三、MDF v4 解析要点(本轮实测得到,供后续维护)
文件实测为 **MDF 4.10 / CANape 16.0.50**(`##HD … ##DG … ##CG … ##CN … ##CC … ##DT`)。
* **块头(24 字节)**:`id[4]` + `reserved[4]` + `block_len(U64)` + `link_count(U64)`;之后是
`link_count` 个 **8 字节绝对文件偏移**(0 = 空);数据段紧随链接区。DT/HL 等块可以 0 链接。
* **ID 块**:偏移 0 起 `"MDF "`,偏移 8 起版本串(如 `"4.10 "`)。
* **HD**:链接 `[0] = 第一个 DG`。
* **DG**:链接 `[1] = 第一个 CG`、`[2] = 该组的数据块(DT)`。
* **CG**:链接 `[1] = 第一个 CN`、`[2] = tx_name`;数据段 `+8 = cycle_count`。
**记录长度**取 `DT 数据段字节数 / cycle_count`(实测 114/110 字节,与声明一致),拿不到时按通道位宽向上取整。
* **CN**:链接 `[2] = 名称 TX`、`[4] = 换算 CC`;数据段
`+0 type`、`+1 sync_type`(**1 = 时间主通道**)、`+2 data_type`、`+3 bit_offset`、
`+4 byte_offset(U32,单位**字节**)`、`+8 bit_count`、`+12 flags`(bit0 整列无效 / bit1 有无效位)、
`+16 invalid_bit_pos`、`+32 val_range_min`、`+40 val_range_max`。
**绝对位偏移 = byte_offset × 8 + bit_offset**。
* **data_type**:0/1 无符号(LE/BE)、2/3 有符号、4/5 IEEE 浮点、≥6 字符串/字节数组(跳过)。
浮点只接受字节对齐的 32/64 位;整数支持 1–64 位任意位偏移(含符号扩展)。
* **CC**:`+0 type`、`+1 precision`、`+2 flags`、`+4 ref_param_nr`、`+6 val_count`、
`+8 phy_range_min`、`+16 phy_range_max`、`+24 cc_val[]`(每个 8 字节)。
**换算公式(实测确认)= `phys = cc_val[0] + cc_val[1] × raw`**。
本文件里时间主通道是「double 纳秒 + 线性换算 1e-9」⇒ 解码后时间范围 `[0.024320125, 42.3918] s`
与 CC 里的物理范围逐位吻合,可据此自检。
* **文本块(TX)**:规范是「U32 长度 + UTF-16 代码单元」(MDF 4.1 起也可 UTF-8 字节数),
但 **CANape 会写无长度前缀的 NUL 结尾裸文本**。解析顺序:先按长度前缀(UTF-16 → UTF-8),
失败则按「裸文本(自动判断 UTF-16/UTF-8)」回退。
---
## 四、验证(全部离屏;进程/线程 `BELOW_NORMAL`、窗口 `SW_HIDE`、不注入真实鼠标键盘)
| 套件 | 覆盖 | 结果 |
| --- | --- | --- |
| `.preflight/perf/datacheck_main.cpp` | 单轴 CSV 回归;多时间轴 CSV(3 轴、7/4/7、不再出现 `time` 信号);MF4(组名、124 信号、3 条轴、主时间轴 ns→s、`setSpeed` 首样本与原始字节人工核对);`DataSet` 不变量(轴共享/解析/时间范围/重命名);派生信号沿用源时间轴;**MF4 集成**(124 信号并入一个网格渲染 67.5ms/帧) | **69/69 通过** |
| `.preflight/perf/edgecheck_main.cpp` | 「调整」符号规则(真实菜单路径);边沿统计按帧推进 / 不饿死 / 与参考实现逐位一致 / 无鼠标输入也完成;面板两列等宽与名称限宽;弱峰复位控件 | **46/46 通过** |
| `.preflight/perf/verify_main.cpp`(既有) | PlotView 渲染/交互:Y 自适应、派生信号、图例命中、GX 徽标悬停、装饰层无残影、平移逐像素 | **47/47**(另有 2 条**本机未同步的旧规则断言**:`derived offsetTime kept`、`mouse in lane1 lower half`,与本轮无关) |
| 构建 | `make release` | **零警告** |
补充:`Target.exe` 手工运行确认加载 `Multi_Timeline.CSV` 与 `MF4-Record.MF4` 正常(真实交互由用户后续验收)。
---
## 五、遗留与建议(未在本轮实施)
1. **错误提示与模态阻塞解耦**:`SetStatusMessage(..., isError=true)` 直接 `MessageBoxW`,
在自动化/离屏场景会永久阻塞。建议改为宿主级非模态提示(或至少让调用方可选择不弹)。
2. **MF4 压缩块(`##DZ`/`##HL`/`##SD`)与 `##DL` 数据列表**未实现:遇到这类文件会跳过相应通道组。
如后续需要,解析 `DL` 的偏移数组即可(数据布局不变)。
3. **MF4 单元/注释(`##CC` 的 unit TX、`##MD`)未使用**:用户明确暂不需要元数据。
4. **`Component` 层仍在 `TabConfig` 里保存逐信号的边沿统计/预览参数**:属于「视图配置」与「数据」的
第二次耦合(当前可用,但若要支持「同一信号在不同标签页用不同统计参数」需再拆一层)。
5. **`Lane` 内含 4 类实验性功能的状态与面板坐标**(`edgeCount/meanFilter/weakPeak` + 各自面板 X/Y),
已经把 `Lane` 撑到 200+ 行;建议后续抽出 `LanePanelState`。
6. **`PlotView.cpp` 仍有 1.1 万行**:渲染分支(静态缓存/宽基底/样式补丁/整幅)与交互命中混在一个类里,
建议按「渲染器 / 命中测试 / 派生曲线调度」拆成 3 个协作类(风险较高,宜单独一轮做)。
7. **`SignalRef` 仍是「分组下标 + 组内下标」**:删除/重排分组时依赖调用方按顺序调用
`Remap*` 系列函数维护;可考虑改为稳定 ID(后续需要撤销/重做时收益更大)。
+33
View File
@@ -0,0 +1,33 @@
#ifndef __CSV_LOADER_HPP__
#define __CSV_LOADER_HPP__
#include <string>
#include "DataTable.hpp"
namespace jdi
{
/**
* @brief 读取并解析一个 CSV 文本文件。
*
* 文件以分块流式方式读取(不把整个文件一次性读入内存),
* 因此内存占用只与解析结果相关,不会因文件体积成倍放大,也没有体积上限。
*
* **多时间轴**:CSV 允许出现多个时间列(列名形如 `time`)。每个时间列都会开启一个新的
* 时间轴块,它**之后**直到下一个时间列之前的所有列都属于该块。
* 各块之间的行数可以不一致(较短的时间轴在数据区提前结束,其余位置留空)。
* 只出现一个时间列时结果与旧的单时间轴行为完全一致。
*
* @param [IN] filePath CSV 文件完整路径(宽字符,支持中文路径)。
* @param [OUT] source 解析结果(按时间列切分为若干时间轴块)。
* @param [OUT] error 失败原因描述(成功时清空)。
* @param [IN] progress 解析进度回调,可为空。
* @return 解析成功返回 true,否则返回 false。
*/
bool LoadCsvSource(const std::wstring& filePath, LoadedSource& source, std::wstring& error,
const LoadProgress& progress = LoadProgress());
} // namespace jdi
#endif // __CSV_LOADER_HPP__
-76
View File
@@ -1,76 +0,0 @@
#ifndef __CSV_TABLE_HPP__
#define __CSV_TABLE_HPP__
#include <cstddef>
#include <functional>
#include <string>
#include <vector>
namespace jdi
{
/**
* @brief CSV 文件的解析结果,保存列名与**列优先**的数值矩阵。
*
* 采用列优先(每条信号一整块连续内存)而不是行优先的二维数组,原因有二:
* 1. 大型文件(百万行级)若按行存储会产生上百万次独立堆分配,
* 每行还要额外承担 vector 的控制块与容量对齐开销,实测内存放大接近 1.5 倍;
* 2. 后续构建信号时可以直接把整列**移动**进 SignalData,不再逐元素拷贝。
*/
struct CsvTable
{
std::vector<std::wstring> headers; /**< 列名(已去除引号与首尾空白)。 */
std::vector<std::vector<double>> columns; /**< 列优先数值矩阵:columns[列][行],缺失处为 NaN。 */
std::size_t rowCount = 0; /**< 有效数据行数。 */
wchar_t delimiter = L','; /**< 实际使用的分隔符。 */
unsigned long long fileBytes = 0; /**< 源文件字节数(用于按比例预留容量)。 */
/**
* @brief 判断解析结果是否包含可用的表格数据。
* @return 存在列名且存在数据行时返回 true。
*/
bool HasData() const
{
return !headers.empty() && (rowCount > 0);
}
};
/**
* @brief 加载进度信息。
*
* 解析阶段会携带真实的"已读 / 总字节数";列重组与统计阶段没有字节可计,
* 此时 `hasBytes` 为 false,界面应只展示百分比。
*/
struct CsvProgressInfo
{
double ratio = 0.0; /**< 0-1 总体完成比例。 */
unsigned long long bytesRead = 0; /**< 已读取字节数。 */
unsigned long long totalBytes = 0; /**< 文件总字节数。 */
bool hasBytes = false; /**< 上述字节数字段是否有效。 */
};
/**
* @brief 解析进度回调。
*
* 在解析过程中按块回调;返回 false 表示请求取消,解析会尽快停止并返回失败。
*/
using CsvProgress = std::function<bool(const CsvProgressInfo& info)>;
/**
* @brief 读取并解析一个 CSV 文件。
*
* 文件以分块流式方式读取(不把整个文件一次性读入内存),
* 因此内存占用只与解析结果相关,不会因文件体积成倍放大,也没有体积上限。
*
* @param [IN] filePath CSV 文件完整路径(宽字符,支持中文路径)。
* @param [OUT] table 解析结果输出。
* @param [OUT] error 失败原因描述(成功时清空)。
* @param [IN] progress 解析进度回调,可为空。
* @return 解析成功返回 true,否则返回 false。
*/
bool LoadCsvTable(const std::wstring& filePath, CsvTable& table, std::wstring& error,
const CsvProgress& progress = CsvProgress());
} // namespace jdi
#endif // __CSV_TABLE_HPP__
+45
View File
@@ -0,0 +1,45 @@
#ifndef __DATA_LOADER_HPP__
#define __DATA_LOADER_HPP__
#include <string>
#include "DataTable.hpp"
namespace jdi
{
/**
* @brief 判断路径是否为应用当前支持的测量文件(按扩展名判断,不访问文件内容)。
* @param [IN] filePath 文件路径。
* @return 支持返回 true。
*/
bool IsSupportedSignalFile(const std::wstring& filePath);
/**
* @brief 供「打开文件」对话框使用的过滤器文本(`GetOpenFileNameW` 的 `lpstrFilter` 格式)。
* @return 双 NUL 结尾的过滤器文本。
*/
const wchar_t* SignalFileDialogFilter();
/**
* @brief 按扩展名选择加载器并读入一个测量文件(**唯一的文件加载入口**)。
*
* 上层(数据模型与界面)只依赖本函数返回的 `LoadedSource`,因此新增格式时
* **只需新增一个加载器并在这里登记**,其余代码完全不用改。
*
* 当前支持的格式:
* * CSV 文本(`.csv` / `.txt`):支持多个 `time` 列(多时间轴)与各时间轴不等长;
* * ASAM MDF v4(`.mf4` / `.mdf`):每个通道组(CG)是一个时间轴。
*
* @param [IN] filePath 文件完整路径。
* @param [OUT] source 解析结果(若干时间轴块)。
* @param [OUT] error 失败原因描述(成功时清空)。
* @param [IN] progress 进度回调,可为空。
* @return 加载成功返回 true。
*/
bool LoadSignalSource(const std::wstring& filePath, LoadedSource& source, std::wstring& error,
const LoadProgress& progress = LoadProgress());
} // namespace jdi
#endif // __DATA_LOADER_HPP__
+210 -105
View File
@@ -9,7 +9,8 @@
#include <atomic>
#include <vector>
#include "CsvTable.hpp"
#include "DataLoader.hpp"
#include "DataTable.hpp"
namespace jdi
{
@@ -61,18 +62,23 @@ namespace jdi
};
/**
* @brief 全局信号引用:文件下标 + 文件内信号下标。
* @brief 全局信号引用:**分组下标 + 组内信号下标**。
*
* ⚠️ 这里的「分组」是信号在应用内的**逻辑归属**(加载一个文件形成一个分组,
* 用户可以重命名),与磁盘上的文件**没有任何关系**:文件可以在加载后被移动、
* 重命名甚至删除,已加载的数据不受影响。因此引用只由两个下标构成,
* 不携带路径等易失效的信息。
*/
struct SignalRef
{
int fileIndex = -1; /**< 所属文件下标。 */
int signalIndex = -1; /**< 文件内信号下标。 */
int groupIndex = -1; /**< 所属分组下标。 */
int signalIndex = -1; /**< 组内信号下标。 */
/**
* @brief 判断引用是否指向有效位置。
* @return 两个下标均非负返回 true。
*/
bool IsValid() const { return fileIndex >= 0 && signalIndex >= 0; }
bool IsValid() const { return groupIndex >= 0 && signalIndex >= 0; }
/**
* @brief 相等比较。
@@ -81,7 +87,7 @@ namespace jdi
*/
bool operator==(const SignalRef& other) const
{
return fileIndex == other.fileIndex && signalIndex == other.signalIndex;
return groupIndex == other.groupIndex && signalIndex == other.signalIndex;
}
/**
@@ -261,15 +267,42 @@ namespace jdi
}
}
/**
* @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` —— 源名称(列名 / 通道名),加载后**原样保留、永不修改**;
* * `displayName` —— 用户在界面上重命名后的名称,为空表示“用源名称”。
* 界面展示一律走 `Title()`。
*
* ⚠️ 信号的时间轴由 `axisIndex` 指向所属分组的时间轴列表,
* 因此采样值本身不需要、也不应该携带时间信息。
*/
struct SignalData
{
std::wstring name; /**< 名称(取自文件列名,完全保留原样)。 */
std::wstring displayName; /**< 用户重命名的显示名;为空时使用 name。 */
std::wstring rawHeader; /**< 原始列名。 */
std::wstring unit; /**< 从列名括号中提取的单位,可能为空。 */
std::wstring name; /**< 源名称(列名 / 通道名,原样保留)。 */
std::wstring displayName; /**< 用户重命名后的显示名;为空时使用 name。 */
std::size_t axisIndex = 0; /**< 所属时间轴在分组内的下标。 */
std::vector<double> values; /**< 采样值,缺失处为 NaN。 */
double minValue = 0.0; /**< 有限采样值最小值。 */
double maxValue = 0.0; /**< 有限采样值最大值。 */
@@ -292,89 +325,154 @@ namespace jdi
/**
* @brief 获取用于界面展示的名称。
* @return 用户重命名后的显示名,未重命名时返回文件中的原始名称。
* @return 用户重命名后的显示名,未重命名时返回源名称。
*/
const std::wstring& Title() const { return displayName.empty() ? name : displayName; }
};
/**
* @brief 一个已加载的数据文件及其自有时间轴。
* @brief 一个**信号分组**:一次加载产生的全部信号,以及它们使用的若干条时间轴。
*
* ⚠️ 「分组」是应用内的逻辑容器,**不是文件**:加载完成后二者已无关系,
* 因此这里**不保存文件路径**(路径只在加载那一刻用于错误提示,之后毫无用处)。
* 分组的默认名称取源文件名(便于用户辨认来源),用户可以重命名。
*
* 一个分组可以包含**多条时间轴**(CSV 的多个 `time` 列、MF4 的多个通道组),
* 每条信号通过 `SignalData::axisIndex` 指向自己那条。
*/
struct DataFile
struct SignalGroup
{
std::wstring filePath; /**< 文件完整路径(仅用于状态提示)。 */
std::wstring fileName; /**< 文件名称。 */
std::wstring displayName; /**< 用户重命名的分组名;为空时使用 fileName。 */
std::wstring timeColumnName; /**< 时间列列名。 */
std::vector<double> time; /**< 时间轴数值。 */
std::vector<SignalData> signals; /**< 该文件包含的信号。 */
double minTime = 0.0; /**< 时间轴最小值。 */
double maxTime = 0.0; /**< 时间轴最大值。 */
bool timeMonotonic = true; /**< 时间轴是否单调不减。 */
std::wstring name; /**< 分组默认名称(源文件名,含扩展名)。 */
std::wstring displayName; /**< 用户重命名后的分组名;为空时使用 name。 */
std::vector<TimeAxis> axes; /**< 该分组使用的时间轴(至少一条)。 */
std::vector<SignalData> signals; /**< 该分组包含的信号。 */
/**
* @brief 获取用于界面展示的分组名。
* @return 用户重命名后的显示名,未重命名时返回文件名。
* @return 用户重命名后的显示名,未重命名时返回源文件名。
*/
const std::wstring& Title() const { return displayName.empty() ? fileName : displayName; }
const std::wstring& Title() const { return displayName.empty() ? name : displayName; }
/**
* @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 由 CSV 解析结果构建可入库的数据文件。
* @brief 由一个**格式无关**的加载结果构建应用内的信号分组。
*
* 该函数**不访问 DataSet**(只依赖传入的 CsvTable 与调色板基准序号),
* 因此可以在工作线程上执行:大文件的解析与列重组占整个加载耗时的大头,
* 放到工作线程后主线程只需做一次 O(1) 的移动入队,加载过程中的界面不会卡住。
* 职责划分(新增文件格式时只需要写一个加载器,本函数完全不用改):
* * 加载器:文件 → `LoadedSource`(若干「时间轴块」,纯格子,无应用语义);
* * 本函数:`LoadedSource` → `SignalGroup`(时间轴**去重合并**、信号命名、
* 调色板取色、统计与粗粒度包络层)。
*
* table 按右值传入:每列会被**移动**进对应信号的 values(零拷贝)。
* 时间轴去重:内容完全相同(长度与全部数值逐位相同)的时间轴只保留一条,
* 于是 CSV 里多个同刻度的 `time` 列、MF4 里多个同基线的通道组不会重复占内存,
* 也不会让信号各自持有实质上相同的轴。
*
* @param [IN,OUT] table CSV 解析结果(列会被移走)。
* @param [IN] filePath 源文件路径。
* 该函数**不访问 DataSet**(只依赖传入数据与调色板基准序号),因此可以在工作线程上执行:
* 大文件的解析与列重组占整个加载耗时的大头,放到工作线程后主线程只需做一次 O(1) 的
* 移动入队,加载过程中的界面不会卡住。
*
* @param [IN,OUT] source 加载结果(列会被**移动**进信号,零拷贝)。
* @param [IN] paletteBase 调色板起始序号(通常传入当前已有信号数)。
* @param [OUT] file 构建结果。
* @param [OUT] group 构建结果。
* @param [OUT] error 失败原因描述(成功时清空)。
* @param [IN] progress 进度回调(0-1),返回 false 表示取消,可为空。
* @return 构建成功返回 true。
*/
bool BuildDataFile(CsvTable&& table, const std::wstring& filePath, std::size_t paletteBase, DataFile& file,
std::wstring& error, const CsvProgress& progress = CsvProgress());
bool BuildSignalGroup(LoadedSource&& source, std::size_t paletteBase, SignalGroup& group,
std::wstring& error, const LoadProgress& progress = LoadProgress());
/**
* @brief 数据集合:统一管理全部已加载文件,与视图配置无关。
* @brief 数据集合:统一管理全部已加载的**信号分组**,与视图配置无关。
*/
class DataSet
{
public:
/**
* @brief 追加一个已构建好的数据文件(仅做一次移动,不拷贝采样数据)。
* @param [IN,OUT] file 数据文件(内部缓冲会被移走)。
* @param [OUT] fileIndex 新文件下标。
* @brief 追加一个已构建好的信号分组(仅做一次移动,不拷贝采样数据)。
* @param [IN,OUT] group 信号分组(内部缓冲会被移走)。
* @param [OUT] groupIndex 新分组下标。
* @return 追加成功返回 true。
*/
bool AddDataFile(DataFile&& file, int& fileIndex);
bool AddGroup(SignalGroup&& group, int& groupIndex);
/**
* @brief 在指定文件的指定信号**下方**插入一条新信号(派生信号)。
* @brief 在指定分组的指定信号**下方**插入一条新信号(派生信号)。
*
* 新信号会占据 `afterSignalIndex + 1` 的位置,该位置及其后的信号下标整体后移一位;
* 调用方必须紧接着对每个标签页调用 `TabConfig::RemapAfterSignalInsert()` 修正引用。
* 采样值会被就地统计(min/max/finiteCount 与粗粒度包络层),显示外观按「全局信号序号」
* 取调色板颜色(与普通新信号一致)。
*
* @param [IN] fileIndex 目标文件下标。
* @param [IN] groupIndex 目标分组下标。
* @param [IN] afterSignalIndex 插入位置的上一条信号下标。
* @param [IN,OUT] signal 待插入的信号(values 会被统计,其余字段原样使用)。
* @param [OUT] newSignalIndex 新信号在本文件内的下标。
* @param [OUT] newSignalIndex 新信号在本组内的下标。
* @return 插入成功返回 true。
*/
bool InsertDerivedSignal(int fileIndex, int afterSignalIndex, SignalData&& signal, int& newSignalIndex);
bool InsertDerivedSignal(int groupIndex, int afterSignalIndex, SignalData&& signal,
int& newSignalIndex);
/**
* @brief 移除指定下标的文件,并立即释放其占用的全部采样内存。
* @param [IN] fileIndex 文件下标。
* @brief 移除指定下标的分组,并立即释放其占用的全部采样内存。
* @param [IN] groupIndex 分组下标。
* @return 移除成功返回 true。
*/
bool RemoveFile(int fileIndex);
bool RemoveGroup(int groupIndex);
/**
* @brief 从某个分组中移除一条信号,并立即释放它的采样内存。
@@ -383,30 +481,30 @@ namespace jdi
* `TabConfig::RemapAfterSignalRemoval()`,并用 `RemapSelectionAfterSignalRemoval()`
* 修正共享选中项。使用者一旦确认不再需要该信号即可调用,本调用后其采样缓冲立即析构。
*
* @param [IN] fileIndex 信号所属文件下标。
* @param [IN] signalIndex 文件内信号下标。
* @param [IN] groupIndex 信号所属分组下标。
* @param [IN] signalIndex 组内信号下标。
* @return 移除成功返回 true。
*/
bool RemoveSignal(int fileIndex, int signalIndex);
bool RemoveSignal(int groupIndex, int signalIndex);
/**
* @brief 在数据层移除某条信号之前,按旧下标修正选中引用。
* @param [IN] fileIndex 信号所属文件下标。
* @param [IN] groupIndex 信号所属分组下标。
* @param [IN] signalIndex 即将被移除的信号下标。
*/
void RemapSelectionAfterSignalRemoval(int fileIndex, int signalIndex);
void RemapSelectionAfterSignalRemoval(int groupIndex, int signalIndex);
/**
* @brief 判断数据集合是否为空。
* @return 未加载任何文件返回 true。
* @return 未加载任何分组返回 true。
*/
bool Empty() const { return m_files.empty(); }
bool Empty() const { return m_groups.empty(); }
/**
* @brief 获取文件数量。
* @return 文件数量。
* @brief 获取分组数量。
* @return 分组数量。
*/
std::size_t FileCount() const { return m_files.size(); }
std::size_t GroupCount() const { return m_groups.size(); }
/**
* @brief 获取信号总数。
@@ -415,34 +513,34 @@ namespace jdi
std::size_t SignalCount() const;
/**
* @brief 获取全部文件(只读)。
* @return 文件集合。
* @brief 获取全部分组(只读)。
* @return 分组集合。
*/
const std::vector<DataFile>& Files() const { return m_files; }
const std::vector<SignalGroup>& Groups() const { return m_groups; }
/**
* @brief 按下标获取文件。
* @param [IN] fileIndex 文件下标。
* @return 文件指针,下标非法时返回 nullptr。
* @brief 按下标获取分组。
* @param [IN] groupIndex 分组下标。
* @return 分组指针,下标非法时返回 nullptr。
*/
const DataFile* FileAt(int fileIndex) const;
const SignalGroup* GroupAt(int groupIndex) const;
/**
* @brief 修改某个信号在界面上的显示名称。
* @param [IN] fileIndex 文件下标。
* @param [IN] signalIndex 文件内信号下标。
* @param [IN] name 新的显示名;传入空串表示恢复使用原始列名。
* @brief 修改某条信号在界面上的显示名称。
* @param [IN] groupIndex 分组下标。
* @param [IN] signalIndex 组内信号下标。
* @param [IN] name 新的显示名;传入空串表示恢复使用源名称。
* @return 修改成功返回 true。
*/
bool SetSignalDisplayName(int fileIndex, int signalIndex, const std::wstring& name);
bool SetSignalDisplayName(int groupIndex, int signalIndex, const std::wstring& name);
/**
* @brief 修改某个文件分组的显示名称。
* @param [IN] fileIndex 文件下标。
* @param [IN] name 新的分组名;传入空串表示恢复使用文件名。
* @brief 修改某个分组的显示名称。
* @param [IN] groupIndex 分组下标。
* @param [IN] name 新的分组名;传入空串表示恢复使用默认名称。
* @return 修改成功返回 true。
*/
bool SetFileDisplayName(int fileIndex, const std::wstring& name);
bool SetGroupDisplayName(int groupIndex, const std::wstring& name);
/**
* @brief 解析信号引用。
@@ -451,6 +549,13 @@ namespace jdi
*/
const SignalData* Resolve(const SignalRef& ref) const;
/**
* @brief 取某条信号所属的时间轴。
* @param [IN] ref 信号引用。
* @return 时间轴指针,引用无效时返回 nullptr。
*/
const TimeAxis* Axis(const SignalRef& ref) const;
/**
* @brief 获取指定信号的显示外观(只读)。
* @param [IN] ref 信号引用。
@@ -466,13 +571,13 @@ namespace jdi
SignalStyle* Style(const SignalRef& ref);
/**
* @brief 计算全部文件时间范围的并集下限。
* @brief 计算全部分组、全部时间轴时间范围的并集下限。
* @return 时间下限。
*/
double MinTime() const;
/**
* @brief 计算全部文件时间范围的并集上限。
* @brief 计算全部分组、全部时间轴时间范围的并集上限。
* @return 时间上限。
*/
double MaxTime() const;
@@ -490,10 +595,10 @@ namespace jdi
void SetSelected(const SignalRef& ref) { m_selected = ref; }
/**
* @brief 在数据层移除某文件之前,按旧下标修正选中引用。
* @param [IN] fileIndex 即将被移除的文件下标。
* @brief 在数据层移除某分组之前,按旧下标修正选中引用。
* @param [IN] groupIndex 即将被移除的分组下标。
*/
void RemapSelectionAfterFileRemoval(int fileIndex);
void RemapSelectionAfterGroupRemoval(int groupIndex);
/**
* @brief 校验选中引用是否仍指向存在的信号,失效则取消选中。
@@ -502,8 +607,8 @@ namespace jdi
bool ValidateSelection();
private:
std::vector<DataFile> m_files; /**< 已加载文件集合。 */
SignalRef m_selected; /**< 当前选中信号(跨标签页共享)。 */
std::vector<SignalGroup> m_groups; /**< 已加载信号分组集合。 */
SignalRef m_selected; /**< 当前选中信号(跨标签页共享)。 */
};
/**
@@ -712,14 +817,14 @@ namespace jdi
*
* 加载完成后全部采样值已在内存中,本函数只查询内存数据,与源文件无关。
*
* @param [IN] file 信号所属的已加载数据分组(提供时间轴)。
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @param [IN] time 采样时刻。
* @param [IN] interpolate 是否在相邻采样点之间做线性插值。
* @param [OUT] value 输出采样值,缺失时为 NaN。
* @return 时间点落在数据范围内且取到有效值返回 true。
*/
bool SampleSignalAt(const DataFile& file, const SignalData& signal, double time, bool interpolate,
bool SampleSignalAt(const TimeAxis& axis, const SignalData& signal, double time, bool interpolate,
double& value);
/**
@@ -732,15 +837,15 @@ namespace jdi
* 否则读数会与看到的波形不一致。
* ⚠️ 系数 / 绝对值 / 垂直镜像 / `offsetY` 不在这里处理,由 `ApplyDrawTransform` 负责。
*
* @param [IN] file 信号所属的已加载数据分组(提供时间轴)。
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @param [IN] style 显示外观(只用 `mirrorHorizontal`)。
* @param [IN] style 显示外观(只用 `mirrorHorizontal` 与 `offsetTime`)。
* @param [IN] time 采样时刻。
* @param [IN] interpolate 是否在相邻采样点之间做线性插值。
* @param [OUT] value 输出采样值,缺失时为 NaN。
* @return 时间点落在数据范围内且取到有效值返回 true。
*/
bool SampleDisplayedSignalAt(const DataFile& file, const SignalData& signal,
bool SampleDisplayedSignalAt(const TimeAxis& axis, const SignalData& signal,
const SignalStyle& style, double time, bool interpolate, double& value);
/**
@@ -827,12 +932,12 @@ namespace jdi
bool throttle = false);
/**
* @brief 在指定文件的时间轴上查找与给定时刻最接近的采样点。
* @param [IN] file 目标文件。
* @brief 在给定时间轴上查找与给定时刻最接近的采样点。
* @param [IN] axis 目标时间轴。
* @param [IN] time 采样时刻。
* @return 采样点下标,文件为空时返回 -1。
* @return 采样点下标,时间轴为空时返回 -1。
*/
int FindNearestSample(const DataFile& file, double time);
int FindNearestSample(const TimeAxis& axis, double time);
/**
* @brief 单个标签页的独立视图配置(信号可见性、网格划分与视图范围)。
@@ -865,10 +970,10 @@ namespace jdi
/**
* @brief 在数据层移除某文件之前,按旧下标修正本配置(不重建网格)。
* @param [IN] fileIndex 即将被移除的文件下标。
* @note 调用顺序必须为:各标签页 RemapAfterFileRemoval → 数据层 RemoveFile → 各标签页 SyncToDataSet。
* @param [IN] groupIndex 即将被移除的分组下标。
* @note 调用顺序必须为:各标签页 RemapAfterGroupRemoval → 数据层 RemoveFile → 各标签页 SyncToDataSet。
*/
void RemapAfterFileRemoval(int fileIndex);
void RemapAfterGroupRemoval(int groupIndex);
/**
* @brief 在数据层移除某条信号之前,按旧下标修正本配置(不重建网格)。
@@ -876,12 +981,12 @@ namespace jdi
* 会删除该信号的可见性记录与网格归属,并把同一文件内位于其后的引用下标前移一位。
* 若某个网格因此变空则一并移除(与“取消勾选后空网格消失”的策略一致)。
*
* @param [IN] fileIndex 信号所属文件下标。
* @param [IN] groupIndex 信号所属分组下标。
* @param [IN] signalIndex 即将被移除的信号下标。
* @note 调用顺序必须为:各标签页 RemapAfterSignalRemoval → 数据层修正选中 →
* 数据层 RemoveSignal → 各标签页 SyncToDataSet。
*/
void RemapAfterSignalRemoval(int fileIndex, int signalIndex);
void RemapAfterSignalRemoval(int groupIndex, int signalIndex);
/**
* @brief 判断指定信号是否可见。
@@ -905,10 +1010,10 @@ namespace jdi
/**
* @brief 设置某个文件全部信号的可见性,并同步网格划分。
* @param [IN] fileIndex 文件下标。
* @param [IN] groupIndex 分组下标。
* @param [IN] visible 目标可见状态。
*/
void SetFileVisible(int fileIndex, bool visible);
void SetGroupVisible(int groupIndex, bool visible);
/**
* @brief 设置全部信号可见性,并同步网格划分。
@@ -924,17 +1029,17 @@ namespace jdi
/**
* @brief 判断某文件是否存在可见信号。
* @param [IN] fileIndex 文件下标。
* @param [IN] groupIndex 分组下标。
* @return 存在可见信号返回 true。
*/
bool FileHasVisible(int fileIndex) const;
bool GroupHasVisible(int groupIndex) const;
/**
* @brief 判断某文件是否全部信号可见。
* @param [IN] fileIndex 文件下标。
* @param [IN] groupIndex 分组下标。
* @return 全部可见且至少有一条信号返回 true。
*/
bool FileAllVisible(int fileIndex) const;
bool GroupAllVisible(int groupIndex) const;
/**
* @brief 判断是否存在任何可见信号。
@@ -1010,10 +1115,10 @@ namespace jdi
* 数据层在下标 `insertPos` 处插入了一条信号,因此本页可见性表需插入一列,
* 且所有指向该文件、下标不小于 `insertPos` 的信号引用都要后移一位。
*
* @param [IN] fileIndex 发生插入的文件下标。
* @param [IN] groupIndex 发生插入的分组下标。
* @param [IN] insertPos 新信号的下标。
*/
void RemapAfterSignalInsert(int fileIndex, int insertPos);
void RemapAfterSignalInsert(int groupIndex, int insertPos);
/**
* @brief 把某信号所在的网格移动到另一信号所在网格的下方(紧邻其后)。
@@ -1046,16 +1151,16 @@ namespace jdi
void RenameLanes();
/** * @brief 查询某文件的分组是否处于折叠状态。
* @param [IN] fileIndex 文件下标。
* @param [IN] groupIndex 分组下标。
* @return 折叠状态。
*/
bool IsCollapsed(int fileIndex) const;
bool IsCollapsed(int groupIndex) const;
/**
* @brief 切换某文件分组的折叠状态。
* @param [IN] fileIndex 文件下标。
* @param [IN] groupIndex 分组下标。
*/
void ToggleCollapsed(int fileIndex);
void ToggleCollapsed(int groupIndex);
/**
* @brief 设置「新出现的文件分组是否默认折起」。
+101
View File
@@ -0,0 +1,101 @@
#ifndef __DATA_TABLE_HPP__
#define __DATA_TABLE_HPP__
#include <cstddef>
#include <functional>
#include <string>
#include <vector>
namespace jdi
{
/**
* @brief 内存中的**列优先**数据表(与具体文件格式无关)。
*
* 这是「从任意格式读出来的原始格子」:一行是表头,其余是各列的数值。
* 它**不是**应用的数据模型(不含时间轴语义、不含信号名规范化),
* 只是各格式加载器(CSV / MF4 / 以后更多格式)与数据模型之间的中转结构。
*
* 采用列优先(每列一整块连续内存)而不是行优先的二维数组,原因有二:
* 1. 大型文件(百万行级)若按行存储会产生上百万次独立堆分配,
* 每行还要额外承担 vector 的控制块与容量对齐开销,实测内存放大接近 1.5 倍;
* 2. 构建信号时可以直接把整列**移动**进 `SignalData`,不再逐元素拷贝。
*/
struct DataTable
{
std::vector<std::wstring> headers; /**< 列名(已去除引号与首尾空白)。 */
std::vector<std::vector<double>> columns; /**< 列优先数值矩阵:columns[列][行],缺失处为 NaN。 */
std::size_t rowCount = 0; /**< 有效数据行数。 */
wchar_t delimiter = L','; /**< 实际使用的分隔符(仅文本格式有意义)。 */
unsigned long long sourceBytes = 0; /**< 源文件字节数(用于按比例预留容量)。 */
/**
* @brief 判断解析结果是否包含可用的表格数据。
* @return 存在列名且存在数据行时返回 true。
*/
bool HasData() const { return !headers.empty() && (rowCount > 0) && (columns.size() >= headers.size()); }
};
/**
* @brief 加载进度信息。
*
* 解析阶段会携带真实的"已读 / 总字节数";列重组与统计阶段没有字节可计,
* 此时 `hasBytes` 为 false,界面应只展示百分比。
*/
struct LoadProgressInfo
{
double ratio = 0.0; /**< 0-1 总体完成比例。 */
unsigned long long bytesRead = 0; /**< 已读取字节数。 */
unsigned long long totalBytes = 0; /**< 文件总字节数。 */
bool hasBytes = false; /**< 上述字节数字段是否有效。 */
};
/**
* @brief 加载进度回调。
*
* 在解析过程中按块回调;返回 false 表示请求取消,解析会尽快停止并返回失败。
*/
using LoadProgress = std::function<bool(const LoadProgressInfo& info)>;
/**
* @brief 一组**共享同一时间轴**的数据列(一个「时间轴块」)。
*
* 一个源文件可以包含多个这样的块:CSV 里每个 `time` 列都会开启一个新块,
* MF4 里每个测量通道组(CG)是一个块。块内的各列长度一致(都等于本块的行数)。
*/
struct TableBlock
{
std::wstring timeHeader; /**< 本块时间列的列名(原样保留,如 `time`)。 */
DataTable table; /**< 本块的列集合(**含**时间列本身)。 */
};
/**
* @brief 一个源文件加载后的全部内容(可能包含多个时间轴块)。
*
* ⚠️ 这是「加载器 → 数据模型」的唯一接口:加载器只负责把文件读成
* `LoadedSource`(纯格子,无应用语义),`BuildSignalGroup` 再把它规范化为
* `SignalGroup`(含时间轴去重、信号命名、调色板等应用语义)。
* 新增文件格式时**只需新增一个加载器**,数据模型与界面代码完全不用改。
*/
struct LoadedSource
{
std::wstring sourceName; /**< 源文件名(不含目录,含扩展名)。 */
std::vector<TableBlock> blocks; /**< 时间轴块集合(至少一个)。 */
/** @brief 是否包含可用的数据。 */
bool HasData() const
{
for (const TableBlock& block : blocks)
{
if (block.table.HasData())
{
return true;
}
}
return false;
}
};
} // namespace jdi
#endif // __DATA_TABLE_HPP__
+36
View File
@@ -0,0 +1,36 @@
#ifndef __MF4_LOADER_HPP__
#define __MF4_LOADER_HPP__
#include <string>
#include "DataTable.hpp"
namespace jdi
{
/**
* @brief 读取并解析一个 ASAM MDF v4(`.mf4` / `.mdf`)测量文件。
*
* 只解析**绘制需要的那部分**:头块版本、数据组(DG)→ 通道组(CG)→ 通道(CN)/换算(CC),
* 以及每个通道组的原始记录;字符串/字节数组通道、压缩数据块(DZ/HZ)与 MIME 通道一律跳过。
*
* **时间轴**:MDF 的每个通道组(CG)自带一个时间基准。本加载器把每个 CG 输出为一个
* 时间轴块(`TableBlock`):块的时间列取自该组的**时间主通道**(`cn_sync_type == 1`,
* 退回名为 `time`/`t` 的通道,再退回第一个数值通道),其余数值通道作为该块的信号列。
* 内容完全相同的时间轴在数据模型层会被合并(见 `BuildSignalGroup`)。
*
* 支持的通道编码:无符号/有符号整数(1–64 位,任意位偏移)与 IEEE-754 浮点(32/64 位)。
* 支持的换算:无换算、线性(`phys = c0 + c1 * raw`)。其它换算按“无换算”处理。
*
* @param [IN] filePath MDF 文件完整路径。
* @param [OUT] source 解析结果(每个通道组一个时间轴块)。
* @param [OUT] error 失败原因描述(成功时清空)。
* @param [IN] progress 进度回调(按字节比例),可为空。
* @return 解析成功返回 true。
*/
bool LoadMf4Source(const std::wstring& filePath, LoadedSource& source, std::wstring& error,
const LoadProgress& progress = LoadProgress());
} // namespace jdi
#endif // __MF4_LOADER_HPP__
@@ -1,9 +1,10 @@
#include "CsvTable.hpp"
#include "CsvLoader.hpp"
#include "Utf8Util.hpp"
#include <windows.h>
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstring>
@@ -236,7 +237,7 @@ namespace jdi
* @param [IN] end 行结束指针(不包含)。
* @return 解析可以继续返回 true;表头非法需要中止时返回 false。
*/
bool HandleLine(CsvTable& table, ParseState& state, const char* begin, const char* end)
bool HandleLine(DataTable& table, ParseState& state, const char* begin, const char* end)
{
const char* lineEnd = end;
while (lineEnd > begin && lineEnd[-1] == '\r')
@@ -280,7 +281,7 @@ namespace jdi
}
// 列优先存储:一次性为每列预留容量,避免逐行扩容(百万行时复制代价可观)。
const std::size_t capacity = EstimateRowCapacity(table.fileBytes);
const std::size_t capacity = EstimateRowCapacity(table.sourceBytes);
table.columns.assign(table.headers.size(), std::vector<double>());
for (std::vector<double>& column : table.columns)
{
@@ -329,12 +330,166 @@ namespace jdi
++table.rowCount;
return true;
}
}
bool LoadCsvTable(const std::wstring& filePath, CsvTable& table, std::wstring& error,
const CsvProgress& progress)
/**
* @brief 判断列名是否为一个时间轴的列名。
*
* 应用的约定:**只有名为 `time`(以及少量等价写法)的列是特殊的时间轴列**,
* 其余列一律视为数据信号(不做单位/量纲推断等元数据解析)。
*
* @param [IN] header 原始列名。
* @return 该列是时间轴列返回 true。
*/
bool LooksLikeTimeHeader(const std::wstring& header)
{
const std::wstring text = ToLowerAscii(Trim(header));
if (text.empty())
{
return false;
}
return text == L"t" || text == L"time" || text == L"timestamp" ||
text == L"simulation time" || text == L"时间" || text == L"时间轴";
}
/**
* @brief 把整表按「时间列」切分为若干时间轴块。
*
* 每遇到一个时间列就开启新块;它之后直到下一个时间列之前的非时间列都属于该块。
* 首个时间列之前若存在数据列(例如表中根本没有任何名为 `time` 的列、或数据列出现在
* 第一个时间列之前),这些列并入**第一个块**,并把第一列当作该块的时间轴
*(保持旧版「回退到第一列」的行为)。
*
* ⚠️ 每个块会按**自己**的时间列截断:较短的时间轴在其数据区提前结束(其余位置为空),
* 因此各块的行数可以不同,列向量会收缩到本块真正有时间的那些行。
*
* @param [IN,OUT] table 整表(列会被移动到各个块中)。
* @param [OUT] blocks 切分结果。
*/
void SplitIntoBlocks(DataTable& table, std::vector<TableBlock>& blocks)
{
const std::size_t columnCount = table.headers.size();
// 找出所有时间列下标(按出现顺序)。
std::vector<std::size_t> timeColumns;
for (std::size_t column = 0; column < columnCount; ++column)
{
if (LooksLikeTimeHeader(table.headers[column]))
{
timeColumns.push_back(column);
}
}
// 一个时间列都没有:整表当作单块,并把第一列当作时间轴(旧版回退行为)。
if (timeColumns.empty())
{
if (columnCount < 2)
{
return; // 只有一列又没有时间列:没有可用的数据
}
timeColumns.push_back(0);
}
// 每个时间列开启一个块;块的成员 = [时间列, 下一个时间列)。
for (std::size_t index = 0; index < timeColumns.size(); ++index)
{
const std::size_t timeColumn = timeColumns[index];
const std::size_t blockEnd = (index + 1 < timeColumns.size()) ? timeColumns[index + 1]
: columnCount;
if (timeColumn >= columnCount || blockEnd <= timeColumn + 1)
{
continue; // 该时间列之后没有任何数据列
}
TableBlock block;
block.timeHeader = table.headers[timeColumn];
block.table.delimiter = table.delimiter;
block.table.sourceBytes = table.sourceBytes;
block.table.headers.reserve(blockEnd - timeColumn);
for (std::size_t column = timeColumn; column < blockEnd; ++column)
{
block.table.headers.push_back(table.headers[column]);
block.table.columns.push_back(std::move(table.columns[column]));
}
block.table.rowCount = table.rowCount;
blocks.push_back(std::move(block));
}
// 首个时间列之前若还有数据列(非时间列),并入第一个块,并让第一列充当时间轴。
if (!blocks.empty() && timeColumns.front() > 0 && blocks.size() >= 1)
{
TableBlock& first = blocks.front();
std::vector<std::size_t> prepended;
for (std::size_t column = 0; column < timeColumns.front(); ++column)
{
first.table.headers.push_back(table.headers[column]);
first.table.columns.push_back(std::move(table.columns[column]));
prepended.push_back(column);
}
if (!prepended.empty())
{
// 把原时间列移到 block.table 的最前面(保持 `columns[0]` = 时间列 的约定)。
std::rotate(first.table.headers.begin(),
first.table.headers.begin() + static_cast<std::ptrdiff_t>(prepended.size()),
first.table.headers.end());
std::rotate(first.table.columns.begin(),
first.table.columns.begin() + static_cast<std::ptrdiff_t>(prepended.size()),
first.table.columns.end());
first.timeHeader = first.table.headers.front();
}
}
}
/**
* @brief 按本块自己的时间列截断(丢掉尾部没有时间的行),并计算有效时间点数量。
*
* 多时间轴文件里各块长度不同:较短的时间轴在数据区提前结束,其余行的时间是空的。
* 这里把列向量收缩到最后一行「时间列有效」的位置,既省内存,也让后续的
* `lower_bound`(依赖时间单调)建立在干净的数据上。
*
* @param [IN,OUT] block 目标块。
* @return 有效时间点数量。
*/
std::size_t TrimBlockRows(TableBlock& block)
{
if (block.table.columns.empty())
{
return 0;
}
const std::vector<double>& time = block.table.columns.front();
std::size_t lastValid = 0;
std::size_t finiteCount = 0;
for (std::size_t row = 0; row < time.size(); ++row)
{
if (std::isfinite(time[row]))
{
lastValid = row + 1;
++finiteCount;
}
}
if (lastValid == 0)
{
return 0;
}
if (lastValid < block.table.rowCount)
{
for (std::vector<double>& column : block.table.columns)
{
if (column.size() > lastValid)
{
column.resize(lastValid);
}
}
block.table.rowCount = lastValid;
}
return finiteCount;
}
} // namespace
bool LoadCsvSource(const std::wstring& filePath, LoadedSource& source, std::wstring& error,
const LoadProgress& progress)
{
table = CsvTable();
source = LoadedSource();
error.clear();
FILE* file = nullptr;
@@ -343,7 +498,12 @@ namespace jdi
{
return false;
}
table.fileBytes = fileSize;
const std::size_t slashPos = filePath.find_last_of(L"\\/");
source.sourceName = (slashPos == std::wstring::npos) ? filePath : filePath.substr(slashPos + 1);
DataTable table;
table.sourceBytes = fileSize;
ParseState state;
std::string staging;
@@ -397,12 +557,12 @@ namespace jdi
if (progress)
{
CsvProgressInfo info;
LoadProgressInfo info;
info.bytesRead = state.bytesConsumed;
info.totalBytes = fileSize;
info.hasBytes = true;
info.ratio = (fileSize > 0) ? static_cast<double>(state.bytesConsumed) /
static_cast<double>(fileSize)
info.ratio = (fileSize > 0) ? (static_cast<double>(state.bytesConsumed) /
static_cast<double>(fileSize))
: 0.0;
if (info.ratio > 1.0)
{
@@ -415,14 +575,16 @@ namespace jdi
}
}
// 处理文件末尾没有换行符的最后一行。
// 处理文件末尾没有换行的最后一行。
if (!cancelled && !fatal && !state.pending.empty())
{
if (!HandleLine(table, state, state.pending.data(), state.pending.data() + state.pending.size()))
{
fatal = true;
}
state.pending.clear();
}
std::fclose(file);
if (cancelled)
@@ -430,16 +592,43 @@ namespace jdi
error = L"已取消加载。";
return false;
}
if (table.headers.empty())
if (fatal || !table.HasData())
{
error = fatal ? L"表头为空,无法解析列名。" : L"未从文件中解析出有效数据行。";
error = L"未能从文件中解析出有效数据表。";
return false;
}
if (!table.HasData())
// 按时间列切分为多个时间轴块,并各自按自己的时间列截断。
SplitIntoBlocks(table, source.blocks);
if (source.blocks.empty())
{
error = L"未从文件中解析出有效数据行。";
error = L"未发现任何时间轴列与数据列的组合。";
return false;
}
// 丢弃没有任何有效时间点的块;若全部无效则报错。
std::vector<TableBlock> kept;
kept.reserve(source.blocks.size());
for (TableBlock& block : source.blocks)
{
if (TrimBlockRows(block) >= 2)
{
kept.push_back(std::move(block));
}
}
if (kept.empty())
{
error = L"时间轴列的有效数值不足(至少需要 2 个)。";
return false;
}
source.blocks = std::move(kept);
if (progress)
{
LoadProgressInfo info;
info.ratio = 1.0;
progress(info);
}
return true;
}
+55
View File
@@ -0,0 +1,55 @@
#include "DataLoader.hpp"
#include "CsvLoader.hpp"
#include "Mf4Loader.hpp"
#include "Utf8Util.hpp"
#include <algorithm>
namespace jdi
{
namespace
{
/** @brief 取小写扩展名(含点)。 */
std::wstring ExtensionOf(const std::wstring& filePath)
{
const std::size_t dot = filePath.find_last_of(L'.');
if (dot == std::wstring::npos)
{
return std::wstring();
}
return ToLowerAscii(filePath.substr(dot));
}
} // namespace
bool IsSupportedSignalFile(const std::wstring& filePath)
{
const std::wstring extension = ExtensionOf(filePath);
return extension == L".csv" || extension == L".txt" || extension == L".mf4" ||
extension == L".mdf" || extension == L".dat";
}
const wchar_t* SignalFileDialogFilter()
{
// GetOpenFileNameW 要求:每个过滤器一对「说明\0通配符\0」,最后以空字符串结束。
return L"测量数据文件 (*.csv;*.txt;*.mf4;*.mdf;*.dat)\0*.csv;*.txt;*.mf4;*.mdf;*.dat\0"
L"CSV 文本 (*.csv;*.txt)\0*.csv;*.txt\0"
L"ASAM MDF v4 (*.mf4;*.mdf)\0*.mf4;*.mdf\0"
L"全部文件 (*.*)\0*.*\0";
}
bool LoadSignalSource(const std::wstring& filePath, LoadedSource& source, std::wstring& error,
const LoadProgress& progress)
{
const std::wstring extension = ExtensionOf(filePath);
if (extension == L".mf4" || extension == L".mdf")
{
return LoadMf4Source(filePath, source, error, progress);
}
// 其余(含未知扩展名)一律按 CSV 文本尝试解析:文本格式的容错性最好,
// 而未知扩展名在「打开文件」对话框里也只能由用户显式指定。
return LoadCsvSource(filePath, source, error, progress);
}
} // namespace jdi
File diff suppressed because it is too large. Load diff
+820
View File
@@ -0,0 +1,820 @@
#include "Mf4Loader.hpp"
#include "Utf8Util.hpp"
#include <windows.h>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <limits>
#include <string>
#include <vector>
namespace jdi
{
namespace
{
const double kMissingValue = std::numeric_limits<double>::quiet_NaN(); /**< 缺失值占位。 */
const std::size_t kReadChunkBytes = 4u * 1024u * 1024u; /**< 数据区流式读取块大小。 */
const std::size_t kMaxChannelsPerGroup = 4096; /**< 单个通道组的通道数上限(防损坏文件死循环)。 */
const std::size_t kMaxGroups = 65536; /**< 数据组数量上限。 */
/** MDF 块头固定长度(id + reserved + length + link_count)。 */
const std::size_t kBlockHeaderBytes = 24;
/** @brief 通道数据类型(`cn_data_type`)。 */
enum class DataType : int
{
UINT_LE = 0,
UINT_BE = 1,
INT_LE = 2,
INT_BE = 3,
FLOAT_LE = 4,
FLOAT_BE = 5,
STRING_LATIN1 = 6,
STRING_UTF8 = 7,
STRING_UTF16_LE = 8,
STRING_UTF16_BE = 9,
BYTE_ARRAY = 10,
MIME_SAMPLE = 11,
MIME_STREAM = 12
};
/** @brief 换算类型(`cc_type`)中我们支持的子集。 */
enum class ConversionType : int
{
NONE = 0, /**< 无换算(恒等)。 */
LINEAR = 1, /**< 线性:`phys = c0 + c1 * raw`。 */
RATIONAL = 2 /**< 有理式(未支持,按恒等处理)。 */
};
/**
* @brief 只读的 MDF 文件访问器(按需 seek + read,不把整个文件读进内存)。
*/
class MdfFile
{
public:
~MdfFile() { Close(); }
bool Open(const std::wstring& path, std::wstring& error)
{
m_file = _wfopen(path.c_str(), L"rb");
if (m_file == nullptr)
{
error = L"无法打开文件,请确认路径与访问权限。";
return false;
}
if (_fseeki64(m_file, 0, SEEK_END) != 0)
{
error = L"无法定位文件末尾。";
return false;
}
const long long size = _ftelli64(m_file);
if (size <= 0)
{
error = L"文件内容为空。";
return false;
}
m_size = static_cast<std::uint64_t>(size);
// ID 块:0..7 = "MDF ",8..15 = 版本号,例如 "4.10 "。
char id[8] = {};
char version[8] = {};
if (!ReadAt(0, id, sizeof(id)) || std::memcmp(id, "MDF", 3) != 0)
{
error = L"不是有效的 MDF 文件(缺少 MDF 标识)。";
return false;
}
if (!ReadAt(8, version, sizeof(version)) || version[0] != '4')
{
error = L"仅支持 MDF v4 格式(当前文件不是 v4)。";
return false;
}
return true;
}
void Close()
{
if (m_file != nullptr)
{
std::fclose(m_file);
m_file = nullptr;
}
}
std::uint64_t Size() const { return m_size; }
/** @brief 读取一段原始字节。 */
bool ReadAt(std::uint64_t offset, void* dst, std::size_t bytes) const
{
if (m_file == nullptr || offset + bytes > m_size)
{
return false;
}
if (_fseeki64(m_file, static_cast<long long>(offset), SEEK_SET) != 0)
{
return false;
}
return std::fread(dst, 1, bytes, m_file) == bytes;
}
/** @brief 读取一个小端标量(最多 8 字节)。越界返回 0。 */
std::uint64_t Scalar(std::uint64_t offset, std::size_t bytes) const
{
unsigned char buffer[8] = {};
if (bytes == 0 || bytes > 8 || !ReadAt(offset, buffer, bytes))
{
return 0;
}
std::uint64_t value = 0;
for (std::size_t i = 0; i < bytes; ++i)
{
value |= static_cast<std::uint64_t>(buffer[i]) << (8u * i);
}
return value;
}
std::uint32_t U32(std::uint64_t offset) const
{
return static_cast<std::uint32_t>(Scalar(offset, 4));
}
std::uint16_t U16(std::uint64_t offset) const
{
return static_cast<std::uint16_t>(Scalar(offset, 2));
}
std::uint8_t U8(std::uint64_t offset) const
{
return static_cast<std::uint8_t>(Scalar(offset, 1));
}
std::uint64_t U64(std::uint64_t offset) const { return Scalar(offset, 8); }
double F64(std::uint64_t offset) const
{
const std::uint64_t bits = U64(offset);
double value = 0.0;
std::memcpy(&value, &bits, sizeof(value));
return value;
}
/** @brief 读取块标识(4 字节 ASCII)。 */
bool BlockId(std::uint64_t offset, char out[5]) const
{
out[0] = out[1] = out[2] = out[3] = '?';
out[4] = '\0';
return ReadAt(offset, out, 4);
}
/** @brief 块总长度(含块头)。 */
std::uint64_t BlockLength(std::uint64_t offset) const { return U64(offset + 8); }
/** @brief 块内链接数量。 */
std::uint64_t LinkCount(std::uint64_t offset) const { return U64(offset + 16); }
/** @brief 第 `index` 个链接(绝对文件偏移,0 表示空)。 */
std::uint64_t Link(std::uint64_t offset, std::uint64_t index) const
{
if (index >= LinkCount(offset))
{
return 0;
}
return U64(offset + kBlockHeaderBytes + 8u * index);
}
/** @brief 块的数据段起始偏移。 */
std::uint64_t DataOffset(std::uint64_t offset) const
{
return offset + kBlockHeaderBytes + 8u * LinkCount(offset);
}
/**
* @brief 判断某个偏移是否为一个有效块的开头(且标识匹配)。
* @param [IN] offset 候选偏移。
* @param [IN] id 期望的 4 字节标识(如 "##CN");空字符串表示只校验偏移合法性。
*/
bool IsBlock(std::uint64_t offset, const char* id) const
{
if (offset == 0 || offset + kBlockHeaderBytes > m_size)
{
return false;
}
char actual[5] = {};
if (!BlockId(offset, actual))
{
return false;
}
if (id != nullptr && id[0] != '\0' && std::memcmp(actual, id, 4) != 0)
{
return false;
}
return BlockLength(offset) >= kBlockHeaderBytes && offset + BlockLength(offset) <= m_size;
}
/** @brief 读取 `##TX` / `##MD` 块里的文本(MDF 的 TEXT 块)。 */
std::wstring TextBlock(std::uint64_t offset) const
{
std::wstring text;
if (!IsBlock(offset, "##TX"))
{
return text;
}
const std::uint64_t data = DataOffset(offset);
const std::uint64_t usable = BlockLength(offset) - (data - offset);
if (usable < 2)
{
return text;
}
// MDF 规范里 TX 的数据段是「长度 + 文本」,长度可能是字符数(UTF-16)或字节数
//(UTF-8,MDF 4.1 起)。但部分工具(如 CANape 16)会**直接写 NUL 结尾的
// 原始文本、不带长度前缀**,因此这里按「先按规范、再按裸文本回退」的顺序解析。
if (usable >= 4)
{
const std::uint64_t declared = U32(data);
if (declared > 0 && 4u + 2u * declared <= usable)
{
// 长度前缀 + UTF-16 代码单元。
std::vector<char> buffer(static_cast<std::size_t>(2u * declared));
if (!ReadAt(data + 4, buffer.data(), buffer.size()))
{
return text;
}
text.reserve(static_cast<std::size_t>(declared));
for (std::uint64_t i = 0; i < declared; ++i)
{
const wchar_t c =
static_cast<wchar_t>(static_cast<unsigned char>(buffer[2 * i]) |
(static_cast<unsigned char>(buffer[2 * i + 1]) << 8));
if (c == L'\0')
{
break;
}
text.push_back(c);
}
return text;
}
if (declared > 0 && 4u + declared <= usable)
{
// 长度前缀 + UTF-8 字节。
std::vector<char> buffer(static_cast<std::size_t>(declared) + 1u, '\0');
if (!ReadAt(data + 4, buffer.data(), static_cast<std::size_t>(declared)))
{
return text;
}
return Utf8ToWide(buffer.data(), static_cast<int>(declared));
}
}
// 回退:数据段直接是 NUL 结尾的文本(UTF-16 或 UTF-8)。
std::vector<unsigned char> buffer(static_cast<std::size_t>(usable));
if (!ReadAt(data, buffer.data(), buffer.size()))
{
return text;
}
const bool utf16 = (buffer.size() >= 2 && buffer[1] == 0 && buffer[0] != 0);
if (utf16)
{
for (std::size_t i = 0; i + 1 < buffer.size(); i += 2)
{
const wchar_t c = static_cast<wchar_t>(buffer[i] | (buffer[i + 1] << 8));
if (c == L'\0')
{
break;
}
text.push_back(c);
}
return text;
}
std::string raw;
for (const unsigned char c : buffer)
{
if (c == '\0')
{
break;
}
raw += static_cast<char>(c);
}
return Utf8ToWide(raw.data(), static_cast<int>(raw.size()));
}
private:
FILE* m_file = nullptr;
std::uint64_t m_size = 0;
};
/** @brief 一个通道的读取方案(已从 CN 块解析出来)。 */
struct ChannelSpec
{
std::wstring name; /**< 通道名(`cn_tx_name`)。 */
std::size_t bitOffset = 0; /**< 记录内的起始位偏移(= byte_offset*8 + bit_offset)。 */
std::size_t bitCount = 0; /**< 位宽。 */
DataType dataType = DataType::UINT_LE; /**< 原始数据类型。 */
bool allInvalid = false; /**< `cn_flags & 1`:整列无效。 */
bool hasInvalidBit = false; /**< `cn_flags & 2`:有无效位。 */
std::size_t invalidBitPos = 0; /**< 无效位的绝对位偏移。 */
bool syncMaster = false; /**< `cn_sync_type == 1`:时间主通道。 */
bool linearConversion = false; /**< 是否带线性换算。 */
double offset = 0.0; /**< 线性换算常数项。 */
double factor = 1.0; /**< 线性换算系数(无法换算时为 1)。 */
/** 数值有效性:只有整数与浮点通道可以绘制。 */
bool numeric = false;
};
/** @brief 判断数据类型是否为可绘制的数值类型。 */
bool IsNumericType(DataType type)
{
return type == DataType::UINT_LE || type == DataType::UINT_BE || type == DataType::INT_LE ||
type == DataType::INT_BE || type == DataType::FLOAT_LE || type == DataType::FLOAT_BE;
}
/**
* @brief 按位从记录缓冲中取出一个数值。
*
* 支持 1–64 位整数(任意位偏移)与 32/64 位浮点(字节对齐)。
*
* @param [IN] record 记录起始指针。
* @param [IN] recordBytes 记录长度(字节),用于边界检查。
* @param [IN] spec 通道读取方案。
* @return 原始数值;越界或类型不支持时返回 NaN。
*/
double DecodeRawValue(const unsigned char* record, std::size_t recordBytes, const ChannelSpec& spec)
{
const std::size_t bitEnd = spec.bitOffset + spec.bitCount;
if (spec.bitCount == 0 || spec.bitCount > 64 || bitEnd > recordBytes * 8u)
{
return kMissingValue;
}
const std::size_t byteStart = spec.bitOffset / 8u;
const std::size_t byteCount = (bitEnd - byteStart * 8u + 7u) / 8u;
unsigned char buffer[9] = {};
std::memcpy(buffer, record + byteStart, byteCount);
if (spec.dataType == DataType::FLOAT_LE || spec.dataType == DataType::FLOAT_BE)
{
if (spec.bitOffset % 8u != 0u || (spec.bitCount != 32u && spec.bitCount != 64u))
{
return kMissingValue;
}
if (spec.dataType == DataType::FLOAT_LE)
{
if (spec.bitCount == 32u)
{
std::uint32_t bits = 0;
std::memcpy(&bits, buffer, 4);
float value = 0.0f;
std::memcpy(&value, &bits, 4);
return static_cast<double>(value);
}
double value = 0.0;
std::memcpy(&value, buffer, 8);
return value;
}
if (spec.bitCount == 32u)
{
std::uint32_t bits = (static_cast<std::uint32_t>(buffer[0]) << 24) |
(static_cast<std::uint32_t>(buffer[1]) << 16) |
(static_cast<std::uint32_t>(buffer[2]) << 8) |
static_cast<std::uint32_t>(buffer[3]);
float value = 0.0f;
std::memcpy(&value, &bits, 4);
return static_cast<double>(value);
}
std::uint64_t bits = 0;
for (int i = 0; i < 8; ++i)
{
bits = (bits << 8) | static_cast<std::uint64_t>(buffer[i]);
}
double value = 0.0;
std::memcpy(&value, &bits, 8);
return value;
}
// 整数:先按字节序拼成一个 64 位无符号数,再按位偏移右移并解释符号。
std::uint64_t raw = 0;
if (spec.dataType == DataType::UINT_LE || spec.dataType == DataType::INT_LE)
{
for (std::size_t i = byteCount; i-- > 0;)
{
raw = (raw << 8) | static_cast<std::uint64_t>(buffer[i]);
}
}
else
{
for (std::size_t i = 0; i < byteCount; ++i)
{
raw = (raw << 8) | static_cast<std::uint64_t>(buffer[i]);
}
}
const std::size_t shift = spec.bitOffset % 8u;
raw >>= shift;
if (spec.bitCount < 64u)
{
raw &= (1ull << spec.bitCount) - 1ull;
}
const bool signedType = (spec.dataType == DataType::INT_LE || spec.dataType == DataType::INT_BE);
if (signedType && spec.bitCount < 64u && (raw & (1ull << (spec.bitCount - 1u))) != 0u)
{
// 符号扩展。
raw |= ~((1ull << spec.bitCount) - 1ull);
}
if (signedType)
{
return static_cast<double>(static_cast<std::int64_t>(raw));
}
return static_cast<double>(raw);
}
/** @brief 解析一个 CN 块为读取方案。 */
bool ParseChannel(const MdfFile& file, std::uint64_t cn, ChannelSpec& spec)
{
if (!file.IsBlock(cn, "##CN"))
{
return false;
}
const std::uint64_t data = file.DataOffset(cn);
const std::uint8_t type = file.U8(data + 0);
const std::uint8_t sync = file.U8(data + 1);
const std::uint8_t dataType = file.U8(data + 2);
const std::uint8_t bitOffset = file.U8(data + 3);
const std::uint32_t byteOffset = file.U32(data + 4);
const std::uint32_t bitCount = file.U32(data + 8);
const std::uint32_t flags = file.U32(data + 12);
const std::uint32_t invalidBit = file.U32(data + 16);
spec.name = file.TextBlock(file.Link(cn, 2));
spec.dataType = static_cast<DataType>(dataType);
spec.bitCount = bitCount;
spec.bitOffset = static_cast<std::size_t>(byteOffset) * 8u + bitOffset;
spec.allInvalid = (flags & 0x1u) != 0u;
spec.hasInvalidBit = (flags & 0x2u) != 0u;
spec.invalidBitPos = invalidBit;
spec.syncMaster = (sync == 1);
// 换算:只支持“无换算”与“线性”。
const std::uint64_t cc = file.Link(cn, 4);
if (file.IsBlock(cc, "##CC"))
{
const std::uint64_t ccData = file.DataOffset(cc);
const std::uint8_t ccType = file.U8(ccData + 0);
const std::uint16_t valCount = file.U16(ccData + 6);
if (ccType == static_cast<std::uint8_t>(ConversionType::LINEAR) && valCount >= 2)
{
spec.linearConversion = true;
spec.offset = file.F64(ccData + 24);
spec.factor = file.F64(ccData + 32);
if (spec.factor == 0.0 && spec.offset == 0.0)
{
// 换算表未填写:按恒等处理,避免整列变成 0。
spec.linearConversion = false;
spec.offset = 0.0;
spec.factor = 1.0;
}
}
}
// 通道类型 2 = 虚拟主通道(CANape 的时间轴),同样可以取值。
spec.numeric = IsNumericType(spec.dataType) && spec.bitCount > 0 && spec.bitCount <= 64;
(void)type;
return true;
}
/** @brief 在一条 `next` 链上迭代块偏移(带访问次数上限,避免损坏文件死循环)。 */
template <typename Fn>
void WalkChain(const MdfFile& file, std::uint64_t first, const char* expectedId, std::size_t limit,
Fn&& visit)
{
std::uint64_t current = first;
std::size_t guard = 0;
while (current != 0 && guard++ < limit)
{
if (!file.IsBlock(current, expectedId))
{
break;
}
if (!visit(current))
{
break;
}
current = file.Link(current, 0);
}
}
/** @brief 一个通道组(CG)解析出的时间轴 + 信号列。 */
struct ChannelGroupData
{
std::wstring timeName; /**< 时间列名(主通道名或 `time`)。 */
std::vector<ChannelSpec> channels; /**< 非时间通道。 */
std::vector<double> time; /**< 时间轴数值。 */
std::vector<std::vector<double>> values; /**< 每个非时间通道的采样值。 */
};
/**
* @brief 读取一个通道组的全部记录(流式,按 4MB 分块)。
*
* @param [IN] file 文件访问器。
* @param [IN] dt 数据块偏移(`##DT`),记录从 `DataOffset(dt)` 开始连续存放。
* @param [IN] cycles 记录条数。
* @param [IN] recordBytes 单条记录字节数。
* @param [IN,OUT] group 输出(`timeName`/`channels` 已填好,函数填充 `time`/`values`)。
*/
bool ReadChannelGroupRecords(const MdfFile& file, std::uint64_t dt, std::uint64_t cycles,
std::size_t recordBytes, std::size_t masterIndex,
ChannelGroupData& group)
{
if (cycles == 0 || recordBytes == 0)
{
return false;
}
const std::uint64_t dataStart = file.DataOffset(dt);
const std::uint64_t payload = file.BlockLength(dt) - (dataStart - dt);
if (payload < cycles * recordBytes)
{
return false; // 数据不足(损坏或带压缩)
}
group.time.assign(static_cast<std::size_t>(cycles), kMissingValue);
group.values.assign(group.channels.size(),
std::vector<double>(static_cast<std::size_t>(cycles), kMissingValue));
std::vector<unsigned char> buffer(kReadChunkBytes);
std::uint64_t done = 0;
while (done < cycles)
{
const std::uint64_t remainingRecords = cycles - done;
const std::uint64_t wantBytes = std::min<std::uint64_t>(
remainingRecords * recordBytes, static_cast<std::uint64_t>(buffer.size()));
const std::uint64_t wantRecords = wantBytes / recordBytes;
if (wantRecords == 0)
{
break;
}
const std::size_t bytes = static_cast<std::size_t>(wantRecords * recordBytes);
if (!file.ReadAt(dataStart + done * recordBytes, buffer.data(), bytes))
{
return false;
}
for (std::uint64_t r = 0; r < wantRecords; ++r)
{
const unsigned char* record = buffer.data() + r * recordBytes;
const std::size_t row = static_cast<std::size_t>(done + r);
// 时间主通道。
{
const ChannelSpec& master = group.channels[masterIndex];
double value = DecodeRawValue(record, recordBytes, master);
if (master.allInvalid)
{
value = kMissingValue;
}
else if (master.hasInvalidBit)
{
const std::size_t byteIndex = master.invalidBitPos / 8u;
if (byteIndex < recordBytes &&
((record[byteIndex] >> (master.invalidBitPos % 8u)) & 1u) != 0u)
{
value = kMissingValue;
}
}
if (std::isfinite(value))
{
value = master.linearConversion ? (master.offset + master.factor * value) : value;
}
group.time[row] = value;
}
// 各信号通道。
for (std::size_t c = 0; c < group.channels.size(); ++c)
{
if (c == masterIndex)
{
continue;
}
const ChannelSpec& spec = group.channels[c];
double value = DecodeRawValue(record, recordBytes, spec);
if (spec.allInvalid)
{
value = kMissingValue;
}
else if (spec.hasInvalidBit)
{
const std::size_t byteIndex = spec.invalidBitPos / 8u;
if (byteIndex < recordBytes &&
((record[byteIndex] >> (spec.invalidBitPos % 8u)) & 1u) != 0u)
{
value = kMissingValue;
}
}
if (std::isfinite(value))
{
value = spec.linearConversion ? (spec.offset + spec.factor * value) : value;
}
group.values[c][row] = value;
}
}
done += wantRecords;
}
return true;
}
/**
* @brief 从通道组里选出时间主通道。
* @return 主通道下标;找不到时返回 `channels.size()`(表示没有可用时间轴)。
*/
std::size_t SelectMasterChannel(const std::vector<ChannelSpec>& channels)
{
for (std::size_t i = 0; i < channels.size(); ++i)
{
if (channels[i].syncMaster && channels[i].numeric)
{
return i;
}
}
for (std::size_t i = 0; i < channels.size(); ++i)
{
if (!channels[i].numeric)
{
continue;
}
std::wstring lower;
lower.reserve(channels[i].name.size());
for (const wchar_t c : channels[i].name)
{
lower.push_back((c >= L'A' && c <= L'Z') ? static_cast<wchar_t>(c - L'A' + L'a') : c);
}
if (lower == L"time" || lower == L"t" || lower == L"timestamp")
{
return i;
}
}
return channels.size();
}
} // namespace
bool LoadMf4Source(const std::wstring& filePath, LoadedSource& source, std::wstring& error,
const LoadProgress& progress)
{
source = LoadedSource();
error.clear();
MdfFile file;
if (!file.Open(filePath, error))
{
return false;
}
const std::size_t slashPos = filePath.find_last_of(L"\\/");
source.sourceName = (slashPos == std::wstring::npos) ? filePath : filePath.substr(slashPos + 1);
// 头块 → 第一个数据组。
std::uint64_t hd = 0;
{
char id[5] = {};
// 头块通常紧跟在 64 字节 ID 块之后;用一个小的扫描窗口定位,兼容前置填充。
for (std::uint64_t offset = 24; offset + 64 <= file.Size() && offset < 4096; offset += 8)
{
if (file.BlockId(offset, id) && std::memcmp(id, "##HD", 4) == 0)
{
hd = offset;
break;
}
}
}
if (hd == 0)
{
error = L"未找到 MDF 头块(##HD)。";
return false;
}
const std::uint64_t firstDg = file.Link(hd, 0);
std::size_t groupCount = 0;
WalkChain(file, firstDg, "##DG", kMaxGroups,
[&](std::uint64_t dg)
{
++groupCount;
const std::uint64_t cg = file.Link(dg, 1);
const std::uint64_t dt = file.Link(dg, 2);
if (!file.IsBlock(cg, "##CG") || !file.IsBlock(dt, "##DT"))
{
return true; // 跳过不完整的数据组
}
const std::uint64_t cgData = file.DataOffset(cg);
const std::uint64_t cycles = file.U64(cgData + 8);
if (cycles == 0)
{
return true;
}
const std::uint64_t payload = file.BlockLength(dt) - (file.DataOffset(dt) - dt);
// 解析通道链。
std::vector<ChannelSpec> channels;
WalkChain(file, file.Link(cg, 1), "##CN", kMaxChannelsPerGroup,
[&](std::uint64_t cn)
{
ChannelSpec spec;
if (ParseChannel(file, cn, spec))
{
channels.push_back(std::move(spec));
}
return true;
});
if (channels.empty())
{
return true;
}
// 记录长度:优先用「数据块大小 / 记录数」反推(最可靠),
// 否则按通道覆盖的位宽向上取整到 4 字节。
std::size_t recordBytes = 0;
if (payload % cycles == 0 && payload / cycles > 0)
{
recordBytes = static_cast<std::size_t>(payload / cycles);
}
else
{
std::size_t bits = 0;
for (const ChannelSpec& spec : channels)
{
bits = std::max(bits, spec.bitOffset + spec.bitCount);
}
recordBytes = (bits + 31u) / 32u * 4u;
}
if (recordBytes == 0)
{
return true;
}
const std::size_t masterIndex = SelectMasterChannel(channels);
if (masterIndex >= channels.size())
{
return true; // 没有可用时间轴通道
}
ChannelGroupData group;
group.timeName = channels[masterIndex].name;
if (group.timeName.empty())
{
group.timeName = L"time";
}
group.channels = std::move(channels);
if (!ReadChannelGroupRecords(file, dt, cycles, recordBytes, masterIndex, group))
{
return true;
}
// 组织成一个时间轴块:时间列 + 其余数值通道。
TableBlock block;
block.timeHeader = group.timeName;
block.table.headers.push_back(group.timeName);
block.table.columns.push_back(std::move(group.time));
for (std::size_t c = 0; c < group.channels.size(); ++c)
{
if (c == masterIndex || !group.channels[c].numeric)
{
continue;
}
std::wstring name = group.channels[c].name;
if (name.empty())
{
name = L"Channel" + std::to_wstring(c + 1);
}
block.table.headers.push_back(name);
block.table.columns.push_back(std::move(group.values[c]));
}
if (block.table.columns.size() < 2)
{
return true; // 只有时间列:没有可绘制信号
}
block.table.rowCount = static_cast<std::size_t>(cycles);
source.blocks.push_back(std::move(block));
if (progress)
{
LoadProgressInfo info;
info.ratio = (groupCount > 0) ? std::min(1.0, static_cast<double>(groupCount) / 64.0)
: 0.0;
info.hasBytes = false;
if (!progress(info))
{
return false;
}
}
return true;
});
if (source.blocks.empty())
{
error = L"未在文件中找到可绘制的数值通道。";
return false;
}
if (progress)
{
LoadProgressInfo info;
info.ratio = 1.0;
progress(info);
}
return true;
}
} // namespace jdi