Files
Just-DataInspector/Components/PlotView/Src/PlotView.cpp
T
DeepSeek V4.1 Flash eaf5b9cadf feat(ui): 双光标样式统一与配色、光标滞留视野外、差值标签与拖拽、选区放大交互与提示
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 无警告无错误
2026-10-08 19:37:26 +08:00

4308 lines
132 KiB
C++
Raw Blame History

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