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

一、浮动面板(用户报告:下方空白、滚动时画到面板外、未改造的面板让一切交互卡顿)
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

+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;