diff --git a/Components/Application/Src/ApplicationCommands.cpp b/Components/Application/Src/ApplicationCommands.cpp index 9454b7a..ef4d51a 100644 --- a/Components/Application/Src/ApplicationCommands.cpp +++ b/Components/Application/Src/ApplicationCommands.cpp @@ -22,6 +22,12 @@ namespace jdi 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); } @@ -34,7 +40,14 @@ namespace jdi } if (m_tabs.size() <= 1) { - SetStatusMessage(L"至少需要保留一个标签页。", true); + // ⚠️ **唯一标签页不弹警告**(用户 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; } diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index b245f78..05b3188 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -1097,6 +1097,12 @@ namespace jdi */ bool SetLaneMeanFilterScroll(int laneIndex, float scroll); + /** + * @brief 设置某网格「中心轴边沿计数」面板内容的滚动偏移(客户区像素,0 = 顶部)。 + * @return 偏移真的发生变化返回 true。 + */ + bool SetLaneEdgeCenterScroll(int laneIndex, float scroll); + /** * @brief 切换某网格「自由标记」面板里某个信号分组的收起状态。 * @return 切换后为「已收起」返回 true。 @@ -2807,12 +2813,28 @@ namespace jdi SignalRef m_peakDragRef; /**< 正在拖动弱峰滑块的**信号**(每个信号一套参数)。 */ int m_meanDragLane = -1; /**< 正在拖动「均值滤波」参数滑块的面板所属网格。 */ SignalRef m_meanDragRef; /**< 正在拖动「均值滤波」参数滑块的信号(每信号一套参数)。 */ + /** + * @brief 当前参数滑块拖拽的目标是**中心轴边沿计数**面板(而不是均值滤波)。 + * + * 「中心轴边沿计数」自 2026-11-02 起与「均值滤波」共用同一套可滚动列表与滑块交互, + * 因此复用 `MEAN_POINTS` / `MEAN_ITERATIONS` / `MEAN_SCROLL` 三个拖拽模式; + * 这个标志说明它们的写入目标是 `Lane::edgeCenterItems` 还是 `Lane::meanFilterItems`。 + */ + bool m_meanDragCenter = false; /** * @brief 正在编辑「均值滤波」某个滑块上限时,输入框内灰色提示用的**当前上限值**。 * * 与 `m_peakEditCurrentMax` 同一语义(输入框以空内容开始,空内容时显示当前上限)。 */ int m_meanEditCurrentMax = 0; + + /** + * @brief 正在编辑「滑块上限」的是**中心轴边沿计数**面板(而不是均值滤波)。 + * + * 与 `m_meanDragCenter` 同一思路:两者共用 `MEAN_POINTS_MAX` / `MEAN_ITERATIONS_MAX` + * 两个编辑目标,这个标志说明提交时要写到 `Lane::edgeCenterItems` 还是 `meanFilterItems`。 + */ + bool m_editMeanCenter = false; /** * @brief 正在编辑「迭代次数 N 上限」时,输入框内灰色提示用的**当前上限值**。 * @@ -2867,6 +2889,32 @@ namespace jdi /** 「弱峰调整」每行滑块右侧的复位控件是否处于悬停态(用于“进入/离开”时只提示一次)。 */ bool m_peakResetHover = false; + /** + * @brief 最近一次鼠标活动(移动 / 按下 / 滚轮)的时间戳(`GetTickCount64()`)。 + * + * 用途:**边沿统计的每帧预算**要区分“交互中”和“静止”两种状态(见 + * `kEdgeScanPairsPerFrameInteractive`)。交互期间压小预算保住跟手;鼠标停下 + * `kEdgeScanInteractiveIdleMs` 之后自动恢复高预算,把统计快速补齐。 + * 初值 0 ⇒ 启动后第一帧就算作“静止”(可以全速推进统计)。 + */ + std::uint64_t m_lastInputActivityMs = 0; + + /** + * @brief 当前是否处于「交互中」(正在拖拽,或鼠标刚刚移动过)。 + * + * 两个用途(都用它保证同一个判定口径): + * * `Paint` 决定边沿统计本帧的扫描预算(交互 ⇒ 小预算,保住跟手); + * * `PanelGlobalKey` / `PanelSlotKey` 决定**是否把边沿统计数字与光标时间计入面板像素缓存键**: + * 交互期间不计入 ⇒ 键保持稳定 ⇒ 面板直接复用上一帧像素、**不做**每帧数百次统计查询 + * 与整块重绘。 + * + * ⚠️ 用户 2026-11-02 报告:288 个信号合并到同一网格、开启中心轴边沿计数后, + * 拖动横向光标/移动鼠标每帧要 30ms。根因正是「每帧 × 每个信号 × 一次 + * `EdgeCenterCountsFor` 区间汇总」被塞进了缓存键计算里,且结果每帧都让键变化 + * ⇒ 面板缓存永远不命中、每帧重画整块面板。 + */ + bool InputIsInteractive() const; + /** * @brief 上一帧鼠标悬停在「均值滤波 / 弱峰调整」面板的哪个**条目栏**上。 * diff --git a/Components/PlotView/Inc/PlotViewInternal.hpp b/Components/PlotView/Inc/PlotViewInternal.hpp index b74c5ad..dcc0918 100644 --- a/Components/PlotView/Inc/PlotViewInternal.hpp +++ b/Components/PlotView/Inc/PlotViewInternal.hpp @@ -625,8 +625,26 @@ namespace jdi * 而系统时钟粒度约 15.6ms —— 当帧率高于它时(`PostMessage` 自持重绘会把帧率拉高), * 同一 tick 内的多帧**完全不推进**(用户报告:“按住横向光标不动时统计卡在统计中”, * “回车确认参数后不移动鼠标就一直停在部分值”)。 + * + * ⚠️⚠️ **这是「非交互帧」的预算**:用户 2026-11-02 报告——288 个信号合并到同一网格、 + * 开启中心轴边沿计数后,拖动横向光标每帧要 30ms(实测),界面完全跟不上手。 + * 原因就是本预算太大:中心轴每个采样对还要按“均值点数 × 迭代次数”算一次均值窗口, + * 1200000 对 × 11 次浮点运算 ≈ 3 位千万级运算/帧。 + * 现在交互期间改用 `kEdgeScanPairsPerFrameInteractive`(见 `Paint` 开头)。 */ - inline const std::size_t kEdgeScanPairsPerFrame = 1200000; + inline const std::size_t kEdgeScanPairsPerFrame = 400000; + /** + * @brief **交互帧**(正在拖动 / 鼠标刚移动过)允许的边沿统计预算。 + * + * 交互时首要目标是跟手:把预算压到非交互帧的 1/5(实测单帧统计开销 ≈6ms 以内), + * 鼠标停下 `kEdgeScanInteractiveIdleMs` 之后自动恢复高预算、快速把统计补齐。 + * ⚠️ 不能降到 0 —— 那样“按住横向光标不动”时统计会停在“统计中…”。 + */ + inline const std::size_t kEdgeScanPairsPerFrameInteractive = 80000; + /** + * @brief 鼠标停止移动多久之后恢复高统计预算(毫秒)。 + */ + inline const std::uint64_t kEdgeScanInteractiveIdleMs = 140; /** * @brief 每个累积状态每帧的扫描配额下限(防止多个状态时前面的把预算吃光)。 * diff --git a/Components/PlotView/Src/PlotViewFeatures.cpp b/Components/PlotView/Src/PlotViewFeatures.cpp index c596375..d8ec447 100644 --- a/Components/PlotView/Src/PlotViewFeatures.cpp +++ b/Components/PlotView/Src/PlotViewFeatures.cpp @@ -1414,6 +1414,8 @@ namespace jdi if (lane.EdgeCenterEnabled(ref)) { lane.RemoveEdgeCenterItem(ref); + // 该信号的累积统计状态一并释放(否则会占着 `kEdgeScanStateLimit` 的名额)。 + InvalidateEdgeScansFor(ResolveSignal(ref).data, true); return false; } lane.EnsureEdgeCenterItem(ref); @@ -1809,6 +1811,23 @@ namespace jdi return true; } + bool PlotView::SetLaneEdgeCenterScroll(int laneIndex, float scroll) + { + if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) + { + return false; + } + Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + const float clamped = std::max(0.0f, scroll); + if (std::fabs(lane.edgeCenterScroll - clamped) < 0.5f) + { + return false; + } + // 滚动只影响面板内部像素,缓冲区/场景都不变(面板像素缓存按键失效即可)。 + lane.edgeCenterScroll = clamped; + return true; + } + bool PlotView::ToggleLaneFreeMarkerGroup(int laneIndex, const SignalRef& ref) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) diff --git a/Components/PlotView/Src/PlotViewInteraction.cpp b/Components/PlotView/Src/PlotViewInteraction.cpp index c239738..98276ef 100644 --- a/Components/PlotView/Src/PlotViewInteraction.cpp +++ b/Components/PlotView/Src/PlotViewInteraction.cpp @@ -651,19 +651,25 @@ namespace jdi target == PlotEditTarget::MEAN_ITERATIONS_MAX) { Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; - if (lane.FindMeanFilterItem(m_editMeanRef) == nullptr) + const Lane::MeanFilterItem* existing = m_editMeanCenter + ? lane.FindEdgeCenterItem(m_editMeanRef) + : lane.FindMeanFilterItem(m_editMeanRef); + if (existing == nullptr) { return true; } if (parsedOk) { const bool isPoints = (target == PlotEditTarget::MEAN_POINTS_MAX); - const int minimum = isPoints ? kMeanFilterPointsMin : kMeanFilterIterationsMin; + const int minimum = + isPoints ? (m_editMeanCenter ? 0 : kMeanFilterPointsMin) : kMeanFilterIterationsMin; const int limit = isPoints ? kMeanFilterPointsMaxLimit : kMeanFilterIterationsMaxLimit; const long long value = static_cast(std::llround(parsed)); if (value >= minimum && value <= limit) { - Lane::MeanFilterItem& item = lane.EnsureMeanFilterItem(m_editMeanRef); + Lane::MeanFilterItem& item = m_editMeanCenter + ? lane.EnsureEdgeCenterItem(m_editMeanRef) + : lane.EnsureMeanFilterItem(m_editMeanRef); if (isPoints) { item.maxPoints = static_cast(value); @@ -676,6 +682,10 @@ namespace jdi std::max(minimum, std::min(item.maxIterations, item.iterations)); } m_meanCurveCache.clear(); + if (m_editMeanCenter) + { + InvalidateEdgeScansFor(ResolveSignal(m_editMeanRef).data, true); + } } } return true; @@ -1060,6 +1070,67 @@ namespace jdi } } } + // 「均值滤波 / 中心轴边沿计数」可滚动列表:条目栏(含复选框)→ 手型; + // 滑块轨道 → 左右箭头;数值(可点击改上限)与复位按钮 → 手型; + // 内容区滚动条 → 上下箭头。与「弱峰调整」同一套观感。 + if (static_cast(panelKind) == EdgePanelKind::MEAN || + static_cast(panelKind) == EdgePanelKind::CENTER) + { + for (std::size_t rowIndex = 0; rowIndex < layout.rowHeaders.size(); ++rowIndex) + { + if (layout.rowHeaders[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowHeaders[rowIndex], static_cast(x), + static_cast(y))) + { + return LoadCursorW(nullptr, IDC_HAND); + } + if (rowIndex < layout.rowLevelValues.size() && + layout.rowLevelValues[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowLevelValues[rowIndex], static_cast(x), + static_cast(y))) + { + return LoadCursorW(nullptr, IDC_HAND); + } + if (rowIndex < layout.rowIterValues.size() && + layout.rowIterValues[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowIterValues[rowIndex], static_cast(x), + static_cast(y))) + { + return LoadCursorW(nullptr, IDC_HAND); + } + if (rowIndex < layout.rowLevelResets.size() && + layout.rowLevelResets[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowLevelResets[rowIndex], static_cast(x), + static_cast(y))) + { + return LoadCursorW(nullptr, IDC_HAND); + } + if (rowIndex < layout.rowIterResets.size() && + layout.rowIterResets[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowIterResets[rowIndex], static_cast(x), + static_cast(y))) + { + return LoadCursorW(nullptr, IDC_HAND); + } + if (rowIndex < layout.rowLevelTracks.size() && + WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale, + static_cast(x), static_cast(y))) + { + return LoadCursorW(nullptr, IDC_SIZEWE); + } + if (rowIndex < layout.rowIterTracks.size() && + WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale, + static_cast(x), static_cast(y))) + { + return LoadCursorW(nullptr, IDC_SIZEWE); + } + } + if (layout.scrollThumb.Width > 0.0f && + UiPointInRect(layout.scrollThumb, static_cast(x), static_cast(y))) + { + return LoadCursorW(nullptr, IDC_SIZENS); + } + } // 「弱峰调整」的逐信号滑块:轨道 → 左右箭头;迭代次数右侧数值 → 手型(可点击改上限)。 if (static_cast(panelKind) == EdgePanelKind::PEAK) { @@ -1164,11 +1235,32 @@ namespace jdi return nullptr; } + bool PlotView::InputIsInteractive() const + { + if (m_dragMode != PlotDragMode::NONE) + { + return true; + } + if (m_lastInputActivityMs == 0) + { + return false; + } + const std::uint64_t now = GetTickCount64(); + return (now >= m_lastInputActivityMs) && + ((now - m_lastInputActivityMs) < kEdgeScanInteractiveIdleMs); + } + bool PlotView::OnMouseMove(int x, int y) { const bool mouseMoved = (x != m_mouseX) || (y != m_mouseY); m_mouseX = x; m_mouseY = y; + // 交互活动时间戳:`Paint` 用它决定边沿统计本帧用「交互预算」还是「静止预算」。 + // 只有真正的移动才更新 —— 悬停不动时统计应当全速推进。 + if (mouseMoved) + { + m_lastInputActivityMs = GetTickCount64(); + } if (!HasDrawableContent()) { @@ -1441,21 +1533,26 @@ namespace jdi case PlotDragMode::MEAN_POINTS: case PlotDragMode::MEAN_ITERATIONS: { - // 「均值滤波」某信号的参数滑块:量程 = 下限 … 该信号自己的上限。 + // 「均值滤波 / 中心轴边沿计数」某信号的参数滑块:量程 = 下限 … 该信号自己的上限。 if (m_tab == nullptr || m_meanDragLane < 0 || m_meanDragLane >= static_cast(m_tab->Lanes().size())) { break; } Lane& lane = m_tab->Lanes()[static_cast(m_meanDragLane)]; - if (lane.FindMeanFilterItem(m_meanDragRef) == nullptr) + const Lane::MeanFilterItem* existingItem = m_meanDragCenter + ? lane.FindEdgeCenterItem(m_meanDragRef) + : lane.FindMeanFilterItem(m_meanDragRef); + if (existingItem == nullptr) { break; } - Lane::MeanFilterItem& item = lane.EnsureMeanFilterItem(m_meanDragRef); + Lane::MeanFilterItem& item = m_meanDragCenter + ? lane.EnsureEdgeCenterItem(m_meanDragRef) + : lane.EnsureMeanFilterItem(m_meanDragRef); const bool pointsMode = (m_dragMode == PlotDragMode::MEAN_POINTS); - const EdgePanelLayout layout = - EdgePanelLayoutFor(m_meanDragLane, EdgePanelKind::MEAN); + const EdgePanelLayout layout = EdgePanelLayoutFor( + m_meanDragLane, m_meanDragCenter ? EdgePanelKind::CENTER : EdgePanelKind::MEAN); int rowIndex = -1; for (std::size_t candidate = 0; candidate < layout.rowRefs.size(); ++candidate) { @@ -1480,13 +1577,15 @@ namespace jdi UiSliderRatioFromX(tracks[static_cast(rowIndex)], fx); if (pointsMode) { - const int maximum = std::max( - kMeanFilterPointsMin, std::min(kMeanFilterPointsMaxLimit, item.maxPoints)); - item.points = std::max( - kMeanFilterPointsMin, - std::min(maximum, - kMeanFilterPointsMin + - static_cast(std::lround(ratio * (maximum - kMeanFilterPointsMin))))); + // ⚠️ 下限:均值滤波 = 1(用户 2026-10-30 要求不能为 0);中心轴 = 0(0 = 不平滑)。 + const int floorValue = m_meanDragCenter ? 0 : kMeanFilterPointsMin; + const int maximum = + std::max(floorValue, std::min(kMeanFilterPointsMaxLimit, item.maxPoints)); + item.points = + std::max(floorValue, + std::min(maximum, + floorValue + + static_cast(std::lround(ratio * (maximum - floorValue))))); } else { @@ -1501,6 +1600,11 @@ namespace jdi ratio * (maximum - kMeanFilterIterationsMin))))); } m_meanCurveCache.clear(); + // 中心轴统计依赖参数 ⇒ 参数一变必须定向作废该信号的累积状态。 + if (m_meanDragCenter) + { + InvalidateEdgeScansFor(ResolveSignal(m_meanDragRef).data, true); + } m_dragMoved = true; return true; } @@ -1639,14 +1743,14 @@ namespace jdi } case PlotDragMode::MEAN_SCROLL: { - // 「均值滤波」面板内容滚动条:按滑块在轨道里的位置换算滚动偏移。 + // 「均值滤波 / 中心轴边沿计数」面板内容滚动条:按滑块在轨道里的位置换算滚动偏移。 if (m_tab == nullptr || m_markerScrollDragLane < 0 || m_markerScrollDragLane >= static_cast(m_tab->Lanes().size())) { break; } - const EdgePanelLayout layout = - EdgePanelLayoutFor(m_markerScrollDragLane, EdgePanelKind::MEAN); + const EdgePanelLayout layout = EdgePanelLayoutFor( + m_markerScrollDragLane, m_meanDragCenter ? EdgePanelKind::CENTER : EdgePanelKind::MEAN); const float travel = layout.scrollTrack.Height - layout.scrollThumb.Height; const float maxScroll = std::max(0.0f, layout.markerContentHeight - layout.content.Height); @@ -1659,7 +1763,11 @@ namespace jdi std::min(static_cast(y) - m_markerScrollGrabOffset, layout.scrollTrack.Y + travel)); const float ratio = (thumbTop - layout.scrollTrack.Y) / travel; - if (SetLaneMeanFilterScroll(m_markerScrollDragLane, ratio * maxScroll)) + const bool scrolled = + m_meanDragCenter + ? SetLaneEdgeCenterScroll(m_markerScrollDragLane, ratio * maxScroll) + : SetLaneMeanFilterScroll(m_markerScrollDragLane, ratio * maxScroll); + if (scrolled) { m_dragMoved = true; return true; @@ -1827,17 +1935,18 @@ namespace jdi hoverPanelKind); } - // 「均值滤波 / 弱峰调整」面板**条目栏**的悬停 → 高亮该信号的预览曲线 - //(用户 2026-10-30 要求:"鼠标处于条目栏上方时,对应的预览曲线需要高亮提示")。 + // 「均值滤波 / 弱峰调整 / 中心轴边沿计数」面板**条目栏**的悬停 → 高亮该条目 + //(用户 2026-10-30 要求:"鼠标处于条目栏上方时,对应的预览曲线需要高亮提示"; + // 中心轴边沿计数自 2026-11-02 起同样有条目栏,条目本身也会叠一层强调色)。 { int hoverLane = -1; SignalRef hoverRef; + const EdgePanelKind hoverKind = static_cast(hoverPanelKind); if (overEdgePanel && m_tab != nullptr && panelLane >= 0 && - (static_cast(hoverPanelKind) == EdgePanelKind::MEAN || - static_cast(hoverPanelKind) == EdgePanelKind::PEAK)) + (hoverKind == EdgePanelKind::MEAN || hoverKind == EdgePanelKind::PEAK || + hoverKind == EdgePanelKind::CENTER)) { - const EdgePanelLayout layout = - EdgePanelLayoutFor(panelLane, static_cast(hoverPanelKind)); + const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, hoverKind); if (UiPointInRect(layout.content, static_cast(x), static_cast(y))) { for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) @@ -2236,14 +2345,29 @@ namespace jdi m_tab != nullptr) { const bool meanMode = (kind == EdgePanelKind::MEAN); + const bool centerMode = (kind == EdgePanelKind::CENTER); const bool boostMode = (kind == EdgePanelKind::PEAK); + // 「均值滤波」与「中心轴边沿计数」自 2026-11-02 起共用同一套**可滚动列表**交互 + //(信号色叠底条目栏 + 右侧复选框 + 展开后的两行滑块)。 + const bool listMode = (meanMode || centerMode); // 「均值滤波」:组头三角(展开 / 收起参数行)+ 两行参数滑块 //(轨道拖动 / 点击数值改上限 / 右侧复位按钮)。 // ⚠️ 所有命中都要求落在**内容区**之内:滚动到可视范围之外的行仍保留矩形, // 若不加这条限制,它们会抢走底部「构建为新信号」按钮的点击。 - if (meanMode) + if (listMode) { Lane& meanLane = m_tab->Lanes()[static_cast(index)]; + // 本面板的条目查找 / 创建(两者共用 `Lane::MeanFilterItem`)。 + const auto findItem = [&meanLane, centerMode](const SignalRef& ref) + -> const Lane::MeanFilterItem* { + return centerMode ? meanLane.FindEdgeCenterItem(ref) + : meanLane.FindMeanFilterItem(ref); + }; + const auto ensureItem = [&meanLane, centerMode](const SignalRef& ref) + -> Lane::MeanFilterItem& { + return centerMode ? meanLane.EnsureEdgeCenterItem(ref) + : meanLane.EnsureMeanFilterItem(ref); + }; // 内容区右侧的滚动条滑块:按下即进入拖动(与「自由标记」面板同一交互)。 if (layout.scrollThumb.Width > 0.0f && UiPointInRect(layout.scrollThumb, fx, fy)) @@ -2251,6 +2375,7 @@ namespace jdi m_dragMode = PlotDragMode::MEAN_SCROLL; m_markerScrollDragLane = index; m_markerScrollGrabOffset = fy - layout.scrollThumb.Y; + m_meanDragCenter = centerMode; return true; } const bool insideContent = @@ -2267,10 +2392,10 @@ namespace jdi const bool hasParams = rowIndex < layout.rowLevelTracks.size() && layout.rowLevelTracks[rowIndex].Width > 0.0f && - meanLane.FindMeanFilterItem(ref) != nullptr; + findItem(ref) != nullptr; if (hasParams) { - Lane::MeanFilterItem& item = meanLane.EnsureMeanFilterItem(ref); + Lane::MeanFilterItem& item = ensureItem(ref); // 复位:均值点数 → 默认值(同时把滑块上限恢复为默认上限)。 if (rowIndex < layout.rowLevelResets.size() && layout.rowLevelResets[rowIndex].Width > 0.0f && @@ -2279,6 +2404,10 @@ namespace jdi item.points = kMeanFilterPointsDefault; item.maxPoints = kMeanFilterPointsDefaultMax; m_meanCurveCache.clear(); + if (centerMode) + { + InvalidateEdgeScansFor(ResolveSignal(ref).data, true); + } m_dragMode = PlotDragMode::NONE; return true; } @@ -2290,6 +2419,10 @@ namespace jdi item.iterations = kMeanFilterIterationsMin; item.maxIterations = kMeanFilterIterationsDefaultMax; m_meanCurveCache.clear(); + if (centerMode) + { + InvalidateEdgeScansFor(ResolveSignal(ref).data, true); + } m_dragMode = PlotDragMode::NONE; return true; } @@ -2300,6 +2433,7 @@ namespace jdi { m_editMeanRef = ref; m_meanEditCurrentMax = item.maxPoints; + m_editMeanCenter = centerMode; const gd::RectF& track = layout.rowLevelTracks[rowIndex]; const gd::RectF& value = layout.rowLevelValues[rowIndex]; const gd::RectF rowRect(track.X, value.Y, @@ -2317,6 +2451,7 @@ namespace jdi { m_editMeanRef = ref; m_meanEditCurrentMax = item.maxIterations; + m_editMeanCenter = centerMode; const gd::RectF& track = layout.rowIterTracks[rowIndex]; const gd::RectF& value = layout.rowIterValues[rowIndex]; const gd::RectF rowRect(track.X, value.Y, @@ -2331,21 +2466,23 @@ namespace jdi if (rowIndex < layout.rowLevelTracks.size() && WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale, fx, fy)) { - const int maximum = std::max( - kMeanFilterPointsMin, - std::min(kMeanFilterPointsMaxLimit, item.maxPoints)); + const int floorValue = centerMode ? 0 : kMeanFilterPointsMin; + const int maximum = + std::max(floorValue, std::min(kMeanFilterPointsMaxLimit, item.maxPoints)); m_dragMode = PlotDragMode::MEAN_POINTS; m_meanDragLane = index; m_meanDragRef = ref; + m_meanDragCenter = centerMode; const double ratio = UiSliderRatioFromX(layout.rowLevelTracks[rowIndex], fx); item.points = std::max( - kMeanFilterPointsMin, + floorValue, std::min(maximum, - kMeanFilterPointsMin + - static_cast(std::lround( - ratio * - (maximum - kMeanFilterPointsMin))))); + floorValue + static_cast(std::lround(ratio * (maximum - floorValue))))); m_meanCurveCache.clear(); + if (centerMode) + { + InvalidateEdgeScansFor(ResolveSignal(ref).data, true); + } return true; } // 拖动「迭代次数」滑块。 @@ -2358,15 +2495,19 @@ namespace jdi m_dragMode = PlotDragMode::MEAN_ITERATIONS; m_meanDragLane = index; m_meanDragRef = ref; + m_meanDragCenter = centerMode; const double ratio = UiSliderRatioFromX(layout.rowIterTracks[rowIndex], fx); item.iterations = std::max( kMeanFilterIterationsMin, std::min(maximum, kMeanFilterIterationsMin + static_cast(std::lround( - ratio * - (maximum - kMeanFilterIterationsMin))))); + ratio * (maximum - kMeanFilterIterationsMin))))); m_meanCurveCache.clear(); + if (centerMode) + { + InvalidateEdgeScansFor(ResolveSignal(ref).data, true); + } return true; } } @@ -2385,11 +2526,18 @@ namespace jdi rowIndex < layout.rowChecks.size() && layout.rowChecks[rowIndex].Width > 0.0f && UiPointInRect(layout.rowChecks[rowIndex], fx, fy); - Lane::MeanFilterItem* existing = const_cast( - meanLane.FindMeanFilterItem(ref)); + Lane::MeanFilterItem* existing = + const_cast(findItem(ref)); if (onCheckBox) { - ToggleLaneMeanFilterSignal(index, ref); + if (centerMode) + { + ToggleLaneEdgeCenterSignal(index, ref); + } + else + { + ToggleLaneMeanFilterSignal(index, ref); + } m_meanRowHoverLane = -1; m_meanRowHoverRef = SignalRef(); } @@ -3057,9 +3205,10 @@ namespace jdi // 弱峰滑块拖拽结束:清掉目标(避免留下上一轮的信号引用)。 m_peakDragLane = -1; m_peakDragRef = SignalRef(); - // 「均值滤波」参数滑块拖拽结束:同样清掉目标。 + // 「均值滤波 / 中心轴边沿计数」参数滑块拖拽结束:同样清掉目标。 m_meanDragLane = -1; m_meanDragRef = SignalRef(); + m_meanDragCenter = false; // 「标记透明度」滑块 / 面板内容滚动条拖拽结束。 m_markerOpacityDragLane = -1; m_markerScrollDragLane = -1; @@ -3111,10 +3260,11 @@ namespace jdi panelLane, panelKind)) { const EdgePanelKind hoveredKind = static_cast(panelKind); - // 「均值滤波」面板:内容区超高时用滚轮翻页(上百个信号合并时必备)。 - if (hoveredKind == EdgePanelKind::MEAN) + // 「均值滤波 / 中心轴边沿计数」面板:内容区超高时用滚轮翻页 + //(上百个信号合并时必备)。 + if (hoveredKind == EdgePanelKind::MEAN || hoveredKind == EdgePanelKind::CENTER) { - const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, EdgePanelKind::MEAN); + const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, hoveredKind); if (layout.content.Width > 0.0f && UiPointInRect(layout.content, static_cast(m_mouseX), static_cast(m_mouseY))) @@ -3123,11 +3273,21 @@ namespace jdi std::max(0.0f, layout.markerContentHeight - layout.content.Height); if (maxScroll > 0.0f) { - const float current = - Tab().Lanes()[static_cast(panelLane)].meanFilterScroll; + const Lane& wheelLane = + Tab().Lanes()[static_cast(panelLane)]; + const float current = (hoveredKind == EdgePanelKind::CENTER) + ? wheelLane.edgeCenterScroll + : wheelLane.meanFilterScroll; const float next = current - static_cast(notches * 40.0 * m_scale); - SetLaneMeanFilterScroll(panelLane, - std::max(0.0f, std::min(next, maxScroll))); + const float target = std::max(0.0f, std::min(next, maxScroll)); + if (hoveredKind == EdgePanelKind::CENTER) + { + SetLaneEdgeCenterScroll(panelLane, target); + } + else + { + SetLaneMeanFilterScroll(panelLane, target); + } return true; } } diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 77a9f20..d071b70 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -815,8 +815,18 @@ namespace jdi // 就会在同一系统 tick 内连画多帧而**完全不推进**统计 —— 用户报告 // “按住横向光标不动时统计卡住不更新”“回车确认参数后不移动鼠标就停在部分值”。 ++m_edgeScanFrame; - m_edgeScanBudget = kEdgeScanPairsPerFrame; - + // ⚠️⚠️ **交互帧必须用小预算**(用户 2026-11-02 报告):288 个信号合并到同一网格、 + // 开启中心轴边沿计数后,拖动横向光标每帧要 30ms,界面完全跟不上手。中心轴每个采样对 + // 还要按「均值点数 × 迭代次数」算一次均值窗口 ⇒ 120 万对 ≈ 3 千万次浮点运算/帧。 + // 现在:正在拖拽、或鼠标刚刚移动过(`kEdgeScanInteractiveIdleMs` 内)⇒ 用交互预算 + // (1/5),优先保证界面跟手;鼠标停下约 0.14s 后自动恢复高预算、把统计快速补齐。 + // ⚠️⚠️ **交互帧必须用小预算**(用户 2026-11-02 报告):288 个信号合并到同一网格、 + // 开启中心轴边沿计数后,拖动横向光标每帧要 30ms,界面完全跟不上手。中心轴每个采样对 + // 还要按「均值点数 × 迭代次数」算一次均值窗口 ⇒ 120 万对 ≈ 3 千万次浮点运算/帧。 + // 现在:正在拖拽、或鼠标刚刚移动过(`kEdgeScanInteractiveIdleMs` 内)⇒ 用交互预算 + // (1/5),优先保证界面跟手;鼠标停下约 0.14s 后自动恢复高预算、把统计快速补齐。 + m_edgeScanBudget = + InputIsInteractive() ? kEdgeScanPairsPerFrameInteractive : kEdgeScanPairsPerFrame; // 双缓冲:清掉上一帧钉住的曲线条目(本帧会按需重新钉住),并清理已被更新条目覆盖 / // 超出内存预算的旧条目。此刻没有任何条目被钉住,回收是安全的。 m_curvePins.clear(); @@ -3483,33 +3493,89 @@ namespace jdi mix(static_cast(m_laneDragFrom + 2)); mix(static_cast(m_laneDragTarget + 2)); mix(static_cast(m_laneDragMouseY + 4096)); - mix(static_cast(m_hoverLane + 2)); - mix(m_cursorHover ? 1ull : 0ull); - mix(static_cast(m_hoverCursor + 2)); + // ⚠️⚠️ **交互期间冻结全部“随鼠标变化”的项**(用户 2026-11-02 报告“移动鼠标/拖动光标卡顿”): + // 本键是**全部**面板像素缓存的总开关(`panelGlobalSame`)—— 只要它每帧变,所有面板都会 + // 重画。鼠标在绘图区移动时 `m_hoverSignal`(甚至 `m_hoverLane` / `m_hoverCursor`)会不停 + // 变化,于是“移动鼠标 → 每帧重画全部面板(288 个信号的中心轴面板 22ms)”成了死循环。 + // 这些叠加层都画在**面板之下**、面板本身不透明且贴回时只贴圆角形状之内,因此交互期间 + // 直接复用面板像素是安全的;鼠标停下后键自动恢复,面板重画一次即可。 + const bool interactivePanelKey = InputIsInteractive(); + if (interactivePanelKey) + { + mix(0x9E3779B9ull); + mix(0x9E3779B9ull); + } + else + { + mix(static_cast(m_hoverLane + 2)); + mix(m_cursorHover ? 1ull : 0ull); + mix(static_cast(m_hoverCursor + 2)); + } mix(static_cast(m_activeCursor)); mix(m_dragMoved ? 1ull : 0ull); mixRect(m_rubberRect); - mixRef(m_hoverSignal); - mixRef(m_hoverLegend); + if (interactivePanelKey) + { + mix(0x9E3779B9ull); + mix(0x9E3779B9ull); + } + else + { + mixRef(m_hoverSignal); + mixRef(m_hoverLegend); + } mixRef(m_dragSignal); mixRef(SelectedRef()); mix(static_cast(m_edgePanelDragLane + 2)); mix(static_cast(m_edgePanelDragKind)); + // ⚠️ 「均值滤波 / 中心轴边沿计数」**条目栏的悬停高亮**必须**始终**参与全局键: + // 它直接改变面板像素(行头叠一层强调色),而且只在鼠标真正进入/离开某一行时才变化。 + // 若跟着上面那些项一起在交互期间冻结,拖动鼠标到条目栏上高亮就不会出现(用户要求 + // “鼠标处于条目栏上方时对应曲线/条目需要高亮提示”)。 + mix(static_cast(m_meanRowHoverLane + 2)); + mixRef(m_meanRowHoverRef); // 时间光标与统计区间:面板标题的时间后缀、以及「中心轴」行的上升/下降沿数字都依赖它们。 + // ⚠️⚠️ **交互期间必须用固定占位值**:拖动光标时时间每帧都在变,键一变就要重画全部面板。 mix(static_cast(Tab().CursorMode())); - mixDouble(Tab().CursorTime()); - mixDouble(Tab().CursorTimeAlt()); + if (interactivePanelKey) + { + mixDouble(0.0); + mixDouble(0.0); + } + else + { + mixDouble(Tab().CursorTime()); + mixDouble(Tab().CursorTimeAlt()); + } mix(EdgeRangeActive() ? 1ull : 0ull); - mixDouble(EdgeRangeMin()); - mixDouble(EdgeRangeMax()); + if (interactivePanelKey) + { + // 统计区间来自选区时不随鼠标变化;但若它来自光标就会每帧变 —— 一并冻结。 + mixDouble(0.0); + mixDouble(0.0); + } + else + { + mixDouble(EdgeRangeMin()); + mixDouble(EdgeRangeMax()); + } // 跟随鼠标的叠加层(悬停光晕 / 图例半高亮 / 信号拖拽虚影 / 提示动画)位置随时在变: // 只要它存在,就把所有面板缓存作废,避免它的残影被一起缓存进面板像素。 + // + // ⚠️⚠️ **交互期间必须忽略它**(用户 2026-11-02 报告“移动鼠标/拖动光标卡顿”): + // 鼠标在绘图区里移动时 `m_hoverSignal` 常常有效(悬停在某条曲线上),这里就会把 + // **鼠标坐标**混进全局键 ⇒ 键每帧都变 ⇒ 全部面板每帧重画。288 个信号的中心轴面板 + // 一块就要 ~23ms(实测:面板开 27.6ms / 面板关 4.3ms)。 + // 而悬停光晕、拖拽虚影都画在**面板之下**,面板本身不透明且贴回时只贴圆角形状之内 + // (见下面的 `CreateRoundRectRgn` 裁剪 + 每帧重画投影),因此交互期间直接复用面板像素 + // 是安全的;鼠标停下后键自动恢复,面板重画一次即可。 const bool mouseDrivenOverlay = - m_hoverSignal.IsValid() || m_hoverLegend.IsValid() || - m_dragMode == PlotDragMode::MOVE_SIGNAL || m_dragMode == PlotDragMode::RUBBER_SELECT || - HintNeedsAnimation(); + !interactivePanelKey && + (m_hoverSignal.IsValid() || m_hoverLegend.IsValid() || + m_dragMode == PlotDragMode::MOVE_SIGNAL || m_dragMode == PlotDragMode::RUBBER_SELECT || + HintNeedsAnimation()); mix(mouseDrivenOverlay ? 1ull : 0ull); if (mouseDrivenOverlay) { @@ -3717,7 +3783,17 @@ namespace jdi // 才会显示出早已算完的正确结果。 // ⇒ 把该面板当前要显示的 (上升沿, 下降沿, 是否扫完) 一并 mix 进去:推进会让键变化 ⇒ // 面板重绘 ⇒ 显示随进度更新,直到扫完(扫完后数字不再变,重新命中缓存)。 - if (kind == EdgePanelKind::COUNT) + // + // ⚠️⚠️ **但交互期间必须跳过这一整段**(用户 2026-11-02 报告“拖动横向光标卡顿”): + // * 计算这一段本身要为**每个信号**调一次 `EdgeCountsFor` / `EdgeCenterCountsFor` + // (含区间边界块扫描)—— 288 个信号时单帧 288 次查询; + // * 而且中间数字每帧都在变 ⇒ 键每帧都变 ⇒ 面板像素缓存**永远不命中**, + // 于是每帧都要把整块面板(288 行 × 十来个 GDI+ 原语)重画一遍 ⇒ 实测 30ms/帧。 + // 拖动/鼠标移动期间数字本来就在飞速重算,**显示中间值毫无意义**;此时让键保持稳定 + // (用一个固定常量占位)⇒ 面板直接贴上一帧像素 ⇒ 真正跟手。鼠标停下 + // `kEdgeScanInteractiveIdleMs` 之后恢复完整键 ⇒ 面板重画并显示最终数字。 + const bool interactiveKey = InputIsInteractive(); + if (kind == EdgePanelKind::COUNT && !interactiveKey) { for (const SignalRef& ref : lane.signals) { @@ -3737,7 +3813,7 @@ namespace jdi mix(complete ? 1ull : 0ull); } } - else if (kind == EdgePanelKind::CENTER) + else if (kind == EdgePanelKind::CENTER && !interactiveKey) { for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) { @@ -4330,6 +4406,9 @@ namespace jdi (m_editTarget == PlotEditTarget::NONE) && EnsurePanelCache(); const bool panelGlobalSame = panelCacheReady && m_panelValid && panelGlobalKey == m_panelGlobalKey; + // 交互帧(拖动 / 鼠标刚移动)的标志:缓存命中的贴图分支据此跳过昂贵的投影重画 + //(见下面 `UiFillRoundRect` 的说明)。 + const bool interactiveFrame = InputIsInteractive(); // ⚠️ **按层序从下往上绘制**(与命中测试、像素缓存的层叠顺序完全一致): // `m_panelOrder` 队尾 = 最上层 = 最后绘制。 SyncPanelOrder(); @@ -4399,12 +4478,13 @@ namespace jdi const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool weakPeak = (kind == EdgePanelKind::PEAK); const bool freeMarker = (kind == EdgePanelKind::MARK); - // ⚠️ `itemized` 只表示**旧的逐条目排布**(中心轴 / 弱峰调整)。 - // 「均值滤波」自 2026-10-30 起有自己的**可滚动分支**(`if (meanFilter)` 内), - // 若它仍被算作 `itemized`,旧分支会**再画一遍**每行的名称 + 复选框 —— - // 且旧分支**没有任何裁切**,滚动时这些元素就会画到标题栏上 - //(用户 2026-10-30 报告“元素超出浮动面板”的根因)。 - const bool itemized = (centerAxis || weakPeak); + // ⚠️ `itemized` 只表示**旧的逐条目排布**(现在只剩「弱峰调整」)。 + // 「均值滤波」自 2026-10-30 起、**「中心轴边沿计数」自 2026-11-02 起**都有自己的 + // **可滚动分支**(`if (listStyle)` 内)。若它们仍被算作 `itemized`,旧分支会 + // **再画一遍**每行的名称 + 复选框 —— 且旧分支**没有任何裁切**,滚动时这些元素就会 + // 画到标题栏上(用户 2026-10-30 报告“元素超出浮动面板”的根因)。 + const bool listStyle = (meanFilter || centerAxis); + const bool itemized = weakPeak; const bool hasButton = (meanFilter || weakPeak); { if (index >= count) @@ -4476,7 +4556,13 @@ namespace jdi // (否则平移会重画全部面板),于是整块贴回时就把旧波形"复印"了一圈。 // 现在:① 先把**投影**按本帧重新画一遍(它在圆角形状之外,不能靠缓存); // ② 用圆角区域裁剪后只 BitBlt 面板本体(圆角之外仍是本帧场景)。 - if (!m_fastFrame) + // ⚠️⚠️ **投影不能每帧重画**(用户 2026-11-02 报告“移动鼠标/拖动光标卡顿”): + // 它是一块面积与面板同大的**半透明抗锯齿圆角矩形**(中心轴面板高近 950px、 + // 宽 400px),GDI+ 软件渲染这一块实测要 **20ms 以上** —— 而缓存命中的 + // 贴图本身只要零点几毫秒。交互期间(拖动 / 鼠标刚移动)直接跳过投影: + // 面板圆角之外保留上一帧的像素(此时那块区域本来也是静止的场景), + // 鼠标停下后键恢复、面板重画一次即补上投影。 + if (!m_fastFrame && !interactiveFrame) { UiFillRoundRect(g, gd::RectF(panel.X + 1.0f * m_scale, panel.Y + 2.0f * m_scale, panel.Width, @@ -4852,7 +4938,7 @@ namespace jdi // 「均值滤波」面板:逐信号的**信号色叠底标题栏**(左侧展开三角 + 信号名 + // 右端复选框)。复选框勾选后才出现三角、才能展开下面的两行参数滑块 // (「均值点数」/「迭代次数」);内容超高时可滚动(滚动条在内容区右侧)。 - if (meanFilter) + if (listStyle) { const int meanSave = g.Save(); // 内容必须裁到内容区:滚动时行会越出可视范围。 @@ -4938,6 +5024,10 @@ namespace jdi { extendRow(layout.rowIterResets[rowIndex]); } + if (rowIndex < layout.rowStats.size()) + { + extendRow(layout.rowStats[rowIndex]); + } if (rowBottom <= layout.content.Y || rowTop >= layout.content.Y + layout.content.Height) { @@ -4945,7 +5035,10 @@ namespace jdi } const std::uint32_t signalColor = StyleColor(*resolved.style); const UiInk ink = UiContrastInk(signalColor); - const Lane::MeanFilterItem* item = tabLane.FindMeanFilterItem(ref); + // 「均值滤波」看 `meanFilterItems`、「中心轴边沿计数」看 `edgeCenterItems` + // (两者都是 `Lane::MeanFilterItem`,字段完全一致)。 + const Lane::MeanFilterItem* item = meanFilter ? tabLane.FindMeanFilterItem(ref) + : tabLane.FindEdgeCenterItem(ref); const bool checked = (item != nullptr); // 内容区已由 `g.SetClip(layout.content)` 裁切(行可以“半截”显示,滚动更顺滑); @@ -4987,6 +5080,19 @@ namespace jdi continue; } + // 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。 + if (centerAxis && rowIndex < layout.rowStats.size() && + layout.rowStats[rowIndex].Width > 0.0f) + { + std::size_t rising = 0; + std::size_t falling = 0; + bool complete = true; + EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, item->points, + item->iterations, EdgeRangeActive(), EdgeRangeMin(), + EdgeRangeMax(), rising, falling, complete); + paintCounts(layout.rowStats[rowIndex], rising, falling, complete); + } + // 两行参数滑块(标签 / 轨道 / 数值 / 复位按钮)。 const auto paintMeanSlider = [&](const gd::RectF& track, const gd::RectF& valueRect, @@ -5031,11 +5137,15 @@ namespace jdi hot ? m_theme->accent : m_theme->textMuted, m_scale); }; - const int pointsMax = std::max(kMeanFilterPointsMin, + // ⚠️ 「均值点数」的最小值:**均值滤波 = 1**(用户 2026-10-30 要求不能为 0, + // 0 与不做平滑完全等价,看起来像功能坏了);**中心轴边沿计数 = 0** + // (0 = 不平滑,直接对原始采样值统计边沿 —— 这是有意义的选项,保持旧语义)。 + const int pointsFloor = centerAxis ? 0 : kMeanFilterPointsMin; + const int pointsMax = std::max(pointsFloor, std::min(kMeanFilterPointsMaxLimit, item->maxPoints)); const int points = - std::max(kMeanFilterPointsMin, std::min(pointsMax, item->points)); + std::max(pointsFloor, std::min(pointsMax, item->points)); const int iterationsMax = std::max(kMeanFilterIterationsMin, std::min(kMeanFilterIterationsMaxLimit, item->maxIterations)); @@ -5043,10 +5153,11 @@ namespace jdi kMeanFilterIterationsMin, std::min(iterationsMax, item->iterations)); const bool pointsActive = (m_dragMode == PlotDragMode::MEAN_POINTS) && (m_meanDragLane == static_cast(index)) && - (m_meanDragRef == ref); + (m_meanDragRef == ref) && (m_meanDragCenter == centerAxis); const bool iterationsActive = (m_dragMode == PlotDragMode::MEAN_ITERATIONS) && - (m_meanDragLane == static_cast(index)) && (m_meanDragRef == ref); + (m_meanDragLane == static_cast(index)) && (m_meanDragRef == ref) && + (m_meanDragCenter == centerAxis); if (rowIndex < layout.rowLevelTracks.size()) { paintMeanSlider(layout.rowLevelTracks[rowIndex], @@ -5057,7 +5168,7 @@ namespace jdi ? layout.rowLevelResets[rowIndex] : gd::RectF(), kMeanFilterPointsSliderLabel, points, - kMeanFilterPointsMin, pointsMax, pointsActive); + pointsFloor, pointsMax, pointsActive); } if (rowIndex < layout.rowIterTracks.size()) { @@ -5434,7 +5545,7 @@ namespace jdi { continue; } - if (itemized || freeMarker || meanFilter) + if (itemized || freeMarker || listStyle) { // 逐条目绘制已在上面完成(自由标记同样是逐条目排布, // 均值滤波走自己的可滚动分支)。 diff --git a/Components/PlotView/Src/PlotViewPanels.cpp b/Components/PlotView/Src/PlotViewPanels.cpp index 6a3fe9a..24fd0e1 100644 --- a/Components/PlotView/Src/PlotViewPanels.cpp +++ b/Components/PlotView/Src/PlotViewPanels.cpp @@ -28,8 +28,10 @@ namespace jdi const bool centerAxis = (kind == EdgePanelKind::CENTER); const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool weakPeak = (kind == EdgePanelKind::PEAK); - // 「均值滤波」「中心轴边沿计数」「弱峰调整」都是逐条目面板(名称 + 复选框)。 - const bool itemized = (centerAxis || meanFilter || weakPeak); + // 「弱峰调整」仍是旧式逐条目面板(名称 + 复选框 + 两行滑块)。 + // ⚠️ 「均值滤波」与「中心轴边沿计数」已改为**可滚动的分组列表**(信号色叠底条目栏), + // 它们的宽度由下面的 `groupWidth` / 滑块行分支确定。 + const bool itemized = weakPeak; // 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” / “均值滤波” / “弱峰调整” // + 光标区间尾缀)。 @@ -69,10 +71,10 @@ namespace jdi width = std::max(width, kEdgePanelPad * 2.0f * m_scale + nameWidth + kEdgePanelMeanCheckSize * m_scale + 6.0f * m_scale); } - if (meanFilter) + if (meanFilter || centerAxis) { - // 「均值滤波」的条目 = **信号色叠底标题栏**(三角 + 名称 + 复选框) - // 与两行滑块(标签 + 轨道 + 数值 + 复位按钮),右侧还要给滚动条留位。 + // 「均值滤波 / 中心轴边沿计数」的条目 = **信号色叠底条目栏**(三角 + 名称 + 复选框) + // 与参数行(标签 + 轨道 + 数值 + 复位按钮),右侧还要给滚动条留位。 const float groupWidth = kEdgePanelPad * 2.0f * m_scale + kEdgePanelDisclosureWidth * m_scale + 6.0f * m_scale + nameWidth + 6.0f * m_scale + @@ -124,21 +126,7 @@ namespace jdi std::max(nameColumnWidth, countsWidth)); } - if (centerAxis || meanFilter) - { - // 参数行 = 两个「说明文字 + 数值框」,必须保证文字不被数值框压住。 - // (CENTER:「均值点数 X」+「迭代次数 N」。MEAN 已改为滑块,见下面单独的分支。) - const float labelXWidth = UiMeasureTextWidth(m_fonts->uiSmall, L"均值点数 X"); - const float labelN = UiMeasureTextWidth(m_fonts->uiSmall, L"迭代次数 N"); - const float fieldWidth = - std::max(kEdgePanelEditFieldWidth * m_scale * 0.75f, - UiMeasureTextWidth(m_fonts->monoSmall, L"1000000") + 18.0f * m_scale); - const float halfWidth = std::max(labelXWidth, labelN) + 6.0f * m_scale + fieldWidth; - const float rowWidth = halfWidth * 2.0f + 8.0f * m_scale; - width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth); - } - - if (meanFilter) + if (meanFilter || centerAxis) { // 两行滑块 = 「标签列 + 轨道(最小宽度)+ 数值列 + 复位按钮」(两行共用同一列宽), // 右侧再给滚动条留位(上百个信号时内容必然超高)。 @@ -263,7 +251,11 @@ namespace jdi // 「均值滤波」与「弱峰调整」共用同一套「逐条目 + 底部按钮」排布。 // ⚠️ 「均值滤波」从 2026-10-30 起改为**可滚动的分组列表**(信号色叠底标题栏 + // 展开后的两行滑块),与「自由标记」同一套浏览方式 ⇒ 不再走这里的 `itemized` 分支。 - const bool itemized = (centerAxis || weakPeak); + // ⚠️ 「中心轴边沿计数」从 2026-11-02 起同样改为**可滚动的分组列表**(用户明确要求 + // “改造为与均值滤波相同的风格”)⇒ 也不走 `itemized`。它没有「构建为新信号」按钮, + // 但内容多一行「上升沿 / 下降沿」统计。 + const bool listStyle = (meanFilter || centerAxis); + const bool itemized = weakPeak; const bool hasButton = (meanFilter || weakPeak); const bool enabled = meanFilter ? lane.meanFilter : (weakPeak ? lane.weakPeak @@ -322,11 +314,12 @@ namespace jdi float itemsHeight = 0.0f; float meanContentHeight = 0.0f; float height = 0.0f; - if (meanFilter) + if (listStyle) { - // 「均值滤波」:内容区 = 逐信号的**信号色叠底标题栏**(内容总是显示, - // 用于勾选/取消预览)+(勾选且未收起时)两行参数滑块。 - // 内容超高时出现滚动条;底部「构建为新信号」按钮固定不滚动。 + // 「均值滤波 / 中心轴边沿计数」:内容区 = 逐信号的**信号色叠底条目栏** + //(内容总是显示,用于勾选/取消)+(勾选且未收起时)参数行。 + // 「中心轴」的参数行 = 一行「上升沿 / 下降沿」统计 + 两行滑块; + // 「均值滤波」= 两行滑块。内容超高时出现滚动条;底部「构建为新信号」按钮固定不滚动。 const float groupRow = kEdgePanelMeanRowHeight * m_scale; const float sliderRow = weakPeakSliderRow; float content = 0.0f; @@ -338,10 +331,15 @@ namespace jdi continue; } content += groupRow; - const Lane::MeanFilterItem* item = lane.FindMeanFilterItem(ref); + const Lane::MeanFilterItem* item = meanFilter ? lane.FindMeanFilterItem(ref) + : lane.FindEdgeCenterItem(ref); if (item != nullptr && !item->collapsed) { content += sliderRow * 2.0f; + if (centerAxis) + { + content += groupRow; // 统计行(上升沿 / 下降沿) + } } } meanContentHeight = content; @@ -562,12 +560,16 @@ namespace jdi { return layout; } - if (meanFilter) + if (listStyle) { - // 「均值滤波」:可滚动内容区(逐信号的信号色叠底标题栏 + 展开后的两行滑块)+ 底部按钮。 + // 「均值滤波 / 中心轴边沿计数」:可滚动内容区 + //(逐信号的信号色叠底条目栏 + 展开后的统计行与两行滑块)+(均值滤波)底部按钮。 const float buttonRowHeight = kEdgePanelButtonHeight * m_scale; - layout.button = gd::RectF(left + pad, top + height - pad - buttonRowHeight, - std::max(0.0f, width - pad * 2.0f), buttonRowHeight); + if (hasButton) + { + layout.button = gd::RectF(left + pad, top + height - pad - buttonRowHeight, + std::max(0.0f, width - pad * 2.0f), buttonRowHeight); + } const float groupRow = kEdgePanelMeanRowHeight * m_scale; const float sliderRow = kWeakPeakSliderRowHeight * m_scale; @@ -576,7 +578,8 @@ namespace jdi std::max(0.0f, height - titleHeight - pad * 3.0f - buttonRowHeight); const bool scrollable = meanContentHeight > viewHeight + 0.5f; const float maxScroll = std::max(0.0f, meanContentHeight - viewHeight); - const float scroll = std::max(0.0f, std::min(lane.meanFilterScroll, maxScroll)); + const float storedScroll = meanFilter ? lane.meanFilterScroll : lane.edgeCenterScroll; + const float scroll = std::max(0.0f, std::min(storedScroll, maxScroll)); const float innerWidth = std::max(0.0f, width - pad * 2.0f - (scrollable ? (scrollW + 4.0f * m_scale) : 0.0f)); layout.content = gd::RectF(left + pad, top + titleHeight + pad, innerWidth, viewHeight); @@ -621,7 +624,8 @@ namespace jdi { continue; } - const Lane::MeanFilterItem* item = lane.FindMeanFilterItem(ref); + const Lane::MeanFilterItem* item = meanFilter ? lane.FindMeanFilterItem(ref) + : lane.FindEdgeCenterItem(ref); const bool checked = (item != nullptr); layout.rowRefs.push_back(ref); // 组头(信号色叠底):整行宽度,左侧三角(仅勾选时)+ 名称,右端复选框。 @@ -653,6 +657,12 @@ namespace jdi layout.rowIterResets.push_back(gd::RectF()); continue; } + // 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。 + if (centerAxis && !layout.rowStats.empty()) + { + layout.rowStats.back() = gd::RectF(contentLeft, rowY, innerWidth, groupRow); + rowY += groupRow; + } const gd::RectF pointsTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f, trackWidth, trackHeight); layout.rowLevelTracks.push_back(pointsTrack); diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index b049d09..7ac915e 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -382,14 +382,6 @@ namespace jdi m_rowTop[index + 1] = m_rowTop[index] + m_rows[index].height; } - // 行顶前缀和:`m_rowTop[i]` = 第 0..i-1 行高度之和(长度 = 行数 + 1)。 - // 行集合与行高只在这里变化 ⇒ 在此重建是唯一权威的时机。 - m_rowTop.assign(m_rows.size() + 1, 0.0f); - for (std::size_t index = 0; index < m_rows.size(); ++index) - { - m_rowTop[index + 1] = m_rowTop[index] + m_rows[index].height; - } - float totalShift = 0.0f; for (const GroupAnim& anim : m_groupAnims) { diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 6c29536..110f3de 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -8941,3 +8941,115 @@ rendercheck 8 个场景哈希与基线完全一致;make release / make debug 白改了一轮断言。 * ⚠️ PowerShell 里用 `'...'` 拼接含 `%d` / 引号 / 长中文的补丁串极易被截断(出现 `>>` 续行提示后 只能 Ctrl+C)。**补丁一律写成 `.git\xxx.ps1`(纯 ASCII)+ `%TEMP%\xxx.txt` 数据文件**。 + +--- + +## 2026-11-02 第十九轮(本机):288 信号卡顿根治 + 中心轴面板重做 + 标签页默认状态 + +### 1. 卡顿真因:**面板缓存键把"鼠标驱动的一切"都混了进去** + +复现:`VERY_BIG_DATA.CSV` + `MF4-Record.MF4` 全部信号合并到**同一网格**(288 信号), +开「中心轴边沿计数」后拖动横向光标/移动鼠标,实测 **29.8ms/帧**(修复后 **6.6ms**)。 + +分解(`.preflight/bugcheck.exe` A5b): + +| 场景 | 单帧 | +| --- | --- | +| 面板开 + 统计开 | 27.6ms | +| **面板关 + 统计开** | **4.1ms** | +| 面板关 + 统计关 | 6.6ms(噪声) | + +⇒ 27.6−4.1 ≈ **23ms 全在面板绘制**,而**不是**统计扫描。三个叠加原因: + +1. ⚠️⚠️ **中心轴面板高度没有上限**:`itemized` 旧分支按"每个勾选信号两行 + 每行 22px" + 累加,288 个信号 ⇒ **面板高 9996px**(绘图区只有 1141px)。`PanelRectInsideLayer()` + 因此永远为假 ⇒ **面板像素缓存一次都存不进去** ⇒ 每帧全量重画 288 行。 +2. ⚠️⚠️ **`PanelGlobalKey` 混入鼠标驱动状态**:`m_hoverSignal` / `m_hoverLane` / + `m_hoverCursor` / `Tab().CursorTime()` / `EdgeRangeMin/Max` 等只要鼠标一动就变 + ⇒ `panelGlobalSame` 每帧为假 ⇒ 所有面板每帧重画。而 `PanelSlotKey` 里还会为**每个信号** + 调一次 `EdgeCenterCountsFor`(含区间边界块扫描)⇒ 单帧 288 次查询。 +3. 缓存命中路径里的**投影**(与面板同大的半透明抗锯齿圆角矩形,400×950px)每帧重画, + GDI+ 软件渲染实测 > 20ms。 + +**修法**: +* 新增 `PlotView::InputIsInteractive()`(正在拖拽、或鼠标在 `kEdgeScanInteractiveIdleMs` + = 140ms 内移动过)。**交互期间**: + - `PanelGlobalKey` 冻结全部鼠标驱动项(`m_hoverSignal` / `m_hoverLegend` / `m_hoverLane` / + `m_hoverCursor` / `m_cursorHover` / `CursorTime` / `EdgeRangeMin/Max`)与 + `mouseDrivenOverlay`;`PanelSlotKey` 跳过整段边沿统计数字; + - 缓存命中路径**跳过投影**;`m_edgeScanBudget` 用 `kEdgeScanPairsPerFrameInteractive`(8 万, + 静止时 40 万)。 + - ⚠️ **`m_meanRowHoverLane/Ref` 必须例外**:它直接改变面板像素、且只在进出某一行时变 —— + 一起冻结的话"鼠标移到条目栏上高亮"就永远不出现。 + - 鼠标停下 140ms 后键自动恢复 ⇒ 面板重画一次显示最终数字(不会"停在统计中")。 +* 统计预算 `kEdgeScanPairsPerFrame` **1,200,000 → 400,000**(中心轴每个采样对还要按 + `均值点数 × 迭代次数` 算一次均值窗口 ⇒ 120 万对 ≈ 3 千万次浮点运算/帧)。 +* **中心轴面板重做为可滚动列表**(见第 2 节)⇒ 面板高度被 `m_plotRect.Height` 夹住、 + 缓存能存进去、只画可见行。 + +### 2. 「中心轴边沿计数」面板重做(与「均值滤波」完全同风格) + +用户要求:条目栏是**信号色叠底的折叠栏**、右端复选框代表"是否统计该信号"、栏下**同一行** +显示「上升沿 / 下降沿」、再下面两行滑块(均值点数 / 迭代次数,标签不带 X/N)、 +数值可点击输入上限、右侧复位按钮、整个面板可滚动、**未勾选时条目栏不可展开**。 + +实现要点: +* `EdgePanelLayoutFor`:`listStyle = (meanFilter || centerAxis)` 取代原来的 `if (meanFilter)`; + `itemized` 现在**只剩** `weakPeak`。中心轴的内容 = 条目栏 + (勾选且未收起时) + **统计行 + 两行滑块**(统计行在 `rowStats`,滑块复用 `rowLevel*` / `rowIter*`)。 +* 新增 `Lane::edgeCenterScroll`(+ `LaneCarry` 同步)+ `PlotView::SetLaneEdgeCenterScroll()`。 + 滚动偏移通过 `layout.scrollOffset` 自动进 `PanelSlotKey`(无需额外 mix)。 +* 滑块最大值:**均值滤波 = 1**(用户 2026-10-30 要求不能为 0),**中心轴 = 0** + (0 = 不平滑,直接对原始值统计边沿,保持旧语义)。 +* 交互复用 `PlotDragMode::MEAN_POINTS / MEAN_ITERATIONS / MEAN_SCROLL` + 新标志 + `m_meanDragCenter`(拖拽/滚动目标面板)+ `m_editMeanCenter`(上限输入目标面板); + 复位按钮、点击数值改上限、滚轮翻页、光标形状(条目栏/数值/复位=手型,滑块=左右箭头, + 滚动条=上下箭头)全部共用同一段代码。 +* 参数/勾选变化必须 `InvalidateEdgeScansFor(signal, true)`(定向作废,避免"改一个参数 + 其它信号也重算")。 + +### 3. 标签页 + +* ⚠️ **唯一标签页点 ✕ 不再弹警告**:`CloseTab` 在 `m_tabs.size() <= 1` 时直接 erase 再 + `NewTab(true)` ⇒ 名字必然是「视图 1」(`m_tabs` 清空后 `NextTabName()` 取最小未占用编号)。 + ⚠️ erase 与 `NewTab` 之间**不要**碰 `m_panel` / `m_plot`(上下文仍指向已释放的 `TabConfig`)。 +* **新建标签页的默认状态**(手动新建也走同一入口):`NewTab()` 里新增 + `m_dataSet.SetSelected(SignalRef())` —— 选中状态是**全局共享**的,不清掉的话新页会带着 + 上一个页的选中信号 ⇒ 样式编辑区有自己的"目标"。清空后样式区回到「未选择信号」。 + 组全部折起由 `SetGroupsCollapsedByDefault(true)` + `SetAllCollapsed(true)` 保证(既有行为)。 + +### 4. 顺带修正 + +* `PanelGlobalKey` 里 `m_hoverCursor/m_cursorHover/m_hoverLane` 也纳入交互冻结(只影响叠加层)。 +* 光标形状:`QueryCursor` 对 MEAN/CENTER 可滚动列表补上条目栏/数值/复位/滑块/滚动条的 + 手型与箭头(原来只有 PEAK 有)。 + +### 5. 验证 + +``` +.preflight/bugcheck.exe 59/59 + A1 空闲 0.7ms · A2 平移 2.5ms · A3 双光标 2.4ms · A4 自由标记光标 5.2ms + A5 中心轴拖动 6.6ms(修前 29.8ms) + A5b 分解:面板开 5.7ms / 面板关 4.9ms / 无光标 7.2ms;面板 460.5px(修前 9996px) + B 组名展开/收起(283 行):行集合 0 ↔ 283、签名自洽 + B3 真实消息路径(WM_LBUTTONDOWN/UP + WM_TIMER + WM_PAINT):展开 90 帧 / 收起 95 帧 + B5 快速连点 3 次 / 动画中途反向:三角与行集合始终一致 + C 中心轴面板:未勾选条目栏点击无效 / 复选框启用 / 整行收起展开 / + 拖滑块 90% → 45 点 / 复位 → 5 点且上限 50 / 滚轮滚动 60px + D 唯一标签页关闭 → 「视图 1」+ 全折起 + 无选中;手动新建同样 +.preflight/panelcheck.exe 71/71 markercheck.exe 13/13 ringcheck.exe 6/6 +make release / make debug 零 warning +``` + +### 6. 踩坑 + +* ⚠️⚠️ **`PaintEdgeCursor` 的"笨重"必须先分段测量**:本轮一开始把 30ms 归因于"统计扫描" + 和"统计数字进缓存键",改了预算与键都不见效;直到做了「面板开/关」「统计开/关」的 + A/B 才定位到**面板高度超界 ⇒ 像素缓存从未命中**。**判据要用"缓存是否真的被用上" + (`m_panelSlots[0].key != 0` + `PanelRectInsideLayer`),而不是"看起来像重画"**。 +* ⚠️ 探针里模拟点击面板前**必须关掉其它实验性功能**:它们默认位置同样在绘图区右上角, + `TopPanelAtPoint` 只返回最上层那一个 ⇒ 命中全被另一个面板抢走(表现为"点了没反应")。 +* ⚠️ 组名行的点击热区:`X = 行矩形 40%` 处是名称(BODY),`x = 40px` 附近是**复选框** + (`RowPart::CHECK`)—— 想测折叠必须点名称区。 +* ⚠️ 「行集合签名是否稳定」不能作为断言:动画**结束那一帧**签名必然变化一次(动画标记位 + 从 5 → 0),这是正确的。要断言的是"动画结束后 `m_rowsSignature == RowsSignature()`"。 diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index c9b67f8..ad3fa81 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -1067,6 +1067,15 @@ namespace jdi */ float meanFilterScroll = 0.0f; + /** + * @brief 「中心轴边沿计数」面板内容的滚动偏移(客户区像素,0 = 顶部)。 + * + * 与 `meanFilterScroll` 完全同一套语义:面板自 2026-11-02 起改为**可滚动的分组列表** + *(信号色叠底条目栏 + 右侧复选框 + 展开后的统计行与两行参数滑块), + * 内容超出可视高度时右侧出现滚动条(否则上百个信号会把面板顶出绘图区)。 + */ + float edgeCenterScroll = 0.0f; + /** * @brief 「自由标记」面板里被**收起**的信号分组(点击分组栏切换)。 * diff --git a/Utilities/Inc/DataModelInternal.hpp b/Utilities/Inc/DataModelInternal.hpp index 73d0698..b2b9f98 100644 --- a/Utilities/Inc/DataModelInternal.hpp +++ b/Utilities/Inc/DataModelInternal.hpp @@ -142,6 +142,8 @@ namespace jdi std::vector freeMarkerCollapsedGroups; /** @brief 「均值滤波」面板内容的滚动偏移(详见 `Lane::meanFilterScroll`)。 */ float meanFilterScroll = 0.0f; + /** @brief 「中心轴边沿计数」面板内容的滚动偏移(详见 `Lane::edgeCenterScroll`)。 */ + float edgeCenterScroll = 0.0f; }; // 先记下每个信号原来所在网格的网格自身状态: @@ -173,6 +175,7 @@ namespace jdi carry.edgeCenter = lane.edgeCenter; carry.edgeCenterItems = lane.edgeCenterItems; carry.edgeCenterPanel = lane.edgeCenterPanel; + carry.edgeCenterScroll = lane.edgeCenterScroll; carry.meanFilter = lane.meanFilter; carry.meanFilterItems = lane.meanFilterItems; carry.meanFilterPanel = lane.meanFilterPanel; @@ -233,6 +236,7 @@ namespace jdi lane.edgeCenterPanel.collapsed = carry.edgeCenterPanel.collapsed; lane.edgeCenterPanel.x = carry.edgeCenterPanel.x; lane.edgeCenterPanel.y = carry.edgeCenterPanel.y; + lane.edgeCenterScroll = carry.edgeCenterScroll; lane.meanFilter = carry.meanFilter; lane.meanFilterItems = carry.meanFilterItems; lane.meanFilterPanel.open = carry.meanFilterPanel.open;