#include "PlotView.hpp" #include #include #include #include #include "Utf8Util.hpp" namespace jdi { namespace { const int kTimeTickTarget = 10; /**< 时间轴期望刻度数。 */ const int kYTickTarget = 6; /**< 幅值轴期望刻度数(网格较高时)。 */ const int kYTickTargetMin = 3; /**< 幅值轴最少刻度数(网格很矮时也要保证可读数)。 */ const int kYTickTargetMax = 8; /**< 幅值轴最多刻度数。 */ const float kYTickSpacing = 28.0f; /**< 幅值刻度之间的理想间距(逻辑像素)。 */ const float kYLabelMinGap = 15.0f; /**< 幅值标签的最小行距(逻辑像素)。 */ const int kMinorDivisions = 5; /**< 相邻主刻度之间的等分数(用于估读)。 */ const unsigned int kMinorLineAlpha = 0x9Au; /**< 辅助等分线的透明度(越高越清晰)。 */ const float kScrollBarThickness = 9.0f; /**< 滚动条宽度(逻辑像素)。 */ const float kLanePadding = 8.0f; /**< 网格内边距。 */ const size_t kMaxPolylinePoints = 20000; /**< 单条折线最大顶点数(上限保护)。 */ const size_t kPolylinePointsPerPixel = 2; /**< 每个像素列允许的折线顶点数(上/下包络两顶点)。 */ const size_t kMinPolylinePoints = 512; /**< 折线最少顶点数(窗口很窄时仍保留细节)。 */ const float kEnvelopeMinPixelSpan = 0.5f; /**< 包络纵向跨度小于该像素值时合并为单个顶点。 */ const size_t kMaxScatterPoints = 2500; /**< 单条散点最大绘制点数。 */ const size_t kPolylineCacheLimit = 64; /**< 折线顶点缓存条目上限。 */ const float kMinScatterRadius = 1.6f; /**< 散点最小半径(逻辑像素)。 */ const float kMaxScatterRadius = 12.0f; /**< 散点最大半径(逻辑像素)。 */ const float kScatterRadiusScale = 1.5f; /**< 散点半径相对线宽的增长系数。 */ const double kMinTimeSpan = 1.0e-12; /**< 允许的最小时间跨度。 */ const float kPlotSidePadding = 8.0f; /**< 绘图区左右留白(逻辑像素)。 */ const float kLaneDividerWidth = 2.0f; /**< 网格之间分界线的宽度。 */ const float kSignalHitTolerance = 6.0f; /**< 信号曲线命中容差(逻辑像素)。 */ const float kCursorSnapMinPixels = 6.0f; /**< 光标吸附采样点的最小采样间隔(逻辑像素)。 */ const float kLaneLabelPad = 6.0f; /**< 网格内侧标尺带的内边距。 */ const float kLegendHeight = 19.0f; /**< 图例条目高度(逻辑像素)。 */ const float kLegendPad = 5.0f; /**< 图例相对网格左上角的内缩。 */ const float kLegendSwatchSize = 8.0f; /**< 图例色块尺寸。 */ const float kLegendItemGap = 14.0f; /**< 图例条目之间的间距。 */ const float kLegendMaxTextWidth = 200.0f; /**< 图例条目的文本宽度上限(超出则省略)。 */ const std::uint64_t kHintHoldMs = 1300; /**< 提示浮层完全显示的停留时长(毫秒)。 */ const std::uint64_t kHintFadeMs = 500; /**< 提示浮层的淡出时长(毫秒)。 */ /** * @brief 时间间隔的显示单位。 */ struct DurationUnit { double scale = 1.0; /**< 1 个该单位等于多少秒。 */ const wchar_t* name = L"s"; /**< 单位名称。 */ }; /** * @brief 为给定时间间隔挑选最合适的显示单位(使数值落在 [1, 1000))。 * @param [IN] seconds 时间间隔(秒)。 * @return 选中的单位。 */ DurationUnit ChosenDurationUnit(double seconds) { const double value = std::fabs(seconds); // 从大到小排列,取第一个使数值 >= 1 的单位。 const DurationUnit units[] = { {3600.0, L"h"}, {60.0, L"min"}, {1.0, L"s"}, {1.0e-3, L"ms"}, {1.0e-6, L"us"}, {1.0e-9, L"ns"}, {1.0e-12, L"ps"}, }; const DurationUnit* chosen = &units[sizeof(units) / sizeof(units[0]) - 1]; for (const DurationUnit& unit : units) { if (value >= unit.scale) { chosen = &unit; break; } } return *chosen; } /** * @brief 把时间间隔格式化为自适应单位的可读文本(含单位)。 * * 在 h / min / s / ms / us / ns / ps 之间自动选择最合适的单位。 * * @param [IN] seconds 时间间隔(秒)。 * @return 形如 "12.3 ms" 的文本。 */ std::wstring FormatDuration(double seconds) { if (!std::isfinite(seconds)) { return L"—"; } const DurationUnit unit = ChosenDurationUnit(seconds); std::wstring text = FormatValue(std::fabs(seconds) / unit.scale); text += L" "; text += unit.name; return text; } /** * @brief 把时间间隔格式化为"当前显示单位下的纯数值"(不含单位),用于内联编辑。 * @param [IN] seconds 时间间隔(秒)。 * @return 形如 "12.3" 的文本。 */ std::wstring FormatDurationNumber(double seconds) { if (!std::isfinite(seconds)) { return L"0"; } const DurationUnit unit = ChosenDurationUnit(seconds); return FormatValue(std::fabs(seconds) / unit.scale); } /** * @brief 按比例缩放颜色的 alpha 通道(用于提示浮层淡出)。 * @param [IN] argb 原始 0xAARRGGBB 颜色。 * @param [IN] alpha 新的 alpha(0-255)。 * @return 缩放后的颜色。 */ std::uint32_t ScaleAlpha(std::uint32_t argb, unsigned int alpha) { const unsigned int a = std::min(255u, alpha); return (argb & 0x00FFFFFFu) | (a << 24); } /** * @brief 判断颜色是否偏暗(用于在信号色底板上选择可读的文字颜色)。 * @param [IN] color 0xAARRGGBB 颜色值。 * @return 相对亮度低于阈值返回 true。 */ bool IsDarkColor(std::uint32_t color) { const double r = static_cast((color >> 16) & 0xFFu); const double g = static_cast((color >> 8) & 0xFFu); const double b = static_cast(color & 0xFFu); return (0.299 * r + 0.587 * g + 0.114 * b) < 140.0; } /** * @brief 将数值限制到合法区间。 * @param [IN] value 输入值。 * @param [IN] low 下限。 * @param [IN] high 上限。 * @return 限制后的数值。 */ double ClampDouble(double value, double low, double high) { if (value < low) { return low; } if (value > high) { return high; } return value; } /** * @brief 组装图例文本:使用信号当前显示名(用户重命名后立即生效)。 * @param [IN] data 信号数据。 * @return 图例文本。 */ std::wstring BuildLegendText(const SignalData& data) { return data.Title(); } /** * @brief 将信号样式颜色按不透明度换算为实际绘制颜色。 * @param [IN] style 信号样式。 * @return 叠加不透明度后的 0xAARRGGBB 颜色值。 */ std::uint32_t StyleColor(const SignalStyle& style) { double opacity = static_cast(style.opacity); if (opacity < 0.0) { opacity = 0.0; } if (opacity > 1.0) { opacity = 1.0; } return WithAlpha(style.color, static_cast(opacity * 255.0 + 0.5)); } } PlotView::PlotView() = default; PlotView::~PlotView() { ReleaseSceneCache(); } void PlotView::Setup(const UiFonts* fonts, const Theme* theme) { m_fonts = fonts; m_theme = theme; } void PlotView::SetContext(DataSet* dataSet, TabConfig* tab) { m_dataSet = dataSet; m_dataSetWritable = dataSet; m_tab = tab; m_hoverLane = -1; m_dragMode = PlotDragMode::NONE; // 重新绑定数据源后旧的几何键不再有意义,必须清空折线缓存。 InvalidatePolylineCache(); InvalidateSceneCache(); RefreshLanes(); } void PlotView::SetScale(float scale) { m_scale = (scale > 0.1f) ? scale : 1.0f; InvalidateSceneCache(); } bool PlotView::CursorEnabled() const { return Tab().CursorEnabled(); } double PlotView::CursorTime() const { return Tab().CursorTime(); } PlotCursorMode PlotView::CursorMode() const { return Tab().CursorMode(); } double PlotView::ViewMinTime() const { return Tab().ViewMin(); } double PlotView::ViewMaxTime() const { return Tab().ViewMax(); } bool PlotView::HasDrawableContent() const { return (m_dataSet != nullptr) && !m_dataSet->Empty() && (m_tab != nullptr) && m_tab->AnyVisible(); } void PlotView::TimeBounds(double& low, double& high) const { low = 0.0; high = 1.0; if (m_dataSet == nullptr || m_dataSet->Empty()) { return; } // 优先使用可见信号所覆盖的范围,使视图随勾选内容自适应。 if (m_tab != nullptr && m_tab->ComputeVisibleTimeBounds(*m_dataSet, low, high)) { return; } low = m_dataSet->MinTime(); high = m_dataSet->MaxTime(); if (!(high > low)) { high = low + 1.0; } } PlotView::ResolvedSignal PlotView::ResolveSignal(const SignalRef& ref) const { ResolvedSignal result; if (m_dataSet == nullptr) { return result; } result.data = m_dataSet->Resolve(ref); result.file = m_dataSet->FileAt(ref.fileIndex); result.style = m_dataSet->Style(ref); result.visible = (m_tab != nullptr) && m_tab->IsVisible(ref); return result; } void PlotView::Layout(const gd::RectF& area) { m_area = area; // 布局变化(窗口缩放、面板宽度调整、网格数量变化)后静态场景不再有效。 InvalidateSceneCache(); m_labelBandWidth = 66.0f * m_scale; m_timeAxisHeight = 34.0f * m_scale; m_minLaneHeight = 54.0f * m_scale; // 先为时间轴保留固定高度,剩余空间才交给网格,保证时间轴不会被挤压。 const float topPadding = 6.0f * m_scale; const float availHeight = std::max(0.0f, area.Height - m_timeAxisHeight - topPadding); // 预估是否需要纵向滚动条:仅取决于网格数量与最小网格高度(网格之间不再留间隔)。 std::size_t laneCount = (m_tab != nullptr) ? m_tab->Lanes().size() : 0; if (laneCount > 0) { const float needed = static_cast(laneCount) * m_minLaneHeight; m_scrollBarVisible = (needed > availHeight); } else { m_scrollBarVisible = false; } const float sidePadding = kPlotSidePadding * m_scale; const float rightReserve = m_scrollBarVisible ? (kScrollBarThickness * m_scale + sidePadding) : sidePadding; const float plotWidth = std::max(0.0f, area.Width - sidePadding - rightReserve); m_plotRect = gd::RectF(area.X + sidePadding, area.Y + topPadding, plotWidth, availHeight); m_timeAxisRect = gd::RectF(m_plotRect.X, m_plotRect.Y + m_plotRect.Height, m_plotRect.Width, m_timeAxisHeight); m_scrollBarRect = gd::RectF(area.X + area.Width - kScrollBarThickness * m_scale - 3.0f * m_scale, m_plotRect.Y, kScrollBarThickness * m_scale, m_plotRect.Height); BuildLaneVisuals(); } void PlotView::RefreshLanes() { BuildLaneVisuals(); } const SignalRef& PlotView::SelectedRef() const { static const SignalRef fallback; return (m_dataSet != nullptr) ? m_dataSet->Selected() : fallback; } void PlotView::SelectSignal(const SignalRef& ref) { if (m_dataSetWritable != nullptr) { m_dataSetWritable->SetSelected(ref); } } void PlotView::ClampLaneScroll() { const float maxOffset = std::max(0.0f, m_laneContentHeight - m_plotRect.Height); Tab().LaneScroll() = static_cast(ClampDouble(Tab().LaneScroll(), 0.0, maxOffset)); } bool PlotView::LaneSpansMultipleFiles(int laneIndex) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } int firstFile = -1; for (const SignalRef& ref : m_tab->Lanes()[static_cast(laneIndex)].signals) { const SignalStyle* style = m_dataSet->Style(ref); if (style == nullptr || !m_tab->IsVisible(ref)) { continue; } if (firstFile < 0) { firstFile = ref.fileIndex; } else if (ref.fileIndex != firstFile) { return true; } } return false; } void PlotView::BuildLaneVisuals() { m_lanes.clear(); if (m_tab == nullptr || m_dataSet == nullptr || !HasDrawableContent()) { m_laneContentHeight = 0.0f; return; } const std::vector& lanes = m_tab->Lanes(); const std::size_t count = lanes.size(); if (count == 0) { m_laneContentHeight = 0.0f; return; } const float gap = 0.0f; float laneHeight = m_plotRect.Height / static_cast(count); if (laneHeight < m_minLaneHeight * m_scale) { laneHeight = m_minLaneHeight * m_scale; } m_laneContentHeight = laneHeight * static_cast(count); ClampLaneScroll(); const float scrollOffset = Tab().LaneScroll(); m_lanes.resize(count); for (std::size_t index = 0; index < count; ++index) { LaneVisual& visual = m_lanes[index]; visual.rect = gd::RectF( m_plotRect.X, m_plotRect.Y + static_cast(index) * (laneHeight + gap) - scrollOffset, m_plotRect.Width, laneHeight); m_tab->ComputeLaneRange(*m_dataSet, static_cast(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((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(x - m_plotRect.X) / static_cast(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(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(y - lane.rect.Y) / static_cast(lane.rect.Height); return lane.yMin + ratio * (lane.yMax - lane.yMin); } int PlotView::LaneAtY(int y) const { const float fy = static_cast(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(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(fraction * static_cast(m_laneContentHeight)); // 立即重建几何,避免命中测试使用旧位置。 BuildLaneVisuals(); } void PlotView::FitAll() { if (m_dataSet == nullptr || m_dataSet->Empty()) { Tab().ViewMin() = 0.0; Tab().ViewMax() = 1.0; Tab().ViewInitialized() = true; return; } double low = 0.0; double high = 1.0; TimeBounds(low, high); if (!(high > low)) { const double padding = std::max(1.0e-6, std::fabs(low) * 0.01); low -= padding; high += padding; } Tab().ViewMin() = low; Tab().ViewMax() = high; Tab().ViewInitialized() = true; Tab().LaneScroll() = 0.0f; // 自适应即回到初始状态:退出拖拽缩放模式并放下进行中的选区与拖拽。 Tab().ZoomMode() = PlotZoomMode::NONE; m_rubberRect = gd::RectF(); m_dragMode = PlotDragMode::NONE; m_dragSignal = SignalRef(); m_dragTargetLane = -1; m_hoverSignal = SignalRef(); if (m_tab != nullptr) { // 适应视图 = 回到初始状态:全部网格恢复整段数据的自动自适应。 // 「选区 Y 轴自适应」是用户设置项,这里保持不变。 for (Lane& lane : m_tab->Lanes()) { lane.autoRange = true; } } // 适应视图已回到初始状态,这里不再移动光标(光标允许停留在可视区外)。 BuildLaneVisuals(); } void PlotView::DisableCursor() { Tab().CursorMode() = PlotCursorMode::NONE; m_cursorHover = false; m_hoverCursor = 0; if (m_editTarget == PlotEditTarget::CURSOR_TIME || m_editTarget == PlotEditTarget::CURSOR_SPAN) { EndEdit(false); } if (m_dragMode == PlotDragMode::CURSOR_MOVE || m_dragMode == PlotDragMode::CURSOR_SPAN_MOVE) { m_dragMode = PlotDragMode::NONE; } } bool PlotView::CycleCursorMode() { // 三态循环:无 → 单光标 → 双光标 → 无。光标模式本身不显示提示。 switch (Tab().CursorMode()) { case PlotCursorMode::NONE: { Tab().CursorMode() = PlotCursorMode::SINGLE; // 首次启用时置于视图中心,随后可用鼠标靠近拖拽或双击指定精确时间。 const double center = (Tab().ViewMin() + Tab().ViewMax()) * 0.5; Tab().CursorTime() = center; Tab().CursorTimeAlt() = center; m_cursorHover = true; m_hoverCursor = 1; m_activeCursor = 1; break; } case PlotCursorMode::SINGLE: { Tab().CursorMode() = PlotCursorMode::DOUBLE; // 光标 2 首次出现:紧邻光标 1 且必须落在当前可见时间范围内, // 这样无需额外操作就能立即看到两个光标的对比读数。 const double low = Tab().ViewMin(); const double high = Tab().ViewMax(); const double span = (high > low) ? (high - low) : 1.0; const double offset = span * 0.12; double second = Tab().CursorTime() + offset; if (second > high - span * 0.02) { second = Tab().CursorTime() - offset; } if (second < low + span * 0.02 || second > high - span * 0.02) { second = low + span * 0.5; } Tab().CursorTimeAlt() = second; ClampCursorToView(2); m_cursorHover = true; m_hoverCursor = 2; m_activeCursor = 2; break; } case PlotCursorMode::DOUBLE: default: DisableCursor(); break; } return true; } void PlotView::SetHint(const std::wstring& text) { m_hintText = text; m_hintShownAt = (text.empty()) ? 0 : GetTickCount64(); } bool PlotView::HintNeedsAnimation() const { if (m_hintText.empty() || m_hintShownAt == 0) { return false; } const std::uint64_t elapsed = GetTickCount64() - m_hintShownAt; return elapsed < (kHintHoldMs + kHintFadeMs); } double PlotView::CursorSpan() const { if (m_tab == nullptr || Tab().CursorMode() != PlotCursorMode::DOUBLE) { return 0.0; } return std::fabs(Tab().CursorTimeAlt() - Tab().CursorTime()); } void PlotView::ClampCursorToView(int which) { if (m_tab == nullptr || m_plotRect.Width <= 0.0f) { return; } const double low = Tab().ViewMin(); const double high = Tab().ViewMax(); if (!(high > low)) { return; } // 只在拖拽时夹取;留一点内缩,保证光标线及把手完整可见。 // 视图变化(平移/缩放/自适应)**不**调用本函数:光标允许滞留在网格可视区域之外。 const double inset = (high - low) * 0.002; if (which == 2) { Tab().CursorTimeAlt() = ClampDouble(Tab().CursorTimeAlt(), low + inset, high - inset); } else { Tab().CursorTime() = ClampDouble(Tab().CursorTime(), low + inset, high - inset); } } std::uint32_t PlotView::CursorColor(int which) const { if (m_theme == nullptr) { return 0xFF000000u; } return (which == 2) ? m_theme->cursorAlt : m_theme->cursor; } bool PlotView::CancelDrag(bool showHint) { if (m_dragMode == PlotDragMode::NONE) { return false; } // 放弃当前拖拽:清空拖拽状态与尚未生效的缩放选区,不改动任何视图范围。 // 调用方仍持有鼠标捕获,松开左键时会走 OnLButtonUp, // 此时拖拽模式已是 NONE,选区不会被应用到时间轴/幅值轴上。 const bool wasRubber = (m_dragMode == PlotDragMode::RUBBER_SELECT); m_dragMode = PlotDragMode::NONE; m_rubberAxis = PlotRubberAxis::UNDECIDED; m_dragLane = -1; m_dragMoved = false; m_rubberRect = gd::RectF(); m_dragSignal = SignalRef(); m_dragTargetLane = -1; if (showHint && wasRubber) { SetHint(L"已放弃当前选区(右键放弃选区,Esc 退出选区放大)"); } return true; } bool PlotView::ExitZoomModes() { if (m_tab == nullptr) { return false; } // 退出缩放模式:工具栏的「选区放大」按钮会在 Application::Render 中同步为未选中态。 if (Tab().ZoomMode() == PlotZoomMode::NONE) { return false; } Tab().ZoomMode() = PlotZoomMode::NONE; SetHint(std::wstring()); return true; } void PlotView::InvalidatePolylineCache() { m_polylineCache.clear(); InvalidateSceneCache(); } bool PlotView::FitTimeToLane(int laneIndex) { if (m_dataSet == nullptr || m_tab == nullptr) { return false; } const std::vector& lanes = m_tab->Lanes(); if (laneIndex < 0 || laneIndex >= static_cast(lanes.size())) { return false; } double low = std::numeric_limits::infinity(); double high = -std::numeric_limits::infinity(); for (const SignalRef& ref : lanes[static_cast(laneIndex)].signals) { const DataFile* file = m_dataSet->FileAt(ref.fileIndex); if (file == nullptr) { continue; } low = std::min(low, file->minTime); high = std::max(high, file->maxTime); } if (!std::isfinite(low) || !std::isfinite(high) || !(high > low)) { return false; } Tab().ViewMin() = low; Tab().ViewMax() = high; Tab().ViewInitialized() = true; // 与“适应视图”保持一致:退出选区放大模式,避免模式按钮与实际状态不符。 Tab().ZoomMode() = PlotZoomMode::NONE; m_rubberRect = gd::RectF(); m_dragMode = PlotDragMode::NONE; m_rubberAxis = PlotRubberAxis::UNDECIDED; // 针对本网格信号范围展开时间轴后,同时把该网格的 Y 轴自适应一次, // 否则展开后会看到“时间对了但幅值区间不合适”的波形。 AutoRangeLaneToView(laneIndex); // 注意:不在这里移动光标——光标允许滞留在网格可视区域之外。 BuildLaneVisuals(); return true; } bool PlotView::SampleSpacingPixels(const DataFile& file, double time, double& pixels) const { pixels = 0.0; const std::vector& samples = file.time; if (samples.size() < 2 || m_plotRect.Width <= 1.0f) { return false; } const double viewSpan = Tab().ViewMax() - Tab().ViewMin(); if (!(viewSpan > 0.0)) { return false; } const int index = FindNearestSample(file, time); if (index < 0) { return false; } double spacing = 0.0; if (index > 0) { spacing = samples[static_cast(index)] - samples[static_cast(index) - 1]; } else { spacing = samples[1] - samples[0]; } // 时间轴非单调时前后差值可能为负,此时不做吸附。 if (!(spacing > 0.0) || !std::isfinite(spacing)) { return false; } pixels = spacing / viewSpan * static_cast(m_plotRect.Width); return true; } bool PlotView::SnapTimeToSample(double time, double& snapped) const { if (m_dataSet == nullptr) { return false; } const double minPixels = static_cast(kCursorSnapMinPixels) * static_cast(m_scale); bool found = false; double bestDistance = std::numeric_limits::infinity(); double bestTime = 0.0; for (const DataFile& file : m_dataSet->Files()) { // 只有采样点在屏幕上足够稀疏时才吸附,否则缩小视图后光标会“跳格”。 double pixels = 0.0; if (!SampleSpacingPixels(file, time, pixels) || pixels < minPixels) { continue; } const int index = FindNearestSample(file, time); if (index < 0) { continue; } const double candidate = file.time[static_cast(index)]; if (!std::isfinite(candidate)) { continue; } const double distance = std::fabs(candidate - time); if (distance < bestDistance) { bestDistance = distance; bestTime = candidate; found = true; } } if (found) { snapped = bestTime; } return found; } PlotZoomMode PlotView::CurrentZoomMode() const { return Tab().ZoomMode(); } bool PlotView::ToggleZoomMode(PlotZoomMode mode) { const PlotZoomMode current = Tab().ZoomMode(); Tab().ZoomMode() = (current == mode) ? PlotZoomMode::NONE : mode; m_rubberAxis = PlotRubberAxis::UNDECIDED; m_rubberRect = gd::RectF(); SetHint(Tab().ZoomMode() == PlotZoomMode::NONE ? std::wstring() : L"选区放大:横向拖动放大时间轴,纵向拖动放大 Y 轴;右键放弃当前选区,Esc 退出"); return true; } float PlotView::CursorX(int which) const { const double time = (which == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime(); return TimeToX(time); } float PlotView::CursorDisplayX(int which) const { // 光标允许滞留在可视区之外:此时把它"钉"在最近的网格边缘上显示, // 保证它的取值与时间读数始终可见。 const float raw = CursorX(which); return std::max(m_plotRect.X + 1.0f * m_scale, std::min(raw, m_plotRect.X + m_plotRect.Width - 1.0f * m_scale)); } gd::RectF PlotView::CursorLabelRect(int which) const { if (m_fonts == nullptr || m_fonts->monoSmall == nullptr) { return gd::RectF(); } const double time = (which == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime(); const std::wstring text = FormatValue(time); const float width = std::max(64.0f * m_scale, UiMeasureTextWidth(m_fonts->monoSmall, text) + 16.0f * m_scale); const float height = 20.0f * m_scale; // 时间读数改到绘图区**底部**:顶部留给网格图例与幅值标尺,避免互相遮挡。 float left = CursorDisplayX(which) - width * 0.5f; left = std::max(m_plotRect.X, std::min(left, m_plotRect.X + m_plotRect.Width - width)); const float top = m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale; return gd::RectF(left, top, width, height); } bool PlotView::IsOnCursorLine(int x, int& which) const { which = 0; if (!Tab().CursorEnabled() || m_plotRect.Width <= 0.0f) { return false; } const float tolerance = 6.0f * m_scale; const float fx = static_cast(x); // 双光标重叠时优先命中光标 1,避免出现“抓不起来第二条”的情况。 // 使用"显示横坐标":滞留在可视区外的光标被钉在边缘,也应该可以被抓住拖回来。 if (std::fabs(fx - CursorDisplayX(1)) <= tolerance) { which = 1; return true; } if (Tab().CursorMode() == PlotCursorMode::DOUBLE && std::fabs(fx - CursorDisplayX(2)) <= tolerance) { which = 2; return true; } return false; } bool PlotView::ApplyTimeSelection(float x0, float x1) { if (m_plotRect.Width <= 0.0f) { return false; } float left = std::min(x0, x1); float right = std::max(x0, x1); left = std::max(left, m_plotRect.X); right = std::min(right, m_plotRect.X + m_plotRect.Width); if (right - left < 8.0f * m_scale) { return false; } double timeA = XToTime(left); double timeB = XToTime(right); if (timeB < timeA) { std::swap(timeA, timeB); } if (!(timeB - timeA > 0.0)) { return false; } Tab().ViewMin() = timeA; Tab().ViewMax() = timeB; ClampTimeView(); // 注意:不在这里移动光标——即使选区不包含光标,也只把它的读数贴在网格边缘显示。 // 「选区 Y 轴自适应」开关开启的网格:时间选区放大后自动把 Y 轴匹配到新的可见区间。 ApplySelectionAutoFit(); return true; } bool PlotView::ApplyValueSelection(int laneIndex, float y0, float y1) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) { return false; } const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; if (visual.rect.Height <= 0.0f) { return false; } float top = std::min(y0, y1); float bottom = std::max(y0, y1); top = std::max(top, visual.rect.Y); bottom = std::min(bottom, visual.rect.Y + visual.rect.Height); if (bottom - top < 10.0f * m_scale) { return false; } const double valueTop = YToValue(top, visual); const double valueBottom = YToValue(bottom, visual); if (!(std::fabs(valueTop - valueBottom) > 0.0)) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; lane.autoRange = false; // 手动 Y 轴选区放大:关闭本网格的「选区 Y 轴自适应」。 lane.fitYToSelection = false; lane.yMin = std::min(valueTop, valueBottom); lane.yMax = std::max(valueTop, valueBottom); return true; } int PlotView::LaneIndexAt(int x, int y) const { if (!UiPointInRect(m_plotRect, static_cast(x), static_cast(y))) { return -1; } return LaneAtY(y); } bool PlotView::LaneAutoFitToSelection(int laneIndex) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } return m_tab->Lanes()[static_cast(laneIndex)].fitYToSelection; } bool PlotView::AllLanesAutoFitToSelection() const { if (m_tab == nullptr) { return false; } const std::vector& lanes = m_tab->Lanes(); if (lanes.empty()) { return false; } for (const Lane& lane : lanes) { if (!lane.fitYToSelection) { // 任一网格未开启 → 全局项就不是勾选状态。 return false; } } return true; } bool PlotView::ToggleLaneAutoFitToSelection(int laneIndex) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; lane.fitYToSelection = !lane.fitYToSelection; if (lane.fitYToSelection) { // 打开时立即按当前视图自适应一次,否则当前量程不合适时看不到任何变化。 AutoRangeLaneToView(laneIndex); } return lane.fitYToSelection; } void PlotView::ApplyAutoFitToSelectionNow(int laneIndex) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return; } if (!m_tab->Lanes()[static_cast(laneIndex)].fitYToSelection) { return; } AutoRangeLaneToView(laneIndex); } void PlotView::ApplyAllAutoFitToSelectionNow() { if (m_tab == nullptr) { return; } const std::size_t count = m_tab->Lanes().size(); for (std::size_t index = 0; index < count; ++index) { ApplyAutoFitToSelectionNow(static_cast(index)); } } bool PlotView::SetAllLanesAutoFitToSelection(bool enabled, bool applyNow) { if (m_tab == nullptr) { return false; } bool changed = false; for (Lane& lane : m_tab->Lanes()) { if (lane.fitYToSelection != enabled) { lane.fitYToSelection = enabled; changed = true; } } if (enabled && applyNow) { // 全局打开时立刻让每个网格都自适应一次,保证界面立刻反映开关状态。 ApplyAllAutoFitToSelectionNow(); } return changed; } bool PlotView::AutoRangeLaneToView(int laneIndex) { if (m_tab == nullptr || m_dataSet == nullptr) { return false; } const std::vector& lanes = m_tab->Lanes(); if (laneIndex < 0 || laneIndex >= static_cast(lanes.size())) { return false; } const double viewMin = Tab().ViewMin(); const double viewMax = Tab().ViewMax(); if (!(viewMax > viewMin)) { return false; } // 只统计时间轴当前可见区间内的采样点,因此放大局部波形后可以快速把该段铺满网格。 double low = std::numeric_limits::infinity(); double high = -std::numeric_limits::infinity(); for (const SignalRef& ref : lanes[static_cast(laneIndex)].signals) { if (!Tab().IsVisible(ref)) { continue; } const SignalData* signal = m_dataSet->Resolve(ref); const DataFile* file = m_dataSet->FileAt(ref.fileIndex); if (signal == nullptr || file == nullptr) { continue; } const std::size_t count = std::min(file->time.size(), signal->values.size()); std::size_t begin = 0; std::size_t end = count; if (file->timeMonotonic) { // 时间单调时直接二分定位区间,百万级采样也只需扫过可见段。 begin = static_cast( std::lower_bound(file->time.begin(), file->time.end(), viewMin) - file->time.begin()); if (begin > 0) { --begin; } end = static_cast( std::lower_bound(file->time.begin(), file->time.end(), viewMax) - file->time.begin()); if (end < count) { ++end; } } for (std::size_t index = begin; index < end; ++index) { if (!file->timeMonotonic) { const double t = file->time[index]; if (!std::isfinite(t) || t < viewMin || t > viewMax) { continue; } } const double value = signal->values[index]; if (std::isfinite(value)) { low = std::min(low, value); high = std::max(high, value); } } } if (!std::isfinite(low) || !std::isfinite(high)) { return false; } // 与自动自适应保持一致的上下留白,避免波形顶到网格边界。 if (high > low) { const double pad = (high - low) * 0.05; low -= pad; high += pad; } else { const double pad = std::max(1.0e-9, std::fabs(low) * 0.05); low -= pad; high += pad; } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; lane.autoRange = false; lane.yMin = low; lane.yMax = high; BuildLaneVisuals(); return true; } void PlotView::ApplySelectionAutoFit() { if (m_tab == nullptr || m_dataSet == nullptr) { return; } const std::vector& lanes = m_tab->Lanes(); bool any = false; for (const Lane& lane : lanes) { if (lane.fitYToSelection) { any = true; break; } } if (!any) { return; } // 只对开启了「选区 Y 轴自适应」的网格重新匹配 Y 轴; // 重新匹配依据的是刚刚更新过的时间视图范围。 for (std::size_t index = 0; index < lanes.size(); ++index) { if (m_tab->Lanes()[index].fitYToSelection) { AutoRangeLaneToView(static_cast(index)); } } } void PlotView::ScaleLaneRange(int laneIndex, double factor, double anchorValue) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) { return; } if (!(factor > 0.0) || !std::isfinite(anchorValue)) { return; } const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; const double span = visual.yMax - visual.yMin; if (!(span > 0.0)) { return; } const double magnitude = std::max(std::fabs(visual.yMax), std::fabs(visual.yMin)); const double minSpan = std::max(magnitude * 1.0e-9, 1.0e-12); const double newSpan = ClampDouble(span / factor, minSpan, magnitude * 100.0 + 1.0); const double ratio = (visual.yMax - anchorValue) / span; Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; lane.autoRange = false; lane.fitYToSelection = false; lane.yMin = anchorValue - ratio * newSpan; lane.yMax = lane.yMin + newSpan; BuildLaneVisuals(); } void PlotView::ShiftLaneRange(int laneIndex, double deltaValue) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_lanes.size()) || !std::isfinite(deltaValue) || deltaValue == 0.0) { return; } // 首次纵向拖动时先把当前自适应范围固化下来,再进行偏移。 const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; // 手动纵向平移属于手动调整幅值:关闭本网格的「选区 Y 轴自适应」。 lane.fitYToSelection = false; if (lane.autoRange) { lane.autoRange = false; lane.yMin = visual.yMin; lane.yMax = visual.yMax; } lane.yMin += deltaValue; lane.yMax += deltaValue; BuildLaneVisuals(); } bool PlotView::BeginLaneValueEdit(int laneIndex, bool editMax, const gd::RectF& anchorRect) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } if (m_lanes.empty()) { BuildLaneVisuals(); } if (laneIndex >= static_cast(m_lanes.size())) { return false; } const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; const double current = editMax ? visual.yMax : visual.yMin; return BeginEdit(editMax ? PlotEditTarget::LANE_Y_MAX : PlotEditTarget::LANE_Y_MIN, laneIndex, FormatValue(current), anchorRect); } bool PlotView::ApplyLaneValueText(int laneIndex, bool editMax, const std::wstring& text) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } if (m_lanes.empty()) { BuildLaneVisuals(); } if (laneIndex >= static_cast(m_lanes.size())) { return false; } const std::wstring trimmed = Trim(text); if (trimmed.empty()) { return false; } wchar_t* stop = nullptr; const double parsed = std::wcstod(trimmed.c_str(), &stop); if (stop == trimmed.c_str() || !std::isfinite(parsed)) { return false; } const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; const double span = visual.yMax - visual.yMin; const double minimumGap = (span > 0.0) ? span * 0.01 : std::max(1.0e-9, std::fabs(parsed) * 0.01); Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; // 手工输入即视为取消自动自适应,与拖拽框选缩放的语义一致。 // 必须先把当前(自适应计算出的)显示范围固化到 lane 里,再只改动被编辑的一端, // 否则另一端会回退到 Lane 的初始占位值,导致量程被莫名其妙地放大。 lane.fitYToSelection = false; if (lane.autoRange) { lane.autoRange = false; lane.yMin = visual.yMin; lane.yMax = visual.yMax; } if (editMax) { lane.yMax = parsed; if (!(lane.yMax > lane.yMin)) { lane.yMin = lane.yMax - minimumGap; } } else { lane.yMin = parsed; if (!(lane.yMax > lane.yMin)) { lane.yMax = lane.yMin + minimumGap; } } BuildLaneVisuals(); return true; } bool PlotView::LaneRange(int laneIndex, double& yMin, double& yMax) const { if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) { // 菜单弹出时几何可能尚未建立,回退到配置内的自适应范围。 if (m_tab == nullptr || m_dataSet == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; } m_tab->ComputeLaneRange(*m_dataSet, laneIndex, yMin, yMax); return true; } const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; yMin = visual.yMin; yMax = visual.yMax; return true; } SignalRef PlotView::HitTestSignal(int x, int y) const { SignalRef empty; if (m_tab == nullptr || m_dataSet == nullptr || !HasDrawableContent()) { return empty; } if (!UiPointInRect(m_plotRect, static_cast(x), static_cast(y))) { return empty; } const int laneIndex = LaneAtY(y); if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) { return empty; } const LaneVisual& lane = m_lanes[static_cast(laneIndex)]; const float tolerance = kSignalHitTolerance * m_scale; const float fx = static_cast(x); const float fy = static_cast(y); SignalRef best; float bestDistance = tolerance; // 只为当前网格内的可见信号做精确的点到线段距离检测,避免跨网格误命中。 for (const SignalRef& ref : m_tab->Lanes()[static_cast(laneIndex)].signals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible) { continue; } // 复用绘制阶段的折线顶点:悬停检测通常与上一帧几何完全相同, // 不再为“鼠标移动但命中结果未变”重复扫描全量采样点。 const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, lane); if (cached == nullptr) { continue; } const std::vector& points = cached->points; const std::vector& breaks = cached->breaks; if (points.size() < 2) { continue; } // 横向先行裁剪:点到线段的距离必然不小于横向距离本身, // 因此只有横向落在容差范围内的顶点才可能命中。 // 时间单调时折线顶点按 x 递增,可二分定位后只扫描这一小段; // 否则每次鼠标移动都要遍历上万个顶点(悬停卡顿的来源之一)。 std::size_t windowFirst = 0; std::size_t windowLast = points.size(); if (resolved.file->timeMonotonic) { const auto byX = [](const gd::PointF& point, float limit) { return point.X < limit; }; windowFirst = static_cast( std::lower_bound(points.begin(), points.end(), fx - tolerance, byX) - points.begin()); windowLast = static_cast( std::upper_bound(points.begin(), points.end(), fx + tolerance, [](float limit, const gd::PointF& point) { return limit < point.X; }) - points.begin()); } if (windowFirst >= windowLast) { continue; } // 散点模式下把采样点本身也视为可命中目标。 if (resolved.style->mode == DrawMode::SCATTER) { for (std::size_t index = windowFirst; index < windowLast; ++index) { const gd::PointF& point = points[index]; const float dx = point.X - fx; const float dy = point.Y - fy; const float distance = std::sqrt(dx * dx + dy * dy); if (distance <= bestDistance) { bestDistance = distance; best = ref; } } continue; } for (std::size_t k = 0; k < breaks.size(); ++k) { const std::size_t start = breaks[k]; const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size(); const std::size_t scanBegin = std::max(start + 1, windowFirst); const std::size_t scanEnd = std::min(stop, windowLast); for (std::size_t index = scanBegin; index < scanEnd; ++index) { const gd::PointF& a = points[index - 1]; const gd::PointF& b = points[index]; const float vx = b.X - a.X; const float vy = b.Y - a.Y; const float wx = fx - a.X; const float wy = fy - a.Y; const float lengthSquared = vx * vx + vy * vy; float t = 0.0f; if (lengthSquared > 1.0e-6f) { t = (wx * vx + wy * vy) / lengthSquared; t = std::max(0.0f, std::min(1.0f, t)); } const float dx = wx - t * vx; const float dy = wy - t * vy; const float distance = std::sqrt(dx * dx + dy * dy); if (distance < bestDistance) { bestDistance = distance; best = ref; } } } } return best; } gd::RectF PlotView::EditFieldRect() const { if (m_editTarget == PlotEditTarget::NONE || m_fonts == nullptr || m_fonts->monoSmall == nullptr) { return gd::RectF(); } const float height = 22.0f * m_scale; float width = std::max(90.0f * m_scale, UiMeasureTextWidth(m_fonts->monoSmall, m_edit.Text()) + 24.0f * m_scale); if (m_editTarget == PlotEditTarget::CURSOR_TIME) { // 与显示态标签取相同宽度与位置(底部),避免进入编辑时框体跳动。 const int which = (m_editCursor == 2) ? 2 : 1; const gd::RectF displayRect = CursorLabelRect(which); width = std::max(width, displayRect.Width); float left = CursorX(which) - width * 0.5f; left = std::max(m_plotRect.X, std::min(left, m_plotRect.X + m_plotRect.Width - width)); return gd::RectF(left, m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale, width, height); } if (m_editTarget == PlotEditTarget::CURSOR_SPAN) { // 差值输入框落在差值标签处(时间轴上方或时间轴内部)。 bool insideAxis = false; const gd::RectF displayRect = CursorSpanRect(insideAxis); if (displayRect.Width <= 0.0f) { return gd::RectF(); } width = std::max(width, displayRect.Width); float left = displayRect.X + displayRect.Width * 0.5f - width * 0.5f; left = std::max(m_plotRect.X, std::min(left, m_plotRect.X + m_plotRect.Width - width)); return gd::RectF(left, displayRect.Y, width, height); } if (m_editTarget == PlotEditTarget::SIGNAL_NAME) { // 图例改名:输入框直接落在被双击的图例条目位置上。 if (m_editFieldRect.Width <= 0.0f) { return gd::RectF(); } const float nameWidth = std::max(width, m_editFieldRect.Width); return gd::RectF(m_editFieldRect.X, m_editFieldRect.Y, nameWidth, height); } // 网格 Y 轴上下限输入框:优先落在被点击的菜单条目位置,保证"就地编辑"的观感。 if (m_editFieldRect.Width > 0.0f && m_editFieldRect.Height > 0.0f) { float left = m_editFieldRect.X; float top = m_editFieldRect.Y; left = std::max(m_area.X + 2.0f * m_scale, std::min(left, m_area.X + m_area.Width - width - 2.0f * m_scale)); top = std::max(m_area.Y + 2.0f * m_scale, std::min(top, m_area.Y + m_area.Height - height - 2.0f * m_scale)); return gd::RectF(left, top, width, height); } if (m_editLane < 0 || m_editLane >= static_cast(m_lanes.size())) { return gd::RectF(); } const LaneVisual& lane = m_lanes[static_cast(m_editLane)]; const float left = lane.rect.X + kLaneLabelPad * m_scale; const float top = lane.rect.Y + kLaneLabelPad * m_scale; return gd::RectF(left, top, std::min(width, lane.rect.Width - 2.0f * kLaneLabelPad * m_scale), height); } bool PlotView::BeginEdit(PlotEditTarget target, int laneIndex, const std::wstring& initialText, const gd::RectF& anchorRect) { if (target == PlotEditTarget::NONE) { return false; } // 切换编辑目标时清掉上一轮残留(图例改名需由调用方在进入前先设置 m_editSignal)。 if (target != PlotEditTarget::SIGNAL_NAME) { m_editSignal = SignalRef(); } // 调用方可以用锚点矩形指定输入框位置(如菜单条目、图例条目); // 未提供锚点时由 EditFieldRect() 按目标类型自动推导。 if (anchorRect.Width > 0.0f && anchorRect.Height > 0.0f) { m_editFieldRect = anchorRect; } else if (target != PlotEditTarget::SIGNAL_NAME) { m_editFieldRect = gd::RectF(); } m_editTarget = target; m_editLane = laneIndex; // 名称与数值均不限长度(数值在提交时会做范围夹取)。 const bool nameField = (target == PlotEditTarget::SIGNAL_NAME); m_edit.Begin(initialText, true, nameField ? EditFilter::ANY : EditFilter::NUMBER, 0u); return true; } bool PlotView::TakePendingRename(SignalRef& ref, std::wstring& name) { if (!m_pendingRename) { return false; } ref = m_pendingRenameRef; name = m_pendingRenameName; m_pendingRename = false; m_pendingRenameRef = SignalRef(); m_pendingRenameName.clear(); return true; } bool PlotView::EndEdit(bool commit) { if (m_editTarget == PlotEditTarget::NONE) { return false; } const PlotEditTarget target = m_editTarget; const int laneIndex = m_editLane; const SignalRef editSignal = m_editSignal; const std::wstring text = Trim(m_edit.Text()); m_editTarget = PlotEditTarget::NONE; m_editLane = -1; m_editSignal = SignalRef(); m_editFieldRect = gd::RectF(); m_edit.End(); // 信号改名:交给宿主窗口落到数据层,绘图区只持有只读数据。 if (target == PlotEditTarget::SIGNAL_NAME) { if (commit && !text.empty() && editSignal.IsValid()) { m_pendingRenameRef = editSignal; m_pendingRenameName = text; m_pendingRename = true; } return true; } double parsed = 0.0; bool parsedOk = false; if (commit && !text.empty()) { wchar_t* stop = nullptr; parsed = std::wcstod(text.c_str(), &stop); parsedOk = (stop != text.c_str()) && (parsed == parsed) && std::isfinite(parsed); } if (!parsedOk) { return true; } if (target == PlotEditTarget::CURSOR_TIME) { double low = 0.0; double high = 1.0; TimeBounds(low, high); const double span = std::max(high - low, kMinTimeSpan); const double clamped = ClampDouble(parsed, low - span * 10.0, high + span * 10.0); if (m_editCursor == 2 && Tab().CursorMode() == PlotCursorMode::DOUBLE) { Tab().CursorTimeAlt() = clamped; } else { Tab().CursorTime() = clamped; } m_activeCursor = (m_editCursor == 2) ? 2 : 1; return true; } if (target == PlotEditTarget::CURSOR_SPAN) { // 差值编辑:输入值采用编辑开始时的显示单位(如标签显示 ms 则输入的是 ms)。 // **前方(时间较小)的光标不动**,后方光标向后移动到"前方 + 输入差值"。 const double span = std::fabs(parsed) * m_editDurationUnit; const double front = std::min(Tab().CursorTime(), Tab().CursorTimeAlt()); const bool secondIsBack = (Tab().CursorTimeAlt() >= Tab().CursorTime()); const double moved = front + span; if (secondIsBack) { Tab().CursorTime() = front; Tab().CursorTimeAlt() = moved; } else { Tab().CursorTimeAlt() = front; Tab().CursorTime() = moved; } m_activeCursor = secondIsBack ? 2 : 1; return true; } if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return true; } // 直接输入上下限:先固化当前范围,再用新值替换对应端点(保证 max > min)。 if (laneIndex >= static_cast(m_lanes.size())) { return true; } const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; // 直接输入上下限属于手动调整幅值:关闭本网格的「选区 Y 轴自适应」。 lane.fitYToSelection = false; if (lane.autoRange) { lane.autoRange = false; lane.yMin = visual.yMin; lane.yMax = visual.yMax; } const double magnitude = std::max(std::fabs(lane.yMin), std::fabs(lane.yMax)); const double minimumGap = std::max(magnitude * 1.0e-6, 1.0e-12); if (target == PlotEditTarget::LANE_Y_MAX) { if (parsed <= lane.yMin) { parsed = lane.yMin + minimumGap; } lane.yMax = parsed; } else { if (parsed >= lane.yMax) { parsed = lane.yMax - minimumGap; } lane.yMin = parsed; } BuildLaneVisuals(); return true; } bool PlotView::OnEditChar(wchar_t character) { if (m_editTarget == PlotEditTarget::NONE) { return false; } // 剪贴板与全选快捷键经 WM_CHAR 送达(Ctrl+C/X/V/A 分别对应 3/24/22/1)。 switch (character) { case 0x03: // Ctrl+C m_edit.CopyToClipboard(m_host); return false; case 0x18: // Ctrl+X return m_edit.CutToClipboard(m_host); case 0x16: // Ctrl+V return m_edit.PasteFromClipboard(m_host); case 0x01: // Ctrl+A m_edit.SelectAll(); return true; default: break; } if (character == L'\r' || character == L'\n') { return EndEdit(true); } // 退格/删除已由 WM_KEYDOWN 的 OnEditKey 处理,此处忽略控制字符避免重复删除。 if (character < 0x20 || character == 0x7F) { return false; } return m_edit.Insert(character); } bool PlotView::CollapseEditSelectionToEnd() { if (m_editTarget == PlotEditTarget::NONE) { return false; } return m_edit.DeselectToEnd(); } bool PlotView::OnEditMouseDown(int x, int y) { if (m_editTarget == PlotEditTarget::NONE) { return false; } const gd::RectF rect = EditFieldRect(); if (rect.Width <= 0.0f) { return false; } return UiEditFieldMouseDown(m_fonts->uiSmall, rect, m_edit, m_scale, x, y); } bool PlotView::OnEditMouseMove(int x) { if (m_editTarget == PlotEditTarget::NONE) { return false; } const gd::RectF rect = EditFieldRect(); if (rect.Width <= 0.0f) { return false; } return UiEditFieldMouseMove(m_fonts->uiSmall, rect, m_edit, m_scale, x); } bool PlotView::OnEditMouseUp() { if (!m_edit.Dragging()) { return false; } m_edit.EndDrag(); return true; } bool PlotView::OnEditKey(WPARAM key) { if (m_editTarget == PlotEditTarget::NONE) { return false; } const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0; const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0; switch (key) { case VK_RETURN: return EndEdit(true); case VK_ESCAPE: return EndEdit(false); case VK_BACK: return m_edit.Backspace(); case VK_DELETE: return m_edit.DeleteForward(); case VK_LEFT: return m_edit.MoveCaret(-1, shift); case VK_RIGHT: return m_edit.MoveCaret(1, shift); case VK_HOME: return m_edit.MoveHome(shift); case VK_END: return m_edit.MoveEnd(shift); case 'A': if (control) { m_edit.SelectAll(); } return true; default: break; } return true; } bool PlotView::HitTest(int x, int y) const { return UiPointInRect(m_area, static_cast(x), static_cast(y)); } HCURSOR PlotView::QueryCursor(int x, int y) const { switch (m_dragMode) { case PlotDragMode::PAN: return LoadCursorW(nullptr, IDC_SIZEALL); case PlotDragMode::RUBBER_SELECT: // 选区放大:轴向未定时为十字,确定后按方向给出上下/左右箭头。 if (m_rubberAxis == PlotRubberAxis::VALUE) { return LoadCursorW(nullptr, IDC_SIZENS); } if (m_rubberAxis == PlotRubberAxis::TIME) { return LoadCursorW(nullptr, IDC_SIZEWE); } return LoadCursorW(nullptr, IDC_CROSS); case PlotDragMode::SCROLL_LANES: return LoadCursorW(nullptr, IDC_SIZENS); case PlotDragMode::CURSOR_MOVE: case PlotDragMode::CURSOR_SPAN_MOVE: return LoadCursorW(nullptr, IDC_SIZEWE); case PlotDragMode::MOVE_SIGNAL: return LoadCursorW(nullptr, IDC_SIZEALL); default: break; } if (m_scrollBarVisible && UiPointInRect(m_scrollBarRect, static_cast(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_SIZENS); } // 双光标差值标签可拖拽。 if (Tab().CursorMode() == PlotCursorMode::DOUBLE && m_cursorSpanRect.Width > 0.0f && UiPointInRect(m_cursorSpanRect, static_cast(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_SIZEWE); } { int cursorWhich = 0; if (IsOnCursorLine(x, cursorWhich) && UiPointInRect(m_area, static_cast(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_SIZEWE); } } if (UiPointInRect(m_plotRect, static_cast(x), static_cast(y))) { // 命中信号曲线时给出可拖拽提示光标(仅在常规模式下)。 if (Tab().ZoomMode() == PlotZoomMode::NONE && HitTestSignal(x, y).IsValid()) { return LoadCursorW(nullptr, IDC_SIZEALL); } // 选区放大模式下**未开始框选时保持默认箭头**(不改变鼠标形状); // 只有真正按下并拖动后才切成十字/方向箭头。 // (m_dragMode == RUBBER_SELECT 的情况已在上面处理。) } return nullptr; } bool PlotView::OnMouseMove(int x, int y) { m_mouseX = x; m_mouseY = y; if (!HasDrawableContent()) { return false; } const float fx = static_cast(x); switch (m_dragMode) { case PlotDragMode::PAN: { if (m_plotRect.Width <= 0.0f) { return false; } // 横向拖动平移时间轴;纵向拖动偏移当前网格的幅值显示位置。 const double span = Tab().ViewMax() - Tab().ViewMin(); const double delta = -static_cast(x - m_dragLastX) / static_cast(m_plotRect.Width) * span; if (delta != 0.0) { Tab().ViewMin() += delta; Tab().ViewMax() += delta; ClampTimeView(); } const int laneIndex = m_dragLane; if (laneIndex >= 0 && laneIndex < static_cast(m_lanes.size()) && y != m_dragLastY) { const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; if (visual.rect.Height > 0.0f) { const double valueSpan = visual.yMax - visual.yMin; const double deltaValue = static_cast(y - m_dragLastY) / static_cast(visual.rect.Height) * valueSpan; ShiftLaneRange(laneIndex, deltaValue); } } m_dragLastX = x; m_dragLastY = y; if (std::abs(x - m_dragStartX) > 3 || std::abs(y - m_dragStartY) > 3) { m_dragMoved = true; } return true; } case PlotDragMode::RUBBER_SELECT: { const float dx = static_cast(x - m_dragStartX); const float dy = static_cast(y - m_dragStartY); // 轴向由首次明显位移决定,之后固定不变(避免拖拽中途选区形态跳变)。 if (m_rubberAxis == PlotRubberAxis::UNDECIDED) { const float threshold = 4.0f * m_scale; if (std::fabs(dx) < threshold && std::fabs(dy) < threshold) { return true; } m_rubberAxis = (std::fabs(dx) >= std::fabs(dy)) ? PlotRubberAxis::TIME : PlotRubberAxis::VALUE; } if (m_rubberAxis == PlotRubberAxis::TIME) { // 竖向选区贯穿整个绘图区高度,只取横向区间用于时间轴缩放。 const float left = static_cast(std::min(x, m_dragStartX)); const float width = static_cast(std::abs(x - m_dragStartX)); m_rubberRect = gd::RectF(left, m_plotRect.Y, width, m_plotRect.Height); } else { // 横向选区限定在按下时的网格内,只取纵向区间用于幅值轴缩放。 if (m_dragLane < 0 || m_dragLane >= static_cast(m_lanes.size())) { return false; } const gd::RectF& laneRect = m_lanes[static_cast(m_dragLane)].rect; const float top = static_cast(std::min(y, m_dragStartY)); const float bottom = static_cast(std::max(y, m_dragStartY)); m_rubberRect = gd::RectF(laneRect.X, top, laneRect.Width, bottom - top); } m_dragMoved = true; return true; } case PlotDragMode::SCROLL_LANES: { if (m_plotRect.Height <= 0.0f || m_laneContentHeight <= m_plotRect.Height) { return false; } const double travel = static_cast(y - m_dragLastY) / static_cast(m_plotRect.Height); Tab().LaneScroll() += static_cast(travel * m_laneContentHeight); m_dragLastY = y; m_dragMoved = true; BuildLaneVisuals(); return true; } case PlotDragMode::CURSOR_MOVE: { // 放大到采样点可分辨时吸附到最近的数据点,便于精确对比取值。 double target = XToTime(fx); double snapped = target; if (SnapTimeToSample(target, snapped)) { target = snapped; } if (m_hoverCursor == 2 && Tab().CursorMode() == PlotCursorMode::DOUBLE) { Tab().CursorTimeAlt() = target; } else { Tab().CursorTime() = target; } m_dragMoved = true; m_cursorHover = true; return true; } case PlotDragMode::CURSOR_SPAN_MOVE: { // 拖拽差值标签:两条光标整体平移,间隔保持不变。 const double startTime = XToTime(static_cast(m_spanDragStartX)); const double delta = XToTime(fx) - startTime; Tab().CursorTime() = m_spanDragTime1 + delta; Tab().CursorTimeAlt() = m_spanDragTime2 + delta; m_dragMoved = true; return true; } case PlotDragMode::MOVE_SIGNAL: { const int target = LaneAtY(y); m_dragMoved = true; m_dragMouseX = x; m_dragMouseY = y; if (target != m_dragTargetLane) { m_dragTargetLane = target; } return true; } case PlotDragMode::NONE: default: break; } bool changed = false; const int lane = LaneAtY(y); if (lane != m_hoverLane) { m_hoverLane = lane; changed = true; } // 时间光标不再跟随鼠标移动,仅在鼠标覆盖光标时才显示具体时间读数。 int hoverWhich = 0; const bool hover = Tab().CursorEnabled() && IsOnCursorLine(x, hoverWhich); if (hover != m_cursorHover) { m_cursorHover = hover; changed = true; } if (hoverWhich != m_hoverCursor) { m_hoverCursor = hoverWhich; changed = true; } // 常规模式下精确检测鼠标是否贴近某条信号曲线,用于给出可拖拽提示。 // 图例优先于曲线:图例条目本身就是信号的可点选目标(与左侧信号列表一致)。 SignalRef hoverSignal; if (Tab().ZoomMode() == PlotZoomMode::NONE && !m_cursorHover) { hoverSignal = LegendAtPoint(x, y); if (!hoverSignal.IsValid()) { hoverSignal = HitTestSignal(x, y); } } if (hoverSignal != m_hoverSignal) { m_hoverSignal = hoverSignal; changed = true; } return changed; } bool PlotView::OnLButtonDown(int x, int y) { if (!HasDrawableContent()) { return false; } const float fx = static_cast(x); const float fy = static_cast(y); m_dragStartX = x; m_dragStartY = y; m_dragLastX = x; m_dragLastY = y; m_dragMoved = false; m_rubberRect = gd::RectF(); m_rubberAxis = PlotRubberAxis::UNDECIDED; // 新的交互开始:清掉上一次留下的提示(选区放大会在下面重新设置自己的提示)。 SetHint(std::wstring()); if (m_scrollBarVisible && UiPointInRect(m_scrollBarRect, static_cast(x), static_cast(y))) { m_dragMode = PlotDragMode::SCROLL_LANES; return true; } // 内联输入中:输入框之外的点击视为提交(与线宽输入一致)。 if (IsEditing()) { if (!UiPointInRect(EditFieldRect(), fx, fy)) { EndEdit(true); } return true; } // 双光标模式下先检查差值标签:它可拖拽(两条光标整体平移,间隔不变)。 if (Tab().CursorMode() == PlotCursorMode::DOUBLE && m_cursorSpanRect.Width > 0.0f && UiPointInRect(m_cursorSpanRect, fx, fy)) { m_dragMode = PlotDragMode::CURSOR_SPAN_MOVE; m_spanDragStartX = x; m_spanDragTime1 = Tab().CursorTime(); m_spanDragTime2 = Tab().CursorTimeAlt(); return true; } // 靠近时间光标线时优先进入拖动,避免误触发平移或缩放选区。 { int cursorWhich = 0; if (IsOnCursorLine(x, cursorWhich) && UiPointInRect(m_area, fx, fy)) { m_dragMode = PlotDragMode::CURSOR_MOVE; m_hoverCursor = cursorWhich; // 点中的光标成为“活动光标”:它触发的内容将画在上层。 m_activeCursor = cursorWhich; m_cursorHover = true; return true; } } if (!UiPointInRect(m_plotRect, fx, fy)) { m_dragMode = PlotDragMode::NONE; return false; } const PlotZoomMode mode = Tab().ZoomMode(); if (mode == PlotZoomMode::SELECT) { // 选区放大:轴向由鼠标移动方向决定,因此按下时先记录网格并置为未定。 m_dragLane = LaneAtY(y); m_rubberAxis = PlotRubberAxis::UNDECIDED; m_dragMode = PlotDragMode::RUBBER_SELECT; SetHint(L"横向拖动放大时间轴,纵向拖动放大 Y 轴;右键放弃当前选区,Esc 退出"); return true; } // 常规模式下:点击图例条目等价于在左侧列表中选中该信号(样式区随之切换目标)。 if (mode == PlotZoomMode::NONE) { const SignalRef legendHit = LegendAtPoint(x, y); if (legendHit.IsValid()) { SelectSignal(legendHit); m_dragMode = PlotDragMode::NONE; return true; } } // 常规模式下:先检测是否落在信号曲线上;只有命中提示时才允许拖拽该信号。 const SignalRef hit = HitTestSignal(x, y); if (hit.IsValid()) { m_dragSignal = hit; m_dragTargetLane = Tab().LaneOfSignal(hit); SelectSignal(hit); m_dragMouseX = x; m_dragMouseY = y; m_dragMode = PlotDragMode::MOVE_SIGNAL; return true; } m_dragLane = LaneAtY(y); m_dragMode = PlotDragMode::PAN; return true; } bool PlotView::OnLButtonUp(int x, int y) { bool changed = (m_dragMode != PlotDragMode::NONE); switch (m_dragMode) { case PlotDragMode::RUBBER_SELECT: { // 按拖拽方向应用对应的缩放(横向 → 时间轴;纵向 → 当前网格幅值轴)。 if (m_rubberAxis == PlotRubberAxis::TIME) { if (m_dragMoved && ApplyTimeSelection(m_rubberRect.X, m_rubberRect.X + m_rubberRect.Width)) { changed = true; } } else if (m_rubberAxis == PlotRubberAxis::VALUE) { if (m_dragMoved && ApplyValueSelection(m_dragLane, m_rubberRect.Y, m_rubberRect.Y + m_rubberRect.Height)) { changed = true; } } break; } case PlotDragMode::CURSOR_MOVE: m_cursorHover = true; break; case PlotDragMode::CURSOR_SPAN_MOVE: break; case PlotDragMode::MOVE_SIGNAL: { const int target = LaneAtY(y); const int source = (m_tab != nullptr) ? m_tab->LaneOfSignal(m_dragSignal) : -1; if (m_dataSet != nullptr && m_tab != nullptr && m_dragSignal.IsValid() && target >= 0 && target != source && m_dragMoved) { m_tab->MoveSignalToLane(*m_dataSet, m_dragSignal, target); changed = true; } m_dragSignal = SignalRef(); m_dragTargetLane = -1; break; } default: break; } m_dragMode = PlotDragMode::NONE; m_rubberAxis = PlotRubberAxis::UNDECIDED; m_dragLane = -1; m_dragMoved = false; m_rubberRect = gd::RectF(); (void)x; return changed; } bool PlotView::OnMouseWheel(int x, int y, int delta, bool shift) { if (!HasDrawableContent() || delta == 0) { return false; } const double notches = static_cast(delta) / 120.0; // 滚轮不再承担缩放:默认纵向滚动网格,按住 Shift 时水平平移时间轴。 if (shift) { const double span = Tab().ViewMax() - Tab().ViewMin(); const double sweep = -notches * span * 0.15; Tab().ViewMin() += sweep; Tab().ViewMax() += sweep; ClampTimeView(); return true; } if (m_plotRect.Height <= 0.0f || m_laneContentHeight <= m_plotRect.Height) { return false; } Tab().LaneScroll() -= static_cast(notches * 48.0 * m_scale); BuildLaneVisuals(); (void)x; (void)y; return true; } bool PlotView::OnDoubleClick(int x, int y) { if (!UiPointInRect(m_area, static_cast(x), static_cast(y))) { return false; } // 双光标差值标签:双击进入差值编辑(输入值采用当前显示单位)。 if (Tab().CursorMode() == PlotCursorMode::DOUBLE && m_cursorSpanRect.Width > 0.0f && UiPointInRect(m_cursorSpanRect, static_cast(x), static_cast(y))) { m_editDurationUnit = ChosenDurationUnit(CursorSpan()).scale; return BeginEdit(PlotEditTarget::CURSOR_SPAN, -1, FormatDurationNumber(CursorSpan())); } // 命中时间光标:进入精确时间输入(交互与线宽输入框一致)。 { int cursorWhich = 0; if (IsOnCursorLine(x, cursorWhich)) { m_editCursor = (cursorWhich == 2) ? 2 : 1; m_activeCursor = m_editCursor; const double time = (m_editCursor == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime(); return BeginEdit(PlotEditTarget::CURSOR_TIME, -1, FormatValue(time)); } } // 命中图例条目:进入信号改名(与左侧列表双击改名的交互一致)。 const SignalRef legendHit = LegendAtPoint(x, y); if (legendHit.IsValid()) { const ResolvedSignal resolved = ResolveSignal(legendHit); if (resolved.data != nullptr) { SelectSignal(legendHit); m_editSignal = legendHit; // 先把输入框位置落在被双击的条目上,再进入编辑态。 for (const LegendHit& hit : m_legendHits) { if (hit.ref == legendHit) { m_editFieldRect = hit.rect; break; } } return BeginEdit(PlotEditTarget::SIGNAL_NAME, -1, resolved.data->Title()); } } // 其它位置:回到初始状态(自适应 + 退出缩放模式)。 FitAll(); return true; } bool PlotView::OnMouseLeave() { bool changed = false; if (m_hoverLane != -1) { m_hoverLane = -1; changed = true; } if (m_hoverSignal.IsValid()) { m_hoverSignal = SignalRef(); changed = true; } if (m_cursorHover) { m_cursorHover = false; changed = true; } if (m_hoverCursor != 0 && m_dragMode != PlotDragMode::CURSOR_MOVE) { m_hoverCursor = 0; changed = true; } return changed; } const PlotView::PolylineCacheEntry* PlotView::AcquirePolyline(const SignalRef& ref, const ResolvedSignal& resolved, const LaneVisual& lane) const { if (resolved.data == nullptr || resolved.file == nullptr) { return nullptr; } // 键 = 构建折线时实际读取的全部输入: // 信号(引用 + 文件内容 + 采样点数)、时间轴显示范围、绘图区横向几何、网格纵向几何。 // 这些值全部相同时,BuildPolyline 的输出必然完全一致,可直接复用。 const std::size_t sampleCount = std::min(resolved.file->time.size(), resolved.data->values.size()); const double viewMin = Tab().ViewMin(); const double viewMax = Tab().ViewMax(); for (const PolylineCacheEntry& entry : m_polylineCache) { if (entry.ref == ref && entry.file == resolved.file && entry.sampleCount == sampleCount && entry.viewMin == viewMin && entry.viewMax == viewMax && entry.plotX == m_plotRect.X && entry.plotWidth == m_plotRect.Width && entry.laneY == lane.rect.Y && entry.laneHeight == lane.rect.Height && entry.laneYMin == lane.yMin && entry.laneYMax == lane.yMax) { return &entry; } } // 未命中:重建并写入缓存(deque 保证已有元素的引用在追加后依然有效)。 // 缓存只用于消除重复计算,容量上限约等于“网格数 × 每网格信号数”的两倍。 if (m_polylineCache.size() >= kPolylineCacheLimit) { m_polylineCache.clear(); } m_polylineCache.push_back(PolylineCacheEntry()); PolylineCacheEntry& entry = m_polylineCache.back(); entry.ref = ref; entry.file = resolved.file; entry.sampleCount = sampleCount; entry.viewMin = viewMin; entry.viewMax = viewMax; entry.plotX = m_plotRect.X; entry.plotWidth = m_plotRect.Width; entry.laneY = lane.rect.Y; entry.laneHeight = lane.rect.Height; entry.laneYMin = lane.yMin; entry.laneYMax = lane.yMax; BuildPolyline(resolved, lane, entry.points, entry.breaks); // BuildPolyline 通过引用填充,push_back 过程中不会再次进入本函数,引用安全。 return &m_polylineCache.back(); } void PlotView::BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane, std::vector& points, std::vector& breaks) const { points.clear(); breaks.clear(); if (resolved.data == nullptr || resolved.file == nullptr) { return; } const std::vector& time = resolved.file->time; const std::vector& 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::const_iterator first = std::lower_bound(time.begin(), time.end(), viewMin); begin = static_cast(first - time.begin()); if (begin > 0) { --begin; } const std::vector::const_iterator last = std::lower_bound(time.begin(), time.end(), viewMax); end = static_cast(last - time.begin()); if (end < count) { ++end; } } if (end <= begin) { return; } const float limitTop = lane.rect.Y - lane.rect.Height * 4.0f; const float limitBottom = lane.rect.Y + lane.rect.Height * 5.0f; const std::size_t visibleCount = end - begin; // 顶点预算由绘图区宽度决定(而不是固定值):折线顶点数直接决定 // GDI+ 抗锯齿折线的绘制耗时,百万级采样文件若按固定 20000 顶点绘制, // 会把大量顶点堆在同一个像素列上(画出来完全重合),纯粹浪费 CPU。 std::size_t budget = static_cast(std::max(1.0f, m_plotRect.Width)) * kPolylinePointsPerPixel; budget = std::max(budget, kMinPolylinePoints); budget = std::min(budget, kMaxPolylinePoints); // 采样点密度远超像素分辨率时改用“像素列上下包络”抽稀: // 每个像素列只保留最小/最大两个顶点(按出现先后排序), // 既不会漏掉尖峰(等间隔抽样会整段跳过),又把顶点数压到像素量级。 if (visibleCount > budget && resolved.file->timeMonotonic && m_plotRect.Width > 1.0f) { const double viewSpan = viewMax - viewMin; const float plotLeft = m_plotRect.X; const float plotWidth = m_plotRect.Width; const std::size_t columns = std::max(1, budget / 2); const float columnWidth = plotWidth / static_cast(columns); const std::vector::const_iterator rangeEnd = time.begin() + static_cast(end); // 粗粒度包络层(若已构建):每列只需读取覆盖到的分组, // 数据量从“可见采样数”降到“像素列数 × 少量分组”, // 且分组内存的就是真实极值,绘制结果与逐点扫描完全一致。 const std::vector& coarseMin = resolved.data->coarseMin; const std::vector& coarseMax = resolved.data->coarseMax; const std::size_t coarseStride = resolved.data->coarseStride; const bool hasCoarse = (coarseStride > 0) && !coarseMin.empty() && (coarseMin.size() == coarseMax.size()); bool inSegment = false; std::size_t searchFrom = begin; for (std::size_t column = 0; column < columns; ++column) { const double t0 = viewMin + viewSpan * (static_cast(column) / static_cast(columns)); const double t1 = viewMin + viewSpan * (static_cast(column + 1) / static_cast(columns)); // 列边界随 column 单调递增,从上一列的位置继续二分, // 把每次搜索的范围压到剩余区间,避免 2400 次全区间二分带来的缓存缺失。 const std::size_t i0 = static_cast( std::lower_bound(time.begin() + static_cast(searchFrom), rangeEnd, t0) - time.begin()); searchFrom = i0; const std::size_t i1 = (column + 1 == columns) ? end : static_cast( std::lower_bound(time.begin() + static_cast(i0), rangeEnd, t1) - time.begin()); if (i1 <= i0) { // 该列没有采样点:说明数据在此处存在时间间隙,断开当前折线。 inSegment = false; continue; } double low = std::numeric_limits::infinity(); double high = -std::numeric_limits::infinity(); std::size_t lowOrder = 0; std::size_t highOrder = 0; bool broken = false; // 一列至少覆盖两个分组时才用分组层(否则直接扫原始采样更准且更省)。 if (hasCoarse && (i1 - i0) >= 2 * coarseStride) { const std::size_t firstBucket = i0 / coarseStride; const std::size_t lastBucket = std::min((i1 - 1) / coarseStride, coarseMin.size() - 1); for (std::size_t bucket = firstBucket; bucket <= lastBucket; ++bucket) { const double bucketLow = coarseMin[bucket]; const double bucketHigh = coarseMax[bucket]; if (!std::isfinite(bucketLow)) { // 该分组全部是无效值:包络到此断开,后续另起一段。 broken = true; continue; } if (bucketLow < low) { low = bucketLow; lowOrder = bucket; } if (bucketHigh > high) { high = bucketHigh; highOrder = bucket; } } } else { for (std::size_t index = i0; index < i1; ++index) { const double value = values[index]; if (!std::isfinite(value)) { // 该列含无效值:包络绘制到本列结束,后续另起一段。 broken = true; continue; } if (value < low) { low = value; lowOrder = index; } if (value > high) { high = value; highOrder = index; } } } if (!(low <= high)) { // 本列全是无效值。 inSegment = false; continue; } const float x = plotLeft + (static_cast(column) + 0.5f) * columnWidth; const float yTop = static_cast(ClampDouble(ValueToY(high, lane), limitTop, limitBottom)); const float yBottom = static_cast(ClampDouble(ValueToY(low, lane), limitTop, limitBottom)); if (yBottom - yTop < kEnvelopeMinPixelSpan) { // 波动不足半个像素:只留一个顶点,避免无意义的重复顶点。 if (!inSegment) { breaks.push_back(points.size()); inSegment = true; } points.push_back(gd::PointF(x, yTop)); } else { if (!inSegment) { breaks.push_back(points.size()); inSegment = true; } // 按采样点出现的先后顺序连接上下包络,保持走线方向一致。 if (lowOrder <= highOrder) { points.push_back(gd::PointF(x, yBottom)); points.push_back(gd::PointF(x, yTop)); } else { points.push_back(gd::PointF(x, yTop)); points.push_back(gd::PointF(x, yBottom)); } } if (broken) { inSegment = false; } } return; } std::size_t stride = 1; if (visibleCount > budget) { stride = (visibleCount + budget - 1) / budget; } bool inSegment = false; for (std::size_t index = begin; index < end; index += stride) { const double t = time[index]; const double value = values[index]; if (!std::isfinite(t) || !std::isfinite(value)) { inSegment = false; continue; } const float x = TimeToX(t); float y = ValueToY(value, lane); y = static_cast(ClampDouble(y, limitTop, limitBottom)); if (!inSegment) { breaks.push_back(points.size()); points.push_back(gd::PointF(x, y)); inSegment = true; } else { points.push_back(gd::PointF(x, y)); } } } void PlotView::PaintEmptyState(gd::Graphics& g) { std::wstring message; if (m_dataSet == nullptr || m_dataSet->Empty()) { message = L"打开 CSV 文件以查看仿真数据波形"; } else { message = L"请在左侧勾选需要查看的信号(可同时加载多个文件进行对比)"; } UiText(g, message, m_fonts->ui, m_area, m_theme->textMuted, TextAlign::CENTER); } void PlotView::Paint(gd::Graphics& g) { if (m_theme == nullptr || m_fonts == nullptr) { return; } BuildLaneVisuals(); if (m_lanes.empty()) { InvalidateSceneCache(); PaintEmptyState(g); return; } // 静态场景复用(不再局限于拖拽):只要视图几何、信号集合与显示样式都没变, // "绘图区底色 + 全部网格 + 时间轴"就逐像素相同,直接贴图即可。 // 大文件下整幅重绘约 80ms,贴图约 1ms —— 鼠标悬停、拖拽选区这些 // "只改一小块叠加层"的交互因此不再重绘波形。 // 随鼠标变化的内容(悬停高亮、读数框、选区、光标、拖拽虚影、输入框)一律画在贴图之后。 if (SceneCacheMatches() && BlitSceneCache(g)) { PaintHoverHighlight(g); PaintRubberBand(g); PaintCursor(g, m_lanes); PaintSignalInteraction(g); PaintHint(g); if (m_editTarget == PlotEditTarget::LANE_Y_MAX || m_editTarget == PlotEditTarget::LANE_Y_MIN) { PaintEditField(g); } PaintScrollBar(g); // 绘图区外框(比网格内部分界线更重,形成明确整体边界)。 gd::Pen borderPen(ToGdiColor(m_theme->axisLine), 1.6f); g.DrawRectangle(&borderPen, m_plotRect.X, m_plotRect.Y, m_plotRect.Width, m_plotRect.Height); return; } const double viewMin = Tab().ViewMin(); const double viewMax = Tab().ViewMax(); const double span = viewMax - viewMin; const double timeStep = NiceStep((span > 0.0) ? span : 1.0, kTimeTickTarget); std::vector timeTicks; BuildTicks(viewMin, viewMax, timeStep, timeTicks); // 绘图区整体底色。 UiFillRect(g, m_area, m_theme->plotBg); // 波形区域底色。 UiFillRect(g, m_plotRect, m_theme->cardBg); // 图例命中矩形每帧重建,避免残留已消失的信号。 m_legendHits.clear(); for (std::size_t index = 0; index < m_lanes.size(); ++index) { PaintLane(g, static_cast(index), m_lanes[index], timeTicks); } PaintTimeAxis(g, timeTicks, timeStep); // 此刻画布上正好是"绘图区底色 + 全部网格 + 时间轴",尚未绘制任何随鼠标变化的内容, // 因此是捕获静态场景缓存的最佳时点(必须在裁剪区已全部恢复之后执行)。 CaptureSceneCache(g); PaintHoverHighlight(g); PaintRubberBand(g); PaintCursor(g, m_lanes); PaintSignalInteraction(g); PaintHint(g); // 网格 Y 轴上下限的输入框绘制在最上层。 if (m_editTarget == PlotEditTarget::LANE_Y_MAX || m_editTarget == PlotEditTarget::LANE_Y_MIN) { PaintEditField(g); } PaintScrollBar(g); // 绘图区外框(比网格内部分界线更重,形成明确整体边界)。 gd::Pen borderPen(ToGdiColor(m_theme->axisLine), 1.6f); g.DrawRectangle(&borderPen, m_plotRect.X, m_plotRect.Y, m_plotRect.Width, m_plotRect.Height); } void PlotView::PaintSignalHalo(gd::Graphics& g, const SignalRef& ref, const ResolvedSignal& resolved, const std::vector& points, const std::vector& breaks) { const bool drawLine = (resolved.style->mode != DrawMode::SCATTER); if (!drawLine || breaks.empty()) { // 散点模式没有光晕(原实现同样只对折线绘制光晕)。 (void)g; (void)ref; return; } gd::Pen halo(ToGdiColor(WithAlpha(m_theme->accent, 0x66)), (resolved.style->lineWidth + 4.0f) * m_scale); halo.SetLineJoin(gd::LineJoinRound); halo.SetStartCap(gd::LineCapRound); halo.SetEndCap(gd::LineCapRound); for (std::size_t k = 0; k < breaks.size(); ++k) { const std::size_t start = breaks[k]; const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size(); if (stop <= start + 1) { continue; } g.DrawLines(&halo, points.data() + start, static_cast(stop - start)); } } void PlotView::PaintHoverHighlight(gd::Graphics& g) { if (m_tab == nullptr || m_dragMode != PlotDragMode::NONE) { // 拖拽过程中不显示悬停提示(拖拽中的信号光晕已绘入静态场景缓存)。 return; } const int saveState = g.Save(); g.SetClip(m_area, gd::CombineModeIntersect); // 悬停网格:一层很淡的强调色叠加。为了能画在静态场景之上, // 这里用半透明而非不透明混色,观感与原实现基本一致。 if (m_hoverLane >= 0 && m_hoverLane < static_cast(m_lanes.size())) { const gd::RectF& laneRect = m_lanes[static_cast(m_hoverLane)].rect; const float clipTop = std::max(laneRect.Y, m_plotRect.Y); const float clipBottom = std::min(laneRect.Y + laneRect.Height, m_plotRect.Y + m_plotRect.Height); if (clipBottom > clipTop) { // alpha 0x0A ≈ 4%,与原「不透明混色 3.5%」的观感基本一致。 UiFillRect(g, gd::RectF(laneRect.X, clipTop, laneRect.Width, clipBottom - clipTop), WithAlpha(m_theme->accent, 0x0A)); } } // 悬停信号:外扩一圈半透明强调色作为"可拖拽"提示。 if (m_hoverSignal.IsValid()) { const ResolvedSignal resolved = ResolveSignal(m_hoverSignal); if (resolved.data != nullptr && resolved.style != nullptr && resolved.visible) { const int laneIndex = m_tab->LaneOfSignal(m_hoverSignal); if (laneIndex >= 0 && laneIndex < static_cast(m_lanes.size())) { const LaneVisual& lane = m_lanes[static_cast(laneIndex)]; const PolylineCacheEntry* cached = AcquirePolyline(m_hoverSignal, resolved, lane); if (cached != nullptr) { PaintSignalHalo(g, m_hoverSignal, resolved, cached->points, cached->breaks); } } } } g.Restore(saveState); } PlotView::SceneCacheKey PlotView::MakeSceneCacheKey() const { SceneCacheKey key; key.viewMin = Tab().ViewMin(); key.viewMax = Tab().ViewMax(); key.laneScroll = Tab().LaneScroll(); key.laneCount = static_cast(m_lanes.size()); key.areaX = m_area.X; key.areaY = m_area.Y; key.areaW = m_area.Width; key.areaH = m_area.Height; key.scale = m_scale; const SignalRef halo = SceneHaloRef(); key.haloFile = halo.fileIndex; key.haloSignal = halo.signalIndex; key.contentHash = SceneContentHash(); return key; } bool PlotView::SceneCacheMatches() const { if (!m_sceneValid) { return false; } const SceneCacheKey& cached = m_sceneKey; const SceneCacheKey key = MakeSceneCacheKey(); return (key.viewMin == cached.viewMin) && (key.viewMax == cached.viewMax) && (key.laneScroll == cached.laneScroll) && (key.laneCount == cached.laneCount) && (key.areaX == cached.areaX) && (key.areaY == cached.areaY) && (key.areaW == cached.areaW) && (key.areaH == cached.areaH) && (key.scale == cached.scale) && (key.haloFile == cached.haloFile) && (key.haloSignal == cached.haloSignal) && (key.contentHash == cached.contentHash); } SignalRef PlotView::SceneHaloRef() const { // 只有拖拽中的信号光晕是“整个拖拽过程都不变”的,可以安全地绘入静态场景缓存; // 悬停光晕随鼠标移动,必须画在缓存之外(见 PaintHoverHighlight)。 if (m_dragMode == PlotDragMode::MOVE_SIGNAL && m_dragSignal.IsValid()) { return m_dragSignal; } return SignalRef(); } std::uint64_t PlotView::SceneContentHash() const { // FNV-1a:把“视图几何之外的一切影响绘图区像素的内容”折叠成一个指纹。 // 这样颜色/线宽/透明度/样式/重命名/信号增删等变化都会自动让缓存失效, // 不需要在各处手工调用 InvalidateSceneCache(容易漏)。 std::uint64_t hash = 1469598103934665603ull; const auto mix = [&hash](std::uint64_t value) { hash ^= value; hash *= 1099511628211ull; }; const auto mixFloat = [&mix](float value) { std::uint32_t bits = 0; std::memcpy(&bits, &value, sizeof(bits)); mix(bits); }; const auto mixText = [&mix](const std::wstring& text) { mix(text.size()); for (const wchar_t character : text) { mix(static_cast(character)); } }; mixFloat(m_plotRect.X); mixFloat(m_plotRect.Y); mixFloat(m_plotRect.Width); mixFloat(m_plotRect.Height); mixFloat(m_laneContentHeight); mix(m_scrollBarVisible ? 1ull : 0ull); for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) { const LaneVisual& visual = m_lanes[laneIndex]; mixFloat(visual.rect.Y); mixFloat(visual.rect.Height); mixFloat(static_cast(visual.yMin)); mixFloat(static_cast(visual.yMax)); const std::vector& signals = Tab().Lanes()[laneIndex].signals; mix(signals.size()); for (const SignalRef& ref : signals) { const ResolvedSignal resolved = ResolveSignal(ref); mix(static_cast(ref.fileIndex)); mix(static_cast(ref.signalIndex)); mix(resolved.visible ? 1ull : 0ull); if (resolved.data == nullptr || resolved.style == nullptr) { continue; } mix(static_cast(resolved.style->color)); mixFloat(resolved.style->opacity); mixFloat(resolved.style->lineWidth); mix(static_cast(resolved.style->mode)); // 图例文案参与绘制,重命名必须让缓存失效。 mixText(resolved.data->Title()); if (resolved.file != nullptr) { mixText(resolved.file->Title()); } } } return hash; } bool PlotView::EnsureSceneCache(int width, int height) { if (width <= 0 || height <= 0) { return false; } // 位图尺寸必须与当前缓存区域**严格一致**:贴图时按位图尺寸整块拷贝, // 若沿用更大的旧位图,缩小窗口后会把旧像素画到绘图区之外。 if (m_sceneDc != nullptr && m_sceneWidth == width && m_sceneHeight == height) { return true; } ReleaseSceneCache(); HDC screen = GetDC(nullptr); if (screen == nullptr) { return false; } m_sceneDc = CreateCompatibleDC(screen); m_sceneBitmap = CreateCompatibleBitmap(screen, width, height); ReleaseDC(nullptr, screen); if (m_sceneDc == nullptr || m_sceneBitmap == nullptr) { ReleaseSceneCache(); return false; } m_sceneOldBitmap = SelectObject(m_sceneDc, m_sceneBitmap); m_sceneWidth = width; m_sceneHeight = height; return true; } bool PlotView::CaptureSceneCache(gd::Graphics& g) { // 缓存区域严格限定为绘图区本身(m_area):工具栏/标签栏/面板由宿主每帧绘制, // 因为它们各自带有悬停态;绘图区的波形才是真正昂贵的部分(大文件约 80ms/帧)。 const int x = static_cast(std::floor(m_area.X)); const int y = static_cast(std::floor(m_area.Y)); const int right = static_cast(std::ceil(m_area.X + m_area.Width)); const int bottom = static_cast(std::ceil(m_area.Y + m_area.Height)); const int width = right - x; const int height = bottom - y; if (width <= 0 || height <= 0) { m_sceneValid = false; return false; } m_sceneX = x; m_sceneY = y; if (!EnsureSceneCache(width, height)) { m_sceneValid = false; return false; } HDC source = g.GetHDC(); if (source == nullptr) { m_sceneValid = false; return false; } const BOOL ok = BitBlt(m_sceneDc, 0, 0, width, height, source, m_sceneX, m_sceneY, SRCCOPY); g.ReleaseHDC(source); if (ok == FALSE) { m_sceneValid = false; return false; } m_sceneKey = MakeSceneCacheKey(); m_sceneValid = true; return true; } bool PlotView::BlitSceneCache(gd::Graphics& g) { if (!m_sceneValid || m_sceneDc == nullptr) { return false; } HDC target = g.GetHDC(); if (target == nullptr) { return false; } const BOOL ok = BitBlt(target, m_sceneX, m_sceneY, m_sceneWidth, m_sceneHeight, m_sceneDc, 0, 0, SRCCOPY); g.ReleaseHDC(target); return ok != FALSE; } void PlotView::InvalidateSceneCache() { m_sceneValid = false; } void PlotView::ReleaseSceneCache() { m_sceneValid = false; if (m_sceneDc != nullptr) { if (m_sceneOldBitmap != nullptr) { SelectObject(m_sceneDc, m_sceneOldBitmap); m_sceneOldBitmap = nullptr; } DeleteDC(m_sceneDc); m_sceneDc = nullptr; } if (m_sceneBitmap != nullptr) { DeleteObject(m_sceneBitmap); m_sceneBitmap = nullptr; } m_sceneWidth = 0; m_sceneHeight = 0; } void PlotView::PaintRubberBand(gd::Graphics& g) { if (!m_dragMoved || m_rubberRect.Width <= 0.0f || m_rubberRect.Height <= 0.0f) { return; } if (m_dragMode != PlotDragMode::RUBBER_SELECT) { return; } // 轴向未定时不绘制选区,避免刚按下就闪出一个奇怪的方框。 const bool timeMode = (m_rubberAxis == PlotRubberAxis::TIME); const bool valueMode = (m_rubberAxis == PlotRubberAxis::VALUE); if (!timeMode && !valueMode) { return; } // 两种方向的选区使用**同一种强调色**,保持视觉一致。 const std::uint32_t band = m_theme->accent; UiFillRect(g, m_rubberRect, WithAlpha(band, 0x28)); UiDrawRoundRect(g, m_rubberRect, 2.0f * m_scale, band, 1.4f); // 选区跨度提示(时间用秒/数值用幅度量纲),放在选区顶部。 std::wstring text; if (timeMode) { text = FormatValue(std::fabs(XToTime(m_rubberRect.X + m_rubberRect.Width) - XToTime(m_rubberRect.X))); } else if (m_dragLane >= 0 && m_dragLane < static_cast(m_lanes.size())) { const LaneVisual& lane = m_lanes[static_cast(m_dragLane)]; text = FormatValue(std::fabs(YToValue(m_rubberRect.Y + m_rubberRect.Height, lane) - YToValue(m_rubberRect.Y, lane))); } if (text.empty()) { return; } const float textWidth = UiTextWidth(g, text, m_fonts->monoSmall) + 12.0f * m_scale; const float textHeight = 18.0f * m_scale; float labelX = m_rubberRect.X + m_rubberRect.Width * 0.5f - textWidth * 0.5f; labelX = std::max(m_plotRect.X, std::min(labelX, m_plotRect.X + m_plotRect.Width - textWidth)); const gd::RectF labelRect(labelX, m_rubberRect.Y + 3.0f * m_scale, textWidth, textHeight); UiFillRoundRect(g, labelRect, 3.0f * m_scale, band); UiText(g, text, m_fonts->monoSmall, labelRect, m_theme->textOnAccent, TextAlign::CENTER); } void PlotView::PaintHint(gd::Graphics& g) { if (m_hintText.empty() || m_fonts == nullptr || m_fonts->ui == nullptr || m_hintShownAt == 0) { return; } const std::uint64_t elapsed = GetTickCount64() - m_hintShownAt; if (elapsed >= (kHintHoldMs + kHintFadeMs)) { return; } // 停留结束后在 kHintFadeMs 内线性淡出(颜色 alpha 按比例缩放)。 const double fade = (elapsed <= kHintHoldMs) ? 1.0 : (1.0 - static_cast(elapsed - kHintHoldMs) / static_cast(kHintFadeMs)); const unsigned int alpha = static_cast(std::max(0.0, std::min(1.0, fade)) * 255.0); // 提示条贴在绘图区底部居中;时间光标的时间读数也在底部, // 因此光标启用时把提示条上移一行,避免两者互相遮挡。 const float reserved = (m_tab != nullptr && Tab().CursorEnabled()) ? 26.0f * m_scale : 0.0f; const float width = UiTextWidth(g, m_hintText, m_fonts->ui) + 30.0f * m_scale; const float height = 30.0f * m_scale; float left = m_plotRect.X + (m_plotRect.Width - width) * 0.5f; left = std::max(m_plotRect.X + 2.0f * m_scale, std::min(left, m_plotRect.X + m_plotRect.Width - width - 2.0f * m_scale)); const gd::RectF pill(left, m_plotRect.Y + m_plotRect.Height - height - 4.0f * m_scale - reserved, width, height); UiFillRoundRect(g, pill, 4.0f * m_scale, ScaleAlpha(m_theme->readoutBg, alpha)); UiDrawRoundRect(g, pill, 4.0f * m_scale, ScaleAlpha(m_theme->borderStrong, alpha), 1.2f); // 文字走 GDI(不支持 alpha 通道),因此把文字颜色向胶囊底色**混色**来实现淡出。 const std::uint32_t textColor = MixColor(m_theme->textStrong, m_theme->readoutBg, 1.0f - static_cast(fade)); UiText(g, m_hintText, m_fonts->ui, pill, textColor, TextAlign::CENTER); } void PlotView::PaintLane(gd::Graphics& g, int laneIndex, const LaneVisual& lane, const std::vector& timeTicks) { // 网格内容必须裁剪在绘图区之内:纵向滚动时网格会部分移出可视范围, // 若不与 m_plotRect 求交,波形就会溢出到工具栏与时间轴区域。 const float clipTop = std::max(lane.rect.Y, m_plotRect.Y); const float clipBottom = std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height); if (clipBottom <= clipTop) { return; } const int saveState = g.Save(); g.SetClip(gd::RectF(m_area.X, clipTop, m_area.Width, clipBottom - clipTop)); // 幅值网格线(先画线,标尺稍后作为覆盖层绘制,避免被波形遮挡)。 // 刻度密度随网格高度自适应:所有网格高度相同 → 各网格的刻度数量一致, // 不会出现“有的网格只有 1 条标签、有的有 3 条”的不一致观感。 int yTickTarget = static_cast(std::lround(lane.rect.Height / (kYTickSpacing * m_scale))); yTickTarget = std::max(kYTickTargetMin, std::min(yTickTarget, kYTickTargetMax)); const double yStep = NiceStep(lane.yMax - lane.yMin, yTickTarget); std::vector yTicks; BuildTicks(lane.yMin, lane.yMax, yStep, yTicks); // 五等分估读线:在相邻主刻度之间再细分,便于目视读取中间值。 const double yMinorStep = yStep / static_cast(kMinorDivisions); std::vector yMinorTicks; BuildTicks(lane.yMin, lane.yMax, yMinorStep, yMinorTicks); for (const double tick : yMinorTicks) { // 与主刻度重合的位置跳过(主刻度会以更重的线条绘制)。 const double majorIndex = tick / yStep; if (std::fabs(majorIndex - std::floor(majorIndex + 0.5)) < 1.0e-6) { continue; } const float y = ValueToY(tick, lane); if (y < lane.rect.Y - 1.0f || y > lane.rect.Y + lane.rect.Height + 1.0f) { continue; } UiDrawLine(g, lane.rect.X, y, lane.rect.X + lane.rect.Width, y, WithAlpha(m_theme->gridWeak, kMinorLineAlpha), 1.0f); } for (const double tick : yTicks) { const float y = ValueToY(tick, lane); if (y < lane.rect.Y - 1.0f || y > lane.rect.Y + lane.rect.Height + 1.0f) { continue; } const bool zeroLine = (tick == 0.0); UiDrawLine(g, lane.rect.X, y, lane.rect.X + lane.rect.Width, y, zeroLine ? m_theme->gridStrong : m_theme->gridWeak, 1.0f); } // 时间网格线(同样补充五等分估读线)。 const double timeSpan = Tab().ViewMax() - Tab().ViewMin(); const double timeMajorStep = NiceStep((timeSpan > 0.0) ? timeSpan : 1.0, kTimeTickTarget); const double timeMinorStep = timeMajorStep / static_cast(kMinorDivisions); std::vector timeMinorTicks; BuildTicks(Tab().ViewMin(), Tab().ViewMax(), timeMinorStep, timeMinorTicks); for (const double tick : timeMinorTicks) { const double majorIndex = tick / timeMajorStep; if (std::fabs(majorIndex - std::floor(majorIndex + 0.5)) < 1.0e-6) { continue; } const float x = TimeToX(tick); if (x < lane.rect.X || x > lane.rect.X + lane.rect.Width) { continue; } UiDrawLine(g, x, lane.rect.Y, x, lane.rect.Y + lane.rect.Height, WithAlpha(m_theme->gridWeak, kMinorLineAlpha), 1.0f); } for (const double tick : timeTicks) { const float x = TimeToX(tick); if (x < lane.rect.X || x > lane.rect.X + lane.rect.Width) { continue; } UiDrawLine(g, x, lane.rect.Y, x, lane.rect.Y + lane.rect.Height, m_theme->gridWeak, 1.0f); } // 波形绘制。 const std::vector& laneSignals = m_tab->Lanes()[static_cast(laneIndex)].signals; if (laneSignals.empty()) { const gd::RectF visibleRect(lane.rect.X, clipTop, lane.rect.Width, clipBottom - clipTop); UiText(g, L"(空网格)", m_fonts->uiSmall, visibleRect, m_theme->textMuted, TextAlign::CENTER); } else { const int waveSave = g.Save(); g.SetClip(lane.rect, gd::CombineModeIntersect); // 只有拖拽中的信号光晕可以进入静态场景缓存(悬停光晕见 PaintHoverHighlight)。 const SignalRef haloRef = SceneHaloRef(); for (const SignalRef& ref : laneSignals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible) { continue; } const DrawMode mode = resolved.style->mode; // 顶点由“几何键”缓存提供:同一视图下重复绘制不再重算全量采样。 const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, lane); if (cached == nullptr) { continue; } const std::vector& points = cached->points; const std::vector& breaks = cached->breaks; const bool drawLine = (mode != DrawMode::SCATTER); const bool drawScatter = (mode == DrawMode::SCATTER || mode == DrawMode::LINE_SCATTER); // 悬停命中的信号先画一层浅色光晕,作为“可拖拽”的视觉提示。 // 这里只处理拖拽中的信号(其光晕在拖拽期间不变,可以进入静态场景缓存); // 悬停光晕随鼠标变化,改由 PaintHoverHighlight 在缓存贴图之后绘制。 const bool highlighted = (ref == haloRef); if (highlighted) { PaintSignalHalo(g, ref, resolved, points, breaks); } 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(stop - start)); } } if (drawScatter && !points.empty()) { // 散点半径随线宽线性增长(达到上限前不过早触顶),使“线宽”直观控制数据点大小。 const float radius = static_cast( ClampDouble(static_cast(resolved.style->lineWidth) * kScatterRadiusScale, kMinScatterRadius, kMaxScatterRadius) * m_scale); const std::size_t scatterStride = std::max(1, points.size() / kMaxScatterPoints); // 所有散点合并到一条 GraphicsPath 后只调用一次 FillPath: // 逐点 FillEllipse 每次都要完整走一遍 GDI+ 的状态检查与光栅化入口, // 实测 2500 点需要约 51ms;合并后约 1/9(实测基准 63ms → 7ms)。 // **必须显式设为缠绕填充规则**:GraphicsPath 默认是交替(奇偶)规则, // 两个重叠的圆在交集处会互相抵消形成空洞,表现为"数据点重叠处出现异常白边"。 gd::GraphicsPath scatterPath; scatterPath.SetFillMode(gd::FillModeWinding); for (std::size_t k = 0; k < points.size(); k += scatterStride) { scatterPath.AddEllipse(points[k].X - radius, points[k].Y - radius, radius * 2.0f, radius * 2.0f); } gd::SolidBrush brush(ToGdiColor(StyleColor(*resolved.style))); if (scatterPath.GetPointCount() > 0) { g.FillPath(&brush, &scatterPath); } } } // g.Restore 也会还原绘制状态,此处直接恢复图形状态即可。 g.Restore(waveSave); } // 网格分界线:网格之间不再留间隔,改用较重的横线明确分隔相邻网格。 const float dividerY = lane.rect.Y; if (dividerY >= m_plotRect.Y - 1.0f && dividerY <= m_plotRect.Y + m_plotRect.Height) { UiDrawLine(g, lane.rect.X, dividerY, lane.rect.X + lane.rect.Width, dividerY, (laneIndex == 0) ? m_theme->axisLine : m_theme->borderStrong, (laneIndex == 0) ? 1.6f : kLaneDividerWidth); } // 内侧纵轴标尺作为覆盖层绘制:每个刻度数值带半透明底,保证在波形之上仍清晰可读, // 同时只在数值背后遮挡波形,不牺牲波形本身的可见范围。 { const bool showLabels = (lane.rect.Height >= 34.0f * m_scale); float lastLabelY = -1.0e9f; // 网格较矮时同步收紧标签行距,让密度随可用高度提升而不是整片被过滤掉。 const float labelMinGap = std::min(kYLabelMinGap * m_scale, lane.rect.Height / static_cast(yTickTarget + 1)); for (const double tick : yTicks) { if (!showLabels) { break; } const float y = ValueToY(tick, lane); if (y < lane.rect.Y - 1.0f || y > lane.rect.Y + lane.rect.Height + 1.0f) { continue; } if (std::fabs(y - lastLabelY) < labelMinGap) { continue; } lastLabelY = y; const std::wstring raw = FormatTick(tick, yStep); const float textWidth = UiMeasureTextWidth(m_fonts->monoSmall, raw); const float pillHeight = 17.0f * m_scale; const gd::RectF pill(lane.rect.X + kLaneLabelPad * m_scale, y - pillHeight * 0.5f, std::min(textWidth + 8.0f * m_scale, lane.rect.Width - 2.0f * kLaneLabelPad * m_scale), pillHeight); if (pill.Width <= 0.0f) { continue; } // 与图例重叠时不再跳过:标尺值先绘制,随后绘制的图例会盖在其上层 // (顶层始终为图例),因此两者都不丢失。 // 但仍要求数值完整落在可见区内——否则会在网格分界处露出被截断的半行数字, // 且 GDI 文本不受 GDI+ 裁剪约束会越过绘图区(改用 UiTextAtClipped 兜底)。 if (!IsFullyVisible(pill, clipTop, clipBottom)) { continue; } UiFillRoundRect(g, pill, 3.0f * m_scale, WithAlpha(m_theme->cardBg, 0xF0)); const std::wstring text = UiEllipsize(g, raw, m_fonts->monoSmall, pill.Width - 6.0f * m_scale); UiText(g, text, m_fonts->monoSmall, gd::RectF(pill.X + 4.0f * m_scale, pill.Y, pill.Width - 8.0f * m_scale, pill.Height), (tick == 0.0) ? m_theme->text : m_theme->textMuted, TextAlign::LEFT); } } if (lane.rect.Height >= 24.0f * m_scale) { PaintLaneLegend(g, laneIndex, lane, clipTop, clipBottom); } g.Restore(saveState); } SignalRef PlotView::LegendAtPoint(int x, int y) const { const float fx = static_cast(x); const float fy = static_cast(y); for (const LegendHit& hit : m_legendHits) { if (UiPointInRect(hit.rect, fx, fy)) { return hit.ref; } } return SignalRef(); } void PlotView::PaintLaneLegend(gd::Graphics& g, int laneIndex, const LaneVisual& lane, float visibleTop, float visibleBottom) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return; } // 图例固定在网格左上角:网格被滚动出可视区时整体跳过,避免文字溢出到工具栏。 const gd::RectF legendBand(lane.rect.X + kLegendPad * m_scale, lane.rect.Y + kLegendPad * m_scale, lane.rect.Width, kLegendHeight * m_scale); if (!IsFullyVisible(legendBand, visibleTop, visibleBottom)) { return; } const std::vector& laneSignals = m_tab->Lanes()[static_cast(laneIndex)].signals; const bool multiFile = LaneSpansMultipleFiles(laneIndex); const float top = legendBand.Y; const float height = kLegendHeight * m_scale; float cursorX = legendBand.X; const float maxX = lane.rect.X + lane.rect.Width - kLanePadding * m_scale; bool anyVisible = false; int visibleCount = 0; int drawnCount = 0; // 先测量图例总宽度,再用半透明底板掠开底下的网格线,提升可读性。 float legendWidth = 0.0f; for (const SignalRef& ref : laneSignals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible) { continue; } std::wstring label = BuildLegendText(*resolved.data); if (multiFile && resolved.file != nullptr) { label += L" · " + resolved.file->Title(); } const float itemWidth = kLegendSwatchSize * m_scale + 5.0f * m_scale + std::min(UiTextWidth(g, label, m_fonts->uiSmall), kLegendMaxTextWidth * m_scale) + kLegendItemGap * m_scale; if (cursorX + legendWidth + itemWidth > maxX) { break; } legendWidth += itemWidth; } if (legendWidth > 0.0f) { UiFillRoundRect(g, gd::RectF(cursorX - 4.0f * m_scale, top - 2.0f * m_scale, legendWidth + 6.0f * m_scale, height + 4.0f * m_scale), 3.0f * m_scale, WithAlpha(m_theme->cardBg, 0xE8)); } // 记录本次绘制命中的条目矩形,供点击选中与双击改名使用。 for (auto iterator = m_legendHits.begin(); iterator != m_legendHits.end();) { if (m_tab->LaneOfSignal(iterator->ref) == laneIndex) { iterator = m_legendHits.erase(iterator); } else { ++iterator; } } // 绘制图例条目(与测量阶段保持一致的裁剪条件)。 float drawnWidth = 0.0f; for (const SignalRef& ref : laneSignals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible) { continue; } anyVisible = true; ++visibleCount; std::wstring label = BuildLegendText(*resolved.data); if (multiFile && resolved.file != nullptr) { label += L" · " + resolved.file->Title(); } // 文本宽度按上限夹取,超出部分用省略号(与测量阶段保持一致)。 const float maxTextWidth = kLegendMaxTextWidth * m_scale; const float fullTextWidth = UiTextWidth(g, label, m_fonts->uiSmall); const float textWidth = std::min(fullTextWidth, maxTextWidth); const std::wstring drawLabel = (fullTextWidth > maxTextWidth) ? UiEllipsize(g, label, m_fonts->uiSmall, maxTextWidth) : label; const float itemWidth = kLegendSwatchSize * m_scale + 5.0f * m_scale + textWidth + kLegendItemGap * m_scale; if (drawnWidth + itemWidth > legendWidth + 0.5f) { break; } drawnWidth += itemWidth; const float itemLeft = cursorX; const float swatchSize = kLegendSwatchSize * m_scale; // 图例条目与被选中的信号保持一致的高亮反馈(与左侧列表选中态呼应)。 const bool selected = (SelectedRef() == ref); const bool hovered = (ref == m_hoverSignal) || (ref == m_dragSignal); if (selected || hovered) { UiFillRoundRect(g, gd::RectF(itemLeft - 3.0f * m_scale, top - 1.0f * m_scale, itemWidth + 2.0f * m_scale, height + 2.0f * m_scale), 3.0f * m_scale, selected ? m_theme->selection : m_theme->hover); } UiDrawSwatch(g, gd::RectF(cursorX, top + (height - swatchSize) * 0.5f, swatchSize, swatchSize), resolved.style->color, resolved.style->opacity, 1.5f * m_scale, m_scale); // 改名编辑态:在原位置绘制输入框。 LegendHit hit; hit.ref = ref; hit.rect = gd::RectF(itemLeft - 3.0f * m_scale, top - 2.0f * m_scale, itemWidth + 2.0f * m_scale, height + 4.0f * m_scale); m_legendHits.push_back(hit); if (m_editTarget == PlotEditTarget::SIGNAL_NAME && m_editSignal == ref) { m_editFieldRect = hit.rect; UiDrawEditField(g, hit.rect, m_edit, m_fonts->uiSmall, m_scale); cursorX += itemWidth; ++drawnCount; continue; } cursorX += swatchSize + 5.0f * m_scale; UiText(g, drawLabel, m_fonts->uiSmall, gd::RectF(cursorX, top, std::max(8.0f, textWidth + 2.0f), height), selected ? m_theme->textStrong : m_theme->text, TextAlign::LEFT); cursorX += textWidth + kLegendItemGap * m_scale; ++drawnCount; } if (!anyVisible) { UiText(g, L"(无可见信号)", m_fonts->uiSmall, gd::RectF(legendBand.X, top, lane.rect.Width, height), m_theme->textMuted, TextAlign::LEFT); } else if (drawnCount < visibleCount) { // 图例空间不足时提示剩余信号数量,避免误认为信号丢失。 const std::wstring moreText = L"+" + std::to_wstring(visibleCount - drawnCount); if (cursorX + 20.0f * m_scale <= maxX) { UiText(g, moreText, m_fonts->uiSmall, gd::RectF(cursorX, top, 24.0f * m_scale, height), m_theme->textMuted, TextAlign::LEFT); } } } bool PlotView::IsFullyVisible(const gd::RectF& rect, float visibleTop, float visibleBottom) { return (rect.Y >= visibleTop - 0.5f) && (rect.Y + rect.Height <= visibleBottom + 0.5f); } void PlotView::PaintTimeAxis(gd::Graphics& g, const std::vector& timeTicks, double timeStep) { const float axisY = m_plotRect.Y + m_plotRect.Height; UiDrawLine(g, m_plotRect.X, axisY, m_plotRect.X + m_plotRect.Width, axisY, m_theme->axisLine, 1.0f); // 时间轴列名(取自首个可见信号所属文件),贴左侧绘制。 std::wstring axisName = L"时间"; if (m_dataSet != nullptr && m_tab != nullptr) { for (std::size_t fileIndex = 0; fileIndex < m_dataSet->FileCount(); ++fileIndex) { if (!m_tab->FileHasVisible(*m_dataSet, static_cast(fileIndex))) { continue; } const DataFile* file = m_dataSet->FileAt(static_cast(fileIndex)); if (file != nullptr && !file->timeColumnName.empty()) { axisName = file->timeColumnName; // CSV 中该列通常直接叫 "time",这里补上时间单位秒便于阅读; // 若列名自带单位描述(以右括号结尾)则不重复追加。 if (axisName.back() != L')') { axisName += L" (s)"; } } break; } } const float nameWidth = std::min(UiMeasureTextWidth(m_fonts->monoSmall, axisName) + 8.0f * m_scale, m_plotRect.Width * 0.4f); const gd::RectF nameRect(m_plotRect.X + 2.0f * m_scale, axisY + 3.0f * m_scale, nameWidth, std::max(0.0f, m_timeAxisRect.Height - 5.0f * m_scale)); UiText(g, UiEllipsize(g, axisName, m_fonts->monoSmall, nameWidth), m_fonts->monoSmall, nameRect, m_theme->textMuted, TextAlign::LEFT); const float labelWidth = 96.0f * m_scale; for (const double tick : timeTicks) { const float x = TimeToX(tick); if (x < m_plotRect.X - 1.0f || x > m_plotRect.X + m_plotRect.Width + 1.0f) { continue; } UiDrawLine(g, x, axisY, x, axisY + 4.0f * m_scale, m_theme->axisLine, 1.0f); // 刻度标签夹在绘图区横向范围内,并与左侧列名互不重叠。 float labelX = x - labelWidth * 0.5f; labelX = std::max(m_plotRect.X, std::min(labelX, m_plotRect.X + m_plotRect.Width - labelWidth)); const gd::RectF labelRect(labelX, axisY + 4.0f * m_scale, labelWidth, std::max(0.0f, m_timeAxisRect.Height - 6.0f * m_scale)); if (labelRect.X < nameRect.X + nameRect.Width) { continue; } UiText(g, FormatTick(tick, timeStep), m_fonts->monoSmall, labelRect, m_theme->textMuted, TextAlign::CENTER); } } void PlotView::DrawLabelOutline(gd::Graphics& g, const gd::RectF& rect, float radius, bool textDark) { // 用与文字同色的半透明细描边:深色文字 → 浅色描边,浅色文字 → 深色描边。 // 这样胶囊压在同类色背景或信号曲线上时仍有清晰的边界。 const std::uint32_t outline = textDark ? 0x99FDF6E3u : 0x99073642u; UiDrawRoundRect(g, rect, radius, outline, 1.0f); } gd::RectF PlotView::CursorSpanRect(bool& insideAxis) const { insideAxis = false; if (m_tab == nullptr || Tab().CursorMode() != PlotCursorMode::DOUBLE || m_fonts == nullptr || m_fonts->monoSmall == nullptr) { return gd::RectF(); } const std::wstring text = FormatDuration(CursorSpan()); const float width = std::max( 72.0f * m_scale, UiMeasureTextWidth(m_fonts->monoSmall, text) + 18.0f * m_scale); const float height = 20.0f * m_scale; // 水平位置取两条光标时间读数的中点(与它们同一行,位于时间轴上方)。 // 注意用"显示横坐标":光标滞留在可视区外时会被钉在网格边缘, // 此时二者在屏幕上必然很近,差值标签应改放到主时间轴内部。 const gd::RectF first = CursorLabelRect(1); const gd::RectF second = CursorLabelRect(2); float center = (CursorDisplayX(1) + CursorDisplayX(2)) * 0.5f; if (first.Width > 0.0f && second.Width > 0.0f) { center = ((first.X + first.Width * 0.5f) + (second.X + second.Width * 0.5f)) * 0.5f; } float left = center - width * 0.5f; left = std::max(m_plotRect.X + 2.0f * m_scale, std::min(left, m_plotRect.X + m_plotRect.Width - width - 2.0f * m_scale)); // 两条**时间读数标签**之间的净空不足时,同一行放不下第三个标签 // → 改放到主时间轴内部(时间轴上方已无空间)。 const gd::RectF& leftRect = (first.X <= second.X) ? first : second; const gd::RectF& rightRect = (first.X <= second.X) ? second : first; const float freeStart = leftRect.X + leftRect.Width; const float freeEnd = rightRect.X; const bool fits = (freeStart <= freeEnd) && ((freeEnd - freeStart) >= (width + 2.0f * m_scale)); if (!fits) { insideAxis = true; const float top = m_timeAxisRect.Y + 2.0f * m_scale; const float axisHeight = std::max(14.0f * m_scale, m_timeAxisRect.Height - 4.0f * m_scale); return gd::RectF(left, top, width, std::min(height, axisHeight)); } return gd::RectF(left, m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale, width, height); } void PlotView::PaintCursor(gd::Graphics& g, const std::vector& lanes) { if (!Tab().CursorEnabled() || m_tab == nullptr) { return; } const bool doubleMode = (Tab().CursorMode() == PlotCursorMode::DOUBLE); const int cursorCount = doubleMode ? 2 : 1; // 重叠时把"最近操作过的那条光标"的内容画在上层,因此先画另一条。 const int order[2] = { (m_activeCursor == 2) ? 1 : 2, (m_activeCursor == 2) ? 2 : 1 }; // ---- 光标竖线 + 顶部把手 ---- for (int pass = 0; pass < cursorCount; ++pass) { const int which = doubleMode ? order[pass] : 1; const double time = (which == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime(); const float x = TimeToX(time); if (x < m_plotRect.X - 2.0f || x > m_plotRect.X + m_plotRect.Width + 2.0f) { // 光标滞留在可视区外:不画竖线(其取值/时间读数会在下面贴边显示)。 continue; } const bool hot = (m_hoverCursor == which); const std::uint32_t color = CursorColor(which); const float lineTop = m_plotRect.Y; const float lineBottom = m_timeAxisRect.Y + m_timeAxisRect.Height; UiDrawLine(g, x, lineTop, x, lineBottom, color, (hot ? 2.0f : 1.4f) * m_scale); // 顶部三角把手(两条光标样式一致,靠颜色区分)。 const float arm = 5.0f * m_scale; gd::PointF points[3]; points[0] = gd::PointF(x - arm, lineTop); points[1] = gd::PointF(x + arm, lineTop); points[2] = gd::PointF(x, lineTop + arm * 1.4f); gd::SolidBrush handle(ToGdiColor(color)); g.FillPolygon(&handle, points, 3); } // ---- 数据点取值标注 ---- for (int pass = 0; pass < cursorCount; ++pass) { const int which = doubleMode ? order[pass] : 1; const double time = (which == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime(); const double otherTime = (which == 2) ? Tab().CursorTime() : Tab().CursorTimeAlt(); const bool showDelta = doubleMode && (which == order[1]); // 光标在可视区外时,把标注"钉"在对应的网格边上,保证取值仍然可见。 const float pinX = CursorDisplayX(which); const float rawX = TimeToX(time); const bool pinned = (std::fabs(rawX - pinX) > 0.5f); for (std::size_t laneIndex = 0; laneIndex < lanes.size(); ++laneIndex) { const LaneVisual& lane = lanes[laneIndex]; const float clipTop = std::max(lane.rect.Y, m_plotRect.Y); const float clipBottom = std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height); if (clipBottom <= clipTop) { continue; } for (const SignalRef& ref : m_tab->Lanes()[laneIndex].signals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr || resolved.file == nullptr || !resolved.visible) { continue; } const bool interpolate = (resolved.style->mode != DrawMode::SCATTER); double value = 0.0; if (!SampleFileAt(*resolved.file, *resolved.data, time, interpolate, value)) { continue; } const float y = ValueToY(value, lane); if (y < clipTop - 2.0f || y > clipBottom + 2.0f) { continue; } const std::uint32_t signalColor = StyleColor(*resolved.style); // 数据点:底色环 + 信号色实心点(贴边时改画成三角指示方向)。 const float radius = 3.4f * m_scale; gd::SolidBrush ring(ToGdiColor(m_theme->cardBg)); gd::SolidBrush fill(ToGdiColor(signalColor)); if (pinned) { const float arm = 5.0f * m_scale; const bool offLeft = (rawX < pinX); const float baseX = offLeft ? (pinX - arm) : (pinX + arm); gd::PointF tri[3]; tri[0] = gd::PointF(baseX, y - arm); tri[1] = gd::PointF(baseX, y + arm); tri[2] = gd::PointF(pinX, y); g.FillPolygon(&ring, tri, 3); gd::PointF inner[3]; inner[0] = gd::PointF(baseX + (offLeft ? 1.4f : -1.4f) * m_scale, y - arm * 0.72f); inner[1] = gd::PointF(baseX + (offLeft ? 1.4f : -1.4f) * m_scale, y + arm * 0.72f); inner[2] = gd::PointF(pinX - (offLeft ? -1.4f : 1.4f) * m_scale, y); g.FillPolygon(&fill, inner, 3); } else { g.FillEllipse(&ring, pinX - 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, pinX - radius, y - radius, radius * 2.0f, radius * 2.0f); } // 取值标注:底色用信号色、文字用反色,并加一层细描边提升对比度。 std::wstring text = FormatValue(value); if (showDelta) { double otherValue = 0.0; if (SampleFileAt(*resolved.file, *resolved.data, otherTime, interpolate, otherValue)) { const double delta = value - otherValue; // 增量为右侧光标减去左侧光标;正值用 ±,负值直接用负号, // 避免出现 “±-0.001” 这种双符号写法。 text += L" , "; text += (delta < 0.0) ? L"-" : L"±"; text += FormatValue(std::fabs(delta)); } } const float textWidth = UiTextWidth(g, text, m_fonts->monoSmall) + 10.0f * m_scale; const float textHeight = 17.0f * m_scale; float labelLeft = pinX + radius + 4.0f * m_scale; if (labelLeft + textWidth > m_plotRect.X + m_plotRect.Width - 2.0f * m_scale) { labelLeft = pinX - radius - 4.0f * m_scale - textWidth; } labelLeft = std::max( m_plotRect.X + 2.0f * m_scale, std::min(labelLeft, m_plotRect.X + m_plotRect.Width - textWidth - 2.0f * m_scale)); float labelTop = y - textHeight * 0.5f; labelTop = std::max(clipTop + 1.0f * m_scale, std::min(labelTop, clipBottom - textHeight - 1.0f * m_scale)); const gd::RectF labelRect(labelLeft, labelTop, textWidth, textHeight); UiFillRoundRect(g, labelRect, 3.0f * m_scale, WithAlpha(signalColor, 0xF0)); const bool dark = IsDarkColor(signalColor); DrawLabelOutline(g, labelRect, 3.0f * m_scale, dark); UiText(g, text, m_fonts->monoSmall, labelRect, dark ? 0xFFFDF6E3u : 0xFF073642u, TextAlign::CENTER); } } } // ---- 底部:时间读数 + 双光标差值 ---- if (m_editTarget == PlotEditTarget::CURSOR_TIME || m_editTarget == PlotEditTarget::CURSOR_SPAN) { PaintEditField(g); return; } for (int pass = 0; pass < cursorCount; ++pass) { const int which = doubleMode ? order[pass] : 1; const gd::RectF labelRect = CursorLabelRect(which); if (labelRect.Width <= 0.0f) { continue; } const bool highlighted = (m_cursorHover && m_hoverCursor == which); const std::uint32_t color = CursorColor(which); UiFillRoundRect(g, labelRect, 3.0f * m_scale, highlighted ? color : WithAlpha(color, 0xE0)); const bool dark = IsDarkColor(color); DrawLabelOutline(g, labelRect, 3.0f * m_scale, dark); UiText(g, FormatValue(which == 2 ? Tab().CursorTimeAlt() : Tab().CursorTime()), m_fonts->monoSmall, labelRect, dark ? 0xFFFDF6E3u : 0xFF073642u, TextAlign::CENTER); } // 双光标差值:显示在两条时间读数中间(时间轴上方;过近时落到主时间轴上)。 if (doubleMode) { bool insideAxis = false; const gd::RectF spanRect = CursorSpanRect(insideAxis); m_cursorSpanRect = spanRect; if (spanRect.Width > 0.0f && spanRect.Height > 0.0f) { const std::uint32_t fill = m_theme->readoutBg; UiFillRoundRect(g, spanRect, 3.0f * m_scale, WithAlpha(fill, 0xF2)); UiDrawRoundRect(g, spanRect, 3.0f * m_scale, m_theme->borderStrong, 1.0f); UiText(g, FormatDuration(CursorSpan()), m_fonts->monoSmall, spanRect, m_theme->textStrong, TextAlign::CENTER); } } else { m_cursorSpanRect = gd::RectF(); } } void PlotView::PaintEditField(gd::Graphics& g) { const gd::RectF fieldRect = EditFieldRect(); if (fieldRect.Width <= 0.0f || fieldRect.Height <= 0.0f) { return; } // 编辑态:强调色外框 + 选区高亮 + 插入符,首次键入即整体替换(与常规输入框一致)。 UiDrawEditField(g, fieldRect, m_edit, m_fonts->monoSmall, m_scale); } void PlotView::PaintSignalInteraction(gd::Graphics& g) { if (m_tab == nullptr || m_dataSet == nullptr || m_lanes.empty()) { return; } // 拖拽中:虚影跟随鼠标纵向位移,同时在目标网格内高亮落点。 if (m_dragMode == PlotDragMode::MOVE_SIGNAL && m_dragSignal.IsValid()) { const ResolvedSignal resolved = ResolveSignal(m_dragSignal); const int sourceLane = m_tab->LaneOfSignal(m_dragSignal); if (resolved.data == nullptr || resolved.style == nullptr || sourceLane < 0 || sourceLane >= static_cast(m_lanes.size())) { return; } // 虚影:以源网格的幅值映射构建波形,再按鼠标位移整体平移。 // 第一帧位移为 0,因此虚影必然精确落在鼠标下方(曲线形态与抓住时完全一致); // 之后纵向与横向均自由跟随,形成“拖着曲线走”的观感。 const LaneVisual& sourceVisual = m_lanes[static_cast(sourceLane)]; const float offsetX = static_cast(m_dragMouseX - m_dragStartX); const float offsetY = static_cast(m_dragMouseY - m_dragStartY); { const int saveState = g.Save(); g.SetClip(gd::RectF(m_plotRect.X, m_plotRect.Y, m_plotRect.Width, m_plotRect.Height), gd::CombineModeIntersect); // 虚影的几何与源网格里的信号完全一致,直接复用折线顶点缓存, // 再用平移变换让整体跟随鼠标:避免每帧重新构建并逐点搬运上万顶点。 const PolylineCacheEntry* ghostSource = AcquirePolyline(m_dragSignal, resolved, sourceVisual); if (ghostSource == nullptr) { g.Restore(saveState); return; } const std::vector& points = ghostSource->points; const std::vector& breaks = ghostSource->breaks; g.TranslateTransform(offsetX, offsetY); const DrawMode mode = resolved.style->mode; const bool drawLine = (mode != DrawMode::SCATTER); const bool drawScatter = (mode == DrawMode::SCATTER || mode == DrawMode::LINE_SCATTER); // 虚影保持原信号的颜色与样式(不再是虚线),仅以轻微透明区分“尚未落地”。 const std::uint32_t ghostColor = WithAlpha(resolved.style->color, static_cast( std::max(0.35, static_cast(resolved.style->opacity)) * 255.0 + 0.5)); if (drawLine && !breaks.empty()) { gd::Pen ghost(ToGdiColor(ghostColor), std::max(1.0f, resolved.style->lineWidth) * m_scale); ghost.SetLineJoin(gd::LineJoinRound); ghost.SetStartCap(gd::LineCapRound); ghost.SetEndCap(gd::LineCapRound); for (std::size_t k = 0; k < breaks.size(); ++k) { const std::size_t start = breaks[k]; const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size(); if (stop <= start + 1) { continue; } g.DrawLines(&ghost, points.data() + start, static_cast(stop - start)); } } if (drawScatter && !points.empty()) { const float radius = static_cast( ClampDouble(static_cast(resolved.style->lineWidth) * kScatterRadiusScale, kMinScatterRadius, kMaxScatterRadius) * m_scale); const std::size_t scatterStride = std::max(1, points.size() / kMaxScatterPoints); // 与主绘制一致:合并成一条路径一次填充,并显式使用缠绕规则避免重叠处出现空洞。 gd::GraphicsPath ghostPath; ghostPath.SetFillMode(gd::FillModeWinding); for (std::size_t k = 0; k < points.size(); k += scatterStride) { ghostPath.AddEllipse(points[k].X - radius, points[k].Y - radius, radius * 2.0f, radius * 2.0f); } gd::SolidBrush brush(ToGdiColor(ghostColor)); if (ghostPath.GetPointCount() > 0) { g.FillPath(&brush, &ghostPath); } } g.Restore(saveState); } // 落点提示:高亮鼠标所在网格并给出文字说明。 const int target = (m_dragTargetLane >= 0) ? m_dragTargetLane : sourceLane; if (target >= 0 && target < static_cast(m_lanes.size())) { const gd::RectF& targetRect = m_lanes[static_cast(target)].rect; const float visibleTop = std::max(targetRect.Y, m_plotRect.Y); const float visibleBottom = std::min(targetRect.Y + targetRect.Height, m_plotRect.Y + m_plotRect.Height); if (visibleBottom > visibleTop) { gd::Pen frame(ToGdiColor(m_theme->accent), 2.0f); g.DrawRectangle(&frame, targetRect.X, visibleTop, targetRect.Width, visibleBottom - visibleTop); const std::wstring hint = (target == sourceLane) ? std::wstring(L"松开左键放回本网格") : (L"移动到网格 " + std::to_wstring(target + 1) + L":松开左键确认"); const float textWidth = UiTextWidth(g, hint, m_fonts->uiSmall) + 16.0f * m_scale; const float height = 20.0f * m_scale; float left = targetRect.X + (targetRect.Width - textWidth) * 0.5f; left = std::max(m_plotRect.X, std::min(left, m_plotRect.X + m_plotRect.Width - textWidth)); const gd::RectF pill(left, visibleTop + 4.0f * m_scale, textWidth, height); UiFillRoundRect(g, pill, 3.0f * m_scale, m_theme->accent); UiText(g, hint, m_fonts->uiSmall, pill, m_theme->textOnAccent, TextAlign::CENTER); } } return; } // 悬停提示:光标贴近曲线时给出可拖拽说明(与时间光标的提示方式一致)。 if (!m_hoverSignal.IsValid()) { return; } const int laneIndex = m_tab->LaneOfSignal(m_hoverSignal); if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) { return; } const ResolvedSignal resolved = ResolveSignal(m_hoverSignal); if (resolved.data == nullptr) { return; } const LaneVisual& lane = m_lanes[static_cast(laneIndex)]; const std::wstring text = resolved.data->Title() + L" · 按住拖拽可移入其它网格"; const float textWidth = UiTextWidth(g, text, m_fonts->uiSmall) + 16.0f * m_scale; const float height = 20.0f * m_scale; float left = m_mouseX + 12.0f * m_scale; if (left + textWidth > m_plotRect.X + m_plotRect.Width) { left = m_plotRect.X + m_plotRect.Width - textWidth; } left = std::max(m_plotRect.X, left); float top = m_mouseY + 14.0f * m_scale; top = std::min(top, m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale); top = std::max(top, lane.rect.Y + 2.0f * m_scale); const gd::RectF pill(left, top, textWidth, height); UiFillRoundRect(g, pill, 3.0f * m_scale, WithAlpha(m_theme->readoutBg, 0xF2)); UiDrawRoundRect(g, pill, 3.0f * m_scale, m_theme->accent, 1.0f); UiText(g, text, m_fonts->uiSmall, pill, m_theme->textStrong, TextAlign::CENTER); } void PlotView::PaintScrollBar(gd::Graphics& g) { if (!m_scrollBarVisible || m_laneContentHeight <= m_plotRect.Height) { return; } UiFillRoundRect(g, m_scrollBarRect, kScrollBarThickness * m_scale * 0.5f, m_theme->gridWeak); const float ratio = std::min(1.0f, m_plotRect.Height / m_laneContentHeight); const float thumbHeight = std::max(24.0f * m_scale, m_scrollBarRect.Height * ratio); const float maxScroll = m_laneContentHeight - m_plotRect.Height; const float progress = (maxScroll > 0.0f) ? (Tab().LaneScroll() / maxScroll) : 0.0f; const float thumbY = m_scrollBarRect.Y + progress * (m_scrollBarRect.Height - thumbHeight); const gd::RectF thumb(m_scrollBarRect.X, thumbY, m_scrollBarRect.Width, thumbHeight); UiFillRoundRect(g, thumb, kScrollBarThickness * m_scale * 0.5f, m_theme->borderStrong); } } // namespace jdi