Files
Just-DataInspector/Components/PlotView/Src/PlotViewPaint.cpp
T
DeepSeek V4.1 Flash 0c320cf1b3 fix(plotview): 修复面板条目栏悬停时预览曲线高亮丢失 + 极端工况绘制管线提速
一、悬停高亮回归修复
- 根因:高亮画在预览曲线绘制路径里,而该路径在命中静态场景缓存时完全不会被调用
  (悬停态又不参与 SceneContentHash),因此缓存命中帧永远看不到高亮。
- 修法:拆出 PaintDerivedCurvesFor(hoverOnly) 两态,新增 PaintDerivedHoverHighlight,
  在 paintOverlays() 中紧跟 PaintHoverHighlight 以前景方式绘制(面板之下、光标之上)。
  高亮仍按面板种类(均值滤波/中心轴/弱峰调整)分别判定。

二、预览曲线绘制重写(单条 15ms → 1.7ms)
- 稠密区间改用与波形基底同一套「逐像素列 min/max 包络带」+ 一次 FillPath;
  旧抽稀折线在稠密视界下每段都是竖直满幅,GDI+ 描边代价正比于覆盖像素数
  (3200 顶点满幅锯齿抗锯齿 128ms / 无抗锯齿 31ms,包络带 1.9ms)。
- 列密度自适应:中等密度 2px/列,极密(>8 采样/像素)4px/列。
- 稀疏区间保持逐点折线;附带修复「等间隔抽样漏掉尖峰」。

三、预览曲线像素缓存层(Picture cache + pan roll)
- 45 条曲线画进带 alpha 的 32bpp DIB,正常帧只做一次 AlphaBlend(1~2ms)。
- 纯平移时整行 memmove 一个 dx 并只重画新露出的竖带(与宽缓冲区滚动同构),
  因此拖动视界的每帧代价与信号数无关。
- 内容指纹 = SceneContentHash(含样式/派生参数/网格几何) ⊕ 曲线发布序号;高亮不进指纹。

四、其他热点
- 光标取值胶囊不再逐帧重算并发布 45 条曲线(CurveSpanFor 新增 computeIfMissing),
  只取已覆盖的缓存曲线,未命中退到最新曲线或本帧不标注。
- 样式补丁路径不再同步重建网格图层(宽基底 2 倍宽 × 2 倍高,实测单次 2.2 秒),
  且开启派生功能时直接走整幅重绘(补丁会覆盖缓存中已烘入的曲线像素)。

实测(15 信号合并一条网格 + 全部实验性功能,1600x900 离屏探针):
拖动视界 264→5.9ms,双光标拖动 187→20.9ms,弱峰滑块 555→89ms,
改信号颜色 2661→200ms,悬停面板行 225→38ms,深放大拖动 207→23.8ms。
全屏 1920x1080:拖动视界 9.3ms。

验证:新增离屏探针 p27 8/8(悬停高亮在缓存命中帧可见、平移/缩放/改颜色后的叠加层
与「完全重画」参考帧逐像素一致 diff=0);p20 9/9、p21 9/9、p22 14/14、p23 8/8、p25 12/12;
make release / make debug 零 warning;Release 814,080 B。规则已同步到 Documents/agent_mem.md。
2026-11-02 18:47:21 +08:00

