承接上一条提交(0871eb0)—— 该提交只完成了 Components/Shared 与注释/命名清理,并**主动未做**
大文件拆分;本提交把大文件拆分补齐,至此“按功能拆分逻辑块到各组件自己的 Inc/Src”已落地。
## 一、拆分结果
| 原文件 | 拆分后 |
| --- | --- |
| Components/PlotView/Src/PlotView.cpp(13 695 行 / 544 KB) | PlotView.cpp(787) + PlotViewPaint.cpp(6 367) + PlotViewInteraction.cpp(2 715) + PlotViewFeatures.cpp(3 005) + PlotViewInternal.hpp(936) |
| Components/SignalPanel/Src/SignalPanel.cpp(4 184 行) | SignalPanel.cpp(1 141) + SignalPanelPaint.cpp(697) + SignalPanelInteraction.cpp(2 095) + SignalPanelInternal.hpp |
| Components/Application/Src/Application.cpp(3 135 行) | Application.cpp(1 427) + ApplicationCommands.cpp(1 184) + ApplicationLoading.cpp(270) + ApplicationInternal.hpp |
| Utilities/Src/DataModel.cpp(2 615 行) | DataModel.cpp(1 583,含自由函数区) + DataModelTab.cpp(732) + DataModelInternal.hpp |
分片职责(以 PlotView 为例):
core 视图核心:构造/析构、上下文、布局、坐标映射、视界与平移、网格结构与归属
paint 渲染与像素缓存:全部绘制、场景/装饰缓存、每网格图层、宽基底与滚动补条
interaction 交互与编辑:命中测试、鼠标/键盘、光标形状、内联输入框、浮动面板层序
features 视图特性与派生数据:光标读数/提示/缩放、自适应、吸附、单点、派生曲线线程池、
边沿统计、实验性功能参数
(SignalPanel 为 core/paint/interaction;Application 为 core/commands/loading;
DataModel 为 core/tab。)
## 二、关键手法(这是能安全拆开的前提)
原文件的辅助区都是**匿名命名空间**(PlotView 约 870 行常量与工具函数),被所有成员函数广泛引用。
直接拆文件会让各 .cpp 拿不到这些工具,因此:
1. 把该块整块搬进 `<组件>Internal.hpp`,从 `namespace { }` 改为**具名**命名空间
(pvdetail / spdetail / appdetail / dmdetail);
2. 块内**每个函数与常量都标 inline** ⇒ 多个翻译单元共用同一个定义:既不重复符号,
也不产生 N 份函数定义;
3. 头部末尾 `using namespace <ns>;` ⇒ 各 .cpp 函数体里原有的非限定调用(IntersectRect /
NiceStep / ClampDouble …)**一行都不用改**;
4. 成员定义按「方法名 → 分片」归类后**整块搬移** —— 全程**纯代码搬移**,没有改动任何一行函数体。
## 三、拆分过程中踩到并修正的四个坑(已固化到 .preflight/perf/split_component.ps1)
1. `[[maybe_unused]]` 等属性**必须排在 inline 之前**(`inline [[x]] T` 非法);
单独的 `{`/`}` 行、注释行、`struct`/`enum`/`using`/`template` 行不能加 inline。
2. 匿名命名空间的**结束行可能带注释**(`} // namespace`),只认 `^\t}$` 会一路匹配到后面
某个成员函数的结束括号,把大段成员定义误当成“辅助区”搬走。
3. **首个成员定义之前的所有行**必须跟着进核心文件:DataModel.cpp 在匿名命名空间之后、
首个成员定义之前还有约 390 行命名空间外的自由函数(SignalStats / ComputeStats /
CacheSparseSamples / ApplySinglePointDefaultStyle …),漏掉会被静默丢弃。
4. 内部头必须带上原文件真正用到的全部头:DataModel 需要 Theme.hpp / <cstring> / <cstdio> /
<deque> / <atomic> / <cwctype> / <new>(原文件靠这些头才编译得过)。
## 四、附带完成的命名审计
PlotView 中残留的 File 语义命名一并修正:LaneSpansMultipleFiles → LaneSpansMultipleGroups、
firstFile → firstGroup、multiFile → multiGroup、SceneCacheKey::haloFile → haloGroup。
## 五、验证(全部离屏:SW_HIDE + 不注入真实输入 + 进程/线程 BELOW_NORMAL)
- .preflight/perf/verify.exe(PlotView 渲染/交互,含逐像素断言)49/49
- .preflight/perf/panelanim.exe(信号面板:卷尺式折起/展开、筛选态、悬浮组名、目标行)156/156
- .preflight/perf/datacheck.exe(数据层:单轴/多时间轴 CSV、MF4、重命名语义)69/69
- .preflight/perf/edgecheck.exe(边沿统计与面板排版)46/46
- make release / make debug 零警告。
即:拆分后**行为与拆分前完全一致**(纯代码搬移,四套探针全绿)。
## 六、体积(诚实记录)
Target.exe: 754 176 → 764 416 B(+10 240 B,+1.4%)
因为 inline 工具函数会在每个引用它的翻译单元里各生成一份(PE/COFF 链接器没有 ICF 合并)。
这再次印证:拆分解决的是**可维护性**,不是体积(本轮的 −24.6% 全部来自 ICO 256×256 条目改 PNG)。
## 七、文档
Documents/agent_mem.md:§9.37 新增“七、多文件拆分(已完成)”与“八、仍未完成”(只剩
Lane → LanePanelState、模态提示解耦、MF4 压缩块三项);§11.2 新增第 91、92 条(多文件化的
唯一安全手法 + 体积代价)。Documents/refactor_report_2026-10-16.md 同步更新 §五/§七/§八。
1185 lines
37 KiB
C++
1185 lines
37 KiB
C++
// Application 实现分片 —— 本文件只包含 Application 的**成员函数定义**(纯代码搬移,行为与拆分前完全一致)。
|
||
//
|
||
// 共用的常量与自由函数在 ApplicationInternal.hpp(命名空间 `appdetail`,全部 inline)。
|
||
// 分片按“职责”划分:核心(状态/布局/几何)、渲染、交互与编辑,见各文件名。
|
||
#include "ApplicationInternal.hpp"
|
||
|
||
namespace jdi
|
||
{
|
||
|
||
void Application::NewTab(bool collapseGroups)
|
||
{
|
||
TabConfig tab;
|
||
tab.SetName(NextTabName());
|
||
// 用户新建的标签页:信号分组**默认折起**(用户明确要求:列表默认紧凑;
|
||
// 之后在该标签页里新加载的分组也沿用这个默认值)。
|
||
tab.SetGroupsCollapsedByDefault(collapseGroups);
|
||
tab.SyncToDataSet(m_dataSet);
|
||
if (collapseGroups)
|
||
{
|
||
tab.SetAllCollapsed(true);
|
||
}
|
||
|
||
m_tabs.push_back(std::move(tab));
|
||
SetActiveTab(static_cast<int>(m_tabs.size()) - 1);
|
||
}
|
||
|
||
void Application::CloseTab(int index)
|
||
{
|
||
if (index < 0 || index >= static_cast<int>(m_tabs.size()))
|
||
{
|
||
return;
|
||
}
|
||
if (m_tabs.size() <= 1)
|
||
{
|
||
SetStatusMessage(L"至少需要保留一个标签页。", true);
|
||
return;
|
||
}
|
||
|
||
m_tabs.erase(m_tabs.begin() + index);
|
||
|
||
int next = index;
|
||
if (next >= static_cast<int>(m_tabs.size()))
|
||
{
|
||
next = static_cast<int>(m_tabs.size()) - 1;
|
||
}
|
||
SetActiveTab(next);
|
||
}
|
||
|
||
void Application::SetActiveTab(int index)
|
||
{
|
||
if (index < 0 || index >= static_cast<int>(m_tabs.size()))
|
||
{
|
||
return;
|
||
}
|
||
|
||
m_activeTab = index;
|
||
|
||
// 重新绑定上下文:面板与绘图区均指向同一数据层与当前标签页配置。
|
||
m_panel.SetContext(&m_dataSet, &m_tabs[static_cast<std::size_t>(m_activeTab)]);
|
||
m_plot.SetContext(&m_dataSet, &m_tabs[static_cast<std::size_t>(m_activeTab)]);
|
||
|
||
RefreshTabBar();
|
||
Relayout();
|
||
|
||
// 切换标签页后把该页已选中的信号滚动到可见位置。
|
||
// 切换标签页后把共享的选中信号滚动到可见位置(选中状态跨页保持一致)。
|
||
m_panel.ScrollToSignal(m_dataSet.Selected());
|
||
|
||
SynchronizeView(true);
|
||
UpdateWindowTitle();
|
||
InvalidateRect(m_hwnd, nullptr, TRUE);
|
||
}
|
||
|
||
void Application::RefreshTabBar()
|
||
{
|
||
std::vector<std::wstring> names;
|
||
names.reserve(m_tabs.size());
|
||
for (const TabConfig& tab : m_tabs)
|
||
{
|
||
names.push_back(tab.Name());
|
||
}
|
||
m_tabBar.SetTabs(names);
|
||
m_tabBar.SetActiveIndex(m_activeTab);
|
||
}
|
||
|
||
void Application::SynchronizeView(bool force)
|
||
{
|
||
if (m_tabs.empty() || m_activeTab < 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
TabConfig& tab = m_tabs[static_cast<std::size_t>(m_activeTab)];
|
||
|
||
const int visibleCount = tab.VisibleCount();
|
||
const bool anyVisible = tab.AnyVisible();
|
||
const std::size_t laneCount = tab.Lanes().size();
|
||
|
||
const bool changed = (visibleCount != m_cachedVisibleCount) || (anyVisible != m_cachedAnyVisible) ||
|
||
(laneCount != m_cachedLaneCount);
|
||
if (!changed && !force)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const std::size_t previousLaneCount = m_cachedLaneCount;
|
||
m_cachedVisibleCount = visibleCount;
|
||
m_cachedAnyVisible = anyVisible;
|
||
m_cachedLaneCount = laneCount;
|
||
|
||
// 标签页首次出现可见信号时,把时间轴对齐到可见数据的范围。
|
||
// 没有任何可见信号时(未勾选 / 全部取消勾选 / 清空网格)主动复位自适应标记,
|
||
// 这样用户下一次勾选信号时会重新做一次自适应,而不是停留在上次的视图范围。
|
||
if (!anyVisible)
|
||
{
|
||
tab.ViewInitialized() = false;
|
||
}
|
||
else if (!tab.ViewInitialized())
|
||
{
|
||
// 首次出现可见信号时做一次自适应。单点信号(时间/幅值范围退化成 0)由
|
||
// `FitAll` 内部按"单值散点"的规则处理(居中 + 一大格 1s),这里不再分开判断。
|
||
m_plot.FitAll();
|
||
}
|
||
|
||
const bool laneCountChanged = (laneCount != previousLaneCount);
|
||
if (laneCountChanged || force)
|
||
{
|
||
// 网格数量变化会影响滚动条与绘图区宽度,必须重新布局。
|
||
Relayout();
|
||
}
|
||
else
|
||
{
|
||
m_plot.RefreshLanes();
|
||
}
|
||
}
|
||
|
||
void Application::SyncTabsToDataSet()
|
||
{
|
||
for (TabConfig& tab : m_tabs)
|
||
{
|
||
tab.SyncToDataSet(m_dataSet);
|
||
}
|
||
|
||
// 选中状态由数据层共享维护,数据变动后需校验其是否仍指向存在的信号。
|
||
m_dataSet.ValidateSelection();
|
||
m_panel.InvalidateRows();
|
||
|
||
// 分组/信号结构变化会让信号引用与分组地址改变,派生缓存必须整体作废。
|
||
m_plot.InvalidateDerivedCaches();
|
||
|
||
// 强制下一次同步重新计算视图状态。
|
||
m_cachedVisibleCount = -1;
|
||
m_cachedAnyVisible = false;
|
||
m_cachedLaneCount = 0;
|
||
|
||
if (m_hwnd != nullptr)
|
||
{
|
||
InvalidateRect(m_hwnd, nullptr, TRUE);
|
||
}
|
||
}
|
||
|
||
void Application::RemoveGroup(int groupIndex)
|
||
{
|
||
const SignalGroup* group = m_dataSet.GroupAt(groupIndex);
|
||
if (group == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
const std::wstring name = group->name;
|
||
|
||
// 移除分组会释放大量采样内存:先暂停后台派生曲线计算,避免其读取已释放内存。
|
||
m_plot.PauseDerivedAsync();
|
||
// 先按旧下标修正各标签页配置与共享选中项,再从数据层移除,最后统一对齐。
|
||
for (TabConfig& tab : m_tabs)
|
||
{
|
||
tab.RemapAfterGroupRemoval(groupIndex);
|
||
}
|
||
m_dataSet.RemapSelectionAfterGroupRemoval(groupIndex);
|
||
m_dataSet.RemoveGroup(groupIndex);
|
||
SyncTabsToDataSet();
|
||
|
||
SetStatusMessage(L"已从工作区移除:" + name, false);
|
||
UpdateWindowTitle();
|
||
}
|
||
|
||
void Application::RemoveSignal(int groupIndex, int signalIndex)
|
||
{
|
||
SignalRef target;
|
||
target.groupIndex = groupIndex;
|
||
target.signalIndex = signalIndex;
|
||
|
||
const SignalData* signal = m_dataSet.Resolve(target);
|
||
const SignalGroup* group = m_dataSet.GroupAt(groupIndex);
|
||
if (signal == nullptr || group == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
const std::wstring name = signal->name;
|
||
|
||
if (group->signals.size() <= 1)
|
||
{
|
||
// 组内只剩这一条信号:直接移除整个分组,避免在列表里留下一个空分组。
|
||
RemoveGroup(groupIndex);
|
||
return;
|
||
}
|
||
|
||
// 移除信号会释放采样内存:先暂停后台派生曲线计算,避免其读取已释放内存。
|
||
m_plot.PauseDerivedAsync();
|
||
// 先按旧下标修正各标签页配置与共享选中项,再从数据层移除(释放采样内存),最后对齐。
|
||
for (TabConfig& tab : m_tabs)
|
||
{
|
||
tab.RemapAfterSignalRemoval(groupIndex, signalIndex);
|
||
}
|
||
m_dataSet.RemapSelectionAfterSignalRemoval(groupIndex, signalIndex);
|
||
m_dataSet.RemoveSignal(groupIndex, signalIndex);
|
||
SyncTabsToDataSet();
|
||
|
||
SetStatusMessage(L"已从工作区移除信号:" + name, false);
|
||
}
|
||
|
||
void Application::UpdateWindowTitle()
|
||
{
|
||
// 标题栏 = 应用名称 + 分隔竖线 + 内存占用(单位在 KB/MB/GB 间自适应,保留两位小数)。
|
||
// 除内存占用外不再附带标签页、文件数量等无关信息。
|
||
// ⚠️ 内存口径 = **私有工作集**(与任务管理器"内存"列一致),不是总工作集。
|
||
std::wstring title = kWindowTitle;
|
||
title += kTitleSeparator;
|
||
title += kRamTitlePrefix;
|
||
title += FormatByteSize(ProcessPrivateWorkingSetBytes());
|
||
// 数值未变化时不调用 SetWindowTextW,避免无谓地重绘标题栏。
|
||
if (title == m_windowTitleText)
|
||
{
|
||
return;
|
||
}
|
||
m_windowTitleText = title;
|
||
SetWindowTextW(m_hwnd, m_windowTitleText.c_str());
|
||
}
|
||
|
||
void Application::OpenFileDialog()
|
||
{
|
||
// 允许一次选择多个文件,便于跨文件对比信号。
|
||
std::vector<wchar_t> buffer(static_cast<std::size_t>(MAX_PATH) * 16, L'\0');
|
||
|
||
OPENFILENAMEW dialog = {};
|
||
dialog.lStructSize = sizeof(dialog);
|
||
dialog.hwndOwner = m_hwnd;
|
||
dialog.lpstrFilter = SignalFileDialogFilter();
|
||
dialog.lpstrFile = buffer.data();
|
||
dialog.nMaxFile = static_cast<DWORD>(buffer.size());
|
||
dialog.lpstrTitle = L"打开测量数据文件(可多选)";
|
||
dialog.lpstrDefExt = L"csv";
|
||
dialog.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_EXPLORER | OFN_NOCHANGEDIR |
|
||
OFN_ALLOWMULTISELECT;
|
||
|
||
if (GetOpenFileNameW(&dialog) == FALSE)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// 多选时首段为目录,其余为文件名;单选时整段即为完整路径。
|
||
const std::wstring first(buffer.data());
|
||
const wchar_t* cursor = buffer.data() + first.size() + 1;
|
||
|
||
int loaded = 0;
|
||
if (*cursor == L'\0')
|
||
{
|
||
if (LoadFile(first))
|
||
{
|
||
++loaded;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
while (*cursor != L'\0')
|
||
{
|
||
const std::wstring name(cursor);
|
||
if (LoadFile(first + L"\\" + name))
|
||
{
|
||
++loaded;
|
||
}
|
||
cursor += name.size() + 1;
|
||
}
|
||
}
|
||
|
||
if (loaded > 0)
|
||
{
|
||
SetStatusMessage(L"已加载 " + std::to_wstring(loaded) +
|
||
L" 个文件,请在左侧勾选需要查看的信号。",
|
||
false);
|
||
}
|
||
}
|
||
|
||
void Application::ExecuteToolbarAction(ToolbarAction action)
|
||
{
|
||
switch (action)
|
||
{
|
||
case ToolbarAction::OPEN_FILE:
|
||
OpenFileDialog();
|
||
break;
|
||
case ToolbarAction::FIT_ALL:
|
||
// 自适应同时回到初始状态:退出缩放模式并显示全部信号范围。
|
||
m_plot.FitAll();
|
||
SetStatusMessage(L"已恢复自适应视图。", false);
|
||
break;
|
||
case ToolbarAction::AUTO_FIT_Y:
|
||
{
|
||
// 与网格右键菜单中的「选区 Y 轴自适应(全局)」同一开关:
|
||
// 打开后立即按当前视图自适应一次,之后每次时间轴选区放大都会自动匹配。
|
||
const bool enable = !m_plot.AllLanesAutoFitToSelection();
|
||
m_plot.SetAllLanesAutoFitToSelection(enable, true);
|
||
SetStatusMessage(enable ? L"已开启选区 Y 轴自适应:时间轴选区放大后自动匹配各网格 Y 轴。"
|
||
: L"已关闭选区 Y 轴自适应。",
|
||
false);
|
||
break;
|
||
}
|
||
case ToolbarAction::ZOOM_SELECT:
|
||
m_plot.ToggleZoomMode(PlotZoomMode::SELECT);
|
||
break;
|
||
case ToolbarAction::CURSOR:
|
||
m_plot.CycleCursorMode();
|
||
break;
|
||
case ToolbarAction::MERGE_LANES:
|
||
if (!m_dataSet.Empty())
|
||
{
|
||
ActiveTab().SetMode(LaneMode::MERGED);
|
||
SetStatusMessage(L"已将当前标签页的全部可见信号合并到同一网格。", false);
|
||
}
|
||
break;
|
||
case ToolbarAction::SPLIT_LANES:
|
||
if (!m_dataSet.Empty())
|
||
{
|
||
ActiveTab().SetMode(LaneMode::PER_SIGNAL);
|
||
SetStatusMessage(L"当前标签页已切换为「每信号一格」。", false);
|
||
}
|
||
break;
|
||
case ToolbarAction::CLEAR_ALL:
|
||
if (!m_dataSet.Empty())
|
||
{
|
||
ActiveTab().SetAllVisible(false);
|
||
m_panel.InvalidateRows();
|
||
SetStatusMessage(L"已清空当前标签页的全部网格。", false);
|
||
}
|
||
break;
|
||
case ToolbarAction::NONE:
|
||
default:
|
||
break;
|
||
}
|
||
|
||
if (m_hwnd != nullptr)
|
||
{
|
||
InvalidateRect(m_hwnd, nullptr, TRUE);
|
||
}
|
||
}
|
||
|
||
bool Application::HandleKeyDown(WPARAM key)
|
||
{
|
||
const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0;
|
||
|
||
if (control && key == L'O')
|
||
{
|
||
OpenFileDialog();
|
||
return true;
|
||
}
|
||
if (control && key == L'T')
|
||
{
|
||
NewTab(true);
|
||
return true;
|
||
}
|
||
if (control && key == L'W')
|
||
{
|
||
CloseTab(m_activeTab);
|
||
return true;
|
||
}
|
||
if (control && key == VK_TAB)
|
||
{
|
||
if (m_tabs.size() > 1)
|
||
{
|
||
const bool backward = (GetKeyState(VK_SHIFT) & 0x8000) != 0;
|
||
const int count = static_cast<int>(m_tabs.size());
|
||
SetActiveTab((m_activeTab + (backward ? count - 1 : 1)) % count);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
switch (key)
|
||
{
|
||
case VK_ESCAPE:
|
||
{
|
||
// Esc 的语义:
|
||
// 1) 放弃当前正在进行的拖拽(典型场景:缩放的框选选区还没松开左键);
|
||
// 取消后不应用选区、视图范围保持不变;
|
||
// 2) 同时退出全部缩放模式,使工具栏「时间轴选区」「Y 轴选区」回到未选中态。
|
||
// Esc 不与时间光标关联:关闭光标请用工具栏「光标」按钮,避免误关指针。
|
||
bool changed = m_plot.CancelDrag();
|
||
changed = m_plot.ExitZoomModes() || changed;
|
||
return changed;
|
||
}
|
||
case VK_ADD:
|
||
case VK_OEM_PLUS:
|
||
m_plot.ZoomTimeStep(1.4);
|
||
return true;
|
||
case VK_SUBTRACT:
|
||
case VK_OEM_MINUS:
|
||
m_plot.ZoomTimeStep(1.0 / 1.4);
|
||
return true;
|
||
case VK_LEFT:
|
||
m_plot.PanTimeByFraction(-0.12);
|
||
return true;
|
||
case VK_RIGHT:
|
||
m_plot.PanTimeByFraction(0.12);
|
||
return true;
|
||
case VK_UP:
|
||
m_plot.ScrollLanesByFraction(-0.12);
|
||
return true;
|
||
case VK_DOWN:
|
||
m_plot.ScrollLanesByFraction(0.12);
|
||
return true;
|
||
default:
|
||
break;
|
||
}
|
||
|
||
if (key == L'F')
|
||
{
|
||
m_plot.FitAll();
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
void Application::UpdateDragCursor(int x, int y)
|
||
{
|
||
// 鼠标捕获期间不再有 WM_SETCURSOR,只能自己按当前状态设置光标。
|
||
HCURSOR cursor = nullptr;
|
||
if (m_activeTarget == Target::PLOT)
|
||
{
|
||
cursor = m_plot.QueryCursor(x, y);
|
||
}
|
||
SetCursor(cursor != nullptr ? cursor : LoadCursorW(nullptr, IDC_ARROW));
|
||
}
|
||
|
||
bool Application::HandleMouseMove(int x, int y, bool buttonDown)
|
||
{
|
||
if (buttonDown && m_activeTarget != Target::NONE)
|
||
{
|
||
switch (m_activeTarget)
|
||
{
|
||
case Target::SPLITTER:
|
||
if (m_draggingSplitter && m_scale > 0.0f)
|
||
{
|
||
const float desired = static_cast<float>(x - m_splitterGrabOffset) / m_scale;
|
||
const float maximum =
|
||
std::min(kMaxPanelWidth, static_cast<float>(kMinClientWidth) / m_scale);
|
||
m_panelWidth = std::max(kMinPanelWidth, std::min(desired, maximum));
|
||
Relayout();
|
||
return true;
|
||
}
|
||
return false;
|
||
case Target::PLOT:
|
||
return m_plot.OnMouseMove(x, y);
|
||
case Target::PANEL:
|
||
return m_panel.OnMouseMove(x, y);
|
||
case Target::TOOLBAR:
|
||
return m_toolbar.OnMouseMove(x, y);
|
||
case Target::TAB_BAR:
|
||
return m_tabBar.OnMouseMove(x, y);
|
||
case Target::EDIT_FIELD:
|
||
{
|
||
// 输入框拖拽框选:按当前正在编辑的组件分发。
|
||
bool changed = m_tabBar.OnEditMouseMove(x);
|
||
changed = m_panel.OnEditMouseMove(x) || changed;
|
||
changed = m_plot.OnEditMouseMove(x) || changed;
|
||
changed = m_colorPicker.OnEditMouseMove(x) || changed;
|
||
return changed;
|
||
}
|
||
case Target::NONE:
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
bool changed = false;
|
||
changed =
|
||
(m_tabBar.HitTest(x, y) ? m_tabBar.OnMouseMove(x, y) : m_tabBar.OnMouseLeave()) || changed;
|
||
// ⚠️ 按住工具栏按钮期间**绝不能**走 `OnMouseLeave()`:它会清掉按下态(`m_pressedIndex`),
|
||
// 于是随后在按钮上松手时什么也不会发生 —— 这正是“快速点击工具栏按钮时状态没切换”
|
||
// 的另一个成因(鼠标带着按下态从按钮边缘移出时会命中这里)。
|
||
if (m_toolbar.HitTest(x, y))
|
||
{
|
||
changed = m_toolbar.OnMouseMove(x, y) || changed;
|
||
}
|
||
else if (m_activeTarget != Target::TOOLBAR)
|
||
{
|
||
changed = m_toolbar.OnMouseLeave() || changed;
|
||
}
|
||
changed = (m_panel.HitTest(x, y) ? m_panel.OnMouseMove(x, y) : m_panel.OnMouseLeave()) || changed;
|
||
changed = (m_plot.HitTest(x, y) ? m_plot.OnMouseMove(x, y) : m_plot.OnMouseLeave()) || changed;
|
||
return changed;
|
||
}
|
||
|
||
void Application::OnTabBarResult(const TabBarResult& result)
|
||
{
|
||
switch (result.type)
|
||
{
|
||
case TabBarResult::Type::ADD:
|
||
NewTab(true);
|
||
break;
|
||
case TabBarResult::Type::SELECT:
|
||
if (result.index != m_activeTab)
|
||
{
|
||
SetActiveTab(result.index);
|
||
}
|
||
break;
|
||
case TabBarResult::Type::CLOSE:
|
||
CloseTab(result.index);
|
||
break;
|
||
case TabBarResult::Type::CONTEXT:
|
||
ShowTabContextMenu(result);
|
||
break;
|
||
case TabBarResult::Type::REORDER:
|
||
MoveTab(result.index, result.targetIndex);
|
||
break;
|
||
case TabBarResult::Type::RENAME:
|
||
if (result.index >= 0 && result.index < static_cast<int>(m_tabs.size()) && !result.text.empty())
|
||
{
|
||
m_tabs[static_cast<std::size_t>(result.index)].SetName(result.text);
|
||
RefreshTabBar();
|
||
// SetTabs 会重置各标签的矩形,必须重新布局才能把它们算回来。
|
||
Relayout();
|
||
InvalidateRect(m_hwnd, nullptr, TRUE);
|
||
}
|
||
break;
|
||
case TabBarResult::Type::NONE:
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
void Application::MoveTab(int from, int target)
|
||
{
|
||
if (from < 0 || from >= static_cast<int>(m_tabs.size()))
|
||
{
|
||
return;
|
||
}
|
||
if (target < 0 || target > static_cast<int>(m_tabs.size()))
|
||
{
|
||
return;
|
||
}
|
||
|
||
// 拖拽语义:target 表示「插入到该下标之前」;向后拖时需扣除自身占位。
|
||
int destination = target;
|
||
if (destination > from)
|
||
{
|
||
--destination;
|
||
}
|
||
destination = std::max(0, std::min(destination, static_cast<int>(m_tabs.size()) - 1));
|
||
if (destination == from)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// 保持当前激活的标签页在重排后仍处于激活状态。
|
||
TabConfig moved = m_tabs[static_cast<std::size_t>(from)];
|
||
const int activeIndex = m_activeTab;
|
||
|
||
m_tabs.erase(m_tabs.begin() + from);
|
||
m_tabs.insert(m_tabs.begin() + destination, std::move(moved));
|
||
|
||
int newActive = activeIndex;
|
||
if (activeIndex == from)
|
||
{
|
||
newActive = destination;
|
||
}
|
||
else if (from < activeIndex && destination >= activeIndex)
|
||
{
|
||
--newActive;
|
||
}
|
||
else if (from > activeIndex && destination <= activeIndex)
|
||
{
|
||
++newActive;
|
||
}
|
||
|
||
SetActiveTab(newActive);
|
||
InvalidateRect(m_hwnd, nullptr, TRUE);
|
||
}
|
||
|
||
bool Application::ApplyPendingRename()
|
||
{
|
||
SignalRef ref;
|
||
std::wstring name;
|
||
if (!m_plot.TakePendingRename(ref, name))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (!m_dataSet.RenameSignal(ref.groupIndex, ref.signalIndex, name))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// 改名只影响显示,但需要刷新左侧列表与绘图区图例。
|
||
m_panel.InvalidateRows();
|
||
SetStatusMessage(L"信号已重命名为:" + name, false);
|
||
InvalidateRect(m_hwnd, nullptr, TRUE);
|
||
return true;
|
||
}
|
||
|
||
bool Application::BuildDerivedSignal(const DerivedSignalRequest& request, bool makeVisible)
|
||
{
|
||
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 = group->signals[static_cast<std::size_t>(request.signalIndex)];
|
||
const TimeAxis& axis = group->AxisOf(source);
|
||
const std::wstring baseName = source.name;
|
||
|
||
// 0. 先把源信号的「调整」变换快照下来(下面 `InsertDerivedSignal` 会移动信号存储,
|
||
// 之后再取 `source` 会悬垂)。
|
||
//
|
||
// ⚠️ **变换要"烘进数据",而不是让新信号继承这套指纹**(用户明确要求):
|
||
// 网格里的预览曲线画的是 `变换(滤波结果)`。若新信号只存 `滤波结果` 再**继承变换**,
|
||
// 它的「调整」面板里就会莫名其妙带着一整套系数 / 镜像 / 偏移 —— 用户看到的是
|
||
// "新信号只是继承了原信号的指纹,数据本身没变"。现在改为:
|
||
// * 数值域变换(系数 → 绝对值 → 垂直镜像 → Y 偏移)**与**水平镜像(数据反序)
|
||
// 全部作用在派生采样值上;
|
||
// * 于是新信号**用默认外观**绘制出来就与预览曲线逐位一致。
|
||
// * ⚠️ **时间轴偏移也一并烘进数据**(见 2c:按 `time[i] - offsetTime` 重采样)。
|
||
// 新信号与源信号共用同一个文件时间轴,因此时间平移只能写进采样值 —— 这样
|
||
// 新信号**不启用任何「调整」**就与预览逐点重合(用户明确要求)。
|
||
// 只有"无法烘值"的退化情形才退回把 `offsetTime` 带在样式上(见 3b)。
|
||
SignalStyle sourceTransform;
|
||
bool hasSourceStyle = false;
|
||
{
|
||
SignalRef sourceRef;
|
||
sourceRef.groupIndex = request.groupIndex;
|
||
sourceRef.signalIndex = request.signalIndex;
|
||
if (const SignalStyle* sourceStyle = m_dataSet.Style(sourceRef))
|
||
{
|
||
sourceTransform = *sourceStyle;
|
||
hasSourceStyle = true;
|
||
}
|
||
}
|
||
|
||
// 1. 计算派生采样值(与网格内的预览曲线共用同一实现,因此逐位一致)。
|
||
// 多次迭代均值滤波形如「<原信号名称> (N点均值 M次迭代)」,
|
||
// 弱峰调整形如「<原信号名称> (弱峰调整 M% N次迭代)」。
|
||
std::vector<float> derived_values;
|
||
SignalData derived;
|
||
if (request.weakPeak)
|
||
{
|
||
ComputeWeakPeakAdjust(source.values, static_cast<double>(request.amountPercent) / 100.0,
|
||
request.iterations, derived_values);
|
||
derived.name = baseName + L" (弱峰调整 " + std::to_wstring(request.amountPercent) + L"% " +
|
||
std::to_wstring(request.iterations) + L"次迭代)";
|
||
}
|
||
else
|
||
{
|
||
ComputeMovingAverageIterated(source.values, request.points, request.iterations, derived_values);
|
||
derived.name = baseName + L" (" + std::to_wstring(request.points) + L"点均值 " +
|
||
std::to_wstring(request.iterations) + L"次迭代)";
|
||
}
|
||
|
||
// 2. 组装新信号(外观一律用默认值:变换已经烘进采样数据,不再是"继承的指纹")。
|
||
// ⚠️ 新信号**沿用源信号的时间轴**(`axisIndex`):派生只改采样值,不改时间基准。
|
||
derived.axisIndex = source.axisIndex;
|
||
derived.values.assign(derived_values.begin(), derived_values.end());
|
||
|
||
// 2b. 把源信号的「调整」变换**烘进派生采样值**:
|
||
// * 数值域(系数 → 绝对值 → 垂直镜像 → Y 偏移)逐点套用 `ApplyDrawTransform`;
|
||
// * 水平镜像 = 数据反序,等价于把采样数组整体反转。
|
||
// ⚠️ `ApplyDrawTransform` 是逐点函数,因此"先变换再反转"与"先反转再变换"等价。
|
||
if (hasSourceStyle)
|
||
{
|
||
const bool valueTransform =
|
||
(sourceTransform.gain != 1.0f || sourceTransform.absolute ||
|
||
sourceTransform.mirrorVertical || sourceTransform.offsetY != 0.0f);
|
||
if (valueTransform)
|
||
{
|
||
for (double& value : derived.values)
|
||
{
|
||
value = ApplyDrawTransform(sourceTransform, value);
|
||
}
|
||
}
|
||
if (sourceTransform.mirrorHorizontal)
|
||
{
|
||
std::reverse(derived.values.begin(), derived.values.end());
|
||
}
|
||
}
|
||
|
||
// 2c. 时间轴偏移:把 `offsetTime` 也**重采样烘进采样值**。
|
||
//
|
||
// 预览曲线是按「源信号样式」绘制的,它的时间轴偏移会把曲线整体横向平移。
|
||
// 派生信号与源信号**共用同一个文件时间轴**,所以时间平移本无法写进"时间列";
|
||
// 但用户明确要求:**新信号不启用任何「调整」也要与预览逐点重合**
|
||
//(新信号的「调整」面板必须是默认值,不允许靠"继承 offsetTime"来表达)。
|
||
// 因此这里把平移直接写进数据:
|
||
// * 新信号第 i 个采样 = 预览曲线在 `time[i]` 时刻显示的值
|
||
// = 已变换的曲线在 `time[i] - offsetTime` 处的取值(取最近的采样点);
|
||
// * 平移后落在数据范围之外的采样置 NaN —— 与预览"该处没有曲线"完全一致。
|
||
// ⚠️ 一次整段重采样需要一份等长的临时缓冲(千万级采样 ≈ 数十 MB),用完立即释放;
|
||
// 这是"构建为新信号"的一次性操作,不在交互路径上。
|
||
// ⚠️ 时间轴非单调时无法按下标单调推进取样(只能整表扫描),此时退回"继承
|
||
// `offsetTime`"(见 3b):两条路径的**显示效果相同**,只是偏移留在样式里。
|
||
bool timeShiftBaked = false;
|
||
if (hasSourceStyle && sourceTransform.offsetTime != 0.0 && axis.monotonic)
|
||
{
|
||
const std::vector<double>& times = axis.time;
|
||
const std::size_t total = derived.values.size();
|
||
if (total > 1 && times.size() >= total)
|
||
{
|
||
const double shift = sourceTransform.offsetTime;
|
||
std::vector<double> shifted(total, std::numeric_limits<double>::quiet_NaN());
|
||
// 目标时刻 `times[index] - shift` 随 index 单调不减 → 游标只前进,整体 O(n)。
|
||
std::size_t probe = 0;
|
||
for (std::size_t index = 0; index < total; ++index)
|
||
{
|
||
const double target = times[index] - shift;
|
||
if (!std::isfinite(target) || target < times[0] || target > times[total - 1])
|
||
{
|
||
continue; // 平移后越界:该处没有曲线
|
||
}
|
||
while (probe + 1 < total && times[probe + 1] < target)
|
||
{
|
||
++probe;
|
||
}
|
||
std::size_t nearest = probe;
|
||
if (probe + 1 < total &&
|
||
std::fabs(times[probe + 1] - target) < std::fabs(times[probe] - target))
|
||
{
|
||
nearest = probe + 1;
|
||
}
|
||
shifted[index] = derived.values[nearest];
|
||
}
|
||
derived.values.swap(shifted);
|
||
timeShiftBaked = true;
|
||
}
|
||
}
|
||
|
||
// 3. 插入到原信号的下方(数据层 + 各标签页引用重映射)。
|
||
int newIndex = -1;
|
||
// 插入派生信号会移动该分组的信号存储:先暂停后台计算,避免其读取已移动的数据。
|
||
m_plot.PauseDerivedAsync();
|
||
if (!m_dataSet.InsertDerivedSignal(request.groupIndex, request.signalIndex, std::move(derived),
|
||
newIndex))
|
||
{
|
||
return false;
|
||
}
|
||
for (TabConfig& tab : m_tabs)
|
||
{
|
||
tab.RemapAfterSignalInsert(request.groupIndex, newIndex);
|
||
}
|
||
|
||
// 3b. 时间轴偏移正常情况下已烘进采样值(见 2c),新信号的「调整」全部保持默认。
|
||
// ⚠️ 只有"无法烘值"的退化情形(时间轴非单调 / 时间列缺失)才退回到把
|
||
// `offsetTime` 带在新信号样式上 —— 无论如何都要让**显示**与预览一致。
|
||
if (hasSourceStyle && sourceTransform.offsetTime != 0.0 && !timeShiftBaked)
|
||
{
|
||
if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{request.groupIndex, newIndex}))
|
||
{
|
||
derivedStyle->offsetTime = sourceTransform.offsetTime;
|
||
}
|
||
}
|
||
|
||
// 4. 在当前标签页中勾选新信号,并把它所在的网格移到原信号网格的下方。
|
||
if (makeVisible && !m_tabs.empty())
|
||
{
|
||
SignalRef derivedRef;
|
||
derivedRef.groupIndex = request.groupIndex;
|
||
derivedRef.signalIndex = newIndex;
|
||
ActiveTab().SetVisible(derivedRef, true);
|
||
|
||
SignalRef sourceRef;
|
||
sourceRef.groupIndex = request.groupIndex;
|
||
sourceRef.signalIndex = request.signalIndex;
|
||
ActiveTab().MoveLaneOfSignalBelow(derivedRef, sourceRef);
|
||
}
|
||
|
||
// 5. 数据层结构变化:刷新列表、作废折线/派生缓存并重绘。
|
||
SyncTabsToDataSet();
|
||
return true;
|
||
}
|
||
|
||
bool Application::ApplyPendingPanelScroll()
|
||
{
|
||
SignalRef ref;
|
||
if (!m_plot.TakePanelScrollRequest(ref) || !ref.IsValid())
|
||
{
|
||
return false;
|
||
}
|
||
// 平滑滚动(带动画):用户要求"是滚动的而不是立刻翻到目标信号位置"。
|
||
m_panel.ScrollToSignalAnimated(ref);
|
||
return true;
|
||
}
|
||
|
||
bool Application::ApplyPendingDerivedBuild()
|
||
{
|
||
// 两类「构建为新信号」请求(均值滤波 / 弱峰调整)机制完全一致,
|
||
// 统一整理成同一份构建清单后按下标降序处理。
|
||
std::vector<DerivedSignalRequest> requests;
|
||
{
|
||
int laneIndex = -1;
|
||
std::vector<PlotView::MeanFilterBuild> items;
|
||
if (m_plot.TakePendingMeanFilterBuild(laneIndex, items))
|
||
{
|
||
for (const PlotView::MeanFilterBuild& item : items)
|
||
{
|
||
DerivedSignalRequest request;
|
||
request.groupIndex = item.groupIndex;
|
||
request.signalIndex = item.signalIndex;
|
||
request.points = item.points;
|
||
request.iterations = item.iterations;
|
||
request.weakPeak = false;
|
||
requests.push_back(request);
|
||
}
|
||
}
|
||
}
|
||
{
|
||
int laneIndex = -1;
|
||
std::vector<PlotView::WeakPeakBuild> items;
|
||
if (m_plot.TakePendingWeakPeakBuild(laneIndex, items))
|
||
{
|
||
for (const PlotView::WeakPeakBuild& item : items)
|
||
{
|
||
DerivedSignalRequest request;
|
||
request.groupIndex = item.groupIndex;
|
||
request.signalIndex = item.signalIndex;
|
||
request.amountPercent = item.amountPercent;
|
||
request.iterations = item.iterations;
|
||
request.weakPeak = true;
|
||
requests.push_back(request);
|
||
}
|
||
}
|
||
}
|
||
if (requests.empty())
|
||
{
|
||
return false;
|
||
}
|
||
if (m_tabs.empty())
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// **同一文件内必须按下标降序处理**:每次插入只让插入点之后的信号下标 +1,
|
||
// 降序处理可保证尚未处理的信号下标始终有效(升序会把新插入的信号当成原信号)。
|
||
std::sort(requests.begin(), requests.end(),
|
||
[](const DerivedSignalRequest& a, const DerivedSignalRequest& b)
|
||
{
|
||
if (a.groupIndex != b.groupIndex)
|
||
{
|
||
return a.groupIndex < b.groupIndex;
|
||
}
|
||
return a.signalIndex > b.signalIndex;
|
||
});
|
||
|
||
int built = 0;
|
||
DerivedSignalRequest last;
|
||
for (const DerivedSignalRequest& request : requests)
|
||
{
|
||
if (BuildDerivedSignal(request, true))
|
||
{
|
||
++built;
|
||
last = request;
|
||
}
|
||
}
|
||
|
||
if (built > 0)
|
||
{
|
||
std::wstring text = L"已构建 " + std::to_wstring(built) + L" 条 ";
|
||
if (last.weakPeak)
|
||
{
|
||
text += L"弱峰调整 " + std::to_wstring(last.amountPercent) + L"% " +
|
||
std::to_wstring(last.iterations) + L"次迭代信号。";
|
||
}
|
||
else
|
||
{
|
||
text += std::to_wstring(last.points) + L"点均值 " +
|
||
std::to_wstring(last.iterations) + L"次迭代滤波信号。";
|
||
}
|
||
SetStatusMessage(text, false);
|
||
}
|
||
return built > 0;
|
||
}
|
||
|
||
void Application::ShowTabContextMenu(const TabBarResult& result)
|
||
{
|
||
if (result.index < 0 || result.index >= static_cast<int>(m_tabs.size()))
|
||
{
|
||
return;
|
||
}
|
||
|
||
m_tabMenuIndex = result.index;
|
||
|
||
std::vector<MenuItem> items;
|
||
|
||
MenuItem header;
|
||
header.kind = MenuItemKind::HEADER;
|
||
header.label = m_tabs[static_cast<std::size_t>(result.index)].Name();
|
||
items.push_back(header);
|
||
|
||
MenuItem create;
|
||
create.label = L"新建标签页";
|
||
create.id = kMenuTabNew;
|
||
items.push_back(create);
|
||
|
||
MenuItem close;
|
||
close.label = L"关闭该标签页";
|
||
close.id = kMenuTabClose;
|
||
close.enabled = (m_tabs.size() > 1);
|
||
items.push_back(close);
|
||
|
||
MenuItem closeOthers;
|
||
closeOthers.label = L"关闭其它标签页";
|
||
closeOthers.id = kMenuTabCloseOthers;
|
||
closeOthers.enabled = (m_tabs.size() > 1);
|
||
items.push_back(closeOthers);
|
||
|
||
// 菜单绘制在客户区内,因此把屏幕坐标换算为客户区坐标。
|
||
POINT anchor = {result.screenX, result.screenY};
|
||
ScreenToClient(m_hwnd, &anchor);
|
||
|
||
RECT client = {};
|
||
GetClientRect(m_hwnd, &client);
|
||
const gd::RectF bounds(0.0f, 0.0f, static_cast<float>(client.right - client.left),
|
||
static_cast<float>(client.bottom - client.top));
|
||
|
||
m_menu.Open(MenuScope::TAB_BAR, bounds, anchor.x, anchor.y, items);
|
||
}
|
||
|
||
void Application::ShowPlotContextMenu(int laneIndex, int x, int y)
|
||
{
|
||
if (laneIndex < 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
m_plotMenuLane = laneIndex;
|
||
|
||
std::vector<MenuItem> items;
|
||
|
||
MenuItem header;
|
||
header.kind = MenuItemKind::HEADER;
|
||
header.label = L"网格 " + std::to_wstring(laneIndex + 1);
|
||
items.push_back(header);
|
||
|
||
// 当前网格的 Y 轴上下限值直接在菜单内的输入框里显示,单击即可就地编辑(菜单不关闭)。
|
||
double laneYMin = 0.0;
|
||
double laneYMax = 1.0;
|
||
m_plot.LaneRange(laneIndex, laneYMin, laneYMax);
|
||
|
||
MenuItem maxItem;
|
||
maxItem.kind = MenuItemKind::EDIT;
|
||
maxItem.label = L"Y 轴最大值";
|
||
maxItem.id = PLOT_CMD_LANE_Y_MAX;
|
||
maxItem.value = FormatValue(laneYMax);
|
||
maxItem.filter = EditFilter::NUMBER;
|
||
items.push_back(maxItem);
|
||
|
||
MenuItem minItem;
|
||
minItem.kind = MenuItemKind::EDIT;
|
||
minItem.label = L"Y 轴最小值";
|
||
minItem.id = PLOT_CMD_LANE_Y_MIN;
|
||
minItem.value = FormatValue(laneYMin);
|
||
minItem.filter = EditFilter::NUMBER;
|
||
items.push_back(minItem);
|
||
|
||
// 选区自适应(切换项):勾选后每次本网格的时间轴选区放大都会自动匹配 Y 轴幅值。
|
||
MenuItem autoViewItem;
|
||
autoViewItem.label = L"选区 Y 轴自适应";
|
||
autoViewItem.id = PLOT_CMD_LANE_AUTO_VIEW;
|
||
autoViewItem.checked = m_plot.LaneAutoFitToSelection(laneIndex);
|
||
items.push_back(autoViewItem);
|
||
|
||
// 时间轴自适应到本网格信号范围(原先位于分隔线下方,现移到分隔线上方)。
|
||
MenuItem fitLaneTime;
|
||
fitLaneTime.label = L"按网格时间轴展开";
|
||
fitLaneTime.id = PLOT_CMD_LANE_FIT_TIME;
|
||
items.push_back(fitLaneTime);
|
||
|
||
// 「拆分当前网格」:把该网格内的信号按个拆成独立网格,插到其下方依次排列。
|
||
// 可点击时在后方追加「(N个信号)」说明将拆出的网格数;只有 1 条信号时置灰且不加尾缀。
|
||
MenuItem splitLane;
|
||
const std::size_t laneSignalCount = m_plot.LaneSignalCount(laneIndex);
|
||
splitLane.label = (laneSignalCount > 1)
|
||
? (L"拆分当前网格(" + std::to_wstring(laneSignalCount) + L"个信号)")
|
||
: L"拆分当前网格";
|
||
splitLane.id = PLOT_CMD_LANE_SPLIT;
|
||
splitLane.enabled = laneSignalCount > 1;
|
||
items.push_back(splitLane);
|
||
|
||
// 实验性功能(二级菜单):横线上方,鼠标悬停即向右展开。
|
||
MenuItem experimental; experimental.kind = MenuItemKind::SUBMENU;
|
||
experimental.label = L"实验性功能";
|
||
{
|
||
MenuItem edgeCount;
|
||
edgeCount.label = L"边沿计数";
|
||
edgeCount.id = PLOT_CMD_LANE_EDGE_COUNT;
|
||
edgeCount.checked = m_plot.LaneEdgeCount(laneIndex);
|
||
experimental.submenu.push_back(edgeCount);
|
||
|
||
MenuItem edgeCenter;
|
||
edgeCenter.label = L"边沿计数(中心轴)";
|
||
edgeCenter.id = PLOT_CMD_LANE_EDGE_CENTER;
|
||
edgeCenter.checked = m_plot.LaneEdgeCenterCount(laneIndex);
|
||
experimental.submenu.push_back(edgeCenter);
|
||
|
||
MenuItem meanFilter;
|
||
meanFilter.label = L"均值滤波";
|
||
meanFilter.id = PLOT_CMD_LANE_MEAN_FILTER;
|
||
meanFilter.checked = m_plot.LaneMeanFilter(laneIndex);
|
||
experimental.submenu.push_back(meanFilter);
|
||
|
||
MenuItem weakPeak;
|
||
weakPeak.label = L"弱峰调整";
|
||
weakPeak.id = PLOT_CMD_LANE_WEAK_PEAK;
|
||
weakPeak.checked = m_plot.LaneWeakPeak(laneIndex);
|
||
experimental.submenu.push_back(weakPeak);
|
||
}
|
||
items.push_back(experimental);
|
||
|
||
MenuItem separator;
|
||
separator.kind = MenuItemKind::SEPARATOR;
|
||
items.push_back(separator);
|
||
|
||
// 全局开关:一次性打开/关闭全部网格各自的「选区 Y 轴自适应」。
|
||
MenuItem autoFitAllItem;
|
||
autoFitAllItem.label = L"选区 Y 轴自适应(全局)";
|
||
autoFitAllItem.id = PLOT_CMD_AUTO_FIT_ALL;
|
||
autoFitAllItem.checked = m_plot.AllLanesAutoFitToSelection();
|
||
items.push_back(autoFitAllItem);
|
||
|
||
MenuItem fitItem;
|
||
fitItem.label = L"全部自适应";
|
||
fitItem.id = PLOT_CMD_FIT_ALL;
|
||
items.push_back(fitItem);
|
||
|
||
RECT client = {};
|
||
GetClientRect(m_hwnd, &client);
|
||
const gd::RectF bounds(0.0f, 0.0f, static_cast<float>(client.right - client.left),
|
||
static_cast<float>(client.bottom - client.top));
|
||
|
||
m_menu.Open(MenuScope::PLOT_VIEW, bounds, x, y, items);
|
||
}
|
||
|
||
void Application::RefreshPlotMenuValues(int laneIndex)
|
||
{
|
||
if (laneIndex < 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
double laneYMin = 0.0;
|
||
double laneYMax = 1.0;
|
||
m_plot.LaneRange(laneIndex, laneYMin, laneYMax);
|
||
m_menu.UpdateItemValue(PLOT_CMD_LANE_Y_MAX, FormatValue(laneYMax));
|
||
m_menu.UpdateItemValue(PLOT_CMD_LANE_Y_MIN, FormatValue(laneYMin));
|
||
|
||
// 「选区 Y 轴自适应」(本网格)与「(全局)」都是切换项,实时反映各自的勾选状态。
|
||
m_menu.UpdateItemLabel(PLOT_CMD_LANE_AUTO_VIEW, L"选区 Y 轴自适应",
|
||
m_plot.LaneAutoFitToSelection(laneIndex), true);
|
||
m_menu.UpdateItemLabel(PLOT_CMD_AUTO_FIT_ALL, L"选区 Y 轴自适应(全局)",
|
||
m_plot.AllLanesAutoFitToSelection(), true);
|
||
}
|
||
|
||
void Application::DispatchMenuResult(const MenuResult& result)
|
||
{
|
||
if (!result.valid)
|
||
{
|
||
return;
|
||
}
|
||
|
||
switch (result.scope)
|
||
{
|
||
case MenuScope::SIGNAL_PANEL:
|
||
{
|
||
const bool needRelayout = m_panel.OnMenuCommand(result);
|
||
if (needRelayout)
|
||
{
|
||
Relayout();
|
||
}
|
||
InvalidateRect(m_hwnd, nullptr, FALSE);
|
||
break;
|
||
}
|
||
case MenuScope::PLOT_VIEW:
|
||
{
|
||
// 菜单内联输入框的提交:就地更新网格 Y 轴上下限,菜单保持打开,
|
||
// 并把夹取后的真实值回写到菜单上(输入值可能被合法区间修正)。
|
||
if (result.editCommit)
|
||
{
|
||
const int laneIndex = m_plotMenuLane;
|
||
if (laneIndex >= 0)
|
||
{
|
||
m_plot.ApplyLaneValueText(laneIndex, result.id == PLOT_CMD_LANE_Y_MAX, result.editText);
|
||
RefreshPlotMenuValues(laneIndex);
|
||
}
|
||
SetFocus(m_hwnd);
|
||
InvalidateRect(m_hwnd, nullptr, FALSE);
|
||
break;
|
||
}
|
||
|
||
const int laneIndex = m_plotMenuLane;
|
||
m_plotMenuLane = -1;
|
||
switch (result.id)
|
||
{
|
||
case PLOT_CMD_LANE_AUTO_VIEW:
|
||
// 切换本网格的「选区 Y 轴自适应」。
|
||
m_plot.ToggleLaneAutoFitToSelection(laneIndex);
|
||
break;
|
||
case PLOT_CMD_AUTO_FIT_ALL:
|
||
{
|
||
// 全局切换:一次打开/关闭全部网格各自的开关;打开时立即生效一次。
|
||
const bool enable = !m_plot.AllLanesAutoFitToSelection();
|
||
m_plot.SetAllLanesAutoFitToSelection(enable, true);
|
||
break;
|
||
}
|
||
case PLOT_CMD_LANE_FIT_TIME:
|
||
// 「按网格时间轴展开」:展开时间轴后同时把该网格的 Y 轴自适应一次。
|
||
m_plot.FitTimeToLane(laneIndex);
|
||
break;
|
||
case PLOT_CMD_LANE_EDGE_COUNT:
|
||
// 「实验性功能 → 边沿计数」:切换本网格的横向光标与统计面板。
|
||
m_plot.ToggleLaneEdgeCount(laneIndex);
|
||
break;
|
||
case PLOT_CMD_LANE_EDGE_CENTER:
|
||
// 「实验性功能 → 边沿计数(中心轴)」:切换中心平均值曲线与统计面板。
|
||
m_plot.ToggleLaneEdgeCenterCount(laneIndex);
|
||
break;
|
||
case PLOT_CMD_LANE_MEAN_FILTER:
|
||
// 「实验性功能 → 均值滤波」:切换均值滤波曲线与参数面板。
|
||
m_plot.ToggleLaneMeanFilter(laneIndex);
|
||
break;
|
||
case PLOT_CMD_LANE_WEAK_PEAK:
|
||
// 「实验性功能 → 弱峰调整」:切换弱峰调整曲线与参数面板。
|
||
m_plot.ToggleLaneWeakPeak(laneIndex);
|
||
break;
|
||
case PLOT_CMD_LANE_SPLIT:
|
||
// 「拆分当前网格」:信号按个拆成独立网格,新网格顺次插入其下方。
|
||
m_plot.SplitLane(laneIndex);
|
||
break;
|
||
case PLOT_CMD_FIT_ALL:
|
||
m_plot.FitAll();
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
SetFocus(m_hwnd);
|
||
InvalidateRect(m_hwnd, nullptr, TRUE);
|
||
break;
|
||
}
|
||
case MenuScope::TAB_BAR:
|
||
{
|
||
const int index = m_tabMenuIndex;
|
||
if (result.id == kMenuTabNew)
|
||
{
|
||
NewTab(true);
|
||
}
|
||
else if (result.id == kMenuTabClose)
|
||
{
|
||
CloseTab(index);
|
||
}
|
||
else if (result.id == kMenuTabCloseOthers)
|
||
{
|
||
if (index >= 0 && index < static_cast<int>(m_tabs.size()) && m_tabs.size() > 1)
|
||
{
|
||
const TabConfig keep = m_tabs[static_cast<std::size_t>(index)];
|
||
m_tabs.clear();
|
||
m_tabs.push_back(keep);
|
||
SetActiveTab(0);
|
||
}
|
||
}
|
||
m_tabMenuIndex = -1;
|
||
InvalidateRect(m_hwnd, nullptr, TRUE);
|
||
break;
|
||
}
|
||
case MenuScope::NONE:
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
|
||
} // namespace jdi
|
||
|