网格底衬永不空白 + 动画流畅性根治 + 光标随首个信号重现 + 「放大自动点线」新判据

一、网格底衬消失(半屏/局部/全屏,且要等一会才刷新好)—— 两个机制叠加
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 条与变更日志一行。
This commit is contained in:
DeepSeek-V4.1-Flash committed 2026-10-17 00:56:19 +08:00
1 parent f845616938
commit 34375481de
7 files changed
+702 -168

No files matched your search

@@ -120,6 +120,10 @@ namespace jdi
// 首次出现可见信号时做一次自适应。单点信号(时间/幅值范围退化成 0)由
// `FitAll` 内部按"单值散点"的规则处理(居中 + 一大格 1s),这里不再分开判断。
m_plot.FitAll();
// 用户 2026-10-17 要求:光标工具处于激活状态(单 / 双光标)而之前**没有任何信号绘制**时,
// 第一个信号出现后光标要按“光标出现的规则”重新出现一次,而不是停在旧的残留位置。
// 规则与 `CycleCursorMode()` 首次启用完全同源;光标工具未激活时本调用无副作用。
m_plot.ResetCursorPlacementOnFreshContent();
}
const bool laneCountChanged = (laneCount != previousLaneCount);
@@ -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 查询本进程当前占用的内存(**私有工作集**,与任务管理器"内存"列同口径)。
+94 -9
View File
@@ -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<double>& 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<double>& 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 渲染信号图层时,横向网格线(幅值刻度)的「参考跨度 / 参考高度」。
*
+166 -4
View File
@@ -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<std::size_t>(cells) *
static_cast<std::size_t>(kAutoPointLineMinPointsPerCell);
if (signal.finiteCount < need)
{
return true;
}
const double cellSpan = span / static_cast<double>(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<double>(cell);
const double cellMax =
(cell + 1 == cells)
? viewMax
: (viewMin + cellSpan * static_cast<double>(cell + 1));
const std::size_t lo = static_cast<std::size_t>(
std::lower_bound(axis.time.begin(), axis.time.end(), cellMin) - axis.time.begin());
const std::size_t hi = static_cast<std::size_t>(
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<std::size_t>(kAutoPointLineMinPointsPerCell))
{
break;
}
}
if (valid < static_cast<std::size_t>(kAutoPointLineMinPointsPerCell))
{
return true; // 该小格稀疏 ⇒ 自动切「点线」
}
}
return false;
}
// 非单调时间轴:一次有界扫描,按小格分桶计数。
std::vector<int> buckets(static_cast<std::size_t>(cells), 0);
const std::size_t scanLimit =
kAutoPointLineCellScanCap * static_cast<std::size_t>(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<int>((time - viewMin) / cellSpan);
if (cell < 0)
{
cell = 0;
}
if (cell >= cells)
{
cell = cells - 1;
}
const std::size_t slot = static_cast<std::size_t>(cell);
if (buckets[slot] < kAutoPointLineMinPointsPerCell)
{
++buckets[slot];
}
}
for (int cell = 0; cell < cells; ++cell)
{
if (buckets[static_cast<std::size_t>(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)
{
@@ -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 「所有可见数据点都是单值信号、且时刻完全相同」时,时间轴**一个大格**对应的秒数。
*
+263 -154
View File
@@ -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<double> 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<double> 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<int>(m_lanes.size()))
{
return false;
}
if (!(m_plotRect.Height > 0.0f))
{
return false;
}
const gd::RectF& rect = m_lanes[static_cast<std::size_t>(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<int>(index)))
{
continue;
}
anyRelevant = true;
if (!LaneLayerReady(static_cast<int>(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<int>(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<int>(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<double>& 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<double>(width) / span;
std::lock_guard<std::mutex> 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<LayerBlit> blits;
std::vector<int> backing; // 本帧拿不到(完整)图层像素的网格
blits.reserve(m_lanes.size());
backing.reserve(m_lanes.size());
{
std::lock_guard<std::mutex> 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<int>(std::lround(bottom - top));
// 纵向映射是否仍然可用:图层高度必须等于"当前网格高 + 上下预渲染余量",
// 且时间跨度必须与当前视界一致(跨度不同则像素的时间映射已经不对)。
const int expectBitmapHeight =
static_cast<int>(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<int>(
std::llround((layer.anchorYMax - m_lanes[index].yMax) * layer.pxPerValue));
}
int srcY = static_cast<int>(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<int>(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<int>(std::lround(m_plotRect.X)) + destOffset;
blit.destY = static_cast<int>(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<int>(index));
}
continue;
}
}
// ⚠️⚠️ **必须兜底补齐网格底衬,绝不能留白**:旧实现在这里直接 `continue`
//(网格空白)或用旧跨度像素 `StretchBlt` 拉伸占位(深度放大时把一小段像素放大
// 几十倍 → 整块网格变成一片模糊底色)—— 两者在用户眼里都是
// 「网格叠底消失,没有任何内容」。改为就地补画底衬(底色 + 网格线 + 分界线):
// 代价极低,可以每帧执行,因此**网格底衬永远不会空白/闪烁**(用户 2026-10-17 要求)。
backing.push_back(static_cast<int>(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<int>(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<int>(std::lround(m_plotRect.X));
const int destY = static_cast<int>(std::lround(top));
int height = static_cast<int>(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<int>(
std::llround((layer.anchorYMax - m_lanes[index].yMax) * layer.pxPerValue));
}
srcY = static_cast<int>(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<double>(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<double>(destX));
const double cx1 =
std::min(dx1, static_cast<double>(destX) + static_cast<double>(width));
if (cx1 - cx0 < 1.0)
{
continue;
}
const double sx0 = (cx0 - dx0) / (dx1 - dx0) * static_cast<double>(layer.width);
const double sx1 = (cx1 - dx0) / (dx1 - dx0) * static_cast<double>(layer.width);
const int dstX = static_cast<int>(std::lround(cx0));
const int dstW = std::max(1, static_cast<int>(std::lround(cx1 - cx0)));
const int srcX2 = static_cast<int>(std::lround(sx0));
const int srcW = std::max(1, static_cast<int>(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<int>(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<double>& timeTicks)
{
if (laneIndex < 0 || laneIndex >= static_cast<int>(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<std::size_t>(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);
}