// PlotView 实现分片 —— 绘制(paint):只负责"把内容画出来"。 // // 共用的常量与工具函数在 PlotViewInternal.hpp(命名空间 `pvdetail`,全部 inline)。 // 七个分片的职责划分(按**关注点**,不再按函数名前缀): // PlotView.cpp 视图核心:构造/析构、上下文绑定、布局、坐标映射、视界与平移、 // 网格结构与归属、选区/网格划分、拖动取消 // PlotViewPaint.cpp 绘制:全部 Paint*/Render* 与内联输入框绘制(**本文件**) // PlotViewRaster.cpp 光栅化像素缓存与每帧预算:光晕层、场景缓存、装饰层、每网格宽基底 // 图层、滚动补条、预热("怎么画才不卡") // PlotViewCacheKey.cpp 内容指纹与缓存键:SceneCacheKey / *Hash / 样式与派生快照 / 样式补丁帧 // PlotViewPanels.cpp 几何:浮动面板布局(EdgePanelWidth/EdgePanelLayoutFor)、图例徽标、 // 「自由标记」的取值/胶囊矩形/探测(绘制与命中同源的唯一来源) // PlotViewInteraction.cpp 交互与编辑:命中测试、鼠标与键盘处理、光标形状、内联输入框、 // 浮动面板层序与命中 // PlotViewFeatures.cpp 视图特性与派生数据:光标状态与读数、提示浮层、缩放模式、自适应、 // 光标吸附、单点信号、派生曲线线程池、边沿统计、实验性功能参数 // // ⚠️ 新增函数按**职责**放入对应分片;`PlotView::Paint/PaintLane` 仍只允许定义在本文件。 #include "PlotViewInternal.hpp" #include "UiBox.hpp" #include "UiLayout.hpp" namespace jdi { namespace { /** * @brief 面板渲染期间的文本几何收集器:**记录 + 转发**给外层收集器。 * * 为什么需要它:浮动面板是"先画进像素图层、命中缓存时直接贴图"的。 * 贴图那一帧不会真的画字 —— 若文本框只靠"画的时候顺手收集",它在那一帧就会 * **凭空消失**,观感是"拖动视界时面板上的布局边界不停闪烁"(用户 2026-10-29 报告)。 * * 所以渲染面板时用本收集器:一边把矩形转发给外层(保持正常行为), * 一边存一份进 `PanelSlot::textRects`;命中缓存贴图时再把存下的矩形回放出去。 * * `OnTextLayer` 也必须转发:外层收集器靠它把文本记到正确的绘制层 * (面板层比网格层高一层),否则面板文字会被自己登记的遮挡矩形挖掉。 */ class PanelTextCollector : public UiTextRectSink { public: explicit PanelTextCollector(UiTextRectSink* outer) : m_outer(outer) {} void OnTextRect(const gd::RectF& rect) override { m_rects.push_back(rect); if (m_outer != nullptr) { m_outer->OnTextRect(rect); } } void OnTextLayer(int layer) override { if (m_outer != nullptr) { m_outer->OnTextLayer(layer); } } int TextLayer() const override { return (m_outer != nullptr) ? m_outer->TextLayer() : 0; } /** @brief 外层收集器(面板画完后要还原它)。 */ UiTextRectSink* Outer() const { return m_outer; } /** @brief 本次渲染收集到的文本框。 */ const std::vector& Rects() const { return m_rects; } private: UiTextRectSink* m_outer = nullptr; std::vector m_rects; }; } // namespace 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; // 全部键字段的 64 位哈希(顺序固定;只用于**查找**,命中后再做廉价校验)。 std::uint64_t key = 1469598103934665603ull; const auto mixHash = [&key](std::uint64_t value) { key ^= value; key *= 1099511628211ull; }; const auto mixHashFloat = [&mixHash](float value) { std::uint32_t bits = 0; std::memcpy(&bits, &value, sizeof(bits)); mixHash(static_cast(bits)); }; const auto mixHashDouble = [&mixHash](double value) { std::uint64_t bits = 0; std::memcpy(&bits, &value, sizeof(bits)); mixHash(bits); }; mixHash(static_cast(static_cast(ref.groupIndex)) + 2ull); mixHash(static_cast(static_cast(ref.signalIndex)) + 2ull); mixHash(reinterpret_cast(resolved.group)); mixHash(static_cast(sampleCount)); mixHashDouble(viewMin); mixHashDouble(viewMax); mixHashFloat(m_plotRect.X); mixHashFloat(m_plotRect.Width); mixHashFloat(lane.rect.Y); mixHashFloat(lane.rect.Height); mixHashDouble(lane.yMin); mixHashDouble(lane.yMax); mixHashFloat(gain); mixHash((absolute ? 1ull : 0ull) | (mirrorVertical ? 2ull : 0ull) | (mirrorHorizontal ? 4ull : 0ull)); mixHashFloat(offsetY); mixHashDouble(offsetTime); mixHashFloat(lineWidth); for (const PolylineCacheEntry& entry : m_polylineCache) { // 快路径:只比哈希。命中后再校验三项最可能变化的字段(碰撞代价可忽略)。 if (entry.key != key || entry.sampleCount != sampleCount || !(entry.ref == ref) || entry.viewMin != viewMin || entry.viewMax != viewMax) { continue; } return &entry; } // 未命中:重建并写入缓存(deque 保证已有元素的引用在追加后依然有效)。 // ⚠️ 容量必须**覆盖所有网格的全部信号**:旧实现固定 64 条,283 个信号合并到 // 同一网格时会不断"清空重建"⇒ 绘制与自由标记探测每帧重算全部折线(严重卡顿)。 if (m_polylineCache.size() >= m_polylineCacheCapacity) { m_polylineCache.clear(); } m_polylineCache.push_back(PolylineCacheEntry()); PolylineCacheEntry& entry = m_polylineCache.back(); entry.key = key; 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; // 线宽(设备像素)的一半:包络带在**纵/横两个方向**各外扩这么多。 // ⚠️ 旧实现只在每列上下各外扩 —— 水平段会变粗,但方波的**竖直沿**横向宽度始终是 // 1 个像素列,于是用户把线宽从 1px 拖到 10px 时"竖直方向的沿没有变粗"(2026-10-20 报告)。 // 现在把线宽交给 `EmitEnvelopeBand`,由它对上下包络做横向膨胀。 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, strokeHalf]() { bool spiky = false; EmitEnvelopeBand(segmentColumns, band, bandBreaks, spiky, lane.rect.Height, strokeHalf); 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)); // 包络带每列只记录**精确**的上下包络(线宽在 `EmitEnvelopeBand` 里统一补偿: // 纵向各外扩半个线宽 + 横向窗口膨胀,两个方向都随线宽变化)。 BandColumn bandColumn; bandColumn.x = x; bandColumn.top = yTop; bandColumn.bottom = yBottom; 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::BuildBoxes(UiBoxTable& boxes) { UiComponent::BuildBoxes(boxes); // 本组件的基础绘制层(= 宿主组件表里的下标)。**必须显式记录并复位**: // 下面为浮动面板临时提了一层,若不复位,宿主后续组件的层号会被整体推高。 const int baseLayer = boxes.Layer(); // 层级 1:每个网格的波形区;层级 2:该网格的 GX 编号徽标。 // ⚠️ **绝不能读 `m_decorRects`**:那里存的是"装饰层位图缓存"的登记矩形, // 坐标处在**位图/图层坐标系**(会带负 Y、或超出绘图区),拿它当屏幕矩形会出现 // 横跨工具栏与绘图区的巨型幽灵框(用户报告的"绘制残留"的直接原因)。 // 需要装饰元素的屏幕几何时,请用 `m_legendHits`(屏幕坐标)或 `LaneBadgeRect`。 for (std::size_t index = 0; index < m_lanes.size(); ++index) { const LaneVisual& lane = m_lanes[index]; boxes.Add(lane.rect, 1); // GX 编号徽标:矩形由 `LaneBadgeRect` 统一给出(绘制与命中都用它)。 boxes.Add(LaneBadgeRect(static_cast(index)), 2); } // 层级 2/3:图例条目(托底 + 色块 + 文本),绘制时已登记在 `m_legendHits`。 for (const LegendHit& hit : m_legendHits) { boxes.Add(hit.rect, 2); boxes.Add(hit.swatch, 3); boxes.Add(hit.text, 3); } // 层级 1/2:绘图区里**不属于任何网格**的固定构件。 // 这些曾经全部漏掉(调试时"看得见却没框")—— 它们都已经有成员变量,直接读即可。 // ⚠️ 这些构件都绘制在**浮动面板之前**,因此与网格同层(`baseLayer`)。 boxes.SetLayer(baseLayer); boxes.Add(m_timeAxisRect, 1); // 层级 2/3:时间光标读数标签(避让后的实际矩形,绘制时已登记)。 boxes.Add(m_cursorSpanRect, 2); boxes.AddAll(m_cursorLabelRects, 3); // 下面这三样在 `paintOverlays` 里绘制在**浮动面板之后**(压在面板之上): // 滚动条、右下角「回到最上方」悬浮按钮、内联输入框。 // ⇒ 它们的层号在下面「面板循环」之后统一给(比最上面板再高一层), // 否则与实际像素的层序相反。这里先复位到组件基层。 boxes.SetLayer(baseLayer); // ⚠️⚠️ **浮动面板必须另起绘制层,并把面板矩形登记为遮挡矩形**: // 面板是不透明浮层、且常常**跨多个网格上下排列**,与网格区域重叠。 // 若与网格同层,网格的矩形边线会横穿面板 —— 用户 2026-10-29 报告的 // "网格的布局边界穿透显示在浮动面板上"。`layer` 决定谁压谁, // `AddOccluder` 才真正把下层 box 从面板区域里**挖掉**。 // 层级 2/3/4:5 类实验性功能浮动面板的内部控件。 // 这些矩形由 `EdgePanelLayoutFor` 统一算出(绘制与命中共用),一个循环即可全部上报。 // // ⚠️⚠️ **每个面板各占一个绘制层,且顺序必须是 `m_panelOrder`(底 → 顶)**: // 旧实现把所有面板都放在 `baseLayer + 1` 一层,于是同一层内只能按 `depth` 排序 —— // 下层面板的"行/文本"(depth 4)会画在**上层**面板的本体(depth 2/3)之上, // 表现为"被遮挡的下层面板的边框/文字穿透到上层面板上"(用户 2026-10-30 报告)。 // 层号与 `paintOverlays` 里 `UiSetTextLayer` 的推进**完全同源**。 // // 说明(已知边界):面板层号从 `baseLayer + 1` 起逐面板递增,因此**同时显示 15 个以上** // 面板时会撞上浮层层号(`OverlayLayer(1)` = 组件数 × 3 + 3)。那时只有"菜单不再挖掉 // 面板边框"这一条调试观感受影响;实际布局下同时可见的面板是几个到十几个。 SyncPanelOrder(); const std::size_t panelCount = m_panelOrder.size(); const int topOverlayLayer = baseLayer + 1 + static_cast(panelCount); // ⚠️ **先用最便宜的判据跳过"没有面板"的网格**:`EdgePanelLayoutFor` 内部要取 // 标签页配置、逐项判断种类与开关,单次约 2~3µs;283 个网格 × 5 种 = 1415 次调用 // 实测约 **4ms/帧**(这是"开调试后卡"的主要来源之一)。 // 现在只对**层序表里真正显示出来的面板**算布局(层序表本来就只含展开且启用的面板)。 for (std::size_t ordinal = 0; ordinal < panelCount; ++ordinal) { const PanelRef& panelRef = m_panelOrder[ordinal]; const EdgePanelLayout layout = EdgePanelLayoutFor(panelRef.laneIndex, static_cast(panelRef.kind)); if (!UiRectHasArea(layout.panel)) { continue; } // 本面板独占一层(层号越大越靠上)。 boxes.SetLayer(baseLayer + 1 + static_cast(ordinal)); boxes.AddOccluder(layout.panel); // 面板内部元素的调试框同样裁到面板本身:面板滚动的内容区里, // 滚出可视区的行其矩形仍在(与弹出菜单的条目同一类问题)。 boxes.SetClip(layout.panel); boxes.Add(layout.panel, 2); boxes.Add(layout.title, 3); boxes.Add(layout.close, 3); boxes.Add(layout.disclosure, 3); boxes.Add(layout.badge, 3); boxes.Add(layout.edit, 3); boxes.Add(layout.button, 3); boxes.Add(layout.content, 3); boxes.Add(layout.scrollTrack, 3); boxes.Add(layout.scrollThumb, 4); boxes.Add(layout.opacityTrack, 3); boxes.Add(layout.opacityValue, 3); boxes.Add(layout.showDelta, 3); boxes.Add(layout.showEnum, 3); boxes.AddAll(layout.rowChecks, 4); boxes.AddAll(layout.rowPoints, 4); boxes.AddAll(layout.rowIterations, 4); boxes.AddAll(layout.rowStats, 4); boxes.AddAll(layout.rowLevelTracks, 4); boxes.AddAll(layout.rowLevelValues, 4); boxes.AddAll(layout.rowIterTracks, 4); boxes.AddAll(layout.rowIterValues, 4); boxes.AddAll(layout.rowLevelResets, 4); boxes.AddAll(layout.rowIterResets, 4); boxes.AddAll(layout.rowNameRects, 4); boxes.AddAll(layout.rowNameCloses, 4); boxes.AddAll(layout.rowTriangles, 4); boxes.AddAll(layout.rowHeaders, 4); for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) { boxes.Add(group.header, 3); boxes.Add(group.tableHeader, 3); boxes.Add(group.triangle, 4); boxes.Add(group.close, 4); boxes.Add(group.check, 4); } for (const EdgePanelLayout::MarkerRow& row : layout.markerRows) { boxes.Add(row.rect, 4); boxes.Add(row.close, 4); } } // 收尾:解除面板裁剪并**复位层号**(宿主后续组件依赖它)。 // 浮动面板之上的叠加层(滚动条 / 回到最上方 / 内联输入框)必须比**最上面板**再高一层。 boxes.SetClip(gd::RectF()); boxes.SetLayer(topOverlayLayer); boxes.Add(m_scrollBarRect, 5); boxes.Add(BackToTopRect(), 5); boxes.Add(EditFieldRect(), 5); boxes.SetLayer(baseLayer); } 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]() { // 文本几何的层号必须跟着**真实绘制顺序**走: // 这一段前半部分的文字(光标取值胶囊、读数标签…)在浮动面板**之下**(同组件层), // 而 `PaintEdgeCursor` 之后的一切(面板、输入框、滚动条、回到最上方)在面板之上。 // 少了这一步,胶囊里的文字框会记在比面板更低的层,然后被面板的遮挡矩形挖掉 —— // 或者反过来(旧实现在所有组件之上统一按固定层收集)穿透到面板表面 //(用户 2026-10-29 报告的两处现象)。 const int textLayer = UiGetTextLayer(); PaintHoverHighlight(g); PaintLegendHighlight(g); PaintRubberBand(g); PaintCursor(g, m_lanes); // 「自由标记」的跟随光标与已固定数据点(在统计面板**之下**绘制, // 这样浮动面板仍压在最上层,与其它实验性功能的层序一致)。 PaintFreeMarker(g, m_lanes); // 从这里开始都在面板之上:文本层同步 +1(与 `BuildBoxes` 的 `baseLayer + 1` 一致)。 // ⚠️ `PaintEdgeCursor` 内部会**逐面板**再把层号 +1(每个面板独占一层), // 并在返回前把层号推到**最上面板之上**,因此这里不再写死 `+2`。 UiSetTextLayer(textLayer + 1); 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 || m_editTarget == PlotEditTarget::MEAN_POINTS_MAX || m_editTarget == PlotEditTarget::MEAN_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); } // 复原文本层(宿主按组件下发,回收成进入时的值最保险)。 UiSetTextLayer(textLayer); }; // 网格次序拖拽:静态场景缓存仍然有效,但各网格要按位移重新"摆放"。 // 这里逐网格把缓存中的横向条带平移贴回(几十微秒), // 因此拖动百万级采样的网格时依然流畅,且被拖网格的波形随鼠标自由移动。 // ⚠️ `m_forceFullRepaint`(调试叠加层)下必须跳过:局部贴图会留下上一帧的边界。 if (!m_forceFullRepaint && m_dragMode == PlotDragMode::LANE_REORDER && m_laneDragFrom >= 0 && m_sceneValid) { PaintLaneReorder(g); paintOverlays(); return; } // 静态场景复用(不再局限于拖拽):只要视图几何、信号集合与显示样式都没变, // "绘图区底色 + 全部网格 + 时间轴"就逐像素相同,直接贴图即可。 // 大文件下整幅重绘约 80ms,贴图约 1ms —— 鼠标悬停、拖拽选区这些 // "只改一小块叠加层"的交互因此不再重绘波形。 // 随鼠标变化的内容(悬停高亮、读数框、选区、光标、拖拽虚影、输入框)一律画在贴图之后。 if (!m_forceFullRepaint && SceneCacheMatches() && BlitSceneCache(g)) { // ⚠️ **顺手做一次"空闲预热"**:图层不存在(刚加载 / 网格数量变化)或仍是旧跨度时, // 在这里用既有预算机制**每帧至多补 1 个网格**。若不预热,用户第一次平移 / 选区放大 // 时,`Paint` 会在那一帧里**同步重建全部网格图层**(5 个网格实测 30~80ms)—— // 这正是"选区放大的第一帧特别卡"的根因(该帧其余工作只有约 16ms 的占位拉伸)。 // 命中静态场景缓存 ⇒ 本帧视图与缓存同源(视界没变),因此预热出来的图层不会白费。 WarmLaneLayersStep(); paintOverlays(); return; } // 视界平移 / 派生预览曲线参数变化:画面**基底**(网格/波形)不变,只是横向平移或 // 叠加的派生曲线变化。直接复用「宽缓冲区」已渲染的**基底**像素,只把派生曲线作为 // 叠加层重画 —— 前台 O(1),不重画波形,因此无论放大到多深都保持跟手。 // 判定:网格结构未变(几何指纹一致)且**逐信号外观样式未变** → 属视图/派生变化,可用宽基底; // 外观样式(颜色/粗细/透明度/模式)变化 → 交给下方"样式补丁"分支局部重画。 // ⚠️ 位置必须在"静态场景贴图"**之后**:视界与样式都没变时(典型:鼠标悬停移动) // 应由静态场景贴图命中,绝不能落到这里 —— 否则每两帧就会触发一次"结算整幅重绘", // 悬停反而变卡。而它又必须在"样式补丁"**之前**:派生曲线参数变化(拖弱峰调整 / // 均值滤波滑块)同样只需叠加重画派生曲线,走本分支比"逐网格补丁"更快且质量更高。 // ⚠️ **不要求"正在拖拽"**:非拖拽的视界变化(键盘方向键 / 工具栏)同样走此快路径, // 避免该放大区间"整幅重绘约 124ms"的卡顿。 bool wideUsable = false; if (!m_forceFullRepaint && 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 (!m_forceFullRepaint && 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); // 波形区域底色。 // ⚠️ 「网格叠底」必须用 `ThemeLaneBackground()`:主题的 `cardBg` 与 `plotBg` 过于接近时 // (深色主题)它会自动拉开一点点,保证网格这一层结构在任何主题下都看得见。 UiFillRect(g, m_plotRect, ThemeLaneBackground(*m_theme)); // 图例命中矩形每帧重建,避免残留已消失的信号。 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::PaintHoverHighlight(gd::Graphics& g) { if (m_tab == nullptr) { return; } const bool dragging = (m_dragMode != PlotDragMode::NONE); const int saveState = g.Save(); g.SetClip(m_area, gd::CombineModeIntersect); // 悬停网格:一层很淡的强调色叠加。为了能画在静态场景之上, // 这里用半透明而非不透明混色,观感与原实现基本一致。 // ⚠️ 高亮来源见 `EffectiveHoverLane()`(浮动面板悬停 / 信号列表悬停), // **不再**由"鼠标在网格里"触发。 // ⚠️ 拖拽过程中不画(避免在拖拽中改变已缓存的场景像素)。 const int hoverLane = dragging ? -1 : 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)); } } // 光晕: // * 拖拽中 → 画**被拖拽**的信号(`SceneHaloRef()`)。 // ⚠️ 宽基底图层位图里**不能**烘光晕(`LaneLayerHash` 不含光晕状态,重建一次就会 // 把半透明光晕永久烘进去),因此这里每帧叠加画一遍(离屏位图缓存,~0.5ms)。 // * 未拖拽 → 悬停信号。鼠标在绘图区内直接命中时用 `m_hoverSignal`; // 信号列表 / 样式区悬停时用 `m_externalHoverSignal`(用户要求列表悬停也高亮曲线)。 SignalRef haloRef = dragging ? SceneHaloRef() : m_hoverSignal; if (!haloRef.IsValid() && !dragging) { 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); } 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); } } /** * @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 laneBackground = ThemeLaneBackground(*m_theme); const std::uint32_t minorLineColor = UiBlendOver(laneBackground, 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 在缓存贴图之后绘制。 // // ⚠️ **宽基底图层(`m_renderWide`)必须跳过光晕**: // 图层位图会被反复贴图复用,而 `LaneLayerHash` 不含光晕状态 ⇒ // 拖拽期间重建一次图层就会把半透明光晕**烘进位图**,松手后一直残留。 // 拖拽光晕由 `PaintHoverHighlight` 每帧叠加绘制。 const bool highlighted = (ref == haloRef) && !m_renderWide && !m_renderGridOnly; 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; } // 图例固定在网格左上角:网格被滚动出可视区时整体跳过,避免文字溢出到工具栏。 // // ⚠️ **提前返回前必须先清掉本网格的旧命中矩形**:图例只在"完全可见"时才绘制, // 但命中测试用的是 `m_legendHits`。若网格滚到只露出半条图例带时直接 return, // 上一帧登记的命中矩形会留在原位 —— `LegendAtPoint` 仍会在旧屏幕位置返回该信号, // 于是点击/双击会命中一个**看不见**的图例条目(选中/改名到错误的信号)。 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)) { for (auto iterator = m_legendHits.begin(); iterator != m_legendHits.end();) { if (m_tab->LaneOfSignal(iterator->ref) == laneIndex) { iterator = m_legendHits.erase(iterator); } else { ++iterator; } } 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; } // 枚举文本按**采样值原值**查找(下面会叠加系数/绝对值/镜像等界面变换, // 那些变换只影响画出来的数值,不改变「这个采样值代表哪个状态」)。 const double enumLookupValue = value; // 取值读数必须与画出来的波形一致:同样套用系数/绝对值/镜像。 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")"; } } // 信号带「值 → 文本」枚举表时(如 MDF4 `cc_type = 7`),在数值之后追加 // ` = <枚举文本>` 尾缀(等号两侧各一个空格、不用尖括号)。 // ⚠️ 常规状态(未启用光标)不显示任何枚举文本,观感与以前完全一致。 if (const std::wstring* enumText = resolved.data->enumText.Find(enumLookupValue)) { text += L" = "; text += *enumText; } 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; } // ⚠️ 数据点圆点被底部的「时间读数 / Δt 面板」盖住时,圆点根本看不见 //(时间读数在取值标注之后绘制、直接盖在上面)。此时改成**向下的三角**。 CoverHiddenMarker(marker, radius, clipTop); 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) { // ⚠️⚠️ 取派生曲线在**光标准确时刻**的取值(相邻两个采样点之间按时间线性插值), // 而不是“最近采样点”的曲线值。旧实现取最近采样点 ⇒ 光标落在两个采样点 // 之间时,标注点会脱离画出来的折线(用户 2026-10-27 报告的“预览曲线数值点悬空”)。 // ⚠️ 插值必须在**屏幕空间**(即先套用「调整」变换再线性插值): // `PaintDerivedCurve` 逐顶点取 `ApplyDrawTransform` 后用直线连接, // 而绝对值 / 垂直镜像不是线性变换,先插值再变换会偏离折线。 const std::size_t total = std::min(resolved.axis->time.size(), resolved.data->values.size()); if (total == 0) { return; } const std::size_t sourceCount = resolved.data->values.size(); /** 某个显示时刻两侧的采样点(时间序下标)与插值比例。 */ struct CurveSampleRef { int first = -1; int second = -1; double fraction = 0.0; }; const auto locate = [&](double displayTime) -> CurveSampleRef { CurveSampleRef ref; const double target = displayTime - resolved.style->offsetTime; if (resolved.axis->monotonic) { const auto lower = std::lower_bound( resolved.axis->time.begin(), resolved.axis->time.begin() + static_cast(total), target); const std::size_t hi = static_cast( lower - resolved.axis->time.begin()); if (hi >= total) { ref.first = ref.second = static_cast(total - 1); return ref; } if (hi == 0) { ref.first = ref.second = 0; return ref; } ref.first = static_cast(hi - 1); ref.second = static_cast(hi); const double t0 = resolved.axis->time[static_cast(ref.first)]; const double t1 = resolved.axis->time[static_cast(ref.second)]; ref.fraction = (t1 > t0) ? std::max(0.0, std::min(1.0, (target - t0) / (t1 - t0))) : 0.0; return ref; } // 非单调时间轴:无法按时间插值,退回最近采样点。 const int nearest = FindNearestSample(*resolved.axis, target); if (nearest < 0 || static_cast(nearest) >= total) { return CurveSampleRef(); } ref.first = ref.second = nearest; return ref; }; // 时间序下标 → 源下标。派生曲线是在 `signal.values` 的**源下标顺序**上计算的 //(`ComputeDerivedWindow` 直接切 `values[windowBegin..windowEnd)`), // 水平镜像(数据反序)时两者相反 ⇒ 必须经 `SourceIndexFor` 映射。 const auto sourceIndexAt = [&](int timeIndex) -> int { if (timeIndex < 0 || static_cast(timeIndex) >= sourceCount) { return -1; } return static_cast(SourceIndexFor( *resolved.style, sourceCount, static_cast(timeIndex))); }; const CurveSampleRef here = locate(time); if (here.first < 0) { return; } const CurveSampleRef other = showDelta ? locate(otherTime) : CurveSampleRef(); // 本函数**只需要光标附近那几个下标的派生取值**,因此把请求窗口收窄到 // 「需要的那几个下标」即可,均值滤波半径造成的取值依赖由 `CurveSpanFor` // 自身的保护带负责覆盖。 // // ⚠️ 绝不能把窗口扩成「光标 ↔ 视界边缘」的并集:光标停在视界之外(例如 // 平移视界把光标甩到很远,或光标远离当前缩放区间)时会得到一个上千万采样的 // 巨大窗口,超过缓存上限后走一次性缓冲 → **每帧重算整段滤波**, // 实测单帧 300ms+(用户报告的"平移/拖动光标异常卡顿"的直接原因)。 std::size_t begin = sourceCount; std::size_t end = 0; const auto includeTime = [&](int timeIndex) { const int source = sourceIndexAt(timeIndex); if (source < 0) { return; } const std::size_t index = static_cast(source); begin = std::min(begin, index); end = std::max(end, index + 1); }; includeTime(here.first); includeTime(here.second); if (other.first >= 0) { includeTime(other.first); includeTime(other.second); } if (end <= begin) { return; } 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 auto curveDisplayedValue = [&](const CurveSampleRef& ref, double& out) -> bool { const int source0 = sourceIndexAt(ref.first); if (source0 < 0) { return false; } double value0 = curveValueAt(source0); if (!std::isfinite(value0)) { return false; } value0 = ApplyDrawTransform(*resolved.style, value0); if (!std::isfinite(value0)) { return false; } if (ref.second == ref.first || ref.fraction <= 0.0) { out = value0; return true; } const int source1 = sourceIndexAt(ref.second); if (source1 < 0) { return false; } double value1 = curveValueAt(source1); if (!std::isfinite(value1)) { return false; } value1 = ApplyDrawTransform(*resolved.style, value1); if (!std::isfinite(value1)) { return false; } out = value0 + (value1 - value0) * ref.fraction; return true; }; double derivedValue = 0.0; if (!curveDisplayedValue(here, 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) { double otherDerived = 0.0; if (curveDisplayedValue(other, 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; } // 同原始信号:圆点被底部时间读数面板盖住时改为向下三角。 CoverHiddenMarker(centerMarker, radius, clipTop); 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(); } } 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), ThemeLaneBackground(*m_theme)), 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; } // 与「边沿计数(中心轴)」共用同一段绘制逻辑 //(同色相高对比度的派生配色 + 同透明度),只是曲线本身按多次迭代滤波计算。 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)与时间视界(ViewMin/ViewMax)刻意不入面板指纹**:面板内容 //(标题、条目、滑块、参数与边沿统计)都不依赖它们,而拖动平移会每帧小幅改动 —— // 一旦计入,拖拽期间**全部**悬浮面板都会每帧重画一遍 //(实测 20 个面板:竖向拖拽 167ms/帧 vs 横向 38ms/帧,正是用户报告的 // "左右流畅、上下/斜向卡顿")。为保证“背后的场景”不会因此变旧,面板主体 // 已改为**完全不透明**(见面板绘制处的说明)。 } if (m_tab != nullptr) { const std::vector& lanes = m_tab->Lanes(); for (const Lane& lane : lanes) { // ⚠️⚠️ **面板的位置 / 尺寸绝不能进全局键**(用户 2026-10-21 报告“拖动面板时 // 下方另一个面板出现拖拽残影”):位置只影响该面板自己,已在 `PanelSlotKey` // 里(它混合 `layout.panel/title/...`)。以前把 4 类面板的 x/y 混进全局键 ⇒ // 拖动任意面板都会让**所有**面板重画(15 个 ≈33ms/帧),而「自由标记」面板的 // 位置偏偏漏了没混 ⇒ 拖动它时其它面板照旧贴旧像素 → 正好在下方留残影。 // 现在:拖动面板只重画它自己;它**腾出的区域**由 `PaintEdgeCursor` 的 // `vacatedRects` 机制兜底(覆盖到该区域的面板必须重画)。 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.freeMarkerPanel.open ? 1ull : 0ull); mix(lane.edgePanel.collapsed ? 1ull : 0ull); mix(lane.edgeCenterPanel.collapsed ? 1ull : 0ull); mix(lane.meanFilterPanel.collapsed ? 1ull : 0ull); mix(lane.weakPeakPanel.collapsed ? 1ull : 0ull); mix(lane.freeMarkerPanel.collapsed ? 1ull : 0ull); mix(lane.edgeValueValid ? 1ull : 0ull); mixDouble(lane.edgeValue); // 「自由标记」面板内容区的滚动偏移(历史行为:计入全局键)。 // ⚠️ 「均值滤波」的滚动偏移只进**该面板自己的**槽键(见 `PanelSlotKey`), // 否则滚动一个面板会让屏幕上所有面板一起重画。 mixFloat(lane.freeMarkerScroll); } } // ⚠️ **面板层序也是"全局"状态**:层序变化(点击某个面板把它置顶)会让"谁盖住谁"改变, // 因此必须让所有面板缓存失效、按新层序整层重画(否则贴回的旧像素仍是旧层叠关系)。 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.disclosure); mixRect(layout.badge); mixRect(layout.edit); mixRect(layout.button); mixRect(layout.showDelta); mixRect(layout.showEnum); mixRect(layout.content); mixRect(layout.scrollTrack); mixRect(layout.scrollThumb); mixRect(layout.opacityTrack); mixRect(layout.opacityValue); mix(layout.collapsed ? 1ull : 0ull); mix(layout.markerHasEnumColumn ? 1ull : 0ull); 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); } // 「自由标记」:固定点行/名称 ✕ 的矩形、五列边界、表头/吸顶状态与每个固定点的身份 + 时刻都要入键。 for (const EdgePanelLayout::MarkerRow& row : layout.markerRows) { mixRect(row.rect); mixRect(row.close); mix(static_cast(row.itemIndex + 2)); } for (const gd::RectF& rect : layout.rowNameCloses) { mixRect(rect); } for (const gd::RectF& rect : layout.rowNameRects) { mixRect(rect); } for (const EdgePanelLayout::MarkerColumn& column : layout.markerColumns) { mixFloat(column.left); mixFloat(column.width); } // 「自由标记」的分组栏(托底矩形 / 三角 / ✕)与各自的收起状态都在面板像素里。 for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) { mixRect(group.header); mixRect(group.tableHeader); mixRect(group.triangle); mixRect(group.close); // 「均值滤波」的组头右端是复选框(「自由标记」为空矩形,不影响它的键)。 mixRect(group.check); mix(group.collapsed ? 1ull : 0ull); mix(group.pinned ? 1ull : 0ull); mix(static_cast(group.ref.groupIndex + 2)); mix(static_cast(group.ref.signalIndex + 2)); } const Lane& lane = Tab().Lanes()[static_cast(laneIndex)]; if (kind == EdgePanelKind::MARK) { // 底部切换按钮的激活态、透明度滑块与内容滚动偏移直接改变面板像素 ⇒ 必须进键, // 否则开关 / 拖滑块 / 滚动之后仍会贴回旧像素。 mix(lane.freeMarkerCursor ? 1ull : 0ull); // 「显示增量 / 显示枚举文本」两个开关直接改变已固定点的胶囊文本 ⇒ 必须进键 // (否则切换开关后仍会贴回旧像素)。 mix(lane.freeMarkerShowDelta ? 1ull : 0ull); mix(lane.freeMarkerShowEnum ? 1ull : 0ull); mixFloat(lane.freeMarkerOpacity); mixFloat(lane.freeMarkerScroll); // 选中行会改变整行底色 ⇒ 必须进键(否则单击行后贴回旧像素)。 mix(static_cast(lane.freeMarkerSelected + 2)); // 固定点的**内容**(哪个信号、什么时刻)直接决定面板文字与行数,必须进键; // 否则新增/删除一个固定点后可能贴回旧像素。 for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems) { mix(static_cast(item.ref.groupIndex + 2)); mix(static_cast(item.ref.signalIndex + 2)); std::uint64_t bits = 0; std::memcpy(&bits, &item.time, sizeof(bits)); mix(bits); } } 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)); // 「均值滤波」改为滑块后,**滑块上限**决定圆钮位置、**收起状态**决定是否显示参数行 // ⇒ 两者都直接改变面板像素,必须进键(否则拖完滑块仍会贴回旧像素)。 mix(static_cast(meanItem != nullptr ? meanItem->maxPoints + 2 : 0)); mix(static_cast(meanItem != nullptr ? meanItem->maxIterations + 2 : 0)); mix((meanItem != nullptr && meanItem->collapsed) ? 1ull : 0ull); 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); } // 「均值滤波」:信号色叠底标题栏 / 展开三角 / 内容区滚动偏移都要入键 //(滚动改变的是面板内部像素,位置与尺寸不变)。 for (const gd::RectF& rect : layout.rowHeaders) { mixRect(rect); } for (const gd::RectF& rect : layout.rowTriangles) { mixRect(rect); } mixFloat(layout.scrollOffset); // 鼠标落在面板内时,✕ / 复选框 / 滑块 / 底部按钮的悬停态都依赖精确坐标。 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; } /** * @brief 用 **GDI+** 绘制一段可带 alpha 的文字(仅用于「标记透明度」低于 100% 的胶囊)。 * * ⚠️ 界面其余文字一律走 `UiText`(GDI `DrawTextW`)。GDI 文本**忽略 alpha** *(`SetTextColor` 只吃 RGB)⇒ 直接把 alpha 乘进颜色不会让文字淡出。 * 因此只有在"固定点被调成半透明"这个特殊状态下才改用 GDI+ `DrawString`: * 那样才能让文字与胶囊一起真正淡出;不透明度为 100% 时仍走 `UiText`,像素零变化。 * * @param [IN,OUT] g 绘图上下文。 * @param [IN] text 文本。 * @param [IN] font 字体(与 `UiText` 同一 HFONT)。 * @param [IN] rect 文本矩形(居中)。 * @param [IN] argb 颜色(含目标 alpha)。 */ void DrawFreeMarkerAlphaText(gd::Graphics& g, const std::wstring& text, HFONT font, const gd::RectF& rect, std::uint32_t argb) { if (text.empty() || font == nullptr || rect.Width <= 0.0f || rect.Height <= 0.0f) { return; } LOGFONTW lf{}; if (GetObjectW(font, static_cast(sizeof(lf)), &lf) != static_cast(sizeof(lf))) { UiText(g, text, font, rect, argb, TextAlign::CENTER); return; } HDC hdc = g.GetHDC(); if (hdc == nullptr) { return; } gd::Font textFont(hdc, &lf); g.ReleaseHDC(hdc); if (textFont.GetLastStatus() != gd::Ok) { UiText(g, text, font, rect, argb, TextAlign::CENTER); return; } gd::SolidBrush brush(ToGdiColor(argb)); gd::StringFormat format; format.SetAlignment(gd::StringAlignmentCenter); format.SetLineAlignment(gd::StringAlignmentCenter); format.SetTrimming(gd::StringTrimmingEllipsisCharacter); format.SetFormatFlags(gd::StringFormatFlagsNoWrap); g.DrawString(text.c_str(), static_cast(text.size()), &textFont, rect, &format, &brush); } void PlotView::PaintFreeMarkerCapsule(gd::Graphics& g, const gd::RectF& label, const std::wstring& text, std::uint32_t color, const gd::RectF& close, bool closeHot, float alphaScale, bool vectorText) { const float scale = std::max(0.0f, std::min(1.0f, alphaScale)); const auto scaled = [scale](std::uint32_t argb, float base) -> std::uint32_t { const int alpha = static_cast(std::lround(base * scale)); return WithAlpha(argb, (alpha <= 0) ? 0 : (alpha >= 255 ? 255 : alpha)); }; const UiInk ink = UiContrastInk(color); if (scale <= 0.02f) { return; // 完全透明:连描边与文字都不画(否则会留下"看不见的"像素) } UiFillRoundRect(g, label, 3.0f * m_scale, scaled(color, 0xF0)); DrawLabelOutline(g, label, 3.0f * m_scale, scaled(ink.outline, 255.0f)); if (!vectorText && scale >= 0.999f) { UiText(g, text, m_fonts->monoSmall, label, ink.text, TextAlign::CENTER); } else { // GDI 文本忽略 alpha **也不认 GDI+ 裁剪区** ⇒ 半透明、或压在 GX 标签/图例上时 // 必须改用 GDI+ 才能真的淡出 / 被裁剪(见 `vectorText` 参数说明)。 DrawFreeMarkerAlphaText(g, text, m_fonts->monoSmall, label, scaled(ink.text, 255.0f)); } if (close.Width <= 0.0f) { return; } // ✕ 按钮:悬停时用 danger 底色 + 叉(与面板里的 ✕ 同一套语义)。 UiFillRoundRect(g, close, 3.0f * m_scale, closeHot ? WithAlpha(m_theme->danger, 0x66) : scaled(color, 0xC0)); const float crossPad = 4.0f * m_scale; const std::uint32_t crossColor = closeHot ? m_theme->danger : scaled(ink.text, 255.0f); UiDrawLine(g, close.X + crossPad, close.Y + crossPad, close.X + close.Width - crossPad, close.Y + close.Height - crossPad, crossColor, 1.4f * m_scale); UiDrawLine(g, close.X + close.Width - crossPad, close.Y + crossPad, close.X + crossPad, close.Y + close.Height - crossPad, crossColor, 1.4f * m_scale); } void PlotView::PaintFreeMarker(gd::Graphics& g, const std::vector& lanes) { m_freeMarkerHits.clear(); if (m_tab == nullptr || m_dataSet == nullptr || m_fonts == nullptr) { return; } const std::uint32_t lineColor = m_theme->accent; const int saveState = g.Save(); g.SetClip(m_plotRect, gd::CombineModeIntersect); const std::vector& tabLanes = m_tab->Lanes(); const std::size_t count = std::min(lanes.size(), tabLanes.size()); // ---- (0) **图层遮挡**:GX 编号徽标与图例条目必须压在所有标记点之上 ---- // 用户 2026-10-27 明确要求:固定标记点(数据点 + 取值胶囊)不得遮挡网格的 GX 标签与图例。 // 图例与徽标画在静态场景 / 装饰缓存里,而标记点画在贴图之后,因此这里从**绘制区域**里 // 把这些矩形整体排除掉(GDI+ 区域裁剪):标记点真正位于它们“下方”,被盖住的部分不画, // 图例/徽标像素保持原样(连圆角之外的抗锯齿边缘都不会被标记点渗出)。 // ⚠️ GDI 文字(`UiText`)不认 GDI+ 裁剪区 ⇒ 胶囊文字与遮挡矩形相交时改走 GDI+ //(`PaintFreeMarkerCapsule(..., vectorText=true)`)。 // ⚠️ 只为**确实有标记点/预览**的网格做排除,避免每帧对全部网格做区域运算。 std::vector occluders; { bool needExclusion = (m_freeMarker.valid && m_freeMarker.ref.IsValid()); for (std::size_t index = 0; !needExclusion && index < count; ++index) { needExclusion = !tabLanes[index].freeMarkerItems.empty(); } if (needExclusion) { for (const LegendHit& hit : m_legendHits) { occluders.push_back(hit.rect); } for (std::size_t index = 0; index < count; ++index) { const gd::RectF badge = LaneBadgeRect(static_cast(index)); if (badge.Width > 0.0f && badge.Height > 0.0f) { occluders.push_back(badge); } } gd::Region clip; if (!occluders.empty() && g.GetClip(&clip) == gd::Ok) { for (const gd::RectF& rect : occluders) { const int x0 = static_cast(std::floor(rect.X)); const int y0 = static_cast(std::floor(rect.Y)); const int x1 = static_cast(std::ceil(rect.X + rect.Width)); const int y1 = static_cast(std::ceil(rect.Y + rect.Height)); if (x1 <= x0 || y1 <= y0) { continue; } gd::Region exclude(gd::Rect(x0, y0, x1 - x0, y1 - y0)); clip.Exclude(&exclude); } g.SetClip(&clip, gd::CombineModeReplace); } } } // 某个矩形是否与任一遮挡矩形相交(用于决定胶囊文字是否必须走可裁剪的 GDI+ 路径)。 const auto overlapsOccluder = [&occluders](const gd::RectF& rect) -> bool { for (const gd::RectF& occluder : occluders) { if (rect.X < occluder.X + occluder.Width && occluder.X < rect.X + rect.Width && rect.Y < occluder.Y + occluder.Height && occluder.Y < rect.Y + rect.Height) { return true; } } return false; }; // ---- (1) 已固定的数据点(含各自的 ✕)---- // ⚠️ 固定点**不画纵向细线**(2026-10-21 要求),且取值胶囊**就在原位显示**、 // 不做重叠避让(用户 2026-10-22 明确要求恢复初版方案:允许遮挡)。 for (std::size_t laneIndex = 0; laneIndex < count; ++laneIndex) { const Lane& lane = tabLanes[laneIndex]; if (!lane.freeMarker || lane.freeMarkerItems.empty()) { continue; } const float markerAlpha = std::max(0.0f, std::min(1.0f, lane.freeMarkerOpacity)); // 面板里正悬停着本网格的某一行 ⇒ 其余固定点“压暗”以突出目标 //(用户 2026-10-23 要求:透明度高于 50% 时降到 50%,移出后恢复)。 const bool rowHoverActive = (m_freeMarkerRowHoverLane == static_cast(laneIndex) && m_freeMarkerRowHoverItem != static_cast(-1)); // 内容(取值 / 文本 / 信号色)来自与视界无关的缓存,不逐帧重新采样。 const std::vector& contents = FreeMarkerContents(static_cast(laneIndex)); for (std::size_t itemIndex = 0; itemIndex < lane.freeMarkerItems.size(); ++itemIndex) { gd::RectF label; gd::RectF close; std::wstring text; if (!FreeMarkerLabelRects(static_cast(laneIndex), itemIndex, label, close, text)) { continue; // 超出视界直接不显示(不贴边、不画三角) } const Lane::FreeMarkerItem& item = lane.freeMarkerItems[itemIndex]; if (itemIndex >= contents.size()) { continue; } const FreeMarkerContentEntry& entry = contents[itemIndex]; const std::uint32_t signalColor = entry.color; const LaneVisual& visual = lanes[laneIndex]; 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); const bool valueOk = entry.valid; // 鼠标悬停在面板里这一行时:网格内对应的固定点整体高亮(用户 2026-10-22 要求)。 const bool rowHot = (m_freeMarkerRowHoverLane == static_cast(laneIndex) && m_freeMarkerRowHoverItem == itemIndex); // 本行实际使用的不透明度:目标行强制不透明;其它行在“悬停联动”期间封顶 50%。 const float rowAlpha = rowHot ? 1.0f : ((rowHoverActive && markerAlpha > 0.5f) ? 0.5f : markerAlpha); // 数据点:与光标的数据点同一画法(圆点),同样受「标记透明度」控制; // 悬停联动期间其它点封顶 50%(见 `rowAlpha`)。 const float x = TimeToX(item.time); const float radius = 3.4f * m_scale; const int dotAlpha = static_cast(std::lround(255.0f * rowAlpha)); gd::SolidBrush dot(ToGdiColor(WithAlpha(signalColor, dotAlpha))); const float dotY = valueOk ? ValueToY(entry.value, visual) : (clipTop + clipBottom) * 0.5f; if (rowHot) { // 高亮提示:数据点外留一圈强调色环(与“吸附提示”的圆环同尺寸量级)。 gd::Pen hotRing(ToGdiColor(WithAlpha(m_theme->accent, 0xE0)), 2.0f * m_scale); g.DrawEllipse(&hotRing, x - radius - 3.0f * m_scale, dotY - radius - 3.0f * m_scale, (radius + 3.0f * m_scale) * 2.0f, (radius + 3.0f * m_scale) * 2.0f); } g.FillEllipse(&dot, x - radius, dotY - radius, radius * 2.0f, radius * 2.0f); // ✕ 按钮的悬停态决定它的配色,因此先算再画胶囊。 const bool hot = (m_freeMarkerHoverLane == static_cast(laneIndex) && m_freeMarkerHoverItem == itemIndex); // 「标记透明度」:鼠标停在某个取值胶囊上时把**这一个**胶囊恢复为全不透明 //(用户 2026-10-21 要求:既能看淡全局标记,又能读清当前关注的数值)。 // ⚠️ 例外:如果此刻跟随探针正瞄着一个**被这个胶囊遮住的数据点** //(用户意图标记被遮住的那条曲线),则不恢复 —— 否则胶囊会重新遮住目标点、 // 无法看出自己到底瞄到了哪个采样点。 float capsuleAlpha = rowAlpha; const float fx = static_cast(m_mouseX); const float fy = static_cast(m_mouseY); if (capsuleAlpha < 0.999f && UiPointInRect(label, fx, fy)) { const bool probeHiddenByLabel = m_freeMarker.valid && m_freeMarker.laneIndex == static_cast(laneIndex) && UiPointInRect(label, m_freeMarker.x, m_freeMarker.y); if (!probeHiddenByLabel) { capsuleAlpha = 1.0f; } } // 面板里悬停该行 ⇒ 数值显示同样恢复全不透明并加一圈强调色描边。 if (rowHot) { capsuleAlpha = 1.0f; } // 胶囊压在 GX 标签 / 图例上时文字必须走 GDI+(GDI 文字不认裁剪区)。 PaintFreeMarkerCapsule(g, label, text, signalColor, close, hot, capsuleAlpha, overlapsOccluder(label)); if (rowHot) { UiDrawRoundRect(g, label, 3.0f * m_scale, m_theme->accent, 2.0f * m_scale); } FreeMarkerHit hit; hit.laneIndex = static_cast(laneIndex); hit.itemIndex = itemIndex; hit.close = close; if (rowAlpha > 0.02f) { // 完全透明时不可见 ⇒ 也不该被点中(否则会“隔着看不见的按钮”移除固定点)。 m_freeMarkerHits.push_back(hit); } } } // ---- (2) 跟随鼠标的纵向光标(**仅「自由标记点选取」开启时**;`probe.valid` 恒真)---- const FreeMarkerProbe& probe = m_freeMarker; if (probe.valid && probe.laneIndex >= 0 && probe.laneIndex < static_cast(lanes.size())) { const LaneVisual& visual = lanes[static_cast(probe.laneIndex)]; 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); float x = probe.x; x = std::max(visual.rect.X + kFreeMarkerLineInset * m_scale, std::min(x, visual.rect.X + visual.rect.Width - kFreeMarkerLineInset * m_scale)); // ⚠️ 用户明确要求:未开启「自由标记点选取」时**连这条线都不画**(`ProbeFreeMarker` 直接早退, // `probe.valid` 为假);`cursorMode` 只是“确实处于选取模式”的双重保险。 if (probe.cursorMode && clipBottom > clipTop) { UiDrawLine(g, x, clipTop, x, clipBottom, lineColor, 1.4f * m_scale); // 上把手:尖端朝下(与常规光标的顶部把手一致);下把手:尖端朝上(用户明确要求)。 const float arm = 5.0f * m_scale; gd::SolidBrush handle(ToGdiColor(lineColor)); gd::PointF top[3]; top[0] = gd::PointF(x - arm, clipTop); top[1] = gd::PointF(x + arm, clipTop); top[2] = gd::PointF(x, clipTop + arm * 1.4f); g.FillPolygon(&handle, top, 3); gd::PointF bottom[3]; bottom[0] = gd::PointF(x - arm, clipBottom); bottom[1] = gd::PointF(x + arm, clipBottom); bottom[2] = gd::PointF(x, clipBottom - arm * 1.4f); g.FillPolygon(&handle, bottom, 3); } // 就近数据点的取值胶囊(**不显示时间读数**,用户明确要求)。 if (probe.ref.IsValid() && probe.time >= Tab().ViewMin() && probe.time <= Tab().ViewMax()) { const ResolvedSignal resolved = ResolveSignal(probe.ref); if (resolved.data != nullptr && resolved.style != nullptr) { const std::uint32_t signalColor = StyleColor(*resolved.style); const std::wstring* enumText = resolved.data->enumText.Find(probe.enumValue); // 跟随鼠标的**预览**胶囊不受「显示增量 / 显示枚举文本」开关影响: // 它总是显示「值 = 枚举」(与两个开关的默认关闭状态无关)。 const std::wstring text = FreeMarkerValueText(probe.value, false, false, 0.0, true, enumText); const float textWidth = UiMeasureTextWidth(m_fonts->monoSmall, text) + 10.0f * m_scale; const float textHeight = 17.0f * m_scale; float left = x + 6.0f * m_scale; if (left + textWidth > m_plotRect.X + m_plotRect.Width - 2.0f * m_scale) { left = x - 6.0f * m_scale - textWidth; } left = std::max(m_plotRect.X + 2.0f * m_scale, std::min(left, m_plotRect.X + m_plotRect.Width - textWidth - 2.0f * m_scale)); float top = probe.y - textHeight * 0.5f; top = std::max(clipTop + 1.0f * m_scale, std::min(top, clipBottom - textHeight - 1.0f * m_scale)); const gd::RectF label(left, top, textWidth, textHeight); // 同样:压在 GX 标签 / 图例上时文字必须走 GDI+(否则会盖在图例上)。 PaintFreeMarkerCapsule(g, label, text, signalColor, gd::RectF(), false, 1.0f, overlapsOccluder(label)); // 吸附提示:命中自动点线判据时数据点被吸附到采样点,用一圈描边示意 //(与"可拖拽"的光晕区分:这里只是单点圆环,不描整条曲线)。 const float radius = 3.4f * m_scale; gd::SolidBrush dot(ToGdiColor(signalColor)); // 取值圆点与"已吸附"圆环都画在**数据点本身**的横坐标上(`pointX`), // 而纵向光标画在鼠标位置(`x`)。稀疏信号下两者会分开 —— 这是刻意的: // 光标跟随鼠标、提示落在真实采样点上(用户 2026-10-30 要求)。 float pointX = probe.pointX; if (!(pointX > 0.0f)) { pointX = x; // 兼容:未填 `pointX` 时退回光标位置 } g.FillEllipse(&dot, pointX - radius, probe.y - radius, radius * 2.0f, radius * 2.0f); if (probe.snapped) { gd::Pen ring(ToGdiColor(lineColor), 1.2f * m_scale); g.DrawEllipse(&ring, pointX - radius - 3.0f * m_scale, probe.y - radius - 3.0f * m_scale, (radius + 3.0f * m_scale) * 2.0f, (radius + 3.0f * m_scale) * 2.0f); } } } } g.Restore(saveState); } 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); // 进入本函数时的文本层号 = 宿主给本组件分配的面板层起始(`baseLayer + 1`)。 // 每个面板在循环里各用 `panelTextLayer + 面板序数`,函数结束前把层号推到**最上面板之上**。 const int panelTextLayer = UiGetTextLayer(); // ---- 横向光标(线与两端把手 + 左侧幅值读数)---- 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(); // 预扫描一:本帧各面板的布局(循环里直接复用,避免每帧重复计算)。 std::vector panelLayouts(m_panelOrder.size()); for (std::size_t ordinal = 0; ordinal < m_panelOrder.size(); ++ordinal) { panelLayouts[ordinal] = EdgePanelLayoutFor( m_panelOrder[ordinal].laneIndex, static_cast(m_panelOrder[ordinal].kind)); } // 预扫描二:上一帧已缓存、但本帧**位置/尺寸变了(或被移除)**的面板——它们腾出的 // 区域可能仍然留在**其它面板**的缓存像素里(那块像素是上一帧连它一起拷下来的), // 于是鼠标拖动面板时,下方面板上会出现拖拽残影(用户 2026-10-21 报告)。 // 把腾空区域也当“脏区”,强制覆盖到它的面板重画,就能彻底消除残影。 std::vector vacatedRects; if (panelCacheReady && !m_panelSlots.empty()) { const float margin = 4.0f * m_scale; for (const PanelSlot& slot : m_panelSlots) { int cx0 = slot.x0; int cy0 = slot.y0; int cx1 = slot.x1; int cy1 = slot.y1; bool alive = false; for (std::size_t ordinal = 0; ordinal < m_panelOrder.size(); ++ordinal) { if (m_panelOrder[ordinal].laneIndex != slot.laneIndex || m_panelOrder[ordinal].kind != slot.kind) { continue; } const gd::RectF& panel = panelLayouts[ordinal].panel; if (panel.Width <= 0.0f || panel.Height <= 0.0f) { break; } cx0 = std::max(0, static_cast(std::floor(panel.X - margin))); cy0 = std::max(0, static_cast(std::floor(panel.Y - margin))); cx1 = std::max(cx0, static_cast(std::ceil(panel.X + panel.Width + margin))); cy1 = std::max(cy0, static_cast(std::ceil(panel.Y + panel.Height + margin))); alive = true; break; } if (!alive || cx0 != slot.x0 || cy0 != slot.y0 || cx1 != slot.x1 || cy1 != slot.y1) { vacatedRects.push_back(gd::RectF(static_cast(slot.x0), static_cast(slot.y0), static_cast(slot.x1 - slot.x0), static_cast(slot.y1 - slot.y0))); } } } for (std::size_t panelOrdinal = 0; panelOrdinal < m_panelOrder.size(); ++panelOrdinal) { const PanelRef& panelRef = m_panelOrder[panelOrdinal]; const EdgePanelKind kind = static_cast(panelRef.kind); const std::size_t index = static_cast(panelRef.laneIndex); // 文本几何层号:**每个面板独占一层**,与 `BuildBoxes` 完全同源 //(`baseLayer + 1 + 面板序数`,面板序数按 `m_panelOrder` 底 → 顶)。 // 少了这一步,下层面板的文字框会记在比上层面板更低的层里被反复挖洞/穿透。 UiSetTextLayer(panelTextLayer + static_cast(panelOrdinal)); const bool centerAxis = (kind == EdgePanelKind::CENTER); const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool weakPeak = (kind == EdgePanelKind::PEAK); const bool freeMarker = (kind == EdgePanelKind::MARK); // 「均值滤波」与「弱峰调整」共用「逐条目 + 底部按钮」排布。 const bool itemized = (centerAxis || meanFilter || weakPeak); const bool hasButton = (meanFilter || weakPeak); { if (index >= count) { continue; } const Lane& tabLane = tabLanes[index]; // 布局已在预扫描里算好(同一帧内复用,避免重复计算)。 const EdgePanelLayout& layout = panelLayouts[panelOrdinal]; 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; } } if (!forcedDirty) { // 上一帧有面板在这里腾出了位置(移动 / 缩小 / 关闭)⇒ 本面板的缓存像素可能 // 仍然包含那块旧内容,必须重画(否则拖动上面那个面板时这里会留残影)。 for (const gd::RectF& vacated : vacatedRects) { if (vacated.IntersectsWith(cachedRect)) { forcedDirty = true; break; } } } std::uint64_t slotKey = 0; 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) { // ⚠️⚠️ **只贴「面板圆角形状之内」的像素**(用户 2026-10-30 报告: // 中键移动视界时面板周围带着一圈**旧视界的波形像素**)。 // 原因是缓存位图按 `panel ± cacheMargin` 取块,块的边缘那几像素是 // **上一帧的场景**(波形/网格线);`PanelGlobalKey` 刻意不含视界 // (否则平移会重画全部面板),于是整块贴回时就把旧波形"复印"了一圈。 // 现在:① 先把**投影**按本帧重新画一遍(它在圆角形状之外,不能靠缓存); // ② 用圆角区域裁剪后只 BitBlt 面板本体(圆角之外仍是本帧场景)。 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); } HDC target = g.GetHDC(); if (target != nullptr) { const int bx0 = static_cast(std::floor(panel.X)) - 1; const int by0 = static_cast(std::floor(panel.Y)) - 1; const int bx1 = static_cast(std::ceil(panel.X + panel.Width)) + 1; const int by1 = static_cast(std::ceil(panel.Y + panel.Height)) + 1; HRGN rounded = CreateRoundRectRgn( bx0, by0, bx1 + 1, by1 + 1, static_cast(std::lround(10.0f * m_scale)), static_cast(std::lround(10.0f * m_scale))); const int savedDc = SaveDC(target); if (rounded != nullptr) { ExtSelectClipRgn(target, rounded, RGN_AND); } BitBlt(target, bx0, by0, bx1 - bx0, by1 - by0, m_panelDc, bx0, by0, SRCCOPY); if (rounded != nullptr) { DeleteObject(rounded); } RestoreDC(target, savedDc); g.ReleaseHDC(target); } // 贴图不会真的画字 ⇒ 必须把上一帧渲染时存下的文本框**回放**给 // 外层收集器,否则调试框这一帧凭空消失(= 面板上边界闪烁)。 if (UiTextRectSink* const outerSink = UiGetTextRectSink()) { for (const gd::RectF& textRect : hit->textRects) { outerSink->OnTextRect(textRect); } } nextSlots.push_back(*hit); continue; } } } const std::size_t storedIndex = nextSlots.size(); nextSlots.push_back(PanelSlot()); dirtyRects.push_back(cachedRect); // 本面板渲染期间把文本框收集到本地容器(同时**转发**给外层收集器), // 渲染完再存进 slot,供命中缓存时回放。详见 `PanelSlot::textRects`。 PanelTextCollector panelTextBoxes(UiGetTextRectSink()); const float titleHeight = kEdgePanelTitleHeight * m_scale; const float rowHeight = kEdgePanelRowHeight * m_scale; const float pad = kEdgePanelPad * m_scale; // 以下整段(面板底色 → 标题栏 → 各控件 → 行内容)的画字都要走本地收集器。 UiSetTextRectSink(&panelTextBoxes); // 面板主体 + 标题栏。 // 投影响用两张大面积的半透明圆角矩形模拟,代价不低;交互期间(快速帧)省略它, // 让拖动参数滑块时面板重绘不再被投影拖累(松手后的全质量帧会补回投影)。 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); } // 面板主体:**完全不透明**(0xFF)。 // ⚠️ 曾经用 0xF4(约 96%),那 4% 透出的是**面板背后的场景像素**;而面板像素图层 // 会被缓存复用,`PanelGlobalKey` 又刻意不含视界/幅值(否则平移、竖向拖拽会每帧 // 重画全部面板)⇒ 平移后那 4% 里会一直残留**平移前**的波形(视觉上是面板下缘的 // 淡影)。改为不透明后这个取舍彻底消失,且零额外代价。 UiFillRoundRect(g, panel, 5.0f * m_scale, m_theme->cardBg); 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)); // 标题栏左侧的「展开 / 折起」指示器:**与信号列表组名最左边的折叠三角完全同款** //(同一尺寸 12 逻辑像素、同一个 `UiDrawTriangleIcon`、同样只在悬停时变强调色、 // 不再带三角后方那条横线 —— 用户 2026-10-21 要求“统一使用相同标记”)。 // 单击它切换折起状态;折起后面板只剩标题栏,这里是唯一的“重新展开”按钮。 { const gd::RectF& disclosureRect = layout.disclosure; if (disclosureRect.Width > 0.0f) { const bool disclosureHot = (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && UiPointInRect(disclosureRect, static_cast(m_mouseX), static_cast(m_mouseY)); const std::uint32_t disclosureColor = disclosureHot ? m_theme->accent : m_theme->text; const float centerX = disclosureRect.X + disclosureRect.Width * 0.5f; const float centerY = disclosureRect.Y + disclosureRect.Height * 0.5f; UiDrawTriangleIcon( g, gd::RectF(centerX - 6.0f * m_scale, centerY - 6.0f * m_scale, 12.0f * m_scale, 12.0f * m_scale), disclosureColor, layout.collapsed ? IconDirection::RIGHT : IconDirection::DOWN); } } // 标题:与左侧信号面板一致的 “GX” 徽标。 // ⚠️ 几何必须取 `layout.badge`(与悬停提示 / 手型光标 / 点击联动同源); // 悬停时给一圈强调色描边,作为“可点击”的视觉提示(用户 2026-10-19 要求)。 const gd::RectF& badgeRect = layout.badge; const bool badgeHot = (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && badgeRect.Width > 0.0f && UiPointInRect(badgeRect, static_cast(m_mouseX), static_cast(m_mouseY)); UiFillRoundRect(g, badgeRect, badgeRect.Height * 0.5f, badgeHot ? m_theme->accentSoft : m_theme->cardBg); UiDrawRoundRect(g, badgeRect, badgeRect.Height * 0.5f, badgeHot ? m_theme->accent : m_theme->border, badgeHot ? 1.4f : 1.0f); UiText(g, L"G" + std::to_wstring(index + 1), m_fonts->monoSmall, badgeRect, badgeHot ? m_theme->textStrong : m_theme->text, TextAlign::CENTER); const std::wstring title = meanFilter ? std::wstring(L"均值滤波") : (weakPeak ? std::wstring(kWeakPeakTitle) : (freeMarker ? std::wstring(L"自由标记") : ((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 (!layout.collapsed) { // 「中心轴边沿计数」/「均值滤波」/「弱峰调整」面板都采用「逐条目」排布: // 第一排 = 名称 + 复选框,第二排 = 两个参数输入框(仅勾选的条目, // 弱峰调整没有参数输入框),中心轴再多第三排 = 对应的统计结果。 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); } } } // 「均值滤波」面板:逐信号的**信号色叠底标题栏**(左侧展开三角 + 信号名 + // 右端复选框)。复选框勾选后才出现三角、才能展开下面的两行参数滑块 // (「均值点数」/「迭代次数」);内容超高时可滚动(滚动条在内容区右侧)。 if (meanFilter) { const int meanSave = g.Save(); // 内容必须裁到内容区:滚动时行会越出可视范围。 g.SetClip(layout.content, gd::CombineModeIntersect); const float valueColumn = std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"), UiMeasureTextWidth(m_fonts->monoSmall, L"1000")); // 复选框自绘(主题色在信号色叠底上看不清):用**自适应对比色**描边/填充。 const auto paintMeanCheck = [this, &g](const gd::RectF& rect, bool checked, const UiInk& ink, std::uint32_t background) { if (rect.Width <= 0.0f || rect.Height <= 0.0f) { return; } const float radius = 2.5f * m_scale; if (!checked) { UiDrawRoundRect(g, rect, radius, ink.text, 1.2f * m_scale); return; } UiFillRoundRect(g, rect, radius, ink.text); // 对勾:两段折线,颜色取叠底色(与填充色必成对比)。 const float left = rect.X + rect.Width * 0.22f; const float middleX = rect.X + rect.Width * 0.44f; const float right = rect.X + rect.Width * 0.78f; const float highY = rect.Y + rect.Height * 0.30f; const float lowY = rect.Y + rect.Height * 0.72f; UiDrawLine(g, left, rect.Y + rect.Height * 0.52f, middleX, lowY, background, 1.6f * m_scale); UiDrawLine(g, middleX, lowY, right, highY, background, 1.6f * m_scale); }; for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) { const SignalRef& ref = layout.rowRefs[rowIndex]; if (rowIndex >= layout.rowHeaders.size() || rowIndex >= layout.rowChecks.size()) { break; } const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr) { continue; } const gd::RectF& header = layout.rowHeaders[rowIndex]; if (header.Y + header.Height <= layout.content.Y || header.Y >= layout.content.Y + layout.content.Height) { continue; // 完全滚出可视范围 } const std::uint32_t signalColor = StyleColor(*resolved.style); const UiInk ink = UiContrastInk(signalColor); const Lane::MeanFilterItem* item = tabLane.FindMeanFilterItem(ref); const bool checked = (item != nullptr); // 叠底:先铺一层不透明同色再叠 0xE8(与「自由标记」分组栏同一做法), // 这样滚动时下层内容不会透出来。 UiFillRoundRect(g, header, 3.0f * m_scale, signalColor); UiFillRoundRect(g, header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); // 左侧三角(仅勾选时存在):朝下 = 展开、朝右 = 收起。 const gd::RectF& triangle = layout.rowTriangles[rowIndex]; if (triangle.Width > 0.0f && item != nullptr) { UiDrawTriangleIcon(g, triangle, ink.text, item->collapsed ? IconDirection::RIGHT : IconDirection::DOWN); } // 名称:从三角右侧到复选框左侧。 const gd::RectF& check = layout.rowChecks[rowIndex]; const float nameLeft = (triangle.Width > 0.0f) ? (triangle.X + triangle.Width + 6.0f * m_scale) : (header.X + 8.0f * m_scale); const float nameWidth = std::max(10.0f, check.X - 6.0f * m_scale - nameLeft); UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth), m_fonts->uiSmall, gd::RectF(nameLeft, header.Y, nameWidth, header.Height), ink.text, TextAlign::LEFT, layout.content); paintMeanCheck(check, checked, ink, signalColor); if (!checked || item->collapsed) { continue; } // 两行参数滑块(标签 / 轨道 / 数值 / 复位按钮)。 const auto paintMeanSlider = [&](const gd::RectF& track, const gd::RectF& valueRect, const gd::RectF& resetRect, const wchar_t* label, int current, int minimum, int maximum, bool active) { if (track.Width <= 0.0f) { return; } const float labelLeft = track.X - 6.0f * m_scale - UiMeasureTextWidth(m_fonts->uiSmall, label); UiText(g, label, m_fonts->uiSmall, gd::RectF(labelLeft, track.Y - 8.0f * m_scale, UiMeasureTextWidth(m_fonts->uiSmall, label), track.Height + 16.0f * m_scale), m_theme->textMuted, TextAlign::LEFT); const double ratio = (maximum > minimum) ? (static_cast(current - minimum) / static_cast(maximum - minimum)) : 0.0; UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale); UiText(g, std::to_wstring(current), m_fonts->monoSmall, gd::RectF(valueRect.X, valueRect.Y, valueColumn, valueRect.Height), m_theme->textStrong, TextAlign::LEFT); // 复位按钮:悬停高亮 + 与「系数/偏移」菜单同款图标。 const bool hot = (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && UiPointInRect(resetRect, static_cast(m_mouseX), static_cast(m_mouseY)); if (hot) { UiFillRoundRect( g, gd::RectF(resetRect.X - 1.0f * m_scale, resetRect.Y - 1.0f * m_scale, resetRect.Width + 2.0f * m_scale, resetRect.Height + 2.0f * m_scale), 3.0f * m_scale, WithAlpha(m_theme->accent, 0x33)); } PaintPanelResetIcon(g, resetRect, hot ? m_theme->accent : m_theme->textMuted, m_scale); }; const int pointsMax = std::max(kMeanFilterPointsMin, std::min(kMeanFilterPointsMaxLimit, item->maxPoints)); const int points = std::max(kMeanFilterPointsMin, std::min(pointsMax, item->points)); const int iterationsMax = std::max(kMeanFilterIterationsMin, std::min(kMeanFilterIterationsMaxLimit, item->maxIterations)); const int iterations = std::max( kMeanFilterIterationsMin, std::min(iterationsMax, item->iterations)); const bool pointsActive = (m_dragMode == PlotDragMode::MEAN_POINTS) && (m_meanDragLane == static_cast(index)) && (m_meanDragRef == ref); const bool iterationsActive = (m_dragMode == PlotDragMode::MEAN_ITERATIONS) && (m_meanDragLane == static_cast(index)) && (m_meanDragRef == ref); if (rowIndex < layout.rowLevelTracks.size()) { paintMeanSlider(layout.rowLevelTracks[rowIndex], rowIndex < layout.rowLevelValues.size() ? layout.rowLevelValues[rowIndex] : gd::RectF(), rowIndex < layout.rowLevelResets.size() ? layout.rowLevelResets[rowIndex] : gd::RectF(), kMeanFilterPointsSliderLabel, points, kMeanFilterPointsMin, pointsMax, pointsActive); } if (rowIndex < layout.rowIterTracks.size()) { paintMeanSlider(layout.rowIterTracks[rowIndex], rowIndex < layout.rowIterValues.size() ? layout.rowIterValues[rowIndex] : gd::RectF(), rowIndex < layout.rowIterResets.size() ? layout.rowIterResets[rowIndex] : gd::RectF(), kMeanFilterIterationsSliderLabel, iterations, kMeanFilterIterationsMin, iterationsMax, iterationsActive); } } g.Restore(meanSave); } // 「自由标记」面板:按信号分组的**分组栏**(信号色托底 + 三角 + 名称 + // 「N 标记点」+ ✕),下属固定点条目六列对齐(序号 / 取值 / 枚举 / 增量 / 时刻 / 间隔), // 行尾各带一个 ✕。内容区有滚动偏移时按内容区裁剪。 if (freeMarker) { const float groupRowHeight = kFreeMarkerNameRowHeight * m_scale; bool markerAny = false; for (const Lane::FreeMarkerItem& item : tabLane.freeMarkerItems) { markerAny = markerAny || item.ref.IsValid(); } const int contentState = g.Save(); g.SetClip(layout.content, gd::CombineModeIntersect); if (!markerAny) { // ⚠️ 未开启「自由标记点选取」时鼠标是普通鼠标,**单击不会固定**任何数据点 //(用户 2026-10-23 要求)⇒ 提示语必须说清前提,否则用户会以为 bug。 const wchar_t* emptyHint = tabLane.freeMarkerCursor ? L"在网格内单击以固定数据点" : L"开启「自由标记点选取」后单击网格可固定数据点"; UiTextClipped(g, emptyHint, m_fonts->uiSmall, gd::RectF(layout.content.X, layout.content.Y, layout.content.Width, groupRowHeight), m_theme->textMuted, TextAlign::LEFT, layout.content); } // ---- 分组栏 ---- // ⚠️ 抽取成 lambda:**吸顶**的分组栏必须在条目之后重画一次(否则正在向上滚的 // 条目会盖住它)。见下方“条目 → 表头 → 吸顶分组栏”的绘制顺序。 const auto paintGroupBar = [this, &g, &layout, &tabLane, index]( const EdgePanelLayout::MarkerGroup& group) { const ResolvedSignal resolved = ResolveSignal(group.ref); if (resolved.data == nullptr) { return; } const std::uint32_t signalColor = (resolved.style != nullptr) ? StyleColor(*resolved.style) : m_theme->border; // 托底 = 信号色;文字一律用**自适应对比色**(与信号面板的组名徽标同规则), // 因此无论信号是亮黄还是深蓝都能看清分组栏上的所有内容。 const UiInk ink = UiContrastInk(signalColor); // 先铺一层**完全不透明**的同色,再叠 0xE8(观感不变): // 吸顶时它要压住下面滚动的条目,半透明会让条目透出来。 UiFillRoundRect(g, group.header, 3.0f * m_scale, signalColor); UiFillRoundRect(g, group.header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); // 左侧三角(与信号选择区域组名同款):朝下 = 展开、朝右 = 收起。 const bool triangleHot = (m_freeMarkerTriangleHoverLane == static_cast(index) && m_freeMarkerTriangleHoverSignal == group.ref); UiDrawTriangleIcon(g, group.triangle, triangleHot ? m_theme->accentSoft : ink.text, group.collapsed ? IconDirection::RIGHT : IconDirection::DOWN); // 右侧:`N 标记点` + ✕(清除该信号全部固定点)。 std::size_t markerCount = 0; for (const Lane::FreeMarkerItem& entry : tabLane.freeMarkerItems) { if (entry.ref == group.ref) { ++markerCount; } } const std::wstring countText = std::to_wstring(markerCount) + L" 标记点"; const float countWidth = UiMeasureTextWidth(m_fonts->monoSmall, countText) + 2.0f * m_scale; const float countRight = group.close.X - 5.0f * m_scale; UiTextClipped(g, countText, m_fonts->monoSmall, gd::RectF(countRight - countWidth, group.header.Y, countWidth, group.header.Height), ink.text, TextAlign::RIGHT, layout.content); const float nameLeft = group.triangle.X + group.triangle.Width + 6.0f * m_scale; const float nameWidth = std::max(10.0f, countRight - countWidth - 6.0f * m_scale - nameLeft); UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth), m_fonts->uiSmall, gd::RectF(nameLeft, group.header.Y, nameWidth, group.header.Height), ink.text, TextAlign::LEFT, layout.content); const bool closeHot = (m_freeMarkerPanelHoverLane == static_cast(index) && m_freeMarkerPanelHoverSignal == group.ref && m_freeMarkerPanelHoverItem == static_cast(-1)); if (closeHot) { UiFillRoundRect(g, group.close, 3.0f * m_scale, WithAlpha(m_theme->danger, 0x77)); } const float crossPad = 3.5f * m_scale; const std::uint32_t crossColor = closeHot ? m_theme->danger : ink.text; UiDrawLine(g, group.close.X + crossPad, group.close.Y + crossPad, group.close.X + group.close.Width - crossPad, group.close.Y + group.close.Height - crossPad, crossColor, 1.5f * m_scale); UiDrawLine(g, group.close.X + group.close.Width - crossPad, group.close.Y + crossPad, group.close.X + crossPad, group.close.Y + group.close.Height - crossPad, crossColor, 1.5f * m_scale); }; for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) { if (!group.pinned) { paintGroupBar(group); } } // ---- 固定点条目(六列,列起点由布局给定 ⇒ 纵向严格对齐)---- // ⚠️ 取值与各列文本来自**与视界无关的内容缓存**(`FreeMarkerContents`): // 大量固定点时逐帧重新采样会让"拖光标 / 平移视界"明显变卡。 const std::vector& contents = FreeMarkerContents(static_cast(index)); // 六列的**实测宽度**:含滚出视界的条目,保证列宽稳定、文本不被省略。 // 存回 `Lane` 供下一步的布局(`EdgePanelWidth` / `EdgePanelLayoutFor`)使用。 { float widest[6] = {0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f}; const auto measure = [this, &widest](std::size_t column, const std::wstring& text) { if (text.empty() || column >= 6) { return; } widest[column] = std::max(widest[column], UiMeasureTextWidth(m_fonts->monoSmall, text)); }; // 「序号」列:取本网格所有分组里最大的序号文本宽度。 std::size_t maxSerial = 0; std::vector serialOrder; for (const SignalRef& ref : tabLane.signals) { SortedFreeMarkerIndices(tabLane, ref, serialOrder); maxSerial = std::max(maxSerial, serialOrder.size()); } if (maxSerial > 0) { measure(EdgePanelLayout::MARKER_COL_SERIAL, std::to_wstring(maxSerial)); } for (const FreeMarkerContentEntry& entry : contents) { measure(EdgePanelLayout::MARKER_COL_VALUE, entry.valueText); measure(EdgePanelLayout::MARKER_COL_ENUM, entry.enumText); measure(EdgePanelLayout::MARKER_COL_DELTA, entry.deltaText); measure(EdgePanelLayout::MARKER_COL_TIME, entry.timeText); measure(EdgePanelLayout::MARKER_COL_DT, entry.deltaTimeText); } std::array& stored = tabLanes[index].freeMarkerColumnWidths; for (std::size_t column = 0; column < 6; ++column) { // +2px 余量:`DrawTextW` 的 `DT_END_ELLIPSIS` 对"刚好装满"的文本 // 有时仍会截成省略号(亚像素/字形伸部),留一点余量可彻底避免。 const float want = (widest[column] > 0.0f) ? (widest[column] + 2.0f * m_scale) : 0.0f; if (std::fabs(stored[column] - want) > 0.5f) { stored[column] = want; } } } // 列分隔竖线:在每列右边界(加上半个列间隙)画一条淡竖线, // 让「序号 / 标记值 / 枚举文本 / 增量 / 时间点 / 时间间隔」有明显的列分界 //(用户 2026-10-23 要求)。最后一列不画(它右边紧接行尾 ✕)。 const auto paintColumnSeparators = [this, &g, &layout](float y, float height) { int lastVisible = -1; for (int column = 0; column < 6; ++column) { if (layout.markerColumns[column].width > 0.0f) { lastVisible = column; } } for (int column = 0; column < lastVisible; ++column) { const EdgePanelLayout::MarkerColumn& cell = layout.markerColumns[column]; if (cell.width <= 0.0f) { continue; } const float lineX = cell.left + cell.width + kFreeMarkerColumnGap * m_scale * 0.5f; UiDrawLine(g, lineX, y + 2.0f * m_scale, lineX, y + height - 2.0f * m_scale, WithAlpha(m_theme->borderStrong, 0x6C), 1.0f); } }; for (const EdgePanelLayout::MarkerRow& markerRow : layout.markerRows) { if (markerRow.itemIndex >= contents.size()) { continue; } const FreeMarkerContentEntry& entry = contents[markerRow.itemIndex]; // ---- 行的选中 / 悬停高亮(用户 2026-10-22 要求)---- const bool selected = (tabLane.freeMarkerSelected == static_cast(markerRow.itemIndex)); const bool rowHot = (m_freeMarkerRowHoverLane == static_cast(index) && m_freeMarkerRowHoverItem == markerRow.itemIndex); if (selected || rowHot) { UiFillRect(g, gd::RectF(markerRow.rect.X, markerRow.rect.Y + 1.0f * m_scale, markerRow.rect.Width, markerRow.rect.Height - 2.0f * m_scale), selected ? WithAlpha(m_theme->accent, 0x3C) : WithAlpha(m_theme->accent, 0x1E)); } const auto columnRect = [&](EdgePanelLayout::MarkerColumnIndex column) { return gd::RectF(layout.markerColumns[column].left, markerRow.rect.Y, layout.markerColumns[column].width, markerRow.rect.Height); }; // ⚠️ 面板里的数据列**不得省略**(用户 2026-10-21 明确要求): // 列宽已按实测文本宽度撑开,因此这里直接整段输出,不再 `UiEllipsize`。 // ⚠️ 必须用 `UiTextClipped`(GDI 裁剪):`UiText` 走 `DrawTextW`, // **不认 GDI+ 裁剪** ⇒ 滚动到内容区上方的条目会把文字画到标题栏上 //(用户 2026-10-22 报告的显示错误)。 const auto paintCell = [&](EdgePanelLayout::MarkerColumnIndex column, const std::wstring& text, std::uint32_t color) { if (layout.markerColumns[column].width <= 0.0f || text.empty()) { return; } UiTextClipped(g, text, m_fonts->monoSmall, columnRect(column), color, TextAlign::LEFT, layout.content); }; paintCell(EdgePanelLayout::MARKER_COL_SERIAL, std::to_wstring(markerRow.serial), m_theme->textMuted); paintCell(EdgePanelLayout::MARKER_COL_VALUE, entry.valueText, m_theme->textStrong); if (layout.markerHasEnumColumn) { paintCell(EdgePanelLayout::MARKER_COL_ENUM, entry.enumText, m_theme->text); } paintCell(EdgePanelLayout::MARKER_COL_DELTA, entry.deltaText, m_theme->textMuted); paintCell(EdgePanelLayout::MARKER_COL_TIME, entry.timeText, m_theme->textMuted); paintCell(EdgePanelLayout::MARKER_COL_DT, entry.deltaTimeText, m_theme->textMuted); // 列分隔竖线(用户 2026-10-23 要求:各列之间要有明显分界)。 paintColumnSeparators(markerRow.rect.Y, markerRow.rect.Height); // 行尾 ✕:移除该固定点(与之前一致)。 const gd::RectF& closeRect = markerRow.close; const bool hot = (m_freeMarkerPanelHoverLane == static_cast(index) && m_freeMarkerPanelHoverItem == markerRow.itemIndex); UiFillRoundRect(g, closeRect, 3.0f * m_scale, hot ? WithAlpha(m_theme->danger, 0x66) : m_theme->cardBg); UiDrawRoundRect(g, closeRect, 3.0f * m_scale, hot ? m_theme->danger : m_theme->border, 1.0f); const float crossPad = 3.5f * m_scale; const std::uint32_t crossColor = hot ? 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.2f * m_scale); UiDrawLine(g, closeRect.X + closeRect.Width - crossPad, closeRect.Y + crossPad, closeRect.X + crossPad, closeRect.Y + closeRect.Height - crossPad, crossColor, 1.2f * m_scale); } // ---- 表头(每个展开分组一行,与条目共用列边界)---- // ⚠️ 必须在条目**之后**绘制:表头底衬要不透明地盖住向上滚动的条目。 for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) { if (group.tableHeader.Width <= 0.0f || group.tableHeader.Height <= 0.0f) { continue; } // 不透明底衬(与面板底色 + 少量边框色调混合,观感是“一条浅灰带”)。 UiFillRect(g, group.tableHeader, MixColor(m_theme->cardBg, m_theme->borderStrong, 0.22)); UiDrawLine(g, group.tableHeader.X, group.tableHeader.Y + group.tableHeader.Height - 1.0f, group.tableHeader.X + group.tableHeader.Width, group.tableHeader.Y + group.tableHeader.Height - 1.0f, WithAlpha(m_theme->borderStrong, 0x99), 1.0f); for (int column = 0; column < 6; ++column) { const EdgePanelLayout::MarkerColumn& cell = layout.markerColumns[column]; if (cell.width <= 0.0f) { continue; } UiTextClipped(g, kFreeMarkerColumnLabels[column], m_fonts->uiSmall, gd::RectF(cell.left, group.tableHeader.Y, cell.width, group.tableHeader.Height), m_theme->textMuted, TextAlign::LEFT, layout.content); } // 表头的列分隔竖线(与数据行同一套列边界)。 paintColumnSeparators(group.tableHeader.Y, group.tableHeader.Height); } // ---- 吸顶的分组栏(最后画:压住向上滚动的条目)---- for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) { if (group.pinned) { paintGroupBar(group); } } g.Restore(contentState); // ---- 内容滚动条(只在内容超高时出现;按钮与滑块不参与滚动)---- if (layout.scrollTrack.Width > 0.0f) { UiFillRoundRect(g, layout.scrollTrack, layout.scrollTrack.Width * 0.5f, WithAlpha(m_theme->border, 0x88)); UiFillRoundRect(g, layout.scrollThumb, layout.scrollThumb.Width * 0.5f, m_theme->borderStrong); } // ---- 「标记透明度」滑动条(固定在「自由标记点选取」按钮上方)---- if (layout.opacityTrack.Width > 0.0f) { UiText(g, kFreeMarkerOpacityLabel, m_fonts->uiSmall, gd::RectF(panel.X + pad, layout.opacityValue.Y, layout.opacityTrack.X - panel.X - pad, layout.opacityValue.Height), m_theme->textMuted, TextAlign::LEFT); const bool sliderActive = (m_dragMode == PlotDragMode::MARKER_OPACITY && m_markerOpacityDragLane == static_cast(index)) || UiPointInRect(layout.opacityTrack, static_cast(m_mouseX), static_cast(m_mouseY)); // 外观与信号样式区的「透明度」滑动条完全相同(同一 `UiDrawSlider` 口径)。 UiDrawSlider(g, layout.opacityTrack, static_cast(tabLane.freeMarkerOpacity), m_theme->accent, sliderActive, m_scale); const int percent = static_cast(tabLane.freeMarkerOpacity * 100.0f + 0.5f); UiText(g, std::to_wstring(percent) + L"%", m_fonts->monoSmall, layout.opacityValue, m_theme->textStrong, TextAlign::RIGHT); } } 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 || freeMarker) { // 逐条目绘制已在上面完成(自由标记同样是逐条目排布)。 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; } // 逐条目面板底部按钮行:「构建为新信号」(悬停高亮); // 「自由标记」面板的底部固定区有三行: // ①「标记透明度」滑块(画在上面); // ②「自由标记点选取」切换按钮; // ③「显示增量 | 显示枚举文本」两个开关(用户 2026-10-23 要求)。 // 三者都是切换类按钮外观,与工具栏的切换按钮一致 //(激活时浅强调色底 + 强调描边 + 强文本色)。 const auto paintToggleButton = [this, &g](const gd::RectF& buttonRect, const wchar_t* label, bool checked) { if (buttonRect.Width <= 0.0f) { return; } 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)); const std::uint32_t background = checked ? (hot ? MixColor(m_theme->accentSoft, m_theme->accent, 0.16) : m_theme->accentSoft) : (hot ? m_theme->hover : m_theme->cardBg); UiFillRoundRect(g, inner, 4.0f * m_scale, background); UiDrawRoundRect(g, inner, 4.0f * m_scale, checked ? m_theme->accent : m_theme->border, 1.2f); // 窄按钮(两个开关各占半行)里文本可能超宽 ⇒ 省略,不允许画到按钮外。 const float labelWidth = UiMeasureTextWidth(m_fonts->uiSmall, label); const std::wstring text = (labelWidth <= inner.Width - 6.0f * m_scale) ? std::wstring(label) : UiEllipsize(label, m_fonts->uiSmall, inner.Width - 6.0f * m_scale); UiText(g, text, m_fonts->uiSmall, inner, checked ? m_theme->textStrong : m_theme->text, TextAlign::CENTER); }; if (hasButton) { paintToggleButton(layout.button, kBuildSignalLabel, false); } if (freeMarker) { paintToggleButton(layout.button, kFreeMarkerCursorModeLabel, tabLane.freeMarkerCursor); paintToggleButton(layout.showDelta, kFreeMarkerShowDeltaLabel, tabLane.freeMarkerShowDelta); paintToggleButton(layout.showEnum, kFreeMarkerShowEnumLabel, tabLane.freeMarkerShowEnum); } } // if (!layout.collapsed) // 面板画完了:恢复外层收集器,并把本面板的文本框存进 slot //(命中缓存时靠它回放,见 `PanelSlot::textRects`)。 UiSetTextRectSink(panelTextBoxes.Outer()); // 把它刚画进后备缓冲区的像素(连同缓存键)存进面板图层, // 下一帧只要键不变就直接贴图。矩形外的 4 逻辑像素边距用于容纳投影。 PanelSlot& stored = nextSlots[storedIndex]; 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; stored.textRects = panelTextBoxes.Rects(); } } } } } } m_panelSlots = std::move(nextSlots); m_panelGlobalKey = panelGlobalKey; m_panelValid = panelCacheReady; // 面板之后绘制的叠加层(信号拖拽虚影 / 提示 / 输入框 / 滚动条 / 回到最上方) // 都在**所有面板之上** ⇒ 文本层号推到最上面板的上一层(与 `BuildBoxes` 同源)。 UiSetTextLayer(panelTextLayer + static_cast(m_panelOrder.size())); } 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_editTarget == PlotEditTarget::MEAN_POINTS_MAX || m_editTarget == PlotEditTarget::MEAN_ITERATIONS_MAX) && m_edit.Text().empty()) { const bool peakTarget = (m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX); const int currentMax = peakTarget ? ((m_peakEditCurrentMax > 0) ? m_peakEditCurrentMax : kWeakPeakIterationsDefaultMax) : ((m_meanEditCurrentMax > 0) ? m_meanEditCurrentMax : ((m_editTarget == PlotEditTarget::MEAN_POINTS_MAX) ? kMeanFilterPointsDefaultMax : kMeanFilterIterationsDefaultMax)); 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 要求)。 // ⚠️ 同理(用户 2026-10-23 要求):该网格开启「自由标记点选取」时,左键专用于固定 // 标记点、拖动也不再是拖信号 ⇒ 提示只显示信号名,不再提示可以拖拽。 const std::vector& tabLanes = m_tab->Lanes(); const bool pickMode = laneIndex < static_cast(tabLanes.size()) && tabLanes[static_cast(laneIndex)].freeMarker && tabLanes[static_cast(laneIndex)].freeMarkerCursor; const std::wstring text = (Tab().ZoomMode() == PlotZoomMode::SELECT || pickMode) ? 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