Files
Just-DataInspector/Components/PlotView/Src/PlotView.cpp
T
DeepSeek V4.1 Flash 709cbe4a87 后台计算线程优先级降为低于正常,确保拖动时 UI 优先获得 CPU
派生曲线计算是可延迟的后台工作,不能与 UI/绘制线程抢核。后台线程池的每个线程设为 THREAD_PRIORITY_BELOW_NORMAL,UI 线程需要运行时系统优先调度 UI,计算只在 UI 空闲时占用 CPU。
2026-10-11 14:23:42 +08:00

9513 lines
334 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "PlotView.hpp"
#include <algorithm>
#include <cmath>
#include <cstring>
#include <limits>
#include "Utf8Util.hpp"
namespace jdi
{
namespace
{
const int kTimeTickTarget = 10; /**< 时间轴期望刻度数。 */
const int kYTickTarget = 6; /**< 幅值轴期望刻度数(网格较高时)。 */
const int kYTickTargetMin = 3; /**< 幅值轴最少刻度数(网格很矮时也要保证可读数)。 */
const int kYTickTargetMax = 8; /**< 幅值轴最多刻度数。 */
const float kYTickSpacing = 28.0f; /**< 幅值刻度之间的理想间距(逻辑像素)。 */
const float kYLabelMinGap = 15.0f; /**< 幅值标签的最小行距(逻辑像素)。 */
const int kMinorDivisions = 5; /**< 相邻主刻度之间的等分数(用于估读)。 */
const unsigned int kMinorLineAlpha = 0x9Au; /**< 辅助等分线的透明度(越高越清晰)。 */
const float kScrollBarThickness = 9.0f; /**< 滚动条宽度(逻辑像素)。 */
const float kLanePadding = 8.0f; /**< 网格内边距。 */
const size_t kMaxPolylinePoints = 20000; /**< 单条折线最大顶点数(上限保护)。 */
const size_t kPolylinePointsPerPixel = 2; /**< 每个像素列允许的折线顶点数(上/下包络两顶点)。 */
const size_t kMinPolylinePoints = 512; /**< 折线最少顶点数(窗口很窄时仍保留细节)。 */
const float kEnvelopeMinPixelSpan = 0.5f; /**< 包络纵向跨度小于该像素值时合并为单个顶点。 */
const float kCursorHandleReserve = 9.0f; /**< 光标顶部三角把手占用的高度(数据点贴边时需避让)。 */
const float kMarkerEdgeInset = 7.0f; /**< 数据点越界贴边时距网格可见边界的间距。 */
const float kMarkerInsideMargin = 2.0f; /**< 数据点在视界内时允许贴到边界的最小余量。 */
const float kMarkerTriangleArm = 5.0f; /**< 越界方向三角"半宽/半高"(垂直于朝向的半边长)。 */
/** 越界方向三角沿朝向的深度(与光标把手同比例)。 */
const float kMarkerTriangleDepth = kMarkerTriangleArm * 1.4f;
/**
* 越界方向三角与数值/时间标注之间的间隙(逻辑像素)。
*
* 三角与胶囊之间**必须留出这段空白**:贴在一起时三角看起来像胶囊上多出来的
* 一个尖角(用户报告的"三角形与数值显示部分没有间隔")。
*/
const float kMarkerTriangleGap = 4.0f;
const size_t kMaxScatterPoints = 2500; /**< 单条散点最大绘制点数。 */
const size_t kPolylineCacheLimit = 64; /**< 折线顶点缓存条目上限。 */
const float kMinScatterRadius = 1.6f; /**< 散点最小半径(逻辑像素)。 */
const float kMaxScatterRadius = 12.0f; /**< 散点最大半径(逻辑像素)。 */
const float kScatterRadiusScale = 1.5f; /**< 散点半径相对线宽的增长系数。 */
const double kMinTimeSpan = 1.0e-12; /**< 允许的最小时间跨度。 */
const float kPlotSidePadding = 8.0f; /**< 绘图区左右留白(逻辑像素)。 */
const float kLaneDividerWidth = 2.0f; /**< 网格之间分界线的宽度。 */
const float kSignalHitTolerance = 6.0f; /**< 信号曲线命中容差(逻辑像素)。 */
const float kCursorSnapMinPixels = 6.0f; /**< 光标吸附采样点的最小采样间隔(逻辑像素)。 */
const float kLaneLabelPad = 6.0f; /**< 网格内侧标尺带的内边距。 */
const float kLegendHeight = 19.0f; /**< 图例条目高度(逻辑像素)。 */
const float kLegendPad = 5.0f; /**< 图例相对网格左上角的内缩。 */
const float kLegendSwatchSize = 8.0f; /**< 图例色块尺寸。 */
const float kLegendItemGap = 14.0f; /**< 图例条目之间的间距。 */
const float kLegendMaxTextWidth = 200.0f; /**< 图例条目的文本宽度上限(超出则省略)。 */
const float kLaneBadgeWidth = 30.0f; /**< 网格编号徽标宽度(与信号面板保持一致)。 */
const float kLaneBadgeHeight = 17.0f; /**< 网格编号徽标高度。 */
const float kLaneBadgeGap = 8.0f; /**< 编号徽标与图例条目之间的间距。 */
const float kEdgePanelWidth = 230.0f; /**< 边沿统计浮动面板宽度。 */
const float kEdgePanelTitleHeight = 26.0f; /**< 面板标题栏高度。 */
const float kEdgePanelRowHeight = 18.0f; /**< 面板内单行文本高度。 */
const float kEdgePanelPad = 7.0f; /**< 面板内边距。 */
const float kEdgePanelEditFieldWidth = 64.0f; /**< 数值框的最小宽度。 */
const float kEdgePanelButtonHeight = 30.0f; /**< 均值滤波面板底部按钮行高度。 */
const float kEdgePanelMeanRowHeight = 22.0f; /**< 逐条目面板中每条信号的名称行高度。 */
const float kEdgePanelMeanEditHeight = 24.0f; /**< 逐条目面板中每条信号的参数行高度。 */
const float kEdgePanelMeanRowGap = 2.0f; /**< 名称行与参数行之间的间距。 */
const float kEdgePanelMeanCheckSize = 15.0f; /**< 逐条目面板中条目复选框尺寸。 */
const float kWeakBoostSliderRowHeight = 26.0f; /**< 弱波增强面板中每个滑块行的高度。 */
const float kWeakBoostTrackHeight = 6.0f; /**< 弱波增强滑块的轨道高度。 */
const float kWeakBoostTrackMinWidth = 120.0f; /**< 弱波增强滑块轨道的最小宽度。 */
const int kWeakBoostIterationsMin = 1; /**< 迭代次数 N 的下限。 */
const int kWeakBoostIterationsMax = 10; /**< 迭代次数 N 的上限。 */
/**
* @brief 派生曲线「区间窗口」缓存的保护带上限(采样点数)。
*
* 窗口只覆盖可见区间 ± 保护带,可见区间内的取值与整条曲线一致(见 `CurveSpanFor`)。
* 上限避免在完全缩小时把保护带放大到整条信号(那就退化成重算整条曲线)。
*/
const std::size_t kDerivedCurveGuardMax = 65536;
/** 派生曲线缓存条目上限(超出时按「最旧优先」淘汰;它只是加速与内存控制手段)。 */
const std::size_t kCurveSpanCacheLimit = 32;
/** 派生曲线缓存允许占用的总字节数(约 768MB):超出时按「最旧优先」淘汰。
* 取足够大是为了让「完全放大时 5 个网格各缓存一条整段曲线」可以同时驻留。 */
const std::size_t kCurveSpanCacheMaxBytes = 768ull * 1024ull * 1024ull;
/**
* @brief 派生(预览)曲线走「后台异步计算」的窗口采样数门槛。
*
* 拖动滑块 / 移动视界 / 缩放每帧都会请求预览曲线。窗口不超过本门槛时同步重算足够快
* (弱波增强 ≈0.9ms/6.5 万采样),预览曲线能**实时跟随控件**;一旦窗口超过门槛
* (大时间跨度、整条信号),同步重算会阻塞交互(弱波增强整条信号单条 ≈368ms),
* 此时改为交后台线程计算、本帧先用旧曲线顶替(双缓冲),保证控件反馈始终跟手。
*/
const std::size_t kDerivedAsyncWindowThreshold = 65536;
const wchar_t* const kWeakBoostLevelLabel = L"弱波增强等级"; /**< 等级滑块行左侧的说明文字。 */
const wchar_t* const kWeakBoostIterationsLabel = L"迭代次数 N"; /**< 迭代次数滑块行左侧的说明文字。 */
const wchar_t* const kWeakBoostTitle = L"弱波增强"; /**< 弱波增强面板标题。 */
const wchar_t* const kBuildSignalLabel = L"构建为新信号"; /**< 逐条目面板底部按钮文案(均值滤波 / 弱波增强共用)。 */
const std::size_t kEdgeCountCacheLimit = 48; /**< 边沿统计缓存条目上限(超出时淘汰最早的一条)。 */
/**
* @brief 判断坐标是否命中「弱波增强」的等级滑块。
*
* 轨道本身只有几个像素高,直接按轨道矩形命中很难抓住,因此把命中区在纵向上下各放宽
* (至少一个轨道高度),与样式区滑块的交互一致。
*
* @param [IN] track 滑块轨道矩形(宽度为 0 表示当前没有滑块)。
* @param [IN] scale DPI 缩放比例。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 命中返回 true。
*/
bool WeakBoostSliderHit(const gd::RectF& track, float scale, float x, float y)
{
if (track.Width <= 0.0f)
{
return false;
}
gd::RectF hit = track;
const float pad = std::max(8.0f * scale, track.Height);
hit.Y -= pad;
hit.Height += pad * 2.0f;
return UiPointInRect(hit, x, y);
}
/**
* @brief 取折线(相邻采样点之间线性连接)在给定时刻的取值。
*
* 包络是按像素列聚合的,因此某一段折线可能**跨越列边界**:跨越边界的那一小段
* 在两个列里都不属于"落在本列内的采样点",只看列内采样点就会把它整段丢掉。
* 该函数用于在列边界处取回这段折线的取值,使相邻两列的包络严丝合缝。
*
* @param [IN] time 时间轴。
* @param [IN] values 采样值。
* @param [IN] count 可用采样点数量。
* @param [IN] firstIndex 第一个时间不小于查询时刻的采样下标(相交线段为 firstIndex-1 → firstIndex)。
* @param [IN] t 查询时刻。
* @param [OUT] out 输出取值。
* @return 折线覆盖该时刻且取到有效值返回 true。
*/
bool PolylineValueAt(const std::vector<double>& time, const std::vector<double>& values,
std::size_t count, std::size_t firstIndex, double t, double& out)
{
if (firstIndex == 0 || firstIndex >= count)
{
// 该时刻之前(或之后)没有采样点,折线并未延伸到这里。
return false;
}
const double t0 = time[firstIndex - 1];
const double t1 = time[firstIndex];
const double v0 = values[firstIndex - 1];
const double v1 = values[firstIndex];
if (!std::isfinite(t0) || !std::isfinite(t1) || !std::isfinite(v0) || !std::isfinite(v1) ||
!(t1 > t0))
{
return false;
}
double ratio = (t - t0) / (t1 - t0);
if (ratio < 0.0)
{
ratio = 0.0;
}
else if (ratio > 1.0)
{
ratio = 1.0;
}
out = v0 + (v1 - v0) * ratio;
return true;
}
/**
* @brief 统计相邻采样对 (k-1, k) 中穿越判定基准的边沿数量。
*
* `deltaAt(k)` 返回第 k 个采样相对判定基准的偏差;返回非有限值(NaN)表示该采样
* 不参与判定,并**打断连续性**(与前一点之间不计边沿)——这正好覆盖
* 「采样缺失」与「采样落在统计时间区间之外」两种情况。
*
* 统计的是采样对 `k ∈ [pairFirst, pairLast]`(闭区间),每对的「前一点」为 `k-1`,
* 因此起点对的连续性由 `deltaAt(pairFirst - 1)` 决定(它无效时从下一个有效点重新起算)。
*
* 该函数是增量统计的基本单位:只要把区间切成互不相交的若干段分别累加(或从总量中减去),
* 结果与一次性扫描整段完全一致,因此拖动光标时可以只扫描两端差集。
*
* @param [IN] pairFirst 起始采样对的下标 k(含),必须 ≥ 1。
* @param [IN] pairLast 结束采样对的下标 k(含);小于起点时不做任何事。
* @param [IN] deltaAt 偏差取值仿函数。
* @param [IN,OUT] rising 累加的上升沿数量。
* @param [IN,OUT] falling 累加的下降沿数量。
*/
template <typename DeltaAt>
void AccumulateEdgeCrossings(std::size_t pairFirst, std::size_t pairLast, DeltaAt deltaAt,
std::size_t& rising, std::size_t& falling)
{
if (pairFirst == 0 || pairFirst > pairLast)
{
return;
}
double previousDelta = deltaAt(pairFirst - 1);
bool hasPrevious = std::isfinite(previousDelta);
for (std::size_t index = pairFirst; index <= pairLast; ++index)
{
const double current = deltaAt(index);
if (!std::isfinite(current))
{
hasPrevious = false;
continue;
}
if (hasPrevious)
{
if (previousDelta < 0.0 && current >= 0.0)
{
++rising;
}
else if (previousDelta > 0.0 && current <= 0.0)
{
++falling;
}
}
previousDelta = current;
hasPrevious = true;
}
}
/**
* @brief 计算统计区间对应的采样下标区间 [begin, end)。
*
* 时间轴单调时用二分定位,并在区间左侧多取一个采样点,
* 使跨越区间边界的边沿也能被计入。
*
* @param [IN] file 所属数据分组。
* @param [IN] count 采样点数量。
* @param [IN] hasRange 是否限定时间区间。
* @param [IN] rangeMin 区间左端点时间。
* @param [IN] rangeMax 区间右端点时间。
* @param [OUT] begin 输出区间起点(含)。
* @param [OUT] end 输出区间终点(不含)。
* @return 计数是否完全由该下标区间决定(时间轴单调时为真)。
*/
bool EdgeIndexRange(const DataFile& file, std::size_t count, bool hasRange, double rangeMin,
double rangeMax, std::size_t& begin, std::size_t& end)
{
begin = 0;
end = count;
if (!hasRange)
{
return true;
}
if (!file.timeMonotonic)
{
// 时间轴非单调时无法用下标区间表达统计范围(需逐点按时间过滤)。
return false;
}
const std::vector<double>& times = file.time;
const std::size_t timeCount = std::min(times.size(), count);
begin = static_cast<std::size_t>(
std::lower_bound(times.begin(), times.begin() + static_cast<std::ptrdiff_t>(timeCount),
rangeMin) -
times.begin());
end = static_cast<std::size_t>(
std::upper_bound(times.begin(), times.begin() + static_cast<std::ptrdiff_t>(timeCount),
rangeMax) -
times.begin());
if (begin > 0)
{
--begin;
}
end = std::min(end, timeCount);
begin = std::min(begin, end);
return true;
}
const std::uint64_t kHintHoldMs = 1300; /**< 提示浮层完全显示的停留时长(毫秒)。 */
const std::uint64_t kHintFadeMs = 500; /**< 提示浮层的淡出时长(毫秒)。 */
/**
* @brief 实验性功能浮动面板的遍历顺序(命中测试与绘制共用,保证层序一致)。
*
* 顺序即绘制层序:先绘制的在下方,最后绘制的在最上方。
*/
const EdgePanelKind kEdgePanelKinds[] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER,
EdgePanelKind::MEAN, EdgePanelKind::BOOST};
/** 实验性功能浮动面板的种类数(遍历与命中测试统一用它,避免新增功能时漏改)。 */
const int kEdgePanelKindCount = static_cast<int>(sizeof(kEdgePanelKinds) / sizeof(kEdgePanelKinds[0]));
/**
* @brief 计算一条派生曲线在给定采样窗口内的取值(同步路径与后台线程共用)。
*
* 只拷贝所需的窗口,再调用与「构建为新信号」完全相同的自由函数,因此
* 预览曲线与构建出的采样值逐位一致。本函数**只读**入参、不接触任何成员状态,
* 因此可以安全地在后台线程调用。
*
* @param [IN] signal 目标信号(数据只读)。
* @param [IN] windowBegin 窗口起点(采样下标)。
* @param [IN] windowEnd 窗口终点(不含)。
* @param [IN] kind `DerivedCurveKind` 的整型值。
* @param [IN] points 均值点数 X。
* @param [IN] iterations 迭代次数 N。
* @param [IN] levelPercent 弱波增强等级(0–100)。
* @return 曲线数值(长度 = 有效窗口长度)。
*/
std::vector<float> ComputeDerivedWindow(const SignalData& signal, std::size_t windowBegin,
std::size_t windowEnd, int kind, int points,
int iterations, int levelPercent)
{
std::vector<float> out;
const std::vector<double>& values = signal.values;
const std::size_t hi = std::min(windowEnd, values.size());
const std::size_t lo = std::min(windowBegin, hi);
if (hi <= lo)
{
return out;
}
const std::vector<double> window(values.begin() + static_cast<std::ptrdiff_t>(lo),
values.begin() + static_cast<std::ptrdiff_t>(hi));
if (kind == static_cast<int>(DerivedCurveKind::WEAK_BOOST))
{
ComputeWeakWaveBoost(window, static_cast<double>(levelPercent) / 100.0, iterations, out);
}
else
{
ComputeMovingAverageIterated(window, points, iterations, out);
}
return out;
}
/**
* @brief 包络带的单个像素列(已映射到屏幕坐标)。
*/
struct BandColumn
{
float x = 0.0f; /**< 列中心横坐标。 */
float top = 0.0f; /**< 本列的**上**包络(较小的纵坐标)。 */
float bottom = 0.0f; /**< 本列的**下**包络(较大的纵坐标)。 */
};
/**
* @brief 把逐列极值拼成闭合的包络带多边形。
*
* 输出格式为「上包络正向 + 下包络反向」,调用方(`PaintLane`)依赖这个顺序把
* 它拆成上下两条折线画成一个闭合环。
*
* ⚠️ **几何必须是精确的**:这里**不做任何**列窗口加宽。曾经为了让填充更快而做过
* ±1 列的极值扩展,结果把"梳状"振荡糊成了实心块,波形细节整体丢失
* (用户反馈"显示效果没有之前好了")。性能问题改由**绘制侧**解决:把梳状带
* 交给无抗锯齿填充(见 `spiky` 输出与 `PlotView::m_fastFrame`)。
*
* @param [IN] columns 逐列极值(同一段内按列顺序排列)。
* @param [OUT] band 追加写入的包络顶点(上包络正向 + 下包络反向)。
* @param [OUT] bandBreaks 追加写入的各段起点(相对 band 的下标)。
* @param [OUT] spiky 该段是否为「梳状」(相邻列包络几乎不相交)。
*/
void EmitEnvelopeBand(const std::vector<BandColumn>& columns, std::vector<gd::PointF>& band,
std::vector<std::size_t>& bandBreaks, bool& spiky)
{
if (columns.size() < 2)
{
return;
}
const std::size_t count = columns.size();
bandBreaks.push_back(band.size());
// 「梳状」判定:相邻列的上下包络几乎不相交(振荡周期与像素列宽相当时出现)。
// 这种形状下抗锯齿填充的扫描线代价暴涨,而抗锯齿对它的平滑作用几乎看不出来。
std::size_t inverted = 0;
const float slack = kEnvelopeMinPixelSpan;
for (std::size_t index = 1; index < count; ++index)
{
if (columns[index].top > columns[index - 1].bottom + slack ||
columns[index].bottom < columns[index - 1].top - slack)
{
++inverted;
}
}
if (inverted * 8 > count)
{
spiky = true;
}
for (std::size_t index = 0; index < count; ++index)
{
band.push_back(gd::PointF(columns[index].x, columns[index].top));
}
for (std::size_t index = count; index-- > 0;)
{
band.push_back(gd::PointF(columns[index].x, columns[index].bottom));
}
}
/**
* @brief 时间间隔的显示单位。
*/
struct DurationUnit
{
double scale = 1.0; /**< 1 个该单位等于多少秒。 */
const wchar_t* name = L"s"; /**< 单位名称。 */
};
/**
* @brief 把时间间隔格式化为"Δt 语义"的复合单位文本。
*
* 规则(用户明确要求):
* - 顶级单位高于 s(即 h / min)时**一路整合到 s**,例如 3662s → "1h 1min 2s";
* - 顶级单位为 s 及以下时,若不是该单位的整数,则**向下一级递进**,
* 例如 5.06s → "5s 60ms";整秒数不加下级单位,例如 5s → "5s";
* - 顶级单位为 ps 时**不再递进次级单位**,例如 5.123ps → "5.123 ps"。
*
* @param [IN] seconds 时间间隔(秒)。
* @return 不含 "Δt" 前缀的间隔文本。
*/
std::wstring FormatDurationCompound(double seconds)
{
if (!std::isfinite(seconds))
{
return L"—";
}
const double value = std::fabs(seconds);
if (value <= 0.0)
{
return L"0s";
}
// ---- 顶级单位高于 s(h / min):整合为 h/min/s(整数秒)----
// 注意条件必须是 >= 60s:1~60s 属于"顶级单位为 s"的情况,需按下一条规则处理
// (非整数秒要递进到 ms,例如 5.06s → "5s 60ms")。
if (value >= 60.0)
{
const long long totalSeconds = static_cast<long long>(std::llround(value));
long long hours = 0;
long long minutes = 0;
long long secs = totalSeconds;
if (totalSeconds >= 3600)
{
hours = totalSeconds / 3600;
secs = totalSeconds % 3600;
}
if (secs >= 60)
{
minutes = secs / 60;
secs = secs % 60;
}
std::wstring text;
const auto append = [&text](long long amount, const wchar_t* unit)
{
if (amount <= 0)
{
return;
}
if (!text.empty())
{
text += L" ";
}
text += std::to_wstring(amount);
text += unit;
};
append(hours, L"h");
append(minutes, L"min");
append(secs, L"s");
if (!text.empty())
{
return text;
}
}
// ---- 顶级单位为 s:整数秒 + 可选 ms ----
if (value >= 1.0)
{
const double whole = std::floor(value);
const double fraction = value - whole;
std::wstring text = FormatValue(whole) + L"s";
const double remainderMs = fraction * 1000.0;
if (remainderMs >= 1.0e-6)
{
text += L" ";
text += FormatValue(remainderMs);
text += L"ms";
}
return text;
}
// ---- 顶级单位低于 s:ms / us / ns / ps ----
const DurationUnit subUnits[] = {
{1.0e-3, L"ms"}, {1.0e-6, L"us"}, {1.0e-9, L"ns"}, {1.0e-12, L"ps"},
};
const std::size_t subCount = sizeof(subUnits) / sizeof(subUnits[0]);
std::size_t index = subCount - 1;
for (std::size_t i = 0; i < subCount; ++i)
{
if (value >= subUnits[i].scale)
{
index = i;
break;
}
}
// 顶级单位为 ps:不再递进次级单位(保留小数)。
if (index + 1 >= subCount)
{
return FormatValue(value / subUnits[index].scale) + L" " + subUnits[index].name;
}
const double scaled = value / subUnits[index].scale;
double whole = std::floor(scaled);
double fraction = scaled - whole;
// 浮点误差可能导致 fraction 极接近 1(例如 4.9999999),归一化一下。
if (fraction >= 1.0 - 1.0e-9)
{
whole += 1.0;
fraction = 0.0;
}
std::wstring text = FormatValue(whole);
text += subUnits[index].name;
// 仍是该单位的整数 → 不显示下级单位。
const double remainder = fraction * subUnits[index].scale;
const double inNext = remainder / subUnits[index + 1].scale;
if (inNext >= 1.0e-6)
{
text += L" ";
text += FormatValue(inNext);
text += subUnits[index + 1].name;
}
return text;
}
/**
* @brief 双光标差值的完整显示文本("Δt:" + 复合单位间隔)。
* @param [IN] seconds 时间间隔(秒)。
* @return 形如 "Δt:1h 1min 2s" 的文本。
*/
std::wstring FormatSpanText(double seconds)
{
return L"Δt:" + FormatDurationCompound(seconds);
}
/**
* @brief 时间光标的读数文本("数值 + s 后缀")。
*
* 时间轴本身就是秒,但与幅值读数放在一起时没有单位容易误读,
* 因此读数恒带 "s" 后缀;内联编辑仍使用不带单位的纯数值(见 BeginEdit 调用处)。
*
* @param [IN] seconds 光标所在时刻(秒)。
* @return 形如 "1.25s" 的文本。
*/
std::wstring FormatCursorTime(double seconds)
{
return FormatValue(seconds) + L"s";
}
/**
* @brief 把时间间隔格式化为"秒"数值文本(不含单位),用于内联编辑。
*
* 编辑时的量级**始终是秒**:即使显示为 "1.5min",双击编辑的内容也是 90。
*
* @param [IN] seconds 时间间隔(秒)。
* @return 形如 "60" 的文本。
*/
std::wstring FormatDurationSeconds(double seconds)
{
if (!std::isfinite(seconds))
{
return L"0";
}
return FormatValue(std::fabs(seconds));
}
/**
* @brief 按比例缩放颜色的 alpha 通道(用于提示浮层淡出)。
* @param [IN] argb 原始 0xAARRGGBB 颜色。
* @param [IN] alpha 新的 alpha(0-255)。
* @return 缩放后的颜色。
*/
std::uint32_t ScaleAlpha(std::uint32_t argb, unsigned int alpha)
{
const unsigned int a = std::min(255u, alpha);
return (argb & 0x00FFFFFFu) | (a << 24);
}
/**
* @brief 将数值限制到合法区间。
* @param [IN] value 输入值。
* @param [IN] low 下限。
* @param [IN] high 上限。
* @return 限制后的数值。
*/
double ClampDouble(double value, double low, double high)
{
if (value < low)
{
return low;
}
if (value > high)
{
return high;
}
return value;
}
/**
* @brief 组装图例文本:使用信号当前显示名(用户重命名后立即生效)。
* @param [IN] data 信号数据。
* @return 图例文本。
*/
std::wstring BuildLegendText(const SignalData& data)
{
return data.Title();
}
/**
* @brief 将信号样式颜色按不透明度换算为实际绘制颜色。
* @param [IN] style 信号样式。
* @return 叠加不透明度后的 0xAARRGGBB 颜色值。
*/
std::uint32_t StyleColor(const SignalStyle& style)
{
double opacity = static_cast<double>(style.opacity);
if (opacity < 0.0)
{
opacity = 0.0;
}
if (opacity > 1.0)
{
opacity = 1.0;
}
return WithAlpha(style.color, static_cast<unsigned int>(opacity * 255.0 + 0.5));
}
/**
* @brief 由信号色派生一个「高对比度」颜色:保持色相、大幅拉开明度。
*
* 用途:「边沿计数(中心轴)」的平均值曲线叠加在原始波形之上,
* 若直接用信号色会被波形淹没。这里把信号色向黑或白方向大幅偏移,
* 并**同时考虑与原始波形色以及与绘图区底色的距离**,取对比更强的一侧,
* 保证曲线既能与波形区分、又能压在底图上看得清。
*
* 结果是从信号色派生的,因此原信号改颜色时曲线颜色会自动跟着变。
*
* @param [IN] color 原始信号色(0xAARRGGBB)。
* @param [IN] background 绘图区底色,用于评估可见度。
* @return 派生出的高对比度颜色(alpha 与输入一致,由调用方再覆盖)。
*/
std::uint32_t HighContrastDerived(std::uint32_t color, std::uint32_t background = 0xFFFDF6E3u)
{
const auto squaredDistance = [](std::uint32_t a, std::uint32_t b)
{
const double dr =
static_cast<double>((a >> 16) & 0xFFu) - static_cast<double>((b >> 16) & 0xFFu);
const double dg =
static_cast<double>((a >> 8) & 0xFFu) - static_cast<double>((b >> 8) & 0xFFu);
const double db = static_cast<double>(a & 0xFFu) - static_cast<double>(b & 0xFFu);
return dr * dr + dg * dg + db * db;
};
const std::uint32_t darker = MixColor(color, 0xFF000000u, 0.72);
const std::uint32_t lighter = MixColor(color, 0xFFFFFFFFu, 0.72);
// 评分:与原始波形色的区分度为主(权重 1.0),与底色的可见度为辅(权重 0.6)。
const double scoreDarker =
squaredDistance(darker, color) + 0.6 * squaredDistance(darker, background);
const double scoreLighter =
squaredDistance(lighter, color) + 0.6 * squaredDistance(lighter, background);
return (scoreDarker >= scoreLighter) ? darker : lighter;
}
}
PlotView::PlotView() = default;
PlotView::~PlotView()
{
// 先停掉后台计算线程:它可能正在读取采样数据,必须在释放其它资源前回收。
StopCurveWorker();
ReleaseSceneCache();
ReleasePanelCache();
}
void PlotView::Setup(const UiFonts* fonts, const Theme* theme)
{
m_fonts = fonts;
m_theme = theme;
}
void PlotView::SetContext(DataSet* dataSet, TabConfig* tab)
{
m_dataSet = dataSet;
m_dataSetWritable = dataSet;
m_tab = tab;
m_hoverLane = -1;
m_dragMode = PlotDragMode::NONE;
// 重新绑定数据源后旧的几何键不再有意义,必须清空折线缓存。
InvalidateDerivedCaches();
InvalidateSceneCache();
RefreshLanes();
}
void PlotView::SetScale(float scale)
{
m_scale = (scale > 0.1f) ? scale : 1.0f;
InvalidateSceneCache();
}
bool PlotView::CursorEnabled() const
{
return Tab().CursorEnabled();
}
double PlotView::CursorTime() const
{
return Tab().CursorTime();
}
PlotCursorMode PlotView::CursorMode() const
{
return Tab().CursorMode();
}
bool PlotView::HasDrawableContent() const
{
return (m_dataSet != nullptr) && !m_dataSet->Empty() && (m_tab != nullptr) && m_tab->AnyVisible();
}
void PlotView::TimeBounds(double& low, double& high) const
{
low = 0.0;
high = 1.0;
if (m_dataSet == nullptr || m_dataSet->Empty())
{
return;
}
// 优先使用可见信号所覆盖的范围,使视图随勾选内容自适应。
if (m_tab != nullptr && m_tab->ComputeVisibleTimeBounds(*m_dataSet, low, high))
{
return;
}
low = m_dataSet->MinTime();
high = m_dataSet->MaxTime();
if (!(high > low))
{
high = low + 1.0;
}
}
PlotView::ResolvedSignal PlotView::ResolveSignal(const SignalRef& ref) const
{
ResolvedSignal result;
if (m_dataSet == nullptr)
{
return result;
}
result.data = m_dataSet->Resolve(ref);
result.file = m_dataSet->FileAt(ref.fileIndex);
result.style = m_dataSet->Style(ref);
result.visible = (m_tab != nullptr) && m_tab->IsVisible(ref);
return result;
}
void PlotView::Layout(const gd::RectF& area)
{
m_area = area;
// 布局变化(窗口缩放、面板宽度调整、网格数量变化)后静态场景不再有效。
InvalidateSceneCache();
m_labelBandWidth = 66.0f * m_scale;
m_timeAxisHeight = 34.0f * m_scale;
m_minLaneHeight = 54.0f * m_scale;
// 先为时间轴保留固定高度,剩余空间才交给网格,保证时间轴不会被挤压。
const float topPadding = 6.0f * m_scale;
const float availHeight = std::max(0.0f, area.Height - m_timeAxisHeight - topPadding);
// 预估是否需要纵向滚动条:仅取决于网格数量与最小网格高度(网格之间不再留间隔)。
std::size_t laneCount = (m_tab != nullptr) ? m_tab->Lanes().size() : 0;
if (laneCount > 0)
{
const float needed = static_cast<float>(laneCount) * m_minLaneHeight;
m_scrollBarVisible = (needed > availHeight);
}
else
{
m_scrollBarVisible = false;
}
const float sidePadding = kPlotSidePadding * m_scale;
const float rightReserve = m_scrollBarVisible
? (kScrollBarThickness * m_scale + sidePadding)
: sidePadding;
const float plotWidth = std::max(0.0f, area.Width - sidePadding - rightReserve);
m_plotRect = gd::RectF(area.X + sidePadding, area.Y + topPadding, plotWidth, availHeight);
m_timeAxisRect =
gd::RectF(m_plotRect.X, m_plotRect.Y + m_plotRect.Height, m_plotRect.Width, m_timeAxisHeight);
m_scrollBarRect = gd::RectF(area.X + area.Width - kScrollBarThickness * m_scale - 3.0f * m_scale,
m_plotRect.Y, kScrollBarThickness * m_scale, m_plotRect.Height);
BuildLaneVisuals();
}
void PlotView::RefreshLanes()
{
BuildLaneVisuals();
}
const SignalRef& PlotView::SelectedRef() const
{
static const SignalRef fallback;
return (m_dataSet != nullptr) ? m_dataSet->Selected() : fallback;
}
void PlotView::SelectSignal(const SignalRef& ref)
{
if (m_dataSetWritable != nullptr)
{
m_dataSetWritable->SetSelected(ref);
}
}
void PlotView::ClampLaneScroll()
{
const float maxOffset = std::max(0.0f, m_laneContentHeight - m_plotRect.Height);
Tab().LaneScroll() = static_cast<float>(ClampDouble(Tab().LaneScroll(), 0.0, maxOffset));
}
bool PlotView::LaneSpansMultipleFiles(int laneIndex) const
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
int firstFile = -1;
for (const SignalRef& ref : m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].signals)
{
const SignalStyle* style = m_dataSet->Style(ref);
if (style == nullptr || !m_tab->IsVisible(ref))
{
continue;
}
if (firstFile < 0)
{
firstFile = ref.fileIndex;
}
else if (ref.fileIndex != firstFile)
{
return true;
}
}
return false;
}
void PlotView::BuildLaneVisuals()
{
m_lanes.clear();
if (m_tab == nullptr || m_dataSet == nullptr || !HasDrawableContent())
{
m_laneContentHeight = 0.0f;
return;
}
const std::vector<Lane>& lanes = m_tab->Lanes();
const std::size_t count = lanes.size();
if (count == 0)
{
m_laneContentHeight = 0.0f;
return;
}
const float gap = 0.0f;
float laneHeight = m_plotRect.Height / static_cast<float>(count);
if (laneHeight < m_minLaneHeight * m_scale)
{
laneHeight = m_minLaneHeight * m_scale;
}
m_laneContentHeight = laneHeight * static_cast<float>(count);
ClampLaneScroll();
const float scrollOffset = Tab().LaneScroll();
m_lanes.resize(count);
for (std::size_t index = 0; index < count; ++index)
{
LaneVisual& visual = m_lanes[index];
visual.rect = gd::RectF(
m_plotRect.X,
m_plotRect.Y + static_cast<float>(index) * (laneHeight + gap) - scrollOffset,
m_plotRect.Width, laneHeight);
m_tab->ComputeLaneRange(*m_dataSet, static_cast<int>(index), visual.yMin, visual.yMax);
if (!(visual.yMax > visual.yMin))
{
visual.yMin = 0.0;
visual.yMax = 1.0;
}
}
}
float PlotView::TimeToX(double time) const
{
const double viewMin = Tab().ViewMin();
const double viewMax = Tab().ViewMax();
const double span = viewMax - viewMin;
if (!(span > 0.0) || m_plotRect.Width <= 0.0f)
{
return m_plotRect.X;
}
return m_plotRect.X + static_cast<float>((time - viewMin) / span * m_plotRect.Width);
}
double PlotView::XToTime(float x) const
{
const double viewMin = Tab().ViewMin();
if (m_plotRect.Width <= 0.0f)
{
return viewMin;
}
const double ratio = static_cast<double>(x - m_plotRect.X) / static_cast<double>(m_plotRect.Width);
return viewMin + ratio * (Tab().ViewMax() - viewMin);
}
float PlotView::ValueToY(double value, const LaneVisual& lane) const
{
const double span = lane.yMax - lane.yMin;
if (!(span > 0.0))
{
return lane.rect.Y + lane.rect.Height * 0.5f;
}
double ratio = (value - lane.yMin) / span;
ratio = ClampDouble(ratio, -50.0, 50.0);
return lane.rect.Y + lane.rect.Height * static_cast<float>(1.0 - ratio);
}
double PlotView::YToValue(float y, const LaneVisual& lane) const
{
if (lane.rect.Height <= 0.0f)
{
return lane.yMin;
}
const double ratio = 1.0 - static_cast<double>(y - lane.rect.Y) / static_cast<double>(lane.rect.Height);
return lane.yMin + ratio * (lane.yMax - lane.yMin);
}
int PlotView::LaneAtY(int y) const
{
const float fy = static_cast<float>(y);
for (std::size_t index = 0; index < m_lanes.size(); ++index)
{
const gd::RectF& rect = m_lanes[index].rect;
if (fy >= rect.Y && fy <= rect.Y + rect.Height)
{
return static_cast<int>(index);
}
}
return -1;
}
void PlotView::ClampTimeView()
{
if (m_dataSet == nullptr || m_dataSet->Empty())
{
return;
}
double span = Tab().ViewMax() - Tab().ViewMin();
if (!(span > 0.0))
{
span = kMinTimeSpan;
}
double dataMin = 0.0;
double dataMax = 1.0;
TimeBounds(dataMin, dataMax);
const double pad = span * 2.0;
const double minAllowed = dataMin - pad;
const double maxAllowed = dataMax + pad;
if (Tab().ViewMin() < minAllowed)
{
Tab().ViewMin() = minAllowed;
Tab().ViewMax() = Tab().ViewMin() + span;
}
if (Tab().ViewMax() > maxAllowed)
{
Tab().ViewMax() = maxAllowed;
Tab().ViewMin() = Tab().ViewMax() - span;
}
}
void PlotView::ZoomTimeAt(double factor, double anchorTime)
{
if (m_dataSet == nullptr || m_dataSet->Empty() || !(factor > 0.0))
{
return;
}
double dataMin = 0.0;
double dataMax = 1.0;
TimeBounds(dataMin, dataMax);
const double dataSpan = std::max(dataMax - dataMin, kMinTimeSpan);
const double span = Tab().ViewMax() - Tab().ViewMin();
double newSpan = ClampDouble(span / factor, dataSpan * 1.0e-9, dataSpan * 100.0);
const double ratio = (span > 0.0) ? (anchorTime - Tab().ViewMin()) / span : 0.5;
Tab().ViewMin() = anchorTime - ratio * newSpan;
Tab().ViewMax() = Tab().ViewMin() + newSpan;
ClampTimeView();
}
void PlotView::ZoomTimeStep(double factor)
{
const double anchor = (Tab().ViewMin() + Tab().ViewMax()) * 0.5;
ZoomTimeAt(factor, anchor);
}
void PlotView::PanTimeByFraction(double fraction)
{
const double span = Tab().ViewMax() - Tab().ViewMin();
const double delta = span * fraction;
Tab().ViewMin() += delta;
Tab().ViewMax() += delta;
ClampTimeView();
}
void PlotView::ScrollLanesByFraction(double fraction)
{
Tab().LaneScroll() += static_cast<float>(fraction * static_cast<double>(m_laneContentHeight));
// 立即重建几何,避免命中测试使用旧位置。
BuildLaneVisuals();
}
void PlotView::FitAll()
{
if (m_dataSet == nullptr || m_dataSet->Empty())
{
Tab().ViewMin() = 0.0;
Tab().ViewMax() = 1.0;
Tab().ViewInitialized() = true;
return;
}
double low = 0.0;
double high = 1.0;
TimeBounds(low, high);
if (!(high > low))
{
const double padding = std::max(1.0e-6, std::fabs(low) * 0.01);
low -= padding;
high += padding;
}
Tab().ViewMin() = low;
Tab().ViewMax() = high;
Tab().ViewInitialized() = true;
Tab().LaneScroll() = 0.0f;
// 只放下进行中的选区与拖拽,**不改变「选区放大」等缩放模式的启用状态**
// (用户明确要求:点适应视图后仍保持已启用的缩放模式)。
m_rubberRect = gd::RectF();
m_dragMode = PlotDragMode::NONE;
m_dragSignal = SignalRef();
m_dragTargetLane = -1;
m_hoverSignal = SignalRef();
SetHint(Tab().ZoomMode() == PlotZoomMode::NONE
? std::wstring()
: L"横向或纵向拖动以放大选择区域,ESC退出选区模式");
if (m_tab != nullptr)
{
// 适应视图 = 回到初始状态:恢复整段数据的自动自适应。
// 但**开启了「选区 Y 轴自适应」的网格必须继续跟随时间视图**:
// 直接置 autoRange 会让 ComputeLaneRange 忽略已经算好的时间段幅值,
// 表现为"点一下适应视图就把 Y 轴自适应关掉了"。这里改为按新的
// (整段)时间视图重新匹配一次,开关状态与视觉效果都保持。
const int laneCount = static_cast<int>(m_tab->Lanes().size());
for (int index = 0; index < laneCount; ++index)
{
if (m_tab->Lanes()[static_cast<std::size_t>(index)].fitYToSelection)
{
AutoRangeLaneToView(index);
}
else
{
m_tab->Lanes()[static_cast<std::size_t>(index)].autoRange = true;
}
}
}
// 适应视图已回到初始状态,这里不再移动光标(光标允许停留在可视区外)。
BuildLaneVisuals();
}
void PlotView::DisableCursor()
{
Tab().CursorMode() = PlotCursorMode::NONE;
m_cursorHover = false;
m_hoverCursor = 0;
if (m_editTarget == PlotEditTarget::CURSOR_TIME || m_editTarget == PlotEditTarget::CURSOR_SPAN)
{
EndEdit(false);
}
if (m_dragMode == PlotDragMode::CURSOR_MOVE || m_dragMode == PlotDragMode::CURSOR_SPAN_MOVE)
{
m_dragMode = PlotDragMode::NONE;
}
}
bool PlotView::CycleCursorMode()
{
// 三态循环:无 → 单光标 → 双光标 → 无。光标模式本身不显示提示。
switch (Tab().CursorMode())
{
case PlotCursorMode::NONE:
{
Tab().CursorMode() = PlotCursorMode::SINGLE;
// 首次启用时置于视图中心,随后可用鼠标靠近拖拽或双击指定精确时间。
const double center = (Tab().ViewMin() + Tab().ViewMax()) * 0.5;
Tab().CursorTime() = center;
Tab().CursorTimeAlt() = center;
m_cursorHover = true;
m_hoverCursor = 1;
m_activeCursor = 1;
break;
}
case PlotCursorMode::SINGLE:
{
Tab().CursorMode() = PlotCursorMode::DOUBLE;
// 光标 2 首次出现:紧邻光标 1 且必须落在当前可见时间范围内,
// 这样无需额外操作就能立即看到两个光标的对比读数。
const double low = Tab().ViewMin();
const double high = Tab().ViewMax();
const double span = (high > low) ? (high - low) : 1.0;
const double offset = span * 0.12;
double second = Tab().CursorTime() + offset;
if (second > high - span * 0.02)
{
second = Tab().CursorTime() - offset;
}
if (second < low + span * 0.02 || second > high - span * 0.02)
{
second = low + span * 0.5;
}
Tab().CursorTimeAlt() = second;
ClampCursorToView(2);
m_cursorHover = true;
m_hoverCursor = 2;
m_activeCursor = 2;
break;
}
case PlotCursorMode::DOUBLE:
default:
DisableCursor();
break;
}
return true;
}
void PlotView::SetHint(const std::wstring& text)
{
m_hintText = text;
m_hintSticky = false;
m_hintShownAt = (text.empty()) ? 0 : GetTickCount64();
}
void PlotView::SetHintSticky(const std::wstring& text)
{
m_hintText = text;
m_hintSticky = !text.empty();
m_hintShownAt = (text.empty()) ? 0 : GetTickCount64();
}
void PlotView::ReleaseHint()
{
// 粘滞结束:从这里开始重新计时(先停留再淡出)。
m_hintSticky = false;
if (!m_hintText.empty())
{
m_hintShownAt = GetTickCount64();
}
}
bool PlotView::HintNeedsAnimation() const
{
if (m_hintText.empty() || m_hintShownAt == 0)
{
return false;
}
if (m_hintSticky)
{
return true;
}
const std::uint64_t elapsed = GetTickCount64() - m_hintShownAt;
return elapsed < (kHintHoldMs + kHintFadeMs);
}
double PlotView::CursorSpan() const
{
if (m_tab == nullptr || Tab().CursorMode() != PlotCursorMode::DOUBLE)
{
return 0.0;
}
return std::fabs(Tab().CursorTimeAlt() - Tab().CursorTime());
}
void PlotView::ClampCursorToView(int which)
{
if (m_tab == nullptr || m_plotRect.Width <= 0.0f)
{
return;
}
const double low = Tab().ViewMin();
const double high = Tab().ViewMax();
if (!(high > low))
{
return;
}
// 只在拖拽时夹取;留一点内缩,保证光标线及把手完整可见。
// 视图变化(平移/缩放/自适应)**不**调用本函数:光标允许滞留在网格可视区域之外。
const double inset = (high - low) * 0.002;
if (which == 2)
{
Tab().CursorTimeAlt() = ClampDouble(Tab().CursorTimeAlt(), low + inset, high - inset);
}
else
{
Tab().CursorTime() = ClampDouble(Tab().CursorTime(), low + inset, high - inset);
}
}
std::uint32_t PlotView::CursorColor() const
{
// 两条光标统一使用同一样式与颜色,不按序号区分配色。
if (m_theme == nullptr)
{
return 0xFF000000u;
}
return m_theme->cursor;
}
bool PlotView::CancelDrag(bool showHint)
{
if (m_dragMode == PlotDragMode::NONE)
{
return false;
}
// 放弃当前拖拽:清空拖拽状态与尚未生效的缩放选区,不改动任何视图范围。
// 调用方仍持有鼠标捕获,松开左键时会走 OnLButtonUp,
// 此时拖拽模式已是 NONE,选区不会被应用到时间轴/幅值轴上。
const bool wasRubber = (m_dragMode == PlotDragMode::RUBBER_SELECT);
m_dragMode = PlotDragMode::NONE;
m_rubberAxis = PlotRubberAxis::UNDECIDED;
m_dragLane = -1;
m_dragMoved = false;
m_rubberRect = gd::RectF();
m_dragSignal = SignalRef();
m_dragTargetLane = -1;
if (showHint && wasRubber)
{
SetHint(L"已放弃当前选区(右键放弃选区,Esc 退出选区放大)");
}
return true;
}
bool PlotView::ExitZoomModes()
{
if (m_tab == nullptr)
{
return false;
}
// 退出缩放模式:工具栏的「选区放大」按钮会在 Application::Render 中同步为未选中态。
if (Tab().ZoomMode() == PlotZoomMode::NONE)
{
return false;
}
Tab().ZoomMode() = PlotZoomMode::NONE;
SetHint(std::wstring());
return true;
}
void PlotView::InvalidateDerivedCaches()
{
// 后台线程可能在读取采样数据:先暂停并作废异步结果,再清同步缓存。
PauseDerivedAsync();
m_polylineCache.clear();
// 数据层结构变化后边沿统计与均值滤波曲线的派生结果都会失效。
m_edgeCountCache.clear();
m_edgeCenterCountCache.clear();
m_meanCurveCache.clear();
m_weakBoostCurveCache.clear();
// 信号下标可能已经整体位移:清掉按引用缓存的悬停/拖拽目标,避免指向错误的信号。
m_hoverSignal = SignalRef();
m_hoverLegend = SignalRef();
m_dragSignal = SignalRef();
m_dragTargetLane = -1;
InvalidateSceneCache();
}
void PlotView::PauseDerivedAsync()
{
// 等待在算任务结束,然后清空队列与缓存,并自增代次使任何迟到结果作废。
StopCurveWorker();
std::lock_guard<std::mutex> lock(m_curveMutex);
++m_curveGeneration;
m_curveJobs.clear();
m_curveEntries.clear();
m_curvePins.clear();
m_curveSceneDirty.store(false, std::memory_order_relaxed);
}
bool PlotView::FitTimeToLane(int laneIndex)
{
if (m_dataSet == nullptr || m_tab == nullptr)
{
return false;
}
const std::vector<Lane>& lanes = m_tab->Lanes();
if (laneIndex < 0 || laneIndex >= static_cast<int>(lanes.size()))
{
return false;
}
double low = std::numeric_limits<double>::infinity();
double high = -std::numeric_limits<double>::infinity();
for (const SignalRef& ref : lanes[static_cast<std::size_t>(laneIndex)].signals)
{
const DataFile* file = m_dataSet->FileAt(ref.fileIndex);
if (file == nullptr)
{
continue;
}
low = std::min(low, file->minTime);
high = std::max(high, file->maxTime);
}
if (!std::isfinite(low) || !std::isfinite(high) || !(high > low))
{
return false;
}
Tab().ViewMin() = low;
Tab().ViewMax() = high;
Tab().ViewInitialized() = true;
// 与“适应视图”保持一致:退出选区放大模式,避免模式按钮与实际状态不符。
Tab().ZoomMode() = PlotZoomMode::NONE;
m_rubberRect = gd::RectF();
m_dragMode = PlotDragMode::NONE;
m_rubberAxis = PlotRubberAxis::UNDECIDED;
// 针对本网格信号范围展开时间轴后,同时把该网格的 Y 轴自适应一次,
// 否则展开后会看到“时间对了但幅值区间不合适”的波形。
AutoRangeLaneToView(laneIndex);
// 其它开启了「选区 Y 轴自适应」的网格同样要跟新的时间视图重新匹配:
// 时间视图变了,只让被展开的那个网格合适、其余网格停留在旧幅值区间并不符合预期。
ApplySelectionAutoFit();
// 注意:不在这里移动光标——光标允许滞留在网格可视区域之外。
BuildLaneVisuals();
return true;
}
bool PlotView::SplitLane(int laneIndex)
{
if (m_tab == nullptr)
{
return false;
}
// 拆分由 TabConfig 完成(含网格自身状态的继承与编号刷新)。
const int first = m_tab->SplitLane(laneIndex);
if (first < 0)
{
return false;
}
// 网格数量变化 → 几何与滚动范围需要重建,静态场景缓存也要失效。
BuildLaneVisuals();
InvalidateSceneCache();
return true;
}
std::size_t PlotView::LaneSignalCount(int laneIndex) const
{ if (m_tab == nullptr || laneIndex < 0 ||
laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return 0;
}
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].signals.size();
}
bool PlotView::SampleSpacingPixels(const DataFile& file, double time, double& pixels) const
{ pixels = 0.0;
const std::vector<double>& samples = file.time;
if (samples.size() < 2 || m_plotRect.Width <= 1.0f)
{
return false;
}
const double viewSpan = Tab().ViewMax() - Tab().ViewMin();
if (!(viewSpan > 0.0))
{
return false;
}
const int index = FindNearestSample(file, time);
if (index < 0)
{
return false;
}
double spacing = 0.0;
if (index > 0)
{
spacing = samples[static_cast<std::size_t>(index)] - samples[static_cast<std::size_t>(index) - 1];
}
else
{
spacing = samples[1] - samples[0];
}
// 时间轴非单调时前后差值可能为负,此时不做吸附。
if (!(spacing > 0.0) || !std::isfinite(spacing))
{
return false;
}
pixels = spacing / viewSpan * static_cast<double>(m_plotRect.Width);
return true;
}
bool PlotView::SnapTimeToSample(double time, double& snapped) const
{
if (m_dataSet == nullptr)
{
return false;
}
const double minPixels = static_cast<double>(kCursorSnapMinPixels) * static_cast<double>(m_scale);
bool found = false;
double bestDistance = std::numeric_limits<double>::infinity();
double bestTime = 0.0;
for (const DataFile& file : m_dataSet->Files())
{
// 只有采样点在屏幕上足够稀疏时才吸附,否则缩小视图后光标会“跳格”。
double pixels = 0.0;
if (!SampleSpacingPixels(file, time, pixels) || pixels < minPixels)
{
continue;
}
const int index = FindNearestSample(file, time);
if (index < 0)
{
continue;
}
const double candidate = file.time[static_cast<std::size_t>(index)];
if (!std::isfinite(candidate))
{
continue;
}
const double distance = std::fabs(candidate - time);
if (distance < bestDistance)
{
bestDistance = distance;
bestTime = candidate;
found = true;
}
}
if (found)
{
snapped = bestTime;
}
return found;
}
PlotZoomMode PlotView::CurrentZoomMode() const
{
return Tab().ZoomMode();
}
bool PlotView::ToggleZoomMode(PlotZoomMode mode)
{
const PlotZoomMode current = Tab().ZoomMode();
Tab().ZoomMode() = (current == mode) ? PlotZoomMode::NONE : mode;
m_rubberAxis = PlotRubberAxis::UNDECIDED;
m_rubberRect = gd::RectF();
SetHint(Tab().ZoomMode() == PlotZoomMode::NONE
? std::wstring()
: L"横向或纵向拖动以放大选择区域,ESC退出选区模式");
return true;
}
float PlotView::CursorX(int which) const
{
const double time = (which == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime();
return TimeToX(time);
}
float PlotView::CursorDisplayX(int which) const
{
// 光标允许滞留在可视区之外:此时把它"钉"在最近的网格边缘上显示,
// 保证它的取值与时间读数始终可见。
const float raw = CursorX(which);
return std::max(m_plotRect.X + 1.0f * m_scale,
std::min(raw, m_plotRect.X + m_plotRect.Width - 1.0f * m_scale));
}
gd::RectF PlotView::CursorLabelRect(int which) const
{
if (m_fonts == nullptr || m_fonts->monoSmall == nullptr)
{
return gd::RectF();
}
// 未经叠放处理的"原始"位置:单条标签独占底部一行。
const auto rawRect = [this](int index) -> gd::RectF
{
const double time = (index == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime();
const std::wstring text = FormatCursorTime(time);
const float width =
std::max(64.0f * m_scale, UiMeasureTextWidth(m_fonts->monoSmall, text) + 16.0f * m_scale);
const float height = 20.0f * m_scale;
// 时间读数改到绘图区**底部**:顶部留给网格图例与幅值标尺,避免互相遮挡。
float left = CursorDisplayX(index) - width * 0.5f;
left = std::max(m_plotRect.X, std::min(left, m_plotRect.X + m_plotRect.Width - width));
// 光标滞留在视界之外时,胶囊外侧还要放一个方向三角(与网格内数据点越界同一套画法):
// 把胶囊整体**向内**让出三角 + 间隙的宽度,否则贴边时三角没有落脚之处、
// 只能压在胶囊上(用户报告的"没有间隔、样式不统一")。
bool offRight = false;
if (CursorOffViewSide(index, offRight))
{
const float reserved = (kMarkerTriangleDepth + kMarkerTriangleGap) * m_scale;
left = offRight ? (left - reserved) : (left + reserved);
left = std::max(m_plotRect.X, std::min(left, m_plotRect.X + m_plotRect.Width - width));
}
const float top = m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale;
return gd::RectF(left, top, width, height);
};
gd::RectF rect = rawRect(which);
// 双光标靠得很近时,两条时间读数标签会互相覆盖:把它们分两行叠放。
// **小值在上、大值在下**(用户明确要求)——按时间值决定行序,
// 而不是固定让光标 2 上移。左/右光标被拖动交换后排序依然正确。
if (Tab().CursorMode() == PlotCursorMode::DOUBLE)
{
const gd::RectF first = rawRect(1);
const gd::RectF second = rawRect(2);
// 方向三角已移到胶囊**外侧**,胶囊之间不再需要额外留白。
const float gap = 2.0f * m_scale;
const bool overlapX = (first.X < second.X + second.Width + gap) &&
(second.X < first.X + first.Width + gap);
if (overlapX)
{
const bool firstIsSmaller = (Tab().CursorTime() <= Tab().CursorTimeAlt());
// which == 1 是否属于"小值"。
const bool whichIsSmaller = (which == 1) ? firstIsSmaller : !firstIsSmaller;
const float bottomRow = rawRect(1).Y;
const float topRow = std::max(m_plotRect.Y + 2.0f * m_scale,
bottomRow - rect.Height - gap);
rect.Y = whichIsSmaller ? topRow : bottomRow;
}
}
return rect;
}
bool PlotView::IsOnCursorLine(int x, int& which) const
{
which = 0;
if (!Tab().CursorEnabled() || m_plotRect.Width <= 0.0f)
{
return false;
}
const float tolerance = 6.0f * m_scale;
const float fx = static_cast<float>(x);
// 双光标重叠时优先命中光标 1,避免出现“抓不起来第二条”的情况。
// 使用"显示横坐标":滞留在可视区外的光标被钉在边缘,也应该可以被抓住拖回来。
if (std::fabs(fx - CursorDisplayX(1)) <= tolerance)
{
which = 1;
return true;
}
if (Tab().CursorMode() == PlotCursorMode::DOUBLE && std::fabs(fx - CursorDisplayX(2)) <= tolerance)
{
which = 2;
return true;
}
return false;
}
bool PlotView::IsOnCursorTimeLabel(int x, int y, int& which) const
{
which = 0;
if (!Tab().CursorEnabled())
{
return false;
}
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
// 双光标重叠时优先命中光标 1(与光标线的命中顺序一致)。
const int count = (Tab().CursorMode() == PlotCursorMode::DOUBLE) ? 2 : 1;
for (int index = 1; index <= count; ++index)
{
const gd::RectF rect = CursorLabelRect(index);
if (rect.Width > 0.0f && UiPointInRect(rect, fx, fy))
{
which = index;
return true;
}
}
return false;
}
bool PlotView::IsOnCursorSpanLabel(int x, int y) const
{
if (Tab().CursorMode() != PlotCursorMode::DOUBLE || m_cursorSpanRect.Width <= 0.0f)
{
return false;
}
return UiPointInRect(m_cursorSpanRect, static_cast<float>(x), static_cast<float>(y));
}
bool PlotView::ApplyTimeSelection(float x0, float x1)
{
if (m_plotRect.Width <= 0.0f)
{
return false;
}
float left = std::min(x0, x1);
float right = std::max(x0, x1);
left = std::max(left, m_plotRect.X);
right = std::min(right, m_plotRect.X + m_plotRect.Width);
if (right - left < 8.0f * m_scale)
{
return false;
}
double timeA = XToTime(left);
double timeB = XToTime(right);
if (timeB < timeA)
{
std::swap(timeA, timeB);
}
if (!(timeB - timeA > 0.0))
{
return false;
}
Tab().ViewMin() = timeA;
Tab().ViewMax() = timeB;
ClampTimeView();
// 注意:不在这里移动光标——即使选区不包含光标,也只把它的读数贴在网格边缘显示。
// 「选区 Y 轴自适应」开关开启的网格:时间选区放大后自动把 Y 轴匹配到新的可见区间。
ApplySelectionAutoFit();
return true;
}
bool PlotView::ApplyValueSelection(int laneIndex, float y0, float y1)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
return false;
}
const LaneVisual& visual = m_lanes[static_cast<std::size_t>(laneIndex)];
if (visual.rect.Height <= 0.0f)
{
return false;
}
float top = std::min(y0, y1);
float bottom = std::max(y0, y1);
top = std::max(top, visual.rect.Y);
bottom = std::min(bottom, visual.rect.Y + visual.rect.Height);
if (bottom - top < 10.0f * m_scale)
{
return false;
}
const double valueTop = YToValue(top, visual);
const double valueBottom = YToValue(bottom, visual);
if (!(std::fabs(valueTop - valueBottom) > 0.0))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
lane.autoRange = false;
// 手动 Y 轴选区放大:关闭本网格的「选区 Y 轴自适应」。
lane.fitYToSelection = false;
lane.yMin = std::min(valueTop, valueBottom);
lane.yMax = std::max(valueTop, valueBottom);
return true;
}
int PlotView::LaneIndexAt(int x, int y) const
{
if (!UiPointInRect(m_plotRect, static_cast<float>(x), static_cast<float>(y)))
{
return -1;
}
return LaneAtY(y);
}
bool PlotView::LaneAutoFitToSelection(int laneIndex) const
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].fitYToSelection;
}
bool PlotView::AllLanesAutoFitToSelection() const
{
if (m_tab == nullptr)
{
return false;
}
const std::vector<Lane>& lanes = m_tab->Lanes();
if (lanes.empty())
{
return false;
}
for (const Lane& lane : lanes)
{
if (!lane.fitYToSelection)
{
// 任一网格未开启 → 全局项就不是勾选状态。
return false;
}
}
return true;
}
bool PlotView::LaneEdgeCount(int laneIndex) const
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].edgeCount;
}
bool PlotView::SetLaneEdgeCount(int laneIndex, bool enabled)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
if (lane.edgeCount == enabled)
{
return false;
}
lane.edgeCount = enabled;
if (enabled)
{
// 首次开启:把横向光标放到可见幅值范围的中点,并让面板重新展开。
if (!lane.edgeValueValid)
{
double yMin = 0.0;
double yMax = 1.0;
LaneRange(laneIndex, yMin, yMax);
lane.edgeValue = (yMin + yMax) * 0.5;
lane.edgeValueValid = true;
}
lane.edgePanelOpen = true;
}
else
{
lane.edgePanelOpen = false;
}
return true;
}
bool PlotView::ToggleLaneEdgeCount(int laneIndex)
{
const bool enabled = !LaneEdgeCount(laneIndex);
SetLaneEdgeCount(laneIndex, enabled);
return enabled;
}
bool PlotView::LaneEdgeCenterCount(int laneIndex) const
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].edgeCenter;
}
bool PlotView::SetLaneEdgeCenterCount(int laneIndex, bool enabled)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
if (lane.edgeCenter == enabled)
{
return false;
}
lane.edgeCenter = enabled;
if (enabled)
{
// 与「均值滤波」完全一致:默认只勾选网格内**第一个可见信号**。
if (lane.edgeCenterItems.empty())
{
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data != nullptr && resolved.visible)
{
lane.EnsureEdgeCenterItem(ref);
break;
}
}
}
lane.edgeCenterPanelOpen = true;
}
else
{
lane.edgeCenterPanelOpen = false;
}
return true;
}
bool PlotView::ToggleLaneEdgeCenterCount(int laneIndex)
{
const bool enabled = !LaneEdgeCenterCount(laneIndex);
SetLaneEdgeCenterCount(laneIndex, enabled);
return enabled;
}
bool PlotView::ToggleLaneEdgeCenterSignal(int laneIndex, const SignalRef& ref)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
if (lane.EdgeCenterEnabled(ref))
{
lane.RemoveEdgeCenterItem(ref);
return false;
}
lane.EnsureEdgeCenterItem(ref);
return true;
}
bool PlotView::LaneEdgeCenterSignalEnabled(int laneIndex, const SignalRef& ref) const
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].EdgeCenterEnabled(ref);
}
bool PlotView::LaneMeanFilter(int laneIndex) const
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].meanFilter;
}
bool PlotView::LaneWeakBoost(int laneIndex) const
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].weakBoost;
}
double PlotView::LaneWeakBoostLevel(int laneIndex) const
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return 0.5;
}
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].weakBoostLevel;
}
int PlotView::LaneWeakBoostIterations(int laneIndex) const
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return kWeakBoostIterationsMin;
}
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].weakBoostIterations;
}
bool PlotView::SetLaneWeakBoostIterations(int laneIndex, int iterations)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
const int clamped = std::max(kWeakBoostIterationsMin, std::min(kWeakBoostIterationsMax, iterations));
if (lane.weakBoostIterations == clamped)
{
return false;
}
lane.weakBoostIterations = clamped;
// 迭代次数变化会改变预览曲线:清掉整条曲线的缓存即可。
// ⚠️ **不要**再 `InvalidateSceneCache()`:那会整幅重绘(5 个网格、上万个顶点),
// 拖滑块时每帧都发生;改为依赖 `m_sceneDerived` 快照 → 只重画受影响的网格。
m_weakBoostCurveCache.clear();
return true;
}
bool PlotView::SetLaneWeakBoostLevel(int laneIndex, double level)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
const double clamped = std::max(0.0, std::min(1.0, level));
if (lane.weakBoostLevel == clamped)
{
return false;
}
lane.weakBoostLevel = clamped;
// 等级变化会改变预览曲线:清掉整条曲线的缓存即可(同 `SetLaneWeakBoostIterations`,
// 故意**不**作废静态场景缓存,以便走"只重画受影响网格"的快路径)。
m_weakBoostCurveCache.clear();
return true;
}
bool PlotView::SetLaneWeakBoost(int laneIndex, bool enabled)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
if (lane.weakBoost == enabled)
{
return false;
}
lane.weakBoost = enabled;
if (enabled)
{
// 与「均值滤波」完全一致:默认只勾选网格内**第一个可见信号**。
if (lane.weakBoostItems.empty())
{
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data != nullptr && resolved.visible)
{
lane.EnsureWeakBoostItem(ref);
break;
}
}
}
lane.weakBoostPanelOpen = true;
}
else
{
lane.weakBoostPanelOpen = false;
}
return true;
}
bool PlotView::ToggleLaneWeakBoost(int laneIndex)
{
const bool enabled = !LaneWeakBoost(laneIndex);
SetLaneWeakBoost(laneIndex, enabled);
return enabled;
}
bool PlotView::ToggleLaneWeakBoostSignal(int laneIndex, const SignalRef& ref)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
if (lane.WeakBoostEnabled(ref))
{
lane.RemoveWeakBoostItem(ref);
return false;
}
lane.EnsureWeakBoostItem(ref);
return true;
}
bool PlotView::LaneWeakBoostSignalEnabled(int laneIndex, const SignalRef& ref) const
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].WeakBoostEnabled(ref);
}
bool PlotView::TakePendingWeakBoostBuild(int& laneIndex, std::vector<WeakBoostBuild>& items)
{
if (m_pendingWeakBoostLane < 0)
{
return false;
}
laneIndex = m_pendingWeakBoostLane;
items = m_pendingWeakBoostItems;
m_pendingWeakBoostLane = -1;
m_pendingWeakBoostItems.clear();
return true;
}
bool PlotView::SetLaneMeanFilter(int laneIndex, bool enabled)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
if (lane.meanFilter == enabled)
{
return false;
}
lane.meanFilter = enabled;
if (enabled)
{
// 默认只勾选网格内**第一个可见信号**(用户明确要求),其余保持未勾选。
if (lane.meanFilterItems.empty())
{
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data != nullptr && resolved.visible)
{
lane.EnsureMeanFilterItem(ref);
break;
}
}
}
lane.meanFilterPanelOpen = true;
}
else
{
lane.meanFilterPanelOpen = false;
}
return true;
}
bool PlotView::ToggleLaneMeanFilterSignal(int laneIndex, const SignalRef& ref)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
if (lane.MeanFilterEnabled(ref))
{
lane.RemoveMeanFilterItem(ref);
return false;
}
lane.EnsureMeanFilterItem(ref);
return true;
}
bool PlotView::LaneMeanFilterSignalEnabled(int laneIndex, const SignalRef& ref) const
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].MeanFilterEnabled(ref);
}
bool PlotView::ToggleLaneMeanFilter(int laneIndex)
{
const bool enabled = !LaneMeanFilter(laneIndex);
SetLaneMeanFilter(laneIndex, enabled);
return enabled;
}
bool PlotView::TakePendingMeanFilterBuild(int& laneIndex, std::vector<MeanFilterBuild>& items)
{
if (m_pendingMeanFilterLane < 0)
{
return false;
}
laneIndex = m_pendingMeanFilterLane;
items = m_pendingMeanFilterItems;
m_pendingMeanFilterLane = -1;
m_pendingMeanFilterItems.clear();
return true;
}
bool PlotView::MeanFilterValueAt(const DataFile& file, const SignalData& signal, int points, int iterations,
double time, double& value) const
{
const int index = FindNearestSample(file, time);
if (index < 0)
{
return false;
}
const std::vector<float>& curve = MeanFilterCurveFor(signal, points, iterations);
if (static_cast<std::size_t>(index) >= curve.size())
{
return false;
}
const float sampled = curve[static_cast<std::size_t>(index)];
if (!std::isfinite(sampled))
{
return false;
}
value = static_cast<double>(sampled);
return true;
}
void PlotView::VisibleIndexRange(const DataFile& file, std::size_t count, std::size_t& begin,
std::size_t& end) const
{
begin = 0;
end = count;
if (!file.timeMonotonic || count == 0)
{
return;
}
const std::vector<double>& time = file.time;
begin = static_cast<std::size_t>(
std::lower_bound(time.begin(), time.end(), Tab().ViewMin()) - time.begin());
if (begin > 0)
{
--begin;
}
end = static_cast<std::size_t>(
std::lower_bound(time.begin(), time.end(), Tab().ViewMax()) - time.begin());
if (end < count)
{
++end;
}
end = std::max(end, begin);
begin = std::min(begin, count);
end = std::min(end, count);
}
const std::vector<float>* PlotView::CurveSpanFor(DerivedCurveKind kind, const SignalData& signal,
int points, int iterations, int levelPercent,
std::size_t begin, std::size_t end,
std::size_t& spanBegin,
bool allowAsyncCompute) const
{
spanBegin = 0;
const std::size_t count = signal.values.size();
if (count == 0)
{
return nullptr;
}
begin = std::min(begin, count);
end = std::min(std::max(end, begin), count);
if (end <= begin)
{
return nullptr;
}
const int kindValue = static_cast<int>(kind);
const int safePoints = std::max(0, std::min(points, 1000000));
const int safeIterations = std::max(1, std::min(iterations, 1000));
const int safeLevel = std::max(0, std::min(100, levelPercent));
// 对齐到粗网格:轻微平移/缩放时可直接复用同一条窗口,避免每帧重算。
const std::size_t grid = std::max<std::size_t>(4096, (end - begin) / 4);
const std::size_t alignedBegin = (begin / grid) * grid;
const std::size_t alignedEnd = std::min(count, ((end + grid - 1) / grid) * grid);
CurveKey key;
key.signal = &signal;
key.kind = kindValue;
key.points = safePoints;
key.iterations = safeIterations;
key.levelPercent = safeLevel;
// 1) 已有覆盖请求区间的最新条目 → 直接复用(钉住,防止本帧被回收)。
if (const CurveEntry* hit = AcquireCoveringCurve(key, begin, end, spanBegin))
{
return &hit->values;
}
// 2) 未就绪且窗口很大:预览曲线交后台计算,本帧先用最新旧曲线顶替(双缓冲)。
// 这样绘制线程永远不做重计算,拖动滑块/光标/视界/缩放的反馈因此始终跟手。
if (allowAsyncCompute && (end - begin) > kDerivedAsyncWindowThreshold)
{
ScheduleCurveJob(key, alignedBegin, alignedEnd, count);
if (const CurveEntry* stale = AcquireNewestCurve(key, spanBegin))
{
return &stale->values;
}
return nullptr; // 尚无任何可显示曲线:本帧跳过该预览曲线(不阻塞绘制)
}
// 3) 窗口较小或需要精确取值 → 同步计算并发布(代价可接受,且保证取值精确)。
std::size_t computedBegin = 0;
std::size_t computedEnd = 0;
DerivedGuardWindow(kind, alignedBegin, alignedEnd, safePoints, safeIterations, count,
computedBegin, computedEnd);
if (computedEnd <= computedBegin)
{
return nullptr;
}
std::vector<float> values = ComputeDerivedWindow(signal, computedBegin, computedEnd, kindValue,
safePoints, safeIterations, safeLevel);
const CurveEntry* published =
PublishCurve(key, computedBegin, computedEnd, std::move(values), spanBegin);
return (published != nullptr) ? &published->values : nullptr;
}
void PlotView::DerivedGuardWindow(DerivedCurveKind kind, std::size_t alignedBegin,
std::size_t alignedEnd, int points, int iterations,
std::size_t count, std::size_t& begin, std::size_t& end) const
{
// 保护带取窗口长度的 1/8(夹在 4096~65536):弱波增强的修正沿相邻极值向内传播,
// 1/8 个可见宽度足以覆盖真实信号的极值间距。
// 均值滤波必须额外**覆盖滤波半径**:N 阶共向外传播 X*N 个采样,否则窗口边界取值
// 会与「整条曲线」不同(边界上可用采样数更少)。
std::size_t guard =
std::clamp((alignedEnd - alignedBegin) / 8, std::size_t(4096), kDerivedCurveGuardMax);
if (kind == DerivedCurveKind::MEAN_FILTER)
{
const std::size_t reach =
static_cast<std::size_t>(points + 1) * static_cast<std::size_t>(iterations) + 64u;
guard = std::clamp(std::max(guard, reach), std::size_t(4096), kDerivedCurveGuardMax);
}
begin = (alignedBegin > guard) ? (alignedBegin - guard) : 0;
end = std::min(count, alignedEnd + guard);
}
const PlotView::CurveEntry* PlotView::AcquireCoveringCurve(const CurveKey& key, std::size_t begin,
std::size_t end,
std::size_t& spanBegin) const
{
std::lock_guard<std::mutex> lock(m_curveMutex);
const CurveEntryPtr* best = nullptr;
for (const CurveEntryPtr& entry : m_curveEntries)
{
if (entry->key == key && entry->begin <= begin && entry->end >= end)
{
if (best == nullptr || entry->serial > (*best)->serial)
{
best = &entry;
}
}
}
if (best == nullptr)
{
return nullptr;
}
m_curvePins.push_back(*best); // 钉住:保证本帧内指针有效
spanBegin = (*best)->begin;
return best->get();
}
const PlotView::CurveEntry* PlotView::AcquireNewestCurve(const CurveKey& key,
std::size_t& spanBegin) const
{
std::lock_guard<std::mutex> lock(m_curveMutex);
const CurveEntryPtr* newest = nullptr;
for (const CurveEntryPtr& entry : m_curveEntries)
{
if (entry->key == key)
{
if (newest == nullptr || entry->serial > (*newest)->serial)
{
newest = &entry;
}
}
}
if (newest == nullptr)
{
return nullptr;
}
m_curvePins.push_back(*newest);
spanBegin = (*newest)->begin;
return newest->get();
}
const PlotView::CurveEntry* PlotView::PublishCurve(const CurveKey& key, std::size_t begin,
std::size_t end, std::vector<float>&& values,
std::size_t& spanBegin) const
{
std::shared_ptr<CurveEntry> entry = std::make_shared<CurveEntry>();
entry->key = key;
entry->begin = begin;
entry->end = end;
entry->values = std::move(values);
{
std::lock_guard<std::mutex> lock(m_curveMutex);
entry->serial = ++m_curveSerial;
m_curveEntries.push_back(entry);
}
m_curvePins.push_back(entry);
spanBegin = begin;
return entry.get();
}
void PlotView::ScheduleCurveJob(const CurveKey& key, std::size_t alignedBegin,
std::size_t alignedEnd, std::size_t count) const
{
std::size_t windowBegin = 0;
std::size_t windowEnd = 0;
DerivedGuardWindow(static_cast<DerivedCurveKind>(key.kind), alignedBegin, alignedEnd, key.points,
key.iterations, count, windowBegin, windowEnd);
if (windowEnd <= windowBegin)
{
return;
}
{
std::lock_guard<std::mutex> lock(m_curveMutex);
for (const CurveJob& job : m_curveJobs)
{
if (job.key == key)
{
return; // 该曲线已有在算任务,避免任务堆积
}
}
CurveJob job;
job.key = key;
job.windowBegin = windowBegin;
job.windowEnd = windowEnd;
job.generation = m_curveGeneration;
m_curveJobs.push_back(job);
}
EnsureCurveWorker();
m_curveCv.notify_one();
}
void PlotView::EnsureCurveWorker() const
{
if (!m_curveWorkers.empty())
{
return;
}
// **多核并行**:后台线程池并发计算多条派生曲线(5 个网格各一条时,
// 串行要 5×368ms,并行只要约 1×368ms),预览曲线因此能很快出现。
// 线程数 = 硬件并发 - 1(给 UI/绘制线程留一个核),夹在 1~4。
unsigned int hardware = std::thread::hardware_concurrency();
int workers = static_cast<int>(hardware == 0 ? 2u : hardware) - 1;
workers = std::max(1, std::min(workers, 4));
m_curveWorkerStop = false;
m_curveWorkers.reserve(static_cast<std::size_t>(workers));
for (int index = 0; index < workers; ++index)
{
m_curveWorkers.emplace_back([this]() { CurveWorkerLoop(); });
}
}
void PlotView::StopCurveWorker() const
{
if (m_curveWorkers.empty())
{
return;
}
{
std::lock_guard<std::mutex> lock(m_curveMutex);
m_curveWorkerStop = true;
m_curveJobs.clear();
}
m_curveCv.notify_all();
for (std::thread& worker : m_curveWorkers)
{
if (worker.joinable())
{
worker.join();
}
}
m_curveWorkers.clear();
}
void PlotView::CurveWorkerLoop() const
{
// 后台计算线程的优先级降到「低于正常」:派生曲线计算是**可延迟的后台工作**,
// 绝不能让它在拖动时与 UI / 绘制线程抢 CPU。降低优先级后,只要 UI 线程要运行,
// 系统就会优先调度 UI;计算只在 UI 空闲时占用 CPU,从而保证交互始终跟手
//(用户明确要求"无论如何 UI 及控件的响应需要是跟手的")。
SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_BELOW_NORMAL);
for (;;)
{
CurveJob job;
{
std::unique_lock<std::mutex> lock(m_curveMutex);
m_curveCv.wait(lock, [this]() { return m_curveWorkerStop || !m_curveJobs.empty(); });
if (m_curveJobs.empty())
{
return; // 仅在 stop 且队列为空时到达
}
job = m_curveJobs.front();
m_curveJobs.pop_front();
if (job.generation != m_curveGeneration)
{
continue; // 代次已变化:丢弃过期任务
}
}
// 计算在锁外进行:读取的采样在「结构未变」期间只读、且地址稳定
//(结构变化前宿主会先 `PauseDerivedAsync`)。
std::vector<float> values =
ComputeDerivedWindow(*job.key.signal, job.windowBegin, job.windowEnd, job.key.kind,
job.key.points, job.key.iterations, job.key.levelPercent);
{
std::lock_guard<std::mutex> lock(m_curveMutex);
if (job.generation != m_curveGeneration)
{
continue; // 计算期间发生了结构变化:丢弃,避免发布过期结果
}
std::shared_ptr<CurveEntry> entry = std::make_shared<CurveEntry>();
entry->key = job.key;
entry->begin = job.windowBegin;
entry->end = job.windowEnd;
entry->serial = ++m_curveSerial;
entry->values = std::move(values);
m_curveEntries.push_back(std::move(entry));
}
// 通知宿主重绘:下一帧会取到刚发布的曲线(双缓冲切换)。
m_curveSceneDirty.store(true, std::memory_order_relaxed);
const HWND host = m_host;
if (host != nullptr)
{
PostMessageW(host, kMessagePlotDerivedReady, 0, 0);
}
}
}
void PlotView::PruneCurveEntriesLocked() const
{
// 1) 去掉被「同键、更新、且完全覆盖」的条目取代的旧条目。
std::deque<CurveEntryPtr> kept;
for (const CurveEntryPtr& entry : m_curveEntries)
{
bool superseded = false;
for (const CurveEntryPtr& other : m_curveEntries)
{
if (other.get() == entry.get())
{
continue;
}
if (other->key == entry->key && other->serial > entry->serial &&
other->begin <= entry->begin && other->end >= entry->end)
{
superseded = true;
break;
}
}
if (!superseded)
{
kept.push_back(entry);
}
}
m_curveEntries.swap(kept);
// 2) 条目数与总字节数双重约束(最旧优先淘汰)。
const auto bytes = [this]() -> std::size_t
{
std::size_t total = 0;
for (const CurveEntryPtr& entry : m_curveEntries)
{
total += entry->values.size() * sizeof(float);
}
return total;
};
while (!m_curveEntries.empty() &&
(m_curveEntries.size() > kCurveSpanCacheLimit || bytes() > kCurveSpanCacheMaxBytes))
{
m_curveEntries.pop_front();
}
}
const std::vector<float>& PlotView::MeanFilterCurveFor(const SignalData& signal, int points,
int iterations) const
{
const int radius = std::max(0, points);
const int stages = std::max(1, iterations);
for (const MeanCurveEntry& entry : m_meanCurveCache)
{
if (entry.signal == &signal && entry.points == radius && entry.iterations == stages)
{
return entry.values;
}
}
MeanCurveEntry entry;
entry.signal = &signal;
entry.points = radius;
entry.iterations = stages;
// 与「构建为新信号」共用同一实现(Utilities 内的自由函数),
// 因此预览曲线与构建出的采样值逐位一致。
ComputeMovingAverageIterated(signal.values, radius, stages, entry.values);
m_meanCurveCache.push_back(std::move(entry));
return m_meanCurveCache.back().values;
}
const std::vector<float>& PlotView::WeakBoostCurveFor(const SignalData& signal,
int levelPercent, int iterations) const
{
const int level = std::max(0, std::min(100, levelPercent));
const int stages = std::max(kWeakBoostIterationsMin, std::min(kWeakBoostIterationsMax, iterations));
for (const WeakBoostCurveEntry& entry : m_weakBoostCurveCache)
{
if (entry.signal == &signal && entry.levelPercent == level && entry.iterations == stages)
{
return entry.values;
}
}
WeakBoostCurveEntry entry;
entry.signal = &signal;
entry.levelPercent = level;
entry.iterations = stages;
// 与「构建为新信号」共用同一实现(Utilities 内的自由函数),
// 因此预览曲线与构建出的采样值逐位一致。
ComputeWeakWaveBoost(signal.values, static_cast<double>(level) / 100.0, stages, entry.values);
m_weakBoostCurveCache.push_back(std::move(entry));
return m_weakBoostCurveCache.back().values;
}
bool PlotView::WeakBoostValueAt(const DataFile& file, const SignalData& signal, int levelPercent,
int iterations, double time, double& value) const
{
const int index = FindNearestSample(file, time);
if (index < 0)
{
return false;
}
std::size_t spanBegin = 0;
const std::vector<float>* curve = CurveSpanFor(
DerivedCurveKind::WEAK_BOOST, signal, 0, iterations, levelPercent,
static_cast<std::size_t>(index), static_cast<std::size_t>(index) + 1, spanBegin);
if (curve == nullptr || static_cast<std::size_t>(index) < spanBegin ||
(static_cast<std::size_t>(index) - spanBegin) >= curve->size())
{
return false;
}
const float sampled = (*curve)[static_cast<std::size_t>(index) - spanBegin];
if (!std::isfinite(sampled))
{
return false;
}
value = static_cast<double>(sampled);
return true;
}
void PlotView::EdgeCenterCountsFor(const DataFile& file, const SignalData& signal, int points,
int iterations, bool hasRange, double rangeMin, double rangeMax,
std::size_t& rising, std::size_t& falling) const
{
rising = 0;
falling = 0;
const int radius = std::max(0, points);
const int stages = std::max(1, iterations);
const std::size_t count = signal.values.size();
std::size_t begin = 0;
std::size_t end = 0;
const bool indexed = EdgeIndexRange(file, count, hasRange, rangeMin, rangeMax, begin, end);
// 按「文件 + 信号 + 均值点数 X + 迭代次数 N + 是否限定区间」定位缓存项
//(**时间区间不进缓存键**)。
EdgeCenterCountEntry* entry = nullptr;
for (EdgeCenterCountEntry& candidate : m_edgeCenterCountCache)
{
if (candidate.file == &file && candidate.signal == &signal &&
candidate.hasRange == hasRange && candidate.points == radius &&
candidate.iterations == stages)
{
entry = &candidate;
break;
}
}
if (entry == nullptr)
{
if (m_edgeCenterCountCache.size() >= kEdgeCountCacheLimit)
{
m_edgeCenterCountCache.erase(m_edgeCenterCountCache.begin());
}
EdgeCenterCountEntry created;
created.file = &file;
created.signal = &signal;
created.points = radius;
created.iterations = stages;
created.hasRange = hasRange;
m_edgeCenterCountCache.push_back(created);
entry = &m_edgeCenterCountCache.back();
}
// 区间完全一致 → 直接命中。**必须放在请求曲线之前**:面板每帧都会调用本函数,
// 而「没有限定光标区间」时的统计区间就是整条信号 —— 若先请求曲线,
// 每一帧都会去取(并可能重算)整条曲线,实测单帧 30ms 以上。
if (indexed && entry->reusable && entry->begin == begin && entry->end == end)
{
rising = entry->rising;
falling = entry->falling;
return;
}
// 与上一帧共享一个端点(拖动光标时必然如此)→ 只扫描两端差集并增量加减。
const bool shareBegin = indexed && entry->reusable && entry->begin == begin;
const bool shareEnd = indexed && entry->reusable && entry->end == end;
const std::size_t oldBegin = entry->begin;
const std::size_t oldEnd = entry->end;
// 先确定需要比较的「相邻采样对」下标区间 [pairFirst, pairLast](比较 index-1 与 index),
// 再据此只请求**必需的那一小段**曲线窗口(增量路径下通常只有几十个采样)。
std::size_t pairFirst = 0;
std::size_t pairLast = 0;
int mode = 0; // 0 = 全量重算,1 = 仅尾部差集,2 = 仅头部差集
if (shareBegin && !shareEnd)
{
const std::size_t low = std::min(oldEnd, end);
const std::size_t high = std::max(oldEnd, end);
pairFirst = std::max<std::size_t>(1, low);
pairLast = (high > 0) ? (high - 1) : 0;
mode = 1;
}
else if (shareEnd && !shareBegin)
{
const std::size_t low = std::min(oldBegin, begin);
const std::size_t high = std::max(oldBegin, begin);
pairFirst = low + 1;
pairLast = high;
mode = 2;
}
else
{
pairFirst = (end > begin) ? (begin + 1) : 1;
pairLast = (end > begin) ? (end - 1) : 0;
mode = 0;
}
// 需要的偏差下标区间 = [pairFirst - 1, pairLast];请求曲线窗口需包含它。
const std::size_t needFirst = (pairFirst > 0) ? (pairFirst - 1) : 0;
const std::size_t needEnd = std::min(count, pairLast + 1);
if (needEnd <= needFirst)
{
entry->reusable = indexed;
entry->begin = begin;
entry->end = end;
entry->rising = 0;
entry->falling = 0;
return;
}
// 与「实验性功能 → 均值滤波」共用同一条曲线(含缓存),
// 但**只取必需区间 ± 保护带**的窗口。
std::size_t spanBegin = 0;
const std::vector<float>* curvePtr = CurveSpanFor(
DerivedCurveKind::MEAN_FILTER, signal, radius, stages, 0, needFirst, needEnd, spanBegin);
if (curvePtr == nullptr)
{
return;
}
const std::vector<float>& curve = *curvePtr;
// 判定基准:信号与中心平均值曲线之差(区间之外的采样返回 NaN)。
const auto deltaAt = [&signal, &file, &curve, spanBegin, count, hasRange, rangeMin,
rangeMax](std::size_t index) -> double
{
if (index >= count)
{
return std::numeric_limits<double>::quiet_NaN();
}
if (index < spanBegin || (index - spanBegin) >= curve.size())
{
return std::numeric_limits<double>::quiet_NaN();
}
const double value = signal.values[index];
const float center = curve[index - spanBegin];
if (!std::isfinite(value) || !std::isfinite(center))
{
return std::numeric_limits<double>::quiet_NaN();
}
if (hasRange && !file.timeMonotonic && index < file.time.size())
{
const double time = file.time[index];
if (time < rangeMin || time > rangeMax)
{
return std::numeric_limits<double>::quiet_NaN();
}
}
return value - static_cast<double>(center);
};
std::size_t deltaRising = 0;
std::size_t deltaFalling = 0;
if (mode == 1)
{
AccumulateEdgeCrossings(pairFirst, pairLast, deltaAt, deltaRising, deltaFalling);
rising = (end > oldEnd) ? (entry->rising + deltaRising) : (entry->rising - deltaRising);
falling = (end > oldEnd) ? (entry->falling + deltaFalling) : (entry->falling - deltaFalling);
}
else if (mode == 2)
{
AccumulateEdgeCrossings(pairFirst, pairLast, deltaAt, deltaRising, deltaFalling);
rising = (begin < oldBegin) ? (entry->rising + deltaRising) : (entry->rising - deltaRising);
falling = (begin < oldBegin) ? (entry->falling + deltaFalling) : (entry->falling - deltaFalling);
}
else
{
AccumulateEdgeCrossings(pairFirst, pairLast, deltaAt, rising, falling);
}
entry->reusable = indexed;
entry->begin = begin;
entry->end = end;
entry->rising = rising;
entry->falling = falling;
}
void PlotView::EdgeCountsFor(const DataFile& file, const SignalData& signal, double edgeValue,
bool hasRange, double rangeMin, double rangeMax, std::size_t& rising,
std::size_t& falling) const
{
rising = 0;
falling = 0;
const std::vector<double>& values = signal.values;
std::size_t begin = 0;
std::size_t end = 0;
const bool indexed = EdgeIndexRange(file, values.size(), hasRange, rangeMin, rangeMax, begin, end);
// 判定基准:采样值相对阈值的偏差。区间之外的采样返回 NaN(等价于原实现
// 「区间外采样不参与判定且打断连续」的语义)。
const auto deltaAt = [&values, &file, hasRange, rangeMin, rangeMax](std::size_t index) -> double
{
const double value = values[index];
if (!std::isfinite(value))
{
return std::numeric_limits<double>::quiet_NaN();
}
if (hasRange && !file.timeMonotonic && index < file.time.size())
{
const double time = file.time[index];
if (time < rangeMin || time > rangeMax)
{
return std::numeric_limits<double>::quiet_NaN();
}
}
return value;
};
// 按「文件 + 信号 + 阈值 + 是否限定区间」定位缓存项(**时间区间不进缓存键**)。
EdgeCountEntry* entry = nullptr;
for (EdgeCountEntry& candidate : m_edgeCountCache)
{
if (candidate.file == &file && candidate.signal == &signal &&
candidate.hasRange == hasRange && candidate.edgeValue == edgeValue)
{
entry = &candidate;
break;
}
}
if (entry == nullptr)
{
if (m_edgeCountCache.size() >= kEdgeCountCacheLimit)
{
m_edgeCountCache.erase(m_edgeCountCache.begin());
}
EdgeCountEntry created;
created.file = &file;
created.signal = &signal;
created.edgeValue = edgeValue;
created.hasRange = hasRange;
m_edgeCountCache.push_back(created);
entry = &m_edgeCountCache.back();
}
// 阈值参与判定,因此仿函数要在累加时一并带入。
const auto crossingAt = [&deltaAt, edgeValue](std::size_t index) -> double
{ return deltaAt(index) - edgeValue; };
// 区间完全一致 → 直接命中。
if (indexed && entry->reusable && entry->begin == begin && entry->end == end)
{
rising = entry->rising;
falling = entry->falling;
return;
}
// 与上一帧共享一个端点(拖动光标时必然如此)→ 只扫描两端差集并增量加减。
const bool shareBegin = indexed && entry->reusable && entry->begin == begin;
const bool shareEnd = indexed && entry->reusable && entry->end == end;
const std::size_t oldBegin = entry->begin;
const std::size_t oldEnd = entry->end;
std::size_t deltaRising = 0;
std::size_t deltaFalling = 0;
if (shareBegin && !shareEnd)
{
// 右端移动:变动的是采样对 k ∈ [min(oldEnd, end), max(oldEnd, end) - 1]。
const std::size_t low = std::min(oldEnd, end);
const std::size_t high = std::max(oldEnd, end);
AccumulateEdgeCrossings(std::max<std::size_t>(1, low), high - 1, crossingAt, deltaRising,
deltaFalling);
rising = (end > oldEnd) ? (entry->rising + deltaRising) : (entry->rising - deltaRising);
falling = (end > oldEnd) ? (entry->falling + deltaFalling) : (entry->falling - deltaFalling);
}
else if (shareEnd && !shareBegin)
{
// 左端移动:变动的是采样对 k ∈ [min(oldBegin, begin) + 1, max(oldBegin, begin)]。
const std::size_t low = std::min(oldBegin, begin);
const std::size_t high = std::max(oldBegin, begin);
AccumulateEdgeCrossings(low + 1, high, crossingAt, deltaRising, deltaFalling);
rising = (begin < oldBegin) ? (entry->rising + deltaRising) : (entry->rising - deltaRising);
falling = (begin < oldBegin) ? (entry->falling + deltaFalling) : (entry->falling - deltaFalling);
}
else
{
// 两端都变了(或首次统计):整段重算。
const std::size_t pairFirst = (end > begin) ? (begin + 1) : 1;
const std::size_t pairLast = (end > begin) ? (end - 1) : 0;
AccumulateEdgeCrossings(pairFirst, pairLast, crossingAt, rising, falling);
}
entry->reusable = indexed;
entry->begin = begin;
entry->end = end;
entry->rising = rising;
entry->falling = falling;
}
float PlotView::EdgePanelWidth(int laneIndex, EdgePanelKind kind) const
{
if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 ||
laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return kEdgePanelWidth * m_scale;
}
const Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
const bool centerAxis = (kind == EdgePanelKind::CENTER);
const bool meanFilter = (kind == EdgePanelKind::MEAN);
const bool weakBoost = (kind == EdgePanelKind::BOOST);
// 「均值滤波」「中心轴边沿计数」「弱波增强」都是逐条目面板(名称 + 复选框)。
const bool itemized = (centerAxis || meanFilter || weakBoost);
// 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” / “均值滤波” / “弱波增强”
// + 光标区间尾缀)。
const std::wstring title =
meanFilter ? std::wstring(L"均值滤波")
: (weakBoost ? std::wstring(kWeakBoostTitle)
: ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") + EdgeRangeSuffix()));
float width = kEdgePanelPad * 2.0f * m_scale + kLaneBadgeWidth * m_scale +
8.0f * m_scale + UiMeasureTextWidth(m_fonts->uiSmall, title) +
kEdgePanelTitleHeight * m_scale;
// 数据行 = 名称与“上升沿 N / 下降沿 M”两行,取较宽者。
// 用 UiMeasureTextWidth(无省略号)测量,保证数字完整显示。
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || !resolved.visible)
{
continue;
}
const float nameWidth = UiMeasureTextWidth(m_fonts->uiSmall, resolved.data->Title());
if (itemized)
{
// 逐条目面板:第一排 = 名称 + 复选框;第三排(中心轴)= 统计结果。
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + nameWidth +
kEdgePanelMeanCheckSize * m_scale + 6.0f * m_scale);
}
std::size_t rising = 0;
std::size_t falling = 0;
if (meanFilter || weakBoost)
{
continue;
}
if (centerAxis)
{
const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref);
if (item == nullptr)
{
continue;
}
EdgeCenterCountsFor(*resolved.file, *resolved.data, item->points, item->iterations,
EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling);
}
else
{
EdgeCountsFor(*resolved.file, *resolved.data, lane.edgeValue, EdgeRangeActive(),
EdgeRangeMin(), EdgeRangeMax(), rising, falling);
}
const float countWidth =
UiMeasureTextWidth(m_fonts->monoSmall,
L"上升沿 " + std::to_wstring(rising) + L" 下降沿 " +
std::to_wstring(falling));
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + std::max(nameWidth, countWidth));
}
if (centerAxis || meanFilter)
{
// 参数行 = 两个「说明文字 + 数值框」,必须保证文字不被数值框压住。
// (「均值点数 X」/「迭代次数 N」的布局对两个逐条目面板通用:标签与数值框各占半行。)
const float labelX = UiMeasureTextWidth(m_fonts->uiSmall, L"均值点数 X");
const float labelN = UiMeasureTextWidth(m_fonts->uiSmall, L"迭代次数 N");
const float fieldWidth =
std::max(kEdgePanelEditFieldWidth * m_scale * 0.75f,
UiMeasureTextWidth(m_fonts->monoSmall, L"1000000") + 18.0f * m_scale);
const float halfWidth = std::max(labelX, labelN) + 6.0f * m_scale + fieldWidth;
const float rowWidth = halfWidth * 2.0f + 8.0f * m_scale;
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth);
}
if (weakBoost)
{
// 两个滑块行(等级 / 迭代次数 N)= 「说明文字 + 轨道 + 数值」。
const float labelWidth = std::max(UiMeasureTextWidth(m_fonts->uiSmall, kWeakBoostLevelLabel),
UiMeasureTextWidth(m_fonts->uiSmall, kWeakBoostIterationsLabel));
const float valueWidth = std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100%"),
UiMeasureTextWidth(m_fonts->monoSmall, L"10"));
const float rowWidth = labelWidth + 6.0f * m_scale + kWeakBoostTrackMinWidth * m_scale +
6.0f * m_scale + valueWidth;
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth);
}
if (meanFilter || weakBoost)
{
// 底部按钮行 = 「构建为新信号」,按按钮文字实测宽度确定下限。
const float buttonWidth =
UiMeasureTextWidth(m_fonts->uiSmall, kBuildSignalLabel) + 28.0f * m_scale;
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + buttonWidth);
}
// 夹在绘图区宽度之内(过窄的网格也不至于把面板挤到外面)。
return std::min(width, std::max(120.0f * m_scale, m_plotRect.Width - 8.0f * m_scale));
}
bool PlotView::EdgeRangeActive() const
{
return m_tab != nullptr && Tab().CursorMode() == PlotCursorMode::DOUBLE;
}
double PlotView::EdgeRangeMin() const
{
if (m_tab == nullptr)
{
return 0.0;
}
// 左右光标可能被拖动交换:始终取较小者为区间左端。
return std::min(Tab().CursorTime(), Tab().CursorTimeAlt());
}
double PlotView::EdgeRangeMax() const
{
if (m_tab == nullptr)
{
return 0.0;
}
return std::max(Tab().CursorTime(), Tab().CursorTimeAlt());
}
std::wstring PlotView::EdgeRangeSuffix() const
{
return EdgeRangeActive() ? std::wstring(L"(光标区间内)") : std::wstring();
}
bool PlotView::ToggleLaneAutoFitToSelection(int laneIndex)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
lane.fitYToSelection = !lane.fitYToSelection;
if (lane.fitYToSelection)
{
// 打开时立即按当前视图自适应一次,否则当前量程不合适时看不到任何变化。
AutoRangeLaneToView(laneIndex);
}
return lane.fitYToSelection;
}
void PlotView::ApplyAutoFitToSelectionNow(int laneIndex)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return;
}
if (!m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].fitYToSelection)
{
return;
}
AutoRangeLaneToView(laneIndex);
}
void PlotView::ApplyAllAutoFitToSelectionNow()
{
if (m_tab == nullptr)
{
return;
}
const std::size_t count = m_tab->Lanes().size();
for (std::size_t index = 0; index < count; ++index)
{
ApplyAutoFitToSelectionNow(static_cast<int>(index));
}
}
bool PlotView::SetAllLanesAutoFitToSelection(bool enabled, bool applyNow)
{
if (m_tab == nullptr)
{
return false;
}
bool changed = false;
for (Lane& lane : m_tab->Lanes())
{
if (lane.fitYToSelection != enabled)
{
lane.fitYToSelection = enabled;
changed = true;
}
}
if (enabled && applyNow)
{
// 全局打开时立刻让每个网格都自适应一次,保证界面立刻反映开关状态。
ApplyAllAutoFitToSelectionNow();
}
return changed;
}
bool PlotView::AutoRangeLaneToView(int laneIndex)
{
if (m_tab == nullptr || m_dataSet == nullptr)
{
return false;
}
const std::vector<Lane>& lanes = m_tab->Lanes();
if (laneIndex < 0 || laneIndex >= static_cast<int>(lanes.size()))
{
return false;
}
const double viewMin = Tab().ViewMin();
const double viewMax = Tab().ViewMax();
if (!(viewMax > viewMin))
{
return false;
}
// 只统计时间轴当前可见区间内的采样点,因此放大局部波形后可以快速把该段铺满网格。
double low = std::numeric_limits<double>::infinity();
double high = -std::numeric_limits<double>::infinity();
for (const SignalRef& ref : lanes[static_cast<std::size_t>(laneIndex)].signals)
{
if (!Tab().IsVisible(ref))
{
continue;
}
const SignalData* signal = m_dataSet->Resolve(ref);
const DataFile* file = m_dataSet->FileAt(ref.fileIndex);
if (signal == nullptr || file == nullptr)
{
continue;
}
const std::size_t count = std::min(file->time.size(), signal->values.size());
std::size_t begin = 0;
std::size_t end = count;
if (file->timeMonotonic)
{
// 时间单调时直接二分定位区间,百万级采样也只需扫过可见段。
begin = static_cast<std::size_t>(
std::lower_bound(file->time.begin(), file->time.end(), viewMin) - file->time.begin());
if (begin > 0)
{
--begin;
}
end = static_cast<std::size_t>(
std::lower_bound(file->time.begin(), file->time.end(), viewMax) - file->time.begin());
if (end < count)
{
++end;
}
}
for (std::size_t index = begin; index < end; ++index)
{
if (!file->timeMonotonic)
{
const double t = file->time[index];
if (!std::isfinite(t) || t < viewMin || t > viewMax)
{
continue;
}
}
const double value = signal->values[index];
if (std::isfinite(value))
{
low = std::min(low, value);
high = std::max(high, value);
}
}
}
if (!std::isfinite(low) || !std::isfinite(high))
{
return false;
}
// 与自动自适应保持一致的上下留白,避免波形顶到网格边界。
if (high > low)
{
const double pad = (high - low) * 0.05;
low -= pad;
high += pad;
}
else
{
const double pad = std::max(1.0e-9, std::fabs(low) * 0.05);
low -= pad;
high += pad;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
lane.autoRange = false;
lane.yMin = low;
lane.yMax = high;
BuildLaneVisuals();
return true;
}
void PlotView::ApplySelectionAutoFit()
{
if (m_tab == nullptr || m_dataSet == nullptr)
{
return;
}
const std::vector<Lane>& lanes = m_tab->Lanes();
bool any = false;
for (const Lane& lane : lanes)
{
if (lane.fitYToSelection)
{
any = true;
break;
}
}
if (!any)
{
return;
}
// 只对开启了「选区 Y 轴自适应」的网格重新匹配 Y 轴;
// 重新匹配依据的是刚刚更新过的时间视图范围。
for (std::size_t index = 0; index < lanes.size(); ++index)
{
if (m_tab->Lanes()[index].fitYToSelection)
{
AutoRangeLaneToView(static_cast<int>(index));
}
}
}
void PlotView::ShiftLaneRange(int laneIndex, double deltaValue)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()) ||
!std::isfinite(deltaValue) || deltaValue == 0.0)
{
return;
}
// 首次纵向拖动时先把当前自适应范围固化下来,再进行偏移。
const LaneVisual& visual = m_lanes[static_cast<std::size_t>(laneIndex)];
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
// 手动纵向平移属于手动调整幅值:关闭本网格的「选区 Y 轴自适应」。
lane.fitYToSelection = false;
if (lane.autoRange)
{
lane.autoRange = false;
lane.yMin = visual.yMin;
lane.yMax = visual.yMax;
}
lane.yMin += deltaValue;
lane.yMax += deltaValue;
BuildLaneVisuals();
}
bool PlotView::ApplyLaneValueText(int laneIndex, bool editMax, const std::wstring& text)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
if (m_lanes.empty())
{
BuildLaneVisuals();
}
if (laneIndex >= static_cast<int>(m_lanes.size()))
{
return false;
}
const std::wstring trimmed = Trim(text);
if (trimmed.empty())
{
return false;
}
wchar_t* stop = nullptr;
const double parsed = std::wcstod(trimmed.c_str(), &stop);
if (stop == trimmed.c_str() || !std::isfinite(parsed))
{
return false;
}
const LaneVisual& visual = m_lanes[static_cast<std::size_t>(laneIndex)];
const double span = visual.yMax - visual.yMin;
const double minimumGap = (span > 0.0) ? span * 0.01 : std::max(1.0e-9, std::fabs(parsed) * 0.01);
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
// 手工输入即视为取消自动自适应,与拖拽框选缩放的语义一致。
// 必须先把当前(自适应计算出的)显示范围固化到 lane 里,再只改动被编辑的一端,
// 否则另一端会回退到 Lane 的初始占位值,导致量程被莫名其妙地放大。
lane.fitYToSelection = false;
if (lane.autoRange)
{
lane.autoRange = false;
lane.yMin = visual.yMin;
lane.yMax = visual.yMax;
}
if (editMax)
{
lane.yMax = parsed;
if (!(lane.yMax > lane.yMin))
{
lane.yMin = lane.yMax - minimumGap;
}
}
else
{
lane.yMin = parsed;
if (!(lane.yMax > lane.yMin))
{
lane.yMax = lane.yMin + minimumGap;
}
}
BuildLaneVisuals();
return true;
}
bool PlotView::LaneRange(int laneIndex, double& yMin, double& yMax) const
{
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
// 菜单弹出时几何可能尚未建立,回退到配置内的自适应范围。
if (m_tab == nullptr || m_dataSet == nullptr || laneIndex < 0 ||
laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
m_tab->ComputeLaneRange(*m_dataSet, laneIndex, yMin, yMax);
return true;
}
const LaneVisual& visual = m_lanes[static_cast<std::size_t>(laneIndex)];
yMin = visual.yMin;
yMax = visual.yMax;
return true;
}
SignalRef PlotView::HitTestSignal(int x, int y) const
{
SignalRef empty;
if (m_tab == nullptr || m_dataSet == nullptr || !HasDrawableContent())
{
return empty;
}
if (!UiPointInRect(m_plotRect, static_cast<float>(x), static_cast<float>(y)))
{
return empty;
}
const int laneIndex = LaneAtY(y);
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
return empty;
}
const LaneVisual& lane = m_lanes[static_cast<std::size_t>(laneIndex)];
const float tolerance = kSignalHitTolerance * m_scale;
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
SignalRef best;
float bestDistance = tolerance;
// 只为当前网格内的可见信号做精确的点到线段距离检测,避免跨网格误命中。
for (const SignalRef& ref : m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible)
{
continue;
}
// 复用绘制阶段的折线顶点:悬停检测通常与上一帧几何完全相同,
// 不再为“鼠标移动但命中结果未变”重复扫描全量采样点。
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;
if (points.size() < 2)
{
continue;
}
// 横向先行裁剪:点到线段的距离必然不小于横向距离本身,
// 因此只有横向落在容差范围内的顶点才可能命中。
// 时间单调时折线顶点按 x 递增,可二分定位后只扫描这一小段;
// 否则每次鼠标移动都要遍历上万个顶点(悬停卡顿的来源之一)。
std::size_t windowFirst = 0;
std::size_t windowLast = points.size();
if (resolved.file->timeMonotonic)
{
const auto byX = [](const gd::PointF& point, float limit) { return point.X < limit; };
windowFirst = static_cast<std::size_t>(
std::lower_bound(points.begin(), points.end(), fx - tolerance, byX) - points.begin());
windowLast = static_cast<std::size_t>(
std::upper_bound(points.begin(), points.end(), fx + tolerance,
[](float limit, const gd::PointF& point) { return limit < point.X; }) -
points.begin());
}
if (windowFirst >= windowLast)
{
continue;
}
// 散点模式下把采样点本身也视为可命中目标。
if (resolved.style->mode == DrawMode::SCATTER)
{
for (std::size_t index = windowFirst; index < windowLast; ++index)
{
const gd::PointF& point = points[index];
const float dx = point.X - fx;
const float dy = point.Y - fy;
const float distance = std::sqrt(dx * dx + dy * dy);
if (distance <= bestDistance)
{
bestDistance = distance;
best = ref;
}
}
continue;
}
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();
const std::size_t scanBegin = std::max(start + 1, windowFirst);
const std::size_t scanEnd = std::min(stop, windowLast);
for (std::size_t index = scanBegin; index < scanEnd; ++index)
{
const gd::PointF& a = points[index - 1];
const gd::PointF& b = points[index];
const float vx = b.X - a.X;
const float vy = b.Y - a.Y;
const float wx = fx - a.X;
const float wy = fy - a.Y;
const float lengthSquared = vx * vx + vy * vy;
float t = 0.0f;
if (lengthSquared > 1.0e-6f)
{
t = (wx * vx + wy * vy) / lengthSquared;
t = std::max(0.0f, std::min(1.0f, t));
}
const float dx = wx - t * vx;
const float dy = wy - t * vy;
const float distance = std::sqrt(dx * dx + dy * dy);
if (distance < bestDistance)
{
bestDistance = distance;
best = ref;
}
}
}
}
return best;
}
gd::RectF PlotView::EditFieldRect() const
{
if (m_editTarget == PlotEditTarget::NONE || m_fonts == nullptr || m_fonts->monoSmall == nullptr)
{
return gd::RectF();
}
const float height = 22.0f * m_scale;
float width = std::max(90.0f * m_scale,
UiMeasureTextWidth(m_fonts->monoSmall, m_edit.Text()) + 24.0f * m_scale);
if (m_editTarget == PlotEditTarget::CURSOR_TIME)
{
// 与显示态标签取相同宽度与位置(底部),避免进入编辑时框体跳动。
const int which = (m_editCursor == 2) ? 2 : 1;
const gd::RectF displayRect = CursorLabelRect(which);
width = std::max(width, displayRect.Width);
float left = CursorX(which) - width * 0.5f;
left = std::max(m_plotRect.X, std::min(left, m_plotRect.X + m_plotRect.Width - width));
return gd::RectF(left, m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale, width,
height);
}
if (m_editTarget == PlotEditTarget::CURSOR_SPAN)
{
// 差值输入框落在差值标签处(时间轴上方或时间轴内部)。
bool insideAxis = false;
const gd::RectF displayRect = CursorSpanRect(insideAxis);
if (displayRect.Width <= 0.0f)
{
return gd::RectF();
}
width = std::max(width, displayRect.Width);
float left = displayRect.X + displayRect.Width * 0.5f - width * 0.5f;
left = std::max(m_plotRect.X, std::min(left, m_plotRect.X + m_plotRect.Width - width));
return gd::RectF(left, displayRect.Y, width, height);
}
if (m_editTarget == PlotEditTarget::SIGNAL_NAME)
{
// 图例改名:输入框直接落在被双击的图例条目位置上。
if (m_editFieldRect.Width <= 0.0f)
{
return gd::RectF();
}
const float nameWidth = std::max(width, m_editFieldRect.Width);
return gd::RectF(m_editFieldRect.X, m_editFieldRect.Y, nameWidth, height);
}
if (m_editTarget == PlotEditTarget::EDGE_CENTER_POINTS ||
m_editTarget == PlotEditTarget::EDGE_CENTER_ITERATIONS ||
m_editTarget == PlotEditTarget::MEAN_FILTER_POINTS ||
m_editTarget == PlotEditTarget::MEAN_FILTER_ITERATIONS)
{
// 「均值点数 X」/「迭代次数 N」:输入框就落在对应的字段矩形上
//(中心轴与均值滤波面板的字段矩形含义相同)。
if (m_editFieldRect.Width <= 0.0f)
{
return gd::RectF();
}
const float fieldHeight = std::max(height, m_editFieldRect.Height - 4.0f * m_scale);
return gd::RectF(m_editFieldRect.X, m_editFieldRect.Y + 2.0f * m_scale,
m_editFieldRect.Width, fieldHeight);
}
// 网格 Y 轴上下限输入框:优先落在被点击的菜单条目位置,保证"就地编辑"的观感。
if (m_editFieldRect.Width > 0.0f && m_editFieldRect.Height > 0.0f)
{ float left = m_editFieldRect.X;
float top = m_editFieldRect.Y;
left = std::max(m_area.X + 2.0f * m_scale,
std::min(left, m_area.X + m_area.Width - width - 2.0f * m_scale));
top = std::max(m_area.Y + 2.0f * m_scale,
std::min(top, m_area.Y + m_area.Height - height - 2.0f * m_scale));
return gd::RectF(left, top, width, height);
}
if (m_editLane < 0 || m_editLane >= static_cast<int>(m_lanes.size()))
{
return gd::RectF();
}
const LaneVisual& lane = m_lanes[static_cast<std::size_t>(m_editLane)];
const float left = lane.rect.X + kLaneLabelPad * m_scale;
const float top = lane.rect.Y + kLaneLabelPad * m_scale;
return gd::RectF(left, top, std::min(width, lane.rect.Width - 2.0f * kLaneLabelPad * m_scale), height);
}
bool PlotView::BeginEdit(PlotEditTarget target, int laneIndex, const std::wstring& initialText,
const gd::RectF& anchorRect)
{
if (target == PlotEditTarget::NONE)
{
return false;
}
// 切换编辑目标时清掉上一轮残留(图例改名需由调用方在进入前先设置 m_editSignal)。
if (target != PlotEditTarget::SIGNAL_NAME)
{
m_editSignal = SignalRef();
}
// 调用方可以用锚点矩形指定输入框位置(如菜单条目、图例条目);
// 未提供锚点时由 EditFieldRect() 按目标类型自动推导。
if (anchorRect.Width > 0.0f && anchorRect.Height > 0.0f)
{
m_editFieldRect = anchorRect;
}
else if (target != PlotEditTarget::SIGNAL_NAME)
{
m_editFieldRect = gd::RectF();
}
m_editTarget = target;
m_editLane = laneIndex;
// 名称与数值均不限长度(数值在提交时会做范围夹取)。
const bool nameField = (target == PlotEditTarget::SIGNAL_NAME);
m_edit.Begin(initialText, true, nameField ? EditFilter::ANY : EditFilter::NUMBER, 0u);
return true;
}
bool PlotView::TakePendingRename(SignalRef& ref, std::wstring& name)
{
if (!m_pendingRename)
{
return false;
}
ref = m_pendingRenameRef;
name = m_pendingRenameName;
m_pendingRename = false;
m_pendingRenameRef = SignalRef();
m_pendingRenameName.clear();
return true;
}
bool PlotView::EndEdit(bool commit)
{
if (m_editTarget == PlotEditTarget::NONE)
{
return false;
}
const PlotEditTarget target = m_editTarget;
const int laneIndex = m_editLane;
const SignalRef editSignal = m_editSignal;
const std::wstring text = Trim(m_edit.Text());
m_editTarget = PlotEditTarget::NONE;
m_editLane = -1;
m_editSignal = SignalRef();
m_editFieldRect = gd::RectF();
m_edit.End();
// 信号改名:交给宿主窗口落到数据层,绘图区只持有只读数据。
if (target == PlotEditTarget::SIGNAL_NAME)
{
if (commit && !text.empty() && editSignal.IsValid())
{
m_pendingRenameRef = editSignal;
m_pendingRenameName = text;
m_pendingRename = true;
}
return true;
}
double parsed = 0.0;
bool parsedOk = false;
if (commit && !text.empty())
{
wchar_t* stop = nullptr;
parsed = std::wcstod(text.c_str(), &stop);
parsedOk = (stop != text.c_str()) && (parsed == parsed) && std::isfinite(parsed);
}
if (!parsedOk)
{
return true;
}
if (target == PlotEditTarget::CURSOR_TIME)
{
double low = 0.0;
double high = 1.0;
TimeBounds(low, high);
const double span = std::max(high - low, kMinTimeSpan);
const double clamped = ClampDouble(parsed, low - span * 10.0, high + span * 10.0);
if (m_editCursor == 2 && Tab().CursorMode() == PlotCursorMode::DOUBLE)
{
Tab().CursorTimeAlt() = clamped;
}
else
{
Tab().CursorTime() = clamped;
}
m_activeCursor = (m_editCursor == 2) ? 2 : 1;
return true;
}
if (target == PlotEditTarget::CURSOR_SPAN)
{
// 差值编辑:输入值**始终以秒为单位**(即使界面上显示为 1.5min,编辑内容也是 90)。
// **前方(时间较小)的光标不动**,后方光标向后移动到"前方 + 输入差值"。
const double span = std::fabs(parsed);
const double front = std::min(Tab().CursorTime(), Tab().CursorTimeAlt());
const bool secondIsBack = (Tab().CursorTimeAlt() >= Tab().CursorTime());
const double moved = front + span;
if (secondIsBack)
{
Tab().CursorTime() = front;
Tab().CursorTimeAlt() = moved;
}
else
{
Tab().CursorTimeAlt() = front;
Tab().CursorTime() = moved;
}
m_activeCursor = secondIsBack ? 2 : 1;
return true;
}
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return true;
}
// 「均值点数 X」/「迭代次数 N」:作用于被勾选信号的均值滤波曲线,夹在合理范围内。
// 「中心轴边沿计数」用**完全相同的参数与语义**,只是作用于自己的条目列表。
const bool centerTarget =
(target == PlotEditTarget::EDGE_CENTER_POINTS || target == PlotEditTarget::EDGE_CENTER_ITERATIONS);
if (target == PlotEditTarget::MEAN_FILTER_POINTS || target == PlotEditTarget::MEAN_FILTER_ITERATIONS ||
centerTarget)
{
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
const bool isPoints = (target == PlotEditTarget::MEAN_FILTER_POINTS ||
target == PlotEditTarget::EDGE_CENTER_POINTS);
const Lane::MeanFilterItem* existing = centerTarget ? lane.FindEdgeCenterItem(m_editMeanRef)
: lane.FindMeanFilterItem(m_editMeanRef);
if (existing == nullptr)
{
return true;
}
const int maximum = isPoints ? 1000000 : 1000;
int value = static_cast<int>(std::lround(parsed));
// 迭代次数至少为 1(1 次迭代等于不做滤波);均值点数可以为 0。
value = isPoints ? std::max(0, std::min(value, maximum))
: std::max(1, std::min(value, maximum));
Lane::MeanFilterItem& item = centerTarget ? lane.EnsureEdgeCenterItem(m_editMeanRef)
: lane.EnsureMeanFilterItem(m_editMeanRef);
if (isPoints)
{
item.points = value;
}
else
{
item.iterations = value;
}
// 参数变化会改变曲线:清掉派生缓存即可(局部重画由 `m_sceneDerived` 快照驱动)。
m_meanCurveCache.clear();
m_edgeCenterCountCache.clear();
return true;
}
// 直接输入上下限:先固化当前范围,再用新值替换对应端点(保证 max > min)。
if (laneIndex >= static_cast<int>(m_lanes.size()))
{
return true;
}
const LaneVisual& visual = m_lanes[static_cast<std::size_t>(laneIndex)];
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
// 直接输入上下限属于手动调整幅值:关闭本网格的「选区 Y 轴自适应」。
lane.fitYToSelection = false;
if (lane.autoRange)
{
lane.autoRange = false;
lane.yMin = visual.yMin;
lane.yMax = visual.yMax;
}
const double magnitude = std::max(std::fabs(lane.yMin), std::fabs(lane.yMax));
const double minimumGap = std::max(magnitude * 1.0e-6, 1.0e-12);
if (target == PlotEditTarget::LANE_Y_MAX)
{
if (parsed <= lane.yMin)
{
parsed = lane.yMin + minimumGap;
}
lane.yMax = parsed;
}
else
{
if (parsed >= lane.yMax)
{
parsed = lane.yMax - minimumGap;
}
lane.yMin = parsed;
}
BuildLaneVisuals();
return true;
}
bool PlotView::OnEditChar(wchar_t character)
{
if (m_editTarget == PlotEditTarget::NONE)
{
return false;
}
// 剪贴板与全选快捷键经 WM_CHAR 送达(Ctrl+C/X/V/A 分别对应 3/24/22/1)。
switch (character)
{
case 0x03: // Ctrl+C
m_edit.CopyToClipboard(m_host);
return false;
case 0x18: // Ctrl+X
return m_edit.CutToClipboard(m_host);
case 0x16: // Ctrl+V
return m_edit.PasteFromClipboard(m_host);
case 0x01: // Ctrl+A
m_edit.SelectAll();
return true;
default:
break;
}
if (character == L'\r' || character == L'\n')
{
return EndEdit(true);
}
// 退格/删除已由 WM_KEYDOWN 的 OnEditKey 处理,此处忽略控制字符避免重复删除。
if (character < 0x20 || character == 0x7F)
{
return false;
}
return m_edit.Insert(character);
}
bool PlotView::CollapseEditSelectionToEnd()
{
if (m_editTarget == PlotEditTarget::NONE)
{
return false;
}
return m_edit.DeselectToEnd();
}
bool PlotView::OnEditMouseDown(int x, int y)
{
if (m_editTarget == PlotEditTarget::NONE)
{
return false;
}
const gd::RectF rect = EditFieldRect();
if (rect.Width <= 0.0f)
{
return false;
}
return UiEditFieldMouseDown(m_fonts->uiSmall, rect, m_edit, m_scale, x, y);
}
bool PlotView::OnEditMouseMove(int x)
{
if (m_editTarget == PlotEditTarget::NONE)
{
return false;
}
const gd::RectF rect = EditFieldRect();
if (rect.Width <= 0.0f)
{
return false;
}
return UiEditFieldMouseMove(m_fonts->uiSmall, rect, m_edit, m_scale, x);
}
bool PlotView::OnEditMouseUp()
{
if (!m_edit.Dragging())
{
return false;
}
m_edit.EndDrag();
return true;
}
bool PlotView::OnEditKey(WPARAM key)
{
if (m_editTarget == PlotEditTarget::NONE)
{
return false;
}
const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0;
const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0;
switch (key)
{
case VK_RETURN:
return EndEdit(true);
case VK_ESCAPE:
return EndEdit(false);
case VK_BACK:
return m_edit.Backspace();
case VK_DELETE:
return m_edit.DeleteForward();
case VK_LEFT:
return m_edit.MoveCaret(-1, shift);
case VK_RIGHT:
return m_edit.MoveCaret(1, shift);
case VK_HOME:
return m_edit.MoveHome(shift);
case VK_END:
return m_edit.MoveEnd(shift);
case 'A':
if (control)
{
m_edit.SelectAll();
}
return true;
default:
break;
}
return true;
}
bool PlotView::HitTest(int x, int y) const
{
return UiPointInRect(m_area, static_cast<float>(x), static_cast<float>(y));
}
HCURSOR PlotView::QueryCursor(int x, int y) const
{
switch (m_dragMode)
{
case PlotDragMode::PAN:
return LoadCursorW(nullptr, IDC_SIZEALL);
case PlotDragMode::RUBBER_SELECT:
// 选区放大:轴向未定时为十字,确定后按方向给出上下/左右箭头。
if (m_rubberAxis == PlotRubberAxis::VALUE)
{
return LoadCursorW(nullptr, IDC_SIZENS);
}
if (m_rubberAxis == PlotRubberAxis::TIME)
{
return LoadCursorW(nullptr, IDC_SIZEWE);
}
return LoadCursorW(nullptr, IDC_CROSS);
case PlotDragMode::SCROLL_LANES:
return LoadCursorW(nullptr, IDC_SIZENS);
case PlotDragMode::CURSOR_MOVE:
// 光标拖拽(无论是抓住光标线还是抓住时间读数标签)都沿时间轴移动 → 左右箭头。
return LoadCursorW(nullptr, IDC_SIZEWE);
case PlotDragMode::CURSOR_SPAN_MOVE:
// 差值标签拖拽:两条光标整体沿时间轴平移 → 左右箭头。
return LoadCursorW(nullptr, IDC_SIZEWE);
case PlotDragMode::BOOST_LEVEL:
// 「弱波增强」等级滑块拖拽 → 左右箭头。
return LoadCursorW(nullptr, IDC_SIZEWE);
case PlotDragMode::BOOST_ITERATIONS:
// 「弱波增强」迭代次数 N 滑块拖拽 → 左右箭头。
return LoadCursorW(nullptr, IDC_SIZEWE);
case PlotDragMode::MOVE_SIGNAL:
// 拖动信号(参考 Photoshop 平移元素的抓握光标)。
return LoadCursorW(nullptr, IDC_SIZEALL);
case PlotDragMode::EDGE_MOVE:
// 「边沿计数」横向光标:上下移动幅值。
return LoadCursorW(nullptr, IDC_SIZENS);
case PlotDragMode::EDGE_PANEL_MOVE:
// 边沿统计浮动面板:可自由拖动。
return LoadCursorW(nullptr, IDC_SIZEALL);
case PlotDragMode::LANE_REORDER:
// 网格次序拖拽:上下箭头(只能上下移动)。
return LoadCursorW(nullptr, IDC_SIZENS);
default:
break;
}
if (m_scrollBarVisible &&
UiPointInRect(m_scrollBarRect, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_SIZENS);
}
// 实验性功能面板与横向光标优先于信号悬停提示判定(面板会盖住波形)。
// 面板**只有标题栏**可以拖拽(显示抓握光标);✕ 与面板主体一律保持原始箭头。
if (m_tab != nullptr)
{
for (const EdgePanelKind kind : kEdgePanelKinds)
{
for (int index = 0; index < static_cast<int>(m_lanes.size()); ++index)
{
const EdgePanelLayout layout = EdgePanelLayoutFor(index, kind);
if (layout.panel.Width <= 0.0f ||
!UiPointInRect(layout.panel, static_cast<float>(x), static_cast<float>(y)))
{
continue;
}
if (UiPointInRect(layout.close, static_cast<float>(x), static_cast<float>(y)))
{
// ✕ 按钮:与面板/标签栏的其它 ✕ 一致,用手型光标作为可点击提示。
return LoadCursorW(nullptr, IDC_HAND);
}
if (UiPointInRect(layout.title, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_SIZEALL);
}
if (kind == EdgePanelKind::BOOST &&
(WeakBoostSliderHit(layout.slider, m_scale, static_cast<float>(x),
static_cast<float>(y)) ||
WeakBoostSliderHit(layout.sliderIterations, m_scale, static_cast<float>(x),
static_cast<float>(y))))
{
// 等级 / 迭代次数滑块:左右箭头表示可左右拖动。
return LoadCursorW(nullptr, IDC_SIZEWE);
}
if ((kind == EdgePanelKind::MEAN || kind == EdgePanelKind::BOOST) &&
layout.button.Width > 0.0f &&
UiPointInRect(layout.button, static_cast<float>(x), static_cast<float>(y)))
{
// 「构建为新信号」按钮:手型光标作为可点击提示。
return LoadCursorW(nullptr, IDC_HAND);
}
// 面板主体(含底部编辑行与按钮):保持原始箭头。
return nullptr;
}
}
int edgeLane = -1;
if (IsOnEdgeCursorLine(x, y, edgeLane))
{
return LoadCursorW(nullptr, IDC_SIZENS);
}
}
// 图例中的 GX 编号徽标**悬停时不改变光标样式**(与其它可拖拽区域保持一致);
// 只有真正按下拖拽时才切为上下箭头(见 LANE_REORDER 分支)。
// 光标时间读数标签与双光标差值标签:**悬停(未按下)时不改变鼠标形状**。
// 两个标签都叠在光标线的正上方,因此必须先于"光标线"判定返回 nullptr,
// 由调用方回退成箭头;只有真正拖拽时才切成左右箭头(见 CURSOR_MOVE / CURSOR_SPAN_MOVE)。
if (IsOnCursorSpanLabel(x, y))
{
return nullptr;
}
{
int labelWhich = 0;
if (IsOnCursorTimeLabel(x, y, labelWhich))
{
return nullptr;
}
}
{
int cursorWhich = 0;
if (IsOnCursorLine(x, cursorWhich) && UiPointInRect(m_area, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_SIZEWE);
}
}
if (UiPointInRect(m_plotRect, static_cast<float>(x), static_cast<float>(y)))
{
// 悬停命中可拖拽信号时**不改变鼠标形状**(维持原始箭头);
// 只有真正按下拖动(MOVE_SIGNAL)才切换为抓握光标。
// 悬停图例时同样保持箭头,仅做半高亮提示。
// 选区放大模式下**未开始框选时保持默认箭头**(不改变鼠标形状);
// 只有真正按下并拖动后才切成十字/方向箭头。
// (m_dragMode == RUBBER_SELECT 的情况已在上面处理。)
}
return nullptr;
}
bool PlotView::OnMouseMove(int x, int y)
{
const bool mouseMoved = (x != m_mouseX) || (y != m_mouseY);
m_mouseX = x;
m_mouseY = y;
if (!HasDrawableContent())
{
return false;
}
const float fx = static_cast<float>(x);
switch (m_dragMode)
{
case PlotDragMode::PAN:
{
if (m_plotRect.Width <= 0.0f)
{
return false;
}
// 横向拖动平移时间轴;纵向拖动偏移当前网格的幅值显示位置。
const double span = Tab().ViewMax() - Tab().ViewMin();
const double delta =
-static_cast<double>(x - m_dragLastX) / static_cast<double>(m_plotRect.Width) * span;
if (delta != 0.0)
{
Tab().ViewMin() += delta;
Tab().ViewMax() += delta;
ClampTimeView();
}
const int laneIndex = m_dragLane;
if (laneIndex >= 0 && laneIndex < static_cast<int>(m_lanes.size()) && y != m_dragLastY)
{
const LaneVisual& visual = m_lanes[static_cast<std::size_t>(laneIndex)];
if (visual.rect.Height > 0.0f)
{
const double valueSpan = visual.yMax - visual.yMin;
const double deltaValue = static_cast<double>(y - m_dragLastY) /
static_cast<double>(visual.rect.Height) * valueSpan;
ShiftLaneRange(laneIndex, deltaValue);
}
}
m_dragLastX = x;
m_dragLastY = y;
if (std::abs(x - m_dragStartX) > 3 || std::abs(y - m_dragStartY) > 3)
{
m_dragMoved = true;
}
return true;
}
case PlotDragMode::RUBBER_SELECT:
{
const float dx = static_cast<float>(x - m_dragStartX);
const float dy = static_cast<float>(y - m_dragStartY);
// 轴向由首次明显位移决定,之后固定不变(避免拖拽中途选区形态跳变)。
if (m_rubberAxis == PlotRubberAxis::UNDECIDED)
{
const float threshold = 4.0f * m_scale;
if (std::fabs(dx) < threshold && std::fabs(dy) < threshold)
{
return true;
}
m_rubberAxis = (std::fabs(dx) >= std::fabs(dy)) ? PlotRubberAxis::TIME : PlotRubberAxis::VALUE;
}
if (m_rubberAxis == PlotRubberAxis::TIME)
{
// 竖向选区贯穿整个绘图区高度,只取横向区间用于时间轴缩放。
// **横向必须夹在绘图区内**:鼠标被捕获时可能移动到工具栏 / 信号面板等处,
// 若不夹取,选区会画到绘图区之外(那些区域只会被局部失效,于是残留成"绘制错误")。
const float plotLeft = m_plotRect.X;
const float plotRight = m_plotRect.X + m_plotRect.Width;
const float from = ClampDouble(static_cast<double>(m_dragStartX),
static_cast<double>(plotLeft),
static_cast<double>(plotRight));
const float to = ClampDouble(static_cast<double>(x), static_cast<double>(plotLeft),
static_cast<double>(plotRight));
const float left = std::min(from, to);
const float width = std::fabs(to - from);
m_rubberRect = gd::RectF(left, m_plotRect.Y, width, m_plotRect.Height);
}
else
{
// 横向选区限定在按下时的网格内(纵向同样夹在该网格内),只取纵向区间用于幅值轴缩放。
if (m_dragLane < 0 || m_dragLane >= static_cast<int>(m_lanes.size()))
{
return false;
}
const gd::RectF& laneRect = m_lanes[static_cast<std::size_t>(m_dragLane)].rect;
const float from = ClampDouble(static_cast<double>(m_dragStartY),
static_cast<double>(laneRect.Y),
static_cast<double>(laneRect.Y + laneRect.Height));
const float to = ClampDouble(static_cast<double>(y), static_cast<double>(laneRect.Y),
static_cast<double>(laneRect.Y + laneRect.Height));
const float top = std::min(from, to);
const float bottom = std::max(from, to);
m_rubberRect = gd::RectF(laneRect.X, top, laneRect.Width, bottom - top);
}
m_dragMoved = true;
// 拖动过程中维持显示"右键取消选区"说明(粘滞,直到松开左键后才淡出)。
SetHintSticky(L"右键取消选区,ESC退出选区模式");
return true;
}
case PlotDragMode::SCROLL_LANES:
{
if (m_plotRect.Height <= 0.0f || m_laneContentHeight <= m_plotRect.Height)
{
return false;
}
const double travel =
static_cast<double>(y - m_dragLastY) / static_cast<double>(m_plotRect.Height);
Tab().LaneScroll() += static_cast<float>(travel * m_laneContentHeight);
m_dragLastY = y;
m_dragMoved = true;
BuildLaneVisuals();
return true;
}
case PlotDragMode::CURSOR_MOVE:
{
// 从时间读数标签上抓起时保留横向抓取偏移,避免一按下光标就跳到鼠标中心。
double target = XToTime(fx - static_cast<float>(m_cursorGrabOffset));
double snapped = target;
if (SnapTimeToSample(target, snapped))
{
target = snapped;
}
const bool second =
(m_hoverCursor == 2 && Tab().CursorMode() == PlotCursorMode::DOUBLE);
if (second)
{
Tab().CursorTimeAlt() = target;
}
else
{
Tab().CursorTime() = target;
}
// 抓取读数标签时可能带有一个较大的横向抓取偏移(放大到一定程度后该偏移
// 换算成的时间可能落到视界之外)——拖拽结束前必须把光标夹回当前可见区间,
// 否则用户会看到光标线"跑到视界外"而无法继续操作。
ClampCursorToView(second ? 2 : 1);
m_dragMoved = true;
m_cursorHover = true;
return true;
}
case PlotDragMode::CURSOR_SPAN_MOVE:
{
// 拖拽差值标签:两条光标整体平移,间隔保持不变。
const double startTime = XToTime(static_cast<float>(m_spanDragStartX));
const double delta = XToTime(fx) - startTime;
double first = m_spanDragTime1 + delta;
double second = m_spanDragTime2 + delta;
// 整体平移后同样不能越出可视区:先尝试保持间隔地整体夹取,
// 两光标间隔本身就大于可视跨度时退化为各自夹取。
const double low = Tab().ViewMin();
const double high = Tab().ViewMax();
if (high > low)
{
const double inset = (high - low) * 0.002;
const double lo = low + inset;
const double hi = high - inset;
const double pairLow = std::min(first, second);
const double pairHigh = std::max(first, second);
if (pairHigh - pairLow <= hi - lo)
{
if (pairLow < lo)
{
first += lo - pairLow;
second += lo - pairLow;
}
else if (pairHigh > hi)
{
first -= pairHigh - hi;
second -= pairHigh - hi;
}
}
else
{
first = ClampDouble(first, lo, hi);
second = ClampDouble(second, lo, hi);
}
}
Tab().CursorTime() = first;
Tab().CursorTimeAlt() = second;
m_dragMoved = true;
return true;
}
case PlotDragMode::BOOST_LEVEL:
{
// 「弱波增强」等级滑块:按滑轨上的横向位置换算 0–100%。
if (m_tab == nullptr || m_boostDragLane < 0 ||
m_boostDragLane >= static_cast<int>(m_lanes.size()))
{
break;
}
const EdgePanelLayout layout = EdgePanelLayoutFor(m_boostDragLane, EdgePanelKind::BOOST);
if (layout.slider.Width <= 0.0f)
{
break;
}
const double ratio = UiSliderRatioFromX(layout.slider, fx);
SetLaneWeakBoostLevel(m_boostDragLane, ratio);
m_dragMoved = true;
return true;
}
case PlotDragMode::BOOST_ITERATIONS:
{
// 「弱波增强」迭代次数 N 滑块:按滑轨上的横向位置换算 1–10(整数)。
if (m_tab == nullptr || m_boostDragLane < 0 ||
m_boostDragLane >= static_cast<int>(m_lanes.size()))
{
break;
}
const EdgePanelLayout layout = EdgePanelLayoutFor(m_boostDragLane, EdgePanelKind::BOOST);
if (layout.sliderIterations.Width <= 0.0f)
{
break;
}
const double ratio = UiSliderRatioFromX(layout.sliderIterations, fx);
const int iterations = kWeakBoostIterationsMin + static_cast<int>(std::lround(
ratio * (kWeakBoostIterationsMax - kWeakBoostIterationsMin)));
SetLaneWeakBoostIterations(m_boostDragLane, iterations);
m_dragMoved = true;
return true;
}
case PlotDragMode::MOVE_SIGNAL:
{
const int target = LaneAtY(y);
m_dragMoved = true;
m_dragMouseX = x;
m_dragMouseY = y;
if (target != m_dragTargetLane)
{
m_dragTargetLane = target;
}
return true;
}
case PlotDragMode::LANE_REORDER:
{
// 被拖拽的网格自由跟随鼠标;目标位置变化时其它网格实时让位(吸附)。
m_laneDragMouseY = y;
const int target = LaneDragTargetAt(y);
if (target != m_laneDragTarget)
{
m_laneDragTarget = target;
}
m_dragMoved = true;
return true;
}
case PlotDragMode::EDGE_MOVE:
{
// 横向光标:上下移动幅值(夹在该网格的可见幅值范围内)。
if (m_tab == nullptr || m_edgeDragLane < 0 ||
m_edgeDragLane >= static_cast<int>(m_lanes.size()) ||
m_edgeDragLane >= static_cast<int>(m_tab->Lanes().size()))
{
break;
}
const LaneVisual& visual = m_lanes[static_cast<std::size_t>(m_edgeDragLane)];
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(m_edgeDragLane)];
double value = YToValue(static_cast<float>(y), visual);
const double low = std::min(visual.yMin, visual.yMax);
const double high = std::max(visual.yMin, visual.yMax);
value = std::max(low, std::min(value, high));
lane.edgeValue = value;
lane.edgeValueValid = true;
m_dragMoved = true;
return true;
}
case PlotDragMode::EDGE_PANEL_MOVE:
{
// 浮动面板:**只能从标题栏拖动**(按下时已限制),整体限制在绘图区内。
if (m_tab == nullptr || m_edgePanelDragLane < 0 ||
m_edgePanelDragLane >= static_cast<int>(m_tab->Lanes().size()))
{
break;
}
const gd::RectF panel =
EdgePanelLayoutFor(m_edgePanelDragLane, m_edgePanelDragKind).panel;
if (panel.Width <= 0.0f)
{
break;
}
float left = static_cast<float>(x) - m_edgePanelDragOffset.X;
float top = static_cast<float>(y) - m_edgePanelDragOffset.Y;
left = std::max(m_plotRect.X + 2.0f * m_scale,
std::min(left, m_plotRect.X + m_plotRect.Width - panel.Width - 2.0f * m_scale));
top = std::max(m_plotRect.Y + 2.0f * m_scale,
std::min(top, m_plotRect.Y + m_plotRect.Height - panel.Height - 2.0f * m_scale));
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(m_edgePanelDragLane)];
switch (m_edgePanelDragKind)
{
case EdgePanelKind::CENTER:
lane.edgeCenterPanelX = left;
lane.edgeCenterPanelY = top;
break;
case EdgePanelKind::MEAN:
lane.meanFilterPanelX = left;
lane.meanFilterPanelY = top;
break;
case EdgePanelKind::BOOST:
lane.weakBoostPanelX = left;
lane.weakBoostPanelY = top;
break;
case EdgePanelKind::COUNT:
default:
lane.edgePanelX = left;
lane.edgePanelY = top;
break;
}
m_dragMoved = true;
return true;
}
case PlotDragMode::NONE:
default:
break;
}
bool changed = false;
// 鼠标压在实验性功能面板上时不再触发信号悬停提示(面板本来就盖住波形),
// 但该面板所属的网格仍然要高亮(与鼠标直接停在网格上一致)。
bool overEdgePanel = false;
int panelLane = -1;
for (long kindIndex = 0;
kindIndex < static_cast<long>(sizeof(kEdgePanelKinds) / sizeof(kEdgePanelKinds[0])) &&
!overEdgePanel;
++kindIndex)
{
for (int index = 0; index < static_cast<int>(m_lanes.size()); ++index)
{
const gd::RectF panel = EdgePanelLayoutFor(index, kEdgePanelKinds[kindIndex]).panel;
if (panel.Width > 0.0f &&
UiPointInRect(panel, static_cast<float>(x), static_cast<float>(y)))
{
overEdgePanel = true;
panelLane = index;
break;
}
}
}
// 悬停网格:鼠标在面板上时高亮面板所属网格(用户明确要求)。
const int lane = overEdgePanel ? panelLane : LaneAtY(y);
if (lane != m_hoverLane)
{
m_hoverLane = lane;
changed = true;
}
// ✕ 关闭按钮 / 等级与迭代次数滑块 / 底部按钮的悬停提示都直接依赖鼠标坐标,
// 但它们在面板内部移动时上面这些状态可能毫无变化(例如从同一网格的波形移到面板上),
// 于是返回值恒为 false → 宿主不重绘 → **悬停提示不会出现**。
// 因此在“鼠标进入 / 离开 / 在面板内移动”时强制标记需要重绘。
if ((overEdgePanel || m_mouseOverPanel) && mouseMoved)
{
changed = true;
}
m_mouseOverPanel = overEdgePanel;
// 时间光标不再跟随鼠标移动,仅在鼠标覆盖光标时才显示具体时间读数。
int hoverWhich = 0;
const bool hover = Tab().CursorEnabled() && IsOnCursorLine(x, hoverWhich);
if (hover != m_cursorHover)
{
m_cursorHover = hover;
changed = true;
}
if (hoverWhich != m_hoverCursor)
{
m_hoverCursor = hoverWhich;
changed = true;
}
// 精确检测鼠标是否贴近某条信号曲线,用于给出可拖拽提示。
// 图例优先于曲线:图例条目是可点选目标(与左侧信号列表一致),
// 但悬停图例时**只做半高亮提示**,不显示曲线拖拽提示(也不触发光晕)。
// **图例悬停在选区放大模式下同样生效**(该模式只改变拖拽语义)。
SignalRef hoverSignal;
SignalRef hoverLegend;
if (!m_cursorHover && !overEdgePanel)
{
hoverLegend = LegendAtPoint(x, y);
if (!hoverLegend.IsValid() && Tab().ZoomMode() == PlotZoomMode::NONE)
{
hoverSignal = HitTestSignal(x, y);
}
}
if (hoverSignal != m_hoverSignal)
{
m_hoverSignal = hoverSignal;
changed = true;
}
if (hoverLegend != m_hoverLegend)
{
m_hoverLegend = hoverLegend;
changed = true;
}
// 双光标差值标签的悬停状态(用于区分"抓握/悬停"光标与普通状态)。
const bool spanHover = IsOnCursorSpanLabel(x, y);
if (spanHover != m_spanHover)
{
m_spanHover = spanHover;
changed = true;
}
// 悬停提示跟随鼠标:只要提示可见,鼠标移动本身就要触发重绘;
// 否则鼠标在可拖拽区域内继续移动时,提示会停在上一帧的位置。
if (m_hoverSignal.IsValid() && mouseMoved)
{
changed = true;
}
return changed;
}
bool PlotView::OnLButtonDown(int x, int y)
{
if (!HasDrawableContent())
{
return false;
}
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
m_dragStartX = x;
m_dragStartY = y;
m_dragLastX = x;
m_dragLastY = y;
m_dragMoved = false;
m_rubberRect = gd::RectF();
m_rubberAxis = PlotRubberAxis::UNDECIDED;
m_cursorGrabOffset = 0;
m_boostDragLane = -1;
// 新的交互开始:清掉上一次留下的提示(拖动过程中会重新设置粘滞提示)。
SetHint(std::wstring());
// 上一轮尚未抬起确认的面板关闭请求一律作废。
m_pendingPanelClose = false;
m_pendingPanelCloseLane = -1;
if (m_scrollBarVisible &&
UiPointInRect(m_scrollBarRect, static_cast<float>(x), static_cast<float>(y)))
{
m_dragMode = PlotDragMode::SCROLL_LANES;
return true;
}
// 内联输入中:输入框之外的点击视为提交(与线宽输入一致)。
if (IsEditing())
{
if (!UiPointInRect(EditFieldRect(), fx, fy))
{
EndEdit(true);
}
return true;
}
// 双光标模式下先检查差值标签:它可拖拽(两条光标整体平移,间隔不变)。
if (IsOnCursorSpanLabel(x, y))
{
m_dragMode = PlotDragMode::CURSOR_SPAN_MOVE;
m_spanDragStartX = x;
m_spanDragTime1 = Tab().CursorTime();
m_spanDragTime2 = Tab().CursorTimeAlt();
return true;
}
// 光标时间读数标签:可直接抓住拖动(与抓住光标线等价)。
{
int labelWhich = 0;
if (IsOnCursorTimeLabel(x, y, labelWhich))
{
m_dragMode = PlotDragMode::CURSOR_MOVE;
m_hoverCursor = labelWhich;
m_activeCursor = labelWhich;
m_cursorHover = true;
m_cursorGrabOffset = x - static_cast<int>(std::lround(CursorDisplayX(labelWhich)));
return true;
}
}
// 靠近时间光标线时优先进入拖动,避免误触发平移或缩放选区。
{
int cursorWhich = 0;
if (IsOnCursorLine(x, cursorWhich) && UiPointInRect(m_area, fx, fy))
{
m_dragMode = PlotDragMode::CURSOR_MOVE;
m_hoverCursor = cursorWhich;
// 点中的光标成为“活动光标”:它触发的内容将画在上层。
m_activeCursor = cursorWhich;
m_cursorHover = true;
return true;
}
}
// 网格次序拖拽:按在图例的 GX 编号徽标上即可整块拎起该网格上下移动。
// 位置在浮动面板之后(面板压在波形之上,应优先命中),但在信号拖拽之前。
{
const int badgeLane = LaneBadgeAtPoint(x, y);
if (badgeLane >= 0)
{
m_dragMode = PlotDragMode::LANE_REORDER;
m_laneDragFrom = badgeLane;
m_laneDragTarget = badgeLane;
m_laneDragMouseY = y;
m_laneDragGrabOffset =
static_cast<float>(y) - m_lanes[static_cast<std::size_t>(badgeLane)].rect.Y;
// 拖动期间保持静态场景缓存有效:内容按网格条带平移贴回,
// 因此不必重绘百万级采样的波形(见 PaintLaneReorder)。
return true;
}
}
// 实验性功能浮动面板(面板压在波形之上,必须先于信号拖拽判定):
// * ✕ → 关闭该网格对应的功能;
// * 底部编辑行 → 进入「平滑点数 / 均值点数 N」内联编辑;
// * 底部按钮 → 触发「构建为新信号」请求;
// * 标题栏 → 拖动面板;
// * 面板主体 → 消费点击,但**不拖动也不穿透**(用户明确要求只有标题栏可拖拽)。
for (int pass = 0; pass < kEdgePanelKindCount; ++pass)
{
const EdgePanelKind kind = kEdgePanelKinds[pass];
bool handledPanel = false;
for (int index = 0; index < static_cast<int>(m_lanes.size()); ++index)
{
const EdgePanelLayout layout = EdgePanelLayoutFor(index, kind);
if (layout.panel.Width <= 0.0f ||
!UiPointInRect(layout.panel, fx, fy))
{
continue;
}
handledPanel = true;
if (UiPointInRect(layout.close, fx, fy))
{
// ✕:**抬起**时才关闭该网格对应的实验性功能,这里只记录目标;
// 按住后移开再松开视为放弃(与面板/标签栏的关闭 ✕ 语义一致)。
// ⚠️ 必须在此**直接消费**该次按下:✕ 位于标题栏矩形内部,
// 若继续往下走会被“标题栏 → 拖动面板”分支命中,表现为“按下 ✕ 却拖走了面板”。
m_pendingPanelClose = true;
m_pendingPanelCloseLane = index;
m_pendingPanelCloseKind = kind;
m_dragMode = PlotDragMode::NONE;
return true;
}
// 「中心轴边沿计数」/「均值滤波」/「弱波增强」面板:逐信号条目
// (同一套「名称 + 复选框」排布,只是复选框绑定的条目列表不同)。
if ((kind == EdgePanelKind::MEAN || kind == EdgePanelKind::CENTER ||
kind == EdgePanelKind::BOOST) &&
m_tab != nullptr)
{
const bool meanMode = (kind == EdgePanelKind::MEAN);
const bool boostMode = (kind == EdgePanelKind::BOOST);
Lane& tabLane = m_tab->Lanes()[static_cast<std::size_t>(index)];
// 弱波增强的两个滑块:按下即开始拖动(拖动过程中实时更新)。
if (boostMode && WeakBoostSliderHit(layout.slider, m_scale, fx, fy))
{
m_dragMode = PlotDragMode::BOOST_LEVEL;
m_boostDragLane = index;
SetLaneWeakBoostLevel(index, UiSliderRatioFromX(layout.slider, fx));
return true;
}
if (boostMode && WeakBoostSliderHit(layout.sliderIterations, m_scale, fx, fy))
{
m_dragMode = PlotDragMode::BOOST_ITERATIONS;
m_boostDragLane = index;
const double ratio = UiSliderRatioFromX(layout.sliderIterations, fx);
SetLaneWeakBoostIterations(index,
kWeakBoostIterationsMin +
static_cast<int>(std::lround(
ratio * (kWeakBoostIterationsMax - kWeakBoostIterationsMin))));
return true;
}
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex)
{
const SignalRef& ref = layout.rowRefs[rowIndex];
const auto findItem = [&]() -> const Lane::MeanFilterItem*
{
return meanMode ? tabLane.FindMeanFilterItem(ref)
: (boostMode ? tabLane.FindWeakBoostItem(ref)
: tabLane.FindEdgeCenterItem(ref));
};
// 第一排右侧的复选框 → 切换该信号的预览勾选状态。
if (layout.rowChecks[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowChecks[rowIndex], fx, fy))
{
if (meanMode)
{
ToggleLaneMeanFilterSignal(index, ref);
}
else if (boostMode)
{
ToggleLaneWeakBoostSignal(index, ref);
}
else
{
ToggleLaneEdgeCenterSignal(index, ref);
}
m_meanCurveCache.clear();
m_edgeCenterCountCache.clear();
m_weakBoostCurveCache.clear();
// 勾选变化只影响该网格的内容:交给 `m_sceneDerived` 快照驱动局部重画,
// 这里**不**整体作废场景缓存(否则每次勾选都要重绘全部网格)。
m_dragMode = PlotDragMode::NONE;
return true;
}
// 第二排的两个输入框 → 就地编辑该信号的均值点数 X / 迭代次数 N
//(弱波增强面板没有参数输入框,等级由滑块统一控制)。
if (layout.rowPoints[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowPoints[rowIndex], fx, fy))
{
const Lane::MeanFilterItem* item = findItem();
m_editMeanRef = ref;
BeginEdit(meanMode ? PlotEditTarget::MEAN_FILTER_POINTS
: PlotEditTarget::EDGE_CENTER_POINTS,
index, std::to_wstring(item != nullptr ? item->points : 5),
layout.rowPoints[rowIndex]);
return true;
}
if (layout.rowIterations[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowIterations[rowIndex], fx, fy))
{
const Lane::MeanFilterItem* item = findItem();
m_editMeanRef = ref;
BeginEdit(meanMode ? PlotEditTarget::MEAN_FILTER_ITERATIONS
: PlotEditTarget::EDGE_CENTER_ITERATIONS,
index, std::to_wstring(item != nullptr ? item->iterations : 1),
layout.rowIterations[rowIndex]);
return true;
}
}
if (boostMode && layout.button.Width > 0.0f && UiPointInRect(layout.button, fx, fy))
{
// 「弱波增强 → 构建为新信号」:与均值滤波同一套请求机制
//(等级与迭代次数属于整个网格,网格内各被勾选信号共用同一组参数)。
const Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(index)];
const int levelPercent = static_cast<int>(
std::lround(std::max(0.0, std::min(1.0, lane.weakBoostLevel)) * 100.0));
const int iterations = std::max(kWeakBoostIterationsMin,
std::min(kWeakBoostIterationsMax, lane.weakBoostIterations));
m_pendingWeakBoostItems.clear();
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || !resolved.visible)
{
continue;
}
if (lane.FindWeakBoostItem(ref) == nullptr)
{
continue;
}
WeakBoostBuild build;
build.fileIndex = ref.fileIndex;
build.signalIndex = ref.signalIndex;
build.levelPercent = levelPercent;
build.iterations = iterations;
m_pendingWeakBoostItems.push_back(build);
}
if (!m_pendingWeakBoostItems.empty())
{
SetHint(L"正在构建弱波增强信号(等级 " + std::to_wstring(levelPercent) +
L"% / " + std::to_wstring(iterations) + L" 次迭代,共 " +
std::to_wstring(m_pendingWeakBoostItems.size()) + L" 条)…");
m_pendingWeakBoostLane = index;
}
m_dragMode = PlotDragMode::NONE;
return true;
}
if (meanMode && layout.button.Width > 0.0f && UiPointInRect(layout.button, fx, fy))
{
// 「构建为新信号」:只登记请求(含每个信号自己的 X/N),
// 真正的信号构建由宿主(Application)完成。
const Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(index)];
m_pendingMeanFilterItems.clear();
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || !resolved.visible)
{
continue;
}
const Lane::MeanFilterItem* item = lane.FindMeanFilterItem(ref);
if (item == nullptr)
{
continue;
}
MeanFilterBuild build;
build.fileIndex = ref.fileIndex;
build.signalIndex = ref.signalIndex;
build.points = item->points;
build.iterations = item->iterations;
m_pendingMeanFilterItems.push_back(build);
}
if (!m_pendingMeanFilterItems.empty())
{
const MeanFilterBuild& first = m_pendingMeanFilterItems.front();
SetHint(L"正在构建均值滤波信号(" + std::to_wstring(first.points) +
L" 点 " + std::to_wstring(first.iterations) + L" 次迭代,共 " +
std::to_wstring(m_pendingMeanFilterItems.size()) + L" 条)…");
m_pendingMeanFilterLane = index;
}
m_dragMode = PlotDragMode::NONE;
return true;
}
}
if (UiPointInRect(layout.title, fx, fy))
{
m_dragMode = PlotDragMode::EDGE_PANEL_MOVE;
m_edgePanelDragLane = index;
m_edgePanelDragKind = kind;
m_edgePanelDragOffset.X = fx - layout.panel.X;
m_edgePanelDragOffset.Y = fy - layout.panel.Y;
return true;
}
// 面板主体:消费该次点击,避免误触发底层的平移/选区/信号拖拽。
m_dragMode = PlotDragMode::NONE;
return true;
}
if (handledPanel)
{
break;
}
}
// 「边沿计数」横向光标:上下拖动改变幅值。
{
int edgeLane = -1;
if (IsOnEdgeCursorLine(x, y, edgeLane))
{
m_dragMode = PlotDragMode::EDGE_MOVE;
m_edgeDragLane = edgeLane;
return true;
}
}
if (!UiPointInRect(m_plotRect, fx, fy))
{
m_dragMode = PlotDragMode::NONE;
return false;
}
// 点击图例条目等价于在左侧列表中选中该信号(样式区随之切换目标)。
// **必须在选区放大判定之前**:选区放大模式只改变拖拽语义,
// 不应影响图例的悬停与点选(否则用户在选区放大模式下无法切换信号)。
{
const SignalRef legendHit = LegendAtPoint(x, y);
if (legendHit.IsValid())
{
SelectSignal(legendHit);
m_dragMode = PlotDragMode::NONE;
return true;
}
}
const PlotZoomMode mode = Tab().ZoomMode();
if (mode == PlotZoomMode::SELECT)
{
// 选区放大:轴向由鼠标移动方向决定,因此按下时先记录网格并置为未定。
// 按下本身**不显示提示**(拖动开始后才显示"右键取消选区")。
m_dragLane = LaneAtY(y);
m_rubberAxis = PlotRubberAxis::UNDECIDED;
m_dragMode = PlotDragMode::RUBBER_SELECT;
return true;
}
// 常规模式下:先检测是否落在信号曲线上;只有命中提示时才允许拖拽该信号。
const SignalRef hit = HitTestSignal(x, y);
if (hit.IsValid())
{
m_dragSignal = hit;
m_dragTargetLane = Tab().LaneOfSignal(hit);
SelectSignal(hit);
m_dragMouseX = x;
m_dragMouseY = y;
m_dragMode = PlotDragMode::MOVE_SIGNAL;
return true;
}
m_dragLane = LaneAtY(y);
m_dragMode = PlotDragMode::PAN;
return true;
}
bool PlotView::OnMButtonDown(int x, int y)
{
if (!HasDrawableContent())
{
return false;
}
// 中键:无论当前是否处于选区放大模式,都直接进入自由平移
// (不改变选区放大模式本身,松手后仍可继续框选)。
m_dragStartX = x;
m_dragStartY = y;
m_dragLastX = x;
m_dragLastY = y;
m_dragMoved = false;
m_rubberRect = gd::RectF();
m_rubberAxis = PlotRubberAxis::UNDECIDED;
m_cursorGrabOffset = 0;
m_edgeDragLane = -1;
m_edgePanelDragLane = -1;
m_edgePanelDragKind = EdgePanelKind::COUNT;
m_boostDragLane = -1;
SetHint(std::wstring());
m_dragLane = LaneAtY(y);
m_dragMode = PlotDragMode::PAN;
return true;
}
bool PlotView::OnMButtonUp()
{
const bool changed = (m_dragMode == PlotDragMode::PAN);
if (changed)
{
m_dragMode = PlotDragMode::NONE;
m_dragLane = -1;
m_dragMoved = false;
ReleaseHint();
}
return changed;
}
bool PlotView::OnLButtonUp(int x, int y)
{
bool changed = (m_dragMode != PlotDragMode::NONE);
// 实验性功能面板的 ✕:只有"按下后仍在同一个 ✕ 上抬起"才真正关闭该功能。
if (m_pendingPanelClose)
{
const int lane = m_pendingPanelCloseLane;
const EdgePanelKind kind = m_pendingPanelCloseKind;
m_pendingPanelClose = false;
m_pendingPanelCloseLane = -1;
if (lane >= 0 && lane < static_cast<int>(m_lanes.size()))
{
const EdgePanelLayout layout = EdgePanelLayoutFor(lane, kind);
if (layout.close.Width > 0.0f &&
UiPointInRect(layout.close, static_cast<float>(x), static_cast<float>(y)))
{
switch (kind)
{
case EdgePanelKind::CENTER:
SetLaneEdgeCenterCount(lane, false);
break;
case EdgePanelKind::MEAN:
SetLaneMeanFilter(lane, false);
break;
case EdgePanelKind::BOOST:
SetLaneWeakBoost(lane, false);
break;
case EdgePanelKind::COUNT:
default:
SetLaneEdgeCount(lane, false);
break;
}
changed = true;
}
}
}
switch (m_dragMode)
{
case PlotDragMode::RUBBER_SELECT:
{
// 按拖拽方向应用对应的缩放(横向 → 时间轴;纵向 → 当前网格幅值轴)。
if (m_rubberAxis == PlotRubberAxis::TIME)
{
if (m_dragMoved &&
ApplyTimeSelection(m_rubberRect.X, m_rubberRect.X + m_rubberRect.Width))
{
changed = true;
}
}
else if (m_rubberAxis == PlotRubberAxis::VALUE)
{
if (m_dragMoved &&
ApplyValueSelection(m_dragLane, m_rubberRect.Y, m_rubberRect.Y + m_rubberRect.Height))
{
changed = true;
}
}
break;
}
case PlotDragMode::CURSOR_MOVE:
m_cursorHover = true;
break;
case PlotDragMode::CURSOR_SPAN_MOVE:
break;
case PlotDragMode::LANE_REORDER:
// 网格次序拖拽结束:真正重排并重编号(GX 徽标随之同步)。
FinishLaneReorder();
changed = true;
break;
case PlotDragMode::MOVE_SIGNAL:
{
const int target = LaneAtY(y);
const int source = (m_tab != nullptr) ? m_tab->LaneOfSignal(m_dragSignal) : -1;
if (m_dataSet != nullptr && m_tab != nullptr && m_dragSignal.IsValid() && target >= 0 &&
target != source && m_dragMoved)
{
m_tab->MoveSignalToLane(*m_dataSet, m_dragSignal, target);
changed = true;
}
m_dragSignal = SignalRef();
m_dragTargetLane = -1;
break;
}
default:
break;
}
m_dragMode = PlotDragMode::NONE;
m_rubberAxis = PlotRubberAxis::UNDECIDED;
m_dragLane = -1;
m_dragMoved = false;
m_rubberRect = gd::RectF();
m_cursorGrabOffset = 0;
m_edgeDragLane = -1;
m_edgePanelDragLane = -1;
m_edgePanelDragKind = EdgePanelKind::COUNT;
m_boostDragLane = -1;
// 松开左键:粘滞提示结束计时,开始"停留后淡出"。
ReleaseHint();
return changed;
}
bool PlotView::OnMouseWheel(int delta, bool shift)
{
if (!HasDrawableContent() || delta == 0)
{
return false;
}
const double notches = static_cast<double>(delta) / 120.0;
// 滚轮不再承担缩放:默认纵向滚动网格,按住 Shift 时水平平移时间轴。
if (shift)
{
const double span = Tab().ViewMax() - Tab().ViewMin();
const double sweep = -notches * span * 0.15;
Tab().ViewMin() += sweep;
Tab().ViewMax() += sweep;
ClampTimeView();
return true;
}
if (m_plotRect.Height <= 0.0f || m_laneContentHeight <= m_plotRect.Height)
{
return false;
}
Tab().LaneScroll() -= static_cast<float>(notches * 48.0 * m_scale);
BuildLaneVisuals();
return true;
}
bool PlotView::OnDoubleClick(int x, int y)
{
if (!UiPointInRect(m_area, static_cast<float>(x), static_cast<float>(y)))
{
return false;
}
// 双光标差值标签:双击进入差值编辑(输入值的量级始终为秒)。
// 注意:差值标签在"两光标过近"时会落到主时间轴内部,因此必须先于区域判定。
if (IsOnCursorSpanLabel(x, y))
{
return BeginEdit(PlotEditTarget::CURSOR_SPAN, -1, FormatDurationSeconds(CursorSpan()));
}
if (!UiPointInRect(m_area, static_cast<float>(x), static_cast<float>(y)))
{
return false;
}
// 命中光标时间读数标签:编辑该条光标的时间(优先于光标线,避免两条过近时选错)。
{
int labelWhich = 0;
if (IsOnCursorTimeLabel(x, y, labelWhich))
{
m_editCursor = labelWhich;
m_activeCursor = labelWhich;
const double time = (labelWhich == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime();
return BeginEdit(PlotEditTarget::CURSOR_TIME, -1, FormatValue(time));
}
}
// 命中时间光标:进入精确时间输入(交互与线宽输入框一致)。
{
int cursorWhich = 0;
if (IsOnCursorLine(x, cursorWhich))
{
m_editCursor = (cursorWhich == 2) ? 2 : 1;
m_activeCursor = m_editCursor;
const double time =
(m_editCursor == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime();
return BeginEdit(PlotEditTarget::CURSOR_TIME, -1, FormatValue(time));
}
}
// 命中图例条目:进入信号改名(与左侧列表双击改名的交互一致)。
const SignalRef legendHit = LegendAtPoint(x, y);
if (legendHit.IsValid())
{
const ResolvedSignal resolved = ResolveSignal(legendHit);
if (resolved.data != nullptr)
{
SelectSignal(legendHit);
m_editSignal = legendHit;
// 先把输入框位置落在被双击的条目上,再进入编辑态。
for (const LegendHit& hit : m_legendHits)
{
if (hit.ref == legendHit)
{
m_editFieldRect = hit.rect;
break;
}
}
return BeginEdit(PlotEditTarget::SIGNAL_NAME, -1, resolved.data->Title());
}
}
// 其它位置:回到初始状态(自适应 + 退出缩放模式)。
FitAll();
return true;
}
bool PlotView::OnMouseLeave()
{
bool changed = false;
if (m_hoverLane != -1)
{
m_hoverLane = -1;
changed = true;
}
if (m_hoverSignal.IsValid())
{
m_hoverSignal = SignalRef();
changed = true;
}
// 图例悬停半高亮同样随鼠标离开而消失(否则右键弹菜单/移出绘图区后残留在原地)。
if (m_hoverLegend.IsValid())
{
m_hoverLegend = SignalRef();
changed = true;
}
if (m_cursorHover)
{
m_cursorHover = false;
changed = true;
}
if (m_hoverCursor != 0 && m_dragMode != PlotDragMode::CURSOR_MOVE)
{
m_hoverCursor = 0;
changed = true;
}
if (m_spanHover)
{
m_spanHover = false;
changed = true;
}
return changed;
}
const PlotView::PolylineCacheEntry* PlotView::AcquirePolyline(const SignalRef& ref,
const ResolvedSignal& resolved,
const LaneVisual& lane) const
{
if (resolved.data == nullptr || resolved.file == nullptr)
{
return nullptr;
}
// 键 = 构建折线时实际读取的全部输入:
// 信号(引用 + 文件内容 + 采样点数)、时间轴显示范围、绘图区横向几何、网格纵向几何。
// 这些值全部相同时,BuildPolyline 的输出必然完全一致,可直接复用。
const std::size_t sampleCount = std::min(resolved.file->time.size(), resolved.data->values.size());
const double viewMin = Tab().ViewMin();
const double viewMax = Tab().ViewMax();
for (const PolylineCacheEntry& entry : m_polylineCache)
{
if (entry.ref == ref && entry.file == resolved.file && 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)
{
return &entry;
}
}
// 未命中:重建并写入缓存(deque 保证已有元素的引用在追加后依然有效)。
// 缓存只用于消除重复计算,容量上限约等于“网格数 × 每网格信号数”的两倍。
if (m_polylineCache.size() >= kPolylineCacheLimit)
{
m_polylineCache.clear();
}
m_polylineCache.push_back(PolylineCacheEntry());
PolylineCacheEntry& entry = m_polylineCache.back();
entry.ref = ref;
entry.file = resolved.file;
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;
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.file == nullptr)
{
return;
}
const std::vector<double>& time = resolved.file->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();
std::size_t begin = 0;
std::size_t end = count;
if (resolved.file->timeMonotonic)
{
const std::vector<double>::const_iterator first = std::lower_bound(time.begin(), time.end(), viewMin);
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(), viewMax);
end = static_cast<std::size_t>(last - time.begin());
if (end < count)
{
++end;
}
}
if (end <= begin)
{
return;
}
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);
// 采样点密度远超像素分辨率时改用“像素列上下包络”抽稀:
// 每个像素列只保留最小/最大两个顶点(按出现先后排序),
// 既不会漏掉尖峰(等间隔抽样会整段跳过),又把顶点数压到像素量级。
if (visibleCount > budget && resolved.file->timeMonotonic && m_plotRect.Width > 1.0f)
{
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;
const bool hasCoarse =
(coarseStride > 0) && !coarseMin.empty() && (coarseMin.size() == coarseMax.size());
bool inSegment = false;
// 稠密包络带:逐列记录上/下包络,收段时拼成「上包络正向 + 下包络反向」的
// 闭合多边形。绘制时一次 FillPath 即可,实测比逐段描边快约 8 倍
// (1224 列:14.3ms → 1.7ms),且**保留抗锯齿**,
// 因此静态帧与拖拽帧的观感完全一致(不是"交互降级")。
bandMode = true;
const float strokeHalf =
std::max(1.0f, resolved.style->lineWidth) * m_scale * 0.5f;
std::vector<BandColumn> segmentColumns;
const auto flushBandSegment = [&band, &bandBreaks, &segmentColumns, &bandSpiky]()
{
bool spiky = false;
EmitEnvelopeBand(segmentColumns, band, bandBreaks, spiky);
bandSpiky = bandSpiky || spiky;
segmentColumns.clear();
};
std::size_t searchFrom = begin;
for (std::size_t column = 0; column < columns; ++column)
{
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));
// 列边界随 column 单调递增,从上一列的位置继续二分,
// 把每次搜索的范围压到剩余区间,避免 2400 次全区间二分带来的缓存缺失。
const std::size_t i0 = static_cast<std::size_t>(
std::lower_bound(time.begin() + static_cast<std::ptrdiff_t>(searchFrom), rangeEnd, t0) -
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, t1) -
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;
// (一)落在本列区间内的采样点。
if (i1 > i0 && hasCoarse && (i1 - i0) >= 2 * coarseStride)
{
const std::size_t firstBucket = i0 / coarseStride;
const std::size_t lastBucket =
std::min((i1 - 1) / coarseStride, coarseMin.size() - 1);
for (std::size_t bucket = firstBucket; bucket <= lastBucket; ++bucket)
{
const double bucketLow = coarseMin[bucket];
const double bucketHigh = coarseMax[bucket];
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 = i0; index < i1; ++index)
{
const double value = 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, i0, t0, boundary))
{
if (boundary < low)
{
low = boundary;
lowOrder = (i0 > 0) ? (i0 - 1) : 0;
}
if (boundary > high)
{
high = boundary;
highOrder = (i0 > 0) ? (i0 - 1) : 0;
}
}
if (PolylineValueAt(time, values, count, i1, t1, boundary))
{
if (boundary < low)
{
low = boundary;
lowOrder = i1;
}
if (boundary > high)
{
high = boundary;
highOrder = i1;
}
}
if (!(low <= high))
{
// 本列没有任何有效取值:折线尚未开始、已经结束,或该处数据缺失。
inSegment = false;
flushBandSegment();
continue;
}
const float x = plotLeft + (static_cast<float>(column) + 0.5f) * columnWidth;
const float yTop =
static_cast<float>(ClampDouble(ValueToY(high, lane), limitTop, limitBottom));
const float yBottom =
static_cast<float>(ClampDouble(ValueToY(low, lane), limitTop, limitBottom));
// 包络带每列都记录(上下各外扩半个线宽,保证细波动也有可见粗细)。
BandColumn bandColumn;
bandColumn.x = x;
bandColumn.top = yTop - strokeHalf;
bandColumn.bottom = yBottom + strokeHalf;
segmentColumns.push_back(bandColumn);
if (yBottom - yTop < kEnvelopeMinPixelSpan)
{
// 波动不足半个像素:只留一个顶点,避免无意义的重复顶点。
if (!inSegment)
{
breaks.push_back(points.size());
inSegment = true;
}
points.push_back(gd::PointF(x, yTop));
}
else
{
if (!inSegment)
{
breaks.push_back(points.size());
inSegment = true;
}
// 按采样点出现的先后顺序连接上下包络,保持走线方向一致。
if (lowOrder <= highOrder)
{
points.push_back(gd::PointF(x, yBottom));
points.push_back(gd::PointF(x, yTop));
}
else
{
points.push_back(gd::PointF(x, yTop));
points.push_back(gd::PointF(x, yBottom));
}
}
if (broken)
{
inSegment = false;
flushBandSegment();
}
}
flushBandSegment();
return;
}
std::size_t stride = 1;
if (visibleCount > budget)
{
stride = (visibleCount + budget - 1) / budget;
}
bool inSegment = false;
for (std::size_t index = begin; index < end; index += stride)
{
const double t = time[index];
const double value = values[index];
if (!std::isfinite(t) || !std::isfinite(value))
{
inSegment = false;
continue;
}
const float x = TimeToX(t);
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"打开 CSV 文件以查看仿真数据波形";
}
else
{
message = L"请在左侧勾选需要查看的信号(可同时加载多个文件进行对比)";
}
UiText(g, message, m_fonts->ui, m_area, m_theme->textMuted, TextAlign::CENTER);
}
void PlotView::Paint(gd::Graphics& g)
{
if (m_theme == nullptr || m_fonts == nullptr)
{
return;
}
BuildLaneVisuals();
// 双缓冲:清掉上一帧钉住的曲线条目(本帧会按需重新钉住),并清理已被更新条目覆盖 /
// 超出内存预算的旧条目。此刻没有任何条目被钉住,回收是安全的。
m_curvePins.clear();
{
std::lock_guard<std::mutex> lock(m_curveMutex);
PruneCurveEntriesLocked();
}
// 后台算好了新曲线:静态场景缓存里仍是旧曲线(或还没有曲线),作废一次以纳入最新结果。
if (m_curveSceneDirty.exchange(false, std::memory_order_relaxed))
{
InvalidateSceneCache();
}
if (m_lanes.empty())
{
InvalidateSceneCache();
PaintEmptyState(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_dragMode == PlotDragMode::BOOST_LEVEL) ||
(m_dragMode == PlotDragMode::BOOST_ITERATIONS);
m_fastFrame = sceneChangingDrag;
// 贴图之后、静态场景之外的全部内容(随鼠标变化或位于最上层)。
// 三个分支(完全命中 / 局部重画 / 整幅重绘)共用同一段,保证观感完全一致。
const auto paintOverlays = [this, &g]()
{
PaintHoverHighlight(g);
PaintLegendHighlight(g);
PaintRubberBand(g);
PaintCursor(g, m_lanes);
PaintEdgeCursor(g, m_lanes);
PaintSignalInteraction(g);
PaintHint(g);
// 网格 Y 轴上下限的输入框绘制在最上层。
if (m_editTarget == PlotEditTarget::LANE_Y_MAX ||
m_editTarget == PlotEditTarget::LANE_Y_MIN)
{
PaintEditField(g);
}
PaintScrollBar(g);
// 绘图区外框(比网格内部分界线更重,形成明确整体边界)。
// 用 GDI 画 4 条线:GDI+ 的 DrawRectangle 带抗锯齿时单帧要花掉 1ms 以上。
{
const float left = m_plotRect.X;
const float top = m_plotRect.Y;
const float right = m_plotRect.X + m_plotRect.Width;
const float bottom = m_plotRect.Y + m_plotRect.Height;
const float border[16] = {left, top, right, top, right, top, right, bottom,
right, bottom, left, bottom, left, bottom, left, top};
UiDrawSolidLines(g, gd::RectF(left - 2.0f, top - 2.0f, m_plotRect.Width + 4.0f,
m_plotRect.Height + 4.0f),
border, 4, m_theme->axisLine | 0xFF000000u, 2);
}
};
// 网格次序拖拽:静态场景缓存仍然有效,但各网格要按位移重新"摆放"。
// 这里逐网格把缓存中的横向条带平移贴回(几十微秒),
// 因此拖动百万级采样的网格时依然流畅,且被拖网格的波形随鼠标自由移动。
if (m_dragMode == PlotDragMode::LANE_REORDER && m_laneDragFrom >= 0 && m_sceneValid)
{
PaintLaneReorder(g);
paintOverlays();
return;
}
// 静态场景复用(不再局限于拖拽):只要视图几何、信号集合与显示样式都没变,
// "绘图区底色 + 全部网格 + 时间轴"就逐像素相同,直接贴图即可。
// 大文件下整幅重绘约 80ms,贴图约 1ms —— 鼠标悬停、拖拽选区这些
// "只改一小块叠加层"的交互因此不再重绘波形。
// 随鼠标变化的内容(悬停高亮、读数框、选区、光标、拖拽虚影、输入框)一律画在贴图之后。
if (SceneCacheMatches() && BlitSceneCache(g))
{
paintOverlays();
return;
}
// 只有"外观样式"变化(典型场景:拖动自定义颜色滑块实时调色):网格结构、视图几何、
// 波形采样都没变,只是颜色/不透明度/线宽/模式不同 —— 此时只重画受影响的网格,
// 再写回缓存。(VERY_BIG_DATA 实测:整幅重绘 30ms+,单网格重画约 4ms。)
{
std::vector<int> patchLanes;
if (FindStyleOnlyLanes(patchLanes) && BlitSceneCache(g))
{
RepaintSceneCachePatch(g, patchLanes);
paintOverlays();
return;
}
}
const double viewMin = Tab().ViewMin();
const double viewMax = Tab().ViewMax();
const double span = viewMax - viewMin;
const double timeStep = NiceStep((span > 0.0) ? span : 1.0, kTimeTickTarget);
std::vector<double> timeTicks;
BuildTicks(viewMin, viewMax, timeStep, timeTicks);
// 绘图区整体底色。
UiFillRect(g, m_area, m_theme->plotBg);
// 波形区域底色。
UiFillRect(g, m_plotRect, m_theme->cardBg);
// 图例命中矩形每帧重建,避免残留已消失的信号。
m_legendHits.clear();
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);
// 此刻画布上正好是"绘图区底色 + 全部网格 + 时间轴",尚未绘制任何随鼠标变化的内容,
// 因此是捕获静态场景缓存的最佳时点(必须在裁剪区已全部恢复之后执行)。
// ⚠️ 快速帧(交互期间,关闭了抗锯齿)**仍然捕获**(这样拖动参数滑块时能走
// "只重画受影响网格"的快路径),但打上 `m_sceneDegraded` 标记 →
// `SceneCacheMatches()` 会拒绝命中,交互结束后的下一帧必定重画全质量版本。
CaptureSceneCache(g);
m_sceneDegraded = m_fastFrame;
paintOverlays();
}
void PlotView::PaintSignalHaloFor(gd::Graphics& g, const ResolvedSignal& resolved,
const PolylineCacheEntry& cached)
{
if (resolved.style == nullptr)
{
return;
}
// 光晕一律用折线描边(与"之前"的画法一致)。曾经在包络带模式下改成"带外扩填充",
// 虽然更快但观感变了(用户反馈显示效果下降),因此恢复。
PaintSignalHalo(g, resolved, cached.points, cached.breaks);
}
void PlotView::PaintSignalHalo(gd::Graphics& g, const ResolvedSignal& resolved,
const std::vector<gd::PointF>& points,
const std::vector<std::size_t>& breaks)
{
const bool drawLine = (resolved.style->mode != DrawMode::SCATTER);
if (!drawLine || breaks.empty())
{
// 散点模式没有光晕(原实现同样只对折线绘制光晕)。
return;
}
gd::Pen halo(ToGdiColor(WithAlpha(m_theme->accent, 0x66)),
(resolved.style->lineWidth + 4.0f) * m_scale);
halo.SetLineJoin(gd::LineJoinRound);
halo.SetStartCap(gd::LineCapRound);
halo.SetEndCap(gd::LineCapRound);
for (std::size_t k = 0; k < breaks.size(); ++k)
{
const std::size_t start = breaks[k];
const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size();
if (stop <= start + 1)
{
continue;
}
g.DrawLines(&halo, points.data() + start, static_cast<INT>(stop - start));
}
}
void PlotView::PaintHoverHighlight(gd::Graphics& g)
{
if (m_tab == nullptr || m_dragMode != PlotDragMode::NONE)
{
// 拖拽过程中不显示悬停提示(拖拽中的信号光晕已绘入静态场景缓存)。
return;
}
const int saveState = g.Save();
g.SetClip(m_area, gd::CombineModeIntersect);
// 悬停网格:一层很淡的强调色叠加。为了能画在静态场景之上,
// 这里用半透明而非不透明混色,观感与原实现基本一致。
if (m_hoverLane >= 0 && m_hoverLane < static_cast<int>(m_lanes.size()))
{
const gd::RectF& laneRect = m_lanes[static_cast<std::size_t>(m_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));
}
}
// 悬停信号:外扩一圈半透明强调色作为"可拖拽"提示。
if (m_hoverSignal.IsValid())
{
const ResolvedSignal resolved = ResolveSignal(m_hoverSignal);
if (resolved.data != nullptr && resolved.style != nullptr && resolved.visible)
{
const int laneIndex = m_tab->LaneOfSignal(m_hoverSignal);
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(m_hoverSignal, resolved, lane);
if (cached != nullptr)
{
PaintSignalHaloFor(g, resolved, *cached);
}
}
}
}
g.Restore(saveState);
}
void PlotView::PaintLegendHighlight(gd::Graphics& g)
{
if (m_tab == nullptr || m_dragMode != PlotDragMode::NONE || !m_hoverLegend.IsValid())
{
return;
}
// 图例条目的底色绘制在静态场景缓存里;悬停态随鼠标变化,不能进缓存,
// 因此在贴图之后用半透明强调色在本条目范围内叠一层,形成“半高亮”提示。
const int laneIndex = m_tab->LaneOfSignal(m_hoverLegend);
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
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)
{
return;
}
const int saveState = g.Save();
g.SetClip(m_area, gd::CombineModeIntersect);
// 除了图例条目本身,还要把**对应的信号曲线**一并高亮:用户在网格图例上移动时
// 一眼就能确认这条条目对应哪条曲线(与"鼠标贴近曲线"时的光晕提示一致)。
const ResolvedSignal resolved = ResolveSignal(m_hoverLegend);
if (resolved.data != nullptr && resolved.style != nullptr && resolved.visible)
{
const LaneVisual& lane = m_lanes[static_cast<std::size_t>(laneIndex)];
if (const PolylineCacheEntry* cached = AcquirePolyline(m_hoverLegend, resolved, lane))
{
PaintSignalHaloFor(g, resolved, *cached);
}
}
for (const LegendHit& hit : m_legendHits)
{
if (hit.ref == m_hoverLegend)
{
UiFillRoundRect(g, hit.rect, 3.0f * m_scale, WithAlpha(m_theme->accent, 0x38));
break;
}
}
g.Restore(saveState);
}
PlotView::SceneCacheKey PlotView::MakeSceneCacheKey() const
{
SceneCacheKey key;
key.viewMin = Tab().ViewMin();
key.viewMax = Tab().ViewMax();
key.laneScroll = Tab().LaneScroll();
key.laneCount = static_cast<int>(m_lanes.size());
key.areaX = m_area.X;
key.areaY = m_area.Y;
key.areaW = m_area.Width;
key.areaH = m_area.Height;
key.scale = m_scale;
const SignalRef halo = SceneHaloRef();
key.haloFile = halo.fileIndex;
key.haloSignal = halo.signalIndex;
key.contentHash = SceneContentHash();
return key;
}
bool PlotView::SceneCacheMatches() const
{
if (!m_sceneValid)
{
return false;
}
// 缓存里存的是「快速帧」(关掉了抗锯齿)→ 不能直接命中:
// 必须重画一次全质量版本,否则降级的画面会一直留在屏幕上。
if (m_sceneDegraded)
{
return false;
}
const SceneCacheKey& cached = m_sceneKey;
const SceneCacheKey key = MakeSceneCacheKey();
return (key.viewMin == cached.viewMin) && (key.viewMax == cached.viewMax) &&
(key.laneScroll == cached.laneScroll) && (key.laneCount == cached.laneCount) &&
(key.areaX == cached.areaX) && (key.areaY == cached.areaY) &&
(key.areaW == cached.areaW) && (key.areaH == cached.areaH) &&
(key.scale == cached.scale) && (key.haloFile == cached.haloFile) &&
(key.haloSignal == cached.haloSignal) && (key.contentHash == cached.contentHash);
}
SignalRef PlotView::SceneHaloRef() const
{
// 只有拖拽中的信号光晕是“整个拖拽过程都不变”的,可以安全地绘入静态场景缓存;
// 悬停光晕随鼠标移动,必须画在缓存之外(见 PaintHoverHighlight)。
if (m_dragMode == PlotDragMode::MOVE_SIGNAL && m_dragSignal.IsValid())
{
return m_dragSignal;
}
return SignalRef();
}
std::uint64_t PlotView::SceneContentHash(bool includeStyles) const
{
// FNV-1a:把“视图几何之外的一切影响绘图区像素的内容”折叠成一个指纹。
// 这样颜色/线宽/透明度/样式/重命名/信号增删等变化都会自动让缓存失效,
// 不需要在各处手工调用 InvalidateSceneCache(容易漏)。
// `includeStyles == false` 时跳过逐信号的外观样式(颜色/不透明度/线宽/模式),
// 得到的就是几何指纹:它一致说明“只有外观样式变了”。
std::uint64_t hash = 1469598103934665603ull;
const auto mix = [&hash](std::uint64_t value)
{
hash ^= value;
hash *= 1099511628211ull;
};
const auto mixFloat = [&mix](float value)
{
std::uint32_t bits = 0;
std::memcpy(&bits, &value, sizeof(bits));
mix(bits);
};
const auto mixText = [&mix](const std::wstring& text)
{
mix(text.size());
for (const wchar_t character : text)
{
mix(static_cast<std::uint64_t>(character));
}
};
mixFloat(m_plotRect.X);
mixFloat(m_plotRect.Y);
mixFloat(m_plotRect.Width);
mixFloat(m_plotRect.Height);
mixFloat(m_laneContentHeight);
mix(m_scrollBarVisible ? 1ull : 0ull);
// 选中信号会改变图例条目的底色与文字颜色(绘制在缓存的静态场景里),
// 因此必须参与指纹:否则在左侧信号面板点选后图例不会高亮联动。
{
const SignalRef selected = SelectedRef();
mix(selected.IsValid() ? 1ull : 0ull);
mix(static_cast<std::uint64_t>(selected.fileIndex));
mix(static_cast<std::uint64_t>(selected.signalIndex));
}
for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex)
{
const LaneVisual& visual = m_lanes[laneIndex];
mixFloat(visual.rect.Y);
mixFloat(visual.rect.Height);
mixFloat(static_cast<float>(visual.yMin));
mixFloat(static_cast<float>(visual.yMax));
const std::vector<SignalRef>& signals = Tab().Lanes()[laneIndex].signals;
// 「边沿计数(中心轴)」「均值滤波」「弱波增强」的开关与每条被勾选信号的参数
// 会改变网格内的曲线绘制。它们**只影响网格内容**(不影响网格结构/几何),
// 因此只在 includeStyles 时计入 —— 于是拖参数滑块可以走“局部重画”快路径,
// 而不必整幅重绘(`FindStyleOnlyLanes` 用 `m_sceneDerived` 定位受影响的网格)。
if (includeStyles)
{
mix(Tab().Lanes()[laneIndex].edgeCenter ? 1ull : 0ull);
mix(static_cast<std::uint64_t>(Tab().Lanes()[laneIndex].edgeCenterItems.size()));
for (const Lane::EdgeCenterItem& item : Tab().Lanes()[laneIndex].edgeCenterItems)
{
mix(static_cast<std::uint64_t>(item.ref.fileIndex));
mix(static_cast<std::uint64_t>(item.ref.signalIndex));
mix(static_cast<std::uint64_t>(item.points));
mix(static_cast<std::uint64_t>(item.iterations));
}
mix(Tab().Lanes()[laneIndex].meanFilter ? 1ull : 0ull);
mix(static_cast<std::uint64_t>(Tab().Lanes()[laneIndex].meanFilterItems.size()));
for (const Lane::MeanFilterItem& item : Tab().Lanes()[laneIndex].meanFilterItems)
{
mix(static_cast<std::uint64_t>(item.ref.fileIndex));
mix(static_cast<std::uint64_t>(item.ref.signalIndex));
mix(static_cast<std::uint64_t>(item.points));
mix(static_cast<std::uint64_t>(item.iterations));
}
mix(Tab().Lanes()[laneIndex].weakBoost ? 1ull : 0ull);
mixFloat(static_cast<float>(Tab().Lanes()[laneIndex].weakBoostLevel));
mix(static_cast<std::uint64_t>(Tab().Lanes()[laneIndex].weakBoostIterations));
mix(static_cast<std::uint64_t>(Tab().Lanes()[laneIndex].weakBoostItems.size()));
for (const Lane::WeakBoostItem& item : Tab().Lanes()[laneIndex].weakBoostItems)
{
mix(static_cast<std::uint64_t>(item.ref.fileIndex));
mix(static_cast<std::uint64_t>(item.ref.signalIndex));
}
}
mix(signals.size());
for (const SignalRef& ref : signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
mix(static_cast<std::uint64_t>(ref.fileIndex));
mix(static_cast<std::uint64_t>(ref.signalIndex));
mix(resolved.visible ? 1ull : 0ull);
if (resolved.data == nullptr || resolved.style == nullptr)
{
continue;
}
// 以下四项是“只影响外观”的样式:仅在 includeStyles 时计入指纹。
// (不参与时得到的就是 SceneGeometryHash:它一致说明只有外观变了,
// 于是可以只重画颜色变了的网格,而不必整幅重绘。)
if (includeStyles)
{
mix(static_cast<std::uint64_t>(resolved.style->color));
mixFloat(resolved.style->opacity);
mixFloat(resolved.style->lineWidth);
mix(static_cast<std::uint64_t>(resolved.style->mode));
}
// 图例文案参与绘制,重命名必须让缓存失效。
mixText(resolved.data->Title());
if (resolved.file != nullptr)
{
mixText(resolved.file->Title());
}
}
}
return hash;
}
std::uint64_t PlotView::SceneGeometryHash() const
{
return SceneContentHash(false);
}
void PlotView::CollectSceneStyles(std::vector<std::vector<SceneStyleEntry>>& out) const
{
out.clear();
out.resize(m_lanes.size());
for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex)
{
const std::vector<SignalRef>& signals = Tab().Lanes()[laneIndex].signals;
std::vector<SceneStyleEntry>& lane = out[laneIndex];
lane.reserve(signals.size());
for (const SignalRef& ref : signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
SceneStyleEntry entry;
if (resolved.style != nullptr)
{
entry.color = resolved.style->color;
entry.opacity = resolved.style->opacity;
entry.lineWidth = resolved.style->lineWidth;
entry.mode = static_cast<int>(resolved.style->mode);
}
lane.push_back(entry);
}
}
}
bool PlotView::FindStyleOnlyLanes(std::vector<int>& lanes) const
{
lanes.clear();
if (!m_sceneValid || m_sceneGeometryHash != SceneGeometryHash())
{
// 网格结构 / 视图几何 / 信号集合 / 图例文案有变化:必须整幅重绘。
return false;
}
std::vector<std::vector<SceneStyleEntry>> current;
CollectSceneStyles(current);
if (current.size() != m_sceneStyles.size())
{
return false;
}
// 叠加预览曲线(均值滤波 / 中心轴 / 弱波增强)的参数变化同样只需局部重画。
std::vector<SceneDerivedEntry> currentDerived;
CollectSceneDerived(currentDerived);
const bool derivedComparable = (currentDerived.size() == m_sceneDerived.size());
for (std::size_t laneIndex = 0; laneIndex < current.size(); ++laneIndex)
{
const std::vector<SceneStyleEntry>& now = current[laneIndex];
const std::vector<SceneStyleEntry>& before = m_sceneStyles[laneIndex];
if (now.size() != before.size())
{
return false;
}
bool differs = false;
for (std::size_t index = 0; index < now.size() && !differs; ++index)
{
differs = (now[index].color != before[index].color) ||
(now[index].opacity != before[index].opacity) ||
(now[index].lineWidth != before[index].lineWidth) ||
(now[index].mode != before[index].mode);
}
if (!differs && derivedComparable)
{
const SceneDerivedEntry& nowDerived = currentDerived[laneIndex];
const SceneDerivedEntry& beforeDerived = m_sceneDerived[laneIndex];
differs = (nowDerived.edgeCenter != beforeDerived.edgeCenter) ||
(nowDerived.meanFilter != beforeDerived.meanFilter) ||
(nowDerived.weakBoost != beforeDerived.weakBoost) ||
(nowDerived.weakBoostLevelPercent != beforeDerived.weakBoostLevelPercent) ||
(nowDerived.weakBoostIterations != beforeDerived.weakBoostIterations) ||
(nowDerived.edgeCenterItems != beforeDerived.edgeCenterItems) ||
(nowDerived.meanFilterItems != beforeDerived.meanFilterItems) ||
(nowDerived.weakBoostItems != beforeDerived.weakBoostItems);
}
if (differs)
{
lanes.push_back(static_cast<int>(laneIndex));
}
}
return !lanes.empty();
}
void PlotView::CollectSceneDerived(std::vector<SceneDerivedEntry>& out) const
{
out.clear();
out.resize(m_lanes.size());
for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex)
{
const Lane& lane = Tab().Lanes()[laneIndex];
SceneDerivedEntry entry;
entry.edgeCenter = lane.edgeCenter ? 1 : 0;
entry.meanFilter = lane.meanFilter ? 1 : 0;
entry.weakBoost = lane.weakBoost ? 1 : 0;
entry.weakBoostLevelPercent = static_cast<int>(
std::lround(std::max(0.0, std::min(1.0, lane.weakBoostLevel)) * 100.0));
entry.weakBoostIterations = lane.weakBoostIterations;
const auto flatten = [](const auto& items, std::vector<int>& target)
{
target.clear();
target.reserve(items.size() * 4);
for (const auto& item : items)
{
target.push_back(item.ref.fileIndex);
target.push_back(item.ref.signalIndex);
target.push_back(item.points);
target.push_back(item.iterations);
}
};
flatten(lane.edgeCenterItems, entry.edgeCenterItems);
flatten(lane.meanFilterItems, entry.meanFilterItems);
flatten(lane.weakBoostItems, entry.weakBoostItems);
out[laneIndex] = std::move(entry);
}
}
void PlotView::RepaintSceneCachePatch(gd::Graphics& g, const std::vector<int>& lanes)
{
if (lanes.empty() || m_sceneDc == nullptr)
{
return;
}
// 重画范围 = 受影响网格与绘图区的交集(并且不超出缓存区域)。
float top = 1.0e9f;
float bottom = -1.0e9f;
for (const int laneIndex : lanes)
{
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
continue;
}
const gd::RectF& rect = m_lanes[static_cast<std::size_t>(laneIndex)].rect;
top = std::min(top, rect.Y);
bottom = std::max(bottom, rect.Y + rect.Height);
}
if (bottom <= top)
{
return;
}
top = std::max(top, m_plotRect.Y);
bottom = std::min(bottom, m_plotRect.Y + m_plotRect.Height);
if (bottom <= top)
{
return;
}
// ⚠️ 补丁矩形必须**向内**对齐到整数像素(ceil 上边界、floor 下边界):
// 网格矩形是浮点数,而 GDI `FillRect`/`BitBlt` 会按 floor/ceil 向外取整,
// "向外"会多擦掉/多写回一整行,那一行属于相邻网格(例如它的分界线),
// 于是补丁边界上会留下一像素接缝。向内对齐后,边界外的行一律由缓存提供
// (缓存内容与整幅重绘逐像素一致,已由对照帧验证)。
const int x0 = std::max(static_cast<int>(std::ceil(m_area.X)), static_cast<int>(std::ceil(m_area.X)));
const int y0 = std::max(static_cast<int>(std::ceil(top)), static_cast<int>(std::ceil(m_area.Y)));
const int x1 = std::min(static_cast<int>(std::floor(m_area.X + m_area.Width)),
static_cast<int>(std::floor(m_area.X + m_area.Width)));
const int y1 = std::min(static_cast<int>(std::floor(bottom)),
static_cast<int>(std::floor(m_area.Y + m_area.Height)));
if (x1 <= x0 || y1 <= y0)
{
return;
}
const gd::RectF patch(static_cast<float>(x0), static_cast<float>(y0),
static_cast<float>(x1 - x0), static_cast<float>(y1 - y0));
// 补丁区域必须被**完整**重画:凡是与它相交的网格都要重画一遍
// (补丁已向内对齐,正常只会命中受影响的那几个网格)。
std::vector<int> repaint;
for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex)
{
const gd::RectF& rect = m_lanes[laneIndex].rect;
if (rect.Y < static_cast<float>(y1) && rect.Y + rect.Height > static_cast<float>(y0))
{
repaint.push_back(static_cast<int>(laneIndex));
}
}
if (repaint.empty())
{
return;
}
// 与整幅重绘保持完全相同的绘制顺序:底色 → 逐网格内容(PaintLane 会与当前裁剪区求交)。
{
const int saveState = g.Save();
g.SetClip(patch, gd::CombineModeIntersect);
// ⚠️ 底色填充必须**按补丁矩形本身**填充,不能用 m_area / m_plotRect:
// `UiFillRect` 对大面积不透明矩形会走 GDI `FillRect` 快路径,而 GDI 绘制
// **不受 GDI+ 裁剪区约束**,会把已经贴好的缓存内容整片覆盖掉。
UiFillRect(g, patch, m_theme->plotBg);
const float cardTop = std::max(patch.Y, m_plotRect.Y);
const float cardBottom = std::min(patch.Y + patch.Height, m_plotRect.Y + m_plotRect.Height);
if (cardBottom > cardTop)
{
UiFillRect(g, gd::RectF(patch.X, cardTop, patch.Width, cardBottom - cardTop),
m_theme->cardBg);
}
const double viewMin = Tab().ViewMin();
const double viewMax = Tab().ViewMax();
const double span = viewMax - viewMin;
const double timeStep = NiceStep((span > 0.0) ? span : 1.0, kTimeTickTarget);
std::vector<double> timeTicks;
BuildTicks(viewMin, viewMax, timeStep, timeTicks);
for (const int laneIndex : repaint)
{
if (laneIndex >= 0 && laneIndex < static_cast<int>(m_lanes.size()))
{
PaintLane(g, laneIndex, m_lanes[static_cast<std::size_t>(laneIndex)], timeTicks);
}
}
g.Restore(saveState);
}
// 把重画结果写回缓存位图,使缓存与当前样式一致(下一帧即回到"完全命中"快路径)。
{
HDC target = g.GetHDC();
if (target != nullptr)
{
BitBlt(m_sceneDc, x0 - m_sceneX, y0 - m_sceneY, x1 - x0, y1 - y0, target, x0, y0, SRCCOPY);
g.ReleaseHDC(target);
}
}
m_sceneKey = MakeSceneCacheKey();
m_sceneGeometryHash = SceneGeometryHash();
CollectSceneStyles(m_sceneStyles);
CollectSceneDerived(m_sceneDerived);
m_sceneDegraded = m_sceneDegraded || m_fastFrame;
}
gd::RectF PlotView::LaneBadgeRect(int laneIndex) const
{
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
return gd::RectF();
}
const LaneVisual& lane = m_lanes[static_cast<std::size_t>(laneIndex)];
const gd::RectF legendBand(lane.rect.X + kLegendPad * m_scale, lane.rect.Y + kLegendPad * m_scale,
lane.rect.Width, kLegendHeight * m_scale);
// 网格被滚动出可视区时没有徽标可点。
const float visibleTop = std::max(lane.rect.Y, m_plotRect.Y);
const float visibleBottom =
std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height);
if (!IsFullyVisible(legendBand, visibleTop, visibleBottom))
{
return gd::RectF();
}
const float height = kLegendHeight * m_scale;
const float badgeHeight = kLaneBadgeHeight * m_scale;
return gd::RectF(legendBand.X, legendBand.Y + (height - badgeHeight) * 0.5f,
kLaneBadgeWidth * m_scale, badgeHeight);
}
int PlotView::LaneBadgeAtPoint(int x, int y) const
{
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
for (std::size_t index = 0; index < m_lanes.size(); ++index)
{
const gd::RectF badge = LaneBadgeRect(static_cast<int>(index));
if (badge.Width > 0.0f && UiPointInRect(badge, fx, fy))
{
return static_cast<int>(index);
}
}
return -1;
}
float PlotView::LaneDragOffset(int laneIndex) const
{
if (m_dragMode != PlotDragMode::LANE_REORDER || m_laneDragFrom < 0 ||
laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
return 0.0f;
}
const float laneHeight = m_lanes[static_cast<std::size_t>(m_laneDragFrom)].rect.Height;
if (laneIndex == m_laneDragFrom)
{
// 被拖拽的网格自由跟随鼠标(整体绘制区域跟着走)。
const float baseTop = m_lanes[static_cast<std::size_t>(laneIndex)].rect.Y;
return static_cast<float>(m_laneDragMouseY) - m_laneDragGrabOffset - baseTop;
}
const int target = (m_laneDragTarget >= 0) ? m_laneDragTarget : m_laneDragFrom;
if (target > m_laneDragFrom && laneIndex > m_laneDragFrom && laneIndex < target)
{
// 向下拖:被跨越的网格整体上移一格让出位置(大小不变)。
// 移动到插入位置 target 的结果是 [1,2,...,A,target,...],
// 因此需要上移的是 (from, target) 区间内的网格,不含 target 本身。
return -laneHeight;
}
if (target < m_laneDragFrom && laneIndex >= target && laneIndex < m_laneDragFrom)
{
// 向上拖:中间的网格整体下移一格。
return laneHeight;
}
return 0.0f;
}
int PlotView::LaneDragTargetAt(int mouseY) const
{
if (m_lanes.empty())
{
return 0;
}
const int count = static_cast<int>(m_lanes.size());
const float laneHeight = m_lanes.front().rect.Height;
if (!(laneHeight > 0.0f))
{
return m_laneDragFrom;
}
// 以"被拖网格的中心"为判据:中心越过某条网格的中线即认为目标变为该位置。
const float centerY = static_cast<float>(mouseY) - m_laneDragGrabOffset + laneHeight * 0.5f;
const float firstTop = m_lanes.front().rect.Y;
int index = static_cast<int>(std::floor((centerY - firstTop) / laneHeight + 0.5f));
index = std::max(0, std::min(index, count));
return index;
}
void PlotView::FinishLaneReorder()
{
const int from = m_laneDragFrom;
const int target = m_laneDragTarget;
m_laneDragFrom = -1;
m_laneDragTarget = -1;
m_laneDragGrabOffset = 0.0f;
m_dragMode = PlotDragMode::NONE;
if (m_tab == nullptr || from < 0 || target < 0)
{
return;
}
if (!m_tab->MoveLane(from, target))
{
return;
}
// 网格次序变化 → 几何、派生缓存与静态场景缓存全部失效。
BuildLaneVisuals();
InvalidateDerivedCaches();
}
void PlotView::PaintLaneReorder(gd::Graphics& g)
{
if (m_dragMode != PlotDragMode::LANE_REORDER || m_laneDragFrom < 0 || m_sceneDc == nullptr ||
m_lanes.empty())
{
return;
}
const float laneHeight = m_lanes.front().rect.Height;
if (!(laneHeight > 0.0f))
{
return;
}
// 1) 先把整个绘图区(含时间轴)从静态场景缓存贴回:网格次序拖拽只改变网格内部的
// 条带位置,时间轴/边框等内容必须原样保留,否则鼠标拖到工具栏或时间轴上时
// 那一区域会残留上一帧的内容。
if (!BlitSceneCache(g))
{
return;
}
// 2) 把"网格区域"整片铺成绘图区底色:让位留下的空档就是它的原色,
// 网格线/波形随后按各自位移贴回。
UiFillRect(g, m_plotRect, m_theme->cardBg);
HDC target = g.GetHDC();
if (target == nullptr)
{
return;
}
// 逐网格把缓存中该网格的横向条带按位移贴到屏幕上(非常廉价,整幅波形不必重绘)。
// 被拖拽的网格最后贴,因此始终压在最上层,形成"被拎起来"的观感。
std::vector<int> laneOrder;
for (int index = 0; index < static_cast<int>(m_lanes.size()); ++index)
{
if (index != m_laneDragFrom)
{
laneOrder.push_back(index);
}
}
if (m_laneDragFrom < static_cast<int>(m_lanes.size()))
{
laneOrder.push_back(m_laneDragFrom);
}
for (const int index : laneOrder)
{
const gd::RectF& rect = m_lanes[static_cast<std::size_t>(index)].rect;
const float offset = LaneDragOffset(index);
// 源:缓存中该网格的条带;目标:屏幕上的同一条带平移 offset。
const float sourceTop = rect.Y;
const float destTop = rect.Y + offset;
// 与绘图区求交(并夹进缓存区域),避免把波形贴到工具栏或时间轴上。
const float clipTop = std::max(std::max(destTop, m_plotRect.Y), m_plotRect.Y);
const float clipBottom =
std::min(std::min(destTop + rect.Height, m_plotRect.Y + m_plotRect.Height),
m_plotRect.Y + m_plotRect.Height);
if (clipBottom - clipTop < 1.0f)
{
continue;
}
const int x = static_cast<int>(std::floor(rect.X));
const int width = static_cast<int>(std::ceil(rect.X + rect.Width)) - x;
const int y0 = static_cast<int>(std::ceil(clipTop));
const int y1 = static_cast<int>(std::floor(clipBottom));
if (y1 <= y0 || width <= 0)
{
continue;
}
const int sourceY = static_cast<int>(std::lround(sourceTop + (static_cast<float>(y0) - destTop)));
BitBlt(target, x, y0, width, y1 - y0, m_sceneDc, x - m_sceneX, sourceY - m_sceneY, SRCCOPY);
}
g.ReleaseHDC(target);
// 3) 目标位置的高亮提示与被拖网格的轮廓都**必须夹在网格区域内**绘制:
// 鼠标拖到工具栏/时间轴上时,若不裁剪,强调色边框会画到那些区域上。
{
const int clipState = g.Save();
g.SetClip(m_plotRect, gd::CombineModeIntersect);
// 目标位置的高亮提示(吸附提示):在被拖网格将要落到的槽位上画一层强调色底 + 边框。
const int dropSlot = (m_laneDragTarget >= 0) ? m_laneDragTarget : m_laneDragFrom;
float slotTop = m_lanes.front().rect.Y + static_cast<float>(dropSlot) * laneHeight;
if (dropSlot > m_laneDragFrom)
{
slotTop -= laneHeight;
}
const gd::RectF slot(m_plotRect.X, slotTop, m_plotRect.Width, laneHeight);
if (slot.Height > 0.0f)
{
UiFillRect(g, slot, WithAlpha(m_theme->accent, 0x1E));
UiDrawRoundRect(g, gd::RectF(slot.X + 1.0f * m_scale, slot.Y + 1.0f * m_scale,
slot.Width - 2.0f * m_scale, slot.Height - 2.0f * m_scale),
3.0f * m_scale, m_theme->accent, 1.6f * m_scale);
}
// 被拖网格的轮廓:加强"整块网格被拎起"的观感。
const gd::RectF& dragged = m_lanes[static_cast<std::size_t>(m_laneDragFrom)].rect;
const float destTop = dragged.Y + LaneDragOffset(m_laneDragFrom);
const gd::RectF outline(dragged.X + 1.0f * m_scale, destTop + 1.0f * m_scale,
dragged.Width - 2.0f * m_scale, dragged.Height - 2.0f * m_scale);
UiFillRoundRect(g, gd::RectF(outline.X + 2.0f * m_scale, outline.Y + 3.0f * m_scale,
outline.Width, outline.Height),
3.0f * m_scale, 0x22000000u);
UiDrawRoundRect(g, outline, 3.0f * m_scale, m_theme->accent, 1.8f * m_scale);
g.Restore(clipState);
}
}
bool PlotView::EnsureSceneCache(int width, int height)
{
if (width <= 0 || height <= 0)
{
return false;
}
// 位图尺寸必须与当前缓存区域**严格一致**:贴图时按位图尺寸整块拷贝,
// 若沿用更大的旧位图,缩小窗口后会把旧像素画到绘图区之外。
if (m_sceneDc != nullptr && m_sceneWidth == width && m_sceneHeight == height)
{
return true;
}
ReleaseSceneCache();
// 场景位图重建 → 面板图层里缓存的"面板背后的场景"随之失效。
m_panelValid = false;
HDC screen = GetDC(nullptr);
if (screen == nullptr)
{
return false;
}
m_sceneDc = CreateCompatibleDC(screen);
m_sceneBitmap = CreateCompatibleBitmap(screen, width, height);
ReleaseDC(nullptr, screen);
if (m_sceneDc == nullptr || m_sceneBitmap == nullptr)
{
ReleaseSceneCache();
return false;
}
m_sceneOldBitmap = SelectObject(m_sceneDc, m_sceneBitmap);
m_sceneWidth = width;
m_sceneHeight = height;
return true;
}
bool PlotView::CaptureSceneCache(gd::Graphics& g)
{
// 缓存区域严格限定为绘图区本身(m_area):工具栏/标签栏/面板由宿主每帧绘制,
// 因为它们各自带有悬停态;绘图区的波形才是真正昂贵的部分(大文件约 80ms/帧)。
const int x = static_cast<int>(std::floor(m_area.X));
const int y = static_cast<int>(std::floor(m_area.Y));
const int right = static_cast<int>(std::ceil(m_area.X + m_area.Width));
const int bottom = static_cast<int>(std::ceil(m_area.Y + m_area.Height));
const int width = right - x;
const int height = bottom - y;
if (width <= 0 || height <= 0)
{
m_sceneValid = false;
return false;
}
m_sceneX = x;
m_sceneY = y;
if (!EnsureSceneCache(width, height))
{
m_sceneValid = false;
return false;
}
HDC source = g.GetHDC();
if (source == nullptr)
{
m_sceneValid = false;
return false;
}
const BOOL ok = BitBlt(m_sceneDc, 0, 0, width, height, source, m_sceneX, m_sceneY, SRCCOPY);
g.ReleaseHDC(source);
if (ok == FALSE)
{
m_sceneValid = false;
return false;
}
m_sceneKey = MakeSceneCacheKey();
m_sceneGeometryHash = SceneGeometryHash();
CollectSceneStyles(m_sceneStyles);
CollectSceneDerived(m_sceneDerived);
m_sceneDegraded = false;
m_sceneValid = true;
return true;
}
bool PlotView::BlitSceneCache(gd::Graphics& g)
{
if (!m_sceneValid || m_sceneDc == nullptr)
{
return false;
}
HDC target = g.GetHDC();
if (target == nullptr)
{
return false;
}
const BOOL ok = BitBlt(target, m_sceneX, m_sceneY, m_sceneWidth, m_sceneHeight, m_sceneDc, 0, 0,
SRCCOPY);
g.ReleaseHDC(target);
return ok != FALSE;
}
void PlotView::InvalidateSceneCache()
{
m_sceneValid = false;
m_sceneStyles.clear();
m_sceneDerived.clear();
m_sceneDegraded = false;
m_sceneGeometryHash = 0;
// 面板像素图层与场景缓存同源(面板像素里含"面板背后的场景"):
// 信号增删、重命名、参数变化等一律连带作废,避免面板里残留旧波形。
m_panelValid = false;
}
void PlotView::ReleaseSceneCache()
{
m_sceneValid = false;
if (m_sceneDc != nullptr)
{
if (m_sceneOldBitmap != nullptr)
{
SelectObject(m_sceneDc, m_sceneOldBitmap);
m_sceneOldBitmap = nullptr;
}
DeleteDC(m_sceneDc);
m_sceneDc = nullptr;
}
if (m_sceneBitmap != nullptr)
{
DeleteObject(m_sceneBitmap);
m_sceneBitmap = nullptr;
}
m_sceneWidth = 0;
m_sceneHeight = 0;
}
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)时
// 外层已经按“需要重画的网格范围”设过裁剪区,这里必须保留,否则会重画到
// 其他网格上。整幅重绘时外层没有裁剪区,等同于原来的行为。
g.SetClip(gd::RectF(m_area.X, clipTop, m_area.Width, clipBottom - clipTop),
gd::CombineModeIntersect);
// 幅值网格线(先画线,标尺稍后作为覆盖层绘制,避免被波形遮挡)。
// 刻度密度随网格高度自适应:所有网格高度相同 → 各网格的刻度数量一致,
// 不会出现“有的网格只有 1 条标签、有的有 3 条”的不一致观感。
//
// ⚠️ 网格线一律用 **GDI** 批量绘制:GDI+ 的 DrawLine 每条约 0.1–0.19ms,
// 而一张网格有几十条网格线(5 个网格就是数百条),逐条调用会让单帧多花十几毫秒
// —— 这正是"放大后拖动网格视界很卡"的主因。GDI 绘制不受 GDI+ 裁剪约束,
// 因此显式传入裁剪矩形;半透明辅助线先与网格底色混合成不透明色。
const gd::RectF gridClip = gd::RectF(m_area.X, clipTop, m_area.Width, clipBottom - clipTop);
/** 标尺数值文本的裁剪矩形(GDI 文本需要显式裁剪,否则会越过绘图区)。 */
const gd::RectF clipRectForLabels = gridClip;
const std::uint32_t minorLineColor = UiBlendOver(m_theme->cardBg, WithAlpha(m_theme->gridWeak, kMinorLineAlpha));
const std::uint32_t weakLineColor = m_theme->gridWeak | 0xFF000000u;
const std::uint32_t strongLineColor = m_theme->gridStrong | 0xFF000000u;
int yTickTarget = static_cast<int>(std::lround(lane.rect.Height / (kYTickSpacing * m_scale)));
yTickTarget = std::max(kYTickTargetMin, std::min(yTickTarget, kYTickTargetMax));
const double yStep = NiceStep(lane.yMax - lane.yMin, 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});
}
// 时间网格线(同样补充五等分估读线)。
const double timeSpan = Tab().ViewMax() - Tab().ViewMin();
const double timeMajorStep = 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 (!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 (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();
// 「边沿计数(中心轴)」开启时,为**被勾选**的信号各叠加一条均值滤波曲线
//(参数 = 该信号自己的条目:均值点数 X + 迭代次数 N,与「均值滤波」完全同一套语义)。
const bool centerOn = Tab().Lanes()[static_cast<std::size_t>(laneIndex)].edgeCenter;
// 「均值滤波」开启时,为**被勾选**的信号各叠加一条多次迭代滑动平均曲线
//(均值点数 X、迭代次数 N 由该信号自己的条目给出)。
const bool meanFilterOn = Tab().Lanes()[static_cast<std::size_t>(laneIndex)].meanFilter;
// 「弱波增强」开启时同样只为**被勾选**的信号叠加曲线
//(增强等级取整个网格共用的 `weakBoostLevel`)。
const bool weakBoostOn = Tab().Lanes()[static_cast<std::size_t>(laneIndex)].weakBoost;
for (const SignalRef& ref : laneSignals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible)
{
continue;
}
const DrawMode mode = resolved.style->mode;
// 顶点由“几何键”缓存提供:同一视图下重复绘制不再重算全量采样。
const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, lane);
if (cached == nullptr)
{
continue;
}
const std::vector<gd::PointF>& points = cached->points;
const std::vector<std::size_t>& breaks = cached->breaks;
const bool drawLine = (mode != DrawMode::SCATTER);
const bool drawScatter = (mode == DrawMode::SCATTER || mode == DrawMode::LINE_SCATTER);
// 悬停命中的信号先画一层浅色光晕,作为“可拖拽”的视觉提示。
// 这里只处理拖拽中的信号(其光晕在拖拽期间不变,可以进入静态场景缓存);
// 悬停光晕随鼠标变化,改由 PaintHoverHighlight 在缓存贴图之后绘制。
const bool highlighted = (ref == haloRef);
if (highlighted)
{
PaintSignalHaloFor(g, resolved, *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);
}
}
}
// 第二遍:把所有**派生预览曲线**统一绘制在**本网格全部原信号之上**。
// 若在同一个信号循环里就地叠加,后绘制的原信号会把先前信号的预览曲线盖住
//(用户明确要求"预览信号处于原信号上层不要被覆盖")。
for (const SignalRef& ref : laneSignals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible)
{
continue;
}
// 中心轴均值滤波曲线(「实验性功能 → 边沿计数(中心轴)」):与「均值滤波」同源,
// 只有被勾选的信号才绘制。
if (centerOn)
{
const Lane::EdgeCenterItem* centerItem =
Tab().Lanes()[static_cast<std::size_t>(laneIndex)].FindEdgeCenterItem(ref);
if (centerItem != nullptr)
{
PaintMeanFilterCurve(g, resolved, lane, centerItem->points, centerItem->iterations);
}
}
// 均值滤波预览曲线:只有被勾选的信号才绘制(参数取自它自己的条目)。
if (meanFilterOn)
{
const Lane::MeanFilterItem* item =
Tab().Lanes()[static_cast<std::size_t>(laneIndex)].FindMeanFilterItem(ref);
if (item != nullptr)
{
PaintMeanFilterCurve(g, resolved, lane, item->points, item->iterations);
}
}
// 弱波增强预览曲线:只有被勾选的信号才绘制(增强等级取自整个网格)。
if (weakBoostOn)
{
const Lane::WeakBoostItem* item =
Tab().Lanes()[static_cast<std::size_t>(laneIndex)].FindWeakBoostItem(ref);
if (item != nullptr)
{
const int levelPercent = static_cast<int>(std::lround(
std::max(0.0, std::min(1.0,
Tab().Lanes()[static_cast<std::size_t>(laneIndex)]
.weakBoostLevel)) *
100.0));
const int iterations = std::max(
kWeakBoostIterationsMin,
std::min(kWeakBoostIterationsMax,
Tab().Lanes()[static_cast<std::size_t>(laneIndex)].weakBoostIterations));
PaintWeakBoostCurve(g, resolved, lane, levelPercent, iterations);
}
}
}
// g.Restore 也会还原绘制状态,此处直接恢复图形状态即可。
g.Restore(waveSave);
}
// 网格分界线:网格之间不再留间隔,改用较重的横线明确分隔相邻网格。
const float dividerY = lane.rect.Y;
if (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));
}
// 内侧纵轴标尺作为覆盖层绘制:每个刻度数值带半透明底,保证在波形之上仍清晰可读,
// 同时只在数值背后遮挡波形,不牺牲波形本身的可见范围。
{
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);
}
if (!pills.empty())
{
UiFillRoundRects(g, pills, 3.0f * m_scale, WithAlpha(m_theme->cardBg, 0xF0));
}
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 (lane.rect.Height >= 24.0f * m_scale)
{
PaintLaneLegend(g, laneIndex, lane, clipTop, clipBottom);
}
g.Restore(saveState);
}
SignalRef PlotView::LegendAtPoint(int x, int y) const
{
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
for (const LegendHit& hit : m_legendHits)
{
if (UiPointInRect(hit.rect, fx, fy))
{
return hit.ref;
}
}
return SignalRef();
}
void PlotView::PaintLaneLegend(gd::Graphics& g, int laneIndex, const LaneVisual& lane, float visibleTop,
float visibleBottom)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return;
}
// 图例固定在网格左上角:网格被滚动出可视区时整体跳过,避免文字溢出到工具栏。
const gd::RectF legendBand(lane.rect.X + kLegendPad * m_scale,
lane.rect.Y + kLegendPad * m_scale, lane.rect.Width,
kLegendHeight * m_scale);
if (!IsFullyVisible(legendBand, visibleTop, visibleBottom))
{
return;
}
const std::vector<SignalRef>& laneSignals = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].signals;
const bool multiFile = LaneSpansMultipleFiles(laneIndex);
const float top = legendBand.Y;
const float height = kLegendHeight * m_scale;
float cursorX = legendBand.X;
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;
cursorX = badgeRect.X + badgeRect.Width + kLaneBadgeGap * m_scale;
bool anyVisible = false;
int visibleCount = 0;
int drawnCount = 0;
// 先测量图例总宽度,再用半透明底板掠开底下的网格线,提升可读性。
float legendWidth = 0.0f;
for (const SignalRef& ref : laneSignals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible)
{
continue;
}
std::wstring label = BuildLegendText(*resolved.data);
if (multiFile && resolved.file != nullptr)
{
label += L" · " + resolved.file->Title();
}
const float itemWidth = kLegendSwatchSize * m_scale + 5.0f * m_scale +
std::min(UiMeasureTextWidth(m_fonts->uiSmall, label),
kLegendMaxTextWidth * m_scale) +
kLegendItemGap * m_scale;
if (cursorX + legendWidth + itemWidth > maxX)
{
break;
}
legendWidth += itemWidth;
}
{
// 底板从编号徽标开始一直盖到最后一个图例条目(编号徽标一并被掠开底下的网格线)。
const float plateRight = cursorX + legendWidth + (legendWidth > 0.0f ? 2.0f * m_scale : 0.0f);
UiFillRoundRect(g, gd::RectF(legendBand.X - 4.0f * m_scale, top - 2.0f * m_scale,
plateRight - legendBand.X + 4.0f * m_scale, height + 4.0f * m_scale),
3.0f * m_scale, WithAlpha(m_theme->cardBg, 0xE8));
}
// 编号徽标绘制在底板之上(与信号面板的 GX 徽标使用同一套配色与圆角)。
// 拖拽重排时把被拖网格的徽标高亮,给出明确的"已抓住"反馈。
const bool badgeActive = (m_dragMode == PlotDragMode::LANE_REORDER && m_laneDragFrom == laneIndex);
const bool badgeHover = (m_dragMode == PlotDragMode::NONE && m_hoverLane == laneIndex &&
UiPointInRect(badgeRect, static_cast<float>(m_mouseX),
static_cast<float>(m_mouseY)));
UiFillRoundRect(g, badgeRect, badgeHeight * 0.5f,
(badgeActive || badgeHover) ? WithAlpha(m_theme->accent, 0x4A) : m_theme->cardBg);
UiDrawRoundRect(g, badgeRect, badgeHeight * 0.5f,
(badgeActive || badgeHover) ? m_theme->accent : m_theme->border, 1.0f);
UiText(g, L"G" + std::to_wstring(laneIndex + 1), m_fonts->monoSmall, badgeRect,
m_theme->text, TextAlign::CENTER);
// 记录本次绘制命中的条目矩形,供点击选中与双击改名使用。
for (auto iterator = m_legendHits.begin(); iterator != m_legendHits.end();)
{
if (m_tab->LaneOfSignal(iterator->ref) == laneIndex)
{
iterator = m_legendHits.erase(iterator);
}
else
{
++iterator;
}
}
// 绘制图例条目(与测量阶段保持一致的裁剪条件)。
float drawnWidth = 0.0f;
for (const SignalRef& ref : laneSignals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible)
{
continue;
}
anyVisible = true;
++visibleCount;
std::wstring label = BuildLegendText(*resolved.data);
if (multiFile && resolved.file != nullptr)
{
label += L" · " + resolved.file->Title();
}
// 文本宽度按上限夹取,超出部分用省略号(与测量阶段保持一致)。
const float maxTextWidth = kLegendMaxTextWidth * m_scale;
const float fullTextWidth = UiMeasureTextWidth(m_fonts->uiSmall, label);
const float textWidth = std::min(fullTextWidth, maxTextWidth);
const std::wstring drawLabel =
(fullTextWidth > maxTextWidth) ? UiEllipsize(label, m_fonts->uiSmall, maxTextWidth) : label;
const float itemWidth = kLegendSwatchSize * m_scale + 5.0f * m_scale + textWidth +
kLegendItemGap * m_scale;
if (drawnWidth + itemWidth > legendWidth + 0.5f)
{
break;
}
drawnWidth += itemWidth;
const float itemLeft = cursorX;
const float swatchSize = kLegendSwatchSize * m_scale;
// 选中态(与左侧列表呼应)绘制在静态场景里:选中信号参与场景指纹,
// 因此选中变化会让缓存失效并重绘。**悬停半高亮不在此处**(随鼠标变化,
// 画在缓存贴图之后,见 PaintLegendHighlight)。
const bool selected = (SelectedRef() == ref);
if (selected)
{
UiFillRoundRect(g, gd::RectF(itemLeft - 3.0f * m_scale, top - 1.0f * m_scale,
itemWidth + 2.0f * m_scale, height + 2.0f * m_scale),
3.0f * m_scale, m_theme->selection);
}
UiDrawSwatch(g, gd::RectF(cursorX, top + (height - swatchSize) * 0.5f, swatchSize, swatchSize),
resolved.style->color, resolved.style->opacity, 1.5f * m_scale, m_scale);
// 改名编辑态:在原位置绘制输入框。
LegendHit hit;
hit.ref = ref;
hit.rect = gd::RectF(itemLeft - 3.0f * m_scale, top - 2.0f * m_scale,
itemWidth + 2.0f * m_scale, height + 4.0f * m_scale);
m_legendHits.push_back(hit);
if (m_editTarget == PlotEditTarget::SIGNAL_NAME && m_editSignal == ref)
{
m_editFieldRect = hit.rect;
UiDrawEditField(g, hit.rect, m_edit, m_fonts->uiSmall, m_scale);
cursorX += itemWidth;
++drawnCount;
continue;
}
cursorX += swatchSize + 5.0f * m_scale;
UiText(g, drawLabel, m_fonts->uiSmall,
gd::RectF(cursorX, top, std::max(8.0f, textWidth + 2.0f), height),
selected ? m_theme->textStrong : m_theme->text, TextAlign::LEFT);
cursorX += textWidth + kLegendItemGap * m_scale;
++drawnCount;
}
if (!anyVisible)
{
UiText(g, L"(无可见信号)", m_fonts->uiSmall,
gd::RectF(legendBand.X, top, lane.rect.Width, height), m_theme->textMuted,
TextAlign::LEFT);
}
else if (drawnCount < visibleCount)
{
// 图例空间不足时提示剩余信号数量,避免误认为信号丢失。
const std::wstring moreText = L"+" + std::to_wstring(visibleCount - drawnCount);
if (cursorX + 20.0f * m_scale <= maxX)
{
UiText(g, moreText, m_fonts->uiSmall, gd::RectF(cursorX, top, 24.0f * m_scale, height),
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 fileIndex = 0; fileIndex < m_dataSet->FileCount(); ++fileIndex)
{
if (!m_tab->FileHasVisible(static_cast<int>(fileIndex)))
{
continue;
}
const DataFile* file = m_dataSet->FileAt(static_cast<int>(fileIndex));
if (file != nullptr && !file->timeColumnName.empty())
{
axisName = file->timeColumnName;
// 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);
}
gd::RectF PlotView::CursorSpanRect(bool& insideAxis) const
{
insideAxis = false;
if (m_tab == nullptr || Tab().CursorMode() != PlotCursorMode::DOUBLE ||
m_fonts == nullptr || m_fonts->monoSmall == nullptr)
{
return gd::RectF();
}
const std::wstring text = FormatSpanText(CursorSpan());
const float width = std::max(
72.0f * m_scale, UiMeasureTextWidth(m_fonts->monoSmall, text) + 18.0f * m_scale);
const float height = 20.0f * m_scale;
// 水平位置取两条光标时间读数的中点(与它们同一行,位于时间轴上方)。
// 注意用"显示横坐标":光标滞留在可视区外时会被钉在网格边缘,
// 此时二者在屏幕上必然很近,差值标签应改放到主时间轴内部。
const gd::RectF first = CursorLabelRect(1);
const gd::RectF second = CursorLabelRect(2);
float center = (CursorDisplayX(1) + CursorDisplayX(2)) * 0.5f;
if (first.Width > 0.0f && second.Width > 0.0f)
{
center = ((first.X + first.Width * 0.5f) + (second.X + second.Width * 0.5f)) * 0.5f;
}
float left = center - 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& leftRect = (first.X <= second.X) ? first : second;
const gd::RectF& rightRect = (first.X <= second.X) ? second : first;
const float freeStart = leftRect.X + leftRect.Width;
const float freeEnd = rightRect.X;
const bool fits = (freeStart <= freeEnd) && ((freeEnd - freeStart) >= (width + 2.0f * m_scale));
// 双光标都滞留在**同一侧**视界之外时,两条时间读数会被钉在同一条边上并叠成两行;
// 此时差值标签也跟着它们排成一列(用户要求"垂直排列要对齐"),
// 而不是孤零零地留在时间轴上方。
const float pinLeftEdge = m_plotRect.X + 1.0f * m_scale;
const float pinRightEdge = m_plotRect.X + m_plotRect.Width - 1.0f * m_scale;
const bool offLeft1 = CursorX(1) < pinLeftEdge;
const bool offRight1 = CursorX(1) > pinRightEdge;
const bool offLeft2 = CursorX(2) < pinLeftEdge;
const bool offRight2 = CursorX(2) > pinRightEdge;
const bool bothOffSameSide = (offLeft1 && offLeft2 && !offRight1 && !offRight2) ||
(offRight1 && offRight2 && !offLeft1 && !offLeft2);
if (bothOffSameSide)
{
// 与两条时间读数同一列、叠在它们下方(更靠下的一行之下)。
const float columnLeft = std::max(m_plotRect.X, first.X);
const float stackBottom = std::max(first.Y, second.Y) + first.Height + 2.0f * m_scale;
insideAxis = true;
const float axisHeight = std::max(14.0f * m_scale, m_timeAxisRect.Height - 4.0f * m_scale);
return gd::RectF(columnLeft, stackBottom, width, std::min(height, axisHeight));
}
if (!fits)
{
insideAxis = true;
const float top = m_timeAxisRect.Y + 2.0f * m_scale;
const float axisHeight = std::max(14.0f * m_scale, m_timeAxisRect.Height - 4.0f * m_scale);
return gd::RectF(left, top, width, std::min(height, axisHeight));
}
return gd::RectF(left, m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale, width, height);
}
bool PlotView::CursorOffViewSide(int which, bool& offRight) const
{
offRight = false;
if (m_tab == nullptr || !Tab().CursorEnabled())
{
return false;
}
const float pinLeft = m_plotRect.X + 1.0f * m_scale;
const float pinRight = m_plotRect.X + m_plotRect.Width - 1.0f * m_scale;
const float raw = CursorX(which);
if (raw < pinLeft)
{
return true;
}
if (raw > pinRight)
{
offRight = true;
return true;
}
return false;
}
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.file == nullptr || !resolved.visible)
{
continue;
}
const bool interpolate = (resolved.style->mode != DrawMode::SCATTER);
double value = 0.0;
if (!SampleSignalAt(*resolved.file, *resolved.data, time, interpolate, value))
{
continue;
}
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 (SampleSignalAt(*resolved.file, *resolved.data, otherTime, interpolate, otherValue))
{
const double delta = value - otherValue;
// 增量 = 右侧光标取值 − 左侧光标取值,包在圆括号里并按差值显式给出正负号。
text += L" (";
text += (delta < 0.0) ? L"-" : L"+";
text += FormatValue(std::fabs(delta));
text += L")";
}
}
const float textWidth = UiMeasureTextWidth(m_fonts->monoSmall, text) + 10.0f * m_scale;
const float textHeight = 17.0f * m_scale;
// 左右越界时三角贴在视界内侧、尖端朝外;数值标注必须放在三角的**内侧**
// 并留出 `kMarkerTriangleGap` 的空白,否则三角会像"多出来的一个尖角"
// (用户报告的"三角形与数值显示部分没有间隔")。
float labelLeft;
if (marker.offLeft)
{
labelLeft = marker.x + kMarkerTriangleGap * m_scale;
}
else if (marker.offRight)
{
labelLeft = marker.x - kMarkerTriangleGap * m_scale - textWidth;
}
else
{
labelLeft = pinX + radius + 4.0f * m_scale;
if (labelLeft + textWidth > m_plotRect.X + m_plotRect.Width - 2.0f * m_scale)
{
labelLeft = pinX - radius - 4.0f * m_scale - textWidth;
}
}
labelLeft = std::max(
m_plotRect.X + 2.0f * m_scale,
std::min(labelLeft, m_plotRect.X + m_plotRect.Width - textWidth - 2.0f * m_scale));
float labelTop = marker.y - textHeight * 0.5f;
labelTop = std::max(clipTop + 1.0f * m_scale,
std::min(labelTop, clipBottom - textHeight - 1.0f * m_scale));
gd::RectF labelRect(labelLeft, labelTop, textWidth, textHeight);
// 多个信号的取值标注可能落在同一位置(数据点重合或过近):
// 依次向下(再向上)避让已占用的标注,保证不会互相覆盖。
PlaceCursorLabel(labelRect, labelTop, textHeight, clipTop, clipBottom);
// 越界方向三角与最终落位的取值标注纵向居中对齐
// (用户明确要求“超过上下视界时三角需要与显示的数值居中对齐”;
// 左右越界时同样对齐,避免三角与数值错行、看起来不协调)。
if (marker.offUp || marker.offDown || marker.offLeft || marker.offRight)
{
marker.y = labelRect.Y + labelRect.Height * 0.5f;
}
PaintDataMarker(g, marker, radius, signalColor, m_theme->cardBg);
const UiInk ink = UiContrastInk(signalColor);
UiFillRoundRect(g, labelRect, 3.0f * m_scale, WithAlpha(signalColor, 0xF0));
DrawLabelOutline(g, labelRect, 3.0f * m_scale, ink.outline);
UiText(g, text, m_fonts->monoSmall, labelRect, ink.text, TextAlign::CENTER);
// 「边沿计数(中心轴)」/「均值滤波」/「弱波增强」开启时,纵向光标还要额外标注
// **派生曲线**在该时刻的取值(它不是信号,因此单独标注,不并入上面的取值文案)。
// 三条曲线只是曲线来源不同,标注规则完全一致,故共用同一段绘制逻辑。
// ⚠️ 曲线只取「可见区间 ∪ 两个光标时刻 ± 保护带」的窗口(与绘制共用缓存),
// 因此拖动光标/拖动参数滑块都不会触发整条曲线的重算。
const auto paintDerivedCapsule = [&](DerivedCurveKind kind, int points,
int iterations, int levelPercent,
const std::wstring& suffix)
{
const int sampleIndex = FindNearestSample(*resolved.file, time);
if (sampleIndex < 0)
{
return;
}
const int otherIndex =
showDelta ? FindNearestSample(*resolved.file, otherTime) : -1;
// 本函数**只需要光标所在采样点处的派生取值**(以及双光标另一端的取值),
// 因此把请求窗口收窄到「需要的那几个下标」即可,均值滤波半径造成的取值
// 依赖由 `CurveSpanFor` 自身的保护带负责覆盖。
//
// ⚠️ 绝不能把窗口扩成「光标 ↔ 视界边缘」的并集:光标停在视界之外(例如
// 平移视界把光标甩到很远,或光标远离当前缩放区间)时会得到一个上千万采样的
// 巨大窗口,超过缓存上限后走一次性缓冲 → **每帧重算整段滤波**,
// 实测单帧 300ms+(用户报告的"平移/拖动光标异常卡顿"的直接原因)。
std::size_t begin = static_cast<std::size_t>(sampleIndex);
std::size_t end = begin + 1;
if (otherIndex >= 0)
{
const std::size_t other = static_cast<std::size_t>(otherIndex);
begin = std::min(begin, other);
end = std::max(end, other + 1);
}
std::size_t spanBegin = 0;
const std::vector<float>* curvePtr =
CurveSpanFor(kind, *resolved.data, points, iterations, levelPercent, begin,
end, spanBegin, true);
if (curvePtr == nullptr)
{
return;
}
const auto curveValueAt = [curvePtr, spanBegin](int index) -> double
{
if (index < 0 || static_cast<std::size_t>(index) < spanBegin ||
(static_cast<std::size_t>(index) - spanBegin) >= curvePtr->size())
{
return std::numeric_limits<double>::quiet_NaN();
}
return static_cast<double>((*curvePtr)[static_cast<std::size_t>(index) -
spanBegin]);
};
const double derivedValue = curveValueAt(sampleIndex);
if (!std::isfinite(derivedValue))
{
return;
}
const float rawCenterY = ValueToY(derivedValue, lane);
DataMarker centerMarker = MakeDataMarker(
rawX, rawCenterY, pinX, clipTop, clipBottom, kCursorHandleReserve * m_scale);
// 派生数据点的配色与原始信号一致(便于一一对应);
// 但**始终按线段模式**绘制(不跟随原信号的散点模式)。
const std::uint32_t centerColor = StyleColor(*resolved.style);
// 取值文本:数值在前,尾缀「(中心轴)/(均值滤波)」;双光标的右侧光标
// 还要附上增量,形如「(+0.0008 中心轴)」(参考常规信号的增量显示)。
std::wstring centerText = FormatValue(derivedValue);
centerText += L"(";
if (showDelta)
{
const double otherDerived = curveValueAt(otherIndex);
if (std::isfinite(otherDerived))
{
const double delta = derivedValue - otherDerived;
centerText += (delta < 0.0) ? L"-" : L"+";
centerText += FormatValue(std::fabs(delta));
centerText += L" ";
}
}
centerText += suffix;
centerText += L")";
const float centerTextWidth =
UiMeasureTextWidth(m_fonts->monoSmall, centerText) + 10.0f * m_scale;
// 与常规取值标注同一规则:左右越界时标注要让开外侧的方向三角。
float centerLeft;
if (centerMarker.offLeft)
{
centerLeft = centerMarker.x + kMarkerTriangleGap * m_scale;
}
else if (centerMarker.offRight)
{
centerLeft = centerMarker.x - kMarkerTriangleGap * m_scale - centerTextWidth;
}
else
{
centerLeft = pinX + radius + 4.0f * m_scale;
if (centerLeft + centerTextWidth >
m_plotRect.X + m_plotRect.Width - 2.0f * m_scale)
{
centerLeft = pinX - radius - 4.0f * m_scale - centerTextWidth;
}
}
centerLeft = std::max(
m_plotRect.X + 2.0f * m_scale,
std::min(centerLeft,
m_plotRect.X + m_plotRect.Width - centerTextWidth - 2.0f * m_scale));
float centerTop = centerMarker.y - textHeight * 0.5f;
centerTop = std::max(clipTop + 1.0f * m_scale,
std::min(centerTop, clipBottom - textHeight - 1.0f * m_scale));
gd::RectF centerRect(centerLeft, centerTop, centerTextWidth, textHeight);
PlaceCursorLabel(centerRect, centerTop, textHeight, clipTop, clipBottom);
// 与原始信号同一规则:越界方向三角与取值标注纵向居中对齐。
if (centerMarker.offUp || centerMarker.offDown || centerMarker.offLeft ||
centerMarker.offRight)
{
centerMarker.y = centerRect.Y + centerRect.Height * 0.5f;
}
PaintDataMarker(g, centerMarker, radius, centerColor, m_theme->cardBg);
// 颜色样式与原始信号一致(同底色/同描边/同文字配色)。
const UiInk centerInk = UiContrastInk(centerColor);
UiFillRoundRect(g, centerRect, 3.0f * m_scale, WithAlpha(centerColor, 0xE0));
DrawLabelOutline(g, centerRect, 3.0f * m_scale, centerInk.outline);
UiText(g, centerText, m_fonts->monoSmall, centerRect, centerInk.text,
TextAlign::CENTER);
};
if (m_tab->Lanes()[laneIndex].edgeCenter)
{
// 只标注被勾选(即真正绘制了曲线)的信号。
const Lane::EdgeCenterItem* centerItem =
m_tab->Lanes()[laneIndex].FindEdgeCenterItem(ref);
if (centerItem != nullptr)
{
paintDerivedCapsule(DerivedCurveKind::MEAN_FILTER, centerItem->points,
centerItem->iterations, 0, L"中心轴");
}
}
if (m_tab->Lanes()[laneIndex].meanFilter)
{
// 只标注被勾选(即真正绘制了预览曲线)的信号。
const Lane::MeanFilterItem* meanItem =
m_tab->Lanes()[laneIndex].FindMeanFilterItem(ref);
if (meanItem != nullptr)
{
paintDerivedCapsule(DerivedCurveKind::MEAN_FILTER, meanItem->points,
meanItem->iterations, 0, L"均值滤波");
}
}
if (m_tab->Lanes()[laneIndex].weakBoost)
{
// 弱波增强曲线:同样是只标注被勾选的信号,尾缀为「弱波增强」。
const Lane::WeakBoostItem* boostItem =
m_tab->Lanes()[laneIndex].FindWeakBoostItem(ref);
if (boostItem != nullptr)
{
const int levelPercent = static_cast<int>(std::lround(
std::max(0.0, std::min(1.0, m_tab->Lanes()[laneIndex].weakBoostLevel)) *
100.0));
const int iterations = std::max(
kWeakBoostIterationsMin,
std::min(kWeakBoostIterationsMax, m_tab->Lanes()[laneIndex].weakBoostIterations));
paintDerivedCapsule(DerivedCurveKind::WEAK_BOOST, 0, iterations, levelPercent,
L"弱波增强");
}
}
}
}
}
// ---- 底部:时间读数 + 双光标差值 ----
if (m_editTarget == PlotEditTarget::CURSOR_TIME ||
m_editTarget == PlotEditTarget::CURSOR_SPAN)
{
PaintEditField(g);
return;
}
for (int pass = 0; pass < cursorCount; ++pass)
{
const int which = doubleMode ? iterations[pass] : 1;
const gd::RectF labelRect = CursorLabelRect(which);
if (labelRect.Width <= 0.0f)
{
continue;
}
const bool highlighted = (m_cursorHover && m_hoverCursor == which);
const std::uint32_t color = CursorColor();
bool offRight = false;
const bool offView = CursorOffViewSide(which, offRight);
if (offView)
{
PaintCursorOffViewTip(g, labelRect, offRight, color);
}
UiFillRoundRect(g, labelRect, 3.0f * m_scale,
highlighted ? color : WithAlpha(color, 0xE0));
const UiInk ink = UiContrastInk(color);
DrawLabelOutline(g, labelRect, 3.0f * m_scale, ink.outline);
UiText(g, FormatCursorTime(which == 2 ? Tab().CursorTimeAlt() : Tab().CursorTime()),
m_fonts->monoSmall, labelRect, ink.text, TextAlign::CENTER);
}
// 双光标差值:显示在两条时间读数中间(时间轴上方;过近时落到主时间轴上)。
if (doubleMode)
{
bool insideAxis = false;
const gd::RectF spanRect = CursorSpanRect(insideAxis);
m_cursorSpanRect = spanRect;
if (spanRect.Width > 0.0f && spanRect.Height > 0.0f)
{
// 样式与光标时间读数完全一致(同底色/同描边/同文字配色)。
const std::uint32_t color = CursorColor();
// 双光标同时越界到同一侧时,差值标签也在视界之外 → 同样画方向三角。
bool offRight1 = false, offRight2 = false;
const bool off1 = CursorOffViewSide(1, offRight1);
const bool off2 = CursorOffViewSide(2, offRight2);
if (off1 && off2 && offRight1 == offRight2)
{
PaintCursorOffViewTip(g, spanRect, offRight1, color);
}
UiFillRoundRect(g, spanRect, 3.0f * m_scale,
m_spanHover ? color : WithAlpha(color, 0xE0));
const UiInk ink = UiContrastInk(color);
DrawLabelOutline(g, spanRect, 3.0f * m_scale, ink.outline);
UiText(g, FormatSpanText(CursorSpan()), m_fonts->monoSmall, spanRect, ink.text,
TextAlign::CENTER);
}
}
else
{
m_cursorSpanRect = gd::RectF();
}
}
PlotView::EdgePanelLayout PlotView::EdgePanelLayoutFor(int laneIndex, EdgePanelKind kind) const
{
EdgePanelLayout layout;
if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 ||
laneIndex >= static_cast<int>(m_tab->Lanes().size()) ||
laneIndex >= static_cast<int>(m_lanes.size()))
{
return layout;
}
const Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
const bool centerAxis = (kind == EdgePanelKind::CENTER);
const bool meanFilter = (kind == EdgePanelKind::MEAN);
const bool weakBoost = (kind == EdgePanelKind::BOOST);
// 「均值滤波」与「弱波增强」共用同一套「逐条目 + 底部按钮」排布。
const bool itemized = (centerAxis || meanFilter || weakBoost);
const bool hasButton = (meanFilter || weakBoost);
const bool enabled = meanFilter ? lane.meanFilter
: (weakBoost ? lane.weakBoost
: (centerAxis ? lane.edgeCenter : lane.edgeCount));
const bool open = meanFilter ? lane.meanFilterPanelOpen
: (weakBoost ? lane.weakBoostPanelOpen
: (centerAxis ? lane.edgeCenterPanelOpen : lane.edgePanelOpen));
if (!enabled || !open)
{
return layout;
}
int rowCount = 0;
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data != nullptr && resolved.visible)
{
++rowCount;
}
}
if (rowCount <= 0)
{
return layout;
}
layout.rows = rowCount;
const float width = EdgePanelWidth(laneIndex, kind);
const float titleHeight = kEdgePanelTitleHeight * m_scale;
const float rowHeight = kEdgePanelRowHeight * m_scale;
const float pad = kEdgePanelPad * m_scale;
// 「中心轴边沿计数」「均值滤波」「弱波增强」共用同一套「逐条目」排布,
// 因此不再有独立的底部编辑行;只有「边沿计数」保留两行文本式排版。
const float buttonHeight = hasButton ? (kEdgePanelButtonHeight * m_scale) : 0.0f;
const float rowsPerSignal = meanFilter ? 1.0f : 2.0f;
// 「弱波增强」在条目与底部按钮之间额外有两行滑块(等级 / 迭代次数 N),其余逐条目面板没有。
const float sliderRowHeight = weakBoost ? (kWeakBoostSliderRowHeight * m_scale) : 0.0f;
const float sliderRowsHeight = weakBoost ? (sliderRowHeight * 2.0f) : 0.0f;
// 逐条目面板:每个条目 = 第一排(信号名 + 复选框)
// + 第二排(均值点数 X / 迭代次数 N)+(中心轴)第三排(统计结果);
// 后两排**只在该条目被勾选时才占位**,因此面板高度随勾选数量动态变化。
float meanRowHeight = 0.0f;
float itemsHeight = 0.0f;
float height = 0.0f;
if (itemized)
{
meanRowHeight = kEdgePanelMeanRowHeight * m_scale;
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || !resolved.visible)
{
continue;
}
itemsHeight += meanRowHeight;
const bool checked = meanFilter ? lane.MeanFilterEnabled(ref)
: (weakBoost ? lane.WeakBoostEnabled(ref)
: lane.EdgeCenterEnabled(ref));
if (checked && (centerAxis || meanFilter))
{
itemsHeight += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale;
if (centerAxis)
{
itemsHeight += kEdgePanelMeanRowHeight * m_scale;
}
}
}
// 底部按钮行:上方与条目之间、下方与面板下边界之间各留一个 `pad`,
// 与面板的左右内边距完全一致(原先按钮直接贴着下边框,看起来很不协调)。
height = titleHeight + pad + itemsHeight + sliderRowsHeight + pad + buttonHeight +
(hasButton ? pad : 0.0f);
}
else
{
height = titleHeight + pad + static_cast<float>(rowCount) * rowHeight * rowsPerSignal + pad;
}
// 首次使用(位置为负)时自动放到绘图区右侧,并各网格依次错开,避免完全重叠。
const double storedX = meanFilter ? lane.meanFilterPanelX
: (weakBoost ? lane.weakBoostPanelX
: (centerAxis ? lane.edgeCenterPanelX : lane.edgePanelX));
const double storedY = meanFilter ? lane.meanFilterPanelY
: (weakBoost ? lane.weakBoostPanelY
: (centerAxis ? lane.edgeCenterPanelY : lane.edgePanelY));
float left = (storedX >= 0.0)
? static_cast<float>(storedX)
: (m_plotRect.X + m_plotRect.Width - width - 16.0f * m_scale);
float top = (storedY >= 0.0)
? static_cast<float>(storedY)
: (m_plotRect.Y + 16.0f * m_scale +
static_cast<float>(laneIndex) * 28.0f * m_scale);
// 面板始终限制在绘图区内(用户明确要求"可以在右侧的整个绘制区域拖动")。
left = std::max(m_plotRect.X + 2.0f * m_scale,
std::min(left, m_plotRect.X + m_plotRect.Width - width - 2.0f * m_scale));
top = std::max(m_plotRect.Y + 2.0f * m_scale,
std::min(top, m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale));
layout.panel = gd::RectF(left, top, width, height);
// 只有标题栏可以拖拽;✕ 位于标题栏右端,命中时优先按“关闭按钮”处理。
layout.title = gd::RectF(left, top, width, titleHeight);
const float closeSize = titleHeight;
layout.close = gd::RectF(left + width - closeSize, top, closeSize, closeSize);
if (itemized)
{
const float buttonRowHeight = kEdgePanelButtonHeight * m_scale;
if (hasButton)
{
// 底部按钮与面板下边界之间留出与左右、与内容相同的 `pad` 间距
//(原先按钮行直接贴着下边框,视觉上与其它元素不协调);
// 面板高度已含这份 pad,因此这里只上移按钮行即可。
layout.button = gd::RectF(left + pad, top + height - pad - buttonRowHeight,
std::max(0.0f, width - pad * 2.0f), buttonRowHeight);
}
// 弱波增强的两个滑块行(等级 / 迭代次数 N,位于信号条目与底部按钮之间)。
if (weakBoost)
{
const float labelWidth =
std::max(UiMeasureTextWidth(m_fonts->uiSmall, kWeakBoostLevelLabel),
UiMeasureTextWidth(m_fonts->uiSmall, kWeakBoostIterationsLabel));
const float valueWidth = std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100%"),
UiMeasureTextWidth(m_fonts->monoSmall, L"10"));
const float trackHeight = kWeakBoostTrackHeight * m_scale;
const float firstRowTop = top + titleHeight + pad + itemsHeight;
const float trackLeft = left + pad + labelWidth + 6.0f * m_scale;
const float trackRight = left + width - pad - valueWidth - 6.0f * m_scale;
const float trackWidth = std::max(10.0f * m_scale, trackRight - trackLeft);
layout.slider =
gd::RectF(trackLeft, firstRowTop + (sliderRowHeight - trackHeight) * 0.5f,
trackWidth, trackHeight);
layout.sliderIterations =
gd::RectF(trackLeft, firstRowTop + sliderRowHeight + (sliderRowHeight - trackHeight) * 0.5f,
trackWidth, trackHeight);
}
// 逐信号条目:第一排 = 名称 + 复选框(复选框靠右);
// 第二排 = 「均值点数 X」与「迭代次数 N」两个输入框(仅勾选时存在);
// 第三排(中心轴)= 上升沿/下降沿统计结果(仅勾选时存在)。
const float checkSize = kEdgePanelMeanCheckSize * m_scale;
const float fieldHeight = kEdgePanelMeanEditHeight * m_scale;
const float halfWidth = std::max(40.0f, (width - pad * 2.0f - 8.0f * m_scale) * 0.5f);
float rowY = top + titleHeight + pad;
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || !resolved.visible)
{
continue;
}
layout.rowRefs.push_back(ref);
layout.rowChecks.push_back(gd::RectF(
left + width - pad - checkSize, rowY + (meanRowHeight - checkSize) * 0.5f, checkSize,
checkSize));
const bool checked = meanFilter ? lane.MeanFilterEnabled(ref)
: (weakBoost ? lane.WeakBoostEnabled(ref)
: lane.EdgeCenterEnabled(ref));
if (checked && (centerAxis || meanFilter))
{
const float editTop = rowY + meanRowHeight;
layout.rowPoints.push_back(
gd::RectF(left + pad, editTop, halfWidth, fieldHeight));
layout.rowIterations.push_back(
gd::RectF(left + pad + halfWidth + 8.0f * m_scale, editTop, halfWidth, fieldHeight));
if (centerAxis)
{
layout.rowStats.push_back(
gd::RectF(left + pad, editTop + fieldHeight, width - pad * 2.0f,
kEdgePanelMeanRowHeight * m_scale));
}
else
{
layout.rowStats.push_back(gd::RectF());
}
}
else
{
layout.rowPoints.push_back(gd::RectF());
layout.rowIterations.push_back(gd::RectF());
layout.rowStats.push_back(gd::RectF());
}
rowY += meanRowHeight;
if (checked && (centerAxis || meanFilter))
{
rowY += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale;
if (centerAxis)
{
rowY += kEdgePanelMeanRowHeight * m_scale;
}
}
}
}
return layout;
}
bool PlotView::IsOnEdgeCursorLine(int x, int y, int& laneIndex) const
{
laneIndex = -1;
if (m_tab == nullptr)
{
return false;
}
const std::vector<Lane>& tabLanes = m_tab->Lanes();
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
const float tolerance = 5.0f * m_scale;
for (std::size_t index = 0; index < m_lanes.size() && index < tabLanes.size(); ++index)
{
if (!tabLanes[index].edgeCount)
{
continue;
}
const LaneVisual& visual = m_lanes[index];
if (fx < visual.rect.X || fx > visual.rect.X + visual.rect.Width)
{
continue;
}
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;
}
const float lineY = std::max(
clipTop, std::min(ValueToY(tabLanes[index].edgeValue, visual), clipBottom));
if (std::fabs(fy - lineY) <= tolerance)
{
laneIndex = static_cast<int>(index);
return true;
}
}
return false;
}
PlotView::DataMarker PlotView::MakeDataMarker(float rawX, float rawY, float pinX, float clipTop,
float clipBottom, float topReserve) const
{
DataMarker marker;
// 形状**只**由“数据点本身是否越出绘制边界”决定:
// 旧实现拿“夹取之后”的位置反推越界方向,于是贴近边界、其实仍在视界内的数据点
// 也会被误画成三角(用户报告的“数值显示超出边界就改变数据点形状”)。
const float left = m_plotRect.X;
const float right = m_plotRect.X + m_plotRect.Width;
marker.offLeft = (rawX < left);
marker.offRight = (rawX > right);
marker.offUp = (rawY < clipTop);
marker.offDown = (rawY > clipBottom);
marker.x = pinX;
// 左右越界时三角沿水平方向**向外**伸出 `kMarkerTriangleDepth`:
// 光标贴边时 pinX 只离边界 1px,若直接把锚点放在那里,三角会有一大半画到
// 网格绘制区之外(被裁剪后只看到残缺的一角,描边也随之断开)。
// 因此把锚点沿水平方向**向内**推 keepIn,保证三角完整落在绘制区内。
const float keepIn = (kMarkerTriangleDepth + 1.0f) * m_scale;
if (marker.offLeft)
{
marker.x = std::max(pinX, left + keepIn);
}
else if (marker.offRight)
{
marker.x = std::min(pinX, right - keepIn);
}
if (marker.offUp)
{
// 上越界:为方向三角与顶部把手预留高度,避免压住把手。
marker.y = clipTop + kMarkerEdgeInset * m_scale + topReserve;
}
else if (marker.offDown)
{
marker.y = clipBottom - kMarkerEdgeInset * m_scale;
}
else
{
// 视界内:保留数据点的真实纵坐标(越界判定已在上面完成,这里夹取只为了让
// 圆点整体可见,不会影响形状)。
marker.y = std::max(clipTop + kMarkerInsideMargin * m_scale,
std::min(rawY, clipBottom - kMarkerInsideMargin * m_scale));
}
return marker;
}
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::PlaceCursorLabel(gd::RectF& rect, float preferredTop, float textHeight,
float clipTop, float clipBottom)
{
const float gap = 2.0f * m_scale;
const float minTop = clipTop + 1.0f * m_scale;
const float maxTop = std::max(minTop, clipBottom - textHeight - 1.0f * m_scale);
rect.Y = std::max(minTop, std::min(preferredTop, maxTop));
const auto overlaps = [this](const gd::RectF& candidate, const gd::RectF*& hit) -> bool
{
for (const gd::RectF& occupied : m_cursorLabelRects)
{
if (candidate.IntersectsWith(occupied))
{
hit = &occupied;
return true;
}
}
return false;
};
bool resolved = false;
for (int step = 0; step < 16; ++step)
{
const gd::RectF* hit = nullptr;
if (!overlaps(rect, hit))
{
resolved = true;
break;
}
// 向下推一格;超出网格底部则改由原始位置向上避让。
const float next = hit->Y + hit->Height + gap;
if (next > maxTop)
{
break;
}
rect.Y = next;
}
if (!resolved)
{
rect.Y = std::max(minTop, std::min(preferredTop, maxTop));
for (int step = 0; step < 16; ++step)
{
const gd::RectF* hit = nullptr;
if (!overlaps(rect, hit))
{
break;
}
rect.Y = hit->Y - rect.Height - gap;
if (rect.Y < minTop)
{
break;
}
}
}
rect.Y = std::max(minTop, std::min(rect.Y, maxTop));
m_cursorLabelRects.push_back(rect);
}
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.file == nullptr || resolved.style == nullptr)
{
return;
}
const std::vector<double>& time = resolved.file->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.file, count, begin, end);
if (end <= begin)
{
return;
}
const std::size_t visibleCount = end - begin;
const float limitTop = lane.rect.Y - lane.rect.Height * 4.0f;
const float limitBottom = lane.rect.Y + lane.rect.Height * 5.0f;
// 派生曲线的颜色:与原信号**同色相**但大幅拉开明度,使其与原始波形形成高对比
// (原信号改颜色时曲线随之改变,因为它是从信号色派生出来的);透明度与原信号一致。
const unsigned int baseAlpha = static_cast<unsigned int>(
std::max(0.0, std::min(1.0, static_cast<double>(resolved.style->opacity))) * 255.0 + 0.5);
const std::uint32_t derived = WithAlpha(HighContrastDerived(StyleColor(*resolved.style)),
baseAlpha);
// 顶点预算与波形一致(每像素列两个顶点)。
// 交互期间进一步降到 1/2:派生曲线往往有 10 条(5 网格 × 均值滤波 + 弱波增强),
// 顶点数就是绘制耗时;降低密度能让拖动参数滑块保持流畅,
// 松手后下一帧立刻恢复全密度(用户要求的"低分辨率 → 高分辨率")。
std::size_t budget =
static_cast<std::size_t>(std::max(1.0f, m_plotRect.Width)) * kPolylinePointsPerPixel;
budget = std::max(budget, kMinPolylinePoints);
budget = std::min(budget, kMaxPolylinePoints);
if (m_fastFrame)
{
budget = std::max<std::size_t>(kMinPolylinePoints, budget / 2);
}
const std::size_t stride = std::max<std::size_t>(1, visibleCount / std::max<std::size_t>(1, budget));
std::vector<gd::PointF> points;
std::vector<std::size_t> breaks;
points.reserve(std::min<std::size_t>(visibleCount, budget) + 1);
bool inSegment = false;
// 「陡峭段」计数:用于判断抗锯齿是否值得付出(见下)。
std::size_t tallSegments = 0;
std::size_t totalSegments = 0;
for (std::size_t index = begin; index < end; index += stride)
{
if (index < curveBegin || (index - curveBegin) >= curve.size())
{
inSegment = false;
continue;
}
const float sampled = curve[index - curveBegin];
if (!std::isfinite(sampled))
{
inSegment = false;
continue;
}
float y = ValueToY(static_cast<double>(sampled), lane);
y = static_cast<float>(ClampDouble(y, limitTop, limitBottom));
const float x = TimeToX(time[index]);
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 (m_fastFrame || 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.file == nullptr || resolved.style == nullptr)
{
return;
}
// 只取「可见区间 ± 保护带」的窗口(见 `CurveSpanFor`):拖参数滑块不会重算整条曲线。
// 与「边沿计数(中心轴)」共用同一段绘制逻辑
//(同色相高对比度的派生配色 + 同透明度),只是曲线本身按多次迭代滤波计算。
const std::size_t count = std::min(resolved.file->time.size(), resolved.data->values.size());
std::size_t begin = 0;
std::size_t end = 0;
VisibleIndexRange(*resolved.file, count, begin, end);
if (end <= begin)
{
return;
}
std::size_t spanBegin = 0;
const std::vector<float>* curve = CurveSpanFor(DerivedCurveKind::MEAN_FILTER, *resolved.data,
points, iterations, 0, begin, end, spanBegin,
true);
if (curve == nullptr)
{
return;
}
PaintDerivedCurve(g, resolved, lane, *curve, spanBegin);
}
void PlotView::PaintWeakBoostCurve(gd::Graphics& g, const ResolvedSignal& resolved,
const LaneVisual& lane, int levelPercent, int iterations) const
{
if (resolved.data == nullptr || resolved.file == nullptr || resolved.style == nullptr)
{
return;
}
const std::size_t count = std::min(resolved.file->time.size(), resolved.data->values.size());
std::size_t begin = 0;
std::size_t end = 0;
VisibleIndexRange(*resolved.file, count, begin, end);
if (end <= begin)
{
return;
}
std::size_t spanBegin = 0;
const std::vector<float>* curve = CurveSpanFor(DerivedCurveKind::WEAK_BOOST, *resolved.data, 0,
iterations, levelPercent, begin, end, spanBegin,
true);
if (curve == nullptr)
{
return;
}
// 派生曲线一律使用同一段绘制逻辑与配色规则(弱波增强曲线同样如此)。
PaintDerivedCurve(g, resolved, lane, *curve, spanBegin);
}
namespace
{
/** @brief FNV-1a 混合(面板像素缓存键共用)。 */
inline void MixPanelKey(std::uint64_t& hash, std::uint64_t value)
{
hash ^= value;
hash *= 1099511628211ull;
}
} // namespace
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.fileIndex));
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_boostDragLane + 2));
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);
mixFloat(static_cast<float>(visual.yMin));
mixFloat(static_cast<float>(visual.yMax));
}
if (m_tab != nullptr)
{
const std::vector<Lane>& lanes = m_tab->Lanes();
for (const Lane& lane : lanes)
{
mixFloat(static_cast<float>(lane.edgePanelX));
mixFloat(static_cast<float>(lane.edgePanelY));
mixFloat(static_cast<float>(lane.edgeCenterPanelX));
mixFloat(static_cast<float>(lane.edgeCenterPanelY));
mixFloat(static_cast<float>(lane.meanFilterPanelX));
mixFloat(static_cast<float>(lane.meanFilterPanelY));
mixFloat(static_cast<float>(lane.weakBoostPanelX));
mixFloat(static_cast<float>(lane.weakBoostPanelY));
mix(lane.edgePanelOpen ? 1ull : 0ull);
mix(lane.edgeCenterPanelOpen ? 1ull : 0ull);
mix(lane.meanFilterPanelOpen ? 1ull : 0ull);
mix(lane.weakBoostPanelOpen ? 1ull : 0ull);
mix(lane.edgeValueValid ? 1ull : 0ull);
mixDouble(lane.edgeValue);
}
}
return hash;
}
std::uint64_t PlotView::PanelSlotKey(int laneIndex, EdgePanelKind kind,
const EdgePanelLayout& layout) const
{
std::uint64_t hash = 1469598103934665603ull;
const auto mix = [&hash](std::uint64_t value) { MixPanelKey(hash, value); };
const auto mixFloat = [&mix](float value)
{
std::uint32_t bits = 0;
std::memcpy(&bits, &value, sizeof(bits));
mix(bits);
};
const auto mixRect = [&mixFloat](const gd::RectF& rect)
{
mixFloat(rect.X);
mixFloat(rect.Y);
mixFloat(rect.Width);
mixFloat(rect.Height);
};
mix(static_cast<std::uint64_t>(laneIndex + 2));
mix(static_cast<std::uint64_t>(static_cast<int>(kind) + 2));
mixRect(layout.panel);
mixRect(layout.title);
mixRect(layout.close);
mixRect(layout.edit);
mixRect(layout.button);
mixRect(layout.slider);
mixRect(layout.sliderIterations);
mix(static_cast<std::uint64_t>(layout.rows));
for (const gd::RectF& rect : layout.rowChecks)
{
mixRect(rect);
}
for (const gd::RectF& rect : layout.rowPoints)
{
mixRect(rect);
}
for (const gd::RectF& rect : layout.rowIterations)
{
mixRect(rect);
}
for (const gd::RectF& rect : layout.rowStats)
{
mixRect(rect);
}
const Lane& lane = Tab().Lanes()[static_cast<std::size_t>(laneIndex)];
for (const SignalRef& ref : layout.rowRefs)
{
mix(ref.IsValid() ? 1ull : 0ull);
mix(static_cast<std::uint64_t>(ref.fileIndex));
mix(static_cast<std::uint64_t>(ref.signalIndex));
// 勾选状态与参数直接决定行文本与行高。
mix(lane.MeanFilterEnabled(ref) ? 1ull : 0ull);
mix(lane.EdgeCenterEnabled(ref) ? 1ull : 0ull);
mix(lane.WeakBoostEnabled(ref) ? 1ull : 0ull);
const Lane::MeanFilterItem* meanItem = lane.FindMeanFilterItem(ref);
mix(static_cast<std::uint64_t>(meanItem != nullptr ? meanItem->points + 2 : 0));
mix(static_cast<std::uint64_t>(meanItem != nullptr ? meanItem->iterations + 2 : 0));
const Lane::EdgeCenterItem* centerItem = lane.FindEdgeCenterItem(ref);
mix(static_cast<std::uint64_t>(centerItem != nullptr ? centerItem->points + 2 : 0));
mix(static_cast<std::uint64_t>(centerItem != nullptr ? centerItem->iterations + 2 : 0));
}
// 滑块数值(等级百分比 + 迭代次数)只显示在「弱波增强」面板上:
// ⚠️ 必须限定 kind,否则拖等级滑块会把同一网格的均值滤波 / 中心轴面板
// 也判为脏,再经"重叠级联"扩散到其他网格,等于所有面板都重画(实测 40ms/帧)。
if (kind == EdgePanelKind::BOOST)
{
mixFloat(static_cast<float>(lane.weakBoostLevel));
mix(static_cast<std::uint64_t>(lane.weakBoostIterations + 2));
}
// 鼠标落在面板内时,✕ / 复选框 / 滑块 / 底部按钮的悬停态都依赖精确坐标。
const float margin = 6.0f * m_scale;
const gd::RectF hot(layout.panel.X - margin, layout.panel.Y - margin,
layout.panel.Width + margin * 2.0f,
layout.panel.Height + margin * 2.0f);
if (UiPointInRect(hot, static_cast<float>(m_mouseX), static_cast<float>(m_mouseY)))
{
mix(static_cast<std::uint64_t>(m_mouseX + 4096));
mix(static_cast<std::uint64_t>(m_mouseY + 4096));
}
return hash;
}
void PlotView::PaintEdgeCursor(gd::Graphics& g, const std::vector<LaneVisual>& lanes)
{
if (m_tab == nullptr || m_dataSet == nullptr)
{
return;
}
std::vector<Lane>& tabLanes = m_tab->Lanes();
const std::size_t count = std::min(lanes.size(), tabLanes.size());
const std::uint32_t color = CursorColor();
const UiInk ink = UiContrastInk(color);
// ---- 横向光标(线与两端把手 + 左侧幅值读数)----
for (std::size_t index = 0; index < count; ++index)
{
Lane& tabLane = tabLanes[index];
if (!tabLane.edgeCount)
{
continue;
}
const LaneVisual& visual = lanes[index];
const float clipTop = std::max(visual.rect.Y, m_plotRect.Y);
const float clipBottom =
std::min(visual.rect.Y + visual.rect.Height, m_plotRect.Y + m_plotRect.Height);
if (clipBottom <= clipTop)
{
continue;
}
// 首次绘制时把横向光标落在可见幅值范围的中点。
if (!tabLane.edgeValueValid)
{
tabLane.edgeValue = (visual.yMin + visual.yMax) * 0.5;
tabLane.edgeValueValid = true;
}
const float lineY =
std::max(clipTop, std::min(ValueToY(tabLane.edgeValue, visual), clipBottom));
const float left = visual.rect.X;
const float right = visual.rect.X + visual.rect.Width;
// 横线与纵向光标同色同宽;两端各画一个尖端朝内的三角把手。
UiDrawLine(g, left, lineY, right, lineY, color, 1.4f * m_scale);
const float arm = 5.0f * m_scale;
gd::SolidBrush handle(ToGdiColor(color));
gd::PointF leftHandle[3];
leftHandle[0] = gd::PointF(left, lineY - arm);
leftHandle[1] = gd::PointF(left, lineY + arm);
leftHandle[2] = gd::PointF(left + arm * 1.4f, lineY);
g.FillPolygon(&handle, leftHandle, 3);
gd::PointF rightHandle[3];
rightHandle[0] = gd::PointF(right, lineY - arm);
rightHandle[1] = gd::PointF(right, lineY + arm);
rightHandle[2] = gd::PointF(right - arm * 1.4f, lineY);
g.FillPolygon(&handle, rightHandle, 3);
// 幅值读数:靠网格左侧、纵向居中于横线(与纵向光标的时间读数同款胶囊)。
// **必须让开左端的三角把手**(把手横向占 [left, left + arm*1.4]),
// 否则数值胶囊会盖住把手(用户明确要求向右挪一些)。
const std::wstring text = FormatValue(tabLane.edgeValue);
const float textWidth = std::max(
56.0f * m_scale, UiMeasureTextWidth(m_fonts->monoSmall, text) + 16.0f * m_scale);
const float textHeight = 18.0f * m_scale;
const float labelLeft = left + (arm * 1.4f + 4.0f * m_scale);
const float labelTop =
std::max(clipTop + 1.0f * m_scale, std::min(lineY - textHeight * 0.5f,
clipBottom - textHeight - 1.0f * m_scale));
const gd::RectF labelRect(labelLeft, labelTop, textWidth, textHeight);
UiFillRoundRect(g, labelRect, 3.0f * m_scale, WithAlpha(color, 0xE0));
DrawLabelOutline(g, labelRect, 3.0f * m_scale, ink.outline);
UiText(g, text, m_fonts->monoSmall, labelRect, ink.text, TextAlign::CENTER);
// 幅值超出视界时(横线被贴边显示),在数值胶囊的**上方/下方**画一个方向三角,
// 与常规纵向光标数据点越界的处理方式一致;三角与胶囊**水平居中对齐**。
const float rawLineY = ValueToY(tabLane.edgeValue, visual);
const bool offUp = (rawLineY < clipTop - 0.5f);
const bool offDown = (rawLineY > clipBottom + 0.5f);
if (offUp || offDown)
{
const float tipArm = 5.0f * m_scale;
const float depth = tipArm * 1.4f;
const float centerX = labelRect.X + labelRect.Width * 0.5f;
// 三角整体贴在胶囊外侧(上方越界 → 胶囊上方的向上三角)。
const float anchorY = offUp ? (labelRect.Y - depth * 0.5f - 1.0f * m_scale)
: (labelRect.Y + labelRect.Height + depth * 0.5f + 1.0f * m_scale);
gd::PointF outer[3];
if (offUp)
{
outer[0] = gd::PointF(centerX - tipArm, anchorY + depth * 0.5f);
outer[1] = gd::PointF(centerX + tipArm, anchorY + depth * 0.5f);
outer[2] = gd::PointF(centerX, anchorY - depth * 0.5f);
}
else
{
outer[0] = gd::PointF(centerX - tipArm, anchorY - depth * 0.5f);
outer[1] = gd::PointF(centerX + tipArm, anchorY - depth * 0.5f);
outer[2] = gd::PointF(centerX, anchorY + depth * 0.5f);
}
gd::SolidBrush tipBrush(ToGdiColor(color));
g.FillPolygon(&tipBrush, outer, 3);
}
}
// ---- 统计浮动面板(最后绘制,覆盖在波形之上)----
// 「边沿计数」「边沿计数(中心轴)」「均值滤波」「弱波增强」共用同一套样式,
// 仅标题、内容行与底部按钮不同。
//
// 面板是**静态**内容:只有被拖动 / 悬停 / 参数变化的那一个真正重画,
// 其余直接贴上一帧缓存好的像素(15 个面板全画一遍实测约 33ms,
// 而贴图 <1ms —— 拖动弱波增强滑块时的主要卡顿就来自这一层)。
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;
for (int pass = 0; pass < kEdgePanelKindCount; ++pass)
{
const EdgePanelKind kind = kEdgePanelKinds[pass];
const bool centerAxis = (kind == EdgePanelKind::CENTER);
const bool meanFilter = (kind == EdgePanelKind::MEAN);
const bool weakBoost = (kind == EdgePanelKind::BOOST);
// 「均值滤波」与「弱波增强」共用「逐条目 + 底部按钮」排布。
const bool itemized = (centerAxis || meanFilter || weakBoost);
const bool hasButton = (meanFilter || weakBoost);
for (std::size_t index = 0; index < count; ++index)
{
const Lane& tabLane = tabLanes[index];
const EdgePanelLayout layout = EdgePanelLayoutFor(static_cast<int>(index), kind);
const gd::RectF& panel = layout.panel;
if (panel.Width <= 0.0f || panel.Height <= 0.0f)
{
continue;
}
// 面板自身的内容指纹;命中缓存时直接贴图,本帧不再重画这个面板。
// ⚠️ 缓存像素里含**它与它下面的一切**(面板背后的波形、被它遮住的其他面板),
// 因此只要有一个面板重画,所有「在它之后绘制、且与它重叠」的面板也必须重画,
// 否则后画者会把旧像素重新盖回去(面板默认位置本来就常常叠在一起)。
const float cacheMargin = 4.0f * m_scale;
const gd::RectF cachedRect(panel.X - cacheMargin, panel.Y - cacheMargin,
panel.Width + cacheMargin * 2.0f,
panel.Height + cacheMargin * 2.0f);
bool forcedDirty = false;
for (const gd::RectF& dirtyRect : dirtyRects)
{
if (dirtyRect.IntersectsWith(cachedRect))
{
forcedDirty = true;
break;
}
}
std::uint64_t slotKey = 0;
const std::size_t slotIndex = nextSlots.size();
if (panelCacheReady)
{
slotKey = PanelSlotKey(static_cast<int>(index), kind, layout);
if (panelGlobalSame && !forcedDirty)
{
const PanelSlot* hit = nullptr;
for (const PanelSlot& slot : m_panelSlots)
{
if (slot.laneIndex == static_cast<int>(index) &&
slot.kind == static_cast<int>(kind))
{
hit = &slot;
break;
}
}
if (hit != nullptr && hit->key != 0 && hit->key == slotKey)
{
HDC target = g.GetHDC();
if (target != nullptr)
{
BitBlt(target, hit->x0, hit->y0, hit->x1 - hit->x0, hit->y1 - hit->y0,
m_panelDc, hit->x0, hit->y0, SRCCOPY);
g.ReleaseHDC(target);
}
nextSlots.push_back(*hit);
continue;
}
}
}
nextSlots.push_back(PanelSlot());
dirtyRects.push_back(cachedRect);
const float titleHeight = kEdgePanelTitleHeight * m_scale;
const float rowHeight = kEdgePanelRowHeight * m_scale;
const float pad = kEdgePanelPad * m_scale;
// 面板主体 + 标题栏。
UiFillRoundRect(g, gd::RectF(panel.X + 1.0f * m_scale, panel.Y + 2.0f * m_scale,
panel.Width, panel.Height),
5.0f * m_scale, 0x14000000u);
UiFillRoundRect(g, panel, 5.0f * m_scale, WithAlpha(m_theme->cardBg, 0xF4));
UiDrawRoundRect(g, panel, 5.0f * m_scale, m_theme->borderStrong, 1.0f);
UiFillRoundRect(g, gd::RectF(panel.X, panel.Y, panel.Width, titleHeight), 5.0f * m_scale,
WithAlpha(m_theme->accent, 0x2A));
// 标题:与左侧信号面板一致的 “GX” 徽标。
const float badgeWidth = kLaneBadgeWidth * m_scale;
const float badgeHeight = kLaneBadgeHeight * m_scale;
const gd::RectF badgeRect(panel.X + pad, panel.Y + (titleHeight - badgeHeight) * 0.5f,
badgeWidth, badgeHeight);
UiFillRoundRect(g, badgeRect, badgeHeight * 0.5f, m_theme->cardBg);
UiDrawRoundRect(g, badgeRect, badgeHeight * 0.5f, m_theme->border, 1.0f);
UiText(g, L"G" + std::to_wstring(index + 1), m_fonts->monoSmall, badgeRect,
m_theme->text, TextAlign::CENTER);
const std::wstring title =
meanFilter ? std::wstring(L"均值滤波")
: (weakBoost ? std::wstring(kWeakBoostTitle)
: ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") +
EdgeRangeSuffix()));
UiText(g, title, m_fonts->uiSmall,
gd::RectF(badgeRect.X + badgeRect.Width + 8.0f * m_scale, panel.Y,
panel.Width - badgeRect.Width - 8.0f * m_scale - titleHeight,
titleHeight),
m_theme->textStrong, TextAlign::LEFT);
// 关闭按钮 ✕:悬停时高亮提示(鼠标光标本身不改变样式)。
const gd::RectF& closeRect = layout.close;
const bool closeHot =
(m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) &&
UiPointInRect(closeRect, static_cast<float>(m_mouseX), static_cast<float>(m_mouseY));
if (closeHot)
{
UiFillRoundRect(g, gd::RectF(closeRect.X + 3.0f * m_scale, closeRect.Y + 3.0f * m_scale,
closeRect.Width - 6.0f * m_scale,
closeRect.Height - 6.0f * m_scale),
3.0f * m_scale, WithAlpha(m_theme->danger, 0x33));
}
const float crossPad = 8.0f * m_scale;
const std::uint32_t crossColor = closeHot ? m_theme->danger : m_theme->textMuted;
UiDrawLine(g, closeRect.X + crossPad, closeRect.Y + crossPad,
closeRect.X + closeRect.Width - crossPad,
closeRect.Y + closeRect.Height - crossPad, crossColor, 1.6f * m_scale);
UiDrawLine(g, closeRect.X + closeRect.Width - crossPad, closeRect.Y + crossPad,
closeRect.X + crossPad, closeRect.Y + closeRect.Height - crossPad, crossColor,
1.6f * m_scale);
// 「中心轴边沿计数」/「均值滤波」/「弱波增强」面板都采用「逐条目」排布:
// 第一排 = 名称 + 复选框,第二排 = 两个参数输入框(仅勾选的条目,
// 弱波增强没有参数输入框),中心轴再多第三排 = 对应的统计结果。
if (itemized)
{
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex)
{
const SignalRef& ref = layout.rowRefs[rowIndex];
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.style == nullptr)
{
continue;
}
const std::uint32_t signalColor = StyleColor(*resolved.style);
const gd::RectF& checkRect = layout.rowChecks[rowIndex];
const bool checked = meanFilter ? tabLane.MeanFilterEnabled(ref)
: (weakBoost ? tabLane.WeakBoostEnabled(ref)
: tabLane.EdgeCenterEnabled(ref));
// 名称:第一排左侧,避开右侧复选框。
const float nameWidth =
std::max(20.0f, checkRect.X - (panel.X + pad) - 6.0f * m_scale);
UiText(g, UiEllipsize(resolved.data->Title(), 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;
}
// 第二排:两个输入框(「均值点数 X / 迭代次数 N」;弱波增强没有参数输入框)。
const Lane::MeanFilterItem* item = meanFilter ? tabLane.FindMeanFilterItem(ref)
: tabLane.FindEdgeCenterItem(ref);
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], L"迭代次数 N", 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;
EdgeCenterCountsFor(*resolved.file, *resolved.data, statsPoints, statsOrder,
EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising,
falling);
const std::wstring counts = L"上升沿 " + std::to_wstring(rising) +
L" 下降沿 " + std::to_wstring(falling);
UiText(g, counts, m_fonts->monoSmall, statsRect, m_theme->textMuted,
TextAlign::LEFT);
}
}
// 「弱波增强」的两个滑块(等级 0–100% / 迭代次数 N 1–10):
// 左侧说明文字 + 轨道 + 右侧数值。
if (weakBoost && layout.slider.Width > 0.0f)
{
const float rowHeight =
std::max(kWeakBoostSliderRowHeight * m_scale, layout.slider.Height);
// 两行滑块的说明文字必须**左对齐到同一列**(用户明确要求):
// 列宽取两条标签中较宽者,与 `EdgePanelLayoutFor` 里定位轨道的方式一致。
const float labelColumnWidth =
std::max(UiMeasureTextWidth(m_fonts->uiSmall, kWeakBoostLevelLabel),
UiMeasureTextWidth(m_fonts->uiSmall, kWeakBoostIterationsLabel));
const auto paintSliderRow = [&](const gd::RectF& track, const wchar_t* label,
double ratio, const std::wstring& valueText,
bool active)
{
if (track.Width <= 0.0f)
{
return;
}
const float labelLeft = track.X - 6.0f * m_scale - labelColumnWidth;
UiText(g, label, m_fonts->uiSmall,
gd::RectF(labelLeft, track.Y + track.Height * 0.5f - rowHeight * 0.5f,
labelColumnWidth, rowHeight),
m_theme->textMuted, TextAlign::LEFT);
// 与样式区/拾色器共用同一套滑块绘制(轨道 + 已填充部分 + 圆钮)。
UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale);
const float valueWidth = UiMeasureTextWidth(m_fonts->monoSmall, valueText);
UiText(g, valueText, m_fonts->monoSmall,
gd::RectF(track.X + track.Width + 6.0f * m_scale,
track.Y + track.Height * 0.5f - rowHeight * 0.5f,
valueWidth, rowHeight),
m_theme->textStrong, TextAlign::LEFT);
};
const double level = std::max(0.0, std::min(1.0, tabLane.weakBoostLevel));
const bool levelActive = (m_dragMode == PlotDragMode::BOOST_LEVEL) &&
(m_boostDragLane == static_cast<int>(index));
paintSliderRow(layout.slider, kWeakBoostLevelLabel, level,
std::to_wstring(static_cast<int>(std::lround(level * 100.0))) +
L"%",
levelActive);
const int iterations = std::max(kWeakBoostIterationsMin,
std::min(kWeakBoostIterationsMax, tabLane.weakBoostIterations));
const double orderRatio = static_cast<double>(iterations - kWeakBoostIterationsMin) /
static_cast<double>(kWeakBoostIterationsMax -
kWeakBoostIterationsMin);
const bool orderActive = (m_dragMode == PlotDragMode::BOOST_ITERATIONS) &&
(m_boostDragLane == static_cast<int>(index));
paintSliderRow(layout.sliderIterations, kWeakBoostIterationsLabel, orderRatio,
std::to_wstring(iterations), orderActive);
}
}
float rowY = panel.Y + titleHeight + pad;
for (const SignalRef& ref : tabLane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible)
{
continue;
}
if (itemized)
{
// 逐条目绘制已在上面完成。
continue;
}
const std::uint32_t signalColor = StyleColor(*resolved.style);
const float textWidth = panel.Width - pad * 2.0f;
// 名称与统计数字一律完整绘制(不省略):面板宽度已按文本实测宽度确定。
UiText(g, resolved.data->Title(), m_fonts->uiSmall,
gd::RectF(panel.X + pad, rowY, textWidth, rowHeight), signalColor,
TextAlign::LEFT);
std::size_t rising = 0;
std::size_t falling = 0;
EdgeCountsFor(*resolved.file, *resolved.data, tabLane.edgeValue, EdgeRangeActive(),
EdgeRangeMin(), EdgeRangeMax(), rising, falling);
const std::wstring counts = L"上升沿 " + std::to_wstring(rising) + L" 下降沿 " +
std::to_wstring(falling);
UiText(g, counts, m_fonts->monoSmall,
gd::RectF(panel.X + pad, rowY + rowHeight, textWidth, rowHeight),
m_theme->textMuted, TextAlign::LEFT);
rowY += rowHeight * 2.0f;
}
// 逐条目面板底部按钮行:「构建为新信号」(悬停高亮)。
if (hasButton)
{
const gd::RectF& buttonRect = layout.button;
if (buttonRect.Width > 0.0f)
{
const gd::RectF inner(buttonRect.X, buttonRect.Y + 2.0f * m_scale,
buttonRect.Width, buttonRect.Height - 4.0f * m_scale);
const bool hot =
(m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) &&
UiPointInRect(inner, static_cast<float>(m_mouseX),
static_cast<float>(m_mouseY));
UiFillRoundRect(g, inner, 4.0f * m_scale,
hot ? WithAlpha(m_theme->accent, 0x3A) : m_theme->cardBg);
UiDrawRoundRect(g, inner, 4.0f * m_scale, m_theme->accent, 1.2f);
UiText(g, kBuildSignalLabel, m_fonts->uiSmall, inner, m_theme->accent,
TextAlign::CENTER);
}
}
// 面板画完了:把它刚画进后备缓冲区的像素(连同缓存键)存进面板图层,
// 下一帧只要键不变就直接贴图。矩形外的 4 逻辑像素边距用于容纳投影。
PanelSlot& stored = nextSlots[slotIndex];
stored.laneIndex = static_cast<int>(index);
stored.kind = static_cast<int>(kind);
stored.key = 0;
if (panelCacheReady && slotKey != 0)
{
const int x0 = std::max(0, static_cast<int>(std::floor(cachedRect.X)));
const int y0 = std::max(0, static_cast<int>(std::floor(cachedRect.Y)));
const int x1 = std::max(x0, static_cast<int>(std::ceil(cachedRect.X + cachedRect.Width)));
const int y1 = std::max(y0, static_cast<int>(std::ceil(cachedRect.Y + cachedRect.Height)));
const gd::RectF layerRect(static_cast<float>(x0), static_cast<float>(y0),
static_cast<float>(x1 - x0), static_cast<float>(y1 - y0));
if (x1 > x0 && y1 > y0 && PanelRectInsideLayer(layerRect))
{
HDC source = g.GetHDC();
if (source != nullptr)
{
const BOOL ok = BitBlt(m_panelDc, x0, y0, x1 - x0, y1 - y0, source, x0, y0,
SRCCOPY);
g.ReleaseHDC(source);
if (ok != FALSE)
{
stored.key = slotKey;
stored.x0 = x0;
stored.y0 = y0;
stored.x1 = x1;
stored.y1 = y1;
}
}
}
}
}
}
m_panelSlots = std::move(nextSlots);
m_panelGlobalKey = panelGlobalKey;
m_panelValid = panelCacheReady;
}
void PlotView::PaintEditField(gd::Graphics& g)
{
const gd::RectF fieldRect = EditFieldRect();
if (fieldRect.Width <= 0.0f || fieldRect.Height <= 0.0f)
{
return;
}
// 编辑态:强调色外框 + 选区高亮 + 插入符,首次键入即整体替换(与常规输入框一致)。
UiDrawEditField(g, fieldRect, m_edit, m_fonts->monoSmall, m_scale);
}
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);
}
}
g.Restore(saveState);
}
// 落点提示:高亮鼠标所在网格并给出文字说明。
const int target = (m_dragTargetLane >= 0) ? m_dragTargetLane : sourceLane;
if (target >= 0 && target < static_cast<int>(m_lanes.size()))
{
const gd::RectF& targetRect = m_lanes[static_cast<std::size_t>(target)].rect;
const float visibleTop = std::max(targetRect.Y, m_plotRect.Y);
const float visibleBottom = std::min(targetRect.Y + targetRect.Height,
m_plotRect.Y + m_plotRect.Height);
if (visibleBottom > visibleTop)
{
gd::Pen frame(ToGdiColor(m_theme->accent), 2.0f);
g.DrawRectangle(&frame, targetRect.X, visibleTop, targetRect.Width,
visibleBottom - visibleTop);
const std::wstring hint = (target == sourceLane)
? std::wstring(L"松开左键放回本网格")
: (L"移动到网格 " + std::to_wstring(target + 1) +
L":松开左键确认");
const float textWidth = UiMeasureTextWidth(m_fonts->uiSmall, hint) + 16.0f * m_scale;
const float height = 20.0f * m_scale;
float left = targetRect.X + (targetRect.Width - textWidth) * 0.5f;
left = std::max(m_plotRect.X, std::min(left, m_plotRect.X + m_plotRect.Width - textWidth));
const gd::RectF pill(left, visibleTop + 4.0f * m_scale, textWidth, height);
UiFillRoundRect(g, pill, 3.0f * m_scale, m_theme->accent);
UiText(g, hint, m_fonts->uiSmall, pill, m_theme->textOnAccent, TextAlign::CENTER);
}
}
return;
}
// 其它拖拽(光标/平移/中键/横向光标/面板)期间不显示信号悬停提示:
// 这些操作不会更新 m_hoverSignal,按下前的值会残留,表现为提示气泡"挂在原地"。
if (m_dragMode != PlotDragMode::NONE)
{
return;
}
// 悬停提示:光标贴近曲线时给出可拖拽说明(与时间光标的提示方式一致)。
if (!m_hoverSignal.IsValid())
{
return;
}
const int laneIndex = m_tab->LaneOfSignal(m_hoverSignal);
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
return;
}
const ResolvedSignal resolved = ResolveSignal(m_hoverSignal);
if (resolved.data == nullptr)
{
return;
}
const LaneVisual& lane = m_lanes[static_cast<std::size_t>(laneIndex)];
const std::wstring text = resolved.data->Title() + 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);
}
} // namespace jdi