一、实验性功能浮动面板的交互修正(「边沿计数」与「边沿计数(中心轴)」共用) - 面板只有**标题栏**可以拖拽:鼠标在标题栏显示抓握光标(IDC_SIZEALL), 在面板主体(含统计行与底部编辑行)保持原始箭头,按下主体也不再拖动面板(仅消费点击, 不会穿透到底层的平移/选区/信号拖拽)。 - ✕ 关闭按钮:鼠标悬停时高亮(danger 色底 + danger 色叉),但**不改变鼠标光标样式**。 - 为此新增 `EdgePanelLayout`(panel / title / close / edit / rows)与 `EdgePanelLayoutFor(laneIndex, centerAxis)`,`QueryCursor`、`OnMouseMove`、`OnLButtonDown` 与绘制统一按该布局判定。 二、新增「实验性功能 → 边沿计数(中心轴)」(带 √ 的切换项,按网格独立) - 开启后为网格内**每条信号**叠加绘制一条"中心平均值曲线": 每个数据点 = 自身与左右各 N 个数据点(共 2N+1 点,边界处按实际窗口)的平均值。 用滑动窗口求和构建,整体 O(n),不额外占用前缀和内存;结果按 (信号, N) 缓存 (deque 保证引用有效),数据层变化或 N 改变时清空。 - 该曲线只是叠加显示,**不会**加入左侧信号列表; 为与波形区分,颜色按背景明暗把信号色向黑/白方向拉远(浅色主题加深、深色主题提亮), 线宽略粗于原信号。 - 纵向光标仍会标注这条曲线的取值:在原取值胶囊之外额外绘制一个「中 <值>」胶囊 (与曲线同色、半透明),并复用统一的避让逻辑(PlaceCursorLabel,已抽成函数)。 - 统计规则:**信号从下往上越过中心平均值曲线 → 上升沿;从上往下穿过 → 下降沿** (比较 `signal[i] - center[i]` 的符号变化,NaN 视为断点)。 - 浮动面板标题为「中心轴边沿计数」,样式与「边沿计数」面板一致(GX 徽标 + 标题 + ✕ + 每信号一行名称与「上升沿 N / 下降沿 M」,数字完整不省略、面板宽度按文本实测自适应); 面板**底部固定一行「平滑点数 N」内联编辑设置**(新增 EditFilter::NUMBER 编辑目标 PlotEditTarget::EDGE_CENTER_SMOOTH,夹在 0–1000000),始终位于面板最下方。 - 与双光标模式联动:双光标时标题追加「(光标区间内)」,只统计左右光标之间的区间; 区间端点恒取两光标时间的 min/max,因此把任一光标拖到另一侧(左右交换)后依然正确。 - ✕ 关闭面板的同时关闭该网格的「边沿计数(中心轴)」功能(面板与曲线一并消失)。 验证(TestData/VERY_BIG_DATA.CSV,编码器A相,125%、1725x996): - 面板光标形态探测:主体 = ARROW、标题栏 = SIZEALL、✕ = ARROW; 从主体拖动面板不发生位移(截图核对)。 - ✕ 悬停高亮可见。 - 「边沿计数(中心轴)」:面板标题「中心轴边沿计数」、底部「平滑点数 N = 5」; 中心平均值曲线(深蓝)清晰可见;把 N 改为 3000 后计数同步更新。 用独立脚本 .preflight/centerprobe.ps1 按同一规则复算,与界面**逐位一致**: N=5 → 上升沿 2712920 / 下降沿 2712913;N=1000 → 2373176 / 2373176。 - 双光标联动:区间 [48.0211, 64.4051](面板标题带「(光标区间内)」)→ 349220 / 349220; 把光标 1 的时间改到 100(越过光标 2 的 64.4051)后,区间变为 [64.4051, 100], 计数 778085 / 778084,与按该区间复算的结果一致(±2 为光标时间显示值取整导致的边界差)。 - 纵向光标读数:右光标显示 `4.9417 (-0.071945)` + `中 4.94199`, 左光标显示 `中 2.9165`(中心曲线取值单独标注)。 - 左侧信号列表仍只有原信号,未新增曲线条目。 - 回归:[1].CSV 上「边沿计数(光标区间内)」正常(Δt:151ms 200us); 「边沿计数(中心轴)」在 [1].CSV 上曲线清晰可见(上升沿 199 / 下降沿 199)。 - make clean && make all 无警告无错误。
5955 lines
188 KiB
C++
5955 lines
188 KiB
C++
#include "PlotView.hpp"
|
||
|
||
#include <algorithm>
|
||
#include <cmath>
|
||
#include <cstring>
|
||
#include <limits>
|
||
|
||
#include "Utf8Util.hpp"
|
||
|
||
namespace jdi
|
||
{
|
||
|
||
|
||
|
||
namespace
|
||
{
|
||
const int kTimeTickTarget = 10; /**< 时间轴期望刻度数。 */
|
||
const int kYTickTarget = 6; /**< 幅值轴期望刻度数(网格较高时)。 */
|
||
const int kYTickTargetMin = 3; /**< 幅值轴最少刻度数(网格很矮时也要保证可读数)。 */
|
||
const int kYTickTargetMax = 8; /**< 幅值轴最多刻度数。 */
|
||
const float kYTickSpacing = 28.0f; /**< 幅值刻度之间的理想间距(逻辑像素)。 */
|
||
const float kYLabelMinGap = 15.0f; /**< 幅值标签的最小行距(逻辑像素)。 */
|
||
const int kMinorDivisions = 5; /**< 相邻主刻度之间的等分数(用于估读)。 */
|
||
const unsigned int kMinorLineAlpha = 0x9Au; /**< 辅助等分线的透明度(越高越清晰)。 */
|
||
const float kScrollBarThickness = 9.0f; /**< 滚动条宽度(逻辑像素)。 */
|
||
const float kLanePadding = 8.0f; /**< 网格内边距。 */
|
||
const size_t kMaxPolylinePoints = 20000; /**< 单条折线最大顶点数(上限保护)。 */
|
||
const size_t kPolylinePointsPerPixel = 2; /**< 每个像素列允许的折线顶点数(上/下包络两顶点)。 */
|
||
const size_t kMinPolylinePoints = 512; /**< 折线最少顶点数(窗口很窄时仍保留细节)。 */
|
||
const float kEnvelopeMinPixelSpan = 0.5f; /**< 包络纵向跨度小于该像素值时合并为单个顶点。 */
|
||
const 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 std::uint64_t kHintHoldMs = 1300; /**< 提示浮层完全显示的停留时长(毫秒)。 */
|
||
const std::uint64_t kHintFadeMs = 500; /**< 提示浮层的淡出时长(毫秒)。 */
|
||
|
||
/**
|
||
* @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 把时间间隔格式化为"秒"数值文本(不含单位),用于内联编辑。
|
||
*
|
||
* 编辑时的量级**始终是秒**:即使显示为 "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] color 0xAARRGGBB 颜色值。
|
||
* @return 相对亮度低于阈值返回 true。
|
||
*/
|
||
bool IsDarkColor(std::uint32_t color)
|
||
{
|
||
const double r = static_cast<double>((color >> 16) & 0xFFu);
|
||
const double g = static_cast<double>((color >> 8) & 0xFFu);
|
||
const double b = static_cast<double>(color & 0xFFu);
|
||
return (0.299 * r + 0.587 * g + 0.114 * b) < 140.0;
|
||
}
|
||
|
||
/**
|
||
* @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));
|
||
}
|
||
}
|
||
|
||
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;
|
||
// 重新绑定数据源后旧的几何键不再有意义,必须清空折线缓存。
|
||
InvalidatePolylineCache();
|
||
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();
|
||
}
|
||
|
||
double PlotView::ViewMinTime() const
|
||
{
|
||
return Tab().ViewMin();
|
||
}
|
||
|
||
double PlotView::ViewMaxTime() const
|
||
{
|
||
return Tab().ViewMax();
|
||
}
|
||
|
||
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;
|
||
// 自适应即回到初始状态:退出拖拽缩放模式并放下进行中的选区与拖拽。
|
||
Tab().ZoomMode() = PlotZoomMode::NONE;
|
||
m_rubberRect = gd::RectF();
|
||
m_dragMode = PlotDragMode::NONE;
|
||
m_dragSignal = SignalRef();
|
||
m_dragTargetLane = -1;
|
||
m_hoverSignal = SignalRef();
|
||
|
||
if (m_tab != nullptr)
|
||
{
|
||
// 适应视图 = 回到初始状态:全部网格恢复整段数据的自动自适应。
|
||
// 「选区 Y 轴自适应」是用户设置项,这里保持不变。
|
||
for (Lane& lane : m_tab->Lanes())
|
||
{
|
||
lane.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(int which) const
|
||
{
|
||
// 两条光标统一使用同一样式与颜色(与光标 1 一致),不再按序号区分配色。
|
||
(void)which;
|
||
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::InvalidatePolylineCache()
|
||
{
|
||
m_polylineCache.clear();
|
||
// 数据层结构变化后边沿统计与中心平均值曲线的派生结果都会失效。
|
||
m_edgeCountCache.clear();
|
||
m_edgeCenterCountCache.clear();
|
||
m_centerCurveCache.clear();
|
||
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);
|
||
|
||
// 注意:不在这里移动光标——光标允许滞留在网格可视区域之外。
|
||
BuildLaneVisuals();
|
||
return true;
|
||
}
|
||
|
||
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 = FormatValue(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;
|
||
}
|
||
|
||
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;
|
||
entry.values.assign(signal.values.size(), std::numeric_limits<float>::quiet_NaN());
|
||
|
||
const std::size_t count = signal.values.size();
|
||
if (count > 0)
|
||
{
|
||
// 滑动窗口求和:窗口 = [max(0,i-N), min(count-1,i+N)],
|
||
// 每前进一步只增减边界处的两个采样点,整体 O(n),不额外占用前缀和内存。
|
||
double sum = 0.0;
|
||
std::size_t finiteCount = 0;
|
||
std::size_t windowLow = 0;
|
||
std::size_t windowHigh = 0; // 闭区间右端(尚未计入时为 0)。
|
||
|
||
const auto addValue = [&sum, &finiteCount](double value)
|
||
{
|
||
if (std::isfinite(value))
|
||
{
|
||
sum += value;
|
||
++finiteCount;
|
||
}
|
||
};
|
||
const auto removeValue = [&sum, &finiteCount](double value)
|
||
{
|
||
if (std::isfinite(value))
|
||
{
|
||
sum -= value;
|
||
--finiteCount;
|
||
}
|
||
};
|
||
|
||
for (std::size_t index = 0; index < count; ++index)
|
||
{
|
||
const std::size_t low = (index > static_cast<std::size_t>(radius))
|
||
? (index - static_cast<std::size_t>(radius))
|
||
: 0;
|
||
const std::size_t high = std::min(count - 1, index + static_cast<std::size_t>(radius));
|
||
|
||
if (index == 0)
|
||
{
|
||
windowLow = low;
|
||
windowHigh = high;
|
||
for (std::size_t i = windowLow; i <= windowHigh; ++i)
|
||
{
|
||
addValue(signal.values[i]);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// 左端前移:移出旧左端(若已不在窗口内)。
|
||
while (windowLow < low)
|
||
{
|
||
removeValue(signal.values[windowLow]);
|
||
++windowLow;
|
||
}
|
||
// 右端前移:加入新右端。
|
||
while (windowHigh < high)
|
||
{
|
||
++windowHigh;
|
||
addValue(signal.values[windowHigh]);
|
||
}
|
||
}
|
||
|
||
// 中心点本身无效(NaN)时该处不绘制曲线。
|
||
if (finiteCount > 0 && std::isfinite(signal.values[index]))
|
||
{
|
||
entry.values[index] = static_cast<float>(sum / static_cast<double>(finiteCount));
|
||
}
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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;
|
||
|
||
for (const EdgeCenterCountEntry& entry : m_edgeCenterCountCache)
|
||
{
|
||
if (entry.file == &file && entry.signal == &signal && entry.smooth == smooth &&
|
||
entry.hasRange == hasRange && entry.rangeMin == rangeMin && entry.rangeMax == rangeMax)
|
||
{
|
||
rising = entry.rising;
|
||
falling = entry.falling;
|
||
return;
|
||
}
|
||
}
|
||
|
||
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 = count;
|
||
if (hasRange && file.timeMonotonic)
|
||
{
|
||
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);
|
||
|
||
// 边沿判定:比较信号本身与其中心平均值曲线。
|
||
// 信号由下向上越过中心曲线 → 上升沿;由上向下穿过 → 下降沿。
|
||
bool hasPrevious = false;
|
||
double previousDelta = 0.0;
|
||
for (std::size_t index = begin; index < end; ++index)
|
||
{
|
||
const double value = signal.values[index];
|
||
const float center = curve[index];
|
||
if (!std::isfinite(value) || !std::isfinite(center))
|
||
{
|
||
hasPrevious = false;
|
||
continue;
|
||
}
|
||
if (hasRange && !file.timeMonotonic && index < file.time.size())
|
||
{
|
||
const double time = file.time[index];
|
||
if (time < rangeMin || time > rangeMax)
|
||
{
|
||
hasPrevious = false;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
const double delta = value - static_cast<double>(center);
|
||
if (hasPrevious)
|
||
{
|
||
if (previousDelta < 0.0 && delta >= 0.0)
|
||
{
|
||
++rising;
|
||
}
|
||
else if (previousDelta > 0.0 && delta <= 0.0)
|
||
{
|
||
++falling;
|
||
}
|
||
}
|
||
previousDelta = delta;
|
||
hasPrevious = true;
|
||
}
|
||
|
||
EdgeCenterCountEntry entry;
|
||
entry.file = &file;
|
||
entry.signal = &signal;
|
||
entry.smooth = smooth;
|
||
entry.hasRange = hasRange;
|
||
entry.rangeMin = rangeMin;
|
||
entry.rangeMax = rangeMax;
|
||
entry.rising = rising;
|
||
entry.falling = falling;
|
||
m_edgeCenterCountCache.push_back(entry);
|
||
}
|
||
|
||
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;
|
||
|
||
// 缓存键必须包含横向光标幅值与时间区间:拖动光标或拖动时间光标都会改变统计结果。
|
||
for (const EdgeCountEntry& entry : m_edgeCountCache)
|
||
{
|
||
if (entry.file == &file && entry.signal == &signal && entry.hasRange == hasRange &&
|
||
entry.edgeValue == edgeValue && entry.rangeMin == rangeMin && entry.rangeMax == rangeMax)
|
||
{
|
||
rising = entry.rising;
|
||
falling = entry.falling;
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 采样范围:时间单调时二分定位,否则按区间过滤(与波形绘制的处理一致)。
|
||
std::size_t begin = 0;
|
||
std::size_t end = signal.values.size();
|
||
if (hasRange && file.timeMonotonic)
|
||
{
|
||
const std::vector<double>& times = file.time;
|
||
const std::size_t count = std::min(times.size(), signal.values.size());
|
||
begin = static_cast<std::size_t>(
|
||
std::lower_bound(times.begin(), times.begin() + static_cast<std::ptrdiff_t>(count),
|
||
rangeMin) -
|
||
times.begin());
|
||
end = static_cast<std::size_t>(
|
||
std::upper_bound(times.begin(), times.begin() + static_cast<std::ptrdiff_t>(count),
|
||
rangeMax) -
|
||
times.begin());
|
||
if (begin > 0)
|
||
{
|
||
// 区间左侧补一个采样点:跨越区间边界的边沿也能被计入。
|
||
--begin;
|
||
}
|
||
end = std::min(end, count);
|
||
}
|
||
else if (hasRange)
|
||
{
|
||
end = std::min(end, signal.values.size());
|
||
}
|
||
begin = std::min(begin, end);
|
||
|
||
// 边沿判定:以横向光标的幅值 edgeValue 为阈值。
|
||
// 信号从下往上穿越 → 上升沿;从上往下穿越 → 下降沿。
|
||
bool hasPrevious = false;
|
||
double previous = 0.0;
|
||
for (std::size_t index = begin; index < end; ++index)
|
||
{
|
||
const double value = signal.values[index];
|
||
if (!std::isfinite(value))
|
||
{
|
||
// NaN 视为断点:不参与边沿判定,也不与前一点相连。
|
||
hasPrevious = false;
|
||
continue;
|
||
}
|
||
if (hasRange && !file.timeMonotonic && index < file.time.size())
|
||
{
|
||
const double time = file.time[index];
|
||
if (time < rangeMin || time > rangeMax)
|
||
{
|
||
hasPrevious = false;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if (hasPrevious)
|
||
{
|
||
const double before = previous - edgeValue;
|
||
const double after = value - edgeValue;
|
||
if (before < 0.0 && after >= 0.0)
|
||
{
|
||
++rising;
|
||
}
|
||
else if (before > 0.0 && after <= 0.0)
|
||
{
|
||
++falling;
|
||
}
|
||
}
|
||
previous = value;
|
||
hasPrevious = true;
|
||
}
|
||
|
||
EdgeCountEntry entry;
|
||
entry.file = &file;
|
||
entry.signal = &signal;
|
||
entry.edgeValue = edgeValue;
|
||
entry.hasRange = hasRange;
|
||
entry.rangeMin = rangeMin;
|
||
entry.rangeMax = rangeMax;
|
||
entry.rising = rising;
|
||
entry.falling = falling;
|
||
m_edgeCountCache.push_back(entry);
|
||
}
|
||
|
||
float PlotView::EdgePanelWidth(int laneIndex, bool centerAxis) 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)];
|
||
|
||
// 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” + 光标区间尾缀)。
|
||
const std::wstring title = (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());
|
||
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)
|
||
{
|
||
// 底部编辑行 = “平滑点数”说明文字 + 数值框(与菜单内联编辑同一套排版习惯)。
|
||
const float editWidth =
|
||
UiMeasureTextWidth(m_fonts->uiSmall, L"平滑点数 N") + kEdgePanelEditFieldGap * m_scale +
|
||
std::max(kEdgePanelEditFieldWidth * m_scale,
|
||
UiMeasureTextWidth(m_fonts->monoSmall, std::to_wstring(lane.edgeCenterSmooth)) +
|
||
20.0f * m_scale);
|
||
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + editWidth);
|
||
}
|
||
|
||
// 夹在绘图区宽度之内(过窄的网格也不至于把面板挤到外面)。
|
||
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::ScaleLaneRange(int laneIndex, double factor, double anchorValue)
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
|
||
{
|
||
return;
|
||
}
|
||
if (!(factor > 0.0) || !std::isfinite(anchorValue))
|
||
{
|
||
return;
|
||
}
|
||
|
||
const LaneVisual& visual = m_lanes[static_cast<std::size_t>(laneIndex)];
|
||
const double span = visual.yMax - visual.yMin;
|
||
if (!(span > 0.0))
|
||
{
|
||
return;
|
||
}
|
||
|
||
const double magnitude = std::max(std::fabs(visual.yMax), std::fabs(visual.yMin));
|
||
const double minSpan = std::max(magnitude * 1.0e-9, 1.0e-12);
|
||
const double newSpan = ClampDouble(span / factor, minSpan, magnitude * 100.0 + 1.0);
|
||
const double ratio = (visual.yMax - anchorValue) / span;
|
||
|
||
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
lane.autoRange = false;
|
||
lane.fitYToSelection = false;
|
||
lane.yMin = anchorValue - ratio * newSpan;
|
||
lane.yMax = lane.yMin + newSpan;
|
||
BuildLaneVisuals();
|
||
}
|
||
|
||
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::BeginLaneValueEdit(int laneIndex, bool editMax, const gd::RectF& anchorRect)
|
||
{
|
||
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 LaneVisual& visual = m_lanes[static_cast<std::size_t>(laneIndex)];
|
||
const double current = editMax ? visual.yMax : visual.yMin;
|
||
return BeginEdit(editMax ? PlotEditTarget::LANE_Y_MAX : PlotEditTarget::LANE_Y_MIN, laneIndex,
|
||
FormatValue(current), anchorRect);
|
||
}
|
||
|
||
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)
|
||
{
|
||
// 「平滑点数 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;
|
||
}
|
||
|
||
// 直接输入上下限:先固化当前范围,再用新值替换对应端点(保证 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 (int pass = 0; pass < 2; ++pass)
|
||
{
|
||
const bool centerAxis = (pass == 1);
|
||
for (int index = 0; index < static_cast<int>(m_lanes.size()); ++index)
|
||
{
|
||
const EdgePanelLayout layout = EdgePanelLayoutFor(index, centerAxis);
|
||
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);
|
||
}
|
||
// 面板主体(含底部编辑行):保持原始箭头。
|
||
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_edgePanelDragCenter).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)];
|
||
if (m_edgePanelDragCenter)
|
||
{
|
||
lane.edgeCenterPanelX = left;
|
||
lane.edgeCenterPanelY = top;
|
||
}
|
||
else
|
||
{
|
||
lane.edgePanelX = left;
|
||
lane.edgePanelY = top;
|
||
}
|
||
m_dragMoved = true;
|
||
return true;
|
||
}
|
||
case PlotDragMode::NONE:
|
||
default:
|
||
break;
|
||
}
|
||
|
||
bool changed = false;
|
||
|
||
// 鼠标压在实验性功能面板上时不再触发信号悬停提示(面板本来就盖住波形)。
|
||
bool overEdgePanel = false;
|
||
for (int pass = 0; pass < 2 && !overEdgePanel; ++pass)
|
||
{
|
||
const bool centerAxis = (pass == 1);
|
||
for (int index = 0; index < static_cast<int>(m_lanes.size()); ++index)
|
||
{
|
||
const gd::RectF panel = EdgePanelLayoutFor(index, centerAxis).panel;
|
||
if (panel.Width > 0.0f &&
|
||
UiPointInRect(panel, static_cast<float>(x), static_cast<float>(y)))
|
||
{
|
||
overEdgePanel = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
const int lane = 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 (Tab().ZoomMode() == PlotZoomMode::NONE && !m_cursorHover && !overEdgePanel)
|
||
{
|
||
hoverLegend = LegendAtPoint(x, y);
|
||
if (!hoverLegend.IsValid())
|
||
{
|
||
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());
|
||
|
||
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 < 2; ++pass)
|
||
{
|
||
const bool centerAxis = (pass == 1);
|
||
bool handledPanel = false;
|
||
for (int index = 0; index < static_cast<int>(m_lanes.size()); ++index)
|
||
{
|
||
const EdgePanelLayout layout = EdgePanelLayoutFor(index, centerAxis);
|
||
if (layout.panel.Width <= 0.0f ||
|
||
!UiPointInRect(layout.panel, fx, fy))
|
||
{
|
||
continue;
|
||
}
|
||
handledPanel = true;
|
||
|
||
if (UiPointInRect(layout.close, fx, fy))
|
||
{
|
||
// ✕ 关闭面板的同时关闭该网格对应的实验性功能。
|
||
if (centerAxis)
|
||
{
|
||
SetLaneEdgeCenterCount(index, false);
|
||
}
|
||
else
|
||
{
|
||
SetLaneEdgeCount(index, false);
|
||
}
|
||
m_dragMode = PlotDragMode::NONE;
|
||
return true;
|
||
}
|
||
|
||
if (centerAxis && layout.edit.Width > 0.0f && UiPointInRect(layout.edit, fx, fy) &&
|
||
m_tab != nullptr)
|
||
{
|
||
// 底部编辑行 → 就地编辑平滑点数 N(输入框落在该行内)。
|
||
const int smooth = m_tab->Lanes()[static_cast<std::size_t>(index)].edgeCenterSmooth;
|
||
BeginEdit(PlotEditTarget::EDGE_CENTER_SMOOTH, index, std::to_wstring(smooth),
|
||
layout.edit);
|
||
return true;
|
||
}
|
||
|
||
if (UiPointInRect(layout.title, fx, fy))
|
||
{
|
||
m_dragMode = PlotDragMode::EDGE_PANEL_MOVE;
|
||
m_edgePanelDragLane = index;
|
||
m_edgePanelDragCenter = centerAxis;
|
||
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 PlotZoomMode mode = Tab().ZoomMode();
|
||
if (mode == PlotZoomMode::SELECT)
|
||
{
|
||
// 选区放大:轴向由鼠标移动方向决定,因此按下时先记录网格并置为未定。
|
||
// 按下本身**不显示提示**(拖动开始后才显示"右键取消选区")。
|
||
m_dragLane = LaneAtY(y);
|
||
m_rubberAxis = PlotRubberAxis::UNDECIDED;
|
||
m_dragMode = PlotDragMode::RUBBER_SELECT;
|
||
return true;
|
||
}
|
||
|
||
// 常规模式下:点击图例条目等价于在左侧列表中选中该信号(样式区随之切换目标)。
|
||
if (mode == PlotZoomMode::NONE)
|
||
{
|
||
const SignalRef legendHit = LegendAtPoint(x, y);
|
||
if (legendHit.IsValid())
|
||
{
|
||
SelectSignal(legendHit);
|
||
m_dragMode = PlotDragMode::NONE;
|
||
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::OnLButtonUp(int x, int y)
|
||
{
|
||
bool changed = (m_dragMode != PlotDragMode::NONE);
|
||
|
||
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_edgePanelDragCenter = false;
|
||
// 松开左键:粘滞提示结束计时,开始"停留后淡出"。
|
||
ReleaseHint();
|
||
(void)x;
|
||
return changed;
|
||
}
|
||
|
||
bool PlotView::OnMouseWheel(int x, int y, 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();
|
||
(void)x;
|
||
(void)y;
|
||
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);
|
||
// 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) const
|
||
{
|
||
points.clear();
|
||
breaks.clear();
|
||
|
||
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;
|
||
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());
|
||
if (i1 <= i0)
|
||
{
|
||
// 该列没有采样点:说明数据在此处存在时间间隙,断开当前折线。
|
||
inSegment = false;
|
||
continue;
|
||
}
|
||
|
||
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 (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
|
||
{
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!(low <= high))
|
||
{
|
||
// 本列全是无效值。
|
||
inSegment = false;
|
||
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));
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// 静态场景复用(不再局限于拖拽):只要视图几何、信号集合与显示样式都没变,
|
||
// "绘图区底色 + 全部网格 + 时间轴"就逐像素相同,直接贴图即可。
|
||
// 大文件下整幅重绘约 80ms,贴图约 1ms —— 鼠标悬停、拖拽选区这些
|
||
// "只改一小块叠加层"的交互因此不再重绘波形。
|
||
// 随鼠标变化的内容(悬停高亮、读数框、选区、光标、拖拽虚影、输入框)一律画在贴图之后。
|
||
if (SceneCacheMatches() && BlitSceneCache(g))
|
||
{
|
||
PaintHoverHighlight(g);
|
||
PaintLegendHighlight(g);
|
||
PaintRubberBand(g);
|
||
PaintCursor(g, m_lanes);
|
||
PaintEdgeCursor(g, m_lanes);
|
||
PaintSignalInteraction(g);
|
||
PaintHint(g);
|
||
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);
|
||
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);
|
||
|
||
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);
|
||
}
|
||
|
||
void PlotView::PaintSignalHalo(gd::Graphics& g, const SignalRef& ref, 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())
|
||
{
|
||
// 散点模式没有光晕(原实现同样只对折线绘制光晕)。
|
||
(void)g;
|
||
(void)ref;
|
||
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, m_hoverSignal, 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);
|
||
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() const
|
||
{
|
||
// FNV-1a:把“视图几何之外的一切影响绘图区像素的内容”折叠成一个指纹。
|
||
// 这样颜色/线宽/透明度/样式/重命名/信号增删等变化都会自动让缓存失效,
|
||
// 不需要在各处手工调用 InvalidateSceneCache(容易漏)。
|
||
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(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;
|
||
}
|
||
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;
|
||
}
|
||
|
||
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_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;
|
||
}
|
||
|
||
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 = UiTextWidth(g, text, m_fonts->monoSmall) + 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 = UiTextWidth(g, m_hintText, m_fonts->ui) + 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();
|
||
g.SetClip(gd::RectF(m_area.X, clipTop, m_area.Width, clipBottom - clipTop));
|
||
|
||
// 幅值网格线(先画线,标尺稍后作为覆盖层绘制,避免被波形遮挡)。
|
||
// 刻度密度随网格高度自适应:所有网格高度相同 → 各网格的刻度数量一致,
|
||
// 不会出现“有的网格只有 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;
|
||
|
||
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, ref, resolved, points, breaks);
|
||
}
|
||
|
||
if (drawLine && !breaks.empty())
|
||
{
|
||
gd::Pen pen(ToGdiColor(StyleColor(*resolved.style)),
|
||
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);
|
||
}
|
||
}
|
||
|
||
// 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(g, 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(UiTextWidth(g, label, m_fonts->uiSmall),
|
||
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 = UiTextWidth(g, label, m_fonts->uiSmall);
|
||
const float textWidth = std::min(fullTextWidth, maxTextWidth);
|
||
const std::wstring drawLabel =
|
||
(fullTextWidth > maxTextWidth) ? UiEllipsize(g, 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(*m_dataSet, 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(g, 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, bool textDark)
|
||
{
|
||
// 用与文字同色的半透明细描边:深色文字 → 浅色描边,浅色文字 → 深色描边。
|
||
// 这样胶囊压在同类色背景或信号曲线上时仍有清晰的边界。
|
||
const std::uint32_t outline = textDark ? 0x99FDF6E3u : 0x99073642u;
|
||
UiDrawRoundRect(g, rect, radius, outline, 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(which);
|
||
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);
|
||
|
||
// 光标在可视区外时,把标注"钉"在对应的网格边上,保证取值仍然可见。
|
||
const float pinX = CursorDisplayX(which);
|
||
const float rawX = TimeToX(time);
|
||
const bool pinned = (std::fabs(rawX - pinX) > 0.5f);
|
||
|
||
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 (!SampleFileAt(*resolved.file, *resolved.data, time, interpolate, value))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
const float y = ValueToY(value, lane);
|
||
if (y < clipTop - 2.0f || y > clipBottom + 2.0f)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
const std::uint32_t signalColor = StyleColor(*resolved.style);
|
||
|
||
// 数据点:底色环 + 信号色实心点(贴边时改画成三角指示方向)。
|
||
const float radius = 3.4f * m_scale;
|
||
gd::SolidBrush ring(ToGdiColor(m_theme->cardBg));
|
||
gd::SolidBrush fill(ToGdiColor(signalColor));
|
||
if (pinned)
|
||
{
|
||
const float arm = 5.0f * m_scale;
|
||
const bool offLeft = (rawX < pinX);
|
||
const float baseX = offLeft ? (pinX - arm) : (pinX + arm);
|
||
gd::PointF tri[3];
|
||
tri[0] = gd::PointF(baseX, y - arm);
|
||
tri[1] = gd::PointF(baseX, y + arm);
|
||
tri[2] = gd::PointF(pinX, y);
|
||
g.FillPolygon(&ring, tri, 3);
|
||
gd::PointF inner[3];
|
||
inner[0] = gd::PointF(baseX + (offLeft ? 1.4f : -1.4f) * m_scale, y - arm * 0.72f);
|
||
inner[1] = gd::PointF(baseX + (offLeft ? 1.4f : -1.4f) * m_scale, y + arm * 0.72f);
|
||
inner[2] = gd::PointF(pinX - (offLeft ? -1.4f : 1.4f) * m_scale, y);
|
||
g.FillPolygon(&fill, inner, 3);
|
||
}
|
||
else
|
||
{
|
||
g.FillEllipse(&ring, pinX - radius - 1.2f * m_scale, y - radius - 1.2f * m_scale,
|
||
(radius + 1.2f * m_scale) * 2.0f, (radius + 1.2f * m_scale) * 2.0f);
|
||
g.FillEllipse(&fill, pinX - radius, y - radius, radius * 2.0f, radius * 2.0f);
|
||
}
|
||
|
||
// 取值标注:底色用信号色、文字用反色,并加一层细描边提升对比度。
|
||
std::wstring text = FormatValue(value);
|
||
if (showDelta)
|
||
{
|
||
double otherValue = 0.0;
|
||
if (SampleFileAt(*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 = UiTextWidth(g, text, m_fonts->monoSmall) + 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 = 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);
|
||
|
||
UiFillRoundRect(g, labelRect, 3.0f * m_scale, WithAlpha(signalColor, 0xF0));
|
||
const bool dark = IsDarkColor(signalColor);
|
||
DrawLabelOutline(g, labelRect, 3.0f * m_scale, dark);
|
||
UiText(g, text, m_fonts->monoSmall, labelRect,
|
||
dark ? 0xFFFDF6E3u : 0xFF073642u, TextAlign::CENTER);
|
||
|
||
// 「边沿计数(中心轴)」开启时,纵向光标还要额外标注**中心平均值曲线**
|
||
// 在该时刻的取值(它不是信号,因此单独标注,不并入上面的取值文案)。
|
||
if (m_tab->Lanes()[laneIndex].edgeCenter)
|
||
{
|
||
const int smooth = m_tab->Lanes()[laneIndex].edgeCenterSmooth;
|
||
double centerValue = 0.0;
|
||
if (CenterValueAt(*resolved.file, *resolved.data, smooth, time, centerValue))
|
||
{
|
||
const float centerY = ValueToY(centerValue, lane);
|
||
if (centerY >= clipTop - 2.0f && centerY <= clipBottom + 2.0f)
|
||
{
|
||
g.FillEllipse(&ring, pinX - radius - 1.2f * m_scale,
|
||
centerY - radius - 1.2f * m_scale,
|
||
(radius + 1.2f * m_scale) * 2.0f,
|
||
(radius + 1.2f * m_scale) * 2.0f);
|
||
g.FillEllipse(&fill, pinX - radius, centerY - radius, radius * 2.0f,
|
||
radius * 2.0f);
|
||
|
||
const std::wstring centerText = L"中 " + FormatValue(centerValue);
|
||
const float centerTextWidth =
|
||
UiTextWidth(g, centerText, m_fonts->monoSmall) + 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 = centerY - 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);
|
||
UiFillRoundRect(g, centerRect, 3.0f * m_scale,
|
||
WithAlpha(signalColor, 0xB8));
|
||
DrawLabelOutline(g, centerRect, 3.0f * m_scale, dark);
|
||
UiText(g, centerText, m_fonts->monoSmall, centerRect,
|
||
dark ? 0xFFFDF6E3u : 0xFF073642u, TextAlign::CENTER);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- 底部:时间读数 + 双光标差值 ----
|
||
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(which);
|
||
UiFillRoundRect(g, labelRect, 3.0f * m_scale,
|
||
highlighted ? color : WithAlpha(color, 0xE0));
|
||
const bool dark = IsDarkColor(color);
|
||
DrawLabelOutline(g, labelRect, 3.0f * m_scale, dark);
|
||
UiText(g, FormatValue(which == 2 ? Tab().CursorTimeAlt() : Tab().CursorTime()),
|
||
m_fonts->monoSmall, labelRect,
|
||
dark ? 0xFFFDF6E3u : 0xFF073642u, 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(1);
|
||
UiFillRoundRect(g, spanRect, 3.0f * m_scale,
|
||
m_spanHover ? color : WithAlpha(color, 0xE0));
|
||
const bool dark = IsDarkColor(color);
|
||
DrawLabelOutline(g, spanRect, 3.0f * m_scale, dark);
|
||
UiText(g, FormatSpanText(CursorSpan()), m_fonts->monoSmall, spanRect,
|
||
dark ? 0xFFFDF6E3u : 0xFF073642u, TextAlign::CENTER);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
m_cursorSpanRect = gd::RectF();
|
||
}
|
||
}
|
||
|
||
PlotView::EdgePanelLayout PlotView::EdgePanelLayoutFor(int laneIndex, bool centerAxis) 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 enabled = centerAxis ? lane.edgeCenter : lane.edgeCount;
|
||
const bool open = 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, centerAxis);
|
||
const float titleHeight = kEdgePanelTitleHeight * m_scale;
|
||
const float rowHeight = kEdgePanelRowHeight * m_scale;
|
||
const float pad = kEdgePanelPad * m_scale;
|
||
// 中心轴面板在底部固定多出一行「平滑点数」编辑行。
|
||
const float editHeight = centerAxis ? (kEdgePanelEditHeight * m_scale) : 0.0f;
|
||
const float height = titleHeight + pad + static_cast<float>(rowCount) * rowHeight * 2.0f + pad +
|
||
editHeight;
|
||
|
||
// 首次使用(位置为负)时自动放到绘图区右侧,并各网格依次错开,避免完全重叠。
|
||
const double storedX = centerAxis ? lane.edgeCenterPanelX : lane.edgePanelX;
|
||
const double storedY = 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)
|
||
{
|
||
const float editRowHeight = kEdgePanelEditHeight * m_scale;
|
||
layout.edit = gd::RectF(left + pad, top + height - editRowHeight,
|
||
std::max(0.0f, width - pad * 2.0f), editRowHeight);
|
||
}
|
||
return layout;
|
||
}
|
||
|
||
gd::RectF PlotView::EdgePanelRect(int laneIndex) const
|
||
{
|
||
return EdgePanelLayoutFor(laneIndex, false).panel;
|
||
}
|
||
|
||
gd::RectF PlotView::EdgePanelCloseRect(int laneIndex) const
|
||
{
|
||
return EdgePanelLayoutFor(laneIndex, false).close;
|
||
}
|
||
|
||
gd::RectF PlotView::EdgeCenterPanelRect(int laneIndex) const
|
||
{
|
||
return EdgePanelLayoutFor(laneIndex, true).panel;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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 std::uint32_t derived =
|
||
IsDarkColor(m_theme->plotBg) ? MixColor(StyleColor(*resolved.style), 0xFFFFFFFFu, 0.62f)
|
||
: MixColor(StyleColor(*resolved.style), 0xFF000000u, 0.58f);
|
||
gd::Pen pen(ToGdiColor(derived), std::max(1.8f, resolved.style->lineWidth + 0.8f) * 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::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(1);
|
||
const bool dark = IsDarkColor(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, dark);
|
||
UiText(g, text, m_fonts->monoSmall, labelRect, dark ? 0xFFFDF6E3u : 0xFF073642u,
|
||
TextAlign::CENTER);
|
||
}
|
||
|
||
// ---- 统计浮动面板(最后绘制,覆盖在波形之上)----
|
||
// 「边沿计数」与「边沿计数(中心轴)」共用同一套样式,仅标题、统计口径与底部编辑行不同。
|
||
for (int pass = 0; pass < 2; ++pass)
|
||
{
|
||
const bool centerAxis = (pass == 1);
|
||
for (std::size_t index = 0; index < count; ++index)
|
||
{
|
||
const Lane& tabLane = tabLanes[index];
|
||
const EdgePanelLayout layout = EdgePanelLayoutFor(static_cast<int>(index), centerAxis);
|
||
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 =
|
||
(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);
|
||
|
||
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)
|
||
{
|
||
const gd::RectF& editRect = layout.edit;
|
||
if (editRect.Width > 0.0f)
|
||
{
|
||
const float fieldGap = kEdgePanelEditFieldGap * m_scale;
|
||
const std::wstring label = 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(tabLane.edgeCenterSmooth)) +
|
||
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 bool editingThis =
|
||
(m_editTarget == PlotEditTarget::EDGE_CENTER_SMOOTH &&
|
||
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(tabLane.edgeCenterSmooth), m_fonts->monoSmall,
|
||
fieldRect, m_theme->textStrong, TextAlign::CENTER);
|
||
}
|
||
(void)labelWidth;
|
||
(void)fieldGap;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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())
|
||
{
|
||
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 = UiTextWidth(g, hint, m_fonts->uiSmall) + 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;
|
||
}
|
||
|
||
// 悬停提示:光标贴近曲线时给出可拖拽说明(与时间光标的提示方式一致)。
|
||
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 = UiTextWidth(g, text, m_fonts->uiSmall) + 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
|