From 6b155758296b06ec0649f5dec5e50196f6ecaf8a Mon Sep 17 00:00:00 2001 From: "DeepSeek-V4.1-Flash" <> Date: Sat, 17 Oct 2026 09:56:45 +0800 Subject: [PATCH] =?UTF-8?q?=E6=BB=9A=E5=8A=A8=E5=8A=A8=E7=94=BB=E6=A0=B9?= =?UTF-8?q?=E6=B2=BB=EF=BC=88=E5=B7=A8=E5=9E=8B=E4=BF=A1=E5=8F=B7=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=BD=B1=E5=93=8D=E6=BB=9A=E5=8A=A8=EF=BC=89+?= =?UTF-8?q?=E3=80=8CN=20=E9=A1=B9=E3=80=8D=E8=AE=A1=E6=95=B0=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E8=AF=AD=E4=B9=89=20+=20=E6=B5=AE=E5=8A=A8=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E9=A6=96=E7=8E=B0=E5=8F=B3=E4=B8=8A=E8=A7=92=20+=20?= =?UTF-8?q?=E5=BC=80=E5=85=B3/=E5=85=B3=E9=97=AD=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E5=8D=A1=E9=A1=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、滚动过渡动画在巨型信号处卡顿 —— 真因是「退化成整幅重绘」 滚动到**从未构建过图层的区域**时,可见网格一个都贴不出图层像素 ⇒ BlitWideBuffer() 返回 false ⇒ 落到 Paint() 末尾的整幅重绘 ⇒ 把本帧全部可见网格的波形(含千万级采样的巨型信号)每帧重画一遍。 实测(MF4 124 网格 + VERY_BIG_DATA 5 网格,点选最后一个信号):单帧 20~35ms,且「可见的巨型信号 网格数」越多越慢(0 个 ≈20ms、5 个 ≈34ms);对照手动拖滚动条只要 ~6ms。 修法:BlitWideBuffer 只要还能画网格底衬(backing 非空)就返回 true, 只有 blits 与 backing 都为空时才交给整幅重绘 —— 这正是「没来得及画信号时就按只有网格叠底的状态继续滚动」。 实测:动画均值 23.0→11.2ms、最坏 35.6→13.1ms;含 5 个巨型信号网格的帧 33→10.7~12.3ms。 二、面板动画帧只重绘面板(额外 −2.7ms/帧) 新增 Application::m_panelDirty:滚动定时器在只有面板动画在跑时置位,Render 走 else if (panelOnly) 只重绘面板 + 分隔条(SignalPanel::Paint 会铺满自己的区域)。m_chromeDirty(真内容变化)仍走完整装饰重绘。 ⚠️ 回送仍是 if (fullFrame) {整窗} else {绘图区} 的无条件分支(遵守 §9.41)。 实测帧耗时拆解:plot 4.8ms / plot+panel 11.4ms / plot+全装饰 13.9ms。 三、分组行「N 项」计数:只折叠展开,绝不进入改名 新增 RowPart::GROUP_COUNT 与 SignalPanel::GroupStatsRect(row)(绘制与命中的唯一几何来源,命中矩形在 文本左右各外扩 3 逻辑像素)。RowHitPart 在名称区域之前判定计数区: - OnLButtonDown:折叠/展开;OnDoubleClick:只折叠/展开,任何点击频率都不进入改名; - DoubleClickIsPlainPress 返回 true ⇒ 快速连点每一击都生效。 ⚠️「快速双击进改名」的计时只对 RowPart::BODY 记录(否则“点计数、紧接着点名称”会被误判成双击)。 四、浮动面板首次出现的位置:右上角 旧默认位置 top = plotTop + 16 + laneIndex*28:上百个网格时 laneIndex 很大 ⇒ 被夹到右下角 (实测 plotH=1081 时 topMargin=964)。改为按「当前真正显示出来的面板中排在本面板之前的个数」错开 (固定遍历顺序:网格下标 → 面板种类),并把错开量夹到“面板下沿不超过绘图区 45% 高度”以内: 第一个面板错开 0 ⇒ 正好贴在上右角(实测 topMargin=24、rightMargin=24),6 个面板时最坏 topMargin=234。 ⚠️ 判定「面板是否显示」不能只看 *Panel.open —— LanePanelState::open 默认就是 true,必须同时看功能启用。 五、开关实验性功能 / 关闭面板卡顿 —— RebuildWideBuffer() 整批重建 四个 SetLane* 末尾都调 RebuildWideBuffer()(注释说是“预建宽缓冲基底”),而它同步重渲染全部网格的图层: 129 个网格(含 5 个千万级采样的巨型信号)整批重建要几百毫秒 ⇒ 点一下功能开关 / 关闭浮动面板就会卡住。 修法:新增 PlotView::EnsureLaneLayersPrefetched() —— 只有「图层数组与网格数量不符」(首次绘制 / 增删网格) 才同步整批建立;其余只 RollWideBuffer() 登记缺失/过期网格,由既有预算(每帧最多一个)逐帧补齐。 实测:几百毫秒 → ≤22ms;巨型信号网格上开关均值滤波同样 15ms 量级。 验证(全部离屏,不占用鼠标/键盘/屏幕) - 新增 .preflight/perf/lanescroll_main.cpp(lanescroll.exe)23/23:逐帧耗时 + 「可见巨型信号网格数」关联、 「N 项」计数热区与双击不进改名、面板首现位置与多面板错开、开着巨型信号的开关/关闭面板耗时。 - 既有回归:verify 49/49、panelanim 158/158、datacheck 69/69、edgecheck 46/46、errcheck 19/19、 gridanim 93/93。 - make release / make debug 零错误零警告(用 TARGET_FILE_NAME=TargetCheck/TargetCheckD 避免与运行中实例冲突)。 文档:Documents/agent_mem.md 新增 §9.44、§11.2 第 134–140 条与变更日志一行。 --- Components/Application/Inc/Application.hpp | 13 ++ Components/Application/Src/Application.cpp | 24 +++- Components/PlotView/Inc/PlotView.hpp | 15 ++ Components/PlotView/Src/PlotViewFeatures.cpp | 17 ++- Components/PlotView/Src/PlotViewPaint.cpp | 131 +++++++++++++++--- Components/SignalPanel/Inc/SignalPanel.hpp | 12 ++ Components/SignalPanel/Src/SignalPanel.cpp | 28 ++++ .../Src/SignalPanelInteraction.cpp | 23 ++- .../SignalPanel/Src/SignalPanelPaint.cpp | 10 +- Documents/agent_mem.md | 117 ++++++++++++++++ 10 files changed, 352 insertions(+), 38 deletions(-) diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp index 752d0eb..e8626ca 100644 --- a/Components/Application/Inc/Application.hpp +++ b/Components/Application/Inc/Application.hpp @@ -468,6 +468,19 @@ namespace jdi * false,从而每帧只重绘、只回送绘图区所在的矩形,避免整窗重绘的开销。 */ bool m_chromeDirty = true; + /** + * @brief 「只有信号面板(与分隔条)需要重绘」。 + * + * 面板滚动动画(「滚动到目标信号」)每帧只改变面板像素,而 `m_chromeDirty` 会 + * 连**标签栏 / 工具栏 / 整窗底色**一起重绘 —— 实测 129 个网格时这一份装饰重绘 + * 约 **3~4ms/帧**,会把动画挤出 15.6ms 的预算(用户 2026-10-17 报告:滚动过渡动画卡顿)。 + * + * ✓ 由滚动定时器在**只有面板动画在跑**时置位:`Render` 只重绘面板 + 分隔条 + *(标签栏/工具栏/整窗底色与上一帧逐像素相同,后备缓冲区里的旧像素本来就是对的)。 + * ⚠️ 回送仍是 `if (fullFrame) {整窗} else {绘图区}` 的**无条件分支**, + * 与 §9.41 的教训一致(**不**按更新区域决定回送范围)。 + */ + bool m_panelDirty = false; bool m_mouseOverPlot = false; /**< 上一次鼠标移动是否位于绘图区内。 */ }; diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index 86c7a74..5913d27 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -409,8 +409,16 @@ namespace jdi // ✓ 把动画状态直接纳入重绘条件(**只增加重绘条件,不改变任何绘制/回送路径**)。 const bool panelAnimating = m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive(); const bool overlaysVisible = m_menu.IsOpen() || m_colorPicker.IsOpen(); - const bool chromeDirty = m_chromeDirty || m_loading || overlaysVisible || panelAnimating; + // ⚠️ 两部分分开: + // * `fullChrome` —— 标签栏 / 工具栏 / 整窗底色 / 面板都要重绘(有真内容变化); + // * `panelOnly` —— **只有面板像素变了**(面板滚动动画),标签栏/工具栏/整窗底色 + // 与上一帧逐像素相同,没必要重绘(实测 129 网格时约 3~4ms/帧)。 + const bool fullChrome = m_chromeDirty || m_loading || overlaysVisible; + const bool panelOnly = !fullChrome && (panelAnimating || m_panelDirty); m_chromeDirty = false; + m_panelDirty = false; + // 仍需要“重绘装饰区域”这个判定(决定回送整窗还是只回送绘图区)。 + const bool chromeDirty = fullChrome || panelOnly; // 提示浮层淡出由定时器驱动,其绘制范围局限在绘图区,但为稳妥起见整帧回送。 const bool fullFrame = chromeDirty || m_plot.HintNeedsAnimation(); @@ -426,7 +434,7 @@ namespace jdi // 鼠标离开窗口(WM_MOUSELEAVE)、数据/行结构变化、切换标签页等。 m_plot.SetExternalHoverSignal(m_panel.HoveredSignal()); - if (chromeDirty) + if (fullChrome) { UiFillRect(g, gd::RectF(0.0f, 0.0f, static_cast(width), static_cast(height)), m_theme.windowBg); @@ -436,6 +444,12 @@ namespace jdi m_panel.Paint(g); UiFillRect(g, m_splitterRect, m_theme.border); } + else if (panelOnly) + { + // 只有面板像素变了 ⇒ 只重画面板与分隔条(`SignalPanel::Paint` 会铺满自己的区域)。 + m_panel.Paint(g); + UiFillRect(g, m_splitterRect, m_theme.border); + } m_plot.Paint(g); @@ -1514,6 +1528,12 @@ namespace jdi m_plot.AdvanceBackToTopAnimation(); if (panelAnimating) { + // 面板动画:只有面板像素会变(且通常绘图区动画同时也在跑)⇒ + // 用 `m_panelDirty` 让 `Render` 只重画面板 + 分隔条,而**不是**把 + // 标签栏/工具栏/整窗底色也重画一遍(约 3~4ms/帧)。 + // ⚠️ 用**推进之前**的状态判定:动画结束那一帧仍需整窗重绘, + // 保证面板的最终位置被完整重绘并回送。 + m_panelDirty = !m_chromeDirty; InvalidateRect(hwnd, nullptr, FALSE); } else diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 261a683..676720c 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -3198,6 +3198,21 @@ namespace jdi * 调用一次,把整批重建的代价摊到若干静止帧上;任何交互帧都不再承担它。 */ void WarmLaneLayersStep(); + + /** + * @brief 预建「宽缓冲基底」:只为**尚未建立 / 已失效**的网格补图层。 + * + * 用户 2026-10-17 报告:每次开/关一个实验性功能(以及关闭浮动面板)都会卡一下。 + * 根因是旧实现直接 `RebuildWideBuffer()` —— 它**同步重渲染全部网格的图层**: + * “全部信号各自一块网格”时可达上百个网格、其中可能含着千万级采样的巨型信号, + * 整批重建要**几百毫秒**。 + * + * ✓ 现在:只有「图层数组与网格数量不符」(首次绘制 / 增删网格)才同步整批建立; + * 其余只把缺失 / 过期的网格登记进待重画队列,由既有预算机制逐帧补齐。 + * 这已经足够达成原本的目的(避免“首次拖动参数滑块时的一次性重建尖峰”): + * 拖动滑块时视界在变 ⇒ 本就不做重建、直接用现有基底。 + */ + void EnsureLaneLayersPrefetched(); /** @brief 使全部图层失效。 */ void InvalidateWideBuffer(); /** @brief 释放全部图层位图。 */ void ReleaseWideBuffer(); diff --git a/Components/PlotView/Src/PlotViewFeatures.cpp b/Components/PlotView/Src/PlotViewFeatures.cpp index 310f1ea..25483b8 100644 --- a/Components/PlotView/Src/PlotViewFeatures.cpp +++ b/Components/PlotView/Src/PlotViewFeatures.cpp @@ -1294,7 +1294,8 @@ namespace jdi TrimDerivedCurveDataIfIdle(); } // 预建「宽缓冲基底」,避免首次拖动参数滑块时的一次性重建尖峰。 - RebuildWideBuffer(); + // ⚠️ 只补「缺失 / 失效」的网格,**不**整批重建(见 `EnsureLaneLayersPrefetched`)。 + EnsureLaneLayersPrefetched(); return true; } @@ -1387,8 +1388,15 @@ namespace jdi { TrimDerivedCurveDataIfIdle(); } - // 预建「宽缓冲基底」(不含派生曲线),使用户首次拖动该滑块时无需从零重建(避免一次性尖峰)。 - RebuildWideBuffer(); + // 预建「宽缓冲基底」(不含派生曲线):只为**尚未建立 / 已失效**的网格补图层, + // 避免用户首次拖动该滑块时一次性重建的尖峰。 + // ⚠️⚠️ **绝不能在这里无条件重建【全部】网格**(旧实现是 `RebuildWideBuffer()`): + // “全部信号各自一块网格”时可达上百个网格、其中可能含着千万级采样的巨型信号, + // 整批重渲染要**几百毫秒** ⇒ 用户点一下功能开关 / 关闭浮动面板就会“卡住一段时间” + //(用户 2026-10-17 报告:三明治加载后开关面板会卡、关面板要等一会才消失)。 + // ✓ 只有「图层数组与网格数量不符」(首次绘制 / 增删网格)才需要同步整批建立, + // 其余只登记进待重画队列,由既有预算机制(每帧最多一个网格)逐帧补齐。 + EnsureLaneLayersPrefetched(); return true; } @@ -1477,7 +1485,8 @@ namespace jdi TrimDerivedCurveDataIfIdle(); } // 预建「宽缓冲基底」,避免首次拖动参数滑块时的一次性重建尖峰。 - RebuildWideBuffer(); + // ⚠️ 只补「缺失 / 失效」的网格,**不**整批重建(见 `EnsureLaneLayersPrefetched`)。 + EnsureLaneLayersPrefetched(); return true; } diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 922cf65..454d8cc 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -849,23 +849,27 @@ namespace jdi //(即使 5 个网格同时需要重画,前台也不会在某一帧里做完整批而卡顿)。 // ⚠️ **必须在贴图之前**:否则本帧画好的网格要到下一帧才显示, // 拖拽时表现为"波形比鼠标慢半拍"、快速拖动时出现"断线"(图层覆盖不到视界)。 - // - // ⚠️ **只在视界稳定后才花预算做精细重绘**(视界仍在变化的每一帧只贴"旧跨度像素拉伸" - // 的占位图,O(1),把重建推迟到视界停下来之后 —— 用户要求"先画了再说,别卡住前台"), - // 且**仍按预算逐个补**(不用一次把全部过期网格塞进同一帧):实测同一台机器上 4 线程 - // 真并行(3.98x),但**图层光栅化(GDI+)本身会被串行化**,并行重建 5 个网格 ≈ 逐个重建 - //(1.03x)→ 一次性补齐只会把一帧从 ~10ms 抬到 ~31ms(更容易掉帧)。 - // 仍按预算逐个补:不用一次把全部过期网格塞进同一帧(并行重建≈逐个重建)。 - // ⚠️ **“是否再来一帧”的判据**(见 `AnyVisibleLanePending` / `PrefetchStepAllowed`): - // * 还有可视 / 预取带内的网格待补 ⇒ 立刻继续; - // * 只剩离屏网格 ⇒ 按 `kPrefetchMinIntervalMs` 限速继续(不会饿死动画定时器)。 - if (viewStopped) + // ⚠️ **只在视界稳定后才花预算做精细重绘**:视界仍在变化的每一帧只贴现有像素, + // 把重建推迟到视界停下来之后(用户要求"先画了再说,别卡住前台")。 + // ⚠️ 并行重建 5 个网格实测只有 1.03x(GDI+ 光栅化被串行化)⇒ 保持"每帧补一个"。 + // ⚠️⚠️ **网格滚动过渡动画期间一律【不重建任何图层】**(用户 2026-10-17 明确要求): + // 滚动动画只移动视界,**与"网格内的波形有没有画好"无关**。旧实现在视界几乎停止 + // 的那几帧(动画尾部里每帧只移动几个像素)放行 `RenderPendingLaneRebuilds(1)`, + // 于是恰好把**正在滚入视野的巨型信号网格**的图层重建排进了动画帧 —— 实测动画尾部 + // 单帧 20ms → **34ms**(`VERY_BIG_DATA` 25M 采样的图层渲染本身就是十几毫秒量级)。 + // 正确行为:动画期间未就绪的网格**只画网格底衬**(廉价、已在 `BlitWideBuffer` 里兜底), + // 波形等动画结束、视界真正停下之后再按预算逐个补。 + const bool laneScrollAnimating = LaneScrollAnimationActive(); + if (viewStopped && !laneScrollAnimating) { RenderPendingLaneRebuilds(1); } + // 同理:动画期间不要再额外请求帧(定时器本来就在按 15.5ms 驱动重绘), + // 否则会与定时器帧叠加、把预算挤爆。 const bool morePending = - AnyVisibleLanePending() || - (!m_layerRebuildPending.empty() && PrefetchStepAllowed()); + !laneScrollAnimating && + (AnyVisibleLanePending() || + (!m_layerRebuildPending.empty() && PrefetchStepAllowed())); // 绘图区底色(覆盖时间轴与侧边留白区域)。 UiFillRect(g, m_area, m_theme->plotBg); // 时间轴(宽缓冲不含它,前台按当前视图绘制)—— 必须在贴图**之前**算好: @@ -906,8 +910,7 @@ namespace jdi if (m_useDecorCache) { BlitDecorCache(g); - } - else + } else { // 退化路径(无法建立透明缓存,例如系统不提供 AlphaBlend): // 直接把装饰画进后备缓冲区(就地绘制,不做缓存)。 @@ -2984,6 +2987,23 @@ namespace jdi return true; } + void PlotView::EnsureLaneLayersPrefetched() + { + if (m_lanes.empty() || !(m_plotRect.Width > 8.0f) || !(Tab().ViewMax() - Tab().ViewMin() > 0.0)) + { + return; + } + if (!LayersAllocated()) + { + // 图层数组与网格数量不符(首次绘制 / 增删网格):没有可复用的像素,只能整批建立。 + RebuildWideBuffer(); + return; + } + // ⚠️ 只登记「缺失 / 内容过期」的网格(`RollWideBuffer` 不做整块重画), + // 由 `RenderPendingLaneRebuilds` 的既有预算逐帧补齐。 + RollWideBuffer(); + } + void PlotView::WarmLaneLayersStep() { if (m_dragMode != PlotDragMode::NONE || m_lanes.empty() || m_fonts == nullptr) @@ -3322,9 +3342,19 @@ namespace jdi if (blits.empty()) { - // 一个网格都贴不出像素(典型:布局变化后所有图层都失效)→ 交给调用方走整幅重绘, - // 避免画面停留在"只有网格底衬、没有波形"的状态。 - return false; + // ⚠️⚠️ **绝不能因为"本帧一个网格都贴不出图层像素"就退化成整幅重绘** + //(用户 2026-10-17 报告:滚动过渡动画在巨型信号处卡顿)。 + // 这一情形在滚动到**从未构建过图层的区域**时必然出现(上百个网格时绝大多数 + // 网格的图层都还没建立):旧实现在这里 `return false` ⇒ 落到 `Paint` 末尾的 + // **整幅重绘** ⇒ 把**本帧全部可见网格的波形**(可能包含上千�万采样的巨型信号) + // 每帧重画一遍 —— 实测单帧 **20~35ms**,而手动拖滚动条只要 ~6ms。 + // 正确行为:本帧只画**网格底衬**(廉价,见下方),波形等视界真正停下之后再按预算补 + //(这正是用户要求的“没来得及画信号时就按只有网格叠底的状态继续滚动”)。 + if (backing.empty()) + { + // 连底衬都没得画(布局变化 / 图层数组与网格数不符)→ 交给整幅重绘兜底。 + return false; + } } // ⚠️ 顺序:**先画网格底衬(GDI+),再取得 HDC 做 BitBlt** —— @@ -4910,20 +4940,75 @@ namespace jdi height = titleHeight + pad + static_cast(rowCount) * rowHeight * rowsPerSignal + pad; } - // 首次使用(位置为负)时自动放到绘图区右侧,并各网格依次错开,避免完全重叠。 + // 首次使用(位置为负)时自动放到绘图区**右上角**,并按网格在**当前可见网格中的序数** + // 依次错开,避免完全重叠。 + // ⚠️ 旧实现用 `laneIndex` 错开 —— 上百个网格时 `laneIndex` 很大, + // `16 + laneIndex*28` 早就超过绘图区高度、被下面的夹取推到**右下角** + //(用户 2026-10-17 报告:“在当前范围内的网格开启实验性功能,面板首次出现在右下角”)。 + // 改用**可见序数**:它的量级只有一屏的网格数(约 20),再夹到“面板上沿不超过绘图区 + // 中间偏下”以内,于是面板始终停**右上区域**,同时保留“依次错开”的观感。 const double storedX = meanFilter ? lane.meanFilterPanel.x : (weakPeak ? lane.weakPeakPanel.x : (centerAxis ? lane.edgeCenterPanel.x : lane.edgePanel.x)); const double storedY = meanFilter ? lane.meanFilterPanel.y : (weakPeak ? lane.weakPeakPanel.y : (centerAxis ? lane.edgeCenterPanel.y : lane.edgePanel.y)); + int openPanelOrdinal = 0; + { + // ⚠️ 错开量用「**当前真正显示出来的面板**中排在本面板之前的个数」: + // * 第一个打开的面板 ⇒ 0 ⇒ 正好贴在右上角(用户 2026-10-17 要求“应该在右上角”); + // * 后续面板依次下移 28 逻辑像素,避免与已打开的面板完全重叠; + // * 遍历顺序固定为(网格下标 → 面板种类)⇒ 每个面板的槽位稳定、与帧无关。 + // ⚠️ 不能用 `laneIndex`(上百个网格时会把面板推到右下角); + // ⚠️ 也不能只看 `*Panel.open`:该标志**默认就是 true**,必须同时看功能是否启用 + // (与 `EdgePanelLayoutFor` 的 `enabled && open` 判定一致)。 + const auto shown = [](const Lane& lane, EdgePanelKind panelKind) -> bool + { + switch (panelKind) + { + case EdgePanelKind::COUNT: + return lane.edgeCount && lane.edgePanel.open; + case EdgePanelKind::CENTER: + return lane.edgeCenter && lane.edgeCenterPanel.open; + case EdgePanelKind::MEAN: + return lane.meanFilter && lane.meanFilterPanel.open; + case EdgePanelKind::PEAK: + return lane.weakPeak && lane.weakPeakPanel.open; + } + return false; + }; + static const EdgePanelKind kOrder[4] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER, + EdgePanelKind::MEAN, EdgePanelKind::PEAK}; + int openSeen = 0; + bool found = false; + for (std::size_t lane = 0; lane < m_lanes.size() && !found; ++lane) + { + const Lane& tabLane = Tab().Lanes()[lane]; + for (const EdgePanelKind panelKind : kOrder) + { + if (!shown(tabLane, panelKind)) + { + continue; + } + if (lane == static_cast(laneIndex) && panelKind == kind) + { + found = true; + break; + } + ++openSeen; + } + } + openPanelOrdinal = found ? openSeen : 0; + } + // 错开量夹到“面板下沿不超过绘图区 45% 高度”以内 ⇒ 首现位置永远在右上区域。 + const float maxStagger = std::max(0.0f, m_plotRect.Height * 0.45f - height - 16.0f * m_scale); + const float stagger = + std::min(static_cast(openPanelOrdinal) * 28.0f * m_scale, maxStagger); float left = (storedX >= 0.0) ? static_cast(storedX) : (m_plotRect.X + m_plotRect.Width - width - 16.0f * m_scale); - float top = (storedY >= 0.0) - ? static_cast(storedY) - : (m_plotRect.Y + 16.0f * m_scale + - static_cast(laneIndex) * 28.0f * m_scale); + float top = (storedY >= 0.0) ? static_cast(storedY) + : (m_plotRect.Y + 16.0f * m_scale + stagger); // 面板始终限制在绘图区内(用户明确要求"可以在右侧的整个绘制区域拖动")。 left = std::max(m_plotRect.X + 2.0f * m_scale, diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index da574c9..23cfa78 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -438,6 +438,7 @@ namespace jdi CHECK, /**< 复选框。 */ BODY, /**< 行主体(分组行的名称区域 / 信号行的名称区域)。 */ GROUP_TITLE, /**< 分组行的「组X」标签(与折叠三角一样可点击折起)。 */ + GROUP_COUNT, /**< 分组行右侧的「N 项」计数(同样只折叠展开,**绝不**进入改名)。 */ CLOSE, /**< 分组行的关闭按钮。 */ LANE_BADGE, /**< 信号行的网格徽标。 */ REMOVE_SIGNAL /**< 信号行左侧的「移除该信号」✕ 按钮。 */ @@ -730,6 +731,17 @@ namespace jdi */ gd::RectF GroupCloseRect(int row) const; + /** + * @brief 计算分组行右侧「N 项」计数文本的矩形(**绘制与命中的唯一几何来源**)。 + * + * ⚠️ 计数区与分组名相邻,但它**只有折叠/展开语义**(用户 2026-10-17 要求): + * 快速点击计数不得进入改名。命中判定必须用本矩形把 `RowPart::GROUP_COUNT` + * 从名称区域(`RowPart::BODY`)中切出来,否则两者会混在一起。 + * @param [IN] row 行下标。 + * @return 计数文本矩形;该行不是分组行时返回空矩形。 + */ + gd::RectF GroupStatsRect(int row) const; + /** * @brief 计算信号行网格徽标矩形。 * @param [IN] row 行下标。 diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index 6634076..8ad0ec0 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -655,6 +655,12 @@ namespace jdi { return RowPart::GROUP_TITLE; } + // 「N 项」计数:**只有折叠/展开语义**(用户 2026-10-17 要求快速点击计数不得进入改名)。 + // ⚠️ 必须在名称区域之前判定:计数就紧贴在名称右侧、两者同属一行。 + if (UiPointInRect(GroupStatsRect(row), fx, fy)) + { + return RowPart::GROUP_COUNT; + } return RowPart::BODY; } @@ -1077,6 +1083,28 @@ namespace jdi return gd::RectF(left, rect.Y + 3.0f * m_scale, right - left, rect.Height - 6.0f * m_scale); } + gd::RectF SignalPanel::GroupStatsRect(int row) const + { + if (m_fonts == nullptr || m_fonts->uiSmall == nullptr || row < 0 || + row >= static_cast(m_rows.size()) || + m_rows[static_cast(row)].kind != RowKind::GROUP) + { + return gd::RectF(); + } + const gd::RectF rect = RowRect(row); + const float statsRight = GroupCloseRect(row).X - 6.0f * m_scale; + const float statsWidth = UiMeasureTextWidth(m_fonts->uiSmall, GroupStatsText(row)); + if (!(statsWidth > 0.0f)) + { + return gd::RectF(); + } + // 命中矩形在文本左右各多给 3 逻辑像素(计数文字很短,多给一点更好点)。 + // 左侧不会吃掉名称区域:`RowNameRect` 的右边界已经让出了 `6*scale`。 + const gd::RectF textRect(statsRight - statsWidth, rect.Y, statsWidth, rect.Height); + const float pad = 3.0f * m_scale; + return gd::RectF(textRect.X - pad, textRect.Y, textRect.Width + pad * 2.0f, textRect.Height); + } + float SignalPanel::GroupLabelWidth(int row) const { if (m_fonts == nullptr || m_fonts->uiBold == nullptr || row < 0 || diff --git a/Components/SignalPanel/Src/SignalPanelInteraction.cpp b/Components/SignalPanel/Src/SignalPanelInteraction.cpp index c8926fc..436bfa7 100644 --- a/Components/SignalPanel/Src/SignalPanelInteraction.cpp +++ b/Components/SignalPanel/Src/SignalPanelInteraction.cpp @@ -1446,14 +1446,20 @@ namespace jdi m_pendingCloseRow = row; return true; } - // 折叠 / 展开分组:三角、「组X」标签、**以及名称本体**都生效。 + // 折叠 / 展开分组:三角、「组X」标签、**名称本体**、以及右侧的「N 项」计数都生效。 // 用户 2026-10-17 要求:名称本体恢复单击展开/收起(此前为了避开“误入改名” // 而禁掉了它,结果只能点三角、很不方便);误入改名改由**收紧双击阈值**解决 - //(见 `OnDoubleClick`:只有快速双击才改名)。 + //(见 `OnDoubleClick`:只有快速双击才改名); + // 右侧的「N 项」计数**只折叠展开**,任何频率的点击都不进入改名(用户 2026-10-17 要求)。 { - m_groupNameClickMs = GetTickCount64(); - m_groupNameClickRow = row; - m_groupNameClickWasCollapsed = IsGroupCollapsed(item.groupIndex); + // 只有名称区域(BODY)参与“快速双击进改名”的计时;三角 /「组X」/ 计数都不参与, + // 否则“点一次计数、紧接着点一次名称”会被误判成双击。 + if (part == RowPart::BODY) + { + m_groupNameClickMs = GetTickCount64(); + m_groupNameClickRow = row; + m_groupNameClickWasCollapsed = IsGroupCollapsed(item.groupIndex); + } } ToggleGroupFromRow(item.groupIndex, row); return true; @@ -1684,6 +1690,13 @@ namespace jdi // 超过 `kGroupRenameDoubleClickMs` 的“慢双击”只当**又一次单击**(再切一次折叠)。 if (item.kind == RowKind::GROUP) { + if (part == RowPart::GROUP_COUNT) + { + // 「N 项」计数:**只折叠展开**,绝不进入改名(用户 2026-10-17 要求)。 + // 按下那一 次已经切过折叠状态,这里再切一次 = 两次点击各自生效。 + ToggleGroupFromRow(item.groupIndex, row); + return true; + } if (part == RowPart::DISCLOSURE) { // 与单击分组行同一入口(悬浮时先滚动就位再播放折起动画)。 diff --git a/Components/SignalPanel/Src/SignalPanelPaint.cpp b/Components/SignalPanel/Src/SignalPanelPaint.cpp index a5e6230..68620af 100644 --- a/Components/SignalPanel/Src/SignalPanelPaint.cpp +++ b/Components/SignalPanel/Src/SignalPanelPaint.cpp @@ -291,11 +291,13 @@ namespace jdi // 分组标题:左侧为“组X”标签,右侧以绘制的竖线分隔分组名(不使用字符),再显示可重命名的名称。 const std::wstring groupLabel = GroupLabelText(item.groupIndex); - // 项数文本与 `RowNameRect`(组名编辑框)共用同一来源,保证名称宽度与绘制不漂移。 + // 项数文本与 `RowNameRect`(组名编辑框)、`GroupStatsRect`(命中区)共用同一来源, + // 保证“画的”与“点的”不漂移。 const std::wstring stats = GroupStatsText(row); const float groupWidth = GroupLabelWidth(row); const float statsWidth = UiMeasureTextWidth(m_fonts->uiSmall, stats); - const float statsRight = closeRect.X - 6.0f * m_scale; + const float statsRight = GroupCloseRect(row).X - 6.0f * m_scale; + const gd::RectF statsTextRect(statsRight - statsWidth, rect.Y, statsWidth, rect.Height); const float nameLeft = checkRect.X + checkRect.Width + 8.0f * m_scale; const float nameAreaLeft = nameLeft + groupWidth + kGroupSeparatorGap * 2.0f * m_scale + kGroupSeparatorWidth * m_scale; @@ -323,8 +325,8 @@ namespace jdi TextAlign::LEFT); } - PaintRowText(g, stats, m_fonts->uiSmall, gd::RectF(statsRight - statsWidth, rect.Y, statsWidth, rect.Height), - m_theme->textMuted, TextAlign::RIGHT); + PaintRowText(g, stats, m_fonts->uiSmall, statsTextRect, m_theme->textMuted, + TextAlign::RIGHT); // 关闭按钮(从工作区移除该分组)。 const bool closeHot = hovered && (m_hoverPart == RowPart::CLOSE); diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 5c6c31e..0d76a52 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -2744,6 +2744,94 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } --- +### 9.44 滚动动画不再受巨型信号影响 + 「N 项」计数的点击语义 + 浮动面板首现位置与开关卡顿(本轮) + +> 用户 2026-10-17 第八轮(本机)。四项需求: +> ① 快速点击分组行右侧的「N 项」计数会**进入组名改名** —— 它应只响应展开/收起; +> ② 滚动过渡动画在**巨型信号网格**(`VERY_BIG_DATA`)处卡顿,本不该受"网格内信号是否画好"影响; +> ③ 在下方的网格里开启实验性功能,浮动面板**首次出现在右下角**(应在右上角); +> ④ 面板出现/关闭卡顿(三明治加载时更明显,关面板要等一会才消失)。 +> 离屏验证:新增 `.preflight/perf/lanescroll_main.cpp`(`lanescroll.exe`,**23/23**); +> 既有 `verify` 49/49、`panelanim` 158/158、`datacheck` 69/69、`edgecheck` 46/46、 +> `errcheck` 19/19、`gridanim` 93/93;`make release` / `make debug` 零警告。 + +#### 一、滚动动画卡顿的**真正根因**:`BlitWideBuffer` 返回 false ⇒ 退化成「整幅重绘」 + +⚠️⚠️ 上一轮(§9.43)修的是"动画期间的图层重建"与"定时器失效范围",但**没有**解决主要矛盾。 +本轮用**逐阶段计时**(临时插桩,测完已删除)定位到: + +- 滚动到**从未构建过图层的区域**时,可见网格一个都贴不出像素 ⇒ `BlitWideBuffer()` 返回 `false` + ⇒ 落到 `Paint()` 末尾的**整幅重绘** ⇒ 把**本帧全部可见网格的波形**(含千万级采样的巨型信号)每帧重画一遍。 +- 实测(MF4 124 网格 + `VERY_BIG_DATA` 5 网格,点选最后一个信号): + 单帧 **20~35ms**,且「可见的巨型信号网格数」越多越慢(0 个 ≈ 20ms、5 个 ≈ 34ms); + 对照**手动拖滚动条**只要 ~6ms。 +- ✓ 修法:`BlitWideBuffer` 只要还能画**网格底衬**就返回 `true`(`backing` 非空): + ```cpp + if (blits.empty() && backing.empty()) { return false; } // 连底衬都没得画才交给整幅重绘 + ``` + 这正是用户要求的语义:“没来得及画信号时就按**只有网格叠底**的状态继续滚动”。 + +**最终实测**(同一工况,逐帧):均值 **23.0ms → 11.2ms**、最坏 **35.6ms → 13.1ms**; +含 5 个巨型信号网格的帧 **33ms → 10.7~12.3ms**(与不含巨型信号的帧一致); +手动拖滚动条参照 4.5ms。 + +#### 二、`WM_TIMER` 帧的「只重画面板」快路径(额外 −2.7ms/帧) + +- 面板滚动动画期间 `m_chromeDirty` 会把**标签栏 / 工具栏 / 整窗底色**一起重绘(实测 +2.7ms)。 +- ✓ 新增 `Application::m_panelDirty`:滚动定时器在**只有面板动画在跑**时置位, + `Render` 走 `else if (panelOnly)` 只重绘**面板 + 分隔条**(`SignalPanel::Paint` 会铺满自己的区域); + `m_chromeDirty`(真内容变化)仍走完整装饰重绘。 +- ⚠️ **回送**仍是 `if (fullFrame) {整窗} else {绘图区}` 的**无条件分支**(遵守 §9.41 的教训: + 绝不让"更新区域"决定画什么/回送什么;这里决定的是"重画笔哪一层",且由**改变像素的那条路径**显式置位)。 +- 实测帧耗时拆解:`plot 4.8ms` / `plot+panel 11.4ms` / `plot+全装饰 13.9ms`。 + +#### 三、分组行「N 项」计数:只折叠展开,**绝不**进入改名(用户要求) + +- 新增 `RowPart::GROUP_COUNT` 与 `SignalPanel::GroupStatsRect(row)`(**绘制与命中的唯一几何来源**, + 命中矩形在文本左右各外扩 3 逻辑像素)。 +- `RowHitPart` 在**名称区域之前**判定计数区 ⇒ 点击计数返回 `GROUP_COUNT`: + * `OnLButtonDown`:折叠/展开(与三角、「组X」、名称本体一致); + * `OnDoubleClick`:**只折叠/展开**,任何点击频率都不进入改名; + * `DoubleClickIsPlainPress` 对它返回 **true** ⇒ 快速连点每一击都生效。 +- ⚠️ 「快速双击进改名」的计时只对 `RowPart::BODY`(名称本体)记录 —— 否则“点一次计数、紧接着点一次名称” + 会被误判成双击。 + +#### 四、浮动面板**首次出现**的位置:右上角(用户要求) + +- 旧默认位置:`left = 右侧 - 16`、`top = plotTop + 16 + laneIndex*28`。 + ⚠️ **上百个网格时 `laneIndex` 很大** ⇒ `16 + laneIndex*28` 早就超过绘图区高度 ⇒ 被夹到**右下角** + (实测 plotH=1081 时 `topMargin=964`)。 +- ✓ 改为按「**当前真正显示出来的面板**中排在本面板之前的个数」错开(固定遍历顺序:网格下标 → 面板种类), + 并把错开量夹到“面板下沿不超过绘图区 45% 高度”以内: + * 第一个面板 ⇒ 错开 0 ⇒ **正好贴在上右角**(实测 `topMargin=24`、`rightMargin=24`); + * 依次下移 28 逻辑像素,6 个面板时最坏 `topMargin=234`(仍在顶部 22%)。 +- ⚠️ 判定「面板是否显示」**不能只看 `*Panel.open`** —— `LanePanelState::open` **默认就是 true**, + 必须同时看功能是否启用(与 `EdgePanelLayoutFor` 的 `enabled && open` 一致)。 + +#### 五、开关实验性功能 / 关闭面板卡顿的根因:`RebuildWideBuffer()` 整批重建 + +- ⚠️⚠️ 四个 `SetLane*`(边沿计数 / 中心轴 / 均值滤波 / 弱峰调整)在末尾都调 `RebuildWideBuffer()` + (注释说是“预建宽缓冲基底”),而它**同步重渲染全部网格的图层**: + 129 个网格(含 5 个千万级采样的巨型信号)整批重建要**几百毫秒** + ⇒ 点一下功能开关 / 关闭浮动面板就会“卡住一段时间”(用户报告,三明治加载时更明显)。 +- ✓ 新增 `PlotView::EnsureLaneLayersPrefetched()`: + * **只有**「图层数组与网格数量不符」(首次绘制 / 增删网格)才同步整批建立; + * 其余只 `RollWideBuffer()` 把缺失 / 过期的网格登记进待重画队列,由既有预算(每帧最多一个)逐帧补齐。 + 这已足够达成原本目的(拖参数滑块时视界在变 ⇒ 本就不重建、直接用现有基底)。 +- 实测(关/开面板,网格内含巨型信号):**几百毫秒 → 22ms 以内**;巨型信号网格上开关均值滤波同样 15ms 量级。 + +#### 六、⚠️ 方法论教训 + +1. **“修完感觉没变”时要重新插桩定位,而不是继续猜**:上一轮改的两处(动画期禁重建、定时器失效范围) + 合计只省了 ~8ms,真正的 20ms 来自 `BlitWideBuffer` 退化成整幅重绘。 +2. **逐阶段计时(临时插桩)比"改代码做 A/B"快得多**:在 `Paint` 里插 5 个 `QueryPerformanceCounter` + 就定位到"宽基底分支内部几乎不耗时 ⇒ 一定是走了别的分支"。 +3. **`LanePanelState::open` 默认 true** 这类"默认值"极易让统计类判据出错(本轮“已打开面板数”一度算成 8)。 +4. **面板首现位置用 `laneIndex` 做错开量**是典型「小数据量下正确、上百网格时崩掉」的写法; + 任何与 `laneIndex` 线性相关的屏幕坐标都必须重新审视。 + +--- + ## 10. 变更日志(本文件维护记录) | 日期 | 设备/模型 | 摘要 | @@ -2806,6 +2894,8 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } | 2026-10-17 | DeepSeek-V4.1-Flash(**本机**) | **滚动过渡动画根治 + 快速连点跟手 + 分组名单击展开 + 光标吸附同源 + 按钮淡出对齐 + 滚动位置复位(详见 §9.43)**。① **滚动动画卡顿(三处根因)**:**(a)** `WM_TIMER(kScrollTimerId)` 无条件整窗失效 ⇒ `WM_PAINT` 把 `m_chromeDirty` 置真 ⇒ **每帧重绘标签栏/工具栏/面板**(绘图区动画帧 13.6→**3.5ms**;手动拖滚动条参照 4.2ms);**(b)** 图层预热不足 ⇒ 长距离滚动扫进未建图层的网格、每帧靠底衬重画十几份网格(+8ms/帧)⇒ 可视判定加 **±1 屏预取带** + `PrefetchStepAllowed()` 限速后台预热(`lane-only+VERY_BIG_DATA` 13.9→**5.3ms**);**(c)** 网格滚动量参与宽基底可用性判据(上一轮已修)。② **光标吸附改用「自动点线」同一判定**:`AutoPointLineNeeded` 改名 `MinorCellSparse`,吸附与自动点线共用;判定**不看绘制方式** ⇒ 用户改过样式也吸附;删除旧整屏判据 `SignalSnapEnabled`。③ **「回到最上方」按钮淡出与浮动提示一致**:`kBackToTopFadeOutMs = kHintFadeMs`(500ms) + **线性** alpha + 到达顶部立即淡出(实测 50% 时刻 alpha=0.500,旧 ease-out 为 0.875)。④ **快速连点跟手**:`CS_DBLCLKS` 会把快速连点的第 2 次按下合成为 `WM_LBUTTONDBLCLK`,旧实现只给工具栏打补丁 ⇒ 复选框/全部显示隐藏/绝对值/组X/折叠三角/浮动面板复选框的按下**丢失**;新增 `DoubleClickIsPlainPress(x,y)`(`PlotView` + `SignalPanel`),宿主据此选择走 `OnLButtonDown`。⑤ **分组行:名称恢复单击展开收起**(推翻 §9.37 ①),只有**快速双击**(`kGroupRenameDoubleClickMs=300`,比系统 500ms 紧)才进入改名,且进入前**撤销**按下那次的折叠切换。⑥ **信号重现时网格滚动回到最上方**(`SynchronizeView` 首次出现可见信号分支里 `LaneScroll()=0`)。验证:`gridanim` **93/93**(真实消息循环帧距 + 逐帧耗时对比 + 行为断言)、`verify` 49/49、`panelanim` **158/158**(按新规则更新 8 条断言)、`datacheck` 69/69、`edgecheck` 46/46、`errcheck` 19/19;`make release`/`make debug` 零警告。§11.2 新增第 125–133 条。 +| 2026-10-17 | DeepSeek-V4.1-Flash(**本机**) | **滚动动画根治(巨型信号不再影响滚动)+「N 项」计数点击语义 + 浮动面板首现右上角 + 开关/关闭面板卡顿(详见 §9.44)**。① **滚动卡顿真因**:滚动到**从未建过图层**的区域时 `BlitWideBuffer()` 一个网格都贴不出像素 ⇒ 返回 `false` ⇒ 退化成**整幅重绘** ⇒ 每帧重画全部可见网格的波形(含千万级采样的巨型信号),实测 **20~35ms**(手动拖滚动条只要 ~6ms)。修法:只要还能画**网格底衬**(`backing` 非空)就返回 true ⇒ 动画均值 23→**11.2ms**、最坏 35.6→**13.1ms**,含 5 个巨型信号网格的帧 33→**~11ms**。② 新增 `Application::m_panelDirty`:面板动画帧只重绘面板+分隔条(−2.7ms/帧)。③ **「N 项」计数**独立为 `RowPart::GROUP_COUNT`(`GroupStatsRect` 为唯一几何来源),任何点击频率只折叠展开、绝不进改名;改名计时只对 `BODY` 记录。④ **浮动面板首现位置**改用「已显示面板序数」错开(旧实现用 `laneIndex`,上百网格时被夹到右下角),实测第一个面板 `topMargin=rightMargin=16*scale`(正上右角),6 个面板仍在顶部 22%。⑤ **开关/关闭面板卡顿真因**:`SetLane*` 末尾的 `RebuildWideBuffer()` **同步重渲染全部网格图层**(129 网格含巨型信号 ⇒ 几百毫秒);改用 `EnsureLaneLayersPrefetched()`(只在图层数与网格数不符时整批建立,其余登记待重画队列)⇒ 几百毫秒 → **≤22ms**。验证:新增 `.preflight/perf/lanescroll_main.cpp`(`lanescroll.exe`)**23/23**(逐帧耗时 + 巨型网格可见数关联、计数热区、面板位置、开着巨型信号的开关/关闭耗时);`verify` 49/49、`panelanim` 158/158、`datacheck` 69/69、`edgecheck` 46/46、`errcheck` 19/19、`gridanim` 93/93;`make release`/`make debug` 零警告。§11.2 新增第 134–140 条。 | + ## 11. 跨设备协作须知(起源设备视角) > **本文件是唯一权威记忆**。所有设备 / Agent 共用它,**不要再新建 `agent_mem_origin.md` 等旁支记忆文件**。 @@ -3456,6 +3546,33 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } 133. ⚠️ **动画性能 A/B 必须在同一二进制、同一起始状态下对比**:靠“改代码重编”做 A/B 会被 **两次运行之间不同的预热状态**污染(本轮因此得出过“底衬兜底 +8ms”的误导结论); 应加**运行时开关**在**同一次运行**内切换。 +134. ⚠️⚠️ **`BlitWideBuffer()` 只要还能画网格底衬就必须返回 true,绝不能因为“一个网格都贴不出图层像素” + 就退化成整幅重绘**:滚动到从未建过图层的区域时这是必现情形,退化成整幅重绘会把**本帧全部可见网格的 + 波形**(可能含千万级采样的巨型信号)每帧重画一遍 —— 实测单帧 **20~35ms**(用户 2026-10-17 报告: + 滚动动画在巨型信号处卡顿,“本不该受网格内信号绘制的影响”)。 + ✓ 只有 `blits` 与 `backing` **都为空**时才返回 false。这正是“没来得及画信号就按只有网格叠底的状态继续滚动”。 + 实测:动画均值 23→**11.2ms**、最坏 35.6→**13.1ms**;含 5 个巨型信号网格的帧 33→**~11ms**。 +135. ⚠️ **面板滚动动画期间只重绘面板,不要重绘标签栏/工具栏/整窗底色**: + 由滚动定时器置 `Application::m_panelDirty`,`Render` 走 `else if (panelOnly)` 分支 + (`SignalPanel::Paint` 会铺满自己的区域)。实测 −2.7ms/帧。 + ⚠️ 这不是 §9.41 禁止的“按更新区域决定画什么”:**像素由谁改变,就由那条路径显式置位**; + **回送**仍是 `if (fullFrame) {整窗} else {绘图区}` 的无条件分支。 +136. ⚠️ **`LanePanelState::open` 的默认值是 `true`**:任何“哪些面板正在显示”的统计都必须同时检查 + 功能是否启用(`lane.edgeCount / edgeCenter / meanFilter / weakPeak`),否则会把**所有**网格都算成“已打开”。 +137. ⚠️ **浮动面板首次出现的位置必须锚定在右上角**:旧实现用 `top = plotTop + 16 + laneIndex*28` 错开, + 上百个网格时 `laneIndex` 很大 ⇒ 被夹到**右下角**(用户 2026-10-17 报告)。 + ✓ 改用「当前真正显示出来的面板中排在本面板之前的个数」错开,并把错开量夹到 + “面板下沿不超过绘图区 45% 高度”以内 ⇒ 第一个面板正好上右角(`topMargin/rightMargin = 16*scale`)。 + ⚠️ 任何与 `laneIndex` 线性相关的屏幕坐标都必须重新审视。 +138. ⚠️⚠️ **`SetLane*`(实验性功能开关)末尾绝不能调用 `RebuildWideBuffer()`**:它**同步重渲染全部网格图层**, + 129 个网格(含千万级采样的巨型信号)整批重建要**几百毫秒** ⇒ 点一下开关 / 关闭浮动面板就“卡住一段时间” + (用户 2026-10-17 报告)。✓ 用 `EnsureLaneLayersPrefetched()`:只有图层数与网格数不符才整批建立, + 其余只登记进待重画队列、由既有预算(每帧最多一个)逐帧补齐。实测几百毫秒 → **≤22ms**。 +139. ⚠️ **分组行的「N 项」计数是独立热区 `RowPart::GROUP_COUNT`**(`GroupStatsRect` 为唯一几何来源): + **任何**点击频率都只折叠/展开、绝不进入改名(用户 2026-10-17 要求)。 + ⚠️ 「快速双击进改名」的计时只对 `RowPart::BODY` 记录 —— 否则“点计数、紧接着点名称”会被误判成双击。 +140. ⚠️ **“修完感觉没变”时要重新插桩定位**:本轮在 `Paint` 里插 5 个 `QueryPerformanceCounter` 才定位到 + “宽基底分支内部几乎不耗时 ⇒ 一定走了别的分支(整幅重绘)”。逐阶段计时比“改代码做 A/B”快得多。 ### 11.3 离屏验证方法学(本机偏好,强烈建议沿用)