diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 88dcdd3..1916c22 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -917,13 +917,25 @@ namespace jdi /** * @brief 将两条光标的时间夹取到当前可见时间范围内。 * - * ⚠️ 现在**只在拖拽光标时**使用。视图变化(平移/放大/自适应)必须**不**移动光标: + * ⚠️ 拖拽光标时使用;**平移**视界后也要用(见 `ClampCursorsAfterPan`)。 + * 而**离散**的视图变化(选区放大 / 适应视图 / 按网格时间轴展开)必须**不**移动光标: * 光标允许滞留在网格可视区域之外,此时仅把它的取值/时间读数贴在网格边缘显示。 * * @param [IN] which 要夹取的光标序号(1 或 2)。 */ void ClampCursorToView(int which); + /** + * @brief **平移视界**之后把时间光标夹回可见区间(单/双光标都处理)。 + * + * ⚠️ 与「离散视图变化不移动光标」并不矛盾:选区放大 / 适应视图 / 按网格时间轴展开 + * 仍然不动光标(用户可以先量好再自由探索,光标读数贴在边缘可读、可拖回来); + * 但**平移**是连续手势 —— 尤其在深放大区间,光标一旦被落在屏外就会立刻"跑飞" + * 很远(一屏的平移就是几十甚至上百倍可视跨度),用户会以为光标丢失。 + * 因此在平移(中键/左键拖拽、Shift+滚轮、方向键)之后夹一次。 + */ + void ClampCursorsAfterPan(); + /** * @brief 计算双光标之间的时间差(绝对值)。 * @return 时间差;非双光标模式返回 0。 @@ -2274,9 +2286,18 @@ namespace jdi HBITMAP bitmap = nullptr; /**< 离屏位图。 */ HGDIOBJ oldBitmap = nullptr; /**< DC 原选位图。 */ int width = 0; /**< 位图宽度(含左右各半屏余量)。 */ - int height = 0; /**< 位图高度(= 网格高)。 */ + int height = 0; /**< 位图高度(含上下各半个网格高的余量)。 */ + int marginY = 0; /**< 位图在**可见网格上方**预留的像素行数(纵向预渲染余量)。 */ double wideMin = 0.0; /**< 位图 x=0 对应的时间。 */ double span = 0.0; /**< 渲染时的视界跨度(决定像素/时间)。 */ + /** + * @brief 渲染时的**纵向锚点**:该网格的 `yMax`(可见窗口顶行对应的数值)。 + * + * 与 `wideMin` 同理,平移(纵向是幅值平移)时只移动**贴图偏移**、不重画像素: + * 需要贴到屏幕顶行的位图行 = `marginY + round((anchorYMax - 当前 yMax) * pxPerValue)`。 + */ + double anchorYMax = 0.0; + double pxPerValue = 0.0; /**< 位图纵向像素 / 数值单位(= 可见网格高 / 幅值跨度)。 */ std::uint64_t key = 0; /**< 内容指纹(含跨度、网格几何与外观)。 */ bool valid = false; /**< 是否有可显示的像素。 */ }; @@ -2300,8 +2321,27 @@ namespace jdi */ bool RenderLaneLayerRegion(int laneIndex, LaneLayer& layer, int stripX, int stripW, double wideMin); + /** + * @brief 前台补画各网格的**分界线**(网格顶部那条较重的横线)。 + * + * ⚠️ 分界线锚定在网格的**屏幕位置**(`lane.rect.Y`),幅值平移时它**不跟着移动**, + * 因此不能烘进"纵向可平移"的图层里(否则纵向拖动时它会跟着波形跑)。 + * 整幅重绘由 `PaintLane` 直接绘制;宽基底快路径(贴图层像素)必须在这里补画一次。 + */ + void PaintLaneDividers(gd::Graphics& g) const; /** @brief 「以当前视界居中」的默认图层映射(位图 `x=0` 对应的时间)。 */ double LayerDefaultWideMin() const; + /** @brief 图层位图的横向余量(左右各半屏 → 可横向平移 ±半屏)。 */ + int LayerMarginX() const; + /** + * @brief 图层位图的纵向余量(上下各半个网格高 → 可纵向平移 ±半个网格高)。 + * + * ⚠️ 纵向余量让「拖拽平移的纵向分量」(幅值平移)与横向平移一样**只改贴图偏移**, + * 不必每帧重画整块图层 —— 这正是「左右平移流畅、上下平移卡顿」的根因所在。 + * 余量越大可连续拖拽的范围越大,但每个横向补条 / 整块重画的代价也按余量线性增长 + *(因此取「半个网格高」:一整屏的额外内容已足够,且不拖慢已经流畅的横向平移)。 + */ + int LayerMarginY(float laneHeight) const; /** * @brief 以当前视界为中心重建全部网格图层(同步;跨度变化 / 首帧 / 图层缺失时使用)。 @@ -2371,6 +2411,17 @@ namespace jdi bool m_renderWide = false; /**< 正在渲染信号图层(重入 `PaintLane` 时跳过装饰)。 */ + /** + * @brief 渲染信号图层时,横向网格线(幅值刻度)的「参考跨度 / 参考高度」。 + * + * ⚠️ **必须是前台可见网格的值**,而不是图层画布的(含纵向余量的)值: + * 图层的幅值刻度必须以该跨度为基准取「整齐步长」,否则画布里的网格线密度与 + * 整幅渲染不一致 —— 贴图后拖动时网格线会跳变。步长一致时刻度落在同一个 + * 绝对值格点上,于是纵向平移就是纯粹的像素平移(逐像素同源)。 + */ + double m_wideTickSpan = 0.0; + float m_wideTickHeight = 0.0f; + /** * @brief 本次 `PaintLane` 只绘制「装饰」(Y 轴刻度胶囊 + 图例 + 网格分界线)。 * diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 84610db..e1aefd1 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -1055,6 +1055,8 @@ namespace jdi Tab().ViewMin() += delta; Tab().ViewMax() += delta; ClampTimeView(); + // 方向键平移同样是平移:把光标留在可见区间内(与拖拽平移一致)。 + ClampCursorsAfterPan(); } void PlotView::ScrollLanesByFraction(double fraction) @@ -1260,6 +1262,19 @@ namespace jdi } } + void PlotView::ClampCursorsAfterPan() + { + if (m_tab == nullptr || !Tab().CursorEnabled()) + { + return; + } + ClampCursorToView(1); + if (Tab().CursorMode() == PlotCursorMode::DOUBLE) + { + ClampCursorToView(2); + } + } + std::uint32_t PlotView::CursorColor() const { // 两条光标统一使用同一样式与颜色,不按序号区分配色。 @@ -4067,6 +4082,9 @@ namespace jdi Tab().ViewMin() += delta; Tab().ViewMax() += delta; ClampTimeView(); + // 平移是连续手势:必须把光标留在可见区间内,否则深放大下一屏平移就能把光标 + // 甩到视界外很远(用户会认为光标“跑飞”、找不到)。 + ClampCursorsAfterPan(); } const int laneIndex = m_dragLane; @@ -4532,7 +4550,16 @@ namespace jdi m_hoverCursor = labelWhich; m_activeCursor = labelWhich; m_cursorHover = true; - m_cursorGrabOffset = x - static_cast(std::lround(CursorDisplayX(labelWhich))); + // 抓取偏移 = “手指按在读数标签的哪个位置”。 + // ⚠️ 但光标**滞留在视界之外**时读数被钉在网格边缘,此时抓取点与光标真实位置 + // 之间没有任何有意义的对应关系:仍然沿用偏移会让首次移动把目标送到视界之外 + //(再被夹回边缘),表现为“一拖就跳很远 / 光标钉在边缘不动”。 + // 因此这种情形下不带偏移:首次移动就把光标放在鼠标处(自然地拖回来)。 + bool offRight = false; + const bool offView = CursorOffViewSide(labelWhich, offRight); + m_cursorGrabOffset = + offView ? 0 + : (x - static_cast(std::lround(CursorDisplayX(labelWhich)))); return true; } } @@ -5061,6 +5088,8 @@ namespace jdi Tab().ViewMin() += sweep; Tab().ViewMax() += sweep; ClampTimeView(); + // Shift+滚轮也是平移:同样不能把光标落在视界之外(与拖拽平移一致)。 + ClampCursorsAfterPan(); return true; } @@ -5760,6 +5789,9 @@ namespace jdi std::vector wideTimeTicks; BuildTicks(wideViewMin, wideViewMax, wideTimeStep, wideTimeTicks); PaintTimeAxis(g, wideTimeTicks, wideTimeStep); + // 网格分界线:图层里**不含**它(它锚定在网格的屏幕位置,会被纵向偏移贴图带走), + // 因此在这里补画一次(与整幅重绘同一套颜色与线宽)。 + PaintLaneDividers(g); // 派生预览曲线:基底不含它们(拖参数滑块时基底不变、可直接复用像素)。 for (std::size_t i = 0; i < m_lanes.size(); ++i) { @@ -7043,8 +7075,12 @@ namespace jdi std::uint64_t PlotView::LaneLayerHash(int laneIndex) const { - // 单个网格图层的内容 = 位图高度(= 网格高度)+ 幅值范围 + 本网格各信号的 + // 单个网格图层的内容 = 位图高度(= 网格高度)+ 幅值**跨度** + 本网格各信号的 // 可见性与外观 + 图例文案(图例不在图层里,但一起计入无妨且更安全)。 + // + // ⚠️ **只计跨度、不计幅值的绝对范围**:图层的纵向锚点(`anchorYMax`)与 `wideMin` + // 一样是"贴图偏移"的来源,幅值整体平移不改变图层内容(只是贴图时纵向偏移若干像素), + // 因此纵向拖动不会让图层失效。真正改变内容的是**跨度**(决定像素/数值与刻度步长)。 std::uint64_t hash = 1469598103934665603ull; const auto mix = [&hash](std::uint64_t value) { @@ -7063,8 +7099,7 @@ namespace jdi } const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; mixFloat(visual.rect.Height); - mixFloat(static_cast(visual.yMin)); - mixFloat(static_cast(visual.yMax)); + mixFloat(static_cast(visual.yMax - visual.yMin)); const std::vector& signals = Tab().Lanes()[static_cast(laneIndex)].signals; mix(signals.size()); for (const SignalRef& ref : signals) @@ -7085,6 +7120,20 @@ namespace jdi return hash; } + int PlotView::LayerMarginX() const + { + const int width = static_cast(std::lround(m_plotRect.Width)); + return std::max(1, width / 2); // 左右各半屏 + } + + int PlotView::LayerMarginY(float laneHeight) const + { + // 上下各半个网格高:一整屏的额外内容已足够覆盖实际拖拽范围, + // 又不至于让每个横向补条 / 整块重画按三倍高度放大(横向平移因此不受影响)。 + const int half = static_cast(std::lround(static_cast(laneHeight) * 0.5)); + return std::max(1, half); + } + double PlotView::LayerDefaultWideMin() const { // 以当前视界为中心:位图左右各留半个视界宽,因此可向两侧平移 ±半屏。 @@ -7095,8 +7144,7 @@ namespace jdi return Tab().ViewMin(); } const double scale = static_cast(width) / span; - const int margin = std::max(1, width / 2); - return Tab().ViewMin() - static_cast(margin) / scale; + return Tab().ViewMin() - static_cast(LayerMarginX()) / scale; } bool PlotView::LayersStructurallyReady() const @@ -7113,8 +7161,7 @@ namespace jdi return false; } const int width = static_cast(std::lround(plotWidth)); - const int margin = std::max(1, width / 2); - const int bitmapWidth = width + 2 * margin; + const int bitmapWidth = width + 2 * LayerMarginX(); for (std::size_t index = 0; index < m_layerFront.size(); ++index) { const LaneLayer& layer = m_layerFront[index]; @@ -7127,9 +7174,10 @@ namespace jdi { return false; } - // 高度也要一致:网格高度变化会让贴图纵向错位。 + // 高度也要一致:网格高度变化会让贴图纵向错位(含上下预渲染余量)。 + const float laneHeight = m_lanes[index].rect.Height; const int bitmapHeight = - static_cast(std::ceil(m_lanes[index].rect.Height)); + static_cast(std::ceil(laneHeight)) + 2 * LayerMarginY(laneHeight); if (layer.height != bitmapHeight) { return false; @@ -7157,9 +7205,9 @@ namespace jdi return false; } const int width = static_cast(std::lround(plotWidth)); - const int margin = std::max(1, width / 2); - const int bitmapWidth = width + 2 * margin; - const int bitmapHeight = static_cast(std::ceil(m_lanes[static_cast(laneIndex)].rect.Height)); + const int bitmapWidth = width + 2 * LayerMarginX(); + const float laneHeight = m_lanes[static_cast(laneIndex)].rect.Height; + const int bitmapHeight = static_cast(std::ceil(laneHeight)) + 2 * LayerMarginY(laneHeight); if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth || layer.height != bitmapHeight || std::fabs(layer.span - span) > 1.0e-12) { @@ -7259,16 +7307,21 @@ namespace jdi } const int width = static_cast(std::lround(plotWidth)); - const int margin = std::max(1, width / 2); // 左右各半屏 → 可平移 ±半屏 + const int margin = LayerMarginX(); // 左右各半屏 → 可平移 ±半屏 const int bitmapWidth = width + 2 * margin; const float laneHeight = m_lanes[static_cast(laneIndex)].rect.Height; - const int bitmapHeight = static_cast(std::ceil(laneHeight)); + const int lanePixelHeight = static_cast(std::ceil(laneHeight)); + const int marginY = LayerMarginY(laneHeight); // 上下各半个网格高 → 幅值可平移 ±半个网格高 + const int bitmapHeight = lanePixelHeight + 2 * marginY; if (bitmapHeight <= 0 || !EnsureLaneLayer(layer, bitmapWidth, bitmapHeight)) { return false; } const double scale = static_cast(width) / span; // 像素 / 时间 + const double laneSpan = m_lanes[static_cast(laneIndex)].yMax - + m_lanes[static_cast(laneIndex)].yMin; + const double pxPerValue = (laneSpan > 0.0) ? static_cast(laneHeight) / laneSpan : 0.0; if (stripW <= 0 || stripX < 0 || stripX + stripW > bitmapWidth) { @@ -7285,17 +7338,25 @@ namespace jdi const bool savedFastFrame = m_fastFrame; const bool savedRenderWide = m_renderWide; const float savedScroll = Tab().LaneScroll(); + const double savedWideTickSpan = m_wideTickSpan; + const float savedWideTickHeight = m_wideTickHeight; - m_plotRect = gd::RectF(0.0f, 0.0f, static_cast(bitmapWidth), laneHeight); + m_plotRect = gd::RectF(0.0f, 0.0f, static_cast(bitmapWidth), + static_cast(bitmapHeight)); // 只把 `m_area` 限为待渲染的那一段 x 区间,使 GDI/GDI+ 的裁剪只落在它上面; // x 坐标仍用**整条宽窗口**的映射(`m_plotRect` 为完整宽图),因此网格线位置与整块图层一致。 - m_area = gd::RectF(static_cast(stripX), 0.0f, static_cast(stripW), laneHeight); + m_area = gd::RectF(static_cast(stripX), 0.0f, static_cast(stripW), + static_cast(bitmapHeight)); Tab().ViewMin() = wideMin; Tab().ViewMax() = wideMin + static_cast(bitmapWidth) / scale; m_renderWide = true; // 图层只含基底:跳过派生曲线与装饰 // 图层必须与"同一视界的整幅渲染"**逐像素同源**:强制全质量抗锯齿(不受拖拽快速帧影响)。 m_fastFrame = false; + // 幅值刻度的参考跨度 / 参考高度取**前台可见网格**的值(而不是含上下余量的画布值): + // 这样图层里的横向网格线步长与前台一致,纵向平移才是纯粹的像素平移。 + m_wideTickSpan = laneSpan; + m_wideTickHeight = laneHeight; BuildLaneVisuals(); // 沿用**当前视界**的 Y 轴范围,否则幅值映射与前台不一致。 for (std::size_t i = 0; i < m_lanes.size() && i < savedLanes.size(); ++i) @@ -7303,9 +7364,23 @@ namespace jdi m_lanes[i].yMin = savedLanes[i].yMin; m_lanes[i].yMax = savedLanes[i].yMax; } - // 该网格在本图层中的位置:横向铺满整条宽图,纵向从 0 开始。 + // 该网格在本图层中的位置:横向铺满整条宽图,纵向铺满整张画布。 + // 画布 = 「可见网格 + 上下各 `marginY` 行余量」。**上下扩展量按"像素行"精确换算** + //(而不是上下各扩展固定的数值量):位图高度是整数(`ceil(laneHeight) + 2*marginY`), + // 只有当「扩展后总跨度 / 画布行数」严格等于前台的 px/数值 时,可见网格在画布里的映射 + // 才与前台整幅渲染**逐像素同源**(否则会差一个 <1px 的纵向缩放误差, + // 让拖拽期间的像素残差明显变大)。 + if (pxPerValue > 0.0) + { + const double topExtend = static_cast(marginY) / pxPerValue; + const double bottomRows = + static_cast(bitmapHeight - marginY) - static_cast(laneHeight); + const double bottomExtend = std::max(0.0, bottomRows) / pxPerValue; + m_lanes[static_cast(laneIndex)].yMin -= bottomExtend; + m_lanes[static_cast(laneIndex)].yMax += topExtend; + } m_lanes[static_cast(laneIndex)].rect = - gd::RectF(0.0f, 0.0f, static_cast(bitmapWidth), laneHeight); + gd::RectF(0.0f, 0.0f, static_cast(bitmapWidth), static_cast(bitmapHeight)); { gd::Graphics g(layer.dc); g.SetSmoothingMode(gd::SmoothingModeAntiAlias); @@ -7324,6 +7399,8 @@ namespace jdi } m_fastFrame = savedFastFrame; m_renderWide = savedRenderWide; + m_wideTickSpan = savedWideTickSpan; + m_wideTickHeight = savedWideTickHeight; // 恢复布局 / 视图 / 网格。 // ⚠️ **必须调用 `BuildLaneVisuals()`**(而不是只把 `m_lanes` 拷回去):它会按正常布局 @@ -7350,6 +7427,11 @@ namespace jdi layer.wideMin = wideMin; layer.span = span; + layer.marginY = marginY; + // 纵向锚点 = 渲染时该网格的 `yMax`(可见窗口顶行对应的数值)。 + // 贴图时按 `(anchorYMax - 当前 yMax) * pxPerValue` 得到纵向偏移像素数。 + layer.anchorYMax = savedLanes[static_cast(laneIndex)].yMax; + layer.pxPerValue = pxPerValue; layer.key = LaneLayerKey(laneIndex); layer.valid = true; m_layerRenderCount.fetch_add(1, std::memory_order_relaxed); @@ -7394,7 +7476,7 @@ namespace jdi return; } const int width = static_cast(std::lround(plotWidth)); - const int margin = std::max(1, width / 2); + const int margin = LayerMarginX(); const int bitmapWidth = width + 2 * margin; const double scale = static_cast(width) / span; @@ -7419,7 +7501,9 @@ namespace jdi const int laneIndex = static_cast(index); // 结构不可用(尺寸 / 跨度不符):像素的时间映射已经不对,既不能贴也不能滚。 - const int bitmapHeight = static_cast(std::ceil(m_lanes[index].rect.Height)); + const float laneHeight = m_lanes[index].rect.Height; + const int bitmapHeight = + static_cast(std::ceil(laneHeight)) + 2 * LayerMarginY(laneHeight); if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth || layer.height != bitmapHeight || std::fabs(layer.span - span) > 1.0e-12) { @@ -7431,12 +7515,23 @@ namespace jdi const double desired = (Tab().ViewMin() - layer.wideMin) * scale; const int shift = static_cast(std::llround(desired - static_cast(margin))); const bool tooFar = (std::abs(shift) >= bitmapWidth - 4); - // 内容(外观 / 网格几何 / 幅值 / 可见性…)与当前状态不一致。 + // 内容(外观 / 网格几何 / 幅值**跨度** / 可见性…)与当前状态不一致。 const bool contentStale = (layer.key != LaneLayerKey(laneIndex)); - - if (contentStale || tooFar) + // 纵向:幅值**平移**不改内容,只改贴图偏移;但偏移超出画布余量时必须重画(重新居中)。 + int vOffset = 0; + if (layer.pxPerValue > 0.0) { - // 登记待重画(本帧不重画),但仍按现有像素平移,避免网格"卡在原地"。 + vOffset = static_cast( + std::llround((layer.anchorYMax - m_lanes[index].yMax) * layer.pxPerValue)); + } + const int topRow = layer.marginY + vOffset; + const bool verticalTooFar = (topRow < 0) || + (topRow + static_cast(std::ceil(laneHeight)) > layer.height); + + if (contentStale || tooFar || verticalTooFar) + { + // 登记待重画(本帧不重画,改由 `RenderPendingLaneRebuilds` 在**贴图之前**补画), + // 但仍按现有像素平移,避免网格"卡在原地"。 QueueLaneRebuild(laneIndex); if (!tooFar && shift != 0) { @@ -7491,7 +7586,7 @@ namespace jdi return false; } const int width = static_cast(std::lround(plotWidth)); - const int margin = std::max(1, width / 2); + const int margin = LayerMarginX(); const int bitmapWidth = width + 2 * margin; const double scale = static_cast(width) / span; @@ -7526,8 +7621,37 @@ namespace jdi } const int destX = static_cast(std::lround(m_plotRect.X)); const int destY = static_cast(std::lround(top)); - const int srcY = static_cast(std::lround(top - lane.Y)); - const int height = static_cast(std::lround(bottom - top)); + int height = static_cast(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( + std::llround((layer.anchorYMax - m_lanes[index].yMax) * layer.pxPerValue)); + } + srcY = static_cast(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; @@ -7554,7 +7678,8 @@ namespace jdi { continue; // 完全没有可用像素(该网格待重画) } - BitBlt(target, destX + destOffset, destY, blitWidth, height, layer.dc, srcX, srcY, SRCCOPY); + BitBlt(target, destX + destOffset, destY + destTopOffset, blitWidth, height, layer.dc, srcX, + srcY, SRCCOPY); any = true; } g.ReleaseHDC(target); @@ -7705,7 +7830,21 @@ namespace jdi int yTickTarget = static_cast(std::lround(lane.rect.Height / (kYTickSpacing * m_scale))); yTickTarget = std::max(kYTickTargetMin, std::min(yTickTarget, kYTickTargetMax)); - const double yStep = NiceStep(lane.yMax - lane.yMin, yTickTarget); + // ⚠️ 渲染「信号图层」时,幅值刻度必须以**前台可见网格**的跨度/高度为基准: + // 图层画布比可见网格高(上下各留预渲染余量),若按画布高度与扩展后的跨度取刻度, + // 图里的网格线密度就与整幅渲染不一致 —— 贴图后拖动时网格线会跳变。 + // 用可见跨度取步长 + 按扩展范围铺满整张画布 ⇒ 刻度落在同一批绝对值格点上, + // 于是"纵向平移 = 纯粹的像素平移"(逐像素同源)。 + const double tickSpan = (m_renderWide && m_wideTickSpan > 0.0) + ? m_wideTickSpan + : (lane.yMax - lane.yMin); + if (m_renderWide && m_wideTickHeight > 0.0f) + { + yTickTarget = static_cast( + std::lround(m_wideTickHeight / (kYTickSpacing * m_scale))); + yTickTarget = std::max(kYTickTargetMin, std::min(yTickTarget, kYTickTargetMax)); + } + const double yStep = NiceStep(tickSpan, yTickTarget); std::vector yTicks; BuildTicks(lane.yMin, lane.yMax, yStep, yTicks); @@ -7956,8 +8095,11 @@ namespace jdi } // 网格分界线:网格之间不再留间隔,改用较重的横线明确分隔相邻网格。 + // ⚠️ **渲染信号图层时跳过**:分界线锚定在网格的**屏幕位置**(`lane.rect.Y`), + // 而图层会被纵向偏移贴图(幅值平移)—— 若烘进图层,纵向拖动时它会跟着波形一起跑。 + // 因此它在整幅重绘里由这里绘制,宽基底快路径则由 `PaintLaneDividers` 补画一次。 const float dividerY = lane.rect.Y; - if (!m_renderDecorOnly && dividerY >= m_plotRect.Y - 1.0f && + if (!m_renderDecorOnly && !m_renderWide && dividerY >= m_plotRect.Y - 1.0f && dividerY <= m_plotRect.Y + m_plotRect.Height) { const float dividerSegments[4] = {lane.rect.X, dividerY, lane.rect.X + lane.rect.Width, dividerY}; @@ -8068,6 +8210,30 @@ namespace jdi g.Restore(saveState); } + void PlotView::PaintLaneDividers(gd::Graphics& g) const + { + if (m_theme == nullptr) + { + return; + } + for (std::size_t index = 0; index < m_lanes.size(); ++index) + { + const gd::RectF& rect = m_lanes[index].rect; + const float dividerY = rect.Y; + if (dividerY < m_plotRect.Y - 1.0f || dividerY > m_plotRect.Y + m_plotRect.Height) + { + continue; + } + const float segments[4] = {rect.X, dividerY, rect.X + rect.Width, dividerY}; + const std::uint32_t dividerColor = + ((index == 0) ? m_theme->axisLine : m_theme->borderStrong) | 0xFF000000u; + // GDI 批绘不受 GDI+ 裁剪约束 → 显式给出只覆盖该分界线附近的裁剪矩形。 + const gd::RectF clip(m_area.X, dividerY - 4.0f * m_scale, m_area.Width, 8.0f * m_scale); + UiDrawSolidLines(g, clip, segments, 1, dividerColor, + (index == 0) ? 2 : static_cast(kLaneDividerWidth)); + } + } + SignalRef PlotView::LegendAtPoint(int x, int y) const { const float fx = static_cast(x); @@ -9910,8 +10076,12 @@ namespace jdi for (const LaneVisual& visual : m_lanes) { mixRect(visual.rect); - mixFloat(static_cast(visual.yMin)); - mixFloat(static_cast(visual.yMax)); + // ⚠️ **幅值范围(yMin/yMax)刻意不入面板指纹**:面板内容(标题、条目、滑块、 + // 参数与边沿统计)都不依赖网格的幅值区间;而「拖拽平移的纵向分量」会每帧小幅 + // 改动它 —— 一旦计入,拖拽期间**全部**悬浮面板都会每帧重画一遍 + //(实测 20 个面板:竖向拖拽 167ms/帧 vs 横向 38ms/帧,正是用户报告的 + // "左右流畅、上下/斜向卡顿")。代价只是面板不透明底(95.7%)背后那 4% + // 透出的底层像素可能旧一帧,与既有取舍一致(实测最大色差 ≤ 29 级)。 } if (m_tab != nullptr) diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 295b2f4..bd1c4c8 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -513,6 +513,7 @@ Documents/ 项目文档(含本文件) | 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **修复"按住键拖拽平移时波形质量下降 / 断线 / 周期性卡顿"与"浮动面板互相覆盖时交互错位"**,并把弱峰增强改为**逐信号参数**。① 平移:`RollWideBuffer` 不再整块重画(只平移+补细条,内容过期/锚点过远只入队),新增 `RenderPendingLaneRebuilds(budget)`(**每帧最多重画 1 个网格**,其余下帧)+ `BlitWideBuffer` **局部贴图**(图层没覆盖的部分留空,不再整块跳过);"结算"整幅精确帧**仅限非拖拽且全部图层就绪**(消除拖拽中每两帧的整幅重绘/质量振荡)。实测 `VERY_BIG_DATA` 5 网格深放大平移:中位 **6.2ms**、max 7.6ms(无尖峰);按住键且视界停在边界时 **0.29ms/帧**。② 弱峰增强:`WeakPeakItem` 改为携带 `levelPercent(0–200)+iterations`,面板改为与均值滤波同构的**逐信号两个输入框**(删除网格级两滑块),哈希/快照/构建/预览全部改为逐信号。③ 浮动面板:新增 `m_panelOrder` 层序,**命中从最上层往下**、点击 `BringPanelToFront`、新面板置顶;修复"上层 ✕ / 标题栏被下层参数框抢走"。离屏测试 **388/388 通过**。 | | 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | 弱峰增强的参数输入改回**逐信号滑块**(每信号两条轨道:增强等级 0–200% / 迭代次数 N),并让「迭代次数 N」**上限可就地编辑**:点击滑块右侧数值 → 整行变成输入框(`EditFilter::BYTE` 只收数字、空内容显示灰提示当前上限、`0`/超限/空 保持原值、下调后当前迭代次数自动被夹到新上限);上限进 `PanelSlotKey` 但不进 `SceneContentHash`(不影响波形重画)。离屏测试 **409/409 通过**。 | | 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **修复"斜向/不规则拖拽自由移动视界时的卡顿、掉帧与波形错误绘制"、"预览曲线不如普通信号细腻(无抗锯齿)"、"拖动参数滑块时预览曲线跳帧闪烁"**。① 根因:拖拽平移的**纵向分量**会逐帧小改被拖网格的 `yMin/yMax`,而**网格幅值范围同时参与「宽基底可用性」几何指纹与每个网格的图层键**,于是每帧都退化成整幅重绘(`m_fastFrame` 关抗锯齿)+ 5 个网格全部入队(一帧只补 1 个)→ 质量下降、卡顿、图层相互不一致(断线)。修法:`SceneContentHash` 新增 `includeLaneRange`(`SceneGeometryHash()` = `false,true,false`,幅值不进"能不能走快路径"的判定),幅值改由**逐网格** `LaneLayerHash(laneIndex)`(`LaneLayerKey` 的基)承担 → 只让真正变化的那个网格失效;并把 `RenderPendingLaneRebuilds(1)` **移到 `BlitWideBuffer` 之前**(本帧补好的网格本帧就可见)。② 预览曲线抗锯齿不再因交互(`m_fastFrame`)关闭(仅保留"陡峭段过多"这一条形同无声的逃生口)。③ `AcquireNewestCurve` 改为**只按「信号 + 曲线种类」匹配**(参数无关,优先挑覆盖所需区间的条目)→ 拖参数滑块时用上一帧的曲线顶替,后台算好再无缝替换,**不再闪烁/消失**。离屏验证:斜向拖拽 40/40 帧走宽基底快路径(中位 9.2ms,无尖峰),参数连改 10 次 **0 个空白帧**,抗锯齿回归断言(快帧混淆边缘像素 8149/8404);`uitest` **420/420 通过**、`panperf` PASS、`layercheck` 保持"0 次视界覆盖 / 平移对齐 ≤1px"。 | +| 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **修复"深放大下左右平移流畅、上下/斜向平移卡顿"(用户猜测:上下没有预渲染机制——已证实)与"深放大时拖拽光标跑飞"**。① 量化复现:`panperf` 新增纯竖直拖拽 + 逐帧统计**图层重画次数**,并在 5 网格全开 4 类悬浮面板(20 个)后对比 —— 竖直拖拽中位 **166.6ms/帧**、水平 **37.7ms/帧**(4.4 倍差距,用户报告的"上下卡顿"正是它)。② 主因是**面板像素图层被幅值范围击穿**:`PanelGlobalKey()` 计入 `visual.yMin/yMax`,平移的纵向分量每帧小改幅值 → 整层 20 个面板每帧重画(去掉后 166.6→**24.8ms**);次因是**图层没有纵向预渲染余量**(幅值一变就整块重画,实测 40 帧里 40 次 → 修复后 **1 次**)。③ 修法:`LaneLayer` 增加 `marginY/anchorYMax/pxPerValue`,幅值范围按**像素行**精确扩展(上下各半个网格高)、贴图时按 `(anchorYMax - 当前 yMax) * pxPerValue` 取纵向偏移,图层指纹只计幅值**跨度**不计绝对范围;幅值刻度改以**前台可见网格**的跨度/高度取 `NiceStep`(保证与整幅渲染逐像素同源);网格分界线移出图层(改由宽基底路径的 `PaintLaneDividers()` 补画,否则纵向偏移会把它带走)。④ 光标:深放大下平移会把时间光标甩到视界外很远(像"跑飞/丢失")—— 新增 `ClampCursorsAfterPan()`,在**平移**(中键/左键拖拽、Shift+滚轮、方向键)之后夹取;**离散**的视图变化(选区放大/适应视图/按网格时间轴展开)仍然不移动光标;抓取被钉在边缘的光标读数时不再沿用抓取偏移(否则首帧会跳到视界外再被夹回边缘)。验证:`uitest` **432/432**(新增 `[AI]`:贴边读数抓取后光标落在鼠标处误差 0.0px、中键平移 1.5 屏 0 帧出界且跟手)、`panperf` 竖直 3.8~5.0ms(40 帧仅 1 次图层重画)、水平 6.5ms 持平、`layercheck` 0 视界覆盖、新增探针 `.preflight/cursorcheck` 30/30。 | --- @@ -2627,3 +2628,54 @@ Documents/ 项目文档(含本文件) 3. **待重画网格要在贴图之前补**,否则用户看到的是"比鼠标慢半拍"的旧像素。 4. **异步发布的派生曲线必须有"参数无关"的兜底**(按信号 + 种类取最新),否则拖参数滑块必然闪烁。 5. **交互优化只能降分辨率(顶点数),不能关抗锯齿**:基底图层永远是全质量抗锯齿,叠加层一旦关了就会"看着更粗糙"。 + +--- + +## 本轮(2026-10-12,起源设备):上下/斜向平移卡顿(纵向预渲染)+ 深放大拖拽光标跑飞 + +用户报告:以 `VERY_BIG_DATA.CSV` 为例,**Y 轴自适应 + 选区放大**到主时间轴约 1~2ms 一大格后,中键拖拽移动视界时「左右很流畅,但上下或斜向会卡顿」,并猜测「上下移动时没有像左右那样的预渲染缓存」。另外「小缩放区间拖拽光标跑飞」的旧缺陷复现。 + +### A. 先验证猜测(量化,而不是凭感觉) + +`.preflight/panperf` 新增:**纯竖直拖拽**(只改被拖网格幅值、视界不动)、逐帧统计 `BackgroundLayerRenderCount()` 增量,以及「5 网格全开 4 类悬浮面板(20 个)」后的水平/竖直对比。结果: + +| 场景 | 水平拖拽 | 竖直拖拽 | 每次竖直帧的图层重画次数 | +| --- | --- | --- | --- | +| 无面板 | 6.29ms/帧 | 5.26ms/帧 | **1 次/帧**(水平:5 个横向补条/帧) | +| 20 个悬浮面板 | 37.7ms/帧 | **166.6ms/帧** | 同上 | + +结论:猜测**部分成立**——竖直方向确实没有预渲染(每帧整块重画该网格图层),但**主因**是另一处:浮动面板的**像素图层全局键**里含 `visual.yMin/yMax`,平移的纵向分量每帧小改幅值 → 整层面板缓存失效 → 20 个面板每帧全部重画(≈130ms/帧)。水平平移不改幅值,所以只有竖直/斜向才触发 —— 这正好解释了用户观察到的「不对称」。 + +### B. 图层改为**二维预渲染**(横向 ±半屏 / 纵向 ±半个网格高) + +- `LaneLayer` 新增 `marginY`(可见网格上方的余量行数)、`anchorYMax`(渲染时的 `yMax`)、 `pxPerValue`(像素/数值单位);位图高度 = `ceil(可见网格高) + 2*marginY`。 +- 渲染时把该网格幅值范围按**像素行**精确扩展(上扩 `marginY/pxPerValue`,下扩 + `(bitmapHeight - marginY - laneHeight)/pxPerValue`)。⚠️ 必须按行数反推数值扩展量: + 只有这样「扩展跨度 / 画布行数」才严格等于前台的 px/数值,可见网格在画布中的映射才与整幅渲染**逐像素同源**。 + 早先按「上下各扩同样的数值量」时存在 <1px 的纵向缩放误差,水平平移的像素残差实测从 77k 涨到 105k,改对后回到 77k。 +- 图层里的**幅值刻度必须以前台可见网格的跨度/高度为基准**(`m_wideTickSpan` / `m_wideTickHeight`):画布更高、范围更宽,若按画布值取 `NiceStep`,网格线密度就与前台不一致(贴图后拖动网格线跳变)。 + 步长一致 ⇒ 刻度落在同一批绝对值格点上 ⇒ 纵向平移就是纯像素平移。 +- 贴图:`srcY = round(top - lane.Y) + marginY + round((anchorYMax - 当前 yMax) * pxPerValue)`;超出画布上下端时只贴**有像素的那一段**。`RollWideBuffer` 里只有 `|偏移|` 超出余量时才排队重画(重画即重新居中)。 +- 图层指纹:`LaneLayerHash` 只计幅值**跨度**(`yMax - yMin`),**不计绝对范围**(绝对范围是贴图偏移的来源)。 +- ⚠️ **网格分界线必须移出图层**:它锚定在网格的**屏幕位置**,而图层会被纵向偏移贴图;若烘进图层,竖直拖动时它会跟着波形跑。现在整幅重绘由 `PaintLane` 画,宽基底快路径由 `PaintLaneDividers()` 补画一次。 + +### C. 面板像素图层不再被幅值范围击穿 + +- `PanelGlobalKey()` 去掉 `visual.yMin/yMax`:面板内容(标题、条目、滑块、参数、边沿统计)都与幅值区间无关。 +- 代价:面板底是不透明度 95.7% 的圆角矩形,缓存像素里"它下面的场景"可能旧一帧 → 透出 **4.3%**,色差**有上界**(≤ 0.043×255 ≈ 11 级),与既有的「旧一帧」取舍一致,[AF] 的容差 96 覆盖得住。 + +### D. 深放大下拖拽光标「跑飞」 + +- 历史:`388d3b6`/`67f659a` 里「视图变化后夹光标」被 `eaf5b9c` 刻意删除(改为「光标可滞留在视界外 + 贴边读数 + 方向三角」)。但深放大下一屏平移就是几十倍可视跨度,光标瞬间被甩到视界外很远 —— 用户看到的就是「拖拽(视界)时光标跑飞」。 +- 修法(区分**连续手势**与**离散视图变化**): + * 新增 `ClampCursorsAfterPan()`,在**平移**路径调用:PAN 拖拽(中键/左键)、Shift+滚轮、方向键(`PanTimeByFraction`)。 + * ⚠️ **离散**视图变化(选区放大 / 适应视图 / 按网格时间轴展开)**仍然不移动光标** —— 用户可以先量好再自由探索, 贴边读数可读、可拖回来。不要在这几处加夹取。 + * 抓取**被钉在边缘**的光标读数时不带抓取偏移(`CursorOffViewSide` 为真 → `m_cursorGrabOffset = 0`): 否则首次移动会把目标送到视界外再被夹回边缘(表现为"一拖就跳很远 / 光标钉在边缘不动")。 + +### E. 验证(全部离屏,不占用鼠标/键盘/屏幕) + +- `uitest` **432/432**:`[AH]` 更新为"幅值**平移**不再让图层失效(只改贴图偏移)、幅值**跨度**变化仍然失效",并新增「纯竖直拖拽 8 帧 **0 次图层重画**」;新增 `[AI]`:贴边读数抓取后光标落在鼠标处(误差 **0.0px**)、中键平移 1.5 屏期间 **0 帧出界**且光标确实跟手。 +- `panperf`(VERY_BIG_DATA、5 网格、≈1~2ms 一大格):竖直拖拽中位 **3.8~5.0ms**、40 帧仅 **1** 次图层重画;水平 5.7~6.5ms(与修复前持平);对角 11~13ms(略增:补条要按 2 倍画布高渲染);整幅质量对比残差与修复前同量级(水平 77k~81k ↔ 修复后 77k~81k;对角仅被拖动的那一个网格增加,属同一类 ≤0.5px 整数像素残差)。 +- 20 个悬浮面板时:竖直 **166.6 → 24.8ms/帧**,水平 37.7 → 38.8ms(持平)——"上下比左右卡"的不对称消失。 +- `layercheck`:`clobbers=0/24`、`pan frames wrong=0/4`、居中图层 vs 整幅重绘逐像素 0 差异;新增探针 `.preflight/cursorcheck`(真文件 + 真缩放)**30/30**(单/双光标拖拽、贴边读数抓取、中键/键盘/滚轮平移)。 +- `make clean && make all` 零警告。