派生曲线计算是可延迟的后台工作,不能与 UI/绘制线程抢核。后台线程池的每个线程设为 THREAD_PRIORITY_BELOW_NORMAL,UI 线程需要运行时系统优先调度 UI,计算只在 UI 空闲时占用 CPU。
9513 lines
334 KiB
C++
9513 lines
334 KiB
C++
#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
|