基于 Win32 API + GDI+ 从零实现仿真数据检查器,用于替代 Simulink.sdi.view 查看带时间轴的 CSV 数据。 - 新增 Utilities 层:UTF-8/宽字符与数值格式化工具、CSV 解析器(自动识别分隔符与引号)、 信号数据集模型(时间轴识别、信号名解析、取值采样)、Solarised Light 主题色板与绘图基础工具 - 新增 Toolbar 组件:打开 CSV、适应窗口、适应时间、放大、缩小、合并网格、每信号一格 - 新增 SignalPanel 组件:信号列表(可见性开关、颜色标识、所属网格标记)、样式编辑区 (颜色、线宽、线段/散点/点线/阶梯显示方式、所属网格),支持右键上下文菜单与滚轮滚动 - 新增 PlotView 组件:多网格波形绘制、自适应时间轴与幅值轴、网格线、信号图例、 时间光标与实时数值读数、纵向滚动条 - 新增 Application 组件:DPI 感知(Per-Monitor V2)、双缓冲绘制、区域布局与分隔条拖拽、 鼠标与键盘消息分发、文件拖放、状态栏提示 - 交互能力:滚轮缩放、Shift+滚轮平移、Ctrl+滚轮缩放幅值、拖拽平移、幅值轴纵向缩放、 右键框选缩放、单击固定/取消时间光标、双击自适应、方向键平移与滚动 - 补齐 Makefile 的源文件与头文件索引,并新增 gdiplus、comdlg32 链接库
1352 lines
37 KiB
C++
1352 lines
37 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;
|
|
}
|
|
}
|
|
|
|
PlotView::PlotView() = default;
|
|
|
|
void PlotView::Setup(const UiFonts* fonts, const Theme* theme)
|
|
{
|
|
m_fonts = fonts;
|
|
m_theme = theme;
|
|
}
|
|
|
|
void PlotView::SetDocument(SignalSet* document)
|
|
{
|
|
m_document = document;
|
|
m_laneScroll = 0.0f;
|
|
m_cursorVisible = false;
|
|
m_cursorPinned = false;
|
|
m_hoverLane = -1;
|
|
if (m_document != nullptr && m_document->IsLoaded())
|
|
{
|
|
FitTime();
|
|
}
|
|
}
|
|
|
|
void PlotView::SetScale(float scale)
|
|
{
|
|
m_scale = (scale > 0.1f) ? scale : 1.0f;
|
|
}
|
|
|
|
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 = 0;
|
|
if (m_document != nullptr && m_document->IsLoaded())
|
|
{
|
|
laneCount = m_document->Lanes().size();
|
|
}
|
|
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();
|
|
}
|
|
|
|
float PlotView::LaneContentHeight() const
|
|
{
|
|
return m_laneContentHeight;
|
|
}
|
|
|
|
void PlotView::ClampLaneScroll()
|
|
{
|
|
const float maxOffset = std::max(0.0f, m_laneContentHeight - m_plotRect.Height);
|
|
m_laneScroll = ClampDouble(m_laneScroll, 0.0, maxOffset);
|
|
}
|
|
|
|
void PlotView::BuildLaneVisuals()
|
|
{
|
|
m_lanes.clear();
|
|
if (m_document == nullptr || !m_document->IsLoaded())
|
|
{
|
|
m_laneContentHeight = 0.0f;
|
|
return;
|
|
}
|
|
|
|
const std::vector<Lane>& lanes = m_document->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();
|
|
|
|
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) - m_laneScroll,
|
|
m_plotRect.Width, laneHeight);
|
|
m_document->ComputeLaneRange(static_cast<int>(index), visual.yMin, visual.yMax);
|
|
if (!(visual.yMax > visual.yMin))
|
|
{
|
|
visual.yMin = 0.0;
|
|
visual.yMax = 1.0;
|
|
}
|
|
|
|
visual.empty = true;
|
|
for (const int signalIndex : lanes[index].signalIndices)
|
|
{
|
|
if (signalIndex >= 0 && signalIndex < static_cast<int>(m_document->Signals().size()) &&
|
|
m_document->Signals()[static_cast<std::size_t>(signalIndex)].visible)
|
|
{
|
|
visual.empty = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
float PlotView::TimeToX(double time) const
|
|
{
|
|
const double span = m_viewMax - m_viewMin;
|
|
if (!(span > 0.0) || m_plotRect.Width <= 0.0f)
|
|
{
|
|
return m_plotRect.X;
|
|
}
|
|
return m_plotRect.X + static_cast<float>((time - m_viewMin) / span * m_plotRect.Width);
|
|
}
|
|
|
|
double PlotView::XToTime(float x) const
|
|
{
|
|
if (m_plotRect.Width <= 0.0f)
|
|
{
|
|
return m_viewMin;
|
|
}
|
|
const double ratio = static_cast<double>(x - m_plotRect.X) / static_cast<double>(m_plotRect.Width);
|
|
return m_viewMin + ratio * (m_viewMax - m_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_document == nullptr || !m_document->IsLoaded())
|
|
{
|
|
return;
|
|
}
|
|
|
|
double span = m_viewMax - m_viewMin;
|
|
if (!(span > 0.0))
|
|
{
|
|
span = kMinTimeSpan;
|
|
}
|
|
|
|
const double dataSpan = std::max(m_document->MaxTime() - m_document->MinTime(), kMinTimeSpan);
|
|
const double pad = span * 2.0;
|
|
const double minAllowed = m_document->MinTime() - pad;
|
|
const double maxAllowed = m_document->MaxTime() + pad;
|
|
|
|
if (m_viewMin < minAllowed)
|
|
{
|
|
m_viewMin = minAllowed;
|
|
m_viewMax = m_viewMin + span;
|
|
}
|
|
if (m_viewMax > maxAllowed)
|
|
{
|
|
m_viewMax = maxAllowed;
|
|
m_viewMin = m_viewMax - span;
|
|
}
|
|
(void)dataSpan;
|
|
}
|
|
|
|
void PlotView::ZoomTimeAt(double factor, double anchorTime)
|
|
{
|
|
if (m_document == nullptr || !m_document->IsLoaded() || !(factor > 0.0))
|
|
{
|
|
return;
|
|
}
|
|
|
|
const double dataSpan = std::max(m_document->MaxTime() - m_document->MinTime(), kMinTimeSpan);
|
|
const double minSpan = dataSpan * 1.0e-9;
|
|
const double maxSpan = dataSpan * 100.0;
|
|
|
|
const double span = m_viewMax - m_viewMin;
|
|
double newSpan = span / factor;
|
|
newSpan = ClampDouble(newSpan, minSpan, maxSpan);
|
|
|
|
const double ratio = (span > 0.0) ? (anchorTime - m_viewMin) / span : 0.5;
|
|
m_viewMin = anchorTime - ratio * newSpan;
|
|
m_viewMax = m_viewMin + newSpan;
|
|
ClampTimeView();
|
|
}
|
|
|
|
void PlotView::ZoomTimeStep(double factor)
|
|
{
|
|
const double anchor = (m_viewMin + m_viewMax) * 0.5;
|
|
ZoomTimeAt(factor, anchor);
|
|
}
|
|
|
|
void PlotView::PanTimeByFraction(double fraction)
|
|
{
|
|
const double span = m_viewMax - m_viewMin;
|
|
const double delta = span * fraction;
|
|
m_viewMin += delta;
|
|
m_viewMax += delta;
|
|
ClampTimeView();
|
|
}
|
|
|
|
void PlotView::ScrollLanesByFraction(double fraction)
|
|
{
|
|
m_laneScroll += static_cast<float>(fraction * static_cast<double>(m_laneContentHeight));
|
|
ClampLaneScroll();
|
|
}
|
|
|
|
void PlotView::FitTime()
|
|
{
|
|
if (m_document == nullptr || !m_document->IsLoaded())
|
|
{
|
|
m_viewMin = 0.0;
|
|
m_viewMax = 1.0;
|
|
return;
|
|
}
|
|
|
|
m_viewMin = m_document->MinTime();
|
|
m_viewMax = m_document->MaxTime();
|
|
if (!(m_viewMax - m_viewMin > 0.0))
|
|
{
|
|
const double center = m_viewMin;
|
|
const double padding = std::max(1.0e-6, std::fabs(center) * 0.01);
|
|
m_viewMin = center - padding;
|
|
m_viewMax = center + padding;
|
|
}
|
|
}
|
|
|
|
void PlotView::FitAll()
|
|
{
|
|
FitTime();
|
|
m_laneScroll = 0.0f;
|
|
if (m_document != nullptr)
|
|
{
|
|
for (Lane& lane : m_document->Lanes())
|
|
{
|
|
lane.autoRange = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
void PlotView::ClearCursor()
|
|
{
|
|
m_cursorVisible = false;
|
|
m_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 (m_document == nullptr || !m_document->IsLoaded())
|
|
{
|
|
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 = m_viewMax - m_viewMin;
|
|
const double delta = -static_cast<double>(x - m_dragLastX) / static_cast<double>(m_plotRect.Width) * span;
|
|
m_viewMin += delta;
|
|
m_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;
|
|
}
|
|
|
|
double newSpan = span / factor;
|
|
const double dataSpan = std::max(std::fabs(m_dragStartYMax), std::fabs(m_dragStartYMin));
|
|
const double minSpan = std::max(dataSpan * 1.0e-9, 1.0e-12);
|
|
newSpan = ClampDouble(newSpan, minSpan, dataSpan * 100.0 + 1.0);
|
|
|
|
Lane& lane = m_document->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);
|
|
m_laneScroll += static_cast<float>(travel * m_laneContentHeight);
|
|
ClampLaneScroll();
|
|
m_dragLastY = y;
|
|
m_dragMoved = true;
|
|
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 && !m_cursorPinned)
|
|
{
|
|
const double time = XToTime(fx);
|
|
if (!m_cursorVisible || std::fabs(time - m_cursorTime) > 0.0)
|
|
{
|
|
m_cursorTime = time;
|
|
m_cursorVisible = true;
|
|
changed = true;
|
|
}
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
bool PlotView::OnLButtonDown(int x, int y)
|
|
{
|
|
if (m_document == nullptr || !m_document->IsLoaded())
|
|
{
|
|
return false;
|
|
}
|
|
|
|
m_dragStartX = x;
|
|
m_dragStartY = y;
|
|
m_dragLastX = x;
|
|
m_dragLastY = y;
|
|
m_dragMoved = false;
|
|
m_dragStartViewMin = m_viewMin;
|
|
m_dragStartViewMax = m_viewMax;
|
|
|
|
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_document->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 (m_cursorPinned && std::fabs(time - m_cursorTime) < 1.0e-12)
|
|
{
|
|
m_cursorPinned = false;
|
|
m_cursorVisible = false;
|
|
}
|
|
else
|
|
{
|
|
m_cursorTime = time;
|
|
m_cursorVisible = true;
|
|
m_cursorPinned = true;
|
|
}
|
|
changed = true;
|
|
}
|
|
|
|
m_dragMode = PlotDragMode::NONE;
|
|
m_dragLane = -1;
|
|
m_dragMoved = false;
|
|
return changed;
|
|
}
|
|
|
|
bool PlotView::OnRButtonDown(int x, int y)
|
|
{
|
|
if (m_document == nullptr || !m_document->IsLoaded())
|
|
{
|
|
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 (!m_cursorPinned)
|
|
{
|
|
m_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)
|
|
{
|
|
m_viewMin = timeA;
|
|
m_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_document->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 (m_document == nullptr || !m_document->IsLoaded() || delta == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const double notches = static_cast<double>(delta) / 120.0;
|
|
|
|
if (alt)
|
|
{
|
|
m_laneScroll -= static_cast<float>(notches * 48.0 * m_scale);
|
|
ClampLaneScroll();
|
|
return true;
|
|
}
|
|
|
|
if (shift)
|
|
{
|
|
const double span = m_viewMax - m_viewMin;
|
|
const double sweep = -notches * span * 0.15;
|
|
m_viewMin += sweep;
|
|
m_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_document->Lanes()[static_cast<std::size_t>(laneIndex)];
|
|
LaneVisual mutableVisual = visual;
|
|
if (lane.autoRange)
|
|
{
|
|
lane.autoRange = false;
|
|
}
|
|
lane.yMin = visual.yMin;
|
|
lane.yMax = visual.yMax;
|
|
|
|
const double anchorValue = YToValue(static_cast<float>(y), mutableVisual);
|
|
const double span = visual.yMax - visual.yMin;
|
|
if (!(span > 0.0))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const double factor = std::pow(1.25, notches);
|
|
double newSpan = span / factor;
|
|
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);
|
|
newSpan = ClampDouble(newSpan, 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 (!m_cursorPinned && m_cursorVisible)
|
|
{
|
|
m_cursorVisible = false;
|
|
changed = true;
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
void PlotView::BuildPolyline(const Signal& signal, const LaneVisual& lane, bool stepMode,
|
|
std::vector<gd::PointF>& points, std::vector<std::size_t>& breaks) const
|
|
{
|
|
points.clear();
|
|
breaks.clear();
|
|
|
|
if (m_document == nullptr)
|
|
{
|
|
return;
|
|
}
|
|
|
|
const std::vector<double>& time = m_document->Time();
|
|
const std::vector<double>& values = signal.values;
|
|
const std::size_t count = std::min(time.size(), values.size());
|
|
if (count == 0 || m_plotRect.Width <= 1.0f)
|
|
{
|
|
return;
|
|
}
|
|
|
|
std::size_t begin = 0;
|
|
std::size_t end = count;
|
|
|
|
if (m_document->TimeMonotonic())
|
|
{
|
|
const std::vector<double>::const_iterator first =
|
|
std::lower_bound(time.begin(), time.end(), m_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(), m_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;
|
|
double previousY = 0.0;
|
|
|
|
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 = ClampDouble(y, limitTop, limitBottom);
|
|
|
|
if (!inSegment)
|
|
{
|
|
breaks.push_back(points.size());
|
|
points.push_back(gd::PointF(x, y));
|
|
inSegment = true;
|
|
}
|
|
else if (stepMode)
|
|
{
|
|
points.push_back(gd::PointF(x, static_cast<float>(previousY)));
|
|
points.push_back(gd::PointF(x, y));
|
|
}
|
|
else
|
|
{
|
|
points.push_back(gd::PointF(x, y));
|
|
}
|
|
|
|
previousY = y;
|
|
}
|
|
}
|
|
|
|
void PlotView::Paint(gd::Graphics& g)
|
|
{
|
|
if (m_theme == nullptr || m_fonts == nullptr)
|
|
{
|
|
return;
|
|
}
|
|
|
|
UiFillRect(g, m_area, m_theme->plotBg);
|
|
|
|
if (m_document == nullptr || !m_document->IsLoaded())
|
|
{
|
|
UiText(g, L"打开 CSV 文件以查看仿真数据波形", m_fonts->ui, m_area, m_theme->textMuted,
|
|
TextAlign::CENTER);
|
|
return;
|
|
}
|
|
|
|
BuildLaneVisuals();
|
|
|
|
const double span = m_viewMax - m_viewMin;
|
|
const double timeStep = NiceStep((span > 0.0) ? span : 1.0, kTimeTickTarget);
|
|
std::vector<double> timeTicks;
|
|
BuildTicks(m_viewMin, m_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);
|
|
}
|
|
|
|
// 波形绘制。
|
|
if (m_document->Lanes()[static_cast<std::size_t>(laneIndex)].signalIndices.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 int signalIndex : m_document->Lanes()[static_cast<std::size_t>(laneIndex)].signalIndices)
|
|
{
|
|
if (signalIndex < 0 || signalIndex >= static_cast<int>(m_document->Signals().size()))
|
|
{
|
|
continue;
|
|
}
|
|
const Signal& signal = m_document->Signals()[static_cast<std::size_t>(signalIndex)];
|
|
if (!signal.visible)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
const bool stepMode = (signal.mode == DrawMode::STEP);
|
|
BuildPolyline(signal, lane, stepMode, points, breaks);
|
|
|
|
const bool drawLine = (signal.mode != DrawMode::SCATTER);
|
|
const bool drawScatter = (signal.mode == DrawMode::SCATTER || signal.mode == DrawMode::LINE_SCATTER);
|
|
|
|
if (drawLine && !breaks.empty())
|
|
{
|
|
gd::Pen pen(ToGdiColor(signal.color), signal.lineWidth * m_scale);
|
|
pen.SetLineJoin(stepMode ? gd::LineJoinMiter : 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, signal.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(signal.color));
|
|
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 (m_cursorVisible && !lane.empty && 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)
|
|
{
|
|
const std::vector<Lane>& lanes = m_document->Lanes();
|
|
if (laneIndex < 0 || laneIndex >= static_cast<int>(lanes.size()))
|
|
{
|
|
return;
|
|
}
|
|
|
|
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 int signalIndex : lanes[static_cast<std::size_t>(laneIndex)].signalIndices)
|
|
{
|
|
if (signalIndex < 0 || signalIndex >= static_cast<int>(m_document->Signals().size()))
|
|
{
|
|
continue;
|
|
}
|
|
const Signal& signal = m_document->Signals()[static_cast<std::size_t>(signalIndex)];
|
|
if (!signal.visible)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
std::wstring label = signal.name;
|
|
if (!signal.unit.empty())
|
|
{
|
|
label += L" [" + signal.unit + L"]";
|
|
}
|
|
const float itemWidth = 8.0f * m_scale + 5.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 int signalIndex : lanes[static_cast<std::size_t>(laneIndex)].signalIndices)
|
|
{
|
|
if (signalIndex < 0 || signalIndex >= static_cast<int>(m_document->Signals().size()))
|
|
{
|
|
continue;
|
|
}
|
|
const Signal& signal = m_document->Signals()[static_cast<std::size_t>(signalIndex)];
|
|
if (!signal.visible)
|
|
{
|
|
continue;
|
|
}
|
|
anyVisible = true;
|
|
++visibleCount;
|
|
|
|
std::wstring label = signal.name;
|
|
if (!signal.unit.empty())
|
|
{
|
|
label += L" [" + signal.unit + L"]";
|
|
}
|
|
|
|
const float textWidth = UiTextWidth(g, label, m_fonts->uiSmall);
|
|
const float itemWidth = 8.0f * m_scale + 5.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, signal.color);
|
|
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)
|
|
{
|
|
const std::vector<Lane>& lanes = m_document->Lanes();
|
|
if (laneIndex < 0 || laneIndex >= static_cast<int>(lanes.size()))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (m_cursorTime < m_viewMin || m_cursorTime > m_viewMax)
|
|
{
|
|
return;
|
|
}
|
|
|
|
struct ReadoutLine
|
|
{
|
|
std::wstring text;
|
|
std::uint32_t color;
|
|
float value;
|
|
};
|
|
|
|
std::vector<ReadoutLine> lines;
|
|
for (const int signalIndex : lanes[static_cast<std::size_t>(laneIndex)].signalIndices)
|
|
{
|
|
if (signalIndex < 0 || signalIndex >= static_cast<int>(m_document->Signals().size()))
|
|
{
|
|
continue;
|
|
}
|
|
const Signal& signal = m_document->Signals()[static_cast<std::size_t>(signalIndex)];
|
|
if (!signal.visible)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
const bool interpolate = (signal.mode != DrawMode::SCATTER);
|
|
double value = 0.0;
|
|
if (!m_document->SampleAt(signalIndex, m_cursorTime, interpolate, value))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
ReadoutLine line;
|
|
line.text = signal.name + L" = " + FormatValue(value);
|
|
line.color = signal.color;
|
|
line.value = ValueToY(value, lane);
|
|
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);
|
|
}
|
|
|
|
// 时间轴列名(过长时自动省略)。
|
|
const std::wstring axisName =
|
|
m_document->TimeColumnName().empty() ? std::wstring(L"时间") : m_document->TimeColumnName();
|
|
UiText(g, axisName, 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 (!m_cursorVisible)
|
|
{
|
|
return;
|
|
}
|
|
|
|
const float x = TimeToX(m_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, m_cursorPinned ? gd::DashStyleSolid : gd::DashStyleDash);
|
|
|
|
// 波形上的取值标记。
|
|
for (std::size_t laneIndex = 0; laneIndex < lanes.size(); ++laneIndex)
|
|
{
|
|
const LaneVisual& lane = lanes[laneIndex];
|
|
if (lane.empty)
|
|
{
|
|
continue;
|
|
}
|
|
const std::vector<int>& indices = m_document->Lanes()[laneIndex].signalIndices;
|
|
for (const int signalIndex : indices)
|
|
{
|
|
if (signalIndex < 0 || signalIndex >= static_cast<int>(m_document->Signals().size()))
|
|
{
|
|
continue;
|
|
}
|
|
const Signal& signal = m_document->Signals()[static_cast<std::size_t>(signalIndex)];
|
|
if (!signal.visible)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
double value = 0.0;
|
|
if (!m_document->SampleAt(signalIndex, m_cursorTime, signal.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(signal.color));
|
|
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(m_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) ? (m_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
|