From fba40ee1a276b24e9a8f52a0012c1526ca06958e Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Thu, 15 Oct 2026 01:35:47 +0800 Subject: [PATCH] =?UTF-8?q?=E3=80=8C=E8=B0=83=E6=95=B4=E3=80=8D=E6=95=B0?= =?UTF-8?q?=E5=80=BC=E7=AC=A6=E5=8F=B7=E8=A7=84=E5=88=99=20+=20=E8=BE=B9?= =?UTF-8?q?=E6=B2=BF=E7=BB=9F=E8=AE=A1=E6=8C=89=E5=B8=A7=E6=8E=A8=E8=BF=9B?= =?UTF-8?q?=EF=BC=88=E5=90=AB=E9=A6=96=E5=B8=A7=E5=85=AC=E5=B9=B3=E5=88=86?= =?UTF-8?q?=E6=91=8A=EF=BC=89+=20=E6=B5=AE=E5=8A=A8=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E6=8E=92=E7=89=88=E4=B8=8E=E5=BC=B1=E5=B3=B0=E5=A4=8D=E4=BD=8D?= =?UTF-8?q?=E6=8E=A7=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、「调整」类数值的符号(SignalPanel) 1. FormatSignedNumber:0 不再显示 `+`(旧实现恒带符号 → `+0.00`),Y 轴偏移默认值显示 0.00。 2. FormatGainText:非 0 正值带 `+`、负值带 `-`、0 不带符号;仅用于非编辑态显示, 进入编辑的初始文本仍为无符号纯数值。 3. 新增 FormatOffsetTimeText:`Δt:` 后按正负带符号(Δt:+5s 60ms / Δt:-5s 60ms / Δt:0s), 打开面板 / CMD_OFFSET_TIME 提交 / CMD_OFFSET_RESET 三处统一走它。 二、边沿统计:按帧预算 + 每状态公平配额 (用户报告:按住横向光标不动时统计停在“统计中…”不更新;改中心轴参数回车后不动鼠标时停在部分值) 根因:预算按 GetTickCount64() 分桶,而系统时钟粒度约 15.6ms;RequestEdgeScanFrame 的 PostMessage 自持重绘把帧率拉高 ⇒ 同一 tick 内连画多帧却完全不推进。 1. Paint 开头 ++m_edgeScanFrame; m_edgeScanBudget = kEdgeScanPairsPerFrame;(按帧重置), 删除 m_edgeScanTick。 2. 每个状态各自持有每帧配额(pumpedFrame/pumpedPairs):perState = max(60000, 1.2M/share), share = max(kEdgeScanFairShare=6, m_edgeScans.size())。 注意:不能直接用 size() 分摊 —— 状态在同一帧内逐个创建,第一个那一刻 size()==1 会独占整额, 同帧后面新建的状态全部被截断为 0 配额(即“只有第一条信号推进”)。 实测(合成 5 信号 × 200 万采样、同一网格):首帧 5 个状态全部推进;9 帧内全部扫完(期间不注入 任何鼠标消息);数值与独立参考实现逐位一致;阈值变化后就地重置、随后“停住不动”仍精确扫完。 三、浮动面板排版 1. 信号名宽度上限 kEdgePanelNameMaxWidth=150 逻辑像素:宽度计算与绘制都用 min(实测, 上限) + UiEllipsize(300 字符名字只比单字符宽 44px,旧实现要多 5100px)。 2. 统计信息两列等宽(新增 EdgeRisingText/EdgeFallingText/EdgeCountsPending):面板宽度按 2×max(两列文本宽)+kEdgePanelStatColumnGap 预留,绘制时两列各占 (可用宽−间距)/2 ⇒ 多条信号的 “上升沿/下降沿”列起点固定、严格垂直对齐。 3. 未完成时 rising 与 falling 两个数字后面都带 `…`(旧实现只在整行末尾追加一次, 视觉上只跟在“下降沿”的数字后面)。 四、弱峰调整面板 1. 每行滑块右侧新增复位控件(复用还原图标绘制 PaintPanelResetIcon):悬停高亮 + IDC_HAND + 一次性提示;点击恢复幅度 0 / 迭代 1 且 maxIterations 回到初始上限 10。 EdgePanelLayout 新增 rowLevelResets/rowIterResets(并进 PanelSlotKey),两行按钮共用同一 X。 2. 迭代次数上限输入框的空内容提示改为“输入上限(当前N)”。 3. 滑块标签去掉多余的 %/N(kWeakPeakLevelLabel=调整幅度;新增 kWeakPeakIterationsSliderLabel= 迭代次数;MEAN / CENTER 输入框的“迭代次数 N”保持不变)。 五、验证(全部离屏;进程/线程 BELOW_NORMAL、窗口 SW_HIDE、不注入真实鼠标键盘) - 新增 .preflight/perf/panelcheck_main.cpp:46/46 通过(符号规则走真实菜单路径;按帧推进 / 不饿死 / 精确性 / 自持驱动;两列等宽预留(含未完成时两处 …);名字上限; 弱峰复位控件位置 / 对齐 / 点击恢复默认值与上限)。 - 既有 verify_main.cpp:47/47(另 2 条为本机 .preflight 未随远端同步的旧规则断言)。 - make release 零警告。 规则同步:Documents/agent_mem.md 新增 §9.26,§11.2 新增第 43–48 条硬约束,变更日志补一行。 --- Components/PlotView/Inc/PlotView.hpp | 38 +- Components/PlotView/Src/PlotView.cpp | 418 ++++++++++++++++++--- Components/SignalPanel/Src/SignalPanel.cpp | 76 +++- Documents/agent_mem.md | 112 ++++++ 4 files changed, 564 insertions(+), 80 deletions(-) diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 9cd68f5..eb8f8a3 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -1234,6 +1234,14 @@ namespace jdi std::vector rowLevelValues; std::vector rowIterTracks; std::vector rowIterValues; + /** + * @brief 「弱峰调整」每行滑块右侧的**复位按钮**矩形(与 `rowLevelTracks` 同序)。 + * + * 点击把该行参数恢复为默认值(幅度 0 / 迭代次数 1 且上限回到初始上限)。 + * 未勾选或非 PEAK 面板时对应元素为空矩形(数组也可能为空)。 + */ + std::vector rowLevelResets; + std::vector rowIterResets; std::vector rowRefs; }; @@ -1663,9 +1671,11 @@ namespace jdi * * ⚠️ **本函数绝不长时间阻塞 UI 线程**(千万级信号整段扫描实测 ≈115ms/条, * 横向光标每一帧都会换阈值 → 拖动时每帧都要扫,5 条信号就是 ≈575ms/帧): - * 扫描按「逐块累积」进行,每个 tick(约一帧)只消耗 `kEdgeScanPairsPerTick` - * 个采样对的预算,未扫完时 `complete` 返回 false(界面显示上一个完整结果 + “…”)。 - * 拖动横向光标(`EDGE_MOVE`)期间阈值每帧都变,推进等于白做功 → 不推进。 + * 扫描按「逐块累积」进行,**每帧**只消耗 `kEdgeScanPairsPerFrame` 个采样对的预算 + *(且**每个状态各自持有配额**,多条信号不会互相饿死),未扫完时 `complete` 返回 false + *(界面显示上一个完整结果 + “…”)。 + * 阈值变化(横向光标拖动中)就地把累积状态重置(不新建条目),且当帧不展示部分值 + *(`justReset`),避免“错误且不更新的数字”。 * * @param [IN] file 信号所属文件。 * @param [IN] signal 目标信号。 @@ -1871,6 +1881,8 @@ namespace jdi * 因此必须单独记住这个状态才能触发重绘与取消高亮。 */ int m_hoverBadge = -1; + /** 「弱峰调整」每行滑块右侧的复位控件是否处于悬停态(用于“进入/离开”时只提示一次)。 */ + bool m_peakResetHover = false; int m_mouseX = 0; /**< 最近一次鼠标横坐标(用于悬停提示定位)。 */ int m_mouseY = 0; /**< 最近一次鼠标纵坐标(用于悬停提示定位)。 */ bool m_spanHover = false; /**< 鼠标是否悬停在双光标差值标签上。 */ @@ -1915,6 +1927,15 @@ namespace jdi std::size_t falling = 0; /**< 已扫部分的下降沿总数。 */ bool complete = false; /**< 整条信号是否已扫完。 */ bool justReset = false; /**< 本 tick 刚因阈值/参数变化重新开始(不展示部分值)。 */ + /** + * @brief 本状态**上一次推进所在的帧序号**与当时已消耗的配额。 + * + * 每个状态**每帧各自持有配额**(而不是所有状态共享一个总额):旧实现下推进总是 + * 从列表第一个状态扫起,5 条信号同一网格时只有第一条有进展、其余长期显示“统计中…” + *(用户报告“很多项都停在了统计中不更新”)。 + */ + std::uint64_t pumpedFrame = 0; /**< 最近一次推进的帧序号(与 `m_edgeScanFrame` 比较)。 */ + std::size_t pumpedPairs = 0; /**< 本帧已消耗的采样对配额(新帧开始时重置)。 */ /** * @brief 中心轴扫描**就地计算**的一小段均值滤波曲线(源下标空间)。 * @@ -1932,8 +1953,15 @@ namespace jdi bool localValid = false; /**< 本地区间是否可用。 */ }; mutable std::vector m_edgeScans; /**< 边沿统计累积状态(纯缓存)。 */ - mutable std::size_t m_edgeScanBudget = 0; /**< 本 tick 剩余可扫描的采样对数量(限制单帧总开销)。 */ - mutable std::uint64_t m_edgeScanTick = 0; /**< 最近一次重置扫描预算的 tick。 */ + mutable std::size_t m_edgeScanBudget = 0; /**< 本帧剩余可扫描的采样对数量(跨状态共享,限制单帧总开销)。 */ + /** + * @brief 帧序号(`Paint` 每帧递增)。 + * + * ⚠️ 预算必须**按帧**重置,不能按 `GetTickCount64()` 分桶:系统时钟粒度约 15.6ms, + * 帧率一高(`PostMessage` 自持重绘会把它拉高)就会在同一 tick 内连画多帧而不推进, + * 用户看到的就是“按住光标不动时统计卡住、要动鼠标才更新”。 + */ + mutable std::uint64_t m_edgeScanFrame = 0; /** 诊断计数:中心轴本地曲线计算失败次数(内存不足等)。0 = 一切正常。 */ mutable std::size_t m_edgeScanLocalCurveFails = 0; diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index df834ad..46ce039 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -73,7 +73,56 @@ namespace jdi return gd::RectF(left, top, 0.0f, 0.0f); } return gd::RectF(left, top, right - left, bottom - top); - } const float kCursorSnapMinPixels = 6.0f; /**< 光标吸附采样点的最小采样间隔(逻辑像素)。 */ + } + + /** + * @brief 绘制「还原/重置」图标(逆时针回环箭头)。 + * + * 与 `PopupMenu` 里「系数 / 偏移」二级面板标题栏右侧的还原图标**同一套笔画** + *(用户要求:“可以直接挪用信号『调整』功能中『系数/偏移』二级菜单内的复位控件”), + * 因此弱峰调整面板的复位控件与它视觉完全一致。不依赖字体或资源,任意 DPI 都清晰。 + * + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] rect 图标外接矩形(正方形)。 + * @param [IN] color 笔画颜色(0xAARRGGBB)。 + * @param [IN] scale DPI 缩放比例。 + */ + void PaintPanelResetIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t color, + float scale) + { + const float inset = 2.5f * scale; + gd::RectF box(rect.X + inset, rect.Y + inset, rect.Width - inset * 2.0f, + rect.Height - inset * 2.0f); + if (box.Width <= 1.0f || box.Height <= 1.0f) + { + return; + } + gd::Pen pen(gd::Color((color >> 24) & 0xFFu, (color >> 16) & 0xFFu, (color >> 8) & 0xFFu, + color & 0xFFu), + std::max(1.0f, 1.5f * scale)); + pen.SetStartCap(gd::LineCapRound); + pen.SetEndCap(gd::LineCapRound); + // 缺口留在右上角(从 300° 到 20° 处断开),与常见的"重置/刷新"图标一致。 + g.DrawArc(&pen, box.X, box.Y, box.Width, box.Height, 300.0f, 300.0f); + + // 箭头:位于缺口末端(约 300° 方向),指向前进方向。 + const float cx = box.X + box.Width * 0.5f; + const float cy = box.Y + box.Height * 0.5f; + const float radius = box.Width * 0.5f; + const float angle = 300.0f * 3.14159265f / 180.0f; + const float tipX = cx + std::cos(angle) * radius; + const float tipY = cy + std::sin(angle) * radius; + const float size = std::max(2.5f, 3.2f * scale); + gd::PointF arrow[3]; + arrow[0] = gd::PointF(tipX, tipY); + arrow[1] = gd::PointF(tipX - size, tipY - size * 0.25f); + arrow[2] = gd::PointF(tipX - size * 0.25f, tipY - size); + gd::SolidBrush brush(gd::Color((color >> 24) & 0xFFu, (color >> 16) & 0xFFu, + (color >> 8) & 0xFFu, color & 0xFFu)); + g.FillPolygon(&brush, arrow, 3); + } + + const float kCursorSnapMinPixels = 6.0f; /**< 光标吸附采样点的最小采样间隔(逻辑像素)。 */ const float kLaneLabelPad = 6.0f; /**< 网格内侧标尺带的内边距。 */ const float kLegendHeight = 19.0f; /**< 图例条目高度(逻辑像素)。 */ const float kLegendPad = 5.0f; /**< 图例相对网格左上角的内缩。 */ @@ -87,6 +136,8 @@ namespace jdi const float kEdgePanelTitleHeight = 26.0f; /**< 面板标题栏高度。 */ const float kEdgePanelRowHeight = 18.0f; /**< 面板内单行文本高度。 */ const float kEdgePanelPad = 7.0f; /**< 面板内边距。 */ + const float kEdgePanelNameMaxWidth = 150.0f; /**< 面板内信号名的宽度上限(超过则省略;避免超长信号名把面板顶得很长)。 */ + const float kEdgePanelStatColumnGap = 14.0f; /**< 「上升沿 / 下降沿」两列之间的间距(保证多信号的文字垂直对齐)。 */ const float kEdgePanelEditFieldWidth = 64.0f; /**< 数值框的最小宽度。 */ const float kEdgePanelButtonHeight = 30.0f; /**< 均值滤波面板底部按钮行高度。 */ const float kEdgePanelMeanRowHeight = 22.0f; /**< 逐条目面板中每条信号的名称行高度。 */ @@ -96,6 +147,15 @@ namespace jdi const float kWeakPeakSliderRowHeight = 26.0f; /**< 弱峰调整面板中每个滑块行的高度。 */ const float kWeakPeakTrackHeight = 6.0f; /**< 弱峰调整滑块的轨道高度。 */ const float kWeakPeakTrackMinWidth = 120.0f; /**< 弱峰调整滑块轨道的最小宽度。 */ + /** + * @brief 「弱峰调整」滑块行右侧**复位控件**的边长(逻辑像素)。 + * + * 每行滑块都有一个复位按钮(与「系数/偏移」二级面板里的「还原」图标同一套绘制), + * 点击把该行的参数恢复为默认值(幅度 0 / 迭代次数 1 且上限回到初始上限)。 + */ + const float kWeakPeakResetButtonSize = 16.0f; + /** 复位控件与其左侧数值列之间的间距(逻辑像素)。 */ + const float kWeakPeakResetGap = 4.0f; const int kWeakPeakIterationsMin = 1; /**< 迭代次数 N 的下限。 */ /** 迭代次数滑块「自定义上限」的允许范围(与算法内部的阶数夹取保持一致)。 */ const int kWeakPeakIterationsMaxLimit = 1000; @@ -144,8 +204,10 @@ namespace jdi * 整条信号的重算本来就追不上滑块速度,"冻结"与"每帧重排"观感相同,但后者拖慢前台。 */ const std::size_t kHugeSignalPreviewSamples = 4000000ull; - const wchar_t* const kWeakPeakLevelLabel = L"调整幅度 %"; /**< 逐条目面板里「幅度」滑块行的标签。 */ - const wchar_t* const kWeakPeakIterationsLabel = L"迭代次数 N"; /**< 逐条目面板里「迭代次数」滑块行的标签。 */ + const wchar_t* const kWeakPeakLevelLabel = L"调整幅度"; /**< 逐条目面板里「幅度」滑块行的标签(用户要求:滑动条前不再单独显示 `%`)。 */ + const wchar_t* const kWeakPeakIterationsLabel = L"迭代次数 N"; /**< MEAN / CENTER 面板里「迭代次数」输入框的标签。 */ + /** 「弱峰调整」迭代次数滑块行的标签(用户要求:滑动条前不再单独显示 `N`)。 */ + const wchar_t* const kWeakPeakIterationsSliderLabel = L"迭代次数"; const wchar_t* const kWeakPeakTitle = L"弱峰调整"; /**< 弱峰调整面板标题。 */ const wchar_t* const kBuildSignalLabel = L"构建为新信号"; /**< 逐条目面板底部按钮文案(均值滤波 / 弱峰调整共用)。 */ /** @@ -167,27 +229,43 @@ namespace jdi */ const std::size_t kEdgeBlockSamples = 8192; /** - * @brief 每个 tick(约一帧)允许「边沿计数」扫描的采样对总数。 + * @brief 每一帧允许「边沿计数」扫描的采样对总数(跨全部累积状态共享)。 * * 千万级信号整段扫描一次 ≈115ms(用户报告:5 条信号同一网格时单帧 ≈575ms, * 界面完全失去响应)。改成预算推进后,单帧最多只花 ≈5ms 在统计上, * 剩下的部分在后续帧里接着扫(未扫完时界面显示上一个完整结果 + “…”)。 + * + * ⚠️ **必须按帧重置**(在 `Paint` 开头):旧实现按 `GetTickCount64()` 分桶, + * 而系统时钟粒度约 15.6ms —— 当帧率高于它时(`PostMessage` 自持重绘会把帧率拉高), + * 同一 tick 内的多帧**完全不推进**(用户报告:“按住横向光标不动时统计卡在统计中”, + * “回车确认参数后不移动鼠标就一直停在部分值”)。 */ - const std::size_t kEdgeScanPairsPerTick = 1200000; + const std::size_t kEdgeScanPairsPerFrame = 1200000; /** - * @brief 「边沿计数」结果的显示文本。 - * @param [IN] rising 上升沿数量。 @param [IN] falling 下降沿数量。 - * @param [IN] complete 是否为该阈值的完整精确值。 - * @return 面板/宽度测量共用的文本(未算完时带省略号,完全未开始时显示“统计中…”)。 + * @brief 每个累积状态每帧的扫描配额下限(防止多个状态时前面的把预算吃光)。 + * + * 过去所有状态共享一个“本 tick 总预算”,而推进总是从列表中的第一个状态开始 + * —— 5 条信号时只有第一条有进展,其余 4 条长期显示“统计中…”(用户报告 + * “很多项都停在了统计中不更新”)。现在每个状态各自持有每帧配额,互不饿死。 */ - std::wstring EdgeCountsText(std::size_t rising, std::size_t falling, bool complete) + const std::size_t kEdgeScanPairsPerStateMin = 60000; + /** + * @brief 计算每状态配额时假定的“最小并发状态数”。 + * + * ⚠️ 不能直接用 `m_edgeScans.size()`:状态是在**同一帧内逐个创建**的,第一个状态的 + * 那一刻 `size() == 1`,会拿走整额预算 → 同帧后面新建的状态全部被截断为 0 配额 + *(首帧只有第一条信号推进)。按一个典型并发量(普通 + 中心轴 × 网格内信号数) + * 下限分摊,可保证同一帧里所有状态都得到推进。 + */ + const std::size_t kEdgeScanFairShare = 6; + /** + * @brief 「边沿计数」结果里的**上升沿**部分文本。 + * @param [IN] rising 上升沿数量。 @param [IN] complete 是否为完整精确值。 + * @return 形如 "上升沿 123"(未算完时追加 " …")的文本。 + */ + std::wstring EdgeRisingText(std::size_t rising, bool complete) { - if (!complete && rising == 0 && falling == 0) - { - return L"统计中…"; - } - std::wstring text = L"上升沿 " + std::to_wstring(rising) + L" 下降沿 " + - std::to_wstring(falling); + std::wstring text = L"上升沿 " + std::to_wstring(rising); if (!complete) { text += L" …"; @@ -195,6 +273,48 @@ namespace jdi return text; } + /** + * @brief 「边沿计数」结果里的**下降沿**部分文本。 + * @param [IN] falling 下降沿数量。 @param [IN] complete 是否为完整精确值。 + * @return 形如 "下降沿 456"(未算完时追加 " …")的文本。 + */ + std::wstring EdgeFallingText(std::size_t falling, bool complete) + { + std::wstring text = L"下降沿 " + std::to_wstring(falling); + if (!complete) + { + text += L" …"; + } + return text; + } + + /** + * @brief 「边沿计数」是否处于“完全未算出”状态(用于显示“统计中…”)。 + */ + bool EdgeCountsPending(std::size_t rising, std::size_t falling, bool complete) + { + return (!complete && rising == 0 && falling == 0); + } + + /** + * @brief 「边沿计数」结果的显示文本(合并成一行,用于宽度测量与兼容旧调用)。 + * + * ⚠️ 未完成时**两个数字后面都要带 `…`**(旧实现只在整行末尾追加一个 `…`, + * 视觉上只跟在“下降沿”的数字后面,用户明确要求“为上升沿也加上这个尾缀”)。 + * @param [IN] rising 上升沿数量。 @param [IN] falling 下降沿数量。 + * @param [IN] complete 是否为该阈值的完整精确值。 + * @return 面板/宽度测量共用的文本。 + */ + [[maybe_unused]] std::wstring EdgeCountsText(std::size_t rising, std::size_t falling, + bool complete) + { + if (EdgeCountsPending(rising, falling, complete)) + { + return L"统计中…"; + } + return EdgeRisingText(rising, complete) + L" " + EdgeFallingText(falling, complete); + } + /** * @brief 判断坐标是否命中「弱峰调整」的某条滑块轨道。 * @@ -237,7 +357,7 @@ namespace jdi float WeakPeakLabelColumnWidth(HFONT uiSmall) { return std::max(UiMeasureTextWidth(uiSmall, kWeakPeakLevelLabel), - UiMeasureTextWidth(uiSmall, kWeakPeakIterationsLabel)); + UiMeasureTextWidth(uiSmall, kWeakPeakIterationsSliderLabel)); } /** @@ -1391,7 +1511,6 @@ namespace jdi // 数据层结构变化后边沿统计与均值滤波曲线的派生结果都会失效。 m_edgeScans.clear(); m_edgeScanBudget = 0; - m_edgeScanTick = 0; m_meanCurveCache.clear(); m_weakPeakCurveCache.clear(); // 信号下标可能已经整体位移:清掉按引用缓存的悬停/拖拽目标,避免指向错误的信号。 @@ -3238,19 +3357,28 @@ namespace jdi return std::isfinite(drawnCenter) ? (drawn - drawnCenter) : nan; }; - // ---- 推进(受每 tick 预算限制)------------------------------------------ + // ---- 推进(受「每帧总量 + 每状态配额」双重限制)-------------------------- + // ⚠️ 预算在 `Paint` 开头**按帧**重置(不再按 `GetTickCount64()` 分桶):系统时钟 + // 粒度约 15.6ms,帧率一高就会在同一 tick 内连画多帧而完全不推进 —— 用户报告的 + // “按住横向光标不动时统计卡在统计中”“回车确认参数后不移动鼠标就一直停在部分值” + // 就是这个原因。 + // ⚠️ 同时**每个状态各自持有配额**:否则推进总从列表第一个状态扫起,多条信号时 + // 只有第一条有进展、其余长期显示“统计中…”(用户报告“很多项都停在统计中不更新”)。 const bool suppressPartial = state->justReset && !state->complete; if (!state->complete && !state->blockRising.empty()) { - // 扫描预算按 tick 共享:同一帧里本函数会被调用多次(面板宽度测量 + 绘制 + 多条信号), - // 若每次都各扫一份,单帧总开销就会翻好几倍(实测整段 25.8M 采样 ≈115ms)。 - const std::uint64_t tick = GetTickCount64(); - if (tick != m_edgeScanTick) + const std::size_t activeStates = std::max(1, m_edgeScans.size()); + const std::size_t share = std::max(kEdgeScanFairShare, activeStates); + const std::size_t perState = + std::max(kEdgeScanPairsPerStateMin, kEdgeScanPairsPerFrame / share); + if (state->pumpedFrame != m_edgeScanFrame) { - m_edgeScanTick = tick; - m_edgeScanBudget = kEdgeScanPairsPerTick; + state->pumpedFrame = m_edgeScanFrame; + state->pumpedPairs = 0; } - if (m_edgeScanBudget > 0) + std::size_t quota = (perState > state->pumpedPairs) ? (perState - state->pumpedPairs) : 0; + quota = std::min(quota, m_edgeScanBudget); + if (quota > 0) { state->justReset = false; // 快路径:时间单调、无水平镜像、无「调整」变换、非中心轴 ⇒ 直接比较采样值与阈值。 @@ -3260,12 +3388,13 @@ namespace jdi std::size_t scanned = 0; std::size_t pair = state->scannedPair; std::size_t block = (pair > 0) ? ((pair - 1) / kEdgeBlockSamples) : 0; - while (pair < count && scanned < m_edgeScanBudget) + while (pair < count && scanned < quota && m_edgeScanBudget > 0) { const std::size_t blockFirst = block * kEdgeBlockSamples + 1; const std::size_t blockLast = std::min(count - 1, (block + 1) * kEdgeBlockSamples); const std::size_t from = std::max(pair, blockFirst); - const std::size_t to = std::min(blockLast, from + (m_edgeScanBudget - scanned) - 1); + const std::size_t avail = std::min(quota - scanned, m_edgeScanBudget); + const std::size_t to = std::min(blockLast, from + avail - 1); if (to < from) { break; @@ -3340,6 +3469,7 @@ namespace jdi state->localEnd = 0; state->localValid = false; } + state->pumpedPairs += scanned; m_edgeScanBudget -= std::min(m_edgeScanBudget, scanned); } } @@ -3454,7 +3584,12 @@ namespace jdi kEdgePanelTitleHeight * m_scale; // 数据行 = 名称与“上升沿 N / 下降沿 M”两行,取较宽者。 - // 用 UiMeasureTextWidth(无省略号)测量,保证数字完整显示。 + // ⚠️ 名称宽度有上限(`kEdgePanelNameMaxWidth`):超长信号名不再把面板顶得很长, + // 绘制时超出部分用省略号(用户明确要求“超过某个长度就省略名称显示”)。 + // ⚠️ 统计信息按「上升沿 / 下降沿」**两列**排版(列起点固定)→ 多条信号时文字垂直对齐。 + float nameColumnWidth = 0.0f; + float statRisingWidth = 0.0f; + float statFallingWidth = 0.0f; for (const SignalRef& ref : lane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); @@ -3462,7 +3597,10 @@ namespace jdi { continue; } - const float nameWidth = UiMeasureTextWidth(m_fonts->uiSmall, resolved.data->Title()); + const float nameWidth = + std::min(UiMeasureTextWidth(m_fonts->uiSmall, resolved.data->Title()), + kEdgePanelNameMaxWidth * m_scale); + nameColumnWidth = std::max(nameColumnWidth, nameWidth); if (itemized) { // 逐条目面板:第一排 = 名称 + 复选框;第三排(中心轴)= 统计结果。 @@ -3492,11 +3630,25 @@ namespace jdi EdgeCountsFor(*resolved.file, *resolved.data, *resolved.style, lane.edgeValue, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); } - // 宽度按实际要显示的文本测量(含未算完时的“统计中…”/省略号), - // 否则推进过程中数字变长会被截断(统计数字必须完整显示)。 - const float countWidth = - UiMeasureTextWidth(m_fonts->monoSmall, EdgeCountsText(rising, falling, complete)); - width = std::max(width, kEdgePanelPad * 2.0f * m_scale + std::max(nameWidth, countWidth)); + // 宽度按实际要显示的文本测量(含未算完时的“…”),否则推进过程中数字变长会被截断。 + // 完全未算出时整行显示“统计中…”,因此两列都要预留它的宽度。 + const float pendingWidth = UiMeasureTextWidth(m_fonts->monoSmall, L"统计中…"); + statRisingWidth = std::max( + statRisingWidth, + std::max(UiMeasureTextWidth(m_fonts->monoSmall, EdgeRisingText(rising, complete)), + pendingWidth)); + statFallingWidth = + std::max(statFallingWidth, + UiMeasureTextWidth(m_fonts->monoSmall, EdgeFallingText(falling, complete))); + } + if (statRisingWidth > 0.0f || statFallingWidth > 0.0f) + { + // 两列**等宽**(取两列最大值中的较大者)→ 绘制时两列起点固定, + // 多条信号的“上升沿 / 下降沿”文字严格垂直对齐(用户明确要求)。 + const float maxColumn = std::max(statRisingWidth, statFallingWidth); + const float countsWidth = maxColumn * 2.0f + kEdgePanelStatColumnGap * m_scale; + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + + std::max(nameColumnWidth, countsWidth)); } if (centerAxis || meanFilter) @@ -3515,10 +3667,11 @@ namespace jdi if (weakPeak) { - // 滑块行 = 「标签列 + 轨道(最小宽度)+ 数值列」(等级与迭代次数两行共用同一列宽)。 + // 滑块行 = 「标签列 + 轨道(最小宽度)+ 数值列 + 复位按钮」(等级与迭代次数两行共用同一列宽)。 const float rowWidth = WeakPeakLabelColumnWidth(m_fonts->uiSmall) + 6.0f * m_scale + kWeakPeakTrackMinWidth * m_scale + 6.0f * m_scale + - WeakPeakValueColumnWidth(m_fonts->monoSmall); + WeakPeakValueColumnWidth(m_fonts->monoSmall) + + kWeakPeakResetGap * m_scale + kWeakPeakResetButtonSize * m_scale; width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth); } @@ -4616,6 +4769,21 @@ namespace jdi { return LoadCursorW(nullptr, IDC_HAND); } + // 两行滑块右侧的**复位控件**:与其它可点击图标一致,用手型光标提示可点击。 + if (rowIndex < layout.rowLevelResets.size() && + layout.rowLevelResets[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowLevelResets[rowIndex], static_cast(x), + static_cast(y))) + { + return LoadCursorW(nullptr, IDC_HAND); + } + if (rowIndex < layout.rowIterResets.size() && + layout.rowIterResets[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowIterResets[rowIndex], static_cast(x), + static_cast(y))) + { + return LoadCursorW(nullptr, IDC_HAND); + } if (rowIndex < layout.rowIterTracks.size() && WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale, static_cast(x), static_cast(y))) @@ -5029,10 +5197,43 @@ namespace jdi // 按**层序**取最上层的面板(与点击命中保持一致,重叠时以最上层为准)。 bool overEdgePanel = false; int panelLane = -1; + int hoverPanelKind = 0; { - int hoverKind = 0; overEdgePanel = TopPanelAtPoint(static_cast(x), static_cast(y), panelLane, - hoverKind); + hoverPanelKind); + } + + // 「弱峰调整」每行滑块右侧的**复位控件**:悬停时给出一次性提示(进入时设置、离开时清除)。 + // 与「系数/偏移」二级面板标题栏的还原图标同一交互语义(悬停高亮 + 手型 + 提示)。 + bool overPeakReset = false; + if (overEdgePanel && panelLane >= 0 && m_tab != nullptr && + static_cast(hoverPanelKind) == EdgePanelKind::PEAK) + { + const EdgePanelLayout resetLayout = EdgePanelLayoutFor(panelLane, EdgePanelKind::PEAK); + for (std::size_t rowIndex = 0; rowIndex < resetLayout.rowLevelResets.size(); ++rowIndex) + { + const gd::RectF& level = resetLayout.rowLevelResets[rowIndex]; + if (level.Width > 0.0f && UiPointInRect(level, fx, static_cast(y))) + { + overPeakReset = true; + break; + } + if (rowIndex < resetLayout.rowIterResets.size()) + { + const gd::RectF& iter = resetLayout.rowIterResets[rowIndex]; + if (iter.Width > 0.0f && UiPointInRect(iter, fx, static_cast(y))) + { + overPeakReset = true; + break; + } + } + } + } + if (overPeakReset != m_peakResetHover) + { + m_peakResetHover = overPeakReset; + SetHint(overPeakReset ? std::wstring(L"点击恢复该参数的默认值") : std::wstring()); + changed = true; } // 悬停网格:鼠标在面板上时高亮面板所属网格(用户明确要求)。 @@ -5320,6 +5521,28 @@ namespace jdi 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 && @@ -6382,6 +6605,13 @@ namespace jdi BuildLaneVisuals(); + // 「边沿计数」的扫描预算**按帧重置**(见 `m_edgeScanFrame` 的说明): + // 旧实现按 `GetTickCount64()` 分桶,帧率一高(`PostMessage` 自持重绘会把它拉高) + // 就会在同一系统 tick 内连画多帧而**完全不推进**统计 —— 用户报告 + // “按住横向光标不动时统计卡住不更新”“回车确认参数后不移动鼠标就停在部分值”。 + ++m_edgeScanFrame; + m_edgeScanBudget = kEdgeScanPairsPerFrame; + // 双缓冲:清掉上一帧钉住的曲线条目(本帧会按需重新钉住),并清理已被更新条目覆盖 / // 超出内存预算的旧条目。此刻没有任何条目被钉住,回收是安全的。 m_curvePins.clear(); @@ -10799,11 +11029,15 @@ namespace jdi // 滑块行列宽(与 `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 - 6.0f * m_scale) - - weakPeakTrackLeft); + 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) { @@ -10843,13 +11077,24 @@ namespace jdi 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()); @@ -11585,6 +11830,15 @@ namespace jdi { mixRect(rect); } + // 弱峰调整每行滑块右侧的复位按钮(悬停高亮依赖它的精确矩形)。 + for (const gd::RectF& rect : layout.rowLevelResets) + { + mixRect(rect); + } + for (const gd::RectF& rect : layout.rowIterResets) + { + mixRect(rect); + } // 鼠标落在面板内时,✕ / 复选框 / 滑块 / 底部按钮的悬停态都依赖精确坐标。 const float margin = 6.0f * m_scale; @@ -11709,6 +11963,27 @@ namespace jdi // 面板是**静态**内容:只有被拖动 / 悬停 / 参数变化的那一个真正重画, // 其余直接贴上一帧缓存好的像素(15 个面板全画一遍实测约 33ms, // 而贴图 <1ms —— 拖动弱峰调整滑块时的主要卡顿就来自这一层)。 + // 「上升沿 / 下降沿」两列排版(列起点固定 ⇒ 多条信号的文字严格垂直对齐)。 + // ⚠️ 未完成时**两个数字后面都带 `…`**(旧实现只在整行末尾追加一次, + // 视觉上只跟在“下降沿”的数字后面;用户明确要求为“上升沿”也补上这个尾缀)。 + const auto paintCounts = [this, &g](const gd::RectF& rect, std::size_t rising, + std::size_t falling, bool complete) + { + if (EdgeCountsPending(rising, falling, complete)) + { + UiText(g, L"统计中…", m_fonts->monoSmall, rect, m_theme->textMuted, TextAlign::LEFT); + 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); + }; + std::vector nextSlots; std::vector dirtyRects; const std::uint64_t panelGlobalKey = PanelGlobalKey(); @@ -11873,9 +12148,10 @@ namespace jdi : (weakPeak ? tabLane.WeakPeakEnabled(ref) : tabLane.EdgeCenterEnabled(ref)); - // 名称:第一排左侧,避开右侧复选框。 + // 名称:第一排左侧,避开右侧复选框(超长时省略)。 const float nameWidth = - std::max(20.0f, checkRect.X - (panel.X + pad) - 6.0f * m_scale); + std::min(std::max(20.0f, checkRect.X - (panel.X + pad) - 6.0f * m_scale), + kEdgePanelNameMaxWidth * m_scale); UiText(g, UiEllipsize(resolved.data->Title(), m_fonts->uiSmall, nameWidth), m_fonts->uiSmall, gd::RectF(panel.X + pad, checkRect.Y - 3.0f * m_scale, nameWidth, @@ -11930,11 +12206,34 @@ namespace jdi track.Height + 16.0f * m_scale), m_theme->textStrong, TextAlign::LEFT); }; + // 每行滑块右侧的**复位控件**(与「系数/偏移」二级面板里的还原图标同一套绘制): + // 悬停时高亮 + 手型光标,点击把该行参数恢复为默认值(幅度 0 / 迭代 1 且上限回初始值)。 + const auto paintSliderReset = [&](const gd::RectF& rect) + { + if (rect.Width <= 0.0f) + { + return; + } + const bool hot = + (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && + UiPointInRect(rect, static_cast(m_mouseX), + static_cast(m_mouseY)); + if (hot) + { + UiFillRoundRect( + g, gd::RectF(rect.X - 1.0f * m_scale, rect.Y - 1.0f * m_scale, + rect.Width + 2.0f * m_scale, + rect.Height + 2.0f * m_scale), + 3.0f * m_scale, WithAlpha(m_theme->accent, 0x33)); + } + PaintPanelResetIcon(g, rect, + hot ? m_theme->accent : m_theme->textMuted, m_scale); + }; const gd::RectF& levelTrack = layout.rowLevelTracks[rowIndex]; const gd::RectF& iterTrack = layout.rowIterTracks[rowIndex]; paintSliderLabel(levelTrack, kWeakPeakLevelLabel); - paintSliderLabel(iterTrack, kWeakPeakIterationsLabel); + paintSliderLabel(iterTrack, kWeakPeakIterationsSliderLabel); const bool levelActive = (m_dragMode == PlotDragMode::PEAK_LEVEL) && (m_peakDragLane == static_cast(index)) && @@ -11950,6 +12249,7 @@ namespace jdi (peakItem->amountPercent > 0 ? L"+" : L"") + std::to_wstring(peakItem->amountPercent) + L"%", levelActive); + paintSliderReset(layout.rowLevelResets[rowIndex]); // 迭代次数滑块:量程 = 1 … 该信号自己的上限。 const int peakMax = std::max(kWeakPeakIterationsMin, @@ -11972,6 +12272,7 @@ namespace jdi { paintSliderBody(iterTrack, iterRatio, std::to_wstring(peakIter), iterActive); + paintSliderReset(layout.rowIterResets[rowIndex]); } } } @@ -12042,10 +12343,8 @@ namespace jdi statsPoints, statsOrder, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, statsComplete); - const std::wstring counts = - EdgeCountsText(rising, falling, statsComplete); - UiText(g, counts, m_fonts->monoSmall, statsRect, m_theme->textMuted, - TextAlign::LEFT); + // 两列排版(列起点固定)⇒ 多条信号的“上升沿 / 下降沿”垂直对齐。 + paintCounts(statsRect, rising, falling, statsComplete); } } } @@ -12065,10 +12364,12 @@ namespace jdi } const std::uint32_t signalColor = StyleColor(*resolved.style); const float textWidth = panel.Width - pad * 2.0f; - // 名称与统计数字一律完整绘制(不省略):面板宽度已按文本实测宽度确定。 - UiText(g, resolved.data->Title(), m_fonts->uiSmall, - gd::RectF(panel.X + pad, rowY, textWidth, rowHeight), signalColor, - TextAlign::LEFT); + // 名称:超长时省略(面板宽度被 `kEdgePanelNameMaxWidth` 限制,不会把面板顶得很长)。 + UiText(g, + UiEllipsize(resolved.data->Title(), m_fonts->uiSmall, + std::min(textWidth, kEdgePanelNameMaxWidth * m_scale)), + m_fonts->uiSmall, gd::RectF(panel.X + pad, rowY, textWidth, rowHeight), + signalColor, TextAlign::LEFT); std::size_t rising = 0; std::size_t falling = 0; @@ -12076,10 +12377,8 @@ namespace jdi EdgeCountsFor(*resolved.file, *resolved.data, *resolved.style, tabLane.edgeValue, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); - const std::wstring counts = EdgeCountsText(rising, falling, complete); - UiText(g, counts, m_fonts->monoSmall, - gd::RectF(panel.X + pad, rowY + rowHeight, textWidth, rowHeight), - m_theme->textMuted, TextAlign::LEFT); + paintCounts(gd::RectF(panel.X + pad, rowY + rowHeight, textWidth, rowHeight), rising, + falling, complete); rowY += rowHeight * 2.0f; } @@ -12155,14 +12454,13 @@ namespace jdi // 编辑态:强调色外框 + 选区高亮 + 插入符,首次键入即整体替换(与常规输入框一致)。 UiDrawEditField(g, fieldRect, m_edit, m_fonts->monoSmall, m_scale); - // 「迭代次数 N 上限」:输入框**以空内容开始**,未输入任何内容时以灰色提示当前上限值 - //(用户明确要求:"在未输入任何内容时,输入框内提示当前上限值")。 - // 提示文本比插入符稍微右移几像素,避免插入符压在上面看不清数字。 + // 「迭代次数 N 上限」:输入框**以空内容开始**,未输入任何内容时以灰色提示当前上限值。 + // 提示文案 = “输入上限(当前N)”(用户明确要求:旧实现只显示一个孤立的数字,缺少说明)。 if (m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX && m_edit.Text().empty()) { const int currentMax = m_peakEditCurrentMax > 0 ? m_peakEditCurrentMax : kWeakPeakIterationsDefaultMax; - const std::wstring hint = std::to_wstring(currentMax); + const std::wstring hint = L"输入上限(当前" + std::to_wstring(currentMax) + L")"; const float hintLeft = fieldRect.X + 11.0f * m_scale; UiTextAtClipped(g, hint, m_fonts->monoSmall, hintLeft, gd::RectF(hintLeft, fieldRect.Y, diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index f4da1ee..2432c02 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -193,24 +193,65 @@ namespace jdi } /** - * @brief 带正负符号的数值文本:正数也带 `+`(非编辑态显示偏移量时使用)。 + * @brief 带正负符号的数值文本(非编辑态显示偏移量时使用)。 + * + * 规则(用户明确要求):**正数带 `+`、负数带 `-`、0 不带任何符号**。 + * (旧实现 0 也会显示 `+0.00`,用户报告“偏移调整的 Y 轴输入框默认值为 0 时也显示了正号,这不正确”。) * @param [IN] value 数值。 - * @return 形如 `+1.50` / `-0.25` / `+0.00` 的文本。 + * @return 形如 `+1.50` / `-0.25` / `0.00` 的文本。 */ std::wstring FormatSignedNumber(double value) { const std::wstring text = FormatNumberText(std::fabs(value)); - return ((value < 0.0) ? L"-" : L"+") + text; + if (value > 0.0) + { + return L"+" + text; + } + if (value < 0.0) + { + return L"-" + text; + } + return text; // 0:不带符号 } /** - * @brief 把系数格式化为界面文本(与偏移量同一套两位小数规则)。 + * @brief 把系数格式化为界面文本(与偏移量同一套两位小数规则 + 显式正号)。 + * + * 规则(用户明确要求):“系数调整”输入框**非编辑态**显示时,非 0 正值前面加 `+`, + * 负值按原样显示负号,0 不带符号。编辑态仍用不带符号的纯数值(见 `FormatNumberText`)。 * @param [IN] gain 系数。 * @return 显示文本。 */ std::wstring FormatGainText(float gain) { - return FormatNumberText(static_cast(gain)); + const double value = static_cast(gain); + if (value > 0.0) + { + return L"+" + FormatNumberText(value); + } + return FormatNumberText(value); + } + + /** + * @brief 「时间轴」偏移的非编辑态显示文本(`Δt:` + 带正负号的复合单位间隔)。 + * + * 规则(用户明确要求):在 `Δt:` 冒号后的时间显示值**前面加上正负号**, + * 只有值为 0 时不显示符号。示例:`Δt:+1h 1min 2s` / `Δt:-5s 60ms` / `Δt:0s`。 + *(`FormatDurationCompound` 内部取绝对值,因此符号由这里补上。) + * @param [IN] seconds 时间偏移(秒,可为负)。 + * @return 显示文本。 + */ + std::wstring FormatOffsetTimeText(double seconds) + { + if (seconds > 0.0) + { + return L"Δt:+" + FormatDurationCompound(seconds); + } + if (seconds < 0.0) + { + return L"Δt:-" + FormatDurationCompound(seconds); + } + return L"Δt:" + FormatDurationCompound(0.0); } } @@ -2012,13 +2053,15 @@ namespace jdi gainHeader.id = CMD_GAIN_RESET; // 点击标题栏右侧的还原图标 → 系数重置为 1 items.push_back(gainHeader); + // 非编辑态带正号(用户明确要求);进入编辑后的初始文本用不带符号的纯数值。 const std::wstring gainText = FormatGainText(style->gain); + const std::wstring gainEditText = FormatNumberText(static_cast(style->gain)); MenuItem gainEdit; gainEdit.kind = MenuItemKind::EDIT; gainEdit.label.clear(); // 不显示说明文字:输入框独占整行 gainEdit.id = CMD_GAIN_EDIT; gainEdit.value = gainText; - gainEdit.editValue = gainText; + gainEdit.editValue = gainEditText; gainEdit.filter = EditFilter::NUMBER; items.push_back(gainEdit); @@ -2039,13 +2082,13 @@ namespace jdi items.push_back(offsetY); // 「时间轴」:把采样沿时间轴平移(画在 t + offsetTime)。 - // 非编辑态显示与双光标差值同一套复合单位文本;编辑态显示以秒为单位的数值, - // 并在输入框最右侧提示单位 `s`。 + // 非编辑态显示与双光标差值同一套复合单位文本,并**带正负号**(用户明确要求); + // 编辑态显示以秒为单位的数值,并在输入框最右侧提示单位 `s`。 MenuItem offsetTime; offsetTime.kind = MenuItemKind::EDIT; offsetTime.label = L"时间轴"; offsetTime.id = CMD_OFFSET_TIME; - offsetTime.value = L"Δt:" + FormatDurationCompound(style->offsetTime); + offsetTime.value = FormatOffsetTimeText(style->offsetTime); offsetTime.editValue = FormatNumberText(style->offsetTime); offsetTime.editSuffix = L"s"; offsetTime.filter = EditFilter::NUMBER; @@ -2907,7 +2950,8 @@ namespace jdi } if (m_menu != nullptr) { - m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain), FormatGainText(style->gain)); + m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain), + FormatNumberText(static_cast(style->gain))); } return false; } @@ -2936,7 +2980,7 @@ namespace jdi if (m_menu != nullptr) { m_menu->UpdateItemValue(CMD_OFFSET_TIME, - L"Δt:" + FormatDurationCompound(style->offsetTime), + FormatOffsetTimeText(style->offsetTime), FormatNumberText(style->offsetTime)); } return false; @@ -2959,7 +3003,8 @@ namespace jdi style->gain = 1.0f; if (m_menu != nullptr) { - m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain), FormatGainText(style->gain)); + m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain), + FormatNumberText(static_cast(style->gain))); } } return false; @@ -2974,9 +3019,10 @@ namespace jdi style->offsetTime = 0.0; if (m_menu != nullptr) { - m_menu->UpdateItemValue(CMD_OFFSET_Y, FormatSignedNumber(0.0), L"0.00"); - m_menu->UpdateItemValue(CMD_OFFSET_TIME, L"Δt:" + FormatDurationCompound(0.0), - L"0.00"); + m_menu->UpdateItemValue(CMD_OFFSET_Y, FormatSignedNumber(0.0), + FormatNumberText(0.0)); + m_menu->UpdateItemValue(CMD_OFFSET_TIME, FormatOffsetTimeText(0.0), + FormatNumberText(0.0)); } } return false; diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index ec34c2d..8b83f13 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -1275,6 +1275,88 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( --- +### 9.26 「调整」数值符号规则 + 边沿统计按帧推进(含首帧公平分摊)+ 浮动面板排版与弱峰复位控件(本轮) + +> 背景(用户报告):① 时间轴偏移 / 系数 / Y 偏移的符号显示不符合要求; +> ② **按住**横向光标不动时边沿计数停在“统计中…”,松开鼠标才更新; +> ③ 改中心轴参数回车后**不移动鼠标**时统计停在部分值(数字后还有 `…`); +> ④ 浮动面板没有信号名长度上限,超长名字把面板顶得很长; +> ⑤ 多信号统计信息错位(没有排版),且只有“下降沿”的数字带 `…`; +> ⑥ 弱峰调整面板需要每行复位控件、上限输入框提示文案、并去掉滑块前多余的 `%` / `N` 标签。 + +#### 一、「调整」类数值的符号规则(`SignalPanel`) + +- `FormatSignedNumber`:**0 不再显示 `+`**(旧实现恒带符号 → `+0.00`); + Y 轴偏移默认值现在显示 `0.00`。 +- `FormatGainText`:非 0 正值带 `+`、负值带 `-`、0 不带符号。 + ⚠️ 仅用于**非编辑态**显示;进入编辑的初始文本仍用不带符号的纯数值(`FormatNumberText`), + 避免用户一进编辑框就看到 `+`。 +- 新增 `FormatOffsetTimeText(double)`:`Δt:` 后带正负号 —— + `Δt:+5s 60ms` / `Δt:-5s 60ms` / `Δt:0s`(`FormatDurationCompound` 内部取绝对值, + 符号由这里补)。打开面板、`CMD_OFFSET_TIME` 提交、`CMD_OFFSET_RESET` 三处统一走它。 + +#### 二、边沿统计推进:**按帧**预算 + 每状态**公平配额** + +- **根因(用户报告②③)**:预算按 `GetTickCount64()` 分桶,而系统时钟粒度约 15.6ms; + `RequestEdgeScanFrame` 的 `PostMessage` 自持重绘会把帧率拉到远高于它 ⇒ + **同一 tick 内连画多帧但完全不推进**。“按住不动”“回车后不动鼠标”因此停住。 + 修法:`Paint` 开头 `++m_edgeScanFrame; m_edgeScanBudget = kEdgeScanPairsPerFrame;` + (**按帧**重置),删除 `m_edgeScanTick`。 +- **每状态配额**(修“很多项停在统计中不更新”):`perState = max(kEdgeScanPairsPerStateMin, + kEdgeScanPairsPerFrame / share)`,`share = max(kEdgeScanFairShare = 6, m_edgeScans.size())`; + 每个状态记录 `pumpedFrame` / `pumpedPairs`,新帧开始时清零。 + ⚠️ **不能直接用 `size()` 分摊**:状态是**同一帧内逐个创建**的,第一个状态那一刻 `size() == 1` + 会拿走整额预算,同帧后面新建的状态全部被截断为 0 配额(表现为“首帧只有第一条信号推进”)。 +- 实测(合成 5 信号 × 200 万采样、全部同一网格):**首帧 5 个状态全部推进**; + **9 帧内全部扫完**(期间不注入任何鼠标消息);统计值与参考实现**逐位一致**; + 阈值变化(拖动横向光标)后就地重置,随后“停住不动”仍能扫完且精确。 +- 自持驱动断言:未完成时确实投递 `kMessagePlotDerivedReady` —— 这是 + “用户不移动鼠标也会持续推进”的**唯一**驱动源,任何“分帧推进”的新逻辑都要照做。 + +#### 三、浮动面板排版 + +- **信号名宽度上限** `kEdgePanelNameMaxWidth = 150`(逻辑像素):`EdgePanelWidth` 与绘制 + 都用 `min(实测宽度, 上限)` 并走 `UiEllipsize`(超长名字不再把面板顶长)。 + 实测:300 字符名字的面板宽度只比单字符名字宽 **44px**(上限 225px @scale1.5), + 不设上限则要多 **5100px**。 +- **统计信息两列等宽**(修“错位”):新增 `EdgeRisingText` / `EdgeFallingText` / + `EdgeCountsPending`;面板宽度按 **`2 × max(两列文本宽) + kEdgePanelStatColumnGap`** + 预留,绘制时两列各占 `(可用宽 − 间距) / 2` ⇒ 多条信号的“上升沿 / 下降沿” + **列起点固定、严格垂直对齐**(用户明确要求)。 + ⚠️ 绘制函数 `paintCounts` 与宽度计算必须用**同一套等宽假设**,否则较窄的那一列会被截断。 +- **两个数字后面都带 `…`**:旧实现只在整行末尾追加一次,视觉上只跟在“下降沿”的数字后; + 现在 rising / falling 各自在未完成时追加 ` …`。 + +#### 四、弱峰调整面板 + +- 每行滑块右侧新增**复位控件**(复用「系数/偏移」二级面板的还原图标绘制: + PlotView 内新增 `PaintPanelResetIcon`,与 `PopupMenu::PaintResetIcon` 同一套笔画): + 悬停高亮 + `IDC_HAND` + 一次性提示“点击恢复该参数的默认值”; + 点击把该行参数恢复默认(幅度滑块 → `amountPercent = 0`;迭代滑块 → `iterations = 1` + **且 `maxIterations` 回到初始上限 10**)。 + `EdgePanelLayout` 新增 `rowLevelResets` / `rowIterResets`(并进 `PanelSlotKey` 以保证 + 悬停高亮能触发重绘),两行按钮共用同一 X(上下对齐),面板宽度计入按钮列。 +- 「迭代次数上限」输入框的空内容提示改为 **“输入上限(当前N)”**(旧实现只显示一个数字)。 +- 滑块标签去掉多余的 `%` / `N`:`kWeakPeakLevelLabel = L"调整幅度"`、 + 新增 `kWeakPeakIterationsSliderLabel = L"迭代次数"`; + ⚠️ MEAN / CENTER 参数输入框的标签常量 `kWeakPeakIterationsLabel`(`迭代次数 N`)**保持不变**。 + +#### 五、验证(全部离屏,进程/线程均 `BELOW_NORMAL`,窗口 `SW_HIDE`,不注入真实鼠标/键盘) + +- 新增探针 `.preflight/perf/panelcheck_main.cpp`(合成 5 信号 × 200 万采样、同一网格, + 只写本地 stdout 的 ASCII):**46/46 通过** —— 覆盖 + 符号规则 10 条(全部走**真实菜单路径**:`OpenGainMenu` + 读 `PopupMenu` 条目)、 + 按帧推进 / 不饿死 / 精确性(与独立参考实现逐位比对)/ 自持驱动、 + 两列等宽预留(含未完成时两处 `…`)、名字上限、弱峰复位控件(存在 / 位置 / 对齐 / + 点击恢复默认值与上限)。 +- 既有 `verify_main.cpp`(本机版本)**47/47(另有 2 条已知过期断言)**: + `derived offsetTime kept (cannot be baked)`(§9.21 已推翻该规则)与 + `mouse in lane1 lower half -> target 2`(§9.21 已把让位判据改为“严格超过 ±0.5H”)。 + 本机 `.preflight` 是**本机自建**、未随远端同步 ⇒ 这两条是**旧规则断言**,与本轮改动无关。 +- `make release` 零警告。 + +--- + ## 10. 变更日志(本文件维护记录) | 日期 | 设备/模型 | 摘要 | @@ -1313,6 +1395,7 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( | 2026-10-14 | DeepSeek-V4.1-Flash(**起源设备**) | **修复“偶发的改一个参数、其它信号也重新统计”(详见 §9.25)**:根因是中心轴统计靠 `AcquireCoveringCurve`/`ScheduleCurveJob` 拿**整条**曲线 — 千万级信号一条 ≈103MB,多试几个参数值就把曲线缓存推到 768MB 上限 → `PruneCurveEntriesLocked` 从队首淘汰(往往是**其它信号**的曲线)→ 那些信号显示“统计中…”并在后台重新排队(**偶发**,取决于缓存里积了多少条目)。修法:`EdgeScanState` 新增 `localCurve/localBegin/localEnd/localValid`,扫描时**就地计算「当前扫描块 ± 滤波半径」**(半径 = `points*iterations`,窗口 ≥ 8 个块,成本计入扫描预算,扫完即释放)⇒ 与曲线缓存、与其它信号**彻底解耦**(实测统计时 `curve entries 0`);删除“曲线未就绪就把整 tick 预算清零”的门控(它会把同帧后面排队的其它统计**饿死**);`kEdgeScanStateLimit` **16→64** 且超限时优先淘汰「已扫完」的状态。验证:新增间歇性探针 `[AU]`(5 信号同网格、真实编辑路径连改 25 次、逐帧分类 CASE A/B/C)——修复前 baseline **0/10 完成**,修复后 **10/10 完成且 25 次编辑 CASE A/B/C 全 0**;新增「本地窗口 vs 整条曲线」12 组参数(points 5/17/64/300 × iterations 1/2/4)**逐位一致**断言;合计 **789/789 通过**,`make release` 零警告。 | | 2026-10-14 | DeepSeek-V4.1-Flash(**起源设备**) | **新标签页分组默认折起 + GX 徽标悬停不改光标 + 标题栏 RAM 改用「私有工作集」+ 边沿计数改为「逐块累积 + 每 tick 预算推进」(详见 §9.22)**。① `NewTab(bool collapseGroups)`(首个标签页 false、用户新建 true)+ `TabConfig::SetGroupsCollapsedByDefault/SetAllCollapsed`;`SyncToDataSet` 用该默认值初始化**新出现的**分组。② `QueryCursor` 的 GX 徽标分支由 `IDC_HAND` 改为 `nullptr`(悬停不改形状,仅拖拽时 `IDC_SIZENS`)。③ **RAM 口径修正**:原取 `WorkingSetSize`(总工作集,含映射 DLL/共享页与已释放未回收的驻留页)→ 改为 `PROCESS_MEMORY_COUNTERS_EX2::PrivateWorkingSetSize`(与任务管理器「内存」列一致;失败退回 `PrivateUsage` → `WorkingSetSize`)。实测加载后 117.55/109.96 MiB、关闭分组后 15.44/7.62 MiB —— **无泄漏,内存完整归还**。④ **边沿计数性能**:改前 25.8M 采样 ≈**115ms/条/帧**(5 条同网格 = 575ms/帧,用户报告"开启即卡顿、之后拖不动");改为按 `kEdgeBlockSamples=8192` 分块累积、每 tick 预算 `kEdgeScanPairsPerTick=1.2M`、区间用块和相加(区间不进键)、拖动横向光标期间不推进并显示上一个完整值 + `complete=false`(`EdgeCountsText` 显示"统计中…/…");实测首帧 1.7ms、单次最坏 1.9ms、拖动中 10 次查询 0.11ms、最终结果与参考实现逐位一致。⑤ **内存排查报告**(用户要求,第 4 项只排查未改动):弱峰调整工作缓冲实测 **≈34 B/采样**(极值表 `PeakExtremum` 24 B/极值为主导),**4 条工作线程并发 +412 MiB / 3M 采样** ⇒ 25.8M 采样峰值 ≈**3.5GB**(+数据 1.03GB +曲线缓存 ≤768MB ⇒ 4~5GB,与用户 4.4GB 观测吻合);**全仓无 `try`/`catch`** ⇒ 低内存时 `bad_alloc` 逸出线程入口 ⇒ `std::terminate()` 崩溃(无优雅降级机制)。验证:离屏 `uitest` **738/738 通过**(新增 `[AR]` 边沿计数预算/精确性/拖动期 O(1)、`[AS]` 标题栏 RAM 口径),`make release` 零警告。 | | 2026-10-14 | DeepSeek-V4.1-Flash(**起源设备**) | **边沿计数改用「绘制值」+ 派生信号烘入时间偏移 + 图例托底统一为 GX 徽标款 + 调整二级面板向上展开(详见 §9.21)**。① **边沿计数恒为 0**:`EdgeCountsFor`/`EdgeCenterCountsFor` 只比原始采样值,而阈值是屏幕上的值 → 「系数 50」的信号统计恒为 0;修法:判定值改为 `ApplyDrawTransform(style, values[SourceIndexFor(...)])`(中心轴两侧同套变换;水平镜像时曲线窗口也要先映射下标);缓存新增 **`styleKey`**(否则改完「调整」仍命中旧计数)。② **区间边界点被误计**:`EdgeIndexRange` 左端“多取一个采样点”的外扩**已删除**,收紧为“**两个端点都在区间内**才计入”(光标恰落在越过阈值的点 B 上时 B 不再被算作上升沿)。③ **`offsetTime` 也烘进派生采样值**(**推翻 §9.19 的“唯一例外”**):按 `time[i]-offsetTime` 取最近采样点重采样(单调时间轴用只前进的游标,O(n);越界置 NaN),新信号样式**全默认**;非单调时间轴退回到把 `offsetTime` 带在样式上。④ **图例托底 = GX 徽标同款**(同高 / 圆角 = 高度一半 / 同配色 / 水平内边距 = 胶囊半径),文本改用新增字体 **`uiTiny`(10 逻辑 px)**;`kLegendPlateRadius` 已删。⑤ **`PopupMenu::Open(..., expandUp=true)`**(锤点为**左下角**、向上展开,`m_buildExpandUp` 供 `RebuildLayout` 复用)+ 「系数/偏移」「镜像」面板锤在按钮左上角再上移 `kAdjustMenuGap`。验证:`.preflight/uitest` 新增 `[AR]`(五组断言全部与**独立参考实现**逐位比对,含 A/B:“按原始采样值算会得到 7/6 个边沿”);**并同步修正 harness 里两处旧规则断言**(`[AP]` 派生信号继承 → 默认样式 + 烘值;`[AN]` 图例带无托底 → 装饰矩形逐块且暴露列可见底层内容);合计 **722/722 通过**,`make release` 零警告。 | +| 2026-10-15 | DeepSeek-V4.1-Flash(**另一台设备**) | **「调整」数值符号规则 + 边沿统计按帧推进(含首帧公平分摊)+ 浮动面板排版与弱峰复位控件(详见 §9.26)**。① **符号**:Y 轴偏移 0 不再显示 `+0.00`;系数非 0 正值带 `+`(编辑态初始文本仍无符号);`Δt:` 后按正负带符号(`Δt:+5s 60ms` / `Δt:-5s 60ms` / `Δt:0s`)。② **边沿统计卡住**根因:预算按 `GetTickCount64()` 分桶(系统粒度 15.6ms)而 `PostMessage` 自持重绘把帧率拉高 ⇒ 同一 tick 内多帧完全不推进 → 改为 **`Paint` 开头按帧重置**;并给**每个状态独立每帧配额**(`share = max(6, m_edgeScans.size())`,避免同帧新建状态被第一个独占整额)→ 实测首帧 5 个状态全部推进、**9 帧内全部扫完且不注入任何鼠标消息**、与参考实现逐位一致。③ **面板排版**:信号名宽度上限 `kEdgePanelNameMaxWidth=150`(300 字符名字只多 44px,旧实现多 5100px);统计信息**两列等宽**(`2×max(列宽)+间距`)⇒ 多信号“上升沿/下降沿”列起点固定、垂直对齐;未完成时**两个数字后都带 `…`**(旧实现只跟在“下降沿”后)。④ **弱峰调整**:每行滑块右侧新增**复位控件**(复用还原图标,悬停高亮 + `IDC_HAND` + 提示;点击恢复幅度 0 / 迭代 1 且上限回 10);上限输入框提示改为“输入上限(当前N)”;滑块标签去掉多余的 `%` / `N`。验证:新增离屏探针 `panelcheck` **46/46 通过**(全部走真实菜单/点击路径),既有 `verify` 47/47(另 2 条为本机未同步的旧规则断言),`make release` 零警告。 | --- @@ -1449,6 +1532,35 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( 且超限时**优先淘汰「已扫完」的最旧状态**,绝不淘汰正在推进的。 上限太小会淘汰其它信号的已扫完状态 → 它们从头重扫(与第 39 条同一类症状)。 实测 10 个信号 × (普通+中心轴)= 20 个状态,原来的 16 就会踩到。 +43. ⚠️ **“分帧推进”的预算必须按帧重置,禁止按 `GetTickCount64()` 分桶**: + 系统时钟粒度约 15.6ms,而 `RequestEdgeScanFrame` 的 `PostMessage` 自持重绘会把帧率 + 拉到远高于它 ⇒ **同一 tick 内连画多帧但完全不推进**(用户报告“按住光标不动时统计 + 卡在统计中”“回车确认参数后不移动鼠标就停在部分值”)。 + ✓ 正确做法:`Paint` 开头 `++m_edgeScanFrame; m_edgeScanBudget = kEdgeScanPairsPerFrame;`。 +44. ⚠️ **每个累积状态必须各自持有每帧配额**:共享一个总额 + “从列表第一个扫起”会让 + 多条信号里只有第一条有进展、其余长期显示“统计中…”(用户报告“很多项都停在统计中不更新”)。 + ⚠️ 分摊时**不能用 `m_edgeScans.size()` 当分母**:状态是**同一帧内逐个创建**的, + 第一个状态那一刻 `size() == 1` 会独占整额,同帧后面新建的状态全部被截断为 0 配额 + (表现为“首帧只有第一条信号推进”)。用 `share = max(kEdgeScanFairShare, size())`。 +45. ⚠️ **「调整」类数值的符号规则**(用户明确要求,勿回退): + * **Y 轴偏移**:>0 带 `+`、<0 带 `-`、**0 不带符号**(旧实现恒带符号 → `+0.00`); + * **系数**:非 0 正值带 `+`、负值带 `-`、0 不带符号 —— 仅**非编辑态**; + 进入编辑的初始文本用**不带符号**的纯数值; + * **时间轴偏移**:`Δt:` 后按正负带符号(`Δt:+5s 60ms` / `Δt:-5s 60ms` / `Δt:0s`), + 统一走 `SignalPanel::FormatOffsetTimeText`(`FormatDurationCompound` 内部取绝对值)。 +46. ⚠️ **浮动面板的信号名必须限宽**(`kEdgePanelNameMaxWidth = 150` 逻辑像素):宽度计算与 + 绘制都用 `min(实测, 上限)` + `UiEllipsize`;否则超长信号名会把面板顶得极长 + (300 字符名实测多出 5100px,限宽后只多 44px)。 +47. ⚠️ **边沿统计的“上升沿 / 下降沿”必须两列等宽排版**(列起点固定 → 多条信号垂直对齐): + 面板宽度按 `2 × max(两列文本宽) + kEdgePanelStatColumnGap` 预留,绘制时两列各占 + `(可用宽 − 间距) / 2`;⚠️ **绘制与宽度计算必须用同一套等宽假设**,否则较窄的列会被截断。 + 未完成时 **rising 与 falling 两个数字后面都要带 `…`**(旧实现只在整行末尾追加一次)。 +48. ⚠️ **弱峰调整面板每行滑块右侧有复位控件**(与「系数/偏移」二级面板的还原图标同一套绘制): + 点击把该行恢复默认 —— 幅度滑块 → `amountPercent = 0`;迭代滑块 → `iterations = 1` + **且 `maxIterations` 回到初始上限 10**。`EdgePanelLayout` 的 `rowLevelResets` / + `rowIterResets` **必须进 `PanelSlotKey`**(否则悬停高亮不会重绘);两行按钮共用同一 X。 + ⚠️ 滑块标签不得带多余的 `%` / `N`(但 MEAN / CENTER 参数输入框的 `迭代次数 N` 保持不变); + 迭代次数上限输入框的空内容提示为 `输入上限(当前N)`。 ### 11.3 离屏验证方法学(本机偏好,强烈建议沿用)