diff --git a/Components/Application/Src/ApplicationCommands.cpp b/Components/Application/Src/ApplicationCommands.cpp index ba8c65a..4642e43 100644 --- a/Components/Application/Src/ApplicationCommands.cpp +++ b/Components/Application/Src/ApplicationCommands.cpp @@ -1,7 +1,10 @@ -// Application 实现分片 —— 本文件包含:标签页管理、工具栏动作、设置菜单、键盘、菜单结果派发。 +// Application 实现分片 —— 本文件保留:**数据命令**(分组/信号增删、移动与重排)、 +// 文件对话框、工具栏动作、键盘与鼠标分发、布局边界调试绘制。 // // 共用的常量与工具函数在 ApplicationInternal.hpp(命名空间 `appdetail`,全部 inline)。 -// 分片按"职责"划分:核心(状态/布局)、渲染、命令与菜单、加载。 +// 本文件原为 2,165 行的“大杂烩”,2026-11-04 代码审查按职责拆出: +// 标签页 → `ApplicationTabs.cpp`;派生/重采样/截取/克隆 → `ApplicationDerive.cpp`; +// 上下文菜单与设置菜单 → `ApplicationMenus.cpp`。划分见 `Code_Review_Plan.md` 的 P11。 #include "ApplicationInternal.hpp" #include "UiDebugBounds.hpp" @@ -9,182 +12,6 @@ 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); @@ -444,24 +271,6 @@ namespace jdi 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() { // 允许一次选择多个文件,便于跨文件对比信号。 @@ -747,728 +556,6 @@ namespace jdi 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; @@ -1492,446 +579,6 @@ namespace jdi 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) @@ -1996,170 +643,4 @@ namespace jdi 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 - diff --git a/Components/Application/Src/ApplicationDerive.cpp b/Components/Application/Src/ApplicationDerive.cpp new file mode 100644 index 0000000..424c1b4 --- /dev/null +++ b/Components/Application/Src/ApplicationDerive.cpp @@ -0,0 +1,789 @@ +// Application 实现分片 —— **派生信号**:派生烘焙(均值滤波/中心轴/弱峰调整)、 +// 重采样、选区截取(新建 / 就地破坏性截取)、克隆(普通 / 带调整)。 +// +// 共用的常量与工具函数在 ApplicationInternal.hpp(命名空间 `appdetail`,全部 inline)。 +// 本文件由 2026-11-04 代码审查从 `ApplicationCommands.cpp`(原 2,165 行)按职责拆出, +// **纯搬移、零逻辑改动**。分片划分见 `Documents/Code_Review_Plan.md` 的 P11。 +#include "ApplicationInternal.hpp" + +namespace jdi +{ + + 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::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; + } + +} // namespace jdi diff --git a/Components/Application/Src/ApplicationMenus.cpp b/Components/Application/Src/ApplicationMenus.cpp new file mode 100644 index 0000000..8c19bf7 --- /dev/null +++ b/Components/Application/Src/ApplicationMenus.cpp @@ -0,0 +1,409 @@ +// Application 实现分片 —— **菜单**:绘图区右键菜单(含「移动到…」子菜单)、 +// 菜单数值回写、主题切换、设置菜单,以及菜单结果的统一派发。 +// +// 共用的常量与工具函数在 ApplicationInternal.hpp(命名空间 `appdetail`,全部 inline)。 +// 本文件由 2026-11-04 代码审查从 `ApplicationCommands.cpp`(原 2,165 行)按职责拆出, +// **纯搬移、零逻辑改动**。分片划分见 `Documents/Code_Review_Plan.md` 的 P11。 +#include "ApplicationInternal.hpp" + +namespace jdi +{ + + 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); + } + + 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 diff --git a/Components/Application/Src/ApplicationTabs.cpp b/Components/Application/Src/ApplicationTabs.cpp new file mode 100644 index 0000000..c828dbe --- /dev/null +++ b/Components/Application/Src/ApplicationTabs.cpp @@ -0,0 +1,360 @@ +// Application 实现分片 —— **标签页**:新建/关闭/切换/重排、视图同步、窗口标题、 +// 标签栏结果回调与标签页右键菜单。 +// +// 共用的常量与工具函数在 ApplicationInternal.hpp(命名空间 `appdetail`,全部 inline)。 +// 本文件由 2026-11-04 代码审查从 `ApplicationCommands.cpp`(原 2,165 行)按职责拆出, +// **纯搬移、零逻辑改动**。分片划分见 `Documents/Code_Review_Plan.md` 的 P11: +// 标签页 = 本文件;派生/重采样/截取/克隆 = `ApplicationDerive.cpp`; +// 上下文菜单与设置菜单 = `ApplicationMenus.cpp`;其余命令 = `ApplicationCommands.cpp`。 +#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); + } + + // ⚠️ **新标签页不存在任何被点选的信号**(用户 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::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::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); + } + + 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); + } + + 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; + } + +} // namespace jdi diff --git a/Documents/Code_Review_Progress.md b/Documents/Code_Review_Progress.md index 6cfa834..db6d1da 100644 --- a/Documents/Code_Review_Progress.md +++ b/Documents/Code_Review_Progress.md @@ -78,4 +78,28 @@ map 中 `cp-demangle.o` 已无 `.text` 段分配。 **验证**:release 与 debug 均全量重建,零 error 零 warning(`-O0` 下也没有新警告)。 +### P7 面板状态/滚动量收敛为唯一派发点 —— ✅ 完成(提交 `7a3fefb`) + +**问题(屎山核心之一)**:`Lane` 里 7 种实验性功能各有 1 份 `LanePanelState` + 1 个滚动偏移 +(14 个字段)。旧实现在**每一处**“按种类取用”的地方各写一遍嵌套三元式或 `switch`: +`EdgePanelLayoutFor` 5 条 7 层嵌套三元式;标题栏拖拽、✕ 关闭、滚轮、滚动条、错开序数各写一遍。 +新增面板要改 20+ 处,漏一处就是「拖不动 / 关不掉 / 滚轮滚不到底」 +(`MARK`、`RESAMPLE`、`CLIP` 均真实踩过)。 + +**改动**:`PlotViewInternal.hpp` 新增唯一派发点 +`LanePanelEnabled` / `LanePanelStateOf` / `LanePanelScrollOf`(可写 + 只读)。 +这些 `switch` **穷举 7 种且不写 `default:`** ⇒ `-Wswitch` 在漏登记时编译报警 +(已用最小程序验证 `-Wswitch` 确实会报)。 + +调用点:布局 5 条三元式 → 3 行;标题栏拖拽 7 分支 switch → 1 次写入; +滚轮/滚动条/✕ 关闭/折叠切换全部改为穷举无 `default`。 + +**验证**:release + debug 零 warning;产物 **793,088 B 不变**(等价变换,说明只是结构收敛)。 + +**未做(受验证条件限制)**:尚未把 `EdgePanelLayoutFor` 的 957 行分支整体改成 +“每面板一行元数据表”。原因:没有可用的离屏像素对比工具(`.preflight` 已删除, +且崩溃会弹模态框),纯逻辑重写无法自证“像素完全一致”。已通过上面的派发收敛 +消除最容易出错的一类重复,剩余重复属于“几何算式本身”,风险高于收益。 + +