多层加载拖拽卡顿根治 + 高亮命中线段中段 / 散点高亮 + 选区放大也高亮 + 自动点线判定与规则修正
一、三明治加载后拖拽视界卡顿(39.2ms → 8.2ms 均值,47.9ms → 10.3ms 最坏)—— 四个叠加根因 1) RenderLaneLayerRegion 每次「横向补条」都调用两次 BuildLaneVisuals()(对全部网格算 ComputeLaneRange,O(网格数))⇒ 253 网格时单帧 RollWideBuffer = 26.8ms。 修法:进入前快照 m_lanes / m_laneContentHeight,结束时直接拷回(它只渲染一个网格)。 2) 横向「重新居中」本来就不是正确性要求:BlitWideBuffer 按 (ViewMin - layer.wideMin) * scale 算源偏移,不滚动也画得对 ⇒ 新增 kLayerRollQuantumX=32(不足一个量子不滚)+ kLayerRollsPerFrame=3(每帧最多重居中 3 个网格,分帧摊销)。 3) 位移过大时旧实现「立刻整块重画」:巨型信号(25M 采样)一次 20~130ms,实测拖拽最坏帧 28ms。 修法:kLayerRollMaxShiftPixels=160,超出就作废图层 + 登记待重画,本帧由 BlitWideBuffer 的 网格底衬兜底(正是用户要求的“没来得及画信号就按只有网格叠底继续拖”); 并且 RollWideBuffer 只处理可视/预取带内的网格(离屏网格不做像素工作)。 4) 包络带路径也支持横向补条裁剪:包络是逐像素列独立计算的,只构建条带覆盖的列即可。 旧实现对包络带总是构建整幅可见范围 ⇒ 巨型信号一条 32px 竖带要 5ms。 二、鼠标悬停「两点之间的连线」无法高亮(放大到自动点线后) 根因:HitTestSignal 的横向窗口用 upper_bound(fx+tol) 取顶点区间,只扫 [first, last) 的线段; 放大后相邻点相距几十像素,鼠标落在两点中间时两端都在窗口外 ⇒ windowFirst >= windowLast ⇒ 跳过。 修法:线段扫描上界改为 min(stop, windowLast + 1)(必须包含“一端在窗口内、另一端在窗口外”的线段); 散点命中的顶点范围也向两侧各扩一个。实测 11 段长线段中点命中 0/11 → 11/11。 三、散点模式补充高亮(用户要求:原线段/点线高亮“去掉数据点之间连线的部分”) PaintSignalHalo 原来对 DrawMode::SCATTER 直接 return ⇒ 只有拖拽提示、没有高亮。 现在给每个参与绘制的散点套一圈半透明强调色圆环(步长与绘制同源 kMaxScatterPoints)。 线段 / 点线保持原有的折线光晕。列表 / 图例 / 样式区「目标」行联动高亮走同一条路径,自动生效。 四、「选区放大」模式恢复悬停高亮(仍不允许拖信号) OnMouseMove 去掉 ZoomMode() == NONE 的门控;OnLButtonDown 在 SELECT 模式下仍直接进入 RUBBER_SELECT。 该模式下的提示文案只显示信号名(去掉 “· 按住拖拽可移入其它网格” 尾缀)。 五、自动点线误判(MF4 + VERY_BIG_DATA 同时加载时 MF4 信号默认就被切点线) 根因:MinorCellSparse 把整个视界切成小格。VERY_BIG_DATA 跨度 ~130s、MF4 数据只覆盖 ~11s ⇒ MF4 信号在 90% 的格子里本来就没有任何采样点 ⇒ “存在一个格子内 < 5 个点”恒成立。 (用户猜测是“用了抽稀点”,实际是“空格子”,但现象一致。) 修法:先用可见数据的显示时间跨度 [dataMin, dataMax] 收窄参与的格子(firstCell..lastCell), 鸽巢判定的分母改用参与格子数;端点 NaN 时做 64 步有界探测并回退到轴区间。 实测:248 个 MF4/中等信号被误判的数量 248 → 0。 六、自动点线规则收窄(用户本次要求) 只有 DrawMode::LINE 的信号在放大时自动切 LINE_SCATTER;SCATTER 不切换(散点已足够提示数据点位置)、 LINE_SCATTER 不动;用户手动改过样式(autoScatterUserOverride)时不切换,避免“改完下一帧又被改回去”。 退出时(用户未手动改过)恢复原来的 LINE。 实测:LINE → 放大后 mode=2 auto=1 → 适应视图后 mode=0 auto=0;SCATTER → 放大后 mode=1 auto=0。 验证(全部离屏,不占用鼠标/键盘/屏幕) - 新增 .preflight/perf/hoverdrag_main.cpp(hoverdrag.exe)20/20:三明治加载后的点选跳转 + 拖拽逐帧耗时、 线段中点命中、散点高亮像素、选区放大悬停命中、自动点线判定与规则。 - 既有回归:verify 49/49、panelanim 158/158、datacheck 69/69、edgecheck 46/46、errcheck 19/19、 gridanim 93/93、lanescroll 23/23。 - 探针的绝对毫秒阈值改为相对基准(同一基线代码在不同时段可差 2~3 倍,避免假失败)。 - make release / make debug 零错误零警告(用 TARGET_FILE_NAME=TargetCheck/TargetCheckD 避免与运行中实例冲突)。 文档:Documents/agent_mem.md 新增 §9.45、§11.2 第 141–150 条与变更日志一行。
This commit is contained in:
1 parent
6b15575829
commit
fd606a6f80
5 files changed
+394
-70
No files matched your search
@@ -769,23 +769,98 @@ namespace jdi
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// 鸽巢原理:有效点总数都不足“每格 5 个” ⇒ 必然存在一个稀疏小格(O(1) 充分判定)。
|
||||
const std::size_t need = static_cast<std::size_t>(cells) *
|
||||
static_cast<std::size_t>(kSparseMinPointsPerCell);
|
||||
if (signal.finiteCount < need)
|
||||
|
||||
// ⚠️⚠️ **判定只在这个信号【实际有数据】的时间段内进行**(用户 2026-10-17 报告):
|
||||
// 同时加载 `VERY_BIG_DATA`(视界跨度 ~130s)与 MF4(数据只覆盖 ~11s)时,
|
||||
// 旧实现把**整个视界**切成小格 —— MF4 信号在 90% 的格子里本来就没有任何采样点,
|
||||
// 于是“存在一个格子内 < 5 个点”恒成立 ⇒ **默认状态下就被错判成稀疏、自动切点线**。
|
||||
// 现在先用「可见数据的显示时间跨度」把格子范围收窄,只对这些格子做判定。
|
||||
double dataMin = viewMin;
|
||||
double dataMax = viewMax;
|
||||
{
|
||||
return true;
|
||||
// 绝大多数信号的两端采样本就有效 ⇒ **零扫描**快速路径(每帧 × 上百个信号,这很重要)。
|
||||
const bool edgeOk =
|
||||
std::isfinite(signal.values[begin]) && std::isfinite(axis.time[begin]) &&
|
||||
std::isfinite(signal.values[end - 1]) && std::isfinite(axis.time[end - 1]);
|
||||
if (edgeOk)
|
||||
{
|
||||
dataMin = axis.time[begin];
|
||||
dataMax = axis.time[end - 1];
|
||||
}
|
||||
else
|
||||
{
|
||||
// 端点无效(NaN)时向内侧做**有界**探测(NaN 只出现在缺数据段,几步内就能找到);
|
||||
// 探测不到就回退到整段轴区间(宁可多算几格,也不要放弃判定)。
|
||||
const std::size_t probe = 64u;
|
||||
const std::size_t forwardCap = std::min(end, begin + probe);
|
||||
bool found = false;
|
||||
for (std::size_t index = begin; index < forwardCap; ++index)
|
||||
{
|
||||
if (std::isfinite(signal.values[index]) && std::isfinite(axis.time[index]))
|
||||
{
|
||||
dataMin = axis.time[index];
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found)
|
||||
{
|
||||
dataMin = axis.time[begin];
|
||||
}
|
||||
const std::size_t backwardFloor =
|
||||
(end > probe) ? std::max(begin, end - probe) : begin;
|
||||
found = false;
|
||||
for (std::size_t index = end; index-- > backwardFloor;)
|
||||
{
|
||||
if (std::isfinite(signal.values[index]) && std::isfinite(axis.time[index]))
|
||||
{
|
||||
dataMax = axis.time[index];
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found)
|
||||
{
|
||||
dataMax = axis.time[end - 1];
|
||||
}
|
||||
}
|
||||
dataMin = std::max(dataMin, viewMin);
|
||||
dataMax = std::min(dataMax, viewMax);
|
||||
if (!(dataMax > dataMin))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const double cellSpan = span / static_cast<double>(cells);
|
||||
if (!(cellSpan > 0.0) || !std::isfinite(cellSpan))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// 只覆盖「有数据的显示时间范围」所涉及的格子(闭区间)。
|
||||
int firstCell = static_cast<int>(std::floor((dataMin - viewMin) / cellSpan));
|
||||
int lastCell = static_cast<int>(std::floor((dataMax - viewMin) / cellSpan));
|
||||
firstCell = std::max(0, std::min(firstCell, cells - 1));
|
||||
lastCell = std::max(0, std::min(lastCell, cells - 1));
|
||||
if (lastCell < firstCell)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const int consideredCells = lastCell - firstCell + 1;
|
||||
|
||||
// 鸽巢原理:有效点总数都不足“每格 5 个” ⇒ 必然存在一个稀疏小格(O(1) 充分判定)。
|
||||
// ⚠️ 分母必须用**实际参与的格子数**(不是整个视界的格子数)。
|
||||
const std::size_t need =
|
||||
static_cast<std::size_t>(consideredCells) * static_cast<std::size_t>(kSparseMinPointsPerCell);
|
||||
if (signal.finiteCount < need)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 时间轴单调 ⇒ 每个小格用两次二分取出它的样本区间(代价与采样数无关)。
|
||||
if (axis.monotonic)
|
||||
{
|
||||
for (int cell = 0; cell < cells; ++cell)
|
||||
for (int cell = firstCell; cell <= lastCell; ++cell)
|
||||
{
|
||||
const double cellMin = viewMin + cellSpan * static_cast<double>(cell);
|
||||
const double cellMax =
|
||||
@@ -852,7 +927,7 @@ namespace jdi
|
||||
++buckets[slot];
|
||||
}
|
||||
}
|
||||
for (int cell = 0; cell < cells; ++cell)
|
||||
for (int cell = firstCell; cell <= lastCell; ++cell)
|
||||
{
|
||||
if (buckets[static_cast<std::size_t>(cell)] < kSparseMinPointsPerCell)
|
||||
{
|
||||
@@ -3054,15 +3129,21 @@ namespace jdi
|
||||
{
|
||||
if (!style->autoScatterActive)
|
||||
{
|
||||
// ⚠️ **只有「线段」模式的信号才自动切「点线」**(用户 2026-10-17 要求):
|
||||
// 本来就是「散点」的信号放大后**不**自动切换(散点已经足够提示数据点位置,
|
||||
// 再叠上线段反而干扰);已经是「点线」的也不需要动。
|
||||
// ⚠️ 用户在此期间手动改过样式时也不切换,避免“改完下一帧又被改回去”。
|
||||
if (style->mode != DrawMode::LINE || style->autoScatterUserOverride)
|
||||
{
|
||||
style->autoScatterActive = false;
|
||||
continue;
|
||||
}
|
||||
// 进入阈值:保存原绘制方式并自动切到「点线」提示用户数据点位置。
|
||||
style->autoScatterSavedMode = style->mode;
|
||||
style->autoScatterActive = true;
|
||||
style->autoScatterUserOverride = false;
|
||||
if (style->mode != DrawMode::LINE_SCATTER)
|
||||
{
|
||||
style->mode = DrawMode::LINE_SCATTER;
|
||||
changed = true;
|
||||
}
|
||||
style->mode = DrawMode::LINE_SCATTER;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
else if (style->autoScatterActive)
|
||||
|
||||
@@ -272,14 +272,15 @@ namespace jdi
|
||||
[](float limit, const gd::PointF& point) { return limit < point.X; }) -
|
||||
points.begin());
|
||||
}
|
||||
if (windowFirst >= windowLast)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// 散点模式下把采样点本身也视为可命中目标。
|
||||
// ⚠️ 窗口两侧各多含一个顶点:折线是**连续**的,鼠标落在两顶点之间时
|
||||
// 两个端点都可能落在容差窗口之外(深放大 / 点线模式下相邻点相距几十像素),
|
||||
// 旧实现因此完全扫不到这段连线(用户 2026-10-17 报告:只有数据点附近能高亮)。
|
||||
if (resolved.style->mode == DrawMode::SCATTER)
|
||||
{
|
||||
for (std::size_t index = windowFirst; index < windowLast; ++index)
|
||||
const std::size_t scatterBegin = (windowFirst > 0) ? (windowFirst - 1) : 0;
|
||||
const std::size_t scatterEnd = std::min(points.size(), windowLast + 1);
|
||||
for (std::size_t index = scatterBegin; index < scatterEnd; ++index)
|
||||
{
|
||||
const gd::PointF& point = points[index];
|
||||
const float dx = point.X - fx;
|
||||
@@ -299,7 +300,9 @@ namespace jdi
|
||||
const std::size_t start = breaks[k];
|
||||
const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size();
|
||||
const std::size_t scanBegin = std::max(start + 1, windowFirst);
|
||||
const std::size_t scanEnd = std::min(stop, windowLast);
|
||||
// ⚠️ `windowLast + 1`:见上方说明 —— 需要包含"左端点在窗口内、右端点在窗口外"
|
||||
// 以及“左端点在窗口外、右端点在窗口内”的跨窗口线段(正是鼠标所在的那一段)。
|
||||
const std::size_t scanEnd = std::min(stop, windowLast + 1);
|
||||
for (std::size_t index = scanBegin; index < scanEnd; ++index)
|
||||
{
|
||||
const gd::PointF& a = points[index - 1];
|
||||
@@ -1447,7 +1450,10 @@ namespace jdi
|
||||
if (!m_cursorHover && !overEdgePanel)
|
||||
{
|
||||
hoverLegend = LegendAtPoint(x, y);
|
||||
if (!hoverLegend.IsValid() && Tab().ZoomMode() == PlotZoomMode::NONE)
|
||||
// ⚠️ **选区放大模式下也要响应信号悬停高亮**(用户 2026-10-17 要求):
|
||||
// 该模式只改变“按下并拖动”的语义(框选而不是拖信号),悬停提示与高亮应当保留;
|
||||
// 只是提示文案不再带“按住拖拽可移入其它网格”的尾缀(见 `PaintSignalInteraction`)。
|
||||
if (!hoverLegend.IsValid())
|
||||
{
|
||||
hoverSignal = HitTestSignal(x, y);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,40 @@ namespace jdi
|
||||
*/
|
||||
inline const int kLayerRollQuantum = 16;
|
||||
|
||||
/**
|
||||
* @brief 图层**横向滚动**的量化阀值(位图像素)。
|
||||
*
|
||||
* `BlitWideBuffer` 贴图时按 `(ViewMin - layer.wideMin) * scale` 算源偏移,因此图层
|
||||
* **不需要每帧重新居中**:只要视界仍在位图覆盖范围内,直接按偏移贴就够了。
|
||||
* 旧实现每帧都把图层滚回“居中”位置(补一条 2px 宽的竖带),而每次补条都要重跑
|
||||
* 一次 `BuildLaneVisuals()`(O(网格数))⇒ 253 个网格、30 个可视网格时
|
||||
* **单帧 26.8ms**(用户 2026-10-17 报告:三明治加载后拖拽视界卡顿)。
|
||||
* 改成“位移达到一个量子才滚一次”后,正常拖动几乎不会触发补条。
|
||||
*/
|
||||
inline const int kLayerRollQuantumX = 32;
|
||||
|
||||
/**
|
||||
* @brief 图层横向位移超过该值(位图像素)时就**作废图层**(而不是同步整块重画)。
|
||||
*
|
||||
* ⚠️⚠️ 在前台同步重画一条**巨型信号**的图层实测 **20~130ms**;
|
||||
* 拖拽时只要有一个“离屏累积了大位移、刚进入预取带”的网格就会卡一下。
|
||||
* 作废后本帧由 `BlitWideBuffer` 的**网格底衬**兜底(廉价),波形等视界停下后
|
||||
* 由 `RenderPendingLaneRebuilds` 按预算逐个补 —— 这正是用户要求的
|
||||
* “没来得及画信号时就按只有网格叠底的状态继续滚动”。
|
||||
*/
|
||||
inline const int kLayerRollMaxShiftPixels = 160;
|
||||
|
||||
/**
|
||||
* @brief 每帧最多为多少个网格做**横向重新居中**(补细条)。
|
||||
*
|
||||
* ⚠️ 重新居中**纯粹是优化**:`BlitWideBuffer` 本来就按 `(ViewMin - layer.wideMin)` 算源偏移,
|
||||
* 不滚动也照样能正确贴图(只要还在位图覆盖范围内)。因此完全可以分摊到多帧。
|
||||
* 每条补条的固定开销 ~1.5ms(含 `m_lanes` 快照/还原与 GDI+ 调用),
|
||||
* 30 个可见网格同时滚就是 45ms —— 取 3 个 ⇒ 最坏单帧约 4~5ms,
|
||||
* 拖拽时既不会卡顿、也不会让位移累积到需要作废图层的程度。
|
||||
*/
|
||||
inline const int kLayerRollsPerFrame = 3;
|
||||
|
||||
/**
|
||||
* @brief 「图层预热」两次请求重绘之间的最小间隔(毫秒)。
|
||||
*
|
||||
|
||||
@@ -406,7 +406,35 @@ namespace jdi
|
||||
};
|
||||
|
||||
std::size_t searchFrom = begin;
|
||||
for (std::size_t column = 0; column < columns; ++column)
|
||||
// ⚠️ **包络带路径也要支持「横向补条」裁剪**(用户 2026-10-17 报告拖拽卡顿):
|
||||
// 旧实现只对“逐点描边”路径按条带裁剪,包络带路径**总是构建整幅可见范围的包络** ——
|
||||
// 巨型信号(`VERY_BIG_DATA`)一条 32px 宽的竖带就要 **5ms**,拖拽时每帧补几个网格就卡。
|
||||
// 包络是**逐像素列独立**计算的(每列只取自己的 min/max),因此只构建条带覆盖的列
|
||||
// 就能得到与整块渲染**逐像素一致**的条带像素(条带外本来就由已有像素提供)。
|
||||
std::size_t columnFirst = 0;
|
||||
std::size_t columnLast = columns;
|
||||
if (m_stripRenderActive && m_stripX1 > m_stripX0 && columnWidth > 0.0f)
|
||||
{
|
||||
// 两侧各多含 1 列,避免条带边界处的抗锯齿/描边被切掉。
|
||||
const float c0 = (m_stripX0 - plotLeft) / columnWidth - 1.0f;
|
||||
const float c1 = (m_stripX1 - plotLeft) / columnWidth + 1.0f;
|
||||
columnFirst = (c0 <= 0.0f) ? 0 : static_cast<std::size_t>(c0);
|
||||
const std::size_t wantLast =
|
||||
(c1 <= 0.0f) ? 0 : static_cast<std::size_t>(c1) + 1;
|
||||
columnLast = std::min(columns, wantLast);
|
||||
if (columnLast <= columnFirst)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// 从条带第一列对应的源时刻开始二分。
|
||||
const double tFirst =
|
||||
viewMin + viewSpan * (static_cast<double>(columnFirst) / static_cast<double>(columns));
|
||||
searchFrom = static_cast<std::size_t>(
|
||||
std::lower_bound(time.begin() + static_cast<std::ptrdiff_t>(begin), rangeEnd,
|
||||
tFirst - timeShift) -
|
||||
time.begin());
|
||||
}
|
||||
for (std::size_t column = columnFirst; column < columnLast; ++column)
|
||||
{
|
||||
// `t0/t1` 是**绘制时刻**的列边界;源时刻要减去时间偏移(偏移为 0 时二者相同)。
|
||||
const double t0 = viewMin + viewSpan * (static_cast<double>(column) / static_cast<double>(columns));
|
||||
@@ -842,8 +870,6 @@ namespace jdi
|
||||
{
|
||||
RebuildWideBuffer();
|
||||
}
|
||||
// 逐网格滚动跟随视界。⚠️ `RollWideBuffer` **不做整块重画**:内容过期 / 锚点
|
||||
// 过远的网格只登记到待重画队列,因此前台每帧的工作量恒定且很小。
|
||||
RollWideBuffer();
|
||||
// 预算内补画"待重画"的网格:**本帧最多一个**,其余留到后续帧
|
||||
//(即使 5 个网格同时需要重画,前台也不会在某一帧里做完整批而卡顿)。
|
||||
@@ -1048,9 +1074,36 @@ namespace jdi
|
||||
}
|
||||
|
||||
const bool drawLine = (resolved.style->mode != DrawMode::SCATTER);
|
||||
if (!drawLine || breaks.empty())
|
||||
if (!drawLine)
|
||||
{
|
||||
// ⚠️ **散点模式的高亮 = 只高亮数据点本身(不画连线)**(用户 2026-10-17 要求):
|
||||
// 旧实现直接 `return`,于是散点信号悬停时**只有拖拽提示、没有任何高亮**。
|
||||
// 这里给每个参与绘制的散点套一圈强调色圆环 —— 与「线段/点线」的折线光晕相比,
|
||||
// 正好是“去掉数据点之间连线的部分”(用户原话)。
|
||||
// ⚠️ 步长必须与 `PaintLane` 的散点绘制**同源**(`kMaxScatterPoints`),
|
||||
// 否则高亮圈会出现在没画出来的点上(或漏掉可见的点)。
|
||||
if (points.empty() || resolved.style == nullptr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
const float radius = static_cast<float>(
|
||||
ClampDouble(static_cast<double>(resolved.style->lineWidth) * kScatterRadiusScale,
|
||||
static_cast<double>(kMinScatterRadius), static_cast<double>(kMaxScatterRadius)) *
|
||||
m_scale);
|
||||
const float ringRadius = radius + 2.5f * m_scale;
|
||||
const std::size_t scatterStride =
|
||||
std::max<std::size_t>(1, points.size() / kMaxScatterPoints);
|
||||
gd::Pen ring(ToGdiColor(WithAlpha(m_theme->accent, 0x88)), 1.6f * m_scale);
|
||||
for (std::size_t index = 0; index < points.size(); index += scatterStride)
|
||||
{
|
||||
const gd::PointF& point = points[index];
|
||||
g.DrawEllipse(&ring, point.X - ringRadius, point.Y - ringRadius, ringRadius * 2.0f,
|
||||
ringRadius * 2.0f);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (breaks.empty())
|
||||
{
|
||||
// 散点模式没有光晕(原实现同样只对折线绘制光晕)。
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2804,6 +2857,7 @@ namespace jdi
|
||||
const double savedViewMin = Tab().ViewMin();
|
||||
const double savedViewMax = Tab().ViewMax();
|
||||
const std::vector<LaneVisual> savedLanes = m_lanes;
|
||||
const float savedLaneContentHeight = m_laneContentHeight;
|
||||
const bool savedFastFrame = m_fastFrame;
|
||||
const bool savedRenderWide = m_renderWide;
|
||||
const float savedScroll = Tab().LaneScroll();
|
||||
@@ -2836,13 +2890,11 @@ namespace jdi
|
||||
// 这样图层里的横向网格线步长与前台一致,纵向平移才是纯粹的像素平移。
|
||||
m_wideTickSpan = laneSpan;
|
||||
m_wideTickHeight = laneHeight;
|
||||
BuildLaneVisuals();
|
||||
// 沿用**当前视界**的 Y 轴范围,否则幅值映射与前台不一致。
|
||||
for (std::size_t i = 0; i < m_lanes.size() && i < savedLanes.size(); ++i)
|
||||
{
|
||||
m_lanes[i].yMin = savedLanes[i].yMin;
|
||||
m_lanes[i].yMax = savedLanes[i].yMax;
|
||||
}
|
||||
// ⚠️⚠️ **不要在这里再调 `BuildLaneVisuals()`**:它会对**全部网格**重算
|
||||
// `ComputeLaneRange`(O(网格数)),而本函数只需要**被渲染的那一个网格**。
|
||||
// 253 个网格、30 个可见网格时,每帧 30 次补条 × 2 次 BuildLaneVisuals
|
||||
// = 单帧 **26.8ms**(用户 2026-10-17 报告:三明治加载后拖拽视界卡顿)。
|
||||
// 该网格的矩形/幅值范围已在下方显式设置(`m_lanes` 其余成员保持原值不变)。
|
||||
// ⚠️ **该网格的幅值映射必须以本次渲染的纵向锚点为准**(而不是"当前 `yMax`"):
|
||||
// - 整块重画 / 纵向补条:锚点就是当前 `yMax`(纵向补条是滚动后的值),二者一致;
|
||||
// - **横向补细条**:只重画一条竖带,其余像素仍在**原锚点**上;若这里用当前 `yMax`
|
||||
@@ -2897,27 +2949,16 @@ namespace jdi
|
||||
m_stripX1 = savedStripX1;
|
||||
|
||||
// 恢复布局 / 视图 / 网格。
|
||||
// ⚠️ **必须调用 `BuildLaneVisuals()`**(而不是只把 `m_lanes` 拷回去):它会按正常布局
|
||||
// 重算 `m_laneContentHeight`。该值参与内容指纹(`LaneLayerKey`),若残留上面"宽窗口"
|
||||
// 布局算出的值,算出的图层指纹就与实际渲染不符 → 每帧都判定图层"过期"重画,白白耗性能。
|
||||
// ⚠️ 这里**不调 `BuildLaneVisuals()`**(同上面的原因:O(网格数) 的 `ComputeLaneRange`
|
||||
// 在每次补条时都跑一遍是拖拽卡顿的主因)。`m_lanes` 在进入本函数时已经被完整拷贝,
|
||||
// 直接拷回即可 —— 它本来就是正常布局下的正确结果(矩形/幅值/内容高度都在里面)。
|
||||
m_area = savedArea;
|
||||
m_plotRect = savedPlot;
|
||||
Tab().ViewMin() = savedViewMin;
|
||||
Tab().ViewMax() = savedViewMax;
|
||||
BuildLaneVisuals();
|
||||
// `BuildLaneVisuals()` 内部会 `ClampLaneScroll()`,用宽窗口布局可能夹出不同的滚动量,
|
||||
// 因此把滚动量与各网格幅值范围一并还原回进入本函数前的状态。
|
||||
m_lanes = savedLanes;
|
||||
m_laneContentHeight = savedLaneContentHeight;
|
||||
Tab().LaneScroll() = savedScroll;
|
||||
for (std::size_t i = 0; i < m_lanes.size() && i < savedLanes.size(); ++i)
|
||||
{
|
||||
m_lanes[i].yMin = savedLanes[i].yMin;
|
||||
m_lanes[i].yMax = savedLanes[i].yMax;
|
||||
}
|
||||
// 恢复该网格的矩形(`BuildLaneVisuals` 已按正常布局给出,这里只为稳妥保持一致)。
|
||||
if (laneIndex < static_cast<int>(m_lanes.size()) && laneIndex < static_cast<int>(savedLanes.size()))
|
||||
{
|
||||
m_lanes[static_cast<std::size_t>(laneIndex)] = savedLanes[static_cast<std::size_t>(laneIndex)];
|
||||
}
|
||||
|
||||
layer.wideMin = wideMin;
|
||||
layer.span = span;
|
||||
@@ -3070,6 +3111,8 @@ namespace jdi
|
||||
{
|
||||
return;
|
||||
}
|
||||
// 本帧还剩多少个「重新居中」额度(见 `kLayerRollsPerFrame`)。
|
||||
int rollBudget = kLayerRollsPerFrame;
|
||||
|
||||
// ⚠️ **逐网格独立滚动**:每个图层有自己的时间映射(`layer.wideMin`),
|
||||
// 因此位移必须按各自的锚点分别计算。曾用**一个全局锚点**给所有图层算同一个位移,
|
||||
@@ -3100,6 +3143,20 @@ namespace jdi
|
||||
const int shift = static_cast<int>(std::llround(desired - static_cast<double>(margin)));
|
||||
// 内容(外观 / 网格几何 / 幅值**跨度** / 可见性…)与当前状态不一致。
|
||||
const bool contentStale = (layer.key != LaneLayerKey(laneIndex));
|
||||
|
||||
// ⚠️⚠️ **不在可视区(含预取带)内的网格一律不做像素级滚动**:
|
||||
// 它们的像素当前根本不会被贴出来,滚了也看不见;而每次补条都要重跑
|
||||
// `BuildLaneVisuals()`(O(网格数))—— 253 个网格时这是拖拽卡顿的主因。
|
||||
// 只登记待重画(可视网格会被 `RenderPendingLaneRebuilds` 优先处理),
|
||||
// 等它们真的要进入视区时再按新视界重建(期间由 `BlitWideBuffer` 的底衬兜底)。
|
||||
if (!LaneVisuallyRelevant(laneIndex, 1))
|
||||
{
|
||||
if (contentStale)
|
||||
{
|
||||
QueueLaneRebuild(laneIndex);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// 纵向:幅值**平移**不改内容,只改贴图偏移(与横向同构)。
|
||||
int vOffset = 0;
|
||||
if (layer.pxPerValue > 0.0)
|
||||
@@ -3108,29 +3165,19 @@ namespace jdi
|
||||
std::llround((layer.anchorYMax - m_lanes[index].yMax) * layer.pxPerValue));
|
||||
}
|
||||
|
||||
// ⚠️⚠️ **"距离太远"绝不允许退化成"排队等后续帧重画"**:
|
||||
// 长距离拖拽(用户按着中键把视界移动好几个网格高、或横向拖很远)时,
|
||||
// 每一帧的位移都会超过余量;若此时只登记待重画,就会出现
|
||||
// * 本帧 `BlitWideBuffer` 取不到源行 → 网格里出现**空白条带**;
|
||||
// * 每帧都重新入队 → 一帧只补 1 个网格 → **持续抽搐**且总是补不完。
|
||||
// 正确做法是:
|
||||
// * 位移不超过画布 → **滚动**(O(1) BitBlt + 只补画暴露的细条);
|
||||
// * 位移超过画布(内容整体滚出)→ 本帧**立刻整块重画并重新居中**(一次性),
|
||||
// 等价于一次普通的整块渲染(实测 0.4ms),不会累积漂移也不会留空白。
|
||||
// 只有**内容真的变了**(`contentStale`)才交给待重画队列(它可以等后续帧,
|
||||
// 因为画面本身仍然是"旧内容",不缺口)。
|
||||
const bool fullRedraw = (std::abs(shift) >= bitmapWidth) ||
|
||||
(std::abs(vOffset) >= layer.height);
|
||||
if (fullRedraw)
|
||||
// ⚠️⚠️ **位移太大时【作废图层】,绝不能在前台同步整块重画**(用户 2026-10-17 报告):
|
||||
// 旧实现在这里“立刻整块重画并重新居中”。整块重画一条**巨型信号**(`VERY_BIG_DATA`
|
||||
// 25M 采样)实测 **20~130ms** —— 拖拽时只要碰到一个“离屏累积了大位移、刚刚进入预取带”
|
||||
// 的网格,就是一次明显卡顿(实测拖拽最坏帧 28ms、其中 `RollWideBuffer` 占 130ms)。
|
||||
// 现在:位移超出可廉价滚动的范围 ⇒ **作废图层 + 登记待重画**,本帧由 `BlitWideBuffer`
|
||||
// 的**网格底衬**兜底(正是用户要求的“没来得及画信号就按只有网格叠底的状态继续”),
|
||||
// 波形等视界停下后由 `RenderPendingLaneRebuilds` 按预算逐个补。
|
||||
const bool tooFarToRoll = (std::abs(shift) >= kLayerRollMaxShiftPixels) ||
|
||||
(std::abs(vOffset) >= layer.height);
|
||||
if (tooFarToRoll)
|
||||
{
|
||||
// 两个方向都滚出画布:直接重新居中 + 整块重画(一次搞定,避免"滚动 + 巨幅补条")。
|
||||
if (contentStale || std::abs(shift) >= bitmapWidth)
|
||||
{
|
||||
QueueLaneRebuild(laneIndex);
|
||||
}
|
||||
// 整块重画:重新居中 + 纵向锚点取**当前** `yMax`。
|
||||
RenderLaneLayerRegion(laneIndex, layer, 0, bitmapWidth, 0, 0, LayerDefaultWideMin(),
|
||||
m_lanes[static_cast<std::size_t>(laneIndex)].yMax);
|
||||
layer.valid = false;
|
||||
QueueLaneRebuild(laneIndex);
|
||||
continue;
|
||||
}
|
||||
if (contentStale)
|
||||
@@ -3189,6 +3236,23 @@ namespace jdi
|
||||
continue;
|
||||
}
|
||||
|
||||
// ⚠️ **横向滚动量按量子量化**(见 `kLayerRollQuantumX`):
|
||||
// `BlitWideBuffer` 贴图时本来就按 `(ViewMin - layer.wideMin)` 算源偏移,
|
||||
// 因此视界小幅移动**不需要**重新居中(不必每帧补一条窄竖带)。
|
||||
// 拖拽时每帧位移只有几像素,旧实现就是“每帧补一次条 × 每个可见网格”,
|
||||
// 而每次补条都要重跑 `BuildLaneVisuals()`(O(网格数))⇒ 明显卡顿。
|
||||
if (std::abs(shift) < kLayerRollQuantumX)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// ⚠️ **每帧只重居中有限的几个网格**(见 `kLayerRollsPerFrame`):
|
||||
// 不重居中也不会画错(贴图按 `wideMin` 算源偏移),因此可以安全地分帧完成。
|
||||
if (rollBudget <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
--rollBudget;
|
||||
|
||||
// 整数像素自贴图平移:内容整体移动 `shift` 像素。
|
||||
if (shift > 0)
|
||||
{
|
||||
@@ -6568,7 +6632,12 @@ namespace jdi
|
||||
}
|
||||
|
||||
const LaneVisual& lane = m_lanes[static_cast<std::size_t>(laneIndex)];
|
||||
const std::wstring text = resolved.data->name + L" · 按住拖拽可移入其它网格";
|
||||
// ⚠️ 「选区放大」模式下**仍保留悬停高亮**,但该模式下拖动是框选、不是拖信号
|
||||
// ⇒ 提示文案去掉“按住拖拽可移入其它网格”的尾缀,只提示信号名(用户 2026-10-17 要求)。
|
||||
const std::wstring text =
|
||||
(Tab().ZoomMode() == PlotZoomMode::SELECT)
|
||||
? resolved.data->name
|
||||
: (resolved.data->name + L" · 按住拖拽可移入其它网格");
|
||||
|
||||
// 提示跟随鼠标:在可拖拽区域内持续移动时,提示条也要跟着走。
|
||||
const float textWidth = UiMeasureTextWidth(m_fonts->uiSmall, text) + 16.0f * m_scale;
|
||||
|
||||
Reference in new issue
Block a user