Files
Just-DataInspector/Components/PlotView/Src/PlotView.cpp
T
DeepSeek V4.1 Flash 2d029fb09b 修复合并网格后的新增信号、适应视图联动、调色面板排版与左右越界三角
1. 合并网格后新增信号不再挤进合并网格
   - 分组复选框与「全部显示」按钮此前只更新可见性,没有像单信号勾选那样把划分策略
     切到自定义,于是合并网格把新增信号一并吞掉。
   - 现在两者在确有信号由不可见变可见时同样切换到自定义划分,
     新增信号各自独占一个网格并追加在列表最下方,原合并网格保持不变。

2. 适应视图不再取消「选区放大」模式与「选区 Y 轴自适应」
   - 此前适应视图会把缩放模式复位为无,并把全部网格的 autoRange 置真;
     而自动适应区间只由 autoRange 决定,置真等于把已经算好的时间段幅值丢掉,
     表现为"点一下适应视图,Y 轴自适应就失效了"。
   - 现在保留缩放模式;开启了「选区 Y 轴自适应」的网格改为按新的时间视图重新匹配,
     其余网格才恢复整段自适应。

3. 「按网格时间轴展开」联动其它网格
   - 时间视图是全局的,展开后其余开启了「选区 Y 轴自适应」的网格同样重新匹配 Y 轴,
     不再只有被展开的那一个网格量程合适。

4. 自定义颜色面板排版重做
   - R/G/B 的标签、滑块与数值改为同属右侧一列并共用同一行,标签紧贴各自的滑块;
     预览色块与色号移到左列下方并与色域方块同宽,色号编辑框不再超出面板边界;
     面板高度按内容重新计算,底部不再留空。
   - 色域方块上的选点去掉外圈深色描边,改为"中心为所选颜色 + 白色描边"。
   - 色相指示器由黑色横条改为白色描边的圆角长方条,底色取该位置的颜色;
     色相条本身改用分段线性渐变绘制。

5. 修复光标数据点向左/向右越界时的三角
   - 三角沿朝向伸出的深度大于贴边时距边界的距离,导致有一大半画在网格绘制区之外,
     裁剪后只剩残缺一角、描边也因此不完整。
   - 现在左右越界时把锚点向内推一个三角深度,三角完整落在绘制区内;
     同时四方向越界都让三角与显示的数值纵向居中对齐。
2026-10-10 14:38:54 +08:00

6866 lines
228 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "PlotView.hpp"
#include <algorithm>
#include <cmath>
#include <cstring>
#include <limits>
#include "Utf8Util.hpp"
namespace jdi
{
namespace
{
const int kTimeTickTarget = 10; /**< 时间轴期望刻度数。 */
const int kYTickTarget = 6; /**< 幅值轴期望刻度数(网格较高时)。 */
const int kYTickTargetMin = 3; /**< 幅值轴最少刻度数(网格很矮时也要保证可读数)。 */
const int kYTickTargetMax = 8; /**< 幅值轴最多刻度数。 */
const float kYTickSpacing = 28.0f; /**< 幅值刻度之间的理想间距(逻辑像素)。 */
const float kYLabelMinGap = 15.0f; /**< 幅值标签的最小行距(逻辑像素)。 */
const int kMinorDivisions = 5; /**< 相邻主刻度之间的等分数(用于估读)。 */
const unsigned int kMinorLineAlpha = 0x9Au; /**< 辅助等分线的透明度(越高越清晰)。 */
const float kScrollBarThickness = 9.0f; /**< 滚动条宽度(逻辑像素)。 */
const float kLanePadding = 8.0f; /**< 网格内边距。 */
const size_t kMaxPolylinePoints = 20000; /**< 单条折线最大顶点数(上限保护)。 */
const size_t kPolylinePointsPerPixel = 2; /**< 每个像素列允许的折线顶点数(上/下包络两顶点)。 */
const size_t kMinPolylinePoints = 512; /**< 折线最少顶点数(窗口很窄时仍保留细节)。 */
const float kEnvelopeMinPixelSpan = 0.5f; /**< 包络纵向跨度小于该像素值时合并为单个顶点。 */
const float kCursorHandleReserve = 9.0f; /**< 光标顶部三角把手占用的高度(数据点贴边时需避让)。 */
const float kMarkerEdgeInset = 7.0f; /**< 数据点越界贴边时距网格可见边界的间距。 */
const float kMarkerInsideMargin = 2.0f; /**< 数据点在视界内时允许贴到边界的最小余量。 */
const float kMarkerTriangleArm = 5.0f; /**< 越界方向三角"半宽/半高"(垂直于朝向的半边长)。 */
/** 越界方向三角沿朝向的深度(与光标把手同比例)。 */
const float kMarkerTriangleDepth = kMarkerTriangleArm * 1.4f;
const 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