面板限高与滚动改造 + 边沿统计不再卡在“统计中…” + 组名折起真正收起

一、浮动面板(用户报告:下方空白、滚动时画到面板外、未改造的面板让一切交互卡顿)
1. ⭐ 根因:旧「逐条目」排布没有高度上限 —— 288 条信号时面板 13010px(绘图区 846px)
   ⇒ PanelRectInsideLayer 恒假 ⇒ 面板像素缓存一次都存不进去 ⇒ 每帧整块重画。
   现在 5 个面板全部限高 + 可滚动,旧的 itemized 布局/绘制/交互整体删除。
2. 无按钮面板(中心轴边沿计数)不再预留「按钮行 + pad」⇒ 底部空白 55px → 8.8px(1 个 pad)。
3. ⚠️ 统计文字改用 UiTextClipped:GDI 文本不受 GDI+ SetClip 约束,滚动时画到标题栏之外。
4. 「弱峰调整」改造为与均值滤波同款的分组列表(条目栏 + 右侧复选框 + 展开后的两行滑块
   + 复位按钮 + 底部「构建为新信号」);新增 Lane::weakPeakScroll / SetLaneWeakPeakScroll,
   WeakPeakItem 增加 collapsed。
5. 「边沿计数」保留两行排版,但内容区同样限高、可滚动(新增 Lane::edgeCountScroll)。
6. 鼠标指针:列表式面板的条目栏**只有已勾选**才显示手型(未勾选点击无效果,保持箭头);
   参数行控件只在参数行展开时给手型 / 左右箭头。
7. 内部标志 m_meanDragCenter / m_editMeanCenter(bool)改为 m_meanDragKind / m_editMeanKind
   (EdgePanelKind),以便 MEAN / CENTER / PEAK / COUNT 共用同一套滚动与滑块拖拽。

二、边沿统计“全部统计中…且永不更新”(288 信号合并到同一网格)
1. ⭐ 根因:累积状态上限固定 64 < 信号数 288 ⇒ 新建状态总淘汰未完成的 ⇒ 没有任何状态能扫完。
   新增 EdgeScanStateLimit() = max(64, 全部信号数 × 2 + 16)。
2. 去掉每状态 60000 的固定配额下限(状态一多就把整帧预算耗光),改为 budget / 未完成状态数;
   分母只数未完成状态,短信号扫完后立刻把预算让给长信号。

三、组名折起后“三角已收起但信号行仍在、仍可点选”
1. ⭐ 根因:RequestEdgeScanFrame() 在每个未完成状态里各 Post 一次(单帧最多 288 次),
   把优先级最低的 WM_TIMER 饿死 ⇒ AdvanceGroupAnimations 永不执行 ⇒ 折起动画永不结束。
   修法:每帧最多 Post 一次(m_edgeScanRequestFrame)。
2. 另一道保险:SignalPanel::EnsureRows() 开头推进一次动画 —— 动画进度只依赖挂钟时间,
   不依赖消息能否按时到达。

四、验证(离屏探针 .preflight/p20,全部 SW_HIDE + 合成消息,不占用鼠标/键盘/屏幕)
  p20.exe  9/9:面板限高且像素缓存真的写进去了、288 个统计全部 complete、中心轴面板底部留白、
               组名折起后行集合无该组信号、两组都折起后 signalRows == 0(真实消息 + WM_TIMER)。
  p20c.exe 11/11:弱峰面板限高/可滚动/缓存命中、展开与收起、滚动生效、条目栏手型规则、
               中心轴滚动时标题栏 0 像素变化(A/B 关掉裁剪 ⇒ 805px)、288 信号空闲帧 2.6ms。
  make release / make debug 零 warning。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-11-02 10:00:24 +08:00
1 parent 334015c1a9
commit 7a6fcd68e2
9 files changed
+914 -795

No files matched your search

