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

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

+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)
{