一、滚动过渡动画在巨型信号处卡顿 —— 真因是「退化成整幅重绘」 滚动到**从未构建过图层的区域**时,可见网格一个都贴不出图层像素 ⇒ BlitWideBuffer() 返回 false ⇒ 落到 Paint() 末尾的整幅重绘 ⇒ 把本帧全部可见网格的波形(含千万级采样的巨型信号)每帧重画一遍。 实测(MF4 124 网格 + VERY_BIG_DATA 5 网格,点选最后一个信号):单帧 20~35ms,且「可见的巨型信号 网格数」越多越慢(0 个 ≈20ms、5 个 ≈34ms);对照手动拖滚动条只要 ~6ms。 修法:BlitWideBuffer 只要还能画网格底衬(backing 非空)就返回 true, 只有 blits 与 backing 都为空时才交给整幅重绘 —— 这正是「没来得及画信号时就按只有网格叠底的状态继续滚动」。 实测:动画均值 23.0→11.2ms、最坏 35.6→13.1ms;含 5 个巨型信号网格的帧 33→10.7~12.3ms。 二、面板动画帧只重绘面板(额外 −2.7ms/帧) 新增 Application::m_panelDirty:滚动定时器在只有面板动画在跑时置位,Render 走 else if (panelOnly) 只重绘面板 + 分隔条(SignalPanel::Paint 会铺满自己的区域)。m_chromeDirty(真内容变化)仍走完整装饰重绘。 ⚠️ 回送仍是 if (fullFrame) {整窗} else {绘图区} 的无条件分支(遵守 §9.41)。 实测帧耗时拆解:plot 4.8ms / plot+panel 11.4ms / plot+全装饰 13.9ms。 三、分组行「N 项」计数:只折叠展开,绝不进入改名 新增 RowPart::GROUP_COUNT 与 SignalPanel::GroupStatsRect(row)(绘制与命中的唯一几何来源,命中矩形在 文本左右各外扩 3 逻辑像素)。RowHitPart 在名称区域之前判定计数区: - OnLButtonDown:折叠/展开;OnDoubleClick:只折叠/展开,任何点击频率都不进入改名; - DoubleClickIsPlainPress 返回 true ⇒ 快速连点每一击都生效。 ⚠️「快速双击进改名」的计时只对 RowPart::BODY 记录(否则“点计数、紧接着点名称”会被误判成双击)。 四、浮动面板首次出现的位置:右上角 旧默认位置 top = plotTop + 16 + laneIndex*28:上百个网格时 laneIndex 很大 ⇒ 被夹到右下角 (实测 plotH=1081 时 topMargin=964)。改为按「当前真正显示出来的面板中排在本面板之前的个数」错开 (固定遍历顺序:网格下标 → 面板种类),并把错开量夹到“面板下沿不超过绘图区 45% 高度”以内: 第一个面板错开 0 ⇒ 正好贴在上右角(实测 topMargin=24、rightMargin=24),6 个面板时最坏 topMargin=234。 ⚠️ 判定「面板是否显示」不能只看 *Panel.open —— LanePanelState::open 默认就是 true,必须同时看功能启用。 五、开关实验性功能 / 关闭面板卡顿 —— RebuildWideBuffer() 整批重建 四个 SetLane* 末尾都调 RebuildWideBuffer()(注释说是“预建宽缓冲基底”),而它同步重渲染全部网格的图层: 129 个网格(含 5 个千万级采样的巨型信号)整批重建要几百毫秒 ⇒ 点一下功能开关 / 关闭浮动面板就会卡住。 修法:新增 PlotView::EnsureLaneLayersPrefetched() —— 只有「图层数组与网格数量不符」(首次绘制 / 增删网格) 才同步整批建立;其余只 RollWideBuffer() 登记缺失/过期网格,由既有预算(每帧最多一个)逐帧补齐。 实测:几百毫秒 → ≤22ms;巨型信号网格上开关均值滤波同样 15ms 量级。 验证(全部离屏,不占用鼠标/键盘/屏幕) - 新增 .preflight/perf/lanescroll_main.cpp(lanescroll.exe)23/23:逐帧耗时 + 「可见巨型信号网格数」关联、 「N 项」计数热区与双击不进改名、面板首现位置与多面板错开、开着巨型信号的开关/关闭面板耗时。 - 既有回归:verify 49/49、panelanim 158/158、datacheck 69/69、edgecheck 46/46、errcheck 19/19、 gridanim 93/93。 - make release / make debug 零错误零警告(用 TARGET_FILE_NAME=TargetCheck/TargetCheckD 避免与运行中实例冲突)。 文档:Documents/agent_mem.md 新增 §9.44、§11.2 第 134–140 条与变更日志一行。
6660 lines
278 KiB
C++
6660 lines
278 KiB
C++
// 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;
|
||
for (std::size_t column = 0; column < columns; ++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` **不做整块重画**:内容过期 / 锚点
|
||
// 过远的网格只登记到待重画队列,因此前台每帧的工作量恒定且很小。
|
||
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)
|
||
{
|
||
if (resolved.style == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
// 光晕一律用折线描边(与"之前"的画法一致)。曾经在包络带模式下改成"带外扩填充",
|
||
// 虽然更快但观感变了(用户反馈显示效果下降),因此恢复。
|
||
PaintSignalHalo(g, resolved, lane, cached.points, cached.breaks);
|
||
}
|
||
|
||
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)
|
||
{
|
||
// ⚠️ **只有单个数据点**的信号(默认样式是「点线」,那个点本身是一个粗圆点):
|
||
// 折线光晕无从下手(不足两个顶点),改为在数据点外包一圈强调色圆环。
|
||
// 否则悬停时完全没有“可拖拽”的视觉反馈(用户 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 || breaks.empty())
|
||
{
|
||
// 散点模式没有光晕(原实现同样只对折线绘制光晕)。
|
||
return;
|
||
}
|
||
|
||
gd::Pen halo(ToGdiColor(WithAlpha(m_theme->accent, 0x66)),
|
||
(resolved.style->lineWidth + 4.0f) * m_scale);
|
||
halo.SetLineJoin(gd::LineJoinRound);
|
||
halo.SetStartCap(gd::LineCapRound);
|
||
halo.SetEndCap(gd::LineCapRound);
|
||
for (std::size_t k = 0; k < breaks.size(); ++k)
|
||
{
|
||
const std::size_t start = breaks[k];
|
||
const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size();
|
||
if (stop <= start + 1)
|
||
{
|
||
continue;
|
||
}
|
||
g.DrawLines(&halo, points.data() + start, static_cast<INT>(stop - start));
|
||
}
|
||
}
|
||
|
||
void PlotView::PaintHoverHighlight(gd::Graphics& g)
|
||
{
|
||
if (m_tab == nullptr || m_dragMode != PlotDragMode::NONE)
|
||
{
|
||
// 拖拽过程中不显示悬停提示(拖拽中的信号光晕已绘入静态场景缓存)。
|
||
return;
|
||
}
|
||
|
||
const int saveState = g.Save();
|
||
g.SetClip(m_area, gd::CombineModeIntersect);
|
||
|
||
// 悬停网格:一层很淡的强调色叠加。为了能画在静态场景之上,
|
||
// 这里用半透明而非不透明混色,观感与原实现基本一致。
|
||
// ⚠️ 高亮来源见 `EffectiveHoverLane()`(浮动面板悬停 / 信号列表悬停),
|
||
// **不再**由"鼠标在网格里"触发。
|
||
const int hoverLane = EffectiveHoverLane();
|
||
if (hoverLane >= 0 && hoverLane < static_cast<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)
|
||
{
|
||
PaintSignalHaloFor(g, resolved, lane, *cached);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
g.Restore(saveState);
|
||
}
|
||
|
||
void PlotView::PaintLegendHighlight(gd::Graphics& g)
|
||
{
|
||
if (m_tab == nullptr || m_lanes.empty())
|
||
{
|
||
return;
|
||
}
|
||
|
||
const int saveState = g.Save();
|
||
g.SetClip(m_area, gd::CombineModeIntersect);
|
||
|
||
// ------------------------------------------------------------------
|
||
// (1) 网格编号徽标(GX)的悬停高亮。
|
||
// 徽标本体画在静态场景 / 装饰缓存里(不随鼠标变化),因此悬停时必须在贴图之后
|
||
// **把徽标重画一遍**(不透明底色 + 圆角 + 文字)—— 否则它在缓存里被冻结,
|
||
// 鼠标移上去看不到任何反馈(用户要求的"参考信号列表里 GX 徽标"的悬停提示)。
|
||
// ⚠️ 只在**未拖拽**时绘制:`LANE_REORDER` 期间网格连同徽标被整体平移贴图,
|
||
// 这里按静态位置重画会画到错误的地方。
|
||
// ------------------------------------------------------------------
|
||
if (m_dragMode == PlotDragMode::NONE && m_hoverBadge >= 0 &&
|
||
m_hoverBadge < static_cast<int>(m_lanes.size()))
|
||
{
|
||
const gd::RectF badge = LaneBadgeRect(m_hoverBadge);
|
||
if (badge.Width > 0.0f)
|
||
{
|
||
const float radius = kLaneBadgeHeight * m_scale * 0.5f;
|
||
// 只重画**圆角形状本身**(不透明):圆角之外保持"贴图层"的像素,
|
||
// 因此不会出现直角白边。
|
||
UiFillRoundRect(g, badge, radius, m_theme->accentSoft);
|
||
UiDrawRoundRect(g, badge, radius, m_theme->accent, 1.0f);
|
||
UiText(g, L"G" + std::to_wstring(m_hoverBadge + 1), m_fonts->monoSmall, badge,
|
||
m_theme->textStrong, TextAlign::CENTER);
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// (2) 图例条目悬停:条目托底高亮 + **对应信号曲线**的光晕。
|
||
// ------------------------------------------------------------------
|
||
if (m_dragMode != PlotDragMode::NONE || !m_hoverLegend.IsValid())
|
||
{
|
||
g.Restore(saveState);
|
||
return;
|
||
}
|
||
const int laneIndex = m_tab->LaneOfSignal(m_hoverLegend);
|
||
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
|
||
{
|
||
g.Restore(saveState);
|
||
return;
|
||
}
|
||
const gd::RectF laneRect = m_lanes[static_cast<std::size_t>(laneIndex)].rect;
|
||
if (laneRect.Y > m_plotRect.Y + m_plotRect.Height ||
|
||
laneRect.Y + laneRect.Height < m_plotRect.Y)
|
||
{
|
||
g.Restore(saveState);
|
||
return;
|
||
}
|
||
|
||
// 除了图例条目本身,还要把**对应的信号曲线**一并高亮:用户在网格图例上移动时
|
||
// 一眼就能确认这条条目对应哪条曲线(与"鼠标贴近曲线"时的光晕提示一致)。
|
||
const ResolvedSignal resolved = ResolveSignal(m_hoverLegend);
|
||
if (resolved.data != nullptr && resolved.style != nullptr && resolved.visible)
|
||
{
|
||
const LaneVisual& lane = m_lanes[static_cast<std::size_t>(laneIndex)];
|
||
if (const PolylineCacheEntry* cached = AcquirePolyline(m_hoverLegend, resolved, lane))
|
||
{
|
||
PaintSignalHaloFor(g, resolved, lane, *cached);
|
||
}
|
||
}
|
||
|
||
// 条目悬停高亮:**圆角托底 + 与选中态相同的高亮**(用户明确要求"悬停效果与 GX 标签
|
||
// 相同、选中状态与悬停提示效果相同"),并在其上**原样重绘色块与文字** ——
|
||
// 不透明托底若只画底色会把图例内容整块盖住(只剩一个空色块)。
|
||
for (const LegendHit& hit : m_legendHits)
|
||
{
|
||
if (hit.ref == m_hoverLegend)
|
||
{
|
||
const float radius = (hit.radius > 0.0f) ? hit.radius : (kLaneBadgeHeight * 0.5f * m_scale);
|
||
UiFillRoundRect(g, hit.rect, radius, m_theme->accentSoft);
|
||
UiDrawRoundRect(g, hit.rect, radius, m_theme->accent, 1.0f);
|
||
UiDrawSwatch(g, hit.swatch, hit.swatchColor, hit.swatchOpacity, 1.5f * m_scale, m_scale);
|
||
UiText(g, hit.label, m_fonts->uiTiny, hit.text, m_theme->textStrong, TextAlign::LEFT);
|
||
break;
|
||
}
|
||
}
|
||
g.Restore(saveState);
|
||
}
|
||
|
||
PlotView::SceneCacheKey PlotView::MakeSceneCacheKey() const
|
||
{
|
||
SceneCacheKey key;
|
||
key.viewMin = Tab().ViewMin();
|
||
key.viewMax = Tab().ViewMax();
|
||
key.laneScroll = Tab().LaneScroll();
|
||
key.laneCount = static_cast<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 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();
|
||
// 沿用**当前视界**的 Y 轴范围,否则幅值映射与前台不一致。
|
||
for (std::size_t i = 0; i < m_lanes.size() && i < savedLanes.size(); ++i)
|
||
{
|
||
m_lanes[i].yMin = savedLanes[i].yMin;
|
||
m_lanes[i].yMax = savedLanes[i].yMax;
|
||
}
|
||
// ⚠️ **该网格的幅值映射必须以本次渲染的纵向锚点为准**(而不是"当前 `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()`**(而不是只把 `m_lanes` 拷回去):它会按正常布局
|
||
// 重算 `m_laneContentHeight`。该值参与内容指纹(`LaneLayerKey`),若残留上面"宽窗口"
|
||
// 布局算出的值,算出的图层指纹就与实际渲染不符 → 每帧都判定图层"过期"重画,白白耗性能。
|
||
m_area = savedArea;
|
||
m_plotRect = savedPlot;
|
||
Tab().ViewMin() = savedViewMin;
|
||
Tab().ViewMax() = savedViewMax;
|
||
BuildLaneVisuals();
|
||
// `BuildLaneVisuals()` 内部会 `ClampLaneScroll()`,用宽窗口布局可能夹出不同的滚动量,
|
||
// 因此把滚动量与各网格幅值范围一并还原回进入本函数前的状态。
|
||
Tab().LaneScroll() = savedScroll;
|
||
for (std::size_t i = 0; i < m_lanes.size() && i < savedLanes.size(); ++i)
|
||
{
|
||
m_lanes[i].yMin = savedLanes[i].yMin;
|
||
m_lanes[i].yMax = savedLanes[i].yMax;
|
||
}
|
||
// 恢复该网格的矩形(`BuildLaneVisuals` 已按正常布局给出,这里只为稳妥保持一致)。
|
||
if (laneIndex < static_cast<int>(m_lanes.size()) && laneIndex < static_cast<int>(savedLanes.size()))
|
||
{
|
||
m_lanes[static_cast<std::size_t>(laneIndex)] = savedLanes[static_cast<std::size_t>(laneIndex)];
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// ⚠️ **逐网格独立滚动**:每个图层有自己的时间映射(`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));
|
||
// 纵向:幅值**平移**不改内容,只改贴图偏移(与横向同构)。
|
||
int vOffset = 0;
|
||
if (layer.pxPerValue > 0.0)
|
||
{
|
||
vOffset = static_cast<int>(
|
||
std::llround((layer.anchorYMax - m_lanes[index].yMax) * layer.pxPerValue));
|
||
}
|
||
|
||
// ⚠️⚠️ **"距离太远"绝不允许退化成"排队等后续帧重画"**:
|
||
// 长距离拖拽(用户按着中键把视界移动好几个网格高、或横向拖很远)时,
|
||
// 每一帧的位移都会超过余量;若此时只登记待重画,就会出现
|
||
// * 本帧 `BlitWideBuffer` 取不到源行 → 网格里出现**空白条带**;
|
||
// * 每帧都重新入队 → 一帧只补 1 个网格 → **持续抽搐**且总是补不完。
|
||
// 正确做法是:
|
||
// * 位移不超过画布 → **滚动**(O(1) BitBlt + 只补画暴露的细条);
|
||
// * 位移超过画布(内容整体滚出)→ 本帧**立刻整块重画并重新居中**(一次性),
|
||
// 等价于一次普通的整块渲染(实测 0.4ms),不会累积漂移也不会留空白。
|
||
// 只有**内容真的变了**(`contentStale`)才交给待重画队列(它可以等后续帧,
|
||
// 因为画面本身仍然是"旧内容",不缺口)。
|
||
const bool fullRedraw = (std::abs(shift) >= bitmapWidth) ||
|
||
(std::abs(vOffset) >= layer.height);
|
||
if (fullRedraw)
|
||
{
|
||
// 两个方向都滚出画布:直接重新居中 + 整块重画(一次搞定,避免"滚动 + 巨幅补条")。
|
||
if (contentStale || std::abs(shift) >= bitmapWidth)
|
||
{
|
||
QueueLaneRebuild(laneIndex);
|
||
}
|
||
// 整块重画:重新居中 + 纵向锚点取**当前** `yMax`。
|
||
RenderLaneLayerRegion(laneIndex, layer, 0, bitmapWidth, 0, 0, LayerDefaultWideMin(),
|
||
m_lanes[static_cast<std::size_t>(laneIndex)].yMax);
|
||
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);
|
||
}
|
||
|
||
if (shift == 0)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
// 整数像素自贴图平移:内容整体移动 `shift` 像素。
|
||
if (shift > 0)
|
||
{
|
||
// 内容整体左移 shift:new[x] = old[x + shift],右侧暴露 shift 列。
|
||
BitBlt(layer.dc, 0, 0, bitmapWidth - shift, layer.height, layer.dc, shift, 0, SRCCOPY);
|
||
}
|
||
else
|
||
{
|
||
const int amount = -shift;
|
||
BitBlt(layer.dc, amount, 0, bitmapWidth - amount, layer.height, layer.dc, 0, 0, SRCCOPY);
|
||
}
|
||
layer.wideMin += static_cast<double>(shift) / scale;
|
||
|
||
// 只补画新暴露的细条(每帧代价恒为 O(细条))。
|
||
// ⚠️ 细条必须用**该图层更新后的映射**渲染(传 `layer.wideMin`);若改用"当前视界"
|
||
// 的映射,细条与已平移的旧像素之间会有 ≤0.5px 的错位接缝 → 拖动时看起来在抖动。
|
||
// ⚠️ **纵向锚点必须沿用 `layer.anchorYMax`**(不能传当前 `yMax`):横向补条只改一条
|
||
// 竖带,其余像素仍在原锚点上;同时该函数按锚点渲染这条竖带,因此竖带与其余像素
|
||
// 纵向同源。若在这里把锚点重置为当前 `yMax`,残余的纵向偏移会被静默清零、而像素
|
||
// 并未上移 → 整个网格的波形纵向错位("垂直拖动后再横向平移出现纵向跳变")。
|
||
const int stripWidth = std::min(std::abs(shift), bitmapWidth);
|
||
const int stripX = (shift > 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);
|
||
}
|
||
|
||
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)];
|
||
const std::wstring text = 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
|
||
|