From 5cb84e4f9756d0b0ad1f2f1f7a63bcecbd33935d Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Wed, 28 Oct 2026 12:18:42 +0800 Subject: [PATCH] =?UTF-8?q?UI=20=E9=87=8D=E6=9E=84=20P2=EF=BC=9APlotViewPa?= =?UTF-8?q?int.cpp=20=E6=8C=89=E5=85=B3=E6=B3=A8=E7=82=B9=E5=88=86?= =?UTF-8?q?=E7=89=87=EF=BC=88=E6=9C=BA=E6=A2=B0=E6=90=AC=E7=A7=BB=EF=BC=8C?= =?UTF-8?q?=E9=80=90=E5=83=8F=E7=B4=A0=200=20=E5=B7=AE=E5=BC=82=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlotViewPaint.cpp 9,080 行里混住四类内容(真绘制 / 光栅化缓存 / 内容指纹 / 布局与命中几何), 按"函数名前缀"分片是上一轮膨胀的根因。本提交按关注点机械搬移函数定义,不改一行逻辑: - 新增 PlotViewRaster.cpp(1,876 行):光晕离屏层、静态场景缓存、装饰层(AlphaBlend)、 每网格宽基底图层/并行重建/滚动补条/预热预算。 - 新增 PlotViewCacheKey.cpp(474 行):SceneCacheKey、SceneContentHash/Geometry/Decor、 样式与派生快照、只改外观的网格定位、样式补丁帧。 - PlotViewPaint.cpp 只留绘制:9,080 -> 6,252 行(-31%)。 - 顶层注释从「四个分片」改为「六个分片(按关注点)」,并保留 「PlotView::Paint/PaintLane 只允许定义在 PlotViewPaint.cpp」。 pvdetail 全部 inline,故跨分片安全;无需改任何 include,Makefile 按 basename 自动收集。 验证: - make release TARGET_FILE_NAME=TargetCheck 零警告;Target.exe 873,984 -> 875,008 B(+1KB 对齐差异)。 - 逐像素 A/B(仓库外一次性渲染哈希探针,三场景:全览 / 深放大+自由标记+均值滤波+弱峰调整+时间光标 / 再平移一帧):分片前后哈希完全相同 A 2166933039626F66、B C444ECFEB18FD73D、C 7984446D8E7A4418。 - Tools/uicheck 62 passed / 0 failed。 坑:用旧文件回退做 A/B 时必须先删除 Build//PlotViewRaster.o 与 PlotViewCacheKey.o, 否则链接会因重复符号失败。 --- Components/PlotView/Src/PlotViewCacheKey.cpp | 504 ++++ Components/PlotView/Src/PlotViewPaint.cpp | 2463 +----------------- Components/PlotView/Src/PlotViewRaster.cpp | 1977 ++++++++++++++ Documents/UI_Refactor_Progress.md | 41 +- 4 files changed, 2530 insertions(+), 2455 deletions(-) create mode 100644 Components/PlotView/Src/PlotViewCacheKey.cpp create mode 100644 Components/PlotView/Src/PlotViewRaster.cpp diff --git a/Components/PlotView/Src/PlotViewCacheKey.cpp b/Components/PlotView/Src/PlotViewCacheKey.cpp new file mode 100644 index 0000000..9effdbc --- /dev/null +++ b/Components/PlotView/Src/PlotViewCacheKey.cpp @@ -0,0 +1,504 @@ +// PlotView 实现分片 —— 内容指纹与缓存键("为什么这一帧可以复用上一帧的像素")。 +// +// 本分片只包含 `PlotView` 的**成员函数定义**(从 `PlotViewPaint.cpp` 机械搬移,行为与搬移前逐字节一致)。 +// 职责: +// * 场景缓存键 `SceneCacheKey` 的构造与比较; +// * 场景内容指纹(`SceneContentHash` / `SceneGeometryHash` / `SceneDecorHash`); +// * 逐信号样式快照与"只改了外观"的网格定位(`CollectSceneStyles` / `FindStyleOnlyLanes`); +// * 派生曲线参数快照(`CollectSceneDerived`); +// * 样式补丁帧(贴图 → 只重画受影响网格 → 写回缓存)。 +// +// ⚠️ 硬约束:任何"会影响像素"的状态都必须进指纹;漏一个字段的症状是"贴旧像素" +// (本项目历史上至少 4 次真实 BUG 都是这个原因)。 +// ⚠️ 本文件**不做**像素分配与贴图(那些在 `PlotViewRaster.cpp`),也不做绘制(`PlotViewPaint.cpp`)。 + +#include "PlotViewInternal.hpp" + +namespace jdi +{ + PlotView::SceneCacheKey PlotView::MakeSceneCacheKey() const + { + SceneCacheKey key; + key.viewMin = Tab().ViewMin(); + key.viewMax = Tab().ViewMax(); + key.laneScroll = Tab().LaneScroll(); + key.laneCount = static_cast(m_lanes.size()); + key.areaX = m_area.X; + key.areaY = m_area.Y; + key.areaW = m_area.Width; + key.areaH = m_area.Height; + key.scale = m_scale; + + const SignalRef halo = SceneHaloRef(); + key.haloGroup = halo.groupIndex; + key.haloSignal = halo.signalIndex; + key.contentHash = SceneContentHash(); + return key; + } + + bool PlotView::SceneCacheMatches() const + { + if (!m_sceneValid) + { + return false; + } + // 缓存里存的是「快速帧」(关掉了抗锯齿)→ 不能直接命中: + // 必须重画一次全质量版本,否则降级的画面会一直留在屏幕上。 + if (m_sceneDegraded) + { + return false; + } + const SceneCacheKey& cached = m_sceneKey; + const SceneCacheKey key = MakeSceneCacheKey(); + return (key.viewMin == cached.viewMin) && (key.viewMax == cached.viewMax) && + (key.laneScroll == cached.laneScroll) && (key.laneCount == cached.laneCount) && + (key.areaX == cached.areaX) && (key.areaY == cached.areaY) && + (key.areaW == cached.areaW) && (key.areaH == cached.areaH) && + (key.scale == cached.scale) && (key.haloGroup == cached.haloGroup) && + (key.haloSignal == cached.haloSignal) && (key.contentHash == cached.contentHash); + } + + SignalRef PlotView::SceneHaloRef() const + { + // 只有拖拽中的信号光晕是“整个拖拽过程都不变”的,可以安全地绘入静态场景缓存; + // 悬停光晕随鼠标移动,必须画在缓存之外(见 PaintHoverHighlight)。 + if (m_dragMode == PlotDragMode::MOVE_SIGNAL && m_dragSignal.IsValid()) + { + return m_dragSignal; + } + return SignalRef(); + } + + std::uint64_t PlotView::SceneContentHash(bool includeStyles, bool includeDerived, + bool includeLaneRange, bool includeLaneScroll) const + { + // FNV-1a:把“视图几何之外的一切影响绘图区像素的内容”折叠成一个指纹。 + // 这样颜色/线宽/透明度/样式/重命名/信号增删等变化都会自动让缓存失效, + // 不需要在各处手工调用 InvalidateSceneCache(容易漏)。 + // `includeStyles == false` 时跳过逐信号的外观样式(颜色/不透明度/线宽/模式), + // 得到的就是几何指纹:它一致说明“只有外观样式变了”。 + std::uint64_t hash = 1469598103934665603ull; + const auto mix = [&hash](std::uint64_t value) + { + hash ^= value; + hash *= 1099511628211ull; + }; + const auto mixFloat = [&mix](float value) + { + std::uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + mix(bits); + }; + const auto mixText = [&mix](const std::wstring& text) + { + mix(text.size()); + for (const wchar_t character : text) + { + mix(static_cast(character)); + } + }; + + mixFloat(m_plotRect.X); + mixFloat(m_plotRect.Y); + mixFloat(m_plotRect.Width); + mixFloat(m_plotRect.Height); + mixFloat(m_laneContentHeight); + mix(m_scrollBarVisible ? 1ull : 0ull); + // ⚠️ 网格纵向滚动量必须参与**静态场景缓存**的指纹:它只改变各网格的绘制位置。 + // 若不参与,平移宽缓冲区 / 静态场景缓存会把旧滚动位置的像素当成当前内容贴回, + // 表现为“网格位置错位 / 拖影 / 残留”等错误元素。 + // ⚠️ 但它是**纯屏幕落点**(不影响任何网格内容),因此“宽基底可用性”判据 + //(`SceneGeometryHash`)把它排除:网格滚动时 `BlitWideBuffer` 本来就按 `lane.rect` + // 逐网格贴图,内容完全复用 —— 这样“网格滚动到可视区 / 回到最上方”这类 + // **每帧都在改滚动量的动画**才不会退化成每帧整幅重绘(用户 2026-10-17 报告卡顿)。 + if (includeLaneScroll) + { + mixFloat(Tab().LaneScroll()); + } + + // 选中信号会改变图例条目的底色与文字颜色(绘制在缓存的静态场景里), + // 因此必须参与指纹:否则在左侧信号面板点选后图例不会高亮联动。 + { + const SignalRef selected = SelectedRef(); + mix(selected.IsValid() ? 1ull : 0ull); + mix(static_cast(selected.groupIndex)); + mix(static_cast(selected.signalIndex)); + } + + for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) + { + const LaneVisual& visual = m_lanes[laneIndex]; + // ⚠️ 各网格的屏幕纵向位置同样只是“贴图落点”(见 `includeLaneScroll` 的说明)。 + if (includeLaneScroll) + { + mixFloat(visual.rect.Y); + } + mixFloat(visual.rect.Height); + // ⚠️ 幅值范围只在 `includeLaneRange` 时计入:拖拽平移时的纵向分量会每帧小幅改动它, + // 若参与“宽基底可用性”判据,每一帧都会退化成整幅重绘(质量下降 + 卡顿)。 + if (includeLaneRange) + { + mixFloat(static_cast(visual.yMin)); + mixFloat(static_cast(visual.yMax)); + } + + const std::vector& signals = Tab().Lanes()[laneIndex].signals; + // 「边沿计数(中心轴)」「均值滤波」「弱峰调整」的开关与每条被勾选信号的参数 + // 会改变网格内的曲线绘制。它们**只影响网格内容**(不影响网格结构/几何), + // 因此只在 includeStyles 时计入 —— 于是拖参数滑块可以走“局部重画”快路径, + // 而不必整幅重绘(`FindStyleOnlyLanes` 用 `m_sceneDerived` 定位受影响的网格)。 + // `includeDerived == false` 时连这些参数也不计入(用于装饰层指纹:图例/Y 轴刻度 + // 与派生曲线参数无关,拖滑块时装饰层无需失效)。 + if (includeStyles && includeDerived) + { + mix(Tab().Lanes()[laneIndex].edgeCenter ? 1ull : 0ull); + mix(static_cast(Tab().Lanes()[laneIndex].edgeCenterItems.size())); + for (const Lane::EdgeCenterItem& item : Tab().Lanes()[laneIndex].edgeCenterItems) + { + mix(static_cast(item.ref.groupIndex)); + mix(static_cast(item.ref.signalIndex)); + mix(static_cast(item.points)); + mix(static_cast(item.iterations)); + } + mix(Tab().Lanes()[laneIndex].meanFilter ? 1ull : 0ull); + mix(static_cast(Tab().Lanes()[laneIndex].meanFilterItems.size())); + for (const Lane::MeanFilterItem& item : Tab().Lanes()[laneIndex].meanFilterItems) + { + mix(static_cast(item.ref.groupIndex)); + mix(static_cast(item.ref.signalIndex)); + mix(static_cast(item.points)); + mix(static_cast(item.iterations)); + } + mix(Tab().Lanes()[laneIndex].weakPeak ? 1ull : 0ull); + mix(static_cast(Tab().Lanes()[laneIndex].weakPeakItems.size())); + for (const Lane::WeakPeakItem& item : Tab().Lanes()[laneIndex].weakPeakItems) + { + // 逐信号参数(等级 + 迭代次数)都参与指纹:改任一条的参数都会重画。 + mix(static_cast(item.ref.groupIndex)); + mix(static_cast(item.ref.signalIndex)); + mix(static_cast(item.amountPercent)); + mix(static_cast(item.iterations)); + } + } + mix(signals.size()); + for (const SignalRef& ref : signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + mix(static_cast(ref.groupIndex)); + mix(static_cast(ref.signalIndex)); + mix(resolved.visible ? 1ull : 0ull); + if (resolved.data == nullptr || resolved.style == nullptr) + { + continue; + } + // 以下四项是“只影响外观”的样式:仅在 includeStyles 时计入指纹。 + // (不参与时得到的就是 SceneGeometryHash:它一致说明只有外观变了, + // 于是可以只重画颜色变了的网格,而不必整幅重绘。) + if (includeStyles) + { + mix(static_cast(resolved.style->color)); + mixFloat(resolved.style->opacity); + mixFloat(resolved.style->lineWidth); + mix(static_cast(resolved.style->mode)); + // 绘制前的数值变换(系数 / 绝对值 / 镜像)会改变波形形状。 + mixFloat(resolved.style->gain); + mix(resolved.style->absolute ? 1ull : 0ull); + mix(resolved.style->mirrorVertical ? 1ull : 0ull); + mix(resolved.style->mirrorHorizontal ? 1ull : 0ull); + // 偏移(Y 平移 + 时间平移)同样会改变波形。 + mixFloat(resolved.style->offsetY); + mixFloat(static_cast(resolved.style->offsetTime)); + } + // 图例文案参与绘制,重命名必须让缓存失效。 + mixText(resolved.data->name); + if (resolved.group != nullptr) + { + mixText(resolved.group->name); + } + } + } + return hash; + } + + std::uint64_t PlotView::SceneGeometryHash() const + { + // 外观样式不参与;各网格的幅值范围也不参与(那由逐网格图层指纹负责, + // 这样拖拽时的纵向分量不会把整幅重绘的快路径打掉); + // ⚠️ 网格纵向滚动量 / 各网格屏幕纵向位置也不参与:那只是图层的**贴图落点**, + // 不影响任何网格的像素内容(`BlitWideBuffer` 按 `lane.rect` 逐网格贴图), + // 因此滚动网格时可以继续复用宽基底(否则“滚动到可视区 / 回到最上方”的 + // 每一帧都会退化成整幅重绘 —— 用户 2026-10-17 报告的滚动动画卡顿)。 + return SceneContentHash(false, true, false, false); + } + + std::uint64_t PlotView::SceneDecorHash() const + { + // 装饰层(图例 + Y 轴刻度)的指纹:含外观样式,但**不含派生曲线参数** + //(拖弱峰调整等级/迭代次数滑块时图例与刻度不变,装饰层缓存不应失效)。 + return SceneContentHash(true, false); + } + + void PlotView::CollectSceneStyles(std::vector>& out) const + { + out.clear(); + out.resize(m_lanes.size()); + for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) + { + const std::vector& signals = Tab().Lanes()[laneIndex].signals; + std::vector& lane = out[laneIndex]; + lane.reserve(signals.size()); + for (const SignalRef& ref : signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + SceneStyleEntry entry; + if (resolved.style != nullptr) + { + entry.color = resolved.style->color; + entry.opacity = resolved.style->opacity; + entry.lineWidth = resolved.style->lineWidth; + entry.mode = static_cast(resolved.style->mode); + entry.gain = resolved.style->gain; + entry.absolute = resolved.style->absolute; + entry.mirrorVertical = resolved.style->mirrorVertical; + entry.mirrorHorizontal = resolved.style->mirrorHorizontal; + entry.offsetY = resolved.style->offsetY; + entry.offsetTime = resolved.style->offsetTime; + } + lane.push_back(entry); + } + } + } + + bool PlotView::FindStyleOnlyLanes(std::vector& lanes) const + { + lanes.clear(); + if (!m_sceneValid || m_sceneGeometryHash != SceneGeometryHash()) + { + // 网格结构 / 视图几何 / 信号集合 / 图例文案有变化:必须整幅重绘。 + return false; + } + + // ⚠️ **必须额外确认“视图几何与缓存完全一致”**:`SceneGeometryHash()` 刻意不含视界, + // 所以它相等**并不能**说明视图没变。若视界已经变了还走“只重画受影响网格”的局部补丁, + // 就会把缓存里**旧视界**的画面(时间轴、其它网格、底色)留在屏上 —— 表现为用户反馈的 + // “拖影 / 残留 / 错误元素”。因此这里逐项比较除内容指纹之外的缓存键。 + { + const SceneCacheKey key = MakeSceneCacheKey(); + if (key.viewMin != m_sceneKey.viewMin || key.viewMax != m_sceneKey.viewMax || + key.laneScroll != m_sceneKey.laneScroll || key.laneCount != m_sceneKey.laneCount || + key.areaX != m_sceneKey.areaX || key.areaY != m_sceneKey.areaY || + key.areaW != m_sceneKey.areaW || key.areaH != m_sceneKey.areaH || + key.scale != m_sceneKey.scale || key.haloGroup != m_sceneKey.haloGroup || + key.haloSignal != m_sceneKey.haloSignal) + { + return false; + } + } + + std::vector> current; + CollectSceneStyles(current); + if (current.size() != m_sceneStyles.size()) + { + return false; + } + + // 叠加预览曲线(均值滤波 / 中心轴 / 弱峰调整)的参数变化同样只需局部重画。 + std::vector currentDerived; + CollectSceneDerived(currentDerived); + const bool derivedComparable = (currentDerived.size() == m_sceneDerived.size()); + + for (std::size_t laneIndex = 0; laneIndex < current.size(); ++laneIndex) + { + const std::vector& now = current[laneIndex]; + const std::vector& before = m_sceneStyles[laneIndex]; + if (now.size() != before.size()) + { + return false; + } + bool differs = false; + for (std::size_t index = 0; index < now.size() && !differs; ++index) + { + // ⚠️ 必须与 `SceneContentHash(includeStyles) / SceneGeometryHash` 的口径**完全一致**: + // 几何哈希刻意不含外观,而外观哈希含 color/opacity/lineWidth/mode **以及** + // 系数 / 绝对值 / 镜像 / Y 偏移 / 时间偏移。旧实现只比前 4 项 ⇒ 一个网格改颜色、 + // 另一个网格同时改「调整」时,后者不会被登记为脏;随后 `RepaintSceneCachePatch` + // 又写入包含这些字段的新 `m_sceneKey`,那个网格就带着旧像素被当成“已是最新”, + // 一直残留到某次无关的失效。 + differs = !(now[index] == before[index]); + } + if (!differs && derivedComparable) + { + const SceneDerivedEntry& nowDerived = currentDerived[laneIndex]; + const SceneDerivedEntry& beforeDerived = m_sceneDerived[laneIndex]; + differs = (nowDerived.edgeCenter != beforeDerived.edgeCenter) || + (nowDerived.meanFilter != beforeDerived.meanFilter) || + (nowDerived.weakPeak != beforeDerived.weakPeak) || + (nowDerived.edgeCenterItems != beforeDerived.edgeCenterItems) || + (nowDerived.meanFilterItems != beforeDerived.meanFilterItems) || + (nowDerived.weakPeakItems != beforeDerived.weakPeakItems); + } + if (differs) + { + lanes.push_back(static_cast(laneIndex)); + } + } + return !lanes.empty(); + } + + void PlotView::CollectSceneDerived(std::vector& out) const + { + out.clear(); + out.resize(m_lanes.size()); + for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) + { + const Lane& lane = Tab().Lanes()[laneIndex]; + SceneDerivedEntry entry; + entry.edgeCenter = lane.edgeCenter ? 1 : 0; + entry.meanFilter = lane.meanFilter ? 1 : 0; + entry.weakPeak = lane.weakPeak ? 1 : 0; + const auto flatten = [](const auto& items, std::vector& target, auto valueOf) + { + target.clear(); + target.reserve(items.size() * 4); + for (const auto& item : items) + { + target.push_back(item.ref.groupIndex); + target.push_back(item.ref.signalIndex); + // 均值滤波/中心轴取 points,弱峰调整取 amountPercent(由 `valueOf` 决定)。 + target.push_back(static_cast(valueOf(item))); + target.push_back(item.iterations); + } + }; + flatten(lane.edgeCenterItems, entry.edgeCenterItems, + [](const Lane::EdgeCenterItem& item) { return item.points; }); + flatten(lane.meanFilterItems, entry.meanFilterItems, + [](const Lane::MeanFilterItem& item) { return item.points; }); + flatten(lane.weakPeakItems, entry.weakPeakItems, + [](const Lane::WeakPeakItem& item) { return item.amountPercent; }); + out[laneIndex] = std::move(entry); + } + } + + void PlotView::RepaintSceneCachePatch(gd::Graphics& g, const std::vector& lanes) + { + if (lanes.empty() || m_sceneDc == nullptr) + { + return; + } + + // 重画范围 = 受影响网格与绘图区的交集(并且不超出缓存区域)。 + float top = 1.0e9f; + float bottom = -1.0e9f; + for (const int laneIndex : lanes) + { + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + continue; + } + const gd::RectF& rect = m_lanes[static_cast(laneIndex)].rect; + top = std::min(top, rect.Y); + bottom = std::max(bottom, rect.Y + rect.Height); + } + if (bottom <= top) + { + return; + } + top = std::max(top, m_plotRect.Y); + bottom = std::min(bottom, m_plotRect.Y + m_plotRect.Height); + if (bottom <= top) + { + return; + } + + // ⚠️ 补丁矩形必须**向内**对齐到整数像素(ceil 上边界、floor 下边界): + // 网格矩形是浮点数,而 GDI `FillRect`/`BitBlt` 会按 floor/ceil 向外取整, + // "向外"会多擦掉/多写回一整行,那一行属于相邻网格(例如它的分界线), + // 于是补丁边界上会留下一像素接缝。向内对齐后,边界外的行一律由缓存提供 + // (缓存内容与整幅重绘逐像素一致,已由对照帧验证)。 + const int x0 = std::max(static_cast(std::ceil(m_area.X)), static_cast(std::ceil(m_area.X))); + const int y0 = std::max(static_cast(std::ceil(top)), static_cast(std::ceil(m_area.Y))); + const int x1 = std::min(static_cast(std::floor(m_area.X + m_area.Width)), + static_cast(std::floor(m_area.X + m_area.Width))); + const int y1 = std::min(static_cast(std::floor(bottom)), + static_cast(std::floor(m_area.Y + m_area.Height))); + if (x1 <= x0 || y1 <= y0) + { + return; + } + + const gd::RectF patch(static_cast(x0), static_cast(y0), + static_cast(x1 - x0), static_cast(y1 - y0)); + + // 补丁区域必须被**完整**重画:凡是与它相交的网格都要重画一遍 + // (补丁已向内对齐,正常只会命中受影响的那几个网格)。 + std::vector repaint; + for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) + { + const gd::RectF& rect = m_lanes[laneIndex].rect; + if (rect.Y < static_cast(y1) && rect.Y + rect.Height > static_cast(y0)) + { + repaint.push_back(static_cast(laneIndex)); + } + } + if (repaint.empty()) + { + return; + } + + // 与整幅重绘保持完全相同的绘制顺序:底色 → 逐网格内容(PaintLane 会与当前裁剪区求交)。 + { + const int saveState = g.Save(); + g.SetClip(patch, gd::CombineModeIntersect); + // ⚠️ 底色填充必须**按补丁矩形本身**填充,不能用 m_area / m_plotRect: + // `UiFillRect` 对大面积不透明矩形会走 GDI `FillRect` 快路径,而 GDI 绘制 + // **不受 GDI+ 裁剪区约束**,会把已经贴好的缓存内容整片覆盖掉。 + UiFillRect(g, patch, m_theme->plotBg); + const float cardTop = std::max(patch.Y, m_plotRect.Y); + const float cardBottom = std::min(patch.Y + patch.Height, m_plotRect.Y + m_plotRect.Height); + if (cardBottom > cardTop) + { + UiFillRect(g, gd::RectF(patch.X, cardTop, patch.Width, cardBottom - cardTop), + m_theme->cardBg); + } + + const double viewMin = Tab().ViewMin(); + const double viewMax = Tab().ViewMax(); + const double span = viewMax - viewMin; + const double timeStep = NiceStep((span > 0.0) ? span : 1.0, kTimeTickTarget); + std::vector timeTicks; + BuildTicks(viewMin, viewMax, timeStep, timeTicks); + + // ⚠️ **补丁必须就地绘制装饰**(而不是"跳过装饰"):补丁范围覆盖整个受影响网格 + // (含网格左缘的 Y 轴胶囊与图例),若跳过绘制,这部分像素会被网格底色/波形覆盖, + // 而补丁结果还会写回场景缓存 —— 表现为"改样式时图例/Y 轴读数闪掉一下"。 + const bool savedUseDecorCache = m_useDecorCache; + m_useDecorCache = false; + for (const int laneIndex : repaint) + { + if (laneIndex >= 0 && laneIndex < static_cast(m_lanes.size())) + { + PaintLane(g, laneIndex, m_lanes[static_cast(laneIndex)], timeTicks); + } + } + m_useDecorCache = savedUseDecorCache; + g.Restore(saveState); + } + + // 把重画结果写回缓存位图,使缓存与当前样式一致(下一帧即回到"完全命中"快路径)。 + { + HDC target = g.GetHDC(); + if (target != nullptr) + { + BitBlt(m_sceneDc, x0 - m_sceneX, y0 - m_sceneY, x1 - x0, y1 - y0, target, x0, y0, SRCCOPY); + g.ReleaseHDC(target); + } + } + + m_sceneKey = MakeSceneCacheKey(); + m_sceneGeometryHash = SceneGeometryHash(); + CollectSceneStyles(m_sceneStyles); + CollectSceneDerived(m_sceneDerived); + m_sceneDegraded = m_sceneDegraded || m_fastFrame; + } + +} // namespace jdi diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 1235313..1356126 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -1,14 +1,19 @@ -// PlotView 实现分片 —— 本文件只包含 PlotView 的**成员函数定义**(纯代码搬移,行为与拆分前完全一致)。 +// PlotView 实现分片 —— 绘制(paint):只负责"把内容画出来"。 // // 共用的常量与工具函数在 PlotViewInternal.hpp(命名空间 `pvdetail`,全部 inline)。 -// 四个分片的职责划分(按功能): -// core 视图核心:构造/析构、上下文绑定、布局、坐标映射、视界与平移、网格结构与归属、 -// 选区/网格划分、拖动取消 -// paint 渲染与像素缓存:全部绘制(Paint*/Render*)、场景缓存、装饰缓存、每网格图层、 -// 宽基底与滚动补条、并行图层重建 -// interaction 交互与编辑:命中测试、鼠标与键盘处理、光标形状、内联输入框、浮动面板层序与命中 -// features 视图特性与派生数据:光标状态与读数、提示浮层、缩放模式、自适应、光标吸附、 -// 单点信号、派生曲线线程池、边沿统计、实验性功能(均值滤波 / 弱峰调整)参数 +// 六个分片的职责划分(按**关注点**,不再按函数名前缀): +// PlotView.cpp 视图核心:构造/析构、上下文绑定、布局、坐标映射、视界与平移、 +// 网格结构与归属、选区/网格划分、拖动取消 +// PlotViewPaint.cpp 绘制:全部 Paint*/Render* 与内联输入框绘制(**本文件**) +// PlotViewRaster.cpp 光栅化像素缓存与每帧预算:光晕层、场景缓存、装饰层、每网格宽基底 +// 图层、滚动补条、预热("怎么画才不卡") +// PlotViewCacheKey.cpp 内容指纹与缓存键:SceneCacheKey / *Hash / 样式与派生快照 / 样式补丁帧 +// PlotViewInteraction.cpp 交互与编辑:命中测试、鼠标与键盘处理、光标形状、内联输入框、 +// 浮动面板层序与命中 +// PlotViewFeatures.cpp 视图特性与派生数据:光标状态与读数、提示浮层、缩放模式、自适应、 +// 光标吸附、单点信号、派生曲线线程池、边沿统计、实验性功能参数 +// +// ⚠️ 新增函数按**职责**放入对应分片;`PlotView::Paint/PaintLane` 仍只允许定义在本文件。 #include "PlotViewInternal.hpp" namespace jdi @@ -1099,382 +1104,6 @@ namespace jdi paintOverlays(); } - void PlotView::PaintSignalHaloFor(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, - const PolylineCacheEntry& cached) - { - if (resolved.style == nullptr) - { - return; - } - // ------------------------------------------------------------------ - // 稠密信号(走"像素列包络带"):光晕 = **沿包络带向外扩一圈的填充**。 - // - // ⚠️ 绝不能用「加粗折线描边」画稠密信号的光晕:包络带的顶点序列是 - // 「每列上/下包络交替」的满幅竖锯齿,GDI+ 描边的代价 = 线段数 × 覆盖行数, - // 实测 2503 个顶点要 **210ms**。而 `FillPath` 同一条几何只要 ~14ms。 - // ⚠️⚠️ **必须向外扩 `grow`(= 描边笔宽的一半)**:折线描边的笔宽是 - // `lineWidth + 4`,即沿曲线向两侧各扩 2~3px —— 那正是用户看到的"外延高亮描边"。 - // 曾经直接按包络带原始几何填充,结果光晕与曲线**逐像素同形**:当信号颜色恰好等于 - // 主题强调色时(调色板第 1 色就是强调色),曲线上的那层半透明强调色完全看不出变化, - // 只剩下带宽内部的一点点底色变化 ⇒ 用户报告"高亮几乎看不到、只有曲线换了颜色" - //(2026-10-18 第三轮)。扩一圈后与旧版描边的观感一致。 - // ⚠️ 光晕**必须覆盖整条曲线**:只画鼠标附近一段时,在满幅方波上就是一个长方形色块。 - // 代价问题由 `PaintSignalHaloCached`(渲染一次 + 每帧贴图)解决。 - // ------------------------------------------------------------------ - if (resolved.style->mode != DrawMode::SCATTER && cached.bandMode && !cached.bandBreaks.empty()) - { - // 与旧版 `PaintSignalHalo` 的笔宽同源:`(lineWidth + 4) * scale`,向外取一半。 - const float grow = (std::max(1.0f, resolved.style->lineWidth) + 4.0f) * m_scale * 0.5f; - const int savedSmoothing = g.GetSmoothingMode(); - g.SetSmoothingMode(gd::SmoothingModeNone); // 半透明填充:抗锯齿没有观感收益 - gd::GraphicsPath bandPath; - bandPath.SetFillMode(gd::FillModeWinding); - gd::PointF quad[4]; - for (std::size_t k = 0; k < cached.bandBreaks.size(); ++k) - { - const std::size_t start = cached.bandBreaks[k]; - const std::size_t stop = - (k + 1 < cached.bandBreaks.size()) ? cached.bandBreaks[k + 1] : cached.band.size(); - const std::size_t total = stop - start; - if (total < 4 || (total % 2) != 0) - { - continue; - } - // 段内布局 = 「上包络正向(`half` 个)+ 下包络反向(`half` 个)」,因此 - // 列 `i` 的上/下包络分别是 `start+i` 与 `start+half+(half-1-i)`。 - // - // ⚠️⚠️ **逐列成对填四边形,而不是把整条包络拼成一个多边形**:满幅方波的 - // 包络相邻列区间**互相交叉**,拼成单个多边形后缠绕填充规则会在交叠处 - // 互相抵消 ⇒ 填充结果变成一条条竖缝(实测光晕只覆盖约一半带宽,用户看到 - // "只有曲线换了个颜色")。逐列四边形永远是简单多边形,不会被挖空。 - // ⚠️ 横向/纵向都外扩 `grow`:竖向跃变处旧版 5px 笔会涂出一条整高色带, - // 只填 1px 宽的列区间根本看不出来。相邻四边形同向、且在同一次 `FillPath` - // 内,重叠区域只混合一次(不会叠出更深的竖条)。 - const std::size_t half = total / 2; - for (std::size_t column = 0; column + 1 < half; ++column) - { - const gd::PointF& topA = cached.band[start + column]; - const gd::PointF& bottomA = cached.band[start + half + (half - 1 - column)]; - const gd::PointF& topB = cached.band[start + column + 1]; - const gd::PointF& bottomB = - cached.band[start + half + (half - 1 - (column + 1))]; - const float highY = std::min(std::min(topA.Y, bottomA.Y), std::min(topB.Y, bottomB.Y)); - const float lowY = std::max(std::max(topA.Y, bottomA.Y), std::max(topB.Y, bottomB.Y)); - quad[0] = gd::PointF(topA.X - grow, highY - grow); - quad[1] = gd::PointF(topB.X + grow, highY - grow); - quad[2] = gd::PointF(topB.X + grow, lowY + grow); - quad[3] = gd::PointF(topA.X - grow, lowY + grow); - bandPath.AddPolygon(quad, 4); - } - } - if (bandPath.GetPointCount() > 0) - { - // alpha 与旧版描边笔一致(0x66),保证任何信号颜色下观感相同。 - gd::SolidBrush brush(ToGdiColor(WithAlpha(m_theme->accent, 0x66))); - g.FillPath(&brush, &bandPath); - } - g.SetSmoothingMode(static_cast(savedSmoothing)); - return; - } - // 其余情形一律用折线描边(与"之前"的画法一致)。 - PaintSignalHalo(g, resolved, lane, cached.points, cached.breaks); - } - - void PlotView::PaintSignalHaloCached(gd::Graphics& g, const ResolvedSignal& resolved, - const LaneVisual& lane, const PolylineCacheEntry& cached) - { - if (resolved.style == nullptr) - { - return; - } - - // 光晕只可能画在该网格内:位图 = 网格矩形 ∩ 绘图区(外加少量余量容纳加粗线宽)。 - const float pad = (resolved.style->lineWidth + 8.0f) * m_scale; - const float left = std::max(lane.rect.X, m_plotRect.X) - pad; - const float top = std::max(lane.rect.Y, m_plotRect.Y) - pad; - const float right = - std::min(lane.rect.X + lane.rect.Width, m_plotRect.X + m_plotRect.Width) + pad; - const float bottom = - std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height) + pad; - if (!(right > left + 1.0f) || !(bottom > top + 1.0f)) - { - return; - } - const int targetX = static_cast(std::floor(left)); - const int targetY = static_cast(std::floor(top)); - const int width = static_cast(std::ceil(right)) - targetX; - const int height = static_cast(std::ceil(bottom)) - targetY; - if (width <= 0 || height <= 0) - { - return; - } - - HaloLayerCache& cache = m_haloLayer; - // 键 = 决定光晕几何的全部输入(与 `AcquirePolyline` 的键同源)。 - // ⚠️ **不含网格的屏幕位置**:滚动网格时几何不变,只需按新位置贴同一张位图。 - const bool sameSize = cache.bitmap && static_cast(cache.bitmap->GetWidth()) == width && - static_cast(cache.bitmap->GetHeight()) == height; - const bool sameKey = - cache.valid && sameSize && cache.ref == cached.ref && cache.group == cached.group && - cache.sampleCount == cached.sampleCount && cache.viewMin == cached.viewMin && - cache.viewMax == cached.viewMax && cache.plotX == cached.plotX && - cache.plotWidth == cached.plotWidth && cache.laneHeight == cached.laneHeight && - cache.laneYMin == cached.laneYMin && cache.laneYMax == cached.laneYMax && - cache.lineWidth == cached.lineWidth && cache.mode == resolved.style->mode && - cache.gain == resolved.style->gain && cache.absolute == resolved.style->absolute && - cache.mirrorVertical == resolved.style->mirrorVertical && - cache.mirrorHorizontal == resolved.style->mirrorHorizontal && - cache.offsetY == resolved.style->offsetY && - cache.offsetTime == resolved.style->offsetTime; - - if (!sameKey) - { - if (!sameSize) - { - cache.bitmap = std::make_unique(width, height, PixelFormat32bppARGB); - } - { - gd::Graphics layerGraphics(cache.bitmap.get()); - layerGraphics.Clear(gd::Color(0, 0, 0, 0)); - layerGraphics.SetSmoothingMode(gd::SmoothingModeAntiAlias); - layerGraphics.SetPixelOffsetMode(gd::PixelOffsetModeHalf); - // 位图坐标 = 屏幕坐标 - 目标原点,光晕几何可直接沿用屏幕坐标。 - layerGraphics.TranslateTransform(static_cast(-targetX), - static_cast(-targetY)); - if (cached.points.size() > kHaloNoAaMinPoints) - { - // 顶点很多的区间(例如深放大后的方波:每段都跨满网格高): - // 抗锯齿描边的扫描线代价 ≈ 线段数 × 覆盖行数(实测可差 20 倍), - // 而这么密的半透明光晕上平滑作用肉眼不可辨。几何完全不变。 - layerGraphics.SetSmoothingMode(gd::SmoothingModeNone); - } - PaintSignalHaloFor(layerGraphics, resolved, lane, cached); - } - cache.ref = cached.ref; - cache.group = cached.group; - cache.sampleCount = cached.sampleCount; - cache.viewMin = cached.viewMin; - cache.viewMax = cached.viewMax; - cache.plotX = cached.plotX; - cache.plotWidth = cached.plotWidth; - cache.laneHeight = cached.laneHeight; - cache.laneYMin = cached.laneYMin; - cache.laneYMax = cached.laneYMax; - cache.lineWidth = cached.lineWidth; - cache.mode = resolved.style->mode; - cache.gain = resolved.style->gain; - cache.absolute = resolved.style->absolute; - cache.mirrorVertical = resolved.style->mirrorVertical; - cache.mirrorHorizontal = resolved.style->mirrorHorizontal; - cache.offsetY = resolved.style->offsetY; - cache.offsetTime = resolved.style->offsetTime; - cache.originX = targetX; - cache.originY = targetY; - cache.valid = true; - if (!UploadHaloLayer(width, height)) - { - cache.valid = false; - return; - } - } - - // 贴图:`AlphaBlend` + `AC_SRC_ALPHA`(源位图已预乘)。命中缓存时只做这一次位块搬运。 - if (cache.dc == nullptr) - { - return; - } - const HDC targetDc = g.GetHDC(); - if (targetDc != nullptr) - { - BLENDFUNCTION blend = {}; - blend.BlendOp = AC_SRC_OVER; - blend.SourceConstantAlpha = 0xFF; - blend.AlphaFormat = AC_SRC_ALPHA; - ::AlphaBlend(targetDc, targetX, targetY, width, height, cache.dc, 0, 0, width, height, - blend); - g.ReleaseHDC(targetDc); - } - } - - bool PlotView::UploadHaloLayer(int width, int height) - { - HaloLayerCache& cache = m_haloLayer; - if (!cache.bitmap) - { - return false; - } - if (cache.dib == nullptr || cache.dibWidth != width || cache.dibHeight != height) - { - ReleaseHaloLayer(); - BITMAPINFO info = {}; - info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); - info.bmiHeader.biWidth = width; - info.bmiHeader.biHeight = -height; // 自上而下(与 GDI+ 的扫描行顺序一致) - info.bmiHeader.biPlanes = 1; - info.bmiHeader.biBitCount = 32; - info.bmiHeader.biCompression = BI_RGB; - HDC memoryDc = CreateCompatibleDC(nullptr); - if (memoryDc == nullptr) - { - return false; - } - void* bits = nullptr; - HBITMAP dib = CreateDIBSection(memoryDc, &info, DIB_RGB_COLORS, &bits, nullptr, 0); - if (dib == nullptr || bits == nullptr) - { - DeleteDC(memoryDc); - if (dib != nullptr) - { - DeleteObject(dib); - } - return false; - } - SelectObject(memoryDc, dib); - cache.dc = memoryDc; - cache.dib = dib; - cache.bits = bits; - cache.dibWidth = width; - cache.dibHeight = height; - } - - // 预乘 alpha:`AlphaBlend` 的 `AC_SRC_ALPHA` 要求源 RGB 已乘过 alpha。 - // ⚠️ 用 `(v * a + 128) * 257 >> 16` 代替 `(v * a + 127) / 255`(等价、无除法), - // **不要为此建 64KB 查表**:那张表会让 Release 产物凭空多出 64KB(见 §二进制体积), - // 而这里的乘法-移位在 1.6M 像素上也只要几毫秒。 - gd::Rect rect(0, 0, width, height); - gd::BitmapData data = {}; - if (cache.bitmap->LockBits(&rect, gd::ImageLockModeRead, PixelFormat32bppARGB, &data) != - gd::Ok) - { - return false; - } - unsigned char* destination = static_cast(cache.bits); - for (int y = 0; y < height; ++y) - { - const unsigned char* source = - static_cast(data.Scan0) + static_cast(y) * data.Stride; - unsigned char* row = destination + static_cast(y) * width * 4u; - for (int x = 0; x < width; ++x) - { - const unsigned alpha = source[x * 4 + 3]; - if (alpha == 0) - { - row[x * 4 + 0] = 0; - row[x * 4 + 1] = 0; - row[x * 4 + 2] = 0; - row[x * 4 + 3] = 0; - continue; - } - if (alpha == 0xFF) - { - row[x * 4 + 0] = source[x * 4 + 0]; - row[x * 4 + 1] = source[x * 4 + 1]; - row[x * 4 + 2] = source[x * 4 + 2]; - row[x * 4 + 3] = 0xFF; - continue; - } - row[x * 4 + 0] = static_cast( - (static_cast(source[x * 4 + 0]) * alpha + 128u) * 257u >> 16); - row[x * 4 + 1] = static_cast( - (static_cast(source[x * 4 + 1]) * alpha + 128u) * 257u >> 16); - row[x * 4 + 2] = static_cast( - (static_cast(source[x * 4 + 2]) * alpha + 128u) * 257u >> 16); - row[x * 4 + 3] = static_cast(alpha); - } - } - cache.bitmap->UnlockBits(&data); - return true; - } - - void PlotView::ReleaseHaloLayer() - { - HaloLayerCache& cache = m_haloLayer; - if (cache.dc != nullptr) - { - DeleteDC(cache.dc); - cache.dc = nullptr; - } - if (cache.dib != nullptr) - { - DeleteObject(cache.dib); - cache.dib = nullptr; - } - cache.bits = nullptr; - cache.dibWidth = 0; - cache.dibHeight = 0; - cache.valid = false; - } - - void PlotView::PaintSignalHalo(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, - const std::vector& points, - const std::vector& breaks) - { - // ⚠️ **只有单个数据点**的信号(默认样式是「点线」,那个点本身是一个粗圆点): - // 折线光晕无从下手(不足两个顶点),改为在数据点外包一圈强调色圆环。 - // 否则悬停时完全没有“可拖拽”的视觉反馈(用户 2026-10-15 报告)。 - if (resolved.data != nullptr && resolved.data->finiteCount == 1) - { - gd::PointF dot; - if (SinglePointScreenPos(resolved, lane, dot)) - { - const float radius = SinglePointDotRadius(resolved) + 3.0f * m_scale; - gd::Pen ring(ToGdiColor(WithAlpha(m_theme->accent, 0x88)), 2.0f * m_scale); - g.DrawEllipse(&ring, dot.X - radius, dot.Y - radius, radius * 2.0f, radius * 2.0f); - } - return; - } - - const bool drawLine = (resolved.style->mode != DrawMode::SCATTER); - 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); - // 散点数量本身有上限(`kMaxScatterPoints`),逐点画圈是 O(数千) 的小代价。 - 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; - } - - gd::Pen halo(ToGdiColor(WithAlpha(m_theme->accent, 0x66)), - (resolved.style->lineWidth + 4.0f) * m_scale); - halo.SetLineJoin(gd::LineJoinRound); - halo.SetStartCap(gd::LineCapRound); - halo.SetEndCap(gd::LineCapRound); - for (std::size_t k = 0; k < breaks.size(); ++k) - { - const std::size_t start = breaks[k]; - const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size(); - if (stop <= start + 1) - { - continue; - } - g.DrawLines(&halo, points.data() + start, static_cast(stop - start)); - } - } void PlotView::PaintHoverHighlight(gd::Graphics& g) { @@ -1628,490 +1257,6 @@ namespace jdi g.Restore(saveState); } - PlotView::SceneCacheKey PlotView::MakeSceneCacheKey() const - { - SceneCacheKey key; - key.viewMin = Tab().ViewMin(); - key.viewMax = Tab().ViewMax(); - key.laneScroll = Tab().LaneScroll(); - key.laneCount = static_cast(m_lanes.size()); - key.areaX = m_area.X; - key.areaY = m_area.Y; - key.areaW = m_area.Width; - key.areaH = m_area.Height; - key.scale = m_scale; - - const SignalRef halo = SceneHaloRef(); - key.haloGroup = halo.groupIndex; - key.haloSignal = halo.signalIndex; - key.contentHash = SceneContentHash(); - return key; - } - - bool PlotView::SceneCacheMatches() const - { - if (!m_sceneValid) - { - return false; - } - // 缓存里存的是「快速帧」(关掉了抗锯齿)→ 不能直接命中: - // 必须重画一次全质量版本,否则降级的画面会一直留在屏幕上。 - if (m_sceneDegraded) - { - return false; - } - const SceneCacheKey& cached = m_sceneKey; - const SceneCacheKey key = MakeSceneCacheKey(); - return (key.viewMin == cached.viewMin) && (key.viewMax == cached.viewMax) && - (key.laneScroll == cached.laneScroll) && (key.laneCount == cached.laneCount) && - (key.areaX == cached.areaX) && (key.areaY == cached.areaY) && - (key.areaW == cached.areaW) && (key.areaH == cached.areaH) && - (key.scale == cached.scale) && (key.haloGroup == cached.haloGroup) && - (key.haloSignal == cached.haloSignal) && (key.contentHash == cached.contentHash); - } - - SignalRef PlotView::SceneHaloRef() const - { - // 只有拖拽中的信号光晕是“整个拖拽过程都不变”的,可以安全地绘入静态场景缓存; - // 悬停光晕随鼠标移动,必须画在缓存之外(见 PaintHoverHighlight)。 - if (m_dragMode == PlotDragMode::MOVE_SIGNAL && m_dragSignal.IsValid()) - { - return m_dragSignal; - } - return SignalRef(); - } - - std::uint64_t PlotView::SceneContentHash(bool includeStyles, bool includeDerived, - bool includeLaneRange, bool includeLaneScroll) const - { - // FNV-1a:把“视图几何之外的一切影响绘图区像素的内容”折叠成一个指纹。 - // 这样颜色/线宽/透明度/样式/重命名/信号增删等变化都会自动让缓存失效, - // 不需要在各处手工调用 InvalidateSceneCache(容易漏)。 - // `includeStyles == false` 时跳过逐信号的外观样式(颜色/不透明度/线宽/模式), - // 得到的就是几何指纹:它一致说明“只有外观样式变了”。 - std::uint64_t hash = 1469598103934665603ull; - const auto mix = [&hash](std::uint64_t value) - { - hash ^= value; - hash *= 1099511628211ull; - }; - const auto mixFloat = [&mix](float value) - { - std::uint32_t bits = 0; - std::memcpy(&bits, &value, sizeof(bits)); - mix(bits); - }; - const auto mixText = [&mix](const std::wstring& text) - { - mix(text.size()); - for (const wchar_t character : text) - { - mix(static_cast(character)); - } - }; - - mixFloat(m_plotRect.X); - mixFloat(m_plotRect.Y); - mixFloat(m_plotRect.Width); - mixFloat(m_plotRect.Height); - mixFloat(m_laneContentHeight); - mix(m_scrollBarVisible ? 1ull : 0ull); - // ⚠️ 网格纵向滚动量必须参与**静态场景缓存**的指纹:它只改变各网格的绘制位置。 - // 若不参与,平移宽缓冲区 / 静态场景缓存会把旧滚动位置的像素当成当前内容贴回, - // 表现为“网格位置错位 / 拖影 / 残留”等错误元素。 - // ⚠️ 但它是**纯屏幕落点**(不影响任何网格内容),因此“宽基底可用性”判据 - //(`SceneGeometryHash`)把它排除:网格滚动时 `BlitWideBuffer` 本来就按 `lane.rect` - // 逐网格贴图,内容完全复用 —— 这样“网格滚动到可视区 / 回到最上方”这类 - // **每帧都在改滚动量的动画**才不会退化成每帧整幅重绘(用户 2026-10-17 报告卡顿)。 - if (includeLaneScroll) - { - mixFloat(Tab().LaneScroll()); - } - - // 选中信号会改变图例条目的底色与文字颜色(绘制在缓存的静态场景里), - // 因此必须参与指纹:否则在左侧信号面板点选后图例不会高亮联动。 - { - const SignalRef selected = SelectedRef(); - mix(selected.IsValid() ? 1ull : 0ull); - mix(static_cast(selected.groupIndex)); - mix(static_cast(selected.signalIndex)); - } - - for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) - { - const LaneVisual& visual = m_lanes[laneIndex]; - // ⚠️ 各网格的屏幕纵向位置同样只是“贴图落点”(见 `includeLaneScroll` 的说明)。 - if (includeLaneScroll) - { - mixFloat(visual.rect.Y); - } - mixFloat(visual.rect.Height); - // ⚠️ 幅值范围只在 `includeLaneRange` 时计入:拖拽平移时的纵向分量会每帧小幅改动它, - // 若参与“宽基底可用性”判据,每一帧都会退化成整幅重绘(质量下降 + 卡顿)。 - if (includeLaneRange) - { - mixFloat(static_cast(visual.yMin)); - mixFloat(static_cast(visual.yMax)); - } - - const std::vector& signals = Tab().Lanes()[laneIndex].signals; - // 「边沿计数(中心轴)」「均值滤波」「弱峰调整」的开关与每条被勾选信号的参数 - // 会改变网格内的曲线绘制。它们**只影响网格内容**(不影响网格结构/几何), - // 因此只在 includeStyles 时计入 —— 于是拖参数滑块可以走“局部重画”快路径, - // 而不必整幅重绘(`FindStyleOnlyLanes` 用 `m_sceneDerived` 定位受影响的网格)。 - // `includeDerived == false` 时连这些参数也不计入(用于装饰层指纹:图例/Y 轴刻度 - // 与派生曲线参数无关,拖滑块时装饰层无需失效)。 - if (includeStyles && includeDerived) - { - mix(Tab().Lanes()[laneIndex].edgeCenter ? 1ull : 0ull); - mix(static_cast(Tab().Lanes()[laneIndex].edgeCenterItems.size())); - for (const Lane::EdgeCenterItem& item : Tab().Lanes()[laneIndex].edgeCenterItems) - { - mix(static_cast(item.ref.groupIndex)); - mix(static_cast(item.ref.signalIndex)); - mix(static_cast(item.points)); - mix(static_cast(item.iterations)); - } - mix(Tab().Lanes()[laneIndex].meanFilter ? 1ull : 0ull); - mix(static_cast(Tab().Lanes()[laneIndex].meanFilterItems.size())); - for (const Lane::MeanFilterItem& item : Tab().Lanes()[laneIndex].meanFilterItems) - { - mix(static_cast(item.ref.groupIndex)); - mix(static_cast(item.ref.signalIndex)); - mix(static_cast(item.points)); - mix(static_cast(item.iterations)); - } - mix(Tab().Lanes()[laneIndex].weakPeak ? 1ull : 0ull); - mix(static_cast(Tab().Lanes()[laneIndex].weakPeakItems.size())); - for (const Lane::WeakPeakItem& item : Tab().Lanes()[laneIndex].weakPeakItems) - { - // 逐信号参数(等级 + 迭代次数)都参与指纹:改任一条的参数都会重画。 - mix(static_cast(item.ref.groupIndex)); - mix(static_cast(item.ref.signalIndex)); - mix(static_cast(item.amountPercent)); - mix(static_cast(item.iterations)); - } - } - mix(signals.size()); - for (const SignalRef& ref : signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - mix(static_cast(ref.groupIndex)); - mix(static_cast(ref.signalIndex)); - mix(resolved.visible ? 1ull : 0ull); - if (resolved.data == nullptr || resolved.style == nullptr) - { - continue; - } - // 以下四项是“只影响外观”的样式:仅在 includeStyles 时计入指纹。 - // (不参与时得到的就是 SceneGeometryHash:它一致说明只有外观变了, - // 于是可以只重画颜色变了的网格,而不必整幅重绘。) - if (includeStyles) - { - mix(static_cast(resolved.style->color)); - mixFloat(resolved.style->opacity); - mixFloat(resolved.style->lineWidth); - mix(static_cast(resolved.style->mode)); - // 绘制前的数值变换(系数 / 绝对值 / 镜像)会改变波形形状。 - mixFloat(resolved.style->gain); - mix(resolved.style->absolute ? 1ull : 0ull); - mix(resolved.style->mirrorVertical ? 1ull : 0ull); - mix(resolved.style->mirrorHorizontal ? 1ull : 0ull); - // 偏移(Y 平移 + 时间平移)同样会改变波形。 - mixFloat(resolved.style->offsetY); - mixFloat(static_cast(resolved.style->offsetTime)); - } - // 图例文案参与绘制,重命名必须让缓存失效。 - mixText(resolved.data->name); - if (resolved.group != nullptr) - { - mixText(resolved.group->name); - } - } - } - return hash; - } - - std::uint64_t PlotView::SceneGeometryHash() const - { - // 外观样式不参与;各网格的幅值范围也不参与(那由逐网格图层指纹负责, - // 这样拖拽时的纵向分量不会把整幅重绘的快路径打掉); - // ⚠️ 网格纵向滚动量 / 各网格屏幕纵向位置也不参与:那只是图层的**贴图落点**, - // 不影响任何网格的像素内容(`BlitWideBuffer` 按 `lane.rect` 逐网格贴图), - // 因此滚动网格时可以继续复用宽基底(否则“滚动到可视区 / 回到最上方”的 - // 每一帧都会退化成整幅重绘 —— 用户 2026-10-17 报告的滚动动画卡顿)。 - return SceneContentHash(false, true, false, false); - } - - std::uint64_t PlotView::SceneDecorHash() const - { - // 装饰层(图例 + Y 轴刻度)的指纹:含外观样式,但**不含派生曲线参数** - //(拖弱峰调整等级/迭代次数滑块时图例与刻度不变,装饰层缓存不应失效)。 - return SceneContentHash(true, false); - } - - void PlotView::CollectSceneStyles(std::vector>& out) const - { - out.clear(); - out.resize(m_lanes.size()); - for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) - { - const std::vector& signals = Tab().Lanes()[laneIndex].signals; - std::vector& lane = out[laneIndex]; - lane.reserve(signals.size()); - for (const SignalRef& ref : signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - SceneStyleEntry entry; - if (resolved.style != nullptr) - { - entry.color = resolved.style->color; - entry.opacity = resolved.style->opacity; - entry.lineWidth = resolved.style->lineWidth; - entry.mode = static_cast(resolved.style->mode); - entry.gain = resolved.style->gain; - entry.absolute = resolved.style->absolute; - entry.mirrorVertical = resolved.style->mirrorVertical; - entry.mirrorHorizontal = resolved.style->mirrorHorizontal; - entry.offsetY = resolved.style->offsetY; - entry.offsetTime = resolved.style->offsetTime; - } - lane.push_back(entry); - } - } - } - - bool PlotView::FindStyleOnlyLanes(std::vector& lanes) const - { - lanes.clear(); - if (!m_sceneValid || m_sceneGeometryHash != SceneGeometryHash()) - { - // 网格结构 / 视图几何 / 信号集合 / 图例文案有变化:必须整幅重绘。 - return false; - } - - // ⚠️ **必须额外确认“视图几何与缓存完全一致”**:`SceneGeometryHash()` 刻意不含视界, - // 所以它相等**并不能**说明视图没变。若视界已经变了还走“只重画受影响网格”的局部补丁, - // 就会把缓存里**旧视界**的画面(时间轴、其它网格、底色)留在屏上 —— 表现为用户反馈的 - // “拖影 / 残留 / 错误元素”。因此这里逐项比较除内容指纹之外的缓存键。 - { - const SceneCacheKey key = MakeSceneCacheKey(); - if (key.viewMin != m_sceneKey.viewMin || key.viewMax != m_sceneKey.viewMax || - key.laneScroll != m_sceneKey.laneScroll || key.laneCount != m_sceneKey.laneCount || - key.areaX != m_sceneKey.areaX || key.areaY != m_sceneKey.areaY || - key.areaW != m_sceneKey.areaW || key.areaH != m_sceneKey.areaH || - key.scale != m_sceneKey.scale || key.haloGroup != m_sceneKey.haloGroup || - key.haloSignal != m_sceneKey.haloSignal) - { - return false; - } - } - - std::vector> current; - CollectSceneStyles(current); - if (current.size() != m_sceneStyles.size()) - { - return false; - } - - // 叠加预览曲线(均值滤波 / 中心轴 / 弱峰调整)的参数变化同样只需局部重画。 - std::vector currentDerived; - CollectSceneDerived(currentDerived); - const bool derivedComparable = (currentDerived.size() == m_sceneDerived.size()); - - for (std::size_t laneIndex = 0; laneIndex < current.size(); ++laneIndex) - { - const std::vector& now = current[laneIndex]; - const std::vector& before = m_sceneStyles[laneIndex]; - if (now.size() != before.size()) - { - return false; - } - bool differs = false; - for (std::size_t index = 0; index < now.size() && !differs; ++index) - { - // ⚠️ 必须与 `SceneContentHash(includeStyles) / SceneGeometryHash` 的口径**完全一致**: - // 几何哈希刻意不含外观,而外观哈希含 color/opacity/lineWidth/mode **以及** - // 系数 / 绝对值 / 镜像 / Y 偏移 / 时间偏移。旧实现只比前 4 项 ⇒ 一个网格改颜色、 - // 另一个网格同时改「调整」时,后者不会被登记为脏;随后 `RepaintSceneCachePatch` - // 又写入包含这些字段的新 `m_sceneKey`,那个网格就带着旧像素被当成“已是最新”, - // 一直残留到某次无关的失效。 - differs = !(now[index] == before[index]); - } - if (!differs && derivedComparable) - { - const SceneDerivedEntry& nowDerived = currentDerived[laneIndex]; - const SceneDerivedEntry& beforeDerived = m_sceneDerived[laneIndex]; - differs = (nowDerived.edgeCenter != beforeDerived.edgeCenter) || - (nowDerived.meanFilter != beforeDerived.meanFilter) || - (nowDerived.weakPeak != beforeDerived.weakPeak) || - (nowDerived.edgeCenterItems != beforeDerived.edgeCenterItems) || - (nowDerived.meanFilterItems != beforeDerived.meanFilterItems) || - (nowDerived.weakPeakItems != beforeDerived.weakPeakItems); - } - if (differs) - { - lanes.push_back(static_cast(laneIndex)); - } - } - return !lanes.empty(); - } - - void PlotView::CollectSceneDerived(std::vector& out) const - { - out.clear(); - out.resize(m_lanes.size()); - for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) - { - const Lane& lane = Tab().Lanes()[laneIndex]; - SceneDerivedEntry entry; - entry.edgeCenter = lane.edgeCenter ? 1 : 0; - entry.meanFilter = lane.meanFilter ? 1 : 0; - entry.weakPeak = lane.weakPeak ? 1 : 0; - const auto flatten = [](const auto& items, std::vector& target, auto valueOf) - { - target.clear(); - target.reserve(items.size() * 4); - for (const auto& item : items) - { - target.push_back(item.ref.groupIndex); - target.push_back(item.ref.signalIndex); - // 均值滤波/中心轴取 points,弱峰调整取 amountPercent(由 `valueOf` 决定)。 - target.push_back(static_cast(valueOf(item))); - target.push_back(item.iterations); - } - }; - flatten(lane.edgeCenterItems, entry.edgeCenterItems, - [](const Lane::EdgeCenterItem& item) { return item.points; }); - flatten(lane.meanFilterItems, entry.meanFilterItems, - [](const Lane::MeanFilterItem& item) { return item.points; }); - flatten(lane.weakPeakItems, entry.weakPeakItems, - [](const Lane::WeakPeakItem& item) { return item.amountPercent; }); - out[laneIndex] = std::move(entry); - } - } - - void PlotView::RepaintSceneCachePatch(gd::Graphics& g, const std::vector& lanes) - { - if (lanes.empty() || m_sceneDc == nullptr) - { - return; - } - - // 重画范围 = 受影响网格与绘图区的交集(并且不超出缓存区域)。 - float top = 1.0e9f; - float bottom = -1.0e9f; - for (const int laneIndex : lanes) - { - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) - { - continue; - } - const gd::RectF& rect = m_lanes[static_cast(laneIndex)].rect; - top = std::min(top, rect.Y); - bottom = std::max(bottom, rect.Y + rect.Height); - } - if (bottom <= top) - { - return; - } - top = std::max(top, m_plotRect.Y); - bottom = std::min(bottom, m_plotRect.Y + m_plotRect.Height); - if (bottom <= top) - { - return; - } - - // ⚠️ 补丁矩形必须**向内**对齐到整数像素(ceil 上边界、floor 下边界): - // 网格矩形是浮点数,而 GDI `FillRect`/`BitBlt` 会按 floor/ceil 向外取整, - // "向外"会多擦掉/多写回一整行,那一行属于相邻网格(例如它的分界线), - // 于是补丁边界上会留下一像素接缝。向内对齐后,边界外的行一律由缓存提供 - // (缓存内容与整幅重绘逐像素一致,已由对照帧验证)。 - const int x0 = std::max(static_cast(std::ceil(m_area.X)), static_cast(std::ceil(m_area.X))); - const int y0 = std::max(static_cast(std::ceil(top)), static_cast(std::ceil(m_area.Y))); - const int x1 = std::min(static_cast(std::floor(m_area.X + m_area.Width)), - static_cast(std::floor(m_area.X + m_area.Width))); - const int y1 = std::min(static_cast(std::floor(bottom)), - static_cast(std::floor(m_area.Y + m_area.Height))); - if (x1 <= x0 || y1 <= y0) - { - return; - } - - const gd::RectF patch(static_cast(x0), static_cast(y0), - static_cast(x1 - x0), static_cast(y1 - y0)); - - // 补丁区域必须被**完整**重画:凡是与它相交的网格都要重画一遍 - // (补丁已向内对齐,正常只会命中受影响的那几个网格)。 - std::vector repaint; - for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) - { - const gd::RectF& rect = m_lanes[laneIndex].rect; - if (rect.Y < static_cast(y1) && rect.Y + rect.Height > static_cast(y0)) - { - repaint.push_back(static_cast(laneIndex)); - } - } - if (repaint.empty()) - { - return; - } - - // 与整幅重绘保持完全相同的绘制顺序:底色 → 逐网格内容(PaintLane 会与当前裁剪区求交)。 - { - const int saveState = g.Save(); - g.SetClip(patch, gd::CombineModeIntersect); - // ⚠️ 底色填充必须**按补丁矩形本身**填充,不能用 m_area / m_plotRect: - // `UiFillRect` 对大面积不透明矩形会走 GDI `FillRect` 快路径,而 GDI 绘制 - // **不受 GDI+ 裁剪区约束**,会把已经贴好的缓存内容整片覆盖掉。 - UiFillRect(g, patch, m_theme->plotBg); - const float cardTop = std::max(patch.Y, m_plotRect.Y); - const float cardBottom = std::min(patch.Y + patch.Height, m_plotRect.Y + m_plotRect.Height); - if (cardBottom > cardTop) - { - UiFillRect(g, gd::RectF(patch.X, cardTop, patch.Width, cardBottom - cardTop), - m_theme->cardBg); - } - - const double viewMin = Tab().ViewMin(); - const double viewMax = Tab().ViewMax(); - const double span = viewMax - viewMin; - const double timeStep = NiceStep((span > 0.0) ? span : 1.0, kTimeTickTarget); - std::vector timeTicks; - BuildTicks(viewMin, viewMax, timeStep, timeTicks); - - // ⚠️ **补丁必须就地绘制装饰**(而不是"跳过装饰"):补丁范围覆盖整个受影响网格 - // (含网格左缘的 Y 轴胶囊与图例),若跳过绘制,这部分像素会被网格底色/波形覆盖, - // 而补丁结果还会写回场景缓存 —— 表现为"改样式时图例/Y 轴读数闪掉一下"。 - const bool savedUseDecorCache = m_useDecorCache; - m_useDecorCache = false; - for (const int laneIndex : repaint) - { - if (laneIndex >= 0 && laneIndex < static_cast(m_lanes.size())) - { - PaintLane(g, laneIndex, m_lanes[static_cast(laneIndex)], timeTicks); - } - } - m_useDecorCache = savedUseDecorCache; - g.Restore(saveState); - } - - // 把重画结果写回缓存位图,使缓存与当前样式一致(下一帧即回到"完全命中"快路径)。 - { - HDC target = g.GetHDC(); - if (target != nullptr) - { - BitBlt(m_sceneDc, x0 - m_sceneX, y0 - m_sceneY, x1 - x0, y1 - y0, target, x0, y0, SRCCOPY); - g.ReleaseHDC(target); - } - } - - m_sceneKey = MakeSceneCacheKey(); - m_sceneGeometryHash = SceneGeometryHash(); - CollectSceneStyles(m_sceneStyles); - CollectSceneDerived(m_sceneDerived); - m_sceneDegraded = m_sceneDegraded || m_fastFrame; - } gd::RectF PlotView::LaneBadgeRect(int laneIndex) const { @@ -2250,1586 +1395,6 @@ namespace jdi } } - bool PlotView::EnsureSceneCache(int width, int height) - { - if (width <= 0 || height <= 0) - { - return false; - } - // 位图尺寸必须与当前缓存区域**严格一致**:贴图时按位图尺寸整块拷贝, - // 若沿用更大的旧位图,缩小窗口后会把旧像素画到绘图区之外。 - if (m_sceneDc != nullptr && m_sceneWidth == width && m_sceneHeight == height) - { - return true; - } - - ReleaseSceneCache(); - // 场景位图重建 → 面板图层里缓存的"面板背后的场景"随之失效。 - m_panelValid = false; - - HDC screen = GetDC(nullptr); - if (screen == nullptr) - { - return false; - } - m_sceneDc = CreateCompatibleDC(screen); - m_sceneBitmap = CreateCompatibleBitmap(screen, width, height); - ReleaseDC(nullptr, screen); - - if (m_sceneDc == nullptr || m_sceneBitmap == nullptr) - { - ReleaseSceneCache(); - return false; - } - m_sceneOldBitmap = SelectObject(m_sceneDc, m_sceneBitmap); - m_sceneWidth = width; - m_sceneHeight = height; - return true; - } - - bool PlotView::CaptureSceneCache(gd::Graphics& g) - { - // 缓存区域严格限定为绘图区本身(m_area):工具栏/标签栏/面板由宿主每帧绘制, - // 因为它们各自带有悬停态;绘图区的波形才是真正昂贵的部分(大文件约 80ms/帧)。 - const int x = static_cast(std::floor(m_area.X)); - const int y = static_cast(std::floor(m_area.Y)); - const int right = static_cast(std::ceil(m_area.X + m_area.Width)); - const int bottom = static_cast(std::ceil(m_area.Y + m_area.Height)); - const int width = right - x; - const int height = bottom - y; - if (width <= 0 || height <= 0) - { - m_sceneValid = false; - return false; - } - - m_sceneX = x; - m_sceneY = y; - - if (!EnsureSceneCache(width, height)) - { - m_sceneValid = false; - return false; - } - - HDC source = g.GetHDC(); - if (source == nullptr) - { - m_sceneValid = false; - return false; - } - const BOOL ok = BitBlt(m_sceneDc, 0, 0, width, height, source, m_sceneX, m_sceneY, SRCCOPY); - g.ReleaseHDC(source); - - if (ok == FALSE) - { - m_sceneValid = false; - return false; - } - - m_sceneKey = MakeSceneCacheKey(); - m_sceneGeometryHash = SceneGeometryHash(); - CollectSceneStyles(m_sceneStyles); - CollectSceneDerived(m_sceneDerived); - m_sceneDegraded = false; - m_sceneValid = true; - return true; - } - - bool PlotView::BlitSceneCache(gd::Graphics& g) - { - if (!m_sceneValid || m_sceneDc == nullptr) - { - return false; - } - - HDC target = g.GetHDC(); - if (target == nullptr) - { - return false; - } - const BOOL ok = BitBlt(target, m_sceneX, m_sceneY, m_sceneWidth, m_sceneHeight, m_sceneDc, 0, 0, - SRCCOPY); - g.ReleaseHDC(target); - return ok != FALSE; - } - - void PlotView::InvalidateSceneCache() - { - m_sceneValid = false; - m_sceneStyles.clear(); - m_sceneDerived.clear(); - m_sceneDegraded = false; - m_sceneGeometryHash = 0; - // 面板像素图层与场景缓存同源(面板像素里含"面板背后的场景"): - // 信号增删、重命名、参数变化等一律连带作废,避免面板里残留旧波形。 - m_panelValid = false; - } - - void PlotView::ReleaseSceneCache() - { - m_sceneValid = false; - if (m_sceneDc != nullptr) - { - if (m_sceneOldBitmap != nullptr) - { - SelectObject(m_sceneDc, m_sceneOldBitmap); - m_sceneOldBitmap = nullptr; - } - DeleteDC(m_sceneDc); - m_sceneDc = nullptr; - } - if (m_sceneBitmap != nullptr) - { - DeleteObject(m_sceneBitmap); - m_sceneBitmap = nullptr; - } - m_sceneWidth = 0; - m_sceneHeight = 0; - } - - bool PlotView::EnsureDecorCache(int width, int height) - { - if (width <= 0 || height <= 0) - { - return false; - } - if (m_decorDc != nullptr && m_decorBits != nullptr && m_decorW == width && m_decorH == height) - { - return true; - } - ReleaseDecorCache(); - HDC screen = GetDC(nullptr); - if (screen == nullptr) - { - return false; - } - // ⚠️ **必须用 32bpp DIB 段**(而不是 `CreateCompatibleBitmap`): - // 装饰要带真 alpha(圆角之外全透明),才能用 `AlphaBlend` 合成到波形之上; - // 同时 DIB 段的像素可直接写(用于 alpha 修补与清空)。 - BITMAPINFO info = {}; - info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); - info.bmiHeader.biWidth = width; - info.bmiHeader.biHeight = -height; // 自上而下 - info.bmiHeader.biPlanes = 1; - info.bmiHeader.biBitCount = 32; - info.bmiHeader.biCompression = BI_RGB; - void* bits = nullptr; - HBITMAP dib = CreateDIBSection(screen, &info, DIB_RGB_COLORS, &bits, nullptr, 0); - ReleaseDC(nullptr, screen); - if (dib == nullptr || bits == nullptr) - { - if (dib != nullptr) - { - DeleteObject(dib); - } - return false; - } - HDC dc = CreateCompatibleDC(nullptr); - if (dc == nullptr) - { - DeleteObject(dib); - return false; - } - m_decorOldBitmap = SelectObject(dc, dib); - m_decorDc = dc; - m_decorBitmap = dib; - m_decorBits = bits; - m_decorW = width; - m_decorH = height; - return true; - } - - void PlotView::ReleaseDecorCache() - { - m_decorValid = false; - m_decorCapturedRects.clear(); - if (m_decorDc != nullptr) - { - if (m_decorOldBitmap != nullptr) - { - SelectObject(m_decorDc, m_decorOldBitmap); - m_decorOldBitmap = nullptr; - } - DeleteDC(m_decorDc); - m_decorDc = nullptr; - } - if (m_decorBitmap != nullptr) - { - DeleteObject(m_decorBitmap); - m_decorBitmap = nullptr; - } - m_decorBits = nullptr; - m_decorW = 0; - m_decorH = 0; - } - - void* PlotView::ResolveAlphaBlend() - { - // `AlphaBlend` 由 msimg32 导出、并由 gdi32 的 `GdiAlphaBlend` 实现; - // 两个来源都试一遍,取到即缓存(静态局部变量,仅解析一次)。 - static void* cached = nullptr; - static bool resolved = false; - if (resolved) - { - return cached; - } - resolved = true; - HMODULE gdi = GetModuleHandleW(L"gdi32.dll"); - if (gdi != nullptr) - { - cached = reinterpret_cast(GetProcAddress(gdi, "GdiAlphaBlend")); - } - if (cached == nullptr) - { - HMODULE msimg = LoadLibraryW(L"msimg32.dll"); - if (msimg != nullptr) - { - cached = reinterpret_cast(GetProcAddress(msimg, "AlphaBlend")); - } - } - return cached; - } - - bool PlotView::RenderDecorCache() - { - m_decorValid = false; - if (m_theme == nullptr || m_fonts == nullptr || m_lanes.empty()) - { - return false; - } - // 位图覆盖「客户区原点 → 绘图区右下角」:绘制坐标与正常绘制完全一致, - // 不需要任何平移(GDI 文本不认 GDI+ 变换,平移会让文字错位)。 - const int width = static_cast(std::ceil(m_area.X + m_area.Width)); - const int height = static_cast(std::ceil(m_area.Y + m_area.Height)); - if (!EnsureDecorCache(width, height)) - { - return false; - } - // 清空为全透明(圆角之外必须保持透明,贴回时才不会盖住波形)。 - std::memset(m_decorBits, 0, static_cast(width) * static_cast(height) * 4u); - - m_decorRects.clear(); - { - gd::Graphics g(m_decorDc); - g.SetSmoothingMode(gd::SmoothingModeAntiAlias); - g.SetPixelOffsetMode(gd::PixelOffsetModeHalf); - g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit); - - const bool savedUse = m_useDecorCache; - m_useDecorCache = false; // 本次是"真正绘制":不允许走"跳过绘制"的分支 - m_renderDecorOnly = true; // 只画装饰(跳过网格线与波形) - const std::vector noTicks; - for (std::size_t index = 0; index < m_lanes.size(); ++index) - { - PaintLane(g, static_cast(index), m_lanes[index], noTicks); - } - m_renderDecorOnly = false; - m_useDecorCache = savedUse; - } - - // ⚠️ **alpha 修补**:GDI 的文本绘制(`DrawTextW`)会把它写入的像素 alpha 清成 0 - // (GDI 不维护 alpha),若原样保留,贴上后文字位置会变成"透明洞"(透出波形)。 - // 在这里把装饰矩形内"有颜色但 alpha 为 0"的像素还原为不透明即可 —— 装饰本身 - // 是画在不透明底板上的,这些像素本来就该不透明。 - for (const gd::RectF& rect : m_decorRects) - { - const int x0 = std::max(0, static_cast(std::floor(rect.X))); - const int y0 = std::max(0, static_cast(std::floor(rect.Y))); - const int x1 = std::min(width, static_cast(std::ceil(rect.X + rect.Width))); - const int y1 = std::min(height, static_cast(std::ceil(rect.Y + rect.Height))); - for (int y = y0; y < y1; ++y) - { - unsigned char* row = static_cast(m_decorBits) + - (static_cast(y) * static_cast(width)) * 4u; - for (int x = x0; x < x1; ++x) - { - unsigned char* p = row + static_cast(x) * 4u; - if (p[3] == 0 && (p[0] | p[1] | p[2]) != 0) - { - p[3] = 255; - } - } - } - } - - m_decorCapturedRects = m_decorRects; - m_decorValid = true; - return true; - } - - bool PlotView::BlitDecorCache(gd::Graphics& g) - { - if (!m_decorValid || m_decorDc == nullptr || m_decorCapturedRects.empty()) - { - return false; - } - void* proc = ResolveAlphaBlend(); - if (proc == nullptr) - { - return false; // 极小概率:系统不提供 AlphaBlend(贴回会带透明洞,故直接放弃) - } - using AlphaBlendFn = BOOL(WINAPI*)(HDC, int, int, int, int, HDC, int, int, int, int, BLENDFUNCTION); - AlphaBlendFn blend = reinterpret_cast(proc); - HDC target = g.GetHDC(); - if (target == nullptr) - { - return false; - } - // 装饰位图原点 = 客户区 (0,0):源坐标与目标坐标**相同**。 - BLENDFUNCTION fn = {}; - fn.BlendOp = AC_SRC_OVER; - fn.SourceConstantAlpha = 255; - fn.AlphaFormat = AC_SRC_ALPHA; // 源为**预乘** alpha(GDI+ 写入 DIB 段即预乘) - bool any = false; - for (const gd::RectF& rect : m_decorCapturedRects) - { - const int x = static_cast(std::floor(rect.X)); - const int y = static_cast(std::floor(rect.Y)); - const int w = static_cast(std::ceil(rect.X + rect.Width)) - x; - const int h = static_cast(std::ceil(rect.Y + rect.Height)) - y; - if (w <= 0 || h <= 0) - { - continue; - } - if (blend(target, x, y, w, h, m_decorDc, x, y, w, h, fn) != FALSE) - { - any = true; - } - } - g.ReleaseHDC(target); - return any; - } - - bool PlotView::EnsureLaneLayer(LaneLayer& layer, int width, int height) - { - if (width <= 0 || height <= 0) - { - return false; - } - if (layer.dc != nullptr && layer.width == width && layer.height == height) - { - return true; - } - ReleaseLaneLayer(layer); - HDC screen = GetDC(nullptr); - if (screen == nullptr) - { - return false; - } - layer.dc = CreateCompatibleDC(screen); - layer.bitmap = CreateCompatibleBitmap(screen, width, height); - ReleaseDC(nullptr, screen); - if (layer.dc == nullptr || layer.bitmap == nullptr) - { - ReleaseLaneLayer(layer); - return false; - } - layer.oldBitmap = SelectObject(layer.dc, layer.bitmap); - layer.width = width; - layer.height = height; - layer.valid = false; - return true; - } - - void PlotView::ReleaseLaneLayer(LaneLayer& layer) - { - layer.valid = false; - if (layer.dc != nullptr) - { - if (layer.oldBitmap != nullptr) - { - SelectObject(layer.dc, layer.oldBitmap); - layer.oldBitmap = nullptr; - } - DeleteDC(layer.dc); - layer.dc = nullptr; - } - if (layer.bitmap != nullptr) - { - DeleteObject(layer.bitmap); - layer.bitmap = nullptr; - } - layer.width = 0; - layer.height = 0; - } - - void PlotView::ReleaseWideBuffer() - { - std::lock_guard lock(m_layerMutex); - for (LaneLayer& layer : m_layerFront) - { - ReleaseLaneLayer(layer); - } - m_layerFront.clear(); - m_layerRebuildPending.clear(); - } - - void PlotView::InvalidateWideBuffer() - { - std::lock_guard lock(m_layerMutex); - for (LaneLayer& layer : m_layerFront) - { - layer.valid = false; - } - m_layerRebuildPending.clear(); - } - - std::uint64_t PlotView::LaneLayerKey(int laneIndex) const - { - // 图层只含基底(不含派生曲线与装饰),因此指纹 = **该网格自己的内容** - // ⊕ 网格下标 ⊕ 视界跨度 ⊕ 绘图区宽度(决定像素/时间与位图尺寸)。 - const double span = Tab().ViewMax() - Tab().ViewMin(); - std::uint64_t key = LaneLayerHash(laneIndex); - key ^= static_cast(laneIndex + 1) * 1315423911ull; - key *= 1099511628211ull; - key ^= static_cast(std::llround(span * 1.0e6)); - key *= 1099511628211ull; - key ^= static_cast(std::llround(m_plotRect.Width)); - return key; - } - - std::uint64_t PlotView::LaneLayerHash(int laneIndex) const - { - // 单个网格图层的内容 = 位图高度(= 网格高度)+ 幅值**跨度** + 本网格各信号的 - // 可见性与外观。 - // ⚠️ 图例与 GX 徽标**不在图层里**(它们是装饰层,每帧单独绘制),因此也**不在** - // 本指纹里;改信号名不需要重建图层。 - // - // ⚠️ **只计跨度、不计幅值的绝对范围**:图层的纵向锚点(`anchorYMax`)与 `wideMin` - // 一样是"贴图偏移"的来源,幅值整体平移不改变图层内容(只是贴图时纵向偏移若干像素), - // 因此纵向拖动不会让图层失效。真正改变内容的是**跨度**(决定像素/数值与刻度步长)。 - std::uint64_t hash = 1469598103934665603ull; - const auto mix = [&hash](std::uint64_t value) - { - hash ^= value; - hash *= 1099511628211ull; - }; - const auto mixFloat = [&mix](float value) - { - std::uint32_t bits = 0; - std::memcpy(&bits, &value, sizeof(bits)); - mix(bits); - }; - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) - { - return hash; - } - const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; - mixFloat(visual.rect.Height); - 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) - { - const ResolvedSignal resolved = ResolveSignal(ref); - mix(static_cast(ref.groupIndex)); - mix(static_cast(ref.signalIndex)); - mix(resolved.visible ? 1ull : 0ull); - if (resolved.data == nullptr || resolved.style == nullptr) - { - continue; - } - mix(static_cast(resolved.style->color)); - mixFloat(resolved.style->opacity); - mixFloat(resolved.style->lineWidth); - mix(static_cast(resolved.style->mode)); - // 「调整」变换会改变图层里的波形,也必须让图层失效 - //(否则贴回的旧像素会与当前样式不一致)。 - mixFloat(resolved.style->gain); - mix(resolved.style->absolute ? 1ull : 0ull); - mix(resolved.style->mirrorVertical ? 1ull : 0ull); - mix(resolved.style->mirrorHorizontal ? 1ull : 0ull); - mixFloat(resolved.style->offsetY); - mixFloat(static_cast(resolved.style->offsetTime)); - } - 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 - { - // 以当前视界为中心:位图左右各留半个视界宽,因此可向两侧平移 ±半屏。 - const double span = Tab().ViewMax() - Tab().ViewMin(); - const int width = static_cast(std::lround(m_plotRect.Width)); - if (!(span > 0.0) || width <= 0) - { - return Tab().ViewMin(); - } - const double scale = static_cast(width) / span; - return Tab().ViewMin() - static_cast(LayerMarginX()) / scale; - } - - bool PlotView::LayersStructurallyReady() const - { - std::lock_guard lock(m_layerMutex); - if (m_layerFront.size() != m_lanes.size() || m_layerFront.empty()) - { - return false; - } - const float plotWidth = m_plotRect.Width; - const double span = Tab().ViewMax() - Tab().ViewMin(); - if (!(plotWidth > 8.0f) || !(span > 0.0)) - { - return false; - } - const int width = static_cast(std::lround(plotWidth)); - const int bitmapWidth = width + 2 * LayerMarginX(); - for (std::size_t index = 0; index < m_layerFront.size(); ++index) - { - const LaneLayer& layer = m_layerFront[index]; - if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth) - { - return false; - } - // 跨度必须一致:跨度变了像素/时间的映射就不同,旧像素不能再当“坐标偏移”用。 - if (std::fabs(span - layer.span) > 1.0e-12) - { - return false; - } - // 高度也要一致:网格高度变化会让贴图纵向错位(含上下预渲染余量)。 - const float laneHeight = m_lanes[index].rect.Height; - const int bitmapHeight = - static_cast(std::ceil(laneHeight)) + 2 * LayerMarginY(laneHeight); - if (layer.height != bitmapHeight) - { - return false; - } - } - return true; - } - - bool PlotView::LayersAllocated() const - { - std::lock_guard lock(m_layerMutex); - return !m_layerFront.empty() && m_layerFront.size() == m_lanes.size(); - } - - bool PlotView::LaneLayerReady(int laneIndex) const - { - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) - { - return false; - } - std::lock_guard lock(m_layerMutex); - if (laneIndex >= static_cast(m_layerFront.size())) - { - return false; - } - const LaneLayer& layer = m_layerFront[static_cast(laneIndex)]; - const float plotWidth = m_plotRect.Width; - const double span = Tab().ViewMax() - Tab().ViewMin(); - if (!(plotWidth > 8.0f) || !(span > 0.0)) - { - return false; - } - const int width = static_cast(std::lround(plotWidth)); - 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) - { - return false; - } - return layer.key == LaneLayerKey(laneIndex); - } - - bool PlotView::LanesAllReadyForView() const - { - if (m_lanes.empty()) - { - return false; - } - for (std::size_t index = 0; index < m_lanes.size(); ++index) - { - if (!LaneLayerReady(static_cast(index))) - { - return false; - } - } - return true; - } - - bool PlotView::LaneVisuallyRelevant(int laneIndex) const - { - return LaneVisuallyRelevant(laneIndex, 0); - } - - bool PlotView::LaneVisuallyRelevant(int laneIndex, int prefetchScreens) const - { - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) - { - return false; - } - if (!(m_plotRect.Height > 0.0f)) - { - return false; - } - const gd::RectF& rect = m_lanes[static_cast(laneIndex)].rect; - // 可视判定本身留一个网格高的余量(即将滚入视区的网格也算相关); - // 预取带在其上再按屏高放大(见头文件说明)。 - const float margin = std::max(rect.Height, 0.0f) + - static_cast(std::max(0, prefetchScreens)) * m_plotRect.Height; - 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(index))) - { - continue; - } - anyRelevant = true; - if (!LaneLayerReady(static_cast(index))) - { - return false; - } - } - return anyRelevant; - } - - bool PlotView::AnyVisibleLanePending() const - { - // ⚠️ 用 **±1 屏预取带**(而不是“严格可见”):网格滚动动画会在几百毫秒内扫过一整个屏高, - // 只预建严格可见的网格时,动画扫进去的每一帧都要靠底衬兜底重画十几份网格 - // (实测 ~8ms/帧 ⇒ 动画明显卡顿)。预取带让静置期间就把附近网格的图层建好。 - for (const int laneIndex : m_layerRebuildPending) - { - if (LaneVisuallyRelevant(laneIndex, 1)) - { - return true; - } - } - return false; - } - - void PlotView::QueueLaneRebuild(int laneIndex) - { - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) - { - return; - } - for (const int existing : m_layerRebuildPending) - { - if (existing == laneIndex) - { - return; // 已经在队列里 - } - } - m_layerRebuildPending.push_back(laneIndex); - } - - bool PlotView::RenderPendingLaneRebuilds(int budget) - { - if (m_layerRebuildPending.empty() || budget <= 0) - { - 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` 足够大时一次取完(视界已停时调用方会传很大的预算)。 - // ⚠️ **离屏网格一律丢弃**(不重画、也不留在队列里):它们的像素当前不可见, - // 而单个巨型信号的图层重建要 10~30ms。真正进入可视/预取带时 `RollWideBuffer` - // 会按新视界重新登记它们(见那里的说明)。历史实现会排空整个队列 ⇒ 勾选一个分组后 - // 连续数秒每帧 10~30ms(用户 2026-10-18 报告的列表动画卡顿)。 - m_layerRebuildPending.erase( - std::remove_if(m_layerRebuildPending.begin(), m_layerRebuildPending.end(), - [this](int laneIndex) { return !LaneVisuallyRelevant(laneIndex, 1); }), - m_layerRebuildPending.end()); - if (m_layerRebuildPending.empty()) - { - return false; - } - const int available = static_cast(m_layerRebuildPending.size()); - const int take = std::min(budget, available); - std::vector batch; - batch.reserve(static_cast(take)); - for (int index = 0; index < take; ++index) - { - const int laneIndex = m_layerRebuildPending[static_cast(index)]; - if (laneIndex >= 0 && laneIndex < static_cast(m_lanes.size())) - { - batch.push_back(laneIndex); - } - } - m_layerRebuildPending.erase(m_layerRebuildPending.begin(), - m_layerRebuildPending.begin() + take); - - if (batch.size() > 1) - { - // **多网格一次补齐**:并行渲染(各网格用隔离上下文),比"一帧只补 1 个"收敛快 N 倍。 - RebuildLaneLayersParallel(batch, defaultWideMin); - } - else - { - for (const int laneIndex : batch) - { - // 加锁顺序与前台一致:先 `m_renderMutex` 再 `m_layerMutex`。 - std::lock_guard renderLock(m_renderMutex); - std::lock_guard layerLock(m_layerMutex); - if (m_layerFront.size() != m_lanes.size()) - { - break; - } - LaneLayer& layer = m_layerFront[static_cast(laneIndex)]; - // ⚠️ **跨度过期(缩放瞬间)也在这里就地整块重画**(按**新跨度**)。 - // 旧实现遇到跨度不符就 `continue`(把该网格推给"下一次整批重建"), - // 而"整批重建"正是那个 70~80ms 的同步卡顿源;现在改为一帧补一个网格, - // 其余网格由 `BlitWideBuffer` 的占位拉伸顶着 → 前台永不因图层未就绪而滞后。 - // 整块重画:整个图层都对齐到"当前 `yMax`"(锚点 = 当前 yMax)。 - if (!RenderLaneLayerRegion(laneIndex, layer, 0, 0, 0, 0, defaultWideMin, - m_lanes[static_cast(laneIndex)].yMax)) - { - continue; - } - } - } - return !m_layerRebuildPending.empty(); - } - - void PlotView::EnsureLayerWorkers() - { - unsigned int hardware = std::thread::hardware_concurrency(); - const int want = std::max(1, std::min(static_cast(hardware == 0 ? 2u : hardware), 4)); - if (m_layerWorkerCount == want && static_cast(m_layerWorkers.size()) == want) - { - // 字体 / 主题 / 缩放 / 数据源可能已变化:刷新一次绑定(不重建线程上下文)。 - for (int index = 0; index < want; ++index) - { - m_layerWorkers[static_cast(index)]->Setup(m_fonts, m_theme); - m_layerWorkers[static_cast(index)]->SetScale(m_scale); - m_layerWorkers[static_cast(index)]->m_dataSet = m_dataSet; - m_layerWorkers[static_cast(index)]->m_dataSetWritable = m_dataSetWritable; - } - return; - } - ReleaseLayerWorkers(); - m_layerWorkers.reserve(static_cast(want)); - for (int index = 0; index < want; ++index) - { - auto worker = std::make_unique(); - worker->Setup(m_fonts, m_theme); - worker->SetScale(m_scale); - // ⚠️ `m_tab` 置空 → worker 的 `Tab()` 返回它**自带的** `m_localTab` 副本, - // 于是它改写视界 / 网格矩形时不会碰到共享的标签页配置(安全并行的前提)。 - worker->m_dataSet = m_dataSet; - worker->m_dataSetWritable = m_dataSetWritable; - // ⚠️ `m_tab` **必须非空**(`BuildLaneVisuals()` 在空时会清空 `m_lanes`); - // 指向 worker **自有**的 `m_workerTab`,这样它改写视界 / 网格矩形时不会碰到共享标签页。 - worker->m_tab = &worker->m_workerTab; - worker->RefreshLanes(); - m_layerWorkers.push_back(std::move(worker)); - } - m_layerWorkerCount = want; - } - - void PlotView::ReleaseLayerWorkers() - { - m_layerWorkers.clear(); - m_layerWorkerCount = 0; - } - - void PlotView::RebuildLaneLayersParallel(const std::vector& laneIndices, double wideMin) - { - if (laneIndices.empty() || m_tab == nullptr || m_dataSet == nullptr) - { - return; - } - // 单网格 / 单核:直接顺序走既有路径(避免线程开销)。 - unsigned int hardware = std::thread::hardware_concurrency(); - if (laneIndices.size() <= 1 || (hardware == 0 ? 2u : hardware) <= 1) - { - std::lock_guard renderLock(m_renderMutex); - std::lock_guard layerLock(m_layerMutex); - if (m_layerFront.size() != m_lanes.size()) - { - return; - } - for (const int laneIndex : laneIndices) - { - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) - { - continue; - } - RenderLaneLayerRegion(laneIndex, m_layerFront[static_cast(laneIndex)], 0, 0, 0, - 0, wideMin, m_lanes[static_cast(laneIndex)].yMax); - } - return; - } - - { - std::lock_guard renderLock(m_renderMutex); - std::lock_guard layerLock(m_layerMutex); - if (m_layerFront.size() != m_lanes.size()) - { - return; - } - } - EnsureLayerWorkers(); - if (m_layerWorkerCount <= 1) - { - return; // 保守回退:让调用方走顺序路径 - } - - // 主线程**先快照**工作线程需要的一切(之后它们只读,不再触碰共享状态)。 - const TabConfig tabSnapshot = *m_tab; - const gd::RectF plotRectSnapshot = m_plotRect; - const gd::RectF areaSnapshot = m_area; - const float scaleSnapshot = m_scale; - const std::vector laneSnapshot = m_lanes; - // 目标图层位图数组(已确认尺寸与网格数一致,线程内不会扩容)。 - std::vector* const front = &m_layerFront; - - const int workerCount = m_layerWorkerCount; - std::atomic next{0}; - std::vector threads; - threads.reserve(static_cast(workerCount)); - for (int workerIndex = 0; workerIndex < workerCount; ++workerIndex) - { - threads.emplace_back([&, workerIndex]() - { - PlotView* worker = m_layerWorkers[static_cast(workerIndex)].get(); - for (;;) - { - const std::size_t slot = next.fetch_add(1, std::memory_order_relaxed); - if (slot >= laneIndices.size()) - { - break; - } - const int laneIndex = laneIndices[slot]; - if (laneIndex < 0 || laneIndex >= static_cast(laneSnapshot.size()) || - laneIndex >= static_cast(tabSnapshot.Lanes().size())) - { - continue; - } - // 隔离上下文:自带标签页副本 + 只含该网格 + 只读的数据源 / 字体 / 主题。 - worker->m_workerTab = tabSnapshot; - worker->m_workerTab.Lanes() = {tabSnapshot.Lanes()[static_cast(laneIndex)]}; - worker->m_tab = &worker->m_workerTab; - worker->m_fonts = m_fonts; - worker->m_theme = m_theme; - worker->m_scale = scaleSnapshot; - worker->m_dataSet = m_dataSet; - worker->m_dataSetWritable = m_dataSetWritable; - worker->m_area = areaSnapshot; - worker->m_plotRect = plotRectSnapshot; - worker->m_lanes = {laneSnapshot[static_cast(laneIndex)]}; - // ⚠️ 工作线程用**自己的** `m_workerTab` / `m_area` / `m_plotRect` / `m_lanes` 渲染, - // 因此不会与 UI 线程或其它工作线程互相覆盖渲染状态(这正是 2026-10-12 移除 - // "后台图层线程"的根因)。目标图层位图按 `laneIndex` 各自不同,可并发写入。 - worker->RenderLaneLayerRegion(0, (*front)[static_cast(laneIndex)], 0, 0, 0, 0, - wideMin, - laneSnapshot[static_cast(laneIndex)].yMax); - } - }); - } - for (std::thread& thread : threads) - { - if (thread.joinable()) - { - thread.join(); - } - } - - // 合并(主线程):工作线程内部以 `laneIndex = 0` 渲染,因此图层内容指纹要按**真实网格下标**重算。 - std::lock_guard layerLock(m_layerMutex); - if (m_layerFront.size() != m_lanes.size()) - { - return; - } - std::uint64_t rendered = 0; - for (const int laneIndex : laneIndices) - { - if (laneIndex < 0 || laneIndex >= static_cast(m_layerFront.size())) - { - continue; - } - LaneLayer& target = m_layerFront[static_cast(laneIndex)]; - if (!target.valid) - { - continue; - } - target.key = LaneLayerKey(laneIndex); - ++rendered; - } - m_layerRenderCount.fetch_add(rendered, std::memory_order_relaxed); - } - - bool PlotView::RenderLaneLayerRegion(int laneIndex, LaneLayer& layer, int stripX, int stripW, - int stripY, int stripH, double wideMin, double anchorYMax) - { - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) - { - return false; - } - std::lock_guard renderLock(m_renderMutex); - - const float plotWidth = m_plotRect.Width; - const double span = Tab().ViewMax() - Tab().ViewMin(); - if (!(plotWidth > 8.0f) || !(span > 0.0)) - { - layer.valid = false; - return false; - } - - const int width = static_cast(std::lround(plotWidth)); - const int margin = LayerMarginX(); // 左右各半屏 → 可平移 ±半屏 - const int bitmapWidth = width + 2 * margin; - const float laneHeight = m_lanes[static_cast(laneIndex)].rect.Height; - 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) - { - stripX = 0; - stripW = bitmapWidth; - } - // 纵向同理:`stripH <= 0` 表示纵向整块(横向滚动的细条就是这种情形)。 - if (stripH <= 0 || stripY < 0 || stripY + stripH > bitmapHeight) - { - stripY = 0; - stripH = bitmapHeight; - } - - // 保存当前布局 / 视图 / 网格,临时切换到"宽窗口"映射后离屏渲染,再恢复。 - const gd::RectF savedArea = m_area; - const gd::RectF savedPlot = m_plotRect; - 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(); - const double savedWideTickSpan = m_wideTickSpan; - const float savedWideTickHeight = m_wideTickHeight; - const double savedRenderTimeStep = m_renderTimeStep; - - 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), static_cast(stripY), - static_cast(stripW), static_cast(stripH)); - // 横向补条(窄竖带):把折线的构建与描边裁剪到条带的 x 范围(见 `m_stripRenderActive`)。 - // 纵向补条(整宽 × 窄 y)不裁剪 x —— 那一方向本来就需要整条折线的顶点。 - const bool stripIsHorizontalBand = (static_cast(stripW) < static_cast(bitmapWidth) - 0.5f); - const bool savedStripActive = m_stripRenderActive; - const float savedStripX0 = m_stripX0; - const float savedStripX1 = m_stripX1; - m_stripRenderActive = stripIsHorizontalBand; - m_stripX0 = static_cast(stripX); - m_stripX1 = static_cast(stripX + stripW); - Tab().ViewMin() = wideMin; - Tab().ViewMax() = wideMin + static_cast(bitmapWidth) / scale; - - m_renderWide = true; // 图层只含基底:跳过派生曲线与装饰 - // 图层必须与"同一视界的整幅渲染"**逐像素同源**:强制全质量抗锯齿(不受拖拽快速帧影响)。 - m_fastFrame = false; - // 幅值刻度的参考跨度 / 参考高度取**前台可见网格**的值(而不是含上下余量的画布值): - // 这样图层里的横向网格线步长与前台一致,纵向平移才是纯粹的像素平移。 - m_wideTickSpan = laneSpan; - m_wideTickHeight = laneHeight; - // ⚠️⚠️ **不要在这里再调 `BuildLaneVisuals()`**:它会对**全部网格**重算 - // `ComputeLaneRange`(O(网格数)),而本函数只需要**被渲染的那一个网格**。 - // 253 个网格、30 个可见网格时,每帧 30 次补条 × 2 次 BuildLaneVisuals - // = 单帧 **26.8ms**(用户 2026-10-17 报告:三明治加载后拖拽视界卡顿)。 - // 该网格的矩形/幅值范围已在下方显式设置(`m_lanes` 其余成员保持原值不变)。 - // ⚠️ **该网格的幅值映射必须以本次渲染的纵向锚点为准**(而不是"当前 `yMax`"): - // - 整块重画 / 纵向补条:锚点就是当前 `yMax`(纵向补条是滚动后的值),二者一致; - // - **横向补细条**:只重画一条竖带,其余像素仍在**原锚点**上;若这里用当前 `yMax` - // 渲染,竖带与其余像素的纵向映射就不同源,贴图后出现撕裂/错位。 - // 详见 `RenderLaneLayerRegion` 的 `anchorYMax` 形参说明。 - m_lanes[static_cast(laneIndex)].yMax = anchorYMax; - m_lanes[static_cast(laneIndex)].yMin = anchorYMax - laneSpan; - // 该网格在本图层中的位置:横向铺满整条宽图,纵向铺满整张画布。 - // 画布 = 「可见网格 + 上下各 `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), static_cast(bitmapHeight)); - { - gd::Graphics g(layer.dc); - g.SetSmoothingMode(gd::SmoothingModeAntiAlias); - g.SetPixelOffsetMode(gd::PixelOffsetModeHalf); - g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit); - - UiFillRect(g, m_area, m_theme->plotBg); - UiFillRect(g, m_area, m_theme->cardBg); - - // 竖向网格刻度沿用**前台视界跨度**(而非宽窗口的跨度),否则竖网格线会只有一半。 - // ⚠️ 同时把它作为竖向网格的**主刻步长**传给 `PaintLane`(见 `m_renderTimeStep`): - // 不传就会让图层用宽窗口跨度重算步长 → 竖向网格线与前台错位、部分整条丢失。 - const double fgSpan = savedViewMax - savedViewMin; - const double timeStep = NiceStep((fgSpan > 0.0) ? fgSpan : 1.0, kTimeTickTarget); - m_renderTimeStep = timeStep; - std::vector timeTicks; - BuildTicks(Tab().ViewMin(), Tab().ViewMax(), timeStep, timeTicks); - PaintLane(g, laneIndex, m_lanes[static_cast(laneIndex)], timeTicks); - m_renderTimeStep = 0.0; - } - m_fastFrame = savedFastFrame; - m_renderWide = savedRenderWide; - m_wideTickSpan = savedWideTickSpan; - m_wideTickHeight = savedWideTickHeight; - m_renderTimeStep = savedRenderTimeStep; - m_stripRenderActive = savedStripActive; - m_stripX0 = savedStripX0; - m_stripX1 = savedStripX1; - - // 恢复布局 / 视图 / 网格。 - // ⚠️ 这里**不调 `BuildLaneVisuals()`**(同上面的原因:O(网格数) 的 `ComputeLaneRange` - // 在每次补条时都跑一遍是拖拽卡顿的主因)。`m_lanes` 在进入本函数时已经被完整拷贝, - // 直接拷回即可 —— 它本来就是正常布局下的正确结果(矩形/幅值/内容高度都在里面)。 - m_area = savedArea; - m_plotRect = savedPlot; - Tab().ViewMin() = savedViewMin; - Tab().ViewMax() = savedViewMax; - m_lanes = savedLanes; - m_laneContentHeight = savedLaneContentHeight; - Tab().LaneScroll() = savedScroll; - - layer.wideMin = wideMin; - layer.span = span; - layer.marginY = marginY; - // 纵向锚点 = 本次渲染的像素所对应的 `yMax`(由调用方按"改动了哪部分像素"决定; - // 横向补细条必须沿用图层原锚点,否则残余的纵向偏移会被静默清零 —— 见函数说明)。 - layer.anchorYMax = anchorYMax; - layer.pxPerValue = pxPerValue; - layer.key = LaneLayerKey(laneIndex); - layer.valid = true; - m_layerRenderCount.fetch_add(1, std::memory_order_relaxed); - return true; - } - - bool PlotView::RebuildWideBuffer() - { - const float plotWidth = m_plotRect.Width; - const float areaHeight = m_area.Height; - const double span = Tab().ViewMax() - Tab().ViewMin(); - if (!(plotWidth > 8.0f) || !(areaHeight > 8.0f) || !(span > 0.0) || m_lanes.empty()) - { - InvalidateWideBuffer(); - return false; - } - - // 只在**扩容 + 清空待重画队列**这一段持有 `m_layerMutex`; - // 随后释放,`RebuildLaneLayersParallel` 会各自重新获取(它内部还会取 `m_renderMutex`)。 - // ⚠️ 如果这里一直持有 `m_layerMutex`,而并行重建内部再取一次 → `std::mutex` 不可重入 → 死锁。 - { - std::lock_guard renderLock(m_renderMutex); - std::lock_guard layerLock(m_layerMutex); - m_layerFront.resize(m_lanes.size()); - m_layerRebuildPending.clear(); - } - - // 全部网格统一用"当前视界居中"的映射(与 `BlitWideBuffer` 的逐网格 `wideMin` 相容)。 - const double wideMin = LayerDefaultWideMin(); - std::vector allLanes; - allLanes.reserve(m_lanes.size()); - for (std::size_t index = 0; index < m_lanes.size(); ++index) - { - allLanes.push_back(static_cast(index)); - } - // **并行**重建全部网格(各网格用隔离上下文,多核时约等于一块网格的耗时)。 - RebuildLaneLayersParallel(allLanes, wideMin); - std::lock_guard layerLock(m_layerMutex); - bool ok = true; - for (std::size_t index = 0; index < m_layerFront.size(); ++index) - { - if (!m_layerFront[index].valid) - { - ok = false; - } - } - return ok; - } - - bool PlotView::PrefetchStepAllowed() - { - const std::uint64_t now = GetTickCount64(); - if (now >= m_lastPrefetchRequestMs && - (now - m_lastPrefetchRequestMs) < kPrefetchMinIntervalMs) - { - return false; - } - m_lastPrefetchRequestMs = now; - return true; - } - - void PlotView::EnsureLaneLayersPrefetched() - { - if (m_lanes.empty() || !(m_plotRect.Width > 8.0f) || !(Tab().ViewMax() - Tab().ViewMin() > 0.0)) - { - return; - } - if (!LayersAllocated()) - { - // 图层数组与网格数量不符(首次绘制 / 增删网格):没有可复用的像素,只能整批建立。 - RebuildWideBuffer(); - return; - } - // ⚠️ 只登记「缺失 / 内容过期」的网格(`RollWideBuffer` 不做整块重画), - // 由 `RenderPendingLaneRebuilds` 的既有预算逐帧补齐。 - RollWideBuffer(); - } - - void PlotView::WarmLaneLayersStep() - { - if (m_dragMode != PlotDragMode::NONE || m_lanes.empty() || m_fonts == nullptr) - { - return; - } - // ⚠️ **UI 动画期间绝不预热**:单个巨型信号的图层重建要 10~30ms,塞进动画帧就会 - // 让"只有文字的信号列表"看起来卡顿(用户 2026-10-18 报告)。队列保留,动画结束后继续。 - if (m_animationBusy) - { - return; - } - if (!(m_plotRect.Width > 8.0f) || !(Tab().ViewMax() - Tab().ViewMin() > 0.0)) - { - return; - } - { - std::lock_guard renderLock(m_renderMutex); - std::lock_guard layerLock(m_layerMutex); - if (m_layerFront.size() > m_lanes.size()) - { - // 网格变少:先把多出来的图层位图释放掉(`resize` 不会调用 `ReleaseLaneLayer`)。 - for (std::size_t index = m_lanes.size(); index < m_layerFront.size(); ++index) - { - ReleaseLaneLayer(m_layerFront[index]); - } - } - if (m_layerFront.size() != m_lanes.size()) - { - m_layerFront.resize(m_lanes.size()); - } - } - // 登记"跨度 / 内容不一致"的网格。空闲时视界未变 ⇒ `RollWideBuffer` 只登记、不做滚动, - // 也不会走"位移超出画布 → 立刻整块重画"那条分支。 - RollWideBuffer(); - RenderPendingLaneRebuilds(1); - // ⚠️ 是否继续请求下一帧: - // * 还有**可视 / 预取带内**的网格待补 ⇒ 立刻继续(收敛快,见 `AnyVisibleLanePending`); - // * 只剩**离屏**网格 ⇒ 按 `kPrefetchMinIntervalMs` **限速**继续(慢速后台预热, - // 既不饿死动画定时器,又能把整批图层建好 —— 见 `PrefetchStepAllowed`)。 - const bool morePending = - AnyVisibleLanePending() || - (!m_layerRebuildPending.empty() && PrefetchStepAllowed()); - if (morePending) - { - const HWND host = m_host; - if (host != nullptr) - { - PostMessageW(host, kMessagePlotDerivedReady, 0, 0); - } - } - } - - void PlotView::RollWideBuffer() - { - const float plotWidth = m_plotRect.Width; - const double span = Tab().ViewMax() - Tab().ViewMin(); - if (!(plotWidth > 8.0f) || !(span > 0.0)) - { - return; - } - const int width = static_cast(std::lround(plotWidth)); - const int margin = LayerMarginX(); - const int bitmapWidth = width + 2 * margin; - const double scale = static_cast(width) / span; - - std::lock_guard renderLock(m_renderMutex); - std::lock_guard layerLock(m_layerMutex); - if (m_layerFront.size() != m_lanes.size() || m_layerFront.empty()) - { - return; - } - // 本帧还剩多少个「重新居中」额度(见 `kLayerRollsPerFrame`)。 - int rollBudget = kLayerRollsPerFrame; - - // ⚠️ **逐网格独立滚动**:每个图层有自己的时间映射(`layer.wideMin`), - // 因此位移必须按各自的锚点分别计算。曾用**一个全局锚点**给所有图层算同一个位移, - // 结果各网格被平移了不同的真实距离 → 网格之间时间错位、贴回的像素互相矛盾, - // 表现为严重的绘制错误与波形抖动。 - // - // ⚠️ **本函数绝不重画整块图层**:内容过期 / 锚点太远的网格只登记到待重画队列 - //(由 `RenderPendingLaneRebuilds` 按预算在后续帧补画),本帧仍尽量用现有像素跟随视界。 - // 这样前台**永远不会因为"图层没准备好"而做整批重绘**(那正是"拖拽时前台卡顿"的根因)。 - for (std::size_t index = 0; index < m_layerFront.size(); ++index) - { - LaneLayer& layer = m_layerFront[index]; - const int laneIndex = static_cast(index); - - // 结构不可用(尺寸 / 跨度不符):像素的时间映射已经不对,既不能贴也不能滚。 - 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) - { - QueueLaneRebuild(laneIndex); - continue; - } - - // 视界左端当前落在该图层的哪个 x 上;期望它等于 `margin`(左右余量对称)。 - const double desired = (Tab().ViewMin() - layer.wideMin) * scale; - const int shift = static_cast(std::llround(desired - static_cast(margin))); - // 内容(外观 / 网格几何 / 幅值**跨度** / 可见性…)与当前状态不一致。 - const bool contentStale = (layer.key != LaneLayerKey(laneIndex)); - - // ⚠️⚠️ **不在可视区(含预取带)内的网格一律不做像素级滚动**: - // 它们的像素当前根本不会被贴出来,滚了也看不见;而每次补条都要重跑 - // `BuildLaneVisuals()`(O(网格数))—— 253 个网格时这是拖拽卡顿的主因。 - // 只登记待重画(可视网格会被 `RenderPendingLaneRebuilds` 优先处理), - // 等它们真的要进入视区时再按新视界重建(期间由 `BlitWideBuffer` 的底衬兜底)。 - // - // ⚠️⚠️ **离屏网格的"内容过期"也不登记待重画**:否则一次"网格数变化"(勾选/取消 - // 一个分组)就会把**上百个离屏网格**全部塞进队列,随后每帧补 1 个 × 10~30ms - // ⇒ 数秒的持续掉帧(用户 2026-10-18 报告的信号列表动画卡顿有一半来自这里)。 - // 离屏网格真正进入可视/预取带时,下面的可见分支会按新视界登记它。 - if (!LaneVisuallyRelevant(laneIndex, 1)) - { - continue; - } - // 纵向:幅值**平移**不改内容,只改贴图偏移(与横向同构)。 - int vOffset = 0; - if (layer.pxPerValue > 0.0) - { - vOffset = static_cast( - std::llround((layer.anchorYMax - m_lanes[index].yMax) * layer.pxPerValue)); - } - - // ⚠️⚠️ **"位移太大"绝不允许【作废图层】**(用户 2026-10-18 报告: - // 快速拖动中键时**所有网格的信号一起消失**,因为一旦作废就要等拖拽结束才重建)。 - // 关键认识:`BlitWideBuffer` 按 `(ViewMin - layer.wideMin) * scale` 算源偏移, - // 只要视界仍落在位图覆盖范围内(`|shift| <= margin`),**不滚动也画得完全正确**。 - // 因此这里分三种情形: - // * `|shift| <= driftLimit`(位图仍完整覆盖视界)⇒ **什么都不做**(免费); - // * 超出 ⇒ 向中心滚,但**每帧最多滚 `kLayerRollMaxStepPixels`**(把补条代价摊开), - // 剩余位移继续由贴图偏移承担 ⇒ **永远不会空白**; - // * 真的跳到画布之外(`|shift| >= bitmapWidth`)⇒ 才作废 + 登记待重画 - // (那是“点选信号跳转”这类整屏换地方的情形,本来就要重画)。 - const int driftLimit = std::max(8, margin - 24); - const int vOffsetAbs = std::abs(vOffset); - if (vOffsetAbs >= layer.height) - { - // 纵向已超出画布(画布高约等于 2×网格高):像素映射不再可用。 - layer.valid = false; - QueueLaneRebuild(laneIndex); - continue; - } - if (contentStale) - { - QueueLaneRebuild(laneIndex); - } - - // ------------------------------------------------------------------ - // 纵向(幅值)滚动:与横向**完全同构**——内容整体上下移动若干行, - // 再只补画新暴露的那一条(整宽 × 行数)。 - // - // ⚠️ **必须先做纵向再做横向**:横向补条由 `RenderLaneLayerRegion` 渲染, - // 而该函数按传入的**纵向锚点**渲染细条;若先补横向,那时锚点还没跟上, - // 补出来的细条会带着一个错的纵向偏移(贴图后表现为波形错位/撕裂)。 - // ⚠️ 没有这一步时:幅值一变就超出余量 → 每帧都排队**整块重画**(画布比可见网格高 - // 3 倍,代价很大)→ 拖动时"抽搐";而且在重画完成前,`BlitWideBuffer` 只能贴出部分 - // 行(其余留空)→ 网格内出现**空白条带**(用户报告的"绘制异常")。 - // - // ⚠️ **滚动量必须量化**(`kLayerRollQuantum`):不足一个量子的偏移**不滚**, - // 交给贴图时的纵向偏移(`srcY`)处理。这样图层的画布映射在拖动过程中**保持稳定**, - // 折线缓存(键含幅值映射)得以命中;否则每帧滚 1~30 行会让折线每帧重算 - // (实测同样的拖动:量化前 3~4ms 的滚动 + 10~14ms 整帧,量化后回到 ~5ms 量级)。 - // 余量足够大(上下各 `marginY`),因此"不滚只偏移"绝不会超出画布。 - // 上面已保证 |vOffset| < 画布高,所以这里的 BitBlt 高度恒为正。 - // ------------------------------------------------------------------ - if (std::abs(vOffset) >= kLayerRollQuantum) - { - const int roll = vOffset; // 正 = 内容整体上移 roll 行(窗口向下移动) - int stripY = 0; - int stripH = 0; - if (roll > 0) - { - // 内容上移:new[y] = old[y + roll],底部暴露 roll 行。 - BitBlt(layer.dc, 0, 0, bitmapWidth, layer.height - roll, layer.dc, 0, roll, SRCCOPY); - stripY = layer.height - roll; - stripH = roll; - } - else - { - const int amount = -roll; - // 内容下移:new[y] = old[y - amount],顶部暴露 amount 行。 - BitBlt(layer.dc, 0, amount, bitmapWidth, layer.height - amount, layer.dc, 0, 0, - SRCCOPY); - stripY = 0; - stripH = amount; - } - // 锚点随之移动(与横向 `layer.wideMin += shift / scale` 同一套记账方式)。 - layer.anchorYMax -= static_cast(roll) / layer.pxPerValue; - // 补画暴露的那一条(整宽)。锚点用**滚动后**的值 —— 该条与已滚动的像素同一映射。 - RenderLaneLayerRegion(laneIndex, layer, 0, bitmapWidth, stripY, stripH, layer.wideMin, - layer.anchorYMax); - } - - // ⚠️ **横向重新居中只在「快要失去覆盖」时才做**(见上方 `driftLimit` 的说明): - // `BlitWideBuffer` 按 `(ViewMin - layer.wideMin)` 算源偏移,因此视界小幅漂移 - // **完全不需要滚动**(等价且免费)。只有漂移接近 `margin`(位图覆盖边界)时才需要 - // 向中心滚回去,否则网格右侧/左侧会开始用底衬(看起来像“信号被切成两半”)。 - if (std::abs(shift) <= driftLimit) - { - continue; - } - // ⚠️ **每帧只重居中有限的几个网格**(见 `kLayerRollsPerFrame`),且单次滚动量有上限 - // (`kLayerRollMaxStepPixels`):不重居中也不会画错,超出部分继续由贴图偏移承担, - // 因此可以安全地分帧完成。 - if (rollBudget <= 0) - { - continue; - } - --rollBudget; - const int maxStep = std::min(kLayerRollMaxStepPixels, bitmapWidth / 2); - const int roll = std::max(-maxStep, std::min(maxStep, shift)); - - // 整数像素自贴图平移:内容整体移动 `roll` 像素。 - if (roll > 0) - { - // 内容整体左移 roll:new[x] = old[x + roll],右侧暴露 roll 列。 - BitBlt(layer.dc, 0, 0, bitmapWidth - roll, layer.height, layer.dc, roll, 0, SRCCOPY); - } - else if (roll < 0) - { - const int amount = -roll; - BitBlt(layer.dc, amount, 0, bitmapWidth - amount, layer.height, layer.dc, 0, 0, SRCCOPY); - } - else - { - continue; - } - layer.wideMin += static_cast(roll) / scale; - - // 只补画新暴露的细条(每帧代价恒为 O(细条))。 - // ⚠️ 细条必须用**该图层更新后的映射**渲染(传 `layer.wideMin`);若改用"当前视界" - // 的映射,细条与已平移的旧像素之间会有 ≤0.5px 的错位接缝 → 拖动时看起来在抖动。 - // ⚠️ **纵向锚点必须沿用 `layer.anchorYMax`**(不能传当前 `yMax`):横向补条只改一条 - // 竖带,其余像素仍在原锚点上;同时该函数按锚点渲染这条竖带,因此竖带与其余像素 - // 纵向同源。若在这里把锚点重置为当前 `yMax`,残余的纵向偏移会被静默清零、而像素 - // 并未上移 → 整个网格的波形纵向错位("垂直拖动后再横向平移出现纵向跳变")。 - const int stripWidth = std::min(std::abs(roll), bitmapWidth - 1); - const int stripX = (roll > 0) ? (bitmapWidth - stripWidth) : 0; - RenderLaneLayerRegion(laneIndex, layer, stripX, stripWidth, 0, 0, layer.wideMin, - layer.anchorYMax); - } - } - - bool PlotView::BlitWideBuffer(gd::Graphics& g, const std::vector& timeTicks) - { - const float plotWidth = m_plotRect.Width; - const double span = Tab().ViewMax() - Tab().ViewMin(); - if (!(plotWidth > 8.0f) || !(span > 0.0)) - { - return false; - } - const int width = static_cast(std::lround(plotWidth)); - const int margin = LayerMarginX(); - const int bitmapWidth = width + 2 * margin; - const double scale = static_cast(width) / span; - - /** 一次图层贴图(所有参数在释放图层锁之前算好,避免持锁期间做 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 blits; - std::vector backing; // 本帧拿不到(完整)图层像素的网格 - blits.reserve(m_lanes.size()); - backing.reserve(m_lanes.size()); - { - std::lock_guard 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(std::lround(bottom - top)); - // 纵向映射是否仍然可用:图层高度必须等于"当前网格高 + 上下预渲染余量", - // 且时间跨度必须与当前视界一致(跨度不同则像素的时间映射已经不对)。 - const int expectBitmapHeight = - static_cast(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( - std::llround((layer.anchorYMax - m_lanes[index].yMax) * layer.pxPerValue)); - } - int srcY = static_cast(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(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(std::lround(m_plotRect.X)) + destOffset; - blit.destY = static_cast(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(index)); - } - continue; - } - } - - // ⚠️⚠️ **必须兜底补齐网格底衬,绝不能留白**:旧实现在这里直接 `continue` - //(网格空白)或用旧跨度像素 `StretchBlt` 拉伸占位(深度放大时把一小段像素放大 - // 几十倍 → 整块网格变成一片模糊底色)—— 两者在用户眼里都是 - // 「网格叠底消失,没有任何内容」。改为就地补画底衬(底色 + 网格线 + 分界线): - // 代价极低,可以每帧执行,因此**网格底衬永远不会空白/闪烁**(用户 2026-10-17 要求)。 - backing.push_back(static_cast(index)); - } - } - - if (blits.empty()) - { - // ⚠️⚠️ **绝不能因为"本帧一个网格都贴不出图层像素"就退化成整幅重绘** - //(用户 2026-10-17 报告:滚动过渡动画在巨型信号处卡顿)。 - // 这一情形在滚动到**从未构建过图层的区域**时必然出现(上百个网格时绝大多数 - // 网格的图层都还没建立):旧实现在这里 `return false` ⇒ 落到 `Paint` 末尾的 - // **整幅重绘** ⇒ 把**本帧全部可见网格的波形**(可能包含上千�万采样的巨型信号) - // 每帧重画一遍 —— 实测单帧 **20~35ms**,而手动拖滚动条只要 ~6ms。 - // 正确行为:本帧只画**网格底衬**(廉价,见下方),波形等视界真正停下之后再按预算补 - //(这正是用户要求的“没来得及画信号时就按只有网格叠底的状态继续滚动”)。 - if (backing.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 true; - } - for (const LayerBlit& blit : blits) - { - BitBlt(target, blit.destX, blit.destY, blit.width, blit.height, blit.dc, blit.srcX, blit.srcY, - SRCCOPY); - } - g.ReleaseHDC(target); - return true; - } /** * @brief 为单个网格绘制网格底衬(底色 + 横向/竖向网格线 + 网格分界线)。 diff --git a/Components/PlotView/Src/PlotViewRaster.cpp b/Components/PlotView/Src/PlotViewRaster.cpp new file mode 100644 index 0000000..9693e8d --- /dev/null +++ b/Components/PlotView/Src/PlotViewRaster.cpp @@ -0,0 +1,1977 @@ +// PlotView 实现分片 —— 光栅化像素缓存与每帧预算("怎么画才不卡")。 +// +// 本分片只包含 `PlotView` 的**成员函数定义**(从 `PlotViewPaint.cpp` 机械搬移,行为与搬移前逐字节一致)。 +// 职责: +// * 信号光晕的离屏位图层(整条曲线一次性渲染 + 每帧贴图); +// * 静态场景缓存(底色 + 网格 + 时间轴)的建立 / 抓取 / 贴回 / 作废; +// * 装饰层(带真 alpha 的独立图层,AlphaBlend 贴回); +// * 每网格"宽基底"图层:分配 / 内容指纹 / 就绪判定 / 并行重建 / 区域重画 / 滚动补条 / 贴图 / 预热预算。 +// +// ⚠️ 与绘制(`PlotViewPaint.cpp`)、指纹(`PlotViewCacheKey.cpp`)的分工: +// * 本文件**只管像素与预算**,不做业务判据; +// * 内容指纹由 `PlotViewCacheKey.cpp` 提供(`LaneLayerKey/LaneLayerHash` 里用到的哈希函数在那里); +// * 共用的常量与工具函数在 `PlotViewInternal.hpp`(命名空间 `pvdetail`,全部 `inline`),因此跨分片安全。 + +#include "PlotViewInternal.hpp" + +namespace jdi +{ + void PlotView::PaintSignalHaloFor(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, + const PolylineCacheEntry& cached) + { + if (resolved.style == nullptr) + { + return; + } + // ------------------------------------------------------------------ + // 稠密信号(走"像素列包络带"):光晕 = **沿包络带向外扩一圈的填充**。 + // + // ⚠️ 绝不能用「加粗折线描边」画稠密信号的光晕:包络带的顶点序列是 + // 「每列上/下包络交替」的满幅竖锯齿,GDI+ 描边的代价 = 线段数 × 覆盖行数, + // 实测 2503 个顶点要 **210ms**。而 `FillPath` 同一条几何只要 ~14ms。 + // ⚠️⚠️ **必须向外扩 `grow`(= 描边笔宽的一半)**:折线描边的笔宽是 + // `lineWidth + 4`,即沿曲线向两侧各扩 2~3px —— 那正是用户看到的"外延高亮描边"。 + // 曾经直接按包络带原始几何填充,结果光晕与曲线**逐像素同形**:当信号颜色恰好等于 + // 主题强调色时(调色板第 1 色就是强调色),曲线上的那层半透明强调色完全看不出变化, + // 只剩下带宽内部的一点点底色变化 ⇒ 用户报告"高亮几乎看不到、只有曲线换了颜色" + //(2026-10-18 第三轮)。扩一圈后与旧版描边的观感一致。 + // ⚠️ 光晕**必须覆盖整条曲线**:只画鼠标附近一段时,在满幅方波上就是一个长方形色块。 + // 代价问题由 `PaintSignalHaloCached`(渲染一次 + 每帧贴图)解决。 + // ------------------------------------------------------------------ + if (resolved.style->mode != DrawMode::SCATTER && cached.bandMode && !cached.bandBreaks.empty()) + { + // 与旧版 `PaintSignalHalo` 的笔宽同源:`(lineWidth + 4) * scale`,向外取一半。 + const float grow = (std::max(1.0f, resolved.style->lineWidth) + 4.0f) * m_scale * 0.5f; + const int savedSmoothing = g.GetSmoothingMode(); + g.SetSmoothingMode(gd::SmoothingModeNone); // 半透明填充:抗锯齿没有观感收益 + gd::GraphicsPath bandPath; + bandPath.SetFillMode(gd::FillModeWinding); + gd::PointF quad[4]; + for (std::size_t k = 0; k < cached.bandBreaks.size(); ++k) + { + const std::size_t start = cached.bandBreaks[k]; + const std::size_t stop = + (k + 1 < cached.bandBreaks.size()) ? cached.bandBreaks[k + 1] : cached.band.size(); + const std::size_t total = stop - start; + if (total < 4 || (total % 2) != 0) + { + continue; + } + // 段内布局 = 「上包络正向(`half` 个)+ 下包络反向(`half` 个)」,因此 + // 列 `i` 的上/下包络分别是 `start+i` 与 `start+half+(half-1-i)`。 + // + // ⚠️⚠️ **逐列成对填四边形,而不是把整条包络拼成一个多边形**:满幅方波的 + // 包络相邻列区间**互相交叉**,拼成单个多边形后缠绕填充规则会在交叠处 + // 互相抵消 ⇒ 填充结果变成一条条竖缝(实测光晕只覆盖约一半带宽,用户看到 + // "只有曲线换了个颜色")。逐列四边形永远是简单多边形,不会被挖空。 + // ⚠️ 横向/纵向都外扩 `grow`:竖向跃变处旧版 5px 笔会涂出一条整高色带, + // 只填 1px 宽的列区间根本看不出来。相邻四边形同向、且在同一次 `FillPath` + // 内,重叠区域只混合一次(不会叠出更深的竖条)。 + const std::size_t half = total / 2; + for (std::size_t column = 0; column + 1 < half; ++column) + { + const gd::PointF& topA = cached.band[start + column]; + const gd::PointF& bottomA = cached.band[start + half + (half - 1 - column)]; + const gd::PointF& topB = cached.band[start + column + 1]; + const gd::PointF& bottomB = + cached.band[start + half + (half - 1 - (column + 1))]; + const float highY = std::min(std::min(topA.Y, bottomA.Y), std::min(topB.Y, bottomB.Y)); + const float lowY = std::max(std::max(topA.Y, bottomA.Y), std::max(topB.Y, bottomB.Y)); + quad[0] = gd::PointF(topA.X - grow, highY - grow); + quad[1] = gd::PointF(topB.X + grow, highY - grow); + quad[2] = gd::PointF(topB.X + grow, lowY + grow); + quad[3] = gd::PointF(topA.X - grow, lowY + grow); + bandPath.AddPolygon(quad, 4); + } + } + if (bandPath.GetPointCount() > 0) + { + // alpha 与旧版描边笔一致(0x66),保证任何信号颜色下观感相同。 + gd::SolidBrush brush(ToGdiColor(WithAlpha(m_theme->accent, 0x66))); + g.FillPath(&brush, &bandPath); + } + g.SetSmoothingMode(static_cast(savedSmoothing)); + return; + } + // 其余情形一律用折线描边(与"之前"的画法一致)。 + PaintSignalHalo(g, resolved, lane, cached.points, cached.breaks); + } + + void PlotView::PaintSignalHaloCached(gd::Graphics& g, const ResolvedSignal& resolved, + const LaneVisual& lane, const PolylineCacheEntry& cached) + { + if (resolved.style == nullptr) + { + return; + } + + // 光晕只可能画在该网格内:位图 = 网格矩形 ∩ 绘图区(外加少量余量容纳加粗线宽)。 + const float pad = (resolved.style->lineWidth + 8.0f) * m_scale; + const float left = std::max(lane.rect.X, m_plotRect.X) - pad; + const float top = std::max(lane.rect.Y, m_plotRect.Y) - pad; + const float right = + std::min(lane.rect.X + lane.rect.Width, m_plotRect.X + m_plotRect.Width) + pad; + const float bottom = + std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height) + pad; + if (!(right > left + 1.0f) || !(bottom > top + 1.0f)) + { + return; + } + const int targetX = static_cast(std::floor(left)); + const int targetY = static_cast(std::floor(top)); + const int width = static_cast(std::ceil(right)) - targetX; + const int height = static_cast(std::ceil(bottom)) - targetY; + if (width <= 0 || height <= 0) + { + return; + } + + HaloLayerCache& cache = m_haloLayer; + // 键 = 决定光晕几何的全部输入(与 `AcquirePolyline` 的键同源)。 + // ⚠️ **不含网格的屏幕位置**:滚动网格时几何不变,只需按新位置贴同一张位图。 + const bool sameSize = cache.bitmap && static_cast(cache.bitmap->GetWidth()) == width && + static_cast(cache.bitmap->GetHeight()) == height; + const bool sameKey = + cache.valid && sameSize && cache.ref == cached.ref && cache.group == cached.group && + cache.sampleCount == cached.sampleCount && cache.viewMin == cached.viewMin && + cache.viewMax == cached.viewMax && cache.plotX == cached.plotX && + cache.plotWidth == cached.plotWidth && cache.laneHeight == cached.laneHeight && + cache.laneYMin == cached.laneYMin && cache.laneYMax == cached.laneYMax && + cache.lineWidth == cached.lineWidth && cache.mode == resolved.style->mode && + cache.gain == resolved.style->gain && cache.absolute == resolved.style->absolute && + cache.mirrorVertical == resolved.style->mirrorVertical && + cache.mirrorHorizontal == resolved.style->mirrorHorizontal && + cache.offsetY == resolved.style->offsetY && + cache.offsetTime == resolved.style->offsetTime; + + if (!sameKey) + { + if (!sameSize) + { + cache.bitmap = std::make_unique(width, height, PixelFormat32bppARGB); + } + { + gd::Graphics layerGraphics(cache.bitmap.get()); + layerGraphics.Clear(gd::Color(0, 0, 0, 0)); + layerGraphics.SetSmoothingMode(gd::SmoothingModeAntiAlias); + layerGraphics.SetPixelOffsetMode(gd::PixelOffsetModeHalf); + // 位图坐标 = 屏幕坐标 - 目标原点,光晕几何可直接沿用屏幕坐标。 + layerGraphics.TranslateTransform(static_cast(-targetX), + static_cast(-targetY)); + if (cached.points.size() > kHaloNoAaMinPoints) + { + // 顶点很多的区间(例如深放大后的方波:每段都跨满网格高): + // 抗锯齿描边的扫描线代价 ≈ 线段数 × 覆盖行数(实测可差 20 倍), + // 而这么密的半透明光晕上平滑作用肉眼不可辨。几何完全不变。 + layerGraphics.SetSmoothingMode(gd::SmoothingModeNone); + } + PaintSignalHaloFor(layerGraphics, resolved, lane, cached); + } + cache.ref = cached.ref; + cache.group = cached.group; + cache.sampleCount = cached.sampleCount; + cache.viewMin = cached.viewMin; + cache.viewMax = cached.viewMax; + cache.plotX = cached.plotX; + cache.plotWidth = cached.plotWidth; + cache.laneHeight = cached.laneHeight; + cache.laneYMin = cached.laneYMin; + cache.laneYMax = cached.laneYMax; + cache.lineWidth = cached.lineWidth; + cache.mode = resolved.style->mode; + cache.gain = resolved.style->gain; + cache.absolute = resolved.style->absolute; + cache.mirrorVertical = resolved.style->mirrorVertical; + cache.mirrorHorizontal = resolved.style->mirrorHorizontal; + cache.offsetY = resolved.style->offsetY; + cache.offsetTime = resolved.style->offsetTime; + cache.originX = targetX; + cache.originY = targetY; + cache.valid = true; + if (!UploadHaloLayer(width, height)) + { + cache.valid = false; + return; + } + } + + // 贴图:`AlphaBlend` + `AC_SRC_ALPHA`(源位图已预乘)。命中缓存时只做这一次位块搬运。 + if (cache.dc == nullptr) + { + return; + } + const HDC targetDc = g.GetHDC(); + if (targetDc != nullptr) + { + BLENDFUNCTION blend = {}; + blend.BlendOp = AC_SRC_OVER; + blend.SourceConstantAlpha = 0xFF; + blend.AlphaFormat = AC_SRC_ALPHA; + ::AlphaBlend(targetDc, targetX, targetY, width, height, cache.dc, 0, 0, width, height, + blend); + g.ReleaseHDC(targetDc); + } + } + + bool PlotView::UploadHaloLayer(int width, int height) + { + HaloLayerCache& cache = m_haloLayer; + if (!cache.bitmap) + { + return false; + } + if (cache.dib == nullptr || cache.dibWidth != width || cache.dibHeight != height) + { + ReleaseHaloLayer(); + BITMAPINFO info = {}; + info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + info.bmiHeader.biWidth = width; + info.bmiHeader.biHeight = -height; // 自上而下(与 GDI+ 的扫描行顺序一致) + info.bmiHeader.biPlanes = 1; + info.bmiHeader.biBitCount = 32; + info.bmiHeader.biCompression = BI_RGB; + HDC memoryDc = CreateCompatibleDC(nullptr); + if (memoryDc == nullptr) + { + return false; + } + void* bits = nullptr; + HBITMAP dib = CreateDIBSection(memoryDc, &info, DIB_RGB_COLORS, &bits, nullptr, 0); + if (dib == nullptr || bits == nullptr) + { + DeleteDC(memoryDc); + if (dib != nullptr) + { + DeleteObject(dib); + } + return false; + } + SelectObject(memoryDc, dib); + cache.dc = memoryDc; + cache.dib = dib; + cache.bits = bits; + cache.dibWidth = width; + cache.dibHeight = height; + } + + // 预乘 alpha:`AlphaBlend` 的 `AC_SRC_ALPHA` 要求源 RGB 已乘过 alpha。 + // ⚠️ 用 `(v * a + 128) * 257 >> 16` 代替 `(v * a + 127) / 255`(等价、无除法), + // **不要为此建 64KB 查表**:那张表会让 Release 产物凭空多出 64KB(见 §二进制体积), + // 而这里的乘法-移位在 1.6M 像素上也只要几毫秒。 + gd::Rect rect(0, 0, width, height); + gd::BitmapData data = {}; + if (cache.bitmap->LockBits(&rect, gd::ImageLockModeRead, PixelFormat32bppARGB, &data) != + gd::Ok) + { + return false; + } + unsigned char* destination = static_cast(cache.bits); + for (int y = 0; y < height; ++y) + { + const unsigned char* source = + static_cast(data.Scan0) + static_cast(y) * data.Stride; + unsigned char* row = destination + static_cast(y) * width * 4u; + for (int x = 0; x < width; ++x) + { + const unsigned alpha = source[x * 4 + 3]; + if (alpha == 0) + { + row[x * 4 + 0] = 0; + row[x * 4 + 1] = 0; + row[x * 4 + 2] = 0; + row[x * 4 + 3] = 0; + continue; + } + if (alpha == 0xFF) + { + row[x * 4 + 0] = source[x * 4 + 0]; + row[x * 4 + 1] = source[x * 4 + 1]; + row[x * 4 + 2] = source[x * 4 + 2]; + row[x * 4 + 3] = 0xFF; + continue; + } + row[x * 4 + 0] = static_cast( + (static_cast(source[x * 4 + 0]) * alpha + 128u) * 257u >> 16); + row[x * 4 + 1] = static_cast( + (static_cast(source[x * 4 + 1]) * alpha + 128u) * 257u >> 16); + row[x * 4 + 2] = static_cast( + (static_cast(source[x * 4 + 2]) * alpha + 128u) * 257u >> 16); + row[x * 4 + 3] = static_cast(alpha); + } + } + cache.bitmap->UnlockBits(&data); + return true; + } + + void PlotView::ReleaseHaloLayer() + { + HaloLayerCache& cache = m_haloLayer; + if (cache.dc != nullptr) + { + DeleteDC(cache.dc); + cache.dc = nullptr; + } + if (cache.dib != nullptr) + { + DeleteObject(cache.dib); + cache.dib = nullptr; + } + cache.bits = nullptr; + cache.dibWidth = 0; + cache.dibHeight = 0; + cache.valid = false; + } + + void PlotView::PaintSignalHalo(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, + const std::vector& points, + const std::vector& breaks) + { + // ⚠️ **只有单个数据点**的信号(默认样式是「点线」,那个点本身是一个粗圆点): + // 折线光晕无从下手(不足两个顶点),改为在数据点外包一圈强调色圆环。 + // 否则悬停时完全没有“可拖拽”的视觉反馈(用户 2026-10-15 报告)。 + if (resolved.data != nullptr && resolved.data->finiteCount == 1) + { + gd::PointF dot; + if (SinglePointScreenPos(resolved, lane, dot)) + { + const float radius = SinglePointDotRadius(resolved) + 3.0f * m_scale; + gd::Pen ring(ToGdiColor(WithAlpha(m_theme->accent, 0x88)), 2.0f * m_scale); + g.DrawEllipse(&ring, dot.X - radius, dot.Y - radius, radius * 2.0f, radius * 2.0f); + } + return; + } + + const bool drawLine = (resolved.style->mode != DrawMode::SCATTER); + 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); + // 散点数量本身有上限(`kMaxScatterPoints`),逐点画圈是 O(数千) 的小代价。 + 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; + } + + gd::Pen halo(ToGdiColor(WithAlpha(m_theme->accent, 0x66)), + (resolved.style->lineWidth + 4.0f) * m_scale); + halo.SetLineJoin(gd::LineJoinRound); + halo.SetStartCap(gd::LineCapRound); + halo.SetEndCap(gd::LineCapRound); + for (std::size_t k = 0; k < breaks.size(); ++k) + { + const std::size_t start = breaks[k]; + const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size(); + if (stop <= start + 1) + { + continue; + } + g.DrawLines(&halo, points.data() + start, static_cast(stop - start)); + } + } + + bool PlotView::EnsureSceneCache(int width, int height) + { + if (width <= 0 || height <= 0) + { + return false; + } + // 位图尺寸必须与当前缓存区域**严格一致**:贴图时按位图尺寸整块拷贝, + // 若沿用更大的旧位图,缩小窗口后会把旧像素画到绘图区之外。 + if (m_sceneDc != nullptr && m_sceneWidth == width && m_sceneHeight == height) + { + return true; + } + + ReleaseSceneCache(); + // 场景位图重建 → 面板图层里缓存的"面板背后的场景"随之失效。 + m_panelValid = false; + + HDC screen = GetDC(nullptr); + if (screen == nullptr) + { + return false; + } + m_sceneDc = CreateCompatibleDC(screen); + m_sceneBitmap = CreateCompatibleBitmap(screen, width, height); + ReleaseDC(nullptr, screen); + + if (m_sceneDc == nullptr || m_sceneBitmap == nullptr) + { + ReleaseSceneCache(); + return false; + } + m_sceneOldBitmap = SelectObject(m_sceneDc, m_sceneBitmap); + m_sceneWidth = width; + m_sceneHeight = height; + return true; + } + + bool PlotView::CaptureSceneCache(gd::Graphics& g) + { + // 缓存区域严格限定为绘图区本身(m_area):工具栏/标签栏/面板由宿主每帧绘制, + // 因为它们各自带有悬停态;绘图区的波形才是真正昂贵的部分(大文件约 80ms/帧)。 + const int x = static_cast(std::floor(m_area.X)); + const int y = static_cast(std::floor(m_area.Y)); + const int right = static_cast(std::ceil(m_area.X + m_area.Width)); + const int bottom = static_cast(std::ceil(m_area.Y + m_area.Height)); + const int width = right - x; + const int height = bottom - y; + if (width <= 0 || height <= 0) + { + m_sceneValid = false; + return false; + } + + m_sceneX = x; + m_sceneY = y; + + if (!EnsureSceneCache(width, height)) + { + m_sceneValid = false; + return false; + } + + HDC source = g.GetHDC(); + if (source == nullptr) + { + m_sceneValid = false; + return false; + } + const BOOL ok = BitBlt(m_sceneDc, 0, 0, width, height, source, m_sceneX, m_sceneY, SRCCOPY); + g.ReleaseHDC(source); + + if (ok == FALSE) + { + m_sceneValid = false; + return false; + } + + m_sceneKey = MakeSceneCacheKey(); + m_sceneGeometryHash = SceneGeometryHash(); + CollectSceneStyles(m_sceneStyles); + CollectSceneDerived(m_sceneDerived); + m_sceneDegraded = false; + m_sceneValid = true; + return true; + } + + bool PlotView::BlitSceneCache(gd::Graphics& g) + { + if (!m_sceneValid || m_sceneDc == nullptr) + { + return false; + } + + HDC target = g.GetHDC(); + if (target == nullptr) + { + return false; + } + const BOOL ok = BitBlt(target, m_sceneX, m_sceneY, m_sceneWidth, m_sceneHeight, m_sceneDc, 0, 0, + SRCCOPY); + g.ReleaseHDC(target); + return ok != FALSE; + } + + void PlotView::InvalidateSceneCache() + { + m_sceneValid = false; + m_sceneStyles.clear(); + m_sceneDerived.clear(); + m_sceneDegraded = false; + m_sceneGeometryHash = 0; + // 面板像素图层与场景缓存同源(面板像素里含"面板背后的场景"): + // 信号增删、重命名、参数变化等一律连带作废,避免面板里残留旧波形。 + m_panelValid = false; + } + + void PlotView::ReleaseSceneCache() + { + m_sceneValid = false; + if (m_sceneDc != nullptr) + { + if (m_sceneOldBitmap != nullptr) + { + SelectObject(m_sceneDc, m_sceneOldBitmap); + m_sceneOldBitmap = nullptr; + } + DeleteDC(m_sceneDc); + m_sceneDc = nullptr; + } + if (m_sceneBitmap != nullptr) + { + DeleteObject(m_sceneBitmap); + m_sceneBitmap = nullptr; + } + m_sceneWidth = 0; + m_sceneHeight = 0; + } + + bool PlotView::EnsureDecorCache(int width, int height) + { + if (width <= 0 || height <= 0) + { + return false; + } + if (m_decorDc != nullptr && m_decorBits != nullptr && m_decorW == width && m_decorH == height) + { + return true; + } + ReleaseDecorCache(); + HDC screen = GetDC(nullptr); + if (screen == nullptr) + { + return false; + } + // ⚠️ **必须用 32bpp DIB 段**(而不是 `CreateCompatibleBitmap`): + // 装饰要带真 alpha(圆角之外全透明),才能用 `AlphaBlend` 合成到波形之上; + // 同时 DIB 段的像素可直接写(用于 alpha 修补与清空)。 + BITMAPINFO info = {}; + info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + info.bmiHeader.biWidth = width; + info.bmiHeader.biHeight = -height; // 自上而下 + info.bmiHeader.biPlanes = 1; + info.bmiHeader.biBitCount = 32; + info.bmiHeader.biCompression = BI_RGB; + void* bits = nullptr; + HBITMAP dib = CreateDIBSection(screen, &info, DIB_RGB_COLORS, &bits, nullptr, 0); + ReleaseDC(nullptr, screen); + if (dib == nullptr || bits == nullptr) + { + if (dib != nullptr) + { + DeleteObject(dib); + } + return false; + } + HDC dc = CreateCompatibleDC(nullptr); + if (dc == nullptr) + { + DeleteObject(dib); + return false; + } + m_decorOldBitmap = SelectObject(dc, dib); + m_decorDc = dc; + m_decorBitmap = dib; + m_decorBits = bits; + m_decorW = width; + m_decorH = height; + return true; + } + + void PlotView::ReleaseDecorCache() + { + m_decorValid = false; + m_decorCapturedRects.clear(); + if (m_decorDc != nullptr) + { + if (m_decorOldBitmap != nullptr) + { + SelectObject(m_decorDc, m_decorOldBitmap); + m_decorOldBitmap = nullptr; + } + DeleteDC(m_decorDc); + m_decorDc = nullptr; + } + if (m_decorBitmap != nullptr) + { + DeleteObject(m_decorBitmap); + m_decorBitmap = nullptr; + } + m_decorBits = nullptr; + m_decorW = 0; + m_decorH = 0; + } + + void* PlotView::ResolveAlphaBlend() + { + // `AlphaBlend` 由 msimg32 导出、并由 gdi32 的 `GdiAlphaBlend` 实现; + // 两个来源都试一遍,取到即缓存(静态局部变量,仅解析一次)。 + static void* cached = nullptr; + static bool resolved = false; + if (resolved) + { + return cached; + } + resolved = true; + HMODULE gdi = GetModuleHandleW(L"gdi32.dll"); + if (gdi != nullptr) + { + cached = reinterpret_cast(GetProcAddress(gdi, "GdiAlphaBlend")); + } + if (cached == nullptr) + { + HMODULE msimg = LoadLibraryW(L"msimg32.dll"); + if (msimg != nullptr) + { + cached = reinterpret_cast(GetProcAddress(msimg, "AlphaBlend")); + } + } + return cached; + } + + bool PlotView::RenderDecorCache() + { + m_decorValid = false; + if (m_theme == nullptr || m_fonts == nullptr || m_lanes.empty()) + { + return false; + } + // 位图覆盖「客户区原点 → 绘图区右下角」:绘制坐标与正常绘制完全一致, + // 不需要任何平移(GDI 文本不认 GDI+ 变换,平移会让文字错位)。 + const int width = static_cast(std::ceil(m_area.X + m_area.Width)); + const int height = static_cast(std::ceil(m_area.Y + m_area.Height)); + if (!EnsureDecorCache(width, height)) + { + return false; + } + // 清空为全透明(圆角之外必须保持透明,贴回时才不会盖住波形)。 + std::memset(m_decorBits, 0, static_cast(width) * static_cast(height) * 4u); + + m_decorRects.clear(); + { + gd::Graphics g(m_decorDc); + g.SetSmoothingMode(gd::SmoothingModeAntiAlias); + g.SetPixelOffsetMode(gd::PixelOffsetModeHalf); + g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit); + + const bool savedUse = m_useDecorCache; + m_useDecorCache = false; // 本次是"真正绘制":不允许走"跳过绘制"的分支 + m_renderDecorOnly = true; // 只画装饰(跳过网格线与波形) + const std::vector noTicks; + for (std::size_t index = 0; index < m_lanes.size(); ++index) + { + PaintLane(g, static_cast(index), m_lanes[index], noTicks); + } + m_renderDecorOnly = false; + m_useDecorCache = savedUse; + } + + // ⚠️ **alpha 修补**:GDI 的文本绘制(`DrawTextW`)会把它写入的像素 alpha 清成 0 + // (GDI 不维护 alpha),若原样保留,贴上后文字位置会变成"透明洞"(透出波形)。 + // 在这里把装饰矩形内"有颜色但 alpha 为 0"的像素还原为不透明即可 —— 装饰本身 + // 是画在不透明底板上的,这些像素本来就该不透明。 + for (const gd::RectF& rect : m_decorRects) + { + const int x0 = std::max(0, static_cast(std::floor(rect.X))); + const int y0 = std::max(0, static_cast(std::floor(rect.Y))); + const int x1 = std::min(width, static_cast(std::ceil(rect.X + rect.Width))); + const int y1 = std::min(height, static_cast(std::ceil(rect.Y + rect.Height))); + for (int y = y0; y < y1; ++y) + { + unsigned char* row = static_cast(m_decorBits) + + (static_cast(y) * static_cast(width)) * 4u; + for (int x = x0; x < x1; ++x) + { + unsigned char* p = row + static_cast(x) * 4u; + if (p[3] == 0 && (p[0] | p[1] | p[2]) != 0) + { + p[3] = 255; + } + } + } + } + + m_decorCapturedRects = m_decorRects; + m_decorValid = true; + return true; + } + + bool PlotView::BlitDecorCache(gd::Graphics& g) + { + if (!m_decorValid || m_decorDc == nullptr || m_decorCapturedRects.empty()) + { + return false; + } + void* proc = ResolveAlphaBlend(); + if (proc == nullptr) + { + return false; // 极小概率:系统不提供 AlphaBlend(贴回会带透明洞,故直接放弃) + } + using AlphaBlendFn = BOOL(WINAPI*)(HDC, int, int, int, int, HDC, int, int, int, int, BLENDFUNCTION); + AlphaBlendFn blend = reinterpret_cast(proc); + HDC target = g.GetHDC(); + if (target == nullptr) + { + return false; + } + // 装饰位图原点 = 客户区 (0,0):源坐标与目标坐标**相同**。 + BLENDFUNCTION fn = {}; + fn.BlendOp = AC_SRC_OVER; + fn.SourceConstantAlpha = 255; + fn.AlphaFormat = AC_SRC_ALPHA; // 源为**预乘** alpha(GDI+ 写入 DIB 段即预乘) + bool any = false; + for (const gd::RectF& rect : m_decorCapturedRects) + { + const int x = static_cast(std::floor(rect.X)); + const int y = static_cast(std::floor(rect.Y)); + const int w = static_cast(std::ceil(rect.X + rect.Width)) - x; + const int h = static_cast(std::ceil(rect.Y + rect.Height)) - y; + if (w <= 0 || h <= 0) + { + continue; + } + if (blend(target, x, y, w, h, m_decorDc, x, y, w, h, fn) != FALSE) + { + any = true; + } + } + g.ReleaseHDC(target); + return any; + } + + bool PlotView::EnsureLaneLayer(LaneLayer& layer, int width, int height) + { + if (width <= 0 || height <= 0) + { + return false; + } + if (layer.dc != nullptr && layer.width == width && layer.height == height) + { + return true; + } + ReleaseLaneLayer(layer); + HDC screen = GetDC(nullptr); + if (screen == nullptr) + { + return false; + } + layer.dc = CreateCompatibleDC(screen); + layer.bitmap = CreateCompatibleBitmap(screen, width, height); + ReleaseDC(nullptr, screen); + if (layer.dc == nullptr || layer.bitmap == nullptr) + { + ReleaseLaneLayer(layer); + return false; + } + layer.oldBitmap = SelectObject(layer.dc, layer.bitmap); + layer.width = width; + layer.height = height; + layer.valid = false; + return true; + } + + void PlotView::ReleaseLaneLayer(LaneLayer& layer) + { + layer.valid = false; + if (layer.dc != nullptr) + { + if (layer.oldBitmap != nullptr) + { + SelectObject(layer.dc, layer.oldBitmap); + layer.oldBitmap = nullptr; + } + DeleteDC(layer.dc); + layer.dc = nullptr; + } + if (layer.bitmap != nullptr) + { + DeleteObject(layer.bitmap); + layer.bitmap = nullptr; + } + layer.width = 0; + layer.height = 0; + } + + void PlotView::ReleaseWideBuffer() + { + std::lock_guard lock(m_layerMutex); + for (LaneLayer& layer : m_layerFront) + { + ReleaseLaneLayer(layer); + } + m_layerFront.clear(); + m_layerRebuildPending.clear(); + } + + void PlotView::InvalidateWideBuffer() + { + std::lock_guard lock(m_layerMutex); + for (LaneLayer& layer : m_layerFront) + { + layer.valid = false; + } + m_layerRebuildPending.clear(); + } + + std::uint64_t PlotView::LaneLayerKey(int laneIndex) const + { + // 图层只含基底(不含派生曲线与装饰),因此指纹 = **该网格自己的内容** + // ⊕ 网格下标 ⊕ 视界跨度 ⊕ 绘图区宽度(决定像素/时间与位图尺寸)。 + const double span = Tab().ViewMax() - Tab().ViewMin(); + std::uint64_t key = LaneLayerHash(laneIndex); + key ^= static_cast(laneIndex + 1) * 1315423911ull; + key *= 1099511628211ull; + key ^= static_cast(std::llround(span * 1.0e6)); + key *= 1099511628211ull; + key ^= static_cast(std::llround(m_plotRect.Width)); + return key; + } + + std::uint64_t PlotView::LaneLayerHash(int laneIndex) const + { + // 单个网格图层的内容 = 位图高度(= 网格高度)+ 幅值**跨度** + 本网格各信号的 + // 可见性与外观。 + // ⚠️ 图例与 GX 徽标**不在图层里**(它们是装饰层,每帧单独绘制),因此也**不在** + // 本指纹里;改信号名不需要重建图层。 + // + // ⚠️ **只计跨度、不计幅值的绝对范围**:图层的纵向锚点(`anchorYMax`)与 `wideMin` + // 一样是"贴图偏移"的来源,幅值整体平移不改变图层内容(只是贴图时纵向偏移若干像素), + // 因此纵向拖动不会让图层失效。真正改变内容的是**跨度**(决定像素/数值与刻度步长)。 + std::uint64_t hash = 1469598103934665603ull; + const auto mix = [&hash](std::uint64_t value) + { + hash ^= value; + hash *= 1099511628211ull; + }; + const auto mixFloat = [&mix](float value) + { + std::uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + mix(bits); + }; + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + return hash; + } + const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; + mixFloat(visual.rect.Height); + 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) + { + const ResolvedSignal resolved = ResolveSignal(ref); + mix(static_cast(ref.groupIndex)); + mix(static_cast(ref.signalIndex)); + mix(resolved.visible ? 1ull : 0ull); + if (resolved.data == nullptr || resolved.style == nullptr) + { + continue; + } + mix(static_cast(resolved.style->color)); + mixFloat(resolved.style->opacity); + mixFloat(resolved.style->lineWidth); + mix(static_cast(resolved.style->mode)); + // 「调整」变换会改变图层里的波形,也必须让图层失效 + //(否则贴回的旧像素会与当前样式不一致)。 + mixFloat(resolved.style->gain); + mix(resolved.style->absolute ? 1ull : 0ull); + mix(resolved.style->mirrorVertical ? 1ull : 0ull); + mix(resolved.style->mirrorHorizontal ? 1ull : 0ull); + mixFloat(resolved.style->offsetY); + mixFloat(static_cast(resolved.style->offsetTime)); + } + 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 + { + // 以当前视界为中心:位图左右各留半个视界宽,因此可向两侧平移 ±半屏。 + const double span = Tab().ViewMax() - Tab().ViewMin(); + const int width = static_cast(std::lround(m_plotRect.Width)); + if (!(span > 0.0) || width <= 0) + { + return Tab().ViewMin(); + } + const double scale = static_cast(width) / span; + return Tab().ViewMin() - static_cast(LayerMarginX()) / scale; + } + + bool PlotView::LayersStructurallyReady() const + { + std::lock_guard lock(m_layerMutex); + if (m_layerFront.size() != m_lanes.size() || m_layerFront.empty()) + { + return false; + } + const float plotWidth = m_plotRect.Width; + const double span = Tab().ViewMax() - Tab().ViewMin(); + if (!(plotWidth > 8.0f) || !(span > 0.0)) + { + return false; + } + const int width = static_cast(std::lround(plotWidth)); + const int bitmapWidth = width + 2 * LayerMarginX(); + for (std::size_t index = 0; index < m_layerFront.size(); ++index) + { + const LaneLayer& layer = m_layerFront[index]; + if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth) + { + return false; + } + // 跨度必须一致:跨度变了像素/时间的映射就不同,旧像素不能再当“坐标偏移”用。 + if (std::fabs(span - layer.span) > 1.0e-12) + { + return false; + } + // 高度也要一致:网格高度变化会让贴图纵向错位(含上下预渲染余量)。 + const float laneHeight = m_lanes[index].rect.Height; + const int bitmapHeight = + static_cast(std::ceil(laneHeight)) + 2 * LayerMarginY(laneHeight); + if (layer.height != bitmapHeight) + { + return false; + } + } + return true; + } + + bool PlotView::LayersAllocated() const + { + std::lock_guard lock(m_layerMutex); + return !m_layerFront.empty() && m_layerFront.size() == m_lanes.size(); + } + + bool PlotView::LaneLayerReady(int laneIndex) const + { + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + return false; + } + std::lock_guard lock(m_layerMutex); + if (laneIndex >= static_cast(m_layerFront.size())) + { + return false; + } + const LaneLayer& layer = m_layerFront[static_cast(laneIndex)]; + const float plotWidth = m_plotRect.Width; + const double span = Tab().ViewMax() - Tab().ViewMin(); + if (!(plotWidth > 8.0f) || !(span > 0.0)) + { + return false; + } + const int width = static_cast(std::lround(plotWidth)); + 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) + { + return false; + } + return layer.key == LaneLayerKey(laneIndex); + } + + bool PlotView::LanesAllReadyForView() const + { + if (m_lanes.empty()) + { + return false; + } + for (std::size_t index = 0; index < m_lanes.size(); ++index) + { + if (!LaneLayerReady(static_cast(index))) + { + return false; + } + } + return true; + } + + bool PlotView::LaneVisuallyRelevant(int laneIndex) const + { + return LaneVisuallyRelevant(laneIndex, 0); + } + + bool PlotView::LaneVisuallyRelevant(int laneIndex, int prefetchScreens) const + { + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + return false; + } + if (!(m_plotRect.Height > 0.0f)) + { + return false; + } + const gd::RectF& rect = m_lanes[static_cast(laneIndex)].rect; + // 可视判定本身留一个网格高的余量(即将滚入视区的网格也算相关); + // 预取带在其上再按屏高放大(见头文件说明)。 + const float margin = std::max(rect.Height, 0.0f) + + static_cast(std::max(0, prefetchScreens)) * m_plotRect.Height; + 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(index))) + { + continue; + } + anyRelevant = true; + if (!LaneLayerReady(static_cast(index))) + { + return false; + } + } + return anyRelevant; + } + + bool PlotView::AnyVisibleLanePending() const + { + // ⚠️ 用 **±1 屏预取带**(而不是“严格可见”):网格滚动动画会在几百毫秒内扫过一整个屏高, + // 只预建严格可见的网格时,动画扫进去的每一帧都要靠底衬兜底重画十几份网格 + // (实测 ~8ms/帧 ⇒ 动画明显卡顿)。预取带让静置期间就把附近网格的图层建好。 + for (const int laneIndex : m_layerRebuildPending) + { + if (LaneVisuallyRelevant(laneIndex, 1)) + { + return true; + } + } + return false; + } + + void PlotView::QueueLaneRebuild(int laneIndex) + { + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + return; + } + for (const int existing : m_layerRebuildPending) + { + if (existing == laneIndex) + { + return; // 已经在队列里 + } + } + m_layerRebuildPending.push_back(laneIndex); + } + + bool PlotView::RenderPendingLaneRebuilds(int budget) + { + if (m_layerRebuildPending.empty() || budget <= 0) + { + 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` 足够大时一次取完(视界已停时调用方会传很大的预算)。 + // ⚠️ **离屏网格一律丢弃**(不重画、也不留在队列里):它们的像素当前不可见, + // 而单个巨型信号的图层重建要 10~30ms。真正进入可视/预取带时 `RollWideBuffer` + // 会按新视界重新登记它们(见那里的说明)。历史实现会排空整个队列 ⇒ 勾选一个分组后 + // 连续数秒每帧 10~30ms(用户 2026-10-18 报告的列表动画卡顿)。 + m_layerRebuildPending.erase( + std::remove_if(m_layerRebuildPending.begin(), m_layerRebuildPending.end(), + [this](int laneIndex) { return !LaneVisuallyRelevant(laneIndex, 1); }), + m_layerRebuildPending.end()); + if (m_layerRebuildPending.empty()) + { + return false; + } + const int available = static_cast(m_layerRebuildPending.size()); + const int take = std::min(budget, available); + std::vector batch; + batch.reserve(static_cast(take)); + for (int index = 0; index < take; ++index) + { + const int laneIndex = m_layerRebuildPending[static_cast(index)]; + if (laneIndex >= 0 && laneIndex < static_cast(m_lanes.size())) + { + batch.push_back(laneIndex); + } + } + m_layerRebuildPending.erase(m_layerRebuildPending.begin(), + m_layerRebuildPending.begin() + take); + + if (batch.size() > 1) + { + // **多网格一次补齐**:并行渲染(各网格用隔离上下文),比"一帧只补 1 个"收敛快 N 倍。 + RebuildLaneLayersParallel(batch, defaultWideMin); + } + else + { + for (const int laneIndex : batch) + { + // 加锁顺序与前台一致:先 `m_renderMutex` 再 `m_layerMutex`。 + std::lock_guard renderLock(m_renderMutex); + std::lock_guard layerLock(m_layerMutex); + if (m_layerFront.size() != m_lanes.size()) + { + break; + } + LaneLayer& layer = m_layerFront[static_cast(laneIndex)]; + // ⚠️ **跨度过期(缩放瞬间)也在这里就地整块重画**(按**新跨度**)。 + // 旧实现遇到跨度不符就 `continue`(把该网格推给"下一次整批重建"), + // 而"整批重建"正是那个 70~80ms 的同步卡顿源;现在改为一帧补一个网格, + // 其余网格由 `BlitWideBuffer` 的占位拉伸顶着 → 前台永不因图层未就绪而滞后。 + // 整块重画:整个图层都对齐到"当前 `yMax`"(锚点 = 当前 yMax)。 + if (!RenderLaneLayerRegion(laneIndex, layer, 0, 0, 0, 0, defaultWideMin, + m_lanes[static_cast(laneIndex)].yMax)) + { + continue; + } + } + } + return !m_layerRebuildPending.empty(); + } + + void PlotView::EnsureLayerWorkers() + { + unsigned int hardware = std::thread::hardware_concurrency(); + const int want = std::max(1, std::min(static_cast(hardware == 0 ? 2u : hardware), 4)); + if (m_layerWorkerCount == want && static_cast(m_layerWorkers.size()) == want) + { + // 字体 / 主题 / 缩放 / 数据源可能已变化:刷新一次绑定(不重建线程上下文)。 + for (int index = 0; index < want; ++index) + { + m_layerWorkers[static_cast(index)]->Setup(m_fonts, m_theme); + m_layerWorkers[static_cast(index)]->SetScale(m_scale); + m_layerWorkers[static_cast(index)]->m_dataSet = m_dataSet; + m_layerWorkers[static_cast(index)]->m_dataSetWritable = m_dataSetWritable; + } + return; + } + ReleaseLayerWorkers(); + m_layerWorkers.reserve(static_cast(want)); + for (int index = 0; index < want; ++index) + { + auto worker = std::make_unique(); + worker->Setup(m_fonts, m_theme); + worker->SetScale(m_scale); + // ⚠️ `m_tab` 置空 → worker 的 `Tab()` 返回它**自带的** `m_localTab` 副本, + // 于是它改写视界 / 网格矩形时不会碰到共享的标签页配置(安全并行的前提)。 + worker->m_dataSet = m_dataSet; + worker->m_dataSetWritable = m_dataSetWritable; + // ⚠️ `m_tab` **必须非空**(`BuildLaneVisuals()` 在空时会清空 `m_lanes`); + // 指向 worker **自有**的 `m_workerTab`,这样它改写视界 / 网格矩形时不会碰到共享标签页。 + worker->m_tab = &worker->m_workerTab; + worker->RefreshLanes(); + m_layerWorkers.push_back(std::move(worker)); + } + m_layerWorkerCount = want; + } + + void PlotView::ReleaseLayerWorkers() + { + m_layerWorkers.clear(); + m_layerWorkerCount = 0; + } + + void PlotView::RebuildLaneLayersParallel(const std::vector& laneIndices, double wideMin) + { + if (laneIndices.empty() || m_tab == nullptr || m_dataSet == nullptr) + { + return; + } + // 单网格 / 单核:直接顺序走既有路径(避免线程开销)。 + unsigned int hardware = std::thread::hardware_concurrency(); + if (laneIndices.size() <= 1 || (hardware == 0 ? 2u : hardware) <= 1) + { + std::lock_guard renderLock(m_renderMutex); + std::lock_guard layerLock(m_layerMutex); + if (m_layerFront.size() != m_lanes.size()) + { + return; + } + for (const int laneIndex : laneIndices) + { + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + continue; + } + RenderLaneLayerRegion(laneIndex, m_layerFront[static_cast(laneIndex)], 0, 0, 0, + 0, wideMin, m_lanes[static_cast(laneIndex)].yMax); + } + return; + } + + { + std::lock_guard renderLock(m_renderMutex); + std::lock_guard layerLock(m_layerMutex); + if (m_layerFront.size() != m_lanes.size()) + { + return; + } + } + EnsureLayerWorkers(); + if (m_layerWorkerCount <= 1) + { + return; // 保守回退:让调用方走顺序路径 + } + + // 主线程**先快照**工作线程需要的一切(之后它们只读,不再触碰共享状态)。 + const TabConfig tabSnapshot = *m_tab; + const gd::RectF plotRectSnapshot = m_plotRect; + const gd::RectF areaSnapshot = m_area; + const float scaleSnapshot = m_scale; + const std::vector laneSnapshot = m_lanes; + // 目标图层位图数组(已确认尺寸与网格数一致,线程内不会扩容)。 + std::vector* const front = &m_layerFront; + + const int workerCount = m_layerWorkerCount; + std::atomic next{0}; + std::vector threads; + threads.reserve(static_cast(workerCount)); + for (int workerIndex = 0; workerIndex < workerCount; ++workerIndex) + { + threads.emplace_back([&, workerIndex]() + { + PlotView* worker = m_layerWorkers[static_cast(workerIndex)].get(); + for (;;) + { + const std::size_t slot = next.fetch_add(1, std::memory_order_relaxed); + if (slot >= laneIndices.size()) + { + break; + } + const int laneIndex = laneIndices[slot]; + if (laneIndex < 0 || laneIndex >= static_cast(laneSnapshot.size()) || + laneIndex >= static_cast(tabSnapshot.Lanes().size())) + { + continue; + } + // 隔离上下文:自带标签页副本 + 只含该网格 + 只读的数据源 / 字体 / 主题。 + worker->m_workerTab = tabSnapshot; + worker->m_workerTab.Lanes() = {tabSnapshot.Lanes()[static_cast(laneIndex)]}; + worker->m_tab = &worker->m_workerTab; + worker->m_fonts = m_fonts; + worker->m_theme = m_theme; + worker->m_scale = scaleSnapshot; + worker->m_dataSet = m_dataSet; + worker->m_dataSetWritable = m_dataSetWritable; + worker->m_area = areaSnapshot; + worker->m_plotRect = plotRectSnapshot; + worker->m_lanes = {laneSnapshot[static_cast(laneIndex)]}; + // ⚠️ 工作线程用**自己的** `m_workerTab` / `m_area` / `m_plotRect` / `m_lanes` 渲染, + // 因此不会与 UI 线程或其它工作线程互相覆盖渲染状态(这正是 2026-10-12 移除 + // "后台图层线程"的根因)。目标图层位图按 `laneIndex` 各自不同,可并发写入。 + worker->RenderLaneLayerRegion(0, (*front)[static_cast(laneIndex)], 0, 0, 0, 0, + wideMin, + laneSnapshot[static_cast(laneIndex)].yMax); + } + }); + } + for (std::thread& thread : threads) + { + if (thread.joinable()) + { + thread.join(); + } + } + + // 合并(主线程):工作线程内部以 `laneIndex = 0` 渲染,因此图层内容指纹要按**真实网格下标**重算。 + std::lock_guard layerLock(m_layerMutex); + if (m_layerFront.size() != m_lanes.size()) + { + return; + } + std::uint64_t rendered = 0; + for (const int laneIndex : laneIndices) + { + if (laneIndex < 0 || laneIndex >= static_cast(m_layerFront.size())) + { + continue; + } + LaneLayer& target = m_layerFront[static_cast(laneIndex)]; + if (!target.valid) + { + continue; + } + target.key = LaneLayerKey(laneIndex); + ++rendered; + } + m_layerRenderCount.fetch_add(rendered, std::memory_order_relaxed); + } + + bool PlotView::RenderLaneLayerRegion(int laneIndex, LaneLayer& layer, int stripX, int stripW, + int stripY, int stripH, double wideMin, double anchorYMax) + { + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + return false; + } + std::lock_guard renderLock(m_renderMutex); + + const float plotWidth = m_plotRect.Width; + const double span = Tab().ViewMax() - Tab().ViewMin(); + if (!(plotWidth > 8.0f) || !(span > 0.0)) + { + layer.valid = false; + return false; + } + + const int width = static_cast(std::lround(plotWidth)); + const int margin = LayerMarginX(); // 左右各半屏 → 可平移 ±半屏 + const int bitmapWidth = width + 2 * margin; + const float laneHeight = m_lanes[static_cast(laneIndex)].rect.Height; + 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) + { + stripX = 0; + stripW = bitmapWidth; + } + // 纵向同理:`stripH <= 0` 表示纵向整块(横向滚动的细条就是这种情形)。 + if (stripH <= 0 || stripY < 0 || stripY + stripH > bitmapHeight) + { + stripY = 0; + stripH = bitmapHeight; + } + + // 保存当前布局 / 视图 / 网格,临时切换到"宽窗口"映射后离屏渲染,再恢复。 + const gd::RectF savedArea = m_area; + const gd::RectF savedPlot = m_plotRect; + 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(); + const double savedWideTickSpan = m_wideTickSpan; + const float savedWideTickHeight = m_wideTickHeight; + const double savedRenderTimeStep = m_renderTimeStep; + + 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), static_cast(stripY), + static_cast(stripW), static_cast(stripH)); + // 横向补条(窄竖带):把折线的构建与描边裁剪到条带的 x 范围(见 `m_stripRenderActive`)。 + // 纵向补条(整宽 × 窄 y)不裁剪 x —— 那一方向本来就需要整条折线的顶点。 + const bool stripIsHorizontalBand = (static_cast(stripW) < static_cast(bitmapWidth) - 0.5f); + const bool savedStripActive = m_stripRenderActive; + const float savedStripX0 = m_stripX0; + const float savedStripX1 = m_stripX1; + m_stripRenderActive = stripIsHorizontalBand; + m_stripX0 = static_cast(stripX); + m_stripX1 = static_cast(stripX + stripW); + Tab().ViewMin() = wideMin; + Tab().ViewMax() = wideMin + static_cast(bitmapWidth) / scale; + + m_renderWide = true; // 图层只含基底:跳过派生曲线与装饰 + // 图层必须与"同一视界的整幅渲染"**逐像素同源**:强制全质量抗锯齿(不受拖拽快速帧影响)。 + m_fastFrame = false; + // 幅值刻度的参考跨度 / 参考高度取**前台可见网格**的值(而不是含上下余量的画布值): + // 这样图层里的横向网格线步长与前台一致,纵向平移才是纯粹的像素平移。 + m_wideTickSpan = laneSpan; + m_wideTickHeight = laneHeight; + // ⚠️⚠️ **不要在这里再调 `BuildLaneVisuals()`**:它会对**全部网格**重算 + // `ComputeLaneRange`(O(网格数)),而本函数只需要**被渲染的那一个网格**。 + // 253 个网格、30 个可见网格时,每帧 30 次补条 × 2 次 BuildLaneVisuals + // = 单帧 **26.8ms**(用户 2026-10-17 报告:三明治加载后拖拽视界卡顿)。 + // 该网格的矩形/幅值范围已在下方显式设置(`m_lanes` 其余成员保持原值不变)。 + // ⚠️ **该网格的幅值映射必须以本次渲染的纵向锚点为准**(而不是"当前 `yMax`"): + // - 整块重画 / 纵向补条:锚点就是当前 `yMax`(纵向补条是滚动后的值),二者一致; + // - **横向补细条**:只重画一条竖带,其余像素仍在**原锚点**上;若这里用当前 `yMax` + // 渲染,竖带与其余像素的纵向映射就不同源,贴图后出现撕裂/错位。 + // 详见 `RenderLaneLayerRegion` 的 `anchorYMax` 形参说明。 + m_lanes[static_cast(laneIndex)].yMax = anchorYMax; + m_lanes[static_cast(laneIndex)].yMin = anchorYMax - laneSpan; + // 该网格在本图层中的位置:横向铺满整条宽图,纵向铺满整张画布。 + // 画布 = 「可见网格 + 上下各 `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), static_cast(bitmapHeight)); + { + gd::Graphics g(layer.dc); + g.SetSmoothingMode(gd::SmoothingModeAntiAlias); + g.SetPixelOffsetMode(gd::PixelOffsetModeHalf); + g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit); + + UiFillRect(g, m_area, m_theme->plotBg); + UiFillRect(g, m_area, m_theme->cardBg); + + // 竖向网格刻度沿用**前台视界跨度**(而非宽窗口的跨度),否则竖网格线会只有一半。 + // ⚠️ 同时把它作为竖向网格的**主刻步长**传给 `PaintLane`(见 `m_renderTimeStep`): + // 不传就会让图层用宽窗口跨度重算步长 → 竖向网格线与前台错位、部分整条丢失。 + const double fgSpan = savedViewMax - savedViewMin; + const double timeStep = NiceStep((fgSpan > 0.0) ? fgSpan : 1.0, kTimeTickTarget); + m_renderTimeStep = timeStep; + std::vector timeTicks; + BuildTicks(Tab().ViewMin(), Tab().ViewMax(), timeStep, timeTicks); + PaintLane(g, laneIndex, m_lanes[static_cast(laneIndex)], timeTicks); + m_renderTimeStep = 0.0; + } + m_fastFrame = savedFastFrame; + m_renderWide = savedRenderWide; + m_wideTickSpan = savedWideTickSpan; + m_wideTickHeight = savedWideTickHeight; + m_renderTimeStep = savedRenderTimeStep; + m_stripRenderActive = savedStripActive; + m_stripX0 = savedStripX0; + m_stripX1 = savedStripX1; + + // 恢复布局 / 视图 / 网格。 + // ⚠️ 这里**不调 `BuildLaneVisuals()`**(同上面的原因:O(网格数) 的 `ComputeLaneRange` + // 在每次补条时都跑一遍是拖拽卡顿的主因)。`m_lanes` 在进入本函数时已经被完整拷贝, + // 直接拷回即可 —— 它本来就是正常布局下的正确结果(矩形/幅值/内容高度都在里面)。 + m_area = savedArea; + m_plotRect = savedPlot; + Tab().ViewMin() = savedViewMin; + Tab().ViewMax() = savedViewMax; + m_lanes = savedLanes; + m_laneContentHeight = savedLaneContentHeight; + Tab().LaneScroll() = savedScroll; + + layer.wideMin = wideMin; + layer.span = span; + layer.marginY = marginY; + // 纵向锚点 = 本次渲染的像素所对应的 `yMax`(由调用方按"改动了哪部分像素"决定; + // 横向补细条必须沿用图层原锚点,否则残余的纵向偏移会被静默清零 —— 见函数说明)。 + layer.anchorYMax = anchorYMax; + layer.pxPerValue = pxPerValue; + layer.key = LaneLayerKey(laneIndex); + layer.valid = true; + m_layerRenderCount.fetch_add(1, std::memory_order_relaxed); + return true; + } + + bool PlotView::RebuildWideBuffer() + { + const float plotWidth = m_plotRect.Width; + const float areaHeight = m_area.Height; + const double span = Tab().ViewMax() - Tab().ViewMin(); + if (!(plotWidth > 8.0f) || !(areaHeight > 8.0f) || !(span > 0.0) || m_lanes.empty()) + { + InvalidateWideBuffer(); + return false; + } + + // 只在**扩容 + 清空待重画队列**这一段持有 `m_layerMutex`; + // 随后释放,`RebuildLaneLayersParallel` 会各自重新获取(它内部还会取 `m_renderMutex`)。 + // ⚠️ 如果这里一直持有 `m_layerMutex`,而并行重建内部再取一次 → `std::mutex` 不可重入 → 死锁。 + { + std::lock_guard renderLock(m_renderMutex); + std::lock_guard layerLock(m_layerMutex); + m_layerFront.resize(m_lanes.size()); + m_layerRebuildPending.clear(); + } + + // 全部网格统一用"当前视界居中"的映射(与 `BlitWideBuffer` 的逐网格 `wideMin` 相容)。 + const double wideMin = LayerDefaultWideMin(); + std::vector allLanes; + allLanes.reserve(m_lanes.size()); + for (std::size_t index = 0; index < m_lanes.size(); ++index) + { + allLanes.push_back(static_cast(index)); + } + // **并行**重建全部网格(各网格用隔离上下文,多核时约等于一块网格的耗时)。 + RebuildLaneLayersParallel(allLanes, wideMin); + std::lock_guard layerLock(m_layerMutex); + bool ok = true; + for (std::size_t index = 0; index < m_layerFront.size(); ++index) + { + if (!m_layerFront[index].valid) + { + ok = false; + } + } + return ok; + } + + bool PlotView::PrefetchStepAllowed() + { + const std::uint64_t now = GetTickCount64(); + if (now >= m_lastPrefetchRequestMs && + (now - m_lastPrefetchRequestMs) < kPrefetchMinIntervalMs) + { + return false; + } + m_lastPrefetchRequestMs = now; + return true; + } + + void PlotView::EnsureLaneLayersPrefetched() + { + if (m_lanes.empty() || !(m_plotRect.Width > 8.0f) || !(Tab().ViewMax() - Tab().ViewMin() > 0.0)) + { + return; + } + if (!LayersAllocated()) + { + // 图层数组与网格数量不符(首次绘制 / 增删网格):没有可复用的像素,只能整批建立。 + RebuildWideBuffer(); + return; + } + // ⚠️ 只登记「缺失 / 内容过期」的网格(`RollWideBuffer` 不做整块重画), + // 由 `RenderPendingLaneRebuilds` 的既有预算逐帧补齐。 + RollWideBuffer(); + } + + void PlotView::WarmLaneLayersStep() + { + if (m_dragMode != PlotDragMode::NONE || m_lanes.empty() || m_fonts == nullptr) + { + return; + } + // ⚠️ **UI 动画期间绝不预热**:单个巨型信号的图层重建要 10~30ms,塞进动画帧就会 + // 让"只有文字的信号列表"看起来卡顿(用户 2026-10-18 报告)。队列保留,动画结束后继续。 + if (m_animationBusy) + { + return; + } + if (!(m_plotRect.Width > 8.0f) || !(Tab().ViewMax() - Tab().ViewMin() > 0.0)) + { + return; + } + { + std::lock_guard renderLock(m_renderMutex); + std::lock_guard layerLock(m_layerMutex); + if (m_layerFront.size() > m_lanes.size()) + { + // 网格变少:先把多出来的图层位图释放掉(`resize` 不会调用 `ReleaseLaneLayer`)。 + for (std::size_t index = m_lanes.size(); index < m_layerFront.size(); ++index) + { + ReleaseLaneLayer(m_layerFront[index]); + } + } + if (m_layerFront.size() != m_lanes.size()) + { + m_layerFront.resize(m_lanes.size()); + } + } + // 登记"跨度 / 内容不一致"的网格。空闲时视界未变 ⇒ `RollWideBuffer` 只登记、不做滚动, + // 也不会走"位移超出画布 → 立刻整块重画"那条分支。 + RollWideBuffer(); + RenderPendingLaneRebuilds(1); + // ⚠️ 是否继续请求下一帧: + // * 还有**可视 / 预取带内**的网格待补 ⇒ 立刻继续(收敛快,见 `AnyVisibleLanePending`); + // * 只剩**离屏**网格 ⇒ 按 `kPrefetchMinIntervalMs` **限速**继续(慢速后台预热, + // 既不饿死动画定时器,又能把整批图层建好 —— 见 `PrefetchStepAllowed`)。 + const bool morePending = + AnyVisibleLanePending() || + (!m_layerRebuildPending.empty() && PrefetchStepAllowed()); + if (morePending) + { + const HWND host = m_host; + if (host != nullptr) + { + PostMessageW(host, kMessagePlotDerivedReady, 0, 0); + } + } + } + + void PlotView::RollWideBuffer() + { + const float plotWidth = m_plotRect.Width; + const double span = Tab().ViewMax() - Tab().ViewMin(); + if (!(plotWidth > 8.0f) || !(span > 0.0)) + { + return; + } + const int width = static_cast(std::lround(plotWidth)); + const int margin = LayerMarginX(); + const int bitmapWidth = width + 2 * margin; + const double scale = static_cast(width) / span; + + std::lock_guard renderLock(m_renderMutex); + std::lock_guard layerLock(m_layerMutex); + if (m_layerFront.size() != m_lanes.size() || m_layerFront.empty()) + { + return; + } + // 本帧还剩多少个「重新居中」额度(见 `kLayerRollsPerFrame`)。 + int rollBudget = kLayerRollsPerFrame; + + // ⚠️ **逐网格独立滚动**:每个图层有自己的时间映射(`layer.wideMin`), + // 因此位移必须按各自的锚点分别计算。曾用**一个全局锚点**给所有图层算同一个位移, + // 结果各网格被平移了不同的真实距离 → 网格之间时间错位、贴回的像素互相矛盾, + // 表现为严重的绘制错误与波形抖动。 + // + // ⚠️ **本函数绝不重画整块图层**:内容过期 / 锚点太远的网格只登记到待重画队列 + //(由 `RenderPendingLaneRebuilds` 按预算在后续帧补画),本帧仍尽量用现有像素跟随视界。 + // 这样前台**永远不会因为"图层没准备好"而做整批重绘**(那正是"拖拽时前台卡顿"的根因)。 + for (std::size_t index = 0; index < m_layerFront.size(); ++index) + { + LaneLayer& layer = m_layerFront[index]; + const int laneIndex = static_cast(index); + + // 结构不可用(尺寸 / 跨度不符):像素的时间映射已经不对,既不能贴也不能滚。 + 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) + { + QueueLaneRebuild(laneIndex); + continue; + } + + // 视界左端当前落在该图层的哪个 x 上;期望它等于 `margin`(左右余量对称)。 + const double desired = (Tab().ViewMin() - layer.wideMin) * scale; + const int shift = static_cast(std::llround(desired - static_cast(margin))); + // 内容(外观 / 网格几何 / 幅值**跨度** / 可见性…)与当前状态不一致。 + const bool contentStale = (layer.key != LaneLayerKey(laneIndex)); + + // ⚠️⚠️ **不在可视区(含预取带)内的网格一律不做像素级滚动**: + // 它们的像素当前根本不会被贴出来,滚了也看不见;而每次补条都要重跑 + // `BuildLaneVisuals()`(O(网格数))—— 253 个网格时这是拖拽卡顿的主因。 + // 只登记待重画(可视网格会被 `RenderPendingLaneRebuilds` 优先处理), + // 等它们真的要进入视区时再按新视界重建(期间由 `BlitWideBuffer` 的底衬兜底)。 + // + // ⚠️⚠️ **离屏网格的"内容过期"也不登记待重画**:否则一次"网格数变化"(勾选/取消 + // 一个分组)就会把**上百个离屏网格**全部塞进队列,随后每帧补 1 个 × 10~30ms + // ⇒ 数秒的持续掉帧(用户 2026-10-18 报告的信号列表动画卡顿有一半来自这里)。 + // 离屏网格真正进入可视/预取带时,下面的可见分支会按新视界登记它。 + if (!LaneVisuallyRelevant(laneIndex, 1)) + { + continue; + } + // 纵向:幅值**平移**不改内容,只改贴图偏移(与横向同构)。 + int vOffset = 0; + if (layer.pxPerValue > 0.0) + { + vOffset = static_cast( + std::llround((layer.anchorYMax - m_lanes[index].yMax) * layer.pxPerValue)); + } + + // ⚠️⚠️ **"位移太大"绝不允许【作废图层】**(用户 2026-10-18 报告: + // 快速拖动中键时**所有网格的信号一起消失**,因为一旦作废就要等拖拽结束才重建)。 + // 关键认识:`BlitWideBuffer` 按 `(ViewMin - layer.wideMin) * scale` 算源偏移, + // 只要视界仍落在位图覆盖范围内(`|shift| <= margin`),**不滚动也画得完全正确**。 + // 因此这里分三种情形: + // * `|shift| <= driftLimit`(位图仍完整覆盖视界)⇒ **什么都不做**(免费); + // * 超出 ⇒ 向中心滚,但**每帧最多滚 `kLayerRollMaxStepPixels`**(把补条代价摊开), + // 剩余位移继续由贴图偏移承担 ⇒ **永远不会空白**; + // * 真的跳到画布之外(`|shift| >= bitmapWidth`)⇒ 才作废 + 登记待重画 + // (那是“点选信号跳转”这类整屏换地方的情形,本来就要重画)。 + const int driftLimit = std::max(8, margin - 24); + const int vOffsetAbs = std::abs(vOffset); + if (vOffsetAbs >= layer.height) + { + // 纵向已超出画布(画布高约等于 2×网格高):像素映射不再可用。 + layer.valid = false; + QueueLaneRebuild(laneIndex); + continue; + } + if (contentStale) + { + QueueLaneRebuild(laneIndex); + } + + // ------------------------------------------------------------------ + // 纵向(幅值)滚动:与横向**完全同构**——内容整体上下移动若干行, + // 再只补画新暴露的那一条(整宽 × 行数)。 + // + // ⚠️ **必须先做纵向再做横向**:横向补条由 `RenderLaneLayerRegion` 渲染, + // 而该函数按传入的**纵向锚点**渲染细条;若先补横向,那时锚点还没跟上, + // 补出来的细条会带着一个错的纵向偏移(贴图后表现为波形错位/撕裂)。 + // ⚠️ 没有这一步时:幅值一变就超出余量 → 每帧都排队**整块重画**(画布比可见网格高 + // 3 倍,代价很大)→ 拖动时"抽搐";而且在重画完成前,`BlitWideBuffer` 只能贴出部分 + // 行(其余留空)→ 网格内出现**空白条带**(用户报告的"绘制异常")。 + // + // ⚠️ **滚动量必须量化**(`kLayerRollQuantum`):不足一个量子的偏移**不滚**, + // 交给贴图时的纵向偏移(`srcY`)处理。这样图层的画布映射在拖动过程中**保持稳定**, + // 折线缓存(键含幅值映射)得以命中;否则每帧滚 1~30 行会让折线每帧重算 + // (实测同样的拖动:量化前 3~4ms 的滚动 + 10~14ms 整帧,量化后回到 ~5ms 量级)。 + // 余量足够大(上下各 `marginY`),因此"不滚只偏移"绝不会超出画布。 + // 上面已保证 |vOffset| < 画布高,所以这里的 BitBlt 高度恒为正。 + // ------------------------------------------------------------------ + if (std::abs(vOffset) >= kLayerRollQuantum) + { + const int roll = vOffset; // 正 = 内容整体上移 roll 行(窗口向下移动) + int stripY = 0; + int stripH = 0; + if (roll > 0) + { + // 内容上移:new[y] = old[y + roll],底部暴露 roll 行。 + BitBlt(layer.dc, 0, 0, bitmapWidth, layer.height - roll, layer.dc, 0, roll, SRCCOPY); + stripY = layer.height - roll; + stripH = roll; + } + else + { + const int amount = -roll; + // 内容下移:new[y] = old[y - amount],顶部暴露 amount 行。 + BitBlt(layer.dc, 0, amount, bitmapWidth, layer.height - amount, layer.dc, 0, 0, + SRCCOPY); + stripY = 0; + stripH = amount; + } + // 锚点随之移动(与横向 `layer.wideMin += shift / scale` 同一套记账方式)。 + layer.anchorYMax -= static_cast(roll) / layer.pxPerValue; + // 补画暴露的那一条(整宽)。锚点用**滚动后**的值 —— 该条与已滚动的像素同一映射。 + RenderLaneLayerRegion(laneIndex, layer, 0, bitmapWidth, stripY, stripH, layer.wideMin, + layer.anchorYMax); + } + + // ⚠️ **横向重新居中只在「快要失去覆盖」时才做**(见上方 `driftLimit` 的说明): + // `BlitWideBuffer` 按 `(ViewMin - layer.wideMin)` 算源偏移,因此视界小幅漂移 + // **完全不需要滚动**(等价且免费)。只有漂移接近 `margin`(位图覆盖边界)时才需要 + // 向中心滚回去,否则网格右侧/左侧会开始用底衬(看起来像“信号被切成两半”)。 + if (std::abs(shift) <= driftLimit) + { + continue; + } + // ⚠️ **每帧只重居中有限的几个网格**(见 `kLayerRollsPerFrame`),且单次滚动量有上限 + // (`kLayerRollMaxStepPixels`):不重居中也不会画错,超出部分继续由贴图偏移承担, + // 因此可以安全地分帧完成。 + if (rollBudget <= 0) + { + continue; + } + --rollBudget; + const int maxStep = std::min(kLayerRollMaxStepPixels, bitmapWidth / 2); + const int roll = std::max(-maxStep, std::min(maxStep, shift)); + + // 整数像素自贴图平移:内容整体移动 `roll` 像素。 + if (roll > 0) + { + // 内容整体左移 roll:new[x] = old[x + roll],右侧暴露 roll 列。 + BitBlt(layer.dc, 0, 0, bitmapWidth - roll, layer.height, layer.dc, roll, 0, SRCCOPY); + } + else if (roll < 0) + { + const int amount = -roll; + BitBlt(layer.dc, amount, 0, bitmapWidth - amount, layer.height, layer.dc, 0, 0, SRCCOPY); + } + else + { + continue; + } + layer.wideMin += static_cast(roll) / scale; + + // 只补画新暴露的细条(每帧代价恒为 O(细条))。 + // ⚠️ 细条必须用**该图层更新后的映射**渲染(传 `layer.wideMin`);若改用"当前视界" + // 的映射,细条与已平移的旧像素之间会有 ≤0.5px 的错位接缝 → 拖动时看起来在抖动。 + // ⚠️ **纵向锚点必须沿用 `layer.anchorYMax`**(不能传当前 `yMax`):横向补条只改一条 + // 竖带,其余像素仍在原锚点上;同时该函数按锚点渲染这条竖带,因此竖带与其余像素 + // 纵向同源。若在这里把锚点重置为当前 `yMax`,残余的纵向偏移会被静默清零、而像素 + // 并未上移 → 整个网格的波形纵向错位("垂直拖动后再横向平移出现纵向跳变")。 + const int stripWidth = std::min(std::abs(roll), bitmapWidth - 1); + const int stripX = (roll > 0) ? (bitmapWidth - stripWidth) : 0; + RenderLaneLayerRegion(laneIndex, layer, stripX, stripWidth, 0, 0, layer.wideMin, + layer.anchorYMax); + } + } + + bool PlotView::BlitWideBuffer(gd::Graphics& g, const std::vector& timeTicks) + { + const float plotWidth = m_plotRect.Width; + const double span = Tab().ViewMax() - Tab().ViewMin(); + if (!(plotWidth > 8.0f) || !(span > 0.0)) + { + return false; + } + const int width = static_cast(std::lround(plotWidth)); + const int margin = LayerMarginX(); + const int bitmapWidth = width + 2 * margin; + const double scale = static_cast(width) / span; + + /** 一次图层贴图(所有参数在释放图层锁之前算好,避免持锁期间做 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 blits; + std::vector backing; // 本帧拿不到(完整)图层像素的网格 + blits.reserve(m_lanes.size()); + backing.reserve(m_lanes.size()); + { + std::lock_guard 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(std::lround(bottom - top)); + // 纵向映射是否仍然可用:图层高度必须等于"当前网格高 + 上下预渲染余量", + // 且时间跨度必须与当前视界一致(跨度不同则像素的时间映射已经不对)。 + const int expectBitmapHeight = + static_cast(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( + std::llround((layer.anchorYMax - m_lanes[index].yMax) * layer.pxPerValue)); + } + int srcY = static_cast(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(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(std::lround(m_plotRect.X)) + destOffset; + blit.destY = static_cast(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(index)); + } + continue; + } + } + + // ⚠️⚠️ **必须兜底补齐网格底衬,绝不能留白**:旧实现在这里直接 `continue` + //(网格空白)或用旧跨度像素 `StretchBlt` 拉伸占位(深度放大时把一小段像素放大 + // 几十倍 → 整块网格变成一片模糊底色)—— 两者在用户眼里都是 + // 「网格叠底消失,没有任何内容」。改为就地补画底衬(底色 + 网格线 + 分界线): + // 代价极低,可以每帧执行,因此**网格底衬永远不会空白/闪烁**(用户 2026-10-17 要求)。 + backing.push_back(static_cast(index)); + } + } + + if (blits.empty()) + { + // ⚠️⚠️ **绝不能因为"本帧一个网格都贴不出图层像素"就退化成整幅重绘** + //(用户 2026-10-17 报告:滚动过渡动画在巨型信号处卡顿)。 + // 这一情形在滚动到**从未构建过图层的区域**时必然出现(上百个网格时绝大多数 + // 网格的图层都还没建立):旧实现在这里 `return false` ⇒ 落到 `Paint` 末尾的 + // **整幅重绘** ⇒ 把**本帧全部可见网格的波形**(可能包含上千�万采样的巨型信号) + // 每帧重画一遍 —— 实测单帧 **20~35ms**,而手动拖滚动条只要 ~6ms。 + // 正确行为:本帧只画**网格底衬**(廉价,见下方),波形等视界真正停下之后再按预算补 + //(这正是用户要求的“没来得及画信号时就按只有网格叠底的状态继续滚动”)。 + if (backing.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 true; + } + for (const LayerBlit& blit : blits) + { + BitBlt(target, blit.destX, blit.destY, blit.width, blit.height, blit.dc, blit.srcX, blit.srcY, + SRCCOPY); + } + g.ReleaseHDC(target); + return true; + } + +} // namespace jdi diff --git a/Documents/UI_Refactor_Progress.md b/Documents/UI_Refactor_Progress.md index 3ce20c8..41e6b0d 100644 --- a/Documents/UI_Refactor_Progress.md +++ b/Documents/UI_Refactor_Progress.md @@ -17,7 +17,7 @@ | --- | --- | --- | | **P0** | 离屏回归网 `Tools/uicheck`(入库) | ✅ 完成(62 项断言全绿) | | **P1** | `UiLayout` + `UiWidget` 基础层 | ✅ 完成(编译零警告,`uicheck` 覆盖) | -| **P2** | `PlotViewPaint.cpp` 零行为分片 | ⏳ 待做 | +| **P2** | `PlotViewPaint.cpp` 零行为分片 | ✅ 完成(**逐像素 0 差异**,见 §2) | | **P3** | 迁移 chrome(Toolbar / TabBar / 菜单 / 拾色器 / 列表行) | ⏳ 待做 | | **P4** | 迁移 5 个浮动面板 | ⏳ 待做 | | **P5** | 波形管线包成叶子 surface + 共享 DIB | ⏳ 待做 | @@ -75,12 +75,41 @@ Tools/uicheck/uicheck.exe # 62 passed, 0 failed --- +### 2. `P2`:`PlotViewPaint.cpp` 按关注点分片(零行为变更) + +**为什么**:该文件 9 080 行里混住四类内容 —— 真绘制 ≈3.5k / 光栅化缓存 ≈2.5k / 缓存键 ≈1.1k / +布局与命中几何 ≈1.3k。按"函数名前缀"分片是上一轮膨胀的直接原因,本轮按**关注点**再分一次。 + +**做法**:**机械搬移成员函数定义**(不改一行逻辑)。`PlotViewInternal.hpp` 里的 `pvdetail` 全部 `inline` +⇒ 跨分片安全,无需改任何 include、无需改 `Makefile`(按 basename 自动收集 `.cpp`)。 + +| 文件 | 搬入内容 | 行数变化 | +| --- | --- | --- | +| `PlotViewRaster.cpp`(新) | 光晕离屏层(`PaintSignalHalo*` / `UploadHaloLayer` / `ReleaseHaloLayer`)
场景缓存(`Ensure/Capture/Blit/Invalidate/ReleaseSceneCache`)
装饰层(`Ensure/Release/Render/BlitDecorCache` / `ResolveAlphaBlend`)
宽基底图层与预算(`EnsureLaneLayer` … `BlitWideBuffer`、并行重建、滚动补条、预热) | 1 876 | +| `PlotViewCacheKey.cpp`(新) | `MakeSceneCacheKey` / `SceneCacheMatches` / `SceneHaloRef`
`SceneContentHash` / `SceneGeometryHash` / `SceneDecorHash`
`CollectSceneStyles` / `FindStyleOnlyLanes` / `CollectSceneDerived` / `RepaintSceneCachePatch` | 474 | +| `PlotViewPaint.cpp` | 只剩绘制(`Paint*` / `Render*` / 输入框绘制) | 9 080 → **6 252**(−31%) | + +**验证(关键)** + +1. `make release TARGET_FILE_NAME=TargetCheck` **零警告**;`Target.exe` 873 984 → **875 008 B**(+1 KB, + 属分片后各 TU 的对齐差异;仍远低于 900 KB 阈值)。 +2. **逐像素 A/B**:临时写了一个渲染哈希探针(仓库外),对三个场景渲染后备缓冲区并取 FNV 哈希: + * A 默认全览;B 深放大 + 自由标记(含固定点)+ 均值滤波 + 弱峰调整 + 时间光标;C 再平移一帧(触发宽基底滚动/补条)。 + * **分片前**:`A 2166933039626F66 / B C444ECFEB18FD73D / C 7984446D8E7A4418` + * **分片后**:**完全相同**(三个场景逐一一致)。 + * 该探针未入库(一次性验证用),结论记录在此以便复现。 +3. `Tools/uicheck` 62/62 全绿。 +4. 顶层注释从"四个分片"更新为"六个分片(按关注点)",并写明"`Paint/PaintLane` 仍只允许定义在 `PlotViewPaint.cpp`"。 + +**⚠️ 复现 A/B 的坑**:机械搬移后用旧的 `.bak` 回退时,`Build//` 里会残留 +`PlotViewRaster.o` / `PlotViewCacheKey.o`,链接探针(`Build/Release/*.o`)会因重复符号失败 —— +**必须先删掉这两个陈旧 `.o`** 再链接。 + +--- + ## 下一步(接手者请从这里开始) -1. **P2:`PlotViewPaint.cpp` 零行为分片**(9 080 行) - - 目标分片:`Paint`(只画)/ `Raster`(缓存与预算)/ `CacheKey`(指纹)/ `Geometry`(面板与命中几何)。 - - 做法:**机械搬移函数定义**,不改一行逻辑;`PlotViewInternal.hpp` 里的 `pvdetail` 全是 `inline`,跨分片安全。 - - 判据:`make release` 零警告 + `Target.exe` 体积变化 < 1KB + 场景哈希/像素快照与迁移前一致。 - - ⚠️ 搬移后务必确认没有残留的 `static` 文件级变量(只在 `PlotViewPaint.cpp` 内可见的辅助函数要一并搬走)。 +1. **P2 续**:把"布局与命中几何"也搬出去(`EdgePanelLayoutFor` 651 行 + `ProbeFreeMarker` 286 行 + + `FreeMarkerLabelRects` 118 行 → 新 `PlotViewPanels.cpp`),判据同上(逐像素 0 差异)。 2. **P3:Toolbar 优先**(最小、最容易验证的组件),迁移后跑 `uicheck` + 新旧像素对比。 3. 每个阶段单独提交,提交信息写清"改了什么 / 为什么 / 怎么验证"。