6234 lines
278 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<gd::RectF>& Rects() const { return m_rects; }
private:
UiTextRectSink* m_outer = nullptr;
std::vector<gd::RectF> 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<std::uint64_t>(bits));
};
const auto mixHashDouble = [&mixHash](double value)
{
std::uint64_t bits = 0;
std::memcpy(&bits, &value, sizeof(bits));
mixHash(bits);
};
mixHash(static_cast<std::uint64_t>(static_cast<std::uint32_t>(ref.groupIndex)) + 2ull);
mixHash(static_cast<std::uint64_t>(static_cast<std::uint32_t>(ref.signalIndex)) + 2ull);
mixHash(reinterpret_cast<std::uintptr_t>(resolved.group));
mixHash(static_cast<std::uint64_t>(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<gd::PointF>& points, std::vector<std::size_t>& breaks,
std::vector<gd::PointF>& band, std::vector<std::size_t>& 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<double>& time = resolved.axis->time;
const std::vector<double>& 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<double>::const_iterator first = std::lower_bound(time.begin(), time.end(), sourceMin);
begin = static_cast<std::size_t>(first - time.begin());
if (begin > 0)
{
--begin;
}
const std::vector<double>::const_iterator last = std::lower_bound(time.begin(), time.end(), sourceMax);
end = static_cast<std::size_t>(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::size_t>(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<std::size_t>(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::size_t>(
std::lower_bound(time.begin(), time.end(), clipLo) - time.begin());
if (sBegin > 0)
{
--sBegin; // 多含一个采样点:让跨越条带左界的线段完整绘出。
}
std::size_t sEnd = static_cast<std::size_t>(
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<std::size_t>(1, budget / 2);
const float columnWidth = plotWidth / static_cast<float>(columns);
const std::vector<double>::const_iterator rangeEnd = time.begin() + static_cast<std::ptrdiff_t>(end);
// 粗粒度包络层(若已构建):每列只需读取覆盖到的分组,
// 数据量从“可见采样数”降到“像素列数 × 少量分组”,
// 且分组内存的就是真实极值,绘制结果与逐点扫描完全一致。
const std::vector<double>& coarseMin = resolved.data->coarseMin;
const std::vector<double>& 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<double>(resolved.style->gain);
const double coarseOffset = static_cast<double>(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<BandColumn> 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<std::size_t>(c0);
const std::size_t wantLast =
(c1 <= 0.0f) ? 0 : static_cast<std::size_t>(c1) + 1;
columnLast = std::min(columns, wantLast);
if (columnLast <= columnFirst)
{
return;
}
// 从条带第一列对应的源时刻开始二分。
const double tFirst =
viewMin + viewSpan * (static_cast<double>(columnFirst) / static_cast<double>(columns));
searchFrom = static_cast<std::size_t>(
std::lower_bound(time.begin() + static_cast<std::ptrdiff_t>(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<double>(column) / static_cast<double>(columns));
const double t1 =
viewMin + viewSpan * (static_cast<double>(column + 1) / static_cast<double>(columns));
const double s0 = t0 - timeShift;
const double s1 = t1 - timeShift;
// 列边界随 column 单调递增,从上一列的位置继续二分,
// 把每次搜索的范围压到剩余区间,避免 2400 次全区间二分带来的缓存缺失。
const std::size_t i0 = static_cast<std::size_t>(
std::lower_bound(time.begin() + static_cast<std::ptrdiff_t>(searchFrom), rangeEnd, s0) -
time.begin());
searchFrom = i0;
const std::size_t i1 = (column + 1 == columns)
? end
: static_cast<std::size_t>(
std::lower_bound(time.begin() + static_cast<std::ptrdiff_t>(i0),
rangeEnd, s1) -
time.begin());
double low = std::numeric_limits<double>::infinity();
double high = -std::numeric_limits<double>::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<float>(column) + 0.5f) * columnWidth;
const float yTop =
static_cast<float>(ClampDouble(ValueToY(high, lane), limitTop, limitBottom));
const float yBottom =
static_cast<float>(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<float>(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<int>(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<int>(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<EdgePanelKind>(panelRef.kind));
if (!UiRectHasArea(layout.panel))
{
continue;
}
// 本面板独占一层(层号越大越靠上)。
boxes.SetLayer(baseLayer + 1 + static_cast<int>(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.tableHeader, 3);
for (const EdgePanelLayout::TableColumn& column : layout.tableColumns)
{
boxes.Add(gd::RectF(column.left, layout.tableHeader.Y, column.width,
layout.tableHeader.Height),
4);
}
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);
}
}
// 收尾:解除面板裁剪并**复位层号**(宿主后续组件依赖它)。
// 浮动面板之上的叠加层(滚动条 / 回到最上方 / 内联输入框)必须比**最上面板**再高一层。
// ⚠️ **只在它们真的会被绘制时上报**(用户 2026-11-02 报告:关闭分组后仍能看到大量
// 残留的布局边界框):这些矩形在 `Layout()` 里就无条件算好,但绘制时会按可见性 /
// 编辑态门控。旧实现照样上报 ⇒ 空绘图区里同时出现“不存在的滚动条 / 回到最上方 /
// 输入框”的框。
boxes.SetClip(gd::RectF());
boxes.SetLayer(topOverlayLayer);
if (m_scrollBarVisible)
{
boxes.Add(m_scrollBarRect, 5);
}
if (BackToTopVisible())
{
boxes.Add(BackToTopRect(), 5);
}
if (m_editTarget != PlotEditTarget::NONE)
{
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<std::recursive_mutex> renderLock(m_renderMutex);
BuildLaneVisuals();
// 「回到最上方」浮动按钮:目标可见性由当前滚动位置决定(过渡由宿主定时器推进)。
// 在绘制前刷新一次,保证本帧就画出正确的过渡形态。
UpdateBackToTopTarget();
// 「边沿计数」的扫描预算**按帧重置**(见 `m_edgeScanFrame` 的说明):
// 旧实现按 `GetTickCount64()` 分桶,帧率一高(`PostMessage` 自持重绘会把它拉高)
// 就会在同一系统 tick 内连画多帧而**完全不推进**统计 —— 用户报告
// “按住横向光标不动时统计卡住不更新”“回车确认参数后不移动鼠标就停在部分值”。
++m_edgeScanFrame;
// 统计数据版本号 → 面板缓存键(见 `m_edgeScanRevision`):
// * 非交互帧:**立刻**同步(统计一推进就重画面板,用户看到数字实时变化);
// * 交互帧:按 `kEdgeScanStatsRefreshFrames` 节流 —— 否则鼠标一直移动时
// 面板每帧重画上百行(正是“悬停在信号上持续移动、面板一直不更新”的反面:
// 以前是冻结永不更新,现在是有节制地刷新)。
if (m_edgeScanRevision != m_panelStatsSeen &&
(!InputIsInteractive() ||
(m_edgeScanFrame % kEdgeScanStatsRefreshFrames) == 0u))
{
m_panelStatsVersion = m_edgeScanRevision;
m_panelStatsSeen = m_edgeScanRevision;
}
// ⚠️⚠️ **交互帧用小预算 + 时间上限**:288 个信号合并到同一网格、开启中心轴边沿计数时,
// 拖动横向光标每帧要 30ms(实测),界面完全跟不上手。现在:
// * 采样对配额只做“公平分摊”(不让第一个状态吃光全帧);
// * 真正的总开销由 `m_edgeScanDeadline`(时间)限制 —— 普通计数因此能一次推进得很快,
// 中心轴也不会卡住一帧;
// * 加载中(`m_loadingBusy`)完全不推进,避免把文件加载拖慢。
const bool interactive = InputIsInteractive();
m_edgeScanBudget = m_loadingBusy ? 0
: (interactive ? kEdgeScanPairsPerFrameInteractive
: kEdgeScanPairsPerFrame);
{
LARGE_INTEGER frequency = {};
LARGE_INTEGER now = {};
QueryPerformanceFrequency(&frequency);
QueryPerformanceCounter(&now);
const double budgetMs = m_loadingBusy ? 0.0
: (interactive ? kEdgeScanTimeBudgetInteractiveMs
: kEdgeScanTimeBudgetMs);
m_edgeScanDeadline =
now.QuadPart + static_cast<std::int64_t>(static_cast<double>(frequency.QuadPart) *
budgetMs / 1000.0);
}
// 双缓冲:清掉上一帧钉住的曲线条目(本帧会按需重新钉住),并清理已被更新条目覆盖 /
// 超出内存预算的旧条目。此刻没有任何条目被钉住,回收是安全的。
m_curvePins.clear();
{
std::lock_guard<std::mutex> 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();
// ⚠️⚠️ **必须清掉上一帧登记的叠加层/命中几何**(用户 2026-11-02 报告:关闭整个分组后
// 仍能看到大量残留的布局边界框):本分支直接 return,`PaintLaneLegend` /
// `PaintCursor` / `PaintFreeMarker` 都不会跑,于是 `m_legendHits` /
// `m_cursorLabelRects` / `m_cursorSpanRect` / `m_freeMarkerHits` 里的**旧矩形**
// 会一直保留(布局边界会继续把它们画出来,甚至命中测试也会命中已不存在的元素)。
m_legendHits.clear();
m_cursorLabelRects.clear();
m_cursorSpanRect = gd::RectF();
m_freeMarkerHits.clear();
m_decorRects.clear();
m_panelOrder.clear();
m_panelSlots.clear();
m_panelValid = false;
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);
// 实验性功能面板条目栏的悬停 → 对应**预览曲线**的高亮。
// ⚠️ 必须在贴图之后画(见 `PaintDerivedCurvesFor` 的说明):静态场景缓存里
// 只有普通曲线,高亮随鼠标变化,烘进缓存就会“悬停看不到 / 移开不消失”。
PaintDerivedHoverHighlight(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<std::vector<SceneStyleEntry>> currentStyles;
CollectSceneStyles(currentStyles);
wideUsable = (currentStyles == m_sceneStyles);
}
if (wideUsable)
{
// 内容(含视界)与本帧上一条快路径帧相同 → 视界已停止变化 → 走一次整幅"结算"
// 全质量帧(保证最终呈现**像素精确**;此后命中静态场景缓存,不再回到本分支)。
std::uint64_t wideKey = SceneContentHash(true);
wideKey ^= static_cast<std::uint64_t>(std::llround(Tab().ViewMin() * 1.0e6));
wideKey *= 1099511628211ull;
wideKey ^= static_cast<std::uint64_t>(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<double> wideTimeTicks;
BuildTicks(wideViewMin, wideViewMax, wideTimeStep, wideTimeTicks);
// 贴基底(绘图区:网格 + 波形)。拿不到图层像素的网格由底衬兜底,永不空白。
if (BlitWideBuffer(g, wideTimeTicks))
{
m_wideLastKey = wideKey;
m_wideLastValid = true;
PaintTimeAxis(g, wideTimeTicks, wideTimeStep);
// 网格分界线:图层里**不含**它(它锚定在网格的屏幕位置,会被纵向偏移贴图带走),
// 因此在这里补画一次(与整幅重绘同一套颜色与线宽)。
PaintLaneDividers(g);
// 派生预览曲线:基底不含它们(走像素缓存层 + 平移滚动,见 `PaintDerivedOverlayLayer`)。
PaintDerivedOverlayLayer(g);
// 装饰(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<int>(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。)
//
// ⚠️⚠️ **开启了派生预览曲线时不再走这条补丁路径**(用户 2026-11-02 反馈“改颜色极卡”):
// 派生曲线已经改由独立的像素缓存层绘制(`PaintDerivedOverlayLayer`),而补丁是
// “贴回场景缓存 → 就地重画受影响网格 → 写回缓存”,它会把缓存里**已经烘进去的曲线像素**
// 覆盖掉(曲线随即消失),并且它内部 `PaintLane` 也不再画曲线。实测 15 信号 × 3 功能时
// 这条路径每帧还要同步重建一个网格图层(宽基底 = 2 倍宽 × 2 倍高,AA 全质量)
// ⇒ **单帧 2.2 秒**;而直接走整幅重绘只要 ~0.4 秒(其中约 0.06–0.09 秒是曲线重画)。
// 因此这里要求“没有任何派生功能开启”才允许用补丁路径。
bool derivedActive = false;
for (const Lane& lane : Tab().Lanes())
{
if (lane.edgeCenter || lane.meanFilter || lane.weakPeak)
{
derivedActive = true;
break;
}
}
{
std::vector<int> patchLanes;
if (!derivedActive && !m_forceFullRepaint && FindStyleOnlyLanes(patchLanes) &&
BlitSceneCache(g))
{
// ⚠️ 样式**正在连续变化**(拖透明度 / 粗细 / 拾色滑块):这本身就是一种"交互中"。
// 若不切换快速帧,本帧会用**抗锯齿**重画波形包络带 —— 实测单条网格的包络带
// AA 填充 ≈29ms(本机负载下 72ms),而关掉 AA 只要 ≈5.6ms。
// 因此补丁阶段强制快速帧,并在补丁后把场景缓存标记为"已降级",
// 下一帧(参数稳定后)自动补一帧全质量 —— 既保证拖动手感,又不牺牲最终画质。
m_fastFrame = true;
RepaintSceneCachePatch(g, patchLanes);
m_sceneDegraded = true;
// ⚠️ 网格图层(宽基底)里仍是**旧样式**的像素,必须让它失效。
// 但**不要在本帧同步重建**:宽基底是“2 倍宽 × 2 倍高”的整块重绘
//(15 条稠密信号实测 2.2 秒),同步重建等于把整帧拖到几秒
//(用户 2026-11-02 报告“改颜色时卡到无法操作”的直接原因)。
// 只登记待重建:图层指纹(`LaneLayerHash` 含颜色/线宽/粗细…)会让宽基底快路径
// 自动跳过它(用底板兜底),等视界真正需要时再由既有预算机制补齐。
for (const int laneIndex : patchLanes)
{
QueueLaneRebuild(laneIndex);
}
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<double> 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<int>(index), m_lanes[index], timeTicks);
}
PaintTimeAxis(g, timeTicks, timeStep);
// 派生预览曲线(像素缓存层):必须在装饰层**之前** —— 曲线要垫在
// Y 轴刻度胶囊 / 图例下面(与改造前的层序一致)。
PaintDerivedOverlayLayer(g);
// 装饰层:缓存与当前内容同源则直接贴回;否则先把装饰绘制进透明缓存位图再贴回。
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<int>(m_lanes.size()))
{
const gd::RectF& laneRect = m_lanes[static_cast<std::size_t>(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<int>(m_lanes.size()))
{
const LaneVisual& lane = m_lanes[static_cast<std::size_t>(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<int>(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<int>(m_lanes.size()))
{
g.Restore(saveState);
return;
}
const gd::RectF laneRect = m_lanes[static_cast<std::size_t>(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<std::size_t>(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<int> laneOrder;
for (int index = 0; index < static_cast<int>(m_lanes.size()); ++index)
{
if (index != m_laneDragFrom)
{
laneOrder.push_back(index);
}
}
if (m_laneDragFrom < static_cast<int>(m_lanes.size()))
{
laneOrder.push_back(m_laneDragFrom);
}
for (const int index : laneOrder)
{
const gd::RectF& rect = m_lanes[static_cast<std::size_t>(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<int>(std::floor(rect.X));
const int width = static_cast<int>(std::ceil(rect.X + rect.Width)) - x;
const int y0 = static_cast<int>(std::ceil(clipTop));
const int y1 = static_cast<int>(std::floor(clipBottom));
if (y1 <= y0 || width <= 0)
{
continue;
}
const int sourceY = static_cast<int>(std::lround(sourceTop + (static_cast<float>(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<float>(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<std::size_t>(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<double>& timeTicks)
{
if (laneIndex < 0 || laneIndex >= static_cast<int>(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<std::size_t>(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<int>(m_lanes.size()))
{
const LaneVisual& lane = m_lanes[static_cast<std::size_t>(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<double>(elapsed - kHintHoldMs) /
static_cast<double>(kHintFadeMs));
const unsigned int alpha =
static_cast<unsigned int>(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<float>(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<double>& 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<int>(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<int>(
std::lround(m_wideTickHeight / (kYTickSpacing * m_scale)));
yTickTarget = std::max(kYTickTargetMin, std::min(yTickTarget, kYTickTargetMax));
}
const double yStep = NiceStep(tickSpan, yTickTarget);
std::vector<double> yTicks;
BuildTicks(lane.yMin, lane.yMax, yStep, yTicks);
// 五等分估读线:在相邻主刻度之间再细分,便于目视读取中间值。
const double yMinorStep = yStep / static_cast<double>(kMinorDivisions);
std::vector<double> yMinorTicks;
BuildTicks(lane.yMin, lane.yMax, yMinorStep, yMinorTicks);
std::vector<float> minorSegments;
std::vector<float> weakSegments;
std::vector<float> 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<float>& 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<double>(kMinorDivisions);
std::vector<double> 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<SignalRef>& laneSignals = m_tab->Lanes()[static_cast<std::size_t>(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<gd::PointF>& points = cached->points;
const std::vector<std::size_t>& 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<INT>(half));
bandPath.AddLines(cached->band.data() + start + half,
static_cast<INT>(half));
bandPath.CloseFigure();
}
if (bandPath.GetPointCount() > 0)
{
gd::SolidBrush brush(ToGdiColor(lineColor));
g.FillPath(&brush, &bandPath);
}
g.SetSmoothingMode(static_cast<gd::SmoothingMode>(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<INT>(stop - start));
}
}
}
if (drawScatter && !points.empty())
{
// 散点半径随线宽线性增长(达到上限前不过早触顶),使“线宽”直观控制数据点大小。
const float radius = static_cast<float>(
ClampDouble(static_cast<double>(resolved.style->lineWidth) * kScatterRadiusScale,
kMinScatterRadius, kMaxScatterRadius) *
m_scale);
const std::size_t scatterStride =
std::max<std::size_t>(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);
}
}
}
// 第二遍:派生预览曲线**不再**在这里绘制。
//
// ⚠️ 旧实现把派生曲线画进网格绘制流程(因此也烘进了静态场景缓存与宽缓冲图层),
// 代价是“每帧重画全部曲线”(15 信号 × 3 功能 = 45 条,每条按像素列包络带填充
// ≈2ms,单帧 90ms)。现在统一由**派生曲线像素缓存层**绘制:
// `PaintDerivedOverlayLayer` 在两条快路径里贴图,平移时只滚动像素 + 补一条竖带
//(用户 2026-11-02 要求“视界移动 / 光标动画 / 样式修改都要跟手”)。
// 因此这里(以及 `m_renderWide` 的图层渲染)都不再画曲线。
// 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<int>(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<float>(yTickTarget + 1));
// 先收集需要绘制的数值底板:合并成一次 FillPath(几十个小圆角矩形逐个填充
// 会带来明显的 GDI+ 调用开销),文字再逐个绘制(GDI 文本无法合批)。
std::vector<gd::RectF> pills;
std::vector<std::wstring> pillTexts;
std::vector<bool> 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<int>(kLaneDividerWidth));
}
}
void PlotView::PaintLaneDerivedOverlays(gd::Graphics& g, int laneIndex,
const LaneVisual& lane) const
{
PaintDerivedCurvesFor(g, laneIndex, lane, false);
}
void PlotView::PaintDerivedHoverHighlight(gd::Graphics& g) const
{
if (m_loadingBusy || m_meanRowHoverLane < 0 || !m_meanRowHoverRef.IsValid())
{
return;
}
const std::size_t laneIndex = static_cast<std::size_t>(m_meanRowHoverLane);
if (laneIndex >= m_lanes.size())
{
return;
}
PaintDerivedCurvesFor(g, m_meanRowHoverLane, m_lanes[laneIndex], true);
}
void PlotView::PaintDerivedCurvesFor(gd::Graphics& g, int laneIndex, const LaneVisual& lane,
bool hoverOnly) const
{
if (m_tab == nullptr || laneIndex < 0 ||
laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return;
}
const Lane& tabLane = Tab().Lanes()[static_cast<std::size_t>(laneIndex)];
const bool centerOn = tabLane.edgeCenter;
const bool meanFilterOn = tabLane.meanFilter;
const bool weakPeakOn = tabLane.weakPeak;
if (!centerOn && !meanFilterOn && !weakPeakOn)
{
return;
}
if (hoverOnly && !(centerOn || meanFilterOn || weakPeakOn))
{
return;
}
const int saveState = g.Save();
g.SetClip(lane.rect, gd::CombineModeIntersect);
// 面板条目栏的悬停高亮(用户 2026-10-30 要求):鼠标停在哪一行,就把那条**预览曲线**
// 画一圈光晕 + 加粗。`m_meanRowHoverLane/Ref/Kind` 由 `OnMouseMove` 维护。
//
// ⚠️⚠️ **必须按面板种类分别判定**(用户 2026-11-02 报告):三个面板(均值滤波 /
// 中心轴边沿计数 / 弱峰调整)都有“信号条目栏”,同一条信号可能同时在三个面板里各有一行。
// 只给 MEAN 分支传 `highlight` ⇒ 只开弱峰 + 中心轴时悬停毫无反应;三个全开时
// 悬停任何一个面板都只高亮均值滤波那条。
//
// ⚠️⚠️⚠️ **高亮改由前景单独绘制**(用户 2026-11-02 报告“悬停高亮丢失”):
// 悬停状态不参与 `SceneContentHash`,命中静态场景缓存时本函数**根本不会被调用**,
// 因此把高亮画在这里等于“只有整幅重绘的那一帧能看到”。现在:
// * `hoverOnly == false`(基底 / 场景缓存路径)→ 只画普通曲线(进入缓存,永远可见);
// * `hoverOnly == true`(前景叠加)→ 只画悬停那一条并带光晕。
const bool hoverActive = (m_meanRowHoverLane == laneIndex) && m_meanRowHoverRef.IsValid();
for (const SignalRef& ref : tabLane.signals)
{
if (hoverOnly && !(hoverActive && (ref == m_meanRowHoverRef)))
{
continue;
}
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible)
{
continue;
}
const bool hoveredThisRow = hoverActive && (ref == m_meanRowHoverRef);
if (centerOn)
{
const Lane::EdgeCenterItem* centerItem = tabLane.FindEdgeCenterItem(ref);
if (centerItem != nullptr)
{
const bool highlight =
hoverOnly && hoveredThisRow && (m_meanRowHoverKind == EdgePanelKind::CENTER);
PaintMeanFilterCurve(g, resolved, lane, centerItem->points, centerItem->iterations,
highlight);
}
}
if (meanFilterOn)
{
const Lane::MeanFilterItem* item = tabLane.FindMeanFilterItem(ref);
if (item != nullptr)
{
const bool highlight =
hoverOnly && hoveredThisRow && (m_meanRowHoverKind == EdgePanelKind::MEAN);
PaintMeanFilterCurve(g, resolved, lane, item->points, item->iterations, highlight);
}
}
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));
const bool highlight =
hoverOnly && hoveredThisRow && (m_meanRowHoverKind == EdgePanelKind::PEAK);
PaintWeakPeakCurve(g, resolved, lane, amountPercent, boostIterations, highlight);
}
}
}
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<int>(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<SignalRef>& laneSignals = m_tab->Lanes()[static_cast<std::size_t>(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<LegendItem> 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<int>(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<int>(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<double>& 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<int>(groupIndex)))
{
continue;
}
const SignalGroup* group = m_dataSet->GroupAt(static_cast<int>(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<double>(kMinorDivisions);
std::vector<double> minorTicks;
BuildTicks(Tab().ViewMin(), Tab().ViewMax(), minorStep, minorTicks);
std::vector<float> minorSegments;
std::vector<float> 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<LaneVisual>& 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<std::ptrdiff_t>(total),
target);
const std::size_t hi = static_cast<std::size_t>(
lower - resolved.axis->time.begin());
if (hi >= total)
{
ref.first = ref.second = static_cast<int>(total - 1);
return ref;
}
if (hi == 0)
{
ref.first = ref.second = 0;
return ref;
}
ref.first = static_cast<int>(hi - 1);
ref.second = static_cast<int>(hi);
const double t0 =
resolved.axis->time[static_cast<std::size_t>(ref.first)];
const double t1 =
resolved.axis->time[static_cast<std::size_t>(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<std::size_t>(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<std::size_t>(timeIndex) >= sourceCount)
{
return -1;
}
return static_cast<int>(SourceIndexFor(
*resolved.style, sourceCount, static_cast<std::size_t>(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<std::size_t>(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<float>* curvePtr =
CurveSpanFor(kind, *resolved.data, points, iterations, amountPercent, begin,
end, spanBegin, true, false);
if (curvePtr == nullptr)
{
return;
}
const auto curveValueAt = [curvePtr, spanBegin](int index) -> double
{
if (index < 0 || static_cast<std::size_t>(index) < spanBegin ||
(static_cast<std::size_t>(index) - spanBegin) >= curvePtr->size())
{
return std::numeric_limits<double>::quiet_NaN();
}
return static_cast<double>((*curvePtr)[static_cast<std::size_t>(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<float>& curve,
std::size_t curveBegin, bool highlight) const
{
if (resolved.data == nullptr || resolved.axis == nullptr || resolved.style == nullptr)
{
return;
}
const std::vector<double>& 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<unsigned int>(
std::max(0.0, std::min(1.0, static_cast<double>(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::size_t>(std::max(1.0f, m_plotRect.Width)) * kPolylinePointsPerPixel;
budget = std::max(budget, kMinPolylinePoints);
budget = std::min(budget, kMaxPolylinePoints);
if (m_fastFrame)
{
budget = std::max<std::size_t>(kMinPolylinePoints, budget / 2);
}
const std::size_t stride = std::max<std::size_t>(1, visibleCount / std::max<std::size_t>(1, budget));
// ⭐⭐ **稠密区间改用「逐像素列包络带」**(2026-11-02 性能修复):
// 旧实现按 `stride` 抽稀成折线后 `DrawLines`。视界覆盖百万级采样时,相邻抽稀点的
// y 会**整幅跳变**(几乎每个线段都是竖直满幅),而 GDI+ 描边的代价 ∝ **覆盖的像素数**:
// 实测 3200 个顶点的满幅锯齿折线要 **128ms**(抗锯齿)/ **31ms**(无抗锯齿),
// 同样画面的「逐列 min/max 包络带」一次 FillPath 只要 **1.9ms**(约 60 倍),
// 而且**不会漏掉尖峰**(等间隔抽样会整段跳过)。
// 稀疏区间(可见采样数 ≤ 像素列数)继续走逐点折线:点数本来就少,深放大下也更圆滑。
// 列密度:中等密度用「每 2 像素一列」,极密(> 8 采样/像素)时降到 4 像素一列。
// 列数直接决定包络带的边数与填充面积 —— 稠密视界下这是唯一有效的加速手段。
const float columnDivisor =
(visibleCount >
static_cast<std::size_t>(std::max(1.0f, m_plotRect.Width)) *
kDerivedCurveVeryDenseSamplesPerPixel)
? static_cast<float>(kDerivedCurveColumnDivisor)
: 2.0f;
const int columnCount = static_cast<int>(
std::max(1.0f, std::floor(m_plotRect.Width / columnDivisor)));
const float columnWidth = m_plotRect.Width / static_cast<float>(columnCount);
const bool denseBand = resolved.axis->monotonic && (columnWidth > 0.0f) &&
(visibleCount > static_cast<std::size_t>(columnCount));
if (denseBand)
{
// 每列取「等间隔抽样」的若干个采样记录上下包络:抽样密度只影响包络的饱满程度,
// 却把逐点扫描压到「像素列数 × 少量采样」量级。
const std::size_t columnStride = std::max<std::size_t>(
1, visibleCount /
(static_cast<std::size_t>(columnCount) * kDerivedCurveSamplesPerColumn));
std::vector<BandColumn> columnsData;
std::vector<std::size_t> segmentBreaks;
columnsData.reserve(static_cast<std::size_t>(columnCount) + 1);
bool inSegment = false;
int lastColumn = -1;
for (std::size_t index = begin; index < end; index += columnStride)
{
// 水平镜像 = 数据反序:遍历下标映射到源下标后再取曲线值。
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<double>(sampled)), lane);
y = static_cast<float>(ClampDouble(y, limitTop, limitBottom));
// 时间偏移:该采样画在 `t + offsetTime` 处。
const float x = TimeToX(time[index] + resolved.style->offsetTime);
int column = static_cast<int>(std::floor((x - m_plotRect.X) / columnWidth));
column = std::max(0, std::min(columnCount - 1, column));
if (!inSegment)
{
segmentBreaks.push_back(columnsData.size());
BandColumn fresh;
fresh.x = x;
fresh.top = y;
fresh.bottom = y;
columnsData.push_back(fresh);
lastColumn = column;
inSegment = true;
continue;
}
if (column == lastColumn)
{
BandColumn& back = columnsData.back();
back.top = std::min(back.top, y);
back.bottom = std::max(back.bottom, y);
continue;
}
BandColumn next;
next.x = x;
next.top = y;
next.bottom = y;
columnsData.push_back(next);
lastColumn = column;
}
if (columnsData.empty())
{
return;
}
// 线宽补偿与波形基底同一套(`EmitEnvelopeBand` 做纵向外扩 + 横向膨胀),
// 因此“把线宽拖大”时预览曲线的竖直沿同样会变粗。
const float strokeHalf = std::max(1.0f, resolved.style->lineWidth) * m_scale * 0.5f;
const auto buildBand = [&](float halfWidth, std::vector<gd::PointF>& outBand,
std::vector<std::size_t>& outBreaks, bool& outSpiky)
{
outBand.clear();
outBreaks.clear();
outSpiky = false;
for (std::size_t s = 0; s < segmentBreaks.size(); ++s)
{
const std::size_t start = segmentBreaks[s];
const std::size_t stop =
(s + 1 < segmentBreaks.size()) ? segmentBreaks[s + 1] : columnsData.size();
if (stop < start + 2)
{
continue;
}
std::vector<BandColumn> part(columnsData.begin() + static_cast<std::ptrdiff_t>(start),
columnsData.begin() + static_cast<std::ptrdiff_t>(stop));
EmitEnvelopeBand(part, outBand, outBreaks, outSpiky, lane.rect.Height, halfWidth);
}
};
const auto fillBand = [&](const std::vector<gd::PointF>& bandPoints,
const std::vector<std::size_t>& bandBreaks, std::uint32_t color)
{
gd::GraphicsPath path;
// 包络带会自交(相邻列的上下包络交叉),必须用缠绕规则,否则交叠处被挖空。
path.SetFillMode(gd::FillModeWinding);
for (std::size_t k = 0; k < bandBreaks.size(); ++k)
{
const std::size_t start = bandBreaks[k];
const std::size_t stop =
(k + 1 < bandBreaks.size()) ? bandBreaks[k + 1] : bandPoints.size();
const std::size_t total = stop - start;
if (total < 4 || (total % 2) != 0)
{
continue;
}
const std::size_t half = total / 2;
path.StartFigure();
path.AddLines(bandPoints.data() + start, static_cast<INT>(half));
path.AddLines(bandPoints.data() + start + half, static_cast<INT>(half));
path.CloseFigure();
}
if (path.GetPointCount() > 0)
{
gd::SolidBrush brush(ToGdiColor(color));
g.FillPath(&brush, &path);
}
};
std::vector<gd::PointF> bandPoints;
std::vector<std::size_t> bandBreaks;
bool spiky = false;
buildBand(strokeHalf, bandPoints, bandBreaks, spiky);
if (bandBreaks.empty())
{
return;
}
const int savedSmoothing = g.GetSmoothingMode();
if (spiky || m_fastFrame)
{
g.SetSmoothingMode(gd::SmoothingModeNone);
}
// 悬停高亮:先用**更粗的同一几何**铺一层半透明光晕(垫在曲线下面),再画曲线本身。
if (highlight)
{
std::vector<gd::PointF> haloSpan;
std::vector<std::size_t> haloBreaks;
bool haloSpiky = false;
buildBand(strokeHalf + 2.5f * m_scale, haloSpan, haloBreaks, haloSpiky);
fillBand(haloSpan, haloBreaks, WithAlpha(derived, 0x55u));
}
fillBand(bandPoints, bandBreaks, derived);
g.SetSmoothingMode(static_cast<gd::SmoothingMode>(savedSmoothing));
return;
}
std::vector<gd::PointF> points;
std::vector<std::size_t> breaks;
points.reserve(std::min<std::size_t>(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<double>(sampled)), lane);
y = static_cast<float>(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);
// 悬停高亮(用户 2026-10-30 要求):先沿同一批顶点画一遍**加粗的半透明光晕**,
// 再把曲线本身画粗一点。光晕垫在曲线下面 ⇒ 不遮蔽曲线本身、也不影响它的颜色。
if (highlight)
{
gd::Pen halo(ToGdiColor(WithAlpha(derived, 0x55u)),
std::max(1.0f, resolved.style->lineWidth) * m_scale + 5.0f * m_scale);
halo.SetLineJoin(gd::LineJoinRound);
halo.SetStartCap(gd::LineCapRound);
halo.SetEndCap(gd::LineCapRound);
for (std::size_t k = 0; k < breaks.size(); ++k)
{
const std::size_t start = breaks[k];
const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size();
if (stop <= start + 1)
{
continue;
}
g.DrawLines(&halo, points.data() + start, static_cast<INT>(stop - start));
}
pen.SetWidth(std::max(1.0f, resolved.style->lineWidth) * m_scale + 1.6f * m_scale);
}
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<INT>(stop - start));
}
g.SetSmoothingMode(static_cast<gd::SmoothingMode>(savedSmoothing));
}
void PlotView::PaintMeanFilterCurve(gd::Graphics& g, const ResolvedSignal& resolved,
const LaneVisual& lane, int points, int iterations,
bool highlight) 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, ...)` 完全相同,因此预览与构建结果
// **逐位一致**(用户明确要求"预览显示效果要和构建为新信号一致")。
// 整段窗口超过异步门槛时由后台线程计算,绘制线程本帧用已算好的旧曲线顶替,
// 因此不会阻塞交互;小信号同步计算。
// ⚠️ 「均值点数」的下限是 `kMeanFilterPointsMin`(=1,用户 2026-10-30 明确要求不能为 0):
// 即使条目里的值被外部写成了 0/负数,预览也不能按“不平滑”画出来。
points = std::max(kMeanFilterPointsMin, points);
iterations = std::max(kMeanFilterIterationsMin, iterations);
std::size_t spanBegin = 0;
const std::vector<float>* curve = CurveSpanFor(DerivedCurveKind::MEAN_FILTER, *resolved.data,
points, iterations, 0, 0, count, spanBegin, true);
if (curve == nullptr)
{
return;
}
PaintDerivedCurve(g, resolved, lane, *curve, spanBegin, highlight);
}
void PlotView::PaintWeakPeakCurve(gd::Graphics& g, const ResolvedSignal& resolved,
const LaneVisual& lane, int amountPercent, int iterations,
bool highlight) 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<float>* 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<int>(std::ceil(m_area.X + m_area.Width));
const int height = static_cast<int>(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<float>(m_panelWidth) &&
rect.Y + rect.Height <= static_cast<float>(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<std::uint64_t>(ref.groupIndex));
mix(static_cast<std::uint64_t>(ref.signalIndex));
};
mixFloat(m_scale);
mix(reinterpret_cast<std::uintptr_t>(m_theme));
mix(reinterpret_cast<std::uintptr_t>(m_fonts));
mixRect(m_plotRect);
mixRect(m_area);
mix(static_cast<std::uint64_t>(m_dragMode));
mix(static_cast<std::uint64_t>(m_laneDragFrom + 2));
mix(static_cast<std::uint64_t>(m_laneDragTarget + 2));
mix(static_cast<std::uint64_t>(m_laneDragMouseY + 4096));
// ⚠️⚠️ **交互期间冻结全部“随鼠标变化”的项**(用户 2026-11-02 报告“移动鼠标/拖动光标卡顿”):
// 本键是**全部**面板像素缓存的总开关(`panelGlobalSame`)—— 只要它每帧变,所有面板都会
// 重画。鼠标在绘图区移动时 `m_hoverSignal`(甚至 `m_hoverLane` / `m_hoverCursor`)会不停
// 变化,于是“移动鼠标 → 每帧重画全部面板(288 个信号的中心轴面板 22ms)”成了死循环。
// 这些叠加层都画在**面板之下**、面板本身不透明且贴回时只贴圆角形状之内,因此交互期间
// 直接复用面板像素是安全的;鼠标停下后键自动恢复,面板重画一次即可。
const bool interactivePanelKey = InputIsInteractive();
// 供宿主判断“冻结过后需要补一帧”(冻结期间面板显示的是旧像素,鼠标停下后
// 不会再有重绘请求 ⇒ 统计数字会永远停在“统计中…”)。
m_panelKeysFrozen = interactivePanelKey;
if (interactivePanelKey)
{
mix(0x9E3779B9ull);
mix(0x9E3779B9ull);
}
else
{
mix(static_cast<std::uint64_t>(m_hoverLane + 2));
mix(m_cursorHover ? 1ull : 0ull);
mix(static_cast<std::uint64_t>(m_hoverCursor + 2));
}
mix(static_cast<std::uint64_t>(m_activeCursor));
mix(m_dragMoved ? 1ull : 0ull);
mixRect(m_rubberRect);
if (interactivePanelKey)
{
mix(0x9E3779B9ull);
mix(0x9E3779B9ull);
}
else
{
mixRef(m_hoverSignal);
mixRef(m_hoverLegend);
}
mixRef(m_dragSignal);
mixRef(SelectedRef());
mix(static_cast<std::uint64_t>(m_edgePanelDragLane + 2));
mix(static_cast<std::uint64_t>(m_edgePanelDragKind));
// ⚠️ 「均值滤波 / 中心轴边沿计数」**条目栏的悬停高亮**必须**始终**参与全局键:
// 它直接改变面板像素(行头叠一层强调色),而且只在鼠标真正进入/离开某一行时才变化。
// 若跟着上面那些项一起在交互期间冻结,拖动鼠标到条目栏上高亮就不会出现(用户要求
// “鼠标处于条目栏上方时对应曲线/条目需要高亮提示”)。
mix(static_cast<std::uint64_t>(m_meanRowHoverLane + 2));
mixRef(m_meanRowHoverRef);
// 悬停所在的**面板种类**同样改变面板像素:同一条信号可能在多个面板里各有一行,
// 只有“这一行属于本面板”时该面板才叠强调色(否则悬停一个面板会把其它面板的同名行一起点亮)。
mix(static_cast<std::uint64_t>(m_meanRowHoverKind) + 2);
// 时间光标与统计区间:面板标题的时间后缀、以及「中心轴」行的上升/下降沿数字都依赖它们。
// ⚠️⚠️ **交互期间必须用固定占位值**:拖动光标时时间每帧都在变,键一变就要重画全部面板。
mix(static_cast<std::uint64_t>(Tab().CursorMode()));
if (interactivePanelKey)
{
mixDouble(0.0);
mixDouble(0.0);
}
else
{
mixDouble(Tab().CursorTime());
mixDouble(Tab().CursorTimeAlt());
}
mix(EdgeRangeActive() ? 1ull : 0ull);
if (interactivePanelKey)
{
// 统计区间来自选区时不随鼠标变化;但若它来自光标就会每帧变 —— 一并冻结。
mixDouble(0.0);
mixDouble(0.0);
}
else
{
mixDouble(EdgeRangeMin());
mixDouble(EdgeRangeMax());
}
// 跟随鼠标的叠加层(悬停光晕 / 图例半高亮 / 信号拖拽虚影 / 提示动画)位置随时在变:
// 只要它存在,就把所有面板缓存作废,避免它的残影被一起缓存进面板像素。
//
// ⚠️⚠️ **交互期间必须忽略它**(用户 2026-11-02 报告“移动鼠标/拖动光标卡顿”):
// 鼠标在绘图区里移动时 `m_hoverSignal` 常常有效(悬停在某条曲线上),这里就会把
// **鼠标坐标**混进全局键 ⇒ 键每帧都变 ⇒ 全部面板每帧重画。288 个信号的中心轴面板
// 一块就要 ~23ms(实测:面板开 27.6ms / 面板关 4.3ms)。
// 而悬停光晕、拖拽虚影都画在**面板之下**,面板本身不透明且贴回时只贴圆角形状之内
// (见下面的 `CreateRoundRectRgn` 裁剪 + 每帧重画投影),因此交互期间直接复用面板像素
// 是安全的;鼠标停下后键自动恢复,面板重画一次即可。
const bool mouseDrivenOverlay =
!interactivePanelKey &&
(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<std::uint64_t>(m_mouseX + 4096));
mix(static_cast<std::uint64_t>(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<Lane>& 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<std::uint64_t>(ref.laneIndex + 2));
mix(static_cast<std::uint64_t>(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<std::uint64_t>(laneIndex + 2));
mix(static_cast<std::uint64_t>(static_cast<int>(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.tableHeader);
for (const EdgePanelLayout::TableColumn& column : layout.tableColumns)
{
mixFloat(column.left);
mixFloat(column.width);
}
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<std::uint64_t>(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<std::uint64_t>(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<std::uint64_t>(group.ref.groupIndex + 2));
mix(static_cast<std::uint64_t>(group.ref.signalIndex + 2));
}
const Lane& lane = Tab().Lanes()[static_cast<std::size_t>(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<std::uint64_t>(lane.freeMarkerSelected + 2));
// 固定点的**内容**(哪个信号、什么时刻)直接决定面板文字与行数,必须进键;
// 否则新增/删除一个固定点后可能贴回旧像素。
for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems)
{
mix(static_cast<std::uint64_t>(item.ref.groupIndex + 2));
mix(static_cast<std::uint64_t>(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<std::uint64_t>(ref.groupIndex));
mix(static_cast<std::uint64_t>(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<std::uint64_t>(meanItem != nullptr ? meanItem->points + 2 : 0));
mix(static_cast<std::uint64_t>(meanItem != nullptr ? meanItem->iterations + 2 : 0));
// 「均值滤波」改为滑块后,**滑块上限**决定圆钮位置、**收起状态**决定是否显示参数行
// ⇒ 两者都直接改变面板像素,必须进键(否则拖完滑块仍会贴回旧像素)。
mix(static_cast<std::uint64_t>(meanItem != nullptr ? meanItem->maxPoints + 2 : 0));
mix(static_cast<std::uint64_t>(meanItem != nullptr ? meanItem->maxIterations + 2 : 0));
mix((meanItem != nullptr && meanItem->collapsed) ? 1ull : 0ull);
const Lane::EdgeCenterItem* centerItem = lane.FindEdgeCenterItem(ref);
mix(static_cast<std::uint64_t>(centerItem != nullptr ? centerItem->points + 2 : 0));
mix(static_cast<std::uint64_t>(centerItem != nullptr ? centerItem->iterations + 2 : 0));
// 弱峰调整的逐信号参数(调整幅度 % + 迭代次数 + 该信号的滑块上限)直接显示在面板上:
// 上限决定迭代次数滑块的圆钮位置,因此也必须参与缓存键。
const Lane::WeakPeakItem* peakItem = lane.FindWeakPeakItem(ref);
mix(static_cast<std::uint64_t>(peakItem != nullptr ? peakItem->amountPercent + 2 : 0));
mix(static_cast<std::uint64_t>(peakItem != nullptr ? peakItem->iterations + 2 : 0));
mix(static_cast<std::uint64_t>(peakItem != nullptr ? peakItem->maxIterations + 2 : 0));
// 「弱峰调整」的条目栏三角(展开 / 收起)直接改变面板像素 ⇒ 必须进键。
mix((peakItem != nullptr && peakItem->collapsed) ? 1ull : 0ull);
}
// ⚠️⚠️ **边沿统计的数字本身就是面板内容,数字变化必须能让面板重绘**:
// 界面上的「上升沿 / 下降沿」是**分帧推进**出来的中间结果,若数字变化不改变键,
// 面板就会一直贴旧像素 ⇒ 用户看到"永远统计中…"(只有移动鼠标/悬停面板才更新)。
//
// 但**不能**在这里为每个信号调一次 `EdgeCountsFor` / `EdgeCenterCountsFor`:
// * 288 个信号时单帧 288 次查询(每次还要线性查找累积状态);
// * 中间数字每帧都在变 ⇒ 键每帧都变 ⇒ 面板像素缓存**永远不命中**,
// 于是每帧都要把整块面板(上百行 × 十来个 GDI+ 原语)重画一遍 ⇒ 实测 30ms/帧。
//
// ⇒ 改为混合一个**统计数据版本号**(O(1)、零查询):
// `EdgeScanFor` 每推进/完成一次就自增 `m_edgeScanRevision`;`Paint` 开头把它
// 转成 `m_panelStatsVersion`(非交互帧立刻同步;交互帧节流,避免每帧重画)。
// 于是"统计推进 ⇒ 版本号变化 ⇒ 面板重绘",而"没推进 ⇒ 键稳定 ⇒ 贴回缓存"。
if (kind == EdgePanelKind::COUNT || kind == EdgePanelKind::CENTER)
{
mix(m_panelStatsVersion);
}
// 弱峰调整的滑块行矩形(每信号两行)。
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<float>(m_mouseX), static_cast<float>(m_mouseY)))
{
mix(static_cast<std::uint64_t>(m_mouseX + 4096));
mix(static_cast<std::uint64_t>(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<int>(sizeof(lf)), &lf) != static_cast<int>(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<INT>(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<int>(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<LaneVisual>& 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<Lane>& 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<gd::RectF> 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<int>(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<int>(std::floor(rect.X));
const int y0 = static_cast<int>(std::floor(rect.Y));
const int x1 = static_cast<int>(std::ceil(rect.X + rect.Width));
const int y1 = static_cast<int>(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<int>(laneIndex) &&
m_freeMarkerRowHoverItem != static_cast<std::size_t>(-1));
// 内容(取值 / 文本 / 信号色)来自与视界无关的缓存,不逐帧重新采样。
const std::vector<FreeMarkerContentEntry>& contents = FreeMarkerContents(static_cast<int>(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<int>(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<int>(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<int>(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<int>(laneIndex) &&
m_freeMarkerHoverItem == itemIndex);
// 「标记透明度」:鼠标停在某个取值胶囊上时把**这一个**胶囊恢复为全不透明
//(用户 2026-10-21 要求:既能看淡全局标记,又能读清当前关注的数值)。
// ⚠️ 例外:如果此刻跟随探针正瞄着一个**被这个胶囊遮住的数据点**
//(用户意图标记被遮住的那条曲线),则不恢复 —— 否则胶囊会重新遮住目标点、
// 无法看出自己到底瞄到了哪个采样点。
float capsuleAlpha = rowAlpha;
const float fx = static_cast<float>(m_mouseX);
const float fy = static_cast<float>(m_mouseY);
if (capsuleAlpha < 0.999f && UiPointInRect(label, fx, fy))
{
const bool probeHiddenByLabel =
m_freeMarker.valid && m_freeMarker.laneIndex == static_cast<int>(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<int>(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<int>(lanes.size()))
{
const LaneVisual& visual = lanes[static_cast<std::size_t>(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<LaneVisual>& lanes)
{
if (m_tab == nullptr || m_dataSet == nullptr)
{
return;
}
// ⚠️ **加载中跳过全部统计面板绘制**:加载线程主循环不断 `UpdateWindow` 触发整帧绘制,
// 而这里含统计推进与上百行 GDI+ 原语 ⇒ 加载进度被拖慢(用户报告“统计没算完就无法
// 加载文件”)。加载遮罩会盖住整个界面,面板本来也看不见。
if (m_loadingBusy)
{
m_panelSlots.clear();
m_panelValid = false;
return;
}
std::vector<Lane>& 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, const gd::RectF& clip)
{
// ⚠️ **必须用 `UiTextClipped`**:GDI 文本不受 GDI+ `SetClip` 约束,
// 内容区滚动时统计文字会画到面板标题栏 / 面板之外
//(用户 2026-11-02 报告“滚动时绘制超出了浮动面板”的直接原因)。
if (EdgeCountsPending(rising, falling, complete))
{
UiTextClipped(g, L"统计中…", m_fonts->monoSmall, rect, m_theme->textMuted,
TextAlign::LEFT, clip);
return;
}
const float gap = kEdgePanelStatColumnGap * m_scale;
const float columnWidth = std::max(10.0f, (rect.Width - gap) * 0.5f);
UiTextClipped(g, EdgeRisingText(rising, complete), m_fonts->monoSmall,
gd::RectF(rect.X, rect.Y, columnWidth, rect.Height), m_theme->textMuted,
TextAlign::LEFT, clip);
UiTextClipped(g, EdgeFallingText(falling, complete), m_fonts->monoSmall,
gd::RectF(rect.X + columnWidth + gap, rect.Y, columnWidth, rect.Height),
m_theme->textMuted, TextAlign::LEFT, clip);
};
std::vector<PanelSlot> nextSlots;
std::vector<gd::RectF> dirtyRects;
const std::uint64_t panelGlobalKey = PanelGlobalKey();
// 编辑态(输入框 + 插入符)逐个面板变化太细碎,直接整体不缓存。
const bool panelCacheReady =
(m_editTarget == PlotEditTarget::NONE) && EnsurePanelCache();
const bool panelGlobalSame =
panelCacheReady && m_panelValid && panelGlobalKey == m_panelGlobalKey;
// 交互帧(拖动 / 鼠标刚移动)的标志:缓存命中的贴图分支据此跳过昂贵的投影重画
//(见下面 `UiFillRoundRect` 的说明)。
const bool interactiveFrame = InputIsInteractive();
// ⚠️ **按层序从下往上绘制**(与命中测试、像素缓存的层叠顺序完全一致):
// `m_panelOrder` 队尾 = 最上层 = 最后绘制。
SyncPanelOrder();
// 预扫描一:本帧各面板的布局(循环里直接复用,避免每帧重复计算)。
std::vector<EdgePanelLayout> panelLayouts(m_panelOrder.size());
for (std::size_t ordinal = 0; ordinal < m_panelOrder.size(); ++ordinal)
{
panelLayouts[ordinal] = EdgePanelLayoutFor(
m_panelOrder[ordinal].laneIndex, static_cast<EdgePanelKind>(m_panelOrder[ordinal].kind));
}
// 预扫描二:上一帧已缓存、但本帧**位置/尺寸变了(或被移除)**的面板——它们腾出的
// 区域可能仍然留在**其它面板**的缓存像素里(那块像素是上一帧连它一起拷下来的),
// 于是鼠标拖动面板时,下方面板上会出现拖拽残影(用户 2026-10-21 报告)。
// 把腾空区域也当“脏区”,强制覆盖到它的面板重画,就能彻底消除残影。
std::vector<gd::RectF> 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<int>(std::floor(panel.X - margin)));
cy0 = std::max(0, static_cast<int>(std::floor(panel.Y - margin)));
cx1 = std::max(cx0, static_cast<int>(std::ceil(panel.X + panel.Width + margin)));
cy1 = std::max(cy0, static_cast<int>(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<float>(slot.x0),
static_cast<float>(slot.y0),
static_cast<float>(slot.x1 - slot.x0),
static_cast<float>(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<EdgePanelKind>(panelRef.kind);
const std::size_t index = static_cast<std::size_t>(panelRef.laneIndex);
// 文本几何层号:**每个面板独占一层**,与 `BuildBoxes` 完全同源
//(`baseLayer + 1 + 面板序数`,面板序数按 `m_panelOrder` 底 → 顶)。
// 少了这一步,下层面板的文字框会记在比上层面板更低的层里被反复挖洞/穿透。
UiSetTextLayer(panelTextLayer + static_cast<int>(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 listStyle = (meanFilter || centerAxis || weakPeak);
const bool countTable = (kind == EdgePanelKind::COUNT);
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<int>(index), kind, layout);
if (panelGlobalSame && !forcedDirty)
{
const PanelSlot* hit = nullptr;
for (const PanelSlot& slot : m_panelSlots)
{
if (slot.laneIndex == static_cast<int>(index) &&
slot.kind == static_cast<int>(kind))
{
hit = &slot;
break;
}
}
if (hit != nullptr && hit->key != 0 && hit->key == slotKey)
{
// ⚠️⚠️ **只贴「面板圆角形状之内」的像素**(用户 2026-10-30 报告:
// 中键移动视界时面板周围带着一圈**旧视界的波形像素**)。
// 原因是缓存位图按 `panel ± cacheMargin` 取块,块的边缘那几像素是
// **上一帧的场景**(波形/网格线);`PanelGlobalKey` 刻意不含视界
// (否则平移会重画全部面板),于是整块贴回时就把旧波形"复印"了一圈。
// 现在:① 先把**投影**按本帧重新画一遍(它在圆角形状之外,不能靠缓存);
// ② 用圆角区域裁剪后只 BitBlt 面板本体(圆角之外仍是本帧场景)。
// ⚠️⚠️ **投影不能每帧重画**(用户 2026-11-02 报告“移动鼠标/拖动光标卡顿”):
// 它是一块面积与面板同大的**半透明抗锯齿圆角矩形**(中心轴面板高近 950px、
// 宽 400px),GDI+ 软件渲染这一块实测要 **20ms 以上** —— 而缓存命中的
// 贴图本身只要零点几毫秒。交互期间(拖动 / 鼠标刚移动)直接跳过投影:
// 面板圆角之外保留上一帧的像素(此时那块区域本来也是静止的场景),
// 鼠标停下后键恢复、面板重画一次即补上投影。
if (!m_fastFrame && !interactiveFrame)
{
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<int>(std::floor(panel.X)) - 1;
const int by0 = static_cast<int>(std::floor(panel.Y)) - 1;
const int bx1 = static_cast<int>(std::ceil(panel.X + panel.Width)) + 1;
const int by1 = static_cast<int>(std::ceil(panel.Y + panel.Height)) + 1;
HRGN rounded = CreateRoundRectRgn(
bx0, by0, bx1 + 1, by1 + 1,
static_cast<int>(std::lround(10.0f * m_scale)),
static_cast<int>(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 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<float>(m_mouseX),
static_cast<float>(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<float>(m_mouseX), static_cast<float>(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<float>(m_mouseX), static_cast<float>(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 (countTable)
{
// ---- 表头 ----
{
UiFillRoundRect(g, layout.tableHeader, 3.0f * m_scale, m_theme->pressed);
UiDrawRoundRect(g, layout.tableHeader, 3.0f * m_scale, m_theme->border, 1.0f);
for (int column = 0; column < 3; ++column)
{
const EdgePanelLayout::TableColumn& cell = layout.tableColumns[column];
if (cell.width <= 0.0f)
{
continue;
}
const float textLeft =
(column == 0) ? (cell.left + 4.0f * m_scale) : cell.left;
UiTextClipped(g, EdgeCountTableHeader(column), m_fonts->uiSmall,
gd::RectF(textLeft, layout.tableHeader.Y, cell.width,
layout.tableHeader.Height),
m_theme->textMuted,
(column == 0) ? TextAlign::LEFT : TextAlign::CENTER,
layout.tableHeader);
}
}
const int tableSave = g.Save();
// 行内容必须裁到内容区:滚动时行会越出可视范围。
g.SetClip(layout.content, gd::CombineModeIntersect);
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex)
{
if (rowIndex >= layout.rowHeaders.size() ||
rowIndex >= layout.rowNameRects.size())
{
break;
}
const SignalRef& ref = layout.rowRefs[rowIndex];
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.style == nullptr)
{
continue;
}
const gd::RectF& row = layout.rowHeaders[rowIndex];
if (row.Y + row.Height <= layout.content.Y ||
row.Y >= layout.content.Y + layout.content.Height)
{
continue;
}
const std::uint32_t signalColor = StyleColor(*resolved.style);
const UiInk ink = UiContrastInk(signalColor);
// 行底色 = 该信号的配色(用户 2026-11-02 要求)。
UiFillRoundRect(g, row, 3.0f * m_scale, signalColor);
UiFillRoundRect(g, row, 3.0f * m_scale, WithAlpha(signalColor, 0xE8));
// 信号名(第一列):超出列宽时省略。
const gd::RectF& nameRect = layout.rowNameRects[rowIndex];
UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall,
std::max(8.0f, nameRect.Width)),
m_fonts->uiSmall, nameRect, ink.text, TextAlign::LEFT,
layout.content);
// 两列计数:与表头列**同一左边界 / 同一宽度**,纵向严格对齐。
std::size_t rising = 0;
std::size_t falling = 0;
bool complete = true;
if (resolved.axis != nullptr)
{
EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style,
tabLane.edgeValue, EdgeRangeActive(), EdgeRangeMin(),
EdgeRangeMax(), rising, falling, complete);
}
for (int column = 1; column < 3; ++column)
{
const EdgePanelLayout::TableColumn& cell = layout.tableColumns[column];
if (cell.width <= 0.0f)
{
continue;
}
const std::wstring text =
EdgeCountCellText((column == 1) ? rising : falling, complete);
UiTextClipped(g, text, m_fonts->monoSmall,
gd::RectF(cell.left, row.Y, cell.width, row.Height), ink.text,
TextAlign::CENTER, layout.content);
}
}
g.Restore(tableSave);
// 内容滚动条(只在内容超高时出现;表头不参与滚动)。
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 (listStyle)
{
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];
// 行的**可见性**必须按“整行的纵向范围”判定,不能只看条目栏:
// 条目栏已滚出内容区,但它下面两行参数滑块仍可能还在可视区内
//(用户 2026-10-30 报告:此时两个滑块会直接消失 —— 显示错误)。
float rowTop = header.Y;
float rowBottom = header.Y + header.Height;
const auto extendRow = [&rowBottom](const gd::RectF& rect) {
if (rect.Width > 0.0f && rect.Height > 0.0f)
{
rowBottom = std::max(rowBottom, rect.Y + rect.Height);
}
};
if (rowIndex < layout.rowLevelTracks.size())
{
extendRow(layout.rowLevelTracks[rowIndex]);
}
if (rowIndex < layout.rowLevelValues.size())
{
extendRow(layout.rowLevelValues[rowIndex]);
}
if (rowIndex < layout.rowLevelResets.size())
{
extendRow(layout.rowLevelResets[rowIndex]);
}
if (rowIndex < layout.rowIterTracks.size())
{
extendRow(layout.rowIterTracks[rowIndex]);
}
if (rowIndex < layout.rowIterValues.size())
{
extendRow(layout.rowIterValues[rowIndex]);
}
if (rowIndex < layout.rowIterResets.size())
{
extendRow(layout.rowIterResets[rowIndex]);
}
if (rowIndex < layout.rowStats.size())
{
extendRow(layout.rowStats[rowIndex]);
}
if (rowBottom <= layout.content.Y ||
rowTop >= layout.content.Y + layout.content.Height)
{
continue; // 整行(含全部参数行)完全滚出可视范围
}
const std::uint32_t signalColor = StyleColor(*resolved.style);
const UiInk ink = UiContrastInk(signalColor);
// 「均值滤波」看 `meanFilterItems`、「中心轴边沿计数」看 `edgeCenterItems`、
//「弱峰调整」看 `weakPeakItems`(前两者同为 `Lane::MeanFilterItem`)。
const Lane::WeakPeakItem* peakItem =
weakPeak ? tabLane.FindWeakPeakItem(ref) : nullptr;
const Lane::MeanFilterItem* item =
weakPeak ? nullptr
: (centerAxis ? tabLane.FindEdgeCenterItem(ref)
: tabLane.FindMeanFilterItem(ref));
const bool checked = weakPeak ? (peakItem != nullptr) : (item != nullptr);
const bool rowCollapsed =
weakPeak ? (peakItem != nullptr && peakItem->collapsed)
: (item != nullptr && item->collapsed);
// 内容区已由 `g.SetClip(layout.content)` 裁切(行可以“半截”显示,滚动更顺滑);
// 已经完整滚出可视范围的行在上面就 `continue` 了,这里不再额外门控。
UiFillRoundRect(g, header, 3.0f * m_scale, signalColor);
UiFillRoundRect(g, header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8));
// 条目栏悬停:在信号色叠底上再压一层强调色,给出"这一行对应哪条曲线"的提示
//(曲线本身的高亮见 `PaintLaneDerivedOverlays`)。
// ⚠️ 必须同时比**面板种类**:同一条信号在其它面板里也有一行,
// 只比 (lane, ref) 会把别的面板的同名行一起点亮。
if (m_meanRowHoverLane == static_cast<int>(index) &&
m_meanRowHoverRef == ref && m_meanRowHoverKind == kind)
{
UiFillRoundRect(g, header, 3.0f * m_scale,
WithAlpha(m_theme->accent, 0x2E));
}
// 左侧三角(仅勾选时存在):朝下 = 展开、朝右 = 收起。
const gd::RectF& triangle = layout.rowTriangles[rowIndex];
if (triangle.Width > 0.0f && checked)
{
UiDrawTriangleIcon(g, triangle, ink.text,
rowCollapsed ? 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 || rowCollapsed)
{
continue;
}
// 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。
if (centerAxis && rowIndex < layout.rowStats.size() &&
layout.rowStats[rowIndex].Width > 0.0f)
{
std::size_t rising = 0;
std::size_t falling = 0;
bool complete = true;
if (resolved.axis != nullptr)
{
EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style,
item->points, item->iterations, EdgeRangeActive(),
EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete);
}
paintCounts(layout.rowStats[rowIndex], rising, falling, complete,
layout.content);
}
// 两行参数滑块(标签 / 轨道 / 数值 / 复位按钮)。
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)
{
const float labelLeft = track.X - 6.0f * m_scale -
UiMeasureTextWidth(m_fonts->uiSmall, label);
// ⚠️ **必须用 `UiTextClipped`(而不是 `UiText`)**:GDI 文本不受
// GDI+ `SetClip` 约束,滚动时标签/数值会画到内容区之外
//(用户 2026-10-30 报告"元素超出了浮动面板"的直接原因)。
UiTextClipped(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, layout.content);
const double ratio =
(maximum > minimum)
? (static_cast<double>(current - minimum) /
static_cast<double>(maximum - minimum))
: 0.0;
UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale);
UiTextClipped(g, std::to_wstring(current), m_fonts->monoSmall,
gd::RectF(valueRect.X, valueRect.Y, valueColumn, valueRect.Height),
m_theme->textStrong, TextAlign::LEFT, layout.content);
// 复位按钮:悬停高亮 + 与「系数/偏移」菜单同款图标。
const bool hot =
(m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) &&
UiPointInRect(resetRect, static_cast<float>(m_mouseX),
static_cast<float>(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);
};
// ⚠️ 「均值点数」的最小值:**均值滤波 = 1**(用户 2026-10-30 要求不能为 0,
// 0 与不做平滑完全等价,看起来像功能坏了);**中心轴边沿计数 = 0**
// (0 = 不平滑,直接对原始采样值统计边沿 —— 这是有意义的选项,保持旧语义)。
if (weakPeak)
{
// 「弱峰调整」两行滑块:调整幅度(0 在正中间 ⇒ ±400%)与迭代次数(1 … 自定上限)。
const int peakAmount = std::max(
kWeakPeakAmountMin, std::min(kWeakPeakAmountMax, peakItem->amountPercent));
const int peakMax = std::max(
kWeakPeakIterationsMin,
std::min(kWeakPeakIterationsMaxLimit, peakItem->maxIterations));
const int peakIter =
std::max(kWeakPeakIterationsMin, std::min(peakMax, peakItem->iterations));
const bool levelActive = (m_dragMode == PlotDragMode::PEAK_LEVEL) &&
(m_peakDragLane == static_cast<int>(index)) &&
(m_peakDragRef == ref);
const bool iterActive = (m_dragMode == PlotDragMode::PEAK_ITERATIONS) &&
(m_peakDragLane == static_cast<int>(index)) &&
(m_peakDragRef == ref);
const bool editingMax =
(m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX) &&
(m_editLane == static_cast<int>(index)) && (m_editMeanRef == ref);
// 幅度滑块:比例 = (幅度 / (100*4) + 1) / 2(0% 在正中间)。
const auto paintPeakSlider = [&](const gd::RectF& track,
const gd::RectF& valueRect,
const gd::RectF& resetRect,
const wchar_t* label, double ratio,
const std::wstring& valueText, bool active,
bool hideBody)
{
const float labelLeft = track.X - 6.0f * m_scale -
UiMeasureTextWidth(m_fonts->uiSmall, label);
UiTextClipped(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, layout.content);
if (hideBody)
{
return; // 正在编辑上限:轨道与数值改由 `PaintEditField` 画
}
UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale);
UiTextClipped(g, valueText, m_fonts->monoSmall,
gd::RectF(valueRect.X, valueRect.Y, valueColumn,
valueRect.Height),
m_theme->textStrong, TextAlign::LEFT, layout.content);
const bool hot = (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) &&
UiPointInRect(resetRect,
static_cast<float>(m_mouseX),
static_cast<float>(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 double levelRatio =
(static_cast<double>(peakAmount) / (100.0 * kWeakPeakSliderScale) + 1.0) * 0.5;
const double iterRatio =
(peakMax > kWeakPeakIterationsMin)
? (static_cast<double>(peakIter - kWeakPeakIterationsMin) /
static_cast<double>(peakMax - kWeakPeakIterationsMin))
: 0.0;
if (rowIndex < layout.rowLevelTracks.size() &&
layout.rowLevelTracks[rowIndex].Width > 0.0f)
{
paintPeakSlider(
layout.rowLevelTracks[rowIndex],
rowIndex < layout.rowLevelValues.size()
? layout.rowLevelValues[rowIndex]
: gd::RectF(),
rowIndex < layout.rowLevelResets.size()
? layout.rowLevelResets[rowIndex]
: gd::RectF(),
kWeakPeakLevelLabel, levelRatio,
(peakAmount > 0 ? L"+" : L"") + std::to_wstring(peakAmount) + L"%",
levelActive, false);
}
if (rowIndex < layout.rowIterTracks.size() &&
layout.rowIterTracks[rowIndex].Width > 0.0f)
{
paintPeakSlider(
layout.rowIterTracks[rowIndex],
rowIndex < layout.rowIterValues.size()
? layout.rowIterValues[rowIndex]
: gd::RectF(),
rowIndex < layout.rowIterResets.size()
? layout.rowIterResets[rowIndex]
: gd::RectF(),
kWeakPeakIterationsSliderLabel, iterRatio,
std::to_wstring(peakIter), iterActive, editingMax);
}
}
else
{
// 只可能是「均值滤波」:中心轴边沿计数已改为独立的表格分支。
const int pointsFloor = kMeanFilterPointsMin;
const int pointsMax = std::max(pointsFloor,
std::min(kMeanFilterPointsMaxLimit,
item->maxPoints));
const int points =
std::max(pointsFloor, 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<int>(index)) &&
(m_meanDragRef == ref) &&
(m_meanDragKind == kind);
const bool iterationsActive =
(m_dragMode == PlotDragMode::MEAN_ITERATIONS) &&
(m_meanDragLane == static_cast<int>(index)) && (m_meanDragRef == ref) &&
(m_meanDragKind == kind);
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,
pointsFloor, 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);
// ---- 内容滚动条(只在内容超高时出现;底部按钮不参与滚动)----
// ⚠️ 必须在 `g.Restore(meanSave)` **之后**画:滚动条位于内容区右侧、在
// 内容裁剪区之外,若在裁剪内画会被整条裁掉(用户 2026-10-30 报告
// "全部启用后没有出现滚动条"的根因)。
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);
}
}
// 「自由标记」面板:按信号分组的**分组栏**(信号色托底 + 三角 + 名称 +
// 「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<int>(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<int>(index) &&
m_freeMarkerPanelHoverSignal == group.ref &&
m_freeMarkerPanelHoverItem == static_cast<std::size_t>(-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<FreeMarkerContentEntry>& contents =
FreeMarkerContents(static_cast<int>(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<std::size_t> 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<float, 6>& 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<int>(markerRow.itemIndex));
const bool rowHot = (m_freeMarkerRowHoverLane == static_cast<int>(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<int>(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<int>(index)) ||
UiPointInRect(layout.opacityTrack, static_cast<float>(m_mouseX),
static_cast<float>(m_mouseY));
// 外观与信号样式区的「透明度」滑动条完全相同(同一 `UiDrawSlider` 口径)。
UiDrawSlider(g, layout.opacityTrack,
static_cast<double>(tabLane.freeMarkerOpacity), m_theme->accent,
sliderActive, m_scale);
const int percent =
static_cast<int>(tabLane.freeMarkerOpacity * 100.0f + 0.5f);
UiText(g, std::to_wstring(percent) + L"%", m_fonts->monoSmall,
layout.opacityValue, m_theme->textStrong, TextAlign::RIGHT);
}
}
// 逐条目面板底部按钮行:「构建为新信号」(悬停高亮);
// 「自由标记」面板的底部固定区有三行:
// ①「标记透明度」滑块(画在上面);
// ②「自由标记点选取」切换按钮;
// ③「显示增量 | 显示枚举文本」两个开关(用户 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<float>(m_mouseX),
static_cast<float>(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<int>(index);
stored.kind = static_cast<int>(kind);
stored.key = 0;
if (panelCacheReady && slotKey != 0)
{
const int x0 = std::max(0, static_cast<int>(std::floor(cachedRect.X)));
const int y0 = std::max(0, static_cast<int>(std::floor(cachedRect.Y)));
const int x1 = std::max(x0, static_cast<int>(std::ceil(cachedRect.X + cachedRect.Width)));
const int y1 = std::max(y0, static_cast<int>(std::ceil(cachedRect.Y + cachedRect.Height)));
const gd::RectF layerRect(static_cast<float>(x0), static_cast<float>(y0),
static_cast<float>(x1 - x0), static_cast<float>(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<int>(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<int>(m_lanes.size()))
{
return;
}
// 虚影:以源网格的幅值映射构建波形,再按鼠标位移整体平移。
// 第一帧位移为 0,因此虚影必然精确落在鼠标下方(曲线形态与抓住时完全一致);
// 之后纵向与横向均自由跟随,形成“拖着曲线走”的观感。
const LaneVisual& sourceVisual = m_lanes[static_cast<std::size_t>(sourceLane)];
const float offsetX = static_cast<float>(m_dragMouseX - m_dragStartX);
const float offsetY = static_cast<float>(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<gd::PointF>& points = ghostSource->points;
const std::vector<std::size_t>& 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<unsigned int>(
std::max(0.35, static_cast<double>(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<INT>(half));
bandPath.AddLines(ghostSource->band.data() + start + half,
static_cast<INT>(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<INT>(stop - start));
}
}
}
if (drawScatter && !points.empty())
{
const float radius = static_cast<float>(
ClampDouble(static_cast<double>(resolved.style->lineWidth) * kScatterRadiusScale,
kMinScatterRadius, kMaxScatterRadius) *
m_scale);
const std::size_t scatterStride =
std::max<std::size_t>(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<int>(m_lanes.size()))
{
const gd::RectF& targetRect = m_lanes[static_cast<std::size_t>(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<int>(m_lanes.size()))
{
return;
}
const ResolvedSignal resolved = ResolveSignal(m_hoverSignal);
if (resolved.data == nullptr)
{
return;
}
const LaneVisual& lane = m_lanes[static_cast<std::size_t>(laneIndex)];
// ⚠️ 「选区放大」模式下**仍保留悬停高亮**,但该模式下拖动是框选、不是拖信号
// ⇒ 提示文案去掉“按住拖拽可移入其它网格”的尾缀,只提示信号名(用户 2026-10-17 要求)。
// ⚠️ 同理(用户 2026-10-23 要求):该网格开启「自由标记点选取」时,左键专用于固定
// 标记点、拖动也不再是拖信号 ⇒ 提示只显示信号名,不再提示可以拖拽。
const std::vector<Lane>& tabLanes = m_tab->Lanes();
const bool pickMode =
laneIndex < static_cast<int>(tabLanes.size()) &&
tabLanes[static_cast<std::size_t>(laneIndex)].freeMarker &&
tabLanes[static_cast<std::size_t>(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<float>(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<float>(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<std::uint32_t>(static_cast<float>((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