Files
Just-DataInspector/Components/PlotView/Src/PlotViewPaint.cpp
T
DeepSeek-V4.1-Flash 0efaa8c0ca 悬停光晕 210ms/帧 → 4ms(包络带填充 + 光标窗口裁剪)+ 平移丢波形判据修正 + 随机组合压力测试
一、悬停巨型信号时整帧卡死(用户报:VERY_BIG_DATA 的「编码器A相」,浮动提示不再跟随鼠标)
探针复现:hoverMean = 210.31ms / baseline = 0.90ms。
根因:PaintSignalHalo 用「加粗 GDI+ 折线」描边整条曲线,而包络带信号的顶点是
「每像素列上/下包络交替」的满幅竖锯齿 —— GDI+ 描边代价 ≈ 线段数 × 覆盖行数
(2503 顶点 × 1081 行)。同一几何改用 FillPath 只要 ~1ms。
修法(PlotViewPaint.cpp / PlotViewInternal.hpp):
1) 包络带信号(cached.bandMode)的光晕改为**填充与曲线本身同一几何**的包络带
   (bandBreaks 段 → 上包络正向 + 下包络反向,alpha 0x55);
2) 只画鼠标附近一段(kHaloWindowHalfWidth=72 逻辑像素、kHaloWindowMinPoints=360):
   · 包络带:窗口换算成列区间再填充(下包络反向存储 ⇒ 列 c 在 start+half+(half-1-c),
     列区间恰对应一段正向可读的连续区间);
   · 折线描边:O(n) 扫一遍取窗口内的连续顶点段,段首尾各多含一个顶点(跨窗口的线段完整画出);
   · 散点高亮同样按窗口过滤(步长仍与 PaintLane 同源)。
3) 窗口中心 = 鼠标 x;鼠标不在绘图区时取绘图区中心(列表/样式区悬停代价同样恒定)。
实测 210ms → 4.0ms(提示恢复跟手)。几何正确性用「透明位图上两次绘制」探针确认:
窗口内与整条光晕逐像素相同(mismatched=0)、窗口外 0 像素(far=0)、全部落在包络带内(outsideBand=0)。

二、组折叠/展开动画卡顿甚至中断(用户用 MF4→BIG→MF4 + 只勾「编码器A相」稳定复现)
这是上面那条的连带症状:列表悬停时 Application::Render 把 panel.HoveredSignal() 推给
SetExternalHoverSignal ⇒ 鼠标每动一次就重画一次光晕;悬停在 2576 万采样的编码器行上
= 210ms/帧 ⇒ 动画定时器被饿死,看起来「卡顿甚至中断」。
同一修复后列表悬停帧 0.72ms(≈ 基线 0.81ms)。探针 [3b] 固化该判据。

三、快速中键拖拽「所有网格信号一起消失」——主修复已正确,之前的失败是度量错了
上一轮把「位移超出 kLayerRollMaxShiftPixels 就作废图层」改成
「driftLimit + 每帧最多滚 kLayerRollMaxStepPixels=240px」,剩余位移由 BlitWideBuffer 的
srcX 承担 ⇒ |shift| 可长期停在 ±(margin-24) 附近而**不会空白**。
之前探针拿「适应视图时的墨迹」当基准,而平移本身会移出数据范围(时间轴外没有采样),
墨迹自然下降 ⇒ 误报。正确判据(已固化进 panflash / stress):
同一次拖拽中把当前帧与**同一视界的整幅强制重绘**(m_sceneValid=false + 清空 m_layerFront)比墨迹,
比值 < 0.9 才算丢波形。三条拖拽模式(120px/帧、90×7 斜向、260px/帧)全部 badFrames=0,
worstRatio 0.966~0.999;MF4 8 个单点信号网格 framesWithMissingPoints=0。

四、新增随机组合压力测试(用户第 4 项要求:给出评估方法与压力验证)
.preflight/perf/stress_main.cpp:1 个 VERY_BIG_DATA(5×2576 万采样)+ 6 个 MF4 分组
(~1500 信号、41 个可见网格),固定种子的随机序列覆盖:中键拖拽(抽样做空白判据)、
滚轮缩放/分布缩放、悬停扫掠、组折叠展开(**每帧都把鼠标扫过正在出现/消失的行**,并校验
m_contentHeight 单调)、适应视图、面板滚动条拖拽、勾选/取消信号、标签页新建/切换/关闭。
判据全部为**同一次运行内的相对量**(基线帧 vs 交互帧),避免绝对 ms 阈值的漂移。
实测:ops=26 frames=209 mean=8.53ms worst=44.87ms(toggleSignal) overBudget=0;
pan blank check 90 个车道样本 0 个低于整幅重绘的 80%;group animation heightRegressions=0。
最慢帧是勾选信号(41 个网格全部重画曲线像素),属于用户明确排除的「需要重绘信号曲线」场景。

五、验证(全部离屏,不占用鼠标/键盘/屏幕)
- 新增 .preflight/perf/panflash_main.cpp(panflash.exe)19/19:三条拖拽模式的丢波形判据、
  MF4 单点网格、悬停耗时(4.0ms)、列表悬停联动(0.72ms)、光晕窗口化几何精确性;
- 新增 stress 6/6;既有回归 verify 49/49、panelanim 158/158、hoverdrag 20/20、
  gridanim 93/93、lanescroll 23/23、datacheck 69/69;
- make release TARGET_FILE_NAME=TargetCheck 通过(Target.exe 被运行中的实例占用)。

文档:Documents/agent_mem.md 新增本轮小节(含 4 条新规则:GDI+ 加粗描边不得用于包络带几何、
随鼠标变化的绘制必须 O(光标附近)、判据必须是同一次运行内的相对量、平移正确性只能与整幅重绘对比)。
2026-10-18 03:51:53 +08:00

