① 主题自适应(`Theme.cpp`):新增 `SrgbLuminance` / `ContrastRatio` / `EnsureContrast` /
`MixToLuminance` / `ThemeLaneBackground`;四套主题的结构色(gridWeak 1.30 / gridStrong 1.65 /
axisLine 2.10 / borderStrong 1.70)按底色自动校正,Solarised Light 逐位不变;网格叠底改用
`ThemeLaneBackground()`(4 处),深色主题下网格层与绘图区底色可辨。
② 派生预览曲线(`HighContrastDerived`)重写为**连续**方案:方向沿信号所在侧远离底色,
目标亮度 = max(与底色对比度 3.2, 信号亮度 ± 0.18),再按亮度平滑混色;调用点传入真实网格底色
(旧实现默认写死 Solarised Light 底色 ⇒ 深色主题永远选到更暗的那一侧、几乎看不见)。
③ 信号样式区「线宽 / 透明度」两行右侧新增**复位按钮**(`UiPaintResetIcon` 成为唯一实现,
菜单/弱峰面板/样式区共用同一套笔画);默认线宽统一到 `DefaultSignalLineWidth(finiteCount)`
(单点信号取 10.0)。按钮右边界与内容列对齐,内容列过窄时整列不显示。
④ 「均值滤波」面板重做:信号色叠底标题栏 + 复选框 + 展开三角 + 「均值点数 / 迭代次数」两行滑块
(点击数值可改滑块上限、右侧复位)+ 内容区滚动;新增 `MeanFilterItem::{maxPoints,maxIterations,
collapsed}` 与 `Lane::meanFilterScroll`,缓存键同步补全。
⑤ 浮动面板命中像素缓存时只贴**面板圆角形状之内**的像素(`CreateRoundRectRgn` 裁剪)并按本帧重画投影
⇒ 中键移视界后面板周围不再出现一圈旧波形。
⑥ 显示布局边界:每个浮动面板**独占一个绘制层**(按 `m_panelOrder` 递增),绘制侧
`UiSetTextLayer` 在同一循环内同步推进 ⇒ 下层面板的边框/文字不再画到上层面板上。
⑦ 自由标记:采样候选距离改为**二维欧氏距离**(单点信号不再在整幅宽度内吸住光标),
纵向光标画在鼠标位置、圆点落在真实采样点(新增 `FreeMarkerProbe::pointX`);
`MinorCellSparse` 只对胜出信号算一次;折线缓存容量改为 `max(64, 信号数×2)` 且查找改为
单键哈希 ⇒ MF4 的 285 信号合并网格中 `ProbeFreeMarker` 平均 0.11ms。
验证:`.preflight` 探针重建(`panelcheck` 71/71、`markercheck` 13/13、`ringcheck` 6/6,合计 90 条,
LTO 目标文件上复跑同样全绿);`make release` / `make debug` 零警告;产物 815,104 B。
文档:`agent_mem.md` 第十六轮(含补丁脚本与编码/行尾踩坑)。
5810 lines
257 KiB
C++
5810 lines
257 KiB
C++
// PlotView 实现分片 —— 绘制(paint):只负责"把内容画出来"。
|
||
//
|
||
// 共用的常量与工具函数在 PlotViewInternal.hpp(命名空间 `pvdetail`,全部 inline)。
|
||
// 七个分片的职责划分(按**关注点**,不再按函数名前缀):
|
||
// PlotView.cpp 视图核心:构造/析构、上下文绑定、布局、坐标映射、视界与平移、
|
||
// 网格结构与归属、选区/网格划分、拖动取消
|
||
// PlotViewPaint.cpp 绘制:全部 Paint*/Render* 与内联输入框绘制(**本文件**)
|
||
// PlotViewRaster.cpp 光栅化像素缓存与每帧预算:光晕层、场景缓存、装饰层、每网格宽基底
|
||
// 图层、滚动补条、预热("怎么画才不卡")
|
||
// PlotViewCacheKey.cpp 内容指纹与缓存键:SceneCacheKey / *Hash / 样式与派生快照 / 样式补丁帧
|
||
// PlotViewPanels.cpp 几何:浮动面板布局(EdgePanelWidth/EdgePanelLayoutFor)、图例徽标、
|
||
// 「自由标记」的取值/胶囊矩形/探测(绘制与命中同源的唯一来源)
|
||
// PlotViewInteraction.cpp 交互与编辑:命中测试、鼠标与键盘处理、光标形状、内联输入框、
|
||
// 浮动面板层序与命中
|
||
// PlotViewFeatures.cpp 视图特性与派生数据:光标状态与读数、提示浮层、缩放模式、自适应、
|
||
// 光标吸附、单点信号、派生曲线线程池、边沿统计、实验性功能参数
|
||
//
|
||
// ⚠️ 新增函数按**职责**放入对应分片;`PlotView::Paint/PaintLane` 仍只允许定义在本文件。
|
||
#include "PlotViewInternal.hpp"
|
||
|
||
#include "UiBox.hpp"
|
||
#include "UiLayout.hpp"
|
||
|
||
namespace jdi
|
||
{
|
||
|
||
namespace
|
||
{
|
||
/**
|
||
* @brief 面板渲染期间的文本几何收集器:**记录 + 转发**给外层收集器。
|
||
*
|
||
* 为什么需要它:浮动面板是"先画进像素图层、命中缓存时直接贴图"的。
|
||
* 贴图那一帧不会真的画字 —— 若文本框只靠"画的时候顺手收集",它在那一帧就会
|
||
* **凭空消失**,观感是"拖动视界时面板上的布局边界不停闪烁"(用户 2026-10-29 报告)。
|
||
*
|
||
* 所以渲染面板时用本收集器:一边把矩形转发给外层(保持正常行为),
|
||
* 一边存一份进 `PanelSlot::textRects`;命中缓存贴图时再把存下的矩形回放出去。
|
||
*
|
||
* `OnTextLayer` 也必须转发:外层收集器靠它把文本记到正确的绘制层
|
||
* (面板层比网格层高一层),否则面板文字会被自己登记的遮挡矩形挖掉。
|
||
*/
|
||
class PanelTextCollector : public UiTextRectSink
|
||
{
|
||
public:
|
||
explicit PanelTextCollector(UiTextRectSink* outer) : m_outer(outer) {}
|
||
|
||
void OnTextRect(const gd::RectF& rect) override
|
||
{
|
||
m_rects.push_back(rect);
|
||
if (m_outer != nullptr)
|
||
{
|
||
m_outer->OnTextRect(rect);
|
||
}
|
||
}
|
||
|
||
void OnTextLayer(int layer) override
|
||
{
|
||
if (m_outer != nullptr)
|
||
{
|
||
m_outer->OnTextLayer(layer);
|
||
}
|
||
}
|
||
|
||
int TextLayer() const override { return (m_outer != nullptr) ? m_outer->TextLayer() : 0; }
|
||
|
||
/** @brief 外层收集器(面板画完后要还原它)。 */
|
||
UiTextRectSink* Outer() const { return m_outer; }
|
||
|
||
/** @brief 本次渲染收集到的文本框。 */
|
||
const std::vector<gd::RectF>& Rects() const { return m_rects; }
|
||
|
||
private:
|
||
UiTextRectSink* m_outer = nullptr;
|
||
std::vector<gd::RectF> m_rects;
|
||
};
|
||
} // namespace
|
||
|
||
|
||
const PlotView::PolylineCacheEntry* PlotView::AcquirePolyline(const SignalRef& ref,
|
||
const ResolvedSignal& resolved,
|
||
const LaneVisual& lane) const
|
||
{
|
||
if (resolved.data == nullptr || resolved.group == nullptr)
|
||
{
|
||
return nullptr;
|
||
}
|
||
|
||
// ⚠️ **横向补条渲染:不进缓存**。补条只构建"条带区间"的折线顶点,若写进缓存,
|
||
// 后续整块渲染命中同键时会拿到这段**不完整**的折线(只画了条带那一段)。
|
||
// 因此补条专用一个独立条目,避免污染按"宽窗口几何"键控的缓存。
|
||
if (m_stripRenderActive)
|
||
{
|
||
BuildPolyline(resolved, lane, m_stripPolylineEntry.points, m_stripPolylineEntry.breaks,
|
||
m_stripPolylineEntry.band, m_stripPolylineEntry.bandBreaks,
|
||
m_stripPolylineEntry.bandMode, m_stripPolylineEntry.bandSpiky);
|
||
return &m_stripPolylineEntry;
|
||
}
|
||
|
||
// 键 = 构建折线时实际读取的全部输入:
|
||
// 信号(引用 + 文件内容 + 采样点数)、时间轴显示范围、绘图区横向几何、网格纵向几何。
|
||
// 这些值全部相同时,BuildPolyline 的输出必然完全一致,可直接复用。
|
||
const std::size_t sampleCount = std::min(resolved.axis->time.size(), resolved.data->values.size());
|
||
const double viewMin = Tab().ViewMin();
|
||
const double viewMax = Tab().ViewMax();
|
||
// 绘制前的数值变换(系数/绝对值/镜像/偏移)会改变顶点,必须进缓存键。
|
||
const float gain = (resolved.style != nullptr) ? resolved.style->gain : 1.0f;
|
||
const bool absolute = (resolved.style != nullptr) && resolved.style->absolute;
|
||
const bool mirrorVertical = (resolved.style != nullptr) && resolved.style->mirrorVertical;
|
||
const bool mirrorHorizontal = (resolved.style != nullptr) && resolved.style->mirrorHorizontal;
|
||
const float offsetY = (resolved.style != nullptr) ? resolved.style->offsetY : 0.0f;
|
||
const double offsetTime = (resolved.style != nullptr) ? resolved.style->offsetTime : 0.0;
|
||
// ⚠️ 线宽也必须进键:`BuildPolyline` 把它烘进包络带几何(逐列外扩半个线宽)
|
||
//(详见 `PolylineCacheEntry::lineWidth` 的说明;漏掉它会让线宽改动在**稠密信号**上不生效)。
|
||
const float lineWidth = (resolved.style != nullptr) ? resolved.style->lineWidth : 1.0f;
|
||
|
||
// 全部键字段的 64 位哈希(顺序固定;只用于**查找**,命中后再做廉价校验)。
|
||
std::uint64_t key = 1469598103934665603ull;
|
||
const auto mixHash = [&key](std::uint64_t value)
|
||
{
|
||
key ^= value;
|
||
key *= 1099511628211ull;
|
||
};
|
||
const auto mixHashFloat = [&mixHash](float value)
|
||
{
|
||
std::uint32_t bits = 0;
|
||
std::memcpy(&bits, &value, sizeof(bits));
|
||
mixHash(static_cast<std::uint64_t>(bits));
|
||
};
|
||
const auto mixHashDouble = [&mixHash](double value)
|
||
{
|
||
std::uint64_t bits = 0;
|
||
std::memcpy(&bits, &value, sizeof(bits));
|
||
mixHash(bits);
|
||
};
|
||
mixHash(static_cast<std::uint64_t>(static_cast<std::uint32_t>(ref.groupIndex)) + 2ull);
|
||
mixHash(static_cast<std::uint64_t>(static_cast<std::uint32_t>(ref.signalIndex)) + 2ull);
|
||
mixHash(reinterpret_cast<std::uintptr_t>(resolved.group));
|
||
mixHash(static_cast<std::uint64_t>(sampleCount));
|
||
mixHashDouble(viewMin);
|
||
mixHashDouble(viewMax);
|
||
mixHashFloat(m_plotRect.X);
|
||
mixHashFloat(m_plotRect.Width);
|
||
mixHashFloat(lane.rect.Y);
|
||
mixHashFloat(lane.rect.Height);
|
||
mixHashDouble(lane.yMin);
|
||
mixHashDouble(lane.yMax);
|
||
mixHashFloat(gain);
|
||
mixHash((absolute ? 1ull : 0ull) | (mirrorVertical ? 2ull : 0ull) |
|
||
(mirrorHorizontal ? 4ull : 0ull));
|
||
mixHashFloat(offsetY);
|
||
mixHashDouble(offsetTime);
|
||
mixHashFloat(lineWidth);
|
||
|
||
for (const PolylineCacheEntry& entry : m_polylineCache)
|
||
{
|
||
// 快路径:只比哈希。命中后再校验三项最可能变化的字段(碰撞代价可忽略)。
|
||
if (entry.key != key || entry.sampleCount != sampleCount || !(entry.ref == ref) ||
|
||
entry.viewMin != viewMin || entry.viewMax != viewMax)
|
||
{
|
||
continue;
|
||
}
|
||
return &entry;
|
||
}
|
||
|
||
// 未命中:重建并写入缓存(deque 保证已有元素的引用在追加后依然有效)。
|
||
// ⚠️ 容量必须**覆盖所有网格的全部信号**:旧实现固定 64 条,283 个信号合并到
|
||
// 同一网格时会不断"清空重建"⇒ 绘制与自由标记探测每帧重算全部折线(严重卡顿)。
|
||
if (m_polylineCache.size() >= m_polylineCacheCapacity)
|
||
{
|
||
m_polylineCache.clear();
|
||
}
|
||
m_polylineCache.push_back(PolylineCacheEntry());
|
||
PolylineCacheEntry& entry = m_polylineCache.back();
|
||
entry.key = key;
|
||
entry.ref = ref;
|
||
entry.group = resolved.group;
|
||
entry.sampleCount = sampleCount;
|
||
entry.viewMin = viewMin;
|
||
entry.viewMax = viewMax;
|
||
entry.plotX = m_plotRect.X;
|
||
entry.plotWidth = m_plotRect.Width;
|
||
entry.laneY = lane.rect.Y;
|
||
entry.laneHeight = lane.rect.Height;
|
||
entry.laneYMin = lane.yMin;
|
||
entry.laneYMax = lane.yMax;
|
||
entry.gain = gain;
|
||
entry.absolute = absolute;
|
||
entry.mirrorVertical = mirrorVertical;
|
||
entry.mirrorHorizontal = mirrorHorizontal;
|
||
entry.offsetY = offsetY;
|
||
entry.offsetTime = offsetTime;
|
||
entry.lineWidth = lineWidth;
|
||
|
||
BuildPolyline(resolved, lane, entry.points, entry.breaks, entry.band, entry.bandBreaks,
|
||
entry.bandMode, entry.bandSpiky);
|
||
// BuildPolyline 通过引用填充,push_back 过程中不会再次进入本函数,引用安全。
|
||
return &m_polylineCache.back();
|
||
}
|
||
|
||
void PlotView::BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane,
|
||
std::vector<gd::PointF>& points, std::vector<std::size_t>& breaks,
|
||
std::vector<gd::PointF>& band, std::vector<std::size_t>& bandBreaks,
|
||
bool& bandMode, bool& bandSpiky) const
|
||
{
|
||
points.clear();
|
||
breaks.clear();
|
||
band.clear();
|
||
bandBreaks.clear();
|
||
bandMode = false;
|
||
bandSpiky = false;
|
||
|
||
if (resolved.data == nullptr || resolved.group == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const std::vector<double>& time = resolved.axis->time;
|
||
const std::vector<double>& values = resolved.data->values;
|
||
const std::size_t count = std::min(time.size(), values.size());
|
||
if (count == 0 || m_plotRect.Width <= 1.0f)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const double viewMin = Tab().ViewMin();
|
||
const double viewMax = Tab().ViewMax();
|
||
|
||
// 时间偏移:采样画在 `t + offsetTime` 处,因此“落在当前视界内的采样”
|
||
// 其源时刻位于 `[viewMin - offsetTime, viewMax - offsetTime]`。
|
||
const double timeShift = resolved.style->offsetTime;
|
||
const double sourceMin = viewMin - timeShift;
|
||
const double sourceMax = viewMax - timeShift;
|
||
|
||
std::size_t begin = 0;
|
||
std::size_t end = count;
|
||
|
||
if (resolved.axis->monotonic)
|
||
{
|
||
const std::vector<double>::const_iterator first = std::lower_bound(time.begin(), time.end(), sourceMin);
|
||
begin = static_cast<std::size_t>(first - time.begin());
|
||
if (begin > 0)
|
||
{
|
||
--begin;
|
||
}
|
||
const std::vector<double>::const_iterator last = std::lower_bound(time.begin(), time.end(), sourceMax);
|
||
end = static_cast<std::size_t>(last - time.begin());
|
||
if (end < count)
|
||
{
|
||
++end;
|
||
}
|
||
}
|
||
|
||
if (end <= begin)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// ⚠️ **横向补条裁剪**(只对本函数后半段的"逐点描边"路径生效,见 `budget` 之后):
|
||
// 图层横向补条只覆盖位图里的一条窄竖带(`m_area` 的 x 区间),其余像素由已有图层像素提供,
|
||
// 因此折线只需构建并绘制落在条带 x 范围内的那一段顶点。放在"包络/描边"分支判定**之后**
|
||
// 是必须的 —— 若先裁剪,`visibleCount` 会变成条带内的采样数,可能改变"走包络还是走描边"
|
||
// 的判定,使条带与整块渲染的像素不一致(出现接缝)。
|
||
|
||
const float limitTop = lane.rect.Y - lane.rect.Height * 4.0f;
|
||
const float limitBottom = lane.rect.Y + lane.rect.Height * 5.0f;
|
||
|
||
const std::size_t visibleCount = end - begin;
|
||
|
||
// 顶点预算由绘图区宽度决定(而不是固定值):折线顶点数直接决定
|
||
// GDI+ 抗锯齿折线的绘制耗时,百万级采样文件若按固定 20000 顶点绘制,
|
||
// 会把大量顶点堆在同一个像素列上(画出来完全重合),纯粹浪费 CPU。
|
||
std::size_t budget =
|
||
static_cast<std::size_t>(std::max(1.0f, m_plotRect.Width)) * kPolylinePointsPerPixel;
|
||
budget = std::max(budget, kMinPolylinePoints);
|
||
budget = std::min(budget, kMaxPolylinePoints);
|
||
|
||
// 采样点密度超过像素分辨率时改用“像素列上下包络”抽稀:
|
||
// 每个像素列只保留最小/最大两个顶点(按出现先后排序),
|
||
// 既不会漏掉尖峰(等间隔抽样会整段跳过),又把顶点数压到像素量级。
|
||
//
|
||
// ⚠️ **阈值必须是「每像素列 1 个采样」而不是 `budget`(每列 2 个)**:
|
||
// 逐点描边(`DrawLines`)的代价与「竖向线段数 × 覆盖行数」成正比 ——
|
||
// 方波/编码器这类信号在 1~2 采样/像素时会形成**满幅高的锯齿**,GDI+ 抗锯齿
|
||
// 逐段扫描这些竖直段,实测 **4481 个顶点要 712ms**(关抗锯齿也无用,因为瓶颈是
|
||
// 每段的扫描线而非边缘平滑),而同样画面的「像素列包络」只要 ~1.7ms。
|
||
// 把阈值降到 1 采样/像素后,1~2 采样/像素区间走包络带(逐列 min/max 与折线在该密度下
|
||
// 几乎等价,且**保留抗锯齿**),彻底消除这一区间的病态卡顿。
|
||
const bool useEnvelopeBand = (visibleCount > static_cast<std::size_t>(m_plotRect.Width)) &&
|
||
resolved.axis->monotonic && (m_plotRect.Width > 1.0f);
|
||
|
||
// 逐点描边路径若处于"横向补条"渲染:把采样区间裁剪到条带对应的 x 范围
|
||
//(两侧各留少量像素余量以避免接缝与抗锯齿溢出)。逐点描边路径必然 `visibleCount <= budget`
|
||
//(否则走包络),此时 `stride == 1`,裁剪后条带内的顶点序列与整块渲染完全一致 → 像素不变。
|
||
if (!useEnvelopeBand && m_stripRenderActive && resolved.axis->monotonic &&
|
||
m_stripX1 > m_stripX0)
|
||
{
|
||
const float marginPx =
|
||
std::max(4.0f, std::max(1.0f, resolved.style->lineWidth) * m_scale + 3.0f);
|
||
// 条带边界是**绘制时刻**,换算成源时刻要减去时间偏移。
|
||
const double ta = XToTime(m_stripX0 - marginPx) - timeShift;
|
||
const double tb = XToTime(m_stripX1 + marginPx) - timeShift;
|
||
const double clipLo = std::min(ta, tb);
|
||
const double clipHi = std::max(ta, tb);
|
||
std::size_t sBegin = static_cast<std::size_t>(
|
||
std::lower_bound(time.begin(), time.end(), clipLo) - time.begin());
|
||
if (sBegin > 0)
|
||
{
|
||
--sBegin; // 多含一个采样点:让跨越条带左界的线段完整绘出。
|
||
}
|
||
std::size_t sEnd = static_cast<std::size_t>(
|
||
std::lower_bound(time.begin(), time.end(), clipHi) - time.begin());
|
||
if (sEnd < count)
|
||
{
|
||
++sEnd;
|
||
}
|
||
if (sBegin > begin)
|
||
{
|
||
begin = sBegin;
|
||
}
|
||
if (sEnd < end)
|
||
{
|
||
end = sEnd;
|
||
}
|
||
if (end <= begin)
|
||
{
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 采样点密度远超像素分辨率时改用“像素列上下包络”抽稀:
|
||
// 每个像素列只保留最小/最大两个顶点(按出现先后排序),
|
||
// 既不会漏掉尖峰(等间隔抽样会整段跳过),又把顶点数压到像素量级。
|
||
if (useEnvelopeBand)
|
||
{
|
||
const double viewSpan = viewMax - viewMin;
|
||
const float plotLeft = m_plotRect.X;
|
||
const float plotWidth = m_plotRect.Width;
|
||
const std::size_t columns = std::max<std::size_t>(1, budget / 2);
|
||
const float columnWidth = plotWidth / static_cast<float>(columns);
|
||
|
||
const std::vector<double>::const_iterator rangeEnd = time.begin() + static_cast<std::ptrdiff_t>(end);
|
||
|
||
// 粗粒度包络层(若已构建):每列只需读取覆盖到的分组,
|
||
// 数据量从“可见采样数”降到“像素列数 × 少量分组”,
|
||
// 且分组内存的就是真实极值,绘制结果与逐点扫描完全一致。
|
||
const std::vector<double>& coarseMin = resolved.data->coarseMin;
|
||
const std::vector<double>& coarseMax = resolved.data->coarseMax;
|
||
const std::size_t coarseStride = resolved.data->coarseStride;
|
||
// ⚠️ 粗粒度分组只存了逐组的 min/max:取绝对值 / 垂直镜像会把两者互换,
|
||
// 此时分组层无法再给出正确的包络,必须回退到逐采样点变换(见 `DrawTransformOrderPreserving`)。
|
||
// Y 偏移是保序的(整体加常数),因此仍可用分组层 —— 只需把偏移一并加上。
|
||
const bool hasCoarse =
|
||
(coarseStride > 0) && !coarseMin.empty() && (coarseMin.size() == coarseMax.size()) &&
|
||
DrawTransformOrderPreserving(*resolved.style);
|
||
// 保序变换(正系数 + Y 偏移)下:分组极值 × 系数 + 偏移 即为变换后的极值
|
||
//(系数 1、偏移 0 时逐位不变)。
|
||
const double coarseScale = static_cast<double>(resolved.style->gain);
|
||
const double coarseOffset = static_cast<double>(resolved.style->offsetY);
|
||
|
||
bool inSegment = false;
|
||
|
||
// 稠密包络带:逐列记录上/下包络,收段时拼成「上包络正向 + 下包络反向」的
|
||
// 闭合多边形。绘制时一次 FillPath 即可,实测比逐段描边快约 8 倍
|
||
// (1224 列:14.3ms → 1.7ms),且**保留抗锯齿**,
|
||
// 因此静态帧与拖拽帧的观感完全一致(不是"交互降级")。
|
||
bandMode = true;
|
||
// 线宽(设备像素)的一半:包络带在**纵/横两个方向**各外扩这么多。
|
||
// ⚠️ 旧实现只在每列上下各外扩 —— 水平段会变粗,但方波的**竖直沿**横向宽度始终是
|
||
// 1 个像素列,于是用户把线宽从 1px 拖到 10px 时"竖直方向的沿没有变粗"(2026-10-20 报告)。
|
||
// 现在把线宽交给 `EmitEnvelopeBand`,由它对上下包络做横向膨胀。
|
||
const float strokeHalf =
|
||
std::max(1.0f, resolved.style->lineWidth) * m_scale * 0.5f;
|
||
std::vector<BandColumn> segmentColumns;
|
||
const auto flushBandSegment = [&band, &bandBreaks, &segmentColumns, &bandSpiky, &lane, strokeHalf]()
|
||
{
|
||
bool spiky = false;
|
||
EmitEnvelopeBand(segmentColumns, band, bandBreaks, spiky, lane.rect.Height, strokeHalf);
|
||
bandSpiky = bandSpiky || spiky;
|
||
segmentColumns.clear();
|
||
};
|
||
|
||
std::size_t searchFrom = begin;
|
||
// ⚠️ **包络带路径也要支持「横向补条」裁剪**(用户 2026-10-17 报告拖拽卡顿):
|
||
// 旧实现只对“逐点描边”路径按条带裁剪,包络带路径**总是构建整幅可见范围的包络** ——
|
||
// 巨型信号(`VERY_BIG_DATA`)一条 32px 宽的竖带就要 **5ms**,拖拽时每帧补几个网格就卡。
|
||
// 包络是**逐像素列独立**计算的(每列只取自己的 min/max),因此只构建条带覆盖的列
|
||
// 就能得到与整块渲染**逐像素一致**的条带像素(条带外本来就由已有像素提供)。
|
||
std::size_t columnFirst = 0;
|
||
std::size_t columnLast = columns;
|
||
if (m_stripRenderActive && m_stripX1 > m_stripX0 && columnWidth > 0.0f)
|
||
{
|
||
// 两侧各多含 1 列,避免条带边界处的抗锯齿/描边被切掉。
|
||
const float c0 = (m_stripX0 - plotLeft) / columnWidth - 1.0f;
|
||
const float c1 = (m_stripX1 - plotLeft) / columnWidth + 1.0f;
|
||
columnFirst = (c0 <= 0.0f) ? 0 : static_cast<std::size_t>(c0);
|
||
const std::size_t wantLast =
|
||
(c1 <= 0.0f) ? 0 : static_cast<std::size_t>(c1) + 1;
|
||
columnLast = std::min(columns, wantLast);
|
||
if (columnLast <= columnFirst)
|
||
{
|
||
return;
|
||
}
|
||
// 从条带第一列对应的源时刻开始二分。
|
||
const double tFirst =
|
||
viewMin + viewSpan * (static_cast<double>(columnFirst) / static_cast<double>(columns));
|
||
searchFrom = static_cast<std::size_t>(
|
||
std::lower_bound(time.begin() + static_cast<std::ptrdiff_t>(begin), rangeEnd,
|
||
tFirst - timeShift) -
|
||
time.begin());
|
||
}
|
||
for (std::size_t column = columnFirst; column < columnLast; ++column)
|
||
{
|
||
// `t0/t1` 是**绘制时刻**的列边界;源时刻要减去时间偏移(偏移为 0 时二者相同)。
|
||
const double t0 = viewMin + viewSpan * (static_cast<double>(column) / static_cast<double>(columns));
|
||
const double t1 =
|
||
viewMin + viewSpan * (static_cast<double>(column + 1) / static_cast<double>(columns));
|
||
const double s0 = t0 - timeShift;
|
||
const double s1 = t1 - timeShift;
|
||
|
||
// 列边界随 column 单调递增,从上一列的位置继续二分,
|
||
// 把每次搜索的范围压到剩余区间,避免 2400 次全区间二分带来的缓存缺失。
|
||
const std::size_t i0 = static_cast<std::size_t>(
|
||
std::lower_bound(time.begin() + static_cast<std::ptrdiff_t>(searchFrom), rangeEnd, s0) -
|
||
time.begin());
|
||
searchFrom = i0;
|
||
const std::size_t i1 = (column + 1 == columns)
|
||
? end
|
||
: static_cast<std::size_t>(
|
||
std::lower_bound(time.begin() + static_cast<std::ptrdiff_t>(i0),
|
||
rangeEnd, s1) -
|
||
time.begin());
|
||
|
||
double low = std::numeric_limits<double>::infinity();
|
||
double high = -std::numeric_limits<double>::infinity();
|
||
std::size_t lowOrder = 0;
|
||
std::size_t highOrder = 0;
|
||
bool broken = false;
|
||
|
||
// (一)落在本列区间内的采样点。
|
||
// 水平镜像时取的是**源下标**(区间同样反转),因此先把列区间映射到源下标区间。
|
||
const std::size_t srcA = std::min(SourceIndexFor(*resolved.style, count, i0),
|
||
SourceIndexFor(*resolved.style, count, (i1 > 0) ? (i1 - 1) : 0));
|
||
const std::size_t srcB = std::max(SourceIndexFor(*resolved.style, count, i0),
|
||
SourceIndexFor(*resolved.style, count, (i1 > 0) ? (i1 - 1) : 0));
|
||
if (i1 > i0 && hasCoarse && (i1 - i0) >= 2 * coarseStride)
|
||
{
|
||
const std::size_t firstBucket = srcA / coarseStride;
|
||
const std::size_t lastBucket =
|
||
std::min(srcB / coarseStride, coarseMin.size() - 1);
|
||
for (std::size_t bucket = firstBucket; bucket <= lastBucket; ++bucket)
|
||
{
|
||
const double bucketLow = coarseMin[bucket] * coarseScale + coarseOffset;
|
||
const double bucketHigh = coarseMax[bucket] * coarseScale + coarseOffset;
|
||
if (!std::isfinite(bucketLow))
|
||
{
|
||
// 该分组全部是无效值:包络到此断开,后续另起一段。
|
||
broken = true;
|
||
continue;
|
||
}
|
||
if (bucketLow < low)
|
||
{
|
||
low = bucketLow;
|
||
lowOrder = bucket;
|
||
}
|
||
if (bucketHigh > high)
|
||
{
|
||
high = bucketHigh;
|
||
highOrder = bucket;
|
||
}
|
||
}
|
||
}
|
||
else if (i1 > i0)
|
||
{
|
||
for (std::size_t index = srcA; index <= srcB; ++index)
|
||
{
|
||
const double value = ApplyDrawTransform(*resolved.style, values[index]);
|
||
if (!std::isfinite(value))
|
||
{
|
||
// 该列含无效值:包络绘制到本列结束,后续另起一段。
|
||
broken = true;
|
||
continue;
|
||
}
|
||
if (value < low)
|
||
{
|
||
low = value;
|
||
lowOrder = index;
|
||
}
|
||
if (value > high)
|
||
{
|
||
high = value;
|
||
highOrder = index;
|
||
}
|
||
}
|
||
}
|
||
|
||
// (二)跨越列边界的两段折线在边界处的取值。
|
||
//
|
||
// 波形按相邻采样点之间的**直线**绘制,而包络是按像素列聚合的。采样间隔
|
||
// 可能大于一个像素列(本仓库的 Problem_Data.csv 相邻采样间隔约 27µs 且不固定,
|
||
// 放大到 10ms/大格时一个像素列约 60µs),此时一次跃变的两个端点会分别落在
|
||
// 相邻两列里:对每一列来说"落在本列内的采样点"都在同一边,于是这段竖向折线
|
||
// 在两侧的包络里都不出现 → 波形在这些跃变处被整段丢掉,看上去就是"断开"。
|
||
//
|
||
// 把列边界处的折线取值计入本列包络即可消除该现象:折线在列内是线性的,
|
||
// 其极值必然出现在采样点或列边界上,因此「列内采样点 + 两侧边界取值」就是
|
||
// 该列包络的精确解,且相邻两列在边界处取到同一个值,包络带严丝合缝。
|
||
double boundary = 0.0;
|
||
if (PolylineValueAt(time, values, count, *resolved.style, i0, s0, boundary))
|
||
{
|
||
boundary = ApplyDrawTransform(*resolved.style, boundary);
|
||
if (boundary < low)
|
||
{
|
||
low = boundary;
|
||
lowOrder = (i0 > 0) ? SourceIndexFor(*resolved.style, count, i0 - 1) : 0;
|
||
}
|
||
if (boundary > high)
|
||
{
|
||
high = boundary;
|
||
highOrder = (i0 > 0) ? SourceIndexFor(*resolved.style, count, i0 - 1) : 0;
|
||
}
|
||
}
|
||
if (PolylineValueAt(time, values, count, *resolved.style, i1, s1, boundary))
|
||
{
|
||
boundary = ApplyDrawTransform(*resolved.style, boundary);
|
||
const std::size_t order = SourceIndexFor(*resolved.style, count, (i1 > 0) ? (i1 - 1) : 0);
|
||
if (boundary < low)
|
||
{
|
||
low = boundary;
|
||
lowOrder = order;
|
||
}
|
||
if (boundary > high)
|
||
{
|
||
high = boundary;
|
||
highOrder = order;
|
||
}
|
||
}
|
||
|
||
if (!(low <= high))
|
||
{
|
||
// 本列没有任何有效取值:折线尚未开始、已经结束,或该处数据缺失。
|
||
inSegment = false;
|
||
flushBandSegment();
|
||
continue;
|
||
}
|
||
|
||
const float x = plotLeft + (static_cast<float>(column) + 0.5f) * columnWidth;
|
||
const float yTop =
|
||
static_cast<float>(ClampDouble(ValueToY(high, lane), limitTop, limitBottom));
|
||
const float yBottom =
|
||
static_cast<float>(ClampDouble(ValueToY(low, lane), limitTop, limitBottom));
|
||
|
||
// 包络带每列只记录**精确**的上下包络(线宽在 `EmitEnvelopeBand` 里统一补偿:
|
||
// 纵向各外扩半个线宽 + 横向窗口膨胀,两个方向都随线宽变化)。
|
||
BandColumn bandColumn;
|
||
bandColumn.x = x;
|
||
bandColumn.top = yTop;
|
||
bandColumn.bottom = yBottom;
|
||
segmentColumns.push_back(bandColumn);
|
||
|
||
if (yBottom - yTop < kEnvelopeMinPixelSpan)
|
||
{
|
||
// 波动不足半个像素:只留一个顶点,避免无意义的重复顶点。
|
||
if (!inSegment)
|
||
{
|
||
breaks.push_back(points.size());
|
||
inSegment = true;
|
||
}
|
||
points.push_back(gd::PointF(x, yTop));
|
||
}
|
||
else
|
||
{
|
||
if (!inSegment)
|
||
{
|
||
breaks.push_back(points.size());
|
||
inSegment = true;
|
||
}
|
||
// 按采样点出现的先后顺序连接上下包络,保持走线方向一致。
|
||
if (lowOrder <= highOrder)
|
||
{
|
||
points.push_back(gd::PointF(x, yBottom));
|
||
points.push_back(gd::PointF(x, yTop));
|
||
}
|
||
else
|
||
{
|
||
points.push_back(gd::PointF(x, yTop));
|
||
points.push_back(gd::PointF(x, yBottom));
|
||
}
|
||
}
|
||
|
||
if (broken)
|
||
{
|
||
inSegment = false;
|
||
flushBandSegment();
|
||
}
|
||
}
|
||
|
||
flushBandSegment();
|
||
return;
|
||
}
|
||
|
||
std::size_t stride = 1;
|
||
if (visibleCount > budget)
|
||
{
|
||
stride = (visibleCount + budget - 1) / budget;
|
||
}
|
||
|
||
bool inSegment = false;
|
||
|
||
for (std::size_t index = begin; index < end; index += stride)
|
||
{
|
||
const double t = time[index];
|
||
// 水平镜像 = 数据反序:画面上第 index 个位置取源下标 count-1-index 的值。
|
||
const double value = ApplyDrawTransform(
|
||
*resolved.style, values[SourceIndexFor(*resolved.style, count, index)]);
|
||
if (!std::isfinite(t) || !std::isfinite(value))
|
||
{
|
||
inSegment = false;
|
||
continue;
|
||
}
|
||
|
||
// 时间偏移:该采样画在 `t + offsetTime` 处(例如 t=1s、偏移 -0.5s → 画在 0.5s)。
|
||
const float x = TimeToX(t + timeShift);
|
||
float y = ValueToY(value, lane);
|
||
y = static_cast<float>(ClampDouble(y, limitTop, limitBottom));
|
||
|
||
if (!inSegment)
|
||
{
|
||
breaks.push_back(points.size());
|
||
points.push_back(gd::PointF(x, y));
|
||
inSegment = true;
|
||
}
|
||
else
|
||
{
|
||
points.push_back(gd::PointF(x, y));
|
||
}
|
||
}
|
||
}
|
||
|
||
void PlotView::PaintEmptyState(gd::Graphics& g)
|
||
{
|
||
std::wstring message;
|
||
if (m_dataSet == nullptr || m_dataSet->Empty())
|
||
{
|
||
message = L"打开数据文件以查看波形";
|
||
}
|
||
else
|
||
{
|
||
message = L"请在左侧勾选需要查看的信号(可同时加载多个文件,各自成为一个分组)";
|
||
}
|
||
UiText(g, message, m_fonts->ui, m_area, m_theme->textMuted, TextAlign::CENTER);
|
||
}
|
||
|
||
void PlotView::BuildBoxes(UiBoxTable& boxes)
|
||
{
|
||
UiComponent::BuildBoxes(boxes);
|
||
|
||
// 本组件的基础绘制层(= 宿主组件表里的下标)。**必须显式记录并复位**:
|
||
// 下面为浮动面板临时提了一层,若不复位,宿主后续组件的层号会被整体推高。
|
||
const int baseLayer = boxes.Layer();
|
||
|
||
// 层级 1:每个网格的波形区;层级 2:该网格的 GX 编号徽标。
|
||
// ⚠️ **绝不能读 `m_decorRects`**:那里存的是"装饰层位图缓存"的登记矩形,
|
||
// 坐标处在**位图/图层坐标系**(会带负 Y、或超出绘图区),拿它当屏幕矩形会出现
|
||
// 横跨工具栏与绘图区的巨型幽灵框(用户报告的"绘制残留"的直接原因)。
|
||
// 需要装饰元素的屏幕几何时,请用 `m_legendHits`(屏幕坐标)或 `LaneBadgeRect`。
|
||
for (std::size_t index = 0; index < m_lanes.size(); ++index)
|
||
{
|
||
const LaneVisual& lane = m_lanes[index];
|
||
boxes.Add(lane.rect, 1);
|
||
|
||
// GX 编号徽标:矩形由 `LaneBadgeRect` 统一给出(绘制与命中都用它)。
|
||
boxes.Add(LaneBadgeRect(static_cast<int>(index)), 2);
|
||
}
|
||
|
||
// 层级 2/3:图例条目(托底 + 色块 + 文本),绘制时已登记在 `m_legendHits`。
|
||
for (const LegendHit& hit : m_legendHits)
|
||
{
|
||
boxes.Add(hit.rect, 2);
|
||
boxes.Add(hit.swatch, 3);
|
||
boxes.Add(hit.text, 3);
|
||
}
|
||
|
||
// 层级 1/2:绘图区里**不属于任何网格**的固定构件。
|
||
// 这些曾经全部漏掉(调试时"看得见却没框")—— 它们都已经有成员变量,直接读即可。
|
||
// ⚠️ 这些构件都绘制在**浮动面板之前**,因此与网格同层(`baseLayer`)。
|
||
boxes.SetLayer(baseLayer);
|
||
boxes.Add(m_timeAxisRect, 1);
|
||
|
||
// 层级 2/3:时间光标读数标签(避让后的实际矩形,绘制时已登记)。
|
||
boxes.Add(m_cursorSpanRect, 2);
|
||
boxes.AddAll(m_cursorLabelRects, 3);
|
||
|
||
// 下面这三样在 `paintOverlays` 里绘制在**浮动面板之后**(压在面板之上):
|
||
// 滚动条、右下角「回到最上方」悬浮按钮、内联输入框。
|
||
// ⇒ 它们的层号在下面「面板循环」之后统一给(比最上面板再高一层),
|
||
// 否则与实际像素的层序相反。这里先复位到组件基层。
|
||
boxes.SetLayer(baseLayer);
|
||
|
||
// ⚠️⚠️ **浮动面板必须另起绘制层,并把面板矩形登记为遮挡矩形**:
|
||
// 面板是不透明浮层、且常常**跨多个网格上下排列**,与网格区域重叠。
|
||
// 若与网格同层,网格的矩形边线会横穿面板 —— 用户 2026-10-29 报告的
|
||
// "网格的布局边界穿透显示在浮动面板上"。`layer` 决定谁压谁,
|
||
// `AddOccluder` 才真正把下层 box 从面板区域里**挖掉**。
|
||
|
||
// 层级 2/3/4:5 类实验性功能浮动面板的内部控件。
|
||
// 这些矩形由 `EdgePanelLayoutFor` 统一算出(绘制与命中共用),一个循环即可全部上报。
|
||
//
|
||
// ⚠️⚠️ **每个面板各占一个绘制层,且顺序必须是 `m_panelOrder`(底 → 顶)**:
|
||
// 旧实现把所有面板都放在 `baseLayer + 1` 一层,于是同一层内只能按 `depth` 排序 ——
|
||
// 下层面板的"行/文本"(depth 4)会画在**上层**面板的本体(depth 2/3)之上,
|
||
// 表现为"被遮挡的下层面板的边框/文字穿透到上层面板上"(用户 2026-10-30 报告)。
|
||
// 层号与 `paintOverlays` 里 `UiSetTextLayer` 的推进**完全同源**。
|
||
//
|
||
// 说明(已知边界):面板层号从 `baseLayer + 1` 起逐面板递增,因此**同时显示 15 个以上**
|
||
// 面板时会撞上浮层层号(`OverlayLayer(1)` = 组件数 × 3 + 3)。那时只有"菜单不再挖掉
|
||
// 面板边框"这一条调试观感受影响;实际布局下同时可见的面板是几个到十几个。
|
||
SyncPanelOrder();
|
||
const std::size_t panelCount = m_panelOrder.size();
|
||
const int topOverlayLayer = baseLayer + 1 + static_cast<int>(panelCount);
|
||
|
||
// ⚠️ **先用最便宜的判据跳过"没有面板"的网格**:`EdgePanelLayoutFor` 内部要取
|
||
// 标签页配置、逐项判断种类与开关,单次约 2~3µs;283 个网格 × 5 种 = 1415 次调用
|
||
// 实测约 **4ms/帧**(这是"开调试后卡"的主要来源之一)。
|
||
// 现在只对**层序表里真正显示出来的面板**算布局(层序表本来就只含展开且启用的面板)。
|
||
for (std::size_t ordinal = 0; ordinal < panelCount; ++ordinal)
|
||
{
|
||
const PanelRef& panelRef = m_panelOrder[ordinal];
|
||
const EdgePanelLayout layout =
|
||
EdgePanelLayoutFor(panelRef.laneIndex, static_cast<EdgePanelKind>(panelRef.kind));
|
||
if (!UiRectHasArea(layout.panel))
|
||
{
|
||
continue;
|
||
}
|
||
// 本面板独占一层(层号越大越靠上)。
|
||
boxes.SetLayer(baseLayer + 1 + static_cast<int>(ordinal));
|
||
boxes.AddOccluder(layout.panel);
|
||
// 面板内部元素的调试框同样裁到面板本身:面板滚动的内容区里,
|
||
// 滚出可视区的行其矩形仍在(与弹出菜单的条目同一类问题)。
|
||
boxes.SetClip(layout.panel);
|
||
boxes.Add(layout.panel, 2);
|
||
boxes.Add(layout.title, 3);
|
||
boxes.Add(layout.close, 3);
|
||
boxes.Add(layout.disclosure, 3);
|
||
boxes.Add(layout.badge, 3);
|
||
boxes.Add(layout.edit, 3);
|
||
boxes.Add(layout.button, 3);
|
||
boxes.Add(layout.content, 3);
|
||
boxes.Add(layout.scrollTrack, 3);
|
||
boxes.Add(layout.scrollThumb, 4);
|
||
boxes.Add(layout.opacityTrack, 3);
|
||
boxes.Add(layout.opacityValue, 3);
|
||
boxes.Add(layout.showDelta, 3);
|
||
boxes.Add(layout.showEnum, 3);
|
||
|
||
boxes.AddAll(layout.rowChecks, 4);
|
||
boxes.AddAll(layout.rowPoints, 4);
|
||
boxes.AddAll(layout.rowIterations, 4);
|
||
boxes.AddAll(layout.rowStats, 4);
|
||
boxes.AddAll(layout.rowLevelTracks, 4);
|
||
boxes.AddAll(layout.rowLevelValues, 4);
|
||
boxes.AddAll(layout.rowIterTracks, 4);
|
||
boxes.AddAll(layout.rowIterValues, 4);
|
||
boxes.AddAll(layout.rowLevelResets, 4);
|
||
boxes.AddAll(layout.rowIterResets, 4);
|
||
boxes.AddAll(layout.rowNameRects, 4);
|
||
boxes.AddAll(layout.rowNameCloses, 4);
|
||
boxes.AddAll(layout.rowTriangles, 4);
|
||
boxes.AddAll(layout.rowHeaders, 4);
|
||
|
||
for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups)
|
||
{
|
||
boxes.Add(group.header, 3);
|
||
boxes.Add(group.tableHeader, 3);
|
||
boxes.Add(group.triangle, 4);
|
||
boxes.Add(group.close, 4);
|
||
boxes.Add(group.check, 4);
|
||
}
|
||
for (const EdgePanelLayout::MarkerRow& row : layout.markerRows)
|
||
{
|
||
boxes.Add(row.rect, 4);
|
||
boxes.Add(row.close, 4);
|
||
}
|
||
}
|
||
// 收尾:解除面板裁剪并**复位层号**(宿主后续组件依赖它)。
|
||
// 浮动面板之上的叠加层(滚动条 / 回到最上方 / 内联输入框)必须比**最上面板**再高一层。
|
||
boxes.SetClip(gd::RectF());
|
||
boxes.SetLayer(topOverlayLayer);
|
||
boxes.Add(m_scrollBarRect, 5);
|
||
boxes.Add(BackToTopRect(), 5);
|
||
boxes.Add(EditFieldRect(), 5);
|
||
boxes.SetLayer(baseLayer);
|
||
}
|
||
|
||
void PlotView::Paint(gd::Graphics& g)
|
||
{
|
||
if (m_theme == nullptr || m_fonts == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// 本帧整帧都在改写渲染状态(`m_area` / `m_plotRect` / 视界 / 图层…),
|
||
// 因此必须与「后台派生曲线计算」线程隔离:本锁同时保护渲染状态与派生缓存。
|
||
// ⚠️ 图层渲染**只在本线程内进行**(见头文件说明):后台线程一旦改写
|
||
// `m_area` / `m_plotRect` / `Tab().ViewMin()`,就会覆盖用户正在进行的平移。
|
||
std::lock_guard<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]()
|
||
{
|
||
// 文本几何的层号必须跟着**真实绘制顺序**走:
|
||
// 这一段前半部分的文字(光标取值胶囊、读数标签…)在浮动面板**之下**(同组件层),
|
||
// 而 `PaintEdgeCursor` 之后的一切(面板、输入框、滚动条、回到最上方)在面板之上。
|
||
// 少了这一步,胶囊里的文字框会记在比面板更低的层,然后被面板的遮挡矩形挖掉 ——
|
||
// 或者反过来(旧实现在所有组件之上统一按固定层收集)穿透到面板表面
|
||
//(用户 2026-10-29 报告的两处现象)。
|
||
const int textLayer = UiGetTextLayer();
|
||
PaintHoverHighlight(g);
|
||
PaintLegendHighlight(g);
|
||
PaintRubberBand(g);
|
||
PaintCursor(g, m_lanes);
|
||
// 「自由标记」的跟随光标与已固定数据点(在统计面板**之下**绘制,
|
||
// 这样浮动面板仍压在最上层,与其它实验性功能的层序一致)。
|
||
PaintFreeMarker(g, m_lanes);
|
||
// 从这里开始都在面板之上:文本层同步 +1(与 `BuildBoxes` 的 `baseLayer + 1` 一致)。
|
||
// ⚠️ `PaintEdgeCursor` 内部会**逐面板**再把层号 +1(每个面板独占一层),
|
||
// 并在返回前把层号推到**最上面板之上**,因此这里不再写死 `+2`。
|
||
UiSetTextLayer(textLayer + 1);
|
||
PaintEdgeCursor(g, m_lanes);
|
||
PaintSignalInteraction(g);
|
||
PaintHint(g);
|
||
// 网格 Y 轴上下限的输入框、以及「弱峰调整 → 迭代次数上限」的输入框都绘制在最上层
|
||
//(后者覆盖在浮动面板的滑块行之上)。
|
||
if (m_editTarget == PlotEditTarget::LANE_Y_MAX ||
|
||
m_editTarget == PlotEditTarget::LANE_Y_MIN ||
|
||
m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX ||
|
||
m_editTarget == PlotEditTarget::MEAN_POINTS_MAX ||
|
||
m_editTarget == PlotEditTarget::MEAN_ITERATIONS_MAX)
|
||
{
|
||
PaintEditField(g);
|
||
}
|
||
PaintScrollBar(g);
|
||
// 右下角「回到最上方」浮动按钮:在滚动条之上绘制(随 `m_backToTopAlpha` 淡入淡出)。
|
||
PaintBackToTop(g);
|
||
|
||
// 绘图区外框(比网格内部分界线更重,形成明确整体边界)。
|
||
// 用 GDI 画 4 条线:GDI+ 的 DrawRectangle 带抗锯齿时单帧要花掉 1ms 以上。
|
||
{
|
||
const float left = m_plotRect.X;
|
||
const float top = m_plotRect.Y;
|
||
const float right = m_plotRect.X + m_plotRect.Width;
|
||
const float bottom = m_plotRect.Y + m_plotRect.Height;
|
||
const float border[16] = {left, top, right, top, right, top, right, bottom,
|
||
right, bottom, left, bottom, left, bottom, left, top};
|
||
UiDrawSolidLines(g, gd::RectF(left - 2.0f, top - 2.0f, m_plotRect.Width + 4.0f,
|
||
m_plotRect.Height + 4.0f),
|
||
border, 4, m_theme->axisLine | 0xFF000000u, 2);
|
||
}
|
||
// 复原文本层(宿主按组件下发,回收成进入时的值最保险)。
|
||
UiSetTextLayer(textLayer);
|
||
};
|
||
|
||
// 网格次序拖拽:静态场景缓存仍然有效,但各网格要按位移重新"摆放"。
|
||
// 这里逐网格把缓存中的横向条带平移贴回(几十微秒),
|
||
// 因此拖动百万级采样的网格时依然流畅,且被拖网格的波形随鼠标自由移动。
|
||
// ⚠️ `m_forceFullRepaint`(调试叠加层)下必须跳过:局部贴图会留下上一帧的边界。
|
||
if (!m_forceFullRepaint && m_dragMode == PlotDragMode::LANE_REORDER && m_laneDragFrom >= 0 &&
|
||
m_sceneValid)
|
||
{
|
||
PaintLaneReorder(g);
|
||
paintOverlays();
|
||
return;
|
||
}
|
||
|
||
// 静态场景复用(不再局限于拖拽):只要视图几何、信号集合与显示样式都没变,
|
||
// "绘图区底色 + 全部网格 + 时间轴"就逐像素相同,直接贴图即可。
|
||
// 大文件下整幅重绘约 80ms,贴图约 1ms —— 鼠标悬停、拖拽选区这些
|
||
// "只改一小块叠加层"的交互因此不再重绘波形。
|
||
// 随鼠标变化的内容(悬停高亮、读数框、选区、光标、拖拽虚影、输入框)一律画在贴图之后。
|
||
if (!m_forceFullRepaint && SceneCacheMatches() && BlitSceneCache(g))
|
||
{
|
||
// ⚠️ **顺手做一次"空闲预热"**:图层不存在(刚加载 / 网格数量变化)或仍是旧跨度时,
|
||
// 在这里用既有预算机制**每帧至多补 1 个网格**。若不预热,用户第一次平移 / 选区放大
|
||
// 时,`Paint` 会在那一帧里**同步重建全部网格图层**(5 个网格实测 30~80ms)——
|
||
// 这正是"选区放大的第一帧特别卡"的根因(该帧其余工作只有约 16ms 的占位拉伸)。
|
||
// 命中静态场景缓存 ⇒ 本帧视图与缓存同源(视界没变),因此预热出来的图层不会白费。
|
||
WarmLaneLayersStep();
|
||
paintOverlays();
|
||
return;
|
||
}
|
||
|
||
// 视界平移 / 派生预览曲线参数变化:画面**基底**(网格/波形)不变,只是横向平移或
|
||
// 叠加的派生曲线变化。直接复用「宽缓冲区」已渲染的**基底**像素,只把派生曲线作为
|
||
// 叠加层重画 —— 前台 O(1),不重画波形,因此无论放大到多深都保持跟手。
|
||
// 判定:网格结构未变(几何指纹一致)且**逐信号外观样式未变** → 属视图/派生变化,可用宽基底;
|
||
// 外观样式(颜色/粗细/透明度/模式)变化 → 交给下方"样式补丁"分支局部重画。
|
||
// ⚠️ 位置必须在"静态场景贴图"**之后**:视界与样式都没变时(典型:鼠标悬停移动)
|
||
// 应由静态场景贴图命中,绝不能落到这里 —— 否则每两帧就会触发一次"结算整幅重绘",
|
||
// 悬停反而变卡。而它又必须在"样式补丁"**之前**:派生曲线参数变化(拖弱峰调整 /
|
||
// 均值滤波滑块)同样只需叠加重画派生曲线,走本分支比"逐网格补丁"更快且质量更高。
|
||
// ⚠️ **不要求"正在拖拽"**:非拖拽的视界变化(键盘方向键 / 工具栏)同样走此快路径,
|
||
// 避免该放大区间"整幅重绘约 124ms"的卡顿。
|
||
bool wideUsable = false;
|
||
if (!m_forceFullRepaint && HasDrawableContent() && m_sceneValid &&
|
||
m_sceneGeometryHash == SceneGeometryHash())
|
||
{
|
||
std::vector<std::vector<SceneStyleEntry>> currentStyles;
|
||
CollectSceneStyles(currentStyles);
|
||
wideUsable = (currentStyles == m_sceneStyles);
|
||
}
|
||
if (wideUsable)
|
||
{
|
||
// 内容(含视界)与本帧上一条快路径帧相同 → 视界已停止变化 → 走一次整幅"结算"
|
||
// 全质量帧(保证最终呈现**像素精确**;此后命中静态场景缓存,不再回到本分支)。
|
||
std::uint64_t wideKey = SceneContentHash(true);
|
||
wideKey ^= static_cast<std::uint64_t>(std::llround(Tab().ViewMin() * 1.0e6));
|
||
wideKey *= 1099511628211ull;
|
||
wideKey ^= static_cast<std::uint64_t>(std::llround(Tab().ViewMax() * 1.0e6));
|
||
|
||
// 「结算」:视界已停止变化(内容与视界都与上一条快路径帧相同)、**不在拖拽中**、
|
||
// 且**全部网格图层都已就绪** → 落到下方整幅全质量重绘,保证最终呈现像素精确
|
||
// (此后命中静态场景缓存,不再回到本分支)。
|
||
//
|
||
// ⚠️ 三个条件缺一不可:
|
||
// * **不在拖拽中**:拖拽期间不做结算,否则每两帧就会穿插一次整幅重绘 ——
|
||
// 而拖拽帧的 `m_fastFrame` 为真(关抗锯齿),于是出现"波形质量轮流变差/抖动"
|
||
// 与周期性卡顿(这正是用户报告的"按住拖拽时显示效果下降、断线"的根因)。
|
||
// * **全部图层就绪**:还有网格没画好时不能结算,否则结算帧之后的格子又是旧的。
|
||
// * 记录已消费本 key,避免下一帧重复结算。
|
||
const bool viewStopped = (m_wideLastValid && m_wideLastKey == wideKey);
|
||
// ⚠️ 结算只用**可视网格**的就绪状态:上百个网格时若要求全部离屏图层都就绪,
|
||
// 队列按下标顺序推进会让“结算”拖到上百帧之后(期间既没有静态场景缓存可用、
|
||
// 又每帧都在做无谓的图层重建),而离屏图层的就绪与否与画面无关。
|
||
if (viewStopped && m_dragMode == PlotDragMode::NONE && LanesVisiblyReadyForView())
|
||
{
|
||
m_wideLastValid = false; // 结算:落到下方整幅重绘(非拖拽 → 全质量)
|
||
}
|
||
else
|
||
{
|
||
// ⚠️ **绝不在这里同步整批重建全部图层**:跨度变化(选区放大 / 键盘缩放 /
|
||
// 工具栏缩放)时若一次性重建 5 个网格,实测 70~80ms —— 这正是"缩放首帧卡顿"
|
||
// 的根因。改为:
|
||
// * 只有"图层数量与网格数量不符"(首次绘制 / 增删网格)才必须同步整批建立;
|
||
// * 跨度/内容过期的网格一律登记到待重画队列,由 `RollWideBuffer` 登记、
|
||
// `RenderPendingLaneRebuilds` 按预算逐帧补齐;其间 `BlitWideBuffer`
|
||
// 会用**旧跨度像素拉伸**当占位图(低分辨率但立即可用,O(1))。
|
||
if (!LayersAllocated())
|
||
{
|
||
RebuildWideBuffer();
|
||
}
|
||
RollWideBuffer();
|
||
// 预算内补画"待重画"的网格:**本帧最多一个**,其余留到后续帧
|
||
//(即使 5 个网格同时需要重画,前台也不会在某一帧里做完整批而卡顿)。
|
||
// ⚠️ **必须在贴图之前**:否则本帧画好的网格要到下一帧才显示,
|
||
// 拖拽时表现为"波形比鼠标慢半拍"、快速拖动时出现"断线"(图层覆盖不到视界)。
|
||
// ⚠️ **只在视界稳定后才花预算做精细重绘**:视界仍在变化的每一帧只贴现有像素,
|
||
// 把重建推迟到视界停下来之后(用户要求"先画了再说,别卡住前台")。
|
||
// ⚠️ 并行重建 5 个网格实测只有 1.03x(GDI+ 光栅化被串行化)⇒ 保持"每帧补一个"。
|
||
// ⚠️⚠️ **网格滚动过渡动画期间一律【不重建任何图层】**(用户 2026-10-17 明确要求):
|
||
// 滚动动画只移动视界,**与"网格内的波形有没有画好"无关**。旧实现在视界几乎停止
|
||
// 的那几帧(动画尾部里每帧只移动几个像素)放行 `RenderPendingLaneRebuilds(1)`,
|
||
// 于是恰好把**正在滚入视野的巨型信号网格**的图层重建排进了动画帧 —— 实测动画尾部
|
||
// 单帧 20ms → **34ms**(`VERY_BIG_DATA` 25M 采样的图层渲染本身就是十几毫秒量级)。
|
||
// 正确行为:动画期间未就绪的网格**只画网格底衬**(廉价、已在 `BlitWideBuffer` 里兜底),
|
||
// 波形等动画结束、视界真正停下之后再按预算逐个补。
|
||
const bool laneScrollAnimating = LaneScrollAnimationActive();
|
||
if (viewStopped && !laneScrollAnimating)
|
||
{
|
||
RenderPendingLaneRebuilds(1);
|
||
}
|
||
// 同理:动画期间不要再额外请求帧(定时器本来就在按 15.5ms 驱动重绘),
|
||
// 否则会与定时器帧叠加、把预算挤爆。
|
||
const bool morePending =
|
||
!laneScrollAnimating &&
|
||
(AnyVisibleLanePending() ||
|
||
(!m_layerRebuildPending.empty() && PrefetchStepAllowed()));
|
||
// 绘图区底色(覆盖时间轴与侧边留白区域)。
|
||
UiFillRect(g, m_area, m_theme->plotBg);
|
||
// 时间轴(宽缓冲不含它,前台按当前视图绘制)—— 必须在贴图**之前**算好:
|
||
// `BlitWideBuffer` 在图层像素缺失时要就地补画网格底衬,而底衬的竖向网格线
|
||
// 与时间轴共用同一套主刻度。
|
||
const double wideViewMin = Tab().ViewMin();
|
||
const double wideViewMax = Tab().ViewMax();
|
||
const double wideSpanNow = wideViewMax - wideViewMin;
|
||
const double wideTimeStep =
|
||
NiceStep((wideSpanNow > 0.0) ? wideSpanNow : 1.0, kTimeTickTarget);
|
||
std::vector<double> wideTimeTicks;
|
||
BuildTicks(wideViewMin, wideViewMax, wideTimeStep, wideTimeTicks);
|
||
// 贴基底(绘图区:网格 + 波形)。拿不到图层像素的网格由底衬兜底,永不空白。
|
||
if (BlitWideBuffer(g, wideTimeTicks))
|
||
{
|
||
m_wideLastKey = wideKey;
|
||
m_wideLastValid = true;
|
||
PaintTimeAxis(g, wideTimeTicks, wideTimeStep);
|
||
// 网格分界线:图层里**不含**它(它锚定在网格的屏幕位置,会被纵向偏移贴图带走),
|
||
// 因此在这里补画一次(与整幅重绘同一套颜色与线宽)。
|
||
PaintLaneDividers(g);
|
||
// 派生预览曲线:基底不含它们(拖参数滑块时基底不变、可直接复用像素)。
|
||
for (std::size_t i = 0; i < m_lanes.size(); ++i)
|
||
{
|
||
PaintLaneDerivedOverlays(g, static_cast<int>(i), m_lanes[i]);
|
||
}
|
||
// 装饰(Y 轴刻度 + GX 编号 + 图例):基底不含(否则平移会重影)。
|
||
// 内容指纹一致 → 直接按 `AlphaBlend` 贴回缓存(省掉文字绘制);
|
||
// 否则先把装饰单独绘制进透明缓存位图,再贴回。
|
||
// ⚠️ 贴回的是**带 alpha 的圆角图形**:圆角之外是全透明,
|
||
// 因此透出的是**当前**波形 —— 既没有"直角白边",也不会残留旧波形像素。
|
||
m_useDecorCache = m_decorValid && (m_decorKey == SceneDecorHash());
|
||
if (!m_useDecorCache && RenderDecorCache())
|
||
{
|
||
m_decorKey = SceneDecorHash();
|
||
m_useDecorCache = true;
|
||
}
|
||
if (m_useDecorCache)
|
||
{
|
||
BlitDecorCache(g);
|
||
} else
|
||
{
|
||
// 退化路径(无法建立透明缓存,例如系统不提供 AlphaBlend):
|
||
// 直接把装饰画进后备缓冲区(就地绘制,不做缓存)。
|
||
m_renderDecorOnly = true;
|
||
for (std::size_t i = 0; i < m_lanes.size(); ++i)
|
||
{
|
||
PaintLane(g, static_cast<int>(i), m_lanes[i], wideTimeTicks);
|
||
}
|
||
m_renderDecorOnly = false;
|
||
}
|
||
paintOverlays();
|
||
if (morePending)
|
||
{
|
||
// 请宿主再送一帧(与派生曲线就绪用同一条消息:只失效绘图区且不置脏)。
|
||
const HWND host = m_host;
|
||
if (host != nullptr)
|
||
{
|
||
PostMessageW(host, kMessagePlotDerivedReady, 0, 0);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 只有"外观样式"变化(典型场景:拖动自定义颜色滑块实时调色):网格结构、视图几何、
|
||
// 波形采样都没变,只是颜色/不透明度/线宽/模式不同 —— 此时只重画受影响的网格,
|
||
// 再写回缓存。(VERY_BIG_DATA 实测:整幅重绘 30ms+,单网格重画约 4ms。)
|
||
{
|
||
std::vector<int> patchLanes;
|
||
if (!m_forceFullRepaint && FindStyleOnlyLanes(patchLanes) && BlitSceneCache(g))
|
||
{
|
||
// ⚠️ 样式**正在连续变化**(拖透明度 / 粗细 / 拾色滑块):这本身就是一种"交互中"。
|
||
// 若不切换快速帧,本帧会用**抗锯齿**重画波形包络带 —— 实测单条网格的包络带
|
||
// AA 填充 ≈29ms(本机负载下 72ms),而关掉 AA 只要 ≈5.6ms。
|
||
// 因此补丁阶段强制快速帧,并在补丁后把场景缓存标记为"已降级",
|
||
// 下一帧(参数稳定后)自动补一帧全质量 —— 既保证拖动手感,又不牺牲最终画质。
|
||
m_fastFrame = true;
|
||
RepaintSceneCachePatch(g, patchLanes);
|
||
m_sceneDegraded = true;
|
||
// ⚠️ **同步重建受影响的宽基底图层**:补丁只更新了“静态场景缓存 + 后备缓冲区”,
|
||
// 而每网格图层(宽基底)里仍是**旧样式**的像素。若不在这里重建,紧接着命中
|
||
// “宽基底快路径”的那一帧会把刚画好的新样式**覆盖回旧像素** ——
|
||
// 用户报告:拖动线宽滑块期间信号一直按旧的线宽绘制、松手后刷新,
|
||
// 再移动鼠标又变回旧线宽(正是这个覆盖造成的)。
|
||
// 按既有预算机制重建(每帧至多 1 个网格,不会在交互帧里做整批重建)。
|
||
for (const int laneIndex : patchLanes)
|
||
{
|
||
QueueLaneRebuild(laneIndex);
|
||
}
|
||
RenderPendingLaneRebuilds(1);
|
||
paintOverlays();
|
||
return;
|
||
}
|
||
}
|
||
|
||
const double viewMin = Tab().ViewMin();
|
||
const double viewMax = Tab().ViewMax();
|
||
const double span = viewMax - viewMin;
|
||
const double timeStep = NiceStep((span > 0.0) ? span : 1.0, kTimeTickTarget);
|
||
std::vector<double> timeTicks;
|
||
BuildTicks(viewMin, viewMax, timeStep, timeTicks);
|
||
|
||
// 绘图区整体底色。
|
||
UiFillRect(g, m_area, m_theme->plotBg);
|
||
|
||
// 波形区域底色。
|
||
// ⚠️ 「网格叠底」必须用 `ThemeLaneBackground()`:主题的 `cardBg` 与 `plotBg` 过于接近时
|
||
// (深色主题)它会自动拉开一点点,保证网格这一层结构在任何主题下都看得见。
|
||
UiFillRect(g, m_plotRect, ThemeLaneBackground(*m_theme));
|
||
|
||
// 图例命中矩形每帧重建,避免残留已消失的信号。
|
||
m_legendHits.clear();
|
||
|
||
// 「装饰层」:装饰**不在网格循环里就地绘制**,而是统一由带 alpha 的装饰层贴回
|
||
// (这样圆角之外透出的是当前波形,不会出现"直角白边")。
|
||
m_decorRects.clear();
|
||
m_useDecorCache = true;
|
||
|
||
for (std::size_t index = 0; index < m_lanes.size(); ++index)
|
||
{
|
||
PaintLane(g, static_cast<int>(index), m_lanes[index], timeTicks);
|
||
}
|
||
|
||
PaintTimeAxis(g, timeTicks, timeStep);
|
||
|
||
// 装饰层:缓存与当前内容同源则直接贴回;否则先把装饰绘制进透明缓存位图再贴回。
|
||
if (!m_decorValid || m_decorKey != SceneDecorHash())
|
||
{
|
||
if (RenderDecorCache())
|
||
{
|
||
m_decorKey = SceneDecorHash();
|
||
}
|
||
}
|
||
BlitDecorCache(g);
|
||
|
||
// 此刻画布上正好是"绘图区底色 + 全部网格 + 时间轴",尚未绘制任何随鼠标变化的内容,
|
||
// 因此是捕获静态场景缓存的最佳时点(必须在裁剪区已全部恢复之后执行)。
|
||
// ⚠️ 快速帧(交互期间,关闭了抗锯齿)**仍然捕获**(这样拖动参数滑块时能走
|
||
// "只重画受影响网格"的快路径),但打上 `m_sceneDegraded` 标记 →
|
||
// `SceneCacheMatches()` 会拒绝命中,交互结束后的下一帧必定重画全质量版本。
|
||
CaptureSceneCache(g);
|
||
m_sceneDegraded = m_fastFrame;
|
||
|
||
paintOverlays();
|
||
}
|
||
|
||
|
||
void PlotView::PaintHoverHighlight(gd::Graphics& g)
|
||
{
|
||
if (m_tab == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const bool dragging = (m_dragMode != PlotDragMode::NONE);
|
||
|
||
const int saveState = g.Save();
|
||
g.SetClip(m_area, gd::CombineModeIntersect);
|
||
|
||
// 悬停网格:一层很淡的强调色叠加。为了能画在静态场景之上,
|
||
// 这里用半透明而非不透明混色,观感与原实现基本一致。
|
||
// ⚠️ 高亮来源见 `EffectiveHoverLane()`(浮动面板悬停 / 信号列表悬停),
|
||
// **不再**由"鼠标在网格里"触发。
|
||
// ⚠️ 拖拽过程中不画(避免在拖拽中改变已缓存的场景像素)。
|
||
const int hoverLane = dragging ? -1 : EffectiveHoverLane();
|
||
if (hoverLane >= 0 && hoverLane < static_cast<int>(m_lanes.size()))
|
||
{
|
||
const gd::RectF& laneRect = m_lanes[static_cast<std::size_t>(hoverLane)].rect;
|
||
const float clipTop = std::max(laneRect.Y, m_plotRect.Y);
|
||
const float clipBottom = std::min(laneRect.Y + laneRect.Height, m_plotRect.Y + m_plotRect.Height);
|
||
if (clipBottom > clipTop)
|
||
{
|
||
// alpha 0x0A ≈ 4%,与原「不透明混色 3.5%」的观感基本一致。
|
||
UiFillRect(g, gd::RectF(laneRect.X, clipTop, laneRect.Width, clipBottom - clipTop),
|
||
WithAlpha(m_theme->accent, 0x0A));
|
||
}
|
||
}
|
||
|
||
// 光晕:
|
||
// * 拖拽中 → 画**被拖拽**的信号(`SceneHaloRef()`)。
|
||
// ⚠️ 宽基底图层位图里**不能**烘光晕(`LaneLayerHash` 不含光晕状态,重建一次就会
|
||
// 把半透明光晕永久烘进去),因此这里每帧叠加画一遍(离屏位图缓存,~0.5ms)。
|
||
// * 未拖拽 → 悬停信号。鼠标在绘图区内直接命中时用 `m_hoverSignal`;
|
||
// 信号列表 / 样式区悬停时用 `m_externalHoverSignal`(用户要求列表悬停也高亮曲线)。
|
||
SignalRef haloRef = dragging ? SceneHaloRef() : m_hoverSignal;
|
||
if (!haloRef.IsValid() && !dragging)
|
||
{
|
||
haloRef = m_externalHoverSignal;
|
||
}
|
||
if (haloRef.IsValid())
|
||
{
|
||
const ResolvedSignal resolved = ResolveSignal(haloRef);
|
||
if (resolved.data != nullptr && resolved.style != nullptr && resolved.visible)
|
||
{
|
||
const int laneIndex = m_tab->LaneOfSignal(haloRef);
|
||
if (laneIndex >= 0 && laneIndex < static_cast<int>(m_lanes.size()))
|
||
{
|
||
const LaneVisual& lane = m_lanes[static_cast<std::size_t>(laneIndex)];
|
||
const PolylineCacheEntry* cached = AcquirePolyline(haloRef, resolved, lane);
|
||
if (cached != nullptr)
|
||
{
|
||
// 整条光晕一次性渲进离屏位图并缓存(命中时只贴图,~0.5ms/帧),
|
||
// 因此光晕**沿整条曲线**发光而不随鼠标移动(见 `PaintSignalHaloCached`)。
|
||
PaintSignalHaloCached(g, resolved, lane, *cached);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
g.Restore(saveState);
|
||
}
|
||
|
||
void PlotView::PaintLegendHighlight(gd::Graphics& g)
|
||
{
|
||
if (m_tab == nullptr || m_lanes.empty())
|
||
{
|
||
return;
|
||
}
|
||
|
||
const int saveState = g.Save();
|
||
g.SetClip(m_area, gd::CombineModeIntersect);
|
||
|
||
// ------------------------------------------------------------------
|
||
// (1) 网格编号徽标(GX)的悬停高亮。
|
||
// 徽标本体画在静态场景 / 装饰缓存里(不随鼠标变化),因此悬停时必须在贴图之后
|
||
// **把徽标重画一遍**(不透明底色 + 圆角 + 文字)—— 否则它在缓存里被冻结,
|
||
// 鼠标移上去看不到任何反馈(用户要求的"参考信号列表里 GX 徽标"的悬停提示)。
|
||
// ⚠️ 只在**未拖拽**时绘制:`LANE_REORDER` 期间网格连同徽标被整体平移贴图,
|
||
// 这里按静态位置重画会画到错误的地方。
|
||
// ------------------------------------------------------------------
|
||
if (m_dragMode == PlotDragMode::NONE && m_hoverBadge >= 0 &&
|
||
m_hoverBadge < static_cast<int>(m_lanes.size()))
|
||
{
|
||
const gd::RectF badge = LaneBadgeRect(m_hoverBadge);
|
||
if (badge.Width > 0.0f)
|
||
{
|
||
const float radius = kLaneBadgeHeight * m_scale * 0.5f;
|
||
// 只重画**圆角形状本身**(不透明):圆角之外保持"贴图层"的像素,
|
||
// 因此不会出现直角白边。
|
||
UiFillRoundRect(g, badge, radius, m_theme->accentSoft);
|
||
UiDrawRoundRect(g, badge, radius, m_theme->accent, 1.0f);
|
||
UiText(g, L"G" + std::to_wstring(m_hoverBadge + 1), m_fonts->monoSmall, badge,
|
||
m_theme->textStrong, TextAlign::CENTER);
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// (2) 图例条目悬停:条目托底高亮 + **对应信号曲线**的光晕。
|
||
// ------------------------------------------------------------------
|
||
if (m_dragMode != PlotDragMode::NONE || !m_hoverLegend.IsValid())
|
||
{
|
||
g.Restore(saveState);
|
||
return;
|
||
}
|
||
const int laneIndex = m_tab->LaneOfSignal(m_hoverLegend);
|
||
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
|
||
{
|
||
g.Restore(saveState);
|
||
return;
|
||
}
|
||
const gd::RectF laneRect = m_lanes[static_cast<std::size_t>(laneIndex)].rect;
|
||
if (laneRect.Y > m_plotRect.Y + m_plotRect.Height ||
|
||
laneRect.Y + laneRect.Height < m_plotRect.Y)
|
||
{
|
||
g.Restore(saveState);
|
||
return;
|
||
}
|
||
|
||
// 除了图例条目本身,还要把**对应的信号曲线**一并高亮:用户在网格图例上移动时
|
||
// 一眼就能确认这条条目对应哪条曲线(与"鼠标贴近曲线"时的光晕提示一致)。
|
||
const ResolvedSignal resolved = ResolveSignal(m_hoverLegend);
|
||
if (resolved.data != nullptr && resolved.style != nullptr && resolved.visible)
|
||
{
|
||
const LaneVisual& lane = m_lanes[static_cast<std::size_t>(laneIndex)];
|
||
if (const PolylineCacheEntry* cached = AcquirePolyline(m_hoverLegend, resolved, lane))
|
||
{
|
||
// 图例悬停:整条曲线一起发光(与鼠标命中的光晕完全同一种画法与代价)。
|
||
PaintSignalHaloCached(g, resolved, lane, *cached);
|
||
}
|
||
}
|
||
|
||
// 条目悬停高亮:**圆角托底 + 与选中态相同的高亮**(用户明确要求"悬停效果与 GX 标签
|
||
// 相同、选中状态与悬停提示效果相同"),并在其上**原样重绘色块与文字** ——
|
||
// 不透明托底若只画底色会把图例内容整块盖住(只剩一个空色块)。
|
||
for (const LegendHit& hit : m_legendHits)
|
||
{
|
||
if (hit.ref == m_hoverLegend)
|
||
{
|
||
const float radius = (hit.radius > 0.0f) ? hit.radius : (kLaneBadgeHeight * 0.5f * m_scale);
|
||
UiFillRoundRect(g, hit.rect, radius, m_theme->accentSoft);
|
||
UiDrawRoundRect(g, hit.rect, radius, m_theme->accent, 1.0f);
|
||
UiDrawSwatch(g, hit.swatch, hit.swatchColor, hit.swatchOpacity, 1.5f * m_scale, m_scale);
|
||
UiText(g, hit.label, m_fonts->uiTiny, hit.text, m_theme->textStrong, TextAlign::LEFT);
|
||
break;
|
||
}
|
||
}
|
||
g.Restore(saveState);
|
||
}
|
||
|
||
|
||
|
||
void PlotView::PaintLaneReorder(gd::Graphics& g)
|
||
{
|
||
if (m_dragMode != PlotDragMode::LANE_REORDER || m_laneDragFrom < 0 || m_sceneDc == nullptr ||
|
||
m_lanes.empty())
|
||
{
|
||
return;
|
||
}
|
||
|
||
const float laneHeight = m_lanes.front().rect.Height;
|
||
if (!(laneHeight > 0.0f))
|
||
{
|
||
return;
|
||
}
|
||
|
||
// 1) 先把整个绘图区(含时间轴)从静态场景缓存贴回:网格次序拖拽只改变网格内部的
|
||
// 条带位置,时间轴/边框等内容必须原样保留,否则鼠标拖到工具栏或时间轴上时
|
||
// 那一区域会残留上一帧的内容。
|
||
if (!BlitSceneCache(g))
|
||
{
|
||
return;
|
||
}
|
||
|
||
// 2) 把"网格区域"整片铺成绘图区底色:让位留下的空档就是它的原色,
|
||
// 网格线/波形随后按各自位移贴回。
|
||
UiFillRect(g, m_plotRect, m_theme->cardBg);
|
||
|
||
HDC target = g.GetHDC();
|
||
if (target == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// 逐网格把缓存中该网格的横向条带按位移贴到屏幕上(非常廉价,整幅波形不必重绘)。
|
||
// 被拖拽的网格最后贴,因此始终压在最上层,形成"被拎起来"的观感。
|
||
std::vector<int> laneOrder;
|
||
for (int index = 0; index < static_cast<int>(m_lanes.size()); ++index)
|
||
{
|
||
if (index != m_laneDragFrom)
|
||
{
|
||
laneOrder.push_back(index);
|
||
}
|
||
}
|
||
if (m_laneDragFrom < static_cast<int>(m_lanes.size()))
|
||
{
|
||
laneOrder.push_back(m_laneDragFrom);
|
||
}
|
||
|
||
for (const int index : laneOrder)
|
||
{
|
||
const gd::RectF& rect = m_lanes[static_cast<std::size_t>(index)].rect;
|
||
const float offset = LaneDragOffset(index);
|
||
// 源:缓存中该网格的条带;目标:屏幕上的同一条带平移 offset。
|
||
const float sourceTop = rect.Y;
|
||
const float destTop = rect.Y + offset;
|
||
// 与绘图区求交(并夹进缓存区域),避免把波形贴到工具栏或时间轴上。
|
||
const float clipTop = std::max(std::max(destTop, m_plotRect.Y), m_plotRect.Y);
|
||
const float clipBottom =
|
||
std::min(std::min(destTop + rect.Height, m_plotRect.Y + m_plotRect.Height),
|
||
m_plotRect.Y + m_plotRect.Height);
|
||
if (clipBottom - clipTop < 1.0f)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
const int x = static_cast<int>(std::floor(rect.X));
|
||
const int width = static_cast<int>(std::ceil(rect.X + rect.Width)) - x;
|
||
const int y0 = static_cast<int>(std::ceil(clipTop));
|
||
const int y1 = static_cast<int>(std::floor(clipBottom));
|
||
if (y1 <= y0 || width <= 0)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
const int sourceY = static_cast<int>(std::lround(sourceTop + (static_cast<float>(y0) - destTop)));
|
||
BitBlt(target, x, y0, width, y1 - y0, m_sceneDc, x - m_sceneX, sourceY - m_sceneY, SRCCOPY);
|
||
}
|
||
g.ReleaseHDC(target);
|
||
|
||
// 3) 目标位置的高亮提示与被拖网格的轮廓都**必须夹在网格区域内**绘制:
|
||
// 鼠标拖到工具栏/时间轴上时,若不裁剪,强调色边框会画到那些区域上。
|
||
{
|
||
const int clipState = g.Save();
|
||
g.SetClip(m_plotRect, gd::CombineModeIntersect);
|
||
|
||
// 目标位置的高亮提示(吸附提示):在被拖网格将要落到的槽位上画一层强调色底 + 边框。
|
||
const int dropSlot = (m_laneDragTarget >= 0) ? m_laneDragTarget : m_laneDragFrom;
|
||
float slotTop = m_lanes.front().rect.Y + static_cast<float>(dropSlot) * laneHeight;
|
||
if (dropSlot > m_laneDragFrom)
|
||
{
|
||
slotTop -= laneHeight;
|
||
}
|
||
const gd::RectF slot(m_plotRect.X, slotTop, m_plotRect.Width, laneHeight);
|
||
if (slot.Height > 0.0f)
|
||
{
|
||
UiFillRect(g, slot, WithAlpha(m_theme->accent, 0x1E));
|
||
UiDrawRoundRect(g, gd::RectF(slot.X + 1.0f * m_scale, slot.Y + 1.0f * m_scale,
|
||
slot.Width - 2.0f * m_scale, slot.Height - 2.0f * m_scale),
|
||
3.0f * m_scale, m_theme->accent, 1.6f * m_scale);
|
||
}
|
||
|
||
// 被拖网格的轮廓:加强"整块网格被拎起"的观感。
|
||
const gd::RectF& dragged = m_lanes[static_cast<std::size_t>(m_laneDragFrom)].rect;
|
||
const float destTop = dragged.Y + LaneDragOffset(m_laneDragFrom);
|
||
const gd::RectF outline(dragged.X + 1.0f * m_scale, destTop + 1.0f * m_scale,
|
||
dragged.Width - 2.0f * m_scale, dragged.Height - 2.0f * m_scale);
|
||
UiFillRoundRect(g, gd::RectF(outline.X + 2.0f * m_scale, outline.Y + 3.0f * m_scale,
|
||
outline.Width, outline.Height),
|
||
3.0f * m_scale, 0x22000000u);
|
||
UiDrawRoundRect(g, outline, 3.0f * m_scale, m_theme->accent, 1.8f * m_scale);
|
||
g.Restore(clipState);
|
||
}
|
||
}
|
||
|
||
|
||
/**
|
||
* @brief 为单个网格绘制网格底衬(底色 + 横向/竖向网格线 + 网格分界线)。
|
||
*
|
||
* 复用 `PaintLane` 的网格部分(通过 `m_renderGridOnly` 跳过波形 / 散点 / 派生曲线 /
|
||
* Y 轴刻度 / 图例),因此底衬与整幅重绘**逐像素同源** —— 兜底帧与就绪帧之间不会出现
|
||
* 网格线跳变。它是"网格底衬永不空白"的唯一执行点(见 `BlitWideBuffer`)。
|
||
*/
|
||
bool PlotView::PaintLaneGridBacking(gd::Graphics& g, int laneIndex, const std::vector<double>& timeTicks)
|
||
{
|
||
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()) || m_theme == nullptr ||
|
||
m_fonts == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
const bool savedGridOnly = m_renderGridOnly;
|
||
m_renderGridOnly = true;
|
||
PaintLane(g, laneIndex, m_lanes[static_cast<std::size_t>(laneIndex)], timeTicks);
|
||
m_renderGridOnly = savedGridOnly;
|
||
return true;
|
||
}
|
||
|
||
void PlotView::PaintRubberBand(gd::Graphics& g)
|
||
{
|
||
if (!m_dragMoved || m_rubberRect.Width <= 0.0f || m_rubberRect.Height <= 0.0f)
|
||
{
|
||
return;
|
||
}
|
||
if (m_dragMode != PlotDragMode::RUBBER_SELECT)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// 轴向未定时不绘制选区,避免刚按下就闪出一个奇怪的方框。
|
||
const bool timeMode = (m_rubberAxis == PlotRubberAxis::TIME);
|
||
const bool valueMode = (m_rubberAxis == PlotRubberAxis::VALUE);
|
||
if (!timeMode && !valueMode)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// 选区只应出现在绘图区内:即使内部矩形意外越界(例如鼠标被捕获到工具栏 /
|
||
// 信号面板上),也在这里再夹一次,绝不把选区块画到绘图区之外。
|
||
const float clipLeft = std::max(m_rubberRect.X, m_plotRect.X);
|
||
const float clipTop = std::max(m_rubberRect.Y, m_plotRect.Y);
|
||
const float clipRight =
|
||
std::min(m_rubberRect.X + m_rubberRect.Width, m_plotRect.X + m_plotRect.Width);
|
||
const float clipBottom =
|
||
std::min(m_rubberRect.Y + m_rubberRect.Height, m_plotRect.Y + m_plotRect.Height);
|
||
if (clipRight <= clipLeft || clipBottom <= clipTop)
|
||
{
|
||
return;
|
||
}
|
||
const gd::RectF bandRect(clipLeft, clipTop, clipRight - clipLeft, clipBottom - clipTop);
|
||
|
||
// 两种方向的选区使用**同一种强调色**,保持视觉一致。
|
||
const std::uint32_t band = m_theme->accent;
|
||
UiFillRect(g, bandRect, WithAlpha(band, 0x28));
|
||
UiDrawRoundRect(g, bandRect, 2.0f * m_scale, band, 1.4f);
|
||
|
||
// 选区跨度提示(时间用秒/数值用幅度量纲),放在选区顶部。
|
||
std::wstring text;
|
||
if (timeMode)
|
||
{
|
||
text = FormatValue(std::fabs(XToTime(m_rubberRect.X + m_rubberRect.Width) -
|
||
XToTime(m_rubberRect.X)));
|
||
}
|
||
else if (m_dragLane >= 0 && m_dragLane < static_cast<int>(m_lanes.size()))
|
||
{
|
||
const LaneVisual& lane = m_lanes[static_cast<std::size_t>(m_dragLane)];
|
||
text = FormatValue(std::fabs(YToValue(m_rubberRect.Y + m_rubberRect.Height, lane) -
|
||
YToValue(m_rubberRect.Y, lane)));
|
||
}
|
||
if (text.empty())
|
||
{
|
||
return;
|
||
}
|
||
|
||
const float textWidth = UiMeasureTextWidth(m_fonts->monoSmall, text) + 12.0f * m_scale;
|
||
const float textHeight = 18.0f * m_scale;
|
||
float labelX = bandRect.X + bandRect.Width * 0.5f - textWidth * 0.5f;
|
||
labelX = std::max(m_plotRect.X, std::min(labelX, m_plotRect.X + m_plotRect.Width - textWidth));
|
||
const gd::RectF labelRect(labelX, bandRect.Y + 3.0f * m_scale, textWidth, textHeight);
|
||
|
||
UiFillRoundRect(g, labelRect, 3.0f * m_scale, band);
|
||
UiText(g, text, m_fonts->monoSmall, labelRect, m_theme->textOnAccent, TextAlign::CENTER);
|
||
}
|
||
|
||
void PlotView::PaintHint(gd::Graphics& g)
|
||
{
|
||
if (m_hintText.empty() || m_fonts == nullptr || m_fonts->ui == nullptr || m_hintShownAt == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const std::uint64_t elapsed = GetTickCount64() - m_hintShownAt;
|
||
// 粘滞状态下不淡出(用于"拖动过程中维持显示")。
|
||
if (!m_hintSticky && elapsed >= (kHintHoldMs + kHintFadeMs))
|
||
{
|
||
return;
|
||
}
|
||
// 停留结束后在 kHintFadeMs 内线性淡出(颜色 alpha 按比例缩放)。
|
||
const double fade = (m_hintSticky || elapsed <= kHintHoldMs)
|
||
? 1.0
|
||
: (1.0 - static_cast<double>(elapsed - kHintHoldMs) /
|
||
static_cast<double>(kHintFadeMs));
|
||
const unsigned int alpha =
|
||
static_cast<unsigned int>(std::max(0.0, std::min(1.0, fade)) * 255.0);
|
||
|
||
// 提示条贴在绘图区底部居中;时间光标的时间读数也在底部,
|
||
// 因此光标启用时把提示条上移一行,避免两者互相遮挡。
|
||
const float reserved = (m_tab != nullptr && Tab().CursorEnabled()) ? 26.0f * m_scale : 0.0f;
|
||
const float width = UiMeasureTextWidth(m_fonts->ui, m_hintText) + 30.0f * m_scale;
|
||
const float height = 30.0f * m_scale;
|
||
float left = m_plotRect.X + (m_plotRect.Width - width) * 0.5f;
|
||
left = std::max(m_plotRect.X + 2.0f * m_scale,
|
||
std::min(left, m_plotRect.X + m_plotRect.Width - width - 2.0f * m_scale));
|
||
const gd::RectF pill(left, m_plotRect.Y + m_plotRect.Height - height - 4.0f * m_scale - reserved,
|
||
width, height);
|
||
|
||
UiFillRoundRect(g, pill, 4.0f * m_scale, ScaleAlpha(m_theme->readoutBg, alpha));
|
||
UiDrawRoundRect(g, pill, 4.0f * m_scale, ScaleAlpha(m_theme->borderStrong, alpha), 1.2f);
|
||
// 文字走 GDI(不支持 alpha 通道),因此把文字颜色向胶囊底色**混色**来实现淡出。
|
||
const std::uint32_t textColor =
|
||
MixColor(m_theme->textStrong, m_theme->readoutBg, 1.0f - static_cast<float>(fade));
|
||
UiText(g, m_hintText, m_fonts->ui, pill, textColor, TextAlign::CENTER);
|
||
}
|
||
|
||
void PlotView::PaintLane(gd::Graphics& g, int laneIndex, const LaneVisual& lane,
|
||
const std::vector<double>& timeTicks)
|
||
{
|
||
// 网格内容必须裁剪在绘图区之内:纵向滚动时网格会部分移出可视范围,
|
||
// 若不与 m_plotRect 求交,波形就会溢出到工具栏与时间轴区域。
|
||
const float clipTop = std::max(lane.rect.Y, m_plotRect.Y);
|
||
const float clipBottom =
|
||
std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height);
|
||
if (clipBottom <= clipTop)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const int saveState = g.Save();
|
||
// 与当前裁剪区求交(而不是替换):局部重画(RepaintSceneCachePatch)时
|
||
// 外层已经按“需要重画的网格范围”设过裁剪区,这里必须保留,否则会重画到
|
||
// 其他网格上。整幅重绘时外层没有裁剪区,等同于原来的行为。
|
||
//
|
||
// ⚠️ **本次调用只允许改动 `m_area`** —— 它既是"整幅渲染"的绘图区,也是
|
||
// "信号图层"的**条带**(横向补条 = 窄 x 区间 × 全高;纵向补条 = 全宽 × 窄 y 区间)。
|
||
// GDI 绘制(网格线 / 分界线)**不受 GDI+ 裁剪约束**,所以它们的裁剪矩形必须
|
||
// **同时**与 `m_area` 求交:否则纵向补条时横向网格线会被画到条带之外的已正确像素上、
|
||
// 覆盖在波形之上("网格线盖住波形"),并**永久烘进图层**、随之后的每次平移一起移动
|
||
// —— 正是用户报告的"上下拖动之后,左右/上下/斜向拖动都出现绘制错误"。
|
||
const gd::RectF laneClip = IntersectRect(
|
||
gd::RectF(m_area.X, m_area.Y, m_area.Width, m_area.Height),
|
||
gd::RectF(lane.rect.X, clipTop, lane.rect.Width, clipBottom - clipTop));
|
||
g.SetClip(laneClip, gd::CombineModeIntersect);
|
||
|
||
|
||
// 幅值网格线(先画线,标尺稍后作为覆盖层绘制,避免被波形遮挡)。
|
||
// 刻度密度随网格高度自适应:所有网格高度相同 → 各网格的刻度数量一致,
|
||
// 不会出现“有的网格只有 1 条标签、有的有 3 条”的不一致观感。
|
||
//
|
||
// ⚠️ 网格线一律用 **GDI** 批量绘制:GDI+ 的 DrawLine 每条约 0.1–0.19ms,
|
||
// 而一张网格有几十条网格线(5 个网格就是数百条),逐条调用会让单帧多花十几毫秒
|
||
// —— 这正是"放大后拖动网格视界很卡"的主因。GDI 绘制不受 GDI+ 裁剪约束,
|
||
// 因此显式传入裁剪矩形;半透明辅助线先与网格底色混合成不透明色。
|
||
const gd::RectF gridClip = laneClip;
|
||
/** 标尺数值文本的裁剪矩形(GDI 文本需要显式裁剪,否则会越过绘图区)。 */
|
||
const gd::RectF clipRectForLabels = gridClip;
|
||
const std::uint32_t laneBackground = ThemeLaneBackground(*m_theme);
|
||
const std::uint32_t minorLineColor = UiBlendOver(laneBackground, WithAlpha(m_theme->gridWeak, kMinorLineAlpha));
|
||
const std::uint32_t weakLineColor = m_theme->gridWeak | 0xFF000000u;
|
||
const std::uint32_t strongLineColor = m_theme->gridStrong | 0xFF000000u;
|
||
|
||
int yTickTarget = static_cast<int>(std::lround(lane.rect.Height / (kYTickSpacing * m_scale)));
|
||
yTickTarget = std::max(kYTickTargetMin, std::min(yTickTarget, kYTickTargetMax));
|
||
// ⚠️ 渲染「信号图层」时,幅值刻度必须以**前台可见网格**的跨度/高度为基准:
|
||
// 图层画布比可见网格高(上下各留预渲染余量),若按画布高度与扩展后的跨度取刻度,
|
||
// 图里的网格线密度就与整幅渲染不一致 —— 贴图后拖动时网格线会跳变。
|
||
// 用可见跨度取步长 + 按扩展范围铺满整张画布 ⇒ 刻度落在同一批绝对值格点上,
|
||
// 于是"纵向平移 = 纯粹的像素平移"(逐像素同源)。
|
||
const double tickSpan = (m_renderWide && m_wideTickSpan > 0.0)
|
||
? m_wideTickSpan
|
||
: (lane.yMax - lane.yMin);
|
||
if (m_renderWide && m_wideTickHeight > 0.0f)
|
||
{
|
||
yTickTarget = static_cast<int>(
|
||
std::lround(m_wideTickHeight / (kYTickSpacing * m_scale)));
|
||
yTickTarget = std::max(kYTickTargetMin, std::min(yTickTarget, kYTickTargetMax));
|
||
}
|
||
const double yStep = NiceStep(tickSpan, yTickTarget);
|
||
std::vector<double> yTicks;
|
||
BuildTicks(lane.yMin, lane.yMax, yStep, yTicks);
|
||
|
||
// 五等分估读线:在相邻主刻度之间再细分,便于目视读取中间值。
|
||
const double yMinorStep = yStep / static_cast<double>(kMinorDivisions);
|
||
std::vector<double> yMinorTicks;
|
||
BuildTicks(lane.yMin, lane.yMax, yMinorStep, yMinorTicks);
|
||
std::vector<float> minorSegments;
|
||
std::vector<float> weakSegments;
|
||
std::vector<float> strongSegments;
|
||
minorSegments.reserve(yMinorTicks.size() * 4);
|
||
weakSegments.reserve(yTicks.size() * 4);
|
||
for (const double tick : yMinorTicks)
|
||
{
|
||
// 与主刻度重合的位置跳过(主刻度会以更重的线条绘制)。
|
||
const double majorIndex = tick / yStep;
|
||
if (std::fabs(majorIndex - std::floor(majorIndex + 0.5)) < 1.0e-6)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
const float y = ValueToY(tick, lane);
|
||
if (y < lane.rect.Y - 1.0f || y > lane.rect.Y + lane.rect.Height + 1.0f)
|
||
{
|
||
continue;
|
||
}
|
||
minorSegments.insert(minorSegments.end(),
|
||
{lane.rect.X, y, lane.rect.X + lane.rect.Width, y});
|
||
}
|
||
|
||
for (const double tick : yTicks)
|
||
{
|
||
const float y = ValueToY(tick, lane);
|
||
if (y < lane.rect.Y - 1.0f || y > lane.rect.Y + lane.rect.Height + 1.0f)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
// 零线用更重的颜色(单独一批,避免为每段颜色切换画笔)。
|
||
std::vector<float>& target = (tick == 0.0) ? strongSegments : weakSegments;
|
||
target.insert(target.end(), {lane.rect.X, y, lane.rect.X + lane.rect.Width, y});
|
||
}
|
||
|
||
// 时间网格线(同样补充五等分估读线)。
|
||
// ⚠️ 主刻步长优先取 `m_renderTimeStep`(渲染信号图层时由前台跨度给出,见该成员的说明):
|
||
// 图层渲染的 `Tab().ViewMin/ViewMax` 是**宽窗口**区间,直接拿它算 `NiceStep` 会得到比前台
|
||
// 更大的步长(往往正好 2 倍),使图层里的竖向网格线与前台错位、部分整条丢失。
|
||
const double timeSpan = Tab().ViewMax() - Tab().ViewMin();
|
||
const double timeMajorStep =
|
||
(m_renderTimeStep > 0.0) ? m_renderTimeStep
|
||
: NiceStep((timeSpan > 0.0) ? timeSpan : 1.0, kTimeTickTarget);
|
||
const double timeMinorStep = timeMajorStep / static_cast<double>(kMinorDivisions);
|
||
std::vector<double> timeMinorTicks;
|
||
BuildTicks(Tab().ViewMin(), Tab().ViewMax(), timeMinorStep, timeMinorTicks);
|
||
for (const double tick : timeMinorTicks)
|
||
{
|
||
const double majorIndex = tick / timeMajorStep;
|
||
if (std::fabs(majorIndex - std::floor(majorIndex + 0.5)) < 1.0e-6)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
const float x = TimeToX(tick);
|
||
if (x < lane.rect.X || x > lane.rect.X + lane.rect.Width)
|
||
{
|
||
continue;
|
||
}
|
||
minorSegments.insert(minorSegments.end(),
|
||
{x, lane.rect.Y, x, lane.rect.Y + lane.rect.Height});
|
||
}
|
||
|
||
for (const double tick : timeTicks)
|
||
{
|
||
const float x = TimeToX(tick);
|
||
if (x < lane.rect.X || x > lane.rect.X + lane.rect.Width)
|
||
{
|
||
continue;
|
||
}
|
||
weakSegments.insert(weakSegments.end(), {x, lane.rect.Y, x, lane.rect.Y + lane.rect.Height});
|
||
}
|
||
|
||
if (!m_renderDecorOnly)
|
||
{
|
||
if (!minorSegments.empty())
|
||
{
|
||
UiDrawSolidLines(g, gridClip, minorSegments.data(), minorSegments.size() / 4, minorLineColor);
|
||
}
|
||
if (!weakSegments.empty())
|
||
{
|
||
UiDrawSolidLines(g, gridClip, weakSegments.data(), weakSegments.size() / 4, weakLineColor);
|
||
}
|
||
if (!strongSegments.empty())
|
||
{
|
||
UiDrawSolidLines(g, gridClip, strongSegments.data(), strongSegments.size() / 4, strongLineColor);
|
||
}
|
||
}
|
||
|
||
// 波形绘制。
|
||
const std::vector<SignalRef>& laneSignals = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].signals;
|
||
if (m_renderDecorOnly)
|
||
{
|
||
// 只画装饰:网格线与波形已由宽基底提供,这里跳过。
|
||
}
|
||
else if (m_renderGridOnly)
|
||
{
|
||
// 只画网格底衬(宽基底快路径的兜底帧):跳过波形、散点与派生预览曲线。
|
||
// 网格线在上方已经画好(`!m_renderDecorOnly` 分支)。
|
||
}
|
||
else if (laneSignals.empty())
|
||
{
|
||
const gd::RectF visibleRect(lane.rect.X, clipTop, lane.rect.Width, clipBottom - clipTop);
|
||
UiText(g, L"(空网格)", m_fonts->uiSmall, visibleRect, m_theme->textMuted, TextAlign::CENTER);
|
||
}
|
||
else
|
||
{
|
||
const int waveSave = g.Save();
|
||
g.SetClip(lane.rect, gd::CombineModeIntersect);
|
||
|
||
// 只有拖拽中的信号光晕可以进入静态场景缓存(悬停光晕见 PaintHoverHighlight)。
|
||
const SignalRef haloRef = SceneHaloRef();
|
||
// 派生预览曲线(中心轴 / 均值滤波 / 弱峰调整)在下方"第二遍"统一叠加绘制,
|
||
// 见 `PaintLaneDerivedOverlays`(抽出以便宽缓冲区前台路径复用)。
|
||
|
||
for (const SignalRef& ref : laneSignals)
|
||
{
|
||
const ResolvedSignal resolved = ResolveSignal(ref);
|
||
if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
const DrawMode mode = resolved.style->mode;
|
||
// 顶点由“几何键”缓存提供:同一视图下重复绘制不再重算全量采样。
|
||
const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, lane);
|
||
if (cached == nullptr)
|
||
{
|
||
continue;
|
||
}
|
||
const std::vector<gd::PointF>& points = cached->points;
|
||
const std::vector<std::size_t>& breaks = cached->breaks;
|
||
|
||
const bool drawLine = (mode != DrawMode::SCATTER);
|
||
const bool drawScatter = (mode == DrawMode::SCATTER || mode == DrawMode::LINE_SCATTER);
|
||
|
||
// 悬停命中的信号先画一层浅色光晕,作为“可拖拽”的视觉提示。
|
||
// 这里只处理拖拽中的信号(其光晕在拖拽期间不变,可以进入静态场景缓存);
|
||
// 悬停光晕随鼠标变化,改由 PaintHoverHighlight 在缓存贴图之后绘制。
|
||
//
|
||
// ⚠️ **宽基底图层(`m_renderWide`)必须跳过光晕**:
|
||
// 图层位图会被反复贴图复用,而 `LaneLayerHash` 不含光晕状态 ⇒
|
||
// 拖拽期间重建一次图层就会把半透明光晕**烘进位图**,松手后一直残留。
|
||
// 拖拽光晕由 `PaintHoverHighlight` 每帧叠加绘制。
|
||
const bool highlighted = (ref == haloRef) && !m_renderWide && !m_renderGridOnly;
|
||
if (highlighted)
|
||
{
|
||
// 拖拽光晕整条描边并进入静态场景缓存(拖拽期间不随鼠标变化,不必裁剪)。
|
||
PaintSignalHaloFor(g, resolved, lane, *cached);
|
||
}
|
||
|
||
if (drawLine && !breaks.empty())
|
||
{
|
||
const std::uint32_t lineColor = StyleColor(*resolved.style);
|
||
if (cached->bandMode && !cached->bandBreaks.empty())
|
||
{
|
||
// 稠密包络:一次 FillPath 填充包络带。
|
||
// **必须显式设为缠绕填充规则**:包络带自交(相邻列上下包络交叉)时,
|
||
// 交替(奇偶)规则会在交叠处挖出空洞。
|
||
//
|
||
// 抗锯齿开关:几何保持不变,只在"交互期间"或"梳状带"时关闭。
|
||
// 梳状带(振荡周期≈像素列宽)下抗锯齿的扫描线代价暴涨而观感几乎无差别,
|
||
// 实测同一条 2448 顶点的路径 27.6ms → 3.2ms。
|
||
const int savedSmoothing = g.GetSmoothingMode();
|
||
if (m_fastFrame || cached->bandSpiky)
|
||
{
|
||
g.SetSmoothingMode(gd::SmoothingModeNone);
|
||
}
|
||
gd::GraphicsPath bandPath;
|
||
bandPath.SetFillMode(gd::FillModeWinding);
|
||
for (std::size_t k = 0; k < cached->bandBreaks.size(); ++k)
|
||
{
|
||
const std::size_t start = cached->bandBreaks[k];
|
||
const std::size_t stop = (k + 1 < cached->bandBreaks.size())
|
||
? cached->bandBreaks[k + 1]
|
||
: cached->band.size();
|
||
const std::size_t total = stop - start;
|
||
if (total < 4 || (total % 2) != 0)
|
||
{
|
||
continue;
|
||
}
|
||
const std::size_t half = total / 2;
|
||
bandPath.StartFigure();
|
||
bandPath.AddLines(cached->band.data() + start, static_cast<INT>(half));
|
||
bandPath.AddLines(cached->band.data() + start + half,
|
||
static_cast<INT>(half));
|
||
bandPath.CloseFigure();
|
||
}
|
||
if (bandPath.GetPointCount() > 0)
|
||
{
|
||
gd::SolidBrush brush(ToGdiColor(lineColor));
|
||
g.FillPath(&brush, &bandPath);
|
||
}
|
||
g.SetSmoothingMode(static_cast<gd::SmoothingMode>(savedSmoothing));
|
||
}
|
||
else
|
||
{
|
||
gd::Pen pen(ToGdiColor(lineColor), resolved.style->lineWidth * m_scale);
|
||
pen.SetLineJoin(gd::LineJoinRound);
|
||
pen.SetStartCap(gd::LineCapRound);
|
||
pen.SetEndCap(gd::LineCapRound);
|
||
|
||
for (std::size_t k = 0; k < breaks.size(); ++k)
|
||
{
|
||
const std::size_t start = breaks[k];
|
||
const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size();
|
||
if (stop <= start + 1)
|
||
{
|
||
continue;
|
||
}
|
||
g.DrawLines(&pen, points.data() + start, static_cast<INT>(stop - start));
|
||
}
|
||
}
|
||
}
|
||
|
||
if (drawScatter && !points.empty())
|
||
{
|
||
// 散点半径随线宽线性增长(达到上限前不过早触顶),使“线宽”直观控制数据点大小。
|
||
const float radius = static_cast<float>(
|
||
ClampDouble(static_cast<double>(resolved.style->lineWidth) * kScatterRadiusScale,
|
||
kMinScatterRadius, kMaxScatterRadius) *
|
||
m_scale);
|
||
const std::size_t scatterStride =
|
||
std::max<std::size_t>(1, points.size() / kMaxScatterPoints);
|
||
// 所有散点合并到一条 GraphicsPath 后只调用一次 FillPath:
|
||
// 逐点 FillEllipse 每次都要完整走一遍 GDI+ 的状态检查与光栅化入口,
|
||
// 实测 2500 点需要约 51ms;合并后约 1/9(实测基准 63ms → 7ms)。
|
||
// **必须显式设为缠绕填充规则**:GraphicsPath 默认是交替(奇偶)规则,
|
||
// 两个重叠的圆在交集处会互相抵消形成空洞,表现为"数据点重叠处出现异常白边"。
|
||
gd::GraphicsPath scatterPath;
|
||
scatterPath.SetFillMode(gd::FillModeWinding);
|
||
for (std::size_t k = 0; k < points.size(); k += scatterStride)
|
||
{
|
||
scatterPath.AddEllipse(points[k].X - radius, points[k].Y - radius, radius * 2.0f,
|
||
radius * 2.0f);
|
||
}
|
||
gd::SolidBrush brush(ToGdiColor(StyleColor(*resolved.style)));
|
||
if (scatterPath.GetPointCount() > 0)
|
||
{
|
||
g.FillPath(&brush, &scatterPath);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 第二遍:把所有**派生预览曲线**统一绘制在**本网格全部原信号之上**。
|
||
// 渲染「宽缓冲区」时跳过:宽缓冲只存"基底"(网格/波形,不含派生曲线与装饰),
|
||
// 派生曲线改由前台叠加层绘制(见 `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;
|
||
}
|
||
|
||
// 图例固定在网格左上角:网格被滚动出可视区时整体跳过,避免文字溢出到工具栏。
|
||
//
|
||
// ⚠️ **提前返回前必须先清掉本网格的旧命中矩形**:图例只在"完全可见"时才绘制,
|
||
// 但命中测试用的是 `m_legendHits`。若网格滚到只露出半条图例带时直接 return,
|
||
// 上一帧登记的命中矩形会留在原位 —— `LegendAtPoint` 仍会在旧屏幕位置返回该信号,
|
||
// 于是点击/双击会命中一个**看不见**的图例条目(选中/改名到错误的信号)。
|
||
const gd::RectF legendBand(lane.rect.X + kLegendPad * m_scale,
|
||
lane.rect.Y + kLegendPad * m_scale, lane.rect.Width,
|
||
kLegendHeight * m_scale);
|
||
if (!IsFullyVisible(legendBand, visibleTop, visibleBottom))
|
||
{
|
||
for (auto iterator = m_legendHits.begin(); iterator != m_legendHits.end();)
|
||
{
|
||
if (m_tab->LaneOfSignal(iterator->ref) == laneIndex)
|
||
{
|
||
iterator = m_legendHits.erase(iterator);
|
||
}
|
||
else
|
||
{
|
||
++iterator;
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
const std::vector<SignalRef>& laneSignals = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].signals;
|
||
const bool multiGroup = LaneSpansMultipleGroups(laneIndex);
|
||
|
||
const float top = legendBand.Y;
|
||
const float height = kLegendHeight * m_scale;
|
||
const float swatchSize = kLegendSwatchSize * m_scale;
|
||
const float maxX = lane.rect.X + lane.rect.Width - kLanePadding * m_scale;
|
||
|
||
// 网格编号徽标(图例左侧):与左侧信号面板每行末尾的 “GX” 徽标样式完全一致。
|
||
// 矩形由 LaneBadgeRect 统一计算,绘制与命中测试因此永远一致。
|
||
const gd::RectF badgeRect = LaneBadgeRect(laneIndex);
|
||
const float badgeHeight = kLaneBadgeHeight * m_scale;
|
||
float cursorX = badgeRect.X + badgeRect.Width + kLaneBadgeGap * m_scale;
|
||
|
||
// 图例条目的托底 = **与 GX 编号徽标完全同款**(用户明确要求):
|
||
// * 高度与徽标相同(`bgadeRect` 的上下边界一模一样,因此"上下边距相同");
|
||
// * 圆角 = 高度的一半(胶囊形),与徽标的 `badgeHeight * 0.5f` 一致;
|
||
// * 配色与描边宽度也与徽标一致(`cardBg/border`,选中态 `accentSoft/accent`);
|
||
// * 水平内边距 = 胶囊半径(若小于半径,内容会溢出圆角之外)。
|
||
// * 文本改用**更小的字体**(`uiTiny`):原用 `uiSmall` 时文字几乎顶满胶囊高度。
|
||
// ⚠️ 托底矩形**同时就是命中矩形**(看到的与点得到的严格一致)。
|
||
const float plateHeight = badgeHeight;
|
||
const float plateTop = badgeRect.Y;
|
||
const float platePadX = plateHeight * 0.5f;
|
||
|
||
// ------------------------------------------------------------------
|
||
// 1) 几何:算出"放得下"的条目及其矩形(**唯一来源**,命中 / 装饰 / 绘制共用)。
|
||
// ------------------------------------------------------------------
|
||
struct LegendItem
|
||
{
|
||
SignalRef ref;
|
||
float left = 0.0f; // 色块左缘
|
||
float width = 0.0f; // 条目**内容**宽度(色块 + 间距 + 文本)
|
||
float plateWidth = 0.0f; // 托底宽度(内容 + 两侧内边距)
|
||
float textWidth = 0.0f;
|
||
std::wstring label;
|
||
bool selected = false;
|
||
};
|
||
std::vector<LegendItem> items;
|
||
int visibleCount = 0;
|
||
float contentRight = cursorX;
|
||
{
|
||
float probeX = cursorX;
|
||
for (const SignalRef& ref : laneSignals)
|
||
{
|
||
const ResolvedSignal resolved = ResolveSignal(ref);
|
||
if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible)
|
||
{
|
||
continue;
|
||
}
|
||
++visibleCount;
|
||
std::wstring label = BuildLegendText(*resolved.data);
|
||
if (multiGroup && resolved.group != nullptr)
|
||
{
|
||
label += L" · " + resolved.group->name;
|
||
}
|
||
const float maxTextWidth = kLegendMaxTextWidth * m_scale;
|
||
const float fullTextWidth = UiMeasureTextWidth(m_fonts->uiTiny, label);
|
||
const float textWidth = std::min(fullTextWidth, maxTextWidth);
|
||
const float contentWidth = swatchSize + 5.0f * m_scale + textWidth;
|
||
const float plateWidth = contentWidth + platePadX * 2.0f;
|
||
if (probeX + plateWidth > maxX)
|
||
{
|
||
break; // 空间不足:其余条目省略,下方用 "+N" 提示
|
||
}
|
||
LegendItem item;
|
||
item.ref = ref;
|
||
item.left = probeX + platePadX; // 色块左缘(托底内缩一个内边距)
|
||
item.width = contentWidth;
|
||
item.plateWidth = plateWidth;
|
||
item.textWidth = textWidth;
|
||
item.label = (fullTextWidth > maxTextWidth)
|
||
? UiEllipsize(label, m_fonts->uiTiny, maxTextWidth)
|
||
: label;
|
||
item.selected = (SelectedRef() == ref);
|
||
items.push_back(std::move(item));
|
||
probeX += plateWidth + kLegendItemGap * m_scale;
|
||
}
|
||
contentRight = probeX;
|
||
}
|
||
const auto PlateRect = [&](const LegendItem& item)
|
||
{
|
||
return gd::RectF(item.left - platePadX, plateTop, item.plateWidth, plateHeight);
|
||
};
|
||
// 条目托底 = 与 GX 编号徽标同一套风格的**胶囊形圆角矩形**(半径 = 高度的一半)。
|
||
const float plateRadius = plateHeight * 0.5f;
|
||
// 「+N」(图例空间不足)与「(无可见信号)」提示的矩形。
|
||
// ⚠️ 它们同样属于装饰层、必须登记进 `m_decorRects`:否则会被画进装饰位图
|
||
// 却因不在贴回列表里而永远看不见。
|
||
const bool hasMoreText = (visibleCount > static_cast<int>(items.size())) &&
|
||
(contentRight + 20.0f * m_scale <= maxX);
|
||
const bool hasEmptyText = (visibleCount == 0);
|
||
const gd::RectF moreTextRect(contentRight, top, 24.0f * m_scale, height);
|
||
const gd::RectF emptyTextRect(legendBand.X, top, lane.rect.Width, height);
|
||
|
||
// ------------------------------------------------------------------
|
||
// 2) 命中矩形:**无论是否命中装饰缓存都必须重建**。
|
||
// 整幅重绘路径会先清空 `m_legendHits`;若这里因为"装饰缓存命中"而提前返回、
|
||
// 不写入新矩形,图例的悬停高亮 / 点击选中 / 双击改名就会**全部失效**
|
||
//(用户报告:"用中键移动过视角之后图例点不动、悬停也没反应")。
|
||
// ------------------------------------------------------------------
|
||
for (auto iterator = m_legendHits.begin(); iterator != m_legendHits.end();)
|
||
{
|
||
if (m_tab->LaneOfSignal(iterator->ref) == laneIndex)
|
||
{
|
||
iterator = m_legendHits.erase(iterator);
|
||
}
|
||
else
|
||
{
|
||
++iterator;
|
||
}
|
||
}
|
||
for (const LegendItem& item : items)
|
||
{
|
||
const ResolvedSignal resolved = ResolveSignal(item.ref);
|
||
LegendHit hit;
|
||
hit.ref = item.ref;
|
||
hit.rect = PlateRect(item);
|
||
hit.radius = plateRadius;
|
||
hit.swatch =
|
||
gd::RectF(item.left, plateTop + (plateHeight - swatchSize) * 0.5f, swatchSize, swatchSize);
|
||
hit.text = gd::RectF(item.left + swatchSize + 5.0f * m_scale, plateTop,
|
||
std::max(8.0f, item.textWidth + 2.0f), plateHeight);
|
||
hit.label = item.label;
|
||
// 「选中」与「悬停」用同一套配色(用户明确要求),因此文字色在这里也按选中态取。
|
||
hit.textColor = item.selected ? m_theme->textStrong : m_theme->text;
|
||
if (resolved.style != nullptr)
|
||
{
|
||
hit.swatchColor = resolved.style->color;
|
||
hit.swatchOpacity = resolved.style->opacity;
|
||
}
|
||
m_legendHits.push_back(std::move(hit));
|
||
if (m_editTarget == PlotEditTarget::SIGNAL_NAME && m_editSignal == item.ref)
|
||
{
|
||
m_editFieldRect = PlateRect(item);
|
||
}
|
||
}
|
||
|
||
// 装饰矩形:**逐块**(徽标一块 + 每个条目一块),而不是覆盖整条图例带 ——
|
||
// 覆盖整条带没有必要,且逐块能减少装饰层贴图面积。
|
||
m_decorRects.push_back(badgeRect);
|
||
for (const LegendItem& item : items)
|
||
{
|
||
m_decorRects.push_back(PlateRect(item));
|
||
}
|
||
if (hasEmptyText)
|
||
{
|
||
m_decorRects.push_back(emptyTextRect);
|
||
}
|
||
if (hasMoreText)
|
||
{
|
||
m_decorRects.push_back(moreTextRect);
|
||
}
|
||
|
||
// 命中装饰缓存:徽标与图例的像素改由装饰层贴回,跳过重绘(命中矩形已在上方重建)。
|
||
if (m_useDecorCache)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// 3) 绘制。托底是**圆角矩形**(与 GX 编号徽标同风格):
|
||
// "一个 GX 标签,只是内容换成了图例信息"(用户明确要求)。
|
||
// ⚠️ **不再铺任何外扩的不透明直角底色**:装饰位图带 alpha,圆角之外必须保持
|
||
// 全透明,贴回时才能透出**当前**波形 —— 否则圆角外会多出一圈"直角白边"。
|
||
// ⚠️ 徽标与条目的**悬停 / 激活高亮不在这里**(随鼠标变化,画在贴图之后,
|
||
// 见 PaintLegendHighlight)—— 否则会被缓存冻结,悬停时看不到任何反馈。
|
||
// ------------------------------------------------------------------
|
||
{
|
||
UiFillRoundRect(g, badgeRect, badgeHeight * 0.5f, m_theme->cardBg);
|
||
UiDrawRoundRect(g, badgeRect, badgeHeight * 0.5f, m_theme->border, 1.0f);
|
||
UiText(g, L"G" + std::to_wstring(laneIndex + 1), m_fonts->monoSmall, badgeRect,
|
||
m_theme->text, TextAlign::CENTER);
|
||
|
||
for (const LegendItem& item : items)
|
||
{
|
||
const ResolvedSignal resolved = ResolveSignal(item.ref);
|
||
if (resolved.data == nullptr || resolved.style == nullptr)
|
||
{
|
||
continue;
|
||
}
|
||
const gd::RectF plate = PlateRect(item);
|
||
// 选中态 = 悬停态配色(`accentSoft` 底 + `accent` 描边),
|
||
// 与 GX 编号徽标的鼠标悬停效果完全一致。
|
||
UiFillRoundRect(g, plate, plateRadius,
|
||
item.selected ? m_theme->accentSoft : m_theme->cardBg);
|
||
UiDrawRoundRect(g, plate, plateRadius,
|
||
item.selected ? m_theme->accent : m_theme->border, 1.0f);
|
||
|
||
// 改名编辑态:整块托底变成输入框(与左侧列表双击改名一致)。
|
||
if (m_editTarget == PlotEditTarget::SIGNAL_NAME && m_editSignal == item.ref)
|
||
{
|
||
UiDrawEditField(g, plate, m_edit, m_fonts->uiTiny, m_scale);
|
||
continue;
|
||
}
|
||
|
||
UiDrawSwatch(g,
|
||
gd::RectF(item.left, plateTop + (plateHeight - swatchSize) * 0.5f,
|
||
swatchSize, swatchSize),
|
||
resolved.style->color, resolved.style->opacity, 1.5f * m_scale, m_scale);
|
||
UiText(g, item.label, m_fonts->uiTiny,
|
||
gd::RectF(item.left + swatchSize + 5.0f * m_scale, plateTop,
|
||
std::max(8.0f, item.textWidth + 2.0f), plateHeight),
|
||
item.selected ? m_theme->textStrong : m_theme->text, TextAlign::LEFT);
|
||
}
|
||
}
|
||
|
||
if (hasEmptyText)
|
||
{
|
||
UiText(g, L"(无可见信号)", m_fonts->uiTiny, emptyTextRect, m_theme->textMuted,
|
||
TextAlign::LEFT);
|
||
}
|
||
else if (hasMoreText)
|
||
{
|
||
// 图例空间不足时提示剩余信号数量,避免误认为信号丢失。
|
||
const std::wstring moreText =
|
||
L"+" + std::to_wstring(visibleCount - static_cast<int>(items.size()));
|
||
UiText(g, moreText, m_fonts->uiTiny, moreTextRect, m_theme->textMuted, TextAlign::LEFT);
|
||
}
|
||
}
|
||
|
||
bool PlotView::IsFullyVisible(const gd::RectF& rect, float visibleTop, float visibleBottom)
|
||
{
|
||
return (rect.Y >= visibleTop - 0.5f) && (rect.Y + rect.Height <= visibleBottom + 0.5f);
|
||
}
|
||
|
||
void PlotView::PaintTimeAxis(gd::Graphics& g, const std::vector<double>& timeTicks, double timeStep)
|
||
{
|
||
const float axisY = m_plotRect.Y + m_plotRect.Height;
|
||
// 时间轴的轴线与刻度线同样走 GDI 批量绘制(理由见 PaintLane 中的说明)。
|
||
const gd::RectF axisClip(m_area.X, m_area.Y, m_area.Width, m_area.Height);
|
||
const std::uint32_t axisColor = m_theme->axisLine | 0xFF000000u;
|
||
{
|
||
const float axisSegment[4] = {m_plotRect.X, axisY, m_plotRect.X + m_plotRect.Width, axisY};
|
||
UiDrawSolidLines(g, axisClip, axisSegment, 1, axisColor);
|
||
}
|
||
|
||
// 时间轴列名(取自首个可见信号所属分组的第一条时间轴),贴左侧绘制。
|
||
std::wstring axisName = L"时间";
|
||
if (m_dataSet != nullptr && m_tab != nullptr)
|
||
{
|
||
for (std::size_t groupIndex = 0; groupIndex < m_dataSet->GroupCount(); ++groupIndex)
|
||
{
|
||
if (!m_tab->GroupHasVisible(static_cast<int>(groupIndex)))
|
||
{
|
||
continue;
|
||
}
|
||
const SignalGroup* group = m_dataSet->GroupAt(static_cast<int>(groupIndex));
|
||
if (group != nullptr && !group->axes.empty() && !group->axes.front().name.empty())
|
||
{
|
||
axisName = group->axes.front().name;
|
||
// CSV 中该列通常直接叫 "time",这里补上时间单位秒便于阅读;
|
||
// 若列名自带单位描述(以右括号结尾)则不重复追加。
|
||
if (axisName.back() != L')')
|
||
{
|
||
axisName += L" (s)";
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
const float nameWidth =
|
||
std::min(UiMeasureTextWidth(m_fonts->monoSmall, axisName) + 8.0f * m_scale,
|
||
m_plotRect.Width * 0.4f);
|
||
const gd::RectF nameRect(m_plotRect.X + 2.0f * m_scale, axisY + 3.0f * m_scale, nameWidth,
|
||
std::max(0.0f, m_timeAxisRect.Height - 5.0f * m_scale));
|
||
UiText(g, UiEllipsize(axisName, m_fonts->monoSmall, nameWidth), m_fonts->monoSmall, nameRect,
|
||
m_theme->textMuted, TextAlign::LEFT);
|
||
|
||
const float labelWidth = 96.0f * m_scale;
|
||
|
||
// 五等分子刻度线:在相邻主刻度之间插入 4 条短刻度(更长的主刻度在最前绘制,
|
||
// 短刻度与主刻度重合的位置跳过),便于目视读取中间时刻。
|
||
const double minorStep = timeStep / static_cast<double>(kMinorDivisions);
|
||
std::vector<double> minorTicks;
|
||
BuildTicks(Tab().ViewMin(), Tab().ViewMax(), minorStep, minorTicks);
|
||
std::vector<float> minorSegments;
|
||
std::vector<float> majorSegments;
|
||
minorSegments.reserve(minorTicks.size() * 4);
|
||
majorSegments.reserve(timeTicks.size() * 4);
|
||
for (const double tick : minorTicks)
|
||
{
|
||
const double majorIndex = tick / timeStep;
|
||
if (std::fabs(majorIndex - std::floor(majorIndex + 0.5)) < 1.0e-6)
|
||
{
|
||
continue;
|
||
}
|
||
const float x = TimeToX(tick);
|
||
if (x < m_plotRect.X - 1.0f || x > m_plotRect.X + m_plotRect.Width + 1.0f)
|
||
{
|
||
continue;
|
||
}
|
||
minorSegments.insert(minorSegments.end(), {x, axisY, x, axisY + 3.0f * m_scale});
|
||
}
|
||
|
||
for (const double tick : timeTicks)
|
||
{
|
||
const float x = TimeToX(tick);
|
||
if (x < m_plotRect.X - 1.0f || x > m_plotRect.X + m_plotRect.Width + 1.0f)
|
||
{
|
||
continue;
|
||
}
|
||
majorSegments.insert(majorSegments.end(), {x, axisY, x, axisY + 4.0f * m_scale});
|
||
}
|
||
if (!minorSegments.empty())
|
||
{
|
||
UiDrawSolidLines(g, axisClip, minorSegments.data(), minorSegments.size() / 4, axisColor);
|
||
}
|
||
if (!majorSegments.empty())
|
||
{
|
||
UiDrawSolidLines(g, axisClip, majorSegments.data(), majorSegments.size() / 4, axisColor);
|
||
}
|
||
|
||
for (const double tick : timeTicks)
|
||
{
|
||
const float x = TimeToX(tick);
|
||
if (x < m_plotRect.X - 1.0f || x > m_plotRect.X + m_plotRect.Width + 1.0f)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
// 刻度标签夹在绘图区横向范围内,并与左侧列名互不重叠。
|
||
float labelX = x - labelWidth * 0.5f;
|
||
labelX = std::max(m_plotRect.X, std::min(labelX, m_plotRect.X + m_plotRect.Width - labelWidth));
|
||
const gd::RectF labelRect(labelX, axisY + 4.0f * m_scale, labelWidth,
|
||
std::max(0.0f, m_timeAxisRect.Height - 6.0f * m_scale));
|
||
if (labelRect.X < nameRect.X + nameRect.Width)
|
||
{
|
||
continue;
|
||
}
|
||
UiText(g, FormatTick(tick, timeStep), m_fonts->monoSmall, labelRect, m_theme->textMuted,
|
||
TextAlign::CENTER);
|
||
}
|
||
}
|
||
|
||
|
||
void PlotView::DrawLabelOutline(gd::Graphics& g, const gd::RectF& rect, float radius,
|
||
std::uint32_t outlineColor)
|
||
{
|
||
// 描边色由 `UiContrastInk` 与文字色一起算出(恒与文字色反相、且随信号配色线性变化)。
|
||
// 这样胶囊压在同类色背景或信号曲线上时仍有清晰的边界。
|
||
UiDrawRoundRect(g, rect, radius, outlineColor, 1.0f);
|
||
}
|
||
|
||
void PlotView::PaintCursorOffViewTip(gd::Graphics& g, const gd::RectF& labelRect, bool offRight,
|
||
std::uint32_t color)
|
||
{
|
||
// 三角画在读数胶囊的**外侧**(尖端朝外、指向光标实际所在的那一侧),
|
||
// 与胶囊之间留出 `kMarkerTriangleGap` 的空白。
|
||
//
|
||
// 旧实现把三角压在胶囊**上沿**且水平居中:既没有间隙(看起来像胶囊长了个尖角),
|
||
// 又与网格内"数据点越界"的画法(左右三角)不统一。现在两处共用同一套画法。
|
||
const float arm = kMarkerTriangleArm * m_scale;
|
||
const float depth = kMarkerTriangleDepth * m_scale;
|
||
const float gap = kMarkerTriangleGap * m_scale;
|
||
const float centerY = labelRect.Y + labelRect.Height * 0.5f;
|
||
gd::PointF outer[3];
|
||
if (offRight)
|
||
{
|
||
const float baseX = labelRect.X + labelRect.Width + gap;
|
||
outer[0] = gd::PointF(baseX, centerY - arm);
|
||
outer[1] = gd::PointF(baseX, centerY + arm);
|
||
outer[2] = gd::PointF(baseX + depth, centerY);
|
||
}
|
||
else
|
||
{
|
||
const float baseX = labelRect.X - gap;
|
||
outer[0] = gd::PointF(baseX, centerY - arm);
|
||
outer[1] = gd::PointF(baseX, centerY + arm);
|
||
outer[2] = gd::PointF(baseX - depth, centerY);
|
||
}
|
||
gd::SolidBrush brush(ToGdiColor(color));
|
||
g.FillPolygon(&brush, outer, 3);
|
||
}
|
||
|
||
void PlotView::PaintCursor(gd::Graphics& g, const std::vector<LaneVisual>& lanes)
|
||
{
|
||
if (!Tab().CursorEnabled() || m_tab == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const bool doubleMode = (Tab().CursorMode() == PlotCursorMode::DOUBLE);
|
||
const int cursorCount = doubleMode ? 2 : 1;
|
||
|
||
// 本次绘制重新统计已占用的取值标注矩形(用于避免多个信号/光标的内容互相覆盖)。
|
||
// 先把底部的时间读数/差值标签也登记进去:它们在取值标注之后绘制、会盖在上面,
|
||
// 因此取值标注必须主动避开它们,否则会被遮住半个字。
|
||
m_cursorLabelRects.clear();
|
||
for (int which = 1; which <= cursorCount; ++which)
|
||
{
|
||
const gd::RectF timeRect = CursorLabelRect(which);
|
||
if (timeRect.Width > 0.0f)
|
||
{
|
||
m_cursorLabelRects.push_back(timeRect);
|
||
}
|
||
}
|
||
if (doubleMode)
|
||
{
|
||
bool insideAxis = false;
|
||
const gd::RectF spanRect = CursorSpanRect(insideAxis);
|
||
if (spanRect.Width > 0.0f)
|
||
{
|
||
m_cursorLabelRects.push_back(spanRect);
|
||
}
|
||
}
|
||
|
||
// 重叠时把"最近操作过的那条光标"的内容画在上层,因此先画另一条。
|
||
const int iterations[2] = { (m_activeCursor == 2) ? 1 : 2, (m_activeCursor == 2) ? 2 : 1 };
|
||
|
||
// 增量差值**只**显示在"右侧光标"上。右侧 = 时间较大的那条,随拖拽实时变化:
|
||
// 把右光标拖到左边后,增量会自动改由新的右侧光标显示。
|
||
const int rightCursor = (Tab().CursorTime() <= Tab().CursorTimeAlt()) ? 2 : 1;
|
||
|
||
// ---- 光标竖线 + 顶部把手 ----
|
||
for (int pass = 0; pass < cursorCount; ++pass)
|
||
{
|
||
const int which = doubleMode ? iterations[pass] : 1;
|
||
const double time = (which == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime();
|
||
const float x = TimeToX(time);
|
||
if (x < m_plotRect.X - 2.0f || x > m_plotRect.X + m_plotRect.Width + 2.0f)
|
||
{
|
||
// 光标滞留在可视区外:不画竖线(其取值/时间读数会在下面贴边显示)。
|
||
continue;
|
||
}
|
||
|
||
const bool hot = (m_hoverCursor == which);
|
||
const std::uint32_t color = CursorColor();
|
||
const float lineTop = m_plotRect.Y;
|
||
const float lineBottom = m_timeAxisRect.Y + m_timeAxisRect.Height;
|
||
UiDrawLine(g, x, lineTop, x, lineBottom, color, (hot ? 2.0f : 1.4f) * m_scale);
|
||
|
||
// 顶部三角把手(两条光标样式一致,靠颜色区分)。
|
||
const float arm = 5.0f * m_scale;
|
||
gd::PointF points[3];
|
||
points[0] = gd::PointF(x - arm, lineTop);
|
||
points[1] = gd::PointF(x + arm, lineTop);
|
||
points[2] = gd::PointF(x, lineTop + arm * 1.4f);
|
||
gd::SolidBrush handle(ToGdiColor(color));
|
||
g.FillPolygon(&handle, points, 3);
|
||
}
|
||
|
||
// ---- 数据点取值标注 ----
|
||
for (int pass = 0; pass < cursorCount; ++pass)
|
||
{
|
||
const int which = doubleMode ? iterations[pass] : 1;
|
||
const double time = (which == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime();
|
||
const double otherTime = (which == 2) ? Tab().CursorTime() : Tab().CursorTimeAlt();
|
||
// 只有右侧光标显示增量差值(左光标只显示自身取值)。
|
||
const bool showDelta = doubleMode && (which == rightCursor);
|
||
|
||
// 光标在可视区外时,把标注"钉"在对应的网格边上,保证取值仍然可见。
|
||
// 具体是否越界(左右/上下)由 MakeDataMarker 逐点判定。
|
||
const float pinX = CursorDisplayX(which);
|
||
const float rawX = TimeToX(time);
|
||
|
||
for (std::size_t laneIndex = 0; laneIndex < lanes.size(); ++laneIndex)
|
||
{
|
||
const LaneVisual& lane = lanes[laneIndex];
|
||
const float clipTop = std::max(lane.rect.Y, m_plotRect.Y);
|
||
const float clipBottom =
|
||
std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height);
|
||
if (clipBottom <= clipTop)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
for (const SignalRef& ref : m_tab->Lanes()[laneIndex].signals)
|
||
{
|
||
const ResolvedSignal resolved = ResolveSignal(ref);
|
||
if (resolved.data == nullptr || resolved.style == nullptr ||
|
||
resolved.axis == nullptr || !resolved.visible)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
const bool interpolate = (resolved.style->mode != DrawMode::SCATTER);
|
||
double value = 0.0;
|
||
if (!SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style, time,
|
||
interpolate, value))
|
||
{
|
||
continue;
|
||
}
|
||
// 枚举文本按**采样值原值**查找(下面会叠加系数/绝对值/镜像等界面变换,
|
||
// 那些变换只影响画出来的数值,不改变「这个采样值代表哪个状态」)。
|
||
const double enumLookupValue = value;
|
||
// 取值读数必须与画出来的波形一致:同样套用系数/绝对值/镜像。
|
||
value = ApplyDrawTransform(*resolved.style, value);
|
||
|
||
const float rawY = ValueToY(value, lane);
|
||
// 上下越界的数据点不再被丢弃:与左右越界一致,贴边绘制指示方向的三角。
|
||
// 上边界额外预留光标顶部把手的高度,避免数据点三角压住光标把手。
|
||
// 非 const:上下越界时下面还要把方向三角与取值标注纵向居中对齐。
|
||
DataMarker marker =
|
||
MakeDataMarker(rawX, rawY, pinX, clipTop, clipBottom, kCursorHandleReserve * m_scale);
|
||
|
||
const std::uint32_t signalColor = StyleColor(*resolved.style);
|
||
|
||
// 数据点:视界内为圆点,任一方向越界时改为贴边三角。
|
||
// **先算好取值标注、再画数据点**:上下越界时的方向三角需要与标注
|
||
// 纵向居中对齐,而标注的位置要经过避让处理才能最终确定。
|
||
const float radius = 3.4f * m_scale;
|
||
|
||
// 取值标注:底色用信号色,文字/描边用随信号配色线性相关的高对比度配色。
|
||
std::wstring text = FormatValue(value);
|
||
if (showDelta)
|
||
{
|
||
double otherValue = 0.0;
|
||
if (SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style, otherTime,
|
||
interpolate, otherValue))
|
||
{
|
||
otherValue = ApplyDrawTransform(*resolved.style, otherValue);
|
||
const double delta = value - otherValue;
|
||
// 增量 = 右侧光标取值 − 左侧光标取值,包在圆括号里并按差值显式给出正负号。
|
||
text += L" (";
|
||
text += (delta < 0.0) ? L"-" : L"+";
|
||
text += FormatValue(std::fabs(delta));
|
||
text += L")";
|
||
}
|
||
}
|
||
|
||
// 信号带「值 → 文本」枚举表时(如 MDF4 `cc_type = 7`),在数值之后追加
|
||
// ` = <枚举文本>` 尾缀(等号两侧各一个空格、不用尖括号)。
|
||
// ⚠️ 常规状态(未启用光标)不显示任何枚举文本,观感与以前完全一致。
|
||
if (const std::wstring* enumText = resolved.data->enumText.Find(enumLookupValue))
|
||
{
|
||
text += L" = ";
|
||
text += *enumText;
|
||
}
|
||
|
||
const float textWidth = UiMeasureTextWidth(m_fonts->monoSmall, text) + 10.0f * m_scale;
|
||
const float textHeight = 17.0f * m_scale;
|
||
// 左右越界时三角贴在视界内侧、尖端朝外;数值标注必须放在三角的**内侧**
|
||
// 并留出 `kMarkerTriangleGap` 的空白,否则三角会像"多出来的一个尖角"
|
||
// (用户报告的"三角形与数值显示部分没有间隔")。
|
||
float labelLeft;
|
||
if (marker.offLeft)
|
||
{
|
||
labelLeft = marker.x + kMarkerTriangleGap * m_scale;
|
||
}
|
||
else if (marker.offRight)
|
||
{
|
||
labelLeft = marker.x - kMarkerTriangleGap * m_scale - textWidth;
|
||
}
|
||
else
|
||
{
|
||
labelLeft = pinX + radius + 4.0f * m_scale;
|
||
if (labelLeft + textWidth > m_plotRect.X + m_plotRect.Width - 2.0f * m_scale)
|
||
{
|
||
labelLeft = pinX - radius - 4.0f * m_scale - textWidth;
|
||
}
|
||
}
|
||
labelLeft = std::max(
|
||
m_plotRect.X + 2.0f * m_scale,
|
||
std::min(labelLeft, m_plotRect.X + m_plotRect.Width - textWidth - 2.0f * m_scale));
|
||
float labelTop = marker.y - textHeight * 0.5f;
|
||
labelTop = std::max(clipTop + 1.0f * m_scale,
|
||
std::min(labelTop, clipBottom - textHeight - 1.0f * m_scale));
|
||
gd::RectF labelRect(labelLeft, labelTop, textWidth, textHeight);
|
||
|
||
// 多个信号的取值标注可能落在同一位置(数据点重合或过近):
|
||
// 依次向下(再向上)避让已占用的标注,保证不会互相覆盖。
|
||
PlaceCursorLabel(labelRect, labelTop, textHeight, clipTop, clipBottom);
|
||
|
||
// 越界方向三角与最终落位的取值标注纵向居中对齐
|
||
// (用户明确要求“超过上下视界时三角需要与显示的数值居中对齐”;
|
||
// 左右越界时同样对齐,避免三角与数值错行、看起来不协调)。
|
||
if (marker.offUp || marker.offDown || marker.offLeft || marker.offRight)
|
||
{
|
||
marker.y = labelRect.Y + labelRect.Height * 0.5f;
|
||
}
|
||
|
||
// ⚠️ 数据点圆点被底部的「时间读数 / Δt 面板」盖住时,圆点根本看不见
|
||
//(时间读数在取值标注之后绘制、直接盖在上面)。此时改成**向下的三角**。
|
||
CoverHiddenMarker(marker, radius, clipTop);
|
||
PaintDataMarker(g, marker, radius, signalColor, m_theme->cardBg);
|
||
|
||
const UiInk ink = UiContrastInk(signalColor);
|
||
UiFillRoundRect(g, labelRect, 3.0f * m_scale, WithAlpha(signalColor, 0xF0));
|
||
DrawLabelOutline(g, labelRect, 3.0f * m_scale, ink.outline);
|
||
UiText(g, text, m_fonts->monoSmall, labelRect, ink.text, TextAlign::CENTER);
|
||
|
||
// 「边沿计数(中心轴)」/「均值滤波」/「弱峰调整」开启时,纵向光标还要额外标注
|
||
// **派生曲线**在该时刻的取值(它不是信号,因此单独标注,不并入上面的取值文案)。
|
||
// 三条曲线只是曲线来源不同,标注规则完全一致,故共用同一段绘制逻辑。
|
||
// ⚠️ 曲线只取「可见区间 ∪ 两个光标时刻 ± 保护带」的窗口(与绘制共用缓存),
|
||
// 因此拖动光标/拖动参数滑块都不会触发整条曲线的重算。
|
||
const auto paintDerivedCapsule = [&](DerivedCurveKind kind, int points,
|
||
int iterations, int amountPercent,
|
||
const std::wstring& suffix)
|
||
{
|
||
// ⚠️⚠️ 取派生曲线在**光标准确时刻**的取值(相邻两个采样点之间按时间线性插值),
|
||
// 而不是“最近采样点”的曲线值。旧实现取最近采样点 ⇒ 光标落在两个采样点
|
||
// 之间时,标注点会脱离画出来的折线(用户 2026-10-27 报告的“预览曲线数值点悬空”)。
|
||
// ⚠️ 插值必须在**屏幕空间**(即先套用「调整」变换再线性插值):
|
||
// `PaintDerivedCurve` 逐顶点取 `ApplyDrawTransform` 后用直线连接,
|
||
// 而绝对值 / 垂直镜像不是线性变换,先插值再变换会偏离折线。
|
||
const std::size_t total =
|
||
std::min(resolved.axis->time.size(), resolved.data->values.size());
|
||
if (total == 0)
|
||
{
|
||
return;
|
||
}
|
||
const std::size_t sourceCount = resolved.data->values.size();
|
||
|
||
/** 某个显示时刻两侧的采样点(时间序下标)与插值比例。 */
|
||
struct CurveSampleRef
|
||
{
|
||
int first = -1;
|
||
int second = -1;
|
||
double fraction = 0.0;
|
||
};
|
||
const auto locate = [&](double displayTime) -> CurveSampleRef
|
||
{
|
||
CurveSampleRef ref;
|
||
const double target = displayTime - resolved.style->offsetTime;
|
||
if (resolved.axis->monotonic)
|
||
{
|
||
const auto lower = std::lower_bound(
|
||
resolved.axis->time.begin(),
|
||
resolved.axis->time.begin() + static_cast<std::ptrdiff_t>(total),
|
||
target);
|
||
const std::size_t hi = static_cast<std::size_t>(
|
||
lower - resolved.axis->time.begin());
|
||
if (hi >= total)
|
||
{
|
||
ref.first = ref.second = static_cast<int>(total - 1);
|
||
return ref;
|
||
}
|
||
if (hi == 0)
|
||
{
|
||
ref.first = ref.second = 0;
|
||
return ref;
|
||
}
|
||
ref.first = static_cast<int>(hi - 1);
|
||
ref.second = static_cast<int>(hi);
|
||
const double t0 =
|
||
resolved.axis->time[static_cast<std::size_t>(ref.first)];
|
||
const double t1 =
|
||
resolved.axis->time[static_cast<std::size_t>(ref.second)];
|
||
ref.fraction =
|
||
(t1 > t0)
|
||
? std::max(0.0, std::min(1.0, (target - t0) / (t1 - t0)))
|
||
: 0.0;
|
||
return ref;
|
||
}
|
||
// 非单调时间轴:无法按时间插值,退回最近采样点。
|
||
const int nearest = FindNearestSample(*resolved.axis, target);
|
||
if (nearest < 0 || static_cast<std::size_t>(nearest) >= total)
|
||
{
|
||
return CurveSampleRef();
|
||
}
|
||
ref.first = ref.second = nearest;
|
||
return ref;
|
||
};
|
||
// 时间序下标 → 源下标。派生曲线是在 `signal.values` 的**源下标顺序**上计算的
|
||
//(`ComputeDerivedWindow` 直接切 `values[windowBegin..windowEnd)`),
|
||
// 水平镜像(数据反序)时两者相反 ⇒ 必须经 `SourceIndexFor` 映射。
|
||
const auto sourceIndexAt = [&](int timeIndex) -> int
|
||
{
|
||
if (timeIndex < 0 ||
|
||
static_cast<std::size_t>(timeIndex) >= sourceCount)
|
||
{
|
||
return -1;
|
||
}
|
||
return static_cast<int>(SourceIndexFor(
|
||
*resolved.style, sourceCount, static_cast<std::size_t>(timeIndex)));
|
||
};
|
||
|
||
const CurveSampleRef here = locate(time);
|
||
if (here.first < 0)
|
||
{
|
||
return;
|
||
}
|
||
const CurveSampleRef other = showDelta ? locate(otherTime) : CurveSampleRef();
|
||
|
||
// 本函数**只需要光标附近那几个下标的派生取值**,因此把请求窗口收窄到
|
||
// 「需要的那几个下标」即可,均值滤波半径造成的取值依赖由 `CurveSpanFor`
|
||
// 自身的保护带负责覆盖。
|
||
//
|
||
// ⚠️ 绝不能把窗口扩成「光标 ↔ 视界边缘」的并集:光标停在视界之外(例如
|
||
// 平移视界把光标甩到很远,或光标远离当前缩放区间)时会得到一个上千万采样的
|
||
// 巨大窗口,超过缓存上限后走一次性缓冲 → **每帧重算整段滤波**,
|
||
// 实测单帧 300ms+(用户报告的"平移/拖动光标异常卡顿"的直接原因)。
|
||
std::size_t begin = sourceCount;
|
||
std::size_t end = 0;
|
||
const auto includeTime = [&](int timeIndex)
|
||
{
|
||
const int source = sourceIndexAt(timeIndex);
|
||
if (source < 0)
|
||
{
|
||
return;
|
||
}
|
||
const std::size_t index = static_cast<std::size_t>(source);
|
||
begin = std::min(begin, index);
|
||
end = std::max(end, index + 1);
|
||
};
|
||
includeTime(here.first);
|
||
includeTime(here.second);
|
||
if (other.first >= 0)
|
||
{
|
||
includeTime(other.first);
|
||
includeTime(other.second);
|
||
}
|
||
if (end <= begin)
|
||
{
|
||
return;
|
||
}
|
||
std::size_t spanBegin = 0;
|
||
const std::vector<float>* curvePtr =
|
||
CurveSpanFor(kind, *resolved.data, points, iterations, amountPercent, begin,
|
||
end, spanBegin, true);
|
||
if (curvePtr == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
const auto curveValueAt = [curvePtr, spanBegin](int index) -> double
|
||
{
|
||
if (index < 0 || static_cast<std::size_t>(index) < spanBegin ||
|
||
(static_cast<std::size_t>(index) - spanBegin) >= curvePtr->size())
|
||
{
|
||
return std::numeric_limits<double>::quiet_NaN();
|
||
}
|
||
return static_cast<double>((*curvePtr)[static_cast<std::size_t>(index) -
|
||
spanBegin]);
|
||
};
|
||
// 求某个显示时刻的「画出来的曲线值」(已套用「调整」变换,与折线逐点一致)。
|
||
const auto curveDisplayedValue = [&](const CurveSampleRef& ref,
|
||
double& out) -> bool
|
||
{
|
||
const int source0 = sourceIndexAt(ref.first);
|
||
if (source0 < 0)
|
||
{
|
||
return false;
|
||
}
|
||
double value0 = curveValueAt(source0);
|
||
if (!std::isfinite(value0))
|
||
{
|
||
return false;
|
||
}
|
||
value0 = ApplyDrawTransform(*resolved.style, value0);
|
||
if (!std::isfinite(value0))
|
||
{
|
||
return false;
|
||
}
|
||
if (ref.second == ref.first || ref.fraction <= 0.0)
|
||
{
|
||
out = value0;
|
||
return true;
|
||
}
|
||
const int source1 = sourceIndexAt(ref.second);
|
||
if (source1 < 0)
|
||
{
|
||
return false;
|
||
}
|
||
double value1 = curveValueAt(source1);
|
||
if (!std::isfinite(value1))
|
||
{
|
||
return false;
|
||
}
|
||
value1 = ApplyDrawTransform(*resolved.style, value1);
|
||
if (!std::isfinite(value1))
|
||
{
|
||
return false;
|
||
}
|
||
out = value0 + (value1 - value0) * ref.fraction;
|
||
return true;
|
||
};
|
||
|
||
double derivedValue = 0.0;
|
||
if (!curveDisplayedValue(here, derivedValue))
|
||
{
|
||
return;
|
||
}
|
||
|
||
const float rawCenterY = ValueToY(derivedValue, lane);
|
||
DataMarker centerMarker = MakeDataMarker(
|
||
rawX, rawCenterY, pinX, clipTop, clipBottom, kCursorHandleReserve * m_scale);
|
||
// 派生数据点的配色与原始信号一致(便于一一对应);
|
||
// 但**始终按线段模式**绘制(不跟随原信号的散点模式)。
|
||
const std::uint32_t centerColor = StyleColor(*resolved.style);
|
||
|
||
// 取值文本:数值在前,尾缀「(中心轴)/(均值滤波)」;双光标的右侧光标
|
||
// 还要附上增量,形如「(+0.0008 中心轴)」(参考常规信号的增量显示)。
|
||
std::wstring centerText = FormatValue(derivedValue);
|
||
centerText += L"(";
|
||
if (showDelta)
|
||
{
|
||
double otherDerived = 0.0;
|
||
if (curveDisplayedValue(other, otherDerived))
|
||
{
|
||
const double delta = derivedValue - otherDerived;
|
||
centerText += (delta < 0.0) ? L"-" : L"+";
|
||
centerText += FormatValue(std::fabs(delta));
|
||
centerText += L" ";
|
||
}
|
||
}
|
||
centerText += suffix;
|
||
centerText += L")";
|
||
|
||
const float centerTextWidth =
|
||
UiMeasureTextWidth(m_fonts->monoSmall, centerText) + 10.0f * m_scale;
|
||
// 与常规取值标注同一规则:左右越界时标注要让开外侧的方向三角。
|
||
float centerLeft;
|
||
if (centerMarker.offLeft)
|
||
{
|
||
centerLeft = centerMarker.x + kMarkerTriangleGap * m_scale;
|
||
}
|
||
else if (centerMarker.offRight)
|
||
{
|
||
centerLeft = centerMarker.x - kMarkerTriangleGap * m_scale - centerTextWidth;
|
||
}
|
||
else
|
||
{
|
||
centerLeft = pinX + radius + 4.0f * m_scale;
|
||
if (centerLeft + centerTextWidth >
|
||
m_plotRect.X + m_plotRect.Width - 2.0f * m_scale)
|
||
{
|
||
centerLeft = pinX - radius - 4.0f * m_scale - centerTextWidth;
|
||
}
|
||
}
|
||
centerLeft = std::max(
|
||
m_plotRect.X + 2.0f * m_scale,
|
||
std::min(centerLeft,
|
||
m_plotRect.X + m_plotRect.Width - centerTextWidth - 2.0f * m_scale));
|
||
float centerTop = centerMarker.y - textHeight * 0.5f;
|
||
centerTop = std::max(clipTop + 1.0f * m_scale,
|
||
std::min(centerTop, clipBottom - textHeight - 1.0f * m_scale));
|
||
gd::RectF centerRect(centerLeft, centerTop, centerTextWidth, textHeight);
|
||
PlaceCursorLabel(centerRect, centerTop, textHeight, clipTop, clipBottom);
|
||
// 与原始信号同一规则:越界方向三角与取值标注纵向居中对齐。
|
||
if (centerMarker.offUp || centerMarker.offDown || centerMarker.offLeft ||
|
||
centerMarker.offRight)
|
||
{
|
||
centerMarker.y = centerRect.Y + centerRect.Height * 0.5f;
|
||
}
|
||
// 同原始信号:圆点被底部时间读数面板盖住时改为向下三角。
|
||
CoverHiddenMarker(centerMarker, radius, clipTop);
|
||
PaintDataMarker(g, centerMarker, radius, centerColor, m_theme->cardBg);
|
||
// 颜色样式与原始信号一致(同底色/同描边/同文字配色)。
|
||
const UiInk centerInk = UiContrastInk(centerColor);
|
||
UiFillRoundRect(g, centerRect, 3.0f * m_scale, WithAlpha(centerColor, 0xE0));
|
||
DrawLabelOutline(g, centerRect, 3.0f * m_scale, centerInk.outline);
|
||
UiText(g, centerText, m_fonts->monoSmall, centerRect, centerInk.text,
|
||
TextAlign::CENTER);
|
||
};
|
||
|
||
if (m_tab->Lanes()[laneIndex].edgeCenter)
|
||
{
|
||
// 只标注被勾选(即真正绘制了曲线)的信号。
|
||
const Lane::EdgeCenterItem* centerItem =
|
||
m_tab->Lanes()[laneIndex].FindEdgeCenterItem(ref);
|
||
if (centerItem != nullptr)
|
||
{
|
||
paintDerivedCapsule(DerivedCurveKind::MEAN_FILTER, centerItem->points,
|
||
centerItem->iterations, 0, L"中心轴");
|
||
}
|
||
}
|
||
if (m_tab->Lanes()[laneIndex].meanFilter)
|
||
{
|
||
// 只标注被勾选(即真正绘制了预览曲线)的信号。
|
||
const Lane::MeanFilterItem* meanItem =
|
||
m_tab->Lanes()[laneIndex].FindMeanFilterItem(ref);
|
||
if (meanItem != nullptr)
|
||
{
|
||
paintDerivedCapsule(DerivedCurveKind::MEAN_FILTER, meanItem->points,
|
||
meanItem->iterations, 0, L"均值滤波");
|
||
}
|
||
}
|
||
if (m_tab->Lanes()[laneIndex].weakPeak)
|
||
{
|
||
// 弱峰调整曲线:同样是只标注被勾选的信号,尾缀为「弱峰调整」。
|
||
const Lane::WeakPeakItem* boostItem =
|
||
m_tab->Lanes()[laneIndex].FindWeakPeakItem(ref);
|
||
if (boostItem != nullptr)
|
||
{
|
||
const int amountPercent =
|
||
std::max(kWeakPeakAmountMin, std::min(kWeakPeakAmountMax, boostItem->amountPercent));
|
||
const int iterations = std::max(
|
||
kWeakPeakIterationsMin,
|
||
std::min(kWeakPeakIterationsMaxLimit, boostItem->iterations));
|
||
paintDerivedCapsule(DerivedCurveKind::WEAK_PEAK, 0, iterations, amountPercent,
|
||
L"弱峰调整");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- 底部:时间读数 + 双光标差值 ----
|
||
if (m_editTarget == PlotEditTarget::CURSOR_TIME ||
|
||
m_editTarget == PlotEditTarget::CURSOR_SPAN)
|
||
{
|
||
PaintEditField(g);
|
||
return;
|
||
}
|
||
|
||
for (int pass = 0; pass < cursorCount; ++pass)
|
||
{
|
||
const int which = doubleMode ? iterations[pass] : 1;
|
||
const gd::RectF labelRect = CursorLabelRect(which);
|
||
if (labelRect.Width <= 0.0f)
|
||
{
|
||
continue;
|
||
}
|
||
const bool highlighted = (m_cursorHover && m_hoverCursor == which);
|
||
const std::uint32_t color = CursorColor();
|
||
bool offRight = false;
|
||
const bool offView = CursorOffViewSide(which, offRight);
|
||
if (offView)
|
||
{
|
||
PaintCursorOffViewTip(g, labelRect, offRight, color);
|
||
}
|
||
UiFillRoundRect(g, labelRect, 3.0f * m_scale,
|
||
highlighted ? color : WithAlpha(color, 0xE0));
|
||
const UiInk ink = UiContrastInk(color);
|
||
DrawLabelOutline(g, labelRect, 3.0f * m_scale, ink.outline);
|
||
UiText(g, FormatCursorTime(which == 2 ? Tab().CursorTimeAlt() : Tab().CursorTime()),
|
||
m_fonts->monoSmall, labelRect, ink.text, TextAlign::CENTER);
|
||
}
|
||
|
||
// 双光标差值:显示在两条时间读数中间(时间轴上方;过近时落到主时间轴上)。
|
||
if (doubleMode)
|
||
{
|
||
bool insideAxis = false;
|
||
const gd::RectF spanRect = CursorSpanRect(insideAxis);
|
||
m_cursorSpanRect = spanRect;
|
||
if (spanRect.Width > 0.0f && spanRect.Height > 0.0f)
|
||
{
|
||
// 样式与光标时间读数完全一致(同底色/同描边/同文字配色)。
|
||
const std::uint32_t color = CursorColor();
|
||
// 双光标同时越界到同一侧时,差值标签也在视界之外 → 同样画方向三角。
|
||
bool offRight1 = false, offRight2 = false;
|
||
const bool off1 = CursorOffViewSide(1, offRight1);
|
||
const bool off2 = CursorOffViewSide(2, offRight2);
|
||
if (off1 && off2 && offRight1 == offRight2)
|
||
{
|
||
PaintCursorOffViewTip(g, spanRect, offRight1, color);
|
||
}
|
||
UiFillRoundRect(g, spanRect, 3.0f * m_scale,
|
||
m_spanHover ? color : WithAlpha(color, 0xE0));
|
||
const UiInk ink = UiContrastInk(color);
|
||
DrawLabelOutline(g, spanRect, 3.0f * m_scale, ink.outline);
|
||
UiText(g, FormatSpanText(CursorSpan()), m_fonts->monoSmall, spanRect, ink.text,
|
||
TextAlign::CENTER);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
m_cursorSpanRect = gd::RectF();
|
||
}
|
||
}
|
||
|
||
|
||
void PlotView::PaintDataMarker(gd::Graphics& g, const DataMarker& marker, float radius,
|
||
std::uint32_t fillColor, std::uint32_t ringColor) const
|
||
{
|
||
gd::SolidBrush ring(ToGdiColor(ringColor));
|
||
gd::SolidBrush fill(ToGdiColor(fillColor));
|
||
const float arm = kMarkerTriangleArm * m_scale; /**< 三角“半宽/半高”(垂直于朝向的半边长)。 */
|
||
const float depth = kMarkerTriangleDepth * m_scale; /**< 三角沿朝向的深度(与光标把手同比例)。 */
|
||
|
||
// 越界三角:**尖端朝外**(指向数据点所在的方向,即视界之外),底边在网格内侧;
|
||
// 三角外接矩形在横/纵两个方向都与锚点**居中**(上下越界时因此与取值标注纵向居中)。
|
||
// 旧实现方向是反的(上越界画下三角、左越界画右三角),而且内三角按“平移”而非
|
||
// “向质心收缩”构造,填充色会从三角尖端戳出来。
|
||
const auto paintTriangle = [&](const gd::PointF* outer)
|
||
{
|
||
g.FillPolygon(&ring, outer, 3);
|
||
const float centerX = (outer[0].X + outer[1].X + outer[2].X) / 3.0f;
|
||
const float centerY = (outer[0].Y + outer[1].Y + outer[2].Y) / 3.0f;
|
||
// 按质心等比收缩得到内三角:几何上恒在外三角内部,描边宽度也就均匀了。
|
||
const float shrink = 0.58f;
|
||
gd::PointF inner[3];
|
||
for (int index = 0; index < 3; ++index)
|
||
{
|
||
inner[index] = gd::PointF(centerX + (outer[index].X - centerX) * shrink,
|
||
centerY + (outer[index].Y - centerY) * shrink);
|
||
}
|
||
g.FillPolygon(&fill, inner, 3);
|
||
};
|
||
|
||
// 左右越界:三角指向左/右(视界之外)。
|
||
if (marker.offLeft || marker.offRight)
|
||
{
|
||
const bool offLeft = marker.offLeft;
|
||
const float tipX = offLeft ? (marker.x - depth) : (marker.x + depth);
|
||
const float baseX = marker.x;
|
||
gd::PointF outer[3];
|
||
outer[0] = gd::PointF(baseX, marker.y - arm);
|
||
outer[1] = gd::PointF(baseX, marker.y + arm);
|
||
outer[2] = gd::PointF(tipX, marker.y);
|
||
paintTriangle(outer);
|
||
return;
|
||
}
|
||
|
||
// 上下越界:三角指向上/下(视界之外),外接矩形以 marker.y 为纵向中心
|
||
// → 与取值标注的纵向中心对齐。
|
||
if (marker.offUp || marker.offDown)
|
||
{
|
||
const bool offUp = marker.offUp;
|
||
const float half = depth * 0.5f;
|
||
const float tipY = offUp ? (marker.y - half) : (marker.y + half);
|
||
const float baseY = offUp ? (marker.y + half) : (marker.y - half);
|
||
gd::PointF outer[3];
|
||
outer[0] = gd::PointF(marker.x - arm, baseY);
|
||
outer[1] = gd::PointF(marker.x + arm, baseY);
|
||
outer[2] = gd::PointF(marker.x, tipY);
|
||
paintTriangle(outer);
|
||
return;
|
||
}
|
||
|
||
// 视界内:常规圆点。
|
||
g.FillEllipse(&ring, marker.x - radius - 1.2f * m_scale, marker.y - radius - 1.2f * m_scale,
|
||
(radius + 1.2f * m_scale) * 2.0f, (radius + 1.2f * m_scale) * 2.0f);
|
||
g.FillEllipse(&fill, marker.x - radius, marker.y - radius, radius * 2.0f, radius * 2.0f);
|
||
}
|
||
|
||
void PlotView::PaintDerivedCurve(gd::Graphics& g, const ResolvedSignal& resolved,
|
||
const LaneVisual& lane, const std::vector<float>& curve,
|
||
std::size_t curveBegin) const
|
||
{
|
||
if (resolved.data == nullptr || resolved.axis == nullptr || resolved.style == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const std::vector<double>& time = resolved.axis->time;
|
||
const std::size_t count = std::min(time.size(), resolved.data->values.size());
|
||
if (count == 0 || m_plotRect.Width <= 1.0f || curve.empty())
|
||
{
|
||
return;
|
||
}
|
||
|
||
std::size_t begin = 0;
|
||
std::size_t end = 0;
|
||
VisibleIndexRange(*resolved.axis, count, begin, end, resolved.style->offsetTime);
|
||
if (end <= begin)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const std::size_t visibleCount = end - begin;
|
||
const float limitTop = lane.rect.Y - lane.rect.Height * 4.0f;
|
||
const float limitBottom = lane.rect.Y + lane.rect.Height * 5.0f;
|
||
|
||
// 派生曲线的颜色:与原信号**同色相**、亮度按**当前网格底色**自适应拉开,
|
||
// 使其与原始波形、与背景都形成足够对比(原信号改颜色时曲线随之改变,因为它是从信号色
|
||
// 派生出来的);透明度与原信号一致。
|
||
const unsigned int baseAlpha = static_cast<unsigned int>(
|
||
std::max(0.0, std::min(1.0, static_cast<double>(resolved.style->opacity))) * 255.0 + 0.5);
|
||
const std::uint32_t derived = WithAlpha(
|
||
HighContrastDerived(StyleColor(*resolved.style), ThemeLaneBackground(*m_theme)),
|
||
baseAlpha);
|
||
|
||
// 顶点预算与波形一致(每像素列两个顶点)。
|
||
// 交互期间进一步降到 1/2:派生曲线往往有 10 条(5 网格 × 均值滤波 + 弱峰调整),
|
||
// 顶点数就是绘制耗时;降低密度能让拖动参数滑块保持流畅,
|
||
// 松手后下一帧立刻恢复全密度(用户要求的"低分辨率 → 高分辨率")。
|
||
std::size_t budget =
|
||
static_cast<std::size_t>(std::max(1.0f, m_plotRect.Width)) * kPolylinePointsPerPixel;
|
||
budget = std::max(budget, kMinPolylinePoints);
|
||
budget = std::min(budget, kMaxPolylinePoints);
|
||
if (m_fastFrame)
|
||
{
|
||
budget = std::max<std::size_t>(kMinPolylinePoints, budget / 2);
|
||
}
|
||
const std::size_t stride = std::max<std::size_t>(1, visibleCount / std::max<std::size_t>(1, budget));
|
||
|
||
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;
|
||
}
|
||
// 与「边沿计数(中心轴)」共用同一段绘制逻辑
|
||
//(同色相高对比度的派生配色 + 同透明度),只是曲线本身按多次迭代滤波计算。
|
||
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)与时间视界(ViewMin/ViewMax)刻意不入面板指纹**:面板内容
|
||
//(标题、条目、滑块、参数与边沿统计)都不依赖它们,而拖动平移会每帧小幅改动 ——
|
||
// 一旦计入,拖拽期间**全部**悬浮面板都会每帧重画一遍
|
||
//(实测 20 个面板:竖向拖拽 167ms/帧 vs 横向 38ms/帧,正是用户报告的
|
||
// "左右流畅、上下/斜向卡顿")。为保证“背后的场景”不会因此变旧,面板主体
|
||
// 已改为**完全不透明**(见面板绘制处的说明)。
|
||
}
|
||
|
||
if (m_tab != nullptr)
|
||
{
|
||
const std::vector<Lane>& lanes = m_tab->Lanes();
|
||
for (const Lane& lane : lanes)
|
||
{
|
||
// ⚠️⚠️ **面板的位置 / 尺寸绝不能进全局键**(用户 2026-10-21 报告“拖动面板时
|
||
// 下方另一个面板出现拖拽残影”):位置只影响该面板自己,已在 `PanelSlotKey`
|
||
// 里(它混合 `layout.panel/title/...`)。以前把 4 类面板的 x/y 混进全局键 ⇒
|
||
// 拖动任意面板都会让**所有**面板重画(15 个 ≈33ms/帧),而「自由标记」面板的
|
||
// 位置偏偏漏了没混 ⇒ 拖动它时其它面板照旧贴旧像素 → 正好在下方留残影。
|
||
// 现在:拖动面板只重画它自己;它**腾出的区域**由 `PaintEdgeCursor` 的
|
||
// `vacatedRects` 机制兜底(覆盖到该区域的面板必须重画)。
|
||
mix(lane.edgePanel.open ? 1ull : 0ull);
|
||
mix(lane.edgeCenterPanel.open ? 1ull : 0ull);
|
||
mix(lane.meanFilterPanel.open ? 1ull : 0ull);
|
||
mix(lane.weakPeakPanel.open ? 1ull : 0ull);
|
||
mix(lane.freeMarkerPanel.open ? 1ull : 0ull);
|
||
mix(lane.edgePanel.collapsed ? 1ull : 0ull);
|
||
mix(lane.edgeCenterPanel.collapsed ? 1ull : 0ull);
|
||
mix(lane.meanFilterPanel.collapsed ? 1ull : 0ull);
|
||
mix(lane.weakPeakPanel.collapsed ? 1ull : 0ull);
|
||
mix(lane.freeMarkerPanel.collapsed ? 1ull : 0ull);
|
||
mix(lane.edgeValueValid ? 1ull : 0ull);
|
||
mixDouble(lane.edgeValue);
|
||
// 「自由标记」面板内容区的滚动偏移(历史行为:计入全局键)。
|
||
// ⚠️ 「均值滤波」的滚动偏移只进**该面板自己的**槽键(见 `PanelSlotKey`),
|
||
// 否则滚动一个面板会让屏幕上所有面板一起重画。
|
||
mixFloat(lane.freeMarkerScroll);
|
||
}
|
||
}
|
||
|
||
// ⚠️ **面板层序也是"全局"状态**:层序变化(点击某个面板把它置顶)会让"谁盖住谁"改变,
|
||
// 因此必须让所有面板缓存失效、按新层序整层重画(否则贴回的旧像素仍是旧层叠关系)。
|
||
for (const PanelRef& ref : m_panelOrder)
|
||
{
|
||
mix(static_cast<std::uint64_t>(ref.laneIndex + 2));
|
||
mix(static_cast<std::uint64_t>(ref.kind + 2));
|
||
}
|
||
return hash;
|
||
}
|
||
|
||
std::uint64_t PlotView::PanelSlotKey(int laneIndex, EdgePanelKind kind,
|
||
const EdgePanelLayout& layout) const
|
||
{
|
||
std::uint64_t hash = 1469598103934665603ull;
|
||
const auto mix = [&hash](std::uint64_t value) { MixPanelKey(hash, value); };
|
||
const auto mixFloat = [&mix](float value)
|
||
{
|
||
std::uint32_t bits = 0;
|
||
std::memcpy(&bits, &value, sizeof(bits));
|
||
mix(bits);
|
||
};
|
||
const auto mixRect = [&mixFloat](const gd::RectF& rect)
|
||
{
|
||
mixFloat(rect.X);
|
||
mixFloat(rect.Y);
|
||
mixFloat(rect.Width);
|
||
mixFloat(rect.Height);
|
||
};
|
||
mix(static_cast<std::uint64_t>(laneIndex + 2));
|
||
mix(static_cast<std::uint64_t>(static_cast<int>(kind) + 2));
|
||
mixRect(layout.panel);
|
||
mixRect(layout.title);
|
||
mixRect(layout.close);
|
||
mixRect(layout.disclosure);
|
||
mixRect(layout.badge);
|
||
mixRect(layout.edit);
|
||
mixRect(layout.button);
|
||
mixRect(layout.showDelta);
|
||
mixRect(layout.showEnum);
|
||
mixRect(layout.content);
|
||
mixRect(layout.scrollTrack);
|
||
mixRect(layout.scrollThumb);
|
||
mixRect(layout.opacityTrack);
|
||
mixRect(layout.opacityValue);
|
||
mix(layout.collapsed ? 1ull : 0ull);
|
||
mix(layout.markerHasEnumColumn ? 1ull : 0ull);
|
||
mix(static_cast<std::uint64_t>(layout.rows));
|
||
for (const gd::RectF& rect : layout.rowChecks)
|
||
{
|
||
mixRect(rect);
|
||
}
|
||
for (const gd::RectF& rect : layout.rowPoints)
|
||
{
|
||
mixRect(rect);
|
||
}
|
||
for (const gd::RectF& rect : layout.rowIterations)
|
||
{
|
||
mixRect(rect);
|
||
}
|
||
for (const gd::RectF& rect : layout.rowStats)
|
||
{
|
||
mixRect(rect);
|
||
}
|
||
// 「自由标记」:固定点行/名称 ✕ 的矩形、五列边界、表头/吸顶状态与每个固定点的身份 + 时刻都要入键。
|
||
for (const EdgePanelLayout::MarkerRow& row : layout.markerRows)
|
||
{
|
||
mixRect(row.rect);
|
||
mixRect(row.close);
|
||
mix(static_cast<std::uint64_t>(row.itemIndex + 2));
|
||
}
|
||
for (const gd::RectF& rect : layout.rowNameCloses)
|
||
{
|
||
mixRect(rect);
|
||
}
|
||
for (const gd::RectF& rect : layout.rowNameRects)
|
||
{
|
||
mixRect(rect);
|
||
}
|
||
for (const EdgePanelLayout::MarkerColumn& column : layout.markerColumns)
|
||
{
|
||
mixFloat(column.left);
|
||
mixFloat(column.width);
|
||
}
|
||
// 「自由标记」的分组栏(托底矩形 / 三角 / ✕)与各自的收起状态都在面板像素里。
|
||
for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups)
|
||
{
|
||
mixRect(group.header);
|
||
mixRect(group.tableHeader);
|
||
mixRect(group.triangle);
|
||
mixRect(group.close);
|
||
// 「均值滤波」的组头右端是复选框(「自由标记」为空矩形,不影响它的键)。
|
||
mixRect(group.check);
|
||
mix(group.collapsed ? 1ull : 0ull);
|
||
mix(group.pinned ? 1ull : 0ull);
|
||
mix(static_cast<std::uint64_t>(group.ref.groupIndex + 2));
|
||
mix(static_cast<std::uint64_t>(group.ref.signalIndex + 2));
|
||
}
|
||
|
||
const Lane& lane = Tab().Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
if (kind == EdgePanelKind::MARK)
|
||
{
|
||
// 底部切换按钮的激活态、透明度滑块与内容滚动偏移直接改变面板像素 ⇒ 必须进键,
|
||
// 否则开关 / 拖滑块 / 滚动之后仍会贴回旧像素。
|
||
mix(lane.freeMarkerCursor ? 1ull : 0ull);
|
||
// 「显示增量 / 显示枚举文本」两个开关直接改变已固定点的胶囊文本 ⇒ 必须进键
|
||
// (否则切换开关后仍会贴回旧像素)。
|
||
mix(lane.freeMarkerShowDelta ? 1ull : 0ull);
|
||
mix(lane.freeMarkerShowEnum ? 1ull : 0ull);
|
||
mixFloat(lane.freeMarkerOpacity);
|
||
mixFloat(lane.freeMarkerScroll);
|
||
// 选中行会改变整行底色 ⇒ 必须进键(否则单击行后贴回旧像素)。
|
||
mix(static_cast<std::uint64_t>(lane.freeMarkerSelected + 2));
|
||
// 固定点的**内容**(哪个信号、什么时刻)直接决定面板文字与行数,必须进键;
|
||
// 否则新增/删除一个固定点后可能贴回旧像素。
|
||
for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems)
|
||
{
|
||
mix(static_cast<std::uint64_t>(item.ref.groupIndex + 2));
|
||
mix(static_cast<std::uint64_t>(item.ref.signalIndex + 2));
|
||
std::uint64_t bits = 0;
|
||
std::memcpy(&bits, &item.time, sizeof(bits));
|
||
mix(bits);
|
||
}
|
||
}
|
||
for (const SignalRef& ref : layout.rowRefs)
|
||
{
|
||
mix(ref.IsValid() ? 1ull : 0ull);
|
||
mix(static_cast<std::uint64_t>(ref.groupIndex));
|
||
mix(static_cast<std::uint64_t>(ref.signalIndex));
|
||
// 勾选状态与参数直接决定行文本与行高。
|
||
mix(lane.MeanFilterEnabled(ref) ? 1ull : 0ull);
|
||
mix(lane.EdgeCenterEnabled(ref) ? 1ull : 0ull);
|
||
mix(lane.WeakPeakEnabled(ref) ? 1ull : 0ull);
|
||
const Lane::MeanFilterItem* meanItem = lane.FindMeanFilterItem(ref);
|
||
mix(static_cast<std::uint64_t>(meanItem != nullptr ? meanItem->points + 2 : 0));
|
||
mix(static_cast<std::uint64_t>(meanItem != nullptr ? meanItem->iterations + 2 : 0));
|
||
// 「均值滤波」改为滑块后,**滑块上限**决定圆钮位置、**收起状态**决定是否显示参数行
|
||
// ⇒ 两者都直接改变面板像素,必须进键(否则拖完滑块仍会贴回旧像素)。
|
||
mix(static_cast<std::uint64_t>(meanItem != nullptr ? meanItem->maxPoints + 2 : 0));
|
||
mix(static_cast<std::uint64_t>(meanItem != nullptr ? meanItem->maxIterations + 2 : 0));
|
||
mix((meanItem != nullptr && meanItem->collapsed) ? 1ull : 0ull);
|
||
const Lane::EdgeCenterItem* centerItem = lane.FindEdgeCenterItem(ref);
|
||
mix(static_cast<std::uint64_t>(centerItem != nullptr ? centerItem->points + 2 : 0));
|
||
mix(static_cast<std::uint64_t>(centerItem != nullptr ? centerItem->iterations + 2 : 0));
|
||
// 弱峰调整的逐信号参数(调整幅度 % + 迭代次数 + 该信号的滑块上限)直接显示在面板上:
|
||
// 上限决定迭代次数滑块的圆钮位置,因此也必须参与缓存键。
|
||
const Lane::WeakPeakItem* peakItem = lane.FindWeakPeakItem(ref);
|
||
mix(static_cast<std::uint64_t>(peakItem != nullptr ? peakItem->amountPercent + 2 : 0));
|
||
mix(static_cast<std::uint64_t>(peakItem != nullptr ? peakItem->iterations + 2 : 0));
|
||
mix(static_cast<std::uint64_t>(peakItem != nullptr ? peakItem->maxIterations + 2 : 0));
|
||
}
|
||
|
||
// ⚠️⚠️ **边沿统计的数字本身就是面板内容,必须进缓存键**(用户报告的缺陷根因):
|
||
// 面板像素图层按本键缓存,而界面上的「上升沿 / 下降沿」是**分帧推进**出来的中间结果 ——
|
||
// 若这些数字不进键,则「统计推进 → 数字变化」不会让键变化 ⇒ 面板一直贴旧像素
|
||
// ⇒ 用户不动鼠标时统计永远停在“统计中…”或某个中间值,**只有移动鼠标**(改变别的键)
|
||
// 才会显示出早已算完的正确结果。
|
||
// ⇒ 把该面板当前要显示的 (上升沿, 下降沿, 是否扫完) 一并 mix 进去:推进会让键变化 ⇒
|
||
// 面板重绘 ⇒ 显示随进度更新,直到扫完(扫完后数字不再变,重新命中缓存)。
|
||
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);
|
||
}
|
||
// 「均值滤波」:信号色叠底标题栏 / 展开三角 / 内容区滚动偏移都要入键
|
||
//(滚动改变的是面板内部像素,位置与尺寸不变)。
|
||
for (const gd::RectF& rect : layout.rowHeaders)
|
||
{
|
||
mixRect(rect);
|
||
}
|
||
for (const gd::RectF& rect : layout.rowTriangles)
|
||
{
|
||
mixRect(rect);
|
||
}
|
||
mixFloat(layout.scrollOffset);
|
||
|
||
// 鼠标落在面板内时,✕ / 复选框 / 滑块 / 底部按钮的悬停态都依赖精确坐标。
|
||
const float margin = 6.0f * m_scale;
|
||
const gd::RectF hot(layout.panel.X - margin, layout.panel.Y - margin,
|
||
layout.panel.Width + margin * 2.0f,
|
||
layout.panel.Height + margin * 2.0f);
|
||
if (UiPointInRect(hot, static_cast<float>(m_mouseX), static_cast<float>(m_mouseY)))
|
||
{
|
||
mix(static_cast<std::uint64_t>(m_mouseX + 4096));
|
||
mix(static_cast<std::uint64_t>(m_mouseY + 4096));
|
||
}
|
||
return hash;
|
||
}
|
||
|
||
|
||
/**
|
||
* @brief 用 **GDI+** 绘制一段可带 alpha 的文字(仅用于「标记透明度」低于 100% 的胶囊)。
|
||
*
|
||
* ⚠️ 界面其余文字一律走 `UiText`(GDI `DrawTextW`)。GDI 文本**忽略 alpha**
|
||
*(`SetTextColor` 只吃 RGB)⇒ 直接把 alpha 乘进颜色不会让文字淡出。
|
||
* 因此只有在"固定点被调成半透明"这个特殊状态下才改用 GDI+ `DrawString`:
|
||
* 那样才能让文字与胶囊一起真正淡出;不透明度为 100% 时仍走 `UiText`,像素零变化。
|
||
*
|
||
* @param [IN,OUT] g 绘图上下文。
|
||
* @param [IN] text 文本。
|
||
* @param [IN] font 字体(与 `UiText` 同一 HFONT)。
|
||
* @param [IN] rect 文本矩形(居中)。
|
||
* @param [IN] argb 颜色(含目标 alpha)。
|
||
*/
|
||
void DrawFreeMarkerAlphaText(gd::Graphics& g, const std::wstring& text, HFONT font,
|
||
const gd::RectF& rect, std::uint32_t argb)
|
||
{
|
||
if (text.empty() || font == nullptr || rect.Width <= 0.0f || rect.Height <= 0.0f)
|
||
{
|
||
return;
|
||
}
|
||
LOGFONTW lf{};
|
||
if (GetObjectW(font, static_cast<int>(sizeof(lf)), &lf) != static_cast<int>(sizeof(lf)))
|
||
{
|
||
UiText(g, text, font, rect, argb, TextAlign::CENTER);
|
||
return;
|
||
}
|
||
HDC hdc = g.GetHDC();
|
||
if (hdc == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
gd::Font textFont(hdc, &lf);
|
||
g.ReleaseHDC(hdc);
|
||
if (textFont.GetLastStatus() != gd::Ok)
|
||
{
|
||
UiText(g, text, font, rect, argb, TextAlign::CENTER);
|
||
return;
|
||
}
|
||
gd::SolidBrush brush(ToGdiColor(argb));
|
||
gd::StringFormat format;
|
||
format.SetAlignment(gd::StringAlignmentCenter);
|
||
format.SetLineAlignment(gd::StringAlignmentCenter);
|
||
format.SetTrimming(gd::StringTrimmingEllipsisCharacter);
|
||
format.SetFormatFlags(gd::StringFormatFlagsNoWrap);
|
||
g.DrawString(text.c_str(), static_cast<INT>(text.size()), &textFont, rect, &format, &brush);
|
||
}
|
||
|
||
void PlotView::PaintFreeMarkerCapsule(gd::Graphics& g, const gd::RectF& label,
|
||
const std::wstring& text, std::uint32_t color,
|
||
const gd::RectF& close, bool closeHot, float alphaScale,
|
||
bool vectorText)
|
||
{
|
||
const float scale = std::max(0.0f, std::min(1.0f, alphaScale));
|
||
const auto scaled = [scale](std::uint32_t argb, float base) -> std::uint32_t {
|
||
const int alpha = static_cast<int>(std::lround(base * scale));
|
||
return WithAlpha(argb, (alpha <= 0) ? 0 : (alpha >= 255 ? 255 : alpha));
|
||
};
|
||
const UiInk ink = UiContrastInk(color);
|
||
if (scale <= 0.02f)
|
||
{
|
||
return; // 完全透明:连描边与文字都不画(否则会留下"看不见的"像素)
|
||
}
|
||
UiFillRoundRect(g, label, 3.0f * m_scale, scaled(color, 0xF0));
|
||
DrawLabelOutline(g, label, 3.0f * m_scale, scaled(ink.outline, 255.0f));
|
||
if (!vectorText && scale >= 0.999f)
|
||
{
|
||
UiText(g, text, m_fonts->monoSmall, label, ink.text, TextAlign::CENTER);
|
||
}
|
||
else
|
||
{
|
||
// GDI 文本忽略 alpha **也不认 GDI+ 裁剪区** ⇒ 半透明、或压在 GX 标签/图例上时
|
||
// 必须改用 GDI+ 才能真的淡出 / 被裁剪(见 `vectorText` 参数说明)。
|
||
DrawFreeMarkerAlphaText(g, text, m_fonts->monoSmall, label, scaled(ink.text, 255.0f));
|
||
}
|
||
if (close.Width <= 0.0f)
|
||
{
|
||
return;
|
||
}
|
||
// ✕ 按钮:悬停时用 danger 底色 + 叉(与面板里的 ✕ 同一套语义)。
|
||
UiFillRoundRect(g, close, 3.0f * m_scale,
|
||
closeHot ? WithAlpha(m_theme->danger, 0x66) : scaled(color, 0xC0));
|
||
const float crossPad = 4.0f * m_scale;
|
||
const std::uint32_t crossColor =
|
||
closeHot ? m_theme->danger : scaled(ink.text, 255.0f);
|
||
UiDrawLine(g, close.X + crossPad, close.Y + crossPad, close.X + close.Width - crossPad,
|
||
close.Y + close.Height - crossPad, crossColor, 1.4f * m_scale);
|
||
UiDrawLine(g, close.X + close.Width - crossPad, close.Y + crossPad, close.X + crossPad,
|
||
close.Y + close.Height - crossPad, crossColor, 1.4f * m_scale);
|
||
}
|
||
|
||
void PlotView::PaintFreeMarker(gd::Graphics& g, const std::vector<LaneVisual>& lanes)
|
||
{
|
||
m_freeMarkerHits.clear();
|
||
if (m_tab == nullptr || m_dataSet == nullptr || m_fonts == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const std::uint32_t lineColor = m_theme->accent;
|
||
const int saveState = g.Save();
|
||
g.SetClip(m_plotRect, gd::CombineModeIntersect);
|
||
|
||
const std::vector<Lane>& tabLanes = m_tab->Lanes();
|
||
const std::size_t count = std::min(lanes.size(), tabLanes.size());
|
||
|
||
// ---- (0) **图层遮挡**:GX 编号徽标与图例条目必须压在所有标记点之上 ----
|
||
// 用户 2026-10-27 明确要求:固定标记点(数据点 + 取值胶囊)不得遮挡网格的 GX 标签与图例。
|
||
// 图例与徽标画在静态场景 / 装饰缓存里,而标记点画在贴图之后,因此这里从**绘制区域**里
|
||
// 把这些矩形整体排除掉(GDI+ 区域裁剪):标记点真正位于它们“下方”,被盖住的部分不画,
|
||
// 图例/徽标像素保持原样(连圆角之外的抗锯齿边缘都不会被标记点渗出)。
|
||
// ⚠️ GDI 文字(`UiText`)不认 GDI+ 裁剪区 ⇒ 胶囊文字与遮挡矩形相交时改走 GDI+
|
||
//(`PaintFreeMarkerCapsule(..., vectorText=true)`)。
|
||
// ⚠️ 只为**确实有标记点/预览**的网格做排除,避免每帧对全部网格做区域运算。
|
||
std::vector<gd::RectF> occluders;
|
||
{
|
||
bool needExclusion = (m_freeMarker.valid && m_freeMarker.ref.IsValid());
|
||
for (std::size_t index = 0; !needExclusion && index < count; ++index)
|
||
{
|
||
needExclusion = !tabLanes[index].freeMarkerItems.empty();
|
||
}
|
||
if (needExclusion)
|
||
{
|
||
for (const LegendHit& hit : m_legendHits)
|
||
{
|
||
occluders.push_back(hit.rect);
|
||
}
|
||
for (std::size_t index = 0; index < count; ++index)
|
||
{
|
||
const gd::RectF badge = LaneBadgeRect(static_cast<int>(index));
|
||
if (badge.Width > 0.0f && badge.Height > 0.0f)
|
||
{
|
||
occluders.push_back(badge);
|
||
}
|
||
}
|
||
gd::Region clip;
|
||
if (!occluders.empty() && g.GetClip(&clip) == gd::Ok)
|
||
{
|
||
for (const gd::RectF& rect : occluders)
|
||
{
|
||
const int x0 = static_cast<int>(std::floor(rect.X));
|
||
const int y0 = static_cast<int>(std::floor(rect.Y));
|
||
const int x1 = static_cast<int>(std::ceil(rect.X + rect.Width));
|
||
const int y1 = static_cast<int>(std::ceil(rect.Y + rect.Height));
|
||
if (x1 <= x0 || y1 <= y0)
|
||
{
|
||
continue;
|
||
}
|
||
gd::Region exclude(gd::Rect(x0, y0, x1 - x0, y1 - y0));
|
||
clip.Exclude(&exclude);
|
||
}
|
||
g.SetClip(&clip, gd::CombineModeReplace);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 某个矩形是否与任一遮挡矩形相交(用于决定胶囊文字是否必须走可裁剪的 GDI+ 路径)。
|
||
const auto overlapsOccluder = [&occluders](const gd::RectF& rect) -> bool
|
||
{
|
||
for (const gd::RectF& occluder : occluders)
|
||
{
|
||
if (rect.X < occluder.X + occluder.Width && occluder.X < rect.X + rect.Width &&
|
||
rect.Y < occluder.Y + occluder.Height && occluder.Y < rect.Y + rect.Height)
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
};
|
||
|
||
// ---- (1) 已固定的数据点(含各自的 ✕)----
|
||
// ⚠️ 固定点**不画纵向细线**(2026-10-21 要求),且取值胶囊**就在原位显示**、
|
||
// 不做重叠避让(用户 2026-10-22 明确要求恢复初版方案:允许遮挡)。
|
||
for (std::size_t laneIndex = 0; laneIndex < count; ++laneIndex)
|
||
{
|
||
const Lane& lane = tabLanes[laneIndex];
|
||
if (!lane.freeMarker || lane.freeMarkerItems.empty())
|
||
{
|
||
continue;
|
||
}
|
||
const float markerAlpha = std::max(0.0f, std::min(1.0f, lane.freeMarkerOpacity));
|
||
// 面板里正悬停着本网格的某一行 ⇒ 其余固定点“压暗”以突出目标
|
||
//(用户 2026-10-23 要求:透明度高于 50% 时降到 50%,移出后恢复)。
|
||
const bool rowHoverActive = (m_freeMarkerRowHoverLane == static_cast<int>(laneIndex) &&
|
||
m_freeMarkerRowHoverItem != static_cast<std::size_t>(-1));
|
||
// 内容(取值 / 文本 / 信号色)来自与视界无关的缓存,不逐帧重新采样。
|
||
const std::vector<FreeMarkerContentEntry>& contents = FreeMarkerContents(static_cast<int>(laneIndex));
|
||
for (std::size_t itemIndex = 0; itemIndex < lane.freeMarkerItems.size(); ++itemIndex)
|
||
{
|
||
gd::RectF label;
|
||
gd::RectF close;
|
||
std::wstring text;
|
||
if (!FreeMarkerLabelRects(static_cast<int>(laneIndex), itemIndex, label, close, text))
|
||
{
|
||
continue; // 超出视界直接不显示(不贴边、不画三角)
|
||
}
|
||
const Lane::FreeMarkerItem& item = lane.freeMarkerItems[itemIndex];
|
||
if (itemIndex >= contents.size())
|
||
{
|
||
continue;
|
||
}
|
||
const FreeMarkerContentEntry& entry = contents[itemIndex];
|
||
const std::uint32_t signalColor = entry.color;
|
||
const LaneVisual& visual = lanes[laneIndex];
|
||
const float clipTop = std::max(visual.rect.Y, m_plotRect.Y);
|
||
const float clipBottom =
|
||
std::min(visual.rect.Y + visual.rect.Height, m_plotRect.Y + m_plotRect.Height);
|
||
|
||
const bool valueOk = entry.valid;
|
||
// 鼠标悬停在面板里这一行时:网格内对应的固定点整体高亮(用户 2026-10-22 要求)。
|
||
const bool rowHot = (m_freeMarkerRowHoverLane == static_cast<int>(laneIndex) &&
|
||
m_freeMarkerRowHoverItem == itemIndex);
|
||
// 本行实际使用的不透明度:目标行强制不透明;其它行在“悬停联动”期间封顶 50%。
|
||
const float rowAlpha =
|
||
rowHot ? 1.0f
|
||
: ((rowHoverActive && markerAlpha > 0.5f) ? 0.5f : markerAlpha);
|
||
|
||
// 数据点:与光标的数据点同一画法(圆点),同样受「标记透明度」控制;
|
||
// 悬停联动期间其它点封顶 50%(见 `rowAlpha`)。
|
||
const float x = TimeToX(item.time);
|
||
const float radius = 3.4f * m_scale;
|
||
const int dotAlpha = static_cast<int>(std::lround(255.0f * rowAlpha));
|
||
gd::SolidBrush dot(ToGdiColor(WithAlpha(signalColor, dotAlpha)));
|
||
const float dotY =
|
||
valueOk ? ValueToY(entry.value, visual) : (clipTop + clipBottom) * 0.5f;
|
||
if (rowHot)
|
||
{
|
||
// 高亮提示:数据点外留一圈强调色环(与“吸附提示”的圆环同尺寸量级)。
|
||
gd::Pen hotRing(ToGdiColor(WithAlpha(m_theme->accent, 0xE0)), 2.0f * m_scale);
|
||
g.DrawEllipse(&hotRing, x - radius - 3.0f * m_scale,
|
||
dotY - radius - 3.0f * m_scale, (radius + 3.0f * m_scale) * 2.0f,
|
||
(radius + 3.0f * m_scale) * 2.0f);
|
||
}
|
||
g.FillEllipse(&dot, x - radius, dotY - radius, radius * 2.0f, radius * 2.0f);
|
||
|
||
// ✕ 按钮的悬停态决定它的配色,因此先算再画胶囊。
|
||
const bool hot = (m_freeMarkerHoverLane == static_cast<int>(laneIndex) &&
|
||
m_freeMarkerHoverItem == itemIndex);
|
||
// 「标记透明度」:鼠标停在某个取值胶囊上时把**这一个**胶囊恢复为全不透明
|
||
//(用户 2026-10-21 要求:既能看淡全局标记,又能读清当前关注的数值)。
|
||
// ⚠️ 例外:如果此刻跟随探针正瞄着一个**被这个胶囊遮住的数据点**
|
||
//(用户意图标记被遮住的那条曲线),则不恢复 —— 否则胶囊会重新遮住目标点、
|
||
// 无法看出自己到底瞄到了哪个采样点。
|
||
float capsuleAlpha = rowAlpha;
|
||
const float fx = static_cast<float>(m_mouseX);
|
||
const float fy = static_cast<float>(m_mouseY);
|
||
if (capsuleAlpha < 0.999f && UiPointInRect(label, fx, fy))
|
||
{
|
||
const bool probeHiddenByLabel =
|
||
m_freeMarker.valid && m_freeMarker.laneIndex == static_cast<int>(laneIndex) &&
|
||
UiPointInRect(label, m_freeMarker.x, m_freeMarker.y);
|
||
if (!probeHiddenByLabel)
|
||
{
|
||
capsuleAlpha = 1.0f;
|
||
}
|
||
}
|
||
// 面板里悬停该行 ⇒ 数值显示同样恢复全不透明并加一圈强调色描边。
|
||
if (rowHot)
|
||
{
|
||
capsuleAlpha = 1.0f;
|
||
}
|
||
// 胶囊压在 GX 标签 / 图例上时文字必须走 GDI+(GDI 文字不认裁剪区)。
|
||
PaintFreeMarkerCapsule(g, label, text, signalColor, close, hot, capsuleAlpha,
|
||
overlapsOccluder(label));
|
||
if (rowHot)
|
||
{
|
||
UiDrawRoundRect(g, label, 3.0f * m_scale, m_theme->accent, 2.0f * m_scale);
|
||
}
|
||
|
||
FreeMarkerHit hit;
|
||
hit.laneIndex = static_cast<int>(laneIndex);
|
||
hit.itemIndex = itemIndex;
|
||
hit.close = close;
|
||
if (rowAlpha > 0.02f)
|
||
{
|
||
// 完全透明时不可见 ⇒ 也不该被点中(否则会“隔着看不见的按钮”移除固定点)。
|
||
m_freeMarkerHits.push_back(hit);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- (2) 跟随鼠标的纵向光标(**仅「自由标记点选取」开启时**;`probe.valid` 恒真)----
|
||
const FreeMarkerProbe& probe = m_freeMarker;
|
||
if (probe.valid && probe.laneIndex >= 0 &&
|
||
probe.laneIndex < static_cast<int>(lanes.size()))
|
||
{
|
||
const LaneVisual& visual = lanes[static_cast<std::size_t>(probe.laneIndex)];
|
||
const float clipTop = std::max(visual.rect.Y, m_plotRect.Y);
|
||
const float clipBottom =
|
||
std::min(visual.rect.Y + visual.rect.Height, m_plotRect.Y + m_plotRect.Height);
|
||
float x = probe.x;
|
||
x = std::max(visual.rect.X + kFreeMarkerLineInset * m_scale,
|
||
std::min(x, visual.rect.X + visual.rect.Width - kFreeMarkerLineInset * m_scale));
|
||
// ⚠️ 用户明确要求:未开启「自由标记点选取」时**连这条线都不画**(`ProbeFreeMarker` 直接早退,
|
||
// `probe.valid` 为假);`cursorMode` 只是“确实处于选取模式”的双重保险。
|
||
if (probe.cursorMode && clipBottom > clipTop)
|
||
{
|
||
UiDrawLine(g, x, clipTop, x, clipBottom, lineColor, 1.4f * m_scale);
|
||
// 上把手:尖端朝下(与常规光标的顶部把手一致);下把手:尖端朝上(用户明确要求)。
|
||
const float arm = 5.0f * m_scale;
|
||
gd::SolidBrush handle(ToGdiColor(lineColor));
|
||
gd::PointF top[3];
|
||
top[0] = gd::PointF(x - arm, clipTop);
|
||
top[1] = gd::PointF(x + arm, clipTop);
|
||
top[2] = gd::PointF(x, clipTop + arm * 1.4f);
|
||
g.FillPolygon(&handle, top, 3);
|
||
gd::PointF bottom[3];
|
||
bottom[0] = gd::PointF(x - arm, clipBottom);
|
||
bottom[1] = gd::PointF(x + arm, clipBottom);
|
||
bottom[2] = gd::PointF(x, clipBottom - arm * 1.4f);
|
||
g.FillPolygon(&handle, bottom, 3);
|
||
}
|
||
|
||
// 就近数据点的取值胶囊(**不显示时间读数**,用户明确要求)。
|
||
if (probe.ref.IsValid() && probe.time >= Tab().ViewMin() && probe.time <= Tab().ViewMax())
|
||
{
|
||
const ResolvedSignal resolved = ResolveSignal(probe.ref);
|
||
if (resolved.data != nullptr && resolved.style != nullptr)
|
||
{
|
||
const std::uint32_t signalColor = StyleColor(*resolved.style);
|
||
const std::wstring* enumText = resolved.data->enumText.Find(probe.enumValue);
|
||
// 跟随鼠标的**预览**胶囊不受「显示增量 / 显示枚举文本」开关影响:
|
||
// 它总是显示「值 = 枚举」(与两个开关的默认关闭状态无关)。
|
||
const std::wstring text =
|
||
FreeMarkerValueText(probe.value, false, false, 0.0, true, enumText);
|
||
const float textWidth =
|
||
UiMeasureTextWidth(m_fonts->monoSmall, text) + 10.0f * m_scale;
|
||
const float textHeight = 17.0f * m_scale;
|
||
float left = x + 6.0f * m_scale;
|
||
if (left + textWidth > m_plotRect.X + m_plotRect.Width - 2.0f * m_scale)
|
||
{
|
||
left = x - 6.0f * m_scale - textWidth;
|
||
}
|
||
left = std::max(m_plotRect.X + 2.0f * m_scale,
|
||
std::min(left, m_plotRect.X + m_plotRect.Width - textWidth -
|
||
2.0f * m_scale));
|
||
float top = probe.y - textHeight * 0.5f;
|
||
top = std::max(clipTop + 1.0f * m_scale,
|
||
std::min(top, clipBottom - textHeight - 1.0f * m_scale));
|
||
const gd::RectF label(left, top, textWidth, textHeight);
|
||
// 同样:压在 GX 标签 / 图例上时文字必须走 GDI+(否则会盖在图例上)。
|
||
PaintFreeMarkerCapsule(g, label, text, signalColor, gd::RectF(), false, 1.0f,
|
||
overlapsOccluder(label));
|
||
|
||
// 吸附提示:命中自动点线判据时数据点被吸附到采样点,用一圈描边示意
|
||
//(与"可拖拽"的光晕区分:这里只是单点圆环,不描整条曲线)。
|
||
const float radius = 3.4f * m_scale;
|
||
gd::SolidBrush dot(ToGdiColor(signalColor));
|
||
// 取值圆点与"已吸附"圆环都画在**数据点本身**的横坐标上(`pointX`),
|
||
// 而纵向光标画在鼠标位置(`x`)。稀疏信号下两者会分开 —— 这是刻意的:
|
||
// 光标跟随鼠标、提示落在真实采样点上(用户 2026-10-30 要求)。
|
||
float pointX = probe.pointX;
|
||
if (!(pointX > 0.0f))
|
||
{
|
||
pointX = x; // 兼容:未填 `pointX` 时退回光标位置
|
||
}
|
||
g.FillEllipse(&dot, pointX - radius, probe.y - radius, radius * 2.0f,
|
||
radius * 2.0f);
|
||
if (probe.snapped)
|
||
{
|
||
gd::Pen ring(ToGdiColor(lineColor), 1.2f * m_scale);
|
||
g.DrawEllipse(&ring, pointX - radius - 3.0f * m_scale,
|
||
probe.y - radius - 3.0f * m_scale,
|
||
(radius + 3.0f * m_scale) * 2.0f,
|
||
(radius + 3.0f * m_scale) * 2.0f);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
g.Restore(saveState);
|
||
}
|
||
|
||
|
||
|
||
void PlotView::PaintEdgeCursor(gd::Graphics& g, const std::vector<LaneVisual>& lanes)
|
||
{
|
||
if (m_tab == nullptr || m_dataSet == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
|
||
std::vector<Lane>& tabLanes = m_tab->Lanes();
|
||
const std::size_t count = std::min(lanes.size(), tabLanes.size());
|
||
const std::uint32_t color = CursorColor();
|
||
const UiInk ink = UiContrastInk(color);
|
||
// 进入本函数时的文本层号 = 宿主给本组件分配的面板层起始(`baseLayer + 1`)。
|
||
// 每个面板在循环里各用 `panelTextLayer + 面板序数`,函数结束前把层号推到**最上面板之上**。
|
||
const int panelTextLayer = UiGetTextLayer();
|
||
|
||
// ---- 横向光标(线与两端把手 + 左侧幅值读数)----
|
||
for (std::size_t index = 0; index < count; ++index)
|
||
{
|
||
Lane& tabLane = tabLanes[index];
|
||
if (!tabLane.edgeCount)
|
||
{
|
||
continue;
|
||
}
|
||
const LaneVisual& visual = lanes[index];
|
||
const float clipTop = std::max(visual.rect.Y, m_plotRect.Y);
|
||
const float clipBottom =
|
||
std::min(visual.rect.Y + visual.rect.Height, m_plotRect.Y + m_plotRect.Height);
|
||
if (clipBottom <= clipTop)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
// 首次绘制时把横向光标落在可见幅值范围的中点。
|
||
if (!tabLane.edgeValueValid)
|
||
{
|
||
tabLane.edgeValue = (visual.yMin + visual.yMax) * 0.5;
|
||
tabLane.edgeValueValid = true;
|
||
}
|
||
|
||
const float lineY =
|
||
std::max(clipTop, std::min(ValueToY(tabLane.edgeValue, visual), clipBottom));
|
||
const float left = visual.rect.X;
|
||
const float right = visual.rect.X + visual.rect.Width;
|
||
|
||
// 横线与纵向光标同色同宽;两端各画一个尖端朝内的三角把手。
|
||
UiDrawLine(g, left, lineY, right, lineY, color, 1.4f * m_scale);
|
||
const float arm = 5.0f * m_scale;
|
||
gd::SolidBrush handle(ToGdiColor(color));
|
||
gd::PointF leftHandle[3];
|
||
leftHandle[0] = gd::PointF(left, lineY - arm);
|
||
leftHandle[1] = gd::PointF(left, lineY + arm);
|
||
leftHandle[2] = gd::PointF(left + arm * 1.4f, lineY);
|
||
g.FillPolygon(&handle, leftHandle, 3);
|
||
gd::PointF rightHandle[3];
|
||
rightHandle[0] = gd::PointF(right, lineY - arm);
|
||
rightHandle[1] = gd::PointF(right, lineY + arm);
|
||
rightHandle[2] = gd::PointF(right - arm * 1.4f, lineY);
|
||
g.FillPolygon(&handle, rightHandle, 3);
|
||
|
||
// 幅值读数:靠网格左侧、纵向居中于横线(与纵向光标的时间读数同款胶囊)。
|
||
// **必须让开左端的三角把手**(把手横向占 [left, left + arm*1.4]),
|
||
// 否则数值胶囊会盖住把手(用户明确要求向右挪一些)。
|
||
const std::wstring text = FormatValue(tabLane.edgeValue);
|
||
const float textWidth = std::max(
|
||
56.0f * m_scale, UiMeasureTextWidth(m_fonts->monoSmall, text) + 16.0f * m_scale);
|
||
const float textHeight = 18.0f * m_scale;
|
||
const float labelLeft = left + (arm * 1.4f + 4.0f * m_scale);
|
||
const float labelTop =
|
||
std::max(clipTop + 1.0f * m_scale, std::min(lineY - textHeight * 0.5f,
|
||
clipBottom - textHeight - 1.0f * m_scale));
|
||
const gd::RectF labelRect(labelLeft, labelTop, textWidth, textHeight);
|
||
UiFillRoundRect(g, labelRect, 3.0f * m_scale, WithAlpha(color, 0xE0));
|
||
DrawLabelOutline(g, labelRect, 3.0f * m_scale, ink.outline);
|
||
UiText(g, text, m_fonts->monoSmall, labelRect, ink.text, TextAlign::CENTER);
|
||
|
||
// 幅值超出视界时(横线被贴边显示),在数值胶囊的**上方/下方**画一个方向三角,
|
||
// 与常规纵向光标数据点越界的处理方式一致;三角与胶囊**水平居中对齐**。
|
||
const float rawLineY = ValueToY(tabLane.edgeValue, visual);
|
||
const bool offUp = (rawLineY < clipTop - 0.5f);
|
||
const bool offDown = (rawLineY > clipBottom + 0.5f);
|
||
if (offUp || offDown)
|
||
{
|
||
const float tipArm = 5.0f * m_scale;
|
||
const float depth = tipArm * 1.4f;
|
||
const float centerX = labelRect.X + labelRect.Width * 0.5f;
|
||
// 三角整体贴在胶囊外侧(上方越界 → 胶囊上方的向上三角)。
|
||
const float anchorY = offUp ? (labelRect.Y - depth * 0.5f - 1.0f * m_scale)
|
||
: (labelRect.Y + labelRect.Height + depth * 0.5f + 1.0f * m_scale);
|
||
gd::PointF outer[3];
|
||
if (offUp)
|
||
{
|
||
outer[0] = gd::PointF(centerX - tipArm, anchorY + depth * 0.5f);
|
||
outer[1] = gd::PointF(centerX + tipArm, anchorY + depth * 0.5f);
|
||
outer[2] = gd::PointF(centerX, anchorY - depth * 0.5f);
|
||
}
|
||
else
|
||
{
|
||
outer[0] = gd::PointF(centerX - tipArm, anchorY - depth * 0.5f);
|
||
outer[1] = gd::PointF(centerX + tipArm, anchorY - depth * 0.5f);
|
||
outer[2] = gd::PointF(centerX, anchorY + depth * 0.5f);
|
||
}
|
||
gd::SolidBrush tipBrush(ToGdiColor(color));
|
||
g.FillPolygon(&tipBrush, outer, 3);
|
||
}
|
||
}
|
||
|
||
// ---- 统计浮动面板(最后绘制,覆盖在波形之上)----
|
||
// 「边沿计数」「边沿计数(中心轴)」「均值滤波」「弱峰调整」共用同一套样式,
|
||
// 仅标题、内容行与底部按钮不同。
|
||
//
|
||
// 面板是**静态**内容:只有被拖动 / 悬停 / 参数变化的那一个真正重画,
|
||
// 其余直接贴上一帧缓存好的像素(15 个面板全画一遍实测约 33ms,
|
||
// 而贴图 <1ms —— 拖动弱峰调整滑块时的主要卡顿就来自这一层)。
|
||
// 「上升沿 / 下降沿」两列排版(列起点固定 ⇒ 多条信号的文字严格垂直对齐)。
|
||
// ⚠️ 未完成时**两个数字后面都带 `…`**(旧实现只在整行末尾追加一次,
|
||
// 视觉上只跟在“下降沿”的数字后面;用户明确要求为“上升沿”也补上这个尾缀)。
|
||
const auto paintCounts = [this, &g](const gd::RectF& rect, std::size_t rising,
|
||
std::size_t falling, bool complete)
|
||
{
|
||
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();
|
||
|
||
// 预扫描一:本帧各面板的布局(循环里直接复用,避免每帧重复计算)。
|
||
std::vector<EdgePanelLayout> panelLayouts(m_panelOrder.size());
|
||
for (std::size_t ordinal = 0; ordinal < m_panelOrder.size(); ++ordinal)
|
||
{
|
||
panelLayouts[ordinal] = EdgePanelLayoutFor(
|
||
m_panelOrder[ordinal].laneIndex, static_cast<EdgePanelKind>(m_panelOrder[ordinal].kind));
|
||
}
|
||
|
||
// 预扫描二:上一帧已缓存、但本帧**位置/尺寸变了(或被移除)**的面板——它们腾出的
|
||
// 区域可能仍然留在**其它面板**的缓存像素里(那块像素是上一帧连它一起拷下来的),
|
||
// 于是鼠标拖动面板时,下方面板上会出现拖拽残影(用户 2026-10-21 报告)。
|
||
// 把腾空区域也当“脏区”,强制覆盖到它的面板重画,就能彻底消除残影。
|
||
std::vector<gd::RectF> vacatedRects;
|
||
if (panelCacheReady && !m_panelSlots.empty())
|
||
{
|
||
const float margin = 4.0f * m_scale;
|
||
for (const PanelSlot& slot : m_panelSlots)
|
||
{
|
||
int cx0 = slot.x0;
|
||
int cy0 = slot.y0;
|
||
int cx1 = slot.x1;
|
||
int cy1 = slot.y1;
|
||
bool alive = false;
|
||
for (std::size_t ordinal = 0; ordinal < m_panelOrder.size(); ++ordinal)
|
||
{
|
||
if (m_panelOrder[ordinal].laneIndex != slot.laneIndex ||
|
||
m_panelOrder[ordinal].kind != slot.kind)
|
||
{
|
||
continue;
|
||
}
|
||
const gd::RectF& panel = panelLayouts[ordinal].panel;
|
||
if (panel.Width <= 0.0f || panel.Height <= 0.0f)
|
||
{
|
||
break;
|
||
}
|
||
cx0 = std::max(0, static_cast<int>(std::floor(panel.X - margin)));
|
||
cy0 = std::max(0, static_cast<int>(std::floor(panel.Y - margin)));
|
||
cx1 = std::max(cx0, static_cast<int>(std::ceil(panel.X + panel.Width + margin)));
|
||
cy1 = std::max(cy0, static_cast<int>(std::ceil(panel.Y + panel.Height + margin)));
|
||
alive = true;
|
||
break;
|
||
}
|
||
if (!alive || cx0 != slot.x0 || cy0 != slot.y0 || cx1 != slot.x1 || cy1 != slot.y1)
|
||
{
|
||
vacatedRects.push_back(gd::RectF(static_cast<float>(slot.x0),
|
||
static_cast<float>(slot.y0),
|
||
static_cast<float>(slot.x1 - slot.x0),
|
||
static_cast<float>(slot.y1 - slot.y0)));
|
||
}
|
||
}
|
||
}
|
||
|
||
for (std::size_t panelOrdinal = 0; panelOrdinal < m_panelOrder.size(); ++panelOrdinal)
|
||
{
|
||
const PanelRef& panelRef = m_panelOrder[panelOrdinal];
|
||
const EdgePanelKind kind = static_cast<EdgePanelKind>(panelRef.kind);
|
||
const std::size_t index = static_cast<std::size_t>(panelRef.laneIndex);
|
||
// 文本几何层号:**每个面板独占一层**,与 `BuildBoxes` 完全同源
|
||
//(`baseLayer + 1 + 面板序数`,面板序数按 `m_panelOrder` 底 → 顶)。
|
||
// 少了这一步,下层面板的文字框会记在比上层面板更低的层里被反复挖洞/穿透。
|
||
UiSetTextLayer(panelTextLayer + static_cast<int>(panelOrdinal));
|
||
const bool centerAxis = (kind == EdgePanelKind::CENTER);
|
||
const bool meanFilter = (kind == EdgePanelKind::MEAN);
|
||
const bool weakPeak = (kind == EdgePanelKind::PEAK);
|
||
const bool freeMarker = (kind == EdgePanelKind::MARK);
|
||
// 「均值滤波」与「弱峰调整」共用「逐条目 + 底部按钮」排布。
|
||
const bool itemized = (centerAxis || meanFilter || weakPeak);
|
||
const bool hasButton = (meanFilter || weakPeak);
|
||
{
|
||
if (index >= count)
|
||
{
|
||
continue;
|
||
}
|
||
const Lane& tabLane = tabLanes[index];
|
||
// 布局已在预扫描里算好(同一帧内复用,避免重复计算)。
|
||
const EdgePanelLayout& layout = panelLayouts[panelOrdinal];
|
||
const gd::RectF& panel = layout.panel;
|
||
if (panel.Width <= 0.0f || panel.Height <= 0.0f)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
// 面板自身的内容指纹;命中缓存时直接贴图,本帧不再重画这个面板。
|
||
// ⚠️ 缓存像素里含**它与它下面的一切**(面板背后的波形、被它遮住的其他面板),
|
||
// 因此只要有一个面板重画,所有「在它之后绘制、且与它重叠」的面板也必须重画,
|
||
// 否则后画者会把旧像素重新盖回去(面板默认位置本来就常常叠在一起)。
|
||
const float cacheMargin = 4.0f * m_scale;
|
||
const gd::RectF cachedRect(panel.X - cacheMargin, panel.Y - cacheMargin,
|
||
panel.Width + cacheMargin * 2.0f,
|
||
panel.Height + cacheMargin * 2.0f);
|
||
bool forcedDirty = false;
|
||
for (const gd::RectF& dirtyRect : dirtyRects)
|
||
{
|
||
if (dirtyRect.IntersectsWith(cachedRect))
|
||
{
|
||
forcedDirty = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!forcedDirty)
|
||
{
|
||
// 上一帧有面板在这里腾出了位置(移动 / 缩小 / 关闭)⇒ 本面板的缓存像素可能
|
||
// 仍然包含那块旧内容,必须重画(否则拖动上面那个面板时这里会留残影)。
|
||
for (const gd::RectF& vacated : vacatedRects)
|
||
{
|
||
if (vacated.IntersectsWith(cachedRect))
|
||
{
|
||
forcedDirty = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
std::uint64_t slotKey = 0;
|
||
if (panelCacheReady)
|
||
{
|
||
slotKey = PanelSlotKey(static_cast<int>(index), kind, layout);
|
||
if (panelGlobalSame && !forcedDirty)
|
||
{
|
||
const PanelSlot* hit = nullptr;
|
||
for (const PanelSlot& slot : m_panelSlots)
|
||
{
|
||
if (slot.laneIndex == static_cast<int>(index) &&
|
||
slot.kind == static_cast<int>(kind))
|
||
{
|
||
hit = &slot;
|
||
break;
|
||
}
|
||
}
|
||
if (hit != nullptr && hit->key != 0 && hit->key == slotKey)
|
||
{
|
||
// ⚠️⚠️ **只贴「面板圆角形状之内」的像素**(用户 2026-10-30 报告:
|
||
// 中键移动视界时面板周围带着一圈**旧视界的波形像素**)。
|
||
// 原因是缓存位图按 `panel ± cacheMargin` 取块,块的边缘那几像素是
|
||
// **上一帧的场景**(波形/网格线);`PanelGlobalKey` 刻意不含视界
|
||
// (否则平移会重画全部面板),于是整块贴回时就把旧波形"复印"了一圈。
|
||
// 现在:① 先把**投影**按本帧重新画一遍(它在圆角形状之外,不能靠缓存);
|
||
// ② 用圆角区域裁剪后只 BitBlt 面板本体(圆角之外仍是本帧场景)。
|
||
if (!m_fastFrame)
|
||
{
|
||
UiFillRoundRect(g, gd::RectF(panel.X + 1.0f * m_scale,
|
||
panel.Y + 2.0f * m_scale, panel.Width,
|
||
panel.Height),
|
||
5.0f * m_scale, 0x14000000u);
|
||
}
|
||
HDC target = g.GetHDC();
|
||
if (target != nullptr)
|
||
{
|
||
const int bx0 = static_cast<int>(std::floor(panel.X)) - 1;
|
||
const int by0 = static_cast<int>(std::floor(panel.Y)) - 1;
|
||
const int bx1 = static_cast<int>(std::ceil(panel.X + panel.Width)) + 1;
|
||
const int by1 = static_cast<int>(std::ceil(panel.Y + panel.Height)) + 1;
|
||
HRGN rounded = CreateRoundRectRgn(
|
||
bx0, by0, bx1 + 1, by1 + 1,
|
||
static_cast<int>(std::lround(10.0f * m_scale)),
|
||
static_cast<int>(std::lround(10.0f * m_scale)));
|
||
const int savedDc = SaveDC(target);
|
||
if (rounded != nullptr)
|
||
{
|
||
ExtSelectClipRgn(target, rounded, RGN_AND);
|
||
}
|
||
BitBlt(target, bx0, by0, bx1 - bx0, by1 - by0, m_panelDc, bx0, by0,
|
||
SRCCOPY);
|
||
if (rounded != nullptr)
|
||
{
|
||
DeleteObject(rounded);
|
||
}
|
||
RestoreDC(target, savedDc);
|
||
g.ReleaseHDC(target);
|
||
}
|
||
// 贴图不会真的画字 ⇒ 必须把上一帧渲染时存下的文本框**回放**给
|
||
// 外层收集器,否则调试框这一帧凭空消失(= 面板上边界闪烁)。
|
||
if (UiTextRectSink* const outerSink = UiGetTextRectSink())
|
||
{
|
||
for (const gd::RectF& textRect : hit->textRects)
|
||
{
|
||
outerSink->OnTextRect(textRect);
|
||
}
|
||
}
|
||
nextSlots.push_back(*hit);
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
const std::size_t storedIndex = nextSlots.size();
|
||
nextSlots.push_back(PanelSlot());
|
||
dirtyRects.push_back(cachedRect);
|
||
// 本面板渲染期间把文本框收集到本地容器(同时**转发**给外层收集器),
|
||
// 渲染完再存进 slot,供命中缓存时回放。详见 `PanelSlot::textRects`。
|
||
PanelTextCollector panelTextBoxes(UiGetTextRectSink());
|
||
|
||
const float titleHeight = kEdgePanelTitleHeight * m_scale;
|
||
const float rowHeight = kEdgePanelRowHeight * m_scale;
|
||
const float pad = kEdgePanelPad * m_scale;
|
||
|
||
// 以下整段(面板底色 → 标题栏 → 各控件 → 行内容)的画字都要走本地收集器。
|
||
UiSetTextRectSink(&panelTextBoxes);
|
||
|
||
// 面板主体 + 标题栏。
|
||
// 投影响用两张大面积的半透明圆角矩形模拟,代价不低;交互期间(快速帧)省略它,
|
||
// 让拖动参数滑块时面板重绘不再被投影拖累(松手后的全质量帧会补回投影)。
|
||
if (!m_fastFrame)
|
||
{
|
||
UiFillRoundRect(g, gd::RectF(panel.X + 1.0f * m_scale, panel.Y + 2.0f * m_scale,
|
||
panel.Width, panel.Height),
|
||
5.0f * m_scale, 0x14000000u);
|
||
}
|
||
// 面板主体:**完全不透明**(0xFF)。
|
||
// ⚠️ 曾经用 0xF4(约 96%),那 4% 透出的是**面板背后的场景像素**;而面板像素图层
|
||
// 会被缓存复用,`PanelGlobalKey` 又刻意不含视界/幅值(否则平移、竖向拖拽会每帧
|
||
// 重画全部面板)⇒ 平移后那 4% 里会一直残留**平移前**的波形(视觉上是面板下缘的
|
||
// 淡影)。改为不透明后这个取舍彻底消失,且零额外代价。
|
||
UiFillRoundRect(g, panel, 5.0f * m_scale, m_theme->cardBg);
|
||
UiDrawRoundRect(g, panel, 5.0f * m_scale, m_theme->borderStrong, 1.0f);
|
||
UiFillRoundRect(g, gd::RectF(panel.X, panel.Y, panel.Width, titleHeight), 5.0f * m_scale,
|
||
WithAlpha(m_theme->accent, 0x2A));
|
||
|
||
// 标题栏左侧的「展开 / 折起」指示器:**与信号列表组名最左边的折叠三角完全同款**
|
||
//(同一尺寸 12 逻辑像素、同一个 `UiDrawTriangleIcon`、同样只在悬停时变强调色、
|
||
// 不再带三角后方那条横线 —— 用户 2026-10-21 要求“统一使用相同标记”)。
|
||
// 单击它切换折起状态;折起后面板只剩标题栏,这里是唯一的“重新展开”按钮。
|
||
{
|
||
const gd::RectF& disclosureRect = layout.disclosure;
|
||
if (disclosureRect.Width > 0.0f)
|
||
{
|
||
const bool disclosureHot =
|
||
(m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) &&
|
||
UiPointInRect(disclosureRect, static_cast<float>(m_mouseX),
|
||
static_cast<float>(m_mouseY));
|
||
const std::uint32_t disclosureColor =
|
||
disclosureHot ? m_theme->accent : m_theme->text;
|
||
const float centerX = disclosureRect.X + disclosureRect.Width * 0.5f;
|
||
const float centerY = disclosureRect.Y + disclosureRect.Height * 0.5f;
|
||
UiDrawTriangleIcon(
|
||
g,
|
||
gd::RectF(centerX - 6.0f * m_scale, centerY - 6.0f * m_scale,
|
||
12.0f * m_scale, 12.0f * m_scale),
|
||
disclosureColor,
|
||
layout.collapsed ? IconDirection::RIGHT : IconDirection::DOWN);
|
||
}
|
||
}
|
||
|
||
// 标题:与左侧信号面板一致的 “GX” 徽标。
|
||
// ⚠️ 几何必须取 `layout.badge`(与悬停提示 / 手型光标 / 点击联动同源);
|
||
// 悬停时给一圈强调色描边,作为“可点击”的视觉提示(用户 2026-10-19 要求)。
|
||
const gd::RectF& badgeRect = layout.badge;
|
||
const bool badgeHot =
|
||
(m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && badgeRect.Width > 0.0f &&
|
||
UiPointInRect(badgeRect, static_cast<float>(m_mouseX), static_cast<float>(m_mouseY));
|
||
UiFillRoundRect(g, badgeRect, badgeRect.Height * 0.5f,
|
||
badgeHot ? m_theme->accentSoft : m_theme->cardBg);
|
||
UiDrawRoundRect(g, badgeRect, badgeRect.Height * 0.5f,
|
||
badgeHot ? m_theme->accent : m_theme->border, badgeHot ? 1.4f : 1.0f);
|
||
UiText(g, L"G" + std::to_wstring(index + 1), m_fonts->monoSmall, badgeRect,
|
||
badgeHot ? m_theme->textStrong : m_theme->text, TextAlign::CENTER);
|
||
const std::wstring title =
|
||
meanFilter ? std::wstring(L"均值滤波")
|
||
: (weakPeak ? std::wstring(kWeakPeakTitle)
|
||
: (freeMarker ? std::wstring(L"自由标记")
|
||
: ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") +
|
||
EdgeRangeSuffix())));
|
||
UiText(g, title, m_fonts->uiSmall,
|
||
gd::RectF(badgeRect.X + badgeRect.Width + 8.0f * m_scale, panel.Y,
|
||
panel.Width - badgeRect.Width - 8.0f * m_scale - titleHeight,
|
||
titleHeight),
|
||
m_theme->textStrong, TextAlign::LEFT);
|
||
|
||
// 关闭按钮 ✕:悬停时高亮提示(鼠标光标本身不改变样式)。
|
||
const gd::RectF& closeRect = layout.close;
|
||
const bool closeHot =
|
||
(m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) &&
|
||
UiPointInRect(closeRect, static_cast<float>(m_mouseX), static_cast<float>(m_mouseY));
|
||
if (closeHot)
|
||
{
|
||
UiFillRoundRect(g, gd::RectF(closeRect.X + 3.0f * m_scale, closeRect.Y + 3.0f * m_scale,
|
||
closeRect.Width - 6.0f * m_scale,
|
||
closeRect.Height - 6.0f * m_scale),
|
||
3.0f * m_scale, WithAlpha(m_theme->danger, 0x33));
|
||
}
|
||
const float crossPad = 8.0f * m_scale;
|
||
const std::uint32_t crossColor = closeHot ? m_theme->danger : m_theme->textMuted;
|
||
UiDrawLine(g, closeRect.X + crossPad, closeRect.Y + crossPad,
|
||
closeRect.X + closeRect.Width - crossPad,
|
||
closeRect.Y + closeRect.Height - crossPad, crossColor, 1.6f * m_scale);
|
||
UiDrawLine(g, closeRect.X + closeRect.Width - crossPad, closeRect.Y + crossPad,
|
||
closeRect.X + crossPad, closeRect.Y + closeRect.Height - crossPad, crossColor,
|
||
1.6f * m_scale);
|
||
|
||
// 折起的面板只保留标题栏:内容区(条目 / 分组栏 / 按钮 / 滚动条)全部省略。
|
||
if (!layout.collapsed)
|
||
{
|
||
// 「中心轴边沿计数」/「均值滤波」/「弱峰调整」面板都采用「逐条目」排布:
|
||
// 第一排 = 名称 + 复选框,第二排 = 两个参数输入框(仅勾选的条目,
|
||
// 弱峰调整没有参数输入框),中心轴再多第三排 = 对应的统计结果。
|
||
if (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);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 「均值滤波」面板:逐信号的**信号色叠底标题栏**(左侧展开三角 + 信号名 +
|
||
// 右端复选框)。复选框勾选后才出现三角、才能展开下面的两行参数滑块
|
||
// (「均值点数」/「迭代次数」);内容超高时可滚动(滚动条在内容区右侧)。
|
||
if (meanFilter)
|
||
{
|
||
const int meanSave = g.Save();
|
||
// 内容必须裁到内容区:滚动时行会越出可视范围。
|
||
g.SetClip(layout.content, gd::CombineModeIntersect);
|
||
|
||
const float valueColumn =
|
||
std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"),
|
||
UiMeasureTextWidth(m_fonts->monoSmall, L"1000"));
|
||
|
||
// 复选框自绘(主题色在信号色叠底上看不清):用**自适应对比色**描边/填充。
|
||
const auto paintMeanCheck = [this, &g](const gd::RectF& rect, bool checked,
|
||
const UiInk& ink,
|
||
std::uint32_t background)
|
||
{
|
||
if (rect.Width <= 0.0f || rect.Height <= 0.0f)
|
||
{
|
||
return;
|
||
}
|
||
const float radius = 2.5f * m_scale;
|
||
if (!checked)
|
||
{
|
||
UiDrawRoundRect(g, rect, radius, ink.text, 1.2f * m_scale);
|
||
return;
|
||
}
|
||
UiFillRoundRect(g, rect, radius, ink.text);
|
||
// 对勾:两段折线,颜色取叠底色(与填充色必成对比)。
|
||
const float left = rect.X + rect.Width * 0.22f;
|
||
const float middleX = rect.X + rect.Width * 0.44f;
|
||
const float right = rect.X + rect.Width * 0.78f;
|
||
const float highY = rect.Y + rect.Height * 0.30f;
|
||
const float lowY = rect.Y + rect.Height * 0.72f;
|
||
UiDrawLine(g, left, rect.Y + rect.Height * 0.52f, middleX, lowY, background,
|
||
1.6f * m_scale);
|
||
UiDrawLine(g, middleX, lowY, right, highY, background, 1.6f * m_scale);
|
||
};
|
||
|
||
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex)
|
||
{
|
||
const SignalRef& ref = layout.rowRefs[rowIndex];
|
||
if (rowIndex >= layout.rowHeaders.size() ||
|
||
rowIndex >= layout.rowChecks.size())
|
||
{
|
||
break;
|
||
}
|
||
const ResolvedSignal resolved = ResolveSignal(ref);
|
||
if (resolved.data == nullptr || resolved.style == nullptr)
|
||
{
|
||
continue;
|
||
}
|
||
const gd::RectF& header = layout.rowHeaders[rowIndex];
|
||
if (header.Y + header.Height <= layout.content.Y ||
|
||
header.Y >= layout.content.Y + layout.content.Height)
|
||
{
|
||
continue; // 完全滚出可视范围
|
||
}
|
||
const std::uint32_t signalColor = StyleColor(*resolved.style);
|
||
const UiInk ink = UiContrastInk(signalColor);
|
||
const Lane::MeanFilterItem* item = tabLane.FindMeanFilterItem(ref);
|
||
const bool checked = (item != nullptr);
|
||
|
||
// 叠底:先铺一层不透明同色再叠 0xE8(与「自由标记」分组栏同一做法),
|
||
// 这样滚动时下层内容不会透出来。
|
||
UiFillRoundRect(g, header, 3.0f * m_scale, signalColor);
|
||
UiFillRoundRect(g, header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8));
|
||
|
||
// 左侧三角(仅勾选时存在):朝下 = 展开、朝右 = 收起。
|
||
const gd::RectF& triangle = layout.rowTriangles[rowIndex];
|
||
if (triangle.Width > 0.0f && item != nullptr)
|
||
{
|
||
UiDrawTriangleIcon(g, triangle, ink.text,
|
||
item->collapsed ? IconDirection::RIGHT
|
||
: IconDirection::DOWN);
|
||
}
|
||
|
||
// 名称:从三角右侧到复选框左侧。
|
||
const gd::RectF& check = layout.rowChecks[rowIndex];
|
||
const float nameLeft = (triangle.Width > 0.0f)
|
||
? (triangle.X + triangle.Width + 6.0f * m_scale)
|
||
: (header.X + 8.0f * m_scale);
|
||
const float nameWidth =
|
||
std::max(10.0f, check.X - 6.0f * m_scale - nameLeft);
|
||
UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth),
|
||
m_fonts->uiSmall,
|
||
gd::RectF(nameLeft, header.Y, nameWidth, header.Height), ink.text,
|
||
TextAlign::LEFT, layout.content);
|
||
paintMeanCheck(check, checked, ink, signalColor);
|
||
|
||
if (!checked || item->collapsed)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
// 两行参数滑块(标签 / 轨道 / 数值 / 复位按钮)。
|
||
const auto paintMeanSlider = [&](const gd::RectF& track,
|
||
const gd::RectF& valueRect,
|
||
const gd::RectF& resetRect,
|
||
const wchar_t* label, int current, int minimum,
|
||
int maximum, bool active)
|
||
{
|
||
if (track.Width <= 0.0f)
|
||
{
|
||
return;
|
||
}
|
||
const float labelLeft = track.X - 6.0f * m_scale -
|
||
UiMeasureTextWidth(m_fonts->uiSmall, label);
|
||
UiText(g, label, m_fonts->uiSmall,
|
||
gd::RectF(labelLeft, track.Y - 8.0f * m_scale,
|
||
UiMeasureTextWidth(m_fonts->uiSmall, label),
|
||
track.Height + 16.0f * m_scale),
|
||
m_theme->textMuted, TextAlign::LEFT);
|
||
const double ratio =
|
||
(maximum > minimum)
|
||
? (static_cast<double>(current - minimum) /
|
||
static_cast<double>(maximum - minimum))
|
||
: 0.0;
|
||
UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale);
|
||
UiText(g, std::to_wstring(current), m_fonts->monoSmall,
|
||
gd::RectF(valueRect.X, valueRect.Y, valueColumn, valueRect.Height),
|
||
m_theme->textStrong, TextAlign::LEFT);
|
||
// 复位按钮:悬停高亮 + 与「系数/偏移」菜单同款图标。
|
||
const bool hot =
|
||
(m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) &&
|
||
UiPointInRect(resetRect, static_cast<float>(m_mouseX),
|
||
static_cast<float>(m_mouseY));
|
||
if (hot)
|
||
{
|
||
UiFillRoundRect(
|
||
g, gd::RectF(resetRect.X - 1.0f * m_scale,
|
||
resetRect.Y - 1.0f * m_scale,
|
||
resetRect.Width + 2.0f * m_scale,
|
||
resetRect.Height + 2.0f * m_scale),
|
||
3.0f * m_scale, WithAlpha(m_theme->accent, 0x33));
|
||
}
|
||
PaintPanelResetIcon(g, resetRect,
|
||
hot ? m_theme->accent : m_theme->textMuted, m_scale);
|
||
};
|
||
|
||
const int pointsMax = std::max(kMeanFilterPointsMin,
|
||
std::min(kMeanFilterPointsMaxLimit,
|
||
item->maxPoints));
|
||
const int points =
|
||
std::max(kMeanFilterPointsMin, std::min(pointsMax, item->points));
|
||
const int iterationsMax = std::max(kMeanFilterIterationsMin,
|
||
std::min(kMeanFilterIterationsMaxLimit,
|
||
item->maxIterations));
|
||
const int iterations = std::max(
|
||
kMeanFilterIterationsMin, std::min(iterationsMax, item->iterations));
|
||
const bool pointsActive = (m_dragMode == PlotDragMode::MEAN_POINTS) &&
|
||
(m_meanDragLane == static_cast<int>(index)) &&
|
||
(m_meanDragRef == ref);
|
||
const bool iterationsActive =
|
||
(m_dragMode == PlotDragMode::MEAN_ITERATIONS) &&
|
||
(m_meanDragLane == static_cast<int>(index)) && (m_meanDragRef == ref);
|
||
if (rowIndex < layout.rowLevelTracks.size())
|
||
{
|
||
paintMeanSlider(layout.rowLevelTracks[rowIndex],
|
||
rowIndex < layout.rowLevelValues.size()
|
||
? layout.rowLevelValues[rowIndex]
|
||
: gd::RectF(),
|
||
rowIndex < layout.rowLevelResets.size()
|
||
? layout.rowLevelResets[rowIndex]
|
||
: gd::RectF(),
|
||
kMeanFilterPointsSliderLabel, points,
|
||
kMeanFilterPointsMin, pointsMax, pointsActive);
|
||
}
|
||
if (rowIndex < layout.rowIterTracks.size())
|
||
{
|
||
paintMeanSlider(layout.rowIterTracks[rowIndex],
|
||
rowIndex < layout.rowIterValues.size()
|
||
? layout.rowIterValues[rowIndex]
|
||
: gd::RectF(),
|
||
rowIndex < layout.rowIterResets.size()
|
||
? layout.rowIterResets[rowIndex]
|
||
: gd::RectF(),
|
||
kMeanFilterIterationsSliderLabel, iterations,
|
||
kMeanFilterIterationsMin, iterationsMax, iterationsActive);
|
||
}
|
||
}
|
||
|
||
g.Restore(meanSave);
|
||
}
|
||
// 「自由标记」面板:按信号分组的**分组栏**(信号色托底 + 三角 + 名称 +
|
||
// 「N 标记点」+ ✕),下属固定点条目六列对齐(序号 / 取值 / 枚举 / 增量 / 时刻 / 间隔),
|
||
// 行尾各带一个 ✕。内容区有滚动偏移时按内容区裁剪。
|
||
if (freeMarker)
|
||
{
|
||
const float groupRowHeight = kFreeMarkerNameRowHeight * m_scale;
|
||
bool markerAny = false;
|
||
for (const Lane::FreeMarkerItem& item : tabLane.freeMarkerItems)
|
||
{
|
||
markerAny = markerAny || item.ref.IsValid();
|
||
}
|
||
|
||
const int contentState = g.Save();
|
||
g.SetClip(layout.content, gd::CombineModeIntersect);
|
||
|
||
if (!markerAny)
|
||
{
|
||
// ⚠️ 未开启「自由标记点选取」时鼠标是普通鼠标,**单击不会固定**任何数据点
|
||
//(用户 2026-10-23 要求)⇒ 提示语必须说清前提,否则用户会以为 bug。
|
||
const wchar_t* emptyHint = tabLane.freeMarkerCursor
|
||
? L"在网格内单击以固定数据点"
|
||
: L"开启「自由标记点选取」后单击网格可固定数据点";
|
||
UiTextClipped(g, emptyHint, m_fonts->uiSmall,
|
||
gd::RectF(layout.content.X, layout.content.Y, layout.content.Width,
|
||
groupRowHeight),
|
||
m_theme->textMuted, TextAlign::LEFT, layout.content);
|
||
}
|
||
|
||
// ---- 分组栏 ----
|
||
// ⚠️ 抽取成 lambda:**吸顶**的分组栏必须在条目之后重画一次(否则正在向上滚的
|
||
// 条目会盖住它)。见下方“条目 → 表头 → 吸顶分组栏”的绘制顺序。
|
||
const auto paintGroupBar = [this, &g, &layout, &tabLane, index](
|
||
const EdgePanelLayout::MarkerGroup& group)
|
||
{
|
||
const ResolvedSignal resolved = ResolveSignal(group.ref);
|
||
if (resolved.data == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
const std::uint32_t signalColor =
|
||
(resolved.style != nullptr) ? StyleColor(*resolved.style) : m_theme->border;
|
||
// 托底 = 信号色;文字一律用**自适应对比色**(与信号面板的组名徽标同规则),
|
||
// 因此无论信号是亮黄还是深蓝都能看清分组栏上的所有内容。
|
||
const UiInk ink = UiContrastInk(signalColor);
|
||
// 先铺一层**完全不透明**的同色,再叠 0xE8(观感不变):
|
||
// 吸顶时它要压住下面滚动的条目,半透明会让条目透出来。
|
||
UiFillRoundRect(g, group.header, 3.0f * m_scale, signalColor);
|
||
UiFillRoundRect(g, group.header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8));
|
||
|
||
// 左侧三角(与信号选择区域组名同款):朝下 = 展开、朝右 = 收起。
|
||
const bool triangleHot =
|
||
(m_freeMarkerTriangleHoverLane == static_cast<int>(index) &&
|
||
m_freeMarkerTriangleHoverSignal == group.ref);
|
||
UiDrawTriangleIcon(g, group.triangle,
|
||
triangleHot ? m_theme->accentSoft : ink.text,
|
||
group.collapsed ? IconDirection::RIGHT
|
||
: IconDirection::DOWN);
|
||
|
||
// 右侧:`N 标记点` + ✕(清除该信号全部固定点)。
|
||
std::size_t markerCount = 0;
|
||
for (const Lane::FreeMarkerItem& entry : tabLane.freeMarkerItems)
|
||
{
|
||
if (entry.ref == group.ref)
|
||
{
|
||
++markerCount;
|
||
}
|
||
}
|
||
const std::wstring countText = std::to_wstring(markerCount) + L" 标记点";
|
||
const float countWidth =
|
||
UiMeasureTextWidth(m_fonts->monoSmall, countText) + 2.0f * m_scale;
|
||
const float countRight = group.close.X - 5.0f * m_scale;
|
||
UiTextClipped(g, countText, m_fonts->monoSmall,
|
||
gd::RectF(countRight - countWidth, group.header.Y, countWidth,
|
||
group.header.Height),
|
||
ink.text, TextAlign::RIGHT, layout.content);
|
||
|
||
const float nameLeft = group.triangle.X + group.triangle.Width + 6.0f * m_scale;
|
||
const float nameWidth =
|
||
std::max(10.0f, countRight - countWidth - 6.0f * m_scale - nameLeft);
|
||
UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth),
|
||
m_fonts->uiSmall,
|
||
gd::RectF(nameLeft, group.header.Y, nameWidth, group.header.Height),
|
||
ink.text, TextAlign::LEFT, layout.content);
|
||
|
||
const bool closeHot = (m_freeMarkerPanelHoverLane == static_cast<int>(index) &&
|
||
m_freeMarkerPanelHoverSignal == group.ref &&
|
||
m_freeMarkerPanelHoverItem == static_cast<std::size_t>(-1));
|
||
if (closeHot)
|
||
{
|
||
UiFillRoundRect(g, group.close, 3.0f * m_scale,
|
||
WithAlpha(m_theme->danger, 0x77));
|
||
}
|
||
const float crossPad = 3.5f * m_scale;
|
||
const std::uint32_t crossColor = closeHot ? m_theme->danger : ink.text;
|
||
UiDrawLine(g, group.close.X + crossPad, group.close.Y + crossPad,
|
||
group.close.X + group.close.Width - crossPad,
|
||
group.close.Y + group.close.Height - crossPad, crossColor,
|
||
1.5f * m_scale);
|
||
UiDrawLine(g, group.close.X + group.close.Width - crossPad,
|
||
group.close.Y + crossPad, group.close.X + crossPad,
|
||
group.close.Y + group.close.Height - crossPad, crossColor,
|
||
1.5f * m_scale);
|
||
};
|
||
for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups)
|
||
{
|
||
if (!group.pinned)
|
||
{
|
||
paintGroupBar(group);
|
||
}
|
||
}
|
||
|
||
// ---- 固定点条目(六列,列起点由布局给定 ⇒ 纵向严格对齐)----
|
||
// ⚠️ 取值与各列文本来自**与视界无关的内容缓存**(`FreeMarkerContents`):
|
||
// 大量固定点时逐帧重新采样会让"拖光标 / 平移视界"明显变卡。
|
||
const std::vector<FreeMarkerContentEntry>& contents =
|
||
FreeMarkerContents(static_cast<int>(index));
|
||
// 六列的**实测宽度**:含滚出视界的条目,保证列宽稳定、文本不被省略。
|
||
// 存回 `Lane` 供下一步的布局(`EdgePanelWidth` / `EdgePanelLayoutFor`)使用。
|
||
{
|
||
float widest[6] = {0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f};
|
||
const auto measure = [this, &widest](std::size_t column, const std::wstring& text) {
|
||
if (text.empty() || column >= 6)
|
||
{
|
||
return;
|
||
}
|
||
widest[column] = std::max(widest[column],
|
||
UiMeasureTextWidth(m_fonts->monoSmall, text));
|
||
};
|
||
// 「序号」列:取本网格所有分组里最大的序号文本宽度。
|
||
std::size_t maxSerial = 0;
|
||
std::vector<std::size_t> serialOrder;
|
||
for (const SignalRef& ref : tabLane.signals)
|
||
{
|
||
SortedFreeMarkerIndices(tabLane, ref, serialOrder);
|
||
maxSerial = std::max(maxSerial, serialOrder.size());
|
||
}
|
||
if (maxSerial > 0)
|
||
{
|
||
measure(EdgePanelLayout::MARKER_COL_SERIAL, std::to_wstring(maxSerial));
|
||
}
|
||
for (const FreeMarkerContentEntry& entry : contents)
|
||
{
|
||
measure(EdgePanelLayout::MARKER_COL_VALUE, entry.valueText);
|
||
measure(EdgePanelLayout::MARKER_COL_ENUM, entry.enumText);
|
||
measure(EdgePanelLayout::MARKER_COL_DELTA, entry.deltaText);
|
||
measure(EdgePanelLayout::MARKER_COL_TIME, entry.timeText);
|
||
measure(EdgePanelLayout::MARKER_COL_DT, entry.deltaTimeText);
|
||
}
|
||
std::array<float, 6>& stored =
|
||
tabLanes[index].freeMarkerColumnWidths;
|
||
for (std::size_t column = 0; column < 6; ++column)
|
||
{
|
||
// +2px 余量:`DrawTextW` 的 `DT_END_ELLIPSIS` 对"刚好装满"的文本
|
||
// 有时仍会截成省略号(亚像素/字形伸部),留一点余量可彻底避免。
|
||
const float want =
|
||
(widest[column] > 0.0f) ? (widest[column] + 2.0f * m_scale) : 0.0f;
|
||
if (std::fabs(stored[column] - want) > 0.5f)
|
||
{
|
||
stored[column] = want;
|
||
}
|
||
}
|
||
}
|
||
// 列分隔竖线:在每列右边界(加上半个列间隙)画一条淡竖线,
|
||
// 让「序号 / 标记值 / 枚举文本 / 增量 / 时间点 / 时间间隔」有明显的列分界
|
||
//(用户 2026-10-23 要求)。最后一列不画(它右边紧接行尾 ✕)。
|
||
const auto paintColumnSeparators = [this, &g, &layout](float y, float height) {
|
||
int lastVisible = -1;
|
||
for (int column = 0; column < 6; ++column)
|
||
{
|
||
if (layout.markerColumns[column].width > 0.0f)
|
||
{
|
||
lastVisible = column;
|
||
}
|
||
}
|
||
for (int column = 0; column < lastVisible; ++column)
|
||
{
|
||
const EdgePanelLayout::MarkerColumn& cell = layout.markerColumns[column];
|
||
if (cell.width <= 0.0f)
|
||
{
|
||
continue;
|
||
}
|
||
const float lineX =
|
||
cell.left + cell.width + kFreeMarkerColumnGap * m_scale * 0.5f;
|
||
UiDrawLine(g, lineX, y + 2.0f * m_scale, lineX, y + height - 2.0f * m_scale,
|
||
WithAlpha(m_theme->borderStrong, 0x6C), 1.0f);
|
||
}
|
||
};
|
||
for (const EdgePanelLayout::MarkerRow& markerRow : layout.markerRows)
|
||
{
|
||
if (markerRow.itemIndex >= contents.size())
|
||
{
|
||
continue;
|
||
}
|
||
const FreeMarkerContentEntry& entry = contents[markerRow.itemIndex];
|
||
|
||
// ---- 行的选中 / 悬停高亮(用户 2026-10-22 要求)----
|
||
const bool selected =
|
||
(tabLane.freeMarkerSelected == static_cast<int>(markerRow.itemIndex));
|
||
const bool rowHot = (m_freeMarkerRowHoverLane == static_cast<int>(index) &&
|
||
m_freeMarkerRowHoverItem == markerRow.itemIndex);
|
||
if (selected || rowHot)
|
||
{
|
||
UiFillRect(g,
|
||
gd::RectF(markerRow.rect.X, markerRow.rect.Y + 1.0f * m_scale,
|
||
markerRow.rect.Width,
|
||
markerRow.rect.Height - 2.0f * m_scale),
|
||
selected ? WithAlpha(m_theme->accent, 0x3C)
|
||
: WithAlpha(m_theme->accent, 0x1E));
|
||
}
|
||
|
||
const auto columnRect = [&](EdgePanelLayout::MarkerColumnIndex column) {
|
||
return gd::RectF(layout.markerColumns[column].left, markerRow.rect.Y,
|
||
layout.markerColumns[column].width,
|
||
markerRow.rect.Height);
|
||
};
|
||
// ⚠️ 面板里的数据列**不得省略**(用户 2026-10-21 明确要求):
|
||
// 列宽已按实测文本宽度撑开,因此这里直接整段输出,不再 `UiEllipsize`。
|
||
// ⚠️ 必须用 `UiTextClipped`(GDI 裁剪):`UiText` 走 `DrawTextW`,
|
||
// **不认 GDI+ 裁剪** ⇒ 滚动到内容区上方的条目会把文字画到标题栏上
|
||
//(用户 2026-10-22 报告的显示错误)。
|
||
const auto paintCell = [&](EdgePanelLayout::MarkerColumnIndex column,
|
||
const std::wstring& text, std::uint32_t color) {
|
||
if (layout.markerColumns[column].width <= 0.0f || text.empty())
|
||
{
|
||
return;
|
||
}
|
||
UiTextClipped(g, text, m_fonts->monoSmall, columnRect(column), color,
|
||
TextAlign::LEFT, layout.content);
|
||
};
|
||
paintCell(EdgePanelLayout::MARKER_COL_SERIAL, std::to_wstring(markerRow.serial),
|
||
m_theme->textMuted);
|
||
paintCell(EdgePanelLayout::MARKER_COL_VALUE, entry.valueText,
|
||
m_theme->textStrong);
|
||
if (layout.markerHasEnumColumn)
|
||
{
|
||
paintCell(EdgePanelLayout::MARKER_COL_ENUM, entry.enumText, m_theme->text);
|
||
}
|
||
paintCell(EdgePanelLayout::MARKER_COL_DELTA, entry.deltaText,
|
||
m_theme->textMuted);
|
||
paintCell(EdgePanelLayout::MARKER_COL_TIME, entry.timeText, m_theme->textMuted);
|
||
paintCell(EdgePanelLayout::MARKER_COL_DT, entry.deltaTimeText,
|
||
m_theme->textMuted);
|
||
|
||
// 列分隔竖线(用户 2026-10-23 要求:各列之间要有明显分界)。
|
||
paintColumnSeparators(markerRow.rect.Y, markerRow.rect.Height);
|
||
|
||
// 行尾 ✕:移除该固定点(与之前一致)。
|
||
const gd::RectF& closeRect = markerRow.close;
|
||
const bool hot = (m_freeMarkerPanelHoverLane == static_cast<int>(index) &&
|
||
m_freeMarkerPanelHoverItem == markerRow.itemIndex);
|
||
UiFillRoundRect(g, closeRect, 3.0f * m_scale,
|
||
hot ? WithAlpha(m_theme->danger, 0x66) : m_theme->cardBg);
|
||
UiDrawRoundRect(g, closeRect, 3.0f * m_scale,
|
||
hot ? m_theme->danger : m_theme->border, 1.0f);
|
||
const float crossPad = 3.5f * m_scale;
|
||
const std::uint32_t crossColor = hot ? m_theme->danger : m_theme->textMuted;
|
||
UiDrawLine(g, closeRect.X + crossPad, closeRect.Y + crossPad,
|
||
closeRect.X + closeRect.Width - crossPad,
|
||
closeRect.Y + closeRect.Height - crossPad, crossColor, 1.2f * m_scale);
|
||
UiDrawLine(g, closeRect.X + closeRect.Width - crossPad, closeRect.Y + crossPad,
|
||
closeRect.X + crossPad, closeRect.Y + closeRect.Height - crossPad,
|
||
crossColor, 1.2f * m_scale);
|
||
}
|
||
|
||
// ---- 表头(每个展开分组一行,与条目共用列边界)----
|
||
// ⚠️ 必须在条目**之后**绘制:表头底衬要不透明地盖住向上滚动的条目。
|
||
for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups)
|
||
{
|
||
if (group.tableHeader.Width <= 0.0f || group.tableHeader.Height <= 0.0f)
|
||
{
|
||
continue;
|
||
}
|
||
// 不透明底衬(与面板底色 + 少量边框色调混合,观感是“一条浅灰带”)。
|
||
UiFillRect(g, group.tableHeader,
|
||
MixColor(m_theme->cardBg, m_theme->borderStrong, 0.22));
|
||
UiDrawLine(g, group.tableHeader.X, group.tableHeader.Y + group.tableHeader.Height - 1.0f,
|
||
group.tableHeader.X + group.tableHeader.Width,
|
||
group.tableHeader.Y + group.tableHeader.Height - 1.0f,
|
||
WithAlpha(m_theme->borderStrong, 0x99), 1.0f);
|
||
for (int column = 0; column < 6; ++column)
|
||
{
|
||
const EdgePanelLayout::MarkerColumn& cell =
|
||
layout.markerColumns[column];
|
||
if (cell.width <= 0.0f)
|
||
{
|
||
continue;
|
||
}
|
||
UiTextClipped(g, kFreeMarkerColumnLabels[column], m_fonts->uiSmall,
|
||
gd::RectF(cell.left, group.tableHeader.Y, cell.width,
|
||
group.tableHeader.Height),
|
||
m_theme->textMuted, TextAlign::LEFT, layout.content);
|
||
}
|
||
// 表头的列分隔竖线(与数据行同一套列边界)。
|
||
paintColumnSeparators(group.tableHeader.Y, group.tableHeader.Height);
|
||
}
|
||
|
||
// ---- 吸顶的分组栏(最后画:压住向上滚动的条目)----
|
||
for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups)
|
||
{
|
||
if (group.pinned)
|
||
{
|
||
paintGroupBar(group);
|
||
}
|
||
}
|
||
|
||
g.Restore(contentState);
|
||
|
||
// ---- 内容滚动条(只在内容超高时出现;按钮与滑块不参与滚动)----
|
||
if (layout.scrollTrack.Width > 0.0f)
|
||
{
|
||
UiFillRoundRect(g, layout.scrollTrack, layout.scrollTrack.Width * 0.5f,
|
||
WithAlpha(m_theme->border, 0x88));
|
||
UiFillRoundRect(g, layout.scrollThumb, layout.scrollThumb.Width * 0.5f,
|
||
m_theme->borderStrong);
|
||
}
|
||
|
||
// ---- 「标记透明度」滑动条(固定在「自由标记点选取」按钮上方)----
|
||
if (layout.opacityTrack.Width > 0.0f)
|
||
{
|
||
UiText(g, kFreeMarkerOpacityLabel, m_fonts->uiSmall,
|
||
gd::RectF(panel.X + pad, layout.opacityValue.Y,
|
||
layout.opacityTrack.X - panel.X - pad, layout.opacityValue.Height),
|
||
m_theme->textMuted, TextAlign::LEFT);
|
||
const bool sliderActive =
|
||
(m_dragMode == PlotDragMode::MARKER_OPACITY &&
|
||
m_markerOpacityDragLane == static_cast<int>(index)) ||
|
||
UiPointInRect(layout.opacityTrack, static_cast<float>(m_mouseX),
|
||
static_cast<float>(m_mouseY));
|
||
// 外观与信号样式区的「透明度」滑动条完全相同(同一 `UiDrawSlider` 口径)。
|
||
UiDrawSlider(g, layout.opacityTrack,
|
||
static_cast<double>(tabLane.freeMarkerOpacity), m_theme->accent,
|
||
sliderActive, m_scale);
|
||
const int percent =
|
||
static_cast<int>(tabLane.freeMarkerOpacity * 100.0f + 0.5f);
|
||
UiText(g, std::to_wstring(percent) + L"%", m_fonts->monoSmall,
|
||
layout.opacityValue, m_theme->textStrong, TextAlign::RIGHT);
|
||
}
|
||
}
|
||
|
||
float rowY = panel.Y + titleHeight + pad;
|
||
for (const SignalRef& ref : tabLane.signals)
|
||
{
|
||
const ResolvedSignal resolved = ResolveSignal(ref);
|
||
if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible)
|
||
{
|
||
continue;
|
||
}
|
||
if (itemized || freeMarker)
|
||
{
|
||
// 逐条目绘制已在上面完成(自由标记同样是逐条目排布)。
|
||
continue;
|
||
}
|
||
const std::uint32_t signalColor = StyleColor(*resolved.style);
|
||
const float textWidth = panel.Width - pad * 2.0f;
|
||
// 名称:超长时省略(面板宽度被 `kEdgePanelNameMaxWidth` 限制,不会把面板顶得很长)。
|
||
UiText(g,
|
||
UiEllipsize(resolved.data->name, m_fonts->uiSmall,
|
||
std::min(textWidth, kEdgePanelNameMaxWidth * m_scale)),
|
||
m_fonts->uiSmall, gd::RectF(panel.X + pad, rowY, textWidth, rowHeight),
|
||
signalColor, TextAlign::LEFT);
|
||
|
||
std::size_t rising = 0;
|
||
std::size_t falling = 0;
|
||
bool complete = true;
|
||
EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, tabLane.edgeValue,
|
||
EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling,
|
||
complete);
|
||
paintCounts(gd::RectF(panel.X + pad, rowY + rowHeight, textWidth, rowHeight), rising,
|
||
falling, complete);
|
||
rowY += rowHeight * 2.0f;
|
||
}
|
||
|
||
// 逐条目面板底部按钮行:「构建为新信号」(悬停高亮);
|
||
// 「自由标记」面板的底部固定区有三行:
|
||
// ①「标记透明度」滑块(画在上面);
|
||
// ②「自由标记点选取」切换按钮;
|
||
// ③「显示增量 | 显示枚举文本」两个开关(用户 2026-10-23 要求)。
|
||
// 三者都是切换类按钮外观,与工具栏的切换按钮一致
|
||
//(激活时浅强调色底 + 强调描边 + 强文本色)。
|
||
const auto paintToggleButton = [this, &g](const gd::RectF& buttonRect,
|
||
const wchar_t* label, bool checked) {
|
||
if (buttonRect.Width <= 0.0f)
|
||
{
|
||
return;
|
||
}
|
||
const gd::RectF inner(buttonRect.X, buttonRect.Y + 2.0f * m_scale,
|
||
buttonRect.Width, buttonRect.Height - 4.0f * m_scale);
|
||
const bool hot = (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) &&
|
||
UiPointInRect(inner, static_cast<float>(m_mouseX),
|
||
static_cast<float>(m_mouseY));
|
||
const std::uint32_t background =
|
||
checked ? (hot ? MixColor(m_theme->accentSoft, m_theme->accent, 0.16)
|
||
: m_theme->accentSoft)
|
||
: (hot ? m_theme->hover : m_theme->cardBg);
|
||
UiFillRoundRect(g, inner, 4.0f * m_scale, background);
|
||
UiDrawRoundRect(g, inner, 4.0f * m_scale, checked ? m_theme->accent : m_theme->border,
|
||
1.2f);
|
||
// 窄按钮(两个开关各占半行)里文本可能超宽 ⇒ 省略,不允许画到按钮外。
|
||
const float labelWidth = UiMeasureTextWidth(m_fonts->uiSmall, label);
|
||
const std::wstring text =
|
||
(labelWidth <= inner.Width - 6.0f * m_scale)
|
||
? std::wstring(label)
|
||
: UiEllipsize(label, m_fonts->uiSmall, inner.Width - 6.0f * m_scale);
|
||
UiText(g, text, m_fonts->uiSmall, inner,
|
||
checked ? m_theme->textStrong : m_theme->text, TextAlign::CENTER);
|
||
};
|
||
if (hasButton)
|
||
{
|
||
paintToggleButton(layout.button, kBuildSignalLabel, false);
|
||
}
|
||
if (freeMarker)
|
||
{
|
||
paintToggleButton(layout.button, kFreeMarkerCursorModeLabel,
|
||
tabLane.freeMarkerCursor);
|
||
paintToggleButton(layout.showDelta, kFreeMarkerShowDeltaLabel,
|
||
tabLane.freeMarkerShowDelta);
|
||
paintToggleButton(layout.showEnum, kFreeMarkerShowEnumLabel,
|
||
tabLane.freeMarkerShowEnum);
|
||
}
|
||
} // if (!layout.collapsed)
|
||
|
||
// 面板画完了:恢复外层收集器,并把本面板的文本框存进 slot
|
||
//(命中缓存时靠它回放,见 `PanelSlot::textRects`)。
|
||
UiSetTextRectSink(panelTextBoxes.Outer());
|
||
|
||
// 把它刚画进后备缓冲区的像素(连同缓存键)存进面板图层,
|
||
// 下一帧只要键不变就直接贴图。矩形外的 4 逻辑像素边距用于容纳投影。
|
||
PanelSlot& stored = nextSlots[storedIndex];
|
||
stored.laneIndex = static_cast<int>(index);
|
||
stored.kind = static_cast<int>(kind);
|
||
stored.key = 0;
|
||
if (panelCacheReady && slotKey != 0)
|
||
{
|
||
const int x0 = std::max(0, static_cast<int>(std::floor(cachedRect.X)));
|
||
const int y0 = std::max(0, static_cast<int>(std::floor(cachedRect.Y)));
|
||
const int x1 = std::max(x0, static_cast<int>(std::ceil(cachedRect.X + cachedRect.Width)));
|
||
const int y1 = std::max(y0, static_cast<int>(std::ceil(cachedRect.Y + cachedRect.Height)));
|
||
const gd::RectF layerRect(static_cast<float>(x0), static_cast<float>(y0),
|
||
static_cast<float>(x1 - x0), static_cast<float>(y1 - y0));
|
||
if (x1 > x0 && y1 > y0 && PanelRectInsideLayer(layerRect))
|
||
{
|
||
HDC source = g.GetHDC();
|
||
if (source != nullptr)
|
||
{
|
||
const BOOL ok = BitBlt(m_panelDc, x0, y0, x1 - x0, y1 - y0, source, x0, y0,
|
||
SRCCOPY);
|
||
g.ReleaseHDC(source);
|
||
if (ok != FALSE)
|
||
{
|
||
stored.key = slotKey;
|
||
stored.x0 = x0;
|
||
stored.y0 = y0;
|
||
stored.x1 = x1;
|
||
stored.y1 = y1;
|
||
stored.textRects = panelTextBoxes.Rects();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
m_panelSlots = std::move(nextSlots);
|
||
m_panelGlobalKey = panelGlobalKey;
|
||
m_panelValid = panelCacheReady;
|
||
|
||
// 面板之后绘制的叠加层(信号拖拽虚影 / 提示 / 输入框 / 滚动条 / 回到最上方)
|
||
// 都在**所有面板之上** ⇒ 文本层号推到最上面板的上一层(与 `BuildBoxes` 同源)。
|
||
UiSetTextLayer(panelTextLayer + static_cast<int>(m_panelOrder.size()));
|
||
}
|
||
|
||
void PlotView::PaintEditField(gd::Graphics& g)
|
||
{
|
||
const gd::RectF fieldRect = EditFieldRect();
|
||
if (fieldRect.Width <= 0.0f || fieldRect.Height <= 0.0f)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// 编辑态:强调色外框 + 选区高亮 + 插入符,首次键入即整体替换(与常规输入框一致)。
|
||
UiDrawEditField(g, fieldRect, m_edit, m_fonts->monoSmall, m_scale);
|
||
|
||
// 「迭代次数 N 上限」:输入框**以空内容开始**,未输入任何内容时以灰色提示当前上限值。
|
||
// 提示文案 = “输入上限(当前N)”(用户明确要求:旧实现只显示一个孤立的数字,缺少说明)。
|
||
// 「均值滤波」两行滑块的上限输入框共用同一套提示文案。
|
||
if ((m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX ||
|
||
m_editTarget == PlotEditTarget::MEAN_POINTS_MAX ||
|
||
m_editTarget == PlotEditTarget::MEAN_ITERATIONS_MAX) &&
|
||
m_edit.Text().empty())
|
||
{
|
||
const bool peakTarget = (m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX);
|
||
const int currentMax =
|
||
peakTarget
|
||
? ((m_peakEditCurrentMax > 0) ? m_peakEditCurrentMax : kWeakPeakIterationsDefaultMax)
|
||
: ((m_meanEditCurrentMax > 0)
|
||
? m_meanEditCurrentMax
|
||
: ((m_editTarget == PlotEditTarget::MEAN_POINTS_MAX)
|
||
? kMeanFilterPointsDefaultMax
|
||
: kMeanFilterIterationsDefaultMax));
|
||
const std::wstring hint = L"输入上限(当前" + std::to_wstring(currentMax) + L")";
|
||
const float hintLeft = fieldRect.X + 11.0f * m_scale;
|
||
UiTextAtClipped(g, hint, m_fonts->monoSmall, hintLeft,
|
||
gd::RectF(hintLeft, fieldRect.Y,
|
||
std::max(0.0f, fieldRect.X + fieldRect.Width -
|
||
hintLeft - 4.0f * m_scale),
|
||
fieldRect.Height),
|
||
m_theme->textMuted, fieldRect);
|
||
}
|
||
}
|
||
|
||
void PlotView::PaintSignalInteraction(gd::Graphics& g)
|
||
{
|
||
if (m_tab == nullptr || m_dataSet == nullptr || m_lanes.empty())
|
||
{
|
||
return;
|
||
}
|
||
|
||
// 拖拽中:虚影跟随鼠标纵向位移,同时在目标网格内高亮落点。
|
||
if (m_dragMode == PlotDragMode::MOVE_SIGNAL && m_dragSignal.IsValid())
|
||
{
|
||
const ResolvedSignal resolved = ResolveSignal(m_dragSignal);
|
||
const int sourceLane = m_tab->LaneOfSignal(m_dragSignal);
|
||
if (resolved.data == nullptr || resolved.style == nullptr || sourceLane < 0 ||
|
||
sourceLane >= static_cast<int>(m_lanes.size()))
|
||
{
|
||
return;
|
||
}
|
||
|
||
// 虚影:以源网格的幅值映射构建波形,再按鼠标位移整体平移。
|
||
// 第一帧位移为 0,因此虚影必然精确落在鼠标下方(曲线形态与抓住时完全一致);
|
||
// 之后纵向与横向均自由跟随,形成“拖着曲线走”的观感。
|
||
const LaneVisual& sourceVisual = m_lanes[static_cast<std::size_t>(sourceLane)];
|
||
const float offsetX = static_cast<float>(m_dragMouseX - m_dragStartX);
|
||
const float offsetY = static_cast<float>(m_dragMouseY - m_dragStartY);
|
||
|
||
{
|
||
const int saveState = g.Save();
|
||
g.SetClip(gd::RectF(m_plotRect.X, m_plotRect.Y, m_plotRect.Width, m_plotRect.Height),
|
||
gd::CombineModeIntersect);
|
||
|
||
// 虚影的几何与源网格里的信号完全一致,直接复用折线顶点缓存,
|
||
// 再用平移变换让整体跟随鼠标:避免每帧重新构建并逐点搬运上万顶点。
|
||
const PolylineCacheEntry* ghostSource = AcquirePolyline(m_dragSignal, resolved, sourceVisual);
|
||
if (ghostSource == nullptr)
|
||
{
|
||
g.Restore(saveState);
|
||
return;
|
||
}
|
||
const std::vector<gd::PointF>& points = ghostSource->points;
|
||
const std::vector<std::size_t>& breaks = ghostSource->breaks;
|
||
|
||
g.TranslateTransform(offsetX, offsetY);
|
||
|
||
const DrawMode mode = resolved.style->mode;
|
||
const bool drawLine = (mode != DrawMode::SCATTER);
|
||
const bool drawScatter = (mode == DrawMode::SCATTER || mode == DrawMode::LINE_SCATTER);
|
||
|
||
// 虚影保持原信号的颜色与样式(不再是虚线),仅以轻微透明区分“尚未落地”。
|
||
const std::uint32_t ghostColor = WithAlpha(resolved.style->color,
|
||
static_cast<unsigned int>(
|
||
std::max(0.35, static_cast<double>(resolved.style->opacity)) *
|
||
255.0 + 0.5));
|
||
|
||
if (drawLine && !breaks.empty())
|
||
{
|
||
if (ghostSource->bandMode && !ghostSource->bandBreaks.empty())
|
||
{
|
||
// 稠密包络虚影同样用一次 FillPath(见 PaintLane 的说明)。
|
||
gd::GraphicsPath bandPath;
|
||
bandPath.SetFillMode(gd::FillModeWinding);
|
||
for (std::size_t k = 0; k < ghostSource->bandBreaks.size(); ++k)
|
||
{
|
||
const std::size_t start = ghostSource->bandBreaks[k];
|
||
const std::size_t stop = (k + 1 < ghostSource->bandBreaks.size())
|
||
? ghostSource->bandBreaks[k + 1]
|
||
: ghostSource->band.size();
|
||
const std::size_t total = stop - start;
|
||
if (total < 4 || (total % 2) != 0)
|
||
{
|
||
continue;
|
||
}
|
||
const std::size_t half = total / 2;
|
||
bandPath.StartFigure();
|
||
bandPath.AddLines(ghostSource->band.data() + start, static_cast<INT>(half));
|
||
bandPath.AddLines(ghostSource->band.data() + start + half,
|
||
static_cast<INT>(half));
|
||
bandPath.CloseFigure();
|
||
}
|
||
if (bandPath.GetPointCount() > 0)
|
||
{
|
||
gd::SolidBrush brush(ToGdiColor(ghostColor));
|
||
g.FillPath(&brush, &bandPath);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
gd::Pen ghost(ToGdiColor(ghostColor),
|
||
std::max(1.0f, resolved.style->lineWidth) * m_scale);
|
||
ghost.SetLineJoin(gd::LineJoinRound);
|
||
ghost.SetStartCap(gd::LineCapRound);
|
||
ghost.SetEndCap(gd::LineCapRound);
|
||
for (std::size_t k = 0; k < breaks.size(); ++k)
|
||
{
|
||
const std::size_t start = breaks[k];
|
||
const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size();
|
||
if (stop <= start + 1)
|
||
{
|
||
continue;
|
||
}
|
||
g.DrawLines(&ghost, points.data() + start, static_cast<INT>(stop - start));
|
||
}
|
||
}
|
||
}
|
||
|
||
if (drawScatter && !points.empty())
|
||
{
|
||
const float radius = static_cast<float>(
|
||
ClampDouble(static_cast<double>(resolved.style->lineWidth) * kScatterRadiusScale,
|
||
kMinScatterRadius, kMaxScatterRadius) *
|
||
m_scale);
|
||
const std::size_t scatterStride =
|
||
std::max<std::size_t>(1, points.size() / kMaxScatterPoints);
|
||
// 与主绘制一致:合并成一条路径一次填充,并显式使用缠绕规则避免重叠处出现空洞。
|
||
gd::GraphicsPath ghostPath;
|
||
ghostPath.SetFillMode(gd::FillModeWinding);
|
||
for (std::size_t k = 0; k < points.size(); k += scatterStride)
|
||
{
|
||
ghostPath.AddEllipse(points[k].X - radius, points[k].Y - radius, radius * 2.0f,
|
||
radius * 2.0f);
|
||
}
|
||
gd::SolidBrush brush(ToGdiColor(ghostColor));
|
||
if (ghostPath.GetPointCount() > 0)
|
||
{
|
||
g.FillPath(&brush, &ghostPath);
|
||
}
|
||
}
|
||
else if (drawScatter && resolved.data->finiteCount == 1)
|
||
{
|
||
// ⚠️ **只有单个有效数据点**的信号:折线缓存给不出顶点(`points` 可能为空),
|
||
// 因此直接按数据点的屏幕坐标画那个圆点,否则拖动时虚影会**整个消失**
|
||
//(用户 2026-10-15 要求单点信号也能拖拽换网格)。
|
||
gd::PointF dot;
|
||
if (SinglePointScreenPos(resolved, sourceVisual, dot))
|
||
{
|
||
const float radius = SinglePointDotRadius(resolved);
|
||
gd::SolidBrush brush(ToGdiColor(ghostColor));
|
||
g.FillEllipse(&brush, dot.X - radius, dot.Y - radius, radius * 2.0f, radius * 2.0f);
|
||
}
|
||
}
|
||
|
||
g.Restore(saveState);
|
||
}
|
||
|
||
// 落点提示:高亮鼠标所在网格并给出文字说明。
|
||
const int target = (m_dragTargetLane >= 0) ? m_dragTargetLane : sourceLane;
|
||
if (target >= 0 && target < static_cast<int>(m_lanes.size()))
|
||
{
|
||
const gd::RectF& targetRect = m_lanes[static_cast<std::size_t>(target)].rect;
|
||
const float visibleTop = std::max(targetRect.Y, m_plotRect.Y);
|
||
const float visibleBottom = std::min(targetRect.Y + targetRect.Height,
|
||
m_plotRect.Y + m_plotRect.Height);
|
||
if (visibleBottom > visibleTop)
|
||
{
|
||
gd::Pen frame(ToGdiColor(m_theme->accent), 2.0f);
|
||
g.DrawRectangle(&frame, targetRect.X, visibleTop, targetRect.Width,
|
||
visibleBottom - visibleTop);
|
||
|
||
const std::wstring hint = (target == sourceLane)
|
||
? std::wstring(L"松开左键放回本网格")
|
||
: (L"移动到网格 " + std::to_wstring(target + 1) +
|
||
L":松开左键确认");
|
||
const float textWidth = UiMeasureTextWidth(m_fonts->uiSmall, hint) + 16.0f * m_scale;
|
||
const float height = 20.0f * m_scale;
|
||
float left = targetRect.X + (targetRect.Width - textWidth) * 0.5f;
|
||
left = std::max(m_plotRect.X, std::min(left, m_plotRect.X + m_plotRect.Width - textWidth));
|
||
const gd::RectF pill(left, visibleTop + 4.0f * m_scale, textWidth, height);
|
||
UiFillRoundRect(g, pill, 3.0f * m_scale, m_theme->accent);
|
||
UiText(g, hint, m_fonts->uiSmall, pill, m_theme->textOnAccent, TextAlign::CENTER);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 其它拖拽(光标/平移/中键/横向光标/面板)期间不显示信号悬停提示:
|
||
// 这些操作不会更新 m_hoverSignal,按下前的值会残留,表现为提示气泡"挂在原地"。
|
||
if (m_dragMode != PlotDragMode::NONE)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// 悬停提示:光标贴近曲线时给出可拖拽说明(与时间光标的提示方式一致)。
|
||
if (!m_hoverSignal.IsValid())
|
||
{
|
||
return;
|
||
}
|
||
const int laneIndex = m_tab->LaneOfSignal(m_hoverSignal);
|
||
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
|
||
{
|
||
return;
|
||
}
|
||
const ResolvedSignal resolved = ResolveSignal(m_hoverSignal);
|
||
if (resolved.data == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const LaneVisual& lane = m_lanes[static_cast<std::size_t>(laneIndex)];
|
||
// ⚠️ 「选区放大」模式下**仍保留悬停高亮**,但该模式下拖动是框选、不是拖信号
|
||
// ⇒ 提示文案去掉“按住拖拽可移入其它网格”的尾缀,只提示信号名(用户 2026-10-17 要求)。
|
||
// ⚠️ 同理(用户 2026-10-23 要求):该网格开启「自由标记点选取」时,左键专用于固定
|
||
// 标记点、拖动也不再是拖信号 ⇒ 提示只显示信号名,不再提示可以拖拽。
|
||
const std::vector<Lane>& tabLanes = m_tab->Lanes();
|
||
const bool pickMode =
|
||
laneIndex < static_cast<int>(tabLanes.size()) &&
|
||
tabLanes[static_cast<std::size_t>(laneIndex)].freeMarker &&
|
||
tabLanes[static_cast<std::size_t>(laneIndex)].freeMarkerCursor;
|
||
const std::wstring text =
|
||
(Tab().ZoomMode() == PlotZoomMode::SELECT || pickMode)
|
||
? resolved.data->name
|
||
: (resolved.data->name + L" · 按住拖拽可移入其它网格");
|
||
|
||
// 提示跟随鼠标:在可拖拽区域内持续移动时,提示条也要跟着走。
|
||
const float textWidth = UiMeasureTextWidth(m_fonts->uiSmall, text) + 16.0f * m_scale;
|
||
const float height = 20.0f * m_scale;
|
||
float left = static_cast<float>(m_mouseX) + 12.0f * m_scale;
|
||
if (left + textWidth > m_plotRect.X + m_plotRect.Width)
|
||
{
|
||
left = m_plotRect.X + m_plotRect.Width - textWidth;
|
||
}
|
||
left = std::max(m_plotRect.X, left);
|
||
|
||
float top = static_cast<float>(m_mouseY) + 14.0f * m_scale;
|
||
top = std::min(top, m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale);
|
||
top = std::max(top, lane.rect.Y + 2.0f * m_scale);
|
||
|
||
const gd::RectF pill(left, top, textWidth, height);
|
||
UiFillRoundRect(g, pill, 3.0f * m_scale, WithAlpha(m_theme->readoutBg, 0xF2));
|
||
UiDrawRoundRect(g, pill, 3.0f * m_scale, m_theme->accent, 1.0f);
|
||
UiText(g, text, m_fonts->uiSmall, pill, m_theme->textStrong, TextAlign::CENTER);
|
||
}
|
||
|
||
void PlotView::PaintScrollBar(gd::Graphics& g)
|
||
{
|
||
if (!m_scrollBarVisible || m_laneContentHeight <= m_plotRect.Height)
|
||
{
|
||
return;
|
||
}
|
||
|
||
UiFillRoundRect(g, m_scrollBarRect, kScrollBarThickness * m_scale * 0.5f, m_theme->gridWeak);
|
||
|
||
const float ratio = std::min(1.0f, m_plotRect.Height / m_laneContentHeight);
|
||
const float thumbHeight = std::max(24.0f * m_scale, m_scrollBarRect.Height * ratio);
|
||
const float maxScroll = m_laneContentHeight - m_plotRect.Height;
|
||
const float progress = (maxScroll > 0.0f) ? (Tab().LaneScroll() / maxScroll) : 0.0f;
|
||
const float thumbY = m_scrollBarRect.Y + progress * (m_scrollBarRect.Height - thumbHeight);
|
||
|
||
const gd::RectF thumb(m_scrollBarRect.X, thumbY, m_scrollBarRect.Width, thumbHeight);
|
||
UiFillRoundRect(g, thumb, kScrollBarThickness * m_scale * 0.5f, m_theme->borderStrong);
|
||
}
|
||
|
||
void PlotView::PaintBackToTop(gd::Graphics& g)
|
||
{
|
||
if (m_backToTopAlpha <= 0.01f)
|
||
{
|
||
return;
|
||
}
|
||
const gd::RectF rect = BackToTopRect();
|
||
if (rect.Width <= 1.0f)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// 过渡:整个按钮(底色/描边/箭头/投影)按 `m_backToTopAlpha` 淡入淡出。
|
||
const float alpha = std::min(1.0f, std::max(0.0f, m_backToTopAlpha));
|
||
const auto faded = [alpha](std::uint32_t color) -> std::uint32_t
|
||
{
|
||
const std::uint32_t a =
|
||
static_cast<std::uint32_t>(static_cast<float>((color >> 24) & 0xFFu) * alpha);
|
||
return (color & 0x00FFFFFFu) | ((a & 0xFFu) << 24);
|
||
};
|
||
|
||
const float radius = rect.Width * 0.5f; // 圆形按钮(与图例托底/GX 徽标同一套配色)
|
||
// 投影:两层递减透明度的圆角矩形(与弹出菜单/浮动面板一致,不引入系统阴影 API)。
|
||
UiFillRoundRect(g, gd::RectF(rect.X + 1.0f * m_scale, rect.Y + 2.0f * m_scale, rect.Width,
|
||
rect.Height),
|
||
radius, faded(0x1A000000u));
|
||
UiFillRoundRect(g, gd::RectF(rect.X, rect.Y + 1.0f * m_scale, rect.Width, rect.Height), radius,
|
||
faded(0x14000000u));
|
||
|
||
const bool hot = m_backToTopHover || m_backToTopPressed;
|
||
UiFillRoundRect(g, rect, radius, faded(hot ? m_theme->accentSoft : m_theme->cardBg));
|
||
UiDrawRoundRect(g, rect, radius, faded(hot ? m_theme->accent : m_theme->borderStrong),
|
||
hot ? 1.6f : 1.0f);
|
||
|
||
// 向上箭头:竖杆 + 两条斜线组成的箭头(自绘,不依赖字体)。
|
||
const float cx = rect.X + rect.Width * 0.5f;
|
||
const float cy = rect.Y + rect.Height * 0.5f;
|
||
const float depth = rect.Width * kBackToTopArrowDepth;
|
||
const float halfWidth = rect.Width * kBackToTopArrowHalfWidth;
|
||
const std::uint32_t ink = faded(hot ? m_theme->accent : m_theme->textStrong);
|
||
const float stroke = std::max(1.6f, 2.0f * m_scale);
|
||
UiDrawLine(g, cx, cy + depth, cx, cy - depth, ink, stroke);
|
||
UiDrawLine(g, cx, cy - depth, cx - halfWidth, cy - depth * 0.15f, ink, stroke);
|
||
UiDrawLine(g, cx, cy - depth, cx + halfWidth, cy - depth * 0.15f, ink, stroke);
|
||
}
|
||
|
||
} // namespace jdi
|
||
|