1. 合并网格后新增信号不再挤进合并网格
- 分组复选框与「全部显示」按钮此前只更新可见性,没有像单信号勾选那样把划分策略
切到自定义,于是合并网格把新增信号一并吞掉。
- 现在两者在确有信号由不可见变可见时同样切换到自定义划分,
新增信号各自独占一个网格并追加在列表最下方,原合并网格保持不变。
2. 适应视图不再取消「选区放大」模式与「选区 Y 轴自适应」
- 此前适应视图会把缩放模式复位为无,并把全部网格的 autoRange 置真;
而自动适应区间只由 autoRange 决定,置真等于把已经算好的时间段幅值丢掉,
表现为"点一下适应视图,Y 轴自适应就失效了"。
- 现在保留缩放模式;开启了「选区 Y 轴自适应」的网格改为按新的时间视图重新匹配,
其余网格才恢复整段自适应。
3. 「按网格时间轴展开」联动其它网格
- 时间视图是全局的,展开后其余开启了「选区 Y 轴自适应」的网格同样重新匹配 Y 轴,
不再只有被展开的那一个网格量程合适。
4. 自定义颜色面板排版重做
- R/G/B 的标签、滑块与数值改为同属右侧一列并共用同一行,标签紧贴各自的滑块;
预览色块与色号移到左列下方并与色域方块同宽,色号编辑框不再超出面板边界;
面板高度按内容重新计算,底部不再留空。
- 色域方块上的选点去掉外圈深色描边,改为"中心为所选颜色 + 白色描边"。
- 色相指示器由黑色横条改为白色描边的圆角长方条,底色取该位置的颜色;
色相条本身改用分段线性渐变绘制。
5. 修复光标数据点向左/向右越界时的三角
- 三角沿朝向伸出的深度大于贴边时距边界的距离,导致有一大半画在网格绘制区之外,
裁剪后只剩残缺一角、描边也因此不完整。
- 现在左右越界时把锚点向内推一个三角深度,三角完整落在绘制区内;
同时四方向越界都让三角与显示的数值纵向居中对齐。
6866 lines
228 KiB
C++
6866 lines
228 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 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 kEdgePanelEditHeight = 30.0f; /**< 中心轴面板底部编辑行高度。 */
|
||
const float kEdgePanelEditFieldWidth = 64.0f; /**< 编辑行数值框的最小宽度。 */
|
||
const float kEdgePanelEditFieldGap = 8.0f; /**< 编辑行说明文字与数值框之间的间隔。 */
|
||
const float kEdgePanelButtonHeight = 30.0f; /**< 均值滤波面板底部按钮行高度。 */
|
||
const wchar_t* const kMeanFilterBuildLabel = L"构建为新信号"; /**< 均值滤波面板底部按钮文案。 */
|
||
const std::size_t kEdgeCountCacheLimit = 48; /**< 边沿统计缓存条目上限(超出时淘汰最早的一条)。 */
|
||
|
||
/**
|
||
* @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};
|
||
|
||
/**
|
||
* @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()
|
||
{
|
||
ReleaseSceneCache();
|
||
}
|
||
|
||
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()
|
||
{
|
||
m_polylineCache.clear();
|
||
// 数据层结构变化后边沿统计与中心平均值曲线的派生结果都会失效。
|
||
m_edgeCountCache.clear();
|
||
m_edgeCenterCountCache.clear();
|
||
m_centerCurveCache.clear();
|
||
// 信号下标可能已经整体位移:清掉按引用缓存的悬停/拖拽目标,避免指向错误的信号。
|
||
m_hoverSignal = SignalRef();
|
||
m_hoverLegend = SignalRef();
|
||
m_dragSignal = SignalRef();
|
||
m_dragTargetLane = -1;
|
||
InvalidateSceneCache();
|
||
}
|
||
|
||
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));
|
||
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 (which == 2 && Tab().CursorMode() == PlotCursorMode::DOUBLE)
|
||
{
|
||
const gd::RectF first = rawRect(1);
|
||
const float gap = 2.0f * m_scale;
|
||
const bool overlapX = (rect.X < first.X + first.Width + gap) &&
|
||
(first.X < rect.X + rect.Width + gap);
|
||
if (overlapX)
|
||
{
|
||
rect.Y = std::max(m_plotRect.Y + 2.0f * m_scale, first.Y - rect.Height - gap);
|
||
}
|
||
}
|
||
|
||
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.edgeCenterSmooth < 0)
|
||
{
|
||
lane.edgeCenterSmooth = 0;
|
||
}
|
||
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::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::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.meanFilterRadius < 0)
|
||
{
|
||
lane.meanFilterRadius = 0;
|
||
}
|
||
lane.meanFilterPanelOpen = true;
|
||
}
|
||
else
|
||
{
|
||
lane.meanFilterPanelOpen = false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
bool PlotView::ToggleLaneMeanFilter(int laneIndex)
|
||
{
|
||
const bool enabled = !LaneMeanFilter(laneIndex);
|
||
SetLaneMeanFilter(laneIndex, enabled);
|
||
return enabled;
|
||
}
|
||
|
||
bool PlotView::TakePendingMeanFilterBuild(int& laneIndex, int& radius)
|
||
{
|
||
if (m_pendingMeanFilterLane < 0)
|
||
{
|
||
return false;
|
||
}
|
||
laneIndex = m_pendingMeanFilterLane;
|
||
radius = m_pendingMeanFilterRadius;
|
||
m_pendingMeanFilterLane = -1;
|
||
m_pendingMeanFilterRadius = 0;
|
||
return true;
|
||
}
|
||
|
||
const std::vector<float>& PlotView::CenterCurveFor(const SignalData& signal, int smooth) const
|
||
{
|
||
const int radius = std::max(0, smooth);
|
||
for (const CenterCurveEntry& entry : m_centerCurveCache)
|
||
{
|
||
if (entry.signal == &signal && entry.smooth == radius)
|
||
{
|
||
return entry.values;
|
||
}
|
||
}
|
||
|
||
CenterCurveEntry entry;
|
||
entry.signal = &signal;
|
||
entry.smooth = radius;
|
||
// 中心平均值曲线与「均值滤波」共用同一实现(DataSet 内的自由函数),
|
||
// 因此预览曲线与"构建为新信号"得到的采样值逐位一致。
|
||
ComputeMovingAverage(signal.values, radius, entry.values);
|
||
|
||
m_centerCurveCache.push_back(std::move(entry));
|
||
return m_centerCurveCache.back().values;
|
||
}
|
||
|
||
bool PlotView::CenterValueAt(const DataFile& file, const SignalData& signal, int smooth,
|
||
double time, double& value) const
|
||
{
|
||
const int index = FindNearestSample(file, time);
|
||
if (index < 0)
|
||
{
|
||
return false;
|
||
}
|
||
const std::vector<float>& curve = CenterCurveFor(signal, smooth);
|
||
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;
|
||
}
|
||
|
||
bool PlotView::MeanFilterValueAt(const DataFile& file, const SignalData& signal, int radius,
|
||
double time, double& value) const
|
||
{
|
||
// 均值滤波曲线与中心平均值曲线是同一算法,取值路径完全一致。
|
||
return CenterValueAt(file, signal, radius, time, value);
|
||
}
|
||
|
||
void PlotView::EdgeCenterCountsFor(const DataFile& file, const SignalData& signal, int smooth,
|
||
bool hasRange, double rangeMin, double rangeMax,
|
||
std::size_t& rising, std::size_t& falling) const
|
||
{
|
||
rising = 0;
|
||
falling = 0;
|
||
|
||
const std::vector<float>& curve = CenterCurveFor(signal, smooth);
|
||
const std::size_t count = std::min(curve.size(), signal.values.size());
|
||
std::size_t begin = 0;
|
||
std::size_t end = 0;
|
||
const bool indexed = EdgeIndexRange(file, count, hasRange, rangeMin, rangeMax, begin, end);
|
||
|
||
// 判定基准:信号与中心平均值曲线之差(区间之外的采样返回 NaN)。
|
||
const auto deltaAt = [&signal, &file, &curve, count, hasRange, rangeMin,
|
||
rangeMax](std::size_t index) -> double
|
||
{
|
||
if (index >= count)
|
||
{
|
||
return std::numeric_limits<double>::quiet_NaN();
|
||
}
|
||
const double value = signal.values[index];
|
||
const float center = curve[index];
|
||
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);
|
||
};
|
||
|
||
// 按「文件 + 信号 + 平滑半径 + 是否限定区间」定位缓存项(**时间区间不进缓存键**)。
|
||
EdgeCenterCountEntry* entry = nullptr;
|
||
for (EdgeCenterCountEntry& candidate : m_edgeCenterCountCache)
|
||
{
|
||
if (candidate.file == &file && candidate.signal == &signal &&
|
||
candidate.hasRange == hasRange && candidate.smooth == smooth)
|
||
{
|
||
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.smooth = smooth;
|
||
created.hasRange = hasRange;
|
||
m_edgeCenterCountCache.push_back(created);
|
||
entry = &m_edgeCenterCountCache.back();
|
||
}
|
||
|
||
// 区间完全一致 → 直接命中。
|
||
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)
|
||
{
|
||
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, deltaAt, deltaRising,
|
||
deltaFalling);
|
||
rising = (end > oldEnd) ? (entry->rising + deltaRising) : (entry->rising - deltaRising);
|
||
falling = (end > oldEnd) ? (entry->falling + deltaFalling) : (entry->falling - deltaFalling);
|
||
}
|
||
else if (shareEnd && !shareBegin)
|
||
{
|
||
const std::size_t low = std::min(oldBegin, begin);
|
||
const std::size_t high = std::max(oldBegin, begin);
|
||
AccumulateEdgeCrossings(low + 1, high, deltaAt, 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, 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);
|
||
|
||
// 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” / “均值滤波” + 光标区间尾缀)。
|
||
const std::wstring title =
|
||
meanFilter ? std::wstring(L"均值滤波")
|
||
: ((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 (meanFilter)
|
||
{
|
||
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + nameWidth);
|
||
continue;
|
||
}
|
||
std::size_t rising = 0;
|
||
std::size_t falling = 0;
|
||
if (centerAxis)
|
||
{
|
||
EdgeCenterCountsFor(*resolved.file, *resolved.data, lane.edgeCenterSmooth,
|
||
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)
|
||
{
|
||
// 底部编辑行 = “平滑点数 / 均值点数”说明文字 + 数值框(与菜单内联编辑同一套排版习惯)。
|
||
const int shown = meanFilter ? lane.meanFilterRadius : lane.edgeCenterSmooth;
|
||
const std::wstring label = meanFilter ? L"均值点数 N" : L"平滑点数 N";
|
||
const float editWidth =
|
||
UiMeasureTextWidth(m_fonts->uiSmall, label) + kEdgePanelEditFieldGap * m_scale +
|
||
std::max(kEdgePanelEditFieldWidth * m_scale,
|
||
UiMeasureTextWidth(m_fonts->monoSmall, std::to_wstring(shown)) + 20.0f * m_scale);
|
||
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + editWidth);
|
||
}
|
||
|
||
if (meanFilter)
|
||
{
|
||
// 底部按钮行 = 「构建为新信号」,按按钮文字实测宽度确定下限。
|
||
const float buttonWidth =
|
||
UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterBuildLabel) + 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_SMOOTH ||
|
||
m_editTarget == PlotEditTarget::MEAN_FILTER_RADIUS)
|
||
{
|
||
// 「平滑点数 / 均值点数 N」:输入框落在面板底部的编辑行内(靠右,与显示态对齐)。
|
||
if (m_editFieldRect.Width <= 0.0f)
|
||
{
|
||
return gd::RectF();
|
||
}
|
||
const float fieldWidth = std::max(width, kEdgePanelEditFieldWidth * m_scale);
|
||
const float fieldHeight = std::max(height, m_editFieldRect.Height - 6.0f * m_scale);
|
||
return gd::RectF(m_editFieldRect.X + m_editFieldRect.Width - fieldWidth,
|
||
m_editFieldRect.Y + 3.0f * m_scale, fieldWidth, 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;
|
||
}
|
||
|
||
// 「平滑点数 N」:只作用于中心平均值曲线的计算,夹在合理范围内(0–1000000)。
|
||
if (target == PlotEditTarget::EDGE_CENTER_SMOOTH)
|
||
{
|
||
int smooth = static_cast<int>(std::lround(parsed));
|
||
smooth = std::max(0, std::min(smooth, 1000000));
|
||
m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].edgeCenterSmooth = smooth;
|
||
// 平滑半径变化会改变曲线与统计结果:清掉派生缓存后重绘。
|
||
m_centerCurveCache.clear();
|
||
m_edgeCenterCountCache.clear();
|
||
InvalidateSceneCache();
|
||
return true;
|
||
}
|
||
|
||
// 「均值点数 N」:只作用于均值滤波曲线的计算,夹在合理范围内(0–1000000)。
|
||
if (target == PlotEditTarget::MEAN_FILTER_RADIUS)
|
||
{
|
||
int radius = static_cast<int>(std::lround(parsed));
|
||
radius = std::max(0, std::min(radius, 1000000));
|
||
m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].meanFilterRadius = radius;
|
||
// 均值半径变化会改变曲线:清掉派生缓存后重绘(面板宽度也随之变化)。
|
||
m_centerCurveCache.clear();
|
||
InvalidateSceneCache();
|
||
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::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);
|
||
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 nullptr;
|
||
}
|
||
if (UiPointInRect(layout.title, static_cast<float>(x), static_cast<float>(y)))
|
||
{
|
||
return LoadCursorW(nullptr, IDC_SIZEALL);
|
||
}
|
||
if (kind == EdgePanelKind::MEAN && 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);
|
||
}
|
||
}
|
||
|
||
// 光标时间读数标签与双光标差值标签:**悬停(未按下)时不改变鼠标形状**。
|
||
// 两个标签都叠在光标线的正上方,因此必须先于"光标线"判定返回 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 left = static_cast<float>(std::min(x, m_dragStartX));
|
||
const float width = static_cast<float>(std::abs(x - m_dragStartX));
|
||
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 top = static_cast<float>(std::min(y, m_dragStartY));
|
||
const float bottom = static_cast<float>(std::max(y, m_dragStartY));
|
||
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;
|
||
}
|
||
|
||
if (m_hoverCursor == 2 && Tab().CursorMode() == PlotCursorMode::DOUBLE)
|
||
{
|
||
Tab().CursorTimeAlt() = target;
|
||
}
|
||
else
|
||
{
|
||
Tab().CursorTime() = target;
|
||
}
|
||
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;
|
||
Tab().CursorTime() = m_spanDragTime1 + delta;
|
||
Tab().CursorTimeAlt() = m_spanDragTime2 + delta;
|
||
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::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::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;
|
||
}
|
||
|
||
// 时间光标不再跟随鼠标移动,仅在鼠标覆盖光标时才显示具体时间读数。
|
||
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;
|
||
// 新的交互开始:清掉上一次留下的提示(拖动过程中会重新设置粘滞提示)。
|
||
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;
|
||
}
|
||
}
|
||
|
||
// 实验性功能浮动面板(面板压在波形之上,必须先于信号拖拽判定):
|
||
// * ✕ → 关闭该网格对应的功能;
|
||
// * 底部编辑行 → 进入「平滑点数 / 均值点数 N」内联编辑;
|
||
// * 底部按钮 → 触发「构建为新信号」请求;
|
||
// * 标题栏 → 拖动面板;
|
||
// * 面板主体 → 消费点击,但**不拖动也不穿透**(用户明确要求只有标题栏可拖拽)。
|
||
for (int pass = 0; pass < 3; ++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;
|
||
}
|
||
|
||
if ((kind == EdgePanelKind::CENTER || kind == EdgePanelKind::MEAN) &&
|
||
layout.edit.Width > 0.0f && UiPointInRect(layout.edit, fx, fy) && m_tab != nullptr)
|
||
{
|
||
// 底部编辑行 → 就地编辑参数 N(输入框落在该行内)。
|
||
const Lane& tabLane = m_tab->Lanes()[static_cast<std::size_t>(index)];
|
||
if (kind == EdgePanelKind::MEAN)
|
||
{
|
||
BeginEdit(PlotEditTarget::MEAN_FILTER_RADIUS, index,
|
||
std::to_wstring(tabLane.meanFilterRadius), layout.edit);
|
||
}
|
||
else
|
||
{
|
||
BeginEdit(PlotEditTarget::EDGE_CENTER_SMOOTH, index,
|
||
std::to_wstring(tabLane.edgeCenterSmooth), layout.edit);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
if (kind == EdgePanelKind::MEAN && layout.button.Width > 0.0f &&
|
||
UiPointInRect(layout.button, fx, fy) && m_tab != nullptr)
|
||
{
|
||
// 「构建为新信号」:只登记请求,真正的信号构建由宿主(Application)完成。
|
||
SetHint(L"正在构建均值滤波信号(" +
|
||
std::to_wstring(m_tab->Lanes()[static_cast<std::size_t>(index)].meanFilterRadius) +
|
||
L" 点均值)…");
|
||
m_pendingMeanFilterLane = index;
|
||
m_pendingMeanFilterRadius =
|
||
m_tab->Lanes()[static_cast<std::size_t>(index)].meanFilterRadius;
|
||
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;
|
||
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::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::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;
|
||
// 松开左键:粘滞提示结束计时,开始"停留后淡出"。
|
||
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_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);
|
||
// 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) const
|
||
{
|
||
points.clear();
|
||
breaks.clear();
|
||
band.clear();
|
||
bandBreaks.clear();
|
||
bandMode = 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<gd::PointF> segmentTop;
|
||
std::vector<gd::PointF> segmentBottom;
|
||
const auto flushBandSegment = [&band, &bandBreaks, &segmentTop, &segmentBottom]()
|
||
{
|
||
if (segmentTop.size() < 2)
|
||
{
|
||
segmentTop.clear();
|
||
segmentBottom.clear();
|
||
return;
|
||
}
|
||
bandBreaks.push_back(band.size());
|
||
for (const gd::PointF& point : segmentTop)
|
||
{
|
||
band.push_back(point);
|
||
}
|
||
// 下包络反向,形成闭合环。
|
||
for (std::size_t index = segmentBottom.size(); index-- > 0;)
|
||
{
|
||
band.push_back(segmentBottom[index]);
|
||
}
|
||
segmentTop.clear();
|
||
segmentBottom.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));
|
||
|
||
// 包络带每列都记录(上下各外扩半个线宽,保证细波动也有可见粗细)。
|
||
segmentTop.push_back(gd::PointF(x, yTop - strokeHalf));
|
||
segmentBottom.push_back(gd::PointF(x, yBottom + strokeHalf));
|
||
|
||
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();
|
||
|
||
if (m_lanes.empty())
|
||
{
|
||
InvalidateSceneCache();
|
||
PaintEmptyState(g);
|
||
return;
|
||
}
|
||
|
||
// 贴图之后、静态场景之外的全部内容(随鼠标变化或位于最上层)。
|
||
// 三个分支(完全命中 / 局部重画 / 整幅重绘)共用同一段,保证观感完全一致。
|
||
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);
|
||
|
||
// 绘图区外框(比网格内部分界线更重,形成明确整体边界)。
|
||
gd::Pen borderPen(ToGdiColor(m_theme->axisLine), 1.6f);
|
||
g.DrawRectangle(&borderPen, m_plotRect.X, m_plotRect.Y, m_plotRect.Width, m_plotRect.Height);
|
||
};
|
||
|
||
// 静态场景复用(不再局限于拖拽):只要视图几何、信号集合与显示样式都没变,
|
||
// "绘图区底色 + 全部网格 + 时间轴"就逐像素相同,直接贴图即可。
|
||
// 大文件下整幅重绘约 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);
|
||
|
||
// 此刻画布上正好是"绘图区底色 + 全部网格 + 时间轴",尚未绘制任何随鼠标变化的内容,
|
||
// 因此是捕获静态场景缓存的最佳时点(必须在裁剪区已全部恢复之后执行)。
|
||
CaptureSceneCache(g);
|
||
|
||
paintOverlays();
|
||
}
|
||
|
||
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)
|
||
{
|
||
PaintSignalHalo(g, resolved, cached->points, cached->breaks);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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))
|
||
{
|
||
PaintSignalHalo(g, resolved, cached->points, cached->breaks);
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
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;
|
||
// 「边沿计数(中心轴)」的开关与平滑点数会改变网格内的曲线绘制 → 参与指纹。
|
||
mix(Tab().Lanes()[laneIndex].edgeCenter ? 1ull : 0ull);
|
||
mix(static_cast<std::uint64_t>(Tab().Lanes()[laneIndex].edgeCenterSmooth));
|
||
// 「均值滤波」的开关与均值点数同样会改变网格内的曲线绘制 → 参与指纹。
|
||
mix(Tab().Lanes()[laneIndex].meanFilter ? 1ull : 0ull);
|
||
mix(static_cast<std::uint64_t>(Tab().Lanes()[laneIndex].meanFilterRadius));
|
||
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;
|
||
}
|
||
|
||
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)
|
||
{
|
||
lanes.push_back(static_cast<int>(laneIndex));
|
||
}
|
||
}
|
||
return !lanes.empty();
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
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();
|
||
|
||
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);
|
||
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_sceneGeometryHash = 0;
|
||
}
|
||
|
||
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 std::uint32_t band = m_theme->accent;
|
||
UiFillRect(g, m_rubberRect, WithAlpha(band, 0x28));
|
||
UiDrawRoundRect(g, m_rubberRect, 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 = m_rubberRect.X + m_rubberRect.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, m_rubberRect.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 条”的不一致观感。
|
||
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);
|
||
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;
|
||
}
|
||
UiDrawLine(g, lane.rect.X, y, lane.rect.X + lane.rect.Width, y,
|
||
WithAlpha(m_theme->gridWeak, kMinorLineAlpha), 1.0f);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
const bool zeroLine = (tick == 0.0);
|
||
UiDrawLine(g, lane.rect.X, y, lane.rect.X + lane.rect.Width, y,
|
||
zeroLine ? m_theme->gridStrong : m_theme->gridWeak, 1.0f);
|
||
}
|
||
|
||
// 时间网格线(同样补充五等分估读线)。
|
||
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;
|
||
}
|
||
UiDrawLine(g, x, lane.rect.Y, x, lane.rect.Y + lane.rect.Height,
|
||
WithAlpha(m_theme->gridWeak, kMinorLineAlpha), 1.0f);
|
||
}
|
||
|
||
for (const double tick : timeTicks)
|
||
{
|
||
const float x = TimeToX(tick);
|
||
if (x < lane.rect.X || x > lane.rect.X + lane.rect.Width)
|
||
{
|
||
continue;
|
||
}
|
||
UiDrawLine(g, x, lane.rect.Y, x, lane.rect.Y + lane.rect.Height, m_theme->gridWeak, 1.0f);
|
||
}
|
||
|
||
// 波形绘制。
|
||
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();
|
||
// 「边沿计数(中心轴)」开启时,为本网格每条信号额外叠加一条中心平均值曲线。
|
||
// 该曲线属于静态场景(只在开关/平滑点数变化时改变),因此参与场景缓存指纹。
|
||
const int centerSmooth =
|
||
Tab().Lanes()[static_cast<std::size_t>(laneIndex)].edgeCenter
|
||
? Tab().Lanes()[static_cast<std::size_t>(laneIndex)].edgeCenterSmooth
|
||
: -1;
|
||
// 「均值滤波」开启时同样叠加一条 N 点滑动平均曲线(与中心平均值曲线同一算法)。
|
||
const int meanFilterRadius =
|
||
Tab().Lanes()[static_cast<std::size_t>(laneIndex)].meanFilter
|
||
? Tab().Lanes()[static_cast<std::size_t>(laneIndex)].meanFilterRadius
|
||
: -1;
|
||
|
||
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)
|
||
{
|
||
PaintSignalHalo(g, resolved, points, breaks);
|
||
}
|
||
|
||
if (drawLine && !breaks.empty())
|
||
{
|
||
const std::uint32_t lineColor = StyleColor(*resolved.style);
|
||
if (cached->bandMode && !cached->bandBreaks.empty())
|
||
{
|
||
// 稠密包络:一次 FillPath 填充包络带。
|
||
// **必须显式设为缠绕填充规则**:包络带自交(相邻列上下包络交叉)时,
|
||
// 交替(奇偶)规则会在交叠处挖出空洞。
|
||
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);
|
||
}
|
||
}
|
||
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);
|
||
}
|
||
}
|
||
|
||
// 中心平均值曲线(「实验性功能 → 边沿计数(中心轴)」)叠加绘制在原信号之上。
|
||
// 它只是叠加显示的派生曲线,**不会**加入左侧信号列表。
|
||
if (centerSmooth >= 0)
|
||
{
|
||
PaintCenterCurve(g, resolved, lane, centerSmooth);
|
||
}
|
||
// 均值滤波曲线(「实验性功能 → 均值滤波」):同样是仅用于预览的叠加曲线。
|
||
if (meanFilterRadius >= 0)
|
||
{
|
||
PaintMeanFilterCurve(g, resolved, lane, meanFilterRadius);
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
{
|
||
UiDrawLine(g, lane.rect.X, dividerY, lane.rect.X + lane.rect.Width, dividerY,
|
||
(laneIndex == 0) ? m_theme->axisLine : m_theme->borderStrong,
|
||
(laneIndex == 0) ? 1.6f : 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));
|
||
|
||
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 文本不受 GDI+ 裁剪约束会越过绘图区(改用 UiTextAtClipped 兜底)。
|
||
if (!IsFullyVisible(pill, clipTop, clipBottom))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
UiFillRoundRect(g, pill, 3.0f * m_scale, WithAlpha(m_theme->cardBg, 0xF0));
|
||
const std::wstring text = UiEllipsize(raw, m_fonts->monoSmall, pill.Width - 6.0f * m_scale);
|
||
UiText(g, text, m_fonts->monoSmall,
|
||
gd::RectF(pill.X + 4.0f * m_scale, pill.Y, pill.Width - 8.0f * m_scale, pill.Height),
|
||
(tick == 0.0) ? m_theme->text : m_theme->textMuted, TextAlign::LEFT);
|
||
}
|
||
}
|
||
|
||
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” 徽标样式完全一致。
|
||
const float badgeWidth = kLaneBadgeWidth * m_scale;
|
||
const float badgeHeight = kLaneBadgeHeight * m_scale;
|
||
const gd::RectF badgeRect(cursorX, top + (height - badgeHeight) * 0.5f, badgeWidth, badgeHeight);
|
||
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 徽标使用同一套配色与圆角)。
|
||
UiFillRoundRect(g, badgeRect, badgeHeight * 0.5f, m_theme->cardBg);
|
||
UiDrawRoundRect(g, badgeRect, badgeHeight * 0.5f, m_theme->border, 1.0f);
|
||
UiText(g, L"G" + std::to_wstring(laneIndex + 1), m_fonts->monoSmall, badgeRect,
|
||
m_theme->text, TextAlign::CENTER);
|
||
|
||
// 记录本次绘制命中的条目矩形,供点击选中与双击改名使用。
|
||
for (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;
|
||
UiDrawLine(g, m_plotRect.X, axisY, m_plotRect.X + m_plotRect.Width, axisY, m_theme->axisLine, 1.0f);
|
||
|
||
// 时间轴列名(取自首个可见信号所属文件),贴左侧绘制。
|
||
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);
|
||
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;
|
||
}
|
||
UiDrawLine(g, x, axisY, x, axisY + 3.0f * m_scale, m_theme->axisLine, 1.0f);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
UiDrawLine(g, x, axisY, x, axisY + 4.0f * m_scale, m_theme->axisLine, 1.0f);
|
||
|
||
// 刻度标签夹在绘图区横向范围内,并与左侧列名互不重叠。
|
||
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));
|
||
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);
|
||
}
|
||
|
||
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 order[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 ? order[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 ? order[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;
|
||
float 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 = [&](int smooth, const std::wstring& suffix,
|
||
bool meanFilter)
|
||
{
|
||
double derivedValue = 0.0;
|
||
const bool ok = meanFilter
|
||
? MeanFilterValueAt(*resolved.file, *resolved.data, smooth,
|
||
time, derivedValue)
|
||
: CenterValueAt(*resolved.file, *resolved.data, smooth, time,
|
||
derivedValue);
|
||
if (!ok)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const float rawCenterY = ValueToY(derivedValue, lane);
|
||
DataMarker centerMarker = MakeDataMarker(
|
||
rawX, rawCenterY, pinX, clipTop, clipBottom, kCursorHandleReserve * m_scale);
|
||
// 派生数据点的配色与原始信号一致(便于一一对应);
|
||
// 但**始终按线段模式**绘制(不跟随原信号的散点模式)。
|
||
const std::uint32_t centerColor = StyleColor(*resolved.style);
|
||
|
||
// 取值文本:数值在前,尾缀「(中心轴)/(均值滤波)」;双光标的右侧光标
|
||
// 还要附上增量,形如「(+0.0008 中心轴)」(参考常规信号的增量显示)。
|
||
std::wstring centerText = FormatValue(derivedValue);
|
||
centerText += L"(";
|
||
if (showDelta)
|
||
{
|
||
double otherDerived = 0.0;
|
||
const bool otherOk =
|
||
meanFilter
|
||
? MeanFilterValueAt(*resolved.file, *resolved.data, smooth,
|
||
otherTime, otherDerived)
|
||
: CenterValueAt(*resolved.file, *resolved.data, smooth, otherTime,
|
||
otherDerived);
|
||
if (otherOk)
|
||
{
|
||
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 = 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)
|
||
{
|
||
paintDerivedCapsule(m_tab->Lanes()[laneIndex].edgeCenterSmooth, L"中心轴", false);
|
||
}
|
||
if (m_tab->Lanes()[laneIndex].meanFilter)
|
||
{
|
||
paintDerivedCapsule(m_tab->Lanes()[laneIndex].meanFilterRadius, L"均值滤波", true);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- 底部:时间读数 + 双光标差值 ----
|
||
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 ? order[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();
|
||
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();
|
||
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 enabled = meanFilter ? lane.meanFilter : (centerAxis ? lane.edgeCenter : lane.edgeCount);
|
||
const bool open = meanFilter ? lane.meanFilterPanelOpen
|
||
: (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 editHeight =
|
||
(centerAxis || meanFilter) ? (kEdgePanelEditHeight * m_scale) : 0.0f;
|
||
const float buttonHeight = meanFilter ? (kEdgePanelButtonHeight * m_scale) : 0.0f;
|
||
const float rowsPerSignal = meanFilter ? 1.0f : 2.0f;
|
||
const float height = titleHeight + pad + static_cast<float>(rowCount) * rowHeight * rowsPerSignal +
|
||
pad + editHeight + buttonHeight;
|
||
|
||
// 首次使用(位置为负)时自动放到绘图区右侧,并各网格依次错开,避免完全重叠。
|
||
const double storedX = meanFilter ? lane.meanFilterPanelX
|
||
: (centerAxis ? lane.edgeCenterPanelX : lane.edgePanelX);
|
||
const double storedY = meanFilter ? lane.meanFilterPanelY
|
||
: (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 (centerAxis || meanFilter)
|
||
{
|
||
const float editRowHeight = kEdgePanelEditHeight * m_scale;
|
||
const float editTop = top + height - buttonHeight - editRowHeight;
|
||
layout.edit = gd::RectF(left + pad, editTop, std::max(0.0f, width - pad * 2.0f),
|
||
editRowHeight);
|
||
}
|
||
if (meanFilter)
|
||
{
|
||
const float buttonRowHeight = kEdgePanelButtonHeight * m_scale;
|
||
layout.button = gd::RectF(left + pad, top + height - buttonRowHeight,
|
||
std::max(0.0f, width - pad * 2.0f), buttonRowHeight);
|
||
}
|
||
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::PaintCenterCurve(gd::Graphics& g, const ResolvedSignal& resolved,
|
||
const LaneVisual& lane, int smooth) const
|
||
{ if (resolved.data == nullptr || resolved.file == nullptr || resolved.style == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const std::vector<double>& time = resolved.file->time;
|
||
const std::vector<float>& curve = CenterCurveFor(*resolved.data, smooth);
|
||
const std::size_t count = std::min(time.size(), curve.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)
|
||
{
|
||
begin = static_cast<std::size_t>(
|
||
std::lower_bound(time.begin(), time.end(), viewMin) - time.begin());
|
||
if (begin > 0)
|
||
{
|
||
--begin;
|
||
}
|
||
end = static_cast<std::size_t>(
|
||
std::lower_bound(time.begin(), time.end(), viewMax) - time.begin());
|
||
if (end < count)
|
||
{
|
||
++end;
|
||
}
|
||
}
|
||
if (end <= begin)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// 平滑曲线本身没有尖峰,等间隔抽稀即可(不需要包络层),顶点预算与波形一致。
|
||
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);
|
||
const std::size_t visibleCount = end - begin;
|
||
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;
|
||
|
||
const float limitTop = lane.rect.Y - lane.rect.Height * 4.0f;
|
||
const float limitBottom = lane.rect.Y + lane.rect.Height * 5.0f;
|
||
|
||
for (std::size_t index = begin; index < end; index += stride)
|
||
{
|
||
const float sampled = curve[index];
|
||
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
|
||
{
|
||
points.push_back(gd::PointF(x, y));
|
||
}
|
||
}
|
||
|
||
if (breaks.empty())
|
||
{
|
||
return;
|
||
}
|
||
|
||
// 中心平均值曲线:与原信号**同色相**但大幅拉开明度,使其与原始波形形成高对比
|
||
// (原信号改颜色时曲线随之改变,因为它是从信号色派生出来的);
|
||
// 透明度与原信号保持一致。
|
||
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);
|
||
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));
|
||
}
|
||
}
|
||
|
||
void PlotView::PaintMeanFilterCurve(gd::Graphics& g, const ResolvedSignal& resolved,
|
||
const LaneVisual& lane, int radius) const
|
||
{
|
||
// 均值滤波曲线与「边沿计数(中心轴)」的中心平均值曲线是**同一算法、同一视觉效果**
|
||
// (同色相高对比度的派生配色 + 同透明度),因此直接复用同一段绘制逻辑。
|
||
PaintCenterCurve(g, resolved, lane, radius);
|
||
}
|
||
|
||
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);
|
||
|
||
// 幅值读数:靠网格左侧、纵向居中于横线(与纵向光标的时间读数同款胶囊)。
|
||
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 + 3.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);
|
||
}
|
||
|
||
// ---- 统计浮动面板(最后绘制,覆盖在波形之上)----
|
||
// 「边沿计数」「边沿计数(中心轴)」「均值滤波」共用同一套样式,
|
||
// 仅标题、内容行与底部编辑行/按钮不同。
|
||
for (int pass = 0; pass < 3; ++pass)
|
||
{
|
||
const EdgePanelKind kind = kEdgePanelKinds[pass];
|
||
const bool centerAxis = (kind == EdgePanelKind::CENTER);
|
||
const bool meanFilter = (kind == EdgePanelKind::MEAN);
|
||
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 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"均值滤波")
|
||
: ((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);
|
||
|
||
// 每个信号两行:名称(信号色)+ 上升沿/下降沿数量;
|
||
// 均值滤波面板只需列出被处理的信号名(一行)。
|
||
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;
|
||
}
|
||
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);
|
||
if (meanFilter)
|
||
{
|
||
rowY += rowHeight;
|
||
continue;
|
||
}
|
||
|
||
std::size_t rising = 0;
|
||
std::size_t falling = 0;
|
||
if (centerAxis)
|
||
{
|
||
EdgeCenterCountsFor(*resolved.file, *resolved.data, tabLane.edgeCenterSmooth,
|
||
EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising,
|
||
falling);
|
||
}
|
||
else
|
||
{
|
||
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 (centerAxis || meanFilter)
|
||
{
|
||
const gd::RectF& editRect = layout.edit;
|
||
if (editRect.Width > 0.0f)
|
||
{
|
||
const int shownValue = meanFilter ? tabLane.meanFilterRadius
|
||
: tabLane.edgeCenterSmooth;
|
||
const std::wstring label = meanFilter ? L"均值点数 N" : L"平滑点数 N";
|
||
const float labelWidth = UiMeasureTextWidth(m_fonts->uiSmall, label);
|
||
UiText(g, label, m_fonts->uiSmall,
|
||
gd::RectF(editRect.X, editRect.Y, labelWidth, editRect.Height),
|
||
m_theme->text, TextAlign::LEFT);
|
||
|
||
const float fieldWidth =
|
||
std::max(kEdgePanelEditFieldWidth * m_scale,
|
||
UiMeasureTextWidth(m_fonts->monoSmall, std::to_wstring(shownValue)) +
|
||
20.0f * m_scale);
|
||
const float fieldLeft = editRect.X + editRect.Width - fieldWidth;
|
||
const float fieldHeight = editRect.Height - 6.0f * m_scale;
|
||
const gd::RectF fieldRect(fieldLeft, editRect.Y + 3.0f * m_scale, fieldWidth,
|
||
fieldHeight);
|
||
|
||
const PlotEditTarget editTarget = meanFilter ? PlotEditTarget::MEAN_FILTER_RADIUS
|
||
: PlotEditTarget::EDGE_CENTER_SMOOTH;
|
||
const bool editingThis =
|
||
(m_editTarget == editTarget && m_editLane == static_cast<int>(index));
|
||
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(shownValue), m_fonts->monoSmall, fieldRect,
|
||
m_theme->textStrong, TextAlign::CENTER);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 均值滤波面板底部按钮行:「构建为新信号」(悬停高亮)。
|
||
if (meanFilter)
|
||
{
|
||
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, kMeanFilterBuildLabel, m_fonts->uiSmall, inner, m_theme->accent,
|
||
TextAlign::CENTER);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|