Files
Just-DataInspector/Components/PlotView/Src/PlotView.cpp
T
DeepSeek V4.1 Flash 132b305544 feat: 搭建 Just DataInspector 波形数据查看器基础功能
基于 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 链接库
2026-09-29 15:05:29 +08:00

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