From 6eedbe3c5dbcbaa278c60bb3463e07d86c820b6c Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Thu, 15 Oct 2026 03:48:58 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=B1=82=E5=A4=A7=E9=87=8D?= =?UTF-8?q?=E6=9E=84=EF=BC=9A=E6=96=87=E4=BB=B6=E2=86=92=E5=88=86=E7=BB=84?= =?UTF-8?q?=20/=20=E6=97=B6=E9=97=B4=E8=BD=B4=E4=B8=80=E7=AD=89=E5=85=AC?= =?UTF-8?q?=E6=B0=91=20/=20=E5=8A=A0=E8=BD=BD=E5=99=A8=E6=8A=BD=E8=B1=A1?= =?UTF-8?q?=20+=20CSV=20=E5=A4=9A=E6=97=B6=E9=97=B4=E8=BD=B4=20+=20MF4=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、数据模型解耦(详见 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 零警告。 --- Components/Application/Inc/Application.hpp | 12 +- Components/Application/Src/Application.cpp | 158 ++-- Components/PlotView/Inc/PlotView.hpp | 39 +- Components/PlotView/Src/PlotView.cpp | 244 +++--- Components/SignalPanel/Inc/SignalPanel.hpp | 14 +- Components/SignalPanel/Src/SignalPanel.cpp | 126 +-- Documents/agent_mem.md | 119 +++ Documents/refactor_report_2026-10-15.md | 245 ++++++ Utilities/Inc/CsvLoader.hpp | 33 + Utilities/Inc/CsvTable.hpp | 76 -- Utilities/Inc/DataLoader.hpp | 45 + Utilities/Inc/DataModel.hpp | 315 ++++--- Utilities/Inc/DataTable.hpp | 101 +++ Utilities/Inc/Mf4Loader.hpp | 36 + Utilities/Src/{CsvTable.cpp => CsvLoader.cpp} | 221 ++++- Utilities/Src/DataLoader.cpp | 55 ++ Utilities/Src/DataModel.cpp | 703 +++++++-------- Utilities/Src/Mf4Loader.cpp | 820 ++++++++++++++++++ 18 files changed, 2545 insertions(+), 817 deletions(-) create mode 100644 Documents/refactor_report_2026-10-15.md create mode 100644 Utilities/Inc/CsvLoader.hpp delete mode 100644 Utilities/Inc/CsvTable.hpp create mode 100644 Utilities/Inc/DataLoader.hpp create mode 100644 Utilities/Inc/DataTable.hpp create mode 100644 Utilities/Inc/Mf4Loader.hpp rename Utilities/Src/{CsvTable.cpp => CsvLoader.cpp} (60%) create mode 100644 Utilities/Src/DataLoader.cpp create mode 100644 Utilities/Src/Mf4Loader.cpp diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp index a7430f6..d12bd1d 100644 --- a/Components/Application/Inc/Application.hpp +++ b/Components/Application/Inc/Application.hpp @@ -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 的最后一步调用,覆盖整个客户区)。 diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index 59b75e7..8c2b7fd 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -10,7 +10,7 @@ #include #include -#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 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(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(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(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(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(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(file->signals.size())) + const SignalGroup* group = m_dataSet.GroupAt(request.groupIndex); + if (group == nullptr || request.signalIndex < 0 || + request.signalIndex >= static_cast(group->signals.size())) { return false; } - const SignalData& source = file->signals[static_cast(request.signalIndex)]; + const SignalData& source = group->signals[static_cast(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& times = file->time; + const std::vector& 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(wParam)); + RemoveGroup(static_cast(wParam)); return 0; } case kMessageRemoveSignal: diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index eb8f8a3..d38bec8 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -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; /**< 时间轴显示上界。 */ diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 46ce039..f759205 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -1,4 +1,4 @@ -#include "PlotView.hpp" +#include "PlotView.hpp" #include #include @@ -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& times = file.time; + const std::vector& times = axis.time; const std::size_t timeCount = std::min(times.size(), count); begin = static_cast( std::lower_bound(times.begin(), times.begin() + static_cast(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::infinity(); for (const SignalRef& ref : lanes[static_cast(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(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& samples = file.time; + const std::vector& 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::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(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(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& time = file.time; + const std::vector& time = axis.time; begin = static_cast( 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(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::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::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( @@ -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& time = resolved.file->time; + const std::vector& time = resolved.axis->time; const std::vector& 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::const_iterator first = std::lower_bound(time.begin(), time.end(), sourceMin); begin = static_cast(first - time.begin()); @@ -6259,12 +6265,12 @@ namespace jdi // 把阈值降到 1 采样/像素后,1~2 采样/像素区间走包络带(逐列 min/max 与折线在该密度下 // 几乎等价,且**保留抗锯齿**),彻底消除这一区间的病态卡顿。 const bool useEnvelopeBand = (visibleCount > static_cast(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(selected.fileIndex)); + mix(static_cast(selected.groupIndex)); mix(static_cast(selected.signalIndex)); } @@ -7225,7 +7231,7 @@ namespace jdi mix(static_cast(Tab().Lanes()[laneIndex].edgeCenterItems.size())); for (const Lane::EdgeCenterItem& item : Tab().Lanes()[laneIndex].edgeCenterItems) { - mix(static_cast(item.ref.fileIndex)); + mix(static_cast(item.ref.groupIndex)); mix(static_cast(item.ref.signalIndex)); mix(static_cast(item.points)); mix(static_cast(item.iterations)); @@ -7234,7 +7240,7 @@ namespace jdi mix(static_cast(Tab().Lanes()[laneIndex].meanFilterItems.size())); for (const Lane::MeanFilterItem& item : Tab().Lanes()[laneIndex].meanFilterItems) { - mix(static_cast(item.ref.fileIndex)); + mix(static_cast(item.ref.groupIndex)); mix(static_cast(item.ref.signalIndex)); mix(static_cast(item.points)); mix(static_cast(item.iterations)); @@ -7244,7 +7250,7 @@ namespace jdi for (const Lane::WeakPeakItem& item : Tab().Lanes()[laneIndex].weakPeakItems) { // 逐信号参数(等级 + 迭代次数)都参与指纹:改任一条的参数都会重画。 - mix(static_cast(item.ref.fileIndex)); + mix(static_cast(item.ref.groupIndex)); mix(static_cast(item.ref.signalIndex)); mix(static_cast(item.amountPercent)); mix(static_cast(item.iterations)); @@ -7254,7 +7260,7 @@ namespace jdi for (const SignalRef& ref : signals) { const ResolvedSignal resolved = ResolveSignal(ref); - mix(static_cast(ref.fileIndex)); + mix(static_cast(ref.groupIndex)); mix(static_cast(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(valueOf(item))); @@ -8290,7 +8296,7 @@ namespace jdi for (const SignalRef& ref : signals) { const ResolvedSignal resolved = ResolveSignal(ref); - mix(static_cast(ref.fileIndex)); + mix(static_cast(ref.groupIndex)); mix(static_cast(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(fileIndex))) + if (!m_tab->GroupHasVisible(static_cast(groupIndex))) { continue; } - const DataFile* file = m_dataSet->FileAt(static_cast(fileIndex)); - if (file != nullptr && !file->timeColumnName.empty()) + const SignalGroup* group = m_dataSet->GroupAt(static_cast(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& 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& time = resolved.file->time; + const std::vector& 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(ref.fileIndex)); + mix(static_cast(ref.groupIndex)); mix(static_cast(ref.signalIndex)); }; @@ -11798,7 +11804,7 @@ namespace jdi for (const SignalRef& ref : layout.rowRefs) { mix(ref.IsValid() ? 1ull : 0ull); - mix(static_cast(ref.fileIndex)); + mix(static_cast(ref.groupIndex)); mix(static_cast(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, diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index 8062572..c50ee0c 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -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 弹出网格归属菜单(由信号行的网格徽标触发)。 diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index 2432c02..10726e9 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -448,17 +448,17 @@ namespace jdi const float signalHeight = kSignalRowHeight * m_scale; const bool filtering = !m_filterText.empty(); - const std::vector& files = m_dataSet->Files(); - for (std::size_t fileIndex = 0; fileIndex < files.size(); ++fileIndex) + const std::vector& files = m_dataSet->Groups(); + for (std::size_t groupIndex = 0; groupIndex < files.size(); ++groupIndex) { // 筛选时先挑出本组内匹配的信号;整组都没有匹配则**不显示该组**。 std::vector 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(signalIndex)); } @@ -470,13 +470,13 @@ namespace jdi } ListRow fileRow; - fileRow.kind = RowKind::FILE; - fileRow.fileIndex = static_cast(fileIndex); + fileRow.kind = RowKind::GROUP; + fileRow.groupIndex = static_cast(groupIndex); fileRow.height = fileHeight; m_rows.push_back(fileRow); // 筛选结果**默认展开**(忽略折叠状态),便于直接看到匹配项。 - if (!filtering && m_tab->IsCollapsed(static_cast(fileIndex))) + if (!filtering && m_tab->IsCollapsed(static_cast(groupIndex))) { continue; } @@ -487,7 +487,7 @@ namespace jdi { ListRow signalRow; signalRow.kind = RowKind::SIGNAL; - signalRow.fileIndex = static_cast(fileIndex); + signalRow.groupIndex = static_cast(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(fileIndex); + signalRow.groupIndex = static_cast(groupIndex); signalRow.signalIndex = static_cast(signalIndex); signalRow.height = signalHeight; m_rows.push_back(signalRow); @@ -639,7 +639,7 @@ namespace jdi { const int rowIndex = static_cast(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(row)].kind == RowKind::FILE) + if (m_rows[static_cast(row)].kind == RowKind::GROUP) { PaintFileRow(g, row); } @@ -1025,8 +1025,8 @@ namespace jdi } const ListRow& item = m_rows[static_cast(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(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(row)].kind == RowKind::FILE); + const bool isFile = (m_rows[static_cast(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(row)].fileIndex, + const SignalRef ref{m_rows[static_cast(row)].groupIndex, m_rows[static_cast(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(row)].fileIndex + 1); + const std::wstring label = L"组" + std::to_wstring(m_rows[static_cast(row)].groupIndex + 1); return UiMeasureTextWidth(m_fonts->uiBold, label); } @@ -1534,7 +1534,7 @@ namespace jdi return false; } const ListRow& item = m_rows[static_cast(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(row)].fileIndex); - if (file == nullptr) + const SignalGroup* group = m_dataSet->GroupAt(m_rows[static_cast(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(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(row)].fileIndex, trimmed); + m_dataSetWritable->SetGroupDisplayName(m_rows[static_cast(row)].groupIndex, trimmed); break; } default: @@ -2458,12 +2458,12 @@ namespace jdi const ListRow& item = m_rows[static_cast(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(item.fileIndex), 0); + PostMessageW(m_host, WM_APP + 1, static_cast(item.groupIndex), 0); } else { // wParam = 文件下标,lParam = 文件内信号下标(见 Application 的消息处理)。 - PostMessageW(m_host, WM_APP + 2, static_cast(item.fileIndex), + PostMessageW(m_host, WM_APP + 2, static_cast(item.groupIndex), static_cast(item.signalIndex)); } } @@ -2616,7 +2616,7 @@ namespace jdi if (row >= 0 && part == RowPart::LANE_BADGE) { const ListRow& item = m_rows[static_cast(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(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(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 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(m_menuTargetFile), 0); diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 8b83f13..662d630 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -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 离屏验证方法学(本机偏好,强烈建议沿用) diff --git a/Documents/refactor_report_2026-10-15.md b/Documents/refactor_report_2026-10-15.md new file mode 100644 index 0000000..2bb5ccf --- /dev/null +++ b/Documents/refactor_report_2026-10-15.md @@ -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 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 values; + double minValue, maxValue; std::size_t finiteCount; + std::vector 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 axes; // 1..N 条时间轴 ← 取代单一 time + std::vector 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(后续需要撤销/重做时收益更大)。 diff --git a/Utilities/Inc/CsvLoader.hpp b/Utilities/Inc/CsvLoader.hpp new file mode 100644 index 0000000..2c52540 --- /dev/null +++ b/Utilities/Inc/CsvLoader.hpp @@ -0,0 +1,33 @@ +#ifndef __CSV_LOADER_HPP__ +#define __CSV_LOADER_HPP__ + +#include + +#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__ diff --git a/Utilities/Inc/CsvTable.hpp b/Utilities/Inc/CsvTable.hpp deleted file mode 100644 index 9012f31..0000000 --- a/Utilities/Inc/CsvTable.hpp +++ /dev/null @@ -1,76 +0,0 @@ -#ifndef __CSV_TABLE_HPP__ -#define __CSV_TABLE_HPP__ - -#include -#include -#include -#include - -namespace jdi -{ - - /** - * @brief CSV 文件的解析结果,保存列名与**列优先**的数值矩阵。 - * - * 采用列优先(每条信号一整块连续内存)而不是行优先的二维数组,原因有二: - * 1. 大型文件(百万行级)若按行存储会产生上百万次独立堆分配, - * 每行还要额外承担 vector 的控制块与容量对齐开销,实测内存放大接近 1.5 倍; - * 2. 后续构建信号时可以直接把整列**移动**进 SignalData,不再逐元素拷贝。 - */ - struct CsvTable - { - std::vector headers; /**< 列名(已去除引号与首尾空白)。 */ - std::vector> 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; - - /** - * @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__ diff --git a/Utilities/Inc/DataLoader.hpp b/Utilities/Inc/DataLoader.hpp new file mode 100644 index 0000000..2f3427f --- /dev/null +++ b/Utilities/Inc/DataLoader.hpp @@ -0,0 +1,45 @@ +#ifndef __DATA_LOADER_HPP__ +#define __DATA_LOADER_HPP__ + +#include + +#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__ diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index f9369b9..9fdd42a 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -9,7 +9,8 @@ #include #include -#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 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 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 time; /**< 时间轴数值。 */ - std::vector signals; /**< 该文件包含的信号。 */ - double minTime = 0.0; /**< 时间轴最小值。 */ - double maxTime = 0.0; /**< 时间轴最大值。 */ - bool timeMonotonic = true; /**< 时间轴是否单调不减。 */ + std::wstring name; /**< 分组默认名称(源文件名,含扩展名)。 */ + std::wstring displayName; /**< 用户重命名后的分组名;为空时使用 name。 */ + std::vector axes; /**< 该分组使用的时间轴(至少一条)。 */ + std::vector 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& Files() const { return m_files; } + const std::vector& 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 m_files; /**< 已加载文件集合。 */ - SignalRef m_selected; /**< 当前选中信号(跨标签页共享)。 */ + std::vector 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 设置「新出现的文件分组是否默认折起」。 diff --git a/Utilities/Inc/DataTable.hpp b/Utilities/Inc/DataTable.hpp new file mode 100644 index 0000000..a0388f4 --- /dev/null +++ b/Utilities/Inc/DataTable.hpp @@ -0,0 +1,101 @@ +#ifndef __DATA_TABLE_HPP__ +#define __DATA_TABLE_HPP__ + +#include +#include +#include +#include + +namespace jdi +{ + + /** + * @brief 内存中的**列优先**数据表(与具体文件格式无关)。 + * + * 这是「从任意格式读出来的原始格子」:一行是表头,其余是各列的数值。 + * 它**不是**应用的数据模型(不含时间轴语义、不含信号名规范化), + * 只是各格式加载器(CSV / MF4 / 以后更多格式)与数据模型之间的中转结构。 + * + * 采用列优先(每列一整块连续内存)而不是行优先的二维数组,原因有二: + * 1. 大型文件(百万行级)若按行存储会产生上百万次独立堆分配, + * 每行还要额外承担 vector 的控制块与容量对齐开销,实测内存放大接近 1.5 倍; + * 2. 构建信号时可以直接把整列**移动**进 `SignalData`,不再逐元素拷贝。 + */ + struct DataTable + { + std::vector headers; /**< 列名(已去除引号与首尾空白)。 */ + std::vector> 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; + + /** + * @brief 一组**共享同一时间轴**的数据列(一个「时间轴块」)。 + * + * 一个源文件可以包含多个这样的块:CSV 里每个 `time` 列都会开启一个新块, + * MF4 里每个测量通道组(CG)是一个块。块内的各列长度一致(都等于本块的行数)。 + */ + struct TableBlock + { + std::wstring timeHeader; /**< 本块时间列的列名(原样保留,如 `time`)。 */ + DataTable table; /**< 本块的列集合(**含**时间列本身)。 */ + }; + + /** + * @brief 一个源文件加载后的全部内容(可能包含多个时间轴块)。 + * + * ⚠️ 这是「加载器 → 数据模型」的唯一接口:加载器只负责把文件读成 + * `LoadedSource`(纯格子,无应用语义),`BuildSignalGroup` 再把它规范化为 + * `SignalGroup`(含时间轴去重、信号命名、调色板等应用语义)。 + * 新增文件格式时**只需新增一个加载器**,数据模型与界面代码完全不用改。 + */ + struct LoadedSource + { + std::wstring sourceName; /**< 源文件名(不含目录,含扩展名)。 */ + std::vector blocks; /**< 时间轴块集合(至少一个)。 */ + + /** @brief 是否包含可用的数据。 */ + bool HasData() const + { + for (const TableBlock& block : blocks) + { + if (block.table.HasData()) + { + return true; + } + } + return false; + } + }; + +} // namespace jdi + +#endif // __DATA_TABLE_HPP__ diff --git a/Utilities/Inc/Mf4Loader.hpp b/Utilities/Inc/Mf4Loader.hpp new file mode 100644 index 0000000..e7b5547 --- /dev/null +++ b/Utilities/Inc/Mf4Loader.hpp @@ -0,0 +1,36 @@ +#ifndef __MF4_LOADER_HPP__ +#define __MF4_LOADER_HPP__ + +#include + +#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__ diff --git a/Utilities/Src/CsvTable.cpp b/Utilities/Src/CsvLoader.cpp similarity index 60% rename from Utilities/Src/CsvTable.cpp rename to Utilities/Src/CsvLoader.cpp index 4e26f93..e11d0d6 100644 --- a/Utilities/Src/CsvTable.cpp +++ b/Utilities/Src/CsvLoader.cpp @@ -1,9 +1,10 @@ -#include "CsvTable.hpp" +#include "CsvLoader.hpp" #include "Utf8Util.hpp" #include +#include #include #include #include @@ -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()); for (std::vector& 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& blocks) + { + const std::size_t columnCount = table.headers.size(); + + // 找出所有时间列下标(按出现顺序)。 + std::vector 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 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(prepended.size()), + first.table.headers.end()); + std::rotate(first.table.columns.begin(), + first.table.columns.begin() + static_cast(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& 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& 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(state.bytesConsumed) / - static_cast(fileSize) + info.ratio = (fileSize > 0) ? (static_cast(state.bytesConsumed) / + static_cast(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 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; } diff --git a/Utilities/Src/DataLoader.cpp b/Utilities/Src/DataLoader.cpp new file mode 100644 index 0000000..65d47a8 --- /dev/null +++ b/Utilities/Src/DataLoader.cpp @@ -0,0 +1,55 @@ +#include "DataLoader.hpp" + +#include "CsvLoader.hpp" +#include "Mf4Loader.hpp" +#include "Utf8Util.hpp" + +#include + +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 diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index abb4be6..6e027dc 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -23,64 +24,6 @@ namespace jdi const double kMissing = std::numeric_limits::quiet_NaN(); /**< 缺失值占位。 */ const double kDefaultLineWidth = 1.6; /**< 默认线宽。 */ - /** - * @brief 判断列名是否为时间轴候选列。 - * @param [IN] header 原始列名。 - * @return 可能为时间轴返回 true。 - */ - bool LooksLikeTimeHeader(const std::wstring& header) - { - const std::wstring text = ToLowerAscii(Trim(header)); - if (text.empty()) - { - return false; - } - - if (text == L"t" || text == L"time" || text == L"timestamp" || text == L"simulation time" || - text == L"时间" || text == L"时间轴") - { - return true; - } - return ContainsInsensitive(text, L"time") || ContainsInsensitive(text, L"时间"); - } - - /** - * @brief 从名称末尾的括号中提取单位(仅接受形如 mA / V / deg/s 的短标记)。 - * @param [IN] text 列名(已去除首尾空白)。 - * @return 单位文本,无法识别时返回空字符串。 - */ - std::wstring ExtractUnit(const std::wstring& text) - { - if (text.size() < 3 || text.back() != L')') - { - return std::wstring(); - } - - const std::size_t open = text.rfind(L'('); - if (open == std::wstring::npos || open + 2 >= text.size()) - { - return std::wstring(); - } - - const std::wstring inner = Trim(text.substr(open + 1, text.size() - open - 2)); - if (inner.empty() || inner.size() > 10) - { - return std::wstring(); - } - - for (const wchar_t c : inner) - { - const bool allowed = iswalpha(c) || iswdigit(c) || c == L'%' || c == L'/' || c == L'^' || - c == L'-' || c == L'.' || c == L'\u00B0' || c == L'\u00B5' || - c == L'\u03A9' || c == L'\u00B2' || c == L'\u00B3' || c == L'\u00B7'; - if (!allowed) - { - return std::wstring(); - } - } - return inner; - } - /** * @brief 为网格范围补充留白,避免波形紧贴边界。 * @param [IN, OUT] yMin 范围下限。 @@ -218,14 +161,14 @@ namespace jdi // 收集全部可见信号引用。 std::vector visibleRefs; - for (std::size_t fileIndex = 0; fileIndex < visible.size(); ++fileIndex) + for (std::size_t groupIndex = 0; groupIndex < visible.size(); ++groupIndex) { - for (std::size_t signalIndex = 0; signalIndex < visible[fileIndex].size(); ++signalIndex) + for (std::size_t signalIndex = 0; signalIndex < visible[groupIndex].size(); ++signalIndex) { - if (visible[fileIndex][signalIndex]) + if (visible[groupIndex][signalIndex]) { SignalRef ref; - ref.fileIndex = static_cast(fileIndex); + ref.groupIndex = static_cast(groupIndex); ref.signalIndex = static_cast(signalIndex); visibleRefs.push_back(ref); } @@ -235,11 +178,11 @@ namespace jdi // 判断某引用对应的信号是否可见(越界视为不可见)。 const auto isVisibleRef = [&visible](const SignalRef& ref) { - if (ref.fileIndex < 0 || static_cast(ref.fileIndex) >= visible.size()) + if (ref.groupIndex < 0 || static_cast(ref.groupIndex) >= visible.size()) { return false; } - const std::vector& fileVisible = visible[static_cast(ref.fileIndex)]; + const std::vector& fileVisible = visible[static_cast(ref.groupIndex)]; if (ref.signalIndex < 0 || static_cast(ref.signalIndex) >= fileVisible.size()) { return false; @@ -390,7 +333,7 @@ namespace jdi } } - // ============================ DataFile 构建 ============================ + // ============================ SignalGroup 构建 ============================ /** * @brief 粗粒度包络层每个分组覆盖的采样数。 @@ -473,130 +416,194 @@ namespace jdi return stats; } - bool BuildDataFile(CsvTable&& table, const std::wstring& filePath, std::size_t paletteBase, DataFile& file, - std::wstring& error, const CsvProgress& progress) + bool BuildSignalGroup(LoadedSource&& source, std::size_t paletteBase, SignalGroup& group, + std::wstring& error, const LoadProgress& progress) { error.clear(); - file = DataFile(); + group = SignalGroup(); - if (!table.HasData()) + if (source.blocks.empty() || !source.HasData()) { - error = L"数据表为空。"; + error = L"数据源为空。"; return false; } - const std::size_t columnCount = table.headers.size(); - if (table.columns.size() < columnCount) - { - error = L"数据表列结构不完整。"; - return false; - } + // 分组默认名 = 源文件名(含扩展名),用户可重命名。 + group.name = source.sourceName; - // 1. 识别时间轴列:优先取列名匹配的列,否则回退到第一列。 - int timeColumn = -1; - for (std::size_t column = 0; column < columnCount; ++column) - { - if (LooksLikeTimeHeader(table.headers[column])) - { - timeColumn = static_cast(column); - break; - } - } - if (timeColumn < 0) - { - timeColumn = 0; - } - - // 时间列整列移动,不做逐元素拷贝。 - file.time = std::move(table.columns[static_cast(timeColumn)]); - - std::size_t finiteTimeCount = 0; - bool monotonic = true; - double previousTime = 0.0; - bool hasPrevious = false; - for (const double value : file.time) - { - if (std::isfinite(value)) - { - ++finiteTimeCount; - if (hasPrevious && value < previousTime) - { - monotonic = false; - } - previousTime = value; - hasPrevious = true; - } - } - - if (finiteTimeCount < 2) - { - error = L"时间轴列(" + table.headers[static_cast(timeColumn)] + L")有效数值不足。"; - return false; - } - - file.timeColumnName = table.headers[static_cast(timeColumn)]; - file.timeMonotonic = monotonic; - file.filePath = filePath; - - const std::size_t slashPos = filePath.find_last_of(L"\\/"); - file.fileName = (slashPos == std::wstring::npos) ? filePath : filePath.substr(slashPos + 1); - - // 2. 构建信号列:每列整块移动,随后就地统计取值范围。 int paletteCount = 0; const std::uint32_t* palette = SignalPalette(paletteCount); - file.signals.reserve(columnCount); - for (std::size_t column = 0; column < columnCount; ++column) + + // 时间轴去重表:内容完全相同(长度与全部数值逐位相同)的时间轴只保留一条。 + // 这样 CSV 里多个同刻度的 `time` 列、MF4 里多个同基线的通道组不会重复占内存。 + std::vector axisHashToIndex; + std::vector axisHashValues; + + const auto timeHash = [](const std::vector& time) -> std::uint64_t { - if (static_cast(column) == timeColumn) + std::uint64_t hash = 1469598103934665603ull; + for (const double value : time) + { + std::uint64_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + hash ^= bits; + hash *= 1099511628211ull; + } + hash ^= static_cast(time.size()); + hash *= 1099511628211ull; + return hash; + }; + const auto sameTime = [](const std::vector& a, const std::vector& b) -> bool + { + if (a.size() != b.size()) + { + return false; + } + for (std::size_t i = 0; i < a.size(); ++i) + { + const double x = a[i]; + const double y = b[i]; + if (x != y) + { + // NaN 视为“相同”(缺失位置一致),其余必须完全相等。 + if (!(std::isnan(x) && std::isnan(y))) + { + return false; + } + } + } + return true; + }; + + std::size_t blockIndex = 0; + for (TableBlock& block : source.blocks) + { + ++blockIndex; + DataTable& table = block.table; + if (!table.HasData()) { continue; } - - SignalData signal; - signal.rawHeader = table.headers[column]; + const std::size_t columnCount = table.headers.size(); + if (table.columns.size() < columnCount || columnCount < 2) { - // 名称完整保留文件内的列名(含 "[n]" 序号与括号描述),仅用于展示不做改写。 - signal.name = Trim(signal.rawHeader); - signal.unit = ExtractUnit(signal.name); + continue; // 只有时间列:没有可绘制信号 } - signal.values = std::move(table.columns[column]); - const SignalStats stats = ComputeStats(signal.values, signal.coarseMin, signal.coarseMax); - signal.coarseStride = kCoarseStride; - signal.minValue = stats.minValue; - signal.maxValue = stats.maxValue; - signal.finiteCount = stats.finiteCount; + // 时间列固定为块的第 0 列(加载器已保证)。 + std::vector& timeColumn = table.columns[0]; - // 显示外观随信号一起初始化:调色板按「全局信号序号」取色, - // 使同一信号在任意标签页中都保持同一默认颜色。 + // 统计时间轴的取值范围与单调性。 + TimeAxis axis; + axis.name = table.headers[0]; { - const std::size_t globalIndex = paletteBase + file.signals.size(); - if (palette != nullptr && paletteCount > 0) + bool monotonic = true; + bool hasPrevious = false; + double previousTime = 0.0; + double low = std::numeric_limits::infinity(); + double high = -std::numeric_limits::infinity(); + std::size_t finiteCount = 0; + for (const double value : timeColumn) { - const std::size_t count = static_cast(paletteCount); - const std::size_t slot = globalIndex % count; - signal.style.color = palette[slot]; - // 自定义色初值取调色板中「向后递进」的下一个颜色。 - // 若初值等于本信号自身的预设色,样式区里点击该预设色块会被判定成 - // “当前用的是自定义色”,高亮会直接跳到自定义色块上(此前的缺陷)。 - signal.style.customColor = palette[(slot + 1) % count]; + if (!std::isfinite(value)) + { + continue; + } + ++finiteCount; + low = std::min(low, value); + high = std::max(high, value); + if (hasPrevious && value < previousTime) + { + monotonic = false; + } + previousTime = value; + hasPrevious = true; } - else + if (finiteCount < 1) { - signal.style.color = 0xFF268BD2u; - signal.style.customColor = 0xFF859900u; + continue; // 该块的时间轴完全无效 + } + axis.monotonic = monotonic; + axis.finiteCount = finiteCount; + axis.minTime = low; + axis.maxTime = high; + } + axis.time = std::move(timeColumn); + + // 去重:命中已有时间轴则复用它,否则新增一条。 + std::size_t axisIndex = group.axes.size(); + { + const std::uint64_t hash = timeHash(axis.time); + for (std::size_t candidate = 0; candidate < axisHashValues.size(); ++candidate) + { + if (axisHashValues[candidate] == hash && + sameTime(group.axes[axisHashToIndex[candidate]].time, axis.time)) + { + axisIndex = axisHashToIndex[candidate]; + break; + } + } + if (axisIndex == group.axes.size()) + { + group.axes.push_back(std::move(axis)); + axisHashValues.push_back(hash); + axisHashToIndex.push_back(axisIndex); } - signal.style.opacity = 1.0f; - signal.style.lineWidth = 1.0f; - signal.style.mode = DrawMode::LINE; } - file.signals.push_back(std::move(signal)); + // 构建信号列:每列整块移动,随后就地统计取值范围。 + group.signals.reserve(group.signals.size() + columnCount - 1); + for (std::size_t column = 1; column < columnCount; ++column) + { + SignalData signal; + signal.name = Trim(table.headers[column]); + if (signal.name.empty()) + { + signal.name = L"Column" + std::to_wstring(column + 1); + } + signal.axisIndex = axisIndex; + + signal.values = std::move(table.columns[column]); + const SignalStats stats = ComputeStats(signal.values, signal.coarseMin, signal.coarseMax); + signal.coarseStride = kCoarseStride; + signal.minValue = stats.minValue; + signal.maxValue = stats.maxValue; + signal.finiteCount = stats.finiteCount; + + // 显示外观随信号一起初始化:调色板按「全局信号序号」取色, + // 使同一信号在任意标签页中都保持同一默认颜色。 + { + const std::size_t globalIndex = paletteBase + group.signals.size(); + if (palette != nullptr && paletteCount > 0) + { + const std::size_t count = static_cast(paletteCount); + const std::size_t slot = globalIndex % count; + signal.style.color = palette[slot]; + // 自定义色初值取调色板中「向后递进」的下一个颜色。 + // 若初值等于本信号自身的预设色,样式区里点击该预设色块会被判定成 + // “当前用的是自定义色”,高亮会直接跳到自定义色块上(此前的缺陷)。 + signal.style.customColor = palette[(slot + 1) % count]; + } + else + { + signal.style.color = 0xFF268BD2u; + signal.style.customColor = 0xFF859900u; + } + signal.style.opacity = 1.0f; + signal.style.lineWidth = 1.0f; + signal.style.mode = DrawMode::LINE; + } + + group.signals.push_back(std::move(signal)); + } if (progress) { - CsvProgressInfo info; - info.ratio = 0.75 + 0.25 * static_cast(column + 1) / static_cast(columnCount); + LoadProgressInfo info; + const double ratio = static_cast(blockIndex) / + static_cast(std::max(1, source.blocks.size())); + info.ratio = 0.75 + 0.25 * ratio; + info.hasBytes = false; if (!progress(info)) { error = L"已取消加载。"; @@ -605,53 +612,39 @@ namespace jdi } } - if (file.signals.empty()) + if (group.signals.empty() || group.axes.empty()) { error = L"除时间轴外未发现任何数据列。"; return false; } - - // 3. 统计时间范围(时间列为空或全部无效时回退到行数上界)。 - double tMin = std::numeric_limits::infinity(); - double tMax = -std::numeric_limits::infinity(); - for (const double value : file.time) - { - if (std::isfinite(value)) - { - tMin = std::min(tMin, value); - tMax = std::max(tMax, value); - } - } - file.minTime = tMin; - file.maxTime = tMax; return true; } // ============================ DataSet ============================ - bool DataSet::AddDataFile(DataFile&& file, int& fileIndex) + bool DataSet::AddGroup(SignalGroup&& group, int& groupIndex) { - fileIndex = -1; - if (file.signals.empty()) + groupIndex = -1; + if (group.signals.empty() || group.axes.empty()) { return false; } - m_files.push_back(std::move(file)); - fileIndex = static_cast(m_files.size()) - 1; + m_groups.push_back(std::move(group)); + groupIndex = static_cast(m_groups.size()) - 1; return true; } - bool DataSet::InsertDerivedSignal(int fileIndex, int afterSignalIndex, SignalData&& signal, + bool DataSet::InsertDerivedSignal(int groupIndex, int afterSignalIndex, SignalData&& signal, int& newSignalIndex) { newSignalIndex = -1; - if (fileIndex < 0 || fileIndex >= static_cast(m_files.size())) + if (groupIndex < 0 || groupIndex >= static_cast(m_groups.size())) { return false; } - DataFile& file = m_files[static_cast(fileIndex)]; - if (afterSignalIndex < -1 || afterSignalIndex >= static_cast(file.signals.size())) + SignalGroup& group = m_groups[static_cast(groupIndex)]; + if (afterSignalIndex < -1 || afterSignalIndex >= static_cast(group.signals.size())) { return false; } @@ -667,11 +660,11 @@ namespace jdi // 2. 显示外观按「全局信号序号」取调色板颜色(与普通新信号完全一致的规则)。 const int insertPos = afterSignalIndex + 1; { - // 新信号的全局序号 = 本文件之前的信号总数 + 它在文件内的下标。 + // 新信号的全局序号 = 本组之前的信号总数 + 它在组内的下标。 std::size_t globalIndex = static_cast(insertPos); - for (int index = 0; index < fileIndex; ++index) + for (int index = 0; index < groupIndex; ++index) { - globalIndex += m_files[static_cast(index)].signals.size(); + globalIndex += m_groups[static_cast(index)].signals.size(); } int paletteCount = 0; @@ -685,10 +678,10 @@ namespace jdi } } - file.signals.insert(file.signals.begin() + insertPos, std::move(signal)); + group.signals.insert(group.signals.begin() + insertPos, std::move(signal)); - // 3. 全局选中引用:同一文件内位于插入点之后的信号下标整体后移。 - if (m_selected.fileIndex == fileIndex && m_selected.signalIndex >= insertPos) + // 3. 全局选中引用:同一组内位于插入点之后的信号下标整体后移。 + if (m_selected.groupIndex == groupIndex && m_selected.signalIndex >= insertPos) { ++m_selected.signalIndex; } @@ -697,41 +690,41 @@ namespace jdi return true; } - bool DataSet::RemoveFile(int fileIndex) + bool DataSet::RemoveGroup(int groupIndex) { - if (fileIndex < 0 || fileIndex >= static_cast(m_files.size())) + if (groupIndex < 0 || groupIndex >= static_cast(m_groups.size())) { return false; } // 用移动到末位后弹出的方式移除,使被移除文件的时间轴与全部信号缓冲区在本调用内 // 立即析构归还操作系统,不再等待后续插入覆盖;最后收缩容量,避免残留空闲块。 - const std::size_t position = static_cast(fileIndex); - for (std::size_t index = position; index + 1 < m_files.size(); ++index) + const std::size_t position = static_cast(groupIndex); + for (std::size_t index = position; index + 1 < m_groups.size(); ++index) { - m_files[index] = std::move(m_files[index + 1]); + m_groups[index] = std::move(m_groups[index + 1]); } - m_files.pop_back(); - m_files.shrink_to_fit(); + m_groups.pop_back(); + m_groups.shrink_to_fit(); return true; } - bool DataSet::RemoveSignal(int fileIndex, int signalIndex) + bool DataSet::RemoveSignal(int groupIndex, int signalIndex) { - if (fileIndex < 0 || fileIndex >= static_cast(m_files.size())) + if (groupIndex < 0 || groupIndex >= static_cast(m_groups.size())) { return false; } - DataFile& file = m_files[static_cast(fileIndex)]; - if (signalIndex < 0 || signalIndex >= static_cast(file.signals.size())) + SignalGroup& group = m_groups[static_cast(groupIndex)]; + if (signalIndex < 0 || signalIndex >= static_cast(group.signals.size())) { return false; } - // 与 RemoveFile 同一思路:先把待移除项移到末位(move 赋值时其原有缓冲当场析构), + // 与 RemoveGroup 同一思路:先把待移除项移到末位(move 赋值时其原有缓冲当场析构), // 再 pop_back + shrink_to_fit,使该信号的全部采样内存在本调用内立即归还给系统, // 而不是留在容量里等后续插入覆盖。 - std::vector& signals = file.signals; + std::vector& signals = group.signals; const std::size_t position = static_cast(signalIndex); for (std::size_t index = position; index + 1 < signals.size(); ++index) { @@ -742,9 +735,9 @@ namespace jdi return true; } - void DataSet::RemapSelectionAfterSignalRemoval(int fileIndex, int signalIndex) + void DataSet::RemapSelectionAfterSignalRemoval(int groupIndex, int signalIndex) { - if (fileIndex < 0 || signalIndex < 0 || m_selected.fileIndex != fileIndex) + if (groupIndex < 0 || signalIndex < 0 || m_selected.groupIndex != groupIndex) { return; } @@ -763,38 +756,38 @@ namespace jdi std::size_t DataSet::SignalCount() const { std::size_t count = 0; - for (const DataFile& file : m_files) + for (const SignalGroup& group : m_groups) { - count += file.signals.size(); + count += group.signals.size(); } return count; } - const DataFile* DataSet::FileAt(int fileIndex) const + const SignalGroup* DataSet::GroupAt(int groupIndex) const { - if (fileIndex < 0 || fileIndex >= static_cast(m_files.size())) + if (groupIndex < 0 || groupIndex >= static_cast(m_groups.size())) { return nullptr; } - return &m_files[static_cast(fileIndex)]; + return &m_groups[static_cast(groupIndex)]; } - bool DataSet::SetSignalDisplayName(int fileIndex, int signalIndex, const std::wstring& name) + bool DataSet::SetSignalDisplayName(int groupIndex, int signalIndex, const std::wstring& name) { - if (fileIndex < 0 || fileIndex >= static_cast(m_files.size())) + if (groupIndex < 0 || groupIndex >= static_cast(m_groups.size())) { return false; } - DataFile& file = m_files[static_cast(fileIndex)]; - if (signalIndex < 0 || signalIndex >= static_cast(file.signals.size())) + SignalGroup& group = m_groups[static_cast(groupIndex)]; + if (signalIndex < 0 || signalIndex >= static_cast(group.signals.size())) { return false; } - // 统一去除首尾空白;结果为空则视为恢复原始列名。 + // 统一去除首尾空白;结果为空则视为恢复源名称。 std::wstring trimmed = Trim(name); - SignalData& signal = file.signals[static_cast(signalIndex)]; + SignalData& signal = group.signals[static_cast(signalIndex)]; if (trimmed == signal.displayName) { return false; @@ -803,35 +796,50 @@ namespace jdi return true; } - bool DataSet::SetFileDisplayName(int fileIndex, const std::wstring& name) + bool DataSet::SetGroupDisplayName(int groupIndex, const std::wstring& name) { - if (fileIndex < 0 || fileIndex >= static_cast(m_files.size())) + if (groupIndex < 0 || groupIndex >= static_cast(m_groups.size())) { return false; } std::wstring trimmed = Trim(name); - DataFile& file = m_files[static_cast(fileIndex)]; - if (trimmed == file.displayName) + SignalGroup& group = m_groups[static_cast(groupIndex)]; + if (trimmed == group.displayName) { return false; } - file.displayName = std::move(trimmed); + group.displayName = std::move(trimmed); return true; } const SignalData* DataSet::Resolve(const SignalRef& ref) const { - const DataFile* file = FileAt(ref.fileIndex); - if (file == nullptr) + const SignalGroup* group = GroupAt(ref.groupIndex); + if (group == nullptr) { return nullptr; } - if (ref.signalIndex < 0 || ref.signalIndex >= static_cast(file->signals.size())) + if (ref.signalIndex < 0 || ref.signalIndex >= static_cast(group->signals.size())) { return nullptr; } - return &file->signals[static_cast(ref.signalIndex)]; + return &group->signals[static_cast(ref.signalIndex)]; + } + + const TimeAxis* DataSet::Axis(const SignalRef& ref) const + { + const SignalGroup* group = GroupAt(ref.groupIndex); + if (group == nullptr) + { + return nullptr; + } + if (ref.signalIndex < 0 || ref.signalIndex >= static_cast(group->signals.size()) || + group->axes.empty()) + { + return nullptr; + } + return &group->AxisOf(group->signals[static_cast(ref.signalIndex)]); } const SignalStyle* DataSet::Style(const SignalRef& ref) const @@ -842,29 +850,31 @@ namespace jdi SignalStyle* DataSet::Style(const SignalRef& ref) { - const DataFile* file = FileAt(ref.fileIndex); - if (file == nullptr) + const SignalGroup* group = GroupAt(ref.groupIndex); + if (group == nullptr) { return nullptr; } - if (ref.signalIndex < 0 || ref.signalIndex >= static_cast(file->signals.size())) + if (ref.signalIndex < 0 || ref.signalIndex >= static_cast(group->signals.size())) { return nullptr; } // 这里需要写入,因此借助非 const 路径重新定位信号。 - DataFile& mutableFile = m_files[static_cast(ref.fileIndex)]; - return &mutableFile.signals[static_cast(ref.signalIndex)].style; + SignalGroup& mutableGroup = m_groups[static_cast(ref.groupIndex)]; + return &mutableGroup.signals[static_cast(ref.signalIndex)].style; } double DataSet::MinTime() const { double value = std::numeric_limits::infinity(); - for (const DataFile& file : m_files) + for (const SignalGroup& group : m_groups) { - if (std::isfinite(file.minTime)) + double low = 0.0; + double high = 0.0; + if (group.TimeRange(low, high)) { - value = std::min(value, file.minTime); + value = std::min(value, low); } } return std::isfinite(value) ? value : 0.0; @@ -873,29 +883,31 @@ namespace jdi double DataSet::MaxTime() const { double value = -std::numeric_limits::infinity(); - for (const DataFile& file : m_files) + for (const SignalGroup& group : m_groups) { - if (std::isfinite(file.maxTime)) + double low = 0.0; + double high = 0.0; + if (group.TimeRange(low, high)) { - value = std::max(value, file.maxTime); + value = std::max(value, high); } } return std::isfinite(value) ? value : 1.0; } - void DataSet::RemapSelectionAfterFileRemoval(int fileIndex) + void DataSet::RemapSelectionAfterGroupRemoval(int groupIndex) { - if (fileIndex < 0) + if (groupIndex < 0) { return; } - if (m_selected.fileIndex == fileIndex) + if (m_selected.groupIndex == groupIndex) { m_selected = SignalRef(); } - else if (m_selected.fileIndex > fileIndex) + else if (m_selected.groupIndex > groupIndex) { - --m_selected.fileIndex; + --m_selected.groupIndex; } } @@ -1591,30 +1603,30 @@ namespace jdi weakPeakItems.end()); } - int FindNearestSample(const DataFile& file, double time) + int FindNearestSample(const TimeAxis& axis, double time) { - const std::size_t count = file.time.size(); + const std::size_t count = axis.time.size(); if (count == 0) { return -1; } - if (file.timeMonotonic) + if (axis.monotonic) { const std::vector::const_iterator lower = - std::lower_bound(file.time.begin(), file.time.end(), time); - if (lower == file.time.begin()) + std::lower_bound(axis.time.begin(), axis.time.end(), time); + if (lower == axis.time.begin()) { return 0; } - if (lower == file.time.end()) + if (lower == axis.time.end()) { return static_cast(count - 1); } - const std::size_t rightIndex = static_cast(lower - file.time.begin()); + const std::size_t rightIndex = static_cast(lower - axis.time.begin()); const std::size_t leftIndex = rightIndex - 1; - const double leftDistance = std::fabs(time - file.time[leftIndex]); - const double rightDistance = std::fabs(file.time[rightIndex] - time); + const double leftDistance = std::fabs(time - axis.time[leftIndex]); + const double rightDistance = std::fabs(axis.time[rightIndex] - time); return (leftDistance <= rightDistance) ? static_cast(leftIndex) : static_cast(rightIndex); } @@ -1622,11 +1634,11 @@ namespace jdi double bestDistance = std::numeric_limits::infinity(); for (std::size_t index = 0; index < count; ++index) { - if (!std::isfinite(file.time[index])) + if (!std::isfinite(axis.time[index])) { continue; } - const double distance = std::fabs(file.time[index] - time); + const double distance = std::fabs(axis.time[index] - time); if (distance < bestDistance) { bestDistance = distance; @@ -1636,25 +1648,25 @@ namespace jdi return best; } - 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) { value = kMissing; - if (file.time.empty() || signal.values.empty()) + if (axis.time.empty() || signal.values.empty()) { return false; } - if (time < file.minTime || time > file.maxTime) + if (time < axis.minTime || time > axis.maxTime) { return false; } const std::vector& values = signal.values; - if (!file.timeMonotonic) + if (!axis.monotonic) { - const int nearest = FindNearestSample(file, time); + const int nearest = FindNearestSample(axis, time); if (nearest < 0 || static_cast(nearest) >= values.size()) { return false; @@ -1663,14 +1675,14 @@ namespace jdi return std::isfinite(value); } - const std::vector::const_iterator lower = std::lower_bound(file.time.begin(), file.time.end(), time); - const std::size_t rightIndex = static_cast(lower - file.time.begin()); + const std::vector::const_iterator lower = std::lower_bound(axis.time.begin(), axis.time.end(), time); + const std::size_t rightIndex = static_cast(lower - axis.time.begin()); if (rightIndex == 0) { value = values[0]; return std::isfinite(value); } - if (rightIndex >= file.time.size()) + if (rightIndex >= axis.time.size()) { value = values.back(); return std::isfinite(value); @@ -1682,8 +1694,8 @@ namespace jdi return false; } - const double t0 = file.time[leftIndex]; - const double t1 = file.time[rightIndex]; + const double t0 = axis.time[leftIndex]; + const double t1 = axis.time[rightIndex]; const double v0 = values[leftIndex]; const double v1 = values[rightIndex]; @@ -1700,30 +1712,30 @@ namespace jdi 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) { // 未启用水平镜像 / 时间偏移时与 SampleSignalAt 完全等价 //(避免两套实现产生细微差异)。 if (!style.mirrorHorizontal && style.offsetTime == 0.0) { - return SampleSignalAt(file, signal, time, interpolate, value); + return SampleSignalAt(axis, signal, time, interpolate, value); } value = kMissing; - if (file.time.empty() || signal.values.empty()) + if (axis.time.empty() || signal.values.empty()) { return false; } // 绘制位置 = 源时刻 + offsetTime,因此查询源数据要用「绘制时刻 − offsetTime」。 const double query = time - style.offsetTime; - if (query < file.minTime || query > file.maxTime) + if (query < axis.minTime || query > axis.maxTime) { return false; } const std::vector& values = signal.values; - const std::size_t count = std::min(file.time.size(), values.size()); + const std::size_t count = std::min(axis.time.size(), values.size()); if (count == 0) { return false; @@ -1734,9 +1746,9 @@ namespace jdi return values[SourceIndexFor(style, count, index)]; }; - if (!file.timeMonotonic) + if (!axis.monotonic) { - const int nearest = FindNearestSample(file, query); + const int nearest = FindNearestSample(axis, query); if (nearest < 0 || static_cast(nearest) >= count) { return false; @@ -1746,8 +1758,8 @@ namespace jdi } const std::vector::const_iterator lower = - std::lower_bound(file.time.begin(), file.time.end(), query); - const std::size_t rightIndex = static_cast(lower - file.time.begin()); + std::lower_bound(axis.time.begin(), axis.time.end(), query); + const std::size_t rightIndex = static_cast(lower - axis.time.begin()); if (rightIndex == 0) { value = sample(0); @@ -1760,8 +1772,8 @@ namespace jdi } const std::size_t leftIndex = rightIndex - 1; - const double t0 = file.time[leftIndex]; - const double t1 = file.time[rightIndex]; + const double t0 = axis.time[leftIndex]; + const double t1 = axis.time[rightIndex]; const double v0 = sample(leftIndex); const double v1 = sample(rightIndex); @@ -1778,8 +1790,6 @@ namespace jdi return true; } - // ============================ TabConfig ============================ - TabConfig::TabConfig() : m_name(L"视图 1") { @@ -1787,22 +1797,22 @@ namespace jdi void TabConfig::SyncToDataSet(const DataSet& dataSet) { - const std::size_t fileCount = dataSet.FileCount(); - m_visible.resize(fileCount); + const std::size_t groupCount = dataSet.GroupCount(); + m_visible.resize(groupCount); // ⚠️ 只对**新出现的**分组应用默认折叠状态:已存在的分组保持用户当前的展开/折叠选择。 - m_collapsed.resize(fileCount, m_groupsCollapsedByDefault); + m_collapsed.resize(groupCount, m_groupsCollapsedByDefault); - for (std::size_t fileIndex = 0; fileIndex < fileCount; ++fileIndex) + for (std::size_t groupIndex = 0; groupIndex < groupCount; ++groupIndex) { - const DataFile* file = dataSet.FileAt(static_cast(fileIndex)); - if (file == nullptr) + const SignalGroup* group = dataSet.GroupAt(static_cast(groupIndex)); + if (group == nullptr) { continue; } // 新增信号默认为未勾选(由用户自行决定查看哪些信号), // 其显示外观已在 DataSet 中初始化完毕。 - m_visible[fileIndex].resize(file->signals.size(), false); + m_visible[groupIndex].resize(group->signals.size(), false); } // 清理已失效的网格引用(选中状态由 DataSet 统一维护)。 @@ -1820,35 +1830,35 @@ namespace jdi SyncLanes(); } - void TabConfig::RemapAfterFileRemoval(int fileIndex) + void TabConfig::RemapAfterGroupRemoval(int groupIndex) { - if (fileIndex < 0) + if (groupIndex < 0) { return; } - const std::size_t slot = static_cast(fileIndex); + const std::size_t slot = static_cast(groupIndex); if (slot < m_visible.size()) { - m_visible.erase(m_visible.begin() + fileIndex); + m_visible.erase(m_visible.begin() + groupIndex); } if (slot < m_collapsed.size()) { - m_collapsed.erase(m_collapsed.begin() + fileIndex); + m_collapsed.erase(m_collapsed.begin() + groupIndex); } for (Lane& lane : m_lanes) { std::vector& refs = lane.signals; refs.erase(std::remove_if(refs.begin(), refs.end(), - [fileIndex](const SignalRef& ref) - { return ref.fileIndex == fileIndex; }), + [groupIndex](const SignalRef& ref) + { return ref.groupIndex == groupIndex; }), refs.end()); for (SignalRef& ref : refs) { - if (ref.fileIndex > fileIndex) + if (ref.groupIndex > groupIndex) { - --ref.fileIndex; + --ref.groupIndex; } } } @@ -1857,15 +1867,15 @@ namespace jdi m_lanes.end()); } - void TabConfig::RemapAfterSignalRemoval(int fileIndex, int signalIndex) + void TabConfig::RemapAfterSignalRemoval(int groupIndex, int signalIndex) { - if (fileIndex < 0 || signalIndex < 0) + if (groupIndex < 0 || signalIndex < 0) { return; } // 可见性表:删掉该信号对应的那一列。 - const std::size_t slot = static_cast(fileIndex); + const std::size_t slot = static_cast(groupIndex); if (slot < m_visible.size()) { std::vector& fileVisible = m_visible[slot]; @@ -1880,15 +1890,15 @@ namespace jdi { std::vector& refs = lane.signals; refs.erase(std::remove_if(refs.begin(), refs.end(), - [fileIndex, signalIndex](const SignalRef& ref) + [groupIndex, signalIndex](const SignalRef& ref) { - return ref.fileIndex == fileIndex && + return ref.groupIndex == groupIndex && ref.signalIndex == signalIndex; }), refs.end()); for (SignalRef& ref : refs) { - if (ref.fileIndex == fileIndex && ref.signalIndex > signalIndex) + if (ref.groupIndex == groupIndex && ref.signalIndex > signalIndex) { --ref.signalIndex; } @@ -1897,19 +1907,19 @@ namespace jdi // 均值滤波 / 中心轴边沿计数 / 弱峰调整的条目同样持有信号引用,必须一起删除/前移。 // 三类条目都是「带 `ref` 的结构体」,因此用泛型 lambda 统一处理 //(弱峰调整的参数与均值滤波不同,不能再共用同一个具体类型)。 - const auto purgeRefs = [fileIndex, signalIndex](auto& items) + const auto purgeRefs = [groupIndex, signalIndex](auto& items) { using ItemType = typename std::decay_t::value_type; items.erase(std::remove_if(items.begin(), items.end(), - [fileIndex, signalIndex](const ItemType& item) + [groupIndex, signalIndex](const ItemType& item) { - return item.ref.fileIndex == fileIndex && + return item.ref.groupIndex == groupIndex && item.ref.signalIndex == signalIndex; }), items.end()); for (ItemType& item : items) { - if (item.ref.fileIndex == fileIndex && item.ref.signalIndex > signalIndex) + if (item.ref.groupIndex == groupIndex && item.ref.signalIndex > signalIndex) { --item.ref.signalIndex; } @@ -1927,11 +1937,11 @@ namespace jdi bool TabConfig::IsVisible(const SignalRef& ref) const { - if (ref.fileIndex < 0 || static_cast(ref.fileIndex) >= m_visible.size()) + if (ref.groupIndex < 0 || static_cast(ref.groupIndex) >= m_visible.size()) { return false; } - const std::vector& fileVisible = m_visible[static_cast(ref.fileIndex)]; + const std::vector& fileVisible = m_visible[static_cast(ref.groupIndex)]; if (ref.signalIndex < 0 || static_cast(ref.signalIndex) >= fileVisible.size()) { return false; @@ -1941,11 +1951,11 @@ namespace jdi void TabConfig::SetVisible(const SignalRef& ref, bool visible) { - if (ref.fileIndex < 0 || static_cast(ref.fileIndex) >= m_visible.size()) + if (ref.groupIndex < 0 || static_cast(ref.groupIndex) >= m_visible.size()) { return; } - std::vector& fileVisible = m_visible[static_cast(ref.fileIndex)]; + std::vector& fileVisible = m_visible[static_cast(ref.groupIndex)]; if (ref.signalIndex < 0 || static_cast(ref.signalIndex) >= fileVisible.size()) { return; @@ -1971,13 +1981,13 @@ namespace jdi SetVisible(ref, !IsVisible(ref)); } - void TabConfig::SetFileVisible(int fileIndex, bool visible) + void TabConfig::SetGroupVisible(int groupIndex, bool visible) { - if (fileIndex < 0 || static_cast(fileIndex) >= m_visible.size()) + if (groupIndex < 0 || static_cast(groupIndex) >= m_visible.size()) { return; } - std::vector& fileVisible = m_visible[static_cast(fileIndex)]; + std::vector& fileVisible = m_visible[static_cast(groupIndex)]; // 合并模式下“新增”的信号同样应各自独占网格(与 SetVisible 一致): // 先切到自定义划分,RebuildLanes 会把尚未分配网格的可见信号追加到最下方。 // 只有确实存在“原本不可见”的信号时才切模式,避免重复点击默默改掉划分策略。 @@ -2023,23 +2033,23 @@ namespace jdi SetVisible(ref, true); } - bool TabConfig::FileHasVisible(int fileIndex) const + bool TabConfig::GroupHasVisible(int groupIndex) const { - if (fileIndex < 0 || static_cast(fileIndex) >= m_visible.size()) + if (groupIndex < 0 || static_cast(groupIndex) >= m_visible.size()) { return false; } - const std::vector& fileVisible = m_visible[static_cast(fileIndex)]; + const std::vector& fileVisible = m_visible[static_cast(groupIndex)]; return std::find(fileVisible.begin(), fileVisible.end(), true) != fileVisible.end(); } - bool TabConfig::FileAllVisible(int fileIndex) const + bool TabConfig::GroupAllVisible(int groupIndex) const { - if (fileIndex < 0 || static_cast(fileIndex) >= m_visible.size()) + if (groupIndex < 0 || static_cast(groupIndex) >= m_visible.size()) { return false; } - const std::vector& fileVisible = m_visible[static_cast(fileIndex)]; + const std::vector& fileVisible = m_visible[static_cast(groupIndex)]; if (fileVisible.empty()) { return false; @@ -2257,15 +2267,15 @@ namespace jdi return laneIndex; } - void TabConfig::RemapAfterSignalInsert(int fileIndex, int insertPos) + void TabConfig::RemapAfterSignalInsert(int groupIndex, int insertPos) { - if (fileIndex < 0 || insertPos < 0) + if (groupIndex < 0 || insertPos < 0) { return; } // 可见性表:在插入点补一列(新信号默认不可见,由调用方按需勾选)。 - const std::size_t slot = static_cast(fileIndex); + const std::size_t slot = static_cast(groupIndex); if (slot < m_visible.size() && static_cast(insertPos) <= m_visible[slot].size()) { m_visible[slot].insert(m_visible[slot].begin() + insertPos, false); @@ -2276,7 +2286,7 @@ namespace jdi { for (SignalRef& ref : lane.signals) { - if (ref.fileIndex == fileIndex && ref.signalIndex >= insertPos) + if (ref.groupIndex == groupIndex && ref.signalIndex >= insertPos) { ++ref.signalIndex; } @@ -2284,21 +2294,21 @@ namespace jdi // 均值滤波 / 中心轴条目同样持有信号引用,必须一起后移。 for (Lane::MeanFilterItem& item : lane.meanFilterItems) { - if (item.ref.fileIndex == fileIndex && item.ref.signalIndex >= insertPos) + if (item.ref.groupIndex == groupIndex && item.ref.signalIndex >= insertPos) { ++item.ref.signalIndex; } } for (Lane::EdgeCenterItem& item : lane.edgeCenterItems) { - if (item.ref.fileIndex == fileIndex && item.ref.signalIndex >= insertPos) + if (item.ref.groupIndex == groupIndex && item.ref.signalIndex >= insertPos) { ++item.ref.signalIndex; } } for (Lane::WeakPeakItem& item : lane.weakPeakItems) { - if (item.ref.fileIndex == fileIndex && item.ref.signalIndex >= insertPos) + if (item.ref.groupIndex == groupIndex && item.ref.signalIndex >= insertPos) { ++item.ref.signalIndex; } @@ -2373,26 +2383,26 @@ namespace jdi } } - bool TabConfig::IsCollapsed(int fileIndex) const + bool TabConfig::IsCollapsed(int groupIndex) const { - if (fileIndex < 0 || static_cast(fileIndex) >= m_collapsed.size()) + if (groupIndex < 0 || static_cast(groupIndex) >= m_collapsed.size()) { return false; } - return m_collapsed[static_cast(fileIndex)]; + return m_collapsed[static_cast(groupIndex)]; } - void TabConfig::ToggleCollapsed(int fileIndex) + void TabConfig::ToggleCollapsed(int groupIndex) { - if (fileIndex < 0) + if (groupIndex < 0) { return; } - if (static_cast(fileIndex) >= m_collapsed.size()) + if (static_cast(groupIndex) >= m_collapsed.size()) { - m_collapsed.resize(static_cast(fileIndex) + 1, m_groupsCollapsedByDefault); + m_collapsed.resize(static_cast(groupIndex) + 1, m_groupsCollapsedByDefault); } - m_collapsed[static_cast(fileIndex)] = !m_collapsed[static_cast(fileIndex)]; + m_collapsed[static_cast(groupIndex)] = !m_collapsed[static_cast(groupIndex)]; } void TabConfig::ComputeLaneRange(const DataSet& dataSet, int laneIndex, double& yMin, double& yMax) const @@ -2458,26 +2468,37 @@ namespace jdi double low = std::numeric_limits::infinity(); double high = -std::numeric_limits::infinity(); - for (std::size_t fileIndex = 0; fileIndex < m_visible.size(); ++fileIndex) + for (std::size_t groupIndex = 0; groupIndex < m_visible.size(); ++groupIndex) { - const std::vector& fileVisible = m_visible[fileIndex]; - if (std::find(fileVisible.begin(), fileVisible.end(), true) == fileVisible.end()) + const std::vector& groupVisible = m_visible[groupIndex]; + if (std::find(groupVisible.begin(), groupVisible.end(), true) == groupVisible.end()) { continue; } - const DataFile* file = dataSet.FileAt(static_cast(fileIndex)); - if (file == nullptr) + const SignalGroup* group = dataSet.GroupAt(static_cast(groupIndex)); + if (group == nullptr) { continue; } - if (std::isfinite(file->minTime)) + // 只统计**可见信号**真正用到的时间轴:一个分组可能有多条时间轴 + //(多 `time` 列 CSV / MF4 多通道组),把没显示的那些也算进来会让“适应视图” + // 拉出一个过宽的时间范围。 + for (std::size_t signalIndex = 0; signalIndex < groupVisible.size() && + signalIndex < group->signals.size(); + ++signalIndex) { - low = std::min(low, file->minTime); - } - if (std::isfinite(file->maxTime)) - { - high = std::max(high, file->maxTime); + if (!groupVisible[signalIndex]) + { + continue; + } + const TimeAxis& axis = group->AxisOf(group->signals[signalIndex]); + if (axis.finiteCount == 0) + { + continue; + } + low = std::min(low, axis.minTime); + high = std::max(high, axis.maxTime); } } diff --git a/Utilities/Src/Mf4Loader.cpp b/Utilities/Src/Mf4Loader.cpp new file mode 100644 index 0000000..9c462d7 --- /dev/null +++ b/Utilities/Src/Mf4Loader.cpp @@ -0,0 +1,820 @@ +#include "Mf4Loader.hpp" + +#include "Utf8Util.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace jdi +{ + + namespace + { + const double kMissingValue = std::numeric_limits::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(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(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(buffer[i]) << (8u * i); + } + return value; + } + + std::uint32_t U32(std::uint64_t offset) const + { + return static_cast(Scalar(offset, 4)); + } + std::uint16_t U16(std::uint64_t offset) const + { + return static_cast(Scalar(offset, 2)); + } + std::uint8_t U8(std::uint64_t offset) const + { + return static_cast(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 buffer(static_cast(2u * declared)); + if (!ReadAt(data + 4, buffer.data(), buffer.size())) + { + return text; + } + text.reserve(static_cast(declared)); + for (std::uint64_t i = 0; i < declared; ++i) + { + const wchar_t c = + static_cast(static_cast(buffer[2 * i]) | + (static_cast(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 buffer(static_cast(declared) + 1u, '\0'); + if (!ReadAt(data + 4, buffer.data(), static_cast(declared))) + { + return text; + } + return Utf8ToWide(buffer.data(), static_cast(declared)); + } + } + + // 回退:数据段直接是 NUL 结尾的文本(UTF-16 或 UTF-8)。 + std::vector buffer(static_cast(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(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(c); + } + return Utf8ToWide(raw.data(), static_cast(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(value); + } + double value = 0.0; + std::memcpy(&value, buffer, 8); + return value; + } + if (spec.bitCount == 32u) + { + std::uint32_t bits = (static_cast(buffer[0]) << 24) | + (static_cast(buffer[1]) << 16) | + (static_cast(buffer[2]) << 8) | + static_cast(buffer[3]); + float value = 0.0f; + std::memcpy(&value, &bits, 4); + return static_cast(value); + } + std::uint64_t bits = 0; + for (int i = 0; i < 8; ++i) + { + bits = (bits << 8) | static_cast(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(buffer[i]); + } + } + else + { + for (std::size_t i = 0; i < byteCount; ++i) + { + raw = (raw << 8) | static_cast(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(static_cast(raw)); + } + return static_cast(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); + spec.bitCount = bitCount; + spec.bitOffset = static_cast(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(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 + 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 channels; /**< 非时间通道。 */ + std::vector time; /**< 时间轴数值。 */ + std::vector> 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(cycles), kMissingValue); + group.values.assign(group.channels.size(), + std::vector(static_cast(cycles), kMissingValue)); + + std::vector buffer(kReadChunkBytes); + std::uint64_t done = 0; + while (done < cycles) + { + const std::uint64_t remainingRecords = cycles - done; + const std::uint64_t wantBytes = std::min( + remainingRecords * recordBytes, static_cast(buffer.size())); + const std::uint64_t wantRecords = wantBytes / recordBytes; + if (wantRecords == 0) + { + break; + } + const std::size_t bytes = static_cast(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(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& 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(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 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(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(cycles); + source.blocks.push_back(std::move(block)); + + if (progress) + { + LoadProgressInfo info; + info.ratio = (groupCount > 0) ? std::min(1.0, static_cast(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