diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 05b3188..af7c97e 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -1103,6 +1103,21 @@ namespace jdi */ bool SetLaneEdgeCenterScroll(int laneIndex, float scroll); + /** + * @brief 设置某网格「弱峰调整」面板内容的滚动偏移(客户区像素,0 = 顶部)。 + * @return 偏移真的发生变化返回 true。 + */ + bool SetLaneWeakPeakScroll(int laneIndex, float scroll); + + /** + * @brief 设置某网格「边沿计数」面板内容的滚动偏移(客户区像素,0 = 顶部)。 + * + * 该面板仍是旧式「每信号两行」排版,但内容可以滚动:上百个信号合并到同一网格时 + * 内容高达上万像素,不限高会让面板远超绘图区(面板像素缓存因此完全失效)。 + * @return 偏移真的发生变化返回 true。 + */ + bool SetLaneEdgeCountScroll(int laneIndex, float scroll); + /** * @brief 切换某网格「自由标记」面板里某个信号分组的收起状态。 * @return 切换后为「已收起」返回 true。 @@ -2814,13 +2829,15 @@ namespace jdi int m_meanDragLane = -1; /**< 正在拖动「均值滤波」参数滑块的面板所属网格。 */ SignalRef m_meanDragRef; /**< 正在拖动「均值滤波」参数滑块的信号(每信号一套参数)。 */ /** - * @brief 当前参数滑块拖拽的目标是**中心轴边沿计数**面板(而不是均值滤波)。 + * @brief 当前「列表式面板」参数滑块 / 滚动条拖拽的目标面板种类。 * - * 「中心轴边沿计数」自 2026-11-02 起与「均值滤波」共用同一套可滚动列表与滑块交互, - * 因此复用 `MEAN_POINTS` / `MEAN_ITERATIONS` / `MEAN_SCROLL` 三个拖拽模式; - * 这个标志说明它们的写入目标是 `Lane::edgeCenterItems` 还是 `Lane::meanFilterItems`。 + * 「均值滤波」「中心轴边沿计数」「弱峰调整」自 2026-11-02 起共用同一套**可滚动列表** + * (信号色叠底条目栏 + 右侧复选框 + 展开后的参数滑块)与同一套拖拽模式 + *(`MEAN_POINTS` / `MEAN_ITERATIONS` / `MEAN_SCROLL`);本字段说明它们的写入目标是 + * `Lane::meanFilterItems` / `edgeCenterItems` / `weakPeakItems` 中的哪一个。 + * 「边沿计数」面板(旧式两行排版)也用它区分滚动目标。 */ - bool m_meanDragCenter = false; + EdgePanelKind m_meanDragKind = EdgePanelKind::MEAN; /** * @brief 正在编辑「均值滤波」某个滑块上限时,输入框内灰色提示用的**当前上限值**。 * @@ -2829,12 +2846,13 @@ namespace jdi int m_meanEditCurrentMax = 0; /** - * @brief 正在编辑「滑块上限」的是**中心轴边沿计数**面板(而不是均值滤波)。 + * @brief 正在编辑「滑块上限」的目标面板种类(均值滤波 / 中心轴边沿计数)。 * - * 与 `m_meanDragCenter` 同一思路:两者共用 `MEAN_POINTS_MAX` / `MEAN_ITERATIONS_MAX` - * 两个编辑目标,这个标志说明提交时要写到 `Lane::edgeCenterItems` 还是 `meanFilterItems`。 + * 与 `m_meanDragKind` 同一思路:两者共用 `MEAN_POINTS_MAX` / `MEAN_ITERATIONS_MAX` + * 两个编辑目标,本字段说明提交时要写到 `Lane::edgeCenterItems` 还是 `meanFilterItems`。 + * 「弱峰调整」仍用自己的 `PEAK_ITERATIONS_MAX` 与 `m_peakEditCurrentMax`。 */ - bool m_editMeanCenter = false; + EdgePanelKind m_editMeanKind = EdgePanelKind::MEAN; /** * @brief 正在编辑「迭代次数 N 上限」时,输入框内灰色提示用的**当前上限值**。 * @@ -3061,9 +3079,25 @@ namespace jdi * 用户看到的就是“按住光标不动时统计卡住、要动鼠标才更新”。 */ mutable std::uint64_t m_edgeScanFrame = 0; + /** + * @brief 上一次“请求下一帧”发生在哪一帧(收敛成**每帧最多一次** `PostMessage`)。 + * + * ⚠️ 旧实现在 `EdgeScanFor` 里按“未完成状态”逐个 Post:288 条信号时单帧 288 次, + * 把消息队列和重绘打满,`WM_TIMER`(分组折起动画)因此被饿死(用户 2026-11-02 报告)。 + */ + mutable std::uint64_t m_edgeScanRequestFrame = static_cast(-1); /** 诊断计数:中心轴本地曲线计算失败次数(内存不足等)。0 = 一切正常。 */ mutable std::size_t m_edgeScanLocalCurveFails = 0; + /** + * @brief 累积状态上限(随可见信号总数缩放)。 + * + * 每个信号最多两类状态(普通边沿计数 / 中心轴边沿计数),因此上限 ≥ `2 × 信号数`。 + * 固定上限(旧 64)在 288 条信号合并到同一网格时会让所有状态互相淘汰 ⇒ + * 面板永远停在“统计中…”(用户 2026-11-02 报告)。 + */ + std::size_t EdgeScanStateLimit() const; + /** * @brief 边沿统计的统一实现(普通边沿计数与中心轴边沿计数共用)。 * diff --git a/Components/PlotView/Src/PlotViewFeatures.cpp b/Components/PlotView/Src/PlotViewFeatures.cpp index d8ec447..2a481dd 100644 --- a/Components/PlotView/Src/PlotViewFeatures.cpp +++ b/Components/PlotView/Src/PlotViewFeatures.cpp @@ -1828,6 +1828,40 @@ namespace jdi return true; } + bool PlotView::SetLaneWeakPeakScroll(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.weakPeakScroll - clamped) < 0.5f) + { + return false; + } + // 滚动只影响面板内部像素,缓冲区/场景都不变(面板像素缓存按键失效即可)。 + lane.weakPeakScroll = clamped; + return true; + } + + bool PlotView::SetLaneEdgeCountScroll(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.edgeCountScroll - clamped) < 0.5f) + { + return false; + } + // 滚动只影响面板内部像素,缓冲区/场景都不变(面板像素缓存按键失效即可)。 + lane.edgeCountScroll = clamped; + return true; + } + bool PlotView::ToggleLaneFreeMarkerGroup(int laneIndex, const SignalRef& ref) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) @@ -2609,6 +2643,16 @@ namespace jdi // 预算也就永远不推进(用户报告:"面板过去很久都一直显示统计中, // 只有当鼠标瞄到浮动面板上时才会显示统计好的值")。 // 与派生曲线就绪用同一条消息(宿主只失效绘图区、不置脏,代价极小)。 + // + // ⚠️⚠️ **每个绘制帧最多 Post 一次**(用户 2026-11-02 报告“上百条信号时所有交互都卡”): + // 本函数在 `EdgeScanFor` 里按“未完成的状态”逐个调用,288 条信号时单帧会 Post 288 次! + // 大量 `WM_MSG`+重绘会把优先级更低的 `WM_TIMER` 饿死(分组折起动画因此永远结束不了), + // 同时把 CPU 占满、拖慢所有交互。用一个“本帧已请求”标志收敛成一次。 + if (m_edgeScanRequestFrame == m_edgeScanFrame) + { + return; + } + m_edgeScanRequestFrame = m_edgeScanFrame; const HWND host = m_host; if (host != nullptr) { @@ -2616,9 +2660,22 @@ namespace jdi } } - void PlotView::InvalidateEdgeScansFor(const SignalData* signal, bool centreOnly) + std::size_t PlotView::EdgeScanStateLimit() const { - // ⚠️ **定向作废**:只有被改动的那个信号(并且只限需要的那一类)要重算, + // 每个信号最多两类累积状态(普通边沿计数 / 中心轴边沿计数),另留一点余量。 + // ⚠️ 不能用“当前网格内信号数”:边缘面板可能同时开在多个网格上, + // 而这里用全仓可见信号总数更省心(多出来的容量只是几个几十 KB 的块和数组)。 + std::size_t signalCount = 0; + if (m_dataSet != nullptr) + { + signalCount = m_dataSet->SignalCount(); + } + const std::size_t scaled = signalCount * 2u + 16u; + return std::max(kEdgeScanStateLimit, scaled); + } + + void PlotView::InvalidateEdgeScansFor(const SignalData* signal, bool centreOnly) + { // ⚠️ **定向作废**:只有被改动的那个信号(并且只限需要的那一类)要重算, // 其它信号的键没有变化、累计的块和依然有效 → 必须原样保留。 // 否则(过去用 `m_edgeScans.clear()`)会连**普通边沿计数**与**其它信号的中心轴统计** // 一起丢掉,于是两个浮动面板里的每一个数字都要从头重新扫描: @@ -2698,7 +2755,13 @@ namespace jdi } if (state == nullptr) { - if (m_edgeScans.size() >= kEdgeScanStateLimit) + // ⚠️⚠️ **累积状态上限必须随“信号数”缩放**(用户 2026-11-02 报告:288 条信号合并到 + // 同一网格、开启「边沿计数」后**全部停在“统计中…”**且永不更新): + // 旧上限固定 64,而面板会在一帧内为**每条信号**各创建一个状态(288 条 > 64)⇒ + // 新建状态时总是把队首(尚未扫完的)淘汰 ⇒ 没有任何状态能活到扫完 ⇒ 永远重头开始。 + // 每个信号最多两类状态(普通 / 中心轴),因此上限取 `2×信号数 + 余量`。 + const std::size_t stateLimit = EdgeScanStateLimit(); + if (m_edgeScans.size() >= stateLimit) { // ⚠️ 超限时**优先淘汰「已扫完」的最旧状态**(它重算很快、而且通常已经不需要再变), // 绝不淘汰正在推进的那个 —— 否则其它信号会被迫从头重扫 @@ -2891,10 +2954,24 @@ namespace jdi const bool suppressPartial = state->justReset && !state->complete; if (!state->complete && !state->blockRising.empty()) { - const std::size_t activeStates = std::max(1, m_edgeScans.size()); + // ⚠️ 分母只数**还没扫完**的状态:已完成的状态不再消耗配额,若把它们也算进分母, + // 上百条短信号里夹着几条千万级长信号时,长信号每帧只能分到 1/300 的预算 + // (实测要 20 分钟才出结果)。按“未完成数”分摊后,短信号一完成就把预算让出来。 + std::size_t activeStates = 0; + for (const EdgeScanState& candidate : m_edgeScans) + { + if (!candidate.complete) + { + ++activeStates; + } + } + activeStates = std::max(1, activeStates); const std::size_t share = std::max(kEdgeScanFairShare, activeStates); - const std::size_t perState = - std::max(kEdgeScanPairsPerStateMin, kEdgeScanPairsPerFrame / share); + // ⚠️ **不要给每状态一个固定下限**(旧实现是 `max(60000, budget/share)`): + // 状态数一多,下限就让**前几个状态**(恰好是网格里最前面的信号)吃掉整帧预算, + // 后面的信号永远分不到配额 ⇒ 一直显示“统计中…”(用户 2026-11-02 报告的 + // 288 信号场景)。改为纯按未完成状态数平分本帧预算(至多 1)。 + const std::size_t perState = std::max(1, m_edgeScanBudget / share); if (state->pumpedFrame != m_edgeScanFrame) { state->pumpedFrame = m_edgeScanFrame; diff --git a/Components/PlotView/Src/PlotViewInteraction.cpp b/Components/PlotView/Src/PlotViewInteraction.cpp index 98276ef..f0af772 100644 --- a/Components/PlotView/Src/PlotViewInteraction.cpp +++ b/Components/PlotView/Src/PlotViewInteraction.cpp @@ -651,7 +651,8 @@ namespace jdi target == PlotEditTarget::MEAN_ITERATIONS_MAX) { Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; - const Lane::MeanFilterItem* existing = m_editMeanCenter + const bool centerEdit = (m_editMeanKind == EdgePanelKind::CENTER); + const Lane::MeanFilterItem* existing = centerEdit ? lane.FindEdgeCenterItem(m_editMeanRef) : lane.FindMeanFilterItem(m_editMeanRef); if (existing == nullptr) @@ -662,12 +663,12 @@ namespace jdi { const bool isPoints = (target == PlotEditTarget::MEAN_POINTS_MAX); const int minimum = - isPoints ? (m_editMeanCenter ? 0 : kMeanFilterPointsMin) : kMeanFilterIterationsMin; + isPoints ? (centerEdit ? 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 = m_editMeanCenter + Lane::MeanFilterItem& item = centerEdit ? lane.EnsureEdgeCenterItem(m_editMeanRef) : lane.EnsureMeanFilterItem(m_editMeanRef); if (isPoints) @@ -682,7 +683,7 @@ namespace jdi std::max(minimum, std::min(item.maxIterations, item.iterations)); } m_meanCurveCache.clear(); - if (m_editMeanCenter) + if (centerEdit) { InvalidateEdgeScansFor(ResolveSignal(m_editMeanRef).data, true); } @@ -1070,59 +1071,93 @@ namespace jdi } } } - // 「均值滤波 / 中心轴边沿计数」可滚动列表:条目栏(含复选框)→ 手型; - // 滑块轨道 → 左右箭头;数值(可点击改上限)与复位按钮 → 手型; - // 内容区滚动条 → 上下箭头。与「弱峰调整」同一套观感。 - if (static_cast(panelKind) == EdgePanelKind::MEAN || - static_cast(panelKind) == EdgePanelKind::CENTER) + // 「均值滤波 / 中心轴边沿计数 / 弱峰调整」可滚动列表: + // * 条目栏:**只有该信号已被勾选**时才给手型(未勾选时点击毫无效果, + // 用户 2026-11-02 明确要求此时不要改变鼠标指针形状); + // * 滑块轨道 → 左右箭头;数值(可点击改上限)与复位按钮 → 手型; + // * 内容区滚动条 → 上下箭头。 { - for (std::size_t rowIndex = 0; rowIndex < layout.rowHeaders.size(); ++rowIndex) + const EdgePanelKind cursorKind = static_cast(panelKind); + const bool listCursor = (cursorKind == EdgePanelKind::MEAN || + cursorKind == EdgePanelKind::CENTER || + cursorKind == EdgePanelKind::PEAK); + const Lane& cursorLane = + Tab().Lanes()[static_cast(panelLane)]; + if (listCursor) { - if (layout.rowHeaders[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowHeaders[rowIndex], static_cast(x), - static_cast(y))) + for (std::size_t rowIndex = 0; rowIndex < layout.rowHeaders.size(); ++rowIndex) { - 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.rowHeaders[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowHeaders[rowIndex], static_cast(x), + static_cast(y))) + { + bool checked = false; + if (cursorKind == EdgePanelKind::PEAK) + { + checked = cursorLane.FindWeakPeakItem( + layout.rowRefs[rowIndex]) != nullptr; + } + else if (cursorKind == EdgePanelKind::CENTER) + { + checked = cursorLane.FindEdgeCenterItem( + layout.rowRefs[rowIndex]) != nullptr; + } + else + { + checked = cursorLane.FindMeanFilterItem( + layout.rowRefs[rowIndex]) != nullptr; + } + return checked ? LoadCursorW(nullptr, IDC_HAND) : nullptr; + } + const bool hasParams = + rowIndex < layout.rowLevelTracks.size() && + layout.rowLevelTracks[rowIndex].Width > 0.0f; + if (!hasParams) + { + continue; // 参数行未展开(未勾选 / 已收起)⇒ 该行没有可交互控件 + } + // 「均值点数 / 调整幅度」数值:均值滤波与中心轴可点击改上限(弱峰不能)。 + if (cursorKind != EdgePanelKind::PEAK && + 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 && @@ -1131,46 +1166,7 @@ namespace jdi return LoadCursorW(nullptr, IDC_SIZENS); } } - // 「弱峰调整」的逐信号滑块:轨道 → 左右箭头;迭代次数右侧数值 → 手型(可点击改上限)。 - if (static_cast(panelKind) == EdgePanelKind::PEAK) - { - for (std::size_t rowIndex = 0; rowIndex < layout.rowIterValues.size(); ++rowIndex) - { - if (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.rowIterTracks.size() && - WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale, - static_cast(x), static_cast(y))) - { - return LoadCursorW(nullptr, IDC_SIZEWE); - } - if (rowIndex < layout.rowLevelTracks.size() && - WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale, - static_cast(x), static_cast(y))) - { - return LoadCursorW(nullptr, IDC_SIZEWE); - } - } - } + // 面板主体(含底部编辑行与参数输入框):保持原始箭头。 return nullptr; } @@ -1540,19 +1536,19 @@ namespace jdi break; } Lane& lane = m_tab->Lanes()[static_cast(m_meanDragLane)]; - const Lane::MeanFilterItem* existingItem = m_meanDragCenter + const Lane::MeanFilterItem* existingItem = (m_meanDragKind == EdgePanelKind::CENTER) ? lane.FindEdgeCenterItem(m_meanDragRef) : lane.FindMeanFilterItem(m_meanDragRef); if (existingItem == nullptr) { break; } - Lane::MeanFilterItem& item = m_meanDragCenter + Lane::MeanFilterItem& item = (m_meanDragKind == EdgePanelKind::CENTER) ? lane.EnsureEdgeCenterItem(m_meanDragRef) : lane.EnsureMeanFilterItem(m_meanDragRef); const bool pointsMode = (m_dragMode == PlotDragMode::MEAN_POINTS); const EdgePanelLayout layout = EdgePanelLayoutFor( - m_meanDragLane, m_meanDragCenter ? EdgePanelKind::CENTER : EdgePanelKind::MEAN); + m_meanDragLane, m_meanDragKind); int rowIndex = -1; for (std::size_t candidate = 0; candidate < layout.rowRefs.size(); ++candidate) { @@ -1578,7 +1574,7 @@ namespace jdi if (pointsMode) { // ⚠️ 下限:均值滤波 = 1(用户 2026-10-30 要求不能为 0);中心轴 = 0(0 = 不平滑)。 - const int floorValue = m_meanDragCenter ? 0 : kMeanFilterPointsMin; + const int floorValue = (m_meanDragKind == EdgePanelKind::CENTER) ? 0 : kMeanFilterPointsMin; const int maximum = std::max(floorValue, std::min(kMeanFilterPointsMaxLimit, item.maxPoints)); item.points = @@ -1601,7 +1597,7 @@ namespace jdi } m_meanCurveCache.clear(); // 中心轴统计依赖参数 ⇒ 参数一变必须定向作废该信号的累积状态。 - if (m_meanDragCenter) + if ((m_meanDragKind == EdgePanelKind::CENTER)) { InvalidateEdgeScansFor(ResolveSignal(m_meanDragRef).data, true); } @@ -1750,7 +1746,7 @@ namespace jdi break; } const EdgePanelLayout layout = EdgePanelLayoutFor( - m_markerScrollDragLane, m_meanDragCenter ? EdgePanelKind::CENTER : EdgePanelKind::MEAN); + m_markerScrollDragLane, m_meanDragKind); const float travel = layout.scrollTrack.Height - layout.scrollThumb.Height; const float maxScroll = std::max(0.0f, layout.markerContentHeight - layout.content.Height); @@ -1763,10 +1759,23 @@ namespace jdi std::min(static_cast(y) - m_markerScrollGrabOffset, layout.scrollTrack.Y + travel)); const float ratio = (thumbTop - layout.scrollTrack.Y) / travel; - const bool scrolled = - m_meanDragCenter - ? SetLaneEdgeCenterScroll(m_markerScrollDragLane, ratio * maxScroll) - : SetLaneMeanFilterScroll(m_markerScrollDragLane, ratio * maxScroll); + const float target = ratio * maxScroll; + bool scrolled = false; + switch (m_meanDragKind) + { + case EdgePanelKind::COUNT: + scrolled = SetLaneEdgeCountScroll(m_markerScrollDragLane, target); + break; + case EdgePanelKind::CENTER: + scrolled = SetLaneEdgeCenterScroll(m_markerScrollDragLane, target); + break; + case EdgePanelKind::PEAK: + scrolled = SetLaneWeakPeakScroll(m_markerScrollDragLane, target); + break; + default: + scrolled = SetLaneMeanFilterScroll(m_markerScrollDragLane, target); + break; + } if (scrolled) { m_dragMoved = true; @@ -2341,16 +2350,29 @@ namespace jdi // 「中心轴边沿计数」/「均值滤波」/「弱峰调整」面板:逐信号条目 // (前两者是「名称 + 复选框 + 两个参数输入框」,弱峰调整是「名称 + 复选框 + 两行滑块」)。 if ((kind == EdgePanelKind::MEAN || kind == EdgePanelKind::CENTER || - kind == EdgePanelKind::PEAK) && + kind == EdgePanelKind::PEAK || kind == EdgePanelKind::COUNT) && 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); - // 「均值滤波」:组头三角(展开 / 收起参数行)+ 两行参数滑块 + // 「均值滤波」「中心轴边沿计数」「弱峰调整」自 2026-11-02 起共用同一套 + //**可滚动列表**交互(信号色叠底条目栏 + 右侧复选框 + 展开后的两行参数滑块)。 + const bool listMode = (meanMode || centerMode || boostMode); + // 「边沿计数」仍是最简单的两行列表,只需要滚动条拖动。 + if (kind == EdgePanelKind::COUNT) + { + if (layout.scrollThumb.Width > 0.0f && + UiPointInRect(layout.scrollThumb, fx, fy)) + { + m_dragMode = PlotDragMode::MEAN_SCROLL; + m_markerScrollDragLane = index; + m_markerScrollGrabOffset = fy - layout.scrollThumb.Y; + m_meanDragKind = EdgePanelKind::COUNT; + return true; + } + } + // 「列表式面板」:组头三角(展开 / 收起参数行)+ 两行参数滑块 //(轨道拖动 / 点击数值改上限 / 右侧复位按钮)。 // ⚠️ 所有命中都要求落在**内容区**之内:滚动到可视范围之外的行仍保留矩形, // 若不加这条限制,它们会抢走底部「构建为新信号」按钮的点击。 @@ -2375,11 +2397,110 @@ namespace jdi m_dragMode = PlotDragMode::MEAN_SCROLL; m_markerScrollDragLane = index; m_markerScrollGrabOffset = fy - layout.scrollThumb.Y; - m_meanDragCenter = centerMode; + m_meanDragKind = kind; return true; } const bool insideContent = UiPointInRect(layout.content, fx, fy); + // 「弱峰调整」:两行滑块 = 调整幅度(-400% … +400%)/ 迭代次数(1 … 自定上限)。 + // 语义与该面板旧式排版完全一致,只是外观改成与均值滤波同款的可滚动列表。 + if (boostMode) + { + for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) + { + const SignalRef& ref = layout.rowRefs[rowIndex]; + if (!insideContent) + { + break; + } + const Lane::WeakPeakItem* peakItem = meanLane.FindWeakPeakItem(ref); + const bool hasParams = + peakItem != nullptr && rowIndex < layout.rowLevelTracks.size() && + layout.rowLevelTracks[rowIndex].Width > 0.0f; + if (!hasParams) + { + continue; + } + Lane::WeakPeakItem& item = meanLane.EnsureWeakPeakItem(ref); + // 复位:调整幅度 → 0% + if (rowIndex < layout.rowLevelResets.size() && + layout.rowLevelResets[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowLevelResets[rowIndex], fx, fy)) + { + item.amountPercent = 0; + m_weakPeakCurveCache.clear(); + m_dragMode = PlotDragMode::NONE; + return true; + } + // 复位:迭代次数 → 1(上限恢复为默认上限)。 + if (rowIndex < layout.rowIterResets.size() && + layout.rowIterResets[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowIterResets[rowIndex], fx, fy)) + { + item.iterations = kWeakPeakIterationsMin; + item.maxIterations = kWeakPeakIterationsDefaultMax; + m_weakPeakCurveCache.clear(); + m_dragMode = PlotDragMode::NONE; + return true; + } + // 迭代次数行的**数值文本**:点击后整行变成输入框以修改滑块上限。 + // ⚠️ 必须放在轨道命中之前(数值区在轨道右侧,命中区不重叠,但顺序更安全)。 + if (rowIndex < layout.rowIterValues.size() && + layout.rowIterValues[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowIterValues[rowIndex], fx, fy)) + { + m_editMeanRef = ref; + m_peakEditCurrentMax = item.maxIterations; + const gd::RectF& track = layout.rowIterTracks[rowIndex]; + const gd::RectF& value = layout.rowIterValues[rowIndex]; + const gd::RectF rowRect(track.X, value.Y, value.X + value.Width - track.X, + value.Height); + BeginEdit(PlotEditTarget::PEAK_ITERATIONS_MAX, index, L"", rowRect); + m_edit.Begin(L"", false, EditFilter::BYTE, 0u); + m_dragMode = PlotDragMode::NONE; + return true; + } + // 拖动「迭代次数」滑块。 + if (rowIndex < layout.rowIterTracks.size() && + WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale, fx, fy)) + { + m_dragMode = PlotDragMode::PEAK_ITERATIONS; + m_peakDragLane = index; + m_peakDragRef = ref; + const double ratio = + UiSliderRatioFromX(layout.rowIterTracks[rowIndex], fx); + const int peakMax = std::max( + kWeakPeakIterationsMin, + std::min(kWeakPeakIterationsMaxLimit, item.maxIterations)); + item.iterations = std::max( + kWeakPeakIterationsMin, + std::min(peakMax, + kWeakPeakIterationsMin + + static_cast(std::lround( + ratio * (peakMax - kWeakPeakIterationsMin))))); + m_weakPeakCurveCache.clear(); + return true; + } + // 拖动「调整幅度」滑块(0 在正中间 ⇒ ±400%)。 + if (rowIndex < layout.rowLevelTracks.size() && + WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale, fx, fy)) + { + m_dragMode = PlotDragMode::PEAK_LEVEL; + m_peakDragLane = index; + m_peakDragRef = ref; + item.amountPercent = static_cast(std::lround( + (UiSliderRatioFromX(layout.rowLevelTracks[rowIndex], fx) * 2.0 - 1.0) * + 100.0 * kWeakPeakSliderScale)); + item.amountPercent = std::max( + kWeakPeakAmountMin, + std::min(kWeakPeakAmountMax, item.amountPercent)); + m_weakPeakCurveCache.clear(); + return true; + } + } + } + else + { for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) { const SignalRef& ref = layout.rowRefs[rowIndex]; @@ -2433,7 +2554,7 @@ namespace jdi { m_editMeanRef = ref; m_meanEditCurrentMax = item.maxPoints; - m_editMeanCenter = centerMode; + m_editMeanKind = kind; const gd::RectF& track = layout.rowLevelTracks[rowIndex]; const gd::RectF& value = layout.rowLevelValues[rowIndex]; const gd::RectF rowRect(track.X, value.Y, @@ -2451,7 +2572,7 @@ namespace jdi { m_editMeanRef = ref; m_meanEditCurrentMax = item.maxIterations; - m_editMeanCenter = centerMode; + m_editMeanKind = kind; const gd::RectF& track = layout.rowIterTracks[rowIndex]; const gd::RectF& value = layout.rowIterValues[rowIndex]; const gd::RectF rowRect(track.X, value.Y, @@ -2472,7 +2593,7 @@ namespace jdi m_dragMode = PlotDragMode::MEAN_POINTS; m_meanDragLane = index; m_meanDragRef = ref; - m_meanDragCenter = centerMode; + m_meanDragKind = kind; const double ratio = UiSliderRatioFromX(layout.rowLevelTracks[rowIndex], fx); item.points = std::max( floorValue, @@ -2495,7 +2616,7 @@ namespace jdi m_dragMode = PlotDragMode::MEAN_ITERATIONS; m_meanDragLane = index; m_meanDragRef = ref; - m_meanDragCenter = centerMode; + m_meanDragKind = kind; const double ratio = UiSliderRatioFromX(layout.rowIterTracks[rowIndex], fx); item.iterations = std::max( kMeanFilterIterationsMin, @@ -2511,198 +2632,84 @@ namespace jdi return true; } } - // 条目栏(信号色叠底那一整行): - // * 点在右端复选框上 → 切换"是否启用该信号的预览"(与复选框同一语义, - // 因为整行已被本函数消费,复选框自己收不到点击了); - // * 已启用 → 整行切换参数行的收起 / 展开; - // * **未启用 → 什么都不做**(用户 2026-10-30 明确要求:未勾选前点击条目栏 - // 既不能自动勾选、也不能展开 / 收起,只能点复选框)。 - // 仍然消费这次点击(不穿透到底层的平移/选区等)。 - if (rowIndex < layout.rowHeaders.size() && - layout.rowHeaders[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowHeaders[rowIndex], fx, fy)) - { - const bool onCheckBox = - rowIndex < layout.rowChecks.size() && - layout.rowChecks[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowChecks[rowIndex], fx, fy); - Lane::MeanFilterItem* existing = - const_cast(findItem(ref)); - if (onCheckBox) - { - if (centerMode) - { - ToggleLaneEdgeCenterSignal(index, ref); - } - else - { - ToggleLaneMeanFilterSignal(index, ref); - } - m_meanRowHoverLane = -1; - m_meanRowHoverRef = SignalRef(); - } - else if (existing != nullptr) - { - // 已启用:整行切换参数行的收起 / 展开。 - existing->collapsed = !existing->collapsed; - } - m_dragMode = PlotDragMode::NONE; - return true; - } - } - } - Lane& tabLane = m_tab->Lanes()[static_cast(index)]; - - // 「弱峰调整」:先判两行滑块(数值文本 → 改上限;轨道 → 拖动)。 - if (boostMode) - { - for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) - { - const SignalRef& ref = layout.rowRefs[rowIndex]; - const bool hasLevel = rowIndex < layout.rowLevelTracks.size() && - layout.rowLevelTracks[rowIndex].Width > 0.0f; - if (!hasLevel) - { - continue; - } - if (tabLane.FindWeakPeakItem(ref) == nullptr) - { - continue; - } - Lane::WeakPeakItem& item = tabLane.EnsureWeakPeakItem(ref); - // 两行滑块右侧的**复位控件**:点击恢复该行参数的默认值 - //(幅度 → 0;迭代次数 → 1 且上限回到初始上限 `kWeakPeakIterationsDefaultMax`)。 - // 与「系数/偏移」二级面板里的还原图标同一交互语义(面板保持打开、就地回显)。 - if (rowIndex < layout.rowLevelResets.size() && - layout.rowLevelResets[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowLevelResets[rowIndex], fx, fy)) - { - item.amountPercent = 0; - m_weakPeakCurveCache.clear(); - m_dragMode = PlotDragMode::NONE; - return true; - } - if (rowIndex < layout.rowIterResets.size() && - layout.rowIterResets[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowIterResets[rowIndex], fx, fy)) - { - item.iterations = kWeakPeakIterationsMin; - item.maxIterations = kWeakPeakIterationsDefaultMax; - m_weakPeakCurveCache.clear(); - m_dragMode = PlotDragMode::NONE; - return true; - } - // 迭代次数行右侧的**数值文本**:点击后整行变成输入框以修改滑块上限。 - // ⚠️ 必须放在轨道命中之前(数值区在轨道右侧,命中区不重叠,但顺序更安全)。 - if (layout.rowIterValues[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowIterValues[rowIndex], fx, fy)) - { - m_editMeanRef = ref; - m_peakEditCurrentMax = item.maxIterations; - // 输入框以**空内容**开始:空内容时显示当前上限作为灰色提示。 - // 输入框覆盖"整条滑块行"(从轨道左端到数值文本右端)。 - const gd::RectF& track = layout.rowIterTracks[rowIndex]; - const gd::RectF& value = layout.rowIterValues[rowIndex]; - const gd::RectF rowRect(track.X, value.Y, value.X + value.Width - track.X, - value.Height); - BeginEdit(PlotEditTarget::PEAK_ITERATIONS_MAX, index, L"", rowRect); - // `BeginEdit` 默认用 NUMBER 过滤(允许负号/小数点);这里改为**纯数字**, - // 从源头保证"只允许输入正整数"(负数、小数根本无法键入,粘贴同样被清洗)。 - m_edit.Begin(L"", false, EditFilter::BYTE, 0u); - m_dragMode = PlotDragMode::NONE; - return true; - } - if (layout.rowIterTracks[rowIndex].Width > 0.0f && - WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale, fx, fy)) - { - m_dragMode = PlotDragMode::PEAK_ITERATIONS; - m_peakDragLane = index; - m_peakDragRef = ref; - const double ratio = UiSliderRatioFromX(layout.rowIterTracks[rowIndex], fx); - const int peakMax = std::max(kWeakPeakIterationsMin, - std::min(kWeakPeakIterationsMaxLimit, - item.maxIterations)); - item.iterations = std::max( - kWeakPeakIterationsMin, - std::min(peakMax, kWeakPeakIterationsMin + - static_cast(std::lround( - ratio * (peakMax - kWeakPeakIterationsMin))))); - m_weakPeakCurveCache.clear(); - return true; - } - if (layout.rowLevelTracks[rowIndex].Width > 0.0f && - WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale, fx, fy)) - { - m_dragMode = PlotDragMode::PEAK_LEVEL; - m_peakDragLane = index; - m_peakDragRef = ref; - // 单击轨道:直接写入该处对应的幅度(-400% … +400%,0 在正中间)。 - item.amountPercent = static_cast(std::lround( - (UiSliderRatioFromX(layout.rowLevelTracks[rowIndex], fx) * 2.0 - 1.0) * - 100.0 * kWeakPeakSliderScale)); - item.amountPercent = std::max( - kWeakPeakAmountMin, - std::min(kWeakPeakAmountMax, item.amountPercent)); - m_weakPeakCurveCache.clear(); - return true; - } } } + // 条目栏(信号色叠底那一整行): + // * 点在右端复选框上 → 切换「是否启用该信号的预览」; + // * 已启用 → 整行切换参数行的收起 / 展开; + // * **未启用 → 什么都不做**(用户 2026-10-30 明确要求:未勾选前点击条目栏 + // 既不能自动勾选、也不能展开 / 收起,只能点复选框;此时鼠标指针也保持箭头)。 + // 仍然消费这次点击(不穿透到底层的平移 / 选区等)。 for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) { - const SignalRef& ref = layout.rowRefs[rowIndex]; - // 第一排右侧的复选框 → 切换该信号的预览勾选状态。 - if (layout.rowChecks[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowChecks[rowIndex], fx, fy)) + if (!insideContent) { - if (meanMode) - { - ToggleLaneMeanFilterSignal(index, ref); - } - else if (boostMode) + break; + } + const SignalRef& ref = layout.rowRefs[rowIndex]; + if (rowIndex >= layout.rowHeaders.size() || + layout.rowHeaders[rowIndex].Width <= 0.0f || + !UiPointInRect(layout.rowHeaders[rowIndex], fx, fy)) + { + continue; + } + const bool onCheckBox = + rowIndex < layout.rowChecks.size() && + layout.rowChecks[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowChecks[rowIndex], fx, fy); + bool checked = false; + bool collapsed = false; + if (boostMode) + { + const Lane::WeakPeakItem* peakItem = meanLane.FindWeakPeakItem(ref); + checked = (peakItem != nullptr); + collapsed = (peakItem != nullptr && peakItem->collapsed); + } + else + { + const Lane::MeanFilterItem* meanItem = findItem(ref); + checked = (meanItem != nullptr); + collapsed = (meanItem != nullptr && meanItem->collapsed); + } + if (onCheckBox) + { + if (boostMode) { ToggleLaneWeakPeakSignal(index, ref); } - else + else if (centerMode) { ToggleLaneEdgeCenterSignal(index, ref); } - // 勾选变化只改变该信号“是否显示”,它的统计状态(与参数相关)依然有效, - // 其它信号更是完全无关 ⇒ **不要**清任何缓存(清掉就会让所有统计重算)。 - // 这里**不**整体作废场景缓存(交给 `m_sceneDerived` 快照驱动局部重画)。 - m_dragMode = PlotDragMode::NONE; - return true; + else + { + ToggleLaneMeanFilterSignal(index, ref); + } + m_meanRowHoverLane = -1; + m_meanRowHoverRef = SignalRef(); } - // 第二排的两个输入框 → 就地编辑该信号的参数(仅 MEAN / CENTER 面板; - // 弱峰调整用的是滑块,已在上面处理)。 - if (layout.rowPoints[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowPoints[rowIndex], fx, fy)) + else if (checked) { - m_editMeanRef = ref; - const Lane::MeanFilterItem* item = - meanMode ? tabLane.FindMeanFilterItem(ref) - : tabLane.FindEdgeCenterItem(ref); - BeginEdit(meanMode ? PlotEditTarget::MEAN_FILTER_POINTS - : PlotEditTarget::EDGE_CENTER_POINTS, - index, std::to_wstring(item != nullptr ? item->points : 5), - layout.rowPoints[rowIndex]); - return true; - } - if (layout.rowIterations[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowIterations[rowIndex], fx, fy)) - { - m_editMeanRef = ref; - const Lane::MeanFilterItem* item = - meanMode ? tabLane.FindMeanFilterItem(ref) - : tabLane.FindEdgeCenterItem(ref); - BeginEdit(meanMode ? PlotEditTarget::MEAN_FILTER_ITERATIONS - : PlotEditTarget::EDGE_CENTER_ITERATIONS, - index, std::to_wstring(item != nullptr ? item->iterations : 1), - layout.rowIterations[rowIndex]); - return true; + // 已启用:整行切换参数行的收起 / 展开。 + if (boostMode) + { + Lane::WeakPeakItem* item = + const_cast(meanLane.FindWeakPeakItem(ref)); + item->collapsed = !collapsed; + } + else + { + Lane::MeanFilterItem* item = + const_cast(findItem(ref)); + item->collapsed = !collapsed; + } } + m_dragMode = PlotDragMode::NONE; + return true; } + } + if (boostMode && layout.button.Width > 0.0f && UiPointInRect(layout.button, fx, fy)) { @@ -3208,7 +3215,7 @@ namespace jdi // 「均值滤波 / 中心轴边沿计数」参数滑块拖拽结束:同样清掉目标。 m_meanDragLane = -1; m_meanDragRef = SignalRef(); - m_meanDragCenter = false; + m_meanDragKind = EdgePanelKind::MEAN; // 「标记透明度」滑块 / 面板内容滚动条拖拽结束。 m_markerOpacityDragLane = -1; m_markerScrollDragLane = -1; @@ -3260,9 +3267,10 @@ namespace jdi panelLane, panelKind)) { const EdgePanelKind hoveredKind = static_cast(panelKind); - // 「均值滤波 / 中心轴边沿计数」面板:内容区超高时用滚轮翻页 + // 「均值滤波 / 中心轴边沿计数 / 弱峰调整 / 边沿计数」面板:内容区超高时用滚轮翻页 //(上百个信号合并时必备)。 - if (hoveredKind == EdgePanelKind::MEAN || hoveredKind == EdgePanelKind::CENTER) + if (hoveredKind == EdgePanelKind::MEAN || hoveredKind == EdgePanelKind::CENTER || + hoveredKind == EdgePanelKind::PEAK || hoveredKind == EdgePanelKind::COUNT) { const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, hoveredKind); if (layout.content.Width > 0.0f && @@ -3277,16 +3285,27 @@ namespace jdi Tab().Lanes()[static_cast(panelLane)]; const float current = (hoveredKind == EdgePanelKind::CENTER) ? wheelLane.edgeCenterScroll + : (hoveredKind == EdgePanelKind::PEAK) + ? wheelLane.weakPeakScroll + : (hoveredKind == EdgePanelKind::COUNT) + ? wheelLane.edgeCountScroll : wheelLane.meanFilterScroll; const float next = current - static_cast(notches * 40.0 * m_scale); const float target = std::max(0.0f, std::min(next, maxScroll)); - if (hoveredKind == EdgePanelKind::CENTER) + switch (hoveredKind) { + case EdgePanelKind::CENTER: SetLaneEdgeCenterScroll(panelLane, target); - } - else - { + break; + case EdgePanelKind::PEAK: + SetLaneWeakPeakScroll(panelLane, target); + break; + case EdgePanelKind::COUNT: + SetLaneEdgeCountScroll(panelLane, target); + break; + default: SetLaneMeanFilterScroll(panelLane, target); + break; } return true; } @@ -4171,5 +4190,4 @@ namespace jdi } -} // namespace jdi - +} // namespace jdi \ No newline at end of file diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index d071b70..88745a1 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -3774,6 +3774,8 @@ namespace jdi mix(static_cast(peakItem != nullptr ? peakItem->amountPercent + 2 : 0)); mix(static_cast(peakItem != nullptr ? peakItem->iterations + 2 : 0)); mix(static_cast(peakItem != nullptr ? peakItem->maxIterations + 2 : 0)); + // 「弱峰调整」的条目栏三角(展开 / 收起)直接改变面板像素 ⇒ 必须进键。 + mix((peakItem != nullptr && peakItem->collapsed) ? 1ull : 0ull); } // ⚠️⚠️ **边沿统计的数字本身就是面板内容,必须进缓存键**(用户报告的缺陷根因): @@ -4381,21 +4383,25 @@ namespace jdi // ⚠️ 未完成时**两个数字后面都带 `…`**(旧实现只在整行末尾追加一次, // 视觉上只跟在“下降沿”的数字后面;用户明确要求为“上升沿”也补上这个尾缀)。 const auto paintCounts = [this, &g](const gd::RectF& rect, std::size_t rising, - std::size_t falling, bool complete) + std::size_t falling, bool complete, const gd::RectF& clip) { + // ⚠️ **必须用 `UiTextClipped`**:GDI 文本不受 GDI+ `SetClip` 约束, + // 内容区滚动时统计文字会画到面板标题栏 / 面板之外 + //(用户 2026-11-02 报告“滚动时绘制超出了浮动面板”的直接原因)。 if (EdgeCountsPending(rising, falling, complete)) { - UiText(g, L"统计中…", m_fonts->monoSmall, rect, m_theme->textMuted, TextAlign::LEFT); + UiTextClipped(g, L"统计中…", m_fonts->monoSmall, rect, m_theme->textMuted, + TextAlign::LEFT, clip); return; } const float gap = kEdgePanelStatColumnGap * m_scale; const float columnWidth = std::max(10.0f, (rect.Width - gap) * 0.5f); - UiText(g, EdgeRisingText(rising, complete), m_fonts->monoSmall, - gd::RectF(rect.X, rect.Y, columnWidth, rect.Height), m_theme->textMuted, - TextAlign::LEFT); - UiText(g, EdgeFallingText(falling, complete), m_fonts->monoSmall, - gd::RectF(rect.X + columnWidth + gap, rect.Y, columnWidth, rect.Height), - m_theme->textMuted, TextAlign::LEFT); + UiTextClipped(g, EdgeRisingText(rising, complete), m_fonts->monoSmall, + gd::RectF(rect.X, rect.Y, columnWidth, rect.Height), m_theme->textMuted, + TextAlign::LEFT, clip); + UiTextClipped(g, EdgeFallingText(falling, complete), m_fonts->monoSmall, + gd::RectF(rect.X + columnWidth + gap, rect.Y, columnWidth, rect.Height), + m_theme->textMuted, TextAlign::LEFT, clip); }; std::vector nextSlots; @@ -4483,8 +4489,7 @@ namespace jdi // **可滚动分支**(`if (listStyle)` 内)。若它们仍被算作 `itemized`,旧分支会 // **再画一遍**每行的名称 + 复选框 —— 且旧分支**没有任何裁切**,滚动时这些元素就会 // 画到标题栏上(用户 2026-10-30 报告“元素超出浮动面板”的根因)。 - const bool listStyle = (meanFilter || centerAxis); - const bool itemized = weakPeak; + const bool listStyle = (meanFilter || centerAxis || weakPeak); const bool hasButton = (meanFilter || weakPeak); { if (index >= count) @@ -4715,225 +4720,6 @@ namespace jdi // 折起的面板只保留标题栏:内容区(条目 / 分组栏 / 按钮 / 滚动条)全部省略。 if (!layout.collapsed) { - // 「中心轴边沿计数」/「均值滤波」/「弱峰调整」面板都采用「逐条目」排布: - // 第一排 = 名称 + 复选框,第二排 = 两个参数输入框(仅勾选的条目, - // 弱峰调整没有参数输入框),中心轴再多第三排 = 对应的统计结果。 - if (itemized) - { - for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) - { - const SignalRef& ref = layout.rowRefs[rowIndex]; - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || resolved.style == nullptr) - { - continue; - } - const std::uint32_t signalColor = StyleColor(*resolved.style); - const gd::RectF& checkRect = layout.rowChecks[rowIndex]; - const bool checked = meanFilter ? tabLane.MeanFilterEnabled(ref) - : (weakPeak ? tabLane.WeakPeakEnabled(ref) - : tabLane.EdgeCenterEnabled(ref)); - - // 名称:第一排左侧,避开右侧复选框(超长时省略)。 - const float nameWidth = - std::min(std::max(20.0f, checkRect.X - (panel.X + pad) - 6.0f * m_scale), - kEdgePanelNameMaxWidth * m_scale); - UiText(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth), - m_fonts->uiSmall, - gd::RectF(panel.X + pad, checkRect.Y - 3.0f * m_scale, nameWidth, - checkRect.Height + 6.0f * m_scale), - signalColor, TextAlign::LEFT); - UiDrawCheckBox(g, checkRect, checked ? 1 : 0, 3.0f * m_scale); - - if (!checked) - { - continue; - } - - // MEAN / CENTER 的平滑参数条目(弱峰调整用自己的 `WeakPeakItem`)。 - const Lane::MeanFilterItem* item = - (meanFilter || centerAxis) - ? (meanFilter ? tabLane.FindMeanFilterItem(ref) - : tabLane.FindEdgeCenterItem(ref)) - : nullptr; - - // 「弱峰调整」:每个被勾选的信号各有两行**滑块** - //(调整幅度 -200% – +200% / 迭代次数 1 … 该信号自定义上限)。 - // 迭代次数行的右侧数值可点击 → 整行变为输入框以修改滑块上限。 - if (weakPeak) - { - const Lane::WeakPeakItem* peakItem = tabLane.FindWeakPeakItem(ref); - if (peakItem != nullptr && rowIndex < layout.rowLevelTracks.size() && - layout.rowLevelTracks[rowIndex].Width > 0.0f) - { - // 两行滑块的「标签」始终绘制(编辑上限时也要能看出在改哪一项); - // 「轨道 + 数值」在编辑上限时改由 `PaintEditField` 画成输入框。 - const auto paintSliderLabel = [&](const gd::RectF& track, - const wchar_t* label) - { - const float labelColumn = - WeakPeakLabelColumnWidth(m_fonts->uiSmall); - const float labelLeft = - track.X - 6.0f * m_scale - labelColumn; - UiText(g, label, m_fonts->uiSmall, - gd::RectF(labelLeft, track.Y - 8.0f * m_scale, - labelColumn, track.Height + 16.0f * m_scale), - m_theme->textMuted, TextAlign::LEFT); - }; - const auto paintSliderBody = [&](const gd::RectF& track, double ratio, - const std::wstring& valueText, - bool active) - { - UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale); - UiText(g, valueText, m_fonts->monoSmall, - gd::RectF(track.X + track.Width + 6.0f * m_scale, - track.Y - 8.0f * m_scale, - WeakPeakValueColumnWidth(m_fonts->monoSmall), - track.Height + 16.0f * m_scale), - m_theme->textStrong, TextAlign::LEFT); - }; - // 每行滑块右侧的**复位控件**(与「系数/偏移」二级面板里的还原图标同一套绘制): - // 悬停时高亮 + 手型光标,点击把该行参数恢复为默认值(幅度 0 / 迭代 1 且上限回初始值)。 - const auto paintSliderReset = [&](const gd::RectF& rect) - { - if (rect.Width <= 0.0f) - { - return; - } - const bool hot = - (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && - UiPointInRect(rect, static_cast(m_mouseX), - static_cast(m_mouseY)); - if (hot) - { - UiFillRoundRect( - g, gd::RectF(rect.X - 1.0f * m_scale, rect.Y - 1.0f * m_scale, - rect.Width + 2.0f * m_scale, - rect.Height + 2.0f * m_scale), - 3.0f * m_scale, WithAlpha(m_theme->accent, 0x33)); - } - PaintPanelResetIcon(g, rect, - hot ? m_theme->accent : m_theme->textMuted, m_scale); - }; - - const gd::RectF& levelTrack = layout.rowLevelTracks[rowIndex]; - const gd::RectF& iterTrack = layout.rowIterTracks[rowIndex]; - paintSliderLabel(levelTrack, kWeakPeakLevelLabel); - paintSliderLabel(iterTrack, kWeakPeakIterationsSliderLabel); - - const bool levelActive = (m_dragMode == PlotDragMode::PEAK_LEVEL) && - (m_peakDragLane == static_cast(index)) && - (m_peakDragRef == ref); - const double levelRatio = - (static_cast(std::max(kWeakPeakAmountMin, - std::min(kWeakPeakAmountMax, - peakItem->amountPercent))) / - (100.0 * kWeakPeakSliderScale) + - 1.0) * - 0.5; - paintSliderBody(levelTrack, levelRatio, - (peakItem->amountPercent > 0 ? L"+" : L"") + - std::to_wstring(peakItem->amountPercent) + L"%", - levelActive); - paintSliderReset(layout.rowLevelResets[rowIndex]); - - // 迭代次数滑块:量程 = 1 … 该信号自己的上限。 - const int peakMax = std::max(kWeakPeakIterationsMin, - std::min(kWeakPeakIterationsMaxLimit, - peakItem->maxIterations)); - const int peakIter = std::max( - kWeakPeakIterationsMin, std::min(peakMax, peakItem->iterations)); - const double iterRatio = - (peakMax > kWeakPeakIterationsMin) - ? (static_cast(peakIter - kWeakPeakIterationsMin) / - static_cast(peakMax - kWeakPeakIterationsMin)) - : 0.0; - const bool iterActive = (m_dragMode == PlotDragMode::PEAK_ITERATIONS) && - (m_peakDragLane == static_cast(index)) && - (m_peakDragRef == ref); - const bool editingMax = - (m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX) && - (m_editLane == static_cast(index)) && (m_editMeanRef == ref); - if (!editingMax) - { - paintSliderBody(iterTrack, iterRatio, std::to_wstring(peakIter), - iterActive); - paintSliderReset(layout.rowIterResets[rowIndex]); - } - } - } - - // MEAN / CENTER:第二排的两个参数输入框。 - if (!weakPeak) - { - const int shownPoints = (item != nullptr) ? item->points : 5; - const int shownOrder = (item != nullptr) ? item->iterations : 1; - const PlotEditTarget pointsTarget = - meanFilter ? PlotEditTarget::MEAN_FILTER_POINTS - : PlotEditTarget::EDGE_CENTER_POINTS; - const PlotEditTarget orderTarget = - meanFilter ? PlotEditTarget::MEAN_FILTER_ITERATIONS - : PlotEditTarget::EDGE_CENTER_ITERATIONS; - const auto paintField = [&](const gd::RectF& rect, const wchar_t* label, - int value, PlotEditTarget target) - { - if (rect.Width <= 0.0f) - { - return; - } - const float labelWidth = UiMeasureTextWidth(m_fonts->uiSmall, label); - UiText(g, label, m_fonts->uiSmall, - gd::RectF(rect.X, rect.Y, labelWidth, rect.Height), m_theme->textMuted, - TextAlign::LEFT); - - const float fieldWidth = - std::max(kEdgePanelEditFieldWidth * m_scale * 0.75f, - UiMeasureTextWidth(m_fonts->monoSmall, std::to_wstring(value)) + - 18.0f * m_scale); - const float fieldLeft = rect.X + rect.Width - fieldWidth; - const float fieldHeight = rect.Height - 4.0f * m_scale; - const gd::RectF fieldRect(fieldLeft, rect.Y + 2.0f * m_scale, fieldWidth, - fieldHeight); - - const bool editingThis = (m_editTarget == target) && - (m_editLane == static_cast(index)) && - (m_editMeanRef == ref); - if (editingThis) - { - UiDrawEditField(g, fieldRect, m_edit, m_fonts->monoSmall, m_scale); - } - else - { - UiFillRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->pressed); - UiDrawRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->border, 1.0f); - UiText(g, std::to_wstring(value), m_fonts->monoSmall, fieldRect, - m_theme->textStrong, TextAlign::CENTER); - } - }; - paintField(layout.rowPoints[rowIndex], L"均值点数 X", shownPoints, pointsTarget); - paintField(layout.rowIterations[rowIndex], kWeakPeakIterationsLabel, shownOrder, - orderTarget); - } - - // 第三排(中心轴):该信号在当前参数下的上升沿/下降沿统计结果。 - if (centerAxis && rowIndex < layout.rowStats.size() && - layout.rowStats[rowIndex].Width > 0.0f) - { - const gd::RectF& statsRect = layout.rowStats[rowIndex]; - const int statsPoints = (item != nullptr) ? item->points : 5; - const int statsOrder = (item != nullptr) ? item->iterations : 1; - std::size_t rising = 0; - std::size_t falling = 0; - bool statsComplete = false; - EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, - statsPoints, statsOrder, EdgeRangeActive(), - EdgeRangeMin(), EdgeRangeMax(), rising, falling, - statsComplete); - // 两列排版(列起点固定)⇒ 多条信号的“上升沿 / 下降沿”垂直对齐。 - paintCounts(statsRect, rising, falling, statsComplete); - } - } - } // 「均值滤波」面板:逐信号的**信号色叠底标题栏**(左侧展开三角 + 信号名 + // 右端复选框)。复选框勾选后才出现三角、才能展开下面的两行参数滑块 @@ -5036,10 +4822,17 @@ namespace jdi const std::uint32_t signalColor = StyleColor(*resolved.style); const UiInk ink = UiContrastInk(signalColor); // 「均值滤波」看 `meanFilterItems`、「中心轴边沿计数」看 `edgeCenterItems` - // (两者都是 `Lane::MeanFilterItem`,字段完全一致)。 - const Lane::MeanFilterItem* item = meanFilter ? tabLane.FindMeanFilterItem(ref) - : tabLane.FindEdgeCenterItem(ref); - const bool checked = (item != nullptr); + //(两者都是 `Lane::MeanFilterItem`,字段完全一致);「弱峰调整」看 `weakPeakItems`。 + const Lane::WeakPeakItem* peakItem = + weakPeak ? tabLane.FindWeakPeakItem(ref) : nullptr; + const Lane::MeanFilterItem* item = + weakPeak ? nullptr + : (meanFilter ? tabLane.FindMeanFilterItem(ref) + : tabLane.FindEdgeCenterItem(ref)); + const bool checked = weakPeak ? (peakItem != nullptr) : (item != nullptr); + const bool rowCollapsed = + weakPeak ? (peakItem != nullptr && peakItem->collapsed) + : (item != nullptr && item->collapsed); // 内容区已由 `g.SetClip(layout.content)` 裁切(行可以“半截”显示,滚动更顺滑); // 已经完整滚出可视范围的行在上面就 `continue` 了,这里不再额外门控。 @@ -5055,11 +4848,11 @@ namespace jdi // 左侧三角(仅勾选时存在):朝下 = 展开、朝右 = 收起。 const gd::RectF& triangle = layout.rowTriangles[rowIndex]; - if (triangle.Width > 0.0f && item != nullptr) + if (triangle.Width > 0.0f && checked) { UiDrawTriangleIcon(g, triangle, ink.text, - item->collapsed ? IconDirection::RIGHT - : IconDirection::DOWN); + rowCollapsed ? IconDirection::RIGHT + : IconDirection::DOWN); } // 名称:从三角右侧到复选框左侧。 @@ -5075,7 +4868,7 @@ namespace jdi TextAlign::LEFT, layout.content); paintMeanCheck(check, checked, ink, signalColor); - if (!checked || item->collapsed) + if (!checked || rowCollapsed) { continue; } @@ -5090,9 +4883,11 @@ namespace jdi EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, item->points, item->iterations, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); - paintCounts(layout.rowStats[rowIndex], rising, falling, complete); + paintCounts(layout.rowStats[rowIndex], rising, falling, complete, + layout.content); } + // 两行参数滑块(标签 / 轨道 / 数值 / 复位按钮)。 const auto paintMeanSlider = [&](const gd::RectF& track, const gd::RectF& valueRect, @@ -5140,6 +4935,105 @@ namespace jdi // ⚠️ 「均值点数」的最小值:**均值滤波 = 1**(用户 2026-10-30 要求不能为 0, // 0 与不做平滑完全等价,看起来像功能坏了);**中心轴边沿计数 = 0** // (0 = 不平滑,直接对原始采样值统计边沿 —— 这是有意义的选项,保持旧语义)。 + if (weakPeak) + { + // 「弱峰调整」两行滑块:调整幅度(0 在正中间 ⇒ ±400%)与迭代次数(1 … 自定上限)。 + const int peakAmount = std::max( + kWeakPeakAmountMin, std::min(kWeakPeakAmountMax, peakItem->amountPercent)); + const int peakMax = std::max( + kWeakPeakIterationsMin, + std::min(kWeakPeakIterationsMaxLimit, peakItem->maxIterations)); + const int peakIter = + std::max(kWeakPeakIterationsMin, std::min(peakMax, peakItem->iterations)); + const bool levelActive = (m_dragMode == PlotDragMode::PEAK_LEVEL) && + (m_peakDragLane == static_cast(index)) && + (m_peakDragRef == ref); + const bool iterActive = (m_dragMode == PlotDragMode::PEAK_ITERATIONS) && + (m_peakDragLane == static_cast(index)) && + (m_peakDragRef == ref); + const bool editingMax = + (m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX) && + (m_editLane == static_cast(index)) && (m_editMeanRef == ref); + // 幅度滑块:比例 = (幅度 / (100*4) + 1) / 2(0% 在正中间)。 + const auto paintPeakSlider = [&](const gd::RectF& track, + const gd::RectF& valueRect, + const gd::RectF& resetRect, + const wchar_t* label, double ratio, + const std::wstring& valueText, bool active, + bool hideBody) + { + const float labelLeft = track.X - 6.0f * m_scale - + UiMeasureTextWidth(m_fonts->uiSmall, label); + UiTextClipped(g, label, m_fonts->uiSmall, + gd::RectF(labelLeft, track.Y - 8.0f * m_scale, + UiMeasureTextWidth(m_fonts->uiSmall, label), + track.Height + 16.0f * m_scale), + m_theme->textMuted, TextAlign::LEFT, layout.content); + if (hideBody) + { + return; // 正在编辑上限:轨道与数值改由 `PaintEditField` 画 + } + UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale); + UiTextClipped(g, valueText, m_fonts->monoSmall, + gd::RectF(valueRect.X, valueRect.Y, valueColumn, + valueRect.Height), + m_theme->textStrong, TextAlign::LEFT, layout.content); + const bool hot = (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && + UiPointInRect(resetRect, + static_cast(m_mouseX), + static_cast(m_mouseY)); + if (hot) + { + UiFillRoundRect( + g, gd::RectF(resetRect.X - 1.0f * m_scale, + resetRect.Y - 1.0f * m_scale, + resetRect.Width + 2.0f * m_scale, + resetRect.Height + 2.0f * m_scale), + 3.0f * m_scale, WithAlpha(m_theme->accent, 0x33)); + } + PaintPanelResetIcon(g, resetRect, + hot ? m_theme->accent : m_theme->textMuted, + m_scale); + }; + const double levelRatio = + (static_cast(peakAmount) / (100.0 * kWeakPeakSliderScale) + 1.0) * 0.5; + const double iterRatio = + (peakMax > kWeakPeakIterationsMin) + ? (static_cast(peakIter - kWeakPeakIterationsMin) / + static_cast(peakMax - kWeakPeakIterationsMin)) + : 0.0; + if (rowIndex < layout.rowLevelTracks.size() && + layout.rowLevelTracks[rowIndex].Width > 0.0f) + { + paintPeakSlider( + layout.rowLevelTracks[rowIndex], + rowIndex < layout.rowLevelValues.size() + ? layout.rowLevelValues[rowIndex] + : gd::RectF(), + rowIndex < layout.rowLevelResets.size() + ? layout.rowLevelResets[rowIndex] + : gd::RectF(), + kWeakPeakLevelLabel, levelRatio, + (peakAmount > 0 ? L"+" : L"") + std::to_wstring(peakAmount) + L"%", + levelActive, false); + } + if (rowIndex < layout.rowIterTracks.size() && + layout.rowIterTracks[rowIndex].Width > 0.0f) + { + paintPeakSlider( + layout.rowIterTracks[rowIndex], + rowIndex < layout.rowIterValues.size() + ? layout.rowIterValues[rowIndex] + : gd::RectF(), + rowIndex < layout.rowIterResets.size() + ? layout.rowIterResets[rowIndex] + : gd::RectF(), + kWeakPeakIterationsSliderLabel, iterRatio, + std::to_wstring(peakIter), iterActive, editingMax); + } + } + else + { const int pointsFloor = centerAxis ? 0 : kMeanFilterPointsMin; const int pointsMax = std::max(pointsFloor, std::min(kMeanFilterPointsMaxLimit, @@ -5153,11 +5047,12 @@ 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_meanDragCenter == centerAxis); + (m_meanDragRef == ref) && + (m_meanDragKind == kind); const bool iterationsActive = (m_dragMode == PlotDragMode::MEAN_ITERATIONS) && (m_meanDragLane == static_cast(index)) && (m_meanDragRef == ref) && - (m_meanDragCenter == centerAxis); + (m_meanDragKind == kind); if (rowIndex < layout.rowLevelTracks.size()) { paintMeanSlider(layout.rowLevelTracks[rowIndex], @@ -5182,6 +5077,8 @@ namespace jdi kMeanFilterIterationsSliderLabel, iterations, kMeanFilterIterationsMin, iterationsMax, iterationsActive); } + } + } g.Restore(meanSave); @@ -5537,38 +5434,63 @@ namespace jdi } } - float rowY = panel.Y + titleHeight + pad; - for (const SignalRef& ref : tabLane.signals) + // 「边沿计数」:逐信号两行(名称 / 上升沿·下降沿)。内容区**可滚动且按内容区裁剪** + //(上百个信号时内容高达上万像素:限高后出现滚动条,面板高度被绘图区夹住, + // 面板像素缓存才存得进去 —— 否则每帧整块重画,所有交互都会卡顿)。 { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible) + const int contentState = g.Save(); + g.SetClip(layout.content, gd::CombineModeIntersect); + float rowY = layout.content.Y - layout.scrollOffset; + for (const SignalRef& ref : tabLane.signals) { - continue; - } - if (itemized || freeMarker || listStyle) - { - // 逐条目绘制已在上面完成(自由标记同样是逐条目排布, - // 均值滤波走自己的可滚动分支)。 - continue; - } - const std::uint32_t signalColor = StyleColor(*resolved.style); - const float textWidth = panel.Width - pad * 2.0f; - // 名称:超长时省略(面板宽度被 `kEdgePanelNameMaxWidth` 限制,不会把面板顶得很长)。 - UiText(g, - UiEllipsize(resolved.data->name, m_fonts->uiSmall, - std::min(textWidth, kEdgePanelNameMaxWidth * m_scale)), - m_fonts->uiSmall, gd::RectF(panel.X + pad, rowY, textWidth, rowHeight), - signalColor, TextAlign::LEFT); + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible) + { + continue; + } + if (freeMarker || listStyle) + { + // 这两种排布已在上面画完(各自有自己的可滚动分支)。 + continue; + } + if (rowY + rowHeight * 2.0f <= layout.content.Y || + rowY >= layout.content.Y + layout.content.Height) + { + rowY += rowHeight * 2.0f; // 整行滚出可视范围 + continue; + } + const std::uint32_t signalColor = StyleColor(*resolved.style); + const float textWidth = layout.content.Width; + // 名称:超长时省略(面板宽度被 `kEdgePanelNameMaxWidth` 限制,不会把面板顶得很长)。 + // ⚠️ GDI 文本必须走 `UiTextClipped`,否则滚动时会画到标题栏 / 面板之外。 + UiTextClipped(g, + UiEllipsize(resolved.data->name, m_fonts->uiSmall, + std::min(textWidth, kEdgePanelNameMaxWidth * m_scale)), + m_fonts->uiSmall, + gd::RectF(layout.content.X, rowY, textWidth, rowHeight), + signalColor, TextAlign::LEFT, layout.content); - std::size_t rising = 0; - std::size_t falling = 0; - bool complete = true; - EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, tabLane.edgeValue, - EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, - complete); - paintCounts(gd::RectF(panel.X + pad, rowY + rowHeight, textWidth, rowHeight), rising, - falling, complete); - rowY += rowHeight * 2.0f; + std::size_t rising = 0; + std::size_t falling = 0; + bool complete = true; + EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, + tabLane.edgeValue, EdgeRangeActive(), EdgeRangeMin(), + EdgeRangeMax(), rising, falling, complete); + paintCounts( + gd::RectF(layout.content.X, rowY + rowHeight, textWidth, rowHeight), rising, + falling, complete, layout.content); + rowY += rowHeight * 2.0f; + } + g.Restore(contentState); + + // 内容滚动条(只在内容超高时出现)。 + if (layout.scrollTrack.Width > 0.0f) + { + UiFillRoundRect(g, layout.scrollTrack, layout.scrollTrack.Width * 0.5f, + WithAlpha(m_theme->border, 0x88)); + UiFillRoundRect(g, layout.scrollThumb, layout.scrollThumb.Width * 0.5f, + m_theme->borderStrong); + } } // 逐条目面板底部按钮行:「构建为新信号」(悬停高亮); @@ -6006,4 +5928,3 @@ namespace jdi } } // namespace jdi - diff --git a/Components/PlotView/Src/PlotViewPanels.cpp b/Components/PlotView/Src/PlotViewPanels.cpp index 24fd0e1..7b80884 100644 --- a/Components/PlotView/Src/PlotViewPanels.cpp +++ b/Components/PlotView/Src/PlotViewPanels.cpp @@ -28,10 +28,12 @@ namespace jdi const bool centerAxis = (kind == EdgePanelKind::CENTER); const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool weakPeak = (kind == EdgePanelKind::PEAK); - // 「弱峰调整」仍是旧式逐条目面板(名称 + 复选框 + 两行滑块)。 - // ⚠️ 「均值滤波」与「中心轴边沿计数」已改为**可滚动的分组列表**(信号色叠底条目栏), - // 它们的宽度由下面的 `groupWidth` / 滑块行分支确定。 - const bool itemized = weakPeak; + // 「均值滤波」「中心轴边沿计数」「弱峰调整」自 2026-11-02 起全部是**可滚动的分组列表** + //(信号色叠底条目栏 + 右侧复选框 + 展开后的参数行)。 + // ⚠️ 旧式「逐条目」排版(名称 + 复选框 + 参数行,无滚动)已彻底删除: + // 上百个信号时它会算出上万像素高的面板,超出绘图区后**面板像素缓存一次都存不进去** + // ⇒ 每一帧整块重画 ⇒ 所有交互卡顿(用户 2026-11-02 报告)。 + const bool listStyle = (meanFilter || centerAxis || weakPeak); // 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” / “均值滤波” / “弱峰调整” // + 光标区间尾缀)。 @@ -65,16 +67,10 @@ namespace jdi std::min(UiMeasureTextWidth(m_fonts->uiSmall, resolved.data->name), kEdgePanelNameMaxWidth * m_scale); nameColumnWidth = std::max(nameColumnWidth, nameWidth); - if (itemized) + if (listStyle) { - // 逐条目面板:第一排 = 名称 + 复选框;第三排(中心轴)= 统计结果。 - width = std::max(width, kEdgePanelPad * 2.0f * m_scale + nameWidth + - kEdgePanelMeanCheckSize * m_scale + 6.0f * m_scale); - } - if (meanFilter || centerAxis) - { - // 「均值滤波 / 中心轴边沿计数」的条目 = **信号色叠底条目栏**(三角 + 名称 + 复选框) - // 与参数行(标签 + 轨道 + 数值 + 复位按钮),右侧还要给滚动条留位。 + // 条目 = **信号色叠底条目栏**(三角 + 名称 + 复选框)与参数行 + //(标签 + 轨道 + 数值 + 复位按钮),右侧还要给滚动条留位。 const float groupWidth = kEdgePanelPad * 2.0f * m_scale + kEdgePanelDisclosureWidth * m_scale + 6.0f * m_scale + nameWidth + 6.0f * m_scale + @@ -126,19 +122,22 @@ namespace jdi std::max(nameColumnWidth, countsWidth)); } - if (meanFilter || centerAxis) + if (listStyle) { // 两行滑块 = 「标签列 + 轨道(最小宽度)+ 数值列 + 复位按钮」(两行共用同一列宽), // 右侧再给滚动条留位(上百个信号时内容必然超高)。 const float labelColumn = std::max(UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterPointsSliderLabel), UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterIterationsSliderLabel)); + const float weakPeakLabelColumn = + std::max(UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakLevelLabel), + UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakIterationsSliderLabel)); const float valueColumn = std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"), - UiMeasureTextWidth(m_fonts->monoSmall, L"1000")); - const float sliderRowWidth = labelColumn + 6.0f * m_scale + kWeakPeakTrackMinWidth * m_scale + - 6.0f * m_scale + valueColumn + kWeakPeakResetGap * m_scale + - kWeakPeakResetButtonSize * m_scale; + UiMeasureTextWidth(m_fonts->monoSmall, L"-400%")); + const float sliderRowWidth = std::max(labelColumn, weakPeakLabelColumn) + 6.0f * m_scale + + kWeakPeakTrackMinWidth * m_scale + 6.0f * m_scale + valueColumn + + kWeakPeakResetGap * m_scale + kWeakPeakResetButtonSize * m_scale; width = std::max(width, kEdgePanelPad * 2.0f * m_scale + sliderRowWidth + (kFreeMarkerScrollWidth + 4.0f) * m_scale); } @@ -153,7 +152,7 @@ namespace jdi width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth); } - if (meanFilter || weakPeak) + if (listStyle) { // 底部按钮行 = 「构建为新信号」,按按钮文字实测宽度确定下限。 const float buttonWidth = @@ -161,6 +160,13 @@ namespace jdi width = std::max(width, kEdgePanelPad * 2.0f * m_scale + buttonWidth); } + if (kind == EdgePanelKind::COUNT) + { + // 「边沿计数」:右侧要给内容滚动条留位(上百个信号时内容必然超高)。 + width = std::max(width, kEdgePanelPad * 3.0f * m_scale + + (kFreeMarkerScrollWidth + 4.0f) * m_scale); + } + if (kind == EdgePanelKind::MARK) { // 「自由标记」面板:宽度 = 六列(取「缺省列宽」「实测内容宽度」「列标题宽度」三者 @@ -254,8 +260,7 @@ namespace jdi // ⚠️ 「中心轴边沿计数」从 2026-11-02 起同样改为**可滚动的分组列表**(用户明确要求 // “改造为与均值滤波相同的风格”)⇒ 也不走 `itemized`。它没有「构建为新信号」按钮, // 但内容多一行「上升沿 / 下降沿」统计。 - const bool listStyle = (meanFilter || centerAxis); - const bool itemized = weakPeak; + const bool listStyle = (meanFilter || centerAxis || weakPeak); const bool hasButton = (meanFilter || weakPeak); const bool enabled = meanFilter ? lane.meanFilter : (weakPeak ? lane.weakPeak @@ -299,27 +304,27 @@ namespace jdi const float titleHeight = kEdgePanelTitleHeight * m_scale; const float rowHeight = kEdgePanelRowHeight * m_scale; const float pad = kEdgePanelPad * m_scale; - // 「中心轴边沿计数」「均值滤波」「弱峰调整」共用同一套「逐条目」排布, - // 因此不再有独立的底部编辑行;只有「边沿计数」保留两行文本式排版。 + // 「中心轴边沿计数」「均值滤波」「弱峰调整」共用同一套**可滚动列表**排布, + // 因此不再有独立的底部编辑行;只有「边沿计数」保留两行文本式排版(同样可滚动)。 const float buttonHeight = hasButton ? (kEdgePanelButtonHeight * m_scale) : 0.0f; - const float rowsPerSignal = meanFilter ? 1.0f : 2.0f; + const float rowsPerSignal = 2.0f; // 「边沿计数」每信号两行(名称 / 上升沿·下降沿) + // 面板底部(内容区之下)占的纵向空间:有按钮时 = pad + 按钮 + pad, + // 无按钮时只有 pad。⚠️ 旧实现无条件按「按钮行 + 三个 pad」预留 —— + // 无按钮的面板(中心轴边沿计数)因此在内容区下方留下**一大块空白** + //(= 按钮高度 30 逻辑像素 + 多出的 pad,用户 2026-11-02 报告)。 + const float footerHeight = hasButton ? (buttonHeight + pad) : 0.0f; + const float bottomPad = pad; - // 逐条目面板:每个条目 = 第一排(信号名 + 复选框) - // + 参数部分(**只在该条目被勾选时才占位**,因此面板高度随勾选数量动态变化): - // * MEAN / CENTER:一行「均值点数 X + 迭代次数 N」输入框(中心轴再多一行统计结果); - // * PEAK:两行滑块(调整幅度 % / 迭代次数 N),每个信号各有一套自己的参数。 - const bool itemHasParams = (centerAxis || weakPeak); const float weakPeakSliderRow = kWeakPeakSliderRowHeight * m_scale; - float meanRowHeight = 0.0f; - float itemsHeight = 0.0f; float meanContentHeight = 0.0f; float height = 0.0f; if (listStyle) { - // 「均值滤波 / 中心轴边沿计数」:内容区 = 逐信号的**信号色叠底条目栏** + // 「均值滤波 / 中心轴边沿计数 / 弱峰调整」:内容区 = 逐信号的**信号色叠底条目栏** //(内容总是显示,用于勾选/取消)+(勾选且未收起时)参数行。 // 「中心轴」的参数行 = 一行「上升沿 / 下降沿」统计 + 两行滑块; - // 「均值滤波」= 两行滑块。内容超高时出现滚动条;底部「构建为新信号」按钮固定不滚动。 + // 「均值滤波」与「弱峰调整」= 两行滑块。 + // 内容超高时出现滚动条;底部「构建为新信号」按钮固定不滚动。 const float groupRow = kEdgePanelMeanRowHeight * m_scale; const float sliderRow = weakPeakSliderRow; float content = 0.0f; @@ -331,9 +336,19 @@ namespace jdi continue; } content += groupRow; - const Lane::MeanFilterItem* item = meanFilter ? lane.FindMeanFilterItem(ref) - : lane.FindEdgeCenterItem(ref); - if (item != nullptr && !item->collapsed) + bool expanded = false; + if (weakPeak) + { + const Lane::WeakPeakItem* item = lane.FindWeakPeakItem(ref); + expanded = (item != nullptr && !item->collapsed); + } + else + { + const Lane::MeanFilterItem* item = meanFilter ? lane.FindMeanFilterItem(ref) + : lane.FindEdgeCenterItem(ref); + expanded = (item != nullptr && !item->collapsed); + } + if (expanded) { content += sliderRow * 2.0f; if (centerAxis) @@ -343,50 +358,29 @@ namespace jdi } } meanContentHeight = content; - const float budget = m_plotRect.Height - (titleHeight + pad * 3.0f + buttonHeight); + const float budget = + m_plotRect.Height - (titleHeight + pad + bottomPad + footerHeight); const float maxContent = std::max(3.0f * groupRow, std::min(kFreeMarkerContentMaxHeight * m_scale, std::max(3.0f * groupRow, budget))); const float view = std::min(content, maxContent); - height = titleHeight + pad + view + pad + buttonHeight + pad; + height = titleHeight + pad + view + bottomPad + footerHeight; } - else if (itemized) + else if (freeMarker) { - meanRowHeight = kEdgePanelMeanRowHeight * m_scale; - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || !resolved.visible) - { - continue; - } - itemsHeight += meanRowHeight; - const bool checked = meanFilter ? lane.MeanFilterEnabled(ref) - : (weakPeak ? lane.WeakPeakEnabled(ref) - : lane.EdgeCenterEnabled(ref)); - if (checked && itemHasParams) - { - if (weakPeak) - { - itemsHeight += weakPeakSliderRow * 2.0f; - } - else - { - itemsHeight += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale; - if (centerAxis) - { - itemsHeight += kEdgePanelMeanRowHeight * m_scale; - } - } - } - } - // 底部按钮行:上方与条目之间、下方与面板下边界之间各留一个 `pad`, - // 与面板的左右内边距完全一致(原先按钮直接贴着下边框,看起来很不协调)。 - height = titleHeight + pad + itemsHeight + pad + buttonHeight + (hasButton ? pad : 0.0f); + // 「自由标记」的高度在下面的 `if (freeMarker)` 分支里计算(含底部固定区)。 + height = titleHeight; } else { - height = titleHeight + pad + static_cast(rowCount) * rowHeight * rowsPerSignal + pad; + // 「边沿计数」:每信号两行(名称 / 上升沿·下降沿),内容超高时滚动。 + const float content = static_cast(rowCount) * rowHeight * rowsPerSignal; + meanContentHeight = content; + const float budget = m_plotRect.Height - (titleHeight + pad + bottomPad); + const float maxContent = + std::max(2.0f * rowHeight, std::min(kFreeMarkerContentMaxHeight * m_scale, budget)); + const float view = std::min(content, maxContent); + height = titleHeight + pad + view + bottomPad; } // 「自由标记」面板:按信号分组(**分组栏** + 下属固定点条目), @@ -574,11 +568,13 @@ namespace jdi const float groupRow = kEdgePanelMeanRowHeight * m_scale; const float sliderRow = kWeakPeakSliderRowHeight * m_scale; const float scrollW = kFreeMarkerScrollWidth * m_scale; + const float footerHeight = hasButton ? (buttonRowHeight + pad) : 0.0f; const float viewHeight = - std::max(0.0f, height - titleHeight - pad * 3.0f - buttonRowHeight); + std::max(0.0f, height - titleHeight - pad - pad - footerHeight); const bool scrollable = meanContentHeight > viewHeight + 0.5f; const float maxScroll = std::max(0.0f, meanContentHeight - viewHeight); - const float storedScroll = meanFilter ? lane.meanFilterScroll : lane.edgeCenterScroll; + const float storedScroll = meanFilter ? lane.meanFilterScroll + : (weakPeak ? lane.weakPeakScroll : 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)); @@ -600,11 +596,13 @@ namespace jdi // 两行滑块的列宽(与 `EdgePanelWidth` 同一组算式 ⇒ 布局与宽度不会漂移)。 const float labelColumn = - std::max(UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterPointsSliderLabel), - UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterIterationsSliderLabel)); + std::max(std::max(UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterPointsSliderLabel), + UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterIterationsSliderLabel)), + std::max(UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakLevelLabel), + UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakIterationsSliderLabel))); const float valueColumn = std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"), - UiMeasureTextWidth(m_fonts->monoSmall, L"1000")); + UiMeasureTextWidth(m_fonts->monoSmall, L"-400%")); const float resetSize = kWeakPeakResetButtonSize * m_scale; const float resetGap = kWeakPeakResetGap * m_scale; const float trackHeight = kWeakPeakTrackHeight * m_scale; @@ -624,9 +622,21 @@ namespace jdi { continue; } - const Lane::MeanFilterItem* item = meanFilter ? lane.FindMeanFilterItem(ref) - : lane.FindEdgeCenterItem(ref); - const bool checked = (item != nullptr); + bool checked = false; + bool itemCollapsed = false; + if (weakPeak) + { + const Lane::WeakPeakItem* item = lane.FindWeakPeakItem(ref); + checked = (item != nullptr); + itemCollapsed = (item != nullptr && item->collapsed); + } + else + { + const Lane::MeanFilterItem* item = meanFilter ? lane.FindMeanFilterItem(ref) + : lane.FindEdgeCenterItem(ref); + checked = (item != nullptr); + itemCollapsed = (item != nullptr && item->collapsed); + } layout.rowRefs.push_back(ref); // 组头(信号色叠底):整行宽度,左侧三角(仅勾选时)+ 名称,右端复选框。 layout.rowHeaders.push_back( @@ -647,7 +657,7 @@ namespace jdi rowY += groupRow; // 参数行:只有勾选且未收起时才有几何(未勾选的行两者都是空矩形)。 - if (!checked || item->collapsed) + if (!checked || itemCollapsed) { layout.rowLevelTracks.push_back(gd::RectF()); layout.rowLevelValues.push_back(gd::RectF()); @@ -684,139 +694,30 @@ namespace jdi rowY += sliderRow; } } - else if (itemized) + else if (!freeMarker) { - const float buttonRowHeight = kEdgePanelButtonHeight * m_scale; - if (hasButton) + // 「边沿计数」:内容区 = 逐信号两行(名称 / 上升沿·下降沿),可滚动。 + const float scrollW = kFreeMarkerScrollWidth * m_scale; + const float viewHeight = std::max(0.0f, height - titleHeight - pad - pad); + 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.edgeCountScroll, 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); + layout.markerContentHeight = meanContentHeight; + layout.scrollOffset = scroll; + if (scrollable) { - // 底部按钮与面板下边界之间留出与左右、与内容相同的 `pad` 间距 - //(原先按钮行直接贴着下边框,视觉上与其它元素不协调); - // 面板高度已含这份 pad,因此这里只上移按钮行即可。 - layout.button = gd::RectF(left + pad, top + height - pad - buttonRowHeight, - std::max(0.0f, width - pad * 2.0f), buttonRowHeight); - } - - // 逐信号条目:第一排 = 名称 + 复选框(复选框靠右); - // 参数部分:MEAN / CENTER = 一行两个输入框(中心轴再多一行统计结果); - // PEAK = 两行滑块(每个信号各有自己的等级与迭代次数)。 - const float checkSize = kEdgePanelMeanCheckSize * m_scale; - const float fieldHeight = kEdgePanelMeanEditHeight * m_scale; - const float halfWidth = std::max(40.0f, (width - pad * 2.0f - 8.0f * m_scale) * 0.5f); - // 滑块行列宽(与 `EdgePanelWidth` 使用同一组算式,避免布局与宽度计算漂移)。 - const float weakPeakLabelWidth = WeakPeakLabelColumnWidth(m_fonts->uiSmall); - const float weakPeakValueWidth = WeakPeakValueColumnWidth(m_fonts->monoSmall); - const float weakPeakResetSize = kWeakPeakResetButtonSize * m_scale; - const float weakPeakResetGap = kWeakPeakResetGap * m_scale; - const float weakPeakTrackHeight = kWeakPeakTrackHeight * m_scale; - const float weakPeakTrackLeft = left + pad + weakPeakLabelWidth + 6.0f * m_scale; - const float weakPeakTrackWidth = - std::max(10.0f * m_scale, - (left + width - pad - weakPeakValueWidth - weakPeakResetGap - - weakPeakResetSize - 6.0f * m_scale) - - weakPeakTrackLeft); - float rowY = top + titleHeight + pad; - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || !resolved.visible) - { - continue; - } - - layout.rowRefs.push_back(ref); - layout.rowChecks.push_back(gd::RectF( - left + width - pad - checkSize, rowY + (meanRowHeight - checkSize) * 0.5f, checkSize, - checkSize)); - const bool checked = meanFilter ? lane.MeanFilterEnabled(ref) - : (weakPeak ? lane.WeakPeakEnabled(ref) - : lane.EdgeCenterEnabled(ref)); - if (weakPeak) - { - // 每个被勾选的信号各有两行滑块:调整幅度(-400% – +400%)与迭代次数(1 … 上限)。 - if (checked) - { - const float levelRowTop = rowY + meanRowHeight; - const float iterRowTop = levelRowTop + weakPeakSliderRow; - const gd::RectF levelTrack( - weakPeakTrackLeft, - levelRowTop + (weakPeakSliderRow - weakPeakTrackHeight) * 0.5f, - weakPeakTrackWidth, weakPeakTrackHeight); - const gd::RectF iterTrack( - weakPeakTrackLeft, - iterRowTop + (weakPeakSliderRow - weakPeakTrackHeight) * 0.5f, - weakPeakTrackWidth, weakPeakTrackHeight); - layout.rowLevelTracks.push_back(levelTrack); - layout.rowLevelValues.push_back(gd::RectF( - levelTrack.X + levelTrack.Width + 6.0f * m_scale, levelRowTop, - weakPeakValueWidth, weakPeakSliderRow)); - layout.rowIterTracks.push_back(iterTrack); - layout.rowIterValues.push_back(gd::RectF( - iterTrack.X + iterTrack.Width + 6.0f * m_scale, iterRowTop, - weakPeakValueWidth, weakPeakSliderRow)); - // 每行滑块右侧的**复位按钮**(与数值列同一右边界,保证两行上下对齐)。 - const float resetX = levelTrack.X + levelTrack.Width + 6.0f * m_scale + - weakPeakValueWidth + weakPeakResetGap; - layout.rowLevelResets.push_back( - gd::RectF(resetX, levelRowTop + (weakPeakSliderRow - weakPeakResetSize) * 0.5f, - weakPeakResetSize, weakPeakResetSize)); - layout.rowIterResets.push_back( - gd::RectF(resetX, iterRowTop + (weakPeakSliderRow - weakPeakResetSize) * 0.5f, - weakPeakResetSize, weakPeakResetSize)); - } - else - { - layout.rowLevelTracks.push_back(gd::RectF()); - layout.rowLevelValues.push_back(gd::RectF()); - layout.rowLevelResets.push_back(gd::RectF()); - layout.rowIterTracks.push_back(gd::RectF()); - layout.rowIterValues.push_back(gd::RectF()); - layout.rowIterResets.push_back(gd::RectF()); - } - layout.rowPoints.push_back(gd::RectF()); - layout.rowIterations.push_back(gd::RectF()); - layout.rowStats.push_back(gd::RectF()); - - rowY += meanRowHeight; - if (checked) - { - rowY += weakPeakSliderRow * 2.0f; - } - continue; - } - if (checked && itemHasParams) - { - const float editTop = rowY + meanRowHeight; - layout.rowPoints.push_back( - gd::RectF(left + pad, editTop, halfWidth, fieldHeight)); - layout.rowIterations.push_back( - gd::RectF(left + pad + halfWidth + 8.0f * m_scale, editTop, halfWidth, fieldHeight)); - if (centerAxis) - { - layout.rowStats.push_back( - gd::RectF(left + pad, editTop + fieldHeight, width - pad * 2.0f, - kEdgePanelMeanRowHeight * m_scale)); - } - else - { - layout.rowStats.push_back(gd::RectF()); - } - } - else - { - layout.rowPoints.push_back(gd::RectF()); - layout.rowIterations.push_back(gd::RectF()); - layout.rowStats.push_back(gd::RectF()); - } - - rowY += meanRowHeight; - if (checked && itemHasParams) - { - rowY += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale; - if (centerAxis) - { - rowY += kEdgePanelMeanRowHeight * m_scale; - } - } + layout.scrollTrack = + gd::RectF(left + pad + innerWidth + 4.0f * m_scale, layout.content.Y, scrollW, + viewHeight); + const float thumbHeight = + std::max(18.0f * m_scale, viewHeight * (viewHeight / meanContentHeight)); + const float thumbTop = + layout.content.Y + + (viewHeight - thumbHeight) * ((maxScroll > 0.0f) ? (scroll / maxScroll) : 0.0f); + layout.scrollThumb = gd::RectF(layout.scrollTrack.X, thumbTop, scrollW, thumbHeight); } } diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index 7ac915e..21d1bd0 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -615,6 +615,15 @@ namespace jdi void SignalPanel::EnsureRows() { + // ⚠️ 动画推进只依赖挂钟时间(`AdvanceGroupAnimations`),而驱动它的 `WM_TIMER` 是 + // **最低优先级**的消息:绘图区若在自持刷新(例如边沿统计未完成时不断请求新帧), + // `WM_TIMER` 会被饿死 ⇒ 折起动画永远结束不了 ⇒ 组名三角已经显示“已收起”, + // 但行集合里仍保留着该组的信号行(可被点选)。用户 2026-11-02 报告的正是这个现象。 + // 因此绘制 / 命中路径也顺手推进一步:动画是否结束只看时间,不依赖消息到达。 + if (!m_groupAnims.empty()) + { + AdvanceGroupAnimations(); + } if (m_rowsDirty) { BuildRows(); diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index e034faa..abaa3e8 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -9061,3 +9061,128 @@ make release / make debug 零 warning (`RowPart::CHECK`)—— 想测折叠必须点名称区。 * ⚠️ 「行集合签名是否稳定」不能作为断言:动画**结束那一帧**签名必然变化一次(动画标记位 从 5 → 0),这是正确的。要断言的是"动画结束后 `m_rowsSignature == RowsSignature()`"。 + +--- + +## 2026-11-02 第二十轮(本机):面板限高/裁剪/弱峰列表化 + 边沿统计"统计中…"根治 + 组名折起无法完成 + +> 用户复现的四组问题(`VERY_BIG_DATA.CSV` + `MF4-Record.MF4` 全选后合并成同一网格): +> ①「中心轴边沿计数」面板下方有**无意义空白**;② 勾选全部信号后滚动,**绘制超出面板**; +> ③ 启用「边沿计数」后来自 VERY_BIG_DATA 的信号**永远"统计中…"**; +> ④ 折起分组后**三角已收起但下属信号行仍在、仍可点选**(点第二个组时第一个组才真正收起); +> ⑤ 启用**尚未改造**的实验性功能后一切交互(光标/动画)都很卡。 + +### 1. ⭐⭐ 根本原因:**面板高度没有上限 ⇒ 面板像素缓存永远存不进去** + +`itemized`(旧式逐条目)排布按「每勾选信号 2 行 + 每行 22 逻辑像素」累加,288 条信号时 +面板高达 **13010px**(绘图区只有 846px)。`PanelRectInsideLayer()` 因此恒为假 ⇒ +**面板像素缓存一次都没写进去** ⇒ 每帧整块重画(288 行 × 十来个 GDI+ 原语)。 +「边沿计数」与(改造前的)「弱峰调整」都是这个排布,所以"只要开这两个功能就卡"。 + +**硬规则(新)**:**任何**实验性功能面板的高度都必须被绘图区夹住: +`budget = plotH - (标题 + pad + 底部区)`,`view = min(内容高, maxContent)`, +内容超出时右侧出现**滚动条**。`itemized` 排布已**整体删除**(布局 + 绘制 + 交互), +现在 5 个面板全部是「有高度上限 + 可滚动」的实现。 + +### 2. 四个面板的滚动偏移(`Lane`) + +| 面板 | 偏移字段 | setter | +| --- | --- | --- | +| 均值滤波 | `meanFilterScroll` | `SetLaneMeanFilterScroll` | +| 中心轴边沿计数 | `edgeCenterScroll` | `SetLaneEdgeCenterScroll` | +| **弱峰调整(新)** | **`weakPeakScroll`** | **`SetLaneWeakPeakScroll`** | +| **边沿计数(新)** | **`edgeCountScroll`** | **`SetLaneEdgeCountScroll`** | + +前三个是**列表式**(信号色叠底条目栏 + 右侧复选框 + 展开后的两行滑块); +「边沿计数」保留旧式两行排版,但同样有 `layout.content`(裁剪 + 滚动条 + 滚轮 / 拖动)。 + +### 3. ⚠️⚠️ 无按钮面板的底部留白(用户指出的"无意义空白") + +旧高度算式**无条件**按 `标题 + pad + 内容 + pad + 按钮 + pad` 预留,而 `viewHeight` +又无条件减去 `kEdgePanelButtonHeight`;「中心轴边沿计数」没有「构建为新信号」按钮 ⇒ +内容区下方白留了 **按钮高度 30 + pad 7 = 46 逻辑像素**(实测 55px @125%)。 +现改为: + +``` +footer = hasButton ? (buttonHeight + pad) : 0 +height = 标题 + pad + view + pad + footer +viewHeight = height - 标题 - 2*pad - footer // = view +budget = plotH - (标题 + pad + pad + footer) +``` + +### 4. ⚠️⚠️ "绘制超出面板" = **GDI 文字不受 GDI+ 裁剪约束** + +`paintCounts`(上升沿 / 下降沿)用的是 `UiText`(`DrawTextW`)⇒ `g.SetClip(layout.content)` +对它**无效**,滚动时数字会画到标题栏 / 面板之外。现统一改 `UiTextClipped(..., layout.content)`。 +**A/B 已证**:改回 `UiText` 后,标题栏区域像素差 **805px**(x1..241、y12..24)。 + +**硬规则**:凡"可滚动内容区"里的文字一律 `UiTextClipped`(含名称、标签、数值、统计、提示)。 +`UiFillRoundRect` 的 GDI 快路径(≥20000px² 且不透明)同样不认裁剪 —— 条目栏宽 ≈ 420px, +面积 < 20000,目前安全;**宽度再放大时要复核**。 + +### 5. ⭐⭐ 「统计中…」永不更新 = **状态上限 64 < 信号数** + 预算分摊不公平 + +`EdgeScanFor` 的累积状态上限原本固定 `kEdgeScanStateLimit = 64`, +而面板会在同一帧为**每条信号**各创建一个状态(288 > 64)⇒ 新建时总淘汰队首(未完成的) +⇒ **没有任何状态能扫完** ⇒ 永远"统计中…"。 + +- 新增 `PlotView::EdgeScanStateLimit()`:`max(64, 信号总数 × 2 + 16)`(每信号最多两类状态)。 +- **每帧配额去掉固定下限** `kEdgeScanPairsPerStateMin(60000)`:状态一多,下限会让最前面的 + 几个状态吃掉整帧预算,其余永远 0。现为 `budget / share`(至少 1),**`share` 只数「未完成」 + 状态**(否则完成状态仍占分母,长信号每帧只分到 1/300,实测要 20 分钟)。 +- 实测:288 条信号(含 5 条 25.8M 采样)——短信号 1~2 帧内完成,长信号随后独享整帧预算。 + +### 6. ⭐⭐ 组名折起"三角已收起、行还在" = **`WM_TIMER` 被饿死** + +`RequestEdgeScanFrame()` 原本在**每个未完成状态**里各 Post 一次 +⇒ 288 条信号时单帧 **288 次 `PostMessage`** + 高帧率重绘 ⇒ 优先级更低的 `WM_TIMER` +拿不到执行机会 ⇒ `AdvanceGroupAnimations()` 永远不跑 ⇒ 折起动画永不结束 ⇒ +行集合里的信号行永远不会被移除(三角却是从 `IsGroupCollapsed()` 直接画的,所以"看起来收起了")。 +点击下一个组时,那次点击触发的绘制里又推进一次动画,于是"上一组这时才真正收起"。 + +修法两条(都要): +1. `RequestEdgeScanFrame()` **每帧最多一次**(`m_edgeScanRequestFrame == m_edgeScanFrame` 直接返回); +2. `SignalPanel::EnsureRows()` 开头:`if (!m_groupAnims.empty()) AdvanceGroupAnimations();` + —— 动画进度只依赖挂钟时间,**不应依赖消息能否按时到达**。 + +### 7. 「弱峰调整」改为列表式(与均值滤波同款) + +- `WeakPeakItem` 新增 `collapsed`;面板 = 条目栏(三角 + 名称 + 复选框)+ 两行滑块 + (调整幅度 ±400% / 迭代次数 1…自定上限)+ 右侧复位 + 底部「构建为新信号」。 +- 交互全部复用 `MEAN_*` 拖拽模式(滚动用 `MEAN_SCROLL`),滑块拖动仍用 + `PEAK_LEVEL / PEAK_ITERATIONS`,上限编辑仍用 `PEAK_ITERATIONS_MAX + m_peakEditCurrentMax`。 +- ⚠️ `m_meanDragCenter`(bool) / `m_editMeanCenter`(bool) → **`m_meanDragKind` / `m_editMeanKind`** + (`EdgePanelKind`):现在有 MEAN / CENTER / PEAK / COUNT 四个写入目标。 +- **鼠标指针**:列表式面板的条目栏只有**已勾选**时才显示手型;未勾选时保持箭头 + (此时点击没有任何效果,用户 2026-11-02 明确要求)。 + 参数行的数值 / 复位 / 轨道也只在参数行展开(`rowLevelTracks.Width > 0`)时才给手型/左右箭头。 + +### 8. 验证(`.preflight/p20`,离屏 `SW_HIDE` + 合成消息) + +合成数据:5 列 + 283 列两个 CSV → 两组 → 全部显示 → 合并成 1 个网格(288 信号)。 + +``` +p20.exe 9/9 + 合并/信号数;COUNT 面板高 375 ≤ 绘图区 846 且 **像素缓存真的写进去了**(slot.key != 0); + 288 个边沿统计全部 complete(states=288); + CENTER 面板底部留白 8.8px(= 1 个 pad,修前 55px); + 组名折起后行集合无该组信号行;两组都折起后 signalRows == 0(真实 WM_LBUTTONDOWN/UP + + WM_TIMER(kScrollTimerId=3) + Render) +p20c.exe 11/11 + PEAK 面板限高/可滚动/缓存命中;勾选展开两行滑块、收起后内容缩小;滚动偏移生效; + MEAN 条目栏:已勾选→手型、未勾选→箭头; + **CENTER 滚动到统计行越过内容区顶部时,标题栏区域 0 像素变化**(A/B 关掉裁剪 ⇒ 805px); + 288 信号 + 面板打开时空闲帧 avg 2.6ms +make release / make debug 零 warning +``` + +### 9. 踩坑 + +- ⚠️ PowerShell 5.1 按 ANSI 读 `.ps1` ⇒ **脚本里不能出现中文**(连 `'条目栏'` 这种 + 单引号短串都会把解析器带崩,报"字符串缺少终止符")。断言数据放 UTF-8 文件、脚本纯 ASCII。 +- ⚠️ `SetLaneMeanFilter/SetLaneWeakPeak/SetLaneEdgeCenterCount(lane, true)` 会**自动勾选 + 该网格第一个信号** ⇒ 断言"面板初始全未勾选"必然失败;要测未勾选行请用第 2 行。 +- ⚠️ 面板圆角外侧的 1~2px 是"面板 AA 边缘 vs 缓存贴图(圆角裁剪)"的合法差异, + 取像素做 A/B 时要**避开圆角 8px**,否则会误报 7 个像素。 +- ⚠️ `git diff --numstat` 出现整文件级别的增删时要先怀疑行尾/BOM 被 PowerShell 改掉; + 本轮所有写回都用 `[System.IO.File]::WriteAllText(... UTF8Encoding($false))`。 diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index ad3fa81..a6f9b18 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -805,6 +805,13 @@ namespace jdi int amountPercent = 0; /**< 调整幅度(-400–400,默认 0 = 不改动)。 */ int iterations = 1; /**< 迭代次数 N(1 … `maxIterations`)。 */ int maxIterations = 10; /**< 「迭代次数 N」滑块的上限(用户可在面板上修改,恒 ≥ 1)。 */ + /** + * @brief 参数行是否被**收起**(只保留信号色叠底的条目栏)。 + * + * 「弱峰调整」面板自 2026-11-02 起与「均值滤波 / 中心轴边沿计数」同一套可滚动列表: + * 勾选后条目栏左侧出现展开三角,单击它切换收起 / 展开。未勾选时本字段无意义。 + */ + bool collapsed = false; }; /** @@ -1076,6 +1083,25 @@ namespace jdi */ float edgeCenterScroll = 0.0f; + /** + * @brief 「弱峰调整」面板内容的滚动偏移(客户区像素,0 = 顶部)。 + * + * 与 `meanFilterScroll` / `edgeCenterScroll` 完全同一套语义:面板自 2026-11-02 起 + * 改为**可滚动的分组列表**(信号色叠底条目栏 + 右侧复选框 + 展开后的两行滑块), + * 底部「构建为新信号」按钮固定不滚动。 + */ + float weakPeakScroll = 0.0f; + + /** + * @brief 「边沿计数」面板内容的滚动偏移(客户区像素,0 = 顶部)。 + * + * 该面板仍是「每信号两行(名称 / 上升沿·下降沿)」的旧式排版,但**内容可以滚动**: + * 上百个信号合并到同一网格时内容高达上万像素,若不限高,面板会远超绘图区 + * ⇒ 面板像素缓存一次都存不进去 ⇒ 每一帧都要整块重画(用户 2026-11-02 报告 + * “开启该功能后所有交互都非常卡顿”)。 + */ + float edgeCountScroll = 0.0f; + /** * @brief 「自由标记」面板里被**收起**的信号分组(点击分组栏切换)。 * diff --git a/Utilities/Inc/DataModelInternal.hpp b/Utilities/Inc/DataModelInternal.hpp index b2b9f98..c7e59d2 100644 --- a/Utilities/Inc/DataModelInternal.hpp +++ b/Utilities/Inc/DataModelInternal.hpp @@ -144,6 +144,10 @@ namespace jdi float meanFilterScroll = 0.0f; /** @brief 「中心轴边沿计数」面板内容的滚动偏移(详见 `Lane::edgeCenterScroll`)。 */ float edgeCenterScroll = 0.0f; + /** @brief 「弱峰调整」面板内容的滚动偏移(详见 `Lane::weakPeakScroll`)。 */ + float weakPeakScroll = 0.0f; + /** @brief 「边沿计数」面板内容的滚动偏移(详见 `Lane::edgeCountScroll`)。 */ + float edgeCountScroll = 0.0f; }; // 先记下每个信号原来所在网格的网格自身状态: @@ -176,6 +180,8 @@ namespace jdi carry.edgeCenterItems = lane.edgeCenterItems; carry.edgeCenterPanel = lane.edgeCenterPanel; carry.edgeCenterScroll = lane.edgeCenterScroll; + carry.weakPeakScroll = lane.weakPeakScroll; + carry.edgeCountScroll = lane.edgeCountScroll; carry.meanFilter = lane.meanFilter; carry.meanFilterItems = lane.meanFilterItems; carry.meanFilterPanel = lane.meanFilterPanel; @@ -237,6 +243,8 @@ namespace jdi lane.edgeCenterPanel.x = carry.edgeCenterPanel.x; lane.edgeCenterPanel.y = carry.edgeCenterPanel.y; lane.edgeCenterScroll = carry.edgeCenterScroll; + lane.weakPeakScroll = carry.weakPeakScroll; + lane.edgeCountScroll = carry.edgeCountScroll; lane.meanFilter = carry.meanFilter; lane.meanFilterItems = carry.meanFilterItems; lane.meanFilterPanel.open = carry.meanFilterPanel.open;