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

一、网格底衬消失(半屏/局部/全屏,且要等一会才刷新好)—— 两个机制叠加
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

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