+43 -9
View File
@@ -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<std::uint64_t>(-1);
/** 诊断计数:中心轴本地曲线计算失败次数(内存不足等)。0 = 一切正常。 */
mutable std::size_t m_edgeScanLocalCurveFails = 0;
/**
* @brief 累积状态上限(随可见信号总数缩放)。
*
* 每个信号最多两类状态(普通边沿计数 / 中心轴边沿计数),因此上限 ≥ `2 × 信号数`。
* 固定上限(旧 64)在 288 条信号合并到同一网格时会让所有状态互相淘汰 ⇒
* 面板永远停在“统计中…”(用户 2026-11-02 报告)。
*/
std::size_t EdgeScanStateLimit() const;
/**
* @brief 边沿统计的统一实现(普通边沿计数与中心轴边沿计数共用)。
*
+83 -6
View File
@@ -1828,6 +1828,40 @@ namespace jdi
return true;
}
bool PlotView::SetLaneWeakPeakScroll(int laneIndex, float scroll)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(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<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(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<int>(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<std::size_t>(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<std::size_t>(1, activeStates);
const std::size_t share = std::max<std::size_t>(kEdgeScanFairShare, activeStates);
const std::size_t perState =
std::max<std::size_t>(kEdgeScanPairsPerStateMin, kEdgeScanPairsPerFrame / share);
// ⚠️ **不要给每状态一个固定下限**(旧实现是 `max(60000, budget/share)`):
// 状态数一多,下限就让**前几个状态**(恰好是网格里最前面的信号)吃掉整帧预算,
// 后面的信号永远分不到配额 ⇒ 一直显示“统计中…”(用户 2026-11-02 报告的
// 288 信号场景)。改为纯按未完成状态数平分本帧预算(至多 1)。
const std::size_t perState = std::max<std::size_t>(1, m_edgeScanBudget / share);
if (state->pumpedFrame != m_edgeScanFrame)
{
state->pumpedFrame = m_edgeScanFrame;
+317 -299
View File
@@ -651,7 +651,8 @@ namespace jdi
target == PlotEditTarget::MEAN_ITERATIONS_MAX)
{
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(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<long long>(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<EdgePanelKind>(panelKind) == EdgePanelKind::MEAN ||
static_cast<EdgePanelKind>(panelKind) == EdgePanelKind::CENTER)
// 「均值滤波 / 中心轴边沿计数 / 弱峰调整」可滚动列表:
// * 条目栏:**只有该信号已被勾选**时才给手型(未勾选时点击毫无效果,
// 用户 2026-11-02 明确要求此时不要改变鼠标指针形状);
// * 滑块轨道 → 左右箭头;数值(可点击改上限)与复位按钮 → 手型;
// * 内容区滚动条 → 上下箭头。
{
for (std::size_t rowIndex = 0; rowIndex < layout.rowHeaders.size(); ++rowIndex)
const EdgePanelKind cursorKind = static_cast<EdgePanelKind>(panelKind);
const bool listCursor = (cursorKind == EdgePanelKind::MEAN ||
cursorKind == EdgePanelKind::CENTER ||
cursorKind == EdgePanelKind::PEAK);
const Lane& cursorLane =
Tab().Lanes()[static_cast<std::size_t>(panelLane)];
if (listCursor)
{
if (layout.rowHeaders[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowHeaders[rowIndex], static_cast<float>(x),
static_cast<float>(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<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (rowIndex < layout.rowIterValues.size() &&
layout.rowIterValues[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowIterValues[rowIndex], static_cast<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (rowIndex < layout.rowLevelResets.size() &&
layout.rowLevelResets[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowLevelResets[rowIndex], static_cast<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (rowIndex < layout.rowIterResets.size() &&
layout.rowIterResets[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowIterResets[rowIndex], static_cast<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (rowIndex < layout.rowLevelTracks.size() &&
WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale,
static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_SIZEWE);
}
if (rowIndex < layout.rowIterTracks.size() &&
WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale,
static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_SIZEWE);
if (layout.rowHeaders[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowHeaders[rowIndex], static_cast<float>(x),
static_cast<float>(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<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (rowIndex < layout.rowIterValues.size() &&
layout.rowIterValues[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowIterValues[rowIndex], static_cast<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (rowIndex < layout.rowLevelResets.size() &&
layout.rowLevelResets[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowLevelResets[rowIndex], static_cast<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (rowIndex < layout.rowIterResets.size() &&
layout.rowIterResets[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowIterResets[rowIndex], static_cast<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (rowIndex < layout.rowLevelTracks.size() &&
WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale,
static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_SIZEWE);
}
if (rowIndex < layout.rowIterTracks.size() &&
WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale,
static_cast<float>(x), static_cast<float>(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<EdgePanelKind>(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<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
// 两行滑块右侧的**复位控件**:与其它可点击图标一致,用手型光标提示可点击。
if (rowIndex < layout.rowLevelResets.size() &&
layout.rowLevelResets[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowLevelResets[rowIndex], static_cast<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (rowIndex < layout.rowIterResets.size() &&
layout.rowIterResets[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowIterResets[rowIndex], static_cast<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (rowIndex < layout.rowIterTracks.size() &&
WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale,
static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_SIZEWE);
}
if (rowIndex < layout.rowLevelTracks.size() &&
WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale,
static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_SIZEWE);
}
}
}
// 面板主体(含底部编辑行与参数输入框):保持原始箭头。
return nullptr;
}
@@ -1540,19 +1536,19 @@ namespace jdi
break;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(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<float>(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<int>(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<int>(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<Lane::MeanFilterItem*>(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<std::size_t>(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<int>(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<int>(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<Lane::WeakPeakItem*>(meanLane.FindWeakPeakItem(ref));
item->collapsed = !collapsed;
}
else
{
Lane::MeanFilterItem* item =
const_cast<Lane::MeanFilterItem*>(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<EdgePanelKind>(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<std::size_t>(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<float>(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
+191 -270
View File
@@ -3774,6 +3774,8 @@ namespace jdi
mix(static_cast<std::uint64_t>(peakItem != nullptr ? peakItem->amountPercent + 2 : 0));
mix(static_cast<std::uint64_t>(peakItem != nullptr ? peakItem->iterations + 2 : 0));
mix(static_cast<std::uint64_t>(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<PanelSlot> 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<float>(m_mouseX),
static_cast<float>(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<int>(index)) &&
(m_peakDragRef == ref);
const double levelRatio =
(static_cast<double>(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<double>(peakIter - kWeakPeakIterationsMin) /
static_cast<double>(peakMax - kWeakPeakIterationsMin))
: 0.0;
const bool iterActive = (m_dragMode == PlotDragMode::PEAK_ITERATIONS) &&
(m_peakDragLane == static_cast<int>(index)) &&
(m_peakDragRef == ref);
const bool editingMax =
(m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX) &&
(m_editLane == static_cast<int>(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<int>(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<int>(index)) &&
(m_peakDragRef == ref);
const bool iterActive = (m_dragMode == PlotDragMode::PEAK_ITERATIONS) &&
(m_peakDragLane == static_cast<int>(index)) &&
(m_peakDragRef == ref);
const bool editingMax =
(m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX) &&
(m_editLane == static_cast<int>(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<float>(m_mouseX),
static_cast<float>(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<double>(peakAmount) / (100.0 * kWeakPeakSliderScale) + 1.0) * 0.5;
const double iterRatio =
(peakMax > kWeakPeakIterationsMin)
? (static_cast<double>(peakIter - kWeakPeakIterationsMin) /
static_cast<double>(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<int>(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<int>(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
+112 -211
View File
@@ -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<float>(rowCount) * rowHeight * rowsPerSignal + pad;
// 「边沿计数」:每信号两行(名称 / 上升沿·下降沿),内容超高时滚动。
const float content = static_cast<float>(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);
}
}
@@ -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();
+125
View File
@@ -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))`。
+26
View File
@@ -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 「自由标记」面板里被**收起**的信号分组(点击分组栏切换)。
*
+8
View File
@@ -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;