diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index 9a1de37..832197b 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -1528,23 +1528,26 @@ namespace jdi // 0. 先把源信号的「调整」变换快照下来(下面 `InsertDerivedSignal` 会移动信号存储, // 之后再取 `source` 会悬垂)。 // - // ⚠️ **新信号必须继承这套变换**,否则会出现“预览是调整后的波形、构建出来的却按原始状态绘制” - //(用户报告的不一致):网格里的预览曲线绘制的是 `变换(滤波结果)`,而构建出的信号只存了 - // `滤波结果`;把变换一并继承后,两者绘制完全相同。 - // 另外**时间轴偏移只能靠继承**:派生信号与源信号共用同一个文件时间轴,无法把时间平移烘进数据。 - SignalStyle inheritedTransform; + // ⚠️ **变换要"烘进数据",而不是让新信号继承这套指纹**(用户明确要求): + // 网格里的预览曲线画的是 `变换(滤波结果)`。若新信号只存 `滤波结果` 再**继承变换**, + // 它的「调整」面板里就会莫名其妙带着一整套系数 / 镜像 / 偏移 —— 用户看到的是 + // "新信号只是继承了原信号的指纹,数据本身没变"。现在改为: + // * 数值域变换(系数 → 绝对值 → 垂直镜像 → Y 偏移)**与**水平镜像(数据反序) + // 全部作用在派生采样值上; + // * 于是新信号**用默认外观**绘制出来就与预览曲线逐位一致。 + // * ⚠️ **时间轴偏移是唯一的例外**:它改变的是"绘制在哪个时刻",而派生信号与源信号 + // 共用同一个文件时间轴,无法把时间平移烘进采样数据,因此仍由新信号的样式携带 + // (否则新信号会相对预览整体横向错位)。 + SignalStyle sourceTransform; + bool hasSourceStyle = false; { SignalRef sourceRef; sourceRef.fileIndex = request.fileIndex; sourceRef.signalIndex = request.signalIndex; if (const SignalStyle* sourceStyle = m_dataSet.Style(sourceRef)) { - inheritedTransform.gain = sourceStyle->gain; - inheritedTransform.absolute = sourceStyle->absolute; - inheritedTransform.mirrorVertical = sourceStyle->mirrorVertical; - inheritedTransform.mirrorHorizontal = sourceStyle->mirrorHorizontal; - inheritedTransform.offsetY = sourceStyle->offsetY; - inheritedTransform.offsetTime = sourceStyle->offsetTime; + sourceTransform = *sourceStyle; + hasSourceStyle = true; } } @@ -1567,11 +1570,33 @@ namespace jdi std::to_wstring(request.iterations) + L"次迭代)"; } - // 2. 组装新信号(外观先沿用默认值,插入后再继承源信号的「调整」变换)。 + // 2. 组装新信号(外观一律用默认值:变换已经烘进采样数据,不再是"继承的指纹")。 derived.rawHeader = derived.name; derived.unit = source.unit; derived.values.assign(derived_values.begin(), derived_values.end()); + // 2b. 把源信号的「调整」变换**烘进派生采样值**: + // * 数值域(系数 → 绝对值 → 垂直镜像 → Y 偏移)逐点套用 `ApplyDrawTransform`; + // * 水平镜像 = 数据反序,等价于把采样数组整体反转。 + // ⚠️ `ApplyDrawTransform` 是逐点函数,因此"先变换再反转"与"先反转再变换"等价。 + if (hasSourceStyle) + { + const bool valueTransform = + (sourceTransform.gain != 1.0f || sourceTransform.absolute || + sourceTransform.mirrorVertical || sourceTransform.offsetY != 0.0f); + if (valueTransform) + { + for (double& value : derived.values) + { + value = ApplyDrawTransform(sourceTransform, value); + } + } + if (sourceTransform.mirrorHorizontal) + { + std::reverse(derived.values.begin(), derived.values.end()); + } + } + // 3. 插入到原信号的下方(数据层 + 各标签页引用重映射)。 int newIndex = -1; // 插入派生信号会移动该文件的信号存储:先暂停后台计算,避免其读取已移动的数据。 @@ -1586,16 +1611,15 @@ namespace jdi tab.RemapAfterSignalInsert(request.fileIndex, newIndex); } - // 3b. 新信号继承源信号的「调整」变换(系数 / 绝对值 / 镜像 / 偏移), - // 使其绘制结果与网格里的预览曲线**完全一致**。 - if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{request.fileIndex, newIndex})) + // 3b. 仅把**时间轴偏移**带到新信号上(唯一无法烘进数据的一项,见上面第 0 步说明)。 + // 其余「调整」项(系数 / 绝对值 / 镜像 / Y 偏移)保持在默认值 —— 它们已经 + // 烘进了采样数据,因此新信号不经过任何"调整"就与预览曲线完全一致。 + if (hasSourceStyle && sourceTransform.offsetTime != 0.0) { - derivedStyle->gain = inheritedTransform.gain; - derivedStyle->absolute = inheritedTransform.absolute; - derivedStyle->mirrorVertical = inheritedTransform.mirrorVertical; - derivedStyle->mirrorHorizontal = inheritedTransform.mirrorHorizontal; - derivedStyle->offsetY = inheritedTransform.offsetY; - derivedStyle->offsetTime = inheritedTransform.offsetTime; + if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{request.fileIndex, newIndex})) + { + derivedStyle->offsetTime = sourceTransform.offsetTime; + } } // 4. 在当前标签页中勾选新信号,并把它所在的网格移到原信号网格的下方。 diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 79bec2f..2ec3fb9 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -1767,6 +1767,14 @@ namespace jdi EdgePanelKind m_pendingPanelCloseKind = EdgePanelKind::COUNT; /**< 待关闭面板的功能类型。 */ int m_hoverLane = -1; /**< 当前悬停网格下标。 */ + /** + * @brief 上一帧鼠标是否悬停在某个网格的“GX”编号徽标上(是则记录网格下标,否则 -1)。 + * + * 徽标本体绘制在静态场景 / 装饰缓存里(不随鼠标变化),悬停高亮是**贴图之后的叠加层** + *(见 PaintLegendHighlight)。鼠标在同一个网格的徽标范围内移动时 `m_hoverLane` 不变, + * 因此必须单独记住这个状态才能触发重绘与取消高亮。 + */ + int m_hoverBadge = -1; int m_mouseX = 0; /**< 最近一次鼠标横坐标(用于悬停提示定位)。 */ int m_mouseY = 0; /**< 最近一次鼠标纵坐标(用于悬停提示定位)。 */ bool m_spanHover = false; /**< 鼠标是否悬停在双光标差值标签上。 */ @@ -1859,12 +1867,23 @@ namespace jdi gd::PointF m_edgePanelDragOffset; /**< 抓取面板时的相对偏移(保持不跳变)。 */ /** - * @brief 图例条目的命中信息(用于点选与双击改名)。 + * @brief 图例条目的命中信息(用于点选与双击改名),同时记录"悬停高亮"重绘内容所需 + * 的全部几何与颜色。 + * + * 悬停高亮画在**装饰缓存贴图之后**(随鼠标变化,不能进缓存),且托底必须**不透明** + *(半透明叠加会让底下的旧像素透出来)。为了在"不透明托底"上仍能看到色块与文字, + * 这里把绘制时用到的色块矩形 / 文本矩形 / 颜色也一并记下来,供悬停时原样重绘。 */ struct LegendHit { SignalRef ref; /**< 对应信号。 */ - gd::RectF rect; /**< 条目矩形(已含色块与文本)。 */ + gd::RectF rect; /**< 条目托底矩形(同时是命中区域)。 */ + gd::RectF swatch; /**< 色块矩形。 */ + gd::RectF text; /**< 文本矩形。 */ + std::wstring label; /**< 已经过省略处理的显示文本。 */ + std::uint32_t swatchColor = 0; /**< 色块颜色。 */ + float swatchOpacity = 1.0f; /**< 色块不透明度。 */ + std::uint32_t textColor = 0; /**< 文本颜色。 */ }; std::vector m_legendHits; /**< 最近一次绘制时记录的图例条目矩形。 */ @@ -2484,6 +2503,18 @@ namespace jdi * @return 队列中仍有待重画网格返回 true。 */ bool RenderPendingLaneRebuilds(int budget); + /** + * @brief **空闲预热**:静置期间用既有预算机制**每帧至多补画 1 个网格图层**。 + * + * 图层不存在(刚加载 / 网格数量变化)或还是旧跨度时,若等到用户**第一次** + * 平移 / 选区放大才去建,`Paint` 会在那一帧里**同步重建全部网格图层** + *(5 个网格实测 30~80ms)—— 这就是"选区放大的第一帧特别卡"的根因 + *(该帧其余工作只有约 16ms 的占位拉伸)。 + * + * 这里只在**视界没有变化**(命中静态场景缓存 ⇒ 本帧视图与缓存同源)且**不在拖拽中**时 + * 调用一次,把整批重建的代价摊到若干静止帧上;任何交互帧都不再承担它。 + */ + void WarmLaneLayersStep(); /** @brief 使全部图层失效。 */ void InvalidateWideBuffer(); /** @brief 释放全部图层位图。 */ void ReleaseWideBuffer(); diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 3f68115..c411086 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -80,6 +80,7 @@ namespace jdi const float kLegendSwatchSize = 8.0f; /**< 图例色块尺寸。 */ const float kLegendItemGap = 14.0f; /**< 图例条目之间的间距。 */ const float kLegendMaxTextWidth = 200.0f; /**< 图例条目的文本宽度上限(超出则省略)。 */ + const float kLegendPlateRadius = 4.0f; /**< 图例条目托底的圆角半径(逻辑像素)。 */ const float kLaneBadgeWidth = 30.0f; /**< 网格编号徽标宽度(与信号面板保持一致)。 */ const float kLaneBadgeHeight = 17.0f; /**< 网格编号徽标高度。 */ const float kLaneBadgeGap = 8.0f; /**< 编号徽标与图例条目之间的间距。 */ @@ -3162,6 +3163,56 @@ namespace jdi } } + // ⚠️ **性能:把样式指针提到循环之外**。`DataSet::Style(ref)` 每次调用都要做 + // 指针解析与边界检查;放在循环体里等于**每个采样点解析两次** + //(实测全段自适应因此从 147ms 劣化到 1200ms —— 8 倍回归)。 + const SignalStyle* resolvedStyle = style; // 可能为 nullptr(视为无变换) + const bool mirrored = (resolvedStyle != nullptr) && resolvedStyle->mirrorHorizontal; + const bool transform = (resolvedStyle != nullptr) && + (resolvedStyle->gain != 1.0f || resolvedStyle->absolute || + resolvedStyle->mirrorVertical || resolvedStyle->offsetY != 0.0f); + + // ⚠️ **整段自适应直接取信号已缓存的 min/max**(O(1)): + // 当视界覆盖整条信号、且该信号没有任何数值变换时,逐点扫描的结果与 + // `ComputeStats` 预存的 min/max 完全一致。工具栏「Y 轴自适应」开关在全视图下 + // 会一次性对全部网格做这件事 —— 逐点扫描 2580 万 × 5 是秒级卡顿(实测 1200ms), + // 这里改为 O(1)。 + if (file->timeMonotonic && begin == 0 && end == count && !mirrored && !transform && + signal->finiteCount > 0) + { + low = std::min(low, signal->minValue); + high = std::max(high, signal->maxValue); + continue; + } + + if (!mirrored && !transform) + { + // 无变换:与既有实现完全一致的紧凑循环(无逐点分支,便于向量化)。 + for (std::size_t index = begin; index < end; ++index) + { + if (!file->timeMonotonic) + { + const double t = file->time[index]; + if (!std::isfinite(t) || t < sourceMin || t > sourceMax) + { + continue; + } + } + const double value = signal->values[index]; + if (!std::isfinite(value)) + { + continue; + } + low = std::min(low, value); + high = std::max(high, value); + } + continue; + } + + // 有变换(系数 / 绝对值 / 镜像 / Y 偏移)或水平镜像:逐点套用与绘制**相同**的变换。 + // ⚠️ 自适应必须基于**画出来的曲线**,否则调整系数/镜像后自适应范围会按原始数据算错 + //(用户报告"自适应区域错误,看上去还是按照原始数据来的")。 + // 水平镜像时画面上的第 index 个位置取源下标 count-1-index 的值。 for (std::size_t index = begin; index < end; ++index) { if (!file->timeMonotonic) @@ -3172,17 +3223,18 @@ namespace jdi continue; } } - // ⚠️ 自适应必须基于**画出来的曲线**:套用与绘制完全相同的变换 - //(系数 → 绝对值 → 镜像),否则调整系数/镜像后自适应范围会按原始数据算错 - //(用户报告"自适应区域错误,看上去还是按照原始数据来的")。 - // 水平镜像时画面上的第 index 个位置取源下标 count-1-index 的值。 - const std::size_t sourceIndex = SourceIndexFor(*m_dataSet->Style(ref), count, index); + const std::size_t sourceIndex = + (mirrored && count > 0) ? ((count - 1) - index) : index; + if (sourceIndex >= count) + { + continue; + } const double raw = signal->values[sourceIndex]; if (!std::isfinite(raw)) { continue; } - const double value = ApplyDrawTransform(*m_dataSet->Style(ref), raw); + const double value = transform ? ApplyDrawTransform(*resolvedStyle, raw) : raw; if (std::isfinite(value)) { low = std::min(low, value); @@ -4056,8 +4108,13 @@ namespace jdi } } - // 图例中的 GX 编号徽标**悬停时不改变光标样式**(与其它可拖拽区域保持一致); - // 只有真正按下拖拽时才切为上下箭头(见 LANE_REORDER 分支)。 + // 图例中的「GX」编号徽标:悬停时返回**手型** —— 与左侧信号列表里同一个 GX 徽标的 + // 悬停效果完全一致(用户明确要求"参考信号选区的信号名后方的 GX 标签"); + // 真正按下拖拽时再切为上下箭头(见 LANE_REORDER 分支)。 + if (LaneBadgeAtPoint(x, y) >= 0) + { + return LoadCursorW(nullptr, IDC_HAND); + } // 光标时间读数标签与双光标差值标签:**悬停(未按下)时不改变鼠标形状**。 // 两个标签都叠在光标线的正上方,因此必须先于"光标线"判定返回 nullptr, @@ -4454,6 +4511,25 @@ namespace jdi changed = true; } + // 「GX」编号徽标的悬停高亮:徽标本体在静态场景 / 装饰缓存里被冻结,高亮是叠加层, + // 因此必须靠这个**单独的状态**触发重绘与取消(鼠标在同一个网格的徽标范围内移动时 + // `m_hoverLane` 不变)。 + int hoverBadge = -1; + if (m_dragMode == PlotDragMode::NONE && !overEdgePanel && lane >= 0) + { + const gd::RectF badge = LaneBadgeRect(lane); + if (badge.Width > 0.0f && + UiPointInRect(badge, static_cast(x), static_cast(y))) + { + hoverBadge = lane; + } + } + if (hoverBadge != m_hoverBadge) + { + m_hoverBadge = hoverBadge; + changed = true; + } + // ✕ 关闭按钮 / 等级与迭代次数滑块 / 底部按钮的悬停提示都直接依赖鼠标坐标, // 但它们在面板内部移动时上面这些状态可能毫无变化(例如从同一网格的波形移到面板上), // 于是返回值恒为 false → 宿主不重绘 → **悬停提示不会出现**。 @@ -5226,6 +5302,11 @@ namespace jdi m_hoverLane = -1; changed = true; } + if (m_hoverBadge != -1) + { + m_hoverBadge = -1; + changed = true; + } if (m_hoverSignal.IsValid()) { m_hoverSignal = SignalRef(); @@ -5867,6 +5948,12 @@ namespace jdi // 随鼠标变化的内容(悬停高亮、读数框、选区、光标、拖拽虚影、输入框)一律画在贴图之后。 if (SceneCacheMatches() && BlitSceneCache(g)) { + // ⚠️ **顺手做一次"空闲预热"**:图层不存在(刚加载 / 网格数量变化)或仍是旧跨度时, + // 在这里用既有预算机制**每帧至多补 1 个网格**。若不预热,用户第一次平移 / 选区放大 + // 时,`Paint` 会在那一帧里**同步重建全部网格图层**(5 个网格实测 30~80ms)—— + // 这正是"选区放大的第一帧特别卡"的根因(该帧其余工作只有约 16ms 的占位拉伸)。 + // 命中静态场景缓存 ⇒ 本帧视图与缓存同源(视界没变),因此预热出来的图层不会白费。 + WarmLaneLayersStep(); paintOverlays(); return; } @@ -6164,21 +6251,7 @@ namespace jdi void PlotView::PaintLegendHighlight(gd::Graphics& g) { - if (m_tab == nullptr || m_dragMode != PlotDragMode::NONE || !m_hoverLegend.IsValid()) - { - return; - } - - // 图例条目的底色绘制在静态场景缓存里;悬停态随鼠标变化,不能进缓存, - // 因此在贴图之后用半透明强调色在本条目范围内叠一层,形成“半高亮”提示。 - const int laneIndex = m_tab->LaneOfSignal(m_hoverLegend); - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) - { - return; - } - const gd::RectF laneRect = m_lanes[static_cast(laneIndex)].rect; - if (laneRect.Y > m_plotRect.Y + m_plotRect.Height || - laneRect.Y + laneRect.Height < m_plotRect.Y) + if (m_tab == nullptr || m_lanes.empty()) { return; } @@ -6186,6 +6259,55 @@ namespace jdi const int saveState = g.Save(); g.SetClip(m_area, gd::CombineModeIntersect); + // ------------------------------------------------------------------ + // (1) 网格编号徽标(GX)的悬停高亮。 + // 徽标本体画在静态场景 / 装饰缓存里(不随鼠标变化),因此悬停时必须在贴图之后 + // **把徽标重画一遍**(不透明底色 + 圆角 + 文字)—— 否则它在缓存里被冻结, + // 鼠标移上去看不到任何反馈(用户要求的"参考信号列表里 GX 徽标"的悬停提示)。 + // ⚠️ 只在**未拖拽**时绘制:`LANE_REORDER` 期间网格连同徽标被整体平移贴图, + // 这里按静态位置重画会画到错误的地方。 + // ------------------------------------------------------------------ + if (m_dragMode == PlotDragMode::NONE && m_hoverBadge >= 0 && + m_hoverBadge < static_cast(m_lanes.size())) + { + const gd::RectF badge = LaneBadgeRect(m_hoverBadge); + if (badge.Width > 0.0f) + { + const float radius = kLaneBadgeHeight * m_scale * 0.5f; + // 不透明底色铺满外接矩形(与缓存里的画法一致,避免圆角之外残留旧像素)。 + const gd::RectF base(std::floor(badge.X) - 1.0f, std::floor(badge.Y) - 1.0f, + std::ceil(badge.X + badge.Width) - std::floor(badge.X) + 2.0f, + std::ceil(badge.Y + badge.Height) - std::floor(badge.Y) + 2.0f); + UiFillRect(g, base, m_theme->cardBg | 0xFF000000u); + UiFillRoundRect(g, badge, radius, m_theme->accentSoft); + UiDrawRoundRect(g, badge, radius, m_theme->accent, 1.0f); + UiText(g, L"G" + std::to_wstring(m_hoverBadge + 1), m_fonts->monoSmall, badge, + m_theme->textStrong, TextAlign::CENTER); + } + } + + // ------------------------------------------------------------------ + // (2) 图例条目悬停:条目托底高亮 + **对应信号曲线**的光晕。 + // ------------------------------------------------------------------ + if (m_dragMode != PlotDragMode::NONE || !m_hoverLegend.IsValid()) + { + g.Restore(saveState); + return; + } + const int laneIndex = m_tab->LaneOfSignal(m_hoverLegend); + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + g.Restore(saveState); + return; + } + const gd::RectF laneRect = m_lanes[static_cast(laneIndex)].rect; + if (laneRect.Y > m_plotRect.Y + m_plotRect.Height || + laneRect.Y + laneRect.Height < m_plotRect.Y) + { + g.Restore(saveState); + return; + } + // 除了图例条目本身,还要把**对应的信号曲线**一并高亮:用户在网格图例上移动时 // 一眼就能确认这条条目对应哪条曲线(与"鼠标贴近曲线"时的光晕提示一致)。 const ResolvedSignal resolved = ResolveSignal(m_hoverLegend); @@ -6198,11 +6320,17 @@ namespace jdi } } + // 条目托底高亮:**不透明**圆角矩形(避免半透明叠加出杂色),并在其上**原样重绘 + // 色块与文字** —— 否则不透明托底会把图例内容整块盖住(只剩一个空色块)。 for (const LegendHit& hit : m_legendHits) { if (hit.ref == m_hoverLegend) { - UiFillRoundRect(g, hit.rect, 3.0f * m_scale, WithAlpha(m_theme->accent, 0x38)); + const float radius = kLegendPlateRadius * m_scale; + UiFillRoundRect(g, hit.rect, radius, m_theme->accentSoft); + UiDrawRoundRect(g, hit.rect, radius, m_theme->accent, 1.0f); + UiDrawSwatch(g, hit.swatch, hit.swatchColor, hit.swatchOpacity, 1.5f * m_scale, m_scale); + UiText(g, hit.label, m_fonts->uiSmall, hit.text, hit.textColor, TextAlign::LEFT); break; } } @@ -6754,10 +6882,17 @@ namespace jdi return m_laneDragFrom; } - // 以"被拖网格的中心"为判据:中心越过某条网格的中线即认为目标变为该位置。 - const float centerY = static_cast(mouseY) - m_laneDragGrabOffset + laneHeight * 0.5f; + // 判据 = **鼠标指针**落在哪个网格的哪一半:指针进入某网格的"下半"即把插入位置 + // 定在它之后(`index` = 该网格下标 + 1)。 + // + // ⚠️ 旧实现用"被拖网格的**中心**"(`mouseY - grabOffset + laneHeight/2`)做判据, + // 等价于要求把整个网格拖过去一格才会吸附 —— 例如只有两个网格时,把 G2 往上拖 + // 必须一直拖到鼠标接近 G1 的最上沿才会让位(用户报告"要拖到另一个网格的位置附近 + // 才有吸附提示")。改为与鼠标位置挂钩后:**鼠标越过 G1 的中心水平线,G1 立刻让位 + // 并出现吸附提示**(依次类推对任意网格数成立)。 const float firstTop = m_lanes.front().rect.Y; - int index = static_cast(std::floor((centerY - firstTop) / laneHeight + 0.5f)); + const float mouse = static_cast(mouseY); + int index = static_cast(std::floor((mouse - firstTop) / laneHeight + 0.5f)); index = std::max(0, std::min(index, count)); return index; } @@ -7885,6 +8020,46 @@ namespace jdi return ok; } + void PlotView::WarmLaneLayersStep() + { + if (m_dragMode != PlotDragMode::NONE || m_lanes.empty() || m_fonts == nullptr) + { + 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(); + const bool morePending = RenderPendingLaneRebuilds(1); + if (morePending) + { + const HWND host = m_host; + if (host != nullptr) + { + PostMessageW(host, kMessagePlotDerivedReady, 0, 0); + } + } + } + void PlotView::RollWideBuffer() { const float plotWidth = m_plotRect.Width; @@ -8715,7 +8890,21 @@ namespace jdi { // 胶囊底板**不透明**:装饰像素因此与时间视界无关,可以跨视界整块复用; // 半透明底板会把旧视界的波形混进缓存像素,平移贴回时在底板内部留下残影。 - UiFillRoundRects(g, pills, 3.0f * m_scale, m_theme->cardBg | 0xFF000000u); + // ⚠️ 先用不透明底色**铺满每个胶囊的外接矩形**(含圆角之外的几个像素), + // 再画圆角胶囊:否则圆角之外的波形像素会被装饰缓存烘进去, + // 平移贴回时在胶囊边缘留下一圈"旧波形残留"。 + const std::uint32_t pillBase = m_theme->cardBg | 0xFF000000u; + for (const gd::RectF& pill : pills) + { + const gd::RectF outer(pill.X - 2.0f * m_scale, pill.Y - 1.0f * m_scale, + pill.Width + 4.0f * m_scale, pill.Height + 2.0f * m_scale); + UiFillRect(g, + gd::RectF(std::floor(outer.X) - 1.0f, std::floor(outer.Y) - 1.0f, + std::ceil(outer.X + outer.Width) - std::floor(outer.X) + 2.0f, + std::ceil(outer.Y + outer.Height) - std::floor(outer.Y) + 2.0f), + pillBase); + } + UiFillRoundRects(g, pills, 3.0f * m_scale, pillBase); } for (std::size_t index = 0; index < pills.size(); ++index) { @@ -8856,79 +9045,93 @@ namespace jdi const float top = legendBand.Y; const float height = kLegendHeight * m_scale; - float cursorX = legendBand.X; + const float swatchSize = kLegendSwatchSize * m_scale; const float maxX = lane.rect.X + lane.rect.Width - kLanePadding * m_scale; // 网格编号徽标(图例左侧):与左侧信号面板每行末尾的 “GX” 徽标样式完全一致。 // 矩形由 LaneBadgeRect 统一计算,绘制与命中测试因此永远一致。 const gd::RectF badgeRect = LaneBadgeRect(laneIndex); const float badgeHeight = kLaneBadgeHeight * m_scale; - cursorX = badgeRect.X + badgeRect.Width + kLaneBadgeGap * m_scale; + float cursorX = badgeRect.X + badgeRect.Width + kLaneBadgeGap * m_scale; - bool anyVisible = false; + // 图例条目托底的内缩外扩量:托底矩形**同时就是命中矩形**(看到的与点得到的严格一致)。 + const float platePadX = 3.0f * m_scale; + const float platePadY = 2.0f * m_scale; + + // ------------------------------------------------------------------ + // 1) 几何:算出"放得下"的条目及其矩形(**唯一来源**,命中 / 装饰 / 绘制共用)。 + // ------------------------------------------------------------------ + struct LegendItem + { + SignalRef ref; + float left = 0.0f; // 色块左缘 + float width = 0.0f; // 条目内容宽度 + float textWidth = 0.0f; + std::wstring label; + bool selected = false; + }; + std::vector items; int visibleCount = 0; - int drawnCount = 0; - - // 先测量图例总宽度:只用来确定装饰缓存矩形(不再绘制任何托底/底板)。 - float legendWidth = 0.0f; - for (const SignalRef& ref : laneSignals) + float contentRight = cursorX; { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible) + float probeX = cursorX; + for (const SignalRef& ref : laneSignals) { - continue; + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible) + { + continue; + } + ++visibleCount; + std::wstring label = BuildLegendText(*resolved.data); + if (multiFile && resolved.file != nullptr) + { + label += L" · " + resolved.file->Title(); + } + const float maxTextWidth = kLegendMaxTextWidth * m_scale; + const float fullTextWidth = UiMeasureTextWidth(m_fonts->uiSmall, label); + const float textWidth = std::min(fullTextWidth, maxTextWidth); + const float itemWidth = swatchSize + 5.0f * m_scale + textWidth + kLegendItemGap * m_scale; + if (probeX + itemWidth > maxX) + { + break; // 空间不足:其余条目省略,下方用 "+N" 提示 + } + LegendItem item; + item.ref = ref; + item.left = probeX; + item.width = itemWidth; + item.textWidth = textWidth; + item.label = (fullTextWidth > maxTextWidth) + ? UiEllipsize(label, m_fonts->uiSmall, maxTextWidth) + : label; + item.selected = (SelectedRef() == ref); + items.push_back(std::move(item)); + probeX += itemWidth; } - - std::wstring label = BuildLegendText(*resolved.data); - if (multiFile && resolved.file != nullptr) - { - label += L" · " + resolved.file->Title(); - } - const float itemWidth = kLegendSwatchSize * m_scale + 5.0f * m_scale + - std::min(UiMeasureTextWidth(m_fonts->uiSmall, label), - kLegendMaxTextWidth * m_scale) + - kLegendItemGap * m_scale; - if (cursorX + legendWidth + itemWidth > maxX) - { - break; - } - legendWidth += itemWidth; + contentRight = probeX; } + const auto PlateRect = [&](const LegendItem& item) { - // 底板范围(编号徽标 + 图例条目):这里**只记录装饰矩形**用于缓存贴回。 - const float plateRight = cursorX + legendWidth + (legendWidth > 0.0f ? 2.0f * m_scale : 0.0f); - { - const float left = std::min(badgeRect.X, legendBand.X - 4.0f * m_scale) - 2.0f * m_scale; - const float right = std::max(badgeRect.X + badgeRect.Width, plateRight) + 2.0f * m_scale; - m_decorRects.push_back(gd::RectF(left, top - 4.0f * m_scale, right - left, - height + 8.0f * m_scale)); - } - // ⚠️ **不再绘制不透明托底**(用户要求去掉):原来的底板会把徽标与图例下方的 - // 网格线整块擦掉,使这一条看起来像一个"可点击的控件",而它右侧的空白区域 - // 实际并不响应点击(选区放大模式下会变成开始框选)。现在图例与徽标直接叠在 - // 网格线之上,命中范围与看到的控件一一对应。 - // - // 命中装饰缓存:整块图例(徽标 + 色块 + 文字)改由装饰层贴回,跳过重绘。 - if (m_useDecorCache) - { - return; - } - } + return gd::RectF(item.left - platePadX, top - platePadY, item.width + platePadX * 2.0f, + height + platePadY * 2.0f); + }; + // 不透明底色矩形:把托底外接的**整数像素**再外扩 1px。装饰缓存的捕获边界由 + // `floor/ceil` 得出,必须完全落在这块不透明填充之内,否则边缘会残留旧波形像素。 + const auto OpaqueBaseRect = [](const gd::RectF& rect) + { + const float x0 = std::floor(rect.X) - 1.0f; + const float y0 = std::floor(rect.Y) - 1.0f; + const float x1 = std::ceil(rect.X + rect.Width) + 1.0f; + const float y1 = std::ceil(rect.Y + rect.Height) + 1.0f; + return gd::RectF(x0, y0, x1 - x0, y1 - y0); + }; - // 编号徽标绘制在底板之上(与信号面板的 GX 徽标使用同一套配色与圆角)。 - // 拖拽重排时把被拖网格的徽标高亮,给出明确的"已抓住"反馈。 - const bool badgeActive = (m_dragMode == PlotDragMode::LANE_REORDER && m_laneDragFrom == laneIndex); - const bool badgeHover = (m_dragMode == PlotDragMode::NONE && m_hoverLane == laneIndex && - UiPointInRect(badgeRect, static_cast(m_mouseX), - static_cast(m_mouseY))); - UiFillRoundRect(g, badgeRect, badgeHeight * 0.5f, - (badgeActive || badgeHover) ? WithAlpha(m_theme->accent, 0x4A) : m_theme->cardBg); - UiDrawRoundRect(g, badgeRect, badgeHeight * 0.5f, - (badgeActive || badgeHover) ? m_theme->accent : m_theme->border, 1.0f); - UiText(g, L"G" + std::to_wstring(laneIndex + 1), m_fonts->monoSmall, badgeRect, - m_theme->text, TextAlign::CENTER); - - // 记录本次绘制命中的条目矩形,供点击选中与双击改名使用。 + // ------------------------------------------------------------------ + // 2) 命中矩形:**无论是否命中装饰缓存都必须重建**。 + // 整幅重绘路径会先清空 `m_legendHits`;若这里因为"装饰缓存命中"而提前返回、 + // 不写入新矩形,图例的悬停高亮 / 点击选中 / 双击改名就会**全部失效** + //(用户报告:"用中键移动过视角之后图例点不动、悬停也没反应")。 + // ------------------------------------------------------------------ for (auto iterator = m_legendHits.begin(); iterator != m_legendHits.end();) { if (m_tab->LaneOfSignal(iterator->ref) == laneIndex) @@ -8940,93 +9143,105 @@ namespace jdi ++iterator; } } - - // 绘制图例条目(与测量阶段保持一致的裁剪条件)。 - float drawnWidth = 0.0f; - for (const SignalRef& ref : laneSignals) + for (const LegendItem& item : items) { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible) - { - continue; - } - anyVisible = true; - ++visibleCount; - - std::wstring label = BuildLegendText(*resolved.data); - if (multiFile && resolved.file != nullptr) - { - label += L" · " + resolved.file->Title(); - } - // 文本宽度按上限夹取,超出部分用省略号(与测量阶段保持一致)。 - const float maxTextWidth = kLegendMaxTextWidth * m_scale; - const float fullTextWidth = UiMeasureTextWidth(m_fonts->uiSmall, label); - const float textWidth = std::min(fullTextWidth, maxTextWidth); - const std::wstring drawLabel = - (fullTextWidth > maxTextWidth) ? UiEllipsize(label, m_fonts->uiSmall, maxTextWidth) : label; - const float itemWidth = kLegendSwatchSize * m_scale + 5.0f * m_scale + textWidth + - kLegendItemGap * m_scale; - if (drawnWidth + itemWidth > legendWidth + 0.5f) - { - break; - } - drawnWidth += itemWidth; - - const float itemLeft = cursorX; - const float swatchSize = kLegendSwatchSize * m_scale; - - // 选中态(与左侧列表呼应)绘制在静态场景里:选中信号参与场景指纹, - // 因此选中变化会让缓存失效并重绘。**悬停半高亮不在此处**(随鼠标变化, - // 画在缓存贴图之后,见 PaintLegendHighlight)。 - const bool selected = (SelectedRef() == ref); - if (selected) - { - UiFillRoundRect(g, gd::RectF(itemLeft - 3.0f * m_scale, top - 1.0f * m_scale, - itemWidth + 2.0f * m_scale, height + 2.0f * m_scale), - 3.0f * m_scale, m_theme->selection); - } - - UiDrawSwatch(g, gd::RectF(cursorX, top + (height - swatchSize) * 0.5f, swatchSize, swatchSize), - resolved.style->color, resolved.style->opacity, 1.5f * m_scale, m_scale); - - // 改名编辑态:在原位置绘制输入框。 + const ResolvedSignal resolved = ResolveSignal(item.ref); LegendHit hit; - hit.ref = ref; - hit.rect = gd::RectF(itemLeft - 3.0f * m_scale, top - 2.0f * m_scale, - itemWidth + 2.0f * m_scale, height + 4.0f * m_scale); - m_legendHits.push_back(hit); - - if (m_editTarget == PlotEditTarget::SIGNAL_NAME && m_editSignal == ref) + hit.ref = item.ref; + hit.rect = PlateRect(item); + hit.swatch = + gd::RectF(item.left, top + (height - swatchSize) * 0.5f, swatchSize, swatchSize); + hit.text = gd::RectF(item.left + swatchSize + 5.0f * m_scale, top, + std::max(8.0f, item.textWidth + 2.0f), height); + hit.label = item.label; + hit.textColor = item.selected ? m_theme->textStrong : m_theme->text; + if (resolved.style != nullptr) { - m_editFieldRect = hit.rect; - UiDrawEditField(g, hit.rect, m_edit, m_fonts->uiSmall, m_scale); - cursorX += itemWidth; - ++drawnCount; - continue; + hit.swatchColor = resolved.style->color; + hit.swatchOpacity = resolved.style->opacity; + } + m_legendHits.push_back(std::move(hit)); + if (m_editTarget == PlotEditTarget::SIGNAL_NAME && m_editSignal == item.ref) + { + m_editFieldRect = PlateRect(item); } - - cursorX += swatchSize + 5.0f * m_scale; - UiText(g, drawLabel, m_fonts->uiSmall, - gd::RectF(cursorX, top, std::max(8.0f, textWidth + 2.0f), height), - selected ? m_theme->textStrong : m_theme->text, TextAlign::LEFT); - cursorX += textWidth + kLegendItemGap * m_scale; - ++drawnCount; } - if (!anyVisible) + // 装饰矩形:**逐块托底**(徽标一块 + 每个条目一块),而不是覆盖整条图例带 —— + // 覆盖整条带会把中间的波形像素也烘进缓存,平移贴回时露出"旧波形的像素残留"。 + m_decorRects.push_back(badgeRect); + for (const LegendItem& item : items) + { + m_decorRects.push_back(PlateRect(item)); + } + + // 命中装饰缓存:徽标与图例的像素改由装饰层贴回,跳过重绘(命中矩形已在上方重建)。 + if (m_useDecorCache) + { + return; + } + + // ------------------------------------------------------------------ + // 3) 绘制。⚠️ 每块托底都先用**不透明底色铺满外接矩形**再画圆角: + // 圆角之外的那几个像素若仍是波形,会被装饰缓存烘进去,平移贴回时表现为 + // "徽标 / 图例边缘残留旧波形"。底色 = 网格底色,铺满外接矩形在视觉上不可见。 + // ⚠️ 徽标与条目的**悬停 / 激活高亮不在这里**(随鼠标变化,画在贴图之后, + // 见 PaintLegendHighlight)—— 否则会被缓存冻结,悬停时看不到任何反馈。 + // ------------------------------------------------------------------ + { + UiFillRect(g, OpaqueBaseRect(badgeRect), m_theme->cardBg | 0xFF000000u); + UiFillRoundRect(g, badgeRect, badgeHeight * 0.5f, m_theme->cardBg); + UiDrawRoundRect(g, badgeRect, badgeHeight * 0.5f, m_theme->border, 1.0f); + UiText(g, L"G" + std::to_wstring(laneIndex + 1), m_fonts->monoSmall, badgeRect, + m_theme->text, TextAlign::CENTER); + + for (const LegendItem& item : items) + { + const ResolvedSignal resolved = ResolveSignal(item.ref); + if (resolved.data == nullptr || resolved.style == nullptr) + { + continue; + } + const gd::RectF plate = PlateRect(item); + UiFillRect(g, OpaqueBaseRect(plate), m_theme->cardBg | 0xFF000000u); + UiFillRoundRect(g, plate, kLegendPlateRadius * m_scale, + item.selected ? m_theme->selection : m_theme->cardBg); + UiDrawRoundRect(g, plate, kLegendPlateRadius * m_scale, + item.selected ? m_theme->accent : m_theme->border, 1.0f); + + // 改名编辑态:整块托底变成输入框(与左侧列表双击改名一致)。 + if (m_editTarget == PlotEditTarget::SIGNAL_NAME && m_editSignal == item.ref) + { + UiDrawEditField(g, plate, m_edit, m_fonts->uiSmall, m_scale); + continue; + } + + UiDrawSwatch(g, + gd::RectF(item.left, top + (height - swatchSize) * 0.5f, swatchSize, swatchSize), + resolved.style->color, resolved.style->opacity, 1.5f * m_scale, m_scale); + UiText(g, item.label, m_fonts->uiSmall, + gd::RectF(item.left + swatchSize + 5.0f * m_scale, top, + std::max(8.0f, item.textWidth + 2.0f), height), + item.selected ? m_theme->textStrong : m_theme->text, TextAlign::LEFT); + } + } + + if (visibleCount == 0) { UiText(g, L"(无可见信号)", m_fonts->uiSmall, gd::RectF(legendBand.X, top, lane.rect.Width, height), m_theme->textMuted, TextAlign::LEFT); } - else if (drawnCount < visibleCount) + else if (static_cast(items.size()) < visibleCount) { // 图例空间不足时提示剩余信号数量,避免误认为信号丢失。 - const std::wstring moreText = L"+" + std::to_wstring(visibleCount - drawnCount); - if (cursorX + 20.0f * m_scale <= maxX) + const std::wstring moreText = + L"+" + std::to_wstring(visibleCount - static_cast(items.size())); + if (contentRight + 20.0f * m_scale <= maxX) { - UiText(g, moreText, m_fonts->uiSmall, gd::RectF(cursorX, top, 24.0f * m_scale, height), - m_theme->textMuted, TextAlign::LEFT); + UiText(g, moreText, m_fonts->uiSmall, + gd::RectF(contentRight, top, 24.0f * m_scale, height), m_theme->textMuted, + TextAlign::LEFT); } } } diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 924fc10..82b07a5 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -681,6 +681,126 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( --- +### 9.19 远端「调整」功能带来的性能回归 + 首帧卡顿根治 + 图例交互/托底重做(本轮) + +> 接手背景:远端(`c334390`…`940b8e6`)新增了样式区「调整」(系数/绝对值/镜像/偏移)与 +> 标题栏内存占用等。本轮先**逐项量化**再修,全部离屏验证(不占用鼠标/键盘/屏幕)。 + +#### 一、性能回归:`AutoRangeLaneToView` 把 `Style(ref)` 放进了逐采样循环(**8 倍**) + +- **症状**:加载 `VERY_BIG_DATA.CSV` 并显示全部信号后,**点一下工具栏「Y 轴自适应」开关**就卡顿 + 很久;从局部放大状态 `FitAll` 回全局同样卡。 +- **量化**(新增 `.preflight/perf/zoomcheck` mode 21/23): + - `SetAllLanesAutoFitToSelection(true,true)`(全视图、5 个网格):**修复前 1200ms → 修复后 0.02ms**; + 旧提交(`20b9084`)为 **147ms** —— 即远端把这一步劣化了约 8 倍。 + - `AutoRangeLaneToView(0)`(单网格、全视图):**修复前 ≈240ms → 修复后 0.012ms**,且结果与 + 「逐采样扫描」的参考值**逐位一致**(`match=1`)。 +- **根因**:远端为支持「调整」变换,把 `m_dataSet->Style(ref)` **写在了逐采样循环体里**(还写了两次)—— + `Style()` 每次都要解析文件/信号下标;2580 万 × 5 次 × 2 = 亿级解析调用。 +- **修法(显示效果零变化)**: + 1. **把样式指针提到循环外**(`resolvedStyle`),并预计算 `mirrored` / `transform` 两个布尔。 + 2. **整段自适应走 O(1)**:`timeMonotonic && begin==0 && end==count && !mirrored && !transform && + finiteCount>0` 时直接取信号**已缓存的 `minValue/maxValue`**(与 `ComputeStats` 同源, + 因此结果与逐点扫描完全一致)。 + 3. 无变换时走**紧凑循环**(无逐点分支,便于向量化);有变换/水平镜像时逐点套用与绘制相同的变换。 + - ⚠️ 有「调整」变换时仍必须逐点扫描(158ms/单网格,全视图)——这是变换语义的固有代价。 + +#### 二、首帧卡顿根治:**空闲预热「每网格信号图层」** + +- **复现与量化**(`zoomcheck` mode 22,`VERY_BIG_DATA` 5 网格,同一负载下 A/B): + 加载 → 静止 → 第一次「选区放大」的**首帧**:旧提交 **64.3ms**、本轮修复前 **54ms**。 +- **根因**:加载后画面走的是「静态场景缓存贴图」,**宽基底图层从未被分配**;用户第一次平移/缩放时 + `Paint` 里 `if (!LayersAllocated()) RebuildWideBuffer();` 会在**那一帧同步重建全部 5 个网格图层** + (实测整帧 = 5 个图层同步重建 + 占位拉伸)。逐项拆解(mode 25/26): + 占位 `StretchBlt`(HALFTONE)×5 ≈16ms、装饰层命中缓存仅 0.4ms、**其余全是整批图层重建**。 +- **修法**:新增 `PlotView::WarmLaneLayersStep()`,在**命中静态场景缓存**的那条快路径里调用 + (⇒ 本帧视图与缓存同源、**视界没有变化**、且**不在拖拽中**): + - 图层数组与网格数量不符时先对齐(多余图层 `ReleaseLaneLayer` 后 `resize`); + - 用既有预算机制 `RollWideBuffer()` + `RenderPendingLaneRebuilds(1)` **每帧至多补 1 个网格**, + 队列非空时 `PostMessage(kMessagePlotDerivedReady)` 让宿主再送一帧,直到补齐。 + - ⇒ 整批重建的代价被**摊到若干静止帧**上,**任何交互帧都不再承担它**。 +- **效果**(同一负载,mode 22):首帧 **64.3ms → 13.0ms**(≈5 倍);`zoomcheck` mode 0 逐步缩放 + f1 也同步下降。图层数一旦就绪,后续交互全程走"占位拉伸 + 每帧补 1 个网格"。 +- ⚠️ 预热只在**视界不变**时推进,因此不会在用户拖动时做无用功;视界一变,宽基底路径自己按预算补。 + +#### 三、派生信号:把「调整」**烘进数据**,不再继承指纹 + +- **用户要求原文**:"重建出来的新信号原本不经过调整就已经是预览状态的样子,而不是继承指纹"。 +- **旧行为**(`fc77b02`):把源信号的 `gain/absolute/mirrorVideo/mirrorHorizontal/offsetY/offsetTime` + 复制给新信号的 `SignalStyle` —— 新信号只是"套了一层同样的调整",数据本身没变, + 「调整」面板里还莫名带着一整套参数。 +- **新行为**(`Application::BuildDerivedSignal`): + 1. 快照源信号样式; + 2. 滤波结果算完后,**逐点套用 `ApplyDrawTransform`**(系数 → 绝对值 → 垂直镜像 → Y 偏移)写回采样值; + 3. **水平镜像 = 数据反序**:把派生采样数组整体反转(`ApplyDrawTransform` 是逐点函数, + "先变换再反转"与"先反转再变换"等价); + 4. 新信号样式一律**保持默认**(`gain=1 / absolute=false / 两个镜像=false / offsetY=0`)。 +- ⚠️ **唯一例外:时间轴偏移 `offsetTime` 仍由样式携带**——它改变的是"绘制在哪个时刻", + 而派生信号与源信号**共用同一个文件时间轴**,无法把时间平移烘进采样数据; + 若不继承,新信号会相对预览整体横向错位。已在该函数注释中写明。 +- 验证(本轮 `verify` harness):`derived.values[i] == ApplyDrawTransform(tf, float(source[count-1-i]))` + 逐点一致;新样式各项均为默认;`offsetTime` 保留。 + +#### 四、网格 GX 拖拽让位阈值 + 徽标悬停 + +- **让位判据改为「鼠标指针」**(`LaneDragTargetAt`):`index = floor((mouseY - firstTop)/laneHeight + 0.5)`。 + - 旧判据用"**被拖网格的中心**",等价于要求把整个网格拖过去一格才吸附(用户报告"要拖到另一个网格 + 的位置附近才有吸附提示")。新判据下**只有两个网格时,鼠标越过 G1 的中心水平线,G1 立刻让位并 + 出现吸附提示**;任意网格数下都成立(进入相邻网格的上半即让位)。 +- **GX 徽标悬停提示**:徽标本体画在静态场景 / 装饰缓存里(被冻结),因此悬停高亮必须是**叠加层** + ——新增状态 `m_hoverBadge`(`OnMouseMove` 里更新、`OnMouseLeave` 里清零,因为鼠标在同一网格的 + 徽标范围内移动时 `m_hoverLane` 不变),`PaintLegendHighlight` 在贴图之后**重画徽标** + (不透明底色铺满外接矩形 + `accentSoft` 圆角底 + `accent` 描边 + `textStrong` 文字), + 与左侧信号列表里同一个 GX 徽标的悬停效果完全一致;`QueryCursor` 在徽标上返回 `IDC_HAND`。 + +#### 五、图例:命中矩形在装饰缓存路径下**必须重建**(悬停/点选失效的真因) + +- **症状**:一旦用中键平移过视界,网格图例的**悬停高亮 / 点击选中 / 双击改名全部失效**。 +- **根因**:`m_legendHits` 只在**整幅重绘**路径开头被 `clear()`,随后靠 `PaintLaneLegend` 重新填充; + 而装饰缓存命中时 `PaintLaneLegend` **提前 return**(跳过绘制)→ 命中矩形列表**被清空且永不重建**。 + 中键平移后必然出现一次"装饰缓存有效的整幅重绘"(结算帧),于是图例从此点不动。 +- **修法(结构性)**:把 `PaintLaneLegend` 拆成「**几何 → 命中/装饰矩形 → 绘制**」三段: + - 先用**唯一来源**算出"放得下的条目"及其托底矩形(命中矩形 == 托底矩形,看到的与点得到的严格一致); + - **无论是否命中装饰缓存都重建 `m_legendHits` 与 `m_decorRects`**,之后才 `if (m_useDecorCache) return;`; + - 绘制段只负责画像素。 + +#### 六、图例托底 + 消除「旧波形像素残留」 + +- **症状**:中键把密集波形区域推过左上角 GX 编号/图例区域后再移开,**GX/图例周边残留旧波形像素**。 +- **根因**:远端 `c334390` 去掉了图例的整块不透明托底后,装饰缓存捕获的那**一条大矩形** + 里包含了**它下面的波形像素**(波形会透出),而装饰指纹 `SceneDecorHash()` 不含视界 ⇒ + 平移贴回时把旧视界的波形一起贴了回来。 +- **修法**: + 1. **每个图例条目各自一块圆角托底**(`kLegendPlateRadius = 4`),风格与 GX 徽标一致 + (`cardBg` 底 + `border` 描边;选中态用 `selection` 底 + `accent` 描边); + 2. **装饰矩形改为逐块**(徽标一块 + 每个条目一块 + 每个 Y 轴胶囊一块), + **不再覆盖整条图例带** —— 中间的波形像素因此完全不进缓存; + 3. 每块托底先用**不透明底色铺满其"外接整数像素再外扩 1px"的矩形**再画圆角: + 否则圆角之外那几个像素仍是波形,会被烘进缓存(`CaptureDecorCache` 的边界由 `floor/ceil` 得出, + 必须完全落在不透明填充内)。Y 轴胶囊同理。 + 4. 悬停高亮(随鼠标变化)画在**贴图之后**:不透明 `accentSoft` 圆角矩形 + `accent` 描边, + 并**在原托底上重绘色块与文字**(`LegendHit` 现在记录色块矩形/文本矩形/颜色/已省略文本)—— + 不透明托底若只画底色会把图例内容整块盖住(只剩一个空色块)。 + 悬停时**同时**画对应信号的曲线光晕(`PaintSignalHaloFor`)。 +- **实测**(`verify` harness):把"宽基底快路径的实时帧"与"同一视界从零重绘的装饰"逐像素比较, + **全部装饰矩形内差异 = 0**(`decor ghost check: total=0 worst=0 rects=23`)—— + 直接证明缓存像素与时间视界无关、不会残留旧波形。 + +#### 七、验证工具(本轮新增,均在 `.preflight/` 下,不入库) + +- `.preflight/perf/verify_main.cpp`(`build.ps1 -Main verify_main.cpp -Out verify.exe`): + **离屏 Application harness**(`Initialize(GetModuleHandleW(nullptr), SW_HIDE)` + 直接注入鼠标消息 + + 从 `m_backDc` 截图),**40 项检查全部通过**,覆盖:自适应正确性(恒等/变换/镜像、与逐点扫描逐位一致)、 + 派生信号烘值与样式默认、拖拽让位阈值(鼠标越过中线即让位)、平移后图例命中与可点选、 + 装饰矩形不覆盖整条图例带、图例悬停有可见高亮、GX 徽标悬停有可见高亮且离开后清除、 + **装饰矩形内 0 像素残留**。还会导出 `verify_legend.png` / `verify_legend_hover.png` 供目视核对。 +- `.preflight/perf/zoomcheck_main.cpp` 新增 mode:20(FitAll from deep zoom)、21(全局 Y 轴自适应开关)、 + 22(选区放大首帧 + 图层的空闲预热进度)、23(自适应**正确性**与耗时)、24(带着色自适应 FitAll)、 + 25(首帧拆解)、26(占位拉伸 HALFTONE vs COLORONCOLOR 与装饰重绘耗时)。 +- 旧版本 A/B 基线用 `git worktree add --detach ..\jdi_old 20b9084` 建立。 + +--- + ## 10. 变更日志(本文件维护记录) | 日期 | 设备/模型 | 摘要 | @@ -712,6 +832,7 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( | 2026-10-12 | DeepSeek-V4.1-Flash(**另一台设备**) | **修复「垂直中键拖拽后再平移,网格内容纵向跳变 / 绘制错误」**(详见 §9.16):根因是 `RenderLaneLayerRegion` 结束时**无条件**把 `layer.anchorYMax` 重置为"当前 `yMax`"—— 对整块重画 / 纵向补条成立,但**横向补细条(只改一条竖带、其余像素仍在原锚点)**不成立。纵向拖动以 `kLayerRollQuantum=16px` 量化,停下时图层会残留 `<16px` 的纵向偏移且 `anchorYMax != 当前 yMax`;第一次水平微移就把该偏移**静默清零**(`vOffset→0`)而像素未动 → 整个网格波形纵向错位(最多 15px)。修法:`RenderLaneLayerRegion` 新增形参 `anchorYMax`,内部以它作幅值映射基准(`yMin = anchorYMax - laneSpan`)渲染并写回 `layer.anchorYMax`;横向补条传 `layer.anchorYMax`(保持不变),整块重画/纵向补条传当前(或滚动后)`yMax`。`vOffset==0` 时新旧逻辑完全等价。验证:新增离屏探针 `.preflight/perf/pancheck`(窗口 `SW_HIDE` + 注入消息 + 读图层内部 `anchorYMax`/`vOffset` + 持按钮帧 vs 释放后整幅帧的纵向互相关):修复前 A 场景 `dy=+15px`(lane0 差异 39245),修复后 **`dy=0`**(差异 1220,仅已知列相位残差);对照/中间网格/长距水平/斜向/松开重按共 7 个场景在缩放 0/8/26 步下全部 `dy=0`(中间网格 `+1px` 为整像素量化残差);`make clean && make all` 零警告。 | | 2026-10-13 | DeepSeek-V4.1-Flash(**另一台设备**) | **交互流畅性三项修复**(详见 §9.17):① **光标不再跟随视界**——删除 `ClampCursorsAfterPan()` 及其在平移路径(中键/左键拖拽、Shift+滚轮、方向键)的三处调用,视图变化时光标时间位置**完全不变**(可滑出视口),`ClampCursorToView()` 只在拖拽光标时使用(mode 14:平移 20 帧后 `CursorTime` 不变且 `offViewport=1`)。② **缩放首帧不再卡顿**——根因是跨度变化时 `Paint` 会**同步重建全部 5 个网格图层**(实测 70~80ms)+ 随后整幅结算(40ms)=单次缩放 ~110ms 卡顿;修法:`BlitWideBuffer` 对跨度过期的图层改用**旧像素 `StretchBlt` 拉伸占位**(不再跳过导致空白)、`Paint` 只在"图层数量不符"时同步重建(新增 `LayersAllocated()`)、`RenderPendingLaneRebuilds` 允许按新跨度重建、且**只在视界稳定后**才花预算精细重绘(`viewStopped` 门控);实测首帧 **70~80ms → ~10ms**。保留"结算"帧(实测宽基底路径与整幅重绘有 17 万+ 像素列相位差,非同源)。③ **超大信号拖预览滑块**——量化发现开销**不在绘制**(`PaintLaneDerivedOverlays` 仅 0.375ms),而在"参数每帧变化"触发的**整条信号**(2576 万采样)预览重算调度;新增 `kHugeSignalPreviewSamples=4000000`,超大信号拖拽期间不再每帧排队重算(15.0→13.7ms,**剩余 ~8ms 根因未完全定位**)。验证:`pancheck` 7 场景全 `dy=0`、`zoomcheck` mode 0/13/14、`make clean && make all` 零警告。 | | 2026-10-13 | DeepSeek-V4.1-Flash(**另一台设备**) | **深放大中键拖拽卡顿(根因=GDI+ 抗锯齿"满幅锯齿"病态)+ 横向补条裁剪 + 并行网格图层(B/C,详见 §9.18)**。① **B 拆解**:该视界(`[10.2719,10.2831]s`)下 `RollWideBuffer` 补条重绘 **13.08ms**,其余 ≤0.8ms —— 瓶颈全在补条重绘。② **补条裁剪**:横向补条只覆盖窄竖带,却按整条宽窗口构建/描边整段折线;改为按条带 x 范围裁剪采样区间(旁路折线缓存),`RollWideBuffer` **13.08ms → 1.73ms**,拖拽整帧 13~24ms → ~3ms,且**逐像素 0 差异**。③ **病态根因**:拖拽超出预渲染余量时走整块重画,**整宽单网格渲染 655ms** —— 同 4481 点 `DrawLines` 实测 **AA 694ms / 无 AA 29ms(23 倍)**(编码器方波在 1~2 采样/像素形成每列整幅跳变的锯齿,原实现此时既不走包络也不关 AA);修法:**包络带阈值从 2 采样/像素降到 1**,并扩展"梳状(关 AA)"判据(多数列列内跨幅 >0.5×网格高 / 相邻列整幅跳变)——**几何一字未改**;整宽渲染 **655→47.7ms**、补条 6.9→2.4ms、拖拽 200 帧 **avg 4.5 / max 5.8ms / 0 帧 >8ms**。④ **C(用户解除多线程限制)**:实现 `RebuildLaneLayersParallel` —— 每线程一个**隔离 `PlotView`**(自有 `m_workerTab`/`m_area`/`m_plotRect`/`m_lanes`)渲染不同网格,主线程 join 后按真实下标重算 key;并行 vs 顺序**逐像素 0 差异**;⚠️ 踩坑:`BuildLaneVisuals` 在 `m_tab==nullptr` 时清空 `m_lanes`,故**必须给 worker 一个非空的自有 tab**。**诚实结论:并行仅 1.03x**(同机 4 线程 CPU 真并行 3.98x → GDI+ 光栅化被串行化),故**并行对光栅化无收益**,仍保持"视界停后每帧补 1 个网格"。验证:`dragperf` mode 0/1/3/5/6/8/9、`pancheck` 全 `dy=0`、`zoomcheck` 无回归、`make clean && make all` 零警告。 | +| 2026-10-14 | DeepSeek-V4.1-Flash | **修复远端「调整」功能引入的性能回归 + 根治首帧卡顿 + 图例交互/托底重做(详见 §9.19)**。① **8 倍回归**:远端把 `DataSet::Style(ref)` 写进了 `AutoRangeLaneToView` 的逐采样循环(亿级解析调用)→ 工具栏「Y 轴自适应」在全视图下 **1200ms**(旧提交 147ms);修法:样式指针提循环外 + **整段自适应取已缓存 min/max(O(1),结果逐位一致)** + 无变换走紧凑循环 → **0.02ms**。② **首帧卡顿根治**:加载后宽基底图层从未分配,用户第一次平移/缩放时 `Paint` 在那一帧**同步重建全部 5 个网格图层**(首帧 **64.3 → 13.0ms**);修法:新增 `WarmLaneLayersStep()`,在**命中静态场景缓存**(视界未变、非拖拽)时用预算机制**每帧至多补 1 个网格**,把整批重建摊到静止帧。③ **派生信号把「调整」烘进数据**(不再继承样式指纹),仅 `offsetTime` 因共用文件时间轴仍需继承。④ **GX 拖拽让位判据改为鼠标指针**(鼠标越过相邻网格中心水平线即让位),并给 GX 徽标加悬停高亮(叠加层 + `IDC_HAND`,与信号列表 GX 徽标一致)。⑤ **图例命中矩形在装饰缓存路径下必须重建**(原先命中缓存时提前 return → `m_legendHits` 被清空且不重建 → 中键平移后图例悬停/点选全失效)。⑥ **每个图例条目加圆角托底 + 装饰矩形改为逐块 + 托底不透明铺满外接矩形** → 消除「GX/图例周边旧波形像素残留」(逐像素验证装饰矩形内 **0 差异**);悬停高亮为不透明托底并**重绘色块与文字**(否则内容被盖住)。验证:离屏 Application harness **40/40 通过**,`zoomcheck` mode 20–26 量化,`make release` 零警告。 | --- @@ -756,6 +877,22 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( 14. ⚠️ **多线程限制已解禁(2026-10-13),但必须"隔离状态"**:波形渲染可以多线程 —— 前提是每个线程用**自有的渲染上下文**(自有 `m_tab`/`m_area`/`m_plotRect`/`m_lanes`/DC),绝不共享可变渲染状态(见第 5 条的安全姿势)。已落地的并行只有:派生曲线线程池(`m_curveWorkers`)与并行网格图层渲染(`m_layerWorkers`)。⚠️ 实测**光栅化并行无收益**(GDI+ 串行化),所以并行只在"整批重建"这类本来就要一次画完的地方用。任何"移动 / 释放采样"的操作前必须 `PauseDerivedAsync()`。 15. MinGW 的 `swprintf` 按 C99:宽格式串里的 `%s` = **窄**字符串,宽字符串必须用 `%ls`(否则 "KB" 会被截成 "K")。 16. 源文件名必须**全局唯一**(Makefile 按 basename 去重 + vpath 查找);新增目录要同时登记进 `CXX_INCLUDES_PATHS` / `CXX_SOURCES_PATHS`。 +17. ⚠️ **`m_legendHits` 只在"整幅重绘"路径开头被清空**,因此 `PaintLaneLegend` 在**任何**绘制路径 + (**包括装饰缓存命中、提前 return 的那条**)都必须**先重建命中矩形**再决定是否跳过绘制 —— + 否则清空后不再填充,图例的悬停高亮 / 点击选中 / 双击改名会**永久失效** + (用户报告"用中键平移过视界后图例点不动")。同理 `m_editFieldRect` 也必须在几何段设置。 +18. ⚠️ **装饰缓存的像素必须与时间视界无关**:`CaptureDecorCache` 的捕获边界由 `floor/ceil(rect)` 得出, + 因此每块装饰(GX 徽标 / 图例托底 / Y 轴胶囊)都必须**先用不透明底色铺满"外接整数像素再外扩 1px"的矩形** + 再画圆角。否则圆角之外的那几个像素仍是波形 → 平移贴回时表现为"GX/图例周边旧波形像素残留"。 + 装饰矩形也必须**逐块**(禁止覆盖整条图例带)。验证方法:宽基底实时帧 vs 同视界从零重绘, + **装饰矩形内必须 0 差异**。 +19. ⚠️ **逐采样循环里禁止调用 `DataSet::Style(ref)` / `Resolve()` 等按下标解析的函数**: + `AutoRangeLaneToView` 曾因此把全段自适应从 147ms 劣化到 **1200ms(8 倍)**。 + 一律把指针提到循环外。整段(`begin==0 && end==count`)且无变换/无水平镜像时, + 自适应应直接取 `SignalData::minValue/maxValue`(O(1),与原逐点扫描逐位一致)。 +20. ⚠️ **"整批图层重建"绝不允许发生在交互帧里**:新增 `WarmLaneLayersStep()`, + 在**命中静态场景缓存**(视界未变、非拖拽)时用既有预算机制**每帧至多补 1 个网格**并自请求下一帧。 + 否则"加载后的第一次平移 / 选区放大"那一帧会同步重建全部网格图层(首帧 64ms → 预热后 13ms)。 ### 11.3 离屏验证方法学(本机偏好,强烈建议沿用)