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

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

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

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

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

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

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

No files matched your search

+6 -6
View File
@@ -183,9 +183,9 @@ namespace jdi
/**
* @brief 从工作区移除指定文件并修正各标签页配置。
* @param [IN] fileIndex 文件下标。
* @param [IN] groupIndex 文件下标。
*/
void RemoveFile(int fileIndex);
void RemoveGroup(int groupIndex);
/**
* @brief 从工作区移除某分组中的一条信号,并立即释放其采样内存。
@@ -193,10 +193,10 @@ namespace jdi
* 用户点击信号行左侧的 ✕ 时调用:该信号一旦被确认不再需要,就应从数据层删除
* 并释放内存(而非只是隐藏)。若它是所属分组中最后一条信号,则连同分组一起移除。
*
* @param [IN] fileIndex 信号所属文件下标。
* @param [IN] groupIndex 信号所属文件下标。
* @param [IN] signalIndex 文件内信号下标。
*/
void RemoveSignal(int fileIndex, int signalIndex);
void RemoveSignal(int groupIndex, int signalIndex);
/**
* @brief 处理标签栏交互结果。
@@ -259,7 +259,7 @@ namespace jdi
*/
struct DerivedSignalRequest
{
int fileIndex = -1; /**< 原信号所属文件下标。 */
int groupIndex = -1; /**< 原信号所属文件下标。 */
int signalIndex = -1; /**< 原信号下标。 */
int points = 5; /**< 多次迭代均值滤波:均值点数 X(滑平半径)。 */
int iterations = 1; /**< 多次迭代均值滤波:迭代次数 N(至少 1)。 */
@@ -330,7 +330,7 @@ namespace jdi
* @param [OUT] error 失败原因描述。
* @return 加载成功返回 true;用户取消或失败返回 false。
*/
bool LoadFileAsync(const std::wstring& path, DataFile& file, std::wstring& error);
bool LoadFileAsync(const std::wstring& path, SignalGroup& file, std::wstring& error);
/**
* @brief 绘制加载过渡动画遮罩(在 Render 的最后一步调用,覆盖整个客户区)。
+93 -65
View File
@@ -10,7 +10,7 @@
#include <shellapi.h>
#include <windowsx.h>
#include "CsvTable.hpp"
#include "DataLoader.hpp"
#include "ResourceIds.h"
#include "Utf8Util.hpp"
@@ -43,7 +43,7 @@ namespace jdi
const float kMaxPanelWidth = 400.0f; /**< 面板最大宽度(逻辑像素)。 */
const int kMinClientWidth = 860; /**< 窗口最小客户区宽度。 */
const int kMinClientHeight = 540; /**< 窗口最小客户区高度。 */
const UINT kMessageRemoveFile = WM_APP + 1; /**< 面板请求移除文件的自定义消息。 */
const UINT kMessageRemoveGroup = WM_APP + 1; /**< 面板请求移除文件的自定义消息。 */
const UINT kMessageRemoveSignal = WM_APP + 2; /**< 面板请求移除单个信号的自定义消息。 */
const UINT_PTR kHintTimerId = 1; /**< 提示浮层淡出用的定时器 ID。 */
const UINT kHintTimerPeriodMs = 33; /**< 提示浮层淡出的重绘间隔(约 30fps)。 */
@@ -177,11 +177,11 @@ namespace jdi
std::atomic<unsigned long long> totalBytes{0}; /**< 文件总字节数(0 表示未知)。 */
bool ok = false; /**< 加载是否成功。 */
std::wstring error; /**< 失败原因。 */
DataFile file; /**< 构建结果。 */
SignalGroup group; /**< 构建结果。 */
};
/**
* @brief 工作线程主体:解析 CSV 并构建数据文件(不接触 DataSet)。
* @brief 工作线程主体:按扩展名选择加载器读入文件并构建信号分组(不接触 DataSet)。
* @param [IN,OUT] state 共享状态。
* @param [IN] path 文件路径。
* @param [IN] paletteBase 调色板起始序号。
@@ -189,7 +189,7 @@ namespace jdi
void RunAsyncLoad(AsyncLoadState* state, std::wstring path, std::size_t paletteBase)
{
// 解析阶段占总进度的 0-75%,列重组与统计占 75-100%。
CsvProgress parseProgress = [state](const CsvProgressInfo& info)
LoadProgress parseProgress = [state](const LoadProgressInfo& info)
{
state->progress.store(static_cast<float>(info.ratio * 0.75), std::memory_order_relaxed);
if (info.hasBytes)
@@ -200,31 +200,31 @@ namespace jdi
return !state->cancelled.load(std::memory_order_relaxed);
};
CsvTable table;
LoadedSource source;
std::wstring error;
if (!LoadCsvTable(path, table, error, parseProgress))
if (!LoadSignalSource(path, source, error, parseProgress))
{
state->error = error;
state->done.store(true, std::memory_order_release);
return;
}
CsvProgress buildProgress = [state](const CsvProgressInfo& info)
LoadProgress buildProgress = [state](const LoadProgressInfo& info)
{
state->progress.store(static_cast<float>(0.75 + info.ratio * 0.25),
std::memory_order_relaxed);
return !state->cancelled.load(std::memory_order_relaxed);
};
DataFile file;
if (!BuildDataFile(std::move(table), path, paletteBase, file, error, buildProgress))
SignalGroup group;
if (!BuildSignalGroup(std::move(source), paletteBase, group, error, buildProgress))
{
state->error = error;
state->done.store(true, std::memory_order_release);
return;
}
state->file = std::move(file);
state->group = std::move(group);
state->ok = true;
state->progress.store(1.0f, std::memory_order_relaxed);
state->done.store(true, std::memory_order_release);
@@ -357,7 +357,10 @@ namespace jdi
windowClass.hIcon = LoadAppIcon(GetSystemMetrics(SM_CXICON), GetSystemMetrics(SM_CYICON));
windowClass.hIconSm = LoadAppIcon(GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON));
if (RegisterClassExW(&windowClass) == 0)
// ⚠️ 同一个进程里再次创建 `Application`(离屏测试、单进程多次启动)时窗口类已经注册,
// 这时**不是错误**:直接复用已注册的类即可。旧实现把它当致命错误弹模态框,
// 会让调用方永远阻塞在对话框上。
if (RegisterClassExW(&windowClass) == 0 && GetLastError() != ERROR_CLASS_ALREADY_EXISTS)
{
MessageBoxW(nullptr, L"窗口类注册失败。", kWindowTitle, MB_ICONERROR | MB_OK);
return false;
@@ -439,6 +442,15 @@ namespace jdi
gd::GdiplusShutdown(m_gdiplusToken);
m_gdiplusReady = false;
}
// 注销窗口类:否则同一进程里再次创建 `Application` 时会命中
// `ERROR_CLASS_ALREADY_EXISTS`(离屏测试需要多次构造/销毁)。
if (m_hwnd != nullptr)
{
DestroyWindow(m_hwnd);
m_hwnd = nullptr;
}
UnregisterClassW(kWindowClassName, m_instance);
}
void Application::ApplyDpi(UINT dpi)
@@ -655,48 +667,48 @@ namespace jdi
}
}
void Application::RemoveFile(int fileIndex)
void Application::RemoveGroup(int groupIndex)
{
const DataFile* file = m_dataSet.FileAt(fileIndex);
if (file == nullptr)
const SignalGroup* group = m_dataSet.GroupAt(groupIndex);
if (group == nullptr)
{
return;
}
const std::wstring name = file->fileName;
const std::wstring name = group->name;
// 移除文件会释放大量采样内存:先暂停后台派生曲线计算,避免其读取已释放内存。
m_plot.PauseDerivedAsync();
// 先按旧下标修正各标签页配置与共享选中项,再从数据层移除,最后统一对齐。
for (TabConfig& tab : m_tabs)
{
tab.RemapAfterFileRemoval(fileIndex);
tab.RemapAfterGroupRemoval(groupIndex);
}
m_dataSet.RemapSelectionAfterFileRemoval(fileIndex);
m_dataSet.RemoveFile(fileIndex);
m_dataSet.RemapSelectionAfterGroupRemoval(groupIndex);
m_dataSet.RemoveGroup(groupIndex);
SyncTabsToDataSet();
SetStatusMessage(L"已从工作区移除:" + name, false);
UpdateWindowTitle();
}
void Application::RemoveSignal(int fileIndex, int signalIndex)
void Application::RemoveSignal(int groupIndex, int signalIndex)
{
SignalRef target;
target.fileIndex = fileIndex;
target.groupIndex = groupIndex;
target.signalIndex = signalIndex;
const SignalData* signal = m_dataSet.Resolve(target);
const DataFile* file = m_dataSet.FileAt(fileIndex);
if (signal == nullptr || file == nullptr)
const SignalGroup* group = m_dataSet.GroupAt(groupIndex);
if (signal == nullptr || group == nullptr)
{
return;
}
const std::wstring name = signal->Title();
if (file->signals.size() <= 1)
if (group->signals.size() <= 1)
{
// 组内只剩这一条信号:直接移除整个分组,避免在列表里留下一个空分组。
RemoveFile(fileIndex);
RemoveGroup(groupIndex);
return;
}
@@ -705,10 +717,10 @@ namespace jdi
// 先按旧下标修正各标签页配置与共享选中项,再从数据层移除(释放采样内存),最后对齐。
for (TabConfig& tab : m_tabs)
{
tab.RemapAfterSignalRemoval(fileIndex, signalIndex);
tab.RemapAfterSignalRemoval(groupIndex, signalIndex);
}
m_dataSet.RemapSelectionAfterSignalRemoval(fileIndex, signalIndex);
m_dataSet.RemoveSignal(fileIndex, signalIndex);
m_dataSet.RemapSelectionAfterSignalRemoval(groupIndex, signalIndex);
m_dataSet.RemoveSignal(groupIndex, signalIndex);
SyncTabsToDataSet();
SetStatusMessage(L"已从工作区移除信号:" + name, false);
@@ -967,10 +979,10 @@ namespace jdi
OPENFILENAMEW dialog = {};
dialog.lStructSize = sizeof(dialog);
dialog.hwndOwner = m_hwnd;
dialog.lpstrFilter = L"CSV 数据文件 (*.csv)\0*.csv\0文本数据文件 (*.txt)\0*.txt\0所有文件 (*.*)\0*.*\0\0";
dialog.lpstrFilter = SignalFileDialogFilter();
dialog.lpstrFile = buffer.data();
dialog.nMaxFile = static_cast<DWORD>(buffer.size());
dialog.lpstrTitle = L"打开仿真数据文件(可多选)";
dialog.lpstrTitle = L"打开测量数据文件(可多选)";
dialog.lpstrDefExt = L"csv";
dialog.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_EXPLORER | OFN_NOCHANGEDIR |
OFN_ALLOWMULTISELECT;
@@ -1021,9 +1033,9 @@ namespace jdi
}
// 大文件在工作线程解析并构建,主线程播放加载过渡动画,界面不再整块卡住。
DataFile file;
SignalGroup group;
std::wstring error;
if (!LoadFileAsync(path, file, error))
if (!LoadFileAsync(path, group, error))
{
if (!error.empty())
{
@@ -1033,30 +1045,45 @@ namespace jdi
return false;
}
int fileIndex = -1;
// 插入新文件会移动 DataFile/SignalData 的存储位置,必须先暂停后台派生曲线计算,
int groupIndex = -1;
// 插入新分组会移动 SignalGroup/SignalData 的存储位置,必须先暂停后台派生曲线计算,
// 否则后台线程可能正在读取即将被移动的采样数据。
m_plot.PauseDerivedAsync();
// 此处只做一次指针/缓冲移动,不拷贝任何采样数据,因此不会造成可见卡顿。
if (!m_dataSet.AddDataFile(std::move(file), fileIndex))
if (!m_dataSet.AddGroup(std::move(group), groupIndex))
{
SetStatusMessage(L"解析失败:数据为空。", true);
InvalidateRect(m_hwnd, nullptr, FALSE);
return false;
}
// 新文件的信号在全部标签页中默认均为未勾选状态,由用户自行决定查看内容。
// 新分组的信号在全部标签页中默认均为未勾选状态,由用户自行决定查看内容。
SyncTabsToDataSet();
UpdateWindowTitle();
const DataFile* loaded = m_dataSet.FileAt(fileIndex);
const SignalGroup* loaded = m_dataSet.GroupAt(groupIndex);
if (loaded != nullptr)
{
std::wstring message = L"已加载 组" + std::to_wstring(fileIndex + 1) + L"|" + loaded->Title() +
L"(" + FormatCount(static_cast<unsigned long long>(loaded->time.size())) +
// 行数取各时间轴的**最大值**(多时间轴文件的各轴长度可能不同)。
std::size_t rowCount = 0;
bool anyNonMonotonic = false;
std::wstring timeNames;
for (const TimeAxis& axis : loaded->axes)
{
rowCount = std::max(rowCount, axis.Count());
anyNonMonotonic = anyNonMonotonic || !axis.monotonic;
if (!timeNames.empty())
{
timeNames += L"、";
}
timeNames += axis.name;
}
std::wstring message = L"已加载 组" + std::to_wstring(groupIndex + 1) + L"|" + loaded->Title() +
L"(" + FormatCount(static_cast<unsigned long long>(rowCount)) +
L" 行采样," + std::to_wstring(loaded->signals.size()) +
L" 条信号,时间列:" + loaded->timeColumnName + L")";
if (!loaded->timeMonotonic)
L" 条信号," + std::to_wstring(loaded->axes.size()) + L" 条时间轴:" +
timeNames + L")";
if (anyNonMonotonic)
{
message += L" 提示:时间列非单调递增,光标取值使用最近邻策略。";
}
@@ -1065,7 +1092,7 @@ namespace jdi
return true;
}
bool Application::LoadFileAsync(const std::wstring& path, DataFile& file, std::wstring& error)
bool Application::LoadFileAsync(const std::wstring& path, SignalGroup& group, std::wstring& error)
{
error.clear();
@@ -1138,7 +1165,7 @@ namespace jdi
return false;
}
file = std::move(state.file);
group = std::move(state.group);
return true;
}
@@ -1549,7 +1576,7 @@ namespace jdi
return false;
}
if (!m_dataSet.SetSignalDisplayName(ref.fileIndex, ref.signalIndex, name))
if (!m_dataSet.SetSignalDisplayName(ref.groupIndex, ref.signalIndex, name))
{
return false;
}
@@ -1563,13 +1590,14 @@ namespace jdi
bool Application::BuildDerivedSignal(const DerivedSignalRequest& request, bool makeVisible)
{
const DataFile* file = m_dataSet.FileAt(request.fileIndex);
if (file == nullptr || request.signalIndex < 0 ||
request.signalIndex >= static_cast<int>(file->signals.size()))
const SignalGroup* group = m_dataSet.GroupAt(request.groupIndex);
if (group == nullptr || request.signalIndex < 0 ||
request.signalIndex >= static_cast<int>(group->signals.size()))
{
return false;
}
const SignalData& source = file->signals[static_cast<std::size_t>(request.signalIndex)];
const SignalData& source = group->signals[static_cast<std::size_t>(request.signalIndex)];
const TimeAxis& axis = group->AxisOf(source);
const std::wstring baseName = source.Title();
// 0. 先把源信号的「调整」变换快照下来(下面 `InsertDerivedSignal` 会移动信号存储,
@@ -1590,7 +1618,7 @@ namespace jdi
bool hasSourceStyle = false;
{
SignalRef sourceRef;
sourceRef.fileIndex = request.fileIndex;
sourceRef.groupIndex = request.groupIndex;
sourceRef.signalIndex = request.signalIndex;
if (const SignalStyle* sourceStyle = m_dataSet.Style(sourceRef))
{
@@ -1619,8 +1647,8 @@ namespace jdi
}
// 2. 组装新信号(外观一律用默认值:变换已经烘进采样数据,不再是"继承的指纹")。
derived.rawHeader = derived.name;
derived.unit = source.unit;
// ⚠️ 新信号**沿用源信号的时间轴**(`axisIndex`):派生只改采样值,不改时间基准。
derived.axisIndex = source.axisIndex;
derived.values.assign(derived_values.begin(), derived_values.end());
// 2b. 把源信号的「调整」变换**烘进派生采样值**:
@@ -1660,9 +1688,9 @@ namespace jdi
// ⚠️ 时间轴非单调时无法按下标单调推进取样(只能整表扫描),此时退回"继承
// `offsetTime`"(见 3b):两条路径的**显示效果相同**,只是偏移留在样式里。
bool timeShiftBaked = false;
if (hasSourceStyle && sourceTransform.offsetTime != 0.0 && file->timeMonotonic)
if (hasSourceStyle && sourceTransform.offsetTime != 0.0 && axis.monotonic)
{
const std::vector<double>& times = file->time;
const std::vector<double>& times = axis.time;
const std::size_t total = derived.values.size();
if (total > 1 && times.size() >= total)
{
@@ -1698,14 +1726,14 @@ namespace jdi
int newIndex = -1;
// 插入派生信号会移动该文件的信号存储:先暂停后台计算,避免其读取已移动的数据。
m_plot.PauseDerivedAsync();
if (!m_dataSet.InsertDerivedSignal(request.fileIndex, request.signalIndex, std::move(derived),
if (!m_dataSet.InsertDerivedSignal(request.groupIndex, request.signalIndex, std::move(derived),
newIndex))
{
return false;
}
for (TabConfig& tab : m_tabs)
{
tab.RemapAfterSignalInsert(request.fileIndex, newIndex);
tab.RemapAfterSignalInsert(request.groupIndex, newIndex);
}
// 3b. 时间轴偏移正常情况下已烘进采样值(见 2c),新信号的「调整」全部保持默认。
@@ -1713,7 +1741,7 @@ namespace jdi
// `offsetTime` 带在新信号样式上 —— 无论如何都要让**显示**与预览一致。
if (hasSourceStyle && sourceTransform.offsetTime != 0.0 && !timeShiftBaked)
{
if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{request.fileIndex, newIndex}))
if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{request.groupIndex, newIndex}))
{
derivedStyle->offsetTime = sourceTransform.offsetTime;
}
@@ -1723,12 +1751,12 @@ namespace jdi
if (makeVisible && !m_tabs.empty())
{
SignalRef derivedRef;
derivedRef.fileIndex = request.fileIndex;
derivedRef.groupIndex = request.groupIndex;
derivedRef.signalIndex = newIndex;
ActiveTab().SetVisible(derivedRef, true);
SignalRef sourceRef;
sourceRef.fileIndex = request.fileIndex;
sourceRef.groupIndex = request.groupIndex;
sourceRef.signalIndex = request.signalIndex;
ActiveTab().MoveLaneOfSignalBelow(derivedRef, sourceRef);
}
@@ -1751,7 +1779,7 @@ namespace jdi
for (const PlotView::MeanFilterBuild& item : items)
{
DerivedSignalRequest request;
request.fileIndex = item.fileIndex;
request.groupIndex = item.groupIndex;
request.signalIndex = item.signalIndex;
request.points = item.points;
request.iterations = item.iterations;
@@ -1768,7 +1796,7 @@ namespace jdi
for (const PlotView::WeakPeakBuild& item : items)
{
DerivedSignalRequest request;
request.fileIndex = item.fileIndex;
request.groupIndex = item.groupIndex;
request.signalIndex = item.signalIndex;
request.amountPercent = item.amountPercent;
request.iterations = item.iterations;
@@ -1791,9 +1819,9 @@ namespace jdi
std::sort(requests.begin(), requests.end(),
[](const DerivedSignalRequest& a, const DerivedSignalRequest& b)
{
if (a.fileIndex != b.fileIndex)
if (a.groupIndex != b.groupIndex)
{
return a.fileIndex < b.fileIndex;
return a.groupIndex < b.groupIndex;
}
return a.signalIndex > b.signalIndex;
});
@@ -2364,7 +2392,7 @@ namespace jdi
// 可用内存**严重不足**(连一条预览曲线本身都装不下):绘图区已放弃本次派生计算,
// 这里给出一次明确的告警。界面保持可用,绝不会因为内存不足而崩溃。
SetStatusMessage(L"可用内存不足,已暂停派生预览曲线与相关统计的计算。\n"
L"请关闭部分文件分组或信号后重试。",
L"请关闭部分分组或信号后重试。",
true);
InvalidatePlotRect(hwnd, m_plotRect);
return 0;
@@ -2945,9 +2973,9 @@ namespace jdi
m_menu.Close();
InvalidateRect(hwnd, nullptr, FALSE);
return 0;
case kMessageRemoveFile:
case kMessageRemoveGroup:
{
RemoveFile(static_cast<int>(wParam));
RemoveGroup(static_cast<int>(wParam));
return 0;
}
case kMessageRemoveSignal:
+20 -19
View File
@@ -322,7 +322,7 @@ namespace jdi
* @param [OUT] pixels 间隔换算到屏幕上的像素宽度。
* @return 计算成功返回 true。
*/
bool SampleSpacingPixels(const DataFile& file, double time, double& pixels) const;
bool SampleSpacingPixels(const TimeAxis& axis, double time, double& pixels) const;
/**
* @brief 判断坐标是否落在绘图区范围内。
@@ -408,7 +408,7 @@ namespace jdi
* @brief 暂停后台派生曲线计算并作废其结果。
*
* 后台线程会读取采样数据,因此**任何会改变数据层结构(增删文件/信号、插入派生信号,
* 进而移动 `DataFile`/`SignalData` 存储)的操作之前**都必须先调用本函数:它会等待正在
* 进而移动 `SignalGroup`/`SignalData` 存储)的操作之前**都必须先调用本函数:它会等待正在
* 进行的计算结束、清空待算队列与缓存,从而杜绝后台线程读取已释放内存。
* 之后的绘制会按需重新启动后台线程(惰性)。
*/
@@ -587,7 +587,7 @@ namespace jdi
*/
struct MeanFilterBuild
{
int fileIndex = -1; /**< 信号所属文件下标。 */
int groupIndex = -1; /**< 信号所属分组下标。 */
int signalIndex = -1; /**< 文件内信号下标。 */
int points = 5; /**< 均值点数 X(滑平半径)。 */
int iterations = 1; /**< 迭代次数 N(至少 1)。 */
@@ -611,7 +611,7 @@ namespace jdi
*/
struct WeakPeakBuild
{
int fileIndex = -1; /**< 信号所属文件下标。 */
int groupIndex = -1; /**< 信号所属分组下标。 */
int signalIndex = -1; /**< 文件内信号下标。 */
int amountPercent = 0; /**< 弱峰调整幅度(-400–400,默认 0 = 不改动)。 */
int iterations = 1; /**< 迭代次数 N(1–10)。 */
@@ -659,7 +659,7 @@ namespace jdi
/**
* @brief 取某个时刻的弱峰调整曲线取值(用于纵向光标的取值标注)。
* @param [IN] file 信号所属文件。
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @param [IN] amountPercent 弱峰调整幅度(-400–400)。
* @param [IN] iterations 迭代次数 N(1–10)。
@@ -667,7 +667,7 @@ namespace jdi
* @param [OUT] value 输出取值。
* @return 取到有效值返回 true。
*/
bool WeakPeakValueAt(const DataFile& file, const SignalData& signal, int amountPercent, int iterations,
bool WeakPeakValueAt(const TimeAxis& axis, const SignalData& signal, int amountPercent, int iterations,
double time, double& value) const;
/**
@@ -783,7 +783,8 @@ namespace jdi
{
const SignalData* data = nullptr; /**< 信号数据(含全局共享的显示外观)。 */
const SignalStyle* style = nullptr; /**< 显示外观。 */
const DataFile* file = nullptr; /**< 所属文件。 */
const SignalGroup* group = nullptr; /**< 信号所属分组(仅用于分组级信息)。 */
const TimeAxis* axis = nullptr; /**< 该信号使用的时间轴(采样值 ↔ 时刻的唯一依据)。 */
bool visible = false; /**< 在当前标签页中是否可见。 */
};
@@ -1294,7 +1295,7 @@ namespace jdi
/**
* @brief 取某个时刻的多次迭代均值滤波曲线取值(用于纵向光标的取值标注)。
* @param [IN] file 信号所属文件。
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @param [IN] points 均值点数 X。
* @param [IN] iterations 迭代次数 N。
@@ -1302,7 +1303,7 @@ namespace jdi
* @param [OUT] value 输出取值。
* @return 取到有效值返回 true。
*/
bool MeanFilterValueAt(const DataFile& file, const SignalData& signal, int points, int iterations,
bool MeanFilterValueAt(const TimeAxis& axis, const SignalData& signal, int points, int iterations,
double time, double& value) const;
/**
@@ -1329,7 +1330,7 @@ namespace jdi
* ⚠️ 判定对象是**画出来的两条曲线**:信号与中心曲线都要先按 `style` 做水平镜像映射、
* 再套用 `ApplyDrawTransform`(系数 → 绝对值 → 垂直镜像 → Y 偏移)。
*
* @param [IN] file 信号所属文件。
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @param [IN] style 该信号的外观(含水平镜像与「调整」变换)。
* @param [IN] points 均值点数 X。
@@ -1341,7 +1342,7 @@ namespace jdi
* @param [OUT] falling 下降沿数量(信号从上往下穿过均值滤波曲线)。
* @param [OUT] complete 是否为完整精确值(false = 仍在推进,界面显示“…”)。
*/
void EdgeCenterCountsFor(const DataFile& file, const SignalData& signal, const SignalStyle& style,
void EdgeCenterCountsFor(const TimeAxis& axis, const SignalData& signal, const SignalStyle& style,
int points, int iterations, bool hasRange, double rangeMin,
double rangeMax, std::size_t& rising, std::size_t& falling,
bool& complete) const;
@@ -1583,7 +1584,7 @@ namespace jdi
* @param [IN] timeShift 信号的时间偏移:采样画在 `t + timeShift` 处,因此可见采样对应的
* 源时刻区间为 `[ViewMin - timeShift, ViewMax - timeShift]`(偏移 0 时同视界)。
*/
void VisibleIndexRange(const DataFile& file, std::size_t count, std::size_t& begin,
void VisibleIndexRange(const TimeAxis& axis, std::size_t count, std::size_t& begin,
std::size_t& end, double timeShift = 0.0) const;
/**
@@ -1677,7 +1678,7 @@ namespace jdi
* 阈值变化(横向光标拖动中)就地把累积状态重置(不新建条目),且当帧不展示部分值
*(`justReset`),避免“错误且不更新的数字”。
*
* @param [IN] file 信号所属文件。
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @param [IN] style 该信号的外观(含水平镜像与「调整」变换)。
* @param [IN] edgeValue 横向光标所在的幅值。
@@ -1688,7 +1689,7 @@ namespace jdi
* @param [OUT] falling 下降沿数量(从上往下穿越)。
* @param [OUT] complete 结果是否为该阈值的**完整**精确值(false = 仍在推进,当前是临时值)。
*/
void EdgeCountsFor(const DataFile& file, const SignalData& signal, const SignalStyle& style,
void EdgeCountsFor(const TimeAxis& axis, const SignalData& signal, const SignalStyle& style,
double edgeValue, bool hasRange, double rangeMin, double rangeMax,
std::size_t& rising, std::size_t& falling, bool& complete) const;
@@ -1905,12 +1906,12 @@ namespace jdi
* * 未扫完时**主动请宿主再送一帧**(`RequestEdgeScanFrame`),因此**不需要移动鼠标**
* 也会持续推进(修掉"面板一直显示统计中,把鼠标移到面板上才更新")。
*
* 键 = 「文件 + 信号 + 种类 + 样式指纹 + 参数 +(仅非单调时间轴)区间」;
* 键 = 「时间轴 + 信号 + 种类 + 样式指纹 + 参数 +(仅非单调时间轴)区间」;
* 阈值/参数变化时就地重新开始(不新建条目,避免拖动时状态爆炸)。
*/
struct EdgeScanState
{
const DataFile* file = nullptr; /**< 所属数据分组。 */
const TimeAxis* axis = nullptr; /**< 该信号使用的时间轴(统计身份 = 时间轴 + 信号)。 */
const SignalData* signal = nullptr; /**< 目标信号。 */
bool centre = false; /**< false = 穿越横向光标阈值;true = 穿越中心平均值曲线。 */
std::uint64_t styleKey = 0; /**< 「调整」变换 + 水平镜像的指纹。 */
@@ -1972,7 +1973,7 @@ namespace jdi
* 中心轴时两侧(信号与中心曲线)都套用同一套变换。
* 时间区间**之外**的采样一律不参与计数(只有两个端点都在区间内的采样对才计入)。
*
* @param [IN] file 信号所属文件。 @param [IN] signal 目标信号。
* @param [IN] axis 该信号使用的时间轴。 @param [IN] signal 目标信号。
* @param [IN] style 信号外观。 @param [IN] centre 是否为中心轴判定。
* @param [IN] threshold 阈值(`centre = false` 时使用)。
* @param [IN] points 均值点数 X(`centre = true` 时使用)。
@@ -1984,7 +1985,7 @@ namespace jdi
* @param [OUT] rising 上升沿数量。 @param [OUT] falling 下降沿数量。
* @param [OUT] complete 是否为完整精确值(false = 仍在推进)。
*/
void EdgeScanFor(const DataFile& file, const SignalData& signal, const SignalStyle& style,
void EdgeScanFor(const TimeAxis& axis, const SignalData& signal, const SignalStyle& style,
bool centre, double threshold, int points, int iterations, bool hasRange,
double rangeMin, double rangeMax, std::size_t& rising, std::size_t& falling,
bool& complete) const;
@@ -2102,7 +2103,7 @@ namespace jdi
struct PolylineCacheEntry
{
SignalRef ref; /**< 信号引用。 */
const DataFile* file = nullptr; /**< 所属文件(内容不可变)。 */
const SignalGroup* group = nullptr; /**< 所属分组(缓存键的一部分)。 */
std::size_t sampleCount = 0; /**< 采样点数(数据变化时改变)。 */
double viewMin = 0.0; /**< 时间轴显示下界。 */
double viewMax = 0.0; /**< 时间轴显示上界。 */
+125 -119
View File
@@ -1,4 +1,4 @@
#include "PlotView.hpp"
#include "PlotView.hpp"
#include <algorithm>
#include <cmath>
@@ -504,7 +504,7 @@ namespace jdi
* @param [OUT] end 输出区间终点(不含)。
* @return 计数是否完全由该下标区间决定(时间轴单调时为真)。
*/
bool EdgeIndexRange(const DataFile& file, std::size_t count, bool hasRange, double rangeMin,
bool EdgeIndexRange(const TimeAxis& axis, std::size_t count, bool hasRange, double rangeMin,
double rangeMax, std::size_t& begin, std::size_t& end)
{
begin = 0;
@@ -513,13 +513,13 @@ namespace jdi
{
return true;
}
if (!file.timeMonotonic)
if (!axis.monotonic)
{
// 时间轴非单调时无法用下标区间表达统计范围(需逐点按时间过滤)。
return false;
}
const std::vector<double>& times = file.time;
const std::vector<double>& times = axis.time;
const std::size_t timeCount = std::min(times.size(), count);
begin = static_cast<std::size_t>(
std::lower_bound(times.begin(), times.begin() + static_cast<std::ptrdiff_t>(timeCount),
@@ -920,7 +920,8 @@ namespace jdi
return result;
}
result.data = m_dataSet->Resolve(ref);
result.file = m_dataSet->FileAt(ref.fileIndex);
result.group = m_dataSet->GroupAt(ref.groupIndex);
result.axis = m_dataSet->Axis(ref);
result.style = m_dataSet->Style(ref);
result.visible = (m_tab != nullptr) && m_tab->IsVisible(ref);
return result;
@@ -1011,9 +1012,9 @@ namespace jdi
}
if (firstFile < 0)
{
firstFile = ref.fileIndex;
firstFile = ref.groupIndex;
}
else if (ref.fileIndex != firstFile)
else if (ref.groupIndex != firstFile)
{
return true;
}
@@ -1582,13 +1583,13 @@ namespace jdi
double high = -std::numeric_limits<double>::infinity();
for (const SignalRef& ref : lanes[static_cast<std::size_t>(laneIndex)].signals)
{
const DataFile* file = m_dataSet->FileAt(ref.fileIndex);
if (file == nullptr)
const TimeAxis* axis = m_dataSet->Axis(ref);
if (axis == nullptr || axis->finiteCount == 0)
{
continue;
}
low = std::min(low, file->minTime);
high = std::max(high, file->maxTime);
low = std::min(low, axis->minTime);
high = std::max(high, axis->maxTime);
}
if (!std::isfinite(low) || !std::isfinite(high) || !(high > low))
@@ -1644,10 +1645,10 @@ namespace jdi
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].signals.size();
}
bool PlotView::SampleSpacingPixels(const DataFile& file, double time, double& pixels) const
bool PlotView::SampleSpacingPixels(const TimeAxis& axis, double time, double& pixels) const
{ pixels = 0.0;
const std::vector<double>& samples = file.time;
const std::vector<double>& samples = axis.time;
if (samples.size() < 2 || m_plotRect.Width <= 1.0f)
{
return false;
@@ -1659,7 +1660,7 @@ namespace jdi
return false;
}
const int index = FindNearestSample(file, time);
const int index = FindNearestSample(axis, time);
if (index < 0)
{
return false;
@@ -1703,37 +1704,42 @@ namespace jdi
double bestDistance = std::numeric_limits<double>::infinity();
double bestTime = 0.0;
for (const DataFile& file : m_dataSet->Files())
for (const SignalGroup& group : m_dataSet->Groups())
{
// 只有采样点在屏幕上足够稀疏时才吸附,否则缩小视图后光标会“跳格”。
double pixels = 0.0;
if (!SampleSpacingPixels(file, time, pixels) || pixels < minPixels || pixels > maxPixels)
// 一个分组可能有多条时间轴(多 `time` 列 CSV / MF4 多通道组),逐条尝试。
for (const TimeAxis& groupAxis : group.axes)
{
continue;
}
double pixels = 0.0;
if (!SampleSpacingPixels(groupAxis, time, pixels) || pixels < minPixels ||
pixels > maxPixels)
{
continue;
}
const int index = FindNearestSample(file, time);
if (index < 0)
{
continue;
}
const double candidate = file.time[static_cast<std::size_t>(index)];
if (!std::isfinite(candidate))
{
continue;
}
// 只吸附**当前可见区间内**的采样点:视界之外的采样点不是合法的落点。
if (candidate < viewMin || candidate > viewMax)
{
continue;
}
const int index = FindNearestSample(groupAxis, time);
if (index < 0)
{
continue;
}
const double candidate = groupAxis.time[static_cast<std::size_t>(index)];
if (!std::isfinite(candidate))
{
continue;
}
// 只吸附**当前可见区间内**的采样点:视界之外的采样点不是合法的落点。
if (candidate < viewMin || candidate > viewMax)
{
continue;
}
const double distance = std::fabs(candidate - time);
if (distance < bestDistance)
{
bestDistance = distance;
bestTime = candidate;
found = true;
const double distance = std::fabs(candidate - time);
if (distance < bestDistance)
{
bestDistance = distance;
bestTime = candidate;
found = true;
}
}
}
@@ -2372,10 +2378,10 @@ namespace jdi
return true;
}
bool PlotView::MeanFilterValueAt(const DataFile& file, const SignalData& signal, int points, int iterations,
bool PlotView::MeanFilterValueAt(const TimeAxis& axis, const SignalData& signal, int points, int iterations,
double time, double& value) const
{
const int index = FindNearestSample(file, time);
const int index = FindNearestSample(axis, time);
if (index < 0)
{
return false;
@@ -2402,17 +2408,17 @@ namespace jdi
return true;
}
void PlotView::VisibleIndexRange(const DataFile& file, std::size_t count, std::size_t& begin,
void PlotView::VisibleIndexRange(const TimeAxis& axis, std::size_t count, std::size_t& begin,
std::size_t& end, double timeShift) const
{
begin = 0;
end = count;
if (!file.timeMonotonic || count == 0)
if (!axis.monotonic || count == 0)
{
return;
}
// 时间偏移:采样画在 `t + timeShift` 处,因此落在当前视界内的采样其源时刻区间要整体左移。
const std::vector<double>& time = file.time;
const std::vector<double>& time = axis.time;
begin = static_cast<std::size_t>(
std::lower_bound(time.begin(), time.end(), Tab().ViewMin() - timeShift) - time.begin());
if (begin > 0)
@@ -3053,10 +3059,10 @@ namespace jdi
return m_weakPeakCurveCache.back().values;
}
bool PlotView::WeakPeakValueAt(const DataFile& file, const SignalData& signal, int amountPercent,
bool PlotView::WeakPeakValueAt(const TimeAxis& axis, const SignalData& signal, int amountPercent,
int iterations, double time, double& value) const
{
const int index = FindNearestSample(file, time);
const int index = FindNearestSample(axis, time);
if (index < 0)
{
return false;
@@ -3115,7 +3121,7 @@ namespace jdi
m_edgeScans.end());
}
void PlotView::EdgeScanFor(const DataFile& file, const SignalData& signal, const SignalStyle& style,
void PlotView::EdgeScanFor(const TimeAxis& axis, const SignalData& signal, const SignalStyle& style,
bool centre, double threshold, int points, int iterations, bool hasRange,
double rangeMin, double rangeMax, std::size_t& rising, std::size_t& falling,
bool& complete) const
@@ -3130,7 +3136,7 @@ namespace jdi
complete = true;
return;
}
const bool monotonic = file.timeMonotonic;
const bool monotonic = axis.monotonic;
// 非单调时间轴无法用「采样对下标区间」表达统计范围,只能逐点按时间过滤 →
// 此时区间必须进键(否则会混用别的区间结果)。
const bool rangeInKey = hasRange && !monotonic;
@@ -3149,7 +3155,7 @@ namespace jdi
{
std::size_t indexBegin = 0;
std::size_t indexEnd = 0;
EdgeIndexRange(file, count, hasRange, rangeMin, rangeMax, indexBegin, indexEnd);
EdgeIndexRange(axis, count, hasRange, rangeMin, rangeMax, indexBegin, indexEnd);
pairFirst = (indexBegin + 1 < count) ? (indexBegin + 1) : count;
pairLast = (indexEnd > 0) ? (indexEnd - 1) : 0;
if (pairLast < pairFirst)
@@ -3163,7 +3169,7 @@ namespace jdi
EdgeScanState* state = nullptr;
for (EdgeScanState& candidate : m_edgeScans)
{
if (candidate.file == &file && candidate.signal == &signal && candidate.centre == centre &&
if (candidate.axis == &axis && candidate.signal == &signal && candidate.centre == centre &&
candidate.styleKey == styleKey && candidate.points == safePoints &&
candidate.iterations == safeIterations && candidate.hasRange == rangeInKey &&
(!rangeInKey || (candidate.rangeMin == rangeMin && candidate.rangeMax == rangeMax)))
@@ -3197,7 +3203,7 @@ namespace jdi
m_edgeScans.erase(m_edgeScans.begin() + static_cast<std::ptrdiff_t>(victim));
}
EdgeScanState created;
created.file = &file;
created.axis = &axis;
created.signal = &signal;
created.centre = centre;
created.styleKey = styleKey;
@@ -3314,9 +3320,9 @@ namespace jdi
{
return nan;
}
if (filterByTime && index < file.time.size())
if (filterByTime && index < axis.time.size())
{
const double time = file.time[index];
const double time = axis.time[index];
if (time < rangeMin || time > rangeMax)
{
return nan;
@@ -3539,22 +3545,22 @@ namespace jdi
}
}
void PlotView::EdgeCountsFor(const DataFile& file, const SignalData& signal, const SignalStyle& style,
void PlotView::EdgeCountsFor(const TimeAxis& axis, const SignalData& signal, const SignalStyle& style,
double edgeValue, bool hasRange, double rangeMin, double rangeMax,
std::size_t& rising, std::size_t& falling, bool& complete) const
{
EdgeScanFor(file, signal, style, false, edgeValue, 0, 1, hasRange, rangeMin, rangeMax, rising,
EdgeScanFor(axis, signal, style, false, edgeValue, 0, 1, hasRange, rangeMin, rangeMax, rising,
falling, complete);
}
void PlotView::EdgeCenterCountsFor(const DataFile& file, const SignalData& signal,
void PlotView::EdgeCenterCountsFor(const TimeAxis& axis, const SignalData& signal,
const SignalStyle& style, int points, int iterations,
bool hasRange, double rangeMin, double rangeMax,
std::size_t& rising, std::size_t& falling, bool& complete) const
{
// 与普通边沿计数完全共用同一套「逐块累积 + 每 tick 预算推进」机制;
// 区别只在判定基准(信号 − 中心平均值曲线)与所需曲线(均值滤波,需整条)。
EdgeScanFor(file, signal, style, true, 0.0, points, iterations, hasRange, rangeMin, rangeMax,
EdgeScanFor(axis, signal, style, true, 0.0, points, iterations, hasRange, rangeMin, rangeMax,
rising, falling, complete);
}
@@ -3621,13 +3627,13 @@ namespace jdi
{
continue;
}
EdgeCenterCountsFor(*resolved.file, *resolved.data, *resolved.style, item->points,
EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, item->points,
item->iterations, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(),
rising, falling, complete);
}
else
{
EdgeCountsFor(*resolved.file, *resolved.data, *resolved.style, lane.edgeValue,
EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, lane.edgeValue,
EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete);
}
// 宽度按实际要显示的文本测量(含未算完时的“…”),否则推进过程中数字变长会被截断。
@@ -3812,13 +3818,13 @@ namespace jdi
continue;
}
const SignalData* signal = m_dataSet->Resolve(ref);
const DataFile* file = m_dataSet->FileAt(ref.fileIndex);
if (signal == nullptr || file == nullptr)
const TimeAxis* axis = m_dataSet->Axis(ref);
if (signal == nullptr || axis == nullptr)
{
continue;
}
const std::size_t count = std::min(file->time.size(), signal->values.size());
const std::size_t count = std::min(axis->time.size(), signal->values.size());
const SignalStyle* style = m_dataSet->Style(ref);
// 时间偏移:可见采样对应的源时刻区间要整体左移(画在 t + shift 处)。
const double timeShift = (style != nullptr) ? style->offsetTime : 0.0;
@@ -3826,17 +3832,17 @@ namespace jdi
const double sourceMax = viewMax - timeShift;
std::size_t begin = 0;
std::size_t end = count;
if (file->timeMonotonic)
if (axis->monotonic)
{
// 时间单调时直接二分定位区间,百万级采样也只需扫过可见段。
begin = static_cast<std::size_t>(
std::lower_bound(file->time.begin(), file->time.end(), sourceMin) - file->time.begin());
std::lower_bound(axis->time.begin(), axis->time.end(), sourceMin) - axis->time.begin());
if (begin > 0)
{
--begin;
}
end = static_cast<std::size_t>(
std::lower_bound(file->time.begin(), file->time.end(), sourceMax) - file->time.begin());
std::lower_bound(axis->time.begin(), axis->time.end(), sourceMax) - axis->time.begin());
if (end < count)
{
++end;
@@ -3857,7 +3863,7 @@ namespace jdi
// `ComputeStats` 预存的 min/max 完全一致。工具栏「Y 轴自适应」开关在全视图下
// 会一次性对全部网格做这件事 —— 逐点扫描 2580 万 × 5 是秒级卡顿(实测 1200ms),
// 这里改为 O(1)。
if (file->timeMonotonic && begin == 0 && end == count && !mirrored && !transform &&
if (axis->monotonic && begin == 0 && end == count && !mirrored && !transform &&
signal->finiteCount > 0)
{
low = std::min(low, signal->minValue);
@@ -3870,9 +3876,9 @@ namespace jdi
// 无变换:与既有实现完全一致的紧凑循环(无逐点分支,便于向量化)。
for (std::size_t index = begin; index < end; ++index)
{
if (!file->timeMonotonic)
if (!axis->monotonic)
{
const double t = file->time[index];
const double t = axis->time[index];
if (!std::isfinite(t) || t < sourceMin || t > sourceMax)
{
continue;
@@ -3895,9 +3901,9 @@ namespace jdi
// 水平镜像时画面上的第 index 个位置取源下标 count-1-index 的值。
for (std::size_t index = begin; index < end; ++index)
{
if (!file->timeMonotonic)
if (!axis->monotonic)
{
const double t = file->time[index];
const double t = axis->time[index];
if (!std::isfinite(t) || t < sourceMin || t > sourceMax)
{
continue;
@@ -4145,7 +4151,7 @@ namespace jdi
// 否则每次鼠标移动都要遍历上万个顶点(悬停卡顿的来源之一)。
std::size_t windowFirst = 0;
std::size_t windowLast = points.size();
if (resolved.file->timeMonotonic)
if (resolved.axis->monotonic)
{
const auto byX = [](const gd::PointF& point, float limit) { return point.X < limit; };
windowFirst = static_cast<std::size_t>(
@@ -5674,7 +5680,7 @@ namespace jdi
continue;
}
WeakPeakBuild build;
build.fileIndex = ref.fileIndex;
build.groupIndex = ref.groupIndex;
build.signalIndex = ref.signalIndex;
const int peakAmount =
std::max(kWeakPeakAmountMin, std::min(kWeakPeakAmountMax, item->amountPercent));
@@ -5718,7 +5724,7 @@ namespace jdi
continue;
}
MeanFilterBuild build;
build.fileIndex = ref.fileIndex;
build.groupIndex = ref.groupIndex;
build.signalIndex = ref.signalIndex;
build.points = item->points;
build.iterations = item->iterations;
@@ -6094,7 +6100,7 @@ namespace jdi
const ResolvedSignal& resolved,
const LaneVisual& lane) const
{
if (resolved.data == nullptr || resolved.file == nullptr)
if (resolved.data == nullptr || resolved.group == nullptr)
{
return nullptr;
}
@@ -6113,7 +6119,7 @@ namespace jdi
// 键 = 构建折线时实际读取的全部输入:
// 信号(引用 + 文件内容 + 采样点数)、时间轴显示范围、绘图区横向几何、网格纵向几何。
// 这些值全部相同时,BuildPolyline 的输出必然完全一致,可直接复用。
const std::size_t sampleCount = std::min(resolved.file->time.size(), resolved.data->values.size());
const std::size_t sampleCount = std::min(resolved.axis->time.size(), resolved.data->values.size());
const double viewMin = Tab().ViewMin();
const double viewMax = Tab().ViewMax();
// 绘制前的数值变换(系数/绝对值/镜像/偏移)会改变顶点,必须进缓存键。
@@ -6126,7 +6132,7 @@ namespace jdi
for (const PolylineCacheEntry& entry : m_polylineCache)
{
if (entry.ref == ref && entry.file == resolved.file && entry.sampleCount == sampleCount &&
if (entry.ref == ref && entry.group == resolved.group && entry.sampleCount == sampleCount &&
entry.viewMin == viewMin && entry.viewMax == viewMax && entry.plotX == m_plotRect.X &&
entry.plotWidth == m_plotRect.Width && entry.laneY == lane.rect.Y &&
entry.laneHeight == lane.rect.Height && entry.laneYMin == lane.yMin &&
@@ -6147,7 +6153,7 @@ namespace jdi
m_polylineCache.push_back(PolylineCacheEntry());
PolylineCacheEntry& entry = m_polylineCache.back();
entry.ref = ref;
entry.file = resolved.file;
entry.group = resolved.group;
entry.sampleCount = sampleCount;
entry.viewMin = viewMin;
entry.viewMax = viewMax;
@@ -6182,12 +6188,12 @@ namespace jdi
bandMode = false;
bandSpiky = false;
if (resolved.data == nullptr || resolved.file == nullptr)
if (resolved.data == nullptr || resolved.group == nullptr)
{
return;
}
const std::vector<double>& time = resolved.file->time;
const std::vector<double>& time = resolved.axis->time;
const std::vector<double>& values = resolved.data->values;
const std::size_t count = std::min(time.size(), values.size());
if (count == 0 || m_plotRect.Width <= 1.0f)
@@ -6207,7 +6213,7 @@ namespace jdi
std::size_t begin = 0;
std::size_t end = count;
if (resolved.file->timeMonotonic)
if (resolved.axis->monotonic)
{
const std::vector<double>::const_iterator first = std::lower_bound(time.begin(), time.end(), sourceMin);
begin = static_cast<std::size_t>(first - time.begin());
@@ -6259,12 +6265,12 @@ namespace jdi
// 把阈值降到 1 采样/像素后,1~2 采样/像素区间走包络带(逐列 min/max 与折线在该密度下
// 几乎等价,且**保留抗锯齿**),彻底消除这一区间的病态卡顿。
const bool useEnvelopeBand = (visibleCount > static_cast<std::size_t>(m_plotRect.Width)) &&
resolved.file->timeMonotonic && (m_plotRect.Width > 1.0f);
resolved.axis->monotonic && (m_plotRect.Width > 1.0f);
// 逐点描边路径若处于"横向补条"渲染:把采样区间裁剪到条带对应的 x 范围
//(两侧各留少量像素余量以避免接缝与抗锯齿溢出)。逐点描边路径必然 `visibleCount <= budget`
//(否则走包络),此时 `stride == 1`,裁剪后条带内的顶点序列与整块渲染完全一致 → 像素不变。
if (!useEnvelopeBand && m_stripRenderActive && resolved.file->timeMonotonic &&
if (!useEnvelopeBand && m_stripRenderActive && resolved.axis->monotonic &&
m_stripX1 > m_stripX0)
{
const float marginPx =
@@ -6581,11 +6587,11 @@ namespace jdi
std::wstring message;
if (m_dataSet == nullptr || m_dataSet->Empty())
{
message = L"打开 CSV 文件以查看仿真数据波形";
message = L"打开数据文件以查看波形";
}
else
{
message = L"请在左侧勾选需要查看的信号(可同时加载多个文件进行对比)";
message = L"请在左侧勾选需要查看的信号(可同时加载多个文件,各自成为一个分组)";
}
UiText(g, message, m_fonts->ui, m_area, m_theme->textMuted, TextAlign::CENTER);
}
@@ -7111,7 +7117,7 @@ namespace jdi
key.scale = m_scale;
const SignalRef halo = SceneHaloRef();
key.haloFile = halo.fileIndex;
key.haloFile = halo.groupIndex;
key.haloSignal = halo.signalIndex;
key.contentHash = SceneContentHash();
return key;
@@ -7195,7 +7201,7 @@ namespace jdi
{
const SignalRef selected = SelectedRef();
mix(selected.IsValid() ? 1ull : 0ull);
mix(static_cast<std::uint64_t>(selected.fileIndex));
mix(static_cast<std::uint64_t>(selected.groupIndex));
mix(static_cast<std::uint64_t>(selected.signalIndex));
}
@@ -7225,7 +7231,7 @@ namespace jdi
mix(static_cast<std::uint64_t>(Tab().Lanes()[laneIndex].edgeCenterItems.size()));
for (const Lane::EdgeCenterItem& item : Tab().Lanes()[laneIndex].edgeCenterItems)
{
mix(static_cast<std::uint64_t>(item.ref.fileIndex));
mix(static_cast<std::uint64_t>(item.ref.groupIndex));
mix(static_cast<std::uint64_t>(item.ref.signalIndex));
mix(static_cast<std::uint64_t>(item.points));
mix(static_cast<std::uint64_t>(item.iterations));
@@ -7234,7 +7240,7 @@ namespace jdi
mix(static_cast<std::uint64_t>(Tab().Lanes()[laneIndex].meanFilterItems.size()));
for (const Lane::MeanFilterItem& item : Tab().Lanes()[laneIndex].meanFilterItems)
{
mix(static_cast<std::uint64_t>(item.ref.fileIndex));
mix(static_cast<std::uint64_t>(item.ref.groupIndex));
mix(static_cast<std::uint64_t>(item.ref.signalIndex));
mix(static_cast<std::uint64_t>(item.points));
mix(static_cast<std::uint64_t>(item.iterations));
@@ -7244,7 +7250,7 @@ namespace jdi
for (const Lane::WeakPeakItem& item : Tab().Lanes()[laneIndex].weakPeakItems)
{
// 逐信号参数(等级 + 迭代次数)都参与指纹:改任一条的参数都会重画。
mix(static_cast<std::uint64_t>(item.ref.fileIndex));
mix(static_cast<std::uint64_t>(item.ref.groupIndex));
mix(static_cast<std::uint64_t>(item.ref.signalIndex));
mix(static_cast<std::uint64_t>(item.amountPercent));
mix(static_cast<std::uint64_t>(item.iterations));
@@ -7254,7 +7260,7 @@ namespace jdi
for (const SignalRef& ref : signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
mix(static_cast<std::uint64_t>(ref.fileIndex));
mix(static_cast<std::uint64_t>(ref.groupIndex));
mix(static_cast<std::uint64_t>(ref.signalIndex));
mix(resolved.visible ? 1ull : 0ull);
if (resolved.data == nullptr || resolved.style == nullptr)
@@ -7281,9 +7287,9 @@ namespace jdi
}
// 图例文案参与绘制,重命名必须让缓存失效。
mixText(resolved.data->Title());
if (resolved.file != nullptr)
if (resolved.group != nullptr)
{
mixText(resolved.file->Title());
mixText(resolved.group->Title());
}
}
}
@@ -7425,7 +7431,7 @@ namespace jdi
target.reserve(items.size() * 4);
for (const auto& item : items)
{
target.push_back(item.ref.fileIndex);
target.push_back(item.ref.groupIndex);
target.push_back(item.ref.signalIndex);
// 均值滤波/中心轴取 points,弱峰调整取 amountPercent(由 `valueOf` 决定)。
target.push_back(static_cast<int>(valueOf(item)));
@@ -8290,7 +8296,7 @@ namespace jdi
for (const SignalRef& ref : signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
mix(static_cast<std::uint64_t>(ref.fileIndex));
mix(static_cast<std::uint64_t>(ref.groupIndex));
mix(static_cast<std::uint64_t>(ref.signalIndex));
mix(resolved.visible ? 1ull : 0ull);
if (resolved.data == nullptr || resolved.style == nullptr)
@@ -9945,9 +9951,9 @@ namespace jdi
}
++visibleCount;
std::wstring label = BuildLegendText(*resolved.data);
if (multiFile && resolved.file != nullptr)
if (multiFile && resolved.group != nullptr)
{
label += L" · " + resolved.file->Title();
label += L" · " + resolved.group->Title();
}
const float maxTextWidth = kLegendMaxTextWidth * m_scale;
const float fullTextWidth = UiMeasureTextWidth(m_fonts->uiTiny, label);
@@ -10130,20 +10136,20 @@ namespace jdi
UiDrawSolidLines(g, axisClip, axisSegment, 1, axisColor);
}
// 时间轴列名(取自首个可见信号所属文件),贴左侧绘制。
// 时间轴列名(取自首个可见信号所属分组的第一条时间轴),贴左侧绘制。
std::wstring axisName = L"时间";
if (m_dataSet != nullptr && m_tab != nullptr)
{
for (std::size_t fileIndex = 0; fileIndex < m_dataSet->FileCount(); ++fileIndex)
for (std::size_t groupIndex = 0; groupIndex < m_dataSet->GroupCount(); ++groupIndex)
{
if (!m_tab->FileHasVisible(static_cast<int>(fileIndex)))
if (!m_tab->GroupHasVisible(static_cast<int>(groupIndex)))
{
continue;
}
const DataFile* file = m_dataSet->FileAt(static_cast<int>(fileIndex));
if (file != nullptr && !file->timeColumnName.empty())
const SignalGroup* group = m_dataSet->GroupAt(static_cast<int>(groupIndex));
if (group != nullptr && !group->axes.empty() && !group->axes.front().name.empty())
{
axisName = file->timeColumnName;
axisName = group->axes.front().name;
// CSV 中该列通常直接叫 "time",这里补上时间单位秒便于阅读;
// 若列名自带单位描述(以右括号结尾)则不重复追加。
if (axisName.back() != L')')
@@ -10457,14 +10463,14 @@ namespace jdi
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.style == nullptr ||
resolved.file == nullptr || !resolved.visible)
resolved.axis == nullptr || !resolved.visible)
{
continue;
}
const bool interpolate = (resolved.style->mode != DrawMode::SCATTER);
double value = 0.0;
if (!SampleDisplayedSignalAt(*resolved.file, *resolved.data, *resolved.style, time,
if (!SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style, time,
interpolate, value))
{
continue;
@@ -10491,7 +10497,7 @@ namespace jdi
if (showDelta)
{
double otherValue = 0.0;
if (SampleDisplayedSignalAt(*resolved.file, *resolved.data, *resolved.style, otherTime,
if (SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style, otherTime,
interpolate, otherValue))
{
otherValue = ApplyDrawTransform(*resolved.style, otherValue);
@@ -10561,13 +10567,13 @@ namespace jdi
int iterations, int amountPercent,
const std::wstring& suffix)
{
const int sampleIndex = FindNearestSample(*resolved.file, time);
const int sampleIndex = FindNearestSample(*resolved.axis, time);
if (sampleIndex < 0)
{
return;
}
const int otherIndex =
showDelta ? FindNearestSample(*resolved.file, otherTime) : -1;
showDelta ? FindNearestSample(*resolved.axis, otherTime) : -1;
// 本函数**只需要光标所在采样点处的派生取值**(以及双光标另一端的取值),
// 因此把请求窗口收窄到「需要的那几个下标」即可,均值滤波半径造成的取值
// 依赖由 `CurveSpanFor` 自身的保护带负责覆盖。
@@ -11374,12 +11380,12 @@ namespace jdi
const LaneVisual& lane, const std::vector<float>& curve,
std::size_t curveBegin) const
{
if (resolved.data == nullptr || resolved.file == nullptr || resolved.style == nullptr)
if (resolved.data == nullptr || resolved.axis == nullptr || resolved.style == nullptr)
{
return;
}
const std::vector<double>& time = resolved.file->time;
const std::vector<double>& time = resolved.axis->time;
const std::size_t count = std::min(time.size(), resolved.data->values.size());
if (count == 0 || m_plotRect.Width <= 1.0f || curve.empty())
{
@@ -11388,7 +11394,7 @@ namespace jdi
std::size_t begin = 0;
std::size_t end = 0;
VisibleIndexRange(*resolved.file, count, begin, end, resolved.style->offsetTime);
VisibleIndexRange(*resolved.axis, count, begin, end, resolved.style->offsetTime);
if (end <= begin)
{
return;
@@ -11501,14 +11507,14 @@ namespace jdi
void PlotView::PaintMeanFilterCurve(gd::Graphics& g, const ResolvedSignal& resolved,
const LaneVisual& lane, int points, int iterations) const
{
if (resolved.data == nullptr || resolved.file == nullptr || resolved.style == nullptr)
if (resolved.data == nullptr || resolved.axis == nullptr || resolved.style == nullptr)
{
return;
}
// 只取「可见区间 ± 保护带」的窗口(见 `CurveSpanFor`):拖参数滑块不会重算整条曲线。
// 与「边沿计数(中心轴)」共用同一段绘制逻辑
//(同色相高对比度的派生配色 + 同透明度),只是曲线本身按多次迭代滤波计算。
const std::size_t count = std::min(resolved.file->time.size(), resolved.data->values.size());
const std::size_t count = std::min(resolved.axis->time.size(), resolved.data->values.size());
if (count == 0)
{
return;
@@ -11531,11 +11537,11 @@ namespace jdi
void PlotView::PaintWeakPeakCurve(gd::Graphics& g, const ResolvedSignal& resolved,
const LaneVisual& lane, int amountPercent, int iterations) const
{
if (resolved.data == nullptr || resolved.file == nullptr || resolved.style == nullptr)
if (resolved.data == nullptr || resolved.axis == nullptr || resolved.style == nullptr)
{
return;
}
const std::size_t count = std::min(resolved.file->time.size(), resolved.data->values.size());
const std::size_t count = std::min(resolved.axis->time.size(), resolved.data->values.size());
if (count == 0)
{
return;
@@ -11661,7 +11667,7 @@ namespace jdi
const auto mixRef = [&mix](const SignalRef& ref)
{
mix(ref.IsValid() ? 1ull : 0ull);
mix(static_cast<std::uint64_t>(ref.fileIndex));
mix(static_cast<std::uint64_t>(ref.groupIndex));
mix(static_cast<std::uint64_t>(ref.signalIndex));
};
@@ -11798,7 +11804,7 @@ namespace jdi
for (const SignalRef& ref : layout.rowRefs)
{
mix(ref.IsValid() ? 1ull : 0ull);
mix(static_cast<std::uint64_t>(ref.fileIndex));
mix(static_cast<std::uint64_t>(ref.groupIndex));
mix(static_cast<std::uint64_t>(ref.signalIndex));
// 勾选状态与参数直接决定行文本与行高。
mix(lane.MeanFilterEnabled(ref) ? 1ull : 0ull);
@@ -12339,7 +12345,7 @@ namespace jdi
std::size_t rising = 0;
std::size_t falling = 0;
bool statsComplete = false;
EdgeCenterCountsFor(*resolved.file, *resolved.data, *resolved.style,
EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style,
statsPoints, statsOrder, EdgeRangeActive(),
EdgeRangeMin(), EdgeRangeMax(), rising, falling,
statsComplete);
@@ -12374,7 +12380,7 @@ namespace jdi
std::size_t rising = 0;
std::size_t falling = 0;
bool complete = true;
EdgeCountsFor(*resolved.file, *resolved.data, *resolved.style, tabLane.edgeValue,
EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, tabLane.edgeValue,
EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling,
complete);
paintCounts(gd::RectF(panel.X + pad, rowY + rowHeight, textWidth, rowHeight), rising,
+7 -7
View File
@@ -28,9 +28,9 @@ namespace jdi
CMD_SIGNAL_SOLO = 2, /**< 仅显示该信号。 */
CMD_SIGNAL_SHOW_ALL = 3, /**< 显示全部信号。 */
CMD_SIGNAL_HIDE_ALL = 4, /**< 隐藏全部信号。 */
CMD_FILE_SHOW_ALL = 10, /**< 选中该文件全部信号。 */
CMD_FILE_HIDE_ALL = 11, /**< 取消该文件全部信号。 */
CMD_FILE_REMOVE = 12, /**< 从工作区移除该文件。 */
CMD_GROUP_SHOW_ALL = 10, /**< 选中该文件全部信号。 */
CMD_GROUP_HIDE_ALL = 11, /**< 取消该文件全部信号。 */
CMD_GROUP_REMOVE = 12, /**< 从工作区移除该文件。 */
CMD_COLOR_BASE = 100, /**< 预设颜色基准(配合色块下标)。 */
CMD_MODE_BASE = 200, /**< 绘制样式基准(配合下标)。 */
CMD_LANE_BASE = 300, /**< 网格归属基准(配合下标)。 */
@@ -315,7 +315,7 @@ namespace jdi
*/
enum class RowKind : int
{
FILE = 0, /**< 文件分组标题行。 */
GROUP = 0, /**< 文件分组标题行。 */
SIGNAL = 1 /**< 信号行。 */
};
@@ -359,7 +359,7 @@ namespace jdi
struct ListRow
{
RowKind kind = RowKind::SIGNAL; /**< 行类型。 */
int fileIndex = -1; /**< 所属文件下标。 */
int groupIndex = -1; /**< 所属文件下标。 */
int signalIndex = -1; /**< 信号下标(仅信号行有效)。 */
float height = 0.0f; /**< 行高(像素)。 */
};
@@ -478,11 +478,11 @@ namespace jdi
/**
* @brief 弹出文件分组右键菜单。
* @param [IN] fileIndex 目标文件下标。
* @param [IN] groupIndex 目标文件下标。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
*/
void OpenFileMenu(int fileIndex, int x, int y);
void OpenFileMenu(int groupIndex, int x, int y);
/**
* @brief 弹出网格归属菜单(由信号行的网格徽标触发)。
+63 -63
View File
@@ -448,17 +448,17 @@ namespace jdi
const float signalHeight = kSignalRowHeight * m_scale;
const bool filtering = !m_filterText.empty();
const std::vector<DataFile>& files = m_dataSet->Files();
for (std::size_t fileIndex = 0; fileIndex < files.size(); ++fileIndex)
const std::vector<SignalGroup>& files = m_dataSet->Groups();
for (std::size_t groupIndex = 0; groupIndex < files.size(); ++groupIndex)
{
// 筛选时先挑出本组内匹配的信号;整组都没有匹配则**不显示该组**。
std::vector<int> matched;
if (filtering)
{
const std::size_t signalCount = files[fileIndex].signals.size();
const std::size_t signalCount = files[groupIndex].signals.size();
for (std::size_t signalIndex = 0; signalIndex < signalCount; ++signalIndex)
{
if (FilterMatchesSignal(files[fileIndex].signals[signalIndex]))
if (FilterMatchesSignal(files[groupIndex].signals[signalIndex]))
{
matched.push_back(static_cast<int>(signalIndex));
}
@@ -470,13 +470,13 @@ namespace jdi
}
ListRow fileRow;
fileRow.kind = RowKind::FILE;
fileRow.fileIndex = static_cast<int>(fileIndex);
fileRow.kind = RowKind::GROUP;
fileRow.groupIndex = static_cast<int>(groupIndex);
fileRow.height = fileHeight;
m_rows.push_back(fileRow);
// 筛选结果**默认展开**(忽略折叠状态),便于直接看到匹配项。
if (!filtering && m_tab->IsCollapsed(static_cast<int>(fileIndex)))
if (!filtering && m_tab->IsCollapsed(static_cast<int>(groupIndex)))
{
continue;
}
@@ -487,7 +487,7 @@ namespace jdi
{
ListRow signalRow;
signalRow.kind = RowKind::SIGNAL;
signalRow.fileIndex = static_cast<int>(fileIndex);
signalRow.groupIndex = static_cast<int>(groupIndex);
signalRow.signalIndex = signalIndex;
signalRow.height = signalHeight;
m_rows.push_back(signalRow);
@@ -495,12 +495,12 @@ namespace jdi
continue;
}
const std::size_t signalCount = files[fileIndex].signals.size();
const std::size_t signalCount = files[groupIndex].signals.size();
for (std::size_t signalIndex = 0; signalIndex < signalCount; ++signalIndex)
{
ListRow signalRow;
signalRow.kind = RowKind::SIGNAL;
signalRow.fileIndex = static_cast<int>(fileIndex);
signalRow.groupIndex = static_cast<int>(groupIndex);
signalRow.signalIndex = static_cast<int>(signalIndex);
signalRow.height = signalHeight;
m_rows.push_back(signalRow);
@@ -639,7 +639,7 @@ namespace jdi
{
const int rowIndex = static_cast<int>(index);
const gd::RectF rect = RowRect(rowIndex);
const bool isFile = (row.kind == RowKind::FILE);
const bool isFile = (row.kind == RowKind::GROUP);
const float baseX = rect.X + kPadding * m_scale + (isFile ? 0.0f : kIndent * m_scale);
const gd::RectF disclosureRect(baseX, rect.Y, kDisclosureWidth * m_scale, rect.Height);
@@ -668,7 +668,7 @@ namespace jdi
}
else
{
const SignalRef ref{row.fileIndex, row.signalIndex};
const SignalRef ref{row.groupIndex, row.signalIndex};
const bool badgeVisible = (m_tab != nullptr) && m_tab->IsVisible(ref);
// ✕ 命中优先于复选框:两者紧邻,顺序保证不会互相抢命中。
@@ -708,7 +708,7 @@ namespace jdi
for (std::size_t index = 0; index < m_rows.size(); ++index)
{
const ListRow& row = m_rows[index];
if (row.kind == RowKind::SIGNAL && row.fileIndex == ref.fileIndex &&
if (row.kind == RowKind::SIGNAL && row.groupIndex == ref.groupIndex &&
row.signalIndex == ref.signalIndex)
{
if (top < m_scrollOffset)
@@ -968,7 +968,7 @@ namespace jdi
if (m_dataSet == nullptr || m_dataSet->Empty())
{
UiText(g, L"尚未加载数据,请打开 CSV 文件", m_fonts->uiSmall, m_listRect, m_theme->textMuted,
UiText(g, L"尚未加载数据,请打开数据文件", m_fonts->uiSmall, m_listRect, m_theme->textMuted,
TextAlign::CENTER);
}
else if (m_rows.empty() && !m_filterText.empty())
@@ -987,7 +987,7 @@ namespace jdi
{
continue;
}
if (m_rows[static_cast<std::size_t>(row)].kind == RowKind::FILE)
if (m_rows[static_cast<std::size_t>(row)].kind == RowKind::GROUP)
{
PaintFileRow(g, row);
}
@@ -1025,8 +1025,8 @@ namespace jdi
}
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
const DataFile* file = m_dataSet->FileAt(item.fileIndex);
if (file == nullptr)
const SignalGroup* group = m_dataSet->GroupAt(item.groupIndex);
if (group == nullptr)
{
return;
}
@@ -1042,7 +1042,7 @@ namespace jdi
const float baseX = rect.X + kPadding * m_scale;
const float centerY = rect.Y + rect.Height * 0.5f;
const bool collapsed = m_tab->IsCollapsed(item.fileIndex);
const bool collapsed = m_tab->IsCollapsed(item.groupIndex);
// 折叠三角。
{
@@ -1057,15 +1057,15 @@ namespace jdi
// 分组复选框(三态:全选 / 部分 / 未选)。
const gd::RectF checkRect(baseX + kDisclosureWidth * m_scale, centerY - kCheckSize * m_scale * 0.5f,
kCheckSize * m_scale, kCheckSize * m_scale);
const bool allVisible = m_tab->FileAllVisible(item.fileIndex);
const bool anyVisible = m_tab->FileHasVisible(item.fileIndex);
const bool allVisible = m_tab->GroupAllVisible(item.groupIndex);
const bool anyVisible = m_tab->GroupHasVisible(item.groupIndex);
UiDrawCheckBox(g, checkRect, allVisible ? 1 : (anyVisible ? 2 : 0), 3.0f * m_scale);
const gd::RectF closeRect = FileCloseRect(row);
// 分组标题:左侧为“组X”标签,右侧以绘制的竖线分隔分组名(不使用字符),再显示可重命名的名称。
const std::wstring groupLabel = L"组" + std::to_wstring(item.fileIndex + 1);
const std::wstring stats = std::to_wstring(file->signals.size()) + L" 项";
const std::wstring groupLabel = L"组" + std::to_wstring(item.groupIndex + 1);
const std::wstring stats = std::to_wstring(group->signals.size()) + L" 项";
const float groupWidth = FileGroupLabelWidth(row);
const float statsWidth = UiMeasureTextWidth(m_fonts->uiSmall, stats);
const float statsRight = closeRect.X - 6.0f * m_scale;
@@ -1091,7 +1091,7 @@ namespace jdi
}
else
{
UiText(g, UiEllipsize(file->Title(), m_fonts->uiBold, nameWidth), m_fonts->uiBold,
UiText(g, UiEllipsize(group->Title(), m_fonts->uiBold, nameWidth), m_fonts->uiBold,
gd::RectF(nameAreaLeft, rect.Y, nameWidth, rect.Height), m_theme->textStrong,
TextAlign::LEFT);
}
@@ -1116,7 +1116,7 @@ namespace jdi
}
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
const SignalRef ref{item.fileIndex, item.signalIndex};
const SignalRef ref{item.groupIndex, item.signalIndex};
const SignalData* signal = m_dataSet->Resolve(ref);
const SignalStyle* style = StyleOf(ref);
if (signal == nullptr || style == nullptr)
@@ -1451,7 +1451,7 @@ namespace jdi
}
const gd::RectF rect = RowRect(row);
const bool isFile = (m_rows[static_cast<std::size_t>(row)].kind == RowKind::FILE);
const bool isFile = (m_rows[static_cast<std::size_t>(row)].kind == RowKind::GROUP);
const float baseX = rect.X + kPadding * m_scale + (isFile ? 0.0f : kIndent * m_scale);
const float checkRight = baseX + kDisclosureWidth * m_scale + kCheckSize * m_scale;
@@ -1468,7 +1468,7 @@ namespace jdi
{
left = checkRight + 6.0f * m_scale + kSwatchSize * m_scale + 6.0f * m_scale;
// 行尾的可用右界:有网格徽标时让位给徽标,否则可直接延伸到行尾 ✕ 之前。
const SignalRef ref{m_rows[static_cast<std::size_t>(row)].fileIndex,
const SignalRef ref{m_rows[static_cast<std::size_t>(row)].groupIndex,
m_rows[static_cast<std::size_t>(row)].signalIndex};
const bool badgeVisible = (m_tab != nullptr) && m_tab->IsVisible(ref) &&
(m_tab->LaneOfSignal(ref) >= 0);
@@ -1489,7 +1489,7 @@ namespace jdi
{
return 0.0f;
}
const std::wstring label = L"组" + std::to_wstring(m_rows[static_cast<std::size_t>(row)].fileIndex + 1);
const std::wstring label = L"组" + std::to_wstring(m_rows[static_cast<std::size_t>(row)].groupIndex + 1);
return UiMeasureTextWidth(m_fonts->uiBold, label);
}
@@ -1534,7 +1534,7 @@ namespace jdi
return false;
}
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
const SignalData* signal = m_dataSet->Resolve(SignalRef{item.fileIndex, item.signalIndex});
const SignalData* signal = m_dataSet->Resolve(SignalRef{item.groupIndex, item.signalIndex});
if (signal == nullptr)
{
return false;
@@ -1548,12 +1548,12 @@ namespace jdi
{
return false;
}
const DataFile* file = m_dataSet->FileAt(m_rows[static_cast<std::size_t>(row)].fileIndex);
if (file == nullptr)
const SignalGroup* group = m_dataSet->GroupAt(m_rows[static_cast<std::size_t>(row)].groupIndex);
if (group == nullptr)
{
return false;
}
initial = file->Title();
initial = group->Title();
break;
}
default:
@@ -1638,7 +1638,7 @@ namespace jdi
break;
}
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
m_dataSetWritable->SetSignalDisplayName(item.fileIndex, item.signalIndex, trimmed);
m_dataSetWritable->SetSignalDisplayName(item.groupIndex, item.signalIndex, trimmed);
break;
}
case StyleEditField::FILE_NAME:
@@ -1647,7 +1647,7 @@ namespace jdi
{
break;
}
m_dataSetWritable->SetFileDisplayName(m_rows[static_cast<std::size_t>(row)].fileIndex, trimmed);
m_dataSetWritable->SetGroupDisplayName(m_rows[static_cast<std::size_t>(row)].groupIndex, trimmed);
break;
}
default:
@@ -2458,12 +2458,12 @@ namespace jdi
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
if (item.kind == RowKind::FILE)
if (item.kind == RowKind::GROUP)
{
if (part == RowPart::CHECK)
{
const bool allVisible = m_tab->FileAllVisible(item.fileIndex);
m_tab->SetFileVisible(item.fileIndex, !allVisible);
const bool allVisible = m_tab->GroupAllVisible(item.groupIndex);
m_tab->SetGroupVisible(item.groupIndex, !allVisible);
m_rowsDirty = true;
return true;
}
@@ -2475,12 +2475,12 @@ namespace jdi
m_pendingCloseRow = row;
return true;
}
m_tab->ToggleCollapsed(item.fileIndex);
m_tab->ToggleCollapsed(item.groupIndex);
m_rowsDirty = true;
return true;
}
const SignalRef ref{item.fileIndex, item.signalIndex};
const SignalRef ref{item.groupIndex, item.signalIndex};
// ✕:用户明确表示不再需要该信号 → 交由宿主从数据层移除并释放其采样内存。
// 必须在 SelectSignal 之前处理,避免选中一条即将被删除的信号;
@@ -2542,12 +2542,12 @@ namespace jdi
{
if (pendingPart == RowPart::CLOSE)
{
PostMessageW(m_host, WM_APP + 1, static_cast<WPARAM>(item.fileIndex), 0);
PostMessageW(m_host, WM_APP + 1, static_cast<WPARAM>(item.groupIndex), 0);
}
else
{
// wParam = 文件下标,lParam = 文件内信号下标(见 Application 的消息处理)。
PostMessageW(m_host, WM_APP + 2, static_cast<WPARAM>(item.fileIndex),
PostMessageW(m_host, WM_APP + 2, static_cast<WPARAM>(item.groupIndex),
static_cast<LPARAM>(item.signalIndex));
}
}
@@ -2616,7 +2616,7 @@ namespace jdi
if (row >= 0 && part == RowPart::LANE_BADGE)
{
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
const SignalRef ref{item.fileIndex, item.signalIndex};
const SignalRef ref{item.groupIndex, item.signalIndex};
if (ref == m_pendingLaneRef)
{
const gd::RectF badge = SignalBadgeRect(row);
@@ -2660,11 +2660,11 @@ namespace jdi
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
// 分组行:双击折叠三角用于折叠,双击名称区域进入重命名。
if (item.kind == RowKind::FILE)
if (item.kind == RowKind::GROUP)
{
if (part == RowPart::DISCLOSURE)
{
m_tab->ToggleCollapsed(item.fileIndex);
m_tab->ToggleCollapsed(item.groupIndex);
m_rowsDirty = true;
return true;
}
@@ -2683,7 +2683,7 @@ namespace jdi
return false;
}
const SignalRef ref{item.fileIndex, item.signalIndex};
const SignalRef ref{item.groupIndex, item.signalIndex};
SelectSignal(ref);
m_rowsDirty = true;
return BeginEdit(StyleEditField::SIGNAL_NAME, row);
@@ -2704,13 +2704,13 @@ namespace jdi
}
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
if (item.kind == RowKind::FILE)
if (item.kind == RowKind::GROUP)
{
OpenFileMenu(item.fileIndex, x, y);
OpenFileMenu(item.groupIndex, x, y);
return true;
}
const SignalRef ref{item.fileIndex, item.signalIndex};
const SignalRef ref{item.groupIndex, item.signalIndex};
SelectSignal(ref);
OpenSignalMenu(ref, x, y);
return true;
@@ -2825,37 +2825,37 @@ namespace jdi
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items);
}
void SignalPanel::OpenFileMenu(int fileIndex, int x, int y)
void SignalPanel::OpenFileMenu(int groupIndex, int x, int y)
{
if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr)
{
return;
}
const DataFile* file = m_dataSet->FileAt(fileIndex);
if (file == nullptr)
const SignalGroup* group = m_dataSet->GroupAt(groupIndex);
if (group == nullptr)
{
return;
}
m_menuTargetFile = fileIndex;
m_menuTargetFile = groupIndex;
m_menuTargetSignal = SignalRef();
std::vector<MenuItem> items;
MenuItem header;
header.kind = MenuItemKind::HEADER;
header.label = file->Title();
header.label = group->Title();
items.push_back(header);
MenuItem showAll;
showAll.label = L"选中该文件全部信号";
showAll.id = CMD_FILE_SHOW_ALL;
showAll.label = L"选中该分组全部信号";
showAll.id = CMD_GROUP_SHOW_ALL;
items.push_back(showAll);
MenuItem hideAll;
hideAll.label = L"取消该文件全部信号";
hideAll.id = CMD_FILE_HIDE_ALL;
hideAll.label = L"取消该分组全部信号";
hideAll.id = CMD_GROUP_HIDE_ALL;
items.push_back(hideAll);
MenuItem separator;
@@ -2863,8 +2863,8 @@ namespace jdi
items.push_back(separator);
MenuItem remove;
remove.label = L"关闭该文件(从工作区移除)";
remove.id = CMD_FILE_REMOVE;
remove.label = L"关闭该分组(从工作区移除)";
remove.id = CMD_GROUP_REMOVE;
items.push_back(remove);
const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
@@ -3086,23 +3086,23 @@ namespace jdi
m_rowsDirty = true;
needRelayout = true;
break;
case CMD_FILE_SHOW_ALL:
case CMD_GROUP_SHOW_ALL:
if (m_menuTargetFile >= 0)
{
m_tab->SetFileVisible(m_menuTargetFile, true);
m_tab->SetGroupVisible(m_menuTargetFile, true);
m_rowsDirty = true;
needRelayout = true;
}
break;
case CMD_FILE_HIDE_ALL:
case CMD_GROUP_HIDE_ALL:
if (m_menuTargetFile >= 0)
{
m_tab->SetFileVisible(m_menuTargetFile, false);
m_tab->SetGroupVisible(m_menuTargetFile, false);
m_rowsDirty = true;
needRelayout = true;
}
break;
case CMD_FILE_REMOVE:
case CMD_GROUP_REMOVE:
if (m_menuTargetFile >= 0 && m_host != nullptr)
{
PostMessageW(m_host, WM_APP + 1, static_cast<WPARAM>(m_menuTargetFile), 0);