From 34375481de2cdbe0f6acd02a6862071539b09acf Mon Sep 17 00:00:00 2001 From: "DeepSeek-V4.1-Flash" <> Date: Sat, 17 Oct 2026 00:56:19 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BD=91=E6=A0=BC=E5=BA=95=E8=A1=AC=E6=B0=B8?= =?UTF-8?q?=E4=B8=8D=E7=A9=BA=E7=99=BD=20+=20=E5=8A=A8=E7=94=BB=E6=B5=81?= =?UTF-8?q?=E7=95=85=E6=80=A7=E6=A0=B9=E6=B2=BB=20+=20=E5=85=89=E6=A0=87?= =?UTF-8?q?=E9=9A=8F=E9=A6=96=E4=B8=AA=E4=BF=A1=E5=8F=B7=E9=87=8D=E7=8E=B0?= =?UTF-8?q?=20+=20=E3=80=8C=E6=94=BE=E5=A4=A7=E8=87=AA=E5=8A=A8=E7=82=B9?= =?UTF-8?q?=E7=BA=BF=E3=80=8D=E6=96=B0=E5=88=A4=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、网格底衬消失(半屏/局部/全屏,且要等一会才刷新好)—— 两个机制叠加 1) BlitWideBuffer 对「跨度已过期」的图层原用 StretchBlt 放大占位:深度放大时把一小段像素放大 几十倍 ⇒ 整块网格变成一片无网格的模糊色块(用户看到的就是「网格叠底消失、没有任何内容」)。 修法:删除占位拉伸;对拿不到(完整)图层像素的网格【就地补画网格底衬】 (新增 PaintLaneGridBacking + m_renderGridOnly,复用 PaintLane 的网格部分,跳过波形/散点/ 派生曲线/Y 轴刻度/图例)。底衬代价极低,可每帧执行 ⇒ 网格永不空白/闪烁。 ⚠️ 顺序:先画底衬(GDI+)再 g.GetHDC() 做 BitBlt;只有全部网格都贴不出像素时才交给整幅重绘。 2) 待重画队列按下标顺序消费(一次 1 个)⇒ 上百个网格时可视网格要等上百帧。 修法:RenderPendingLaneRebuilds 里做稳定划分,【可视网格优先】(LaneVisuallyRelevant); 「结算」判据由 LanesAllReadyForView() 改为 LanesVisiblyReadyForView()。 二、过渡动画卡顿/不平顺/直接闪到终态 —— 三处根因 1) 「m_layerRebuildPending 非空就每帧请求再送一帧」:上百个网格时 700ms 内挤出 124 次重绘, 而 WM_TIMER 优先级低于投递消息 ⇒ 动画定时器被饿死(实测 frames=124 / timerTicks=1)。 修法:新增 AnyVisibleLanePending(),只用「还有没有可视网格待补」决定是否请求再送一帧; 离屏网格改为机会式预热(实测 frames=25 / timerTicks=16)。 2) SetTimer 的间隔会被量化到系统 tick(默认 15.6ms):请求 16ms 实际等到下一个 tick ⇒ 实测帧距 ~26ms(只有约 38fps)。修法:kScrollTimerPeriodMs 16 → 10(实测 ~15.5ms ≈ 64fps; 真实消息循环帧距 ~25ms → ~16ms)。实测映射:8→15.8 / 10→15.5 / 12→16.5 / 16→26.4 / 31→37.9。 ⚠️ timeBeginPeriod(1) 在本机无效(已 A/B 验证)。 3) 网格滚动联动 / 「回到最上方」每帧都改 LaneScroll(),而它原本参与「宽基底可用性」判据 (SceneGeometryHash)⇒ 每一帧都退化成整幅重绘。修法:SceneContentHash 新增 includeLaneScroll 形参, 宽基底可用性判据不计网格滚动量/各网格屏幕纵向位置(它们只是图层的贴图落点); 静态场景缓存的指纹仍然计入。实测网格滚动动画单帧 18.36ms → 1.19ms。 三、光标工具激活且没有任何信号绘制时,首个信号出现后按规则重新出现(用户要求) 新增 PlotView::ResetCursorPlacementOnFreshContent()(规则与 CycleCursorMode() 的「首次出现」同源: 单光标置于视图中心;双光标第二条紧邻第一条且落在可见范围内),由 Application::SynchronizeView 的「首次出现可见信号」分支在 FitAll() 之后调用。 实测:单光标 stale=4.60 → 新的视图中心 20.92;双光标 stale=(2.89,7.18) → (20.92,26.07)。 四、「放大自动点线」规则改为:任一小格内数据点少于 5 个即自动切换(用户重新定义) 新增 PlotView::AutoPointLineNeeded()(推翻此前「一屏内数据点 < N×4」):按 GridMinorCellCount() 切成横向小格,只要存在任何一个小格内有效数据点 < kAutoPointLineMinPointsPerCell(=5) 就切「点线」。 实现:先「总有效点 < N×5」鸽巢 O(1) 充分判定;单调时间轴逐小格两次二分取区间(单格累计到 5 个即早退, 另给 kAutoPointLineCellScanCap=512 封顶);非单调时间轴一次有界扫描分桶。保留「视界内至少一个可见点」保护。 ⚠️ 光标吸附仍用旧的整屏判据 SignalSnapEnabled(用户明确要求「其它规则不变」)。 验证(全部离屏,不占用鼠标/键盘/屏幕) - 新增 .preflight/perf/gridanim_main.cpp(gridanim.exe)55/55: [A] 缩放/适应视图/「预热未完成再滚动」等每个用例逐帧断言「每个可视网格都有网格底衬 (有竖直锐边、无模糊占位、无未绘制带)」;[B] 直接驱动 + 真实消息循环两条路径统计每帧耗时、 帧距均值/最大与进度单调性;[C] 光标重现;[D] 自动点线新判据(稠密不切、稀疏切换、恢复原样式)。 - 既有回归:verify 49/49、panelanim 156/156、datacheck 69/69、edgecheck 46/46、errcheck 19/19。 - make release 零错误零警告(使用 TARGET_FILE_NAME=TargetCheck 以避免与用户正在运行的实例冲突)。 文档:Documents/agent_mem.md 新增 §9.42、§11.2 第 117–124 条与变更日志一行。 --- .../Application/Src/ApplicationCommands.cpp | 4 + .../Application/Src/ApplicationInternal.hpp | 11 +- Components/PlotView/Inc/PlotView.hpp | 103 ++++- Components/PlotView/Src/PlotViewFeatures.cpp | 170 ++++++- Components/PlotView/Src/PlotViewInternal.hpp | 22 + Components/PlotView/Src/PlotViewPaint.cpp | 417 +++++++++++------- Documents/agent_mem.md | 143 ++++++ 7 files changed, 702 insertions(+), 168 deletions(-) diff --git a/Components/Application/Src/ApplicationCommands.cpp b/Components/Application/Src/ApplicationCommands.cpp index 601e633..9b18754 100644 --- a/Components/Application/Src/ApplicationCommands.cpp +++ b/Components/Application/Src/ApplicationCommands.cpp @@ -120,6 +120,10 @@ namespace jdi // 首次出现可见信号时做一次自适应。单点信号(时间/幅值范围退化成 0)由 // `FitAll` 内部按"单值散点"的规则处理(居中 + 一大格 1s),这里不再分开判断。 m_plot.FitAll(); + // 用户 2026-10-17 要求:光标工具处于激活状态(单 / 双光标)而之前**没有任何信号绘制**时, + // 第一个信号出现后光标要按“光标出现的规则”重新出现一次,而不是停在旧的残留位置。 + // 规则与 `CycleCursorMode()` 首次启用完全同源;光标工具未激活时本调用无副作用。 + m_plot.ResetCursorPlacementOnFreshContent(); } const bool laneCountChanged = (laneCount != previousLaneCount); diff --git a/Components/Application/Src/ApplicationInternal.hpp b/Components/Application/Src/ApplicationInternal.hpp index ed6f182..e088992 100644 --- a/Components/Application/Src/ApplicationInternal.hpp +++ b/Components/Application/Src/ApplicationInternal.hpp @@ -74,7 +74,16 @@ namespace jdi * 按经过时间推进,这里只负责按帧驱动它(约 60fps)。 */ inline const UINT_PTR kScrollTimerId = 3; - inline const UINT kScrollTimerPeriodMs = 16; + /** + * @brief 信号 / 绘图区动画的定时器周期(毫秒)。 + * + * ⚠️⚠️ **必须是「不超过一个系统 tick」的值**:`SetTimer` 的间隔会被量化到系统 tick + *(默认 15.6ms)的整数倍上,因此请求 16ms 实际会等到**下一个** tick ⇒ 实测帧距 + * **≈ 26ms(只有约 38fps)**,动画明显卡顿(用户 2026-10-17 报告:所有过渡动画都不平顺)。 + * 请求 10ms 时量化结果正好是一个 tick ⇒ 实测帧距 **≈ 15.5ms(约 64fps)**, + * 回到“约 60fps 平滑过渡”的设计目标。 + */ + inline const UINT kScrollTimerPeriodMs = 10; /** * @brief 查询本进程当前占用的内存(**私有工作集**,与任务管理器"内存"列同口径)。 diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index ec74371..c928693 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -310,11 +310,12 @@ namespace jdi void ReapplyGlobalAutoFitIfEnabled(); /** - * @brief 依据「光标吸附判定」自动启用/恢复「点线」绘制方式。 + * @brief 依据「每小格数据点数」自动启用/恢复「点线」绘制方式(即“放大自动点线”)。 * - * 用户要求(2026-10-15):**仅在满足光标吸附判定的信号上**生效 —— - * 即“一屏内数据点少于 N×4”(见 `SignalSnapEnabled`)时自动切到「点线」提示 - * 用户数据点的位置;一旦不再满足(适应视图、或把视界从稀疏区移到稠密区) + * 用户 2026-10-17 **重新定义判据**(推翻此前的“一屏内数据点少于 N×4”): + * 按**缩放后的横向时间轴网格**把小格切出来,**只要存在任何一个小格内的数据点数 + * 少于 `kAutoPointLineMinPointsPerCell`(=5)** 就自动切到「点线」提示用户数据点的位置 + *(见 `AutoPointLineNeeded`);一旦不再满足(适应视图、或把视界从稀疏区移到稠密区) * 自动恢复**原来的**绘制方式。 * * ⚠️ 自动启用期间若用户**手动**改了绘制方式(`SignalStyle::autoScatterUserOverride` @@ -354,6 +355,23 @@ namespace jdi */ bool SignalSnapEnabled(const TimeAxis& axis, const SignalData& signal, double timeShift) const; + /** + * @brief 判断某信号在当前视界下是否需要**自动切换到「点线」**(放大自动点线)。 + * + * 用户 2026-10-17 **重新定义**的判据(推翻此前的“一屏内数据点 < N×4”): + * 按缩放后的**横向时间轴网格**把小格切出来,**只要存在任何一个小格内的数据点数 + * 少于 `kAutoPointLineMinPointsPerCell`(=5)**,就返回 true。 + * + * 其余规则不变:不再满足时恢复原绘制方式;用户手动改过绘制方式时不恢复 + *(见 `UpdateAutoScatterStyles`)。光标吸附**仍用** `SignalSnapEnabled` 的整屏判据。 + * + * @param [IN] axis 该信号使用的时间轴。 + * @param [IN] signal 目标信号。 + * @param [IN] timeShift 该信号的显示时间偏移(`offsetTime`)。 + * @return 需要自动切换到「点线」返回 true。 + */ + bool AutoPointLineNeeded(const TimeAxis& axis, const SignalData& signal, double timeShift) const; + /** * @brief 判断某信号在给定可见下标区间内是否存在**落在视界内**的有效采样点。 * @param [IN] axis 该信号使用的时间轴。 @@ -609,6 +627,20 @@ namespace jdi */ bool CycleCursorMode(); + /** + * @brief 当绘图区从「没有任何可见信号」变为「有信号」时,按当前光标模式**重新按规则摆好光标**。 + * + * 用户 2026-10-17 要求:光标工具处于激活状态(单 / 双光标)且当前**没有任何信号被勾选绘制**时, + * 下一个信号首次出现时应按“光标出现的规则”重新出现一次,而不是停留在之前的 + * 残留位置(那时的视界与数据都是空的,残留位置已无意义)。 + * + * 规则与 `CycleCursorMode()` 的“首次出现”完全同源:单光标置于视图中心; + * 双光标的第一条置于视图中心、第二条紧邻第一条并保证落在可见范围内。 + * + * @return 确实重新摆放了光标返回 true(光标工具未激活时返回 false)。 + */ + bool ResetCursorPlacementOnFreshContent(); + /** * @brief 判断时间光标是否已启用(单/双光标均为启用)。 * @return 启用返回 true。 @@ -2574,10 +2606,18 @@ namespace jdi * “宽基底可用性”判据,每一帧都会退化成整幅重绘(质量下降 + 卡顿)。 * 因此宽基底判据用 `includeLaneRange = false` 的几何指纹, * 幅值变化改由**逐网格图层自己的指纹**处理(只有该网格需要重画)。 - * @return 指纹值。 - */ - std::uint64_t SceneContentHash(bool includeStyles = true, bool includeDerived = true, - bool includeLaneRange = true) const; + * @param [IN] includeLaneScroll 是否把**网格纵向滚动量**与各网格的屏幕纵向位置 + * (`LaneScroll()` / `lane.rect.Y`)计入指纹。 + * ⚠️ 静态场景缓存**必须**计入(缓存位图的内容取决于它们); + * 但“宽基底可用性”判据**不能**计入:网格滚动只改变各网格的**贴图落点**, + * 图层内容完全不变(`BlitWideBuffer` 本来就按 `lane.rect` 逐网格贴图)—— + * 若计入,则“网格滚动到可视区 / 回到最上方”的每一帧都会退化成整幅重绘 + * (用户 2026-10-17 报告:滚动动画卡顿)。 + * @return 指纹值。 + */ + std::uint64_t SceneContentHash(bool includeStyles = true, bool includeDerived = true, + bool includeLaneRange = true, + bool includeLaneScroll = true) const; /** * @brief 场景内容中**不含逐信号外观样式**的指纹。 @@ -3033,7 +3073,20 @@ namespace jdi * @param [IN,OUT] g 绘图上下文。 * @return 至少贴了若干像素返回 true。 */ - bool BlitWideBuffer(gd::Graphics& g) const; + bool BlitWideBuffer(gd::Graphics& g, const std::vector& timeTicks); + + /** + * @brief 为单个网格绘制**底衬**(网格底色 + 横向/竖向网格线 + 网格分界线)。 + * + * 不含波形、派生预览曲线、Y 轴刻度与图例 —— 这些要么代价高、要么属于装饰层。 + * 用途:宽基底快路径中本帧拿不到图层像素的网格用它兜底,保证**网格底衬这种 + * 廉价绘制永远不会空白/闪烁**(用户 2026-10-17 明确要求)。 + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] laneIndex 网格下标。 + * @param [IN] timeTicks 当前视界的时间主刻度。 + * @return 绘制了内容返回 true。 + */ + bool PaintLaneGridBacking(gd::Graphics& g, int laneIndex, const std::vector& timeTicks); /** * @brief 全部图层是否**结构可用**(数量 / 位图尺寸 / 视界跨度都与当前状态一致)。 * @@ -3058,6 +3111,29 @@ namespace jdi bool LaneLayerReady(int laneIndex) const; /** @brief 全部网格图层是否都完全就绪(用于判断能否做"结算"整幅精确帧)。 */ bool LanesAllReadyForView() const; + /** + * @brief 该网格是否**在屏幕可视区内**(其纵向带与绘图区相交)。 + * + * ⚠️ 只有可视网格的图层才影响画面。整批重建时必须**优先补画可视网格**, + * 否则(上百个网格时)队列按下标顺序推进,可视网格可能要等上百帧才轮到, + * 期间画面一直停留在旧的兜底状态(用户报告:需要等一会才正确加载)。 + * @param [IN] laneIndex 网格下标。 + * @return 可视返回 true。 + */ + bool LaneVisuallyRelevant(int laneIndex) const; + /** @brief 全部**可视**网格图层是否都已就绪(用于判断能否做结算的整幅精确帧)。 */ + bool LanesVisiblyReadyForView() const; + /** + * @brief 待重画队列里是否还有**可视**网格。 + * + * ⚠️⚠️ 用它(而不是“队列非空”)来决定“是否要请求宿主再送一帧”: + * 上百个网格时队列里会长期残留大量**离屏**网格,若按“队列非空”每帧都请求重绘, + * 就会形成持续 1 秒以上、每帧一次的高频重绘(用户在“显示全部信号 → 折起分组” + * 时正好会撞上),把动画的 `WM_TIMER`(优先级低于投递消息)饿死 ⇒ 动画卡住 / 闪烁。 + * 离屏网格改为**机会式**预热:任何一帧(悬停、动画、滚动…)顺手补一个即可。 + * @return 仍有可视网格待重画返回 true。 + */ + bool AnyVisibleLanePending() const; /** @brief 把某网格排进"待重画"队列(去重;视界/跨度后续变化时会在渲染时再次校验)。 */ void QueueLaneRebuild(int laneIndex); /** @@ -3128,6 +3204,15 @@ namespace jdi bool m_renderWide = false; /**< 正在渲染信号图层(重入 `PaintLane` 时跳过装饰)。 */ + /** + * @brief 本次 `PaintLane` 只绘制「网格底衬」(底色 + 网格线 + 网格分界线)。 + * + * 用于宽基底快路径的兜底:本帧拿不到图层像素的网格必须**立刻**画出正确的网格底衬, + * 否则该网格会显示成一块空白(用户 2026-10-17 报告「网格叠底消失」)。 + * 底色/网格线的绘制代价极低,因此这条兜底路径可以**每帧**执行。 + */ + bool m_renderGridOnly = false; + /** * @brief 渲染信号图层时,横向网格线(幅值刻度)的「参考跨度 / 参考高度」。 * diff --git a/Components/PlotView/Src/PlotViewFeatures.cpp b/Components/PlotView/Src/PlotViewFeatures.cpp index a144a2c..4433562 100644 --- a/Components/PlotView/Src/PlotViewFeatures.cpp +++ b/Components/PlotView/Src/PlotViewFeatures.cpp @@ -499,6 +499,41 @@ namespace jdi return true; } + bool PlotView::ResetCursorPlacementOnFreshContent() + { + if (m_tab == nullptr || !Tab().CursorEnabled() || m_plotRect.Width <= 0.0f) + { + return false; + } + // 与 `CycleCursorMode()` 的“光标首次出现”规则完全同源: + // 单光标置于视图中心;双光标的第二条紧邻第一条、且保证落在可见时间范围内。 + const double low = Tab().ViewMin(); + const double high = Tab().ViewMax(); + const double span = (high > low) ? (high - low) : 1.0; + const double center = (low + high) * 0.5; + Tab().CursorTime() = center; + if (Tab().CursorMode() == PlotCursorMode::DOUBLE) + { + const double offset = span * 0.12; + double second = center + offset; + if (second > high - span * 0.02) + { + second = center - offset; + } + if (second < low + span * 0.02 || second > high - span * 0.02) + { + second = low + span * 0.5; + } + Tab().CursorTimeAlt() = second; + ClampCursorToView(2); + } + ClampCursorToView(1); + m_cursorHover = true; + m_hoverCursor = 1; + m_activeCursor = 1; + return true; + } + void PlotView::SetHint(const std::wstring& text) { m_hintText = text; @@ -769,6 +804,132 @@ namespace jdi return seen > 0; } + bool PlotView::AutoPointLineNeeded(const TimeAxis& axis, const SignalData& signal, + double timeShift) const + { + const int cells = GridMinorCellCount(); + if (cells <= 0 || signal.finiteCount == 0) + { + return false; + } + const std::size_t count = std::min(axis.time.size(), signal.values.size()); + if (count == 0) + { + return false; + } + std::size_t begin = 0; + std::size_t end = 0; + VisibleIndexRange(axis, count, begin, end, timeShift); + if (end <= begin) + { + return false; + } + // 视界内一个可见数据点都没有:既没有要提示的位置,也不该因为“小格全空”而无意义地抖动。 + if (!HasVisibleSample(axis, signal, begin, end, timeShift)) + { + return false; + } + + const double viewMin = Tab().ViewMin() - timeShift; + const double viewMax = Tab().ViewMax() - timeShift; + const double span = viewMax - viewMin; + if (!(span > 0.0)) + { + return false; + } + // 鸽巢原理:有效点总数都不足“每格 5 个” ⇒ 必然存在一个稀疏小格(O(1) 充分判定)。 + const std::size_t need = static_cast(cells) * + static_cast(kAutoPointLineMinPointsPerCell); + if (signal.finiteCount < need) + { + return true; + } + const double cellSpan = span / static_cast(cells); + if (!(cellSpan > 0.0) || !std::isfinite(cellSpan)) + { + return false; + } + + // 时间轴单调 ⇒ 每个小格用两次二分取出它的样本区间(代价与采样数无关)。 + if (axis.monotonic) + { + for (int cell = 0; cell < cells; ++cell) + { + const double cellMin = viewMin + cellSpan * static_cast(cell); + const double cellMax = + (cell + 1 == cells) + ? viewMax + : (viewMin + cellSpan * static_cast(cell + 1)); + const std::size_t lo = static_cast( + std::lower_bound(axis.time.begin(), axis.time.end(), cellMin) - axis.time.begin()); + const std::size_t hi = static_cast( + std::lower_bound(axis.time.begin(), axis.time.end(), cellMax) - axis.time.begin()); + const std::size_t cap = std::min(hi, lo + kAutoPointLineCellScanCap); + std::size_t valid = 0; + for (std::size_t index = lo; index < cap; ++index) + { + if (!std::isfinite(signal.values[index])) + { + continue; + } + if (++valid >= static_cast(kAutoPointLineMinPointsPerCell)) + { + break; + } + } + if (valid < static_cast(kAutoPointLineMinPointsPerCell)) + { + return true; // 该小格稀疏 ⇒ 自动切「点线」 + } + } + return false; + } + + // 非单调时间轴:一次有界扫描,按小格分桶计数。 + std::vector buckets(static_cast(cells), 0); + const std::size_t scanLimit = + kAutoPointLineCellScanCap * static_cast(cells); + std::size_t scanned = 0; + for (std::size_t index = begin; index < end; ++index) + { + if (++scanned > scanLimit) + { + break; + } + if (!std::isfinite(signal.values[index])) + { + continue; + } + const double time = axis.time[index]; + if (!std::isfinite(time) || time < viewMin || time > viewMax) + { + continue; + } + int cell = static_cast((time - viewMin) / cellSpan); + if (cell < 0) + { + cell = 0; + } + if (cell >= cells) + { + cell = cells - 1; + } + const std::size_t slot = static_cast(cell); + if (buckets[slot] < kAutoPointLineMinPointsPerCell) + { + ++buckets[slot]; + } + } + for (int cell = 0; cell < cells; ++cell) + { + if (buckets[static_cast(cell)] < kAutoPointLineMinPointsPerCell) + { + return true; + } + } + return false; + } + bool PlotView::NearestSnapSample(const TimeAxis& axis, const SignalData& signal, double time, double timeShift, double& candidate) const { @@ -2941,11 +3102,12 @@ namespace jdi style->autoScatterUserOverride = true; } - // 判定与**光标吸附完全同源**(用户 2026-10-15 明确要求): - // 只有“一屏内数据点少于 N×4”的信号才自动切「点线」, + // 判定改为**用户 2026-10-17 重新定义的新规则**:按缩放后的横向时间轴网格划分小格, + // **只要存在任何一个小格内的数据点少于 5 个**就自动切「点线」(局部稀疏即提示); // 不再满足时(适应视图、或把视界移到信号的稠密区)自动恢复原样式。 - // ⚠️ 可见性同样按**显示时间**判断(含 `offsetTime`),与吸附保持一致。 - if (SignalSnapEnabled(*axis, *data, style->offsetTime)) + // ⚠️ 可见性同样按**显示时间**判断(含 `offsetTime`)。 + // ⚠️ 「光标吸附」仍用整屏判据(`SignalSnapEnabled`),用户明确要求“其它规则不变”。 + if (AutoPointLineNeeded(*axis, *data, style->offsetTime)) { if (!style->autoScatterActive) { diff --git a/Components/PlotView/Src/PlotViewInternal.hpp b/Components/PlotView/Src/PlotViewInternal.hpp index 10d487a..9f78681 100644 --- a/Components/PlotView/Src/PlotViewInternal.hpp +++ b/Components/PlotView/Src/PlotViewInternal.hpp @@ -210,6 +210,28 @@ namespace jdi */ inline const std::size_t kSnapScanWindowFactor = 64; + /** + * @brief 「放大后自动切点线」的新判据:**单个横向网格小格**内的最少数据点数。 + * + * 用户 2026-10-17 重新定义(**推翻**此前的“一屏内数据点 < N×4”): + * 按缩放后的横向时间轴网格划分小格,**只要存在任何一个小格内的数据点少于 5 个**, + * 就自动切到「点线」(其余规则不变:不再满足时自动恢复,用户手动改过则不恢复)。 + * 这比旧判据更贴近用户意图:**局部**稀疏(例如数据只覆盖视界的一部分、或中间有空洞) + * 也会提示出数据点的位置,而不是等整屏都变稀疏。 + * + * ⚠️ 该判据**只用于**「放大自动切点线」(`UpdateAutoScatterStyles`); + * 光标吸附仍用 `kSnapVisibleCellsPerPoint` 的整屏判据(用户明确要求“其它规则不变”)。 + */ + inline const int kAutoPointLineMinPointsPerCell = 5; + + /** + * @brief 单个小格内“判定为稠密”所需的扫描上限(样本数)。 + * + * 新判据需要逐小格数点,但只需知道“是否 ≥ `kAutoPointLineMinPointsPerCell`” + * ⇒ 累计到该值即早退;这里再给一个上限,保证含大量 NaN 的极端小格也不会长循环。 + */ + inline const std::size_t kAutoPointLineCellScanCap = 512; + /** * @brief 「所有可见数据点都是单值信号、且时刻完全相同」时,时间轴**一个大格**对应的秒数。 * diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 4d18fab..9570249 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -822,7 +822,10 @@ namespace jdi // * **全部图层就绪**:还有网格没画好时不能结算,否则结算帧之后的格子又是旧的。 // * 记录已消费本 key,避免下一帧重复结算。 const bool viewStopped = (m_wideLastValid && m_wideLastKey == wideKey); - if (viewStopped && m_dragMode == PlotDragMode::NONE && LanesAllReadyForView()) + // ⚠️ 结算只用**可视网格**的就绪状态:上百个网格时若要求全部离屏图层都就绪, + // 队列按下标顺序推进会让“结算”拖到上百帧之后(期间既没有静态场景缓存可用、 + // 又每帧都在做无谓的图层重建),而离屏图层的就绪与否与画面无关。 + if (viewStopped && m_dragMode == PlotDragMode::NONE && LanesVisiblyReadyForView()) { m_wideLastValid = false; // 结算:落到下方整幅重绘(非拖拽 → 全质量) } @@ -852,24 +855,31 @@ namespace jdi // 且**仍按预算逐个补**(不用一次把全部过期网格塞进同一帧):实测同一台机器上 4 线程 // 真并行(3.98x),但**图层光栅化(GDI+)本身会被串行化**,并行重建 5 个网格 ≈ 逐个重建 //(1.03x)→ 一次性补齐只会把一帧从 ~10ms 抬到 ~31ms(更容易掉帧)。 - // 因此保持"视界停后**每帧补一个网格**"(`RenderPendingLaneRebuilds` 在 `budget>1` 时才走并行)。 - const bool morePending = - viewStopped ? RenderPendingLaneRebuilds(1) : !m_layerRebuildPending.empty(); + // 仍按预算逐个补:不用一次把全部过期网格塞进同一帧(并行重建≈逐个重建)。 + // ⚠️ **“是否再来一帧”只看还有没有【可视】网格待补**:上百个离屏网格若每帧都 + // 请求重绘,会形成持续一秒以上的高频重绘,把动画定时器饿死(见 `AnyVisibleLanePending`)。 + if (viewStopped) + { + RenderPendingLaneRebuilds(1); + } + const bool morePending = AnyVisibleLanePending(); // 绘图区底色(覆盖时间轴与侧边留白区域)。 UiFillRect(g, m_area, m_theme->plotBg); - // 贴基底(绘图区:网格 + 波形)。图层没覆盖到的部分先留空,后续帧补齐。 - if (BlitWideBuffer(g)) + // 时间轴(宽缓冲不含它,前台按当前视图绘制)—— 必须在贴图**之前**算好: + // `BlitWideBuffer` 在图层像素缺失时要就地补画网格底衬,而底衬的竖向网格线 + // 与时间轴共用同一套主刻度。 + const double wideViewMin = Tab().ViewMin(); + const double wideViewMax = Tab().ViewMax(); + const double wideSpanNow = wideViewMax - wideViewMin; + const double wideTimeStep = + NiceStep((wideSpanNow > 0.0) ? wideSpanNow : 1.0, kTimeTickTarget); + std::vector wideTimeTicks; + BuildTicks(wideViewMin, wideViewMax, wideTimeStep, wideTimeTicks); + // 贴基底(绘图区:网格 + 波形)。拿不到图层像素的网格由底衬兜底,永不空白。 + if (BlitWideBuffer(g, wideTimeTicks)) { m_wideLastKey = wideKey; m_wideLastValid = true; - // 时间轴:宽缓冲不含它,前台按当前视图绘制。 - const double wideViewMin = Tab().ViewMin(); - const double wideViewMax = Tab().ViewMax(); - const double wideSpanNow = wideViewMax - wideViewMin; - const double wideTimeStep = - NiceStep((wideSpanNow > 0.0) ? wideSpanNow : 1.0, kTimeTickTarget); - std::vector wideTimeTicks; - BuildTicks(wideViewMin, wideViewMax, wideTimeStep, wideTimeTicks); PaintTimeAxis(g, wideTimeTicks, wideTimeStep); // 网格分界线:图层里**不含**它(它锚定在网格的屏幕位置,会被纵向偏移贴图带走), // 因此在这里补画一次(与整幅重绘同一套颜色与线宽)。 @@ -1253,7 +1263,7 @@ namespace jdi } std::uint64_t PlotView::SceneContentHash(bool includeStyles, bool includeDerived, - bool includeLaneRange) const + bool includeLaneRange, bool includeLaneScroll) const { // FNV-1a:把“视图几何之外的一切影响绘图区像素的内容”折叠成一个指纹。 // 这样颜色/线宽/透明度/样式/重命名/信号增删等变化都会自动让缓存失效, @@ -1287,10 +1297,17 @@ namespace jdi mixFloat(m_plotRect.Height); mixFloat(m_laneContentHeight); mix(m_scrollBarVisible ? 1ull : 0ull); - // ⚠️ 网格纵向滚动量必须参与指纹:它只改变各网格的绘制位置。 + // ⚠️ 网格纵向滚动量必须参与**静态场景缓存**的指纹:它只改变各网格的绘制位置。 // 若不参与,平移宽缓冲区 / 静态场景缓存会把旧滚动位置的像素当成当前内容贴回, // 表现为“网格位置错位 / 拖影 / 残留”等错误元素。 - mixFloat(Tab().LaneScroll()); + // ⚠️ 但它是**纯屏幕落点**(不影响任何网格内容),因此“宽基底可用性”判据 + //(`SceneGeometryHash`)把它排除:网格滚动时 `BlitWideBuffer` 本来就按 `lane.rect` + // 逐网格贴图,内容完全复用 —— 这样“网格滚动到可视区 / 回到最上方”这类 + // **每帧都在改滚动量的动画**才不会退化成每帧整幅重绘(用户 2026-10-17 报告卡顿)。 + if (includeLaneScroll) + { + mixFloat(Tab().LaneScroll()); + } // 选中信号会改变图例条目的底色与文字颜色(绘制在缓存的静态场景里), // 因此必须参与指纹:否则在左侧信号面板点选后图例不会高亮联动。 @@ -1304,7 +1321,11 @@ namespace jdi for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) { const LaneVisual& visual = m_lanes[laneIndex]; - mixFloat(visual.rect.Y); + // ⚠️ 各网格的屏幕纵向位置同样只是“贴图落点”(见 `includeLaneScroll` 的说明)。 + if (includeLaneScroll) + { + mixFloat(visual.rect.Y); + } mixFloat(visual.rect.Height); // ⚠️ 幅值范围只在 `includeLaneRange` 时计入:拖拽平移时的纵向分量会每帧小幅改动它, // 若参与“宽基底可用性”判据,每一帧都会退化成整幅重绘(质量下降 + 卡顿)。 @@ -1395,8 +1416,12 @@ namespace jdi std::uint64_t PlotView::SceneGeometryHash() const { // 外观样式不参与;各网格的幅值范围也不参与(那由逐网格图层指纹负责, - // 这样拖拽时的纵向分量不会把整幅重绘的快路径打掉)。 - return SceneContentHash(false, true, false); + // 这样拖拽时的纵向分量不会把整幅重绘的快路径打掉); + // ⚠️ 网格纵向滚动量 / 各网格屏幕纵向位置也不参与:那只是图层的**贴图落点**, + // 不影响任何网格的像素内容(`BlitWideBuffer` 按 `lane.rect` 逐网格贴图), + // 因此滚动网格时可以继续复用宽基底(否则“滚动到可视区 / 回到最上方”的 + // 每一帧都会退化成整幅重绘 —— 用户 2026-10-17 报告的滚动动画卡顿)。 + return SceneContentHash(false, true, false, false); } std::uint64_t PlotView::SceneDecorHash() const @@ -2415,6 +2440,57 @@ namespace jdi return true; } + bool PlotView::LaneVisuallyRelevant(int laneIndex) const + { + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + return false; + } + if (!(m_plotRect.Height > 0.0f)) + { + return false; + } + const gd::RectF& rect = m_lanes[static_cast(laneIndex)].rect; + // 用一个网格高的余量:即将滚入视区的网格也算相关,避免滚动时出现一帧空档。 + const float margin = std::max(rect.Height, 0.0f); + return (rect.Y + rect.Height + margin > m_plotRect.Y) && + (rect.Y - margin < m_plotRect.Y + m_plotRect.Height); + } + + bool PlotView::LanesVisiblyReadyForView() const + { + if (m_lanes.empty()) + { + return false; + } + bool anyRelevant = false; + for (std::size_t index = 0; index < m_lanes.size(); ++index) + { + if (!LaneVisuallyRelevant(static_cast(index))) + { + continue; + } + anyRelevant = true; + if (!LaneLayerReady(static_cast(index))) + { + return false; + } + } + return anyRelevant; + } + + bool PlotView::AnyVisibleLanePending() const + { + for (const int laneIndex : m_layerRebuildPending) + { + if (LaneVisuallyRelevant(laneIndex)) + { + return true; + } + } + return false; + } + void PlotView::QueueLaneRebuild(int laneIndex) { if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) @@ -2437,6 +2513,12 @@ namespace jdi { return !m_layerRebuildPending.empty(); } + // ⚠️ **优先补画可视网格**:队列是按下标顺序登记的,若严格 FIFO,用户正在看的那几个 + // 网格(往往位于列表下半部)可能要等上百帧才轮到,期间画面一直停留在兜底状态 + //(用户报告:缩放 / 折起后“需要等一会才正确加载”)。 + // 做一次**稳定划分**:可视网格排到队首,其余保持原有相对顺序。 + std::stable_partition(m_layerRebuildPending.begin(), m_layerRebuildPending.end(), + [this](int laneIndex) { return LaneVisuallyRelevant(laneIndex); }); const double defaultWideMin = LayerDefaultWideMin(); // 本批要重画的网格:`budget` 足够大时一次取完(视界已停时调用方会传很大的预算)。 const int available = static_cast(m_layerRebuildPending.size()); @@ -2906,7 +2988,10 @@ namespace jdi // 登记"跨度 / 内容不一致"的网格。空闲时视界未变 ⇒ `RollWideBuffer` 只登记、不做滚动, // 也不会走"位移超出画布 → 立刻整块重画"那条分支。 RollWideBuffer(); - const bool morePending = RenderPendingLaneRebuilds(1); + RenderPendingLaneRebuilds(1); + // ⚠️ 只在还有**可视**网格待补时才请求再送一帧:离屏网格的数量可能上百, + // 若按“队列非空”每帧请求重绘,会持续一秒以上地高频重绘并饿死动画(见 `AnyVisibleLanePending`)。 + const bool morePending = AnyVisibleLanePending(); if (morePending) { const HWND host = m_host; @@ -3082,7 +3167,7 @@ namespace jdi } } - bool PlotView::BlitWideBuffer(gd::Graphics& g) const + bool PlotView::BlitWideBuffer(gd::Graphics& g, const std::vector& timeTicks) { const float plotWidth = m_plotRect.Width; const double span = Tab().ViewMax() - Tab().ViewMin(); @@ -3095,146 +3180,164 @@ namespace jdi const int bitmapWidth = width + 2 * margin; const double scale = static_cast(width) / span; - std::lock_guard lock(m_layerMutex); - if (m_layerFront.size() != m_lanes.size()) + /** 一次图层贴图(所有参数在释放图层锁之前算好,避免持锁期间做 GDI 绘制)。 */ + struct LayerBlit { + HDC dc = nullptr; + int srcX = 0; + int srcY = 0; + int destX = 0; + int destY = 0; + int width = 0; + int height = 0; + }; + + std::vector blits; + std::vector backing; // 本帧拿不到(完整)图层像素的网格 + blits.reserve(m_lanes.size()); + backing.reserve(m_lanes.size()); + { + std::lock_guard lock(m_layerMutex); + if (m_layerFront.size() != m_lanes.size()) + { + return false; + } + for (std::size_t index = 0; index < m_layerFront.size(); ++index) + { + const LaneLayer& layer = m_layerFront[index]; + const gd::RectF& lane = m_lanes[index].rect; + const float top = std::max(lane.Y, m_plotRect.Y); + const float bottom = std::min(lane.Y + lane.Height, m_plotRect.Y + m_plotRect.Height); + if (bottom <= top) + { + continue; // 网格整体移出可视区 + } + const int bandHeight = static_cast(std::lround(bottom - top)); + // 纵向映射是否仍然可用:图层高度必须等于"当前网格高 + 上下预渲染余量", + // 且时间跨度必须与当前视界一致(跨度不同则像素的时间映射已经不对)。 + const int expectBitmapHeight = + static_cast(std::ceil(lane.Height)) + 2 * LayerMarginY(lane.Height); + bool usable = (layer.valid && layer.dc != nullptr && layer.width == bitmapWidth && + layer.height == expectBitmapHeight && layer.span > 0.0 && + std::fabs(layer.span - span) <= 1.0e-12); + + LayerBlit blit; + if (usable) + { + // ⚠️ **纵向偏移**:图层带「上下各半个网格高」的预渲染余量,幅值平移只改这里的 + // 源行偏移(`anchorYMax` 与当前 `yMax` 之差换算成像素),不必重画图层 —— + // 这就是"纵向拖动与横向拖动同样是 O(1) 贴图"的关键。 + int vOffset = 0; + if (layer.pxPerValue > 0.0) + { + vOffset = static_cast( + std::llround((layer.anchorYMax - m_lanes[index].yMax) * layer.pxPerValue)); + } + int srcY = static_cast(std::lround(top - lane.Y)) + layer.marginY + vOffset; + int height = bandHeight; + int destTopOffset = 0; + // 超出画布的上下两端只贴**有像素的那一段**(其余由网格底衬补齐)。 + if (srcY < 0) + { + destTopOffset = -srcY; + height -= destTopOffset; + srcY = 0; + } + if (srcY + height > layer.height) + { + height = layer.height - srcY; + } + // ⚠️ **局部贴图**:图层只覆盖"视界 ± 半屏",视界可能有一部分落在图层之外。 + // 这里只贴**有像素的那一段**,其余由底衬补齐;整块跳过才是"网格瞬间变空"的直接原因。 + const double subX = (Tab().ViewMin() - layer.wideMin) * scale; + int srcX = static_cast(std::lround(subX)); + int destOffset = 0; // 从视界左端起偏移多少像素开始贴 + int blitWidth = width; + if (srcX < 0) + { + destOffset = -srcX; + blitWidth -= destOffset; + srcX = 0; + } + if (srcX + blitWidth > bitmapWidth) + { + blitWidth = bitmapWidth - srcX; + } + if (height > 0 && blitWidth > 0) + { + blit.dc = layer.dc; + blit.srcX = srcX; + blit.srcY = srcY; + blit.destX = static_cast(std::lround(m_plotRect.X)) + destOffset; + blit.destY = static_cast(std::lround(top)) + destTopOffset; + blit.width = blitWidth; + blit.height = height; + blits.push_back(blit); + // 横向 / 纵向只有**部分覆盖**:该网格的其余部分也要靠底衬补齐。 + if (destOffset > 0 || blitWidth < width || height < bandHeight) + { + backing.push_back(static_cast(index)); + } + continue; + } + } + + // ⚠️⚠️ **必须兜底补齐网格底衬,绝不能留白**:旧实现在这里直接 `continue` + //(网格空白)或用旧跨度像素 `StretchBlt` 拉伸占位(深度放大时把一小段像素放大 + // 几十倍 → 整块网格变成一片模糊底色)—— 两者在用户眼里都是 + // 「网格叠底消失,没有任何内容」。改为就地补画底衬(底色 + 网格线 + 分界线): + // 代价极低,可以每帧执行,因此**网格底衬永远不会空白/闪烁**(用户 2026-10-17 要求)。 + backing.push_back(static_cast(index)); + } + } + + if (blits.empty()) + { + // 一个网格都贴不出像素(典型:布局变化后所有图层都失效)→ 交给调用方走整幅重绘, + // 避免画面停留在"只有网格底衬、没有波形"的状态。 return false; } + + // ⚠️ 顺序:**先画网格底衬(GDI+),再取得 HDC 做 BitBlt** —— + // `g.GetHDC()` 之后再调用 GDI+ 绘制接口是未定义行为。 + for (const int laneIndex : backing) + { + PaintLaneGridBacking(g, laneIndex, timeTicks); + } + HDC target = g.GetHDC(); if (target == nullptr) { - return false; + return true; } - bool any = false; - for (std::size_t index = 0; index < m_layerFront.size(); ++index) + for (const LayerBlit& blit : blits) { - const LaneLayer& layer = m_layerFront[index]; - if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth) - { - continue; - } - const gd::RectF& lane = m_lanes[index].rect; - // 纵向映射是否仍然可用:图层高度必须等于"当前网格高 + 上下预渲染余量"。 - // 高度变了(布局变化)时像素无法映射,只能等整块重画。 - const int expectBitmapHeight = - static_cast(std::ceil(lane.Height)) + 2 * LayerMarginY(lane.Height); - if (layer.height != expectBitmapHeight) - { - continue; - } - // ⚠️ **跨度过期 = 该图层是"旧跨度"的像素**(典型:选区放大 / 键盘缩放的瞬间)。 - // 旧实现直接 `continue` → 整个网格在重画完成前**空白**,且触发"同步整批重建" - // (实测 70~80ms)把前台卡住。 - // 现在改为把它当作**占位图**用一次 StretchBlt 拉出来:旧图层覆盖的时间段 - // [layer.wideMin, layer.wideMin + layer.span] 重新映射到新的视界上 —— - // 画面略糊(低分辨率),但**立即可用**;精确像素由后续按预算重画逐个替换。 - const bool spanStale = (std::fabs(layer.span - span) > 1.0e-12); - const float top = std::max(lane.Y, m_plotRect.Y); - const float bottom = std::min(lane.Y + lane.Height, m_plotRect.Y + m_plotRect.Height); - if (bottom <= top) - { - continue; // 网格整体移出可视区 - } - const int destX = static_cast(std::lround(m_plotRect.X)); - const int destY = static_cast(std::lround(top)); - int height = static_cast(std::lround(bottom - top)); - if (height <= 0) - { - continue; - } - - // ⚠️ **纵向偏移**:图层带「上下各半个网格高」的预渲染余量,幅值平移只改这里的 - // 源行偏移(`anchorYMax` 与当前 `yMax` 之差换算成像素),不必重画图层 —— - // 这就是"纵向拖动与横向拖动同样是 O(1) 贴图"的关键。 - int srcY = 0; - { - int vOffset = 0; - if (layer.pxPerValue > 0.0) - { - vOffset = static_cast( - std::llround((layer.anchorYMax - m_lanes[index].yMax) * layer.pxPerValue)); - } - srcY = static_cast(std::lround(top - lane.Y)) + layer.marginY + vOffset; - } - // 超出画布的上下两端只贴**有像素的那一段**(其余由底色填充,本帧的预算内重画会补齐)。 - int destTopOffset = 0; - if (srcY < 0) - { - destTopOffset = -srcY; - height -= destTopOffset; - srcY = 0; - } - if (srcY + height > layer.height) - { - height = layer.height - srcY; - } - if (height <= 0) - { - continue; - } - - if (spanStale) - { - // 占位拉伸:旧图层的时间跨度映射到当前视界(横向)。纵向映射不变。 - if (!(layer.span > 0.0)) - { - continue; - } - const double dx0 = static_cast(destX) + (layer.wideMin - Tab().ViewMin()) * scale; - const double dx1 = dx0 + layer.span * scale; - if (!(dx1 > dx0)) - { - continue; - } - const double cx0 = std::max(dx0, static_cast(destX)); - const double cx1 = - std::min(dx1, static_cast(destX) + static_cast(width)); - if (cx1 - cx0 < 1.0) - { - continue; - } - const double sx0 = (cx0 - dx0) / (dx1 - dx0) * static_cast(layer.width); - const double sx1 = (cx1 - dx0) / (dx1 - dx0) * static_cast(layer.width); - const int dstX = static_cast(std::lround(cx0)); - const int dstW = std::max(1, static_cast(std::lround(cx1 - cx0))); - const int srcX2 = static_cast(std::lround(sx0)); - const int srcW = std::max(1, static_cast(std::lround(sx1 - sx0))); - // HALFTONE 让拉伸后的占位图明显更接近成图(下采样尤其明显);置一次即可。 - SetStretchBltMode(target, HALFTONE); - SetBrushOrgEx(target, 0, 0, nullptr); - StretchBlt(target, dstX, destY + destTopOffset, dstW, height, layer.dc, srcX2, srcY, - srcW, height, SRCCOPY); - any = true; - continue; - } - - // ⚠️ **局部贴图**:图层只覆盖"视界 ± 半屏",视界可能有一部分落在图层之外。 - // 这里只贴**有像素的那一段**(其余留给底色填充,后续帧由预算内重画补齐), - // 而不是整块跳过 —— 跳过会让整个网格瞬间变空("断线 / 空白"的直接原因)。 - const double subX = (Tab().ViewMin() - layer.wideMin) * scale; - int srcX = static_cast(std::lround(subX)); - int destOffset = 0; // 从视界左端起偏移多少像素开始贴 - int blitWidth = width; - if (srcX < 0) - { - destOffset = -srcX; - blitWidth -= destOffset; - srcX = 0; - } - if (srcX + blitWidth > bitmapWidth) - { - blitWidth = bitmapWidth - srcX; - } - if (blitWidth <= 0) - { - continue; // 完全没有可用像素(该网格待重画) - } - BitBlt(target, destX + destOffset, destY + destTopOffset, blitWidth, height, layer.dc, srcX, - srcY, SRCCOPY); - any = true; + BitBlt(target, blit.destX, blit.destY, blit.width, blit.height, blit.dc, blit.srcX, blit.srcY, + SRCCOPY); } g.ReleaseHDC(target); - return any; + return true; + } + + /** + * @brief 为单个网格绘制网格底衬(底色 + 横向/竖向网格线 + 网格分界线)。 + * + * 复用 `PaintLane` 的网格部分(通过 `m_renderGridOnly` 跳过波形 / 散点 / 派生曲线 / + * Y 轴刻度 / 图例),因此底衬与整幅重绘**逐像素同源** —— 兜底帧与就绪帧之间不会出现 + * 网格线跳变。它是"网格底衬永不空白"的唯一执行点(见 `BlitWideBuffer`)。 + */ + bool PlotView::PaintLaneGridBacking(gd::Graphics& g, int laneIndex, const std::vector& timeTicks) + { + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size()) || m_theme == nullptr || + m_fonts == nullptr) + { + return false; + } + const bool savedGridOnly = m_renderGridOnly; + m_renderGridOnly = true; + PaintLane(g, laneIndex, m_lanes[static_cast(laneIndex)], timeTicks); + m_renderGridOnly = savedGridOnly; + return true; } void PlotView::PaintRubberBand(gd::Graphics& g) @@ -3508,6 +3611,11 @@ namespace jdi { // 只画装饰:网格线与波形已由宽基底提供,这里跳过。 } + else if (m_renderGridOnly) + { + // 只画网格底衬(宽基底快路径的兜底帧):跳过波形、散点与派生预览曲线。 + // 网格线在上方已经画好(`!m_renderDecorOnly` 分支)。 + } else if (laneSignals.empty()) { const gd::RectF visibleRect(lane.rect.X, clipTop, lane.rect.Width, clipBottom - clipTop); @@ -3681,7 +3789,8 @@ namespace jdi // 同时只在数值背后遮挡波形,不牺牲波形本身的可见范围。 // ⚠️ 渲染「宽缓冲区」时跳过 Y 轴刻度与图例:它们锚定在网格左缘、随视界横向移动, // 而宽缓冲区会被平移截取,装饰必须由前台叠加层(装饰层缓存)单独绘制。 - if (!m_renderWide) + // ⚠️ 「只画网格底衬」时同样跳过(底衬的定义就是不含装饰)。 + if (!m_renderWide && !m_renderGridOnly) { const bool showLabels = (lane.rect.Height >= 34.0f * m_scale); float lastLabelY = -1.0e9f; @@ -3767,7 +3876,7 @@ namespace jdi } } - if (!m_renderWide && lane.rect.Height >= 24.0f * m_scale) + if (!m_renderWide && !m_renderGridOnly && lane.rect.Height >= 24.0f * m_scale) { PaintLaneLegend(g, laneIndex, lane, clipTop, clipBottom); } diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 42978d1..7e72bf3 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -2531,6 +2531,102 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } --- +### 9.42 网格底衬永不空白 + 动画流畅性根治 + 光标随首个信号重现 + 「放大自动点线」新判据(本轮) + +> 用户 2026-10-17 第六轮(本机)。四项需求: +> ① 选区放大 / 适应视图后网格底衬**消失(半屏或局部)**、要等一会才刷新好; +> ② 所有过渡动画**卡顿 / 不平顺 / 直接闪到终态**(折起展开、网格滚动、图例联动、回到最上方按钮); +> ③ 光标工具激活且**没有任何信号绘制**时,首个信号出现后光标要**按规则重新出现**(不再停在旧残留位置); +> ④ 「放大自动点线」规则改为:**存在任何一个小格内数据点少于 5 个**即自动切换。 +> 离屏验证:新增 `.preflight/perf/gridanim_main.cpp`(`gridanim.exe`,**55/55**,含**真实消息循环**计时); +> 既有 `verify` **49/49**、`panelanim` **156/156**、`datacheck` **69/69**、`edgecheck` **46/46**、 +> `errcheck` **19/19**;`make release` 零警告。 + +#### 一、网格底衬消失:**旧跨度图层的 `StretchBlt` 占位拉伸** + 预热按下标顺序推进 + +- **两个机制叠加**(都能单独造成用户看到的现象): + 1. `BlitWideBuffer` 对「跨度已过期」的图层(缩放瞬间)原用 `StretchBlt` 把旧图层**放大**占位 —— + 深度放大时那是把一小段像素放大几十倍 ⇒ 整块网格变成**一片无网格的模糊色块**, + 用户眼里就是「网格叠底消失、没有任何内容」。局部/半屏/全屏取决于有多少个网格处于过期状态。 + 2. 待重画队列(`m_layerRebuildPending`)**按下标顺序**消费,一次只补 1 个网格; + 上百个网格时,用户正在看的那些(往往在下半部)可能要等**上百帧**才轮到 + ⇒「需要等一会网格底衬和信号才正确加载」。 +- **修法**: + * **删除占位拉伸**;`BlitWideBuffer` 对「拿不到(完整)图层像素」的网格**就地补画网格底衬** + (`PaintLaneGridBacking` → 复用 `PaintLane` 的网格部分,新增 `m_renderGridOnly` 跳过 + 波形/散点/派生曲线/Y 轴刻度/图例)。底衬代价极低,可**每帧**执行 ⇒ 网格永不空白/闪烁。 + ⚠️ 顺序必须**先画底衬(GDI+)再取 HDC 做 BitBlt**(`g.GetHDC()` 之后不能再调 GDI+)。 + ⚠️ 只有**所有**网格都贴不出像素时才 `return false`(交给整幅重绘),否则会停在「只有底衬、没有波形」。 + * 队列消费改为**可视网格优先**(`std::stable_partition` + `LaneVisuallyRelevant`)⇒ 可视区域 ~10 帧内收敛。 + * 「结算」(整幅精确帧)判据由 `LanesAllReadyForView()` 改为 **`LanesVisiblyReadyForView()`**: + 上百个网格时不必等全部**离屏**图层就绪(否则结算被拖到上百帧之后)。 + +#### 二、动画卡顿根因之一:**「队列非空就请求再送一帧」造成的高频重绘饿死动画定时器** + +- **现象**:用户循环「勾选分组复选框(全部信号→124 个网格)→ 折起/展开分组」时动画卡住/闪烁。 +- **根因**:设备队列里长期残留大量**离屏**网格 ⇒ `WarmLaneLayersStep` / 宽基底分支按 + 「`m_layerRebuildPending` 非空」**每帧**都 `PostMessage(kMessagePlotDerivedReady)` ⇒ 700ms 内挤出 + **124 次重绘**;而 `WM_TIMER` 的优先级**低于投递消息** ⇒ 动画定时器被饿死。 + **实测(探针)**:修复前该场景 `frames=124 / timerTicks=1`(动画几乎没动);修复后 `frames=25 / timerTicks=16`。 +- **修法**:新增 `AnyVisibleLanePending()`,只用「**还有可视网格待补**」决定是否请求再送一帧; + 离屏网格改为**机会式**预热(任何一帧顺手补一个)。 + +#### 三、动画卡顿根因之二:**16ms 定时器被系统 tick 量化成 ~26ms**(只有约 38fps) + +- **现象**:所有过渡动画「不平顺、像掉帧」。用户要求「找出一个能精准评估动画流畅性的方式」。 +- **方法**:探针**子类化宿主窗口**(`SetWindowLongPtrW(GWLP_WNDPROC)`)记录每个 `WM_PAINT` / `WM_TIMER(id=3)` + 的时刻,跑**真实消息循环**(真定时器、真 `WM_PAINT`)并统计帧距均值/最大。窗口需 + 「离屏但可见」(`-30000,-30000` + `WS_EX_TOOLWINDOW` + `SWP_SHOWWINDOW|SWP_NOACTIVATE`), + 否则隐藏窗口没有 `WM_PAINT`。 +- **实测(关键数据)**:请求周期 → 实际帧距:**8ms→15.8 / 10ms→15.5 / 12ms→16.5 / 16ms→26.4 / 31ms→37.9**。 + ⇒ `SetTimer(...,16,...)` 会被量化到**下一个**系统 tick(默认 15.6ms),实际 ~26ms(≈38fps)。 +- **修法**:`kScrollTimerPeriodMs` **16 → 10**(量化结果正好一个 tick,实测 ~15.5ms ≈ 64fps); + 实测真实循环帧距 ~25ms → **~16ms**,动画恢复平滑。`timeBeginPeriod(1)` 在本机**无效果**(已验证)。 +- 另一处:**网格滚动联动 / 回到最上方**每帧都在改 `LaneScroll()`,而它原本参与 + 「宽基底可用性」判据(`SceneGeometryHash`)⇒ 每一帧都退化成**整幅重绘**。 + 修法:`SceneContentHash` 新增 `includeLaneScroll` 形参;**静态场景缓存仍计入**它(缓存位图内容取决于它), + 但**宽基底可用性判据不再计入**(网格滚动只改各网格的**贴图落点**,图层内容完全不变, + `BlitWideBuffer` 本来就按 `lane.rect` 逐网格贴图)。 + **实测**:网格滚动动画单帧 **18.36ms → 1.19ms(均值)/ 23.02ms → 6.83ms(最坏)**;可跑满 60fps。 + +#### 四、光标:没有任何信号绘制时,首个信号出现后**按规则重新出现**(用户要求) + +- `PlotView::ResetCursorPlacementOnFreshContent()`:规则与 `CycleCursorMode()` 的「首次出现」**完全同源** —— + 单光标置于视图中心;双光标第一条置于视图中心、第二条紧邻第一条且保证落在可见范围内。 +- 接入点:`Application::SynchronizeView` 的 `else if (!tab.ViewInitialized())` 分支 + (= 「刚才没有任何可见信号、现在首次出现」)在 `FitAll()` **之后**调用它。 +- 实测:单光标 stale=4.60 → 新位置 = 视图中心 20.92;双光标 stale=(2.89, 7.18) → (20.92, 26.07)(均与中心/邻位一致)。 + +#### 五、「放大自动点线」新判据:**任一小格内数据点 < 5**(用户 2026-10-17 重新定义) + +- **推翻**此前的「一屏内数据点 < N×4」(`SignalSnapEnabled`)。新判据 `PlotView::AutoPointLineNeeded`: + 按 `GridMinorCellCount()` 把视界切成 N 个**横向小格**,**只要存在任何一个小格内的有效数据点 < 5** + 就自动切「点线」(更贴近用户意图:**局部**稀疏也提示)。 +- 实现要点:① 先用「有效点总数 < N×5」做 **O(1) 鸽巢充分判定**; + ② 时间轴单调时每个小格用**两次二分**取样本区间(代价与采样数无关,单格扫到 5 个有效点即早退, + 再给 `kAutoPointLineCellScanCap=512` 封顶);③ 非单调时一次有界扫描按小格分桶。 + ④ 保留「视界内至少有一个可见数据点」的保护(否则整屏无点时会被判成稀疏而白切一次)。 +- ⚠️ **光标吸附仍用旧的整屏判据**(`SignalSnapEnabled`):用户明确要求「其它规则不变」—— + §9.30 记的「自动点线 / 吸附同源」**自本轮起不再成立**。 +- 实测(探针写入 20001 点 / [0,1000] 的单信号):全视图(span=1000,50 小格,每格 400 点)⇒ 不切换; + 放大到 span=2.5(每格 1 点)⇒ 切换并置 `autoScatterActive`;`FitAll()` 回到稠密 ⇒ 恢复原样式。 + +#### 六、⚠️ 本轮方法论教训 + +1. **`theme.cardBg == theme.plotBg`(都是 kBase3)**⇒ 任何「按颜色区分『空白/已绘制』」的探针判据 + 都是**色盲**的(实测假绿)。判断「网格是否画出来」只能用**结构判据**(竖直锐边)。 +2. **竖直锐边判据必须要求「同一列多行都有锐变」**:网格**分界线**是一条横线,会让每一列都"看起来有锐边" + (第一版 `SharpColumns` 因此假绿)。改为「同一 x 至少 3 行有 |Δ|≥阈值」后才有判别力。 +3. **后备缓冲区是持久的**:宽基底路径只把绘图区填成 `plotBg`(== `cardBg`)⇒ 本帧没被覆盖的网格 + **保留上一帧像素**(陈旧但"看起来有内容")⇒ 探针里"没有空白"**不能**证明没问题。 + 要证伪必须**注入一个刺眼占位色**(本轮试过品红)或直接对比两张图。 +4. ⚠️ **探针里动画计时必须在启动动画后先画一帧**:`Render` 才负责 `SetTimer` 武装动画定时器; + 否则消息循环无消息可等 ⇒ `frames=0`(本轮假失败两次)。 +5. ⚠️ 构建 25 个源文件的探针约 **43 秒**;`build.ps1` 若失败只在 `$LASTEXITCODE` 上体现, + 直接跑旧 exe 会拿到**旧结果**(本轮因此误判过一次)—— 必须检查构建输出。 + +--- + ## 10. 变更日志(本文件维护记录) | 日期 | 设备/模型 | 摘要 | @@ -2589,6 +2685,8 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } | 2026-10-16 | DeepSeek-V4.1-Flash(**本机**) | **回退 5b52eef 并修复“屏幕不刷新”根因(拖动滚动条卡顿 / 悬停高亮卡住 / 信号拖拽无效果,详见 §9.41;§9.40 随之作废)**。根因(代码级):`Render()` 的回送被写成 `if (fullFrame) … else if (panelOnly) …` 而**没有兜底 else**,而“鼠标只在绘图区内移动”的帧恰好两个条件都不满足 ⇒ 后备缓冲区画对了、窗口 DC 一个像素都不回送(悬停光晕 / 滑块 / 拖拽结果全部停在旧像素)。修法:`git reset --hard` 撤销该提交(留 `backup-bad-commit` 标签),只挑回与更新区域无关的功能改动(联动前展开折起组 / 适应视图不动滚动条 / 「回到最上方」按钮 / 网格滚动平滑动画),彻底删除 `m_paintPlot`/`m_panelDirty`/`panelOnly`/并集回送;宿主只保留 3 处最小改动且 `Render` 的绘制与 BitBlt 与 `82b5ebe` 逐字节相同。验证:`.preflight/diag` **19/19**(新增离屏可见窗口探针:滚动条拖动 / 悬停光晕出现与消退 / 信号拖拽换网格)、`.preflight/check` **137/137**、源码护栏 `.preflight/diag/verify_blit.ps1` **10/10**(A/B:换回坏版本 ⇒ 4 项如实失败)、`make release` 零警告。教训:① 离屏探针**测不到窗口回送**(屏幕外 DWM 不合成、窗口 DC 恒空白)⇒ 回送类约束必须有源码护栏;② 离屏窗口 + `TrackMouseEvent` 会**立刻**收到 `WM_MOUSELEAVE`,探针须丢弃该消息;③ 网格回收会让下标左移,断言要用锚信号。 | +| 2026-10-17 | DeepSeek-V4.1-Flash(**本机**) | **网格底衬永不空白 + 动画流畅性根治 + 光标随首个信号重现 + 「放大自动点线」新判据(详见 §9.42)**。① **网格底衬消失**:旧跨度图层的 `StretchBlt` 占位拉伸在深度放大时把一小段像素放大几十倍 ⇒ 整块网格变成**无网格的模糊色块**;叠加待重画队列**按下标顺序**消费(可视网格要等上百帧)⇒「消失 + 要等一会才刷新好」。修法:**删除占位拉伸**,改为对拿不到像素的网格**就地补画网格底衬**(`PaintLaneGridBacking` + `m_renderGridOnly`,代价极低可每帧执行),队列改**可视网格优先**,结算判据改 `LanesVisiblyReadyForView()`。② **动画卡顿(两处根因)**:**(a)**「`m_layerRebuildPending` 非空就每帧请求再送一帧」在上百个网格时挤出 100+ 次重绘、把优先级更低的 `WM_TIMER` **饿死**(实测 `frames=124/timerTicks=1`)⇒ 改用 `AnyVisibleLanePending()`(`frames=25/timerTicks=16`);**(b)** `SetTimer(...,16,...)` 被系统 tick **量化成 ~26ms(≈38fps)** ⇒ 周期改 **10ms**(实测 ~15.5ms ≈ 64fps,真实消息循环帧距 ~25ms → ~16ms);**(c)** 网格滚动量原本参与「宽基底可用性」判据 ⇒ 滚动动画每帧整幅重绘 ⇒ 新增 `SceneContentHash(..., includeLaneScroll)`(判据不计、场景缓存仍计),单帧 **18.36ms → 1.19ms**。③ **光标随首个信号重现**:新增 `ResetCursorPlacementOnFreshContent()`(与 `CycleCursorMode()` 首次启用同源),接入 `SynchronizeView` 的「首次出现可见信号」分支。④ **「放大自动点线」新判据**:任一小格内数据点 < 5 即切换(`AutoPointLineNeeded`;二分/鸽巢封顶);⚠️ 光标吸附仍用旧整屏判据(用户要求"其它规则不变")。验证:新增 `.preflight/perf/gridanim_main.cpp` **55/55**(含真实消息循环帧距统计、光标重现、点线规则);`verify` 49/49、`panelanim` 156/156、`datacheck` 69/69、`edgecheck` 46/46、`errcheck` 19/19;`make release` 零警告。⚠️ 教训:`cardBg == plotBg` ⇒ 颜色判据色盲;后备缓冲区持久 ⇒ 「看不到空白」不能证明没问题;网格分界线会让「有锐边」判据假绿(须要求同列多行)。§11.2 新增第 117–124 条。 | + ## 11. 跨设备协作须知(起源设备视角) > **本文件是唯一权威记忆**。所有设备 / Agent 共用它,**不要再新建 `agent_mem_origin.md` 等旁支记忆文件**。 @@ -3152,6 +3250,51 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } 116. ⚠️ **对“看起来坏了”的现象,先怀疑探针自己的取点与判据**:本轮先后误判“悬停不生效” “信号没换网格”,实际都是探针取点踩到了「GX 重排徽标 / 图例 / 时间光标线」这些 **优先级更高的热区**,或用了错误的比较基准(拿滚动后的 rect 当比较区域)。 +117. ⚠️⚠️ **宽基底快路径里拿不到图层像素的网格必须【就地补画网格底衬】,绝不允许留白**: + 旧实现 `continue`(留白)或用旧跨度像素 `StretchBlt` 放大占位(深度放大 ⇒ 一片无网格的模糊色块) + —— 两者在用户眼里都是「网格叠底消失,没有任何内容」(用户 2026-10-17 报告)。 + ✓ `PaintLaneGridBacking`(经 `m_renderGridOnly` 复用 `PaintLane` 的网格部分:底色 + 网格线 + 分界线, + 跳过波形/散点/派生曲线/Y 轴刻度/图例)。**底衬代价极低 ⇒ 每帧都可以画**。 + ⚠️ 顺序:**先画底衬(GDI+)再 `g.GetHDC()` 做 BitBlt**(取到 HDC 后不能再调 GDI+ 绘制)。 + ⚠️ 只有**所有**网格都贴不出像素时才返回 false(交给整幅重绘),否则会停在「只有底衬、没有波形」。 + ⚠️ 绝不要恢复 `StretchBlt` 占位拉伸。 +118. ⚠️ **待重画队列必须【可视网格优先】**(`RenderPendingLaneRebuilds` 里 `std::stable_partition` + + `LaneVisuallyRelevant`):队列是按下标顺序登记的,上百个网格时用户正在看的那些要等上百帧 + ⇒ 表现是「需要等一会才正确加载」(用户 2026-10-17 报告)。同理「结算」判据用 + **`LanesVisiblyReadyForView()`**(不要 `LanesAllReadyForView()`:离屏图层与画面无关,会让结算被拖后上百帧)。 +119. ⚠️⚠️ **“是否请求宿主再送一帧”只能看【还有没有可视网格待补】**(`AnyVisibleLanePending()`), + **绝不能**用「`m_layerRebuildPending` 非空」:上百个网格时队列长期残留大量**离屏**网格 ⇒ 每帧都请求重绘 + ⇒ 700ms 内挤出 100+ 次重绘,而 `WM_TIMER` 优先级**低于投递消息** ⇒ **动画定时器被饿死** + (实测 `frames=124 / timerTicks=1`;改为可视判定后 `frames=25 / timerTicks=16`)。离屏网格改为**机会式**预热。 +120. ⚠️⚠️ **`SetTimer` 的间隔会被量化到系统 tick(默认 15.6ms)**:请求 16ms 实际等到**下一个** tick + ⇒ 实测帧距 **≈26ms(只有约 38fps)**,所有过渡动画都"不平顺"(用户 2026-10-17 报告)。 + ✓ 动画定时器周期必须 **≤ 一个 tick**(当前 `kScrollTimerPeriodMs = 10` ⇒ 实测 ~15.5ms ≈ 64fps)。 + 实测映射:8ms→15.8 / 10ms→15.5 / 12ms→16.5 / **16ms→26.4** / 31ms→37.9。 + ⚠️ `timeBeginPeriod(1)` 在本机**无效**(已 A/B 验证),不要指望它。 +121. ⚠️ **「宽基底可用性」判据(`SceneGeometryHash`)绝不能计入网格纵向滚动量 / 各网格屏幕纵向位置** + (`SceneContentHash(..., includeLaneScroll=false)`):它们只是图层的**贴图落点**, + 图层内容完全不变(`BlitWideBuffer` 按 `lane.rect` 逐网格贴图)。 + 若计入 ⇒「网格滚动到可视区 / 回到最上方」**每一帧都退化成整幅重绘** + (实测单帧 18.36ms → 1.19ms)。⚠️ **静态场景缓存**的指纹**仍然必须**计入 `LaneScroll()`(缓存内容取决于它)。 +122. ⚠️ **「放大自动点线」判据(用户 2026-10-17 重新定义,推翻「一屏内数据点 < N×4」)**: + 按 `GridMinorCellCount()` 切成横向小格,**只要存在任何一个小格内有效数据点 < 5** 就切「点线」 + (`PlotView::AutoPointLineNeeded`)。实现:先「总有效点 < N×5」O(1) 充分判定; + 单调时间轴逐小格**两次二分**(单格累计到 5 个即早退,另给 `kAutoPointLineCellScanCap=512` 封顶); + 非单调时一次有界扫描分桶。保留「视界内至少一个可见点」的保护。 + ⚠️ **光标吸附仍用旧的整屏判据**(`SignalSnapEnabled`)—— 用户明确要求“其它规则不变”, + §9.30 的「自动点线 / 吸附同源」自本轮起**不再成立**。 +123. ⚠️ **光标工具激活且【没有任何信号绘制】时,首个信号出现后光标要按规则重新出现**: + `PlotView::ResetCursorPlacementOnFreshContent()`(规则与 `CycleCursorMode()` 首次启用**同源**), + 由 `Application::SynchronizeView` 的 `else if (!tab.ViewInitialized())`(= 首次出现可见信号)分支在 + `FitAll()` **之后**调用(用户 2026-10-17 要求:不要再停在旧的残留位置)。 +124. ⚠️ **探针里的动画计时**:启动动画后必须**先画一帧**(`Render` 才负责 `SetTimer` 武装动画定时器), + 否则真实消息循环无消息可等 ⇒ `frames=0` 假失败。真实循环计时还必须把窗口置为 + **「离屏但可见」**(`-30000,-30000` + `WS_EX_TOOLWINDOW` + `SWP_SHOWWINDOW|SWP_NOACTIVATE`), + 隐藏窗口**没有** `WM_PAINT`。 + ⚠️ **`theme.cardBg == theme.plotBg`(都是 kBase3)**⇒ 用颜色区分「空白/已绘制」的探针判据是**色盲**的; + 判断网格是否画出必须用结构判据(竖直锐边),且必须要求「同一列多行都有锐变」(否则网格**分界线**会让 + 每列都像有锐边)。⚠️ **后备缓冲区是持久的**:宽基底路径只把绘图区填成 plotBg(==cardBg), + 本帧没被覆盖的网格**保留上一帧像素** ⇒ 探针「看不到空白」不能证明没问题。 ### 11.3 离屏验证方法学(本机偏好,强烈建议沿用)