信号列表 - 网格徽标(G1/G2)改为在左键抬起时弹出菜单,修复此前「按住才显示、松手即消失」的问题。 - 文件分组行新增关闭按钮(✕),可直接从工作区移除文件,与右键菜单「关闭该文件」等价。 - 信号名改为完整保留文件内的列名原文,不再剥离 "[n]" 序号前缀与括号内容。 标签页 - 新建标签页改用「当前未被占用的最小编号」,关闭标签页后编号可被复用, 修复「开 3 关 2 再新建会变成视图 4」的问题。 样式编辑区(统一为固定四行网格布局,面板缩放时不再错位) - 移除顶部「样式 · 信号名 · 文件名」标题行,选中信号即为编辑对象。 - 绘制方式取消「阶梯」,标签由「方式」改为「样式」,仅保留线段 / 散点 / 点线。 - 线宽仅保留最细与最粗两个预设,中间改为带左右箭头的数值旋钮,单位为 px; 支持单击或双击数值框直接键入(首次键入替换原值,回车提交、Esc 取消、越界钳制到 0.5-6.0)。 - 颜色预设后新增与预设色块完全同尺寸、同外框样式的「加号」小方格, 点击后弹出浮动拾色面板(与 G1/G2 菜单同风格的浮动窗口),支持 RGB 三通道滑块、 实时预览与十六进制显示;点击面板外部即收起,取色实时应用到当前信号。 - 移除样式区末尾的网格选择控件,该功能统一由信号行的 G 徽标承载。 - 新增透明度滑块,默认 100%,可在 0-100% 之间调节,并实时反映到波形、 图例色块与信号行色块的显示效果。 其他 - 信号面板默认宽度与行内间距微调,使较长的列名可以完整显示。 - 失焦时提交线宽输入并收起浮动拾色器与弹出菜单。
1478 lines
40 KiB
C++
1478 lines
40 KiB
C++
#include "PlotView.hpp"
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <limits>
|
|
|
|
#include "Utf8Util.hpp"
|
|
|
|
namespace jdi
|
|
{
|
|
|
|
namespace
|
|
{
|
|
const int kTimeTickTarget = 8; /**< 时间轴期望刻度数。 */
|
|
const int kYTickTarget = 4; /**< 幅值轴期望刻度数。 */
|
|
const float kScrollBarThickness = 9.0f; /**< 滚动条宽度(逻辑像素)。 */
|
|
const float kLanePadding = 8.0f; /**< 网格内边距。 */
|
|
const float kAxisLabelGap = 10.0f; /**< 轴上文字与轴线的间距。 */
|
|
const size_t kMaxPolylinePoints = 20000; /**< 单条折线最大顶点数。 */
|
|
const size_t kMaxScatterPoints = 2500; /**< 单条散点最大绘制点数。 */
|
|
const float kMinScatterRadius = 1.6f; /**< 散点最小半径(逻辑像素)。 */
|
|
const float kMaxScatterRadius = 4.0f; /**< 散点最大半径(逻辑像素)。 */
|
|
const double kMinTimeSpan = 1.0e-12; /**< 允许的最小时间跨度。 */
|
|
|
|
/**
|
|
* @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.name;
|
|
}
|
|
|
|
/**
|
|
* @brief 将信号样式颜色按不透明度换算为实际绘制颜色。
|
|
* @param [IN] style 信号样式。
|
|
* @return 叠加不透明度后的 0xAARRGGBB 颜色值。
|
|
*/
|
|
std::uint32_t StyleColor(const SignalStyle& style)
|
|
{
|
|
double opacity = static_cast<double>(style.opacity);
|
|
if (opacity < 0.0)
|
|
{
|
|
opacity = 0.0;
|
|
}
|
|
if (opacity > 1.0)
|
|
{
|
|
opacity = 1.0;
|
|
}
|
|
return WithAlpha(style.color, static_cast<unsigned int>(opacity * 255.0 + 0.5));
|
|
}
|
|
}
|
|
|
|
PlotView::PlotView() = default;
|
|
|
|
void PlotView::Setup(const UiFonts* fonts, const Theme* theme)
|
|
{
|
|
m_fonts = fonts;
|
|
m_theme = theme;
|
|
}
|
|
|
|
void PlotView::SetContext(const DataSet* dataSet, TabConfig* tab)
|
|
{
|
|
m_dataSet = dataSet;
|
|
m_tab = tab;
|
|
m_hoverLane = -1;
|
|
m_dragMode = PlotDragMode::NONE;
|
|
RefreshLanes();
|
|
}
|
|
|
|
void PlotView::SetScale(float scale)
|
|
{
|
|
m_scale = (scale > 0.1f) ? scale : 1.0f;
|
|
}
|
|
|
|
bool PlotView::HasCursor() const
|
|
{
|
|
return Tab().CursorVisible();
|
|
}
|
|
|
|
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);
|
|
if (m_tab != nullptr)
|
|
{
|
|
result.style = m_tab->Style(ref);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
void PlotView::Layout(const gd::RectF& area)
|
|
{
|
|
m_area = area;
|
|
|
|
m_yAxisWidth = 74.0f * m_scale;
|
|
m_timeAxisHeight = 32.0f * m_scale;
|
|
m_laneGap = 6.0f * m_scale;
|
|
m_minLaneHeight = 46.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_laneGap * static_cast<float>(laneCount - 1);
|
|
m_scrollBarVisible = (needed > availHeight);
|
|
}
|
|
else
|
|
{
|
|
m_scrollBarVisible = false;
|
|
}
|
|
|
|
const float rightReserve =
|
|
m_scrollBarVisible ? (kScrollBarThickness * m_scale + 6.0f * m_scale) : (6.0f * m_scale);
|
|
const float plotWidth = std::max(0.0f, area.Width - m_yAxisWidth - rightReserve);
|
|
|
|
m_plotRect = gd::RectF(area.X + m_yAxisWidth, 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();
|
|
}
|
|
|
|
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_tab->Style(ref);
|
|
if (style == nullptr || !style->visible)
|
|
{
|
|
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 = m_laneGap * m_scale;
|
|
float laneHeight =
|
|
(m_plotRect.Height - gap * static_cast<float>(count - 1)) / static_cast<float>(count);
|
|
if (laneHeight < m_minLaneHeight * m_scale)
|
|
{
|
|
laneHeight = m_minLaneHeight * m_scale;
|
|
}
|
|
m_laneContentHeight = laneHeight * static_cast<float>(count) + gap * static_cast<float>(count - 1);
|
|
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;
|
|
|
|
if (m_tab != nullptr)
|
|
{
|
|
for (Lane& lane : m_tab->Lanes())
|
|
{
|
|
lane.autoRange = true;
|
|
}
|
|
}
|
|
BuildLaneVisuals();
|
|
}
|
|
|
|
void PlotView::ClearCursor()
|
|
{
|
|
Tab().CursorVisible() = false;
|
|
Tab().CursorPinned() = false;
|
|
}
|
|
|
|
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_TIME:
|
|
case PlotDragMode::BOX_ZOOM:
|
|
return LoadCursorW(nullptr, IDC_SIZEALL);
|
|
case PlotDragMode::ZOOM_Y:
|
|
case PlotDragMode::SCROLL_LANES:
|
|
return LoadCursorW(nullptr, IDC_SIZENS);
|
|
default:
|
|
break;
|
|
}
|
|
|
|
if (m_scrollBarVisible &&
|
|
UiPointInRect(m_scrollBarRect, static_cast<float>(x), static_cast<float>(y)))
|
|
{
|
|
return LoadCursorW(nullptr, IDC_SIZENS);
|
|
}
|
|
if (static_cast<float>(x) < m_plotRect.X &&
|
|
UiPointInRect(m_area, static_cast<float>(x), static_cast<float>(y)))
|
|
{
|
|
if (LaneAtY(y) >= 0)
|
|
{
|
|
return LoadCursorW(nullptr, IDC_SIZENS);
|
|
}
|
|
}
|
|
if (UiPointInRect(m_plotRect, static_cast<float>(x), static_cast<float>(y)))
|
|
{
|
|
return LoadCursorW(nullptr, IDC_CROSS);
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
bool PlotView::OnMouseMove(int x, int y)
|
|
{
|
|
if (!HasDrawableContent())
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const float fx = static_cast<float>(x);
|
|
const float fy = static_cast<float>(y);
|
|
|
|
switch (m_dragMode)
|
|
{
|
|
case PlotDragMode::PAN_TIME:
|
|
{
|
|
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;
|
|
Tab().ViewMin() += delta;
|
|
Tab().ViewMax() += delta;
|
|
ClampTimeView();
|
|
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::ZOOM_Y:
|
|
{
|
|
if (m_dragLane < 0 || m_dragLane >= static_cast<int>(m_lanes.size()))
|
|
{
|
|
return false;
|
|
}
|
|
const gd::RectF& rect = m_lanes[static_cast<std::size_t>(m_dragLane)].rect;
|
|
if (rect.Height <= 0.0f)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// 上拖放大、下拖缩小,并保持按下点处的幅值不动。
|
|
const double travel = static_cast<double>(m_dragStartY - y) / static_cast<double>(rect.Height);
|
|
const double factor = std::exp(travel * 2.0);
|
|
const double span = m_dragStartYMax - m_dragStartYMin;
|
|
if (!(span > 0.0))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const double scaleRef = std::max(std::fabs(m_dragStartYMax), std::fabs(m_dragStartYMin));
|
|
const double minSpan = std::max(scaleRef * 1.0e-9, 1.0e-12);
|
|
const double newSpan = ClampDouble(span / factor, minSpan, scaleRef * 100.0 + 1.0);
|
|
|
|
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(m_dragLane)];
|
|
lane.autoRange = false;
|
|
lane.yMin = m_dragAnchorValue - m_dragAnchorRatio * newSpan;
|
|
lane.yMax = lane.yMin + newSpan;
|
|
if (std::abs(y - m_dragStartY) > 2)
|
|
{
|
|
m_dragMoved = true;
|
|
}
|
|
return true;
|
|
}
|
|
case PlotDragMode::BOX_ZOOM:
|
|
{
|
|
const float left = static_cast<float>(std::min(x, m_dragStartX));
|
|
const float top = static_cast<float>(std::min(y, m_dragStartY));
|
|
m_boxRect = gd::RectF(left, top, static_cast<float>(std::abs(x - m_dragStartX)),
|
|
static_cast<float>(std::abs(y - m_dragStartY)));
|
|
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::NONE:
|
|
default:
|
|
break;
|
|
}
|
|
|
|
bool changed = false;
|
|
|
|
const int lane = LaneAtY(y);
|
|
if (lane != m_hoverLane)
|
|
{
|
|
m_hoverLane = lane;
|
|
changed = true;
|
|
}
|
|
|
|
const bool insidePlot = UiPointInRect(m_plotRect, fx, fy);
|
|
if (insidePlot && !Tab().CursorPinned())
|
|
{
|
|
const double time = XToTime(fx);
|
|
if (!Tab().CursorVisible() || std::fabs(time - Tab().CursorTime()) > 0.0)
|
|
{
|
|
Tab().CursorTime() = time;
|
|
Tab().CursorVisible() = true;
|
|
changed = true;
|
|
}
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
bool PlotView::OnLButtonDown(int x, int y)
|
|
{
|
|
if (!HasDrawableContent())
|
|
{
|
|
return false;
|
|
}
|
|
|
|
m_dragStartX = x;
|
|
m_dragStartY = y;
|
|
m_dragLastX = x;
|
|
m_dragLastY = y;
|
|
m_dragMoved = false;
|
|
|
|
if (m_scrollBarVisible &&
|
|
UiPointInRect(m_scrollBarRect, static_cast<float>(x), static_cast<float>(y)))
|
|
{
|
|
m_dragMode = PlotDragMode::SCROLL_LANES;
|
|
return true;
|
|
}
|
|
|
|
const int laneIndex = LaneAtY(y);
|
|
if (laneIndex < 0)
|
|
{
|
|
m_dragMode = PlotDragMode::NONE;
|
|
return false;
|
|
}
|
|
|
|
if (static_cast<float>(x) < m_plotRect.X)
|
|
{
|
|
m_dragMode = PlotDragMode::ZOOM_Y;
|
|
m_dragLane = laneIndex;
|
|
|
|
const LaneVisual& visual = m_lanes[static_cast<std::size_t>(laneIndex)];
|
|
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
|
lane.autoRange = false;
|
|
lane.yMin = visual.yMin;
|
|
lane.yMax = visual.yMax;
|
|
|
|
m_dragStartYMin = visual.yMin;
|
|
m_dragStartYMax = visual.yMax;
|
|
m_dragAnchorValue = YToValue(static_cast<float>(y), visual);
|
|
const double span = visual.yMax - visual.yMin;
|
|
m_dragAnchorRatio = (span > 0.0) ? (visual.yMax - m_dragAnchorValue) / span : 0.5;
|
|
return true;
|
|
}
|
|
|
|
if (UiPointInRect(m_plotRect, static_cast<float>(x), static_cast<float>(y)))
|
|
{
|
|
m_dragMode = PlotDragMode::PAN_TIME;
|
|
return true;
|
|
}
|
|
|
|
m_dragMode = PlotDragMode::NONE;
|
|
return false;
|
|
}
|
|
|
|
bool PlotView::OnLButtonUp(int x, int y)
|
|
{
|
|
bool changed = (m_dragMode != PlotDragMode::NONE);
|
|
|
|
if (m_dragMode == PlotDragMode::PAN_TIME && !m_dragMoved &&
|
|
UiPointInRect(m_plotRect, static_cast<float>(x), static_cast<float>(y)))
|
|
{
|
|
const double time = XToTime(static_cast<float>(x));
|
|
if (Tab().CursorPinned() && std::fabs(time - Tab().CursorTime()) < 1.0e-12)
|
|
{
|
|
Tab().CursorPinned() = false;
|
|
Tab().CursorVisible() = false;
|
|
}
|
|
else
|
|
{
|
|
Tab().CursorTime() = time;
|
|
Tab().CursorVisible() = true;
|
|
Tab().CursorPinned() = true;
|
|
}
|
|
changed = true;
|
|
}
|
|
|
|
m_dragMode = PlotDragMode::NONE;
|
|
m_dragLane = -1;
|
|
m_dragMoved = false;
|
|
return changed;
|
|
}
|
|
|
|
bool PlotView::OnRButtonDown(int x, int y)
|
|
{
|
|
if (!HasDrawableContent())
|
|
{
|
|
return false;
|
|
}
|
|
if (!UiPointInRect(m_plotRect, static_cast<float>(x), static_cast<float>(y)))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
m_dragMode = PlotDragMode::BOX_ZOOM;
|
|
m_dragStartX = x;
|
|
m_dragStartY = y;
|
|
m_dragLastX = x;
|
|
m_dragLastY = y;
|
|
m_dragMoved = false;
|
|
m_dragLane = LaneAtY(y);
|
|
m_boxRect = gd::RectF(static_cast<float>(x), static_cast<float>(y), 0.0f, 0.0f);
|
|
return true;
|
|
}
|
|
|
|
bool PlotView::OnRButtonUp(int x, int y)
|
|
{
|
|
if (m_dragMode != PlotDragMode::BOX_ZOOM)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const bool moved = m_dragMoved;
|
|
const int laneIndex = m_dragLane;
|
|
const gd::RectF box = m_boxRect;
|
|
|
|
m_dragMode = PlotDragMode::NONE;
|
|
m_dragMoved = false;
|
|
m_dragLane = -1;
|
|
|
|
if (!moved || box.Width < 6.0f * m_scale)
|
|
{
|
|
// 未形成有效框:清除未固定的光标。
|
|
if (!Tab().CursorPinned())
|
|
{
|
|
Tab().CursorVisible() = false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
double timeA = XToTime(box.X);
|
|
double timeB = XToTime(box.X + box.Width);
|
|
if (timeB < timeA)
|
|
{
|
|
std::swap(timeA, timeB);
|
|
}
|
|
if (timeB - timeA > 0.0)
|
|
{
|
|
Tab().ViewMin() = timeA;
|
|
Tab().ViewMax() = timeB;
|
|
ClampTimeView();
|
|
}
|
|
|
|
if (laneIndex >= 0 && laneIndex < static_cast<int>(m_lanes.size()) && box.Height > 12.0f * m_scale)
|
|
{
|
|
const LaneVisual& visual = m_lanes[static_cast<std::size_t>(laneIndex)];
|
|
const double valueTop = YToValue(box.Y, visual);
|
|
const double valueBottom = YToValue(box.Y + box.Height, visual);
|
|
if (std::fabs(valueTop - valueBottom) > 0.0)
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
(void)y;
|
|
return true;
|
|
}
|
|
|
|
bool PlotView::OnMouseWheel(int x, int y, int delta, bool ctrl, bool shift, bool alt)
|
|
{
|
|
if (!HasDrawableContent() || delta == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const double notches = static_cast<double>(delta) / 120.0;
|
|
|
|
if (alt)
|
|
{
|
|
Tab().LaneScroll() -= static_cast<float>(notches * 48.0 * m_scale);
|
|
BuildLaneVisuals();
|
|
return true;
|
|
}
|
|
|
|
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 (ctrl)
|
|
{
|
|
int laneIndex = LaneAtY(y);
|
|
if (laneIndex < 0)
|
|
{
|
|
laneIndex = m_hoverLane;
|
|
}
|
|
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
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 anchorValue = YToValue(static_cast<float>(y), visual);
|
|
const double span = visual.yMax - visual.yMin;
|
|
if (!(span > 0.0))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const double factor = std::pow(1.25, notches);
|
|
const double scaleRef = std::max(std::fabs(visual.yMax), std::fabs(visual.yMin));
|
|
const double minSpan = std::max(scaleRef * 1.0e-9, 1.0e-12);
|
|
const double newSpan = ClampDouble(span / factor, minSpan, scaleRef * 100.0 + 1.0);
|
|
|
|
const double ratio = (visual.yMax - anchorValue) / span;
|
|
lane.yMin = anchorValue - ratio * newSpan;
|
|
lane.yMax = lane.yMin + newSpan;
|
|
return true;
|
|
}
|
|
|
|
const double anchorTime = XToTime(static_cast<float>(x));
|
|
ZoomTimeAt(std::pow(1.25, notches), anchorTime);
|
|
return true;
|
|
}
|
|
|
|
bool PlotView::OnDoubleClick(int x, int y)
|
|
{
|
|
if (!UiPointInRect(m_area, static_cast<float>(x), static_cast<float>(y)))
|
|
{
|
|
return false;
|
|
}
|
|
FitAll();
|
|
return true;
|
|
}
|
|
|
|
bool PlotView::OnMouseLeave()
|
|
{
|
|
bool changed = false;
|
|
if (m_hoverLane != -1)
|
|
{
|
|
m_hoverLane = -1;
|
|
changed = true;
|
|
}
|
|
if (!Tab().CursorPinned() && Tab().CursorVisible())
|
|
{
|
|
Tab().CursorVisible() = false;
|
|
changed = true;
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
std::size_t stride = 1;
|
|
const std::size_t visibleCount = end - begin;
|
|
if (visibleCount > kMaxPolylinePoints)
|
|
{
|
|
stride = (visibleCount + kMaxPolylinePoints - 1) / kMaxPolylinePoints;
|
|
}
|
|
|
|
const float limitTop = lane.rect.Y - lane.rect.Height * 4.0f;
|
|
const float limitBottom = lane.rect.Y + lane.rect.Height * 5.0f;
|
|
|
|
bool inSegment = false;
|
|
|
|
for (std::size_t index = begin; index < end; index += stride)
|
|
{
|
|
const double t = time[index];
|
|
const double value = values[index];
|
|
if (!std::isfinite(t) || !std::isfinite(value))
|
|
{
|
|
inSegment = false;
|
|
continue;
|
|
}
|
|
|
|
const float x = TimeToX(t);
|
|
float y = ValueToY(value, lane);
|
|
y = static_cast<float>(ClampDouble(y, limitTop, limitBottom));
|
|
|
|
if (!inSegment)
|
|
{
|
|
breaks.push_back(points.size());
|
|
points.push_back(gd::PointF(x, y));
|
|
inSegment = true;
|
|
}
|
|
else
|
|
{
|
|
points.push_back(gd::PointF(x, y));
|
|
}
|
|
}
|
|
}
|
|
|
|
void PlotView::PaintEmptyState(gd::Graphics& g)
|
|
{
|
|
std::wstring message;
|
|
if (m_dataSet == nullptr || m_dataSet->Empty())
|
|
{
|
|
message = L"打开 CSV 文件以查看仿真数据波形";
|
|
}
|
|
else
|
|
{
|
|
message = L"请在左侧勾选需要查看的信号(可同时加载多个文件进行对比)";
|
|
}
|
|
UiText(g, message, m_fonts->ui, m_area, m_theme->textMuted, TextAlign::CENTER);
|
|
}
|
|
|
|
void PlotView::Paint(gd::Graphics& g)
|
|
{
|
|
if (m_theme == nullptr || m_fonts == nullptr)
|
|
{
|
|
return;
|
|
}
|
|
|
|
UiFillRect(g, m_area, m_theme->plotBg);
|
|
|
|
BuildLaneVisuals();
|
|
|
|
if (m_lanes.empty())
|
|
{
|
|
PaintEmptyState(g);
|
|
return;
|
|
}
|
|
|
|
const double viewMin = Tab().ViewMin();
|
|
const double viewMax = Tab().ViewMax();
|
|
const double span = viewMax - viewMin;
|
|
const double timeStep = NiceStep((span > 0.0) ? span : 1.0, kTimeTickTarget);
|
|
std::vector<double> timeTicks;
|
|
BuildTicks(viewMin, viewMax, timeStep, timeTicks);
|
|
|
|
// 波形区域底色。
|
|
UiFillRect(g, m_plotRect, m_theme->cardBg);
|
|
|
|
for (std::size_t index = 0; index < m_lanes.size(); ++index)
|
|
{
|
|
PaintLane(g, static_cast<int>(index), m_lanes[index], timeTicks, timeStep);
|
|
}
|
|
|
|
PaintTimeAxis(g, timeTicks, timeStep);
|
|
PaintCursor(g, m_lanes);
|
|
|
|
if (m_dragMode == PlotDragMode::BOX_ZOOM && m_dragMoved)
|
|
{
|
|
UiFillRect(g, m_boxRect, WithAlpha(m_theme->accent, 0x2A));
|
|
UiDrawRoundRect(g, m_boxRect, 2.0f * m_scale, m_theme->accent, 1.0f);
|
|
}
|
|
|
|
PaintScrollBar(g);
|
|
|
|
// 绘图区外框。
|
|
gd::Pen borderPen(ToGdiColor(m_theme->border), 1.0f);
|
|
g.DrawRectangle(&borderPen, m_plotRect.X, m_plotRect.Y, m_plotRect.Width, m_plotRect.Height);
|
|
}
|
|
|
|
void PlotView::PaintLane(gd::Graphics& g, int laneIndex, const LaneVisual& lane,
|
|
const std::vector<double>& timeTicks, double timeStep)
|
|
{
|
|
const int saveState = g.Save();
|
|
g.SetClip(gd::RectF(m_area.X, lane.rect.Y, m_area.Width, lane.rect.Height));
|
|
|
|
// 悬停高亮。
|
|
if (laneIndex == m_hoverLane && m_dragMode == PlotDragMode::NONE)
|
|
{
|
|
UiFillRect(g, lane.rect, MixColor(m_theme->cardBg, m_theme->accent, 0.035));
|
|
}
|
|
|
|
// 幅值网格线与标签。
|
|
const double yStep = NiceStep(lane.yMax - lane.yMin, kYTickTarget);
|
|
std::vector<double> yTicks;
|
|
BuildTicks(lane.yMin, lane.yMax, yStep, yTicks);
|
|
|
|
const bool showLabels = (lane.rect.Height >= 34.0f * m_scale);
|
|
float lastLabelY = -1.0e9f;
|
|
|
|
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);
|
|
|
|
if (!showLabels || std::fabs(y - lastLabelY) < 15.0f * m_scale)
|
|
{
|
|
continue;
|
|
}
|
|
lastLabelY = y;
|
|
|
|
const gd::RectF labelRect(m_area.X, y - 9.0f * m_scale, m_yAxisWidth - kAxisLabelGap * m_scale,
|
|
18.0f * m_scale);
|
|
UiText(g, FormatTick(tick, yStep), m_fonts->monoSmall, labelRect,
|
|
zeroLine ? m_theme->text : m_theme->textMuted, TextAlign::RIGHT);
|
|
}
|
|
|
|
// 时间网格线。
|
|
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())
|
|
{
|
|
UiText(g, L"(空网格)", m_fonts->uiSmall, lane.rect, m_theme->textMuted, TextAlign::CENTER);
|
|
}
|
|
else
|
|
{
|
|
const int waveSave = g.Save();
|
|
g.SetClip(lane.rect, gd::CombineModeIntersect);
|
|
|
|
std::vector<gd::PointF> points;
|
|
std::vector<std::size_t> breaks;
|
|
|
|
for (const SignalRef& ref : laneSignals)
|
|
{
|
|
const ResolvedSignal resolved = ResolveSignal(ref);
|
|
if (resolved.data == nullptr || resolved.style == nullptr || !resolved.style->visible)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
const DrawMode mode = resolved.style->mode;
|
|
BuildPolyline(resolved, lane, points, breaks);
|
|
|
|
const bool drawLine = (mode != DrawMode::SCATTER);
|
|
const bool drawScatter = (mode == DrawMode::SCATTER || mode == DrawMode::LINE_SCATTER);
|
|
|
|
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(std::max(1.0f, resolved.style->lineWidth) * (kMinScatterRadius + 0.6f),
|
|
kMinScatterRadius, kMaxScatterRadius) *
|
|
m_scale);
|
|
const std::size_t scatterStride =
|
|
std::max<std::size_t>(1, points.size() / kMaxScatterPoints);
|
|
gd::SolidBrush brush(ToGdiColor(StyleColor(*resolved.style)));
|
|
for (std::size_t k = 0; k < points.size(); k += scatterStride)
|
|
{
|
|
g.FillEllipse(&brush, points[k].X - radius, points[k].Y - radius, radius * 2.0f,
|
|
radius * 2.0f);
|
|
}
|
|
}
|
|
}
|
|
|
|
g.Restore(waveSave);
|
|
}
|
|
|
|
// 波形区边框与网格标题。
|
|
UiDrawLine(g, lane.rect.X, lane.rect.Y + lane.rect.Height, lane.rect.X + lane.rect.Width,
|
|
lane.rect.Y + lane.rect.Height, m_theme->gridStrong, 1.0f);
|
|
UiDrawLine(g, m_area.X, lane.rect.Y, m_area.X + m_area.Width, lane.rect.Y, m_theme->border, 1.0f);
|
|
|
|
if (lane.rect.Height >= 24.0f * m_scale)
|
|
{
|
|
PaintLaneLegend(g, laneIndex, lane);
|
|
if (Tab().CursorVisible() && lane.rect.Height >= 40.0f * m_scale)
|
|
{
|
|
PaintLaneReadout(g, laneIndex, lane);
|
|
}
|
|
}
|
|
|
|
g.Restore(saveState);
|
|
(void)timeStep;
|
|
}
|
|
|
|
void PlotView::PaintLaneLegend(gd::Graphics& g, int laneIndex, const LaneVisual& lane)
|
|
{
|
|
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
|
{
|
|
return;
|
|
}
|
|
|
|
const std::vector<SignalRef>& laneSignals = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].signals;
|
|
const bool multiFile = LaneSpansMultipleFiles(laneIndex);
|
|
|
|
const float top = lane.rect.Y + 4.0f * m_scale;
|
|
const float height = 18.0f * m_scale;
|
|
float cursorX = lane.rect.X + kLanePadding * m_scale;
|
|
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.style->visible)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
std::wstring label = BuildLegendText(*resolved.data);
|
|
if (multiFile && resolved.file != nullptr)
|
|
{
|
|
label += L" · " + resolved.file->fileName;
|
|
}
|
|
const float itemWidth = 13.0f * m_scale + UiTextWidth(g, label, m_fonts->uiSmall) +
|
|
14.0f * m_scale;
|
|
if (cursorX + legendWidth + itemWidth > maxX)
|
|
{
|
|
break;
|
|
}
|
|
legendWidth += itemWidth;
|
|
}
|
|
if (legendWidth > 0.0f)
|
|
{
|
|
UiFillRoundRect(g, gd::RectF(cursorX - 5.0f * m_scale, top - 1.0f * m_scale,
|
|
legendWidth + 6.0f * m_scale, height + 2.0f * m_scale),
|
|
3.0f * m_scale, WithAlpha(m_theme->cardBg, 0xD8));
|
|
}
|
|
|
|
// 绘制图例条目(与测量阶段保持一致的裁剪条件)。
|
|
float drawnWidth = 0.0f;
|
|
for (const SignalRef& ref : laneSignals)
|
|
{
|
|
const ResolvedSignal resolved = ResolveSignal(ref);
|
|
if (resolved.data == nullptr || resolved.style == nullptr || !resolved.style->visible)
|
|
{
|
|
continue;
|
|
}
|
|
anyVisible = true;
|
|
++visibleCount;
|
|
|
|
std::wstring label = BuildLegendText(*resolved.data);
|
|
if (multiFile && resolved.file != nullptr)
|
|
{
|
|
label += L" · " + resolved.file->fileName;
|
|
}
|
|
const float textWidth = UiTextWidth(g, label, m_fonts->uiSmall);
|
|
const float itemWidth = 13.0f * m_scale + textWidth + 14.0f * m_scale;
|
|
if (drawnWidth + itemWidth > legendWidth + 0.5f)
|
|
{
|
|
break;
|
|
}
|
|
drawnWidth += itemWidth;
|
|
|
|
const float swatchSize = 8.0f * m_scale;
|
|
UiFillRoundRect(g,
|
|
gd::RectF(cursorX, top + (height - swatchSize) * 0.5f, swatchSize, swatchSize),
|
|
1.5f * m_scale, StyleColor(*resolved.style));
|
|
cursorX += swatchSize + 5.0f * m_scale;
|
|
|
|
UiText(g, label, m_fonts->uiSmall, gd::RectF(cursorX, top, textWidth + 2.0f, height),
|
|
m_theme->text, TextAlign::LEFT);
|
|
cursorX += textWidth + 14.0f * m_scale;
|
|
++drawnCount;
|
|
}
|
|
|
|
if (!anyVisible)
|
|
{
|
|
UiText(g, L"(无可见信号)", m_fonts->uiSmall,
|
|
gd::RectF(lane.rect.X + kLanePadding * m_scale, 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)
|
|
{
|
|
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.style->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->name;
|
|
if (multiFile)
|
|
{
|
|
line.text += L" · " + resolved.file->fileName;
|
|
}
|
|
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);
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
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);
|
|
|
|
const gd::RectF labelRect(x - 48.0f * m_scale, axisY + 4.0f * m_scale, 96.0f * m_scale,
|
|
m_timeAxisRect.Height - 6.0f * m_scale);
|
|
UiText(g, FormatTick(tick, timeStep), m_fonts->monoSmall, labelRect, m_theme->textMuted,
|
|
TextAlign::CENTER);
|
|
}
|
|
|
|
// 时间轴列名(取自首个可见信号所属文件)。
|
|
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;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
UiText(g, UiEllipsize(g, axisName, m_fonts->monoSmall, m_yAxisWidth - kAxisLabelGap * m_scale),
|
|
m_fonts->monoSmall,
|
|
gd::RectF(m_area.X, axisY, m_yAxisWidth - kAxisLabelGap * m_scale, m_timeAxisRect.Height),
|
|
m_theme->textMuted, TextAlign::RIGHT);
|
|
}
|
|
|
|
void PlotView::PaintCursor(gd::Graphics& g, const std::vector<LaneVisual>& lanes)
|
|
{
|
|
if (!Tab().CursorVisible() || 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;
|
|
}
|
|
|
|
UiDrawDashedLine(g, x, m_plotRect.Y, x, m_plotRect.Y + m_plotRect.Height, m_theme->cursor, 1.0f,
|
|
Tab().CursorPinned() ? gd::DashStyleSolid : gd::DashStyleDash);
|
|
|
|
// 波形上的取值标记。
|
|
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.style->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 < lane.rect.Y - 2.0f || y > lane.rect.Y + lane.rect.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);
|
|
}
|
|
}
|
|
|
|
// 时间轴上的光标标签。
|
|
const std::wstring label = FormatValue(cursorTime);
|
|
const float textWidth = UiTextWidth(g, label, m_fonts->monoSmall);
|
|
const float pillWidth = textWidth + 14.0f * m_scale;
|
|
const float pillHeight = 19.0f * m_scale;
|
|
float pillX = x - pillWidth * 0.5f;
|
|
pillX = std::max(m_plotRect.X, std::min(pillX, m_plotRect.X + m_plotRect.Width - pillWidth));
|
|
|
|
const gd::RectF pill(pillX, m_plotRect.Y + m_plotRect.Height + 2.0f * m_scale, pillWidth, pillHeight);
|
|
UiFillRoundRect(g, pill, 3.0f * m_scale, m_theme->cursor);
|
|
UiText(g, label, m_fonts->monoSmall, pill, m_theme->textOnAccent, 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
|