6874 lines
289 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 实现分片 —— 本文件只包含 PlotView 的**成员函数定义**(纯代码搬移,行为与拆分前完全一致)。
//
// 共用的常量与工具函数在 PlotViewInternal.hpp(命名空间 `pvdetail`,全部 inline)。
// 四个分片的职责划分(按功能):
// core 视图核心:构造/析构、上下文绑定、布局、坐标映射、视界与平移、网格结构与归属、
// 选区/网格划分、拖动取消
// paint 渲染与像素缓存:全部绘制(Paint*/Render*)、场景缓存、装饰缓存、每网格图层、
// 宽基底与滚动补条、并行图层重建
// interaction 交互与编辑:命中测试、鼠标与键盘处理、光标形状、内联输入框、浮动面板层序与命中
// features 视图特性与派生数据:光标状态与读数、提示浮层、缩放模式、自适应、光标吸附、
// 单点信号、派生曲线线程池、边沿统计、实验性功能(均值滤波 / 弱峰调整)参数
#include "PlotViewInternal.hpp"
namespace jdi
{
float PlotView::EdgePanelWidth(int laneIndex, EdgePanelKind kind) const
{
if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 ||
laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return kEdgePanelWidth * m_scale;
}
const Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
const bool centerAxis = (kind == EdgePanelKind::CENTER);
const bool meanFilter = (kind == EdgePanelKind::MEAN);
const bool weakPeak = (kind == EdgePanelKind::PEAK);
// 「均值滤波」「中心轴边沿计数」「弱峰调整」都是逐条目面板(名称 + 复选框)。
const bool itemized = (centerAxis || meanFilter || weakPeak);
// 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” / “均值滤波” / “弱峰调整”
// + 光标区间尾缀)。
const std::wstring title =
meanFilter ? std::wstring(L"均值滤波")
: (weakPeak ? std::wstring(kWeakPeakTitle)
: ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") + EdgeRangeSuffix()));
float width = kEdgePanelPad * 2.0f * m_scale + kLaneBadgeWidth * m_scale +
8.0f * m_scale + UiMeasureTextWidth(m_fonts->uiSmall, title) +
kEdgePanelTitleHeight * m_scale;
// 数据行 = 名称与“上升沿 N / 下降沿 M”两行,取较宽者。
// ⚠️ 名称宽度有上限(`kEdgePanelNameMaxWidth`):超长信号名不再把面板顶得很长,
// 绘制时超出部分用省略号(用户明确要求“超过某个长度就省略名称显示”)。
// ⚠️ 统计信息按「上升沿 / 下降沿」**两列**排版(列起点固定)→ 多条信号时文字垂直对齐。
float nameColumnWidth = 0.0f;
float statRisingWidth = 0.0f;
float statFallingWidth = 0.0f;
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || !resolved.visible)
{
continue;
}
const float nameWidth =
std::min(UiMeasureTextWidth(m_fonts->uiSmall, resolved.data->name),
kEdgePanelNameMaxWidth * m_scale);
nameColumnWidth = std::max(nameColumnWidth, nameWidth);
if (itemized)
{
// 逐条目面板:第一排 = 名称 + 复选框;第三排(中心轴)= 统计结果。
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + nameWidth +
kEdgePanelMeanCheckSize * m_scale + 6.0f * m_scale);
}
std::size_t rising = 0;
std::size_t falling = 0;
bool complete = true;
if (meanFilter || weakPeak)
{
continue;
}
if (centerAxis)
{
const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref);
if (item == nullptr)
{
continue;
}
EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, item->points,
item->iterations, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(),
rising, falling, complete);
}
else
{
EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, lane.edgeValue,
EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete);
}
// 宽度按实际要显示的文本测量(含未算完时的“…”),否则推进过程中数字变长会被截断。
// 完全未算出时整行显示“统计中…”,因此两列都要预留它的宽度。
const float pendingWidth = UiMeasureTextWidth(m_fonts->monoSmall, L"统计中…");
statRisingWidth = std::max(
statRisingWidth,
std::max(UiMeasureTextWidth(m_fonts->monoSmall, EdgeRisingText(rising, complete)),
pendingWidth));
statFallingWidth =
std::max(statFallingWidth,
UiMeasureTextWidth(m_fonts->monoSmall, EdgeFallingText(falling, complete)));
}
if (statRisingWidth > 0.0f || statFallingWidth > 0.0f)
{
// 两列**等宽**(取两列最大值中的较大者)→ 绘制时两列起点固定,
// 多条信号的“上升沿 / 下降沿”文字严格垂直对齐(用户明确要求)。
const float maxColumn = std::max(statRisingWidth, statFallingWidth);
const float countsWidth = maxColumn * 2.0f + kEdgePanelStatColumnGap * m_scale;
width = std::max(width, kEdgePanelPad * 2.0f * m_scale +
std::max(nameColumnWidth, countsWidth));
}
if (centerAxis || meanFilter)
{
// 参数行 = 两个「说明文字 + 数值框」,必须保证文字不被数值框压住。
// (MEAN / CENTER:「均值点数 X」+「迭代次数 N」。标签与数值框各占半行。)
const float labelXWidth = UiMeasureTextWidth(m_fonts->uiSmall, L"均值点数 X");
const float labelN = UiMeasureTextWidth(m_fonts->uiSmall, L"迭代次数 N");
const float fieldWidth =
std::max(kEdgePanelEditFieldWidth * m_scale * 0.75f,
UiMeasureTextWidth(m_fonts->monoSmall, L"1000000") + 18.0f * m_scale);
const float halfWidth = std::max(labelXWidth, labelN) + 6.0f * m_scale + fieldWidth;
const float rowWidth = halfWidth * 2.0f + 8.0f * m_scale;
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth);
}
if (weakPeak)
{
// 滑块行 = 「标签列 + 轨道(最小宽度)+ 数值列 + 复位按钮」(等级与迭代次数两行共用同一列宽)。
const float rowWidth = WeakPeakLabelColumnWidth(m_fonts->uiSmall) + 6.0f * m_scale +
kWeakPeakTrackMinWidth * m_scale + 6.0f * m_scale +
WeakPeakValueColumnWidth(m_fonts->monoSmall) +
kWeakPeakResetGap * m_scale + kWeakPeakResetButtonSize * m_scale;
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth);
}
if (meanFilter || weakPeak)
{
// 底部按钮行 = 「构建为新信号」,按按钮文字实测宽度确定下限。
const float buttonWidth =
UiMeasureTextWidth(m_fonts->uiSmall, kBuildSignalLabel) + 28.0f * m_scale;
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + buttonWidth);
}
// 夹在绘图区宽度之内(过窄的网格也不至于把面板挤到外面)。
return std::min(width, std::max(120.0f * m_scale, m_plotRect.Width - 8.0f * m_scale));
}
const PlotView::PolylineCacheEntry* PlotView::AcquirePolyline(const SignalRef& ref,
const ResolvedSignal& resolved,
const LaneVisual& lane) const
{
if (resolved.data == nullptr || resolved.group == nullptr)
{
return nullptr;
}
// ⚠️ **横向补条渲染:不进缓存**。补条只构建"条带区间"的折线顶点,若写进缓存,
// 后续整块渲染命中同键时会拿到这段**不完整**的折线(只画了条带那一段)。
// 因此补条专用一个独立条目,避免污染按"宽窗口几何"键控的缓存。
if (m_stripRenderActive)
{
BuildPolyline(resolved, lane, m_stripPolylineEntry.points, m_stripPolylineEntry.breaks,
m_stripPolylineEntry.band, m_stripPolylineEntry.bandBreaks,
m_stripPolylineEntry.bandMode, m_stripPolylineEntry.bandSpiky);
return &m_stripPolylineEntry;
}
// 键 = 构建折线时实际读取的全部输入:
// 信号(引用 + 文件内容 + 采样点数)、时间轴显示范围、绘图区横向几何、网格纵向几何。
// 这些值全部相同时,BuildPolyline 的输出必然完全一致,可直接复用。
const std::size_t sampleCount = std::min(resolved.axis->time.size(), resolved.data->values.size());
const double viewMin = Tab().ViewMin();
const double viewMax = Tab().ViewMax();
// 绘制前的数值变换(系数/绝对值/镜像/偏移)会改变顶点,必须进缓存键。
const float gain = (resolved.style != nullptr) ? resolved.style->gain : 1.0f;
const bool absolute = (resolved.style != nullptr) && resolved.style->absolute;
const bool mirrorVertical = (resolved.style != nullptr) && resolved.style->mirrorVertical;
const bool mirrorHorizontal = (resolved.style != nullptr) && resolved.style->mirrorHorizontal;
const float offsetY = (resolved.style != nullptr) ? resolved.style->offsetY : 0.0f;
const double offsetTime = (resolved.style != nullptr) ? resolved.style->offsetTime : 0.0;
// ⚠️ 线宽也必须进键:`BuildPolyline` 把它烘进包络带几何(逐列外扩半个线宽)
//(详见 `PolylineCacheEntry::lineWidth` 的说明;漏掉它会让线宽改动在**稠密信号**上不生效)。
const float lineWidth = (resolved.style != nullptr) ? resolved.style->lineWidth : 1.0f;
for (const PolylineCacheEntry& entry : m_polylineCache)
{
if (entry.ref == ref && entry.group == resolved.group && entry.sampleCount == sampleCount &&
entry.viewMin == viewMin && entry.viewMax == viewMax && entry.plotX == m_plotRect.X &&
entry.plotWidth == m_plotRect.Width && entry.laneY == lane.rect.Y &&
entry.laneHeight == lane.rect.Height && entry.laneYMin == lane.yMin &&
entry.laneYMax == lane.yMax && entry.gain == gain && entry.absolute == absolute &&
entry.mirrorVertical == mirrorVertical && entry.mirrorHorizontal == mirrorHorizontal &&
entry.offsetY == offsetY && entry.offsetTime == offsetTime &&
entry.lineWidth == lineWidth)
{
return &entry;
}
}
// 未命中:重建并写入缓存(deque 保证已有元素的引用在追加后依然有效)。
// 缓存只用于消除重复计算,容量上限约等于“网格数 × 每网格信号数”的两倍。
if (m_polylineCache.size() >= kPolylineCacheLimit)
{
m_polylineCache.clear();
}
m_polylineCache.push_back(PolylineCacheEntry());
PolylineCacheEntry& entry = m_polylineCache.back();
entry.ref = ref;
entry.group = resolved.group;
entry.sampleCount = sampleCount;
entry.viewMin = viewMin;
entry.viewMax = viewMax;
entry.plotX = m_plotRect.X;
entry.plotWidth = m_plotRect.Width;
entry.laneY = lane.rect.Y;
entry.laneHeight = lane.rect.Height;
entry.laneYMin = lane.yMin;
entry.laneYMax = lane.yMax;
entry.gain = gain;
entry.absolute = absolute;
entry.mirrorVertical = mirrorVertical;
entry.mirrorHorizontal = mirrorHorizontal;
entry.offsetY = offsetY;
entry.offsetTime = offsetTime;
entry.lineWidth = lineWidth;
BuildPolyline(resolved, lane, entry.points, entry.breaks, entry.band, entry.bandBreaks,
entry.bandMode, entry.bandSpiky);
// BuildPolyline 通过引用填充,push_back 过程中不会再次进入本函数,引用安全。
return &m_polylineCache.back();
}
void PlotView::BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane,
std::vector<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;
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]()
{
bool spiky = false;
EmitEnvelopeBand(segmentColumns, band, bandBreaks, spiky, lane.rect.Height);
bandSpiky = bandSpiky || spiky;
segmentColumns.clear();
};
std::size_t searchFrom = begin;
// ⚠️ **包络带路径也要支持「横向补条」裁剪**(用户 2026-10-17 报告拖拽卡顿):
// 旧实现只对“逐点描边”路径按条带裁剪,包络带路径**总是构建整幅可见范围的包络** ——
// 巨型信号(`VERY_BIG_DATA`)一条 32px 宽的竖带就要 **5ms**,拖拽时每帧补几个网格就卡。
// 包络是**逐像素列独立**计算的(每列只取自己的 min/max),因此只构建条带覆盖的列
// 就能得到与整块渲染**逐像素一致**的条带像素(条带外本来就由已有像素提供)。
std::size_t columnFirst = 0;
std::size_t columnLast = columns;
if (m_stripRenderActive && m_stripX1 > m_stripX0 && columnWidth > 0.0f)
{
// 两侧各多含 1 列,避免条带边界处的抗锯齿/描边被切掉。
const float c0 = (m_stripX0 - plotLeft) / columnWidth - 1.0f;
const float c1 = (m_stripX1 - plotLeft) / columnWidth + 1.0f;
columnFirst = (c0 <= 0.0f) ? 0 : static_cast<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));
// 包络带每列都记录(上下各外扩半个线宽,保证细波动也有可见粗细)。
BandColumn bandColumn;
bandColumn.x = x;
bandColumn.top = yTop - strokeHalf;
bandColumn.bottom = yBottom + strokeHalf;
segmentColumns.push_back(bandColumn);
if (yBottom - yTop < kEnvelopeMinPixelSpan)
{
// 波动不足半个像素:只留一个顶点,避免无意义的重复顶点。
if (!inSegment)
{
breaks.push_back(points.size());
inSegment = true;
}
points.push_back(gd::PointF(x, yTop));
}
else
{
if (!inSegment)
{
breaks.push_back(points.size());
inSegment = true;
}
// 按采样点出现的先后顺序连接上下包络,保持走线方向一致。
if (lowOrder <= highOrder)
{
points.push_back(gd::PointF(x, yBottom));
points.push_back(gd::PointF(x, yTop));
}
else
{
points.push_back(gd::PointF(x, yTop));
points.push_back(gd::PointF(x, yBottom));
}
}
if (broken)
{
inSegment = false;
flushBandSegment();
}
}
flushBandSegment();
return;
}
std::size_t stride = 1;
if (visibleCount > budget)
{
stride = (visibleCount + budget - 1) / budget;
}
bool inSegment = false;
for (std::size_t index = begin; index < end; index += stride)
{
const double t = time[index];
// 水平镜像 = 数据反序:画面上第 index 个位置取源下标 count-1-index 的值。
const double value = ApplyDrawTransform(
*resolved.style, values[SourceIndexFor(*resolved.style, count, index)]);
if (!std::isfinite(t) || !std::isfinite(value))
{
inSegment = false;
continue;
}
// 时间偏移:该采样画在 `t + offsetTime` 处(例如 t=1s、偏移 -0.5s → 画在 0.5s)。
const float x = TimeToX(t + timeShift);
float y = ValueToY(value, lane);
y = static_cast<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::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_edgeScanBudget = kEdgeScanPairsPerFrame;
// 双缓冲:清掉上一帧钉住的曲线条目(本帧会按需重新钉住),并清理已被更新条目覆盖 /
// 超出内存预算的旧条目。此刻没有任何条目被钉住,回收是安全的。
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();
PaintEmptyState(g);
// ⚠️ 空状态(尚未勾选任何信号)也必须绘制提示浮层:加载失败 / 解析异常正是
// **最需要**非模态提示的场景,若在此提前返回,宿主的错误信息将永远不可见。
PaintHint(g);
return;
}
// 「快速绘制(关闭抗锯齿)」只在**会改变画面主体(波形/网格几何)**的拖拽期间启用:
// 这些拖拽每帧都要重画波形,关掉抗锯齿能显著加速(同一条包络带 27.6ms → 3.2ms)。
// ⚠️ 像「拖时间光标」「选区框选」「拖浮动面板」这类**只改叠加层、画面主体不变**的交互,
// 一旦启用快速帧就会给静态场景缓存打上「已降级」标记 → 缓存失效 → 每帧整幅重画
//(实测拖光标从约 1ms 骤升到十几毫秒)。因此这里**不**把它们算作快速帧,
// 让它们直接复用静态场景缓存(贴图 ~1ms)+ 叠加层,做到真正跟手。
const bool sceneChangingDrag =
(m_dragMode == PlotDragMode::PAN) || (m_dragMode == PlotDragMode::SCROLL_LANES) ||
(m_dragMode == PlotDragMode::LANE_REORDER) || (m_dragMode == PlotDragMode::MOVE_SIGNAL);
m_fastFrame = sceneChangingDrag;
// 贴图之后、静态场景之外的全部内容(随鼠标变化或位于最上层)。
// 三个分支(完全命中 / 局部重画 / 整幅重绘)共用同一段,保证观感完全一致。
const auto paintOverlays = [this, &g]()
{
PaintHoverHighlight(g);
PaintLegendHighlight(g);
PaintRubberBand(g);
PaintCursor(g, m_lanes);
PaintEdgeCursor(g, m_lanes);
PaintSignalInteraction(g);
PaintHint(g);
// 网格 Y 轴上下限的输入框、以及「弱峰调整 → 迭代次数上限」的输入框都绘制在最上层
//(后者覆盖在浮动面板的滑块行之上)。
if (m_editTarget == PlotEditTarget::LANE_Y_MAX ||
m_editTarget == PlotEditTarget::LANE_Y_MIN ||
m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX)
{
PaintEditField(g);
}
PaintScrollBar(g);
// 右下角「回到最上方」浮动按钮:在滚动条之上绘制(随 `m_backToTopAlpha` 淡入淡出)。
PaintBackToTop(g);
// 绘图区外框(比网格内部分界线更重,形成明确整体边界)。
// 用 GDI 画 4 条线:GDI+ 的 DrawRectangle 带抗锯齿时单帧要花掉 1ms 以上。
{
const float left = m_plotRect.X;
const float top = m_plotRect.Y;
const float right = m_plotRect.X + m_plotRect.Width;
const float bottom = m_plotRect.Y + m_plotRect.Height;
const float border[16] = {left, top, right, top, right, top, right, bottom,
right, bottom, left, bottom, left, bottom, left, top};
UiDrawSolidLines(g, gd::RectF(left - 2.0f, top - 2.0f, m_plotRect.Width + 4.0f,
m_plotRect.Height + 4.0f),
border, 4, m_theme->axisLine | 0xFF000000u, 2);
}
};
// 网格次序拖拽:静态场景缓存仍然有效,但各网格要按位移重新"摆放"。
// 这里逐网格把缓存中的横向条带平移贴回(几十微秒),
// 因此拖动百万级采样的网格时依然流畅,且被拖网格的波形随鼠标自由移动。
if (m_dragMode == PlotDragMode::LANE_REORDER && m_laneDragFrom >= 0 && m_sceneValid)
{
PaintLaneReorder(g);
paintOverlays();
return;
}
// 静态场景复用(不再局限于拖拽):只要视图几何、信号集合与显示样式都没变,
// "绘图区底色 + 全部网格 + 时间轴"就逐像素相同,直接贴图即可。
// 大文件下整幅重绘约 80ms,贴图约 1ms —— 鼠标悬停、拖拽选区这些
// "只改一小块叠加层"的交互因此不再重绘波形。
// 随鼠标变化的内容(悬停高亮、读数框、选区、光标、拖拽虚影、输入框)一律画在贴图之后。
if (SceneCacheMatches() && BlitSceneCache(g))
{
// ⚠️ **顺手做一次"空闲预热"**:图层不存在(刚加载 / 网格数量变化)或仍是旧跨度时,
// 在这里用既有预算机制**每帧至多补 1 个网格**。若不预热,用户第一次平移 / 选区放大
// 时,`Paint` 会在那一帧里**同步重建全部网格图层**(5 个网格实测 30~80ms)——
// 这正是"选区放大的第一帧特别卡"的根因(该帧其余工作只有约 16ms 的占位拉伸)。
// 命中静态场景缓存 ⇒ 本帧视图与缓存同源(视界没变),因此预热出来的图层不会白费。
WarmLaneLayersStep();
paintOverlays();
return;
}
// 视界平移 / 派生预览曲线参数变化:画面**基底**(网格/波形)不变,只是横向平移或
// 叠加的派生曲线变化。直接复用「宽缓冲区」已渲染的**基底**像素,只把派生曲线作为
// 叠加层重画 —— 前台 O(1),不重画波形,因此无论放大到多深都保持跟手。
// 判定:网格结构未变(几何指纹一致)且**逐信号外观样式未变** → 属视图/派生变化,可用宽基底;
// 外观样式(颜色/粗细/透明度/模式)变化 → 交给下方"样式补丁"分支局部重画。
// ⚠️ 位置必须在"静态场景贴图"**之后**:视界与样式都没变时(典型:鼠标悬停移动)
// 应由静态场景贴图命中,绝不能落到这里 —— 否则每两帧就会触发一次"结算整幅重绘",
// 悬停反而变卡。而它又必须在"样式补丁"**之前**:派生曲线参数变化(拖弱峰调整 /
// 均值滤波滑块)同样只需叠加重画派生曲线,走本分支比"逐网格补丁"更快且质量更高。
// ⚠️ **不要求"正在拖拽"**:非拖拽的视界变化(键盘方向键 / 工具栏)同样走此快路径,
// 避免该放大区间"整幅重绘约 124ms"的卡顿。
bool wideUsable = false;
if (HasDrawableContent() && m_sceneValid && m_sceneGeometryHash == SceneGeometryHash())
{
std::vector<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);
// 派生预览曲线:基底不含它们(拖参数滑块时基底不变、可直接复用像素)。
for (std::size_t i = 0; i < m_lanes.size(); ++i)
{
PaintLaneDerivedOverlays(g, static_cast<int>(i), m_lanes[i]);
}
// 装饰(Y 轴刻度 + GX 编号 + 图例):基底不含(否则平移会重影)。
// 内容指纹一致 → 直接按 `AlphaBlend` 贴回缓存(省掉文字绘制);
// 否则先把装饰单独绘制进透明缓存位图,再贴回。
// ⚠️ 贴回的是**带 alpha 的圆角图形**:圆角之外是全透明,
// 因此透出的是**当前**波形 —— 既没有"直角白边",也不会残留旧波形像素。
m_useDecorCache = m_decorValid && (m_decorKey == SceneDecorHash());
if (!m_useDecorCache && RenderDecorCache())
{
m_decorKey = SceneDecorHash();
m_useDecorCache = true;
}
if (m_useDecorCache)
{
BlitDecorCache(g);
} else
{
// 退化路径(无法建立透明缓存,例如系统不提供 AlphaBlend):
// 直接把装饰画进后备缓冲区(就地绘制,不做缓存)。
m_renderDecorOnly = true;
for (std::size_t i = 0; i < m_lanes.size(); ++i)
{
PaintLane(g, static_cast<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。)
{
std::vector<int> patchLanes;
if (FindStyleOnlyLanes(patchLanes) && BlitSceneCache(g))
{
// ⚠️ 样式**正在连续变化**(拖透明度 / 粗细 / 拾色滑块):这本身就是一种"交互中"。
// 若不切换快速帧,本帧会用**抗锯齿**重画波形包络带 —— 实测单条网格的包络带
// AA 填充 ≈29ms(本机负载下 72ms),而关掉 AA 只要 ≈5.6ms。
// 因此补丁阶段强制快速帧,并在补丁后把场景缓存标记为"已降级",
// 下一帧(参数稳定后)自动补一帧全质量 —— 既保证拖动手感,又不牺牲最终画质。
m_fastFrame = true;
RepaintSceneCachePatch(g, patchLanes);
m_sceneDegraded = true;
// ⚠️ **同步重建受影响的宽基底图层**:补丁只更新了“静态场景缓存 + 后备缓冲区”,
// 而每网格图层(宽基底)里仍是**旧样式**的像素。若不在这里重建,紧接着命中
// “宽基底快路径”的那一帧会把刚画好的新样式**覆盖回旧像素** ——
// 用户报告:拖动线宽滑块期间信号一直按旧的线宽绘制、松手后刷新,
// 再移动鼠标又变回旧线宽(正是这个覆盖造成的)。
// 按既有预算机制重建(每帧至多 1 个网格,不会在交互帧里做整批重建)。
for (const int laneIndex : patchLanes)
{
QueueLaneRebuild(laneIndex);
}
RenderPendingLaneRebuilds(1);
paintOverlays();
return;
}
}
const double viewMin = Tab().ViewMin();
const double viewMax = Tab().ViewMax();
const double span = viewMax - viewMin;
const double timeStep = NiceStep((span > 0.0) ? span : 1.0, kTimeTickTarget);
std::vector<double> timeTicks;
BuildTicks(viewMin, viewMax, timeStep, timeTicks);
// 绘图区整体底色。
UiFillRect(g, m_area, m_theme->plotBg);
// 波形区域底色。
UiFillRect(g, m_plotRect, m_theme->cardBg);
// 图例命中矩形每帧重建,避免残留已消失的信号。
m_legendHits.clear();
// 「装饰层」:装饰**不在网格循环里就地绘制**,而是统一由带 alpha 的装饰层贴回
// (这样圆角之外透出的是当前波形,不会出现"直角白边")。
m_decorRects.clear();
m_useDecorCache = true;
for (std::size_t index = 0; index < m_lanes.size(); ++index)
{
PaintLane(g, static_cast<int>(index), m_lanes[index], timeTicks);
}
PaintTimeAxis(g, timeTicks, timeStep);
// 装饰层:缓存与当前内容同源则直接贴回;否则先把装饰绘制进透明缓存位图再贴回。
if (!m_decorValid || m_decorKey != SceneDecorHash())
{
if (RenderDecorCache())
{
m_decorKey = SceneDecorHash();
}
}
BlitDecorCache(g);
// 此刻画布上正好是"绘图区底色 + 全部网格 + 时间轴",尚未绘制任何随鼠标变化的内容,
// 因此是捕获静态场景缓存的最佳时点(必须在裁剪区已全部恢复之后执行)。
// ⚠️ 快速帧(交互期间,关闭了抗锯齿)**仍然捕获**(这样拖动参数滑块时能走
// "只重画受影响网格"的快路径),但打上 `m_sceneDegraded` 标记 →
// `SceneCacheMatches()` 会拒绝命中,交互结束后的下一帧必定重画全质量版本。
CaptureSceneCache(g);
m_sceneDegraded = m_fastFrame;
paintOverlays();
}
void PlotView::PaintSignalHaloFor(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane,
const PolylineCacheEntry& cached, float windowCenterX, bool windowed)
{
if (resolved.style == nullptr)
{
return;
}
// ------------------------------------------------------------------
// 稠密信号(走"像素列包络带"):光晕改为**填充与曲线本身同一几何的包络带**。
//
// ⚠️ 绝不能用「加粗折线描边」画稠密信号的光晕:包络带的顶点序列是
// 「每列上/下包络交替」的满幅竖锯齿,GDI+ 描边的代价 = 线段数 × 覆盖行数,
// 实测 2503 个顶点要 **210ms/帧**(用户 2026-10-18 报告:鼠标划过巨型信号的
// 曲线时整帧卡死、浮动提示不再跟随鼠标)。而 `FillPath` 同一条几何只需 ~1ms,
// 且观感更贴切(整条曲线一起"发光",与曲线本身逐像素同源)。
// ⚠️ 填充也**只填鼠标附近的一段**(见 `kHaloWindowHalfWidth`):半透明填充
// 的代价与「覆盖面积 + 路径顶点数」成正比,整条满幅带实测 ~13ms/帧。
// ------------------------------------------------------------------
if (resolved.style->mode != DrawMode::SCATTER && cached.bandMode && !cached.bandBreaks.empty())
{
const float halfWidth = kHaloWindowHalfWidth * m_scale;
const int savedSmoothing = g.GetSmoothingMode();
g.SetSmoothingMode(gd::SmoothingModeNone); // 半透明填充:抗锯齿没有观感收益
gd::GraphicsPath bandPath;
bandPath.SetFillMode(gd::FillModeWinding);
for (std::size_t k = 0; k < cached.bandBreaks.size(); ++k)
{
const std::size_t start = cached.bandBreaks[k];
const std::size_t stop =
(k + 1 < cached.bandBreaks.size()) ? cached.bandBreaks[k + 1] : cached.band.size();
const std::size_t total = stop - start;
if (total < 4 || (total % 2) != 0)
{
continue;
}
// 段内布局 = 「上包络正向(`half` 个)+ 下包络反向(`half` 个)」
//(见 `EmitEnvelopeBand`),因此窗口只需换算成列区间。
const std::size_t half = total / 2;
std::size_t columnFirst = 0;
std::size_t columnLast = half;
if (windowed)
{
while (columnFirst < half &&
cached.band[start + columnFirst].X < windowCenterX - halfWidth - 1.0f)
{
++columnFirst;
}
while (columnLast > columnFirst &&
cached.band[start + columnLast - 1].X > windowCenterX + halfWidth + 1.0f)
{
--columnLast;
}
// 两侧各多含 1 列,避免窗口边界处的半透明边缘被切出直边。
if (columnFirst > 0)
{
--columnFirst;
}
if (columnLast < half)
{
++columnLast;
}
}
if (columnLast <= columnFirst + 1)
{
continue;
}
const std::size_t columns = columnLast - columnFirst;
bandPath.StartFigure();
bandPath.AddLines(cached.band.data() + start + columnFirst, static_cast<INT>(columns));
// 下包络反向存储:列 c 在 `start + half + (half - 1 - c)`,
// 因此列区间 `[columnFirst, columnLast)` 对应一段**正向可读**的连续区间。
bandPath.AddLines(cached.band.data() + start + half + (half - columnLast),
static_cast<INT>(columns));
bandPath.CloseFigure();
}
if (bandPath.GetPointCount() > 0)
{
gd::SolidBrush brush(ToGdiColor(WithAlpha(m_theme->accent, 0x55)));
g.FillPath(&brush, &bandPath);
}
g.SetSmoothingMode(static_cast<gd::SmoothingMode>(savedSmoothing));
return;
}
// 其余情形一律用折线描边(与"之前"的画法一致)。
PaintSignalHalo(g, resolved, lane, cached.points, cached.breaks, windowCenterX, windowed);
}
void PlotView::PaintSignalHalo(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane,
const std::vector<gd::PointF>& points,
const std::vector<std::size_t>& breaks, float windowCenterX,
bool windowed)
{
// ⚠️ **只有单个数据点**的信号(默认样式是「点线」,那个点本身是一个粗圆点):
// 折线光晕无从下手(不足两个顶点),改为在数据点外包一圈强调色圆环。
// 否则悬停时完全没有“可拖拽”的视觉反馈(用户 2026-10-15 报告)。
if (resolved.data != nullptr && resolved.data->finiteCount == 1)
{
gd::PointF dot;
if (SinglePointScreenPos(resolved, lane, dot))
{
const float radius = SinglePointDotRadius(resolved) + 3.0f * m_scale;
gd::Pen ring(ToGdiColor(WithAlpha(m_theme->accent, 0x88)), 2.0f * m_scale);
g.DrawEllipse(&ring, dot.X - radius, dot.Y - radius, radius * 2.0f, radius * 2.0f);
}
return;
}
const bool drawLine = (resolved.style->mode != DrawMode::SCATTER);
if (!drawLine)
{
// ⚠️ **散点模式的高亮 = 只高亮数据点本身(不画连线)**(用户 2026-10-17 要求):
// 旧实现直接 `return`,于是散点信号悬停时**只有拖拽提示、没有任何高亮**。
// 这里给每个参与绘制的散点套一圈强调色圆环 —— 与「线段/点线」的折线光晕相比,
// 正好是“去掉数据点之间连线的部分”(用户原话)。
// ⚠️ 步长必须与 `PaintLane` 的散点绘制**同源**(`kMaxScatterPoints`),
// 否则高亮圈会出现在没画出来的点上(或漏掉可见的点)。
if (points.empty() || resolved.style == nullptr)
{
return;
}
const float radius = static_cast<float>(
ClampDouble(static_cast<double>(resolved.style->lineWidth) * kScatterRadiusScale,
static_cast<double>(kMinScatterRadius), static_cast<double>(kMaxScatterRadius)) *
m_scale);
const float ringRadius = radius + 2.5f * m_scale;
const std::size_t scatterStride =
std::max<std::size_t>(1, points.size() / kMaxScatterPoints);
// 散点数量本身有上限(`kMaxScatterPoints`),逐点画圈是 O(数千) 的小代价;
// 但仍按窗口过滤,保持"只高亮鼠标附近"的一致语义(点少时窗口外也全画)。
const bool windowScatter = windowed && points.size() > kHaloWindowMinPoints;
const float halfWidth = kHaloWindowHalfWidth * m_scale;
gd::Pen ring(ToGdiColor(WithAlpha(m_theme->accent, 0x88)), 1.6f * m_scale);
for (std::size_t index = 0; index < points.size(); index += scatterStride)
{
const gd::PointF& point = points[index];
if (windowScatter && std::fabs(point.X - windowCenterX) > halfWidth)
{
continue;
}
g.DrawEllipse(&ring, point.X - ringRadius, point.Y - ringRadius, ringRadius * 2.0f,
ringRadius * 2.0f);
}
return;
}
if (breaks.empty())
{
return;
}
gd::Pen halo(ToGdiColor(WithAlpha(m_theme->accent, 0x66)),
(resolved.style->lineWidth + 4.0f) * m_scale);
halo.SetLineJoin(gd::LineJoinRound);
halo.SetStartCap(gd::LineCapRound);
halo.SetEndCap(gd::LineCapRound);
// 顶点多的信号只描边**鼠标左右一小段**(见 `kHaloWindowHalfWidth` 的说明):
// 整条描边在满幅锯齿信号上要 100~200ms/帧,鼠标移动时整帧卡死。
const bool windowStroke = windowed && points.size() > kHaloWindowMinPoints;
const float halfWidth = kHaloWindowHalfWidth * m_scale;
const float windowLeft = windowCenterX - halfWidth;
const float windowRight = windowCenterX + halfWidth;
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;
}
if (!windowStroke)
{
g.DrawLines(&halo, points.data() + start, static_cast<INT>(stop - start));
continue;
}
// 窗口内的顶点在段内**连续**(折线沿时间单调),因此顺序扫描一次、按连续区段绘制。
// 多含一个窗口外的顶点,让跨越窗口边界的线段完整画出(不出现断头)。
std::size_t runStart = stop;
std::size_t runStop = stop;
for (std::size_t index = start; index < stop; ++index)
{
const bool inside = (points[index].X >= windowLeft - 1.0f) &&
(points[index].X <= windowRight + 1.0f);
if (inside && runStart == stop)
{
runStart = (index > start) ? (index - 1) : index;
runStop = index + 1;
}
else if (inside)
{
runStop = index + 1;
}
else if (runStart != stop)
{
if (index + 1 < stop)
{
runStop = index + 2;
}
if (runStop > runStart + 1)
{
g.DrawLines(&halo, points.data() + runStart, static_cast<INT>(runStop - runStart));
}
runStart = stop;
runStop = stop;
}
}
if (runStart != stop && runStop > runStart + 1)
{
g.DrawLines(&halo, points.data() + runStart, static_cast<INT>(runStop - runStart));
}
}
}
void PlotView::PaintHoverHighlight(gd::Graphics& g)
{
if (m_tab == nullptr || m_dragMode != PlotDragMode::NONE)
{
// 拖拽过程中不显示悬停提示(拖拽中的信号光晕已绘入静态场景缓存)。
return;
}
const int saveState = g.Save();
g.SetClip(m_area, gd::CombineModeIntersect);
// 悬停网格:一层很淡的强调色叠加。为了能画在静态场景之上,
// 这里用半透明而非不透明混色,观感与原实现基本一致。
// ⚠️ 高亮来源见 `EffectiveHoverLane()`(浮动面板悬停 / 信号列表悬停),
// **不再**由"鼠标在网格里"触发。
const int hoverLane = EffectiveHoverLane();
if (hoverLane >= 0 && hoverLane < static_cast<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));
}
}
// 悬停信号:外扩一圈半透明强调色作为"可拖拽"提示。
// 鼠标在绘图区内直接命中时用 `m_hoverSignal`;信号列表 / 样式区悬停时用
// `m_externalHoverSignal`(二者高亮方式相同,用户要求列表悬停也要高亮曲线本身)。
SignalRef haloRef = m_hoverSignal;
if (!haloRef.IsValid())
{
haloRef = m_externalHoverSignal;
}
if (haloRef.IsValid())
{
const ResolvedSignal resolved = ResolveSignal(haloRef);
if (resolved.data != nullptr && resolved.style != nullptr && resolved.visible)
{
const int laneIndex = m_tab->LaneOfSignal(haloRef);
if (laneIndex >= 0 && laneIndex < static_cast<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)
{
// 光晕只描边鼠标附近的一段(见 `kHaloWindowHalfWidth`):鼠标不在绘图区内时
//(列表/样式区悬停)以绘图区中心为窗口中心,保证代价恒定、不平白卡一帧。
const bool mouseInPlot = (m_mouseX >= m_plotRect.X &&
m_mouseX <= m_plotRect.X + m_plotRect.Width &&
m_mouseY >= m_plotRect.Y &&
m_mouseY <= m_plotRect.Y + m_plotRect.Height);
const float haloCenterX =
mouseInPlot ? static_cast<float>(m_mouseX)
: (m_plotRect.X + m_plotRect.Width * 0.5f);
PaintSignalHaloFor(g, resolved, lane, *cached, haloCenterX, true);
}
}
}
}
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))
{
// 图例悬停:鼠标就在图例条目上(网格左上角),以它为窗口中心。
PaintSignalHaloFor(g, resolved, lane, *cached, static_cast<float>(m_mouseX), true);
}
}
// 条目悬停高亮:**圆角托底 + 与选中态相同的高亮**(用户明确要求"悬停效果与 GX 标签
// 相同、选中状态与悬停提示效果相同"),并在其上**原样重绘色块与文字** ——
// 不透明托底若只画底色会把图例内容整块盖住(只剩一个空色块)。
for (const LegendHit& hit : m_legendHits)
{
if (hit.ref == m_hoverLegend)
{
const float radius = (hit.radius > 0.0f) ? hit.radius : (kLaneBadgeHeight * 0.5f * m_scale);
UiFillRoundRect(g, hit.rect, radius, m_theme->accentSoft);
UiDrawRoundRect(g, hit.rect, radius, m_theme->accent, 1.0f);
UiDrawSwatch(g, hit.swatch, hit.swatchColor, hit.swatchOpacity, 1.5f * m_scale, m_scale);
UiText(g, hit.label, m_fonts->uiTiny, hit.text, m_theme->textStrong, TextAlign::LEFT);
break;
}
}
g.Restore(saveState);
}
PlotView::SceneCacheKey PlotView::MakeSceneCacheKey() const
{
SceneCacheKey key;
key.viewMin = Tab().ViewMin();
key.viewMax = Tab().ViewMax();
key.laneScroll = Tab().LaneScroll();
key.laneCount = static_cast<int>(m_lanes.size());
key.areaX = m_area.X;
key.areaY = m_area.Y;
key.areaW = m_area.Width;
key.areaH = m_area.Height;
key.scale = m_scale;
const SignalRef halo = SceneHaloRef();
key.haloGroup = halo.groupIndex;
key.haloSignal = halo.signalIndex;
key.contentHash = SceneContentHash();
return key;
}
bool PlotView::SceneCacheMatches() const
{
if (!m_sceneValid)
{
return false;
}
// 缓存里存的是「快速帧」(关掉了抗锯齿)→ 不能直接命中:
// 必须重画一次全质量版本,否则降级的画面会一直留在屏幕上。
if (m_sceneDegraded)
{
return false;
}
const SceneCacheKey& cached = m_sceneKey;
const SceneCacheKey key = MakeSceneCacheKey();
return (key.viewMin == cached.viewMin) && (key.viewMax == cached.viewMax) &&
(key.laneScroll == cached.laneScroll) && (key.laneCount == cached.laneCount) &&
(key.areaX == cached.areaX) && (key.areaY == cached.areaY) &&
(key.areaW == cached.areaW) && (key.areaH == cached.areaH) &&
(key.scale == cached.scale) && (key.haloGroup == cached.haloGroup) &&
(key.haloSignal == cached.haloSignal) && (key.contentHash == cached.contentHash);
}
SignalRef PlotView::SceneHaloRef() const
{
// 只有拖拽中的信号光晕是“整个拖拽过程都不变”的,可以安全地绘入静态场景缓存;
// 悬停光晕随鼠标移动,必须画在缓存之外(见 PaintHoverHighlight)。
if (m_dragMode == PlotDragMode::MOVE_SIGNAL && m_dragSignal.IsValid())
{
return m_dragSignal;
}
return SignalRef();
}
std::uint64_t PlotView::SceneContentHash(bool includeStyles, bool includeDerived,
bool includeLaneRange, bool includeLaneScroll) const
{
// FNV-1a:把“视图几何之外的一切影响绘图区像素的内容”折叠成一个指纹。
// 这样颜色/线宽/透明度/样式/重命名/信号增删等变化都会自动让缓存失效,
// 不需要在各处手工调用 InvalidateSceneCache(容易漏)。
// `includeStyles == false` 时跳过逐信号的外观样式(颜色/不透明度/线宽/模式),
// 得到的就是几何指纹:它一致说明“只有外观样式变了”。
std::uint64_t hash = 1469598103934665603ull;
const auto mix = [&hash](std::uint64_t value)
{
hash ^= value;
hash *= 1099511628211ull;
};
const auto mixFloat = [&mix](float value)
{
std::uint32_t bits = 0;
std::memcpy(&bits, &value, sizeof(bits));
mix(bits);
};
const auto mixText = [&mix](const std::wstring& text)
{
mix(text.size());
for (const wchar_t character : text)
{
mix(static_cast<std::uint64_t>(character));
}
};
mixFloat(m_plotRect.X);
mixFloat(m_plotRect.Y);
mixFloat(m_plotRect.Width);
mixFloat(m_plotRect.Height);
mixFloat(m_laneContentHeight);
mix(m_scrollBarVisible ? 1ull : 0ull);
// ⚠️ 网格纵向滚动量必须参与**静态场景缓存**的指纹:它只改变各网格的绘制位置。
// 若不参与,平移宽缓冲区 / 静态场景缓存会把旧滚动位置的像素当成当前内容贴回,
// 表现为“网格位置错位 / 拖影 / 残留”等错误元素。
// ⚠️ 但它是**纯屏幕落点**(不影响任何网格内容),因此“宽基底可用性”判据
//(`SceneGeometryHash`)把它排除:网格滚动时 `BlitWideBuffer` 本来就按 `lane.rect`
// 逐网格贴图,内容完全复用 —— 这样“网格滚动到可视区 / 回到最上方”这类
// **每帧都在改滚动量的动画**才不会退化成每帧整幅重绘(用户 2026-10-17 报告卡顿)。
if (includeLaneScroll)
{
mixFloat(Tab().LaneScroll());
}
// 选中信号会改变图例条目的底色与文字颜色(绘制在缓存的静态场景里),
// 因此必须参与指纹:否则在左侧信号面板点选后图例不会高亮联动。
{
const SignalRef selected = SelectedRef();
mix(selected.IsValid() ? 1ull : 0ull);
mix(static_cast<std::uint64_t>(selected.groupIndex));
mix(static_cast<std::uint64_t>(selected.signalIndex));
}
for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex)
{
const LaneVisual& visual = m_lanes[laneIndex];
// ⚠️ 各网格的屏幕纵向位置同样只是“贴图落点”(见 `includeLaneScroll` 的说明)。
if (includeLaneScroll)
{
mixFloat(visual.rect.Y);
}
mixFloat(visual.rect.Height);
// ⚠️ 幅值范围只在 `includeLaneRange` 时计入:拖拽平移时的纵向分量会每帧小幅改动它,
// 若参与“宽基底可用性”判据,每一帧都会退化成整幅重绘(质量下降 + 卡顿)。
if (includeLaneRange)
{
mixFloat(static_cast<float>(visual.yMin));
mixFloat(static_cast<float>(visual.yMax));
}
const std::vector<SignalRef>& signals = Tab().Lanes()[laneIndex].signals;
// 「边沿计数(中心轴)」「均值滤波」「弱峰调整」的开关与每条被勾选信号的参数
// 会改变网格内的曲线绘制。它们**只影响网格内容**(不影响网格结构/几何),
// 因此只在 includeStyles 时计入 —— 于是拖参数滑块可以走“局部重画”快路径,
// 而不必整幅重绘(`FindStyleOnlyLanes` 用 `m_sceneDerived` 定位受影响的网格)。
// `includeDerived == false` 时连这些参数也不计入(用于装饰层指纹:图例/Y 轴刻度
// 与派生曲线参数无关,拖滑块时装饰层无需失效)。
if (includeStyles && includeDerived)
{
mix(Tab().Lanes()[laneIndex].edgeCenter ? 1ull : 0ull);
mix(static_cast<std::uint64_t>(Tab().Lanes()[laneIndex].edgeCenterItems.size()));
for (const Lane::EdgeCenterItem& item : Tab().Lanes()[laneIndex].edgeCenterItems)
{
mix(static_cast<std::uint64_t>(item.ref.groupIndex));
mix(static_cast<std::uint64_t>(item.ref.signalIndex));
mix(static_cast<std::uint64_t>(item.points));
mix(static_cast<std::uint64_t>(item.iterations));
}
mix(Tab().Lanes()[laneIndex].meanFilter ? 1ull : 0ull);
mix(static_cast<std::uint64_t>(Tab().Lanes()[laneIndex].meanFilterItems.size()));
for (const Lane::MeanFilterItem& item : Tab().Lanes()[laneIndex].meanFilterItems)
{
mix(static_cast<std::uint64_t>(item.ref.groupIndex));
mix(static_cast<std::uint64_t>(item.ref.signalIndex));
mix(static_cast<std::uint64_t>(item.points));
mix(static_cast<std::uint64_t>(item.iterations));
}
mix(Tab().Lanes()[laneIndex].weakPeak ? 1ull : 0ull);
mix(static_cast<std::uint64_t>(Tab().Lanes()[laneIndex].weakPeakItems.size()));
for (const Lane::WeakPeakItem& item : Tab().Lanes()[laneIndex].weakPeakItems)
{
// 逐信号参数(等级 + 迭代次数)都参与指纹:改任一条的参数都会重画。
mix(static_cast<std::uint64_t>(item.ref.groupIndex));
mix(static_cast<std::uint64_t>(item.ref.signalIndex));
mix(static_cast<std::uint64_t>(item.amountPercent));
mix(static_cast<std::uint64_t>(item.iterations));
}
}
mix(signals.size());
for (const SignalRef& ref : signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
mix(static_cast<std::uint64_t>(ref.groupIndex));
mix(static_cast<std::uint64_t>(ref.signalIndex));
mix(resolved.visible ? 1ull : 0ull);
if (resolved.data == nullptr || resolved.style == nullptr)
{
continue;
}
// 以下四项是“只影响外观”的样式:仅在 includeStyles 时计入指纹。
// (不参与时得到的就是 SceneGeometryHash:它一致说明只有外观变了,
// 于是可以只重画颜色变了的网格,而不必整幅重绘。)
if (includeStyles)
{
mix(static_cast<std::uint64_t>(resolved.style->color));
mixFloat(resolved.style->opacity);
mixFloat(resolved.style->lineWidth);
mix(static_cast<std::uint64_t>(resolved.style->mode));
// 绘制前的数值变换(系数 / 绝对值 / 镜像)会改变波形形状。
mixFloat(resolved.style->gain);
mix(resolved.style->absolute ? 1ull : 0ull);
mix(resolved.style->mirrorVertical ? 1ull : 0ull);
mix(resolved.style->mirrorHorizontal ? 1ull : 0ull);
// 偏移(Y 平移 + 时间平移)同样会改变波形。
mixFloat(resolved.style->offsetY);
mixFloat(static_cast<float>(resolved.style->offsetTime));
}
// 图例文案参与绘制,重命名必须让缓存失效。
mixText(resolved.data->name);
if (resolved.group != nullptr)
{
mixText(resolved.group->name);
}
}
}
return hash;
}
std::uint64_t PlotView::SceneGeometryHash() const
{
// 外观样式不参与;各网格的幅值范围也不参与(那由逐网格图层指纹负责,
// 这样拖拽时的纵向分量不会把整幅重绘的快路径打掉);
// ⚠️ 网格纵向滚动量 / 各网格屏幕纵向位置也不参与:那只是图层的**贴图落点**,
// 不影响任何网格的像素内容(`BlitWideBuffer` 按 `lane.rect` 逐网格贴图),
// 因此滚动网格时可以继续复用宽基底(否则“滚动到可视区 / 回到最上方”的
// 每一帧都会退化成整幅重绘 —— 用户 2026-10-17 报告的滚动动画卡顿)。
return SceneContentHash(false, true, false, false);
}
std::uint64_t PlotView::SceneDecorHash() const
{
// 装饰层(图例 + Y 轴刻度)的指纹:含外观样式,但**不含派生曲线参数**
//(拖弱峰调整等级/迭代次数滑块时图例与刻度不变,装饰层缓存不应失效)。
return SceneContentHash(true, false);
}
void PlotView::CollectSceneStyles(std::vector<std::vector<SceneStyleEntry>>& out) const
{
out.clear();
out.resize(m_lanes.size());
for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex)
{
const std::vector<SignalRef>& signals = Tab().Lanes()[laneIndex].signals;
std::vector<SceneStyleEntry>& lane = out[laneIndex];
lane.reserve(signals.size());
for (const SignalRef& ref : signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
SceneStyleEntry entry;
if (resolved.style != nullptr)
{
entry.color = resolved.style->color;
entry.opacity = resolved.style->opacity;
entry.lineWidth = resolved.style->lineWidth;
entry.mode = static_cast<int>(resolved.style->mode);
entry.gain = resolved.style->gain;
entry.absolute = resolved.style->absolute;
entry.mirrorVertical = resolved.style->mirrorVertical;
entry.mirrorHorizontal = resolved.style->mirrorHorizontal;
entry.offsetY = resolved.style->offsetY;
entry.offsetTime = resolved.style->offsetTime;
}
lane.push_back(entry);
}
}
}
bool PlotView::FindStyleOnlyLanes(std::vector<int>& lanes) const
{
lanes.clear();
if (!m_sceneValid || m_sceneGeometryHash != SceneGeometryHash())
{
// 网格结构 / 视图几何 / 信号集合 / 图例文案有变化:必须整幅重绘。
return false;
}
// ⚠️ **必须额外确认“视图几何与缓存完全一致”**:`SceneGeometryHash()` 刻意不含视界,
// 所以它相等**并不能**说明视图没变。若视界已经变了还走“只重画受影响网格”的局部补丁,
// 就会把缓存里**旧视界**的画面(时间轴、其它网格、底色)留在屏上 —— 表现为用户反馈的
// “拖影 / 残留 / 错误元素”。因此这里逐项比较除内容指纹之外的缓存键。
{
const SceneCacheKey key = MakeSceneCacheKey();
if (key.viewMin != m_sceneKey.viewMin || key.viewMax != m_sceneKey.viewMax ||
key.laneScroll != m_sceneKey.laneScroll || key.laneCount != m_sceneKey.laneCount ||
key.areaX != m_sceneKey.areaX || key.areaY != m_sceneKey.areaY ||
key.areaW != m_sceneKey.areaW || key.areaH != m_sceneKey.areaH ||
key.scale != m_sceneKey.scale || key.haloGroup != m_sceneKey.haloGroup ||
key.haloSignal != m_sceneKey.haloSignal)
{
return false;
}
}
std::vector<std::vector<SceneStyleEntry>> current;
CollectSceneStyles(current);
if (current.size() != m_sceneStyles.size())
{
return false;
}
// 叠加预览曲线(均值滤波 / 中心轴 / 弱峰调整)的参数变化同样只需局部重画。
std::vector<SceneDerivedEntry> currentDerived;
CollectSceneDerived(currentDerived);
const bool derivedComparable = (currentDerived.size() == m_sceneDerived.size());
for (std::size_t laneIndex = 0; laneIndex < current.size(); ++laneIndex)
{
const std::vector<SceneStyleEntry>& now = current[laneIndex];
const std::vector<SceneStyleEntry>& before = m_sceneStyles[laneIndex];
if (now.size() != before.size())
{
return false;
}
bool differs = false;
for (std::size_t index = 0; index < now.size() && !differs; ++index)
{
differs = (now[index].color != before[index].color) ||
(now[index].opacity != before[index].opacity) ||
(now[index].lineWidth != before[index].lineWidth) ||
(now[index].mode != before[index].mode);
}
if (!differs && derivedComparable)
{
const SceneDerivedEntry& nowDerived = currentDerived[laneIndex];
const SceneDerivedEntry& beforeDerived = m_sceneDerived[laneIndex];
differs = (nowDerived.edgeCenter != beforeDerived.edgeCenter) ||
(nowDerived.meanFilter != beforeDerived.meanFilter) ||
(nowDerived.weakPeak != beforeDerived.weakPeak) ||
(nowDerived.edgeCenterItems != beforeDerived.edgeCenterItems) ||
(nowDerived.meanFilterItems != beforeDerived.meanFilterItems) ||
(nowDerived.weakPeakItems != beforeDerived.weakPeakItems);
}
if (differs)
{
lanes.push_back(static_cast<int>(laneIndex));
}
}
return !lanes.empty();
}
void PlotView::CollectSceneDerived(std::vector<SceneDerivedEntry>& out) const
{
out.clear();
out.resize(m_lanes.size());
for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex)
{
const Lane& lane = Tab().Lanes()[laneIndex];
SceneDerivedEntry entry;
entry.edgeCenter = lane.edgeCenter ? 1 : 0;
entry.meanFilter = lane.meanFilter ? 1 : 0;
entry.weakPeak = lane.weakPeak ? 1 : 0;
const auto flatten = [](const auto& items, std::vector<int>& target, auto valueOf)
{
target.clear();
target.reserve(items.size() * 4);
for (const auto& item : items)
{
target.push_back(item.ref.groupIndex);
target.push_back(item.ref.signalIndex);
// 均值滤波/中心轴取 points,弱峰调整取 amountPercent(由 `valueOf` 决定)。
target.push_back(static_cast<int>(valueOf(item)));
target.push_back(item.iterations);
}
};
flatten(lane.edgeCenterItems, entry.edgeCenterItems,
[](const Lane::EdgeCenterItem& item) { return item.points; });
flatten(lane.meanFilterItems, entry.meanFilterItems,
[](const Lane::MeanFilterItem& item) { return item.points; });
flatten(lane.weakPeakItems, entry.weakPeakItems,
[](const Lane::WeakPeakItem& item) { return item.amountPercent; });
out[laneIndex] = std::move(entry);
}
}
void PlotView::RepaintSceneCachePatch(gd::Graphics& g, const std::vector<int>& lanes)
{
if (lanes.empty() || m_sceneDc == nullptr)
{
return;
}
// 重画范围 = 受影响网格与绘图区的交集(并且不超出缓存区域)。
float top = 1.0e9f;
float bottom = -1.0e9f;
for (const int laneIndex : lanes)
{
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
continue;
}
const gd::RectF& rect = m_lanes[static_cast<std::size_t>(laneIndex)].rect;
top = std::min(top, rect.Y);
bottom = std::max(bottom, rect.Y + rect.Height);
}
if (bottom <= top)
{
return;
}
top = std::max(top, m_plotRect.Y);
bottom = std::min(bottom, m_plotRect.Y + m_plotRect.Height);
if (bottom <= top)
{
return;
}
// ⚠️ 补丁矩形必须**向内**对齐到整数像素(ceil 上边界、floor 下边界):
// 网格矩形是浮点数,而 GDI `FillRect`/`BitBlt` 会按 floor/ceil 向外取整,
// "向外"会多擦掉/多写回一整行,那一行属于相邻网格(例如它的分界线),
// 于是补丁边界上会留下一像素接缝。向内对齐后,边界外的行一律由缓存提供
// (缓存内容与整幅重绘逐像素一致,已由对照帧验证)。
const int x0 = std::max(static_cast<int>(std::ceil(m_area.X)), static_cast<int>(std::ceil(m_area.X)));
const int y0 = std::max(static_cast<int>(std::ceil(top)), static_cast<int>(std::ceil(m_area.Y)));
const int x1 = std::min(static_cast<int>(std::floor(m_area.X + m_area.Width)),
static_cast<int>(std::floor(m_area.X + m_area.Width)));
const int y1 = std::min(static_cast<int>(std::floor(bottom)),
static_cast<int>(std::floor(m_area.Y + m_area.Height)));
if (x1 <= x0 || y1 <= y0)
{
return;
}
const gd::RectF patch(static_cast<float>(x0), static_cast<float>(y0),
static_cast<float>(x1 - x0), static_cast<float>(y1 - y0));
// 补丁区域必须被**完整**重画:凡是与它相交的网格都要重画一遍
// (补丁已向内对齐,正常只会命中受影响的那几个网格)。
std::vector<int> repaint;
for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex)
{
const gd::RectF& rect = m_lanes[laneIndex].rect;
if (rect.Y < static_cast<float>(y1) && rect.Y + rect.Height > static_cast<float>(y0))
{
repaint.push_back(static_cast<int>(laneIndex));
}
}
if (repaint.empty())
{
return;
}
// 与整幅重绘保持完全相同的绘制顺序:底色 → 逐网格内容(PaintLane 会与当前裁剪区求交)。
{
const int saveState = g.Save();
g.SetClip(patch, gd::CombineModeIntersect);
// ⚠️ 底色填充必须**按补丁矩形本身**填充,不能用 m_area / m_plotRect:
// `UiFillRect` 对大面积不透明矩形会走 GDI `FillRect` 快路径,而 GDI 绘制
// **不受 GDI+ 裁剪区约束**,会把已经贴好的缓存内容整片覆盖掉。
UiFillRect(g, patch, m_theme->plotBg);
const float cardTop = std::max(patch.Y, m_plotRect.Y);
const float cardBottom = std::min(patch.Y + patch.Height, m_plotRect.Y + m_plotRect.Height);
if (cardBottom > cardTop)
{
UiFillRect(g, gd::RectF(patch.X, cardTop, patch.Width, cardBottom - cardTop),
m_theme->cardBg);
}
const double viewMin = Tab().ViewMin();
const double viewMax = Tab().ViewMax();
const double span = viewMax - viewMin;
const double timeStep = NiceStep((span > 0.0) ? span : 1.0, kTimeTickTarget);
std::vector<double> timeTicks;
BuildTicks(viewMin, viewMax, timeStep, timeTicks);
// ⚠️ **补丁必须就地绘制装饰**(而不是"跳过装饰"):补丁范围覆盖整个受影响网格
// (含网格左缘的 Y 轴胶囊与图例),若跳过绘制,这部分像素会被网格底色/波形覆盖,
// 而补丁结果还会写回场景缓存 —— 表现为"改样式时图例/Y 轴读数闪掉一下"。
const bool savedUseDecorCache = m_useDecorCache;
m_useDecorCache = false;
for (const int laneIndex : repaint)
{
if (laneIndex >= 0 && laneIndex < static_cast<int>(m_lanes.size()))
{
PaintLane(g, laneIndex, m_lanes[static_cast<std::size_t>(laneIndex)], timeTicks);
}
}
m_useDecorCache = savedUseDecorCache;
g.Restore(saveState);
}
// 把重画结果写回缓存位图,使缓存与当前样式一致(下一帧即回到"完全命中"快路径)。
{
HDC target = g.GetHDC();
if (target != nullptr)
{
BitBlt(m_sceneDc, x0 - m_sceneX, y0 - m_sceneY, x1 - x0, y1 - y0, target, x0, y0, SRCCOPY);
g.ReleaseHDC(target);
}
}
m_sceneKey = MakeSceneCacheKey();
m_sceneGeometryHash = SceneGeometryHash();
CollectSceneStyles(m_sceneStyles);
CollectSceneDerived(m_sceneDerived);
m_sceneDegraded = m_sceneDegraded || m_fastFrame;
}
gd::RectF PlotView::LaneBadgeRect(int laneIndex) const
{
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
return gd::RectF();
}
const LaneVisual& lane = m_lanes[static_cast<std::size_t>(laneIndex)];
const gd::RectF legendBand(lane.rect.X + kLegendPad * m_scale, lane.rect.Y + kLegendPad * m_scale,
lane.rect.Width, kLegendHeight * m_scale);
// 网格被滚动出可视区时没有徽标可点。
const float visibleTop = std::max(lane.rect.Y, m_plotRect.Y);
const float visibleBottom =
std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height);
if (!IsFullyVisible(legendBand, visibleTop, visibleBottom))
{
return gd::RectF();
}
const float height = kLegendHeight * m_scale;
const float badgeHeight = kLaneBadgeHeight * m_scale;
return gd::RectF(legendBand.X, legendBand.Y + (height - badgeHeight) * 0.5f,
kLaneBadgeWidth * m_scale, badgeHeight);
}
void PlotView::PaintLaneReorder(gd::Graphics& g)
{
if (m_dragMode != PlotDragMode::LANE_REORDER || m_laneDragFrom < 0 || m_sceneDc == nullptr ||
m_lanes.empty())
{
return;
}
const float laneHeight = m_lanes.front().rect.Height;
if (!(laneHeight > 0.0f))
{
return;
}
// 1) 先把整个绘图区(含时间轴)从静态场景缓存贴回:网格次序拖拽只改变网格内部的
// 条带位置,时间轴/边框等内容必须原样保留,否则鼠标拖到工具栏或时间轴上时
// 那一区域会残留上一帧的内容。
if (!BlitSceneCache(g))
{
return;
}
// 2) 把"网格区域"整片铺成绘图区底色:让位留下的空档就是它的原色,
// 网格线/波形随后按各自位移贴回。
UiFillRect(g, m_plotRect, m_theme->cardBg);
HDC target = g.GetHDC();
if (target == nullptr)
{
return;
}
// 逐网格把缓存中该网格的横向条带按位移贴到屏幕上(非常廉价,整幅波形不必重绘)。
// 被拖拽的网格最后贴,因此始终压在最上层,形成"被拎起来"的观感。
std::vector<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);
}
}
bool PlotView::EnsureSceneCache(int width, int height)
{
if (width <= 0 || height <= 0)
{
return false;
}
// 位图尺寸必须与当前缓存区域**严格一致**:贴图时按位图尺寸整块拷贝,
// 若沿用更大的旧位图,缩小窗口后会把旧像素画到绘图区之外。
if (m_sceneDc != nullptr && m_sceneWidth == width && m_sceneHeight == height)
{
return true;
}
ReleaseSceneCache();
// 场景位图重建 → 面板图层里缓存的"面板背后的场景"随之失效。
m_panelValid = false;
HDC screen = GetDC(nullptr);
if (screen == nullptr)
{
return false;
}
m_sceneDc = CreateCompatibleDC(screen);
m_sceneBitmap = CreateCompatibleBitmap(screen, width, height);
ReleaseDC(nullptr, screen);
if (m_sceneDc == nullptr || m_sceneBitmap == nullptr)
{
ReleaseSceneCache();
return false;
}
m_sceneOldBitmap = SelectObject(m_sceneDc, m_sceneBitmap);
m_sceneWidth = width;
m_sceneHeight = height;
return true;
}
bool PlotView::CaptureSceneCache(gd::Graphics& g)
{
// 缓存区域严格限定为绘图区本身(m_area):工具栏/标签栏/面板由宿主每帧绘制,
// 因为它们各自带有悬停态;绘图区的波形才是真正昂贵的部分(大文件约 80ms/帧)。
const int x = static_cast<int>(std::floor(m_area.X));
const int y = static_cast<int>(std::floor(m_area.Y));
const int right = static_cast<int>(std::ceil(m_area.X + m_area.Width));
const int bottom = static_cast<int>(std::ceil(m_area.Y + m_area.Height));
const int width = right - x;
const int height = bottom - y;
if (width <= 0 || height <= 0)
{
m_sceneValid = false;
return false;
}
m_sceneX = x;
m_sceneY = y;
if (!EnsureSceneCache(width, height))
{
m_sceneValid = false;
return false;
}
HDC source = g.GetHDC();
if (source == nullptr)
{
m_sceneValid = false;
return false;
}
const BOOL ok = BitBlt(m_sceneDc, 0, 0, width, height, source, m_sceneX, m_sceneY, SRCCOPY);
g.ReleaseHDC(source);
if (ok == FALSE)
{
m_sceneValid = false;
return false;
}
m_sceneKey = MakeSceneCacheKey();
m_sceneGeometryHash = SceneGeometryHash();
CollectSceneStyles(m_sceneStyles);
CollectSceneDerived(m_sceneDerived);
m_sceneDegraded = false;
m_sceneValid = true;
return true;
}
bool PlotView::BlitSceneCache(gd::Graphics& g)
{
if (!m_sceneValid || m_sceneDc == nullptr)
{
return false;
}
HDC target = g.GetHDC();
if (target == nullptr)
{
return false;
}
const BOOL ok = BitBlt(target, m_sceneX, m_sceneY, m_sceneWidth, m_sceneHeight, m_sceneDc, 0, 0,
SRCCOPY);
g.ReleaseHDC(target);
return ok != FALSE;
}
void PlotView::InvalidateSceneCache()
{
m_sceneValid = false;
m_sceneStyles.clear();
m_sceneDerived.clear();
m_sceneDegraded = false;
m_sceneGeometryHash = 0;
// 面板像素图层与场景缓存同源(面板像素里含"面板背后的场景"):
// 信号增删、重命名、参数变化等一律连带作废,避免面板里残留旧波形。
m_panelValid = false;
}
void PlotView::ReleaseSceneCache()
{
m_sceneValid = false;
if (m_sceneDc != nullptr)
{
if (m_sceneOldBitmap != nullptr)
{
SelectObject(m_sceneDc, m_sceneOldBitmap);
m_sceneOldBitmap = nullptr;
}
DeleteDC(m_sceneDc);
m_sceneDc = nullptr;
}
if (m_sceneBitmap != nullptr)
{
DeleteObject(m_sceneBitmap);
m_sceneBitmap = nullptr;
}
m_sceneWidth = 0;
m_sceneHeight = 0;
}
bool PlotView::EnsureDecorCache(int width, int height)
{
if (width <= 0 || height <= 0)
{
return false;
}
if (m_decorDc != nullptr && m_decorBits != nullptr && m_decorW == width && m_decorH == height)
{
return true;
}
ReleaseDecorCache();
HDC screen = GetDC(nullptr);
if (screen == nullptr)
{
return false;
}
// ⚠️ **必须用 32bpp DIB 段**(而不是 `CreateCompatibleBitmap`):
// 装饰要带真 alpha(圆角之外全透明),才能用 `AlphaBlend` 合成到波形之上;
// 同时 DIB 段的像素可直接写(用于 alpha 修补与清空)。
BITMAPINFO info = {};
info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
info.bmiHeader.biWidth = width;
info.bmiHeader.biHeight = -height; // 自上而下
info.bmiHeader.biPlanes = 1;
info.bmiHeader.biBitCount = 32;
info.bmiHeader.biCompression = BI_RGB;
void* bits = nullptr;
HBITMAP dib = CreateDIBSection(screen, &info, DIB_RGB_COLORS, &bits, nullptr, 0);
ReleaseDC(nullptr, screen);
if (dib == nullptr || bits == nullptr)
{
if (dib != nullptr)
{
DeleteObject(dib);
}
return false;
}
HDC dc = CreateCompatibleDC(nullptr);
if (dc == nullptr)
{
DeleteObject(dib);
return false;
}
m_decorOldBitmap = SelectObject(dc, dib);
m_decorDc = dc;
m_decorBitmap = dib;
m_decorBits = bits;
m_decorW = width;
m_decorH = height;
return true;
}
void PlotView::ReleaseDecorCache()
{
m_decorValid = false;
m_decorCapturedRects.clear();
if (m_decorDc != nullptr)
{
if (m_decorOldBitmap != nullptr)
{
SelectObject(m_decorDc, m_decorOldBitmap);
m_decorOldBitmap = nullptr;
}
DeleteDC(m_decorDc);
m_decorDc = nullptr;
}
if (m_decorBitmap != nullptr)
{
DeleteObject(m_decorBitmap);
m_decorBitmap = nullptr;
}
m_decorBits = nullptr;
m_decorW = 0;
m_decorH = 0;
}
void* PlotView::ResolveAlphaBlend()
{
// `AlphaBlend` 由 msimg32 导出、并由 gdi32 的 `GdiAlphaBlend` 实现;
// 两个来源都试一遍,取到即缓存(静态局部变量,仅解析一次)。
static void* cached = nullptr;
static bool resolved = false;
if (resolved)
{
return cached;
}
resolved = true;
HMODULE gdi = GetModuleHandleW(L"gdi32.dll");
if (gdi != nullptr)
{
cached = reinterpret_cast<void*>(GetProcAddress(gdi, "GdiAlphaBlend"));
}
if (cached == nullptr)
{
HMODULE msimg = LoadLibraryW(L"msimg32.dll");
if (msimg != nullptr)
{
cached = reinterpret_cast<void*>(GetProcAddress(msimg, "AlphaBlend"));
}
}
return cached;
}
bool PlotView::RenderDecorCache()
{
m_decorValid = false;
if (m_theme == nullptr || m_fonts == nullptr || m_lanes.empty())
{
return false;
}
// 位图覆盖「客户区原点 → 绘图区右下角」:绘制坐标与正常绘制完全一致,
// 不需要任何平移(GDI 文本不认 GDI+ 变换,平移会让文字错位)。
const int width = static_cast<int>(std::ceil(m_area.X + m_area.Width));
const int height = static_cast<int>(std::ceil(m_area.Y + m_area.Height));
if (!EnsureDecorCache(width, height))
{
return false;
}
// 清空为全透明(圆角之外必须保持透明,贴回时才不会盖住波形)。
std::memset(m_decorBits, 0, static_cast<std::size_t>(width) * static_cast<std::size_t>(height) * 4u);
m_decorRects.clear();
{
gd::Graphics g(m_decorDc);
g.SetSmoothingMode(gd::SmoothingModeAntiAlias);
g.SetPixelOffsetMode(gd::PixelOffsetModeHalf);
g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit);
const bool savedUse = m_useDecorCache;
m_useDecorCache = false; // 本次是"真正绘制":不允许走"跳过绘制"的分支
m_renderDecorOnly = true; // 只画装饰(跳过网格线与波形)
const std::vector<double> noTicks;
for (std::size_t index = 0; index < m_lanes.size(); ++index)
{
PaintLane(g, static_cast<int>(index), m_lanes[index], noTicks);
}
m_renderDecorOnly = false;
m_useDecorCache = savedUse;
}
// ⚠️ **alpha 修补**:GDI 的文本绘制(`DrawTextW`)会把它写入的像素 alpha 清成 0
// (GDI 不维护 alpha),若原样保留,贴上后文字位置会变成"透明洞"(透出波形)。
// 在这里把装饰矩形内"有颜色但 alpha 为 0"的像素还原为不透明即可 —— 装饰本身
// 是画在不透明底板上的,这些像素本来就该不透明。
for (const gd::RectF& rect : m_decorRects)
{
const int x0 = std::max(0, static_cast<int>(std::floor(rect.X)));
const int y0 = std::max(0, static_cast<int>(std::floor(rect.Y)));
const int x1 = std::min(width, static_cast<int>(std::ceil(rect.X + rect.Width)));
const int y1 = std::min(height, static_cast<int>(std::ceil(rect.Y + rect.Height)));
for (int y = y0; y < y1; ++y)
{
unsigned char* row = static_cast<unsigned char*>(m_decorBits) +
(static_cast<std::size_t>(y) * static_cast<std::size_t>(width)) * 4u;
for (int x = x0; x < x1; ++x)
{
unsigned char* p = row + static_cast<std::size_t>(x) * 4u;
if (p[3] == 0 && (p[0] | p[1] | p[2]) != 0)
{
p[3] = 255;
}
}
}
}
m_decorCapturedRects = m_decorRects;
m_decorValid = true;
return true;
}
bool PlotView::BlitDecorCache(gd::Graphics& g)
{
if (!m_decorValid || m_decorDc == nullptr || m_decorCapturedRects.empty())
{
return false;
}
void* proc = ResolveAlphaBlend();
if (proc == nullptr)
{
return false; // 极小概率:系统不提供 AlphaBlend(贴回会带透明洞,故直接放弃)
}
using AlphaBlendFn = BOOL(WINAPI*)(HDC, int, int, int, int, HDC, int, int, int, int, BLENDFUNCTION);
AlphaBlendFn blend = reinterpret_cast<AlphaBlendFn>(proc);
HDC target = g.GetHDC();
if (target == nullptr)
{
return false;
}
// 装饰位图原点 = 客户区 (0,0):源坐标与目标坐标**相同**。
BLENDFUNCTION fn = {};
fn.BlendOp = AC_SRC_OVER;
fn.SourceConstantAlpha = 255;
fn.AlphaFormat = AC_SRC_ALPHA; // 源为**预乘** alpha(GDI+ 写入 DIB 段即预乘)
bool any = false;
for (const gd::RectF& rect : m_decorCapturedRects)
{
const int x = static_cast<int>(std::floor(rect.X));
const int y = static_cast<int>(std::floor(rect.Y));
const int w = static_cast<int>(std::ceil(rect.X + rect.Width)) - x;
const int h = static_cast<int>(std::ceil(rect.Y + rect.Height)) - y;
if (w <= 0 || h <= 0)
{
continue;
}
if (blend(target, x, y, w, h, m_decorDc, x, y, w, h, fn) != FALSE)
{
any = true;
}
}
g.ReleaseHDC(target);
return any;
}
bool PlotView::EnsureLaneLayer(LaneLayer& layer, int width, int height)
{
if (width <= 0 || height <= 0)
{
return false;
}
if (layer.dc != nullptr && layer.width == width && layer.height == height)
{
return true;
}
ReleaseLaneLayer(layer);
HDC screen = GetDC(nullptr);
if (screen == nullptr)
{
return false;
}
layer.dc = CreateCompatibleDC(screen);
layer.bitmap = CreateCompatibleBitmap(screen, width, height);
ReleaseDC(nullptr, screen);
if (layer.dc == nullptr || layer.bitmap == nullptr)
{
ReleaseLaneLayer(layer);
return false;
}
layer.oldBitmap = SelectObject(layer.dc, layer.bitmap);
layer.width = width;
layer.height = height;
layer.valid = false;
return true;
}
void PlotView::ReleaseLaneLayer(LaneLayer& layer)
{
layer.valid = false;
if (layer.dc != nullptr)
{
if (layer.oldBitmap != nullptr)
{
SelectObject(layer.dc, layer.oldBitmap);
layer.oldBitmap = nullptr;
}
DeleteDC(layer.dc);
layer.dc = nullptr;
}
if (layer.bitmap != nullptr)
{
DeleteObject(layer.bitmap);
layer.bitmap = nullptr;
}
layer.width = 0;
layer.height = 0;
}
void PlotView::ReleaseWideBuffer()
{
std::lock_guard<std::mutex> lock(m_layerMutex);
for (LaneLayer& layer : m_layerFront)
{
ReleaseLaneLayer(layer);
}
m_layerFront.clear();
m_layerRebuildPending.clear();
}
void PlotView::InvalidateWideBuffer()
{
std::lock_guard<std::mutex> lock(m_layerMutex);
for (LaneLayer& layer : m_layerFront)
{
layer.valid = false;
}
m_layerRebuildPending.clear();
}
std::uint64_t PlotView::LaneLayerKey(int laneIndex) const
{
// 图层只含基底(不含派生曲线与装饰),因此指纹 = **该网格自己的内容**
// ⊕ 网格下标 ⊕ 视界跨度 ⊕ 绘图区宽度(决定像素/时间与位图尺寸)。
const double span = Tab().ViewMax() - Tab().ViewMin();
std::uint64_t key = LaneLayerHash(laneIndex);
key ^= static_cast<std::uint64_t>(laneIndex + 1) * 1315423911ull;
key *= 1099511628211ull;
key ^= static_cast<std::uint64_t>(std::llround(span * 1.0e6));
key *= 1099511628211ull;
key ^= static_cast<std::uint64_t>(std::llround(m_plotRect.Width));
return key;
}
std::uint64_t PlotView::LaneLayerHash(int laneIndex) const
{
// 单个网格图层的内容 = 位图高度(= 网格高度)+ 幅值**跨度** + 本网格各信号的
// 可见性与外观 + 图例文案(图例不在图层里,但一起计入无妨且更安全)。
//
// ⚠️ **只计跨度、不计幅值的绝对范围**:图层的纵向锚点(`anchorYMax`)与 `wideMin`
// 一样是"贴图偏移"的来源,幅值整体平移不改变图层内容(只是贴图时纵向偏移若干像素),
// 因此纵向拖动不会让图层失效。真正改变内容的是**跨度**(决定像素/数值与刻度步长)。
std::uint64_t hash = 1469598103934665603ull;
const auto mix = [&hash](std::uint64_t value)
{
hash ^= value;
hash *= 1099511628211ull;
};
const auto mixFloat = [&mix](float value)
{
std::uint32_t bits = 0;
std::memcpy(&bits, &value, sizeof(bits));
mix(bits);
};
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
return hash;
}
const LaneVisual& visual = m_lanes[static_cast<std::size_t>(laneIndex)];
mixFloat(visual.rect.Height);
mixFloat(static_cast<float>(visual.yMax - visual.yMin));
const std::vector<SignalRef>& signals = Tab().Lanes()[static_cast<std::size_t>(laneIndex)].signals;
mix(signals.size());
for (const SignalRef& ref : signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
mix(static_cast<std::uint64_t>(ref.groupIndex));
mix(static_cast<std::uint64_t>(ref.signalIndex));
mix(resolved.visible ? 1ull : 0ull);
if (resolved.data == nullptr || resolved.style == nullptr)
{
continue;
}
mix(static_cast<std::uint64_t>(resolved.style->color));
mixFloat(resolved.style->opacity);
mixFloat(resolved.style->lineWidth);
mix(static_cast<std::uint64_t>(resolved.style->mode));
// 「调整」变换会改变图层里的波形,也必须让图层失效
//(否则贴回的旧像素会与当前样式不一致)。
mixFloat(resolved.style->gain);
mix(resolved.style->absolute ? 1ull : 0ull);
mix(resolved.style->mirrorVertical ? 1ull : 0ull);
mix(resolved.style->mirrorHorizontal ? 1ull : 0ull);
mixFloat(resolved.style->offsetY);
mixFloat(static_cast<float>(resolved.style->offsetTime));
}
return hash;
}
int PlotView::LayerMarginX() const
{
const int width = static_cast<int>(std::lround(m_plotRect.Width));
return std::max(1, width / 2); // 左右各半屏
}
int PlotView::LayerMarginY(float laneHeight) const
{
// 上下各半个网格高:一整屏的额外内容已足够覆盖实际拖拽范围,
// 又不至于让每个横向补条 / 整块重画按三倍高度放大(横向平移因此不受影响)。
const int half = static_cast<int>(std::lround(static_cast<double>(laneHeight) * 0.5));
return std::max(1, half);
}
double PlotView::LayerDefaultWideMin() const
{
// 以当前视界为中心:位图左右各留半个视界宽,因此可向两侧平移 ±半屏。
const double span = Tab().ViewMax() - Tab().ViewMin();
const int width = static_cast<int>(std::lround(m_plotRect.Width));
if (!(span > 0.0) || width <= 0)
{
return Tab().ViewMin();
}
const double scale = static_cast<double>(width) / span;
return Tab().ViewMin() - static_cast<double>(LayerMarginX()) / scale;
}
bool PlotView::LayersStructurallyReady() const
{
std::lock_guard<std::mutex> lock(m_layerMutex);
if (m_layerFront.size() != m_lanes.size() || m_layerFront.empty())
{
return false;
}
const float plotWidth = m_plotRect.Width;
const double span = Tab().ViewMax() - Tab().ViewMin();
if (!(plotWidth > 8.0f) || !(span > 0.0))
{
return false;
}
const int width = static_cast<int>(std::lround(plotWidth));
const int bitmapWidth = width + 2 * LayerMarginX();
for (std::size_t index = 0; index < m_layerFront.size(); ++index)
{
const LaneLayer& layer = m_layerFront[index];
if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth)
{
return false;
}
// 跨度必须一致:跨度变了像素/时间的映射就不同,旧像素不能再当“坐标偏移”用。
if (std::fabs(span - layer.span) > 1.0e-12)
{
return false;
}
// 高度也要一致:网格高度变化会让贴图纵向错位(含上下预渲染余量)。
const float laneHeight = m_lanes[index].rect.Height;
const int bitmapHeight =
static_cast<int>(std::ceil(laneHeight)) + 2 * LayerMarginY(laneHeight);
if (layer.height != bitmapHeight)
{
return false;
}
}
return true;
}
bool PlotView::LayersAllocated() const
{
std::lock_guard<std::mutex> lock(m_layerMutex);
return !m_layerFront.empty() && m_layerFront.size() == m_lanes.size();
}
bool PlotView::LaneLayerReady(int laneIndex) const
{
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
return false;
}
std::lock_guard<std::mutex> lock(m_layerMutex);
if (laneIndex >= static_cast<int>(m_layerFront.size()))
{
return false;
}
const LaneLayer& layer = m_layerFront[static_cast<std::size_t>(laneIndex)];
const float plotWidth = m_plotRect.Width;
const double span = Tab().ViewMax() - Tab().ViewMin();
if (!(plotWidth > 8.0f) || !(span > 0.0))
{
return false;
}
const int width = static_cast<int>(std::lround(plotWidth));
const int bitmapWidth = width + 2 * LayerMarginX();
const float laneHeight = m_lanes[static_cast<std::size_t>(laneIndex)].rect.Height;
const int bitmapHeight = static_cast<int>(std::ceil(laneHeight)) + 2 * LayerMarginY(laneHeight);
if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth ||
layer.height != bitmapHeight || std::fabs(layer.span - span) > 1.0e-12)
{
return false;
}
return layer.key == LaneLayerKey(laneIndex);
}
bool PlotView::LanesAllReadyForView() const
{
if (m_lanes.empty())
{
return false;
}
for (std::size_t index = 0; index < m_lanes.size(); ++index)
{
if (!LaneLayerReady(static_cast<int>(index)))
{
return false;
}
}
return true;
}
bool PlotView::LaneVisuallyRelevant(int laneIndex) const
{
return LaneVisuallyRelevant(laneIndex, 0);
}
bool PlotView::LaneVisuallyRelevant(int laneIndex, int prefetchScreens) const
{
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
return false;
}
if (!(m_plotRect.Height > 0.0f))
{
return false;
}
const gd::RectF& rect = m_lanes[static_cast<std::size_t>(laneIndex)].rect;
// 可视判定本身留一个网格高的余量(即将滚入视区的网格也算相关);
// 预取带在其上再按屏高放大(见头文件说明)。
const float margin = std::max(rect.Height, 0.0f) +
static_cast<float>(std::max(0, prefetchScreens)) * m_plotRect.Height;
return (rect.Y + rect.Height + margin > m_plotRect.Y) &&
(rect.Y - margin < m_plotRect.Y + m_plotRect.Height);
}
bool PlotView::LanesVisiblyReadyForView() const
{
if (m_lanes.empty())
{
return false;
}
bool anyRelevant = false;
for (std::size_t index = 0; index < m_lanes.size(); ++index)
{
if (!LaneVisuallyRelevant(static_cast<int>(index)))
{
continue;
}
anyRelevant = true;
if (!LaneLayerReady(static_cast<int>(index)))
{
return false;
}
}
return anyRelevant;
}
bool PlotView::AnyVisibleLanePending() const
{
// ⚠️ 用 **±1 屏预取带**(而不是“严格可见”):网格滚动动画会在几百毫秒内扫过一整个屏高,
// 只预建严格可见的网格时,动画扫进去的每一帧都要靠底衬兜底重画十几份网格
// (实测 ~8ms/帧 ⇒ 动画明显卡顿)。预取带让静置期间就把附近网格的图层建好。
for (const int laneIndex : m_layerRebuildPending)
{
if (LaneVisuallyRelevant(laneIndex, 1))
{
return true;
}
}
return false;
}
void PlotView::QueueLaneRebuild(int laneIndex)
{
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
return;
}
for (const int existing : m_layerRebuildPending)
{
if (existing == laneIndex)
{
return; // 已经在队列里
}
}
m_layerRebuildPending.push_back(laneIndex);
}
bool PlotView::RenderPendingLaneRebuilds(int budget)
{
if (m_layerRebuildPending.empty() || budget <= 0)
{
return !m_layerRebuildPending.empty();
}
// ⚠️ **优先补画可视网格**:队列是按下标顺序登记的,若严格 FIFO,用户正在看的那几个
// 网格(往往位于列表下半部)可能要等上百帧才轮到,期间画面一直停留在兜底状态
//(用户报告:缩放 / 折起后“需要等一会才正确加载”)。
// 做一次**稳定划分**:可视网格排到队首,其余保持原有相对顺序。
std::stable_partition(m_layerRebuildPending.begin(), m_layerRebuildPending.end(),
[this](int laneIndex) { return LaneVisuallyRelevant(laneIndex); });
const double defaultWideMin = LayerDefaultWideMin();
// 本批要重画的网格:`budget` 足够大时一次取完(视界已停时调用方会传很大的预算)。
const int available = static_cast<int>(m_layerRebuildPending.size());
const int take = std::min(budget, available);
std::vector<int> batch;
batch.reserve(static_cast<std::size_t>(take));
for (int index = 0; index < take; ++index)
{
const int laneIndex = m_layerRebuildPending[static_cast<std::size_t>(index)];
if (laneIndex >= 0 && laneIndex < static_cast<int>(m_lanes.size()))
{
batch.push_back(laneIndex);
}
}
m_layerRebuildPending.erase(m_layerRebuildPending.begin(),
m_layerRebuildPending.begin() + take);
if (batch.size() > 1)
{
// **多网格一次补齐**:并行渲染(各网格用隔离上下文),比"一帧只补 1 个"收敛快 N 倍。
RebuildLaneLayersParallel(batch, defaultWideMin);
}
else
{
for (const int laneIndex : batch)
{
// 加锁顺序与前台一致:先 `m_renderMutex` 再 `m_layerMutex`。
std::lock_guard<std::recursive_mutex> renderLock(m_renderMutex);
std::lock_guard<std::mutex> layerLock(m_layerMutex);
if (m_layerFront.size() != m_lanes.size())
{
break;
}
LaneLayer& layer = m_layerFront[static_cast<std::size_t>(laneIndex)];
// ⚠️ **跨度过期(缩放瞬间)也在这里就地整块重画**(按**新跨度**)。
// 旧实现遇到跨度不符就 `continue`(把该网格推给"下一次整批重建"),
// 而"整批重建"正是那个 70~80ms 的同步卡顿源;现在改为一帧补一个网格,
// 其余网格由 `BlitWideBuffer` 的占位拉伸顶着 → 前台永不因图层未就绪而滞后。
// 整块重画:整个图层都对齐到"当前 `yMax`"(锚点 = 当前 yMax)。
if (!RenderLaneLayerRegion(laneIndex, layer, 0, 0, 0, 0, defaultWideMin,
m_lanes[static_cast<std::size_t>(laneIndex)].yMax))
{
continue;
}
}
}
return !m_layerRebuildPending.empty();
}
void PlotView::EnsureLayerWorkers()
{
unsigned int hardware = std::thread::hardware_concurrency();
const int want = std::max(1, std::min(static_cast<int>(hardware == 0 ? 2u : hardware), 4));
if (m_layerWorkerCount == want && static_cast<int>(m_layerWorkers.size()) == want)
{
// 字体 / 主题 / 缩放 / 数据源可能已变化:刷新一次绑定(不重建线程上下文)。
for (int index = 0; index < want; ++index)
{
m_layerWorkers[static_cast<std::size_t>(index)]->Setup(m_fonts, m_theme);
m_layerWorkers[static_cast<std::size_t>(index)]->SetScale(m_scale);
m_layerWorkers[static_cast<std::size_t>(index)]->m_dataSet = m_dataSet;
m_layerWorkers[static_cast<std::size_t>(index)]->m_dataSetWritable = m_dataSetWritable;
}
return;
}
ReleaseLayerWorkers();
m_layerWorkers.reserve(static_cast<std::size_t>(want));
for (int index = 0; index < want; ++index)
{
auto worker = std::make_unique<PlotView>();
worker->Setup(m_fonts, m_theme);
worker->SetScale(m_scale);
// ⚠️ `m_tab` 置空 → worker 的 `Tab()` 返回它**自带的** `m_localTab` 副本,
// 于是它改写视界 / 网格矩形时不会碰到共享的标签页配置(安全并行的前提)。
worker->m_dataSet = m_dataSet;
worker->m_dataSetWritable = m_dataSetWritable;
// ⚠️ `m_tab` **必须非空**(`BuildLaneVisuals()` 在空时会清空 `m_lanes`);
// 指向 worker **自有**的 `m_workerTab`,这样它改写视界 / 网格矩形时不会碰到共享标签页。
worker->m_tab = &worker->m_workerTab;
worker->RefreshLanes();
m_layerWorkers.push_back(std::move(worker));
}
m_layerWorkerCount = want;
}
void PlotView::ReleaseLayerWorkers()
{
m_layerWorkers.clear();
m_layerWorkerCount = 0;
}
void PlotView::RebuildLaneLayersParallel(const std::vector<int>& laneIndices, double wideMin)
{
if (laneIndices.empty() || m_tab == nullptr || m_dataSet == nullptr)
{
return;
}
// 单网格 / 单核:直接顺序走既有路径(避免线程开销)。
unsigned int hardware = std::thread::hardware_concurrency();
if (laneIndices.size() <= 1 || (hardware == 0 ? 2u : hardware) <= 1)
{
std::lock_guard<std::recursive_mutex> renderLock(m_renderMutex);
std::lock_guard<std::mutex> layerLock(m_layerMutex);
if (m_layerFront.size() != m_lanes.size())
{
return;
}
for (const int laneIndex : laneIndices)
{
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
continue;
}
RenderLaneLayerRegion(laneIndex, m_layerFront[static_cast<std::size_t>(laneIndex)], 0, 0, 0,
0, wideMin, m_lanes[static_cast<std::size_t>(laneIndex)].yMax);
}
return;
}
{
std::lock_guard<std::recursive_mutex> renderLock(m_renderMutex);
std::lock_guard<std::mutex> layerLock(m_layerMutex);
if (m_layerFront.size() != m_lanes.size())
{
return;
}
}
EnsureLayerWorkers();
if (m_layerWorkerCount <= 1)
{
return; // 保守回退:让调用方走顺序路径
}
// 主线程**先快照**工作线程需要的一切(之后它们只读,不再触碰共享状态)。
const TabConfig tabSnapshot = *m_tab;
const gd::RectF plotRectSnapshot = m_plotRect;
const gd::RectF areaSnapshot = m_area;
const float scaleSnapshot = m_scale;
const std::vector<LaneVisual> laneSnapshot = m_lanes;
// 目标图层位图数组(已确认尺寸与网格数一致,线程内不会扩容)。
std::vector<LaneLayer>* const front = &m_layerFront;
const int workerCount = m_layerWorkerCount;
std::atomic<std::size_t> next{0};
std::vector<std::thread> threads;
threads.reserve(static_cast<std::size_t>(workerCount));
for (int workerIndex = 0; workerIndex < workerCount; ++workerIndex)
{
threads.emplace_back([&, workerIndex]()
{
PlotView* worker = m_layerWorkers[static_cast<std::size_t>(workerIndex)].get();
for (;;)
{
const std::size_t slot = next.fetch_add(1, std::memory_order_relaxed);
if (slot >= laneIndices.size())
{
break;
}
const int laneIndex = laneIndices[slot];
if (laneIndex < 0 || laneIndex >= static_cast<int>(laneSnapshot.size()) ||
laneIndex >= static_cast<int>(tabSnapshot.Lanes().size()))
{
continue;
}
// 隔离上下文:自带标签页副本 + 只含该网格 + 只读的数据源 / 字体 / 主题。
worker->m_workerTab = tabSnapshot;
worker->m_workerTab.Lanes() = {tabSnapshot.Lanes()[static_cast<std::size_t>(laneIndex)]};
worker->m_tab = &worker->m_workerTab;
worker->m_fonts = m_fonts;
worker->m_theme = m_theme;
worker->m_scale = scaleSnapshot;
worker->m_dataSet = m_dataSet;
worker->m_dataSetWritable = m_dataSetWritable;
worker->m_area = areaSnapshot;
worker->m_plotRect = plotRectSnapshot;
worker->m_lanes = {laneSnapshot[static_cast<std::size_t>(laneIndex)]};
// ⚠️ 工作线程用**自己的** `m_workerTab` / `m_area` / `m_plotRect` / `m_lanes` 渲染,
// 因此不会与 UI 线程或其它工作线程互相覆盖渲染状态(这正是 2026-10-12 移除
// "后台图层线程"的根因)。目标图层位图按 `laneIndex` 各自不同,可并发写入。
worker->RenderLaneLayerRegion(0, (*front)[static_cast<std::size_t>(laneIndex)], 0, 0, 0, 0,
wideMin,
laneSnapshot[static_cast<std::size_t>(laneIndex)].yMax);
}
});
}
for (std::thread& thread : threads)
{
if (thread.joinable())
{
thread.join();
}
}
// 合并(主线程):工作线程内部以 `laneIndex = 0` 渲染,因此图层内容指纹要按**真实网格下标**重算。
std::lock_guard<std::mutex> layerLock(m_layerMutex);
if (m_layerFront.size() != m_lanes.size())
{
return;
}
std::uint64_t rendered = 0;
for (const int laneIndex : laneIndices)
{
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_layerFront.size()))
{
continue;
}
LaneLayer& target = m_layerFront[static_cast<std::size_t>(laneIndex)];
if (!target.valid)
{
continue;
}
target.key = LaneLayerKey(laneIndex);
++rendered;
}
m_layerRenderCount.fetch_add(rendered, std::memory_order_relaxed);
}
bool PlotView::RenderLaneLayerRegion(int laneIndex, LaneLayer& layer, int stripX, int stripW,
int stripY, int stripH, double wideMin, double anchorYMax)
{
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
return false;
}
std::lock_guard<std::recursive_mutex> renderLock(m_renderMutex);
const float plotWidth = m_plotRect.Width;
const double span = Tab().ViewMax() - Tab().ViewMin();
if (!(plotWidth > 8.0f) || !(span > 0.0))
{
layer.valid = false;
return false;
}
const int width = static_cast<int>(std::lround(plotWidth));
const int margin = LayerMarginX(); // 左右各半屏 → 可平移 ±半屏
const int bitmapWidth = width + 2 * margin;
const float laneHeight = m_lanes[static_cast<std::size_t>(laneIndex)].rect.Height;
const int lanePixelHeight = static_cast<int>(std::ceil(laneHeight));
const int marginY = LayerMarginY(laneHeight); // 上下各半个网格高 → 幅值可平移 ±半个网格高
const int bitmapHeight = lanePixelHeight + 2 * marginY;
if (bitmapHeight <= 0 || !EnsureLaneLayer(layer, bitmapWidth, bitmapHeight))
{
return false;
}
const double scale = static_cast<double>(width) / span; // 像素 / 时间
const double laneSpan = m_lanes[static_cast<std::size_t>(laneIndex)].yMax -
m_lanes[static_cast<std::size_t>(laneIndex)].yMin;
const double pxPerValue = (laneSpan > 0.0) ? static_cast<double>(laneHeight) / laneSpan : 0.0;
if (stripW <= 0 || stripX < 0 || stripX + stripW > bitmapWidth)
{
stripX = 0;
stripW = bitmapWidth;
}
// 纵向同理:`stripH <= 0` 表示纵向整块(横向滚动的细条就是这种情形)。
if (stripH <= 0 || stripY < 0 || stripY + stripH > bitmapHeight)
{
stripY = 0;
stripH = bitmapHeight;
}
// 保存当前布局 / 视图 / 网格,临时切换到"宽窗口"映射后离屏渲染,再恢复。
const gd::RectF savedArea = m_area;
const gd::RectF savedPlot = m_plotRect;
const double savedViewMin = Tab().ViewMin();
const double savedViewMax = Tab().ViewMax();
const std::vector<LaneVisual> savedLanes = m_lanes;
const float savedLaneContentHeight = m_laneContentHeight;
const bool savedFastFrame = m_fastFrame;
const bool savedRenderWide = m_renderWide;
const float savedScroll = Tab().LaneScroll();
const double savedWideTickSpan = m_wideTickSpan;
const float savedWideTickHeight = m_wideTickHeight;
const double savedRenderTimeStep = m_renderTimeStep;
m_plotRect = gd::RectF(0.0f, 0.0f, static_cast<float>(bitmapWidth),
static_cast<float>(bitmapHeight));
// 只把 `m_area` 限为待渲染的那一段 x 区间,使 GDI/GDI+ 的裁剪只落在它上面;
// x 坐标仍用**整条宽窗口**的映射(`m_plotRect` 为完整宽图),因此网格线位置与整块图层一致。
m_area = gd::RectF(static_cast<float>(stripX), static_cast<float>(stripY),
static_cast<float>(stripW), static_cast<float>(stripH));
// 横向补条(窄竖带):把折线的构建与描边裁剪到条带的 x 范围(见 `m_stripRenderActive`)。
// 纵向补条(整宽 × 窄 y)不裁剪 x —— 那一方向本来就需要整条折线的顶点。
const bool stripIsHorizontalBand = (static_cast<float>(stripW) < static_cast<float>(bitmapWidth) - 0.5f);
const bool savedStripActive = m_stripRenderActive;
const float savedStripX0 = m_stripX0;
const float savedStripX1 = m_stripX1;
m_stripRenderActive = stripIsHorizontalBand;
m_stripX0 = static_cast<float>(stripX);
m_stripX1 = static_cast<float>(stripX + stripW);
Tab().ViewMin() = wideMin;
Tab().ViewMax() = wideMin + static_cast<double>(bitmapWidth) / scale;
m_renderWide = true; // 图层只含基底:跳过派生曲线与装饰
// 图层必须与"同一视界的整幅渲染"**逐像素同源**:强制全质量抗锯齿(不受拖拽快速帧影响)。
m_fastFrame = false;
// 幅值刻度的参考跨度 / 参考高度取**前台可见网格**的值(而不是含上下余量的画布值):
// 这样图层里的横向网格线步长与前台一致,纵向平移才是纯粹的像素平移。
m_wideTickSpan = laneSpan;
m_wideTickHeight = laneHeight;
// ⚠️⚠️ **不要在这里再调 `BuildLaneVisuals()`**:它会对**全部网格**重算
// `ComputeLaneRange`(O(网格数)),而本函数只需要**被渲染的那一个网格**。
// 253 个网格、30 个可见网格时,每帧 30 次补条 × 2 次 BuildLaneVisuals
// = 单帧 **26.8ms**(用户 2026-10-17 报告:三明治加载后拖拽视界卡顿)。
// 该网格的矩形/幅值范围已在下方显式设置(`m_lanes` 其余成员保持原值不变)。
// ⚠️ **该网格的幅值映射必须以本次渲染的纵向锚点为准**(而不是"当前 `yMax`"):
// - 整块重画 / 纵向补条:锚点就是当前 `yMax`(纵向补条是滚动后的值),二者一致;
// - **横向补细条**:只重画一条竖带,其余像素仍在**原锚点**上;若这里用当前 `yMax`
// 渲染,竖带与其余像素的纵向映射就不同源,贴图后出现撕裂/错位。
// 详见 `RenderLaneLayerRegion` 的 `anchorYMax` 形参说明。
m_lanes[static_cast<std::size_t>(laneIndex)].yMax = anchorYMax;
m_lanes[static_cast<std::size_t>(laneIndex)].yMin = anchorYMax - laneSpan;
// 该网格在本图层中的位置:横向铺满整条宽图,纵向铺满整张画布。
// 画布 = 「可见网格 + 上下各 `marginY` 行余量」。**上下扩展量按"像素行"精确换算**
//(而不是上下各扩展固定的数值量):位图高度是整数(`ceil(laneHeight) + 2*marginY`),
// 只有当「扩展后总跨度 / 画布行数」严格等于前台的 px/数值 时,可见网格在画布里的映射
// 才与前台整幅渲染**逐像素同源**(否则会差一个 <1px 的纵向缩放误差,
// 让拖拽期间的像素残差明显变大)。
if (pxPerValue > 0.0)
{
const double topExtend = static_cast<double>(marginY) / pxPerValue;
const double bottomRows =
static_cast<double>(bitmapHeight - marginY) - static_cast<double>(laneHeight);
const double bottomExtend = std::max(0.0, bottomRows) / pxPerValue;
m_lanes[static_cast<std::size_t>(laneIndex)].yMin -= bottomExtend;
m_lanes[static_cast<std::size_t>(laneIndex)].yMax += topExtend;
}
m_lanes[static_cast<std::size_t>(laneIndex)].rect =
gd::RectF(0.0f, 0.0f, static_cast<float>(bitmapWidth), static_cast<float>(bitmapHeight));
{
gd::Graphics g(layer.dc);
g.SetSmoothingMode(gd::SmoothingModeAntiAlias);
g.SetPixelOffsetMode(gd::PixelOffsetModeHalf);
g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit);
UiFillRect(g, m_area, m_theme->plotBg);
UiFillRect(g, m_area, m_theme->cardBg);
// 竖向网格刻度沿用**前台视界跨度**(而非宽窗口的跨度),否则竖网格线会只有一半。
// ⚠️ 同时把它作为竖向网格的**主刻步长**传给 `PaintLane`(见 `m_renderTimeStep`):
// 不传就会让图层用宽窗口跨度重算步长 → 竖向网格线与前台错位、部分整条丢失。
const double fgSpan = savedViewMax - savedViewMin;
const double timeStep = NiceStep((fgSpan > 0.0) ? fgSpan : 1.0, kTimeTickTarget);
m_renderTimeStep = timeStep;
std::vector<double> timeTicks;
BuildTicks(Tab().ViewMin(), Tab().ViewMax(), timeStep, timeTicks);
PaintLane(g, laneIndex, m_lanes[static_cast<std::size_t>(laneIndex)], timeTicks);
m_renderTimeStep = 0.0;
}
m_fastFrame = savedFastFrame;
m_renderWide = savedRenderWide;
m_wideTickSpan = savedWideTickSpan;
m_wideTickHeight = savedWideTickHeight;
m_renderTimeStep = savedRenderTimeStep;
m_stripRenderActive = savedStripActive;
m_stripX0 = savedStripX0;
m_stripX1 = savedStripX1;
// 恢复布局 / 视图 / 网格。
// ⚠️ 这里**不调 `BuildLaneVisuals()`**(同上面的原因:O(网格数) 的 `ComputeLaneRange`
// 在每次补条时都跑一遍是拖拽卡顿的主因)。`m_lanes` 在进入本函数时已经被完整拷贝,
// 直接拷回即可 —— 它本来就是正常布局下的正确结果(矩形/幅值/内容高度都在里面)。
m_area = savedArea;
m_plotRect = savedPlot;
Tab().ViewMin() = savedViewMin;
Tab().ViewMax() = savedViewMax;
m_lanes = savedLanes;
m_laneContentHeight = savedLaneContentHeight;
Tab().LaneScroll() = savedScroll;
layer.wideMin = wideMin;
layer.span = span;
layer.marginY = marginY;
// 纵向锚点 = 本次渲染的像素所对应的 `yMax`(由调用方按"改动了哪部分像素"决定;
// 横向补细条必须沿用图层原锚点,否则残余的纵向偏移会被静默清零 —— 见函数说明)。
layer.anchorYMax = anchorYMax;
layer.pxPerValue = pxPerValue;
layer.key = LaneLayerKey(laneIndex);
layer.valid = true;
m_layerRenderCount.fetch_add(1, std::memory_order_relaxed);
return true;
}
bool PlotView::RebuildWideBuffer()
{
const float plotWidth = m_plotRect.Width;
const float areaHeight = m_area.Height;
const double span = Tab().ViewMax() - Tab().ViewMin();
if (!(plotWidth > 8.0f) || !(areaHeight > 8.0f) || !(span > 0.0) || m_lanes.empty())
{
InvalidateWideBuffer();
return false;
}
// 只在**扩容 + 清空待重画队列**这一段持有 `m_layerMutex`;
// 随后释放,`RebuildLaneLayersParallel` 会各自重新获取(它内部还会取 `m_renderMutex`)。
// ⚠️ 如果这里一直持有 `m_layerMutex`,而并行重建内部再取一次 → `std::mutex` 不可重入 → 死锁。
{
std::lock_guard<std::recursive_mutex> renderLock(m_renderMutex);
std::lock_guard<std::mutex> layerLock(m_layerMutex);
m_layerFront.resize(m_lanes.size());
m_layerRebuildPending.clear();
}
// 全部网格统一用"当前视界居中"的映射(与 `BlitWideBuffer` 的逐网格 `wideMin` 相容)。
const double wideMin = LayerDefaultWideMin();
std::vector<int> allLanes;
allLanes.reserve(m_lanes.size());
for (std::size_t index = 0; index < m_lanes.size(); ++index)
{
allLanes.push_back(static_cast<int>(index));
}
// **并行**重建全部网格(各网格用隔离上下文,多核时约等于一块网格的耗时)。
RebuildLaneLayersParallel(allLanes, wideMin);
std::lock_guard<std::mutex> layerLock(m_layerMutex);
bool ok = true;
for (std::size_t index = 0; index < m_layerFront.size(); ++index)
{
if (!m_layerFront[index].valid)
{
ok = false;
}
}
return ok;
}
bool PlotView::PrefetchStepAllowed()
{
const std::uint64_t now = GetTickCount64();
if (now >= m_lastPrefetchRequestMs &&
(now - m_lastPrefetchRequestMs) < kPrefetchMinIntervalMs)
{
return false;
}
m_lastPrefetchRequestMs = now;
return true;
}
void PlotView::EnsureLaneLayersPrefetched()
{
if (m_lanes.empty() || !(m_plotRect.Width > 8.0f) || !(Tab().ViewMax() - Tab().ViewMin() > 0.0))
{
return;
}
if (!LayersAllocated())
{
// 图层数组与网格数量不符(首次绘制 / 增删网格):没有可复用的像素,只能整批建立。
RebuildWideBuffer();
return;
}
// ⚠️ 只登记「缺失 / 内容过期」的网格(`RollWideBuffer` 不做整块重画),
// 由 `RenderPendingLaneRebuilds` 的既有预算逐帧补齐。
RollWideBuffer();
}
void PlotView::WarmLaneLayersStep()
{
if (m_dragMode != PlotDragMode::NONE || m_lanes.empty() || m_fonts == nullptr)
{
return;
}
if (!(m_plotRect.Width > 8.0f) || !(Tab().ViewMax() - Tab().ViewMin() > 0.0))
{
return;
}
{
std::lock_guard<std::recursive_mutex> renderLock(m_renderMutex);
std::lock_guard<std::mutex> layerLock(m_layerMutex);
if (m_layerFront.size() > m_lanes.size())
{
// 网格变少:先把多出来的图层位图释放掉(`resize` 不会调用 `ReleaseLaneLayer`)。
for (std::size_t index = m_lanes.size(); index < m_layerFront.size(); ++index)
{
ReleaseLaneLayer(m_layerFront[index]);
}
}
if (m_layerFront.size() != m_lanes.size())
{
m_layerFront.resize(m_lanes.size());
}
}
// 登记"跨度 / 内容不一致"的网格。空闲时视界未变 ⇒ `RollWideBuffer` 只登记、不做滚动,
// 也不会走"位移超出画布 → 立刻整块重画"那条分支。
RollWideBuffer();
RenderPendingLaneRebuilds(1);
// ⚠️ 是否继续请求下一帧:
// * 还有**可视 / 预取带内**的网格待补 ⇒ 立刻继续(收敛快,见 `AnyVisibleLanePending`);
// * 只剩**离屏**网格 ⇒ 按 `kPrefetchMinIntervalMs` **限速**继续(慢速后台预热,
// 既不饿死动画定时器,又能把整批图层建好 —— 见 `PrefetchStepAllowed`)。
const bool morePending =
AnyVisibleLanePending() ||
(!m_layerRebuildPending.empty() && PrefetchStepAllowed());
if (morePending)
{
const HWND host = m_host;
if (host != nullptr)
{
PostMessageW(host, kMessagePlotDerivedReady, 0, 0);
}
}
}
void PlotView::RollWideBuffer()
{
const float plotWidth = m_plotRect.Width;
const double span = Tab().ViewMax() - Tab().ViewMin();
if (!(plotWidth > 8.0f) || !(span > 0.0))
{
return;
}
const int width = static_cast<int>(std::lround(plotWidth));
const int margin = LayerMarginX();
const int bitmapWidth = width + 2 * margin;
const double scale = static_cast<double>(width) / span;
std::lock_guard<std::recursive_mutex> renderLock(m_renderMutex);
std::lock_guard<std::mutex> layerLock(m_layerMutex);
if (m_layerFront.size() != m_lanes.size() || m_layerFront.empty())
{
return;
}
// 本帧还剩多少个「重新居中」额度(见 `kLayerRollsPerFrame`)。
int rollBudget = kLayerRollsPerFrame;
// ⚠️ **逐网格独立滚动**:每个图层有自己的时间映射(`layer.wideMin`),
// 因此位移必须按各自的锚点分别计算。曾用**一个全局锚点**给所有图层算同一个位移,
// 结果各网格被平移了不同的真实距离 → 网格之间时间错位、贴回的像素互相矛盾,
// 表现为严重的绘制错误与波形抖动。
//
// ⚠️ **本函数绝不重画整块图层**:内容过期 / 锚点太远的网格只登记到待重画队列
//(由 `RenderPendingLaneRebuilds` 按预算在后续帧补画),本帧仍尽量用现有像素跟随视界。
// 这样前台**永远不会因为"图层没准备好"而做整批重绘**(那正是"拖拽时前台卡顿"的根因)。
for (std::size_t index = 0; index < m_layerFront.size(); ++index)
{
LaneLayer& layer = m_layerFront[index];
const int laneIndex = static_cast<int>(index);
// 结构不可用(尺寸 / 跨度不符):像素的时间映射已经不对,既不能贴也不能滚。
const float laneHeight = m_lanes[index].rect.Height;
const int bitmapHeight =
static_cast<int>(std::ceil(laneHeight)) + 2 * LayerMarginY(laneHeight);
if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth ||
layer.height != bitmapHeight || std::fabs(layer.span - span) > 1.0e-12)
{
QueueLaneRebuild(laneIndex);
continue;
}
// 视界左端当前落在该图层的哪个 x 上;期望它等于 `margin`(左右余量对称)。
const double desired = (Tab().ViewMin() - layer.wideMin) * scale;
const int shift = static_cast<int>(std::llround(desired - static_cast<double>(margin)));
// 内容(外观 / 网格几何 / 幅值**跨度** / 可见性…)与当前状态不一致。
const bool contentStale = (layer.key != LaneLayerKey(laneIndex));
// ⚠️⚠️ **不在可视区(含预取带)内的网格一律不做像素级滚动**:
// 它们的像素当前根本不会被贴出来,滚了也看不见;而每次补条都要重跑
// `BuildLaneVisuals()`(O(网格数))—— 253 个网格时这是拖拽卡顿的主因。
// 只登记待重画(可视网格会被 `RenderPendingLaneRebuilds` 优先处理),
// 等它们真的要进入视区时再按新视界重建(期间由 `BlitWideBuffer` 的底衬兜底)。
if (!LaneVisuallyRelevant(laneIndex, 1))
{
if (contentStale)
{
QueueLaneRebuild(laneIndex);
}
continue;
}
// 纵向:幅值**平移**不改内容,只改贴图偏移(与横向同构)。
int vOffset = 0;
if (layer.pxPerValue > 0.0)
{
vOffset = static_cast<int>(
std::llround((layer.anchorYMax - m_lanes[index].yMax) * layer.pxPerValue));
}
// ⚠️⚠️ **"位移太大"绝不允许【作废图层】**(用户 2026-10-18 报告:
// 快速拖动中键时**所有网格的信号一起消失**,因为一旦作废就要等拖拽结束才重建)。
// 关键认识:`BlitWideBuffer` 按 `(ViewMin - layer.wideMin) * scale` 算源偏移,
// 只要视界仍落在位图覆盖范围内(`|shift| <= margin`),**不滚动也画得完全正确**。
// 因此这里分三种情形:
// * `|shift| <= driftLimit`(位图仍完整覆盖视界)⇒ **什么都不做**(免费);
// * 超出 ⇒ 向中心滚,但**每帧最多滚 `kLayerRollMaxStepPixels`**(把补条代价摊开),
// 剩余位移继续由贴图偏移承担 ⇒ **永远不会空白**;
// * 真的跳到画布之外(`|shift| >= bitmapWidth`)⇒ 才作废 + 登记待重画
// (那是“点选信号跳转”这类整屏换地方的情形,本来就要重画)。
const int driftLimit = std::max(8, margin - 24);
const int vOffsetAbs = std::abs(vOffset);
if (vOffsetAbs >= layer.height)
{
// 纵向已超出画布(画布高约等于 2×网格高):像素映射不再可用。
layer.valid = false;
QueueLaneRebuild(laneIndex);
continue;
}
if (contentStale)
{
QueueLaneRebuild(laneIndex);
}
// ------------------------------------------------------------------
// 纵向(幅值)滚动:与横向**完全同构**——内容整体上下移动若干行,
// 再只补画新暴露的那一条(整宽 × 行数)。
//
// ⚠️ **必须先做纵向再做横向**:横向补条由 `RenderLaneLayerRegion` 渲染,
// 而该函数按传入的**纵向锚点**渲染细条;若先补横向,那时锚点还没跟上,
// 补出来的细条会带着一个错的纵向偏移(贴图后表现为波形错位/撕裂)。
// ⚠️ 没有这一步时:幅值一变就超出余量 → 每帧都排队**整块重画**(画布比可见网格高
// 3 倍,代价很大)→ 拖动时"抽搐";而且在重画完成前,`BlitWideBuffer` 只能贴出部分
// 行(其余留空)→ 网格内出现**空白条带**(用户报告的"绘制异常")。
//
// ⚠️ **滚动量必须量化**(`kLayerRollQuantum`):不足一个量子的偏移**不滚**,
// 交给贴图时的纵向偏移(`srcY`)处理。这样图层的画布映射在拖动过程中**保持稳定**,
// 折线缓存(键含幅值映射)得以命中;否则每帧滚 1~30 行会让折线每帧重算
// (实测同样的拖动:量化前 3~4ms 的滚动 + 10~14ms 整帧,量化后回到 ~5ms 量级)。
// 余量足够大(上下各 `marginY`),因此"不滚只偏移"绝不会超出画布。
// 上面已保证 |vOffset| < 画布高,所以这里的 BitBlt 高度恒为正。
// ------------------------------------------------------------------
if (std::abs(vOffset) >= kLayerRollQuantum)
{
const int roll = vOffset; // 正 = 内容整体上移 roll 行(窗口向下移动)
int stripY = 0;
int stripH = 0;
if (roll > 0)
{
// 内容上移:new[y] = old[y + roll],底部暴露 roll 行。
BitBlt(layer.dc, 0, 0, bitmapWidth, layer.height - roll, layer.dc, 0, roll, SRCCOPY);
stripY = layer.height - roll;
stripH = roll;
}
else
{
const int amount = -roll;
// 内容下移:new[y] = old[y - amount],顶部暴露 amount 行。
BitBlt(layer.dc, 0, amount, bitmapWidth, layer.height - amount, layer.dc, 0, 0,
SRCCOPY);
stripY = 0;
stripH = amount;
}
// 锚点随之移动(与横向 `layer.wideMin += shift / scale` 同一套记账方式)。
layer.anchorYMax -= static_cast<double>(roll) / layer.pxPerValue;
// 补画暴露的那一条(整宽)。锚点用**滚动后**的值 —— 该条与已滚动的像素同一映射。
RenderLaneLayerRegion(laneIndex, layer, 0, bitmapWidth, stripY, stripH, layer.wideMin,
layer.anchorYMax);
}
// ⚠️ **横向重新居中只在「快要失去覆盖」时才做**(见上方 `driftLimit` 的说明):
// `BlitWideBuffer` 按 `(ViewMin - layer.wideMin)` 算源偏移,因此视界小幅漂移
// **完全不需要滚动**(等价且免费)。只有漂移接近 `margin`(位图覆盖边界)时才需要
// 向中心滚回去,否则网格右侧/左侧会开始用底衬(看起来像“信号被切成两半”)。
if (std::abs(shift) <= driftLimit)
{
continue;
}
// ⚠️ **每帧只重居中有限的几个网格**(见 `kLayerRollsPerFrame`),且单次滚动量有上限
// (`kLayerRollMaxStepPixels`):不重居中也不会画错,超出部分继续由贴图偏移承担,
// 因此可以安全地分帧完成。
if (rollBudget <= 0)
{
continue;
}
--rollBudget;
const int maxStep = std::min(kLayerRollMaxStepPixels, bitmapWidth / 2);
const int roll = std::max(-maxStep, std::min(maxStep, shift));
// 整数像素自贴图平移:内容整体移动 `roll` 像素。
if (roll > 0)
{
// 内容整体左移 roll:new[x] = old[x + roll],右侧暴露 roll 列。
BitBlt(layer.dc, 0, 0, bitmapWidth - roll, layer.height, layer.dc, roll, 0, SRCCOPY);
}
else if (roll < 0)
{
const int amount = -roll;
BitBlt(layer.dc, amount, 0, bitmapWidth - amount, layer.height, layer.dc, 0, 0, SRCCOPY);
}
else
{
continue;
}
layer.wideMin += static_cast<double>(roll) / scale;
// 只补画新暴露的细条(每帧代价恒为 O(细条))。
// ⚠️ 细条必须用**该图层更新后的映射**渲染(传 `layer.wideMin`);若改用"当前视界"
// 的映射,细条与已平移的旧像素之间会有 ≤0.5px 的错位接缝 → 拖动时看起来在抖动。
// ⚠️ **纵向锚点必须沿用 `layer.anchorYMax`**(不能传当前 `yMax`):横向补条只改一条
// 竖带,其余像素仍在原锚点上;同时该函数按锚点渲染这条竖带,因此竖带与其余像素
// 纵向同源。若在这里把锚点重置为当前 `yMax`,残余的纵向偏移会被静默清零、而像素
// 并未上移 → 整个网格的波形纵向错位("垂直拖动后再横向平移出现纵向跳变")。
const int stripWidth = std::min(std::abs(roll), bitmapWidth - 1);
const int stripX = (roll > 0) ? (bitmapWidth - stripWidth) : 0;
RenderLaneLayerRegion(laneIndex, layer, stripX, stripWidth, 0, 0, layer.wideMin,
layer.anchorYMax);
}
}
bool PlotView::BlitWideBuffer(gd::Graphics& g, const std::vector<double>& timeTicks)
{
const float plotWidth = m_plotRect.Width;
const double span = Tab().ViewMax() - Tab().ViewMin();
if (!(plotWidth > 8.0f) || !(span > 0.0))
{
return false;
}
const int width = static_cast<int>(std::lround(plotWidth));
const int margin = LayerMarginX();
const int bitmapWidth = width + 2 * margin;
const double scale = static_cast<double>(width) / span;
/** 一次图层贴图(所有参数在释放图层锁之前算好,避免持锁期间做 GDI 绘制)。 */
struct LayerBlit
{
HDC dc = nullptr;
int srcX = 0;
int srcY = 0;
int destX = 0;
int destY = 0;
int width = 0;
int height = 0;
};
std::vector<LayerBlit> blits;
std::vector<int> backing; // 本帧拿不到(完整)图层像素的网格
blits.reserve(m_lanes.size());
backing.reserve(m_lanes.size());
{
std::lock_guard<std::mutex> lock(m_layerMutex);
if (m_layerFront.size() != m_lanes.size())
{
return false;
}
for (std::size_t index = 0; index < m_layerFront.size(); ++index)
{
const LaneLayer& layer = m_layerFront[index];
const gd::RectF& lane = m_lanes[index].rect;
const float top = std::max(lane.Y, m_plotRect.Y);
const float bottom = std::min(lane.Y + lane.Height, m_plotRect.Y + m_plotRect.Height);
if (bottom <= top)
{
continue; // 网格整体移出可视区
}
const int bandHeight = static_cast<int>(std::lround(bottom - top));
// 纵向映射是否仍然可用:图层高度必须等于"当前网格高 + 上下预渲染余量",
// 且时间跨度必须与当前视界一致(跨度不同则像素的时间映射已经不对)。
const int expectBitmapHeight =
static_cast<int>(std::ceil(lane.Height)) + 2 * LayerMarginY(lane.Height);
bool usable = (layer.valid && layer.dc != nullptr && layer.width == bitmapWidth &&
layer.height == expectBitmapHeight && layer.span > 0.0 &&
std::fabs(layer.span - span) <= 1.0e-12);
LayerBlit blit;
if (usable)
{
// ⚠️ **纵向偏移**:图层带「上下各半个网格高」的预渲染余量,幅值平移只改这里的
// 源行偏移(`anchorYMax` 与当前 `yMax` 之差换算成像素),不必重画图层 ——
// 这就是"纵向拖动与横向拖动同样是 O(1) 贴图"的关键。
int vOffset = 0;
if (layer.pxPerValue > 0.0)
{
vOffset = static_cast<int>(
std::llround((layer.anchorYMax - m_lanes[index].yMax) * layer.pxPerValue));
}
int srcY = static_cast<int>(std::lround(top - lane.Y)) + layer.marginY + vOffset;
int height = bandHeight;
int destTopOffset = 0;
// 超出画布的上下两端只贴**有像素的那一段**(其余由网格底衬补齐)。
if (srcY < 0)
{
destTopOffset = -srcY;
height -= destTopOffset;
srcY = 0;
}
if (srcY + height > layer.height)
{
height = layer.height - srcY;
}
// ⚠️ **局部贴图**:图层只覆盖"视界 ± 半屏",视界可能有一部分落在图层之外。
// 这里只贴**有像素的那一段**,其余由底衬补齐;整块跳过才是"网格瞬间变空"的直接原因。
const double subX = (Tab().ViewMin() - layer.wideMin) * scale;
int srcX = static_cast<int>(std::lround(subX));
int destOffset = 0; // 从视界左端起偏移多少像素开始贴
int blitWidth = width;
if (srcX < 0)
{
destOffset = -srcX;
blitWidth -= destOffset;
srcX = 0;
}
if (srcX + blitWidth > bitmapWidth)
{
blitWidth = bitmapWidth - srcX;
}
if (height > 0 && blitWidth > 0)
{
blit.dc = layer.dc;
blit.srcX = srcX;
blit.srcY = srcY;
blit.destX = static_cast<int>(std::lround(m_plotRect.X)) + destOffset;
blit.destY = static_cast<int>(std::lround(top)) + destTopOffset;
blit.width = blitWidth;
blit.height = height;
blits.push_back(blit);
// 横向 / 纵向只有**部分覆盖**:该网格的其余部分也要靠底衬补齐。
if (destOffset > 0 || blitWidth < width || height < bandHeight)
{
backing.push_back(static_cast<int>(index));
}
continue;
}
}
// ⚠️⚠️ **必须兜底补齐网格底衬,绝不能留白**:旧实现在这里直接 `continue`
//(网格空白)或用旧跨度像素 `StretchBlt` 拉伸占位(深度放大时把一小段像素放大
// 几十倍 → 整块网格变成一片模糊底色)—— 两者在用户眼里都是
// 「网格叠底消失,没有任何内容」。改为就地补画底衬(底色 + 网格线 + 分界线):
// 代价极低,可以每帧执行,因此**网格底衬永远不会空白/闪烁**(用户 2026-10-17 要求)。
backing.push_back(static_cast<int>(index));
}
}
if (blits.empty())
{
// ⚠️⚠️ **绝不能因为"本帧一个网格都贴不出图层像素"就退化成整幅重绘**
//(用户 2026-10-17 报告:滚动过渡动画在巨型信号处卡顿)。
// 这一情形在滚动到**从未构建过图层的区域**时必然出现(上百个网格时绝大多数
// 网格的图层都还没建立):旧实现在这里 `return false` ⇒ 落到 `Paint` 末尾的
// **整幅重绘** ⇒ 把**本帧全部可见网格的波形**(可能包含上千�万采样的巨型信号)
// 每帧重画一遍 —— 实测单帧 **20~35ms**,而手动拖滚动条只要 ~6ms。
// 正确行为:本帧只画**网格底衬**(廉价,见下方),波形等视界真正停下之后再按预算补
//(这正是用户要求的“没来得及画信号时就按只有网格叠底的状态继续滚动”)。
if (backing.empty())
{
// 连底衬都没得画(布局变化 / 图层数组与网格数不符)→ 交给整幅重绘兜底。
return false;
}
}
// ⚠️ 顺序:**先画网格底衬(GDI+),再取得 HDC 做 BitBlt** ——
// `g.GetHDC()` 之后再调用 GDI+ 绘制接口是未定义行为。
for (const int laneIndex : backing)
{
PaintLaneGridBacking(g, laneIndex, timeTicks);
}
HDC target = g.GetHDC();
if (target == nullptr)
{
return true;
}
for (const LayerBlit& blit : blits)
{
BitBlt(target, blit.destX, blit.destY, blit.width, blit.height, blit.dc, blit.srcX, blit.srcY,
SRCCOPY);
}
g.ReleaseHDC(target);
return true;
}
/**
* @brief 为单个网格绘制网格底衬(底色 + 横向/竖向网格线 + 网格分界线)。
*
* 复用 `PaintLane` 的网格部分(通过 `m_renderGridOnly` 跳过波形 / 散点 / 派生曲线 /
* Y 轴刻度 / 图例),因此底衬与整幅重绘**逐像素同源** —— 兜底帧与就绪帧之间不会出现
* 网格线跳变。它是"网格底衬永不空白"的唯一执行点(见 `BlitWideBuffer`)。
*/
bool PlotView::PaintLaneGridBacking(gd::Graphics& g, int laneIndex, const std::vector<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 minorLineColor = UiBlendOver(m_theme->cardBg, WithAlpha(m_theme->gridWeak, kMinorLineAlpha));
const std::uint32_t weakLineColor = m_theme->gridWeak | 0xFF000000u;
const std::uint32_t strongLineColor = m_theme->gridStrong | 0xFF000000u;
int yTickTarget = static_cast<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 在缓存贴图之后绘制。
const bool highlighted = (ref == haloRef);
if (highlighted)
{
// 拖拽光晕整条描边并进入静态场景缓存(拖拽期间不随鼠标变化,不必裁剪)。
PaintSignalHaloFor(g, resolved, lane, *cached, 0.0f, false);
}
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);
}
}
}
// 第二遍:把所有**派生预览曲线**统一绘制在**本网格全部原信号之上**。
// 渲染「宽缓冲区」时跳过:宽缓冲只存"基底"(网格/波形,不含派生曲线与装饰),
// 派生曲线改由前台叠加层绘制(见 `PaintLaneDerivedOverlays`),
// 这样拖派生参数滑块时基底不变、可复用宽缓冲像素。
// ⚠️ `m_renderDecorOnly`(把装饰单独画进透明缓存位图)时**也必须跳过**:
// 派生曲线是"随时间视界变化"的内容,若烘进装饰位图,贴回时会把旧视界的
// 预览曲线永久留在画面上。
if (!m_renderWide && !m_renderDecorOnly)
{
PaintLaneDerivedOverlays(g, laneIndex, lane);
}
// g.Restore 也会还原绘制状态,此处直接恢复图形状态即可。
g.Restore(waveSave);
}
// 网格分界线:网格之间不再留间隔,改用较重的横线明确分隔相邻网格。
// ⚠️ **渲染信号图层时跳过**:分界线锚定在网格的**屏幕位置**(`lane.rect.Y`),
// 而图层会被纵向偏移贴图(幅值平移)—— 若烘进图层,纵向拖动时它会跟着波形一起跑。
// 因此它在整幅重绘里由这里绘制,宽基底快路径则由 `PaintLaneDividers` 补画一次。
const float dividerY = lane.rect.Y;
if (!m_renderDecorOnly && !m_renderWide && dividerY >= m_plotRect.Y - 1.0f &&
dividerY <= m_plotRect.Y + m_plotRect.Height)
{
const float dividerSegments[4] = {lane.rect.X, dividerY, lane.rect.X + lane.rect.Width, dividerY};
const std::uint32_t dividerColor =
((laneIndex == 0) ? m_theme->axisLine : m_theme->borderStrong) | 0xFF000000u;
UiDrawSolidLines(g, gridClip, dividerSegments, 1, dividerColor,
(laneIndex == 0) ? 2 : static_cast<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
{
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;
}
const int saveState = g.Save();
g.SetClip(lane.rect, gd::CombineModeIntersect);
for (const SignalRef& ref : tabLane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible)
{
continue;
}
if (centerOn)
{
const Lane::EdgeCenterItem* centerItem = tabLane.FindEdgeCenterItem(ref);
if (centerItem != nullptr)
{
PaintMeanFilterCurve(g, resolved, lane, centerItem->points, centerItem->iterations);
}
}
if (meanFilterOn)
{
const Lane::MeanFilterItem* item = tabLane.FindMeanFilterItem(ref);
if (item != nullptr)
{
PaintMeanFilterCurve(g, resolved, lane, item->points, item->iterations);
}
}
if (weakPeakOn)
{
// 每个信号用自己的「增强等级 + 迭代次数」(与面板滑块、构建新信号完全一致)。
const Lane::WeakPeakItem* item = tabLane.FindWeakPeakItem(ref);
if (item != nullptr)
{
const int amountPercent =
std::max(kWeakPeakAmountMin, std::min(kWeakPeakAmountMax, item->amountPercent));
const int boostIterations = std::max(
kWeakPeakIterationsMin,
std::min(kWeakPeakIterationsMaxLimit, item->iterations));
PaintWeakPeakCurve(g, resolved, lane, amountPercent, boostIterations);
}
}
}
g.Restore(saveState);
}
void PlotView::PaintLaneLegend(gd::Graphics& g, int laneIndex, const LaneVisual& lane, float visibleTop,
float visibleBottom)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return;
}
// 图例固定在网格左上角:网格被滚动出可视区时整体跳过,避免文字溢出到工具栏。
const gd::RectF legendBand(lane.rect.X + kLegendPad * m_scale,
lane.rect.Y + kLegendPad * m_scale, lane.rect.Width,
kLegendHeight * m_scale);
if (!IsFullyVisible(legendBand, visibleTop, visibleBottom))
{
return;
}
const std::vector<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;
}
// 取值读数必须与画出来的波形一致:同样套用系数/绝对值/镜像。
value = ApplyDrawTransform(*resolved.style, value);
const float rawY = ValueToY(value, lane);
// 上下越界的数据点不再被丢弃:与左右越界一致,贴边绘制指示方向的三角。
// 上边界额外预留光标顶部把手的高度,避免数据点三角压住光标把手。
// 非 const:上下越界时下面还要把方向三角与取值标注纵向居中对齐。
DataMarker marker =
MakeDataMarker(rawX, rawY, pinX, clipTop, clipBottom, kCursorHandleReserve * m_scale);
const std::uint32_t signalColor = StyleColor(*resolved.style);
// 数据点:视界内为圆点,任一方向越界时改为贴边三角。
// **先算好取值标注、再画数据点**:上下越界时的方向三角需要与标注
// 纵向居中对齐,而标注的位置要经过避让处理才能最终确定。
const float radius = 3.4f * m_scale;
// 取值标注:底色用信号色,文字/描边用随信号配色线性相关的高对比度配色。
std::wstring text = FormatValue(value);
if (showDelta)
{
double otherValue = 0.0;
if (SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style, otherTime,
interpolate, otherValue))
{
otherValue = ApplyDrawTransform(*resolved.style, otherValue);
const double delta = value - otherValue;
// 增量 = 右侧光标取值 − 左侧光标取值,包在圆括号里并按差值显式给出正负号。
text += L" (";
text += (delta < 0.0) ? L"-" : L"+";
text += FormatValue(std::fabs(delta));
text += L")";
}
}
const float textWidth = UiMeasureTextWidth(m_fonts->monoSmall, text) + 10.0f * m_scale;
const float textHeight = 17.0f * m_scale;
// 左右越界时三角贴在视界内侧、尖端朝外;数值标注必须放在三角的**内侧**
// 并留出 `kMarkerTriangleGap` 的空白,否则三角会像"多出来的一个尖角"
// (用户报告的"三角形与数值显示部分没有间隔")。
float labelLeft;
if (marker.offLeft)
{
labelLeft = marker.x + kMarkerTriangleGap * m_scale;
}
else if (marker.offRight)
{
labelLeft = marker.x - kMarkerTriangleGap * m_scale - textWidth;
}
else
{
labelLeft = pinX + radius + 4.0f * m_scale;
if (labelLeft + textWidth > m_plotRect.X + m_plotRect.Width - 2.0f * m_scale)
{
labelLeft = pinX - radius - 4.0f * m_scale - textWidth;
}
}
labelLeft = std::max(
m_plotRect.X + 2.0f * m_scale,
std::min(labelLeft, m_plotRect.X + m_plotRect.Width - textWidth - 2.0f * m_scale));
float labelTop = marker.y - textHeight * 0.5f;
labelTop = std::max(clipTop + 1.0f * m_scale,
std::min(labelTop, clipBottom - textHeight - 1.0f * m_scale));
gd::RectF labelRect(labelLeft, labelTop, textWidth, textHeight);
// 多个信号的取值标注可能落在同一位置(数据点重合或过近):
// 依次向下(再向上)避让已占用的标注,保证不会互相覆盖。
PlaceCursorLabel(labelRect, labelTop, textHeight, clipTop, clipBottom);
// 越界方向三角与最终落位的取值标注纵向居中对齐
// (用户明确要求“超过上下视界时三角需要与显示的数值居中对齐”;
// 左右越界时同样对齐,避免三角与数值错行、看起来不协调)。
if (marker.offUp || marker.offDown || marker.offLeft || marker.offRight)
{
marker.y = labelRect.Y + labelRect.Height * 0.5f;
}
PaintDataMarker(g, marker, radius, signalColor, m_theme->cardBg);
const UiInk ink = UiContrastInk(signalColor);
UiFillRoundRect(g, labelRect, 3.0f * m_scale, WithAlpha(signalColor, 0xF0));
DrawLabelOutline(g, labelRect, 3.0f * m_scale, ink.outline);
UiText(g, text, m_fonts->monoSmall, labelRect, ink.text, TextAlign::CENTER);
// 「边沿计数(中心轴)」/「均值滤波」/「弱峰调整」开启时,纵向光标还要额外标注
// **派生曲线**在该时刻的取值(它不是信号,因此单独标注,不并入上面的取值文案)。
// 三条曲线只是曲线来源不同,标注规则完全一致,故共用同一段绘制逻辑。
// ⚠️ 曲线只取「可见区间 ∪ 两个光标时刻 ± 保护带」的窗口(与绘制共用缓存),
// 因此拖动光标/拖动参数滑块都不会触发整条曲线的重算。
const auto paintDerivedCapsule = [&](DerivedCurveKind kind, int points,
int iterations, int amountPercent,
const std::wstring& suffix)
{
const int sampleIndex = FindNearestSample(*resolved.axis, time);
if (sampleIndex < 0)
{
return;
}
const int otherIndex =
showDelta ? FindNearestSample(*resolved.axis, otherTime) : -1;
// 本函数**只需要光标所在采样点处的派生取值**(以及双光标另一端的取值),
// 因此把请求窗口收窄到「需要的那几个下标」即可,均值滤波半径造成的取值
// 依赖由 `CurveSpanFor` 自身的保护带负责覆盖。
//
// ⚠️ 绝不能把窗口扩成「光标 ↔ 视界边缘」的并集:光标停在视界之外(例如
// 平移视界把光标甩到很远,或光标远离当前缩放区间)时会得到一个上千万采样的
// 巨大窗口,超过缓存上限后走一次性缓冲 → **每帧重算整段滤波**,
// 实测单帧 300ms+(用户报告的"平移/拖动光标异常卡顿"的直接原因)。
std::size_t begin = static_cast<std::size_t>(sampleIndex);
std::size_t end = begin + 1;
if (otherIndex >= 0)
{
const std::size_t other = static_cast<std::size_t>(otherIndex);
begin = std::min(begin, other);
end = std::max(end, other + 1);
}
std::size_t spanBegin = 0;
const std::vector<float>* curvePtr =
CurveSpanFor(kind, *resolved.data, points, iterations, amountPercent, begin,
end, spanBegin, true);
if (curvePtr == nullptr)
{
return;
}
const auto curveValueAt = [curvePtr, spanBegin](int index) -> double
{
if (index < 0 || static_cast<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 double derivedValue = curveValueAt(sampleIndex);
if (!std::isfinite(derivedValue))
{
return;
}
const float rawCenterY = ValueToY(derivedValue, lane);
DataMarker centerMarker = MakeDataMarker(
rawX, rawCenterY, pinX, clipTop, clipBottom, kCursorHandleReserve * m_scale);
// 派生数据点的配色与原始信号一致(便于一一对应);
// 但**始终按线段模式**绘制(不跟随原信号的散点模式)。
const std::uint32_t centerColor = StyleColor(*resolved.style);
// 取值文本:数值在前,尾缀「(中心轴)/(均值滤波)」;双光标的右侧光标
// 还要附上增量,形如「(+0.0008 中心轴)」(参考常规信号的增量显示)。
std::wstring centerText = FormatValue(derivedValue);
centerText += L"(";
if (showDelta)
{
const double otherDerived = curveValueAt(otherIndex);
if (std::isfinite(otherDerived))
{
const double delta = derivedValue - otherDerived;
centerText += (delta < 0.0) ? L"-" : L"+";
centerText += FormatValue(std::fabs(delta));
centerText += L" ";
}
}
centerText += suffix;
centerText += L")";
const float centerTextWidth =
UiMeasureTextWidth(m_fonts->monoSmall, centerText) + 10.0f * m_scale;
// 与常规取值标注同一规则:左右越界时标注要让开外侧的方向三角。
float centerLeft;
if (centerMarker.offLeft)
{
centerLeft = centerMarker.x + kMarkerTriangleGap * m_scale;
}
else if (centerMarker.offRight)
{
centerLeft = centerMarker.x - kMarkerTriangleGap * m_scale - centerTextWidth;
}
else
{
centerLeft = pinX + radius + 4.0f * m_scale;
if (centerLeft + centerTextWidth >
m_plotRect.X + m_plotRect.Width - 2.0f * m_scale)
{
centerLeft = pinX - radius - 4.0f * m_scale - centerTextWidth;
}
}
centerLeft = std::max(
m_plotRect.X + 2.0f * m_scale,
std::min(centerLeft,
m_plotRect.X + m_plotRect.Width - centerTextWidth - 2.0f * m_scale));
float centerTop = centerMarker.y - textHeight * 0.5f;
centerTop = std::max(clipTop + 1.0f * m_scale,
std::min(centerTop, clipBottom - textHeight - 1.0f * m_scale));
gd::RectF centerRect(centerLeft, centerTop, centerTextWidth, textHeight);
PlaceCursorLabel(centerRect, centerTop, textHeight, clipTop, clipBottom);
// 与原始信号同一规则:越界方向三角与取值标注纵向居中对齐。
if (centerMarker.offUp || centerMarker.offDown || centerMarker.offLeft ||
centerMarker.offRight)
{
centerMarker.y = centerRect.Y + centerRect.Height * 0.5f;
}
PaintDataMarker(g, centerMarker, radius, centerColor, m_theme->cardBg);
// 颜色样式与原始信号一致(同底色/同描边/同文字配色)。
const UiInk centerInk = UiContrastInk(centerColor);
UiFillRoundRect(g, centerRect, 3.0f * m_scale, WithAlpha(centerColor, 0xE0));
DrawLabelOutline(g, centerRect, 3.0f * m_scale, centerInk.outline);
UiText(g, centerText, m_fonts->monoSmall, centerRect, centerInk.text,
TextAlign::CENTER);
};
if (m_tab->Lanes()[laneIndex].edgeCenter)
{
// 只标注被勾选(即真正绘制了曲线)的信号。
const Lane::EdgeCenterItem* centerItem =
m_tab->Lanes()[laneIndex].FindEdgeCenterItem(ref);
if (centerItem != nullptr)
{
paintDerivedCapsule(DerivedCurveKind::MEAN_FILTER, centerItem->points,
centerItem->iterations, 0, L"中心轴");
}
}
if (m_tab->Lanes()[laneIndex].meanFilter)
{
// 只标注被勾选(即真正绘制了预览曲线)的信号。
const Lane::MeanFilterItem* meanItem =
m_tab->Lanes()[laneIndex].FindMeanFilterItem(ref);
if (meanItem != nullptr)
{
paintDerivedCapsule(DerivedCurveKind::MEAN_FILTER, meanItem->points,
meanItem->iterations, 0, L"均值滤波");
}
}
if (m_tab->Lanes()[laneIndex].weakPeak)
{
// 弱峰调整曲线:同样是只标注被勾选的信号,尾缀为「弱峰调整」。
const Lane::WeakPeakItem* boostItem =
m_tab->Lanes()[laneIndex].FindWeakPeakItem(ref);
if (boostItem != nullptr)
{
const int amountPercent =
std::max(kWeakPeakAmountMin, std::min(kWeakPeakAmountMax, boostItem->amountPercent));
const int iterations = std::max(
kWeakPeakIterationsMin,
std::min(kWeakPeakIterationsMaxLimit, boostItem->iterations));
paintDerivedCapsule(DerivedCurveKind::WEAK_PEAK, 0, iterations, amountPercent,
L"弱峰调整");
}
}
}
}
}
// ---- 底部:时间读数 + 双光标差值 ----
if (m_editTarget == PlotEditTarget::CURSOR_TIME ||
m_editTarget == PlotEditTarget::CURSOR_SPAN)
{
PaintEditField(g);
return;
}
for (int pass = 0; pass < cursorCount; ++pass)
{
const int which = doubleMode ? iterations[pass] : 1;
const gd::RectF labelRect = CursorLabelRect(which);
if (labelRect.Width <= 0.0f)
{
continue;
}
const bool highlighted = (m_cursorHover && m_hoverCursor == which);
const std::uint32_t color = CursorColor();
bool offRight = false;
const bool offView = CursorOffViewSide(which, offRight);
if (offView)
{
PaintCursorOffViewTip(g, labelRect, offRight, color);
}
UiFillRoundRect(g, labelRect, 3.0f * m_scale,
highlighted ? color : WithAlpha(color, 0xE0));
const UiInk ink = UiContrastInk(color);
DrawLabelOutline(g, labelRect, 3.0f * m_scale, ink.outline);
UiText(g, FormatCursorTime(which == 2 ? Tab().CursorTimeAlt() : Tab().CursorTime()),
m_fonts->monoSmall, labelRect, ink.text, TextAlign::CENTER);
}
// 双光标差值:显示在两条时间读数中间(时间轴上方;过近时落到主时间轴上)。
if (doubleMode)
{
bool insideAxis = false;
const gd::RectF spanRect = CursorSpanRect(insideAxis);
m_cursorSpanRect = spanRect;
if (spanRect.Width > 0.0f && spanRect.Height > 0.0f)
{
// 样式与光标时间读数完全一致(同底色/同描边/同文字配色)。
const std::uint32_t color = CursorColor();
// 双光标同时越界到同一侧时,差值标签也在视界之外 → 同样画方向三角。
bool offRight1 = false, offRight2 = false;
const bool off1 = CursorOffViewSide(1, offRight1);
const bool off2 = CursorOffViewSide(2, offRight2);
if (off1 && off2 && offRight1 == offRight2)
{
PaintCursorOffViewTip(g, spanRect, offRight1, color);
}
UiFillRoundRect(g, spanRect, 3.0f * m_scale,
m_spanHover ? color : WithAlpha(color, 0xE0));
const UiInk ink = UiContrastInk(color);
DrawLabelOutline(g, spanRect, 3.0f * m_scale, ink.outline);
UiText(g, FormatSpanText(CursorSpan()), m_fonts->monoSmall, spanRect, ink.text,
TextAlign::CENTER);
}
}
else
{
m_cursorSpanRect = gd::RectF();
}
}
PlotView::EdgePanelLayout PlotView::EdgePanelLayoutFor(int laneIndex, EdgePanelKind kind) const
{
EdgePanelLayout layout;
if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 ||
laneIndex >= static_cast<int>(m_tab->Lanes().size()) ||
laneIndex >= static_cast<int>(m_lanes.size()))
{
return layout;
}
const Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
const bool centerAxis = (kind == EdgePanelKind::CENTER);
const bool meanFilter = (kind == EdgePanelKind::MEAN);
const bool weakPeak = (kind == EdgePanelKind::PEAK);
// 「均值滤波」与「弱峰调整」共用同一套「逐条目 + 底部按钮」排布。
const bool itemized = (centerAxis || meanFilter || weakPeak);
const bool hasButton = (meanFilter || weakPeak);
const bool enabled = meanFilter ? lane.meanFilter
: (weakPeak ? lane.weakPeak
: (centerAxis ? lane.edgeCenter : lane.edgeCount));
const bool open = meanFilter ? lane.meanFilterPanel.open
: (weakPeak ? lane.weakPeakPanel.open
: (centerAxis ? lane.edgeCenterPanel.open : lane.edgePanel.open));
if (!enabled || !open)
{
return layout;
}
int rowCount = 0;
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data != nullptr && resolved.visible)
{
++rowCount;
}
}
if (rowCount <= 0)
{
return layout;
}
layout.rows = rowCount;
const float width = EdgePanelWidth(laneIndex, kind);
const float titleHeight = kEdgePanelTitleHeight * m_scale;
const float rowHeight = kEdgePanelRowHeight * m_scale;
const float pad = kEdgePanelPad * m_scale;
// 「中心轴边沿计数」「均值滤波」「弱峰调整」共用同一套「逐条目」排布,
// 因此不再有独立的底部编辑行;只有「边沿计数」保留两行文本式排版。
const float buttonHeight = hasButton ? (kEdgePanelButtonHeight * m_scale) : 0.0f;
const float rowsPerSignal = meanFilter ? 1.0f : 2.0f;
// 逐条目面板:每个条目 = 第一排(信号名 + 复选框)
// + 参数部分(**只在该条目被勾选时才占位**,因此面板高度随勾选数量动态变化):
// * MEAN / CENTER:一行「均值点数 X + 迭代次数 N」输入框(中心轴再多一行统计结果);
// * PEAK:两行滑块(调整幅度 % / 迭代次数 N),每个信号各有一套自己的参数。
const bool itemHasParams = (centerAxis || meanFilter || weakPeak);
const float weakPeakSliderRow = kWeakPeakSliderRowHeight * m_scale;
float meanRowHeight = 0.0f;
float itemsHeight = 0.0f;
float height = 0.0f;
if (itemized)
{
meanRowHeight = kEdgePanelMeanRowHeight * m_scale;
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || !resolved.visible)
{
continue;
}
itemsHeight += meanRowHeight;
const bool checked = meanFilter ? lane.MeanFilterEnabled(ref)
: (weakPeak ? lane.WeakPeakEnabled(ref)
: lane.EdgeCenterEnabled(ref));
if (checked && itemHasParams)
{
if (weakPeak)
{
itemsHeight += weakPeakSliderRow * 2.0f;
}
else
{
itemsHeight += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale;
if (centerAxis)
{
itemsHeight += kEdgePanelMeanRowHeight * m_scale;
}
}
}
}
// 底部按钮行:上方与条目之间、下方与面板下边界之间各留一个 `pad`,
// 与面板的左右内边距完全一致(原先按钮直接贴着下边框,看起来很不协调)。
height = titleHeight + pad + itemsHeight + pad + buttonHeight + (hasButton ? pad : 0.0f);
}
else
{
height = titleHeight + pad + static_cast<float>(rowCount) * rowHeight * rowsPerSignal + pad;
}
// 首次使用(位置为负)时自动放到绘图区**右上角**,并按网格在**当前可见网格中的序数**
// 依次错开,避免完全重叠。
// ⚠️ 旧实现用 `laneIndex` 错开 —— 上百个网格时 `laneIndex` 很大,
// `16 + laneIndex*28` 早就超过绘图区高度、被下面的夹取推到**右下角**
//(用户 2026-10-17 报告:“在当前范围内的网格开启实验性功能,面板首次出现在右下角”)。
// 改用**可见序数**:它的量级只有一屏的网格数(约 20),再夹到“面板上沿不超过绘图区
// 中间偏下”以内,于是面板始终停**右上区域**,同时保留“依次错开”的观感。
const double storedX = meanFilter ? lane.meanFilterPanel.x
: (weakPeak ? lane.weakPeakPanel.x
: (centerAxis ? lane.edgeCenterPanel.x : lane.edgePanel.x));
const double storedY = meanFilter ? lane.meanFilterPanel.y
: (weakPeak ? lane.weakPeakPanel.y
: (centerAxis ? lane.edgeCenterPanel.y : lane.edgePanel.y));
int openPanelOrdinal = 0;
{
// ⚠️ 错开量用「**当前真正显示出来的面板**中排在本面板之前的个数」:
// * 第一个打开的面板 ⇒ 0 ⇒ 正好贴在右上角(用户 2026-10-17 要求“应该在右上角”);
// * 后续面板依次下移 28 逻辑像素,避免与已打开的面板完全重叠;
// * 遍历顺序固定为(网格下标 → 面板种类)⇒ 每个面板的槽位稳定、与帧无关。
// ⚠️ 不能用 `laneIndex`(上百个网格时会把面板推到右下角);
// ⚠️ 也不能只看 `*Panel.open`:该标志**默认就是 true**,必须同时看功能是否启用
// (与 `EdgePanelLayoutFor` 的 `enabled && open` 判定一致)。
const auto shown = [](const Lane& lane, EdgePanelKind panelKind) -> bool
{
switch (panelKind)
{
case EdgePanelKind::COUNT:
return lane.edgeCount && lane.edgePanel.open;
case EdgePanelKind::CENTER:
return lane.edgeCenter && lane.edgeCenterPanel.open;
case EdgePanelKind::MEAN:
return lane.meanFilter && lane.meanFilterPanel.open;
case EdgePanelKind::PEAK:
return lane.weakPeak && lane.weakPeakPanel.open;
}
return false;
};
static const EdgePanelKind kOrder[4] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER,
EdgePanelKind::MEAN, EdgePanelKind::PEAK};
int openSeen = 0;
bool found = false;
for (std::size_t lane = 0; lane < m_lanes.size() && !found; ++lane)
{
const Lane& tabLane = Tab().Lanes()[lane];
for (const EdgePanelKind panelKind : kOrder)
{
if (!shown(tabLane, panelKind))
{
continue;
}
if (lane == static_cast<std::size_t>(laneIndex) && panelKind == kind)
{
found = true;
break;
}
++openSeen;
}
}
openPanelOrdinal = found ? openSeen : 0;
}
// 错开量夹到“面板下沿不超过绘图区 45% 高度”以内 ⇒ 首现位置永远在右上区域。
const float maxStagger = std::max(0.0f, m_plotRect.Height * 0.45f - height - 16.0f * m_scale);
const float stagger =
std::min(static_cast<float>(openPanelOrdinal) * 28.0f * m_scale, maxStagger);
float left = (storedX >= 0.0)
? static_cast<float>(storedX)
: (m_plotRect.X + m_plotRect.Width - width - 16.0f * m_scale);
float top = (storedY >= 0.0) ? static_cast<float>(storedY)
: (m_plotRect.Y + 16.0f * m_scale + stagger);
// 面板始终限制在绘图区内(用户明确要求"可以在右侧的整个绘制区域拖动")。
left = std::max(m_plotRect.X + 2.0f * m_scale,
std::min(left, m_plotRect.X + m_plotRect.Width - width - 2.0f * m_scale));
top = std::max(m_plotRect.Y + 2.0f * m_scale,
std::min(top, m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale));
layout.panel = gd::RectF(left, top, width, height);
// 只有标题栏可以拖拽;✕ 位于标题栏右端,命中时优先按“关闭按钮”处理。
layout.title = gd::RectF(left, top, width, titleHeight);
const float closeSize = titleHeight;
layout.close = gd::RectF(left + width - closeSize, top, closeSize, closeSize);
if (itemized)
{
const float buttonRowHeight = kEdgePanelButtonHeight * m_scale;
if (hasButton)
{
// 底部按钮与面板下边界之间留出与左右、与内容相同的 `pad` 间距
//(原先按钮行直接贴着下边框,视觉上与其它元素不协调);
// 面板高度已含这份 pad,因此这里只上移按钮行即可。
layout.button = gd::RectF(left + pad, top + height - pad - buttonRowHeight,
std::max(0.0f, width - pad * 2.0f), buttonRowHeight);
}
// 逐信号条目:第一排 = 名称 + 复选框(复选框靠右);
// 参数部分:MEAN / CENTER = 一行两个输入框(中心轴再多一行统计结果);
// PEAK = 两行滑块(每个信号各有自己的等级与迭代次数)。
const float checkSize = kEdgePanelMeanCheckSize * m_scale;
const float fieldHeight = kEdgePanelMeanEditHeight * m_scale;
const float halfWidth = std::max(40.0f, (width - pad * 2.0f - 8.0f * m_scale) * 0.5f);
// 滑块行列宽(与 `EdgePanelWidth` 使用同一组算式,避免布局与宽度计算漂移)。
const float weakPeakLabelWidth = WeakPeakLabelColumnWidth(m_fonts->uiSmall);
const float weakPeakValueWidth = WeakPeakValueColumnWidth(m_fonts->monoSmall);
const float weakPeakResetSize = kWeakPeakResetButtonSize * m_scale;
const float weakPeakResetGap = kWeakPeakResetGap * m_scale;
const float weakPeakTrackHeight = kWeakPeakTrackHeight * m_scale;
const float weakPeakTrackLeft = left + pad + weakPeakLabelWidth + 6.0f * m_scale;
const float weakPeakTrackWidth =
std::max(10.0f * m_scale,
(left + width - pad - weakPeakValueWidth - weakPeakResetGap -
weakPeakResetSize - 6.0f * m_scale) -
weakPeakTrackLeft);
float rowY = top + titleHeight + pad;
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || !resolved.visible)
{
continue;
}
layout.rowRefs.push_back(ref);
layout.rowChecks.push_back(gd::RectF(
left + width - pad - checkSize, rowY + (meanRowHeight - checkSize) * 0.5f, checkSize,
checkSize));
const bool checked = meanFilter ? lane.MeanFilterEnabled(ref)
: (weakPeak ? lane.WeakPeakEnabled(ref)
: lane.EdgeCenterEnabled(ref));
if (weakPeak)
{
// 每个被勾选的信号各有两行滑块:调整幅度(-400% – +400%)与迭代次数(1 … 上限)。
if (checked)
{
const float levelRowTop = rowY + meanRowHeight;
const float iterRowTop = levelRowTop + weakPeakSliderRow;
const gd::RectF levelTrack(
weakPeakTrackLeft,
levelRowTop + (weakPeakSliderRow - weakPeakTrackHeight) * 0.5f,
weakPeakTrackWidth, weakPeakTrackHeight);
const gd::RectF iterTrack(
weakPeakTrackLeft,
iterRowTop + (weakPeakSliderRow - weakPeakTrackHeight) * 0.5f,
weakPeakTrackWidth, weakPeakTrackHeight);
layout.rowLevelTracks.push_back(levelTrack);
layout.rowLevelValues.push_back(gd::RectF(
levelTrack.X + levelTrack.Width + 6.0f * m_scale, levelRowTop,
weakPeakValueWidth, weakPeakSliderRow));
layout.rowIterTracks.push_back(iterTrack);
layout.rowIterValues.push_back(gd::RectF(
iterTrack.X + iterTrack.Width + 6.0f * m_scale, iterRowTop,
weakPeakValueWidth, weakPeakSliderRow));
// 每行滑块右侧的**复位按钮**(与数值列同一右边界,保证两行上下对齐)。
const float resetX = levelTrack.X + levelTrack.Width + 6.0f * m_scale +
weakPeakValueWidth + weakPeakResetGap;
layout.rowLevelResets.push_back(
gd::RectF(resetX, levelRowTop + (weakPeakSliderRow - weakPeakResetSize) * 0.5f,
weakPeakResetSize, weakPeakResetSize));
layout.rowIterResets.push_back(
gd::RectF(resetX, iterRowTop + (weakPeakSliderRow - weakPeakResetSize) * 0.5f,
weakPeakResetSize, weakPeakResetSize));
}
else
{
layout.rowLevelTracks.push_back(gd::RectF());
layout.rowLevelValues.push_back(gd::RectF());
layout.rowLevelResets.push_back(gd::RectF());
layout.rowIterTracks.push_back(gd::RectF());
layout.rowIterValues.push_back(gd::RectF());
layout.rowIterResets.push_back(gd::RectF());
}
layout.rowPoints.push_back(gd::RectF());
layout.rowIterations.push_back(gd::RectF());
layout.rowStats.push_back(gd::RectF());
rowY += meanRowHeight;
if (checked)
{
rowY += weakPeakSliderRow * 2.0f;
}
continue;
}
if (checked && itemHasParams)
{
const float editTop = rowY + meanRowHeight;
layout.rowPoints.push_back(
gd::RectF(left + pad, editTop, halfWidth, fieldHeight));
layout.rowIterations.push_back(
gd::RectF(left + pad + halfWidth + 8.0f * m_scale, editTop, halfWidth, fieldHeight));
if (centerAxis)
{
layout.rowStats.push_back(
gd::RectF(left + pad, editTop + fieldHeight, width - pad * 2.0f,
kEdgePanelMeanRowHeight * m_scale));
}
else
{
layout.rowStats.push_back(gd::RectF());
}
}
else
{
layout.rowPoints.push_back(gd::RectF());
layout.rowIterations.push_back(gd::RectF());
layout.rowStats.push_back(gd::RectF());
}
rowY += meanRowHeight;
if (checked && itemHasParams)
{
rowY += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale;
if (centerAxis)
{
rowY += kEdgePanelMeanRowHeight * m_scale;
}
}
}
}
return layout;
}
void PlotView::PaintDataMarker(gd::Graphics& g, const DataMarker& marker, float radius,
std::uint32_t fillColor, std::uint32_t ringColor) const
{
gd::SolidBrush ring(ToGdiColor(ringColor));
gd::SolidBrush fill(ToGdiColor(fillColor));
const float arm = kMarkerTriangleArm * m_scale; /**< 三角“半宽/半高”(垂直于朝向的半边长)。 */
const float depth = kMarkerTriangleDepth * m_scale; /**< 三角沿朝向的深度(与光标把手同比例)。 */
// 越界三角:**尖端朝外**(指向数据点所在的方向,即视界之外),底边在网格内侧;
// 三角外接矩形在横/纵两个方向都与锚点**居中**(上下越界时因此与取值标注纵向居中)。
// 旧实现方向是反的(上越界画下三角、左越界画右三角),而且内三角按“平移”而非
// “向质心收缩”构造,填充色会从三角尖端戳出来。
const auto paintTriangle = [&](const gd::PointF* outer)
{
g.FillPolygon(&ring, outer, 3);
const float centerX = (outer[0].X + outer[1].X + outer[2].X) / 3.0f;
const float centerY = (outer[0].Y + outer[1].Y + outer[2].Y) / 3.0f;
// 按质心等比收缩得到内三角:几何上恒在外三角内部,描边宽度也就均匀了。
const float shrink = 0.58f;
gd::PointF inner[3];
for (int index = 0; index < 3; ++index)
{
inner[index] = gd::PointF(centerX + (outer[index].X - centerX) * shrink,
centerY + (outer[index].Y - centerY) * shrink);
}
g.FillPolygon(&fill, inner, 3);
};
// 左右越界:三角指向左/右(视界之外)。
if (marker.offLeft || marker.offRight)
{
const bool offLeft = marker.offLeft;
const float tipX = offLeft ? (marker.x - depth) : (marker.x + depth);
const float baseX = marker.x;
gd::PointF outer[3];
outer[0] = gd::PointF(baseX, marker.y - arm);
outer[1] = gd::PointF(baseX, marker.y + arm);
outer[2] = gd::PointF(tipX, marker.y);
paintTriangle(outer);
return;
}
// 上下越界:三角指向上/下(视界之外),外接矩形以 marker.y 为纵向中心
// → 与取值标注的纵向中心对齐。
if (marker.offUp || marker.offDown)
{
const bool offUp = marker.offUp;
const float half = depth * 0.5f;
const float tipY = offUp ? (marker.y - half) : (marker.y + half);
const float baseY = offUp ? (marker.y + half) : (marker.y - half);
gd::PointF outer[3];
outer[0] = gd::PointF(marker.x - arm, baseY);
outer[1] = gd::PointF(marker.x + arm, baseY);
outer[2] = gd::PointF(marker.x, tipY);
paintTriangle(outer);
return;
}
// 视界内:常规圆点。
g.FillEllipse(&ring, marker.x - radius - 1.2f * m_scale, marker.y - radius - 1.2f * m_scale,
(radius + 1.2f * m_scale) * 2.0f, (radius + 1.2f * m_scale) * 2.0f);
g.FillEllipse(&fill, marker.x - radius, marker.y - radius, radius * 2.0f, radius * 2.0f);
}
void PlotView::PaintDerivedCurve(gd::Graphics& g, const ResolvedSignal& resolved,
const LaneVisual& lane, const std::vector<float>& curve,
std::size_t curveBegin) 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)),
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));
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);
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) const
{
if (resolved.data == nullptr || resolved.axis == nullptr || resolved.style == nullptr)
{
return;
}
// 只取「可见区间 ± 保护带」的窗口(见 `CurveSpanFor`):拖参数滑块不会重算整条曲线。
// 与「边沿计数(中心轴)」共用同一段绘制逻辑
//(同色相高对比度的派生配色 + 同透明度),只是曲线本身按多次迭代滤波计算。
const std::size_t count = std::min(resolved.axis->time.size(), resolved.data->values.size());
if (count == 0)
{
return;
}
// ⭐ 预览曲线一律按**整条信号**计算(`[0, count]`),与「构建为新信号」使用的
// `ComputeMovingAverageIterated(整段 values, ...)` 完全相同,因此预览与构建结果
// **逐位一致**(用户明确要求"预览显示效果要和构建为新信号一致")。
// 整段窗口超过异步门槛时由后台线程计算,绘制线程本帧用已算好的旧曲线顶替,
// 因此不会阻塞交互;小信号同步计算。
std::size_t spanBegin = 0;
const std::vector<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);
}
void PlotView::PaintWeakPeakCurve(gd::Graphics& g, const ResolvedSignal& resolved,
const LaneVisual& lane, int amountPercent, int iterations) const
{
if (resolved.data == nullptr || resolved.axis == nullptr || resolved.style == nullptr)
{
return;
}
const std::size_t count = std::min(resolved.axis->time.size(), resolved.data->values.size());
if (count == 0)
{
return;
}
// 预览曲线一律按**整条信号**计算(与「构建为新信号」逐位一致),详见 `PaintMeanFilterCurve`。
std::size_t spanBegin = 0;
const std::vector<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));
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);
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>(Tab().CursorMode()));
mixDouble(Tab().CursorTime());
mixDouble(Tab().CursorTimeAlt());
mix(EdgeRangeActive() ? 1ull : 0ull);
mixDouble(EdgeRangeMin());
mixDouble(EdgeRangeMax());
// 跟随鼠标的叠加层(悬停光晕 / 图例半高亮 / 信号拖拽虚影 / 提示动画)位置随时在变:
// 只要它存在,就把所有面板缓存作废,避免它的残影被一起缓存进面板像素。
const bool mouseDrivenOverlay =
m_hoverSignal.IsValid() || m_hoverLegend.IsValid() ||
m_dragMode == PlotDragMode::MOVE_SIGNAL || m_dragMode == PlotDragMode::RUBBER_SELECT ||
HintNeedsAnimation();
mix(mouseDrivenOverlay ? 1ull : 0ull);
if (mouseDrivenOverlay)
{
mix(static_cast<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)刻意不入面板指纹**:面板内容(标题、条目、滑块、
// 参数与边沿统计)都不依赖网格的幅值区间;而「拖拽平移的纵向分量」会每帧小幅
// 改动它 —— 一旦计入,拖拽期间**全部**悬浮面板都会每帧重画一遍
//(实测 20 个面板:竖向拖拽 167ms/帧 vs 横向 38ms/帧,正是用户报告的
// "左右流畅、上下/斜向卡顿")。代价只是面板不透明底(95.7%)背后那 4%
// 透出的底层像素可能旧一帧,与既有取舍一致(实测最大色差 ≤ 29 级)。
}
if (m_tab != nullptr)
{
const std::vector<Lane>& lanes = m_tab->Lanes();
for (const Lane& lane : lanes)
{
mixFloat(static_cast<float>(lane.edgePanel.x));
mixFloat(static_cast<float>(lane.edgePanel.y));
mixFloat(static_cast<float>(lane.edgeCenterPanel.x));
mixFloat(static_cast<float>(lane.edgeCenterPanel.y));
mixFloat(static_cast<float>(lane.meanFilterPanel.x));
mixFloat(static_cast<float>(lane.meanFilterPanel.y));
mixFloat(static_cast<float>(lane.weakPeakPanel.x));
mixFloat(static_cast<float>(lane.weakPeakPanel.y));
mix(lane.edgePanel.open ? 1ull : 0ull);
mix(lane.edgeCenterPanel.open ? 1ull : 0ull);
mix(lane.meanFilterPanel.open ? 1ull : 0ull);
mix(lane.weakPeakPanel.open ? 1ull : 0ull);
mix(lane.edgeValueValid ? 1ull : 0ull);
mixDouble(lane.edgeValue);
}
}
// ⚠️ **面板层序也是"全局"状态**:层序变化(点击某个面板把它置顶)会让"谁盖住谁"改变,
// 因此必须让所有面板缓存失效、按新层序整层重画(否则贴回的旧像素仍是旧层叠关系)。
for (const PanelRef& ref : m_panelOrder)
{
mix(static_cast<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.edit);
mixRect(layout.button);
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);
}
const Lane& lane = Tab().Lanes()[static_cast<std::size_t>(laneIndex)];
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));
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 进去:推进会让键变化 ⇒
// 面板重绘 ⇒ 显示随进度更新,直到扫完(扫完后数字不再变,重新命中缓存)。
if (kind == EdgePanelKind::COUNT)
{
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr ||
!resolved.visible)
{
continue; // 与绘制时的可见性判定保持一致
}
std::size_t rising = 0;
std::size_t falling = 0;
bool complete = true;
EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, lane.edgeValue,
EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete);
mix(static_cast<std::uint64_t>(rising));
mix(static_cast<std::uint64_t>(falling));
mix(complete ? 1ull : 0ull);
}
}
else if (kind == EdgePanelKind::CENTER)
{
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex)
{
if (rowIndex >= layout.rowStats.size() || layout.rowStats[rowIndex].Width <= 0.0f)
{
continue; // 与绘制时的条件一致:只有这一排才显示统计数字
}
const SignalRef& ref = layout.rowRefs[rowIndex];
const ResolvedSignal resolved = ResolveSignal(ref);
const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref);
if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr ||
item == nullptr)
{
continue;
}
std::size_t rising = 0;
std::size_t falling = 0;
bool complete = true;
EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, item->points,
item->iterations, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(),
rising, falling, complete);
mix(static_cast<std::uint64_t>(rising));
mix(static_cast<std::uint64_t>(falling));
mix(complete ? 1ull : 0ull);
}
}
// 弱峰调整的滑块行矩形(每信号两行)。
for (const gd::RectF& rect : layout.rowLevelTracks)
{
mixRect(rect);
}
for (const gd::RectF& rect : layout.rowIterTracks)
{
mixRect(rect);
}
for (const gd::RectF& rect : layout.rowIterValues)
{
mixRect(rect);
}
// 弱峰调整每行滑块右侧的复位按钮(悬停高亮依赖它的精确矩形)。
for (const gd::RectF& rect : layout.rowLevelResets)
{
mixRect(rect);
}
for (const gd::RectF& rect : layout.rowIterResets)
{
mixRect(rect);
}
// 鼠标落在面板内时,✕ / 复选框 / 滑块 / 底部按钮的悬停态都依赖精确坐标。
const float margin = 6.0f * m_scale;
const gd::RectF hot(layout.panel.X - margin, layout.panel.Y - margin,
layout.panel.Width + margin * 2.0f,
layout.panel.Height + margin * 2.0f);
if (UiPointInRect(hot, static_cast<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;
}
void PlotView::PaintEdgeCursor(gd::Graphics& g, const std::vector<LaneVisual>& lanes)
{
if (m_tab == nullptr || m_dataSet == nullptr)
{
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);
// ---- 横向光标(线与两端把手 + 左侧幅值读数)----
for (std::size_t index = 0; index < count; ++index)
{
Lane& tabLane = tabLanes[index];
if (!tabLane.edgeCount)
{
continue;
}
const LaneVisual& visual = lanes[index];
const float clipTop = std::max(visual.rect.Y, m_plotRect.Y);
const float clipBottom =
std::min(visual.rect.Y + visual.rect.Height, m_plotRect.Y + m_plotRect.Height);
if (clipBottom <= clipTop)
{
continue;
}
// 首次绘制时把横向光标落在可见幅值范围的中点。
if (!tabLane.edgeValueValid)
{
tabLane.edgeValue = (visual.yMin + visual.yMax) * 0.5;
tabLane.edgeValueValid = true;
}
const float lineY =
std::max(clipTop, std::min(ValueToY(tabLane.edgeValue, visual), clipBottom));
const float left = visual.rect.X;
const float right = visual.rect.X + visual.rect.Width;
// 横线与纵向光标同色同宽;两端各画一个尖端朝内的三角把手。
UiDrawLine(g, left, lineY, right, lineY, color, 1.4f * m_scale);
const float arm = 5.0f * m_scale;
gd::SolidBrush handle(ToGdiColor(color));
gd::PointF leftHandle[3];
leftHandle[0] = gd::PointF(left, lineY - arm);
leftHandle[1] = gd::PointF(left, lineY + arm);
leftHandle[2] = gd::PointF(left + arm * 1.4f, lineY);
g.FillPolygon(&handle, leftHandle, 3);
gd::PointF rightHandle[3];
rightHandle[0] = gd::PointF(right, lineY - arm);
rightHandle[1] = gd::PointF(right, lineY + arm);
rightHandle[2] = gd::PointF(right - arm * 1.4f, lineY);
g.FillPolygon(&handle, rightHandle, 3);
// 幅值读数:靠网格左侧、纵向居中于横线(与纵向光标的时间读数同款胶囊)。
// **必须让开左端的三角把手**(把手横向占 [left, left + arm*1.4]),
// 否则数值胶囊会盖住把手(用户明确要求向右挪一些)。
const std::wstring text = FormatValue(tabLane.edgeValue);
const float textWidth = std::max(
56.0f * m_scale, UiMeasureTextWidth(m_fonts->monoSmall, text) + 16.0f * m_scale);
const float textHeight = 18.0f * m_scale;
const float labelLeft = left + (arm * 1.4f + 4.0f * m_scale);
const float labelTop =
std::max(clipTop + 1.0f * m_scale, std::min(lineY - textHeight * 0.5f,
clipBottom - textHeight - 1.0f * m_scale));
const gd::RectF labelRect(labelLeft, labelTop, textWidth, textHeight);
UiFillRoundRect(g, labelRect, 3.0f * m_scale, WithAlpha(color, 0xE0));
DrawLabelOutline(g, labelRect, 3.0f * m_scale, ink.outline);
UiText(g, text, m_fonts->monoSmall, labelRect, ink.text, TextAlign::CENTER);
// 幅值超出视界时(横线被贴边显示),在数值胶囊的**上方/下方**画一个方向三角,
// 与常规纵向光标数据点越界的处理方式一致;三角与胶囊**水平居中对齐**。
const float rawLineY = ValueToY(tabLane.edgeValue, visual);
const bool offUp = (rawLineY < clipTop - 0.5f);
const bool offDown = (rawLineY > clipBottom + 0.5f);
if (offUp || offDown)
{
const float tipArm = 5.0f * m_scale;
const float depth = tipArm * 1.4f;
const float centerX = labelRect.X + labelRect.Width * 0.5f;
// 三角整体贴在胶囊外侧(上方越界 → 胶囊上方的向上三角)。
const float anchorY = offUp ? (labelRect.Y - depth * 0.5f - 1.0f * m_scale)
: (labelRect.Y + labelRect.Height + depth * 0.5f + 1.0f * m_scale);
gd::PointF outer[3];
if (offUp)
{
outer[0] = gd::PointF(centerX - tipArm, anchorY + depth * 0.5f);
outer[1] = gd::PointF(centerX + tipArm, anchorY + depth * 0.5f);
outer[2] = gd::PointF(centerX, anchorY - depth * 0.5f);
}
else
{
outer[0] = gd::PointF(centerX - tipArm, anchorY - depth * 0.5f);
outer[1] = gd::PointF(centerX + tipArm, anchorY - depth * 0.5f);
outer[2] = gd::PointF(centerX, anchorY + depth * 0.5f);
}
gd::SolidBrush tipBrush(ToGdiColor(color));
g.FillPolygon(&tipBrush, outer, 3);
}
}
// ---- 统计浮动面板(最后绘制,覆盖在波形之上)----
// 「边沿计数」「边沿计数(中心轴)」「均值滤波」「弱峰调整」共用同一套样式,
// 仅标题、内容行与底部按钮不同。
//
// 面板是**静态**内容:只有被拖动 / 悬停 / 参数变化的那一个真正重画,
// 其余直接贴上一帧缓存好的像素(15 个面板全画一遍实测约 33ms,
// 而贴图 <1ms —— 拖动弱峰调整滑块时的主要卡顿就来自这一层)。
// 「上升沿 / 下降沿」两列排版(列起点固定 ⇒ 多条信号的文字严格垂直对齐)。
// ⚠️ 未完成时**两个数字后面都带 `…`**(旧实现只在整行末尾追加一次,
// 视觉上只跟在“下降沿”的数字后面;用户明确要求为“上升沿”也补上这个尾缀)。
const auto paintCounts = [this, &g](const gd::RectF& rect, std::size_t rising,
std::size_t falling, bool complete)
{
if (EdgeCountsPending(rising, falling, complete))
{
UiText(g, L"统计中…", m_fonts->monoSmall, rect, m_theme->textMuted, TextAlign::LEFT);
return;
}
const float gap = kEdgePanelStatColumnGap * m_scale;
const float columnWidth = std::max(10.0f, (rect.Width - gap) * 0.5f);
UiText(g, EdgeRisingText(rising, complete), m_fonts->monoSmall,
gd::RectF(rect.X, rect.Y, columnWidth, rect.Height), m_theme->textMuted,
TextAlign::LEFT);
UiText(g, EdgeFallingText(falling, complete), m_fonts->monoSmall,
gd::RectF(rect.X + columnWidth + gap, rect.Y, columnWidth, rect.Height),
m_theme->textMuted, TextAlign::LEFT);
};
std::vector<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;
// ⚠️ **按层序从下往上绘制**(与命中测试、像素缓存的层叠顺序完全一致):
// `m_panelOrder` 队尾 = 最上层 = 最后绘制。
SyncPanelOrder();
for (const PanelRef& panelRef : m_panelOrder)
{
const EdgePanelKind kind = static_cast<EdgePanelKind>(panelRef.kind);
const std::size_t index = static_cast<std::size_t>(panelRef.laneIndex);
const bool centerAxis = (kind == EdgePanelKind::CENTER);
const bool meanFilter = (kind == EdgePanelKind::MEAN);
const bool weakPeak = (kind == EdgePanelKind::PEAK);
// 「均值滤波」与「弱峰调整」共用「逐条目 + 底部按钮」排布。
const bool itemized = (centerAxis || meanFilter || weakPeak);
const bool hasButton = (meanFilter || weakPeak);
{
if (index >= count)
{
continue;
}
const Lane& tabLane = tabLanes[index];
const EdgePanelLayout layout = EdgePanelLayoutFor(static_cast<int>(index), kind);
const gd::RectF& panel = layout.panel;
if (panel.Width <= 0.0f || panel.Height <= 0.0f)
{
continue;
}
// 面板自身的内容指纹;命中缓存时直接贴图,本帧不再重画这个面板。
// ⚠️ 缓存像素里含**它与它下面的一切**(面板背后的波形、被它遮住的其他面板),
// 因此只要有一个面板重画,所有「在它之后绘制、且与它重叠」的面板也必须重画,
// 否则后画者会把旧像素重新盖回去(面板默认位置本来就常常叠在一起)。
const float cacheMargin = 4.0f * m_scale;
const gd::RectF cachedRect(panel.X - cacheMargin, panel.Y - cacheMargin,
panel.Width + cacheMargin * 2.0f,
panel.Height + cacheMargin * 2.0f);
bool forcedDirty = false;
for (const gd::RectF& dirtyRect : dirtyRects)
{
if (dirtyRect.IntersectsWith(cachedRect))
{
forcedDirty = true;
break;
}
}
std::uint64_t slotKey = 0;
const std::size_t slotIndex = nextSlots.size();
if (panelCacheReady)
{
slotKey = PanelSlotKey(static_cast<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)
{
HDC target = g.GetHDC();
if (target != nullptr)
{
BitBlt(target, hit->x0, hit->y0, hit->x1 - hit->x0, hit->y1 - hit->y0,
m_panelDc, hit->x0, hit->y0, SRCCOPY);
g.ReleaseHDC(target);
}
nextSlots.push_back(*hit);
continue;
}
}
}
nextSlots.push_back(PanelSlot());
dirtyRects.push_back(cachedRect);
const float titleHeight = kEdgePanelTitleHeight * m_scale;
const float rowHeight = kEdgePanelRowHeight * m_scale;
const float pad = kEdgePanelPad * m_scale;
// 面板主体 + 标题栏。
// 投影响用两张大面积的半透明圆角矩形模拟,代价不低;交互期间(快速帧)省略它,
// 让拖动参数滑块时面板重绘不再被投影拖累(松手后的全质量帧会补回投影)。
if (!m_fastFrame)
{
UiFillRoundRect(g, gd::RectF(panel.X + 1.0f * m_scale, panel.Y + 2.0f * m_scale,
panel.Width, panel.Height),
5.0f * m_scale, 0x14000000u);
}
UiFillRoundRect(g, panel, 5.0f * m_scale, WithAlpha(m_theme->cardBg, 0xF4));
UiDrawRoundRect(g, panel, 5.0f * m_scale, m_theme->borderStrong, 1.0f);
UiFillRoundRect(g, gd::RectF(panel.X, panel.Y, panel.Width, titleHeight), 5.0f * m_scale,
WithAlpha(m_theme->accent, 0x2A));
// 标题:与左侧信号面板一致的 “GX” 徽标。
const float badgeWidth = kLaneBadgeWidth * m_scale;
const float badgeHeight = kLaneBadgeHeight * m_scale;
const gd::RectF badgeRect(panel.X + pad, panel.Y + (titleHeight - badgeHeight) * 0.5f,
badgeWidth, badgeHeight);
UiFillRoundRect(g, badgeRect, badgeHeight * 0.5f, m_theme->cardBg);
UiDrawRoundRect(g, badgeRect, badgeHeight * 0.5f, m_theme->border, 1.0f);
UiText(g, L"G" + std::to_wstring(index + 1), m_fonts->monoSmall, badgeRect,
m_theme->text, TextAlign::CENTER);
const std::wstring title =
meanFilter ? std::wstring(L"均值滤波")
: (weakPeak ? std::wstring(kWeakPeakTitle)
: ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") +
EdgeRangeSuffix()));
UiText(g, title, m_fonts->uiSmall,
gd::RectF(badgeRect.X + badgeRect.Width + 8.0f * m_scale, panel.Y,
panel.Width - badgeRect.Width - 8.0f * m_scale - titleHeight,
titleHeight),
m_theme->textStrong, TextAlign::LEFT);
// 关闭按钮 ✕:悬停时高亮提示(鼠标光标本身不改变样式)。
const gd::RectF& closeRect = layout.close;
const bool closeHot =
(m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) &&
UiPointInRect(closeRect, static_cast<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 (itemized)
{
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex)
{
const SignalRef& ref = layout.rowRefs[rowIndex];
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.style == nullptr)
{
continue;
}
const std::uint32_t signalColor = StyleColor(*resolved.style);
const gd::RectF& checkRect = layout.rowChecks[rowIndex];
const bool checked = meanFilter ? tabLane.MeanFilterEnabled(ref)
: (weakPeak ? tabLane.WeakPeakEnabled(ref)
: tabLane.EdgeCenterEnabled(ref));
// 名称:第一排左侧,避开右侧复选框(超长时省略)。
const float nameWidth =
std::min(std::max(20.0f, checkRect.X - (panel.X + pad) - 6.0f * m_scale),
kEdgePanelNameMaxWidth * m_scale);
UiText(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth),
m_fonts->uiSmall,
gd::RectF(panel.X + pad, checkRect.Y - 3.0f * m_scale, nameWidth,
checkRect.Height + 6.0f * m_scale),
signalColor, TextAlign::LEFT);
UiDrawCheckBox(g, checkRect, checked ? 1 : 0, 3.0f * m_scale);
if (!checked)
{
continue;
}
// MEAN / CENTER 的平滑参数条目(弱峰调整用自己的 `WeakPeakItem`)。
const Lane::MeanFilterItem* item =
(meanFilter || centerAxis)
? (meanFilter ? tabLane.FindMeanFilterItem(ref)
: tabLane.FindEdgeCenterItem(ref))
: nullptr;
// 「弱峰调整」:每个被勾选的信号各有两行**滑块**
//(调整幅度 -200% – +200% / 迭代次数 1 … 该信号自定义上限)。
// 迭代次数行的右侧数值可点击 → 整行变为输入框以修改滑块上限。
if (weakPeak)
{
const Lane::WeakPeakItem* peakItem = tabLane.FindWeakPeakItem(ref);
if (peakItem != nullptr && rowIndex < layout.rowLevelTracks.size() &&
layout.rowLevelTracks[rowIndex].Width > 0.0f)
{
// 两行滑块的「标签」始终绘制(编辑上限时也要能看出在改哪一项);
// 「轨道 + 数值」在编辑上限时改由 `PaintEditField` 画成输入框。
const auto paintSliderLabel = [&](const gd::RectF& track,
const wchar_t* label)
{
const float labelColumn =
WeakPeakLabelColumnWidth(m_fonts->uiSmall);
const float labelLeft =
track.X - 6.0f * m_scale - labelColumn;
UiText(g, label, m_fonts->uiSmall,
gd::RectF(labelLeft, track.Y - 8.0f * m_scale,
labelColumn, track.Height + 16.0f * m_scale),
m_theme->textMuted, TextAlign::LEFT);
};
const auto paintSliderBody = [&](const gd::RectF& track, double ratio,
const std::wstring& valueText,
bool active)
{
UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale);
UiText(g, valueText, m_fonts->monoSmall,
gd::RectF(track.X + track.Width + 6.0f * m_scale,
track.Y - 8.0f * m_scale,
WeakPeakValueColumnWidth(m_fonts->monoSmall),
track.Height + 16.0f * m_scale),
m_theme->textStrong, TextAlign::LEFT);
};
// 每行滑块右侧的**复位控件**(与「系数/偏移」二级面板里的还原图标同一套绘制):
// 悬停时高亮 + 手型光标,点击把该行参数恢复为默认值(幅度 0 / 迭代 1 且上限回初始值)。
const auto paintSliderReset = [&](const gd::RectF& rect)
{
if (rect.Width <= 0.0f)
{
return;
}
const bool hot =
(m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) &&
UiPointInRect(rect, static_cast<float>(m_mouseX),
static_cast<float>(m_mouseY));
if (hot)
{
UiFillRoundRect(
g, gd::RectF(rect.X - 1.0f * m_scale, rect.Y - 1.0f * m_scale,
rect.Width + 2.0f * m_scale,
rect.Height + 2.0f * m_scale),
3.0f * m_scale, WithAlpha(m_theme->accent, 0x33));
}
PaintPanelResetIcon(g, rect,
hot ? m_theme->accent : m_theme->textMuted, m_scale);
};
const gd::RectF& levelTrack = layout.rowLevelTracks[rowIndex];
const gd::RectF& iterTrack = layout.rowIterTracks[rowIndex];
paintSliderLabel(levelTrack, kWeakPeakLevelLabel);
paintSliderLabel(iterTrack, kWeakPeakIterationsSliderLabel);
const bool levelActive = (m_dragMode == PlotDragMode::PEAK_LEVEL) &&
(m_peakDragLane == static_cast<int>(index)) &&
(m_peakDragRef == ref);
const double levelRatio =
(static_cast<double>(std::max(kWeakPeakAmountMin,
std::min(kWeakPeakAmountMax,
peakItem->amountPercent))) /
(100.0 * kWeakPeakSliderScale) +
1.0) *
0.5;
paintSliderBody(levelTrack, levelRatio,
(peakItem->amountPercent > 0 ? L"+" : L"") +
std::to_wstring(peakItem->amountPercent) + L"%",
levelActive);
paintSliderReset(layout.rowLevelResets[rowIndex]);
// 迭代次数滑块:量程 = 1 … 该信号自己的上限。
const int peakMax = std::max(kWeakPeakIterationsMin,
std::min(kWeakPeakIterationsMaxLimit,
peakItem->maxIterations));
const int peakIter = std::max(
kWeakPeakIterationsMin, std::min(peakMax, peakItem->iterations));
const double iterRatio =
(peakMax > kWeakPeakIterationsMin)
? (static_cast<double>(peakIter - kWeakPeakIterationsMin) /
static_cast<double>(peakMax - kWeakPeakIterationsMin))
: 0.0;
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);
if (!editingMax)
{
paintSliderBody(iterTrack, iterRatio, std::to_wstring(peakIter),
iterActive);
paintSliderReset(layout.rowIterResets[rowIndex]);
}
}
}
// MEAN / CENTER:第二排的两个参数输入框。
if (!weakPeak)
{
const int shownPoints = (item != nullptr) ? item->points : 5;
const int shownOrder = (item != nullptr) ? item->iterations : 1;
const PlotEditTarget pointsTarget =
meanFilter ? PlotEditTarget::MEAN_FILTER_POINTS
: PlotEditTarget::EDGE_CENTER_POINTS;
const PlotEditTarget orderTarget =
meanFilter ? PlotEditTarget::MEAN_FILTER_ITERATIONS
: PlotEditTarget::EDGE_CENTER_ITERATIONS;
const auto paintField = [&](const gd::RectF& rect, const wchar_t* label,
int value, PlotEditTarget target)
{
if (rect.Width <= 0.0f)
{
return;
}
const float labelWidth = UiMeasureTextWidth(m_fonts->uiSmall, label);
UiText(g, label, m_fonts->uiSmall,
gd::RectF(rect.X, rect.Y, labelWidth, rect.Height), m_theme->textMuted,
TextAlign::LEFT);
const float fieldWidth =
std::max(kEdgePanelEditFieldWidth * m_scale * 0.75f,
UiMeasureTextWidth(m_fonts->monoSmall, std::to_wstring(value)) +
18.0f * m_scale);
const float fieldLeft = rect.X + rect.Width - fieldWidth;
const float fieldHeight = rect.Height - 4.0f * m_scale;
const gd::RectF fieldRect(fieldLeft, rect.Y + 2.0f * m_scale, fieldWidth,
fieldHeight);
const bool editingThis = (m_editTarget == target) &&
(m_editLane == static_cast<int>(index)) &&
(m_editMeanRef == ref);
if (editingThis)
{
UiDrawEditField(g, fieldRect, m_edit, m_fonts->monoSmall, m_scale);
}
else
{
UiFillRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->pressed);
UiDrawRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->border, 1.0f);
UiText(g, std::to_wstring(value), m_fonts->monoSmall, fieldRect,
m_theme->textStrong, TextAlign::CENTER);
}
};
paintField(layout.rowPoints[rowIndex], L"均值点数 X", shownPoints, pointsTarget);
paintField(layout.rowIterations[rowIndex], kWeakPeakIterationsLabel, shownOrder,
orderTarget);
}
// 第三排(中心轴):该信号在当前参数下的上升沿/下降沿统计结果。
if (centerAxis && rowIndex < layout.rowStats.size() &&
layout.rowStats[rowIndex].Width > 0.0f)
{
const gd::RectF& statsRect = layout.rowStats[rowIndex];
const int statsPoints = (item != nullptr) ? item->points : 5;
const int statsOrder = (item != nullptr) ? item->iterations : 1;
std::size_t rising = 0;
std::size_t falling = 0;
bool statsComplete = false;
EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style,
statsPoints, statsOrder, EdgeRangeActive(),
EdgeRangeMin(), EdgeRangeMax(), rising, falling,
statsComplete);
// 两列排版(列起点固定)⇒ 多条信号的“上升沿 / 下降沿”垂直对齐。
paintCounts(statsRect, rising, falling, statsComplete);
}
}
}
float rowY = panel.Y + titleHeight + pad;
for (const SignalRef& ref : tabLane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible)
{
continue;
}
if (itemized)
{
// 逐条目绘制已在上面完成。
continue;
}
const std::uint32_t signalColor = StyleColor(*resolved.style);
const float textWidth = panel.Width - pad * 2.0f;
// 名称:超长时省略(面板宽度被 `kEdgePanelNameMaxWidth` 限制,不会把面板顶得很长)。
UiText(g,
UiEllipsize(resolved.data->name, m_fonts->uiSmall,
std::min(textWidth, kEdgePanelNameMaxWidth * m_scale)),
m_fonts->uiSmall, gd::RectF(panel.X + pad, rowY, textWidth, rowHeight),
signalColor, TextAlign::LEFT);
std::size_t rising = 0;
std::size_t falling = 0;
bool complete = true;
EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, tabLane.edgeValue,
EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling,
complete);
paintCounts(gd::RectF(panel.X + pad, rowY + rowHeight, textWidth, rowHeight), rising,
falling, complete);
rowY += rowHeight * 2.0f;
}
// 逐条目面板底部按钮行:「构建为新信号」(悬停高亮)。
if (hasButton)
{
const gd::RectF& buttonRect = layout.button;
if (buttonRect.Width > 0.0f)
{
const gd::RectF inner(buttonRect.X, buttonRect.Y + 2.0f * m_scale,
buttonRect.Width, buttonRect.Height - 4.0f * m_scale);
const bool hot =
(m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) &&
UiPointInRect(inner, static_cast<float>(m_mouseX),
static_cast<float>(m_mouseY));
UiFillRoundRect(g, inner, 4.0f * m_scale,
hot ? WithAlpha(m_theme->accent, 0x3A) : m_theme->cardBg);
UiDrawRoundRect(g, inner, 4.0f * m_scale, m_theme->accent, 1.2f);
UiText(g, kBuildSignalLabel, m_fonts->uiSmall, inner, m_theme->accent,
TextAlign::CENTER);
}
}
// 面板画完了:把它刚画进后备缓冲区的像素(连同缓存键)存进面板图层,
// 下一帧只要键不变就直接贴图。矩形外的 4 逻辑像素边距用于容纳投影。
PanelSlot& stored = nextSlots[slotIndex];
stored.laneIndex = static_cast<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;
}
}
}
}
}
}
m_panelSlots = std::move(nextSlots);
m_panelGlobalKey = panelGlobalKey;
m_panelValid = panelCacheReady;
}
void PlotView::PaintEditField(gd::Graphics& g)
{
const gd::RectF fieldRect = EditFieldRect();
if (fieldRect.Width <= 0.0f || fieldRect.Height <= 0.0f)
{
return;
}
// 编辑态:强调色外框 + 选区高亮 + 插入符,首次键入即整体替换(与常规输入框一致)。
UiDrawEditField(g, fieldRect, m_edit, m_fonts->monoSmall, m_scale);
// 「迭代次数 N 上限」:输入框**以空内容开始**,未输入任何内容时以灰色提示当前上限值。
// 提示文案 = “输入上限(当前N)”(用户明确要求:旧实现只显示一个孤立的数字,缺少说明)。
if (m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX && m_edit.Text().empty())
{
const int currentMax = m_peakEditCurrentMax > 0 ? m_peakEditCurrentMax
: kWeakPeakIterationsDefaultMax;
const std::wstring hint = L"输入上限(当前" + std::to_wstring(currentMax) + L")";
const float hintLeft = fieldRect.X + 11.0f * m_scale;
UiTextAtClipped(g, hint, m_fonts->monoSmall, hintLeft,
gd::RectF(hintLeft, fieldRect.Y,
std::max(0.0f, fieldRect.X + fieldRect.Width -
hintLeft - 4.0f * m_scale),
fieldRect.Height),
m_theme->textMuted, fieldRect);
}
}
void PlotView::PaintSignalInteraction(gd::Graphics& g)
{
if (m_tab == nullptr || m_dataSet == nullptr || m_lanes.empty())
{
return;
}
// 拖拽中:虚影跟随鼠标纵向位移,同时在目标网格内高亮落点。
if (m_dragMode == PlotDragMode::MOVE_SIGNAL && m_dragSignal.IsValid())
{
const ResolvedSignal resolved = ResolveSignal(m_dragSignal);
const int sourceLane = m_tab->LaneOfSignal(m_dragSignal);
if (resolved.data == nullptr || resolved.style == nullptr || sourceLane < 0 ||
sourceLane >= static_cast<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 要求)。
const std::wstring text =
(Tab().ZoomMode() == PlotZoomMode::SELECT)
? resolved.data->name
: (resolved.data->name + L" · 按住拖拽可移入其它网格");
// 提示跟随鼠标:在可拖拽区域内持续移动时,提示条也要跟着走。
const float textWidth = UiMeasureTextWidth(m_fonts->uiSmall, text) + 16.0f * m_scale;
const float height = 20.0f * m_scale;
float left = static_cast<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