diff --git a/Components/PlotView/Src/PlotViewFeatures.cpp b/Components/PlotView/Src/PlotViewFeatures.cpp index 25483b8..0065773 100644 --- a/Components/PlotView/Src/PlotViewFeatures.cpp +++ b/Components/PlotView/Src/PlotViewFeatures.cpp @@ -769,23 +769,98 @@ namespace jdi { return false; } - // 鸽巢原理:有效点总数都不足“每格 5 个” ⇒ 必然存在一个稀疏小格(O(1) 充分判定)。 - const std::size_t need = static_cast(cells) * - static_cast(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(cells); if (!(cellSpan > 0.0) || !std::isfinite(cellSpan)) { return false; } + // 只覆盖「有数据的显示时间范围」所涉及的格子(闭区间)。 + int firstCell = static_cast(std::floor((dataMin - viewMin) / cellSpan)); + int lastCell = static_cast(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(consideredCells) * static_cast(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(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(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) diff --git a/Components/PlotView/Src/PlotViewInteraction.cpp b/Components/PlotView/Src/PlotViewInteraction.cpp index 472d432..3096119 100644 --- a/Components/PlotView/Src/PlotViewInteraction.cpp +++ b/Components/PlotView/Src/PlotViewInteraction.cpp @@ -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); } diff --git a/Components/PlotView/Src/PlotViewInternal.hpp b/Components/PlotView/Src/PlotViewInternal.hpp index 7f2f8f4..b2369f2 100644 --- a/Components/PlotView/Src/PlotViewInternal.hpp +++ b/Components/PlotView/Src/PlotViewInternal.hpp @@ -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 「图层预热」两次请求重绘之间的最小间隔(毫秒)。 * diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 454d8cc..601a2b8 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -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(c0); + const std::size_t wantLast = + (c1 <= 0.0f) ? 0 : static_cast(c1) + 1; + columnLast = std::min(columns, wantLast); + if (columnLast <= columnFirst) + { + return; + } + // 从条带第一列对应的源时刻开始二分。 + const double tFirst = + viewMin + viewSpan * (static_cast(columnFirst) / static_cast(columns)); + searchFrom = static_cast( + std::lower_bound(time.begin() + static_cast(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(column) / static_cast(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( + ClampDouble(static_cast(resolved.style->lineWidth) * kScatterRadiusScale, + static_cast(kMinScatterRadius), static_cast(kMaxScatterRadius)) * + m_scale); + const float ringRadius = radius + 2.5f * m_scale; + const std::size_t scatterStride = + std::max(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 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(m_lanes.size()) && laneIndex < static_cast(savedLanes.size())) - { - m_lanes[static_cast(laneIndex)] = savedLanes[static_cast(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(std::llround(desired - static_cast(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(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(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; diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 0d76a52..582256f 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -2832,6 +2832,103 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } --- +### 9.45 多层加载时拖拽卡顿根治 + 高亮命中「线段中段」/ 散点高亮 + 选区放大也要高亮 + 自动点线判定修正与规则收窄(本轮) + +> 用户 2026-10-17 第九轮(本机)。四项需求: +> ① 三明治加载(MF4 + `VERY_BIG_DATA` + MF4,全部显示)后点选信号跳转到中间的巨型网格, +> 用左/中键拖拽视界明显卡顿,且之后在任何位置拖拽都卡; +> ② 放大到自动点线后,鼠标只有靠近**数据点**才高亮,**两点之间的连线**不行;散点模式只有拖拽提示没有高亮; +> ③ 「选区放大」模式下完全没有悬停高亮(旧规则是刻意禁掉的); +> ④ 同时加载 MF4 + `VERY_BIG_DATA` 时 MF4 信号在**默认视图**就被误判成稀疏、自动切点线。 +> 离屏验证:新增 `.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;`make release`/`make debug` 零警告。 + +#### 一、拖拽卡顿的**三个叠加根因**(用逐阶段计时定位,实测 39ms → 8.8ms/帧) + +1. ⚠️⚠️ **每次「横向补条」都重跑 `BuildLaneVisuals()`(O(网格数))**: + `RollWideBuffer` 每帧把每个**可见**网格的图层滚回“居中”位置并补一条 2~8px 宽的竖带, + 而 `RenderLaneLayerRegion` 内部有**两次** `BuildLaneVisuals()`(每次都对全部网格算 `ComputeLaneRange`)。 + 253 个网格 × 30 个可见网格 ⇒ 单帧 `RollWideBuffer` **26.8ms**。 + ✓ `RenderLaneLayerRegion` 不再调用 `BuildLaneVisuals()`:进入前完整快照 `m_lanes` 与 + `m_laneContentHeight`,结束时直接拷回(`m_lanes = savedLanes`)。它只渲染**一个**网格, + 其余网格的几何保持原值即可。 +2. ⚠️ **重新居中本来就不是必需的**:`BlitWideBuffer` 贴图时按 `(ViewMin - layer.wideMin) * scale` + 算源偏移,只要还在位图覆盖范围内,**不滚动也画得对**。因此改为: + * `kLayerRollQuantumX = 32`:位移不足一个量子就不滚; + * `kLayerRollsPerFrame = 3`:每帧最多重居中 3 个网格(分摊到多帧,纯优化可以随便分摊)。 +3. ⚠️⚠️ **位移过大时“立刻整块重画”**是巨型信号的灾难:离屏网格被跳过像素跟随,位移会累积; + 一旦它进入预取带,旧实现会**同步整块重画**(25M 采样 ⇒ **20~130ms**,实测拖拽最坏帧 28ms)。 + ✓ `kLayerRollMaxShiftPixels = 160`:超出就**作废图层 + 登记待重画**,本帧由 `BlitWideBuffer` + 的**网格底衬**兜底(正是用户要求的“没来得及画信号就按只有网格叠底继续拖”)。 + 同时 `RollWideBuffer` **只处理可视/预取带内的网格**(离屏网格不做任何像素工作)。 +4. 附带:**包络带路径也支持横向补条裁剪** —— 旧实现只对“逐点描边”路径按条带裁剪, + 包络带总是构建**整幅可见范围**的包络 ⇒ 巨型信号一条 32px 竖带要 **5ms**。包络是**逐像素列独立** + 计算的,因此只构建条带覆盖的列即可(像素与整块渲染一致)。 + +**实测(`hoverdrag`,MF4 + VERY_BIG_DATA + MF4 = 253 网格,点选后拖拽 40 帧)**: + +| 指标 | 修前 | 修后 | +| --- | --- | --- | +| 拖拽平均帧 | **39.2ms** | **8.2ms** | +| 拖拽最坏帧 | **47.9ms** | **10.3ms** | +| `RollWideBuffer` | 26.8ms | 0.03ms | +| 远离巨型网格处拖拽 | 43.0ms | 9.4ms | + +#### 二、高亮命中「两点之间的连线」(用户报告:只有数据点附近才高亮) + +- ⚠️ 根因在 `HitTestSignal` 的**横向窗口**:它用 `lower_bound(fx-tol)` / `upper_bound(fx+tol)` + 取顶点区间,然后只扫 `[windowFirst, windowLast)` 的线段。放大到点线模式后相邻点相距几十像素, + 鼠标落在两点**中间**时两个端点都在窗口外 ⇒ `windowFirst >= windowLast` ⇒ 直接跳过。 +- ✓ 修法:线段扫描上界改为 `min(stop, windowLast + 1)`(必须包含“一端在窗口内、另一端在窗口外” + 的跨窗口线段);散点命中的顶点范围也向两侧各扩一个。 +- 实测:11 段长线段的中点,命中 **0/11 → 11/11**。 + +#### 三、散点模式的高亮 = 数据点高亮环(不含连线) + +- `PaintSignalHalo` 原来对 `DrawMode::SCATTER` 直接 `return` ⇒ 散点信号悬停**只有提示、没有高亮**。 +- ✓ 现在散点模式给每个**参与绘制**的散点套一圈半透明强调色圆环(步长与绘制同源 `kMaxScatterPoints`); + 线段 / 点线仍用原来的折线光晕(用户:“原来的线段和点线模式的高亮去掉数据点之间连线的部分似乎就还不错”)。 +- ⚠️ 信号列表 / 图例 / 样式区「目标」行的联动高亮走的是**同一条** `PaintSignalHalo` 路径(`m_externalHoverSignal`), + 因此散点信号在列表里悬停时同样会亮起数据点环。 + +#### 四、「选区放大」模式:保留悬停高亮,但不允许拖信号 + +- ⚠️ 旧规则(§9.34 之前)在 `OnMouseMove` 里直接 `if (ZoomMode == NONE)` 才做 `HitTestSignal`。 +- ✓ 现在**任何缩放模式**都做悬停命中;`OnLButtonDown` 在 `SELECT` 模式下仍然直接进入 `RUBBER_SELECT` + (信号拖拽只在 `NONE` 模式下成立),因此“能高亮、能看提示、但拖不动”。 +- ✓ 提示文案在 `SELECT` 模式下**只显示信号名**(去掉“· 按住拖拽可移入其它网格”的尾缀)。 + +#### 五、自动点线误判:判定范围必须限制在「该信号真正有数据的时间段」 + +- ⚠️⚠️ **根因**:`MinorCellSparse` 把**整个视界**切成小格。同时加载 `VERY_BIG_DATA`(跨度 ~130s) + 与 MF4(数据只覆盖 ~11s)时,MF4 信号在 **90% 的格子里本来就没有任何采样点** ⇒ + “存在一个格子内 < 5 个点”恒成立 ⇒ **默认视图就被判成稀疏、自动切点线**。 + (用户猜测是“用了缺省绘制的抽稀点”—— 实际不是抽稀,是**空格子**;但现象与猜测一致。) + 实测:248 个 MF4/中等信号,**248 → 0** 个被误判。 +- ✓ 修法:先用「可见数据的显示时间跨度」`[dataMin, dataMax]` 收窄参与的格子(`firstCell..lastCell`), + 鸽巢判定也用**参与格子数**作分母;端点无效(NaN)时做 64 步有界探测并回退到轴区间。 + +#### 六、自动点线**只对「线段」模式生效**(用户本次要求) + +- ✔ `DrawMode::LINE` → 放大时自动切 `LINE_SCATTER`;退出时(用户没手动改过)恢复 `LINE`。 +- ✘ `DrawMode::SCATTER`:**不**自动切换(散点已足够提示数据点位置,再叠连线反而干扰)。 +- ✘ 已经是 `LINE_SCATTER` 的:不动。 +- ⚠️ 用户手动改过样式(`autoScatterUserOverride`)时**不再自动切换**,避免“改完下一帧又被改回去”。 +- 实测:LINE → 放大后 `mode=2 auto=1` → 适应视图后 `mode=0 auto=0`;SCATTER → 放大后 `mode=1 auto=0`。 + +#### 七、⚠️ 方法论教训 + +1. **绝对毫秒阈值会被机器状态污染**:本轮同一份**未改动的基线提交**在几小时后跑同一探针, + 数值从 11ms 变成 26ms(普通整帧从 8ms 变成 21ms)。⇒ 断言必须用**相对基准** + (如“同一时段的普通整帧耗时”或“同一时段的手动拖滚动条耗时”),否则假失败。 +2. **printf 的参数个数必须与格式串匹配**:本轮探针漏传参数导致读栈上垃圾, + 把 `roll=130.49`(其实是 span 值)当成真实耗时,误导了排查方向。 +3. **先分离“是否真的贵”再优化**:滚动只是“把位图重新居中”,而贴图本来就按 `wideMin` 算偏移 ⇒ + **不滚动也画得对**。识别出这一点后,量子化 + 每帧限量就都是安全的。 + +--- + ## 10. 变更日志(本文件维护记录) | 日期 | 设备/模型 | 摘要 | @@ -2896,6 +2993,8 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } | 2026-10-17 | DeepSeek-V4.1-Flash(**本机**) | **滚动动画根治(巨型信号不再影响滚动)+「N 项」计数点击语义 + 浮动面板首现右上角 + 开关/关闭面板卡顿(详见 §9.44)**。① **滚动卡顿真因**:滚动到**从未建过图层**的区域时 `BlitWideBuffer()` 一个网格都贴不出像素 ⇒ 返回 `false` ⇒ 退化成**整幅重绘** ⇒ 每帧重画全部可见网格的波形(含千万级采样的巨型信号),实测 **20~35ms**(手动拖滚动条只要 ~6ms)。修法:只要还能画**网格底衬**(`backing` 非空)就返回 true ⇒ 动画均值 23→**11.2ms**、最坏 35.6→**13.1ms**,含 5 个巨型信号网格的帧 33→**~11ms**。② 新增 `Application::m_panelDirty`:面板动画帧只重绘面板+分隔条(−2.7ms/帧)。③ **「N 项」计数**独立为 `RowPart::GROUP_COUNT`(`GroupStatsRect` 为唯一几何来源),任何点击频率只折叠展开、绝不进改名;改名计时只对 `BODY` 记录。④ **浮动面板首现位置**改用「已显示面板序数」错开(旧实现用 `laneIndex`,上百网格时被夹到右下角),实测第一个面板 `topMargin=rightMargin=16*scale`(正上右角),6 个面板仍在顶部 22%。⑤ **开关/关闭面板卡顿真因**:`SetLane*` 末尾的 `RebuildWideBuffer()` **同步重渲染全部网格图层**(129 网格含巨型信号 ⇒ 几百毫秒);改用 `EnsureLaneLayersPrefetched()`(只在图层数与网格数不符时整批建立,其余登记待重画队列)⇒ 几百毫秒 → **≤22ms**。验证:新增 `.preflight/perf/lanescroll_main.cpp`(`lanescroll.exe`)**23/23**(逐帧耗时 + 巨型网格可见数关联、计数热区、面板位置、开着巨型信号的开关/关闭耗时);`verify` 49/49、`panelanim` 158/158、`datacheck` 69/69、`edgecheck` 46/46、`errcheck` 19/19、`gridanim` 93/93;`make release`/`make debug` 零警告。§11.2 新增第 134–140 条。 | +| 2026-10-17 | DeepSeek-V4.1-Flash(**本机**) | **多层加载拖拽卡顿根治 + 高亮命中线段中段 / 散点高亮 + 选区放大也高亮 + 自动点线判定与规则修正(详见 §9.45)**。① **拖拽卡顿(39.2→8.2ms 均值、47.9→10.3ms 最坏)**:**(a)** `RenderLaneLayerRegion` 每次补条都调两次 `BuildLaneVisuals()`(O(网格数))⇒ 253 网格时 `RollWideBuffer` 单帧 **26.8ms**;改为快照/拷回 `m_lanes`;**(b)** 横向“重新居中”纯属优化 ⇒ `kLayerRollQuantumX=32` + `kLayerRollsPerFrame=3`;**(c)** 位移过大时旧实现**同步整块重画**(巨型信号 20~130ms)⇒ `kLayerRollMaxShiftPixels=160` 作废图层交给底衬;`RollWideBuffer` 跳过离屏网格;**(d)** 包络带路径也支持补条裁剪(巨型信号一条 32px 竖带 5ms → 亚毫秒)。② **高亮命中**:`HitTestSignal` 横向窗口少算一个顶点 ⇒ 放大后两点**中间的连线**完全点不中(0/11 → 11/11);散点模式新增“数据点高亮环”(不含连线),列表/图例联动同源。③ **选区放大**恢复悬停高亮(仍不可拖信号),提示只显示信号名。④ **自动点线误判**:判定范围未限制在信号有数据的时段 ⇒ 长跨度文件 + 短信号时 MF4 信号默认视图全部误判(248→0);并改为**只对 `DrawMode::LINE` 生效**(SCATTER 不切换、用户手动改过不切换)。验证:新增 `.preflight/perf/hoverdrag_main.cpp` **20/20**;`verify` 49/49、`panelanim` 158/158、`datacheck` 69/69、`edgecheck` 46/46、`errcheck` 19/19、`gridanim` 93/93、`lanescroll` 23/23;`make release`/`make debug` 零警告。⚠️ 探针的绝对毫秒阈值改为**相对基准**(同一基线代码在不同时段可差 2~3 倍)。§11.2 新增第 141–150 条。 | + ## 11. 跨设备协作须知(起源设备视角) > **本文件是唯一权威记忆**。所有设备 / Agent 共用它,**不要再新建 `agent_mem_origin.md` 等旁支记忆文件**。 @@ -3573,6 +3672,41 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } ⚠️ 「快速双击进改名」的计时只对 `RowPart::BODY` 记录 —— 否则“点计数、紧接着点名称”会被误判成双击。 140. ⚠️ **“修完感觉没变”时要重新插桩定位**:本轮在 `Paint` 里插 5 个 `QueryPerformanceCounter` 才定位到 “宽基底分支内部几乎不耗时 ⇒ 一定走了别的分支(整幅重绘)”。逐阶段计时比“改代码做 A/B”快得多。 +141. ⚠️⚠️ **`RenderLaneLayerRegion` 内部绝不能调用 `BuildLaneVisuals()`**(本轮实测:253 网格时 + `RollWideBuffer` 单帧 **26.8ms**,全是“每次补条 × 2 次 O(网格数) 的 `ComputeLaneRange`”): + 它只渲染**一个**网格,进入前快照 `m_lanes` / `m_laneContentHeight`,结束时直接拷回即可。 +142. ⚠️ **图层“横向重新居中”是纯优化,不是正确性要求**:`BlitWideBuffer` 按 + `(ViewMin - layer.wideMin) * scale` 算源偏移 ⇒ 不滚动也画得对(只要还在位图覆盖内)。 + 因此必须:`kLayerRollQuantumX = 32`(不足一个量子不滚)+ `kLayerRollsPerFrame = 3`(分帧摊销)。 +143. ⚠️⚠️ **位移过大时绝不可以在前台同步“整块重画”**:巨型信号(25M 采样)一次整块重画 + **20~130ms**(实测拖拽最坏帧 28ms)。超出 `kLayerRollMaxShiftPixels = 160` 就**作废图层 + 登记待重画**, + 本帧由 `BlitWideBuffer` 的网格底衬兜底。 + ⚠️ 同时 `RollWideBuffer` 必须**跳过不在可视/预取带内的网格**(离屏网格做像素工作纯属浪费)。 +144. ⚠️ **包络带路径也要支持横向补条裁剪**:包络是**逐像素列独立**计算的,只构建条带覆盖的列即可; + 旧实现对包络带总是构建整幅可见范围 ⇒ 巨型信号一条 32px 竖带要 5ms。 +145. ⚠️⚠️ **折线命中测试的横向窗口必须包含“跨窗口线段”**:`upper_bound(fx+tol)` 给出的是 + **第一个超出容差的顶点**,而“一端在窗口内、另一端在窗口外”的线段正是鼠标所在的那一段 ⇒ + 线段扫描上界必须是 `min(stop, windowLast + 1)`。否则放大到点线模式后 + **两点之间的连线完全点不中**(用户 2026-10-17 报告)。 +146. ⚠️ **散点模式也要有悬停高亮**(用户 2026-10-17 要求):`PaintSignalHalo` 对 `DrawMode::SCATTER` + 不能再直接 `return`,而应给每个参与绘制的散点套强调色圆环(步长与绘制同源 `kMaxScatterPoints`)。 + ⚠️ 列表 / 图例 / 样式区「目标」行的联动高亮走**同一条** `PaintSignalHalo` 路径,改一处即可全覆盖。 +147. ⚠️ **「选区放大」模式必须保留悬停高亮**(用户 2026-10-17 修订 §9.34 的旧规则): + `OnMouseMove` 里不应再有 `ZoomMode() == NONE` 的门控;`OnLButtonDown` 在 `SELECT` 模式下仍然直接 + 进入 `RUBBER_SELECT`(拖信号只在 `NONE` 模式成立)⇒ “能高亮、能看提示、但拖不动”。 + 该模式下的提示文案**只显示信号名**(去掉“· 按住拖拽可移入其它网格”尾缀)。 +148. ⚠️⚠️ **「每小格内数据点 < N」的判定必须限制在【该信号真正有数据的时间段】内**: + 否则同时加载“跨度很长的大文件 + 跨度很短的信号”时,短信号在**大部分格子里本来就没有采样点** ⇒ + “存在空格子”恒成立 ⇒ **默认视图就被误判成稀疏、自动切点线**(用户 2026-10-17 报告; + 实测 248 个 MF4/中等信号全部误判)。✓ 用可见数据的 `[dataMin, dataMax]` 收窄参与的格子 + (`firstCell..lastCell`),鸽巢判定的分母也改用**参与格子数**。 +149. ⚠️ **「放大自动点线」只对 `DrawMode::LINE` 生效**(用户 2026-10-17 要求): + `SCATTER` 不换(散点已足够提示数据点位置)、`LINE_SCATTER` 不动;用户手动改过 + (`autoScatterUserOverride`)时也不切换,避免“改完下一帧又被改回去”。 +150. ⚠️⚠️ **探针的绝对毫秒阈值不可靠**:同一份基线代码在不同时段跑,数值可差 2~3 倍 + (本轮 11ms → 26ms,普通整帧 8ms → 21ms)⇒ 断言必须用**同一时段测得的相对基准** + (普通整帧耗时 / 手动拖滚动条耗时)。⚠️ 另外 **printf 的参数必须与格式串数量一致**, + 否则读到栈上垃圾值(本轮把 `130.49` 当成真实耗时,误导排查)。 ### 11.3 离屏验证方法学(本机偏好,强烈建议沿用)