1) 光标 2 的样式与光标 1 完全一致,并改用更鲜艳的颜色 - 原先光标 2 用朝上的三角把手,观感突兀;现两条光标统一为同款朝下三角 - 主题新增 cursorAlt 并改用 Solarized 洋红 #D33682(原青色偏灰),与光标 1 的 橙色 #CB4B16 同属暖色且更醒目,靠颜色而非形状区分 2) 光标允许滞留在网格可视区域之外(不再被强行夹回视野) - 删除"视图变化后夹取光标"的逻辑(时间轴选区放大、适应视图、按网格时间轴展开、 平移时都不再移动光标);仅在拖拽光标本身时才使用 ClampCursorToView(which) - 新增 CursorDisplayX(which):光标在视野外时把它的显示位置"钉"在最近的网格边缘, 取值标注与时间读数因此始终可见;数据点改为边缘三角指示(指向光标实际方向) - 命中测试改用"显示横坐标",因此被钉在边缘的光标仍可抓住拖回 3) 光标读数增加细描边,避免与背景/曲线颜色过于接近 - 新增 DrawLabelOutline():按文字颜色取反色做 1px 半透明描边, 网格内的取值胶囊、光标时间读数、差值标签统一使用 4) 双光标差值显示(时间轴上方居中,自适应单位,可拖拽、可编辑) - 新增 FormatDuration()/FormatDurationNumber()/ChosenDurationUnit(): 在 h / min / s / ms / us / ns / ps 之间自动选择单位(数值落在 [1,1000)) - 差值标签位于两条时间读数的中点,样式与时间读数一致 - 双击进入编辑:输入值采用**当前显示单位**;确认后**前方(时间较小)光标不动**, 后方光标移动到"前方 + 输入差值" - 标签支持左右拖拽(新增 PlotDragMode::CURSOR_SPAN_MOVE):两条光标整体平移、间隔不变 5) 双光标内容重叠时的层次与差值标签位置 - 新增 m_activeCursor:最后操作过(拖拽/单击/编辑)的光标其内容绘制在上层, 两条光标的竖线、取值胶囊、时间读数都按此顺序绘制 - 差值标签改为按**两条时间读数的实际净空**判断:放不下时自动落到主时间轴内部 (原先按光标线间距判断,光标被钉到边缘时会用差值标签盖住时间读数) 6) 选区放大的光标形状与颜色 - 未开始框选时不再改变鼠标形状(保持箭头);按下但未拖动时为十字 IDC_CROSS; 确定为时间轴选区后为左右箭头、Y 轴选区后为上下箭头 - 两种方向的选区统一使用强调色(原先 Y 轴选区用警示红,观感不一致) - 新增 Application::UpdateDragCursor():Windows 在鼠标捕获期间不派发 WM_SETCURSOR, 必须在 WM_MOUSEMOVE / WM_LBUTTONDOWN 后主动刷新光标形状,否则形状不会变化 7) 提示浮层加大并支持"停留后淡出",光标模式不再提示 - 提示字号由 uiSmall 改为 ui,胶囊 30px 高、内边距更大 - 新增停留 1300ms + 淡出 500ms 的时间控制;宿主用 WM_TIMER(33ms) 驱动重绘, 淡出结束自动 KillTimer(文字走 GDI 不支持 alpha,改为向胶囊底色混色实现淡出) - 「光标」三段式切换不再显示任何提示 验证([1].CSV 11 信号,窗口 1725x996,125% DPI): - 双光标:橙 0.63 / 洋红 0.7812,同款把手;底部时间读数 + 居中差值 "151.2 ms" - 放大到不含光标的区间:光标不移动,取值以边缘三角 + 胶囊贴边显示 - 拖拽差值标签:两光标从 0.63/0.7812 整体平移到 0.424202/0.575402,差值仍为 151.2 ms - 差值编辑:输入 300(显示单位为 ms)→ 前方光标保持 0.63,后方移动到 0.93 - 重叠层次:差值设为 0 时洋红(活动)在上;单击光标线后橙色(活动)在上 - 光标形状:未框选 ARROW / 按下 CROSS / 横向 SIZEWE / 纵向 SIZENS(cursor.ps1 实测) - 提示浮层:出现后约 1.3s 开始淡出、约 1.8s 消失;右键放弃选区后视图不变且模式保持选中 - 空闲 CPU 0.00%;make clean; make all 无警告无错误
4308 lines
132 KiB
C++
4308 lines
132 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 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 为给定时间间隔挑选最合适的显示单位(使数值落在 [1, 1000))。
|
||
* @param [IN] seconds 时间间隔(秒)。
|
||
* @return 选中的单位。
|
||
*/
|
||
DurationUnit ChosenDurationUnit(double seconds)
|
||
{
|
||
const double value = std::fabs(seconds);
|
||
// 从大到小排列,取第一个使数值 >= 1 的单位。
|
||
const DurationUnit units[] = {
|
||
{3600.0, L"h"}, {60.0, L"min"}, {1.0, L"s"}, {1.0e-3, L"ms"},
|
||
{1.0e-6, L"us"}, {1.0e-9, L"ns"}, {1.0e-12, L"ps"},
|
||
};
|
||
const DurationUnit* chosen = &units[sizeof(units) / sizeof(units[0]) - 1];
|
||
for (const DurationUnit& unit : units)
|
||
{
|
||
if (value >= unit.scale)
|
||
{
|
||
chosen = &unit;
|
||
break;
|
||
}
|
||
}
|
||
return *chosen;
|
||
}
|
||
|
||
/**
|
||
* @brief 把时间间隔格式化为自适应单位的可读文本(含单位)。
|
||
*
|
||
* 在 h / min / s / ms / us / ns / ps 之间自动选择最合适的单位。
|
||
*
|
||
* @param [IN] seconds 时间间隔(秒)。
|
||
* @return 形如 "12.3 ms" 的文本。
|
||
*/
|
||
std::wstring FormatDuration(double seconds)
|
||
{
|
||
if (!std::isfinite(seconds))
|
||
{
|
||
return L"—";
|
||
}
|
||
const DurationUnit unit = ChosenDurationUnit(seconds);
|
||
std::wstring text = FormatValue(std::fabs(seconds) / unit.scale);
|
||
text += L" ";
|
||
text += unit.name;
|
||
return text;
|
||
}
|
||
|
||
/**
|
||
* @brief 把时间间隔格式化为"当前显示单位下的纯数值"(不含单位),用于内联编辑。
|
||
* @param [IN] seconds 时间间隔(秒)。
|
||
* @return 形如 "12.3" 的文本。
|
||
*/
|
||
std::wstring FormatDurationNumber(double seconds)
|
||
{
|
||
if (!std::isfinite(seconds))
|
||
{
|
||
return L"0";
|
||
}
|
||
const DurationUnit unit = ChosenDurationUnit(seconds);
|
||
return FormatValue(std::fabs(seconds) / unit.scale);
|
||
}
|
||
|
||
/**
|
||
* @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_hintShownAt = (text.empty()) ? 0 : GetTickCount64();
|
||
}
|
||
|
||
bool PlotView::HintNeedsAnimation() const
|
||
{
|
||
if (m_hintText.empty() || m_hintShownAt == 0)
|
||
{
|
||
return false;
|
||
}
|
||
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
|
||
{
|
||
if (m_theme == nullptr)
|
||
{
|
||
return 0xFF000000u;
|
||
}
|
||
return (which == 2) ? m_theme->cursorAlt : 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();
|
||
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"选区放大:横向拖动放大时间轴,纵向拖动放大 Y 轴;右键放弃当前选区,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 double time = (which == 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(which) - 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);
|
||
}
|
||
|
||
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::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::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);
|
||
}
|
||
|
||
// 网格 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)
|
||
{
|
||
// 差值编辑:输入值采用编辑开始时的显示单位(如标签显示 ms 则输入的是 ms)。
|
||
// **前方(时间较小)的光标不动**,后方光标向后移动到"前方 + 输入差值"。
|
||
const double span = std::fabs(parsed) * m_editDurationUnit;
|
||
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;
|
||
}
|
||
|
||
// 直接输入上下限:先固化当前范围,再用新值替换对应端点(保证 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:
|
||
case PlotDragMode::CURSOR_SPAN_MOVE:
|
||
return LoadCursorW(nullptr, IDC_SIZEWE);
|
||
case PlotDragMode::MOVE_SIGNAL:
|
||
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 (Tab().CursorMode() == PlotCursorMode::DOUBLE && m_cursorSpanRect.Width > 0.0f &&
|
||
UiPointInRect(m_cursorSpanRect, static_cast<float>(x), static_cast<float>(y)))
|
||
{
|
||
return LoadCursorW(nullptr, IDC_SIZEWE);
|
||
}
|
||
|
||
{
|
||
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)))
|
||
{
|
||
// 命中信号曲线时给出可拖拽提示光标(仅在常规模式下)。
|
||
if (Tab().ZoomMode() == PlotZoomMode::NONE && HitTestSignal(x, y).IsValid())
|
||
{
|
||
return LoadCursorW(nullptr, IDC_SIZEALL);
|
||
}
|
||
|
||
// 选区放大模式下**未开始框选时保持默认箭头**(不改变鼠标形状);
|
||
// 只有真正按下并拖动后才切成十字/方向箭头。
|
||
// (m_dragMode == RUBBER_SELECT 的情况已在上面处理。)
|
||
}
|
||
return nullptr;
|
||
}
|
||
|
||
bool PlotView::OnMouseMove(int x, int y)
|
||
{
|
||
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;
|
||
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);
|
||
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::NONE:
|
||
default:
|
||
break;
|
||
}
|
||
|
||
bool changed = false;
|
||
|
||
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;
|
||
if (Tab().ZoomMode() == PlotZoomMode::NONE && !m_cursorHover)
|
||
{
|
||
hoverSignal = LegendAtPoint(x, y);
|
||
if (!hoverSignal.IsValid())
|
||
{
|
||
hoverSignal = HitTestSignal(x, y);
|
||
}
|
||
}
|
||
if (hoverSignal != m_hoverSignal)
|
||
{
|
||
m_hoverSignal = hoverSignal;
|
||
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;
|
||
// 新的交互开始:清掉上一次留下的提示(选区放大会在下面重新设置自己的提示)。
|
||
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 (Tab().CursorMode() == PlotCursorMode::DOUBLE && m_cursorSpanRect.Width > 0.0f &&
|
||
UiPointInRect(m_cursorSpanRect, fx, fy))
|
||
{
|
||
m_dragMode = PlotDragMode::CURSOR_SPAN_MOVE;
|
||
m_spanDragStartX = x;
|
||
m_spanDragTime1 = Tab().CursorTime();
|
||
m_spanDragTime2 = Tab().CursorTimeAlt();
|
||
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;
|
||
}
|
||
}
|
||
|
||
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;
|
||
SetHint(L"横向拖动放大时间轴,纵向拖动放大 Y 轴;右键放弃当前选区,Esc 退出");
|
||
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();
|
||
(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 (Tab().CursorMode() == PlotCursorMode::DOUBLE && m_cursorSpanRect.Width > 0.0f &&
|
||
UiPointInRect(m_cursorSpanRect, static_cast<float>(x), static_cast<float>(y)))
|
||
{
|
||
m_editDurationUnit = ChosenDurationUnit(CursorSpan()).scale;
|
||
return BeginEdit(PlotEditTarget::CURSOR_SPAN, -1, FormatDurationNumber(CursorSpan()));
|
||
}
|
||
|
||
// 命中时间光标:进入精确时间输入(交互与线宽输入框一致)。
|
||
{
|
||
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;
|
||
}
|
||
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);
|
||
PaintRubberBand(g);
|
||
PaintCursor(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);
|
||
PaintRubberBand(g);
|
||
PaintCursor(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);
|
||
}
|
||
|
||
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);
|
||
|
||
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(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 (elapsed >= (kHintHoldMs + kHintFadeMs))
|
||
{
|
||
return;
|
||
}
|
||
// 停留结束后在 kHintFadeMs 内线性淡出(颜色 alpha 按比例缩放)。
|
||
const double fade = (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();
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
|
||
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;
|
||
}
|
||
if (legendWidth > 0.0f)
|
||
{
|
||
UiFillRoundRect(g, gd::RectF(cursorX - 4.0f * m_scale, top - 2.0f * m_scale,
|
||
legendWidth + 6.0f * m_scale, height + 4.0f * m_scale),
|
||
3.0f * m_scale, WithAlpha(m_theme->cardBg, 0xE8));
|
||
}
|
||
|
||
// 记录本次绘制命中的条目矩形,供点击选中与双击改名使用。
|
||
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;
|
||
|
||
// 图例条目与被选中的信号保持一致的高亮反馈(与左侧列表选中态呼应)。
|
||
const bool selected = (SelectedRef() == ref);
|
||
const bool hovered = (ref == m_hoverSignal) || (ref == m_dragSignal);
|
||
if (selected || hovered)
|
||
{
|
||
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, selected ? m_theme->selection : m_theme->hover);
|
||
}
|
||
|
||
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;
|
||
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 = FormatDuration(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;
|
||
|
||
// 重叠时把"最近操作过的那条光标"的内容画在上层,因此先画另一条。
|
||
const int order[2] = { (m_activeCursor == 2) ? 1 : 2, (m_activeCursor == 2) ? 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 == order[1]);
|
||
|
||
// 光标在可视区外时,把标注"钉"在对应的网格边上,保证取值仍然可见。
|
||
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;
|
||
// 增量为右侧光标减去左侧光标;正值用 ±,负值直接用负号,
|
||
// 避免出现 “±-0.001” 这种双符号写法。
|
||
text += L" , ";
|
||
text += (delta < 0.0) ? L"-" : L"±";
|
||
text += FormatValue(std::fabs(delta));
|
||
}
|
||
}
|
||
|
||
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));
|
||
const gd::RectF labelRect(labelLeft, labelTop, textWidth, textHeight);
|
||
|
||
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_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 fill = m_theme->readoutBg;
|
||
UiFillRoundRect(g, spanRect, 3.0f * m_scale, WithAlpha(fill, 0xF2));
|
||
UiDrawRoundRect(g, spanRect, 3.0f * m_scale, m_theme->borderStrong, 1.0f);
|
||
UiText(g, FormatDuration(CursorSpan()), m_fonts->monoSmall, spanRect,
|
||
m_theme->textStrong, TextAlign::CENTER);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
m_cursorSpanRect = gd::RectF();
|
||
}
|
||
}
|
||
|
||
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 = 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 = 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
|