1) 默认线宽改为 1.0px - SignalStyle::lineWidth 初值 1.6f -> 1.0f (DataModel.hpp / DataModel.cpp) - 新增 LaneScroll() const 重载,供 const 的 MakeSceneCacheKey() 读取 2) 网格右键菜单文本左对齐 - PopupMenu 的 HEADER / NORMAL / EDIT 三类条目统一使用 kMarkerColumn(22.0f) 作为文本列,替换原先写死的 8.0f,使 "网格 N"、"Y 轴最大值"、 "Y 轴最小值"、"选区 Y 轴自适应" 等所有条目左边缘严格对齐 3) 修复散点/点线模式下数据点重叠处的白色缝隙 - 根因: GDI+ GraphicsPath 默认 FillModeAlternate(奇偶填充),重叠圆的交叠区域 被反向填充,视觉上表现为白色缺口/白边 - 主绘制与拖拽 ghost 的 GraphicsPath 均显式 SetFillMode(FillModeWinding) - ghost 由逐点 FillEllipse 改为单个 GraphicsPath 一次 FillPath(批量绘制) 4) 大文件拖拽流畅性优化 (VERY_BIG_DATA.CSV 1.77GB / 5 信号) - 新增拖拽静态场景缓存: 在 MOVE_SIGNAL / CURSOR_MOVE 期间把整个客户区 (绘图区 + 标签页 + 工具栏 + 信号面板) 捕快到 m_sceneDc 位图,后续帧直接 BitBlt,仅在 SceneCacheKey(dragMode/viewMin/viewMax/laneScroll/laneCount/ 区域/scale) 变化时重绘。Application::Render 在复用场景时跳过窗口背景与 各子控件绘制,缓存位图由 ~PlotView() 释放 - 失效点: SetContext / SetScale / Layout / InvalidatePolylineCache - 光标数值读数从 PaintLane 抽出为 PaintLaneReadouts(),在缓存 blit 之后绘制, 避免读数被缓存导致拖动时显示旧值 - 拖拽 ghost 复用已有 PolylineCacheEntry,改用 TranslateTransform 偏移, 不再重复构造折线顶点 - 实测: 拖拽帧耗时 134-146ms -> 22-25ms;散点模式拖拽约 24.6ms; 非拖拽重绘维持约 118ms;空闲 CPU 0.00% 构建: make clean; make all 无警告无错误
3599 lines
107 KiB
C++
3599 lines
107 KiB
C++
#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;
|
||
|
||
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();
|
||
}
|
||
|
||
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)
|
||
{
|
||
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;
|
||
}
|
||
|
||
bool PlotView::ExitZoomModes()
|
||
{
|
||
if (m_tab == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// 退出全部缩放模式:工具栏的「时间轴缩放」「Y 轴缩放」按钮会在
|
||
// Application::Render 中按 ZoomMode 同步为未选中态。
|
||
if (Tab().ZoomMode() == PlotZoomMode::NONE)
|
||
{
|
||
return false;
|
||
}
|
||
Tab().ZoomMode() = PlotZoomMode::NONE;
|
||
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();
|
||
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;
|
||
}
|
||
|
||
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::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::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 的初始占位值,导致量程被莫名其妙地放大。
|
||
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 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 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;
|
||
}
|
||
|
||
// 拖拽期间的静态场景复用:拖动信号曲线 / 时间光标时视图与数据都没有变化,
|
||
// 每帧重绘百万级采样的包络纯属浪费(实测 5 条密集信号约 100ms/帧)。
|
||
// 这里命中缓存时只贴图 + 重绘随鼠标变化的部分,使拖拽帧率不随数据量下降。
|
||
const bool sceneCacheable =
|
||
(m_dragMode == PlotDragMode::MOVE_SIGNAL || m_dragMode == PlotDragMode::CURSOR_MOVE);
|
||
|
||
if (sceneCacheable && m_sceneValid)
|
||
{
|
||
const SceneCacheKey& cached = m_sceneKey;
|
||
const SceneCacheKey key = MakeSceneCacheKey();
|
||
const bool same = (key.dragMode == cached.dragMode) && (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);
|
||
if (same && BlitSceneCache(g))
|
||
{
|
||
PaintLaneReadouts(g);
|
||
PaintRubberBand(g);
|
||
PaintCursor(g, m_lanes);
|
||
PaintSignalInteraction(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);
|
||
|
||
// 此刻画布上正好是"绘图区底色 + 全部网格 + 时间轴",尚未绘制任何随鼠标变化的内容,
|
||
// 因此是捕获静态场景缓存的最佳时点(必须在裁剪区已全部恢复之后执行)。
|
||
if (sceneCacheable)
|
||
{
|
||
CaptureSceneCache(g);
|
||
}
|
||
else
|
||
{
|
||
InvalidateSceneCache();
|
||
}
|
||
|
||
PaintLaneReadouts(g);
|
||
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);
|
||
}
|
||
|
||
PlotView::SceneCacheKey PlotView::MakeSceneCacheKey() const
|
||
{
|
||
SceneCacheKey key;
|
||
key.dragMode = static_cast<int>(m_dragMode);
|
||
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;
|
||
return key;
|
||
}
|
||
|
||
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)
|
||
{
|
||
// 缓存覆盖**整个客户区**:拖拽期间工具栏/标签栏/面板也同样不变,
|
||
// 宿主(Application)据此跳过这些区域的重复绘制(实测面板绘制就占 8ms/帧)。
|
||
int clientWidth = 0;
|
||
int clientHeight = 0;
|
||
if (m_host != nullptr)
|
||
{
|
||
RECT client = {};
|
||
GetClientRect(m_host, &client);
|
||
clientWidth = client.right - client.left;
|
||
clientHeight = client.bottom - client.top;
|
||
}
|
||
if (clientWidth <= 0 || clientHeight <= 0)
|
||
{
|
||
// 无宿主句柄时退化为只缓存绘图区。
|
||
const int x = static_cast<int>(std::floor(m_area.X)) - 2;
|
||
const int y = static_cast<int>(std::floor(m_area.Y)) - 2;
|
||
const int right = static_cast<int>(std::ceil(m_area.X + m_area.Width)) + 2;
|
||
const int bottom = static_cast<int>(std::ceil(m_area.Y + m_area.Height)) + 2;
|
||
m_sceneX = x;
|
||
m_sceneY = y;
|
||
clientWidth = right - x;
|
||
clientHeight = bottom - y;
|
||
}
|
||
else
|
||
{
|
||
m_sceneX = 0;
|
||
m_sceneY = 0;
|
||
}
|
||
|
||
if (!EnsureSceneCache(clientWidth, clientHeight))
|
||
{
|
||
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, clientWidth, clientHeight, 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::CanReuseSceneCache() const
|
||
{
|
||
if (!m_sceneValid)
|
||
{
|
||
return false;
|
||
}
|
||
if (m_dragMode != PlotDragMode::MOVE_SIGNAL && m_dragMode != PlotDragMode::CURSOR_MOVE)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
const SceneCacheKey& cached = m_sceneKey;
|
||
const SceneCacheKey key = MakeSceneCacheKey();
|
||
return (key.dragMode == cached.dragMode) && (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);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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);
|
||
// 所有散点合并到一条 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);
|
||
}
|
||
|
||
void PlotView::PaintLaneReadouts(gd::Graphics& g)
|
||
{
|
||
// 读数框随时间光标实时变化,因此**不能**进入拖拽静态场景缓存,
|
||
// 必须每帧单独绘制(否则拖动光标时读数会停在旧值上)。
|
||
if (m_tab == nullptr || !Tab().CursorEnabled())
|
||
{
|
||
return;
|
||
}
|
||
|
||
for (std::size_t index = 0; index < m_lanes.size(); ++index)
|
||
{
|
||
const LaneVisual& lane = m_lanes[index];
|
||
if (lane.rect.Height < 24.0f * m_scale || lane.rect.Height < 40.0f * m_scale)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
const int saveState = g.Save();
|
||
g.SetClip(gd::RectF(m_area.X, clipTop, m_area.Width, clipBottom - clipTop));
|
||
PaintLaneReadout(g, static_cast<int>(index), 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);
|
||
|
||
// 虚影的几何与源网格里的信号完全一致,直接复用折线顶点缓存,
|
||
// 再用平移变换让整体跟随鼠标:避免每帧重新构建并逐点搬运上万顶点。
|
||
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
|