Files
Just-DataInspector/Components/PlotView/Src/PlotView.cpp
T
DeepSeek V4.1 Flash d6a81839bb 优化百万级采样文件的绘制性能,Esc 改为取消拖拽选区
性能(BIG_DATA.csv 128 万采样 × 10 信号全显示,实测):
- 折线抽稀改为按像素列取上下包络(min/max),顶点数由固定 20000 降到像素量级;
  既保住尖峰(等间隔抽样会整段漏掉),又不再把大量顶点堆在同一像素列上。
- 新增折线顶点缓存:以“构建折线实际读取的全部输入”(信号、时间轴范围、绘图区横向
  几何、网格纵向几何)为键,视图未变化时直接复用,重复绘制不再重算全量采样。
- 悬停命中检测先按 x 二分裁剪到容差范围内的一小段(点到线段距离必不小于横向距离),
  鼠标移动不再遍历上万个顶点。
- 结果:整帧渲染 1309ms -> 103~127ms(10 网格);折线构建 25ms -> 0ms(缓存命中);
  单条折线绘制 1255ms -> 45~53ms;悬停命中 ~0ms。两文件 6 网格场景约 52ms。
- 未引入多线程:耗时集中在 GDI+ 单线程绘制调用与一次性 O(N) 包络扫描,
  并行化的收益上限仅约 25ms,不值得引入线程与同步复杂度。
- 空闲(无交互)CPU 占用实测 0.00%,消息循环阻塞在 GetMessage,
  无需重绘时不再产生任何计算。

Esc 行为:
- 缩放拖拽(时间轴/幅值轴框选)按住左键时按 Esc:立即放弃选区并清空拖拽状态,
  随后松开左键也不会把选区应用到视图(已验证:时间轴保持原范围,对照组正常缩放)。
- Esc 不再关闭时间光标(原先 Esc 会调用 DisableCursor);关闭光标只走工具栏「光标」按钮,
  避免顺手按 Esc 时意外关掉指针。
- 数据层结构变化(加载/移除文件、切换上下文)后清空折线缓存,
  防止旧几何键与新信号重合而取到过期顶点。
2026-09-30 17:49:36 +08:00

