// Application 实现分片 —— 本文件包含:标签页管理、工具栏动作、设置菜单、键盘、菜单结果派发。 // // 共用的常量与工具函数在 ApplicationInternal.hpp(命名空间 `appdetail`,全部 inline)。 // 分片按"职责"划分:核心(状态/布局)、渲染、命令与菜单、加载。 #include "ApplicationInternal.hpp" #include "UiDebugBounds.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); } // ⚠️ **新标签页不存在任何被点选的信号**(用户 2026-11-02 要求): // 选中状态是**全局共享**的(`DataSet::Selected()`),不清掉的话新建出来的标签页会 // 直接带着上一个标签页的选中信号 —— 样式编辑区于是有自己的“目标”,与新页“空白” // 的预期不符。清空后样式区回到「未选择信号」状态,直到用户手动点选任意信号。 m_dataSet.SetSelected(SignalRef()); m_tabs.push_back(std::move(tab)); SetActiveTab(static_cast(m_tabs.size()) - 1); } void Application::CloseTab(int index) { if (index < 0 || index >= static_cast(m_tabs.size())) { return; } if (m_tabs.size() <= 1) { // ⚠️ **唯一标签页不弹警告**(用户 2026-11-02 要求):直接删掉它并新建一个默认的 // 「视图 1」—— 组全部折起、无选中信号、样式区无目标(由 `NewTab(true)` 保证)。 // ⚠️ `m_tabs` 清空后 `NextTabName()` 必然返回「视图 1」,编号自然从 1 重新开始。 // erase 与 `NewTab` 之间**不要**碰 `m_panel` / `m_plot`(它们的上下文仍指向 // 刚被释放的 `TabConfig`);`NewTab` 里的 `SetActiveTab` 会第一时间重新绑定。 m_tabs.erase(m_tabs.begin() + index); m_activeTab = 0; NewTab(true); return; } m_tabs.erase(m_tabs.begin() + index); int next = index; if (next >= static_cast(m_tabs.size())) { next = static_cast(m_tabs.size()) - 1; } SetActiveTab(next); } void Application::SetActiveTab(int index) { if (index < 0 || index >= static_cast(m_tabs.size())) { return; } m_activeTab = index; // 重新绑定上下文:面板与绘图区均指向同一数据层与当前标签页配置。 m_panel.SetContext(&m_dataSet, &m_tabs[static_cast(m_activeTab)]); m_plot.SetContext(&m_dataSet, &m_tabs[static_cast(m_activeTab)]); RefreshTabBar(); Relayout(); // 切换标签页后把该页已选中的信号滚动到可见位置。 // 切换标签页后把共享的选中信号滚动到可见位置(选中状态跨页保持一致)。 m_panel.ScrollToSignal(m_dataSet.Selected()); SynchronizeView(true); UpdateWindowTitle(); InvalidateRect(m_hwnd, nullptr, TRUE); } void Application::RefreshTabBar() { std::vector 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(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(); // ⚠️ **网格纵向滚动也要回到最上方**(用户 2026-10-17 要求): // “有过网格 → 没有信号显示 → 又加载了需要滚动条数量的网格”这一整类情形, // 新出现的网格集必须从最上方开始看。旧实现保留了上一次的 `LaneScroll()`, // 于是重新显示后会**停在与取消显示时相同的滚动位置**(用户报告)。 // 放在 `FitAll()` 之后:`FitAll` 本身不改变滚动量(§11.2 第 112 条), // 这里显式归零即可;随后 `Relayout`/`RefreshLanes` 会重算几何。 tab.LaneScroll() = 0.0f; // 用户 2026-10-17 要求:光标工具处于激活状态(单 / 双光标)而之前**没有任何信号绘制**时, // 第一个信号出现后光标要按“光标出现的规则”重新出现一次,而不是停在旧的残留位置。 // 规则与 `CycleCursorMode()` 首次启用完全同源;光标工具未激活时本调用无副作用。 m_plot.ResetCursorPlacementOnFreshContent(); } 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); } std::wstring Application::UniqueGroupName(const std::wstring& base) const { const auto taken = [this](const std::wstring& candidate) { for (const SignalGroup& group : m_dataSet.Groups()) { if (group.name == candidate) { return true; } } return false; }; if (!taken(base)) { return base; } for (int suffix = 2; suffix < 1000; ++suffix) { const std::wstring candidate = base + L" (" + std::to_wstring(suffix) + L")"; if (!taken(candidate)) { return candidate; } } return base; } void Application::MoveSignalCore(int fromGroup, int fromSignal, int toGroup, int toIndex) { const SignalGroup* sourceGroup = m_dataSet.GroupAt(fromGroup); const SignalGroup* targetGroup = m_dataSet.GroupAt(toGroup); if (sourceGroup == nullptr || targetGroup == nullptr || fromSignal < 0 || static_cast(fromSignal) >= sourceGroup->signals.size()) { return; } const bool sameGroup = (fromGroup == toGroup); // 目标下标:负数/越界表示追加到末尾(组内重排时"末尾"= 组内信号数-1,因为该信号会先被摘出)。 int position = toIndex; const int targetCount = static_cast(targetGroup->signals.size()); if (sameGroup) { if (position < 0 || position >= targetCount) { position = targetCount - 1; } if (position == fromSignal) { return; } } else if (position < 0 || position > targetCount) { position = targetCount; } SignalRef from; from.groupIndex = fromGroup; from.signalIndex = fromSignal; SignalRef to; to.groupIndex = toGroup; to.signalIndex = position; const bool visible = m_tabs.empty() ? false : m_tabs.front().IsVisible(from); // 移动会搬运大量采样缓冲:先暂停后台派生曲线计算,避免其读取正在被移动的内存。 m_plot.PauseDerivedAsync(); // 跨组且目标分组是多标签页里"新出现"的(例如刚建的分组)时,先补齐可见性表列, // 否则 `RemapAfterSignalMove` 无法在新组里插入"沿用源可见性"的那一列, // 移动过去后信号会变成未勾选、曲线直接消失。 if (!sameGroup) { for (TabConfig& tab : m_tabs) { tab.SyncToDataSet(m_dataSet); } for (TabConfig& tab : m_tabs) { tab.RemapAfterSignalMove(from, to, tab.IsVisible(from)); } } else { for (TabConfig& tab : m_tabs) { tab.RemapAfterSignalReorder(fromGroup, fromSignal, position); } } (void)visible; // 共享选中项:被移动的正是当前选中信号时,改为指向新位置。 if (m_dataSet.Selected().groupIndex == fromGroup) { if (sameGroup) { // 与 `RemapAfterSignalReorder` 同一套线性映射。 int sel = m_dataSet.Selected().signalIndex; if (sel == fromSignal) { sel = position; } else if (fromSignal < position) { if (sel > fromSignal && sel <= position) { --sel; } } else if (sel >= position && sel < fromSignal) { ++sel; } SignalRef next; next.groupIndex = fromGroup; next.signalIndex = sel; m_dataSet.SetSelected(next); } else if (m_dataSet.Selected().signalIndex == fromSignal) { m_dataSet.SetSelected(to); } } int newSignalIndex = -1; // 参数已在此前校验过(分组与信号下标都合法),本调用不会失败。 m_dataSet.MoveSignal(fromGroup, fromSignal, toGroup, position, newSignalIndex); SyncTabsToDataSet(); } void Application::ReorderSignal(int fromGroup, int fromSignal, int toGroup, int toIndex) { MoveSignalCore(fromGroup, fromSignal, toGroup, toIndex); } void Application::MoveSignalToGroup(int fromGroup, int fromSignal, int toGroup) { const SignalGroup* sourceGroup = m_dataSet.GroupAt(fromGroup); if (sourceGroup == nullptr || fromSignal < 0 || static_cast(fromSignal) >= sourceGroup->signals.size()) { return; } const std::wstring signalName = sourceGroup->signals[static_cast(fromSignal)].name; // 「新建分组」:先追加一个空分组(名字不重复),再按普通跨组移动处理。 int targetGroup = toGroup; if (targetGroup < 0) { SignalGroup group; group.name = UniqueGroupName(L"新建分组"); if (!m_dataSet.AddGroup(std::move(group), targetGroup)) { return; } } if (targetGroup == fromGroup) { return; // 已经在该分组,无需移动 } const SignalGroup* targetGroupData = m_dataSet.GroupAt(targetGroup); if (targetGroupData == nullptr) { return; } MoveSignalCore(fromGroup, fromSignal, targetGroup, static_cast(targetGroupData->signals.size())); const SignalGroup* destination = m_dataSet.GroupAt(targetGroup); SetStatusMessage(L"已将信号「" + signalName + L"」移动到分组:" + (destination != nullptr ? destination->name : std::wstring()), 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 buffer(static_cast(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(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::SETTINGS: // 齿轮按钮只负责**展开菜单**:不改变任何业务状态,因此不必走后面的"整帧失效", // 只需要把浮层画出来(菜单打开后 `Render` 会因 `overlaysVisible` 自动整帧重绘)。 OpenSettingsMenu(); if (m_hwnd != nullptr) { InvalidateRect(m_hwnd, nullptr, TRUE); } return; 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(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; switch (m_activeTarget) { case Target::PLOT: cursor = m_plot.QueryCursor(x, y); break; case Target::SPLITTER: // 拖分隔条 → 左右箭头(否则按下瞬间光标会跳回箭头)。 cursor = LoadCursorW(nullptr, IDC_SIZEWE); break; case Target::EDIT_FIELD: // 输入框内拖拽框选 → I 形光标。 cursor = LoadCursorW(nullptr, IDC_IBEAM); break; default: break; } 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(x - m_splitterGrabOffset) / m_scale; // ⚠️ 上限必须与 `Relayout()` 里**同一个公式**(`min(kMaxPanelWidth, 客户区宽 × 0.45)`): // 旧实现用 `min(kMaxPanelWidth, kMinClientWidth / m_scale)` 恒等于 `kMaxPanelWidth`, // 窄窗口下能把面板拖到 400 逻辑像素,随后 `Relayout` 又悄悄夹回来 ⇒ 拖动时宽度回弹。 const float clientWidth = static_cast(std::max(0, m_backWidth)) / m_scale; const float maximum = std::min(kMaxPanelWidth, std::max(0.0f, clientWidth) * 0.45f); 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(m_tabs.size()) && !result.text.empty()) { m_tabs[static_cast(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(m_tabs.size())) { return; } if (target < 0 || target > static_cast(m_tabs.size())) { return; } // 拖拽语义:target 表示「插入到该下标之前」;向后拖时需扣除自身占位。 int destination = target; if (destination > from) { --destination; } destination = std::max(0, std::min(destination, static_cast(m_tabs.size()) - 1)); if (destination == from) { return; } // 保持当前激活的标签页在重排后仍处于激活状态。 TabConfig moved = m_tabs[static_cast(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(group->signals.size())) { return false; } const SignalData& source = group->signals[static_cast(request.signalIndex)]; const std::wstring baseName = source.name; // 0. 先把源信号的「调整」变换快照下来(下面 `InsertDerivedSignal` 会移动信号存储, // 之后再取 `source` 会悬垂)。 // // ⚠️ **变换要"烘进数据",而不是让新信号继承这套指纹**(用户明确要求): // 网格里的预览曲线画的是 `变换(滤波结果)`。若新信号只存 `滤波结果` 再**继承变换**, // 它的「调整」面板里就会莫名其妙带着一整套系数 / 镜像 / 偏移 —— 用户看到的是 // "新信号只是继承了原信号的指纹,数据本身没变"。现在改为: // * 数值域变换(系数 → 绝对值 → 垂直镜像 → Y 偏移)**与**水平镜像(数据反序) // 全部作用在派生采样值上; // * 于是新信号**用默认外观**绘制出来就与预览曲线逐位一致。 // * ⚠️ **只有「数值域」的变换烘进采样值;时间轴偏移留在样式上**(见 3b)。 // 曾经把 `offsetTime` 也按 `time[i] - offsetTime` 重采样烘进采样值("新信号 // 不启用任何调整"),但那是**有损**的:平移出时间轴范围的那一段只能写成 NaN, // 偏移稍大(≥ 数据跨度)就连一个有效采样都不剩 —— 用户点「构建为新信号」 // 得到的是一条**空信号**(2026-10-29 报告)。派生信号与源信号共用同一条文件 // 时间轴,无法用采样值表达超出轴范围的平移,因此时间偏移只能由样式表达。 // 预览曲线本来也是用源样式的 `offsetTime` 绘制的,两条路径**显示完全一致**。 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 derived_values; SignalData derived; if (request.weakPeak) { ComputeWeakPeakAdjust(source.values, static_cast(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` 是逐点函数,因此"先变换再反转"与"先反转再变换"等价。 // ⚠️⚠️ **公式映射必须算作“有变换”**(用户 2026-11-03):旧判定只列了系数/绝对值/ // 垂直镜像/Y 偏移,于是带公式的信号派生出来的新信号**丢掉了公式**(不烘也不继承) // ⇒ 新信号曲线与预览曲线不一致。 if (hasSourceStyle) { const bool valueTransform = sourceTransform.HasFormula() || 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.(已删除)时间轴偏移**不再**按 `time[i] - offsetTime` 重采样烘进采样值。 // // 旧实现为了让"新信号的「调整」面板保持默认"而把平移烘进数据,代价是**有损**: // 平移出时间轴范围的那一段只能写成 NaN,偏移 ≥ 数据跨度时**整条信号全变 NaN** //(用户 2026-10-29 报告的"派生出来是一条空信号");即便偏移很小,也会在两端 // 留下与实际曲线不符的空洞。 // 派生信号与源信号共用同一条文件时间轴,无法用采样值表达超出轴范围的平移, // 因此时间偏移改由**新信号样式**表达(见 3b)——预览曲线本来就用源样式的 // `offsetTime` 绘制,所以两条路径的**显示完全一致**,且数据一点都不会丢。 // 3. 插入到原信号的下方(数据层 + 各标签页引用重映射)。 int newIndex = -1; if (!InsertSignalBelowSource(request.groupIndex, request.signalIndex, std::move(derived), makeVisible, newIndex)) { return false; } // 3b. 时间轴偏移:**只**写进新信号的样式(不再烘进采样值,见 2c 的说明)。 // 绘制路径本来就把采样画在 `t + offsetTime` 处,因此新信号的曲线与预览曲线 // 逐点重合,同时采样数据保持完整(不会因为平移越界而变成 NaN)。 if (hasSourceStyle && sourceTransform.offsetTime != 0.0) { if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{request.groupIndex, newIndex})) { derivedStyle->offsetTime = sourceTransform.offsetTime; } } // 4. 数据层结构变化:刷新列表、作废折线/派生缓存并重绘。 SyncTabsToDataSet(); return true; } bool Application::BuildResampledSignal(int groupIndex, int signalIndex, double interval) { const SignalGroup* group = m_dataSet.GroupAt(groupIndex); if (group == nullptr || signalIndex < 0 || signalIndex >= static_cast(group->signals.size())) { return false; } if (!std::isfinite(interval) || interval <= 0.0) { return false; } const SignalData& source = group->signals[static_cast(signalIndex)]; const TimeAxis& axis = group->AxisOf(source); const std::wstring baseName = source.name; // 0. 快照源信号的「调整」变换(插入会移动信号存储,之后取 `source` 会悬垂)。 SignalStyle sourceStyle; bool hasSourceStyle = false; { const SignalStyle* style = m_dataSet.Style(SignalRef{groupIndex, signalIndex}); if (style != nullptr) { sourceStyle = *style; hasSourceStyle = true; } } // 1. 收集「变换后」的采样点(升序)。变换与绘制一致: // 公式映射 → 系数 → 绝对值 → 垂直镜像 → Y 偏移(时间轴偏移不烘值)。 // ⚠️ 判定统一走 `HasValueTransform()`(含公式映射),否则带公式的信号 // 重采样时会**丢掉公式**(用户 2026-11-03 报告)。 const std::size_t count = std::min(axis.time.size(), source.values.size()); std::vector sourceTimes; std::vector sourceValues; sourceTimes.reserve(count); sourceValues.reserve(count); bool monotonic = true; double previous = 0.0; for (std::size_t index = 0; index < count; ++index) { const double time = axis.time[index]; const double value = source.values[index]; if (!std::isfinite(time) || !std::isfinite(value)) { continue; } if (!sourceTimes.empty()) { if (time < previous) { monotonic = false; break; } if (time == previous) { continue; // 重复时刻只保留第一个 } } previous = time; sourceTimes.push_back(time); sourceValues.push_back(hasSourceStyle ? ApplyDrawTransform(sourceStyle, value) : value); } if (!monotonic || sourceTimes.size() < 2) { m_plot.ShowPersistentHint(!monotonic ? L"重采样失败:该信号的时间轴不是单调递增的。" : L"重采样失败:有效采样点不足两个。"); return false; } // 2. 生成目标时间刻度(等间隔)。 const double t0 = sourceTimes.front(); const double t1 = sourceTimes.back(); const double span = t1 - t0; if (!(span > 0.0)) { m_plot.ShowPersistentHint(L"重采样失败:该信号的时间跨度为 0。"); return false; } // 采样点数上限:防止用户把间隔输得太小导致内存爆炸。 constexpr double kMaxResamplePoints = 20000000.0; const double stepsExact = std::floor(span / interval + 1.0e-9) + 1.0; if (!(stepsExact >= 2.0)) { m_plot.ShowPersistentHint(L"重采样失败:重采样间隔大于信号时间跨度。"); return false; } if (stepsExact > kMaxResamplePoints) { m_plot.ShowPersistentHint(L"重采样失败:目标采样点过多(超过 2000 万),请增大重采样间隔。"); return false; } const std::size_t targetCount = static_cast(stepsExact); TimeAxis newAxis; newAxis.name = axis.name; newAxis.time.resize(targetCount); newAxis.monotonic = true; std::vector resampled(targetCount); std::size_t cursor = 0; const std::size_t last = sourceTimes.size() - 1; for (std::size_t index = 0; index < targetCount; ++index) { const double time = std::min(t1, t0 + static_cast(index) * interval); newAxis.time[index] = time; // 单调推进游标(O(n) 总代价)。 while (cursor < last && sourceTimes[cursor + 1] < time) { ++cursor; } if (cursor >= last) { resampled[index] = sourceValues[last]; continue; } const double ta = sourceTimes[cursor]; const double tb = sourceTimes[cursor + 1]; if (time <= ta) { resampled[index] = sourceValues[cursor]; continue; } if (tb <= ta) { resampled[index] = sourceValues[cursor]; continue; } const double ratio = (time - ta) / (tb - ta); const double a = sourceValues[cursor]; const double b = sourceValues[cursor + 1]; resampled[index] = a + (b - a) * ratio; } newAxis.minTime = t0; newAxis.maxTime = newAxis.time.back(); newAxis.finiteCount = targetCount; // 3. 追加新时间轴(重采样后的采样刻度无法复用源信号的时间轴)。 std::size_t axisIndex = 0; if (!m_dataSet.AppendGroupAxis(groupIndex, std::move(newAxis), axisIndex)) { return false; } // 4. 组装新信号并插入到源信号下方。 SignalData derived; derived.name = baseName + L" (重采样 " + FormatFrequency(1.0 / interval) + L")"; derived.axisIndex = axisIndex; derived.values = std::move(resampled); int newIndex = -1; if (!InsertSignalBelowSource(groupIndex, signalIndex, std::move(derived), true, newIndex)) { return false; } // 5. 时间轴偏移**不烘值**:留在新信号的样式上(与其它派生信号同一规则)。 if (hasSourceStyle && sourceStyle.offsetTime != 0.0) { if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{groupIndex, newIndex})) { derivedStyle->offsetTime = sourceStyle.offsetTime; } } SyncTabsToDataSet(); return true; } // ---------------------------------------------------------------- 选区截取 bool Application::BuildClippedSignal(int groupIndex, int signalIndex, double begin, double end) { const SignalGroup* group = m_dataSet.GroupAt(groupIndex); if (group == nullptr || signalIndex < 0 || signalIndex >= static_cast(group->signals.size())) { return false; } if (!std::isfinite(begin) || !std::isfinite(end)) { return false; } const double low = std::min(begin, end); const double high = std::max(begin, end); if (!(high > low)) { m_plot.ShowPersistentHint(L"选区截取失败:双光标区间宽度为 0。"); return false; } const SignalData& source = group->signals[static_cast(signalIndex)]; const TimeAxis& axis = group->AxisOf(source); const std::wstring baseName = source.name; // 源信号的整套「调整」快照(插入会移动信号存储,之后不能再解引用 `source`)。 SignalStyle sourceStyle; bool hasSourceStyle = false; { const SignalStyle* style = m_dataSet.Style(SignalRef{groupIndex, signalIndex}); if (style != nullptr) { sourceStyle = *style; hasSourceStyle = true; } } // 1. 逐点收集**区间内**的原始采样(不烘焙任何调整)。 // ⚠️⚠️ 比较必须用**显示时刻**(`time + offsetTime`):双光标给出的是屏幕上看到的时刻, // 而采样画在 `time + offsetTime` 处(`offsetTime` 不进采样值,只平移绘制位置)。 // 若直接拿 `time` 与选区比较,带时间轴偏移的信号会截错区间(用户 2026-11-03)。 const double timeShift = hasSourceStyle ? sourceStyle.offsetTime : 0.0; const std::size_t count = std::min(axis.time.size(), source.values.size()); std::vector keptTimes; std::vector keptValues; keptTimes.reserve(count); keptValues.reserve(count); for (std::size_t index = 0; index < count; ++index) { const double time = axis.time[index]; if (!std::isfinite(time)) { continue; } const double shown = time + timeShift; if (shown < low || shown > high) { continue; } keptTimes.push_back(time); keptValues.push_back(source.values[index]); } if (keptValues.size() < 2) { m_plot.ShowPersistentHint(L"选区截取失败:区间内的有效采样点不足两个。"); return false; } // 2. 名称尾缀:`(AAAA - BBBB)`,秒、保留三位小数(用户 2026-11-03 明确要求)。 // 数值取**显示时刻**(与双光标读数一致),因此加上 `timeShift`。 const std::wstring suffix = L" (" + FormatDecimalFixed(keptTimes.front() + timeShift, 3) + L"s - " + FormatDecimalFixed(keptTimes.back() + timeShift, 3) + L"s)"; // 3. 新建时间轴(截取后的采样刻度无法复用源信号的时间轴)。 TimeAxis newAxis; newAxis.name = axis.name; newAxis.time = std::move(keptTimes); newAxis.monotonic = true; newAxis.finiteCount = newAxis.time.size(); newAxis.minTime = newAxis.time.front(); newAxis.maxTime = newAxis.time.back(); std::size_t axisIndex = 0; if (!m_dataSet.AppendGroupAxis(groupIndex, std::move(newAxis), axisIndex)) { return false; } // 4. 组装新信号并插入到源信号下方。 SignalData derived; derived.name = baseName + suffix; derived.axisIndex = axisIndex; derived.values = std::move(keptValues); int newIndex = -1; if (!InsertSignalBelowSource(groupIndex, signalIndex, std::move(derived), true, newIndex)) { return false; } // 5. 继承源信号的**整套调整**(不烘值 ⇒ 新曲线与源信号在区间内逐像素重合)。 if (hasSourceStyle) { if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{groupIndex, newIndex})) { derivedStyle->color = sourceStyle.color; derivedStyle->customColor = sourceStyle.customColor; derivedStyle->opacity = sourceStyle.opacity; derivedStyle->lineWidth = sourceStyle.lineWidth; derivedStyle->mode = sourceStyle.mode; derivedStyle->formula = sourceStyle.formula; derivedStyle->gain = sourceStyle.gain; derivedStyle->absolute = sourceStyle.absolute; derivedStyle->mirrorVertical = sourceStyle.mirrorVertical; derivedStyle->mirrorHorizontal = sourceStyle.mirrorHorizontal; derivedStyle->offsetY = sourceStyle.offsetY; derivedStyle->offsetTime = sourceStyle.offsetTime; } } SyncTabsToDataSet(); return true; } bool Application::ClipSignalInPlace(int groupIndex, int signalIndex, double begin, double end) { if (!std::isfinite(begin) || !std::isfinite(end)) { return false; } const double low = std::min(begin, end); const double high = std::max(begin, end); if (!(high > low)) { m_plot.ShowPersistentHint(L"选区截取失败:双光标区间宽度为 0。"); return false; } // 破坏性截取会移动/释放采样 ⇒ 先暂停后台派生计算,避免它读到已释放的数据。 m_plot.PauseDerivedAsync(); // ⚠️ 双光标给的是**显示时刻**,而采样时刻是 `time`;这里换算回采样时刻区间 // (`offsetTime` 不烘进采样值,只平移绘制位置)。 double timeShift = 0.0; if (const SignalStyle* style = m_dataSet.Style(SignalRef{groupIndex, signalIndex})) { timeShift = style->offsetTime; } // ⚠️⚠️ **必须先让该信号独占时间轴**再裁剪:同组信号常常共享同一条时间轴, // 直接裁剪会把其它信号的时间基准一起改短 ⇒ 它们的采样与时间错位 //(用户 2026-11-03 报告:"多个信号同时截取时截取出来的信号并不对")。 std::size_t ownAxis = 0; if (!m_dataSet.ForkAxisForSignal(groupIndex, signalIndex, ownAxis)) { m_plot.ShowPersistentHint(L"选区截取失败:无法为该信号创建独立时间轴。"); return false; } if (!m_dataSet.ClipSignalRange(groupIndex, signalIndex, low - timeShift, high - timeShift)) { m_plot.ShowPersistentHint(L"选区截取失败:区间内的有效采样点不足两个。"); return false; } SyncTabsToDataSet(); return true; } bool Application::InsertSignalBelowSource(int groupIndex, int sourceIndex, SignalData&& signal, bool makeVisible, int& newIndex) { newIndex = -1; // 插入派生信号会移动该分组的信号存储:先暂停后台计算,避免其读取已移动的数据。 m_plot.PauseDerivedAsync(); if (!m_dataSet.InsertDerivedSignal(groupIndex, sourceIndex, std::move(signal), newIndex)) { return false; } for (TabConfig& tab : m_tabs) { tab.RemapAfterSignalInsert(groupIndex, newIndex); } // 在当前标签页中勾选新信号,并把它所在的网格移到原信号网格的下方。 if (makeVisible && !m_tabs.empty()) { const SignalRef insertedRef{groupIndex, newIndex}; ActiveTab().SetVisible(insertedRef, true); const SignalRef sourceRef{groupIndex, sourceIndex}; ActiveTab().MoveLaneOfSignalBelow(insertedRef, sourceRef); } return true; } bool Application::CloneSignal(const SignalRef& sourceRef) { const SignalGroup* group = m_dataSet.GroupAt(sourceRef.groupIndex); if (group == nullptr || sourceRef.signalIndex < 0 || sourceRef.signalIndex >= static_cast(group->signals.size())) { return false; } const SignalData& source = group->signals[static_cast(sourceRef.signalIndex)]; // 源信号的样式快照(插入会移动信号存储,之后不能再解引用 `source`)。 SignalStyle transform; bool hasStyle = false; if (const SignalStyle* style = m_dataSet.Style(sourceRef)) { transform = *style; hasStyle = true; } // ⚠️ 用户 2026-11-03 明确要求:「克隆该信号」**不做任何烘焙**。 // 新信号直接使用源信号的**原始采样数据**,配方上只沿用: // * 外观(颜色 / 自定义色 / 透明度 / 线宽 / 绘制方式); // * **时间轴偏移**(`offsetTime` 只能留在样式上 —— 共用同一条时间轴, // 烘焙进采样值会在越界段写 NaN,见 `BuildDerivedSignal` 2c 的说明)。 // 公式映射 / 系数 / 绝对值 / 镜像 / Y 偏移一律**保持默认**(不烘也不应用)。 // 需要“连调整一起复制”请用「克隆该信号(带调整)」(`CloneSignalAdjusted`)。 SignalData clone; clone.name = L"*" + source.name; clone.axisIndex = source.axisIndex; clone.values = source.values; int newIndex = -1; if (!InsertSignalBelowSource(sourceRef.groupIndex, sourceRef.signalIndex, std::move(clone), true, newIndex)) { return false; } // 外观:克隆要**看起来与原信号一样**,但所有数值调整保持默认值。 if (SignalStyle* cloned = m_dataSet.Style(SignalRef{sourceRef.groupIndex, newIndex})) { if (hasStyle) { cloned->color = transform.color; cloned->customColor = transform.customColor; cloned->opacity = transform.opacity; cloned->lineWidth = transform.lineWidth; cloned->mode = transform.mode; // ⚠️ 只保留时间轴偏移,其余调整保持默认(用户 2026-11-03 明确要求)。 cloned->offsetTime = transform.offsetTime; } // ⚠️ 单点信号默认会改成「散点 + 最大线宽」(`InsertDerivedSignal` 里的规则), // 克隆应该用**原信号的样式**,因此上面几行把它覆盖回去了。 } SyncTabsToDataSet(); SetStatusMessage(L"已克隆信号:" + m_dataSet.GroupAt(sourceRef.groupIndex) ->signals[static_cast(newIndex)] .name, false); InvalidateRect(m_hwnd, nullptr, TRUE); return true; } bool Application::CloneSignalAdjusted(const SignalRef& sourceRef) { const SignalGroup* group = m_dataSet.GroupAt(sourceRef.groupIndex); if (group == nullptr || sourceRef.signalIndex < 0 || sourceRef.signalIndex >= static_cast(group->signals.size())) { return false; } const SignalData& source = group->signals[static_cast(sourceRef.signalIndex)]; SignalStyle transform; bool hasStyle = false; if (const SignalStyle* style = m_dataSet.Style(sourceRef)) { transform = *style; hasStyle = true; } // 「克隆该信号(带调整)」:同样是**原始采样数据**(不做烘焙), // 区别只在样式——完整沿用源信号的整套调整参数(公式映射 / 系数 / Y 偏移 / // 时间轴偏移 / 水平与垂直镜像 / 绝对值),因此两条曲线**逐像素重合**, // 且新信号可以独立继续调参而不影响源信号(用户 2026-11-03 明确要求)。 SignalData clone; clone.name = L"*" + source.name; clone.axisIndex = source.axisIndex; clone.values = source.values; int newIndex = -1; if (!InsertSignalBelowSource(sourceRef.groupIndex, sourceRef.signalIndex, std::move(clone), true, newIndex)) { return false; } if (SignalStyle* cloned = m_dataSet.Style(SignalRef{sourceRef.groupIndex, newIndex})) { if (hasStyle) { // 外观。 cloned->color = transform.color; cloned->customColor = transform.customColor; cloned->opacity = transform.opacity; cloned->lineWidth = transform.lineWidth; cloned->mode = transform.mode; // 整套「调整」参数(与源信号完全一致)。 cloned->formula = transform.formula; cloned->gain = transform.gain; cloned->absolute = transform.absolute; cloned->mirrorVertical = transform.mirrorVertical; cloned->mirrorHorizontal = transform.mirrorHorizontal; cloned->offsetY = transform.offsetY; cloned->offsetTime = transform.offsetTime; } } SyncTabsToDataSet(); SetStatusMessage(L"已克隆信号(带调整):" + m_dataSet.GroupAt(sourceRef.groupIndex) ->signals[static_cast(newIndex)] .name, false); InvalidateRect(m_hwnd, nullptr, TRUE); return true; } bool Application::ApplyPendingPanelScroll() { SignalRef ref; if (!m_plot.TakePanelScrollRequest(ref) || !ref.IsValid()) { return false; } // 平滑滚动(带动画):用户要求"是滚动的而不是立刻翻到目标信号位置"。 m_panel.ScrollToSignalAnimated(ref); return true; } bool Application::ApplyPendingPlotLaneScroll() { int laneIndex = -1; if (!m_panel.TakePlotLaneScrollRequest(laneIndex)) { return false; } // 目标网格不在绘图区可视区内时才真正滚动(已完整可见则不动视图)。 return m_plot.ScrollLaneIntoView(laneIndex); } bool Application::ApplyPendingDerivedBuild() { // 「重采样」的构建流程与均值/弱峰不同(要新建时间轴),单独处理。 { int laneIndex = -1; std::vector items; if (m_plot.TakePendingResampleBuild(laneIndex, items) && !items.empty()) { // **同一文件内必须按下标降序处理**(每次插入只让插入点之后的信号下标 +1)。 std::sort(items.begin(), items.end(), [](const PlotView::ResampleBuild& a, const PlotView::ResampleBuild& b) { if (a.groupIndex != b.groupIndex) { return a.groupIndex < b.groupIndex; } return a.signalIndex > b.signalIndex; }); int built = 0; double lastInterval = 0.0; for (const PlotView::ResampleBuild& item : items) { if (BuildResampledSignal(item.groupIndex, item.signalIndex, item.interval)) { ++built; lastInterval = item.interval; } } if (built > 0) { SetStatusMessage(L"已构建 " + std::to_wstring(built) + L" 条重采样信号(频率 " + FormatFrequency(lastInterval > 0.0 ? 1.0 / lastInterval : 0.0) + L")。", false); } return built > 0; } } // 「选区截取」:两个按钮(截取为新信号 / 原始信号截取)共用同一份区间。 { int laneIndex = -1; std::vector items; if (m_plot.TakePendingClipBuild(laneIndex, items) && !items.empty()) { // 同一文件内必须按下标降序处理(插入会让后续下标 +1)。 std::sort(items.begin(), items.end(), [](const PlotView::ClipBuild& a, const PlotView::ClipBuild& b) { if (a.groupIndex != b.groupIndex) { return a.groupIndex < b.groupIndex; } return a.signalIndex > b.signalIndex; }); int built = 0; for (const PlotView::ClipBuild& item : items) { if (item.destructive ? ClipSignalInPlace(item.groupIndex, item.signalIndex, item.begin, item.end) : BuildClippedSignal(item.groupIndex, item.signalIndex, item.begin, item.end)) { ++built; } } if (built > 0) { SetStatusMessage((items.front().destructive ? L"已截取 " : L"已截取为新信号 ") + std::to_wstring(built) + L" 条。", false); } return built > 0; } } // 两类「构建为新信号」请求(均值滤波 / 弱峰调整)机制完全一致, // 统一整理成同一份构建清单后按下标降序处理。 std::vector requests; { int laneIndex = -1; std::vector 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 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(m_tabs.size())) { return; } m_tabMenuIndex = result.index; std::vector items; MenuItem header; header.kind = MenuItemKind::HEADER; header.label = m_tabs[static_cast(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(client.right - client.left), static_cast(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 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); MenuItem freeMarker; freeMarker.label = L"自由标记"; freeMarker.id = PLOT_CMD_LANE_FREE_MARKER; freeMarker.checked = m_plot.LaneFreeMarker(laneIndex); // 「重采样」位于「自由标记」**上方**(用户 2026-11-02 要求)。 MenuItem resample; resample.label = L"重采样"; resample.id = PLOT_CMD_LANE_RESAMPLE; resample.checked = m_plot.LaneResample(laneIndex); experimental.submenu.push_back(resample); // 「选区截取」位于「重采样」下方、「自由标记」上方(用户 2026-11-03 要求)。 MenuItem clip; clip.label = L"选区截取"; clip.id = PLOT_CMD_LANE_CLIP; clip.checked = m_plot.LaneClip(laneIndex); experimental.submenu.push_back(clip); experimental.submenu.push_back(freeMarker); } 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(client.right - client.left), static_cast(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::ApplyTheme(ThemeKind kind) { if (kind == m_themeKind && m_theme.windowBg == ThemeFor(kind).windowBg) { return; // 幂等:同一个主题重复点击不必作废缓存 } m_themeKind = kind; m_theme = ThemeFor(kind); SetCurrentTheme(&m_theme); // 主题色烘进了多处位图缓存,必须整体作废(只作废场景缓存是不够的: // 宽基底图层、装饰层、浮动面板层、信号列表行位图里都存着旧配色的像素)。 m_plot.OnThemeChanged(); m_panel.OnThemeChanged(); // 装饰区(标签栏 / 工具栏 / 面板)全部重画,并整窗回送。 m_chromeDirty = true; m_panelDirty = true; InvalidateRect(m_hwnd, nullptr, TRUE); } void Application::OpenSettingsMenu() { // 锚点取齿轮按钮的**实际矩形**:按钮宽度由控件实测,宿主不应自己推算几何。 gd::RectF anchor; if (!m_toolbar.ButtonRect(ToolbarAction::SETTINGS, anchor)) { // 尚未布局(极端情形):退化为从菜单快捷键位置展开,而不是什么都不发生。 anchor = m_toolbarRect; } std::vector items; // ── 调试 ────────────────────────────────────────────── MenuItem debugHeader; debugHeader.kind = MenuItemKind::HEADER; debugHeader.label = L"调试"; items.push_back(debugHeader); MenuItem boundsItem; boundsItem.label = L"显示布局边界"; boundsItem.id = kAppCmdShowLayoutBounds; boundsItem.checked = m_showLayoutBounds; // 切换项:点击后菜单保持打开,勾选态由宿主回写(与既有「镜像」等切换项一致)。 boundsItem.keepOpen = true; items.push_back(boundsItem); // ── 主题配色(二级菜单:鼠标悬停展开,带勾选态)────────────── // 顺序固定为 Light / Solarised Light / Dark / Solarised Dark(用户 2026-10-29 指定)。 // `keepOpen` 让点击主题后菜单不关闭,勾选态由宿主回写 —— 便于连续预览几种配色。 MenuItem themeItem; themeItem.kind = MenuItemKind::SUBMENU; themeItem.label = L"主题配色"; for (int index = 0; index <= static_cast(ThemeKind::SOLARIZED_DARK); ++index) { const ThemeKind kind = static_cast(index); MenuItem option; option.kind = MenuItemKind::NORMAL; option.label = ThemeName(kind); option.id = kAppCmdThemeBase + index; option.checked = (m_themeKind == kind); option.keepOpen = true; themeItem.submenu.push_back(option); } items.push_back(themeItem); RECT client = {}; GetClientRect(m_hwnd, &client); const gd::RectF bounds(0.0f, 0.0f, static_cast(client.right - client.left), static_cast(client.bottom - client.top)); // 右下展开,锚在按钮**下沿**(留 4 逻辑像素,视觉上不贴着按钮边框)。 const int x = static_cast(anchor.X); const int y = static_cast(anchor.Y + anchor.Height + 4.0f * m_scale); m_menu.Open(MenuScope::APP_SETTINGS, bounds, x, y, items); } int Application::ComponentLayer(const UiComponent* component) const { if (component == nullptr) { return 0; } for (std::size_t index = 0; index < m_components.size(); ++index) { if (m_components[index].component == component) { return static_cast(index) * kLayersPerComponent; } } return 0; } void Application::PaintLayoutBounds(gd::Graphics& g, int width, int height) { // ⚠️ 复用成员容器(不是局部变量):文本几何已经在**绘制期间**由 `PaintUtil` // 通过 `UiSetTextRectSink(&m_debugBoxes)` 收进来了,这里只负责补组件结构。 // // 层号与 `Render` 里下发的"文本层"**完全同源**(组件表下标 / `OverlayLayer`): // 只有两者一致,遮挡矩形才能正确地把"浮层下面的内容"(含文字框)挖掉, // 而不是反过来让下层文字穿透到浮层之上(用户 2026-10-29 报告的缺陷)。 m_debugBoxes.SetLayer(0); // 客户区外框:便于确认"窗口边缘 == 最外层边界"。 m_debugBoxes.Add(gd::RectF(0.0f, 0.0f, static_cast(width), static_cast(height)), 0); // 组件区域(底 → 顶;组件自己负责补齐内部结构)。 // ⚠️ 三点都很重要: // 1. `SetClip(组件区域)`:组件内部的元素可能落在自己地盘之外(典型:滚出可视区的 // 网格 `lane.rect` 的 Y 为负数),不裁剪会把边框画到工具栏/标签栏上。 // 2. 每个组件收集前**显式**把层号设成它在组件表里的下标:不能依赖 `PushLayer()` // 累加 —— 组件内部可能为了自己的浮层临时提层(`PlotView` 的浮动面板), // 一旦它没有复位,后面所有组件的层号会被整体推高,遮挡关系全乱。 // 3. 层号必须与 `Render` 里给文本下发的层号同源(见 `ComponentLayer`)。 for (std::size_t index = 0; index < m_components.size(); ++index) { const ComponentEntry& entry = m_components[index]; m_debugBoxes.SetLayer(static_cast(index) * kLayersPerComponent); if (entry.component != nullptr) { m_debugBoxes.SetClip(entry.component->Area()); entry.component->BuildBoxes(m_debugBoxes); } } // 不属于任何组件的两块:面板分隔条(由宿主直接填充色块)、菜单 / 拾色器(浮层)。 // ⚠️ 浮层**不设裁剪矩形**:菜单的二级子菜单会展开到菜单本体之外,设裁剪会把它裁掉; // 它们自己会把本体登记为"遮挡矩形"(见 `PopupMenu::BuildBoxes`), // 由绘制阶段把更低层的 box 从浮层区域里挖掉。 m_debugBoxes.SetClip(gd::RectF()); m_debugBoxes.SetLayer(OverlayLayer(0)); m_debugBoxes.Add(m_splitterRect, 0); m_debugBoxes.SetLayer(OverlayLayer(1)); m_menu.BuildBoxes(m_debugBoxes); m_debugBoxes.SetLayer(OverlayLayer(2)); m_colorPicker.BuildBoxes(m_debugBoxes); UiDebugPaintBoxList(g, m_debugBoxes); } void Application::DispatchMenuResult(const MenuResult& result) { if (!result.valid) { return; } switch (result.scope) { case MenuScope::APP_SETTINGS: { // 应用设置菜单:全部条目都是**带勾选态的切换项**,菜单保持打开, // 勾选变化通过 `UpdateItemLabel` 立即回写(连续切换多项时不用反复打开菜单)。 if (result.id == kAppCmdShowLayoutBounds) { m_showLayoutBounds = !m_showLayoutBounds; m_menu.UpdateItemLabel(kAppCmdShowLayoutBounds, L"显示布局边界", m_showLayoutBounds); // 边界比组件矩形外扩 1px 且跨越所有装饰区 ⇒ 必须整帧重绘。 m_chromeDirty = true; } else if (result.id >= kAppCmdThemeBase && result.id <= kAppCmdThemeBase + static_cast(ThemeKind::SOLARIZED_DARK)) { const ThemeKind kind = static_cast(result.id - kAppCmdThemeBase); ApplyTheme(kind); // 回写勾选态:四项里只有当前主题打勾(子菜单项要用专用接口才会重建布局)。 for (int index = 0; index <= static_cast(ThemeKind::SOLARIZED_DARK); ++index) { const ThemeKind option = static_cast(index); m_menu.UpdateSubmenuItemLabel(kAppCmdThemeBase + index, ThemeName(option), m_themeKind == option); } } InvalidateRect(m_hwnd, nullptr, TRUE); break; } case MenuScope::SIGNAL_PANEL: { const bool needRelayout = m_panel.OnMenuCommand(result); // 「公式映射」等就地输入可能产生**非模态**警告(不使用 Windows 消息框), // 由绘图区底部的自绘提示浮层显示(与加载失败提示同一套)。 { std::wstring hint; if (m_panel.TakePendingHint(hint)) { m_plot.ShowPersistentHint(hint); } } 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_FREE_MARKER: // 「实验性功能 → 自由标记」:切换网格内跟随鼠标的纵向光标与固定数据点面板。 m_plot.ToggleLaneFreeMarker(laneIndex); break; case PLOT_CMD_LANE_RESAMPLE: // 「实验性功能 → 重采样」:切换重采样参数面板。 m_plot.ToggleLaneResample(laneIndex); break; case PLOT_CMD_LANE_CLIP: // 「实验性功能 → 选区截取」:切换选区截取面板。 m_plot.ToggleLaneClip(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(m_tabs.size()) && m_tabs.size() > 1) { const TabConfig keep = m_tabs[static_cast(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