// PlotView 实现分片 —— 本文件只包含 PlotView 的**成员函数定义**(纯代码搬移,行为与拆分前完全一致)。 // // 共用的常量与工具函数在 PlotViewInternal.hpp(命名空间 `pvdetail`,全部 inline)。 // 四个分片的职责划分(按功能): // core 视图核心:构造/析构、上下文绑定、布局、坐标映射、视界与平移、网格结构与归属、 // 选区/网格划分、拖动取消 // paint 渲染与像素缓存:全部绘制(Paint*/Render*)、场景缓存、装饰缓存、每网格图层、 // 宽基底与滚动补条、并行图层重建 // interaction 交互与编辑:命中测试、鼠标与键盘处理、光标形状、内联输入框、浮动面板层序与命中 // features 视图特性与派生数据:光标状态与读数、提示浮层、缩放模式、自适应、光标吸附、 // 单点信号、派生曲线线程池、边沿统计、实验性功能(均值滤波 / 弱峰调整)参数 #include "PlotViewInternal.hpp" namespace jdi { float PlotView::EdgePanelWidth(int laneIndex, EdgePanelKind kind) const { if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return kEdgePanelWidth * m_scale; } const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; const bool centerAxis = (kind == EdgePanelKind::CENTER); const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool weakPeak = (kind == EdgePanelKind::PEAK); // 「均值滤波」「中心轴边沿计数」「弱峰调整」都是逐条目面板(名称 + 复选框)。 const bool itemized = (centerAxis || meanFilter || weakPeak); // 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” / “均值滤波” / “弱峰调整” // + 光标区间尾缀)。 const std::wstring title = meanFilter ? std::wstring(L"均值滤波") : (weakPeak ? std::wstring(kWeakPeakTitle) : ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") + EdgeRangeSuffix())); float width = kEdgePanelPad * 2.0f * m_scale + kLaneBadgeWidth * m_scale + 8.0f * m_scale + UiMeasureTextWidth(m_fonts->uiSmall, title) + kEdgePanelTitleHeight * m_scale; // 数据行 = 名称与“上升沿 N / 下降沿 M”两行,取较宽者。 // ⚠️ 名称宽度有上限(`kEdgePanelNameMaxWidth`):超长信号名不再把面板顶得很长, // 绘制时超出部分用省略号(用户明确要求“超过某个长度就省略名称显示”)。 // ⚠️ 统计信息按「上升沿 / 下降沿」**两列**排版(列起点固定)→ 多条信号时文字垂直对齐。 float nameColumnWidth = 0.0f; float statRisingWidth = 0.0f; float statFallingWidth = 0.0f; for (const SignalRef& ref : lane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || !resolved.visible) { continue; } const float nameWidth = std::min(UiMeasureTextWidth(m_fonts->uiSmall, resolved.data->name), kEdgePanelNameMaxWidth * m_scale); nameColumnWidth = std::max(nameColumnWidth, nameWidth); if (itemized) { // 逐条目面板:第一排 = 名称 + 复选框;第三排(中心轴)= 统计结果。 width = std::max(width, kEdgePanelPad * 2.0f * m_scale + nameWidth + kEdgePanelMeanCheckSize * m_scale + 6.0f * m_scale); } std::size_t rising = 0; std::size_t falling = 0; bool complete = true; if (meanFilter || weakPeak) { continue; } if (centerAxis) { const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); if (item == nullptr) { continue; } EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, item->points, item->iterations, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); } else { EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, lane.edgeValue, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); } // 宽度按实际要显示的文本测量(含未算完时的“…”),否则推进过程中数字变长会被截断。 // 完全未算出时整行显示“统计中…”,因此两列都要预留它的宽度。 const float pendingWidth = UiMeasureTextWidth(m_fonts->monoSmall, L"统计中…"); statRisingWidth = std::max( statRisingWidth, std::max(UiMeasureTextWidth(m_fonts->monoSmall, EdgeRisingText(rising, complete)), pendingWidth)); statFallingWidth = std::max(statFallingWidth, UiMeasureTextWidth(m_fonts->monoSmall, EdgeFallingText(falling, complete))); } if (statRisingWidth > 0.0f || statFallingWidth > 0.0f) { // 两列**等宽**(取两列最大值中的较大者)→ 绘制时两列起点固定, // 多条信号的“上升沿 / 下降沿”文字严格垂直对齐(用户明确要求)。 const float maxColumn = std::max(statRisingWidth, statFallingWidth); const float countsWidth = maxColumn * 2.0f + kEdgePanelStatColumnGap * m_scale; width = std::max(width, kEdgePanelPad * 2.0f * m_scale + std::max(nameColumnWidth, countsWidth)); } if (centerAxis || meanFilter) { // 参数行 = 两个「说明文字 + 数值框」,必须保证文字不被数值框压住。 // (MEAN / CENTER:「均值点数 X」+「迭代次数 N」。标签与数值框各占半行。) const float labelXWidth = UiMeasureTextWidth(m_fonts->uiSmall, L"均值点数 X"); const float labelN = UiMeasureTextWidth(m_fonts->uiSmall, L"迭代次数 N"); const float fieldWidth = std::max(kEdgePanelEditFieldWidth * m_scale * 0.75f, UiMeasureTextWidth(m_fonts->monoSmall, L"1000000") + 18.0f * m_scale); const float halfWidth = std::max(labelXWidth, labelN) + 6.0f * m_scale + fieldWidth; const float rowWidth = halfWidth * 2.0f + 8.0f * m_scale; width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth); } if (weakPeak) { // 滑块行 = 「标签列 + 轨道(最小宽度)+ 数值列 + 复位按钮」(等级与迭代次数两行共用同一列宽)。 const float rowWidth = WeakPeakLabelColumnWidth(m_fonts->uiSmall) + 6.0f * m_scale + kWeakPeakTrackMinWidth * m_scale + 6.0f * m_scale + WeakPeakValueColumnWidth(m_fonts->monoSmall) + kWeakPeakResetGap * m_scale + kWeakPeakResetButtonSize * m_scale; width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth); } if (meanFilter || weakPeak) { // 底部按钮行 = 「构建为新信号」,按按钮文字实测宽度确定下限。 const float buttonWidth = UiMeasureTextWidth(m_fonts->uiSmall, kBuildSignalLabel) + 28.0f * m_scale; width = std::max(width, kEdgePanelPad * 2.0f * m_scale + buttonWidth); } // 夹在绘图区宽度之内(过窄的网格也不至于把面板挤到外面)。 return std::min(width, std::max(120.0f * m_scale, m_plotRect.Width - 8.0f * m_scale)); } const PlotView::PolylineCacheEntry* PlotView::AcquirePolyline(const SignalRef& ref, const ResolvedSignal& resolved, const LaneVisual& lane) const { if (resolved.data == nullptr || resolved.group == nullptr) { return nullptr; } // ⚠️ **横向补条渲染:不进缓存**。补条只构建"条带区间"的折线顶点,若写进缓存, // 后续整块渲染命中同键时会拿到这段**不完整**的折线(只画了条带那一段)。 // 因此补条专用一个独立条目,避免污染按"宽窗口几何"键控的缓存。 if (m_stripRenderActive) { BuildPolyline(resolved, lane, m_stripPolylineEntry.points, m_stripPolylineEntry.breaks, m_stripPolylineEntry.band, m_stripPolylineEntry.bandBreaks, m_stripPolylineEntry.bandMode, m_stripPolylineEntry.bandSpiky); return &m_stripPolylineEntry; } // 键 = 构建折线时实际读取的全部输入: // 信号(引用 + 文件内容 + 采样点数)、时间轴显示范围、绘图区横向几何、网格纵向几何。 // 这些值全部相同时,BuildPolyline 的输出必然完全一致,可直接复用。 const std::size_t sampleCount = std::min(resolved.axis->time.size(), resolved.data->values.size()); const double viewMin = Tab().ViewMin(); const double viewMax = Tab().ViewMax(); // 绘制前的数值变换(系数/绝对值/镜像/偏移)会改变顶点,必须进缓存键。 const float gain = (resolved.style != nullptr) ? resolved.style->gain : 1.0f; const bool absolute = (resolved.style != nullptr) && resolved.style->absolute; const bool mirrorVertical = (resolved.style != nullptr) && resolved.style->mirrorVertical; const bool mirrorHorizontal = (resolved.style != nullptr) && resolved.style->mirrorHorizontal; const float offsetY = (resolved.style != nullptr) ? resolved.style->offsetY : 0.0f; const double offsetTime = (resolved.style != nullptr) ? resolved.style->offsetTime : 0.0; // ⚠️ 线宽也必须进键:`BuildPolyline` 把它烘进包络带几何(逐列外扩半个线宽) //(详见 `PolylineCacheEntry::lineWidth` 的说明;漏掉它会让线宽改动在**稠密信号**上不生效)。 const float lineWidth = (resolved.style != nullptr) ? resolved.style->lineWidth : 1.0f; for (const PolylineCacheEntry& entry : m_polylineCache) { if (entry.ref == ref && entry.group == resolved.group && entry.sampleCount == sampleCount && entry.viewMin == viewMin && entry.viewMax == viewMax && entry.plotX == m_plotRect.X && entry.plotWidth == m_plotRect.Width && entry.laneY == lane.rect.Y && entry.laneHeight == lane.rect.Height && entry.laneYMin == lane.yMin && entry.laneYMax == lane.yMax && entry.gain == gain && entry.absolute == absolute && entry.mirrorVertical == mirrorVertical && entry.mirrorHorizontal == mirrorHorizontal && entry.offsetY == offsetY && entry.offsetTime == offsetTime && entry.lineWidth == lineWidth) { return &entry; } } // 未命中:重建并写入缓存(deque 保证已有元素的引用在追加后依然有效)。 // 缓存只用于消除重复计算,容量上限约等于“网格数 × 每网格信号数”的两倍。 if (m_polylineCache.size() >= kPolylineCacheLimit) { m_polylineCache.clear(); } m_polylineCache.push_back(PolylineCacheEntry()); PolylineCacheEntry& entry = m_polylineCache.back(); entry.ref = ref; entry.group = resolved.group; entry.sampleCount = sampleCount; entry.viewMin = viewMin; entry.viewMax = viewMax; entry.plotX = m_plotRect.X; entry.plotWidth = m_plotRect.Width; entry.laneY = lane.rect.Y; entry.laneHeight = lane.rect.Height; entry.laneYMin = lane.yMin; entry.laneYMax = lane.yMax; entry.gain = gain; entry.absolute = absolute; entry.mirrorVertical = mirrorVertical; entry.mirrorHorizontal = mirrorHorizontal; entry.offsetY = offsetY; entry.offsetTime = offsetTime; entry.lineWidth = lineWidth; BuildPolyline(resolved, lane, entry.points, entry.breaks, entry.band, entry.bandBreaks, entry.bandMode, entry.bandSpiky); // BuildPolyline 通过引用填充,push_back 过程中不会再次进入本函数,引用安全。 return &m_polylineCache.back(); } void PlotView::BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane, std::vector& points, std::vector& breaks, std::vector& band, std::vector& bandBreaks, bool& bandMode, bool& bandSpiky) const { points.clear(); breaks.clear(); band.clear(); bandBreaks.clear(); bandMode = false; bandSpiky = false; if (resolved.data == nullptr || resolved.group == nullptr) { return; } const std::vector& time = resolved.axis->time; const std::vector& values = resolved.data->values; const std::size_t count = std::min(time.size(), values.size()); if (count == 0 || m_plotRect.Width <= 1.0f) { return; } const double viewMin = Tab().ViewMin(); const double viewMax = Tab().ViewMax(); // 时间偏移:采样画在 `t + offsetTime` 处,因此“落在当前视界内的采样” // 其源时刻位于 `[viewMin - offsetTime, viewMax - offsetTime]`。 const double timeShift = resolved.style->offsetTime; const double sourceMin = viewMin - timeShift; const double sourceMax = viewMax - timeShift; std::size_t begin = 0; std::size_t end = count; if (resolved.axis->monotonic) { const std::vector::const_iterator first = std::lower_bound(time.begin(), time.end(), sourceMin); begin = static_cast(first - time.begin()); if (begin > 0) { --begin; } const std::vector::const_iterator last = std::lower_bound(time.begin(), time.end(), sourceMax); end = static_cast(last - time.begin()); if (end < count) { ++end; } } if (end <= begin) { return; } // ⚠️ **横向补条裁剪**(只对本函数后半段的"逐点描边"路径生效,见 `budget` 之后): // 图层横向补条只覆盖位图里的一条窄竖带(`m_area` 的 x 区间),其余像素由已有图层像素提供, // 因此折线只需构建并绘制落在条带 x 范围内的那一段顶点。放在"包络/描边"分支判定**之后** // 是必须的 —— 若先裁剪,`visibleCount` 会变成条带内的采样数,可能改变"走包络还是走描边" // 的判定,使条带与整块渲染的像素不一致(出现接缝)。 const float limitTop = lane.rect.Y - lane.rect.Height * 4.0f; const float limitBottom = lane.rect.Y + lane.rect.Height * 5.0f; const std::size_t visibleCount = end - begin; // 顶点预算由绘图区宽度决定(而不是固定值):折线顶点数直接决定 // GDI+ 抗锯齿折线的绘制耗时,百万级采样文件若按固定 20000 顶点绘制, // 会把大量顶点堆在同一个像素列上(画出来完全重合),纯粹浪费 CPU。 std::size_t budget = static_cast(std::max(1.0f, m_plotRect.Width)) * kPolylinePointsPerPixel; budget = std::max(budget, kMinPolylinePoints); budget = std::min(budget, kMaxPolylinePoints); // 采样点密度超过像素分辨率时改用“像素列上下包络”抽稀: // 每个像素列只保留最小/最大两个顶点(按出现先后排序), // 既不会漏掉尖峰(等间隔抽样会整段跳过),又把顶点数压到像素量级。 // // ⚠️ **阈值必须是「每像素列 1 个采样」而不是 `budget`(每列 2 个)**: // 逐点描边(`DrawLines`)的代价与「竖向线段数 × 覆盖行数」成正比 —— // 方波/编码器这类信号在 1~2 采样/像素时会形成**满幅高的锯齿**,GDI+ 抗锯齿 // 逐段扫描这些竖直段,实测 **4481 个顶点要 712ms**(关抗锯齿也无用,因为瓶颈是 // 每段的扫描线而非边缘平滑),而同样画面的「像素列包络」只要 ~1.7ms。 // 把阈值降到 1 采样/像素后,1~2 采样/像素区间走包络带(逐列 min/max 与折线在该密度下 // 几乎等价,且**保留抗锯齿**),彻底消除这一区间的病态卡顿。 const bool useEnvelopeBand = (visibleCount > static_cast(m_plotRect.Width)) && resolved.axis->monotonic && (m_plotRect.Width > 1.0f); // 逐点描边路径若处于"横向补条"渲染:把采样区间裁剪到条带对应的 x 范围 //(两侧各留少量像素余量以避免接缝与抗锯齿溢出)。逐点描边路径必然 `visibleCount <= budget` //(否则走包络),此时 `stride == 1`,裁剪后条带内的顶点序列与整块渲染完全一致 → 像素不变。 if (!useEnvelopeBand && m_stripRenderActive && resolved.axis->monotonic && m_stripX1 > m_stripX0) { const float marginPx = std::max(4.0f, std::max(1.0f, resolved.style->lineWidth) * m_scale + 3.0f); // 条带边界是**绘制时刻**,换算成源时刻要减去时间偏移。 const double ta = XToTime(m_stripX0 - marginPx) - timeShift; const double tb = XToTime(m_stripX1 + marginPx) - timeShift; const double clipLo = std::min(ta, tb); const double clipHi = std::max(ta, tb); std::size_t sBegin = static_cast( std::lower_bound(time.begin(), time.end(), clipLo) - time.begin()); if (sBegin > 0) { --sBegin; // 多含一个采样点:让跨越条带左界的线段完整绘出。 } std::size_t sEnd = static_cast( std::lower_bound(time.begin(), time.end(), clipHi) - time.begin()); if (sEnd < count) { ++sEnd; } if (sBegin > begin) { begin = sBegin; } if (sEnd < end) { end = sEnd; } if (end <= begin) { return; } } // 采样点密度远超像素分辨率时改用“像素列上下包络”抽稀: // 每个像素列只保留最小/最大两个顶点(按出现先后排序), // 既不会漏掉尖峰(等间隔抽样会整段跳过),又把顶点数压到像素量级。 if (useEnvelopeBand) { const double viewSpan = viewMax - viewMin; const float plotLeft = m_plotRect.X; const float plotWidth = m_plotRect.Width; const std::size_t columns = std::max(1, budget / 2); const float columnWidth = plotWidth / static_cast(columns); const std::vector::const_iterator rangeEnd = time.begin() + static_cast(end); // 粗粒度包络层(若已构建):每列只需读取覆盖到的分组, // 数据量从“可见采样数”降到“像素列数 × 少量分组”, // 且分组内存的就是真实极值,绘制结果与逐点扫描完全一致。 const std::vector& coarseMin = resolved.data->coarseMin; const std::vector& coarseMax = resolved.data->coarseMax; const std::size_t coarseStride = resolved.data->coarseStride; // ⚠️ 粗粒度分组只存了逐组的 min/max:取绝对值 / 垂直镜像会把两者互换, // 此时分组层无法再给出正确的包络,必须回退到逐采样点变换(见 `DrawTransformOrderPreserving`)。 // Y 偏移是保序的(整体加常数),因此仍可用分组层 —— 只需把偏移一并加上。 const bool hasCoarse = (coarseStride > 0) && !coarseMin.empty() && (coarseMin.size() == coarseMax.size()) && DrawTransformOrderPreserving(*resolved.style); // 保序变换(正系数 + Y 偏移)下:分组极值 × 系数 + 偏移 即为变换后的极值 //(系数 1、偏移 0 时逐位不变)。 const double coarseScale = static_cast(resolved.style->gain); const double coarseOffset = static_cast(resolved.style->offsetY); bool inSegment = false; // 稠密包络带:逐列记录上/下包络,收段时拼成「上包络正向 + 下包络反向」的 // 闭合多边形。绘制时一次 FillPath 即可,实测比逐段描边快约 8 倍 // (1224 列:14.3ms → 1.7ms),且**保留抗锯齿**, // 因此静态帧与拖拽帧的观感完全一致(不是"交互降级")。 bandMode = true; const float strokeHalf = std::max(1.0f, resolved.style->lineWidth) * m_scale * 0.5f; std::vector segmentColumns; const auto flushBandSegment = [&band, &bandBreaks, &segmentColumns, &bandSpiky, &lane]() { bool spiky = false; EmitEnvelopeBand(segmentColumns, band, bandBreaks, spiky, lane.rect.Height); bandSpiky = bandSpiky || spiky; segmentColumns.clear(); }; std::size_t searchFrom = begin; // ⚠️ **包络带路径也要支持「横向补条」裁剪**(用户 2026-10-17 报告拖拽卡顿): // 旧实现只对“逐点描边”路径按条带裁剪,包络带路径**总是构建整幅可见范围的包络** —— // 巨型信号(`VERY_BIG_DATA`)一条 32px 宽的竖带就要 **5ms**,拖拽时每帧补几个网格就卡。 // 包络是**逐像素列独立**计算的(每列只取自己的 min/max),因此只构建条带覆盖的列 // 就能得到与整块渲染**逐像素一致**的条带像素(条带外本来就由已有像素提供)。 std::size_t columnFirst = 0; std::size_t columnLast = columns; if (m_stripRenderActive && m_stripX1 > m_stripX0 && columnWidth > 0.0f) { // 两侧各多含 1 列,避免条带边界处的抗锯齿/描边被切掉。 const float c0 = (m_stripX0 - plotLeft) / columnWidth - 1.0f; const float c1 = (m_stripX1 - plotLeft) / columnWidth + 1.0f; columnFirst = (c0 <= 0.0f) ? 0 : static_cast(c0); const std::size_t wantLast = (c1 <= 0.0f) ? 0 : static_cast(c1) + 1; columnLast = std::min(columns, wantLast); if (columnLast <= columnFirst) { return; } // 从条带第一列对应的源时刻开始二分。 const double tFirst = viewMin + viewSpan * (static_cast(columnFirst) / static_cast(columns)); searchFrom = static_cast( std::lower_bound(time.begin() + static_cast(begin), rangeEnd, tFirst - timeShift) - time.begin()); } for (std::size_t column = columnFirst; column < columnLast; ++column) { // `t0/t1` 是**绘制时刻**的列边界;源时刻要减去时间偏移(偏移为 0 时二者相同)。 const double t0 = viewMin + viewSpan * (static_cast(column) / static_cast(columns)); const double t1 = viewMin + viewSpan * (static_cast(column + 1) / static_cast(columns)); const double s0 = t0 - timeShift; const double s1 = t1 - timeShift; // 列边界随 column 单调递增,从上一列的位置继续二分, // 把每次搜索的范围压到剩余区间,避免 2400 次全区间二分带来的缓存缺失。 const std::size_t i0 = static_cast( std::lower_bound(time.begin() + static_cast(searchFrom), rangeEnd, s0) - time.begin()); searchFrom = i0; const std::size_t i1 = (column + 1 == columns) ? end : static_cast( std::lower_bound(time.begin() + static_cast(i0), rangeEnd, s1) - time.begin()); double low = std::numeric_limits::infinity(); double high = -std::numeric_limits::infinity(); std::size_t lowOrder = 0; std::size_t highOrder = 0; bool broken = false; // (一)落在本列区间内的采样点。 // 水平镜像时取的是**源下标**(区间同样反转),因此先把列区间映射到源下标区间。 const std::size_t srcA = std::min(SourceIndexFor(*resolved.style, count, i0), SourceIndexFor(*resolved.style, count, (i1 > 0) ? (i1 - 1) : 0)); const std::size_t srcB = std::max(SourceIndexFor(*resolved.style, count, i0), SourceIndexFor(*resolved.style, count, (i1 > 0) ? (i1 - 1) : 0)); if (i1 > i0 && hasCoarse && (i1 - i0) >= 2 * coarseStride) { const std::size_t firstBucket = srcA / coarseStride; const std::size_t lastBucket = std::min(srcB / coarseStride, coarseMin.size() - 1); for (std::size_t bucket = firstBucket; bucket <= lastBucket; ++bucket) { const double bucketLow = coarseMin[bucket] * coarseScale + coarseOffset; const double bucketHigh = coarseMax[bucket] * coarseScale + coarseOffset; if (!std::isfinite(bucketLow)) { // 该分组全部是无效值:包络到此断开,后续另起一段。 broken = true; continue; } if (bucketLow < low) { low = bucketLow; lowOrder = bucket; } if (bucketHigh > high) { high = bucketHigh; highOrder = bucket; } } } else if (i1 > i0) { for (std::size_t index = srcA; index <= srcB; ++index) { const double value = ApplyDrawTransform(*resolved.style, values[index]); if (!std::isfinite(value)) { // 该列含无效值:包络绘制到本列结束,后续另起一段。 broken = true; continue; } if (value < low) { low = value; lowOrder = index; } if (value > high) { high = value; highOrder = index; } } } // (二)跨越列边界的两段折线在边界处的取值。 // // 波形按相邻采样点之间的**直线**绘制,而包络是按像素列聚合的。采样间隔 // 可能大于一个像素列(本仓库的 Problem_Data.csv 相邻采样间隔约 27µs 且不固定, // 放大到 10ms/大格时一个像素列约 60µs),此时一次跃变的两个端点会分别落在 // 相邻两列里:对每一列来说"落在本列内的采样点"都在同一边,于是这段竖向折线 // 在两侧的包络里都不出现 → 波形在这些跃变处被整段丢掉,看上去就是"断开"。 // // 把列边界处的折线取值计入本列包络即可消除该现象:折线在列内是线性的, // 其极值必然出现在采样点或列边界上,因此「列内采样点 + 两侧边界取值」就是 // 该列包络的精确解,且相邻两列在边界处取到同一个值,包络带严丝合缝。 double boundary = 0.0; if (PolylineValueAt(time, values, count, *resolved.style, i0, s0, boundary)) { boundary = ApplyDrawTransform(*resolved.style, boundary); if (boundary < low) { low = boundary; lowOrder = (i0 > 0) ? SourceIndexFor(*resolved.style, count, i0 - 1) : 0; } if (boundary > high) { high = boundary; highOrder = (i0 > 0) ? SourceIndexFor(*resolved.style, count, i0 - 1) : 0; } } if (PolylineValueAt(time, values, count, *resolved.style, i1, s1, boundary)) { boundary = ApplyDrawTransform(*resolved.style, boundary); const std::size_t order = SourceIndexFor(*resolved.style, count, (i1 > 0) ? (i1 - 1) : 0); if (boundary < low) { low = boundary; lowOrder = order; } if (boundary > high) { high = boundary; highOrder = order; } } if (!(low <= high)) { // 本列没有任何有效取值:折线尚未开始、已经结束,或该处数据缺失。 inSegment = false; flushBandSegment(); continue; } const float x = plotLeft + (static_cast(column) + 0.5f) * columnWidth; const float yTop = static_cast(ClampDouble(ValueToY(high, lane), limitTop, limitBottom)); const float yBottom = static_cast(ClampDouble(ValueToY(low, lane), limitTop, limitBottom)); // 包络带每列都记录(上下各外扩半个线宽,保证细波动也有可见粗细)。 BandColumn bandColumn; bandColumn.x = x; bandColumn.top = yTop - strokeHalf; bandColumn.bottom = yBottom + strokeHalf; segmentColumns.push_back(bandColumn); if (yBottom - yTop < kEnvelopeMinPixelSpan) { // 波动不足半个像素:只留一个顶点,避免无意义的重复顶点。 if (!inSegment) { breaks.push_back(points.size()); inSegment = true; } points.push_back(gd::PointF(x, yTop)); } else { if (!inSegment) { breaks.push_back(points.size()); inSegment = true; } // 按采样点出现的先后顺序连接上下包络,保持走线方向一致。 if (lowOrder <= highOrder) { points.push_back(gd::PointF(x, yBottom)); points.push_back(gd::PointF(x, yTop)); } else { points.push_back(gd::PointF(x, yTop)); points.push_back(gd::PointF(x, yBottom)); } } if (broken) { inSegment = false; flushBandSegment(); } } flushBandSegment(); return; } std::size_t stride = 1; if (visibleCount > budget) { stride = (visibleCount + budget - 1) / budget; } bool inSegment = false; for (std::size_t index = begin; index < end; index += stride) { const double t = time[index]; // 水平镜像 = 数据反序:画面上第 index 个位置取源下标 count-1-index 的值。 const double value = ApplyDrawTransform( *resolved.style, values[SourceIndexFor(*resolved.style, count, index)]); if (!std::isfinite(t) || !std::isfinite(value)) { inSegment = false; continue; } // 时间偏移:该采样画在 `t + offsetTime` 处(例如 t=1s、偏移 -0.5s → 画在 0.5s)。 const float x = TimeToX(t + timeShift); float y = ValueToY(value, lane); y = static_cast(ClampDouble(y, limitTop, limitBottom)); if (!inSegment) { breaks.push_back(points.size()); points.push_back(gd::PointF(x, y)); inSegment = true; } else { points.push_back(gd::PointF(x, y)); } } } void PlotView::PaintEmptyState(gd::Graphics& g) { std::wstring message; if (m_dataSet == nullptr || m_dataSet->Empty()) { message = L"打开数据文件以查看波形"; } else { message = L"请在左侧勾选需要查看的信号(可同时加载多个文件,各自成为一个分组)"; } UiText(g, message, m_fonts->ui, m_area, m_theme->textMuted, TextAlign::CENTER); } void PlotView::Paint(gd::Graphics& g) { if (m_theme == nullptr || m_fonts == nullptr) { return; } // 本帧整帧都在改写渲染状态(`m_area` / `m_plotRect` / 视界 / 图层…), // 因此必须与「后台派生曲线计算」线程隔离:本锁同时保护渲染状态与派生缓存。 // ⚠️ 图层渲染**只在本线程内进行**(见头文件说明):后台线程一旦改写 // `m_area` / `m_plotRect` / `Tab().ViewMin()`,就会覆盖用户正在进行的平移。 std::lock_guard renderLock(m_renderMutex); BuildLaneVisuals(); // 「回到最上方」浮动按钮:目标可见性由当前滚动位置决定(过渡由宿主定时器推进)。 // 在绘制前刷新一次,保证本帧就画出正确的过渡形态。 UpdateBackToTopTarget(); // 「边沿计数」的扫描预算**按帧重置**(见 `m_edgeScanFrame` 的说明): // 旧实现按 `GetTickCount64()` 分桶,帧率一高(`PostMessage` 自持重绘会把它拉高) // 就会在同一系统 tick 内连画多帧而**完全不推进**统计 —— 用户报告 // “按住横向光标不动时统计卡住不更新”“回车确认参数后不移动鼠标就停在部分值”。 ++m_edgeScanFrame; m_edgeScanBudget = kEdgeScanPairsPerFrame; // 双缓冲:清掉上一帧钉住的曲线条目(本帧会按需重新钉住),并清理已被更新条目覆盖 / // 超出内存预算的旧条目。此刻没有任何条目被钉住,回收是安全的。 m_curvePins.clear(); { std::lock_guard lock(m_curveMutex); PruneCurveEntriesLocked(); } // 后台算好了新曲线: // * **静止**时:整幅作废一次,让新曲线(与新的静态场景缓存)立刻出现。 // * **拖拽中**(拖弱峰调整/均值滤波参数滑块):**不作废整幅** —— 拖拽时参数每帧都在变, // 本来就每帧走"样式补丁"路径,补丁会直接取到最新曲线;若此时还整幅作废, // 每帧会退化成整幅重绘(实测单帧 150ms+,与"拖动要跟手"直接冲突)。 // 改为标记"已降级",等拖拽结束后再补一帧全质量。 if (m_curveSceneDirty.exchange(false, std::memory_order_relaxed)) { if (m_dragMode == PlotDragMode::NONE) { InvalidateSceneCache(); } else { m_sceneDegraded = true; } } if (m_lanes.empty()) { InvalidateSceneCache(); PaintEmptyState(g); // ⚠️ 空状态(尚未勾选任何信号)也必须绘制提示浮层:加载失败 / 解析异常正是 // **最需要**非模态提示的场景,若在此提前返回,宿主的错误信息将永远不可见。 PaintHint(g); return; } // 「快速绘制(关闭抗锯齿)」只在**会改变画面主体(波形/网格几何)**的拖拽期间启用: // 这些拖拽每帧都要重画波形,关掉抗锯齿能显著加速(同一条包络带 27.6ms → 3.2ms)。 // ⚠️ 像「拖时间光标」「选区框选」「拖浮动面板」这类**只改叠加层、画面主体不变**的交互, // 一旦启用快速帧就会给静态场景缓存打上「已降级」标记 → 缓存失效 → 每帧整幅重画 //(实测拖光标从约 1ms 骤升到十几毫秒)。因此这里**不**把它们算作快速帧, // 让它们直接复用静态场景缓存(贴图 ~1ms)+ 叠加层,做到真正跟手。 const bool sceneChangingDrag = (m_dragMode == PlotDragMode::PAN) || (m_dragMode == PlotDragMode::SCROLL_LANES) || (m_dragMode == PlotDragMode::LANE_REORDER) || (m_dragMode == PlotDragMode::MOVE_SIGNAL); m_fastFrame = sceneChangingDrag; // 贴图之后、静态场景之外的全部内容(随鼠标变化或位于最上层)。 // 三个分支(完全命中 / 局部重画 / 整幅重绘)共用同一段,保证观感完全一致。 const auto paintOverlays = [this, &g]() { PaintHoverHighlight(g); PaintLegendHighlight(g); PaintRubberBand(g); PaintCursor(g, m_lanes); PaintEdgeCursor(g, m_lanes); PaintSignalInteraction(g); PaintHint(g); // 网格 Y 轴上下限的输入框、以及「弱峰调整 → 迭代次数上限」的输入框都绘制在最上层 //(后者覆盖在浮动面板的滑块行之上)。 if (m_editTarget == PlotEditTarget::LANE_Y_MAX || m_editTarget == PlotEditTarget::LANE_Y_MIN || m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX) { PaintEditField(g); } PaintScrollBar(g); // 右下角「回到最上方」浮动按钮:在滚动条之上绘制(随 `m_backToTopAlpha` 淡入淡出)。 PaintBackToTop(g); // 绘图区外框(比网格内部分界线更重,形成明确整体边界)。 // 用 GDI 画 4 条线:GDI+ 的 DrawRectangle 带抗锯齿时单帧要花掉 1ms 以上。 { const float left = m_plotRect.X; const float top = m_plotRect.Y; const float right = m_plotRect.X + m_plotRect.Width; const float bottom = m_plotRect.Y + m_plotRect.Height; const float border[16] = {left, top, right, top, right, top, right, bottom, right, bottom, left, bottom, left, bottom, left, top}; UiDrawSolidLines(g, gd::RectF(left - 2.0f, top - 2.0f, m_plotRect.Width + 4.0f, m_plotRect.Height + 4.0f), border, 4, m_theme->axisLine | 0xFF000000u, 2); } }; // 网格次序拖拽:静态场景缓存仍然有效,但各网格要按位移重新"摆放"。 // 这里逐网格把缓存中的横向条带平移贴回(几十微秒), // 因此拖动百万级采样的网格时依然流畅,且被拖网格的波形随鼠标自由移动。 if (m_dragMode == PlotDragMode::LANE_REORDER && m_laneDragFrom >= 0 && m_sceneValid) { PaintLaneReorder(g); paintOverlays(); return; } // 静态场景复用(不再局限于拖拽):只要视图几何、信号集合与显示样式都没变, // "绘图区底色 + 全部网格 + 时间轴"就逐像素相同,直接贴图即可。 // 大文件下整幅重绘约 80ms,贴图约 1ms —— 鼠标悬停、拖拽选区这些 // "只改一小块叠加层"的交互因此不再重绘波形。 // 随鼠标变化的内容(悬停高亮、读数框、选区、光标、拖拽虚影、输入框)一律画在贴图之后。 if (SceneCacheMatches() && BlitSceneCache(g)) { // ⚠️ **顺手做一次"空闲预热"**:图层不存在(刚加载 / 网格数量变化)或仍是旧跨度时, // 在这里用既有预算机制**每帧至多补 1 个网格**。若不预热,用户第一次平移 / 选区放大 // 时,`Paint` 会在那一帧里**同步重建全部网格图层**(5 个网格实测 30~80ms)—— // 这正是"选区放大的第一帧特别卡"的根因(该帧其余工作只有约 16ms 的占位拉伸)。 // 命中静态场景缓存 ⇒ 本帧视图与缓存同源(视界没变),因此预热出来的图层不会白费。 WarmLaneLayersStep(); paintOverlays(); return; } // 视界平移 / 派生预览曲线参数变化:画面**基底**(网格/波形)不变,只是横向平移或 // 叠加的派生曲线变化。直接复用「宽缓冲区」已渲染的**基底**像素,只把派生曲线作为 // 叠加层重画 —— 前台 O(1),不重画波形,因此无论放大到多深都保持跟手。 // 判定:网格结构未变(几何指纹一致)且**逐信号外观样式未变** → 属视图/派生变化,可用宽基底; // 外观样式(颜色/粗细/透明度/模式)变化 → 交给下方"样式补丁"分支局部重画。 // ⚠️ 位置必须在"静态场景贴图"**之后**:视界与样式都没变时(典型:鼠标悬停移动) // 应由静态场景贴图命中,绝不能落到这里 —— 否则每两帧就会触发一次"结算整幅重绘", // 悬停反而变卡。而它又必须在"样式补丁"**之前**:派生曲线参数变化(拖弱峰调整 / // 均值滤波滑块)同样只需叠加重画派生曲线,走本分支比"逐网格补丁"更快且质量更高。 // ⚠️ **不要求"正在拖拽"**:非拖拽的视界变化(键盘方向键 / 工具栏)同样走此快路径, // 避免该放大区间"整幅重绘约 124ms"的卡顿。 bool wideUsable = false; if (HasDrawableContent() && m_sceneValid && m_sceneGeometryHash == SceneGeometryHash()) { std::vector> currentStyles; CollectSceneStyles(currentStyles); wideUsable = (currentStyles == m_sceneStyles); } if (wideUsable) { // 内容(含视界)与本帧上一条快路径帧相同 → 视界已停止变化 → 走一次整幅"结算" // 全质量帧(保证最终呈现**像素精确**;此后命中静态场景缓存,不再回到本分支)。 std::uint64_t wideKey = SceneContentHash(true); wideKey ^= static_cast(std::llround(Tab().ViewMin() * 1.0e6)); wideKey *= 1099511628211ull; wideKey ^= static_cast(std::llround(Tab().ViewMax() * 1.0e6)); // 「结算」:视界已停止变化(内容与视界都与上一条快路径帧相同)、**不在拖拽中**、 // 且**全部网格图层都已就绪** → 落到下方整幅全质量重绘,保证最终呈现像素精确 // (此后命中静态场景缓存,不再回到本分支)。 // // ⚠️ 三个条件缺一不可: // * **不在拖拽中**:拖拽期间不做结算,否则每两帧就会穿插一次整幅重绘 —— // 而拖拽帧的 `m_fastFrame` 为真(关抗锯齿),于是出现"波形质量轮流变差/抖动" // 与周期性卡顿(这正是用户报告的"按住拖拽时显示效果下降、断线"的根因)。 // * **全部图层就绪**:还有网格没画好时不能结算,否则结算帧之后的格子又是旧的。 // * 记录已消费本 key,避免下一帧重复结算。 const bool viewStopped = (m_wideLastValid && m_wideLastKey == wideKey); // ⚠️ 结算只用**可视网格**的就绪状态:上百个网格时若要求全部离屏图层都就绪, // 队列按下标顺序推进会让“结算”拖到上百帧之后(期间既没有静态场景缓存可用、 // 又每帧都在做无谓的图层重建),而离屏图层的就绪与否与画面无关。 if (viewStopped && m_dragMode == PlotDragMode::NONE && LanesVisiblyReadyForView()) { m_wideLastValid = false; // 结算:落到下方整幅重绘(非拖拽 → 全质量) } else { // ⚠️ **绝不在这里同步整批重建全部图层**:跨度变化(选区放大 / 键盘缩放 / // 工具栏缩放)时若一次性重建 5 个网格,实测 70~80ms —— 这正是"缩放首帧卡顿" // 的根因。改为: // * 只有"图层数量与网格数量不符"(首次绘制 / 增删网格)才必须同步整批建立; // * 跨度/内容过期的网格一律登记到待重画队列,由 `RollWideBuffer` 登记、 // `RenderPendingLaneRebuilds` 按预算逐帧补齐;其间 `BlitWideBuffer` // 会用**旧跨度像素拉伸**当占位图(低分辨率但立即可用,O(1))。 if (!LayersAllocated()) { RebuildWideBuffer(); } RollWideBuffer(); // 预算内补画"待重画"的网格:**本帧最多一个**,其余留到后续帧 //(即使 5 个网格同时需要重画,前台也不会在某一帧里做完整批而卡顿)。 // ⚠️ **必须在贴图之前**:否则本帧画好的网格要到下一帧才显示, // 拖拽时表现为"波形比鼠标慢半拍"、快速拖动时出现"断线"(图层覆盖不到视界)。 // ⚠️ **只在视界稳定后才花预算做精细重绘**:视界仍在变化的每一帧只贴现有像素, // 把重建推迟到视界停下来之后(用户要求"先画了再说,别卡住前台")。 // ⚠️ 并行重建 5 个网格实测只有 1.03x(GDI+ 光栅化被串行化)⇒ 保持"每帧补一个"。 // ⚠️⚠️ **网格滚动过渡动画期间一律【不重建任何图层】**(用户 2026-10-17 明确要求): // 滚动动画只移动视界,**与"网格内的波形有没有画好"无关**。旧实现在视界几乎停止 // 的那几帧(动画尾部里每帧只移动几个像素)放行 `RenderPendingLaneRebuilds(1)`, // 于是恰好把**正在滚入视野的巨型信号网格**的图层重建排进了动画帧 —— 实测动画尾部 // 单帧 20ms → **34ms**(`VERY_BIG_DATA` 25M 采样的图层渲染本身就是十几毫秒量级)。 // 正确行为:动画期间未就绪的网格**只画网格底衬**(廉价、已在 `BlitWideBuffer` 里兜底), // 波形等动画结束、视界真正停下之后再按预算逐个补。 const bool laneScrollAnimating = LaneScrollAnimationActive(); if (viewStopped && !laneScrollAnimating) { RenderPendingLaneRebuilds(1); } // 同理:动画期间不要再额外请求帧(定时器本来就在按 15.5ms 驱动重绘), // 否则会与定时器帧叠加、把预算挤爆。 const bool morePending = !laneScrollAnimating && (AnyVisibleLanePending() || (!m_layerRebuildPending.empty() && PrefetchStepAllowed())); // 绘图区底色(覆盖时间轴与侧边留白区域)。 UiFillRect(g, m_area, m_theme->plotBg); // 时间轴(宽缓冲不含它,前台按当前视图绘制)—— 必须在贴图**之前**算好: // `BlitWideBuffer` 在图层像素缺失时要就地补画网格底衬,而底衬的竖向网格线 // 与时间轴共用同一套主刻度。 const double wideViewMin = Tab().ViewMin(); const double wideViewMax = Tab().ViewMax(); const double wideSpanNow = wideViewMax - wideViewMin; const double wideTimeStep = NiceStep((wideSpanNow > 0.0) ? wideSpanNow : 1.0, kTimeTickTarget); std::vector wideTimeTicks; BuildTicks(wideViewMin, wideViewMax, wideTimeStep, wideTimeTicks); // 贴基底(绘图区:网格 + 波形)。拿不到图层像素的网格由底衬兜底,永不空白。 if (BlitWideBuffer(g, wideTimeTicks)) { m_wideLastKey = wideKey; m_wideLastValid = true; PaintTimeAxis(g, wideTimeTicks, wideTimeStep); // 网格分界线:图层里**不含**它(它锚定在网格的屏幕位置,会被纵向偏移贴图带走), // 因此在这里补画一次(与整幅重绘同一套颜色与线宽)。 PaintLaneDividers(g); // 派生预览曲线:基底不含它们(拖参数滑块时基底不变、可直接复用像素)。 for (std::size_t i = 0; i < m_lanes.size(); ++i) { PaintLaneDerivedOverlays(g, static_cast(i), m_lanes[i]); } // 装饰(Y 轴刻度 + GX 编号 + 图例):基底不含(否则平移会重影)。 // 内容指纹一致 → 直接按 `AlphaBlend` 贴回缓存(省掉文字绘制); // 否则先把装饰单独绘制进透明缓存位图,再贴回。 // ⚠️ 贴回的是**带 alpha 的圆角图形**:圆角之外是全透明, // 因此透出的是**当前**波形 —— 既没有"直角白边",也不会残留旧波形像素。 m_useDecorCache = m_decorValid && (m_decorKey == SceneDecorHash()); if (!m_useDecorCache && RenderDecorCache()) { m_decorKey = SceneDecorHash(); m_useDecorCache = true; } if (m_useDecorCache) { BlitDecorCache(g); } else { // 退化路径(无法建立透明缓存,例如系统不提供 AlphaBlend): // 直接把装饰画进后备缓冲区(就地绘制,不做缓存)。 m_renderDecorOnly = true; for (std::size_t i = 0; i < m_lanes.size(); ++i) { PaintLane(g, static_cast(i), m_lanes[i], wideTimeTicks); } m_renderDecorOnly = false; } paintOverlays(); if (morePending) { // 请宿主再送一帧(与派生曲线就绪用同一条消息:只失效绘图区且不置脏)。 const HWND host = m_host; if (host != nullptr) { PostMessageW(host, kMessagePlotDerivedReady, 0, 0); } } return; } } } // 只有"外观样式"变化(典型场景:拖动自定义颜色滑块实时调色):网格结构、视图几何、 // 波形采样都没变,只是颜色/不透明度/线宽/模式不同 —— 此时只重画受影响的网格, // 再写回缓存。(VERY_BIG_DATA 实测:整幅重绘 30ms+,单网格重画约 4ms。) { std::vector patchLanes; if (FindStyleOnlyLanes(patchLanes) && BlitSceneCache(g)) { // ⚠️ 样式**正在连续变化**(拖透明度 / 粗细 / 拾色滑块):这本身就是一种"交互中"。 // 若不切换快速帧,本帧会用**抗锯齿**重画波形包络带 —— 实测单条网格的包络带 // AA 填充 ≈29ms(本机负载下 72ms),而关掉 AA 只要 ≈5.6ms。 // 因此补丁阶段强制快速帧,并在补丁后把场景缓存标记为"已降级", // 下一帧(参数稳定后)自动补一帧全质量 —— 既保证拖动手感,又不牺牲最终画质。 m_fastFrame = true; RepaintSceneCachePatch(g, patchLanes); m_sceneDegraded = true; // ⚠️ **同步重建受影响的宽基底图层**:补丁只更新了“静态场景缓存 + 后备缓冲区”, // 而每网格图层(宽基底)里仍是**旧样式**的像素。若不在这里重建,紧接着命中 // “宽基底快路径”的那一帧会把刚画好的新样式**覆盖回旧像素** —— // 用户报告:拖动线宽滑块期间信号一直按旧的线宽绘制、松手后刷新, // 再移动鼠标又变回旧线宽(正是这个覆盖造成的)。 // 按既有预算机制重建(每帧至多 1 个网格,不会在交互帧里做整批重建)。 for (const int laneIndex : patchLanes) { QueueLaneRebuild(laneIndex); } RenderPendingLaneRebuilds(1); paintOverlays(); return; } } 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); // 绘图区整体底色。 UiFillRect(g, m_area, m_theme->plotBg); // 波形区域底色。 UiFillRect(g, m_plotRect, m_theme->cardBg); // 图例命中矩形每帧重建,避免残留已消失的信号。 m_legendHits.clear(); // 「装饰层」:装饰**不在网格循环里就地绘制**,而是统一由带 alpha 的装饰层贴回 // (这样圆角之外透出的是当前波形,不会出现"直角白边")。 m_decorRects.clear(); m_useDecorCache = true; for (std::size_t index = 0; index < m_lanes.size(); ++index) { PaintLane(g, static_cast(index), m_lanes[index], timeTicks); } PaintTimeAxis(g, timeTicks, timeStep); // 装饰层:缓存与当前内容同源则直接贴回;否则先把装饰绘制进透明缓存位图再贴回。 if (!m_decorValid || m_decorKey != SceneDecorHash()) { if (RenderDecorCache()) { m_decorKey = SceneDecorHash(); } } BlitDecorCache(g); // 此刻画布上正好是"绘图区底色 + 全部网格 + 时间轴",尚未绘制任何随鼠标变化的内容, // 因此是捕获静态场景缓存的最佳时点(必须在裁剪区已全部恢复之后执行)。 // ⚠️ 快速帧(交互期间,关闭了抗锯齿)**仍然捕获**(这样拖动参数滑块时能走 // "只重画受影响网格"的快路径),但打上 `m_sceneDegraded` 标记 → // `SceneCacheMatches()` 会拒绝命中,交互结束后的下一帧必定重画全质量版本。 CaptureSceneCache(g); m_sceneDegraded = m_fastFrame; 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, // 且观感与原实现(沿折线描粗)一致:整条曲线一起"发光"。 // ⚠️ 光晕**必须覆盖整条曲线**:曾经只画鼠标附近 ±72px 的一段,在满幅方波上 // 看起来就是一个**长方形色块**(像选区框),用户 2026-10-18 第二轮明确报告。 // 代价问题改由 `PaintSignalHaloCached`(渲染一次 + 每帧贴图)解决。 // ------------------------------------------------------------------ if (resolved.style->mode != DrawMode::SCATTER && cached.bandMode && !cached.bandBreaks.empty()) { const int savedSmoothing = g.GetSmoothingMode(); g.SetSmoothingMode(gd::SmoothingModeNone); // 半透明填充:抗锯齿没有观感收益 gd::GraphicsPath bandPath; bandPath.SetFillMode(gd::FillModeWinding); 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` 个)」。 const std::size_t half = total / 2; bandPath.StartFigure(); bandPath.AddLines(cached.band.data() + start, static_cast(half)); bandPath.AddLines(cached.band.data() + start + half, static_cast(half)); bandPath.CloseFigure(); } if (bandPath.GetPointCount() > 0) { gd::SolidBrush brush(ToGdiColor(WithAlpha(m_theme->accent, 0x55))); 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。 // 用 256×256 查表代替逐像素除法(1.6M 像素时可省 ~5ms)。 static std::uint8_t premultiply[256 * 256]; static bool premultiplyReady = false; if (!premultiplyReady) { for (int alpha = 0; alpha < 256; ++alpha) { for (int value = 0; value < 256; ++value) { premultiply[(alpha << 8) | value] = static_cast((value * alpha + 127) / 255); } } premultiplyReady = true; } 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 int alpha = source[x * 4 + 3]; const int table = alpha << 8; row[x * 4 + 0] = premultiply[table | source[x * 4 + 0]]; row[x * 4 + 1] = premultiply[table | source[x * 4 + 1]]; row[x * 4 + 2] = premultiply[table | source[x * 4 + 2]]; 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) { if (m_tab == nullptr || m_dragMode != PlotDragMode::NONE) { // 拖拽过程中不显示悬停提示(拖拽中的信号光晕已绘入静态场景缓存)。 return; } const int saveState = g.Save(); g.SetClip(m_area, gd::CombineModeIntersect); // 悬停网格:一层很淡的强调色叠加。为了能画在静态场景之上, // 这里用半透明而非不透明混色,观感与原实现基本一致。 // ⚠️ 高亮来源见 `EffectiveHoverLane()`(浮动面板悬停 / 信号列表悬停), // **不再**由"鼠标在网格里"触发。 const int hoverLane = EffectiveHoverLane(); if (hoverLane >= 0 && hoverLane < static_cast(m_lanes.size())) { const gd::RectF& laneRect = m_lanes[static_cast(hoverLane)].rect; const float clipTop = std::max(laneRect.Y, m_plotRect.Y); const float clipBottom = std::min(laneRect.Y + laneRect.Height, m_plotRect.Y + m_plotRect.Height); if (clipBottom > clipTop) { // alpha 0x0A ≈ 4%,与原「不透明混色 3.5%」的观感基本一致。 UiFillRect(g, gd::RectF(laneRect.X, clipTop, laneRect.Width, clipBottom - clipTop), WithAlpha(m_theme->accent, 0x0A)); } } // 悬停信号:外扩一圈半透明强调色作为"可拖拽"提示。 // 鼠标在绘图区内直接命中时用 `m_hoverSignal`;信号列表 / 样式区悬停时用 // `m_externalHoverSignal`(二者高亮方式相同,用户要求列表悬停也要高亮曲线本身)。 SignalRef haloRef = m_hoverSignal; if (!haloRef.IsValid()) { haloRef = m_externalHoverSignal; } if (haloRef.IsValid()) { const ResolvedSignal resolved = ResolveSignal(haloRef); if (resolved.data != nullptr && resolved.style != nullptr && resolved.visible) { const int laneIndex = m_tab->LaneOfSignal(haloRef); if (laneIndex >= 0 && laneIndex < static_cast(m_lanes.size())) { const LaneVisual& lane = m_lanes[static_cast(laneIndex)]; const PolylineCacheEntry* cached = AcquirePolyline(haloRef, resolved, lane); if (cached != nullptr) { // 整条光晕一次性渲进离屏位图并缓存(命中时只贴图,~0.5ms/帧), // 因此光晕**沿整条曲线**发光而不随鼠标移动(见 `PaintSignalHaloCached`)。 PaintSignalHaloCached(g, resolved, lane, *cached); } } } } g.Restore(saveState); } void PlotView::PaintLegendHighlight(gd::Graphics& g) { if (m_tab == nullptr || m_lanes.empty()) { return; } 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; // 只重画**圆角形状本身**(不透明):圆角之外保持"贴图层"的像素, // 因此不会出现直角白边。 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); if (resolved.data != nullptr && resolved.style != nullptr && resolved.visible) { const LaneVisual& lane = m_lanes[static_cast(laneIndex)]; if (const PolylineCacheEntry* cached = AcquirePolyline(m_hoverLegend, resolved, lane)) { // 图例悬停:整条曲线一起发光(与鼠标命中的光晕完全同一种画法与代价)。 PaintSignalHaloCached(g, resolved, lane, *cached); } } // 条目悬停高亮:**圆角托底 + 与选中态相同的高亮**(用户明确要求"悬停效果与 GX 标签 // 相同、选中状态与悬停提示效果相同"),并在其上**原样重绘色块与文字** —— // 不透明托底若只画底色会把图例内容整块盖住(只剩一个空色块)。 for (const LegendHit& hit : m_legendHits) { if (hit.ref == m_hoverLegend) { const float radius = (hit.radius > 0.0f) ? hit.radius : (kLaneBadgeHeight * 0.5f * 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->uiTiny, hit.text, m_theme->textStrong, TextAlign::LEFT); break; } } 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) { differs = (now[index].color != before[index].color) || (now[index].opacity != before[index].opacity) || (now[index].lineWidth != before[index].lineWidth) || (now[index].mode != before[index].mode); } 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 { if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) { return gd::RectF(); } const LaneVisual& lane = m_lanes[static_cast(laneIndex)]; const gd::RectF legendBand(lane.rect.X + kLegendPad * m_scale, lane.rect.Y + kLegendPad * m_scale, lane.rect.Width, kLegendHeight * m_scale); // 网格被滚动出可视区时没有徽标可点。 const float visibleTop = std::max(lane.rect.Y, m_plotRect.Y); const float visibleBottom = std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height); if (!IsFullyVisible(legendBand, visibleTop, visibleBottom)) { return gd::RectF(); } const float height = kLegendHeight * m_scale; const float badgeHeight = kLaneBadgeHeight * m_scale; return gd::RectF(legendBand.X, legendBand.Y + (height - badgeHeight) * 0.5f, kLaneBadgeWidth * m_scale, badgeHeight); } void PlotView::PaintLaneReorder(gd::Graphics& g) { if (m_dragMode != PlotDragMode::LANE_REORDER || m_laneDragFrom < 0 || m_sceneDc == nullptr || m_lanes.empty()) { return; } const float laneHeight = m_lanes.front().rect.Height; if (!(laneHeight > 0.0f)) { return; } // 1) 先把整个绘图区(含时间轴)从静态场景缓存贴回:网格次序拖拽只改变网格内部的 // 条带位置,时间轴/边框等内容必须原样保留,否则鼠标拖到工具栏或时间轴上时 // 那一区域会残留上一帧的内容。 if (!BlitSceneCache(g)) { return; } // 2) 把"网格区域"整片铺成绘图区底色:让位留下的空档就是它的原色, // 网格线/波形随后按各自位移贴回。 UiFillRect(g, m_plotRect, m_theme->cardBg); HDC target = g.GetHDC(); if (target == nullptr) { return; } // 逐网格把缓存中该网格的横向条带按位移贴到屏幕上(非常廉价,整幅波形不必重绘)。 // 被拖拽的网格最后贴,因此始终压在最上层,形成"被拎起来"的观感。 std::vector laneOrder; for (int index = 0; index < static_cast(m_lanes.size()); ++index) { if (index != m_laneDragFrom) { laneOrder.push_back(index); } } if (m_laneDragFrom < static_cast(m_lanes.size())) { laneOrder.push_back(m_laneDragFrom); } for (const int index : laneOrder) { const gd::RectF& rect = m_lanes[static_cast(index)].rect; const float offset = LaneDragOffset(index); // 源:缓存中该网格的条带;目标:屏幕上的同一条带平移 offset。 const float sourceTop = rect.Y; const float destTop = rect.Y + offset; // 与绘图区求交(并夹进缓存区域),避免把波形贴到工具栏或时间轴上。 const float clipTop = std::max(std::max(destTop, m_plotRect.Y), m_plotRect.Y); const float clipBottom = std::min(std::min(destTop + rect.Height, m_plotRect.Y + m_plotRect.Height), m_plotRect.Y + m_plotRect.Height); if (clipBottom - clipTop < 1.0f) { continue; } const int x = static_cast(std::floor(rect.X)); const int width = static_cast(std::ceil(rect.X + rect.Width)) - x; const int y0 = static_cast(std::ceil(clipTop)); const int y1 = static_cast(std::floor(clipBottom)); if (y1 <= y0 || width <= 0) { continue; } const int sourceY = static_cast(std::lround(sourceTop + (static_cast(y0) - destTop))); BitBlt(target, x, y0, width, y1 - y0, m_sceneDc, x - m_sceneX, sourceY - m_sceneY, SRCCOPY); } g.ReleaseHDC(target); // 3) 目标位置的高亮提示与被拖网格的轮廓都**必须夹在网格区域内**绘制: // 鼠标拖到工具栏/时间轴上时,若不裁剪,强调色边框会画到那些区域上。 { const int clipState = g.Save(); g.SetClip(m_plotRect, gd::CombineModeIntersect); // 目标位置的高亮提示(吸附提示):在被拖网格将要落到的槽位上画一层强调色底 + 边框。 const int dropSlot = (m_laneDragTarget >= 0) ? m_laneDragTarget : m_laneDragFrom; float slotTop = m_lanes.front().rect.Y + static_cast(dropSlot) * laneHeight; if (dropSlot > m_laneDragFrom) { slotTop -= laneHeight; } const gd::RectF slot(m_plotRect.X, slotTop, m_plotRect.Width, laneHeight); if (slot.Height > 0.0f) { UiFillRect(g, slot, WithAlpha(m_theme->accent, 0x1E)); UiDrawRoundRect(g, gd::RectF(slot.X + 1.0f * m_scale, slot.Y + 1.0f * m_scale, slot.Width - 2.0f * m_scale, slot.Height - 2.0f * m_scale), 3.0f * m_scale, m_theme->accent, 1.6f * m_scale); } // 被拖网格的轮廓:加强"整块网格被拎起"的观感。 const gd::RectF& dragged = m_lanes[static_cast(m_laneDragFrom)].rect; const float destTop = dragged.Y + LaneDragOffset(m_laneDragFrom); const gd::RectF outline(dragged.X + 1.0f * m_scale, destTop + 1.0f * m_scale, dragged.Width - 2.0f * m_scale, dragged.Height - 2.0f * m_scale); UiFillRoundRect(g, gd::RectF(outline.X + 2.0f * m_scale, outline.Y + 3.0f * m_scale, outline.Width, outline.Height), 3.0f * m_scale, 0x22000000u); UiDrawRoundRect(g, outline, 3.0f * m_scale, m_theme->accent, 1.8f * m_scale); g.Restore(clipState); } } 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 { // 单个网格图层的内容 = 位图高度(= 网格高度)+ 幅值**跨度** + 本网格各信号的 // 可见性与外观 + 图例文案(图例不在图层里,但一起计入无妨且更安全)。 // // ⚠️ **只计跨度、不计幅值的绝对范围**:图层的纵向锚点(`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 为单个网格绘制网格底衬(底色 + 横向/竖向网格线 + 网格分界线)。 * * 复用 `PaintLane` 的网格部分(通过 `m_renderGridOnly` 跳过波形 / 散点 / 派生曲线 / * Y 轴刻度 / 图例),因此底衬与整幅重绘**逐像素同源** —— 兜底帧与就绪帧之间不会出现 * 网格线跳变。它是"网格底衬永不空白"的唯一执行点(见 `BlitWideBuffer`)。 */ bool PlotView::PaintLaneGridBacking(gd::Graphics& g, int laneIndex, const std::vector& timeTicks) { if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size()) || m_theme == nullptr || m_fonts == nullptr) { return false; } const bool savedGridOnly = m_renderGridOnly; m_renderGridOnly = true; PaintLane(g, laneIndex, m_lanes[static_cast(laneIndex)], timeTicks); m_renderGridOnly = savedGridOnly; return true; } void PlotView::PaintRubberBand(gd::Graphics& g) { if (!m_dragMoved || m_rubberRect.Width <= 0.0f || m_rubberRect.Height <= 0.0f) { return; } if (m_dragMode != PlotDragMode::RUBBER_SELECT) { return; } // 轴向未定时不绘制选区,避免刚按下就闪出一个奇怪的方框。 const bool timeMode = (m_rubberAxis == PlotRubberAxis::TIME); const bool valueMode = (m_rubberAxis == PlotRubberAxis::VALUE); if (!timeMode && !valueMode) { return; } // 选区只应出现在绘图区内:即使内部矩形意外越界(例如鼠标被捕获到工具栏 / // 信号面板上),也在这里再夹一次,绝不把选区块画到绘图区之外。 const float clipLeft = std::max(m_rubberRect.X, m_plotRect.X); const float clipTop = std::max(m_rubberRect.Y, m_plotRect.Y); const float clipRight = std::min(m_rubberRect.X + m_rubberRect.Width, m_plotRect.X + m_plotRect.Width); const float clipBottom = std::min(m_rubberRect.Y + m_rubberRect.Height, m_plotRect.Y + m_plotRect.Height); if (clipRight <= clipLeft || clipBottom <= clipTop) { return; } const gd::RectF bandRect(clipLeft, clipTop, clipRight - clipLeft, clipBottom - clipTop); // 两种方向的选区使用**同一种强调色**,保持视觉一致。 const std::uint32_t band = m_theme->accent; UiFillRect(g, bandRect, WithAlpha(band, 0x28)); UiDrawRoundRect(g, bandRect, 2.0f * m_scale, band, 1.4f); // 选区跨度提示(时间用秒/数值用幅度量纲),放在选区顶部。 std::wstring text; if (timeMode) { text = FormatValue(std::fabs(XToTime(m_rubberRect.X + m_rubberRect.Width) - XToTime(m_rubberRect.X))); } else if (m_dragLane >= 0 && m_dragLane < static_cast(m_lanes.size())) { const LaneVisual& lane = m_lanes[static_cast(m_dragLane)]; text = FormatValue(std::fabs(YToValue(m_rubberRect.Y + m_rubberRect.Height, lane) - YToValue(m_rubberRect.Y, lane))); } if (text.empty()) { return; } const float textWidth = UiMeasureTextWidth(m_fonts->monoSmall, text) + 12.0f * m_scale; const float textHeight = 18.0f * m_scale; float labelX = bandRect.X + bandRect.Width * 0.5f - textWidth * 0.5f; labelX = std::max(m_plotRect.X, std::min(labelX, m_plotRect.X + m_plotRect.Width - textWidth)); const gd::RectF labelRect(labelX, bandRect.Y + 3.0f * m_scale, textWidth, textHeight); UiFillRoundRect(g, labelRect, 3.0f * m_scale, band); UiText(g, text, m_fonts->monoSmall, labelRect, m_theme->textOnAccent, TextAlign::CENTER); } void PlotView::PaintHint(gd::Graphics& g) { if (m_hintText.empty() || m_fonts == nullptr || m_fonts->ui == nullptr || m_hintShownAt == 0) { return; } const std::uint64_t elapsed = GetTickCount64() - m_hintShownAt; // 粘滞状态下不淡出(用于"拖动过程中维持显示")。 if (!m_hintSticky && elapsed >= (kHintHoldMs + kHintFadeMs)) { return; } // 停留结束后在 kHintFadeMs 内线性淡出(颜色 alpha 按比例缩放)。 const double fade = (m_hintSticky || elapsed <= kHintHoldMs) ? 1.0 : (1.0 - static_cast(elapsed - kHintHoldMs) / static_cast(kHintFadeMs)); const unsigned int alpha = static_cast(std::max(0.0, std::min(1.0, fade)) * 255.0); // 提示条贴在绘图区底部居中;时间光标的时间读数也在底部, // 因此光标启用时把提示条上移一行,避免两者互相遮挡。 const float reserved = (m_tab != nullptr && Tab().CursorEnabled()) ? 26.0f * m_scale : 0.0f; const float width = UiMeasureTextWidth(m_fonts->ui, m_hintText) + 30.0f * m_scale; const float height = 30.0f * m_scale; float left = m_plotRect.X + (m_plotRect.Width - width) * 0.5f; left = std::max(m_plotRect.X + 2.0f * m_scale, std::min(left, m_plotRect.X + m_plotRect.Width - width - 2.0f * m_scale)); const gd::RectF pill(left, m_plotRect.Y + m_plotRect.Height - height - 4.0f * m_scale - reserved, width, height); UiFillRoundRect(g, pill, 4.0f * m_scale, ScaleAlpha(m_theme->readoutBg, alpha)); UiDrawRoundRect(g, pill, 4.0f * m_scale, ScaleAlpha(m_theme->borderStrong, alpha), 1.2f); // 文字走 GDI(不支持 alpha 通道),因此把文字颜色向胶囊底色**混色**来实现淡出。 const std::uint32_t textColor = MixColor(m_theme->textStrong, m_theme->readoutBg, 1.0f - static_cast(fade)); UiText(g, m_hintText, m_fonts->ui, pill, textColor, TextAlign::CENTER); } void PlotView::PaintLane(gd::Graphics& g, int laneIndex, const LaneVisual& lane, const std::vector& timeTicks) { // 网格内容必须裁剪在绘图区之内:纵向滚动时网格会部分移出可视范围, // 若不与 m_plotRect 求交,波形就会溢出到工具栏与时间轴区域。 const float clipTop = std::max(lane.rect.Y, m_plotRect.Y); const float clipBottom = std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height); if (clipBottom <= clipTop) { return; } const int saveState = g.Save(); // 与当前裁剪区求交(而不是替换):局部重画(RepaintSceneCachePatch)时 // 外层已经按“需要重画的网格范围”设过裁剪区,这里必须保留,否则会重画到 // 其他网格上。整幅重绘时外层没有裁剪区,等同于原来的行为。 // // ⚠️ **本次调用只允许改动 `m_area`** —— 它既是"整幅渲染"的绘图区,也是 // "信号图层"的**条带**(横向补条 = 窄 x 区间 × 全高;纵向补条 = 全宽 × 窄 y 区间)。 // GDI 绘制(网格线 / 分界线)**不受 GDI+ 裁剪约束**,所以它们的裁剪矩形必须 // **同时**与 `m_area` 求交:否则纵向补条时横向网格线会被画到条带之外的已正确像素上、 // 覆盖在波形之上("网格线盖住波形"),并**永久烘进图层**、随之后的每次平移一起移动 // —— 正是用户报告的"上下拖动之后,左右/上下/斜向拖动都出现绘制错误"。 const gd::RectF laneClip = IntersectRect( gd::RectF(m_area.X, m_area.Y, m_area.Width, m_area.Height), gd::RectF(lane.rect.X, clipTop, lane.rect.Width, clipBottom - clipTop)); g.SetClip(laneClip, gd::CombineModeIntersect); // 幅值网格线(先画线,标尺稍后作为覆盖层绘制,避免被波形遮挡)。 // 刻度密度随网格高度自适应:所有网格高度相同 → 各网格的刻度数量一致, // 不会出现“有的网格只有 1 条标签、有的有 3 条”的不一致观感。 // // ⚠️ 网格线一律用 **GDI** 批量绘制:GDI+ 的 DrawLine 每条约 0.1–0.19ms, // 而一张网格有几十条网格线(5 个网格就是数百条),逐条调用会让单帧多花十几毫秒 // —— 这正是"放大后拖动网格视界很卡"的主因。GDI 绘制不受 GDI+ 裁剪约束, // 因此显式传入裁剪矩形;半透明辅助线先与网格底色混合成不透明色。 const gd::RectF gridClip = laneClip; /** 标尺数值文本的裁剪矩形(GDI 文本需要显式裁剪,否则会越过绘图区)。 */ const gd::RectF clipRectForLabels = gridClip; const std::uint32_t minorLineColor = UiBlendOver(m_theme->cardBg, WithAlpha(m_theme->gridWeak, kMinorLineAlpha)); const std::uint32_t weakLineColor = m_theme->gridWeak | 0xFF000000u; const std::uint32_t strongLineColor = m_theme->gridStrong | 0xFF000000u; int yTickTarget = static_cast(std::lround(lane.rect.Height / (kYTickSpacing * m_scale))); yTickTarget = std::max(kYTickTargetMin, std::min(yTickTarget, kYTickTargetMax)); // ⚠️ 渲染「信号图层」时,幅值刻度必须以**前台可见网格**的跨度/高度为基准: // 图层画布比可见网格高(上下各留预渲染余量),若按画布高度与扩展后的跨度取刻度, // 图里的网格线密度就与整幅渲染不一致 —— 贴图后拖动时网格线会跳变。 // 用可见跨度取步长 + 按扩展范围铺满整张画布 ⇒ 刻度落在同一批绝对值格点上, // 于是"纵向平移 = 纯粹的像素平移"(逐像素同源)。 const double tickSpan = (m_renderWide && m_wideTickSpan > 0.0) ? m_wideTickSpan : (lane.yMax - lane.yMin); if (m_renderWide && m_wideTickHeight > 0.0f) { yTickTarget = static_cast( std::lround(m_wideTickHeight / (kYTickSpacing * m_scale))); yTickTarget = std::max(kYTickTargetMin, std::min(yTickTarget, kYTickTargetMax)); } const double yStep = NiceStep(tickSpan, yTickTarget); std::vector yTicks; BuildTicks(lane.yMin, lane.yMax, yStep, yTicks); // 五等分估读线:在相邻主刻度之间再细分,便于目视读取中间值。 const double yMinorStep = yStep / static_cast(kMinorDivisions); std::vector yMinorTicks; BuildTicks(lane.yMin, lane.yMax, yMinorStep, yMinorTicks); std::vector minorSegments; std::vector weakSegments; std::vector strongSegments; minorSegments.reserve(yMinorTicks.size() * 4); weakSegments.reserve(yTicks.size() * 4); for (const double tick : yMinorTicks) { // 与主刻度重合的位置跳过(主刻度会以更重的线条绘制)。 const double majorIndex = tick / yStep; if (std::fabs(majorIndex - std::floor(majorIndex + 0.5)) < 1.0e-6) { continue; } const float y = ValueToY(tick, lane); if (y < lane.rect.Y - 1.0f || y > lane.rect.Y + lane.rect.Height + 1.0f) { continue; } minorSegments.insert(minorSegments.end(), {lane.rect.X, y, lane.rect.X + lane.rect.Width, y}); } for (const double tick : yTicks) { const float y = ValueToY(tick, lane); if (y < lane.rect.Y - 1.0f || y > lane.rect.Y + lane.rect.Height + 1.0f) { continue; } // 零线用更重的颜色(单独一批,避免为每段颜色切换画笔)。 std::vector& target = (tick == 0.0) ? strongSegments : weakSegments; target.insert(target.end(), {lane.rect.X, y, lane.rect.X + lane.rect.Width, y}); } // 时间网格线(同样补充五等分估读线)。 // ⚠️ 主刻步长优先取 `m_renderTimeStep`(渲染信号图层时由前台跨度给出,见该成员的说明): // 图层渲染的 `Tab().ViewMin/ViewMax` 是**宽窗口**区间,直接拿它算 `NiceStep` 会得到比前台 // 更大的步长(往往正好 2 倍),使图层里的竖向网格线与前台错位、部分整条丢失。 const double timeSpan = Tab().ViewMax() - Tab().ViewMin(); const double timeMajorStep = (m_renderTimeStep > 0.0) ? m_renderTimeStep : NiceStep((timeSpan > 0.0) ? timeSpan : 1.0, kTimeTickTarget); const double timeMinorStep = timeMajorStep / static_cast(kMinorDivisions); std::vector timeMinorTicks; BuildTicks(Tab().ViewMin(), Tab().ViewMax(), timeMinorStep, timeMinorTicks); for (const double tick : timeMinorTicks) { const double majorIndex = tick / timeMajorStep; if (std::fabs(majorIndex - std::floor(majorIndex + 0.5)) < 1.0e-6) { continue; } const float x = TimeToX(tick); if (x < lane.rect.X || x > lane.rect.X + lane.rect.Width) { continue; } minorSegments.insert(minorSegments.end(), {x, lane.rect.Y, x, lane.rect.Y + lane.rect.Height}); } for (const double tick : timeTicks) { const float x = TimeToX(tick); if (x < lane.rect.X || x > lane.rect.X + lane.rect.Width) { continue; } weakSegments.insert(weakSegments.end(), {x, lane.rect.Y, x, lane.rect.Y + lane.rect.Height}); } if (!m_renderDecorOnly) { if (!minorSegments.empty()) { UiDrawSolidLines(g, gridClip, minorSegments.data(), minorSegments.size() / 4, minorLineColor); } if (!weakSegments.empty()) { UiDrawSolidLines(g, gridClip, weakSegments.data(), weakSegments.size() / 4, weakLineColor); } if (!strongSegments.empty()) { UiDrawSolidLines(g, gridClip, strongSegments.data(), strongSegments.size() / 4, strongLineColor); } } // 波形绘制。 const std::vector& laneSignals = m_tab->Lanes()[static_cast(laneIndex)].signals; if (m_renderDecorOnly) { // 只画装饰:网格线与波形已由宽基底提供,这里跳过。 } else if (m_renderGridOnly) { // 只画网格底衬(宽基底快路径的兜底帧):跳过波形、散点与派生预览曲线。 // 网格线在上方已经画好(`!m_renderDecorOnly` 分支)。 } else if (laneSignals.empty()) { const gd::RectF visibleRect(lane.rect.X, clipTop, lane.rect.Width, clipBottom - clipTop); UiText(g, L"(空网格)", m_fonts->uiSmall, visibleRect, m_theme->textMuted, TextAlign::CENTER); } else { const int waveSave = g.Save(); g.SetClip(lane.rect, gd::CombineModeIntersect); // 只有拖拽中的信号光晕可以进入静态场景缓存(悬停光晕见 PaintHoverHighlight)。 const SignalRef haloRef = SceneHaloRef(); // 派生预览曲线(中心轴 / 均值滤波 / 弱峰调整)在下方"第二遍"统一叠加绘制, // 见 `PaintLaneDerivedOverlays`(抽出以便宽缓冲区前台路径复用)。 for (const SignalRef& ref : laneSignals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible) { continue; } const DrawMode mode = resolved.style->mode; // 顶点由“几何键”缓存提供:同一视图下重复绘制不再重算全量采样。 const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, lane); if (cached == nullptr) { continue; } const std::vector& points = cached->points; const std::vector& breaks = cached->breaks; const bool drawLine = (mode != DrawMode::SCATTER); const bool drawScatter = (mode == DrawMode::SCATTER || mode == DrawMode::LINE_SCATTER); // 悬停命中的信号先画一层浅色光晕,作为“可拖拽”的视觉提示。 // 这里只处理拖拽中的信号(其光晕在拖拽期间不变,可以进入静态场景缓存); // 悬停光晕随鼠标变化,改由 PaintHoverHighlight 在缓存贴图之后绘制。 const bool highlighted = (ref == haloRef); if (highlighted) { // 拖拽光晕整条描边并进入静态场景缓存(拖拽期间不随鼠标变化,不必裁剪)。 PaintSignalHaloFor(g, resolved, lane, *cached); } if (drawLine && !breaks.empty()) { const std::uint32_t lineColor = StyleColor(*resolved.style); if (cached->bandMode && !cached->bandBreaks.empty()) { // 稠密包络:一次 FillPath 填充包络带。 // **必须显式设为缠绕填充规则**:包络带自交(相邻列上下包络交叉)时, // 交替(奇偶)规则会在交叠处挖出空洞。 // // 抗锯齿开关:几何保持不变,只在"交互期间"或"梳状带"时关闭。 // 梳状带(振荡周期≈像素列宽)下抗锯齿的扫描线代价暴涨而观感几乎无差别, // 实测同一条 2448 顶点的路径 27.6ms → 3.2ms。 const int savedSmoothing = g.GetSmoothingMode(); if (m_fastFrame || cached->bandSpiky) { g.SetSmoothingMode(gd::SmoothingModeNone); } gd::GraphicsPath bandPath; bandPath.SetFillMode(gd::FillModeWinding); 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; } const std::size_t half = total / 2; bandPath.StartFigure(); bandPath.AddLines(cached->band.data() + start, static_cast(half)); bandPath.AddLines(cached->band.data() + start + half, static_cast(half)); bandPath.CloseFigure(); } if (bandPath.GetPointCount() > 0) { gd::SolidBrush brush(ToGdiColor(lineColor)); g.FillPath(&brush, &bandPath); } g.SetSmoothingMode(static_cast(savedSmoothing)); } else { gd::Pen pen(ToGdiColor(lineColor), resolved.style->lineWidth * m_scale); pen.SetLineJoin(gd::LineJoinRound); pen.SetStartCap(gd::LineCapRound); pen.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(&pen, points.data() + start, static_cast(stop - start)); } } } if (drawScatter && !points.empty()) { // 散点半径随线宽线性增长(达到上限前不过早触顶),使“线宽”直观控制数据点大小。 const float radius = static_cast( ClampDouble(static_cast(resolved.style->lineWidth) * kScatterRadiusScale, kMinScatterRadius, kMaxScatterRadius) * m_scale); const std::size_t scatterStride = std::max(1, points.size() / kMaxScatterPoints); // 所有散点合并到一条 GraphicsPath 后只调用一次 FillPath: // 逐点 FillEllipse 每次都要完整走一遍 GDI+ 的状态检查与光栅化入口, // 实测 2500 点需要约 51ms;合并后约 1/9(实测基准 63ms → 7ms)。 // **必须显式设为缠绕填充规则**:GraphicsPath 默认是交替(奇偶)规则, // 两个重叠的圆在交集处会互相抵消形成空洞,表现为"数据点重叠处出现异常白边"。 gd::GraphicsPath scatterPath; scatterPath.SetFillMode(gd::FillModeWinding); for (std::size_t k = 0; k < points.size(); k += scatterStride) { scatterPath.AddEllipse(points[k].X - radius, points[k].Y - radius, radius * 2.0f, radius * 2.0f); } gd::SolidBrush brush(ToGdiColor(StyleColor(*resolved.style))); if (scatterPath.GetPointCount() > 0) { g.FillPath(&brush, &scatterPath); } } } // 第二遍:把所有**派生预览曲线**统一绘制在**本网格全部原信号之上**。 // 渲染「宽缓冲区」时跳过:宽缓冲只存"基底"(网格/波形,不含派生曲线与装饰), // 派生曲线改由前台叠加层绘制(见 `PaintLaneDerivedOverlays`), // 这样拖派生参数滑块时基底不变、可复用宽缓冲像素。 // ⚠️ `m_renderDecorOnly`(把装饰单独画进透明缓存位图)时**也必须跳过**: // 派生曲线是"随时间视界变化"的内容,若烘进装饰位图,贴回时会把旧视界的 // 预览曲线永久留在画面上。 if (!m_renderWide && !m_renderDecorOnly) { PaintLaneDerivedOverlays(g, laneIndex, lane); } // g.Restore 也会还原绘制状态,此处直接恢复图形状态即可。 g.Restore(waveSave); } // 网格分界线:网格之间不再留间隔,改用较重的横线明确分隔相邻网格。 // ⚠️ **渲染信号图层时跳过**:分界线锚定在网格的**屏幕位置**(`lane.rect.Y`), // 而图层会被纵向偏移贴图(幅值平移)—— 若烘进图层,纵向拖动时它会跟着波形一起跑。 // 因此它在整幅重绘里由这里绘制,宽基底快路径则由 `PaintLaneDividers` 补画一次。 const float dividerY = lane.rect.Y; if (!m_renderDecorOnly && !m_renderWide && dividerY >= m_plotRect.Y - 1.0f && dividerY <= m_plotRect.Y + m_plotRect.Height) { const float dividerSegments[4] = {lane.rect.X, dividerY, lane.rect.X + lane.rect.Width, dividerY}; const std::uint32_t dividerColor = ((laneIndex == 0) ? m_theme->axisLine : m_theme->borderStrong) | 0xFF000000u; UiDrawSolidLines(g, gridClip, dividerSegments, 1, dividerColor, (laneIndex == 0) ? 2 : static_cast(kLaneDividerWidth)); } // 内侧纵轴标尺作为覆盖层绘制:每个刻度数值带半透明底,保证在波形之上仍清晰可读, // 同时只在数值背后遮挡波形,不牺牲波形本身的可见范围。 // ⚠️ 渲染「宽缓冲区」时跳过 Y 轴刻度与图例:它们锚定在网格左缘、随视界横向移动, // 而宽缓冲区会被平移截取,装饰必须由前台叠加层(装饰层缓存)单独绘制。 // ⚠️ 「只画网格底衬」时同样跳过(底衬的定义就是不含装饰)。 if (!m_renderWide && !m_renderGridOnly) { const bool showLabels = (lane.rect.Height >= 34.0f * m_scale); float lastLabelY = -1.0e9f; // 网格较矮时同步收紧标签行距,让密度随可用高度提升而不是整片被过滤掉。 const float labelMinGap = std::min(kYLabelMinGap * m_scale, lane.rect.Height / static_cast(yTickTarget + 1)); // 先收集需要绘制的数值底板:合并成一次 FillPath(几十个小圆角矩形逐个填充 // 会带来明显的 GDI+ 调用开销),文字再逐个绘制(GDI 文本无法合批)。 std::vector pills; std::vector pillTexts; std::vector pillZero; for (const double tick : yTicks) { if (!showLabels) { break; } const float y = ValueToY(tick, lane); if (y < lane.rect.Y - 1.0f || y > lane.rect.Y + lane.rect.Height + 1.0f) { continue; } if (std::fabs(y - lastLabelY) < labelMinGap) { continue; } lastLabelY = y; const std::wstring raw = FormatTick(tick, yStep); const float textWidth = UiMeasureTextWidth(m_fonts->monoSmall, raw); const float pillHeight = 17.0f * m_scale; const gd::RectF pill(lane.rect.X + kLaneLabelPad * m_scale, y - pillHeight * 0.5f, std::min(textWidth + 8.0f * m_scale, lane.rect.Width - 2.0f * kLaneLabelPad * m_scale), pillHeight); if (pill.Width <= 0.0f) { continue; } // 与图例重叠时不再跳过:标尺值先绘制,随后绘制的图例会盖在其上层 // (顶层始终为图例),因此两者都不丢失。 // 数值**完全**移出可见区时才不绘制;只要还有一部分在区内就绘制未被遮挡的部分 // (裁剪交给 GDI 文本的 ETO_CLIPPED 与下面的底板夹取共同保证)。 const float visibleTop = std::max(pill.Y, clipTop); const float visibleBottom = std::min(pill.Y + pill.Height, clipBottom); if (visibleBottom <= visibleTop) { continue; } pills.push_back(gd::RectF(pill.X, visibleTop, pill.Width, visibleBottom - visibleTop)); pillTexts.push_back( UiEllipsize(raw, m_fonts->monoSmall, pill.Width - 6.0f * m_scale)); pillZero.push_back(tick == 0.0); } // 记录胶囊矩形(供装饰层绘制/贴回;略微外扩以容纳圆角与文字边缘)。 for (const gd::RectF& pill : pills) { m_decorRects.push_back(gd::RectF(pill.X - 2.0f * m_scale, pill.Y - 1.0f * m_scale, pill.Width + 4.0f * m_scale, pill.Height + 2.0f * m_scale)); } // 命中装饰缓存:跳过重绘(文字 + 圆角底板),由上层整块贴回。 // ⚠️ 底板是**圆角且不透明**:装饰位图带 alpha,圆角之外必须保持全透明, // 贴回时才能透出当前波形(避免"圆角外多出一圈直角白边")。 if (!m_useDecorCache) { UiFillRoundRects(g, pills, 3.0f * m_scale, m_theme->cardBg | 0xFF000000u); for (std::size_t index = 0; index < pills.size(); ++index) { const gd::RectF& pill = pills[index]; // 用 UiTextAtClipped 保证长文本不越过绘图区(GDI 文本不受 GDI+ 裁剪约束)。 UiTextAtClipped(g, pillTexts[index], m_fonts->monoSmall, pill.X + 4.0f * m_scale, gd::RectF(pill.X + 4.0f * m_scale, pill.Y, pill.Width - 8.0f * m_scale, pill.Height), pillZero[index] ? m_theme->text : m_theme->textMuted, clipRectForLabels); } } } if (!m_renderWide && !m_renderGridOnly && lane.rect.Height >= 24.0f * m_scale) { PaintLaneLegend(g, laneIndex, lane, clipTop, clipBottom); } g.Restore(saveState); } void PlotView::PaintLaneDividers(gd::Graphics& g) const { if (m_theme == nullptr) { return; } for (std::size_t index = 0; index < m_lanes.size(); ++index) { const gd::RectF& rect = m_lanes[index].rect; const float dividerY = rect.Y; if (dividerY < m_plotRect.Y - 1.0f || dividerY > m_plotRect.Y + m_plotRect.Height) { continue; } const float segments[4] = {rect.X, dividerY, rect.X + rect.Width, dividerY}; const std::uint32_t dividerColor = ((index == 0) ? m_theme->axisLine : m_theme->borderStrong) | 0xFF000000u; // GDI 批绘不受 GDI+ 裁剪约束 → 显式给出只覆盖该分界线附近的裁剪矩形。 const gd::RectF clip(m_area.X, dividerY - 4.0f * m_scale, m_area.Width, 8.0f * m_scale); UiDrawSolidLines(g, clip, segments, 1, dividerColor, (index == 0) ? 2 : static_cast(kLaneDividerWidth)); } } void PlotView::PaintLaneDerivedOverlays(gd::Graphics& g, int laneIndex, const LaneVisual& lane) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return; } const Lane& tabLane = Tab().Lanes()[static_cast(laneIndex)]; const bool centerOn = tabLane.edgeCenter; const bool meanFilterOn = tabLane.meanFilter; const bool weakPeakOn = tabLane.weakPeak; if (!centerOn && !meanFilterOn && !weakPeakOn) { return; } const int saveState = g.Save(); g.SetClip(lane.rect, gd::CombineModeIntersect); for (const SignalRef& ref : tabLane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible) { continue; } if (centerOn) { const Lane::EdgeCenterItem* centerItem = tabLane.FindEdgeCenterItem(ref); if (centerItem != nullptr) { PaintMeanFilterCurve(g, resolved, lane, centerItem->points, centerItem->iterations); } } if (meanFilterOn) { const Lane::MeanFilterItem* item = tabLane.FindMeanFilterItem(ref); if (item != nullptr) { PaintMeanFilterCurve(g, resolved, lane, item->points, item->iterations); } } if (weakPeakOn) { // 每个信号用自己的「增强等级 + 迭代次数」(与面板滑块、构建新信号完全一致)。 const Lane::WeakPeakItem* item = tabLane.FindWeakPeakItem(ref); if (item != nullptr) { const int amountPercent = std::max(kWeakPeakAmountMin, std::min(kWeakPeakAmountMax, item->amountPercent)); const int boostIterations = std::max( kWeakPeakIterationsMin, std::min(kWeakPeakIterationsMaxLimit, item->iterations)); PaintWeakPeakCurve(g, resolved, lane, amountPercent, boostIterations); } } } g.Restore(saveState); } void PlotView::PaintLaneLegend(gd::Graphics& g, int laneIndex, const LaneVisual& lane, float visibleTop, float visibleBottom) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return; } // 图例固定在网格左上角:网格被滚动出可视区时整体跳过,避免文字溢出到工具栏。 const gd::RectF legendBand(lane.rect.X + kLegendPad * m_scale, lane.rect.Y + kLegendPad * m_scale, lane.rect.Width, kLegendHeight * m_scale); if (!IsFullyVisible(legendBand, visibleTop, visibleBottom)) { return; } const std::vector& laneSignals = m_tab->Lanes()[static_cast(laneIndex)].signals; const bool multiGroup = LaneSpansMultipleGroups(laneIndex); const float top = legendBand.Y; const float height = kLegendHeight * m_scale; 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; float cursorX = badgeRect.X + badgeRect.Width + kLaneBadgeGap * m_scale; // 图例条目的托底 = **与 GX 编号徽标完全同款**(用户明确要求): // * 高度与徽标相同(`bgadeRect` 的上下边界一模一样,因此"上下边距相同"); // * 圆角 = 高度的一半(胶囊形),与徽标的 `badgeHeight * 0.5f` 一致; // * 配色与描边宽度也与徽标一致(`cardBg/border`,选中态 `accentSoft/accent`); // * 水平内边距 = 胶囊半径(若小于半径,内容会溢出圆角之外)。 // * 文本改用**更小的字体**(`uiTiny`):原用 `uiSmall` 时文字几乎顶满胶囊高度。 // ⚠️ 托底矩形**同时就是命中矩形**(看到的与点得到的严格一致)。 const float plateHeight = badgeHeight; const float plateTop = badgeRect.Y; const float platePadX = plateHeight * 0.5f; // ------------------------------------------------------------------ // 1) 几何:算出"放得下"的条目及其矩形(**唯一来源**,命中 / 装饰 / 绘制共用)。 // ------------------------------------------------------------------ struct LegendItem { SignalRef ref; float left = 0.0f; // 色块左缘 float width = 0.0f; // 条目**内容**宽度(色块 + 间距 + 文本) float plateWidth = 0.0f; // 托底宽度(内容 + 两侧内边距) float textWidth = 0.0f; std::wstring label; bool selected = false; }; std::vector items; int visibleCount = 0; float contentRight = cursorX; { float probeX = cursorX; for (const SignalRef& ref : laneSignals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible) { continue; } ++visibleCount; std::wstring label = BuildLegendText(*resolved.data); if (multiGroup && resolved.group != nullptr) { label += L" · " + resolved.group->name; } const float maxTextWidth = kLegendMaxTextWidth * m_scale; const float fullTextWidth = UiMeasureTextWidth(m_fonts->uiTiny, label); const float textWidth = std::min(fullTextWidth, maxTextWidth); const float contentWidth = swatchSize + 5.0f * m_scale + textWidth; const float plateWidth = contentWidth + platePadX * 2.0f; if (probeX + plateWidth > maxX) { break; // 空间不足:其余条目省略,下方用 "+N" 提示 } LegendItem item; item.ref = ref; item.left = probeX + platePadX; // 色块左缘(托底内缩一个内边距) item.width = contentWidth; item.plateWidth = plateWidth; item.textWidth = textWidth; item.label = (fullTextWidth > maxTextWidth) ? UiEllipsize(label, m_fonts->uiTiny, maxTextWidth) : label; item.selected = (SelectedRef() == ref); items.push_back(std::move(item)); probeX += plateWidth + kLegendItemGap * m_scale; } contentRight = probeX; } const auto PlateRect = [&](const LegendItem& item) { return gd::RectF(item.left - platePadX, plateTop, item.plateWidth, plateHeight); }; // 条目托底 = 与 GX 编号徽标同一套风格的**胶囊形圆角矩形**(半径 = 高度的一半)。 const float plateRadius = plateHeight * 0.5f; // 「+N」(图例空间不足)与「(无可见信号)」提示的矩形。 // ⚠️ 它们同样属于装饰层、必须登记进 `m_decorRects`:否则会被画进装饰位图 // 却因不在贴回列表里而永远看不见。 const bool hasMoreText = (visibleCount > static_cast(items.size())) && (contentRight + 20.0f * m_scale <= maxX); const bool hasEmptyText = (visibleCount == 0); const gd::RectF moreTextRect(contentRight, top, 24.0f * m_scale, height); const gd::RectF emptyTextRect(legendBand.X, top, lane.rect.Width, height); // ------------------------------------------------------------------ // 2) 命中矩形:**无论是否命中装饰缓存都必须重建**。 // 整幅重绘路径会先清空 `m_legendHits`;若这里因为"装饰缓存命中"而提前返回、 // 不写入新矩形,图例的悬停高亮 / 点击选中 / 双击改名就会**全部失效** //(用户报告:"用中键移动过视角之后图例点不动、悬停也没反应")。 // ------------------------------------------------------------------ for (auto iterator = m_legendHits.begin(); iterator != m_legendHits.end();) { if (m_tab->LaneOfSignal(iterator->ref) == laneIndex) { iterator = m_legendHits.erase(iterator); } else { ++iterator; } } for (const LegendItem& item : items) { const ResolvedSignal resolved = ResolveSignal(item.ref); LegendHit hit; hit.ref = item.ref; hit.rect = PlateRect(item); hit.radius = plateRadius; hit.swatch = gd::RectF(item.left, plateTop + (plateHeight - swatchSize) * 0.5f, swatchSize, swatchSize); hit.text = gd::RectF(item.left + swatchSize + 5.0f * m_scale, plateTop, std::max(8.0f, item.textWidth + 2.0f), plateHeight); hit.label = item.label; // 「选中」与「悬停」用同一套配色(用户明确要求),因此文字色在这里也按选中态取。 hit.textColor = item.selected ? m_theme->textStrong : m_theme->text; if (resolved.style != nullptr) { 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); } } // 装饰矩形:**逐块**(徽标一块 + 每个条目一块),而不是覆盖整条图例带 —— // 覆盖整条带没有必要,且逐块能减少装饰层贴图面积。 m_decorRects.push_back(badgeRect); for (const LegendItem& item : items) { m_decorRects.push_back(PlateRect(item)); } if (hasEmptyText) { m_decorRects.push_back(emptyTextRect); } if (hasMoreText) { m_decorRects.push_back(moreTextRect); } // 命中装饰缓存:徽标与图例的像素改由装饰层贴回,跳过重绘(命中矩形已在上方重建)。 if (m_useDecorCache) { return; } // ------------------------------------------------------------------ // 3) 绘制。托底是**圆角矩形**(与 GX 编号徽标同风格): // "一个 GX 标签,只是内容换成了图例信息"(用户明确要求)。 // ⚠️ **不再铺任何外扩的不透明直角底色**:装饰位图带 alpha,圆角之外必须保持 // 全透明,贴回时才能透出**当前**波形 —— 否则圆角外会多出一圈"直角白边"。 // ⚠️ 徽标与条目的**悬停 / 激活高亮不在这里**(随鼠标变化,画在贴图之后, // 见 PaintLegendHighlight)—— 否则会被缓存冻结,悬停时看不到任何反馈。 // ------------------------------------------------------------------ { 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); // 选中态 = 悬停态配色(`accentSoft` 底 + `accent` 描边), // 与 GX 编号徽标的鼠标悬停效果完全一致。 UiFillRoundRect(g, plate, plateRadius, item.selected ? m_theme->accentSoft : m_theme->cardBg); UiDrawRoundRect(g, plate, plateRadius, 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->uiTiny, m_scale); continue; } UiDrawSwatch(g, gd::RectF(item.left, plateTop + (plateHeight - swatchSize) * 0.5f, swatchSize, swatchSize), resolved.style->color, resolved.style->opacity, 1.5f * m_scale, m_scale); UiText(g, item.label, m_fonts->uiTiny, gd::RectF(item.left + swatchSize + 5.0f * m_scale, plateTop, std::max(8.0f, item.textWidth + 2.0f), plateHeight), item.selected ? m_theme->textStrong : m_theme->text, TextAlign::LEFT); } } if (hasEmptyText) { UiText(g, L"(无可见信号)", m_fonts->uiTiny, emptyTextRect, m_theme->textMuted, TextAlign::LEFT); } else if (hasMoreText) { // 图例空间不足时提示剩余信号数量,避免误认为信号丢失。 const std::wstring moreText = L"+" + std::to_wstring(visibleCount - static_cast(items.size())); UiText(g, moreText, m_fonts->uiTiny, moreTextRect, m_theme->textMuted, TextAlign::LEFT); } } bool PlotView::IsFullyVisible(const gd::RectF& rect, float visibleTop, float visibleBottom) { return (rect.Y >= visibleTop - 0.5f) && (rect.Y + rect.Height <= visibleBottom + 0.5f); } void PlotView::PaintTimeAxis(gd::Graphics& g, const std::vector& timeTicks, double timeStep) { const float axisY = m_plotRect.Y + m_plotRect.Height; // 时间轴的轴线与刻度线同样走 GDI 批量绘制(理由见 PaintLane 中的说明)。 const gd::RectF axisClip(m_area.X, m_area.Y, m_area.Width, m_area.Height); const std::uint32_t axisColor = m_theme->axisLine | 0xFF000000u; { const float axisSegment[4] = {m_plotRect.X, axisY, m_plotRect.X + m_plotRect.Width, axisY}; UiDrawSolidLines(g, axisClip, axisSegment, 1, axisColor); } // 时间轴列名(取自首个可见信号所属分组的第一条时间轴),贴左侧绘制。 std::wstring axisName = L"时间"; if (m_dataSet != nullptr && m_tab != nullptr) { for (std::size_t groupIndex = 0; groupIndex < m_dataSet->GroupCount(); ++groupIndex) { if (!m_tab->GroupHasVisible(static_cast(groupIndex))) { continue; } const SignalGroup* group = m_dataSet->GroupAt(static_cast(groupIndex)); if (group != nullptr && !group->axes.empty() && !group->axes.front().name.empty()) { axisName = group->axes.front().name; // CSV 中该列通常直接叫 "time",这里补上时间单位秒便于阅读; // 若列名自带单位描述(以右括号结尾)则不重复追加。 if (axisName.back() != L')') { axisName += L" (s)"; } } break; } } const float nameWidth = std::min(UiMeasureTextWidth(m_fonts->monoSmall, axisName) + 8.0f * m_scale, m_plotRect.Width * 0.4f); const gd::RectF nameRect(m_plotRect.X + 2.0f * m_scale, axisY + 3.0f * m_scale, nameWidth, std::max(0.0f, m_timeAxisRect.Height - 5.0f * m_scale)); UiText(g, UiEllipsize(axisName, m_fonts->monoSmall, nameWidth), m_fonts->monoSmall, nameRect, m_theme->textMuted, TextAlign::LEFT); const float labelWidth = 96.0f * m_scale; // 五等分子刻度线:在相邻主刻度之间插入 4 条短刻度(更长的主刻度在最前绘制, // 短刻度与主刻度重合的位置跳过),便于目视读取中间时刻。 const double minorStep = timeStep / static_cast(kMinorDivisions); std::vector minorTicks; BuildTicks(Tab().ViewMin(), Tab().ViewMax(), minorStep, minorTicks); std::vector minorSegments; std::vector majorSegments; minorSegments.reserve(minorTicks.size() * 4); majorSegments.reserve(timeTicks.size() * 4); for (const double tick : minorTicks) { const double majorIndex = tick / timeStep; if (std::fabs(majorIndex - std::floor(majorIndex + 0.5)) < 1.0e-6) { continue; } const float x = TimeToX(tick); if (x < m_plotRect.X - 1.0f || x > m_plotRect.X + m_plotRect.Width + 1.0f) { continue; } minorSegments.insert(minorSegments.end(), {x, axisY, x, axisY + 3.0f * m_scale}); } for (const double tick : timeTicks) { const float x = TimeToX(tick); if (x < m_plotRect.X - 1.0f || x > m_plotRect.X + m_plotRect.Width + 1.0f) { continue; } majorSegments.insert(majorSegments.end(), {x, axisY, x, axisY + 4.0f * m_scale}); } if (!minorSegments.empty()) { UiDrawSolidLines(g, axisClip, minorSegments.data(), minorSegments.size() / 4, axisColor); } if (!majorSegments.empty()) { UiDrawSolidLines(g, axisClip, majorSegments.data(), majorSegments.size() / 4, axisColor); } for (const double tick : timeTicks) { const float x = TimeToX(tick); if (x < m_plotRect.X - 1.0f || x > m_plotRect.X + m_plotRect.Width + 1.0f) { continue; } // 刻度标签夹在绘图区横向范围内,并与左侧列名互不重叠。 float labelX = x - labelWidth * 0.5f; labelX = std::max(m_plotRect.X, std::min(labelX, m_plotRect.X + m_plotRect.Width - labelWidth)); const gd::RectF labelRect(labelX, axisY + 4.0f * m_scale, labelWidth, std::max(0.0f, m_timeAxisRect.Height - 6.0f * m_scale)); if (labelRect.X < nameRect.X + nameRect.Width) { continue; } UiText(g, FormatTick(tick, timeStep), m_fonts->monoSmall, labelRect, m_theme->textMuted, TextAlign::CENTER); } } void PlotView::DrawLabelOutline(gd::Graphics& g, const gd::RectF& rect, float radius, std::uint32_t outlineColor) { // 描边色由 `UiContrastInk` 与文字色一起算出(恒与文字色反相、且随信号配色线性变化)。 // 这样胶囊压在同类色背景或信号曲线上时仍有清晰的边界。 UiDrawRoundRect(g, rect, radius, outlineColor, 1.0f); } void PlotView::PaintCursorOffViewTip(gd::Graphics& g, const gd::RectF& labelRect, bool offRight, std::uint32_t color) { // 三角画在读数胶囊的**外侧**(尖端朝外、指向光标实际所在的那一侧), // 与胶囊之间留出 `kMarkerTriangleGap` 的空白。 // // 旧实现把三角压在胶囊**上沿**且水平居中:既没有间隙(看起来像胶囊长了个尖角), // 又与网格内"数据点越界"的画法(左右三角)不统一。现在两处共用同一套画法。 const float arm = kMarkerTriangleArm * m_scale; const float depth = kMarkerTriangleDepth * m_scale; const float gap = kMarkerTriangleGap * m_scale; const float centerY = labelRect.Y + labelRect.Height * 0.5f; gd::PointF outer[3]; if (offRight) { const float baseX = labelRect.X + labelRect.Width + gap; outer[0] = gd::PointF(baseX, centerY - arm); outer[1] = gd::PointF(baseX, centerY + arm); outer[2] = gd::PointF(baseX + depth, centerY); } else { const float baseX = labelRect.X - gap; outer[0] = gd::PointF(baseX, centerY - arm); outer[1] = gd::PointF(baseX, centerY + arm); outer[2] = gd::PointF(baseX - depth, centerY); } gd::SolidBrush brush(ToGdiColor(color)); g.FillPolygon(&brush, outer, 3); } void PlotView::PaintCursor(gd::Graphics& g, const std::vector& lanes) { if (!Tab().CursorEnabled() || m_tab == nullptr) { return; } const bool doubleMode = (Tab().CursorMode() == PlotCursorMode::DOUBLE); const int cursorCount = doubleMode ? 2 : 1; // 本次绘制重新统计已占用的取值标注矩形(用于避免多个信号/光标的内容互相覆盖)。 // 先把底部的时间读数/差值标签也登记进去:它们在取值标注之后绘制、会盖在上面, // 因此取值标注必须主动避开它们,否则会被遮住半个字。 m_cursorLabelRects.clear(); for (int which = 1; which <= cursorCount; ++which) { const gd::RectF timeRect = CursorLabelRect(which); if (timeRect.Width > 0.0f) { m_cursorLabelRects.push_back(timeRect); } } if (doubleMode) { bool insideAxis = false; const gd::RectF spanRect = CursorSpanRect(insideAxis); if (spanRect.Width > 0.0f) { m_cursorLabelRects.push_back(spanRect); } } // 重叠时把"最近操作过的那条光标"的内容画在上层,因此先画另一条。 const int iterations[2] = { (m_activeCursor == 2) ? 1 : 2, (m_activeCursor == 2) ? 2 : 1 }; // 增量差值**只**显示在"右侧光标"上。右侧 = 时间较大的那条,随拖拽实时变化: // 把右光标拖到左边后,增量会自动改由新的右侧光标显示。 const int rightCursor = (Tab().CursorTime() <= Tab().CursorTimeAlt()) ? 2 : 1; // ---- 光标竖线 + 顶部把手 ---- for (int pass = 0; pass < cursorCount; ++pass) { const int which = doubleMode ? iterations[pass] : 1; const double time = (which == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime(); const float x = TimeToX(time); if (x < m_plotRect.X - 2.0f || x > m_plotRect.X + m_plotRect.Width + 2.0f) { // 光标滞留在可视区外:不画竖线(其取值/时间读数会在下面贴边显示)。 continue; } const bool hot = (m_hoverCursor == which); const std::uint32_t color = CursorColor(); const float lineTop = m_plotRect.Y; const float lineBottom = m_timeAxisRect.Y + m_timeAxisRect.Height; UiDrawLine(g, x, lineTop, x, lineBottom, color, (hot ? 2.0f : 1.4f) * m_scale); // 顶部三角把手(两条光标样式一致,靠颜色区分)。 const float arm = 5.0f * m_scale; gd::PointF points[3]; points[0] = gd::PointF(x - arm, lineTop); points[1] = gd::PointF(x + arm, lineTop); points[2] = gd::PointF(x, lineTop + arm * 1.4f); gd::SolidBrush handle(ToGdiColor(color)); g.FillPolygon(&handle, points, 3); } // ---- 数据点取值标注 ---- for (int pass = 0; pass < cursorCount; ++pass) { const int which = doubleMode ? iterations[pass] : 1; const double time = (which == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime(); const double otherTime = (which == 2) ? Tab().CursorTime() : Tab().CursorTimeAlt(); // 只有右侧光标显示增量差值(左光标只显示自身取值)。 const bool showDelta = doubleMode && (which == rightCursor); // 光标在可视区外时,把标注"钉"在对应的网格边上,保证取值仍然可见。 // 具体是否越界(左右/上下)由 MakeDataMarker 逐点判定。 const float pinX = CursorDisplayX(which); const float rawX = TimeToX(time); for (std::size_t laneIndex = 0; laneIndex < lanes.size(); ++laneIndex) { const LaneVisual& lane = lanes[laneIndex]; const float clipTop = std::max(lane.rect.Y, m_plotRect.Y); const float clipBottom = std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height); if (clipBottom <= clipTop) { continue; } for (const SignalRef& ref : m_tab->Lanes()[laneIndex].signals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr || !resolved.visible) { continue; } const bool interpolate = (resolved.style->mode != DrawMode::SCATTER); double value = 0.0; if (!SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style, time, interpolate, value)) { continue; } // 取值读数必须与画出来的波形一致:同样套用系数/绝对值/镜像。 value = ApplyDrawTransform(*resolved.style, value); const float rawY = ValueToY(value, lane); // 上下越界的数据点不再被丢弃:与左右越界一致,贴边绘制指示方向的三角。 // 上边界额外预留光标顶部把手的高度,避免数据点三角压住光标把手。 // 非 const:上下越界时下面还要把方向三角与取值标注纵向居中对齐。 DataMarker marker = MakeDataMarker(rawX, rawY, pinX, clipTop, clipBottom, kCursorHandleReserve * m_scale); const std::uint32_t signalColor = StyleColor(*resolved.style); // 数据点:视界内为圆点,任一方向越界时改为贴边三角。 // **先算好取值标注、再画数据点**:上下越界时的方向三角需要与标注 // 纵向居中对齐,而标注的位置要经过避让处理才能最终确定。 const float radius = 3.4f * m_scale; // 取值标注:底色用信号色,文字/描边用随信号配色线性相关的高对比度配色。 std::wstring text = FormatValue(value); if (showDelta) { double otherValue = 0.0; if (SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style, otherTime, interpolate, otherValue)) { otherValue = ApplyDrawTransform(*resolved.style, otherValue); const double delta = value - otherValue; // 增量 = 右侧光标取值 − 左侧光标取值,包在圆括号里并按差值显式给出正负号。 text += L" ("; text += (delta < 0.0) ? L"-" : L"+"; text += FormatValue(std::fabs(delta)); text += L")"; } } const float textWidth = UiMeasureTextWidth(m_fonts->monoSmall, text) + 10.0f * m_scale; const float textHeight = 17.0f * m_scale; // 左右越界时三角贴在视界内侧、尖端朝外;数值标注必须放在三角的**内侧** // 并留出 `kMarkerTriangleGap` 的空白,否则三角会像"多出来的一个尖角" // (用户报告的"三角形与数值显示部分没有间隔")。 float labelLeft; if (marker.offLeft) { labelLeft = marker.x + kMarkerTriangleGap * m_scale; } else if (marker.offRight) { labelLeft = marker.x - kMarkerTriangleGap * m_scale - textWidth; } else { labelLeft = pinX + radius + 4.0f * m_scale; if (labelLeft + textWidth > m_plotRect.X + m_plotRect.Width - 2.0f * m_scale) { labelLeft = pinX - radius - 4.0f * m_scale - textWidth; } } labelLeft = std::max( m_plotRect.X + 2.0f * m_scale, std::min(labelLeft, m_plotRect.X + m_plotRect.Width - textWidth - 2.0f * m_scale)); float labelTop = marker.y - textHeight * 0.5f; labelTop = std::max(clipTop + 1.0f * m_scale, std::min(labelTop, clipBottom - textHeight - 1.0f * m_scale)); gd::RectF labelRect(labelLeft, labelTop, textWidth, textHeight); // 多个信号的取值标注可能落在同一位置(数据点重合或过近): // 依次向下(再向上)避让已占用的标注,保证不会互相覆盖。 PlaceCursorLabel(labelRect, labelTop, textHeight, clipTop, clipBottom); // 越界方向三角与最终落位的取值标注纵向居中对齐 // (用户明确要求“超过上下视界时三角需要与显示的数值居中对齐”; // 左右越界时同样对齐,避免三角与数值错行、看起来不协调)。 if (marker.offUp || marker.offDown || marker.offLeft || marker.offRight) { marker.y = labelRect.Y + labelRect.Height * 0.5f; } PaintDataMarker(g, marker, radius, signalColor, m_theme->cardBg); const UiInk ink = UiContrastInk(signalColor); UiFillRoundRect(g, labelRect, 3.0f * m_scale, WithAlpha(signalColor, 0xF0)); DrawLabelOutline(g, labelRect, 3.0f * m_scale, ink.outline); UiText(g, text, m_fonts->monoSmall, labelRect, ink.text, TextAlign::CENTER); // 「边沿计数(中心轴)」/「均值滤波」/「弱峰调整」开启时,纵向光标还要额外标注 // **派生曲线**在该时刻的取值(它不是信号,因此单独标注,不并入上面的取值文案)。 // 三条曲线只是曲线来源不同,标注规则完全一致,故共用同一段绘制逻辑。 // ⚠️ 曲线只取「可见区间 ∪ 两个光标时刻 ± 保护带」的窗口(与绘制共用缓存), // 因此拖动光标/拖动参数滑块都不会触发整条曲线的重算。 const auto paintDerivedCapsule = [&](DerivedCurveKind kind, int points, int iterations, int amountPercent, const std::wstring& suffix) { const int sampleIndex = FindNearestSample(*resolved.axis, time); if (sampleIndex < 0) { return; } const int otherIndex = showDelta ? FindNearestSample(*resolved.axis, otherTime) : -1; // 本函数**只需要光标所在采样点处的派生取值**(以及双光标另一端的取值), // 因此把请求窗口收窄到「需要的那几个下标」即可,均值滤波半径造成的取值 // 依赖由 `CurveSpanFor` 自身的保护带负责覆盖。 // // ⚠️ 绝不能把窗口扩成「光标 ↔ 视界边缘」的并集:光标停在视界之外(例如 // 平移视界把光标甩到很远,或光标远离当前缩放区间)时会得到一个上千万采样的 // 巨大窗口,超过缓存上限后走一次性缓冲 → **每帧重算整段滤波**, // 实测单帧 300ms+(用户报告的"平移/拖动光标异常卡顿"的直接原因)。 std::size_t begin = static_cast(sampleIndex); std::size_t end = begin + 1; if (otherIndex >= 0) { const std::size_t other = static_cast(otherIndex); begin = std::min(begin, other); end = std::max(end, other + 1); } std::size_t spanBegin = 0; const std::vector* curvePtr = CurveSpanFor(kind, *resolved.data, points, iterations, amountPercent, begin, end, spanBegin, true); if (curvePtr == nullptr) { return; } const auto curveValueAt = [curvePtr, spanBegin](int index) -> double { if (index < 0 || static_cast(index) < spanBegin || (static_cast(index) - spanBegin) >= curvePtr->size()) { return std::numeric_limits::quiet_NaN(); } return static_cast((*curvePtr)[static_cast(index) - spanBegin]); }; const double derivedValue = curveValueAt(sampleIndex); if (!std::isfinite(derivedValue)) { return; } const float rawCenterY = ValueToY(derivedValue, lane); DataMarker centerMarker = MakeDataMarker( rawX, rawCenterY, pinX, clipTop, clipBottom, kCursorHandleReserve * m_scale); // 派生数据点的配色与原始信号一致(便于一一对应); // 但**始终按线段模式**绘制(不跟随原信号的散点模式)。 const std::uint32_t centerColor = StyleColor(*resolved.style); // 取值文本:数值在前,尾缀「(中心轴)/(均值滤波)」;双光标的右侧光标 // 还要附上增量,形如「(+0.0008 中心轴)」(参考常规信号的增量显示)。 std::wstring centerText = FormatValue(derivedValue); centerText += L"("; if (showDelta) { const double otherDerived = curveValueAt(otherIndex); if (std::isfinite(otherDerived)) { const double delta = derivedValue - otherDerived; centerText += (delta < 0.0) ? L"-" : L"+"; centerText += FormatValue(std::fabs(delta)); centerText += L" "; } } centerText += suffix; centerText += L")"; const float centerTextWidth = UiMeasureTextWidth(m_fonts->monoSmall, centerText) + 10.0f * m_scale; // 与常规取值标注同一规则:左右越界时标注要让开外侧的方向三角。 float centerLeft; if (centerMarker.offLeft) { centerLeft = centerMarker.x + kMarkerTriangleGap * m_scale; } else if (centerMarker.offRight) { centerLeft = centerMarker.x - kMarkerTriangleGap * m_scale - centerTextWidth; } else { centerLeft = pinX + radius + 4.0f * m_scale; if (centerLeft + centerTextWidth > m_plotRect.X + m_plotRect.Width - 2.0f * m_scale) { centerLeft = pinX - radius - 4.0f * m_scale - centerTextWidth; } } centerLeft = std::max( m_plotRect.X + 2.0f * m_scale, std::min(centerLeft, m_plotRect.X + m_plotRect.Width - centerTextWidth - 2.0f * m_scale)); float centerTop = centerMarker.y - textHeight * 0.5f; centerTop = std::max(clipTop + 1.0f * m_scale, std::min(centerTop, clipBottom - textHeight - 1.0f * m_scale)); gd::RectF centerRect(centerLeft, centerTop, centerTextWidth, textHeight); PlaceCursorLabel(centerRect, centerTop, textHeight, clipTop, clipBottom); // 与原始信号同一规则:越界方向三角与取值标注纵向居中对齐。 if (centerMarker.offUp || centerMarker.offDown || centerMarker.offLeft || centerMarker.offRight) { centerMarker.y = centerRect.Y + centerRect.Height * 0.5f; } PaintDataMarker(g, centerMarker, radius, centerColor, m_theme->cardBg); // 颜色样式与原始信号一致(同底色/同描边/同文字配色)。 const UiInk centerInk = UiContrastInk(centerColor); UiFillRoundRect(g, centerRect, 3.0f * m_scale, WithAlpha(centerColor, 0xE0)); DrawLabelOutline(g, centerRect, 3.0f * m_scale, centerInk.outline); UiText(g, centerText, m_fonts->monoSmall, centerRect, centerInk.text, TextAlign::CENTER); }; if (m_tab->Lanes()[laneIndex].edgeCenter) { // 只标注被勾选(即真正绘制了曲线)的信号。 const Lane::EdgeCenterItem* centerItem = m_tab->Lanes()[laneIndex].FindEdgeCenterItem(ref); if (centerItem != nullptr) { paintDerivedCapsule(DerivedCurveKind::MEAN_FILTER, centerItem->points, centerItem->iterations, 0, L"中心轴"); } } if (m_tab->Lanes()[laneIndex].meanFilter) { // 只标注被勾选(即真正绘制了预览曲线)的信号。 const Lane::MeanFilterItem* meanItem = m_tab->Lanes()[laneIndex].FindMeanFilterItem(ref); if (meanItem != nullptr) { paintDerivedCapsule(DerivedCurveKind::MEAN_FILTER, meanItem->points, meanItem->iterations, 0, L"均值滤波"); } } if (m_tab->Lanes()[laneIndex].weakPeak) { // 弱峰调整曲线:同样是只标注被勾选的信号,尾缀为「弱峰调整」。 const Lane::WeakPeakItem* boostItem = m_tab->Lanes()[laneIndex].FindWeakPeakItem(ref); if (boostItem != nullptr) { const int amountPercent = std::max(kWeakPeakAmountMin, std::min(kWeakPeakAmountMax, boostItem->amountPercent)); const int iterations = std::max( kWeakPeakIterationsMin, std::min(kWeakPeakIterationsMaxLimit, boostItem->iterations)); paintDerivedCapsule(DerivedCurveKind::WEAK_PEAK, 0, iterations, amountPercent, L"弱峰调整"); } } } } } // ---- 底部:时间读数 + 双光标差值 ---- if (m_editTarget == PlotEditTarget::CURSOR_TIME || m_editTarget == PlotEditTarget::CURSOR_SPAN) { PaintEditField(g); return; } for (int pass = 0; pass < cursorCount; ++pass) { const int which = doubleMode ? iterations[pass] : 1; const gd::RectF labelRect = CursorLabelRect(which); if (labelRect.Width <= 0.0f) { continue; } const bool highlighted = (m_cursorHover && m_hoverCursor == which); const std::uint32_t color = CursorColor(); bool offRight = false; const bool offView = CursorOffViewSide(which, offRight); if (offView) { PaintCursorOffViewTip(g, labelRect, offRight, color); } UiFillRoundRect(g, labelRect, 3.0f * m_scale, highlighted ? color : WithAlpha(color, 0xE0)); const UiInk ink = UiContrastInk(color); DrawLabelOutline(g, labelRect, 3.0f * m_scale, ink.outline); UiText(g, FormatCursorTime(which == 2 ? Tab().CursorTimeAlt() : Tab().CursorTime()), m_fonts->monoSmall, labelRect, ink.text, TextAlign::CENTER); } // 双光标差值:显示在两条时间读数中间(时间轴上方;过近时落到主时间轴上)。 if (doubleMode) { bool insideAxis = false; const gd::RectF spanRect = CursorSpanRect(insideAxis); m_cursorSpanRect = spanRect; if (spanRect.Width > 0.0f && spanRect.Height > 0.0f) { // 样式与光标时间读数完全一致(同底色/同描边/同文字配色)。 const std::uint32_t color = CursorColor(); // 双光标同时越界到同一侧时,差值标签也在视界之外 → 同样画方向三角。 bool offRight1 = false, offRight2 = false; const bool off1 = CursorOffViewSide(1, offRight1); const bool off2 = CursorOffViewSide(2, offRight2); if (off1 && off2 && offRight1 == offRight2) { PaintCursorOffViewTip(g, spanRect, offRight1, color); } UiFillRoundRect(g, spanRect, 3.0f * m_scale, m_spanHover ? color : WithAlpha(color, 0xE0)); const UiInk ink = UiContrastInk(color); DrawLabelOutline(g, spanRect, 3.0f * m_scale, ink.outline); UiText(g, FormatSpanText(CursorSpan()), m_fonts->monoSmall, spanRect, ink.text, TextAlign::CENTER); } } else { m_cursorSpanRect = gd::RectF(); } } PlotView::EdgePanelLayout PlotView::EdgePanelLayoutFor(int laneIndex, EdgePanelKind kind) const { EdgePanelLayout layout; if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size()) || laneIndex >= static_cast(m_lanes.size())) { return layout; } const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; const bool centerAxis = (kind == EdgePanelKind::CENTER); const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool weakPeak = (kind == EdgePanelKind::PEAK); // 「均值滤波」与「弱峰调整」共用同一套「逐条目 + 底部按钮」排布。 const bool itemized = (centerAxis || meanFilter || weakPeak); const bool hasButton = (meanFilter || weakPeak); const bool enabled = meanFilter ? lane.meanFilter : (weakPeak ? lane.weakPeak : (centerAxis ? lane.edgeCenter : lane.edgeCount)); const bool open = meanFilter ? lane.meanFilterPanel.open : (weakPeak ? lane.weakPeakPanel.open : (centerAxis ? lane.edgeCenterPanel.open : lane.edgePanel.open)); if (!enabled || !open) { return layout; } int rowCount = 0; for (const SignalRef& ref : lane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data != nullptr && resolved.visible) { ++rowCount; } } if (rowCount <= 0) { return layout; } layout.rows = rowCount; const float width = EdgePanelWidth(laneIndex, kind); const float titleHeight = kEdgePanelTitleHeight * m_scale; const float rowHeight = kEdgePanelRowHeight * m_scale; const float pad = kEdgePanelPad * m_scale; // 「中心轴边沿计数」「均值滤波」「弱峰调整」共用同一套「逐条目」排布, // 因此不再有独立的底部编辑行;只有「边沿计数」保留两行文本式排版。 const float buttonHeight = hasButton ? (kEdgePanelButtonHeight * m_scale) : 0.0f; const float rowsPerSignal = meanFilter ? 1.0f : 2.0f; // 逐条目面板:每个条目 = 第一排(信号名 + 复选框) // + 参数部分(**只在该条目被勾选时才占位**,因此面板高度随勾选数量动态变化): // * MEAN / CENTER:一行「均值点数 X + 迭代次数 N」输入框(中心轴再多一行统计结果); // * PEAK:两行滑块(调整幅度 % / 迭代次数 N),每个信号各有一套自己的参数。 const bool itemHasParams = (centerAxis || meanFilter || weakPeak); const float weakPeakSliderRow = kWeakPeakSliderRowHeight * m_scale; float meanRowHeight = 0.0f; float itemsHeight = 0.0f; float height = 0.0f; if (itemized) { meanRowHeight = kEdgePanelMeanRowHeight * m_scale; for (const SignalRef& ref : lane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || !resolved.visible) { continue; } itemsHeight += meanRowHeight; const bool checked = meanFilter ? lane.MeanFilterEnabled(ref) : (weakPeak ? lane.WeakPeakEnabled(ref) : lane.EdgeCenterEnabled(ref)); if (checked && itemHasParams) { if (weakPeak) { itemsHeight += weakPeakSliderRow * 2.0f; } else { itemsHeight += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale; if (centerAxis) { itemsHeight += kEdgePanelMeanRowHeight * m_scale; } } } } // 底部按钮行:上方与条目之间、下方与面板下边界之间各留一个 `pad`, // 与面板的左右内边距完全一致(原先按钮直接贴着下边框,看起来很不协调)。 height = titleHeight + pad + itemsHeight + pad + buttonHeight + (hasButton ? pad : 0.0f); } else { height = titleHeight + pad + static_cast(rowCount) * rowHeight * rowsPerSignal + pad; } // 首次使用(位置为负)时自动放到绘图区**右上角**,并按网格在**当前可见网格中的序数** // 依次错开,避免完全重叠。 // ⚠️ 旧实现用 `laneIndex` 错开 —— 上百个网格时 `laneIndex` 很大, // `16 + laneIndex*28` 早就超过绘图区高度、被下面的夹取推到**右下角** //(用户 2026-10-17 报告:“在当前范围内的网格开启实验性功能,面板首次出现在右下角”)。 // 改用**可见序数**:它的量级只有一屏的网格数(约 20),再夹到“面板上沿不超过绘图区 // 中间偏下”以内,于是面板始终停**右上区域**,同时保留“依次错开”的观感。 const double storedX = meanFilter ? lane.meanFilterPanel.x : (weakPeak ? lane.weakPeakPanel.x : (centerAxis ? lane.edgeCenterPanel.x : lane.edgePanel.x)); const double storedY = meanFilter ? lane.meanFilterPanel.y : (weakPeak ? lane.weakPeakPanel.y : (centerAxis ? lane.edgeCenterPanel.y : lane.edgePanel.y)); int openPanelOrdinal = 0; { // ⚠️ 错开量用「**当前真正显示出来的面板**中排在本面板之前的个数」: // * 第一个打开的面板 ⇒ 0 ⇒ 正好贴在右上角(用户 2026-10-17 要求“应该在右上角”); // * 后续面板依次下移 28 逻辑像素,避免与已打开的面板完全重叠; // * 遍历顺序固定为(网格下标 → 面板种类)⇒ 每个面板的槽位稳定、与帧无关。 // ⚠️ 不能用 `laneIndex`(上百个网格时会把面板推到右下角); // ⚠️ 也不能只看 `*Panel.open`:该标志**默认就是 true**,必须同时看功能是否启用 // (与 `EdgePanelLayoutFor` 的 `enabled && open` 判定一致)。 const auto shown = [](const Lane& lane, EdgePanelKind panelKind) -> bool { switch (panelKind) { case EdgePanelKind::COUNT: return lane.edgeCount && lane.edgePanel.open; case EdgePanelKind::CENTER: return lane.edgeCenter && lane.edgeCenterPanel.open; case EdgePanelKind::MEAN: return lane.meanFilter && lane.meanFilterPanel.open; case EdgePanelKind::PEAK: return lane.weakPeak && lane.weakPeakPanel.open; } return false; }; static const EdgePanelKind kOrder[4] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER, EdgePanelKind::MEAN, EdgePanelKind::PEAK}; int openSeen = 0; bool found = false; for (std::size_t lane = 0; lane < m_lanes.size() && !found; ++lane) { const Lane& tabLane = Tab().Lanes()[lane]; for (const EdgePanelKind panelKind : kOrder) { if (!shown(tabLane, panelKind)) { continue; } if (lane == static_cast(laneIndex) && panelKind == kind) { found = true; break; } ++openSeen; } } openPanelOrdinal = found ? openSeen : 0; } // 错开量夹到“面板下沿不超过绘图区 45% 高度”以内 ⇒ 首现位置永远在右上区域。 const float maxStagger = std::max(0.0f, m_plotRect.Height * 0.45f - height - 16.0f * m_scale); const float stagger = std::min(static_cast(openPanelOrdinal) * 28.0f * m_scale, maxStagger); float left = (storedX >= 0.0) ? static_cast(storedX) : (m_plotRect.X + m_plotRect.Width - width - 16.0f * m_scale); float top = (storedY >= 0.0) ? static_cast(storedY) : (m_plotRect.Y + 16.0f * m_scale + stagger); // 面板始终限制在绘图区内(用户明确要求"可以在右侧的整个绘制区域拖动")。 left = std::max(m_plotRect.X + 2.0f * m_scale, std::min(left, m_plotRect.X + m_plotRect.Width - width - 2.0f * m_scale)); top = std::max(m_plotRect.Y + 2.0f * m_scale, std::min(top, m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale)); layout.panel = gd::RectF(left, top, width, height); // 只有标题栏可以拖拽;✕ 位于标题栏右端,命中时优先按“关闭按钮”处理。 layout.title = gd::RectF(left, top, width, titleHeight); const float closeSize = titleHeight; layout.close = gd::RectF(left + width - closeSize, top, closeSize, closeSize); if (itemized) { const float buttonRowHeight = kEdgePanelButtonHeight * m_scale; if (hasButton) { // 底部按钮与面板下边界之间留出与左右、与内容相同的 `pad` 间距 //(原先按钮行直接贴着下边框,视觉上与其它元素不协调); // 面板高度已含这份 pad,因此这里只上移按钮行即可。 layout.button = gd::RectF(left + pad, top + height - pad - buttonRowHeight, std::max(0.0f, width - pad * 2.0f), buttonRowHeight); } // 逐信号条目:第一排 = 名称 + 复选框(复选框靠右); // 参数部分:MEAN / CENTER = 一行两个输入框(中心轴再多一行统计结果); // PEAK = 两行滑块(每个信号各有自己的等级与迭代次数)。 const float checkSize = kEdgePanelMeanCheckSize * m_scale; const float fieldHeight = kEdgePanelMeanEditHeight * m_scale; const float halfWidth = std::max(40.0f, (width - pad * 2.0f - 8.0f * m_scale) * 0.5f); // 滑块行列宽(与 `EdgePanelWidth` 使用同一组算式,避免布局与宽度计算漂移)。 const float weakPeakLabelWidth = WeakPeakLabelColumnWidth(m_fonts->uiSmall); const float weakPeakValueWidth = WeakPeakValueColumnWidth(m_fonts->monoSmall); const float weakPeakResetSize = kWeakPeakResetButtonSize * m_scale; const float weakPeakResetGap = kWeakPeakResetGap * m_scale; const float weakPeakTrackHeight = kWeakPeakTrackHeight * m_scale; const float weakPeakTrackLeft = left + pad + weakPeakLabelWidth + 6.0f * m_scale; const float weakPeakTrackWidth = std::max(10.0f * m_scale, (left + width - pad - weakPeakValueWidth - weakPeakResetGap - weakPeakResetSize - 6.0f * m_scale) - weakPeakTrackLeft); float rowY = top + titleHeight + pad; for (const SignalRef& ref : lane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || !resolved.visible) { continue; } layout.rowRefs.push_back(ref); layout.rowChecks.push_back(gd::RectF( left + width - pad - checkSize, rowY + (meanRowHeight - checkSize) * 0.5f, checkSize, checkSize)); const bool checked = meanFilter ? lane.MeanFilterEnabled(ref) : (weakPeak ? lane.WeakPeakEnabled(ref) : lane.EdgeCenterEnabled(ref)); if (weakPeak) { // 每个被勾选的信号各有两行滑块:调整幅度(-400% – +400%)与迭代次数(1 … 上限)。 if (checked) { const float levelRowTop = rowY + meanRowHeight; const float iterRowTop = levelRowTop + weakPeakSliderRow; const gd::RectF levelTrack( weakPeakTrackLeft, levelRowTop + (weakPeakSliderRow - weakPeakTrackHeight) * 0.5f, weakPeakTrackWidth, weakPeakTrackHeight); const gd::RectF iterTrack( weakPeakTrackLeft, iterRowTop + (weakPeakSliderRow - weakPeakTrackHeight) * 0.5f, weakPeakTrackWidth, weakPeakTrackHeight); layout.rowLevelTracks.push_back(levelTrack); layout.rowLevelValues.push_back(gd::RectF( levelTrack.X + levelTrack.Width + 6.0f * m_scale, levelRowTop, weakPeakValueWidth, weakPeakSliderRow)); layout.rowIterTracks.push_back(iterTrack); layout.rowIterValues.push_back(gd::RectF( iterTrack.X + iterTrack.Width + 6.0f * m_scale, iterRowTop, weakPeakValueWidth, weakPeakSliderRow)); // 每行滑块右侧的**复位按钮**(与数值列同一右边界,保证两行上下对齐)。 const float resetX = levelTrack.X + levelTrack.Width + 6.0f * m_scale + weakPeakValueWidth + weakPeakResetGap; layout.rowLevelResets.push_back( gd::RectF(resetX, levelRowTop + (weakPeakSliderRow - weakPeakResetSize) * 0.5f, weakPeakResetSize, weakPeakResetSize)); layout.rowIterResets.push_back( gd::RectF(resetX, iterRowTop + (weakPeakSliderRow - weakPeakResetSize) * 0.5f, weakPeakResetSize, weakPeakResetSize)); } else { layout.rowLevelTracks.push_back(gd::RectF()); layout.rowLevelValues.push_back(gd::RectF()); layout.rowLevelResets.push_back(gd::RectF()); layout.rowIterTracks.push_back(gd::RectF()); layout.rowIterValues.push_back(gd::RectF()); layout.rowIterResets.push_back(gd::RectF()); } layout.rowPoints.push_back(gd::RectF()); layout.rowIterations.push_back(gd::RectF()); layout.rowStats.push_back(gd::RectF()); rowY += meanRowHeight; if (checked) { rowY += weakPeakSliderRow * 2.0f; } continue; } if (checked && itemHasParams) { const float editTop = rowY + meanRowHeight; layout.rowPoints.push_back( gd::RectF(left + pad, editTop, halfWidth, fieldHeight)); layout.rowIterations.push_back( gd::RectF(left + pad + halfWidth + 8.0f * m_scale, editTop, halfWidth, fieldHeight)); if (centerAxis) { layout.rowStats.push_back( gd::RectF(left + pad, editTop + fieldHeight, width - pad * 2.0f, kEdgePanelMeanRowHeight * m_scale)); } else { layout.rowStats.push_back(gd::RectF()); } } else { layout.rowPoints.push_back(gd::RectF()); layout.rowIterations.push_back(gd::RectF()); layout.rowStats.push_back(gd::RectF()); } rowY += meanRowHeight; if (checked && itemHasParams) { rowY += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale; if (centerAxis) { rowY += kEdgePanelMeanRowHeight * m_scale; } } } } return layout; } void PlotView::PaintDataMarker(gd::Graphics& g, const DataMarker& marker, float radius, std::uint32_t fillColor, std::uint32_t ringColor) const { gd::SolidBrush ring(ToGdiColor(ringColor)); gd::SolidBrush fill(ToGdiColor(fillColor)); const float arm = kMarkerTriangleArm * m_scale; /**< 三角“半宽/半高”(垂直于朝向的半边长)。 */ const float depth = kMarkerTriangleDepth * m_scale; /**< 三角沿朝向的深度(与光标把手同比例)。 */ // 越界三角:**尖端朝外**(指向数据点所在的方向,即视界之外),底边在网格内侧; // 三角外接矩形在横/纵两个方向都与锚点**居中**(上下越界时因此与取值标注纵向居中)。 // 旧实现方向是反的(上越界画下三角、左越界画右三角),而且内三角按“平移”而非 // “向质心收缩”构造,填充色会从三角尖端戳出来。 const auto paintTriangle = [&](const gd::PointF* outer) { g.FillPolygon(&ring, outer, 3); const float centerX = (outer[0].X + outer[1].X + outer[2].X) / 3.0f; const float centerY = (outer[0].Y + outer[1].Y + outer[2].Y) / 3.0f; // 按质心等比收缩得到内三角:几何上恒在外三角内部,描边宽度也就均匀了。 const float shrink = 0.58f; gd::PointF inner[3]; for (int index = 0; index < 3; ++index) { inner[index] = gd::PointF(centerX + (outer[index].X - centerX) * shrink, centerY + (outer[index].Y - centerY) * shrink); } g.FillPolygon(&fill, inner, 3); }; // 左右越界:三角指向左/右(视界之外)。 if (marker.offLeft || marker.offRight) { const bool offLeft = marker.offLeft; const float tipX = offLeft ? (marker.x - depth) : (marker.x + depth); const float baseX = marker.x; gd::PointF outer[3]; outer[0] = gd::PointF(baseX, marker.y - arm); outer[1] = gd::PointF(baseX, marker.y + arm); outer[2] = gd::PointF(tipX, marker.y); paintTriangle(outer); return; } // 上下越界:三角指向上/下(视界之外),外接矩形以 marker.y 为纵向中心 // → 与取值标注的纵向中心对齐。 if (marker.offUp || marker.offDown) { const bool offUp = marker.offUp; const float half = depth * 0.5f; const float tipY = offUp ? (marker.y - half) : (marker.y + half); const float baseY = offUp ? (marker.y + half) : (marker.y - half); gd::PointF outer[3]; outer[0] = gd::PointF(marker.x - arm, baseY); outer[1] = gd::PointF(marker.x + arm, baseY); outer[2] = gd::PointF(marker.x, tipY); paintTriangle(outer); return; } // 视界内:常规圆点。 g.FillEllipse(&ring, marker.x - radius - 1.2f * m_scale, marker.y - radius - 1.2f * m_scale, (radius + 1.2f * m_scale) * 2.0f, (radius + 1.2f * m_scale) * 2.0f); g.FillEllipse(&fill, marker.x - radius, marker.y - radius, radius * 2.0f, radius * 2.0f); } void PlotView::PaintDerivedCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, const std::vector& curve, std::size_t curveBegin) const { if (resolved.data == nullptr || resolved.axis == nullptr || resolved.style == nullptr) { return; } const std::vector& time = resolved.axis->time; const std::size_t count = std::min(time.size(), resolved.data->values.size()); if (count == 0 || m_plotRect.Width <= 1.0f || curve.empty()) { return; } std::size_t begin = 0; std::size_t end = 0; VisibleIndexRange(*resolved.axis, count, begin, end, resolved.style->offsetTime); if (end <= begin) { return; } const std::size_t visibleCount = end - begin; const float limitTop = lane.rect.Y - lane.rect.Height * 4.0f; const float limitBottom = lane.rect.Y + lane.rect.Height * 5.0f; // 派生曲线的颜色:与原信号**同色相**但大幅拉开明度,使其与原始波形形成高对比 // (原信号改颜色时曲线随之改变,因为它是从信号色派生出来的);透明度与原信号一致。 const unsigned int baseAlpha = static_cast( std::max(0.0, std::min(1.0, static_cast(resolved.style->opacity))) * 255.0 + 0.5); const std::uint32_t derived = WithAlpha(HighContrastDerived(StyleColor(*resolved.style)), baseAlpha); // 顶点预算与波形一致(每像素列两个顶点)。 // 交互期间进一步降到 1/2:派生曲线往往有 10 条(5 网格 × 均值滤波 + 弱峰调整), // 顶点数就是绘制耗时;降低密度能让拖动参数滑块保持流畅, // 松手后下一帧立刻恢复全密度(用户要求的"低分辨率 → 高分辨率")。 std::size_t budget = static_cast(std::max(1.0f, m_plotRect.Width)) * kPolylinePointsPerPixel; budget = std::max(budget, kMinPolylinePoints); budget = std::min(budget, kMaxPolylinePoints); if (m_fastFrame) { budget = std::max(kMinPolylinePoints, budget / 2); } const std::size_t stride = std::max(1, visibleCount / std::max(1, budget)); std::vector points; std::vector breaks; points.reserve(std::min(visibleCount, budget) + 1); bool inSegment = false; // 「陡峭段」计数:用于判断抗锯齿是否值得付出(见下)。 std::size_t tallSegments = 0; std::size_t totalSegments = 0; for (std::size_t index = begin; index < end; index += stride) { // 水平镜像 = 数据反序:遍历下标映射到源下标后再取曲线值。 const std::size_t sourceIndex = SourceIndexFor(*resolved.style, count, index); if (sourceIndex < curveBegin || (sourceIndex - curveBegin) >= curve.size()) { inSegment = false; continue; } const float sampled = curve[sourceIndex - curveBegin]; if (!std::isfinite(sampled)) { inSegment = false; continue; } float y = ValueToY(ApplyDrawTransform(*resolved.style, static_cast(sampled)), lane); y = static_cast(ClampDouble(y, limitTop, limitBottom)); // 时间偏移:该采样画在 `t + offsetTime` 处。 const float x = TimeToX(time[index] + resolved.style->offsetTime); if (!inSegment) { breaks.push_back(points.size()); points.push_back(gd::PointF(x, y)); inSegment = true; } else { if (std::fabs(y - points.back().Y) > lane.rect.Height * 0.5f) { ++tallSegments; } ++totalSegments; points.push_back(gd::PointF(x, y)); } } if (breaks.empty()) { return; } // 派生曲线**恢复为抗锯齿折线**(与"之前"的显示效果一致)。 // 但抗锯齿在"陡峭段很多"的形状上代价极高(实测 2448 顶点 26~37ms,无抗锯齿仅 3.5ms), // 而那种形状下抗锯齿的平滑作用几乎看不出来 —— 因此: // * ⚠️ **不再因交互(`m_fastFrame`)关闭抗锯齿**:波形基底(图层)始终是全质量抗锯齿, // 若预览曲线在交互期间改成无抗锯齿,就会"比普通信号粗糙"(用户报告的观感下降); // 预览曲线只是叠加层,抗锯齿的代价与它自身的顶点数成正比(可见区间很小),可以承受。 // * 陡峭段占比过高时(方波/编码器这类信号)仍关闭:那种形态下观感几乎无差别。 const bool spiky = (totalSegments > 0) && (tallSegments * 8 > totalSegments); const int savedSmoothing = g.GetSmoothingMode(); if (spiky) { g.SetSmoothingMode(gd::SmoothingModeNone); } gd::Pen pen(ToGdiColor(derived), std::max(1.0f, resolved.style->lineWidth) * m_scale); pen.SetLineJoin(gd::LineJoinRound); pen.SetStartCap(gd::LineCapRound); pen.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(&pen, points.data() + start, static_cast(stop - start)); } g.SetSmoothingMode(static_cast(savedSmoothing)); } void PlotView::PaintMeanFilterCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, int points, int iterations) const { if (resolved.data == nullptr || resolved.axis == nullptr || resolved.style == nullptr) { return; } // 只取「可见区间 ± 保护带」的窗口(见 `CurveSpanFor`):拖参数滑块不会重算整条曲线。 // 与「边沿计数(中心轴)」共用同一段绘制逻辑 //(同色相高对比度的派生配色 + 同透明度),只是曲线本身按多次迭代滤波计算。 const std::size_t count = std::min(resolved.axis->time.size(), resolved.data->values.size()); if (count == 0) { return; } // ⭐ 预览曲线一律按**整条信号**计算(`[0, count]`),与「构建为新信号」使用的 // `ComputeMovingAverageIterated(整段 values, ...)` 完全相同,因此预览与构建结果 // **逐位一致**(用户明确要求"预览显示效果要和构建为新信号一致")。 // 整段窗口超过异步门槛时由后台线程计算,绘制线程本帧用已算好的旧曲线顶替, // 因此不会阻塞交互;小信号同步计算。 std::size_t spanBegin = 0; const std::vector* curve = CurveSpanFor(DerivedCurveKind::MEAN_FILTER, *resolved.data, points, iterations, 0, 0, count, spanBegin, true); if (curve == nullptr) { return; } PaintDerivedCurve(g, resolved, lane, *curve, spanBegin); } void PlotView::PaintWeakPeakCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, int amountPercent, int iterations) const { if (resolved.data == nullptr || resolved.axis == nullptr || resolved.style == nullptr) { return; } const std::size_t count = std::min(resolved.axis->time.size(), resolved.data->values.size()); if (count == 0) { return; } // 预览曲线一律按**整条信号**计算(与「构建为新信号」逐位一致),详见 `PaintMeanFilterCurve`。 std::size_t spanBegin = 0; const std::vector* curve = CurveSpanFor(DerivedCurveKind::WEAK_PEAK, *resolved.data, 0, iterations, amountPercent, 0, count, spanBegin, true); if (curve == nullptr) { return; } // 派生曲线一律使用同一段绘制逻辑与配色规则(弱峰调整曲线同样如此)。 PaintDerivedCurve(g, resolved, lane, *curve, spanBegin); } bool PlotView::EnsurePanelCache() { // 图层只需覆盖绘图区(面板一律被夹在 m_plotRect 内),且**原点必须与客户区一致**: // 面板绘制代码用的是客户区绝对坐标,而 `UiText` 走 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 (width <= 0 || height <= 0) { return false; } if (m_panelDc != nullptr && m_panelWidth == width && m_panelHeight == height) { return true; } ReleasePanelCache(); HDC screen = GetDC(nullptr); if (screen == nullptr) { return false; } m_panelDc = CreateCompatibleDC(screen); m_panelBitmap = CreateCompatibleBitmap(screen, width, height); ReleaseDC(nullptr, screen); if (m_panelDc == nullptr || m_panelBitmap == nullptr) { ReleasePanelCache(); return false; } m_panelOldBitmap = SelectObject(m_panelDc, m_panelBitmap); m_panelWidth = width; m_panelHeight = height; m_panelValid = false; return true; } void PlotView::ReleasePanelCache() { m_panelValid = false; m_panelSlots.clear(); if (m_panelDc != nullptr) { if (m_panelOldBitmap != nullptr) { SelectObject(m_panelDc, m_panelOldBitmap); m_panelOldBitmap = nullptr; } DeleteDC(m_panelDc); m_panelDc = nullptr; } if (m_panelBitmap != nullptr) { DeleteObject(m_panelBitmap); m_panelBitmap = nullptr; } m_panelWidth = 0; m_panelHeight = 0; } bool PlotView::PanelRectInsideLayer(const gd::RectF& rect) const { if (m_panelDc == nullptr) { return false; } return rect.X >= 0.0f && rect.Y >= 0.0f && rect.X + rect.Width <= static_cast(m_panelWidth) && rect.Y + rect.Height <= static_cast(m_panelHeight); } std::uint64_t PlotView::PanelGlobalKey() const { std::uint64_t hash = 1469598103934665603ull; const auto mix = [&hash](std::uint64_t value) { MixPanelKey(hash, value); }; const auto mixFloat = [&mix](float value) { std::uint32_t bits = 0; std::memcpy(&bits, &value, sizeof(bits)); mix(bits); }; const auto mixDouble = [&mix](double value) { std::uint64_t bits = 0; std::memcpy(&bits, &value, sizeof(bits)); mix(bits); }; const auto mixRect = [&mixFloat](const gd::RectF& rect) { mixFloat(rect.X); mixFloat(rect.Y); mixFloat(rect.Width); mixFloat(rect.Height); }; const auto mixRef = [&mix](const SignalRef& ref) { mix(ref.IsValid() ? 1ull : 0ull); mix(static_cast(ref.groupIndex)); mix(static_cast(ref.signalIndex)); }; mixFloat(m_scale); mix(reinterpret_cast(m_theme)); mix(reinterpret_cast(m_fonts)); mixRect(m_plotRect); mixRect(m_area); mix(static_cast(m_dragMode)); mix(static_cast(m_laneDragFrom + 2)); mix(static_cast(m_laneDragTarget + 2)); mix(static_cast(m_laneDragMouseY + 4096)); mix(static_cast(m_hoverLane + 2)); mix(m_cursorHover ? 1ull : 0ull); mix(static_cast(m_hoverCursor + 2)); mix(static_cast(m_activeCursor)); mix(m_dragMoved ? 1ull : 0ull); mixRect(m_rubberRect); mixRef(m_hoverSignal); mixRef(m_hoverLegend); mixRef(m_dragSignal); mixRef(SelectedRef()); mix(static_cast(m_edgePanelDragLane + 2)); mix(static_cast(m_edgePanelDragKind)); // 时间光标与统计区间:面板标题的时间后缀、以及「中心轴」行的上升/下降沿数字都依赖它们。 mix(static_cast(Tab().CursorMode())); mixDouble(Tab().CursorTime()); mixDouble(Tab().CursorTimeAlt()); mix(EdgeRangeActive() ? 1ull : 0ull); mixDouble(EdgeRangeMin()); mixDouble(EdgeRangeMax()); // 跟随鼠标的叠加层(悬停光晕 / 图例半高亮 / 信号拖拽虚影 / 提示动画)位置随时在变: // 只要它存在,就把所有面板缓存作废,避免它的残影被一起缓存进面板像素。 const bool mouseDrivenOverlay = m_hoverSignal.IsValid() || m_hoverLegend.IsValid() || m_dragMode == PlotDragMode::MOVE_SIGNAL || m_dragMode == PlotDragMode::RUBBER_SELECT || HintNeedsAnimation(); mix(mouseDrivenOverlay ? 1ull : 0ull); if (mouseDrivenOverlay) { mix(static_cast(m_mouseX + 4096)); mix(static_cast(m_mouseY + 4096)); } for (const LaneVisual& visual : m_lanes) { mixRect(visual.rect); // ⚠️ **幅值范围(yMin/yMax)刻意不入面板指纹**:面板内容(标题、条目、滑块、 // 参数与边沿统计)都不依赖网格的幅值区间;而「拖拽平移的纵向分量」会每帧小幅 // 改动它 —— 一旦计入,拖拽期间**全部**悬浮面板都会每帧重画一遍 //(实测 20 个面板:竖向拖拽 167ms/帧 vs 横向 38ms/帧,正是用户报告的 // "左右流畅、上下/斜向卡顿")。代价只是面板不透明底(95.7%)背后那 4% // 透出的底层像素可能旧一帧,与既有取舍一致(实测最大色差 ≤ 29 级)。 } if (m_tab != nullptr) { const std::vector& lanes = m_tab->Lanes(); for (const Lane& lane : lanes) { mixFloat(static_cast(lane.edgePanel.x)); mixFloat(static_cast(lane.edgePanel.y)); mixFloat(static_cast(lane.edgeCenterPanel.x)); mixFloat(static_cast(lane.edgeCenterPanel.y)); mixFloat(static_cast(lane.meanFilterPanel.x)); mixFloat(static_cast(lane.meanFilterPanel.y)); mixFloat(static_cast(lane.weakPeakPanel.x)); mixFloat(static_cast(lane.weakPeakPanel.y)); mix(lane.edgePanel.open ? 1ull : 0ull); mix(lane.edgeCenterPanel.open ? 1ull : 0ull); mix(lane.meanFilterPanel.open ? 1ull : 0ull); mix(lane.weakPeakPanel.open ? 1ull : 0ull); mix(lane.edgeValueValid ? 1ull : 0ull); mixDouble(lane.edgeValue); } } // ⚠️ **面板层序也是"全局"状态**:层序变化(点击某个面板把它置顶)会让"谁盖住谁"改变, // 因此必须让所有面板缓存失效、按新层序整层重画(否则贴回的旧像素仍是旧层叠关系)。 for (const PanelRef& ref : m_panelOrder) { mix(static_cast(ref.laneIndex + 2)); mix(static_cast(ref.kind + 2)); } return hash; } std::uint64_t PlotView::PanelSlotKey(int laneIndex, EdgePanelKind kind, const EdgePanelLayout& layout) const { std::uint64_t hash = 1469598103934665603ull; const auto mix = [&hash](std::uint64_t value) { MixPanelKey(hash, value); }; const auto mixFloat = [&mix](float value) { std::uint32_t bits = 0; std::memcpy(&bits, &value, sizeof(bits)); mix(bits); }; const auto mixRect = [&mixFloat](const gd::RectF& rect) { mixFloat(rect.X); mixFloat(rect.Y); mixFloat(rect.Width); mixFloat(rect.Height); }; mix(static_cast(laneIndex + 2)); mix(static_cast(static_cast(kind) + 2)); mixRect(layout.panel); mixRect(layout.title); mixRect(layout.close); mixRect(layout.edit); mixRect(layout.button); mix(static_cast(layout.rows)); for (const gd::RectF& rect : layout.rowChecks) { mixRect(rect); } for (const gd::RectF& rect : layout.rowPoints) { mixRect(rect); } for (const gd::RectF& rect : layout.rowIterations) { mixRect(rect); } for (const gd::RectF& rect : layout.rowStats) { mixRect(rect); } const Lane& lane = Tab().Lanes()[static_cast(laneIndex)]; for (const SignalRef& ref : layout.rowRefs) { mix(ref.IsValid() ? 1ull : 0ull); mix(static_cast(ref.groupIndex)); mix(static_cast(ref.signalIndex)); // 勾选状态与参数直接决定行文本与行高。 mix(lane.MeanFilterEnabled(ref) ? 1ull : 0ull); mix(lane.EdgeCenterEnabled(ref) ? 1ull : 0ull); mix(lane.WeakPeakEnabled(ref) ? 1ull : 0ull); const Lane::MeanFilterItem* meanItem = lane.FindMeanFilterItem(ref); mix(static_cast(meanItem != nullptr ? meanItem->points + 2 : 0)); mix(static_cast(meanItem != nullptr ? meanItem->iterations + 2 : 0)); const Lane::EdgeCenterItem* centerItem = lane.FindEdgeCenterItem(ref); mix(static_cast(centerItem != nullptr ? centerItem->points + 2 : 0)); mix(static_cast(centerItem != nullptr ? centerItem->iterations + 2 : 0)); // 弱峰调整的逐信号参数(调整幅度 % + 迭代次数 + 该信号的滑块上限)直接显示在面板上: // 上限决定迭代次数滑块的圆钮位置,因此也必须参与缓存键。 const Lane::WeakPeakItem* peakItem = lane.FindWeakPeakItem(ref); mix(static_cast(peakItem != nullptr ? peakItem->amountPercent + 2 : 0)); mix(static_cast(peakItem != nullptr ? peakItem->iterations + 2 : 0)); mix(static_cast(peakItem != nullptr ? peakItem->maxIterations + 2 : 0)); } // ⚠️⚠️ **边沿统计的数字本身就是面板内容,必须进缓存键**(用户报告的缺陷根因): // 面板像素图层按本键缓存,而界面上的「上升沿 / 下降沿」是**分帧推进**出来的中间结果 —— // 若这些数字不进键,则「统计推进 → 数字变化」不会让键变化 ⇒ 面板一直贴旧像素 // ⇒ 用户不动鼠标时统计永远停在“统计中…”或某个中间值,**只有移动鼠标**(改变别的键) // 才会显示出早已算完的正确结果。 // ⇒ 把该面板当前要显示的 (上升沿, 下降沿, 是否扫完) 一并 mix 进去:推进会让键变化 ⇒ // 面板重绘 ⇒ 显示随进度更新,直到扫完(扫完后数字不再变,重新命中缓存)。 if (kind == EdgePanelKind::COUNT) { for (const SignalRef& ref : lane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr || !resolved.visible) { continue; // 与绘制时的可见性判定保持一致 } std::size_t rising = 0; std::size_t falling = 0; bool complete = true; EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, lane.edgeValue, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); mix(static_cast(rising)); mix(static_cast(falling)); mix(complete ? 1ull : 0ull); } } else if (kind == EdgePanelKind::CENTER) { for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) { if (rowIndex >= layout.rowStats.size() || layout.rowStats[rowIndex].Width <= 0.0f) { continue; // 与绘制时的条件一致:只有这一排才显示统计数字 } const SignalRef& ref = layout.rowRefs[rowIndex]; const ResolvedSignal resolved = ResolveSignal(ref); const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr || item == nullptr) { continue; } std::size_t rising = 0; std::size_t falling = 0; bool complete = true; EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, item->points, item->iterations, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); mix(static_cast(rising)); mix(static_cast(falling)); mix(complete ? 1ull : 0ull); } } // 弱峰调整的滑块行矩形(每信号两行)。 for (const gd::RectF& rect : layout.rowLevelTracks) { mixRect(rect); } for (const gd::RectF& rect : layout.rowIterTracks) { mixRect(rect); } for (const gd::RectF& rect : layout.rowIterValues) { mixRect(rect); } // 弱峰调整每行滑块右侧的复位按钮(悬停高亮依赖它的精确矩形)。 for (const gd::RectF& rect : layout.rowLevelResets) { mixRect(rect); } for (const gd::RectF& rect : layout.rowIterResets) { mixRect(rect); } // 鼠标落在面板内时,✕ / 复选框 / 滑块 / 底部按钮的悬停态都依赖精确坐标。 const float margin = 6.0f * m_scale; const gd::RectF hot(layout.panel.X - margin, layout.panel.Y - margin, layout.panel.Width + margin * 2.0f, layout.panel.Height + margin * 2.0f); if (UiPointInRect(hot, static_cast(m_mouseX), static_cast(m_mouseY))) { mix(static_cast(m_mouseX + 4096)); mix(static_cast(m_mouseY + 4096)); } return hash; } void PlotView::PaintEdgeCursor(gd::Graphics& g, const std::vector& lanes) { if (m_tab == nullptr || m_dataSet == nullptr) { return; } std::vector& tabLanes = m_tab->Lanes(); const std::size_t count = std::min(lanes.size(), tabLanes.size()); const std::uint32_t color = CursorColor(); const UiInk ink = UiContrastInk(color); // ---- 横向光标(线与两端把手 + 左侧幅值读数)---- for (std::size_t index = 0; index < count; ++index) { Lane& tabLane = tabLanes[index]; if (!tabLane.edgeCount) { continue; } const LaneVisual& visual = lanes[index]; const float clipTop = std::max(visual.rect.Y, m_plotRect.Y); const float clipBottom = std::min(visual.rect.Y + visual.rect.Height, m_plotRect.Y + m_plotRect.Height); if (clipBottom <= clipTop) { continue; } // 首次绘制时把横向光标落在可见幅值范围的中点。 if (!tabLane.edgeValueValid) { tabLane.edgeValue = (visual.yMin + visual.yMax) * 0.5; tabLane.edgeValueValid = true; } const float lineY = std::max(clipTop, std::min(ValueToY(tabLane.edgeValue, visual), clipBottom)); const float left = visual.rect.X; const float right = visual.rect.X + visual.rect.Width; // 横线与纵向光标同色同宽;两端各画一个尖端朝内的三角把手。 UiDrawLine(g, left, lineY, right, lineY, color, 1.4f * m_scale); const float arm = 5.0f * m_scale; gd::SolidBrush handle(ToGdiColor(color)); gd::PointF leftHandle[3]; leftHandle[0] = gd::PointF(left, lineY - arm); leftHandle[1] = gd::PointF(left, lineY + arm); leftHandle[2] = gd::PointF(left + arm * 1.4f, lineY); g.FillPolygon(&handle, leftHandle, 3); gd::PointF rightHandle[3]; rightHandle[0] = gd::PointF(right, lineY - arm); rightHandle[1] = gd::PointF(right, lineY + arm); rightHandle[2] = gd::PointF(right - arm * 1.4f, lineY); g.FillPolygon(&handle, rightHandle, 3); // 幅值读数:靠网格左侧、纵向居中于横线(与纵向光标的时间读数同款胶囊)。 // **必须让开左端的三角把手**(把手横向占 [left, left + arm*1.4]), // 否则数值胶囊会盖住把手(用户明确要求向右挪一些)。 const std::wstring text = FormatValue(tabLane.edgeValue); const float textWidth = std::max( 56.0f * m_scale, UiMeasureTextWidth(m_fonts->monoSmall, text) + 16.0f * m_scale); const float textHeight = 18.0f * m_scale; const float labelLeft = left + (arm * 1.4f + 4.0f * m_scale); const float labelTop = std::max(clipTop + 1.0f * m_scale, std::min(lineY - textHeight * 0.5f, clipBottom - textHeight - 1.0f * m_scale)); const gd::RectF labelRect(labelLeft, labelTop, textWidth, textHeight); UiFillRoundRect(g, labelRect, 3.0f * m_scale, WithAlpha(color, 0xE0)); DrawLabelOutline(g, labelRect, 3.0f * m_scale, ink.outline); UiText(g, text, m_fonts->monoSmall, labelRect, ink.text, TextAlign::CENTER); // 幅值超出视界时(横线被贴边显示),在数值胶囊的**上方/下方**画一个方向三角, // 与常规纵向光标数据点越界的处理方式一致;三角与胶囊**水平居中对齐**。 const float rawLineY = ValueToY(tabLane.edgeValue, visual); const bool offUp = (rawLineY < clipTop - 0.5f); const bool offDown = (rawLineY > clipBottom + 0.5f); if (offUp || offDown) { const float tipArm = 5.0f * m_scale; const float depth = tipArm * 1.4f; const float centerX = labelRect.X + labelRect.Width * 0.5f; // 三角整体贴在胶囊外侧(上方越界 → 胶囊上方的向上三角)。 const float anchorY = offUp ? (labelRect.Y - depth * 0.5f - 1.0f * m_scale) : (labelRect.Y + labelRect.Height + depth * 0.5f + 1.0f * m_scale); gd::PointF outer[3]; if (offUp) { outer[0] = gd::PointF(centerX - tipArm, anchorY + depth * 0.5f); outer[1] = gd::PointF(centerX + tipArm, anchorY + depth * 0.5f); outer[2] = gd::PointF(centerX, anchorY - depth * 0.5f); } else { outer[0] = gd::PointF(centerX - tipArm, anchorY - depth * 0.5f); outer[1] = gd::PointF(centerX + tipArm, anchorY - depth * 0.5f); outer[2] = gd::PointF(centerX, anchorY + depth * 0.5f); } gd::SolidBrush tipBrush(ToGdiColor(color)); g.FillPolygon(&tipBrush, outer, 3); } } // ---- 统计浮动面板(最后绘制,覆盖在波形之上)---- // 「边沿计数」「边沿计数(中心轴)」「均值滤波」「弱峰调整」共用同一套样式, // 仅标题、内容行与底部按钮不同。 // // 面板是**静态**内容:只有被拖动 / 悬停 / 参数变化的那一个真正重画, // 其余直接贴上一帧缓存好的像素(15 个面板全画一遍实测约 33ms, // 而贴图 <1ms —— 拖动弱峰调整滑块时的主要卡顿就来自这一层)。 // 「上升沿 / 下降沿」两列排版(列起点固定 ⇒ 多条信号的文字严格垂直对齐)。 // ⚠️ 未完成时**两个数字后面都带 `…`**(旧实现只在整行末尾追加一次, // 视觉上只跟在“下降沿”的数字后面;用户明确要求为“上升沿”也补上这个尾缀)。 const auto paintCounts = [this, &g](const gd::RectF& rect, std::size_t rising, std::size_t falling, bool complete) { if (EdgeCountsPending(rising, falling, complete)) { UiText(g, L"统计中…", m_fonts->monoSmall, rect, m_theme->textMuted, TextAlign::LEFT); return; } const float gap = kEdgePanelStatColumnGap * m_scale; const float columnWidth = std::max(10.0f, (rect.Width - gap) * 0.5f); UiText(g, EdgeRisingText(rising, complete), m_fonts->monoSmall, gd::RectF(rect.X, rect.Y, columnWidth, rect.Height), m_theme->textMuted, TextAlign::LEFT); UiText(g, EdgeFallingText(falling, complete), m_fonts->monoSmall, gd::RectF(rect.X + columnWidth + gap, rect.Y, columnWidth, rect.Height), m_theme->textMuted, TextAlign::LEFT); }; std::vector nextSlots; std::vector dirtyRects; const std::uint64_t panelGlobalKey = PanelGlobalKey(); // 编辑态(输入框 + 插入符)逐个面板变化太细碎,直接整体不缓存。 const bool panelCacheReady = (m_editTarget == PlotEditTarget::NONE) && EnsurePanelCache(); const bool panelGlobalSame = panelCacheReady && m_panelValid && panelGlobalKey == m_panelGlobalKey; // ⚠️ **按层序从下往上绘制**(与命中测试、像素缓存的层叠顺序完全一致): // `m_panelOrder` 队尾 = 最上层 = 最后绘制。 SyncPanelOrder(); for (const PanelRef& panelRef : m_panelOrder) { const EdgePanelKind kind = static_cast(panelRef.kind); const std::size_t index = static_cast(panelRef.laneIndex); const bool centerAxis = (kind == EdgePanelKind::CENTER); const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool weakPeak = (kind == EdgePanelKind::PEAK); // 「均值滤波」与「弱峰调整」共用「逐条目 + 底部按钮」排布。 const bool itemized = (centerAxis || meanFilter || weakPeak); const bool hasButton = (meanFilter || weakPeak); { if (index >= count) { continue; } const Lane& tabLane = tabLanes[index]; const EdgePanelLayout layout = EdgePanelLayoutFor(static_cast(index), kind); const gd::RectF& panel = layout.panel; if (panel.Width <= 0.0f || panel.Height <= 0.0f) { continue; } // 面板自身的内容指纹;命中缓存时直接贴图,本帧不再重画这个面板。 // ⚠️ 缓存像素里含**它与它下面的一切**(面板背后的波形、被它遮住的其他面板), // 因此只要有一个面板重画,所有「在它之后绘制、且与它重叠」的面板也必须重画, // 否则后画者会把旧像素重新盖回去(面板默认位置本来就常常叠在一起)。 const float cacheMargin = 4.0f * m_scale; const gd::RectF cachedRect(panel.X - cacheMargin, panel.Y - cacheMargin, panel.Width + cacheMargin * 2.0f, panel.Height + cacheMargin * 2.0f); bool forcedDirty = false; for (const gd::RectF& dirtyRect : dirtyRects) { if (dirtyRect.IntersectsWith(cachedRect)) { forcedDirty = true; break; } } std::uint64_t slotKey = 0; const std::size_t slotIndex = nextSlots.size(); if (panelCacheReady) { slotKey = PanelSlotKey(static_cast(index), kind, layout); if (panelGlobalSame && !forcedDirty) { const PanelSlot* hit = nullptr; for (const PanelSlot& slot : m_panelSlots) { if (slot.laneIndex == static_cast(index) && slot.kind == static_cast(kind)) { hit = &slot; break; } } if (hit != nullptr && hit->key != 0 && hit->key == slotKey) { HDC target = g.GetHDC(); if (target != nullptr) { BitBlt(target, hit->x0, hit->y0, hit->x1 - hit->x0, hit->y1 - hit->y0, m_panelDc, hit->x0, hit->y0, SRCCOPY); g.ReleaseHDC(target); } nextSlots.push_back(*hit); continue; } } } nextSlots.push_back(PanelSlot()); dirtyRects.push_back(cachedRect); const float titleHeight = kEdgePanelTitleHeight * m_scale; const float rowHeight = kEdgePanelRowHeight * m_scale; const float pad = kEdgePanelPad * m_scale; // 面板主体 + 标题栏。 // 投影响用两张大面积的半透明圆角矩形模拟,代价不低;交互期间(快速帧)省略它, // 让拖动参数滑块时面板重绘不再被投影拖累(松手后的全质量帧会补回投影)。 if (!m_fastFrame) { UiFillRoundRect(g, gd::RectF(panel.X + 1.0f * m_scale, panel.Y + 2.0f * m_scale, panel.Width, panel.Height), 5.0f * m_scale, 0x14000000u); } UiFillRoundRect(g, panel, 5.0f * m_scale, WithAlpha(m_theme->cardBg, 0xF4)); UiDrawRoundRect(g, panel, 5.0f * m_scale, m_theme->borderStrong, 1.0f); UiFillRoundRect(g, gd::RectF(panel.X, panel.Y, panel.Width, titleHeight), 5.0f * m_scale, WithAlpha(m_theme->accent, 0x2A)); // 标题:与左侧信号面板一致的 “GX” 徽标。 const float badgeWidth = kLaneBadgeWidth * m_scale; const float badgeHeight = kLaneBadgeHeight * m_scale; const gd::RectF badgeRect(panel.X + pad, panel.Y + (titleHeight - badgeHeight) * 0.5f, badgeWidth, badgeHeight); 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(index + 1), m_fonts->monoSmall, badgeRect, m_theme->text, TextAlign::CENTER); const std::wstring title = meanFilter ? std::wstring(L"均值滤波") : (weakPeak ? std::wstring(kWeakPeakTitle) : ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") + EdgeRangeSuffix())); UiText(g, title, m_fonts->uiSmall, gd::RectF(badgeRect.X + badgeRect.Width + 8.0f * m_scale, panel.Y, panel.Width - badgeRect.Width - 8.0f * m_scale - titleHeight, titleHeight), m_theme->textStrong, TextAlign::LEFT); // 关闭按钮 ✕:悬停时高亮提示(鼠标光标本身不改变样式)。 const gd::RectF& closeRect = layout.close; const bool closeHot = (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && UiPointInRect(closeRect, static_cast(m_mouseX), static_cast(m_mouseY)); if (closeHot) { UiFillRoundRect(g, gd::RectF(closeRect.X + 3.0f * m_scale, closeRect.Y + 3.0f * m_scale, closeRect.Width - 6.0f * m_scale, closeRect.Height - 6.0f * m_scale), 3.0f * m_scale, WithAlpha(m_theme->danger, 0x33)); } const float crossPad = 8.0f * m_scale; const std::uint32_t crossColor = closeHot ? m_theme->danger : m_theme->textMuted; UiDrawLine(g, closeRect.X + crossPad, closeRect.Y + crossPad, closeRect.X + closeRect.Width - crossPad, closeRect.Y + closeRect.Height - crossPad, crossColor, 1.6f * m_scale); UiDrawLine(g, closeRect.X + closeRect.Width - crossPad, closeRect.Y + crossPad, closeRect.X + crossPad, closeRect.Y + closeRect.Height - crossPad, crossColor, 1.6f * m_scale); // 「中心轴边沿计数」/「均值滤波」/「弱峰调整」面板都采用「逐条目」排布: // 第一排 = 名称 + 复选框,第二排 = 两个参数输入框(仅勾选的条目, // 弱峰调整没有参数输入框),中心轴再多第三排 = 对应的统计结果。 if (itemized) { for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) { const SignalRef& ref = layout.rowRefs[rowIndex]; const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr) { continue; } const std::uint32_t signalColor = StyleColor(*resolved.style); const gd::RectF& checkRect = layout.rowChecks[rowIndex]; const bool checked = meanFilter ? tabLane.MeanFilterEnabled(ref) : (weakPeak ? tabLane.WeakPeakEnabled(ref) : tabLane.EdgeCenterEnabled(ref)); // 名称:第一排左侧,避开右侧复选框(超长时省略)。 const float nameWidth = std::min(std::max(20.0f, checkRect.X - (panel.X + pad) - 6.0f * m_scale), kEdgePanelNameMaxWidth * m_scale); UiText(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth), m_fonts->uiSmall, gd::RectF(panel.X + pad, checkRect.Y - 3.0f * m_scale, nameWidth, checkRect.Height + 6.0f * m_scale), signalColor, TextAlign::LEFT); UiDrawCheckBox(g, checkRect, checked ? 1 : 0, 3.0f * m_scale); if (!checked) { continue; } // MEAN / CENTER 的平滑参数条目(弱峰调整用自己的 `WeakPeakItem`)。 const Lane::MeanFilterItem* item = (meanFilter || centerAxis) ? (meanFilter ? tabLane.FindMeanFilterItem(ref) : tabLane.FindEdgeCenterItem(ref)) : nullptr; // 「弱峰调整」:每个被勾选的信号各有两行**滑块** //(调整幅度 -200% – +200% / 迭代次数 1 … 该信号自定义上限)。 // 迭代次数行的右侧数值可点击 → 整行变为输入框以修改滑块上限。 if (weakPeak) { const Lane::WeakPeakItem* peakItem = tabLane.FindWeakPeakItem(ref); if (peakItem != nullptr && rowIndex < layout.rowLevelTracks.size() && layout.rowLevelTracks[rowIndex].Width > 0.0f) { // 两行滑块的「标签」始终绘制(编辑上限时也要能看出在改哪一项); // 「轨道 + 数值」在编辑上限时改由 `PaintEditField` 画成输入框。 const auto paintSliderLabel = [&](const gd::RectF& track, const wchar_t* label) { const float labelColumn = WeakPeakLabelColumnWidth(m_fonts->uiSmall); const float labelLeft = track.X - 6.0f * m_scale - labelColumn; UiText(g, label, m_fonts->uiSmall, gd::RectF(labelLeft, track.Y - 8.0f * m_scale, labelColumn, track.Height + 16.0f * m_scale), m_theme->textMuted, TextAlign::LEFT); }; const auto paintSliderBody = [&](const gd::RectF& track, double ratio, const std::wstring& valueText, bool active) { UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale); UiText(g, valueText, m_fonts->monoSmall, gd::RectF(track.X + track.Width + 6.0f * m_scale, track.Y - 8.0f * m_scale, WeakPeakValueColumnWidth(m_fonts->monoSmall), track.Height + 16.0f * m_scale), m_theme->textStrong, TextAlign::LEFT); }; // 每行滑块右侧的**复位控件**(与「系数/偏移」二级面板里的还原图标同一套绘制): // 悬停时高亮 + 手型光标,点击把该行参数恢复为默认值(幅度 0 / 迭代 1 且上限回初始值)。 const auto paintSliderReset = [&](const gd::RectF& rect) { if (rect.Width <= 0.0f) { return; } const bool hot = (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && UiPointInRect(rect, static_cast(m_mouseX), static_cast(m_mouseY)); if (hot) { UiFillRoundRect( g, gd::RectF(rect.X - 1.0f * m_scale, rect.Y - 1.0f * m_scale, rect.Width + 2.0f * m_scale, rect.Height + 2.0f * m_scale), 3.0f * m_scale, WithAlpha(m_theme->accent, 0x33)); } PaintPanelResetIcon(g, rect, hot ? m_theme->accent : m_theme->textMuted, m_scale); }; const gd::RectF& levelTrack = layout.rowLevelTracks[rowIndex]; const gd::RectF& iterTrack = layout.rowIterTracks[rowIndex]; paintSliderLabel(levelTrack, kWeakPeakLevelLabel); paintSliderLabel(iterTrack, kWeakPeakIterationsSliderLabel); const bool levelActive = (m_dragMode == PlotDragMode::PEAK_LEVEL) && (m_peakDragLane == static_cast(index)) && (m_peakDragRef == ref); const double levelRatio = (static_cast(std::max(kWeakPeakAmountMin, std::min(kWeakPeakAmountMax, peakItem->amountPercent))) / (100.0 * kWeakPeakSliderScale) + 1.0) * 0.5; paintSliderBody(levelTrack, levelRatio, (peakItem->amountPercent > 0 ? L"+" : L"") + std::to_wstring(peakItem->amountPercent) + L"%", levelActive); paintSliderReset(layout.rowLevelResets[rowIndex]); // 迭代次数滑块:量程 = 1 … 该信号自己的上限。 const int peakMax = std::max(kWeakPeakIterationsMin, std::min(kWeakPeakIterationsMaxLimit, peakItem->maxIterations)); const int peakIter = std::max( kWeakPeakIterationsMin, std::min(peakMax, peakItem->iterations)); const double iterRatio = (peakMax > kWeakPeakIterationsMin) ? (static_cast(peakIter - kWeakPeakIterationsMin) / static_cast(peakMax - kWeakPeakIterationsMin)) : 0.0; const bool iterActive = (m_dragMode == PlotDragMode::PEAK_ITERATIONS) && (m_peakDragLane == static_cast(index)) && (m_peakDragRef == ref); const bool editingMax = (m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX) && (m_editLane == static_cast(index)) && (m_editMeanRef == ref); if (!editingMax) { paintSliderBody(iterTrack, iterRatio, std::to_wstring(peakIter), iterActive); paintSliderReset(layout.rowIterResets[rowIndex]); } } } // MEAN / CENTER:第二排的两个参数输入框。 if (!weakPeak) { const int shownPoints = (item != nullptr) ? item->points : 5; const int shownOrder = (item != nullptr) ? item->iterations : 1; const PlotEditTarget pointsTarget = meanFilter ? PlotEditTarget::MEAN_FILTER_POINTS : PlotEditTarget::EDGE_CENTER_POINTS; const PlotEditTarget orderTarget = meanFilter ? PlotEditTarget::MEAN_FILTER_ITERATIONS : PlotEditTarget::EDGE_CENTER_ITERATIONS; const auto paintField = [&](const gd::RectF& rect, const wchar_t* label, int value, PlotEditTarget target) { if (rect.Width <= 0.0f) { return; } const float labelWidth = UiMeasureTextWidth(m_fonts->uiSmall, label); UiText(g, label, m_fonts->uiSmall, gd::RectF(rect.X, rect.Y, labelWidth, rect.Height), m_theme->textMuted, TextAlign::LEFT); const float fieldWidth = std::max(kEdgePanelEditFieldWidth * m_scale * 0.75f, UiMeasureTextWidth(m_fonts->monoSmall, std::to_wstring(value)) + 18.0f * m_scale); const float fieldLeft = rect.X + rect.Width - fieldWidth; const float fieldHeight = rect.Height - 4.0f * m_scale; const gd::RectF fieldRect(fieldLeft, rect.Y + 2.0f * m_scale, fieldWidth, fieldHeight); const bool editingThis = (m_editTarget == target) && (m_editLane == static_cast(index)) && (m_editMeanRef == ref); if (editingThis) { UiDrawEditField(g, fieldRect, m_edit, m_fonts->monoSmall, m_scale); } else { UiFillRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->pressed); UiDrawRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->border, 1.0f); UiText(g, std::to_wstring(value), m_fonts->monoSmall, fieldRect, m_theme->textStrong, TextAlign::CENTER); } }; paintField(layout.rowPoints[rowIndex], L"均值点数 X", shownPoints, pointsTarget); paintField(layout.rowIterations[rowIndex], kWeakPeakIterationsLabel, shownOrder, orderTarget); } // 第三排(中心轴):该信号在当前参数下的上升沿/下降沿统计结果。 if (centerAxis && rowIndex < layout.rowStats.size() && layout.rowStats[rowIndex].Width > 0.0f) { const gd::RectF& statsRect = layout.rowStats[rowIndex]; const int statsPoints = (item != nullptr) ? item->points : 5; const int statsOrder = (item != nullptr) ? item->iterations : 1; std::size_t rising = 0; std::size_t falling = 0; bool statsComplete = false; EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, statsPoints, statsOrder, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, statsComplete); // 两列排版(列起点固定)⇒ 多条信号的“上升沿 / 下降沿”垂直对齐。 paintCounts(statsRect, rising, falling, statsComplete); } } } float rowY = panel.Y + titleHeight + pad; for (const SignalRef& ref : tabLane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible) { continue; } if (itemized) { // 逐条目绘制已在上面完成。 continue; } const std::uint32_t signalColor = StyleColor(*resolved.style); const float textWidth = panel.Width - pad * 2.0f; // 名称:超长时省略(面板宽度被 `kEdgePanelNameMaxWidth` 限制,不会把面板顶得很长)。 UiText(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, std::min(textWidth, kEdgePanelNameMaxWidth * m_scale)), m_fonts->uiSmall, gd::RectF(panel.X + pad, rowY, textWidth, rowHeight), signalColor, TextAlign::LEFT); std::size_t rising = 0; std::size_t falling = 0; bool complete = true; EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, tabLane.edgeValue, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); paintCounts(gd::RectF(panel.X + pad, rowY + rowHeight, textWidth, rowHeight), rising, falling, complete); rowY += rowHeight * 2.0f; } // 逐条目面板底部按钮行:「构建为新信号」(悬停高亮)。 if (hasButton) { const gd::RectF& buttonRect = layout.button; if (buttonRect.Width > 0.0f) { const gd::RectF inner(buttonRect.X, buttonRect.Y + 2.0f * m_scale, buttonRect.Width, buttonRect.Height - 4.0f * m_scale); const bool hot = (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && UiPointInRect(inner, static_cast(m_mouseX), static_cast(m_mouseY)); UiFillRoundRect(g, inner, 4.0f * m_scale, hot ? WithAlpha(m_theme->accent, 0x3A) : m_theme->cardBg); UiDrawRoundRect(g, inner, 4.0f * m_scale, m_theme->accent, 1.2f); UiText(g, kBuildSignalLabel, m_fonts->uiSmall, inner, m_theme->accent, TextAlign::CENTER); } } // 面板画完了:把它刚画进后备缓冲区的像素(连同缓存键)存进面板图层, // 下一帧只要键不变就直接贴图。矩形外的 4 逻辑像素边距用于容纳投影。 PanelSlot& stored = nextSlots[slotIndex]; stored.laneIndex = static_cast(index); stored.kind = static_cast(kind); stored.key = 0; if (panelCacheReady && slotKey != 0) { const int x0 = std::max(0, static_cast(std::floor(cachedRect.X))); const int y0 = std::max(0, static_cast(std::floor(cachedRect.Y))); const int x1 = std::max(x0, static_cast(std::ceil(cachedRect.X + cachedRect.Width))); const int y1 = std::max(y0, static_cast(std::ceil(cachedRect.Y + cachedRect.Height))); const gd::RectF layerRect(static_cast(x0), static_cast(y0), static_cast(x1 - x0), static_cast(y1 - y0)); if (x1 > x0 && y1 > y0 && PanelRectInsideLayer(layerRect)) { HDC source = g.GetHDC(); if (source != nullptr) { const BOOL ok = BitBlt(m_panelDc, x0, y0, x1 - x0, y1 - y0, source, x0, y0, SRCCOPY); g.ReleaseHDC(source); if (ok != FALSE) { stored.key = slotKey; stored.x0 = x0; stored.y0 = y0; stored.x1 = x1; stored.y1 = y1; } } } } } } m_panelSlots = std::move(nextSlots); m_panelGlobalKey = panelGlobalKey; m_panelValid = panelCacheReady; } void PlotView::PaintEditField(gd::Graphics& g) { const gd::RectF fieldRect = EditFieldRect(); if (fieldRect.Width <= 0.0f || fieldRect.Height <= 0.0f) { return; } // 编辑态:强调色外框 + 选区高亮 + 插入符,首次键入即整体替换(与常规输入框一致)。 UiDrawEditField(g, fieldRect, m_edit, m_fonts->monoSmall, m_scale); // 「迭代次数 N 上限」:输入框**以空内容开始**,未输入任何内容时以灰色提示当前上限值。 // 提示文案 = “输入上限(当前N)”(用户明确要求:旧实现只显示一个孤立的数字,缺少说明)。 if (m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX && m_edit.Text().empty()) { const int currentMax = m_peakEditCurrentMax > 0 ? m_peakEditCurrentMax : kWeakPeakIterationsDefaultMax; const std::wstring hint = L"输入上限(当前" + std::to_wstring(currentMax) + L")"; const float hintLeft = fieldRect.X + 11.0f * m_scale; UiTextAtClipped(g, hint, m_fonts->monoSmall, hintLeft, gd::RectF(hintLeft, fieldRect.Y, std::max(0.0f, fieldRect.X + fieldRect.Width - hintLeft - 4.0f * m_scale), fieldRect.Height), m_theme->textMuted, fieldRect); } } void PlotView::PaintSignalInteraction(gd::Graphics& g) { if (m_tab == nullptr || m_dataSet == nullptr || m_lanes.empty()) { return; } // 拖拽中:虚影跟随鼠标纵向位移,同时在目标网格内高亮落点。 if (m_dragMode == PlotDragMode::MOVE_SIGNAL && m_dragSignal.IsValid()) { const ResolvedSignal resolved = ResolveSignal(m_dragSignal); const int sourceLane = m_tab->LaneOfSignal(m_dragSignal); if (resolved.data == nullptr || resolved.style == nullptr || sourceLane < 0 || sourceLane >= static_cast(m_lanes.size())) { return; } // 虚影:以源网格的幅值映射构建波形,再按鼠标位移整体平移。 // 第一帧位移为 0,因此虚影必然精确落在鼠标下方(曲线形态与抓住时完全一致); // 之后纵向与横向均自由跟随,形成“拖着曲线走”的观感。 const LaneVisual& sourceVisual = m_lanes[static_cast(sourceLane)]; const float offsetX = static_cast(m_dragMouseX - m_dragStartX); const float offsetY = static_cast(m_dragMouseY - m_dragStartY); { const int saveState = g.Save(); g.SetClip(gd::RectF(m_plotRect.X, m_plotRect.Y, m_plotRect.Width, m_plotRect.Height), gd::CombineModeIntersect); // 虚影的几何与源网格里的信号完全一致,直接复用折线顶点缓存, // 再用平移变换让整体跟随鼠标:避免每帧重新构建并逐点搬运上万顶点。 const PolylineCacheEntry* ghostSource = AcquirePolyline(m_dragSignal, resolved, sourceVisual); if (ghostSource == nullptr) { g.Restore(saveState); return; } const std::vector& points = ghostSource->points; const std::vector& breaks = ghostSource->breaks; g.TranslateTransform(offsetX, offsetY); const DrawMode mode = resolved.style->mode; const bool drawLine = (mode != DrawMode::SCATTER); const bool drawScatter = (mode == DrawMode::SCATTER || mode == DrawMode::LINE_SCATTER); // 虚影保持原信号的颜色与样式(不再是虚线),仅以轻微透明区分“尚未落地”。 const std::uint32_t ghostColor = WithAlpha(resolved.style->color, static_cast( std::max(0.35, static_cast(resolved.style->opacity)) * 255.0 + 0.5)); if (drawLine && !breaks.empty()) { if (ghostSource->bandMode && !ghostSource->bandBreaks.empty()) { // 稠密包络虚影同样用一次 FillPath(见 PaintLane 的说明)。 gd::GraphicsPath bandPath; bandPath.SetFillMode(gd::FillModeWinding); for (std::size_t k = 0; k < ghostSource->bandBreaks.size(); ++k) { const std::size_t start = ghostSource->bandBreaks[k]; const std::size_t stop = (k + 1 < ghostSource->bandBreaks.size()) ? ghostSource->bandBreaks[k + 1] : ghostSource->band.size(); const std::size_t total = stop - start; if (total < 4 || (total % 2) != 0) { continue; } const std::size_t half = total / 2; bandPath.StartFigure(); bandPath.AddLines(ghostSource->band.data() + start, static_cast(half)); bandPath.AddLines(ghostSource->band.data() + start + half, static_cast(half)); bandPath.CloseFigure(); } if (bandPath.GetPointCount() > 0) { gd::SolidBrush brush(ToGdiColor(ghostColor)); g.FillPath(&brush, &bandPath); } } else { gd::Pen ghost(ToGdiColor(ghostColor), std::max(1.0f, resolved.style->lineWidth) * m_scale); ghost.SetLineJoin(gd::LineJoinRound); ghost.SetStartCap(gd::LineCapRound); ghost.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(&ghost, points.data() + start, static_cast(stop - start)); } } } if (drawScatter && !points.empty()) { const float radius = static_cast( ClampDouble(static_cast(resolved.style->lineWidth) * kScatterRadiusScale, kMinScatterRadius, kMaxScatterRadius) * m_scale); const std::size_t scatterStride = std::max(1, points.size() / kMaxScatterPoints); // 与主绘制一致:合并成一条路径一次填充,并显式使用缠绕规则避免重叠处出现空洞。 gd::GraphicsPath ghostPath; ghostPath.SetFillMode(gd::FillModeWinding); for (std::size_t k = 0; k < points.size(); k += scatterStride) { ghostPath.AddEllipse(points[k].X - radius, points[k].Y - radius, radius * 2.0f, radius * 2.0f); } gd::SolidBrush brush(ToGdiColor(ghostColor)); if (ghostPath.GetPointCount() > 0) { g.FillPath(&brush, &ghostPath); } } else if (drawScatter && resolved.data->finiteCount == 1) { // ⚠️ **只有单个有效数据点**的信号:折线缓存给不出顶点(`points` 可能为空), // 因此直接按数据点的屏幕坐标画那个圆点,否则拖动时虚影会**整个消失** //(用户 2026-10-15 要求单点信号也能拖拽换网格)。 gd::PointF dot; if (SinglePointScreenPos(resolved, sourceVisual, dot)) { const float radius = SinglePointDotRadius(resolved); gd::SolidBrush brush(ToGdiColor(ghostColor)); g.FillEllipse(&brush, dot.X - radius, dot.Y - radius, radius * 2.0f, radius * 2.0f); } } g.Restore(saveState); } // 落点提示:高亮鼠标所在网格并给出文字说明。 const int target = (m_dragTargetLane >= 0) ? m_dragTargetLane : sourceLane; if (target >= 0 && target < static_cast(m_lanes.size())) { const gd::RectF& targetRect = m_lanes[static_cast(target)].rect; const float visibleTop = std::max(targetRect.Y, m_plotRect.Y); const float visibleBottom = std::min(targetRect.Y + targetRect.Height, m_plotRect.Y + m_plotRect.Height); if (visibleBottom > visibleTop) { gd::Pen frame(ToGdiColor(m_theme->accent), 2.0f); g.DrawRectangle(&frame, targetRect.X, visibleTop, targetRect.Width, visibleBottom - visibleTop); const std::wstring hint = (target == sourceLane) ? std::wstring(L"松开左键放回本网格") : (L"移动到网格 " + std::to_wstring(target + 1) + L":松开左键确认"); const float textWidth = UiMeasureTextWidth(m_fonts->uiSmall, hint) + 16.0f * m_scale; const float height = 20.0f * m_scale; float left = targetRect.X + (targetRect.Width - textWidth) * 0.5f; left = std::max(m_plotRect.X, std::min(left, m_plotRect.X + m_plotRect.Width - textWidth)); const gd::RectF pill(left, visibleTop + 4.0f * m_scale, textWidth, height); UiFillRoundRect(g, pill, 3.0f * m_scale, m_theme->accent); UiText(g, hint, m_fonts->uiSmall, pill, m_theme->textOnAccent, TextAlign::CENTER); } } return; } // 其它拖拽(光标/平移/中键/横向光标/面板)期间不显示信号悬停提示: // 这些操作不会更新 m_hoverSignal,按下前的值会残留,表现为提示气泡"挂在原地"。 if (m_dragMode != PlotDragMode::NONE) { return; } // 悬停提示:光标贴近曲线时给出可拖拽说明(与时间光标的提示方式一致)。 if (!m_hoverSignal.IsValid()) { return; } const int laneIndex = m_tab->LaneOfSignal(m_hoverSignal); if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) { return; } const ResolvedSignal resolved = ResolveSignal(m_hoverSignal); if (resolved.data == nullptr) { return; } const LaneVisual& lane = m_lanes[static_cast(laneIndex)]; // ⚠️ 「选区放大」模式下**仍保留悬停高亮**,但该模式下拖动是框选、不是拖信号 // ⇒ 提示文案去掉“按住拖拽可移入其它网格”的尾缀,只提示信号名(用户 2026-10-17 要求)。 const std::wstring text = (Tab().ZoomMode() == PlotZoomMode::SELECT) ? resolved.data->name : (resolved.data->name + L" · 按住拖拽可移入其它网格"); // 提示跟随鼠标:在可拖拽区域内持续移动时,提示条也要跟着走。 const float textWidth = UiMeasureTextWidth(m_fonts->uiSmall, text) + 16.0f * m_scale; const float height = 20.0f * m_scale; float left = static_cast(m_mouseX) + 12.0f * m_scale; if (left + textWidth > m_plotRect.X + m_plotRect.Width) { left = m_plotRect.X + m_plotRect.Width - textWidth; } left = std::max(m_plotRect.X, left); float top = static_cast(m_mouseY) + 14.0f * m_scale; top = std::min(top, m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale); top = std::max(top, lane.rect.Y + 2.0f * m_scale); const gd::RectF pill(left, top, textWidth, height); UiFillRoundRect(g, pill, 3.0f * m_scale, WithAlpha(m_theme->readoutBg, 0xF2)); UiDrawRoundRect(g, pill, 3.0f * m_scale, m_theme->accent, 1.0f); UiText(g, text, m_fonts->uiSmall, pill, m_theme->textStrong, TextAlign::CENTER); } void PlotView::PaintScrollBar(gd::Graphics& g) { if (!m_scrollBarVisible || m_laneContentHeight <= m_plotRect.Height) { return; } UiFillRoundRect(g, m_scrollBarRect, kScrollBarThickness * m_scale * 0.5f, m_theme->gridWeak); const float ratio = std::min(1.0f, m_plotRect.Height / m_laneContentHeight); const float thumbHeight = std::max(24.0f * m_scale, m_scrollBarRect.Height * ratio); const float maxScroll = m_laneContentHeight - m_plotRect.Height; const float progress = (maxScroll > 0.0f) ? (Tab().LaneScroll() / maxScroll) : 0.0f; const float thumbY = m_scrollBarRect.Y + progress * (m_scrollBarRect.Height - thumbHeight); const gd::RectF thumb(m_scrollBarRect.X, thumbY, m_scrollBarRect.Width, thumbHeight); UiFillRoundRect(g, thumb, kScrollBarThickness * m_scale * 0.5f, m_theme->borderStrong); } void PlotView::PaintBackToTop(gd::Graphics& g) { if (m_backToTopAlpha <= 0.01f) { return; } const gd::RectF rect = BackToTopRect(); if (rect.Width <= 1.0f) { return; } // 过渡:整个按钮(底色/描边/箭头/投影)按 `m_backToTopAlpha` 淡入淡出。 const float alpha = std::min(1.0f, std::max(0.0f, m_backToTopAlpha)); const auto faded = [alpha](std::uint32_t color) -> std::uint32_t { const std::uint32_t a = static_cast(static_cast((color >> 24) & 0xFFu) * alpha); return (color & 0x00FFFFFFu) | ((a & 0xFFu) << 24); }; const float radius = rect.Width * 0.5f; // 圆形按钮(与图例托底/GX 徽标同一套配色) // 投影:两层递减透明度的圆角矩形(与弹出菜单/浮动面板一致,不引入系统阴影 API)。 UiFillRoundRect(g, gd::RectF(rect.X + 1.0f * m_scale, rect.Y + 2.0f * m_scale, rect.Width, rect.Height), radius, faded(0x1A000000u)); UiFillRoundRect(g, gd::RectF(rect.X, rect.Y + 1.0f * m_scale, rect.Width, rect.Height), radius, faded(0x14000000u)); const bool hot = m_backToTopHover || m_backToTopPressed; UiFillRoundRect(g, rect, radius, faded(hot ? m_theme->accentSoft : m_theme->cardBg)); UiDrawRoundRect(g, rect, radius, faded(hot ? m_theme->accent : m_theme->borderStrong), hot ? 1.6f : 1.0f); // 向上箭头:竖杆 + 两条斜线组成的箭头(自绘,不依赖字体)。 const float cx = rect.X + rect.Width * 0.5f; const float cy = rect.Y + rect.Height * 0.5f; const float depth = rect.Width * kBackToTopArrowDepth; const float halfWidth = rect.Width * kBackToTopArrowHalfWidth; const std::uint32_t ink = faded(hot ? m_theme->accent : m_theme->textStrong); const float stroke = std::max(1.6f, 2.0f * m_scale); UiDrawLine(g, cx, cy + depth, cx, cy - depth, ink, stroke); UiDrawLine(g, cx, cy - depth, cx - halfWidth, cy - depth * 0.15f, ink, stroke); UiDrawLine(g, cx, cy - depth, cx + halfWidth, cy - depth * 0.15f, ink, stroke); } } // namespace jdi