3084 lines
92 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 <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; /**< 图例条目的文本宽度上限(超出则省略)。 */
/**
* @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;
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();
RefreshLanes();
}
void PlotView::SetScale(float scale)
{
m_scale = (scale > 0.1f) ? scale : 1.0f;
}
bool PlotView::CursorEnabled() const
{
return Tab().CursorEnabled();
}
double PlotView::CursorTime() const
{
return Tab().CursorTime();
}
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;
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)
{
for (Lane& lane : m_tab->Lanes())
{
lane.autoRange = true;
}
}
BuildLaneVisuals();
}
void PlotView::DisableCursor()
{
Tab().CursorEnabled() = false;
m_cursorHover = false;
if (m_editTarget == PlotEditTarget::CURSOR_TIME)
{
EndEdit(false);
}
if (m_dragMode == PlotDragMode::CURSOR_MOVE)
{
m_dragMode = PlotDragMode::NONE;
}
}
bool PlotView::CancelDrag()
{
if (m_dragMode == PlotDragMode::NONE)
{
return false;
}
// 放弃当前拖拽:清空拖拽状态与尚未生效的缩放选区,不改动任何视图范围。
// 调用方仍持有鼠标捕获,松开左键时会走 OnLButtonUp,
// 此时拖拽模式已是 NONE,选区不会被应用到时间轴/幅值轴上。
m_dragMode = PlotDragMode::NONE;
m_dragLane = -1;
m_dragMoved = false;
m_rubberRect = gd::RectF();
m_dragSignal = SignalRef();
m_dragTargetLane = -1;
return true;
}
void PlotView::InvalidatePolylineCache()
{
m_polylineCache.clear();
}
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();
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;
}
bool PlotView::ToggleCursor()
{
if (Tab().CursorEnabled())
{
DisableCursor();
return true;
}
Tab().CursorEnabled() = true;
// 启用时光标居中放置,随后可用鼠标靠近拖拽或双击指定精确时间。
Tab().CursorTime() = (Tab().ViewMin() + Tab().ViewMax()) * 0.5;
m_cursorHover = true;
return true;
}
PlotZoomMode PlotView::CurrentZoomMode() const
{
return Tab().ZoomMode();
}
bool PlotView::ToggleZoomMode(PlotZoomMode mode)
{
const PlotZoomMode current = Tab().ZoomMode();
Tab().ZoomMode() = (current == mode) ? PlotZoomMode::NONE : mode;
return true;
}
float PlotView::CursorX() const
{
return TimeToX(Tab().CursorTime());
}
gd::RectF PlotView::CursorLabelRect() const
{
if (m_fonts == nullptr || m_fonts->monoSmall == nullptr)
{
return gd::RectF();
}
const std::wstring text = FormatValue(Tab().CursorTime());
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 = CursorX() - 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 + 2.0f * m_scale;
return gd::RectF(left, top, width, height);
}
bool PlotView::IsOnCursorLine(int x) const
{
if (!Tab().CursorEnabled() || m_plotRect.Width <= 0.0f)
{
return false;
}
const float cursorX = CursorX();
const float tolerance = 6.0f * m_scale;
return (static_cast<float>(x) >= cursorX - tolerance) && (static_cast<float>(x) <= cursorX + tolerance);
}
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();
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;
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::LaneRangeIsAuto(int laneIndex) const
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return true;
}
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].autoRange;
}
bool PlotView::ResetLaneRange(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)];
if (lane.autoRange)
{
return false;
}
lane.autoRange = true;
BuildLaneVisuals();
return true;
}
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.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)];
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::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 gd::RectF displayRect = CursorLabelRect();
width = std::max(width, displayRect.Width);
float left = CursorX() - 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 + 2.0f * m_scale, 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);
Tab().CursorTime() = ClampDouble(parsed, low - span * 10.0, high + span * 10.0);
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)];
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_TIME:
return LoadCursorW(nullptr, IDC_SIZEWE);
case PlotDragMode::RUBBER_VALUE:
return LoadCursorW(nullptr, IDC_SIZENS);
case PlotDragMode::SCROLL_LANES:
return LoadCursorW(nullptr, IDC_SIZENS);
case PlotDragMode::CURSOR_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 (IsOnCursorLine(x) && 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);
}
// 仅在缩放模式下才切换到对应的缩放光标,常规模式保持箭头(不出现十字指针)。
const PlotZoomMode mode = Tab().ZoomMode();
if (mode == PlotZoomMode::TIME)
{
return LoadCursorW(nullptr, IDC_SIZEWE);
}
if (mode == PlotZoomMode::VALUE)
{
return LoadCursorW(nullptr, IDC_SIZENS);
}
}
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_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);
m_dragMoved = true;
return true;
}
case PlotDragMode::RUBBER_VALUE:
{
// 横向选区限定在按下时的网格内,只取纵向区间用于幅值轴缩放。
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;
}
Tab().CursorTime() = target;
m_dragMoved = true;
m_cursorHover = 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;
}
// 时间光标不再跟随鼠标移动,仅在鼠标覆盖光标时才显示具体时间读数。
const bool hover = Tab().CursorEnabled() && IsOnCursorLine(x);
if (hover != m_cursorHover)
{
m_cursorHover = hover;
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();
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().CursorEnabled() && IsOnCursorLine(x) && UiPointInRect(m_area, fx, fy))
{
m_dragMode = PlotDragMode::CURSOR_MOVE;
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::TIME)
{
m_dragMode = PlotDragMode::RUBBER_TIME;
m_rubberRect = gd::RectF(fx, m_plotRect.Y, 0.0f, m_plotRect.Height);
return true;
}
if (mode == PlotZoomMode::VALUE)
{
const int laneIndex = LaneAtY(y);
if (laneIndex < 0)
{
m_dragMode = PlotDragMode::NONE;
return false;
}
m_dragLane = laneIndex;
m_dragMode = PlotDragMode::RUBBER_VALUE;
const gd::RectF& laneRect = m_lanes[static_cast<std::size_t>(laneIndex)].rect;
m_rubberRect = gd::RectF(laneRect.X, fy, laneRect.Width, 0.0f);
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_TIME:
if (m_dragMoved &&
ApplyTimeSelection(m_rubberRect.X, m_rubberRect.X + m_rubberRect.Width))
{
changed = true;
}
break;
case PlotDragMode::RUBBER_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::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_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().CursorEnabled() && IsOnCursorLine(x))
{
return BeginEdit(PlotEditTarget::CURSOR_TIME, -1, FormatValue(Tab().CursorTime()));
}
// 命中图例条目:进入信号改名(与左侧列表双击改名的交互一致)。
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;
}
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 rangeBegin = time.begin() + static_cast<std::ptrdiff_t>(begin);
const std::vector<double>::const_iterator rangeEnd = time.begin() + static_cast<std::ptrdiff_t>(end);
bool inSegment = false;
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));
const std::size_t i0 = static_cast<std::size_t>(
std::lower_bound(rangeBegin, rangeEnd, t0) - time.begin());
const std::size_t i1 = (column + 1 == columns)
? end
: static_cast<std::size_t>(
std::lower_bound(rangeBegin, 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 lowIndex = 0;
std::size_t highIndex = 0;
bool broken = false;
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;
lowIndex = index;
}
if (value > high)
{
high = value;
highIndex = 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 (lowIndex <= highIndex)
{
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;
}
UiFillRect(g, m_area, m_theme->plotBg);
BuildLaneVisuals();
if (m_lanes.empty())
{
PaintEmptyState(g);
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_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);
PaintRubberBand(g);
PaintCursor(g, m_lanes);
PaintSignalInteraction(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::PaintRubberBand(gd::Graphics& g)
{
if (!m_dragMoved || m_rubberRect.Width <= 0.0f || m_rubberRect.Height <= 0.0f)
{
return;
}
const bool timeMode = (m_dragMode == PlotDragMode::RUBBER_TIME);
const bool valueMode = (m_dragMode == PlotDragMode::RUBBER_VALUE);
if (!timeMode && !valueMode)
{
return;
}
// 时间轴缩放用强调色,Y 轴缩放用警示色,便于区分两种模式。
const std::uint32_t band = timeMode ? m_theme->accent : m_theme->danger;
UiFillRect(g, m_rubberRect, WithAlpha(band, 0x24));
UiDrawRoundRect(g, m_rubberRect, 2.0f * m_scale, band, 1.2f);
// 选区跨度提示(时间用秒/数值用幅度量纲),放在选区顶部。
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::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));
// 悬停高亮。
if (laneIndex == m_hoverLane && m_dragMode == PlotDragMode::NONE)
{
UiFillRect(g, lane.rect, MixColor(m_theme->cardBg, m_theme->accent, 0.035));
}
// 幅值网格线(先画线,标尺稍后作为覆盖层绘制,避免被波形遮挡)。
// 刻度密度随网格高度自适应:所有网格高度相同 → 各网格的刻度数量一致,
// 不会出现“有的网格只有 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);
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);
// 悬停命中的信号先画一层浅色光晕,作为“可拖拽”的视觉提示。
const bool highlighted = (ref == m_hoverSignal) || (ref == m_dragSignal);
if (highlighted && drawLine && !breaks.empty())
{
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));
}
}
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);
gd::SolidBrush brush(ToGdiColor(StyleColor(*resolved.style)));
for (std::size_t k = 0; k < points.size(); k += scatterStride)
{
g.FillEllipse(&brush, points[k].X - radius, points[k].Y - radius, radius * 2.0f,
radius * 2.0f);
}
}
}
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);
if (Tab().CursorEnabled() && lane.rect.Height >= 40.0f * m_scale)
{
PaintLaneReadout(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);
}
}
}
void PlotView::PaintLaneReadout(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 double cursorTime = Tab().CursorTime();
if (cursorTime < Tab().ViewMin() || cursorTime > Tab().ViewMax())
{
return;
}
struct ReadoutLine
{
std::wstring text;
};
std::vector<ReadoutLine> lines;
const bool multiFile = LaneSpansMultipleFiles(laneIndex);
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.file == nullptr ||
!resolved.visible)
{
continue;
}
const bool interpolate = (resolved.style->mode != DrawMode::SCATTER);
double value = 0.0;
if (!SampleFileAt(*resolved.file, *resolved.data, cursorTime, interpolate, value))
{
continue;
}
ReadoutLine line;
line.text = resolved.data->Title();
if (multiFile)
{
line.text += L" · " + resolved.file->Title();
}
line.text += L" = " + FormatValue(value);
lines.push_back(std::move(line));
}
if (lines.empty())
{
return;
}
const float lineHeight = 17.0f * m_scale;
const float maxLines =
std::max(1.0f, std::floor((lane.rect.Height - 12.0f * m_scale) / lineHeight));
const std::size_t shown = std::min(lines.size(), static_cast<std::size_t>(maxLines));
float boxWidth = 0.0f;
for (std::size_t index = 0; index < shown; ++index)
{
boxWidth = std::max(boxWidth, UiTextWidth(g, lines[index].text, m_fonts->monoSmall));
}
boxWidth += 20.0f * m_scale;
const float boxHeight = static_cast<float>(shown) * lineHeight + 8.0f * m_scale;
const gd::RectF box(lane.rect.X + lane.rect.Width - kLanePadding * m_scale - boxWidth,
lane.rect.Y + 4.0f * m_scale, boxWidth, boxHeight);
// 读数框整体必须落在可见区内,否则文本会越过绘图区边界。
if (!IsFullyVisible(box, visibleTop, visibleBottom))
{
return;
}
UiFillRoundRect(g, box, 4.0f * m_scale, WithAlpha(m_theme->readoutBg, 0xEE));
UiDrawRoundRect(g, box, 4.0f * m_scale, m_theme->border, 1.0f);
float textY = box.Y + 4.0f * m_scale;
for (std::size_t index = 0; index < shown; ++index)
{
const gd::RectF lineRect(box.X + 10.0f * m_scale, textY, boxWidth - 14.0f * m_scale, lineHeight);
UiText(g, lines[index].text, m_fonts->monoSmall, lineRect, m_theme->textStrong, TextAlign::LEFT);
textY += lineHeight;
}
}
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::PaintCursor(gd::Graphics& g, const std::vector<LaneVisual>& lanes)
{
if (!Tab().CursorEnabled() || m_tab == nullptr)
{
return;
}
const double cursorTime = Tab().CursorTime();
const float x = TimeToX(cursorTime);
if (x < m_plotRect.X - 2.0f || x > m_plotRect.X + m_plotRect.Width + 2.0f)
{
return;
}
// 光标纵向贯穿全部网格,并延伸到时间轴底部,便于与刻度对齐读数。
const float lineTop = m_plotRect.Y;
const float lineBottom = m_timeAxisRect.Y + m_timeAxisRect.Height;
const float lineWidth = (m_cursorHover || m_dragMode == PlotDragMode::CURSOR_MOVE) ? 2.0f : 1.2f;
UiDrawLine(g, x, lineTop, x, lineBottom, m_theme->cursor, lineWidth * 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(m_theme->cursor));
g.FillPolygon(&handle, points, 3);
}
// 各网格上的取值标记(仅在网格可见范围内绘制)。
for (std::size_t laneIndex = 0; laneIndex < lanes.size(); ++laneIndex)
{
const LaneVisual& lane = lanes[laneIndex];
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;
}
double value = 0.0;
if (!SampleFileAt(*resolved.file, *resolved.data, cursorTime,
resolved.style->mode != DrawMode::SCATTER, value))
{
continue;
}
const float y = ValueToY(value, lane);
if (y < std::max(lane.rect.Y, m_plotRect.Y) - 2.0f ||
y > std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height) + 2.0f)
{
continue;
}
const float radius = 3.4f * m_scale;
gd::SolidBrush ring(ToGdiColor(m_theme->cardBg));
gd::SolidBrush fill(ToGdiColor(StyleColor(*resolved.style)));
g.FillEllipse(&ring, x - 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, x - radius, y - radius, radius * 2.0f, radius * 2.0f);
}
}
// 具体时间显示在绘图区顶部(不遮挡时间轴刻度),且仅在鼠标覆盖光标时出现。
if (m_editTarget == PlotEditTarget::CURSOR_TIME)
{
PaintEditField(g);
return;
}
if (!m_cursorHover)
{
return;
}
const gd::RectF labelRect = CursorLabelRect();
if (labelRect.Width <= 0.0f)
{
return;
}
UiFillRoundRect(g, labelRect, 3.0f * m_scale, m_theme->cursor);
UiText(g, FormatValue(cursorTime), m_fonts->monoSmall, labelRect, m_theme->textOnAccent,
TextAlign::CENTER);
}
void PlotView::PaintEditField(gd::Graphics& g)
{
const gd::RectF fieldRect = EditFieldRect();
if (fieldRect.Width <= 0.0f || fieldRect.Height <= 0.0f)
{
return;
}
// 编辑态:强调色外框 + 选区高亮 + 插入符,首次键入即整体替换(与常规输入框一致)。
UiDrawEditField(g, fieldRect, m_edit, m_fonts->monoSmall, m_scale);
}
void PlotView::PaintSignalInteraction(gd::Graphics& g)
{
if (m_tab == nullptr || m_dataSet == nullptr || m_lanes.empty())
{
return;
}
// 拖拽中:虚影跟随鼠标纵向位移,同时在目标网格内高亮落点。
if (m_dragMode == PlotDragMode::MOVE_SIGNAL && m_dragSignal.IsValid())
{
const ResolvedSignal resolved = ResolveSignal(m_dragSignal);
const int sourceLane = m_tab->LaneOfSignal(m_dragSignal);
if (resolved.data == nullptr || resolved.style == nullptr || sourceLane < 0 ||
sourceLane >= static_cast<int>(m_lanes.size()))
{
return;
}
// 虚影:以源网格的幅值映射构建波形,再按鼠标位移整体平移。
// 第一帧位移为 0,因此虚影必然精确落在鼠标下方(曲线形态与抓住时完全一致);
// 之后纵向与横向均自由跟随,形成“拖着曲线走”的观感。
const LaneVisual& sourceVisual = m_lanes[static_cast<std::size_t>(sourceLane)];
const float offsetX = static_cast<float>(m_dragMouseX - m_dragStartX);
const float offsetY = static_cast<float>(m_dragMouseY - m_dragStartY);
{
const int saveState = g.Save();
g.SetClip(gd::RectF(m_plotRect.X, m_plotRect.Y, m_plotRect.Width, m_plotRect.Height),
gd::CombineModeIntersect);
std::vector<gd::PointF> points;
std::vector<std::size_t> breaks;
BuildPolyline(resolved, sourceVisual, points, breaks);
for (gd::PointF& point : points)
{
point.X += offsetX;
point.Y += 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::SolidBrush brush(ToGdiColor(ghostColor));
for (std::size_t k = 0; k < points.size(); k += scatterStride)
{
g.FillEllipse(&brush, points[k].X - radius, points[k].Y - radius, radius * 2.0f,
radius * 2.0f);
}
}
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