#include "PlotView.hpp" #include #include #include #include "Utf8Util.hpp" namespace jdi { namespace { const int kTimeTickTarget = 8; /**< 时间轴期望刻度数。 */ const int kYTickTarget = 4; /**< 幅值轴期望刻度数。 */ const float kScrollBarThickness = 9.0f; /**< 滚动条宽度(逻辑像素)。 */ const float kLanePadding = 8.0f; /**< 网格内边距。 */ const size_t kMaxPolylinePoints = 20000; /**< 单条折线最大顶点数。 */ const size_t kMaxScatterPoints = 2500; /**< 单条散点最大绘制点数。 */ 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 kLaneLabelPad = 6.0f; /**< 网格内侧标尺带的内边距。 */ /** * @brief 将数值限制到合法区间。 * @param [IN] value 输入值。 * @param [IN] low 下限。 * @param [IN] high 上限。 * @return 限制后的数值。 */ double ClampDouble(double value, double low, double high) { if (value < low) { return low; } if (value > high) { return high; } return value; } /** * @brief 组装图例文本:直接使用列名原文(已含序号与括号描述)。 * @param [IN] data 信号数据。 * @return 图例文本。 */ std::wstring BuildLegendText(const SignalData& data) { return data.name; } /** * @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; void PlotView::Setup(const UiFonts* fonts, const Theme* theme) { m_fonts = fonts; m_theme = theme; } void PlotView::SetContext(const DataSet* dataSet, TabConfig* tab) { m_dataSet = dataSet; m_tab = tab; m_hoverLane = -1; m_dragMode = PlotDragMode::NONE; RefreshLanes(); } void PlotView::SetScale(float scale) { m_scale = (scale > 0.1f) ? scale : 1.0f; } bool PlotView::CursorEnabled() const { return Tab().CursorEnabled(); } double PlotView::CursorTime() const { return Tab().CursorTime(); } double PlotView::ViewMinTime() const { return Tab().ViewMin(); } double PlotView::ViewMaxTime() const { return Tab().ViewMax(); } bool PlotView::HasDrawableContent() const { return (m_dataSet != nullptr) && !m_dataSet->Empty() && (m_tab != nullptr) && m_tab->AnyVisible(); } void PlotView::TimeBounds(double& low, double& high) const { low = 0.0; high = 1.0; if (m_dataSet == nullptr || m_dataSet->Empty()) { return; } // 优先使用可见信号所覆盖的范围,使视图随勾选内容自适应。 if (m_tab != nullptr && m_tab->ComputeVisibleTimeBounds(*m_dataSet, low, high)) { return; } low = m_dataSet->MinTime(); high = m_dataSet->MaxTime(); if (!(high > low)) { high = low + 1.0; } } PlotView::ResolvedSignal PlotView::ResolveSignal(const SignalRef& ref) const { ResolvedSignal result; if (m_dataSet == nullptr) { return result; } result.data = m_dataSet->Resolve(ref); result.file = m_dataSet->FileAt(ref.fileIndex); if (m_tab != nullptr) { result.style = m_tab->Style(ref); } return result; } void PlotView::Layout(const gd::RectF& area) { m_area = area; 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(); } 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_tab->Style(ref); if (style == nullptr || !style->visible) { continue; } if (firstFile < 0) { firstFile = ref.fileIndex; } else if (ref.fileIndex != firstFile) { return true; } } return false; } void PlotView::BuildLaneVisuals() { m_lanes.clear(); if (m_tab == nullptr || m_dataSet == nullptr || !HasDrawableContent()) { m_laneContentHeight = 0.0f; return; } const std::vector& 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) { for (Lane& lane : m_tab->Lanes()) { lane.autoRange = true; } } BuildLaneVisuals(); } void PlotView::DisableCursor() { Tab().CursorEnabled() = false; m_cursorHover = false; if (m_editTarget == PlotEditTarget::CURSOR_TIME) { EndEdit(false); } if (m_dragMode == PlotDragMode::CURSOR_MOVE) { m_dragMode = PlotDragMode::NONE; } } bool PlotView::ToggleCursor() { if (Tab().CursorEnabled()) { DisableCursor(); return true; } Tab().CursorEnabled() = true; // 启用时光标居中放置,随后可用鼠标靠近拖拽或双击指定精确时间。 Tab().CursorTime() = (Tab().ViewMin() + Tab().ViewMax()) * 0.5; m_cursorHover = true; return true; } PlotZoomMode PlotView::CurrentZoomMode() const { return Tab().ZoomMode(); } bool PlotView::ToggleZoomMode(PlotZoomMode mode) { const PlotZoomMode current = Tab().ZoomMode(); Tab().ZoomMode() = (current == mode) ? PlotZoomMode::NONE : mode; return true; } float PlotView::CursorX() const { return TimeToX(Tab().CursorTime()); } gd::RectF PlotView::CursorLabelRect() const { if (m_fonts == nullptr || m_fonts->monoSmall == nullptr) { return gd::RectF(); } const std::wstring text = FormatValue(Tab().CursorTime()); const float width = std::max(64.0f * m_scale, UiMeasureTextWidth(m_fonts->monoSmall, text) + 16.0f * m_scale); const float height = 20.0f * m_scale; // 读数标签固定在绘图区顶部,避免遮挡底部时间轴刻度尺。 float left = CursorX() - width * 0.5f; left = std::max(m_plotRect.X, std::min(left, m_plotRect.X + m_plotRect.Width - width)); const float top = m_plotRect.Y + 2.0f * m_scale; return gd::RectF(left, top, width, height); } bool PlotView::IsOnCursorLine(int x) const { if (!Tab().CursorEnabled() || m_plotRect.Width <= 0.0f) { return false; } const float cursorX = CursorX(); const float tolerance = 6.0f * m_scale; return (static_cast(x) >= cursorX - tolerance) && (static_cast(x) <= cursorX + tolerance); } bool PlotView::ApplyTimeSelection(float x0, float x1) { if (m_plotRect.Width <= 0.0f) { return false; } float left = std::min(x0, x1); float right = std::max(x0, x1); left = std::max(left, m_plotRect.X); right = std::min(right, m_plotRect.X + m_plotRect.Width); if (right - left < 8.0f * m_scale) { return false; } double timeA = XToTime(left); double timeB = XToTime(right); if (timeB < timeA) { std::swap(timeA, timeB); } if (!(timeB - timeA > 0.0)) { return false; } Tab().ViewMin() = timeA; Tab().ViewMax() = timeB; ClampTimeView(); return true; } bool PlotView::ApplyValueSelection(int laneIndex, float y0, float y1) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(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; 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::LaneRangeIsAuto(int laneIndex) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return true; } return m_tab->Lanes()[static_cast(laneIndex)].autoRange; } bool PlotView::ResetLaneRange(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)]; if (lane.autoRange) { return false; } lane.autoRange = true; BuildLaneVisuals(); return true; } 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.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)]; 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) { 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)); } 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); std::vector points; std::vector breaks; 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.style->visible) { continue; } BuildPolyline(resolved, lane, points, breaks); if (points.size() < 2) { continue; } // 散点模式下把采样点本身也视为可命中目标。 if (resolved.style->mode == DrawMode::SCATTER) { for (const gd::PointF& point : points) { 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(); for (std::size_t index = start + 1; index < stop; ++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_editText) + 24.0f * m_scale); if (m_editTarget == PlotEditTarget::CURSOR_TIME) { // 与显示态标签取相同宽度,避免进入编辑时框体跳动。 const gd::RectF displayRect = CursorLabelRect(); width = std::max(width, displayRect.Width); float left = CursorX() - width * 0.5f; left = std::max(m_plotRect.X, std::min(left, m_plotRect.X + m_plotRect.Width - width)); return gd::RectF(left, m_plotRect.Y + 2.0f * m_scale, width, height); } // 网格 Y 轴上下限输入框贴在该网格内侧标尺带下方。 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) { if (target == PlotEditTarget::NONE) { return false; } m_editTarget = target; m_editLane = laneIndex; m_editText = initialText; m_editFresh = true; 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 std::wstring text = Trim(m_editText); 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); } m_editTarget = PlotEditTarget::NONE; m_editLane = -1; m_editFresh = false; m_editText.clear(); if (!parsedOk) { return true; } if (target == PlotEditTarget::CURSOR_TIME) { double low = 0.0; double high = 1.0; TimeBounds(low, high); const double span = std::max(high - low, kMinTimeSpan); Tab().CursorTime() = ClampDouble(parsed, low - span * 10.0, high + span * 10.0); return true; } if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(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)]; 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; } if (character == L'\r' || character == L'\n') { return EndEdit(true); } if (character == L'\b') { m_editFresh = false; if (!m_editText.empty()) { m_editText.pop_back(); } return true; } const bool digit = (character >= L'0' && character <= L'9'); const bool special = (character == L'.' || character == L'-' || character == L'+' || character == L'e' || character == L'E'); if (!digit && !special) { // 数值输入只接受数字与科学计数法符号,其余字符吞掉。 return true; } // 首次键入直接替换原有数值,无需先手动删除。 if (m_editFresh) { m_editText.clear(); m_editFresh = false; } if (m_editText.size() < 16) { if (special && character == L'.' && (m_editText.find(L'.') != std::wstring::npos || m_editText.find(L'e') != std::wstring::npos || m_editText.find(L'E') != std::wstring::npos)) { return true; } if ((character == L'-' || character == L'+') && !m_editText.empty() && m_editText.back() != L'e' && m_editText.back() != L'E') { return true; } m_editText.push_back(character); } return true; } bool PlotView::OnEditKey(WPARAM key) { if (m_editTarget == PlotEditTarget::NONE) { return false; } switch (key) { case VK_RETURN: return EndEdit(true); case VK_ESCAPE: return EndEdit(false); case VK_BACK: m_editFresh = false; if (!m_editText.empty()) { m_editText.pop_back(); } 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_TIME: return LoadCursorW(nullptr, IDC_SIZEWE); case PlotDragMode::RUBBER_VALUE: return LoadCursorW(nullptr, IDC_SIZENS); case PlotDragMode::SCROLL_LANES: return LoadCursorW(nullptr, IDC_SIZENS); case PlotDragMode::CURSOR_MOVE: return LoadCursorW(nullptr, IDC_SIZEWE); case PlotDragMode::MOVE_SIGNAL: return LoadCursorW(nullptr, IDC_SIZEALL); default: break; } if (m_scrollBarVisible && UiPointInRect(m_scrollBarRect, static_cast(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_SIZENS); } if (IsOnCursorLine(x) && 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); } // 仅在缩放模式下才切换到对应的缩放光标,常规模式保持箭头(不出现十字指针)。 const PlotZoomMode mode = Tab().ZoomMode(); if (mode == PlotZoomMode::TIME) { return LoadCursorW(nullptr, IDC_SIZEWE); } if (mode == PlotZoomMode::VALUE) { return LoadCursorW(nullptr, IDC_SIZENS); } } return nullptr; } bool PlotView::OnMouseMove(int x, int y) { m_mouseX = x; m_mouseY = y; if (!HasDrawableContent()) { return false; } const float fx = static_cast(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_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); m_dragMoved = true; return true; } case PlotDragMode::RUBBER_VALUE: { // 横向选区限定在按下时的网格内,只取纵向区间用于幅值轴缩放。 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: { Tab().CursorTime() = XToTime(fx); m_dragMoved = true; m_cursorHover = true; return true; } case PlotDragMode::MOVE_SIGNAL: { const int target = LaneAtY(y); m_dragMoved = true; if (target != m_dragTargetLane) { m_dragTargetLane = target; } return true; } case PlotDragMode::NONE: default: break; } bool changed = false; const int lane = LaneAtY(y); if (lane != m_hoverLane) { m_hoverLane = lane; changed = true; } // 时间光标不再跟随鼠标移动,仅在鼠标覆盖光标时才显示具体时间读数。 const bool hover = Tab().CursorEnabled() && IsOnCursorLine(x); if (hover != m_cursorHover) { m_cursorHover = hover; changed = true; } // 常规模式下精确检测鼠标是否贴近某条信号曲线,用于给出可拖拽提示。 SignalRef hoverSignal; if (Tab().ZoomMode() == PlotZoomMode::NONE && !m_cursorHover) { hoverSignal = 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(); 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().CursorEnabled() && IsOnCursorLine(x) && UiPointInRect(m_area, fx, fy)) { m_dragMode = PlotDragMode::CURSOR_MOVE; m_cursorHover = true; return true; } if (!UiPointInRect(m_plotRect, fx, fy)) { m_dragMode = PlotDragMode::NONE; return false; } const PlotZoomMode mode = Tab().ZoomMode(); if (mode == PlotZoomMode::TIME) { m_dragMode = PlotDragMode::RUBBER_TIME; m_rubberRect = gd::RectF(fx, m_plotRect.Y, 0.0f, m_plotRect.Height); return true; } if (mode == PlotZoomMode::VALUE) { const int laneIndex = LaneAtY(y); if (laneIndex < 0) { m_dragMode = PlotDragMode::NONE; return false; } m_dragLane = laneIndex; m_dragMode = PlotDragMode::RUBBER_VALUE; const gd::RectF& laneRect = m_lanes[static_cast(laneIndex)].rect; m_rubberRect = gd::RectF(laneRect.X, fy, laneRect.Width, 0.0f); return true; } // 常规模式下:先检测是否落在信号曲线上;只有命中提示时才允许拖拽该信号。 const SignalRef hit = HitTestSignal(x, y); if (hit.IsValid()) { m_dragSignal = hit; m_dragTargetLane = Tab().LaneOfSignal(hit); m_dragMode = PlotDragMode::MOVE_SIGNAL; return true; } m_dragLane = LaneAtY(y); m_dragMode = PlotDragMode::PAN; return true; } bool PlotView::OnLButtonUp(int x, int y) { bool changed = (m_dragMode != PlotDragMode::NONE); switch (m_dragMode) { case PlotDragMode::RUBBER_TIME: if (m_dragMoved && ApplyTimeSelection(m_rubberRect.X, m_rubberRect.X + m_rubberRect.Width)) { changed = true; } break; case PlotDragMode::RUBBER_VALUE: if (m_dragMoved && ApplyValueSelection(m_dragLane, m_rubberRect.Y, m_rubberRect.Y + m_rubberRect.Height)) { changed = true; } break; case PlotDragMode::CURSOR_MOVE: m_cursorHover = true; break; case PlotDragMode::MOVE_SIGNAL: { const int target = LaneAtY(y); const int source = (m_tab != nullptr) ? m_tab->LaneOfSignal(m_dragSignal) : -1; if (m_dataSet != nullptr && m_tab != nullptr && m_dragSignal.IsValid() && target >= 0 && target != source && m_dragMoved) { m_tab->MoveSignalToLane(*m_dataSet, m_dragSignal, target); changed = true; } m_dragSignal = SignalRef(); m_dragTargetLane = -1; break; } default: break; } m_dragMode = PlotDragMode::NONE; m_dragLane = -1; m_dragMoved = false; m_rubberRect = gd::RectF(); (void)x; return changed; } bool PlotView::OnMouseWheel(int x, int y, int delta, bool shift) { if (!HasDrawableContent() || delta == 0) { return false; } const double notches = static_cast(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().CursorEnabled() && IsOnCursorLine(x)) { return BeginEdit(PlotEditTarget::CURSOR_TIME, -1, FormatValue(Tab().CursorTime())); } // 其它位置:回到初始状态(自适应 + 退出缩放模式)。 FitAll(); return true; } bool PlotView::OnMouseLeave() { bool changed = false; if (m_hoverLane != -1) { m_hoverLane = -1; changed = true; } if (m_hoverSignal.IsValid()) { m_hoverSignal = SignalRef(); changed = true; } if (m_cursorHover) { m_cursorHover = false; changed = true; } return changed; } 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; } std::size_t stride = 1; const std::size_t visibleCount = end - begin; if (visibleCount > kMaxPolylinePoints) { stride = (visibleCount + kMaxPolylinePoints - 1) / kMaxPolylinePoints; } const float limitTop = lane.rect.Y - lane.rect.Height * 4.0f; const float limitBottom = lane.rect.Y + lane.rect.Height * 5.0f; bool inSegment = false; for (std::size_t index = begin; index < end; index += stride) { const double t = time[index]; const double value = values[index]; if (!std::isfinite(t) || !std::isfinite(value)) { inSegment = false; continue; } const float x = TimeToX(t); float y = ValueToY(value, lane); y = static_cast(ClampDouble(y, limitTop, limitBottom)); if (!inSegment) { breaks.push_back(points.size()); points.push_back(gd::PointF(x, y)); inSegment = true; } else { points.push_back(gd::PointF(x, y)); } } } void PlotView::PaintEmptyState(gd::Graphics& g) { std::wstring message; if (m_dataSet == nullptr || m_dataSet->Empty()) { message = L"打开 CSV 文件以查看仿真数据波形"; } else { message = L"请在左侧勾选需要查看的信号(可同时加载多个文件进行对比)"; } UiText(g, message, m_fonts->ui, m_area, m_theme->textMuted, TextAlign::CENTER); } void PlotView::Paint(gd::Graphics& g) { if (m_theme == nullptr || m_fonts == nullptr) { return; } UiFillRect(g, m_area, m_theme->plotBg); BuildLaneVisuals(); if (m_lanes.empty()) { PaintEmptyState(g); return; } const double viewMin = Tab().ViewMin(); const double viewMax = Tab().ViewMax(); const double span = viewMax - viewMin; const double timeStep = NiceStep((span > 0.0) ? span : 1.0, kTimeTickTarget); std::vector timeTicks; BuildTicks(viewMin, viewMax, timeStep, timeTicks); // 波形区域底色。 UiFillRect(g, m_plotRect, m_theme->cardBg); for (std::size_t index = 0; index < m_lanes.size(); ++index) { PaintLane(g, static_cast(index), m_lanes[index], timeTicks); } PaintTimeAxis(g, timeTicks, timeStep); PaintRubberBand(g); PaintCursor(g, m_lanes); PaintSignalInteraction(g); // 网格 Y 轴上下限的输入框绘制在最上层。 if (m_editTarget == PlotEditTarget::LANE_Y_MAX || m_editTarget == PlotEditTarget::LANE_Y_MIN) { PaintEditField(g); } PaintScrollBar(g); // 绘图区外框(比网格内部分界线更重,形成明确整体边界)。 gd::Pen borderPen(ToGdiColor(m_theme->axisLine), 1.6f); g.DrawRectangle(&borderPen, m_plotRect.X, m_plotRect.Y, m_plotRect.Width, m_plotRect.Height); } void PlotView::PaintRubberBand(gd::Graphics& g) { if (!m_dragMoved || m_rubberRect.Width <= 0.0f || m_rubberRect.Height <= 0.0f) { return; } const bool timeMode = (m_dragMode == PlotDragMode::RUBBER_TIME); const bool valueMode = (m_dragMode == PlotDragMode::RUBBER_VALUE); if (!timeMode && !valueMode) { return; } // 时间轴缩放用强调色,Y 轴缩放用警示色,便于区分两种模式。 const std::uint32_t band = timeMode ? m_theme->accent : m_theme->danger; UiFillRect(g, m_rubberRect, WithAlpha(band, 0x24)); UiDrawRoundRect(g, m_rubberRect, 2.0f * m_scale, band, 1.2f); // 选区跨度提示(时间用秒/数值用幅度量纲),放在选区顶部。 std::wstring text; if (timeMode) { text = FormatValue(std::fabs(XToTime(m_rubberRect.X + m_rubberRect.Width) - XToTime(m_rubberRect.X))); } else if (m_dragLane >= 0 && m_dragLane < static_cast(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::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)); // 悬停高亮。 if (laneIndex == m_hoverLane && m_dragMode == PlotDragMode::NONE) { UiFillRect(g, lane.rect, MixColor(m_theme->cardBg, m_theme->accent, 0.035)); } // 幅值网格线(先画线,标尺稍后作为覆盖层绘制,避免被波形遮挡)。 const double yStep = NiceStep(lane.yMax - lane.yMin, kYTickTarget); std::vector yTicks; BuildTicks(lane.yMin, lane.yMax, yStep, yTicks); 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); } // 时间网格线。 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); std::vector points; std::vector breaks; for (const SignalRef& ref : laneSignals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr || !resolved.style->visible) { continue; } const DrawMode mode = resolved.style->mode; BuildPolyline(resolved, lane, points, breaks); const bool drawLine = (mode != DrawMode::SCATTER); const bool drawScatter = (mode == DrawMode::SCATTER || mode == DrawMode::LINE_SCATTER); // 悬停命中的信号先画一层浅色光晕,作为“可拖拽”的视觉提示。 const bool highlighted = (ref == m_hoverSignal) || (ref == m_dragSignal); if (highlighted && drawLine && !breaks.empty()) { gd::Pen halo(ToGdiColor(WithAlpha(m_theme->accent, 0x66)), (resolved.style->lineWidth + 4.0f) * m_scale); halo.SetLineJoin(gd::LineJoinRound); halo.SetStartCap(gd::LineCapRound); halo.SetEndCap(gd::LineCapRound); for (std::size_t k = 0; k < breaks.size(); ++k) { const std::size_t start = breaks[k]; const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size(); if (stop <= start + 1) { continue; } g.DrawLines(&halo, points.data() + start, static_cast(stop - start)); } } if (drawLine && !breaks.empty()) { gd::Pen pen(ToGdiColor(StyleColor(*resolved.style)), resolved.style->lineWidth * m_scale); pen.SetLineJoin(gd::LineJoinRound); pen.SetStartCap(gd::LineCapRound); pen.SetEndCap(gd::LineCapRound); for (std::size_t k = 0; k < breaks.size(); ++k) { const std::size_t start = breaks[k]; const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size(); if (stop <= start + 1) { continue; } g.DrawLines(&pen, points.data() + start, static_cast(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::SolidBrush brush(ToGdiColor(StyleColor(*resolved.style))); for (std::size_t k = 0; k < points.size(); k += scatterStride) { g.FillEllipse(&brush, points[k].X - radius, points[k].Y - radius, radius * 2.0f, radius * 2.0f); } } } g.Restore(waveSave); } // 网格分界线:网格之间不再留间隔,改用较重的横线明确分隔相邻网格。 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; 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) < 15.0f * m_scale) { 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+ 裁剪约束)。 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); if (Tab().CursorEnabled() && lane.rect.Height >= 40.0f * m_scale) { PaintLaneReadout(g, laneIndex, lane, clipTop, clipBottom); } } g.Restore(saveState); } 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, lane.rect.Y + 4.0f * m_scale, lane.rect.Width, 18.0f * 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 = lane.rect.Y + 4.0f * m_scale; const float height = 18.0f * m_scale; // 图例从内侧标尺带的右侧开始,避免与纵轴刻度重叠。 float cursorX = lane.rect.X + m_labelBandWidth + 6.0f * m_scale; const float maxX = lane.rect.X + lane.rect.Width - kLanePadding * m_scale; bool anyVisible = false; int visibleCount = 0; int drawnCount = 0; // 先测量图例总宽度,再用半透明底板掠开底下的网格线,提升可读性。 float legendWidth = 0.0f; for (const SignalRef& ref : laneSignals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr || !resolved.style->visible) { continue; } std::wstring label = BuildLegendText(*resolved.data); if (multiFile && resolved.file != nullptr) { label += L" · " + resolved.file->fileName; } const float itemWidth = 13.0f * m_scale + UiTextWidth(g, label, m_fonts->uiSmall) + 14.0f * m_scale; if (cursorX + legendWidth + itemWidth > maxX) { break; } legendWidth += itemWidth; } if (legendWidth > 0.0f) { UiFillRoundRect(g, gd::RectF(cursorX - 5.0f * m_scale, top - 1.0f * m_scale, legendWidth + 6.0f * m_scale, height + 2.0f * m_scale), 3.0f * m_scale, WithAlpha(m_theme->cardBg, 0xD8)); } // 绘制图例条目(与测量阶段保持一致的裁剪条件)。 float drawnWidth = 0.0f; for (const SignalRef& ref : laneSignals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr || !resolved.style->visible) { continue; } anyVisible = true; ++visibleCount; std::wstring label = BuildLegendText(*resolved.data); if (multiFile && resolved.file != nullptr) { label += L" · " + resolved.file->fileName; } const float textWidth = UiTextWidth(g, label, m_fonts->uiSmall); const float itemWidth = 13.0f * m_scale + textWidth + 14.0f * m_scale; if (drawnWidth + itemWidth > legendWidth + 0.5f) { break; } drawnWidth += itemWidth; const float swatchSize = 8.0f * m_scale; UiDrawSwatch(g, gd::RectF(cursorX, top + (height - swatchSize) * 0.5f, swatchSize, swatchSize), resolved.style->color, resolved.style->opacity, 1.5f * m_scale, m_scale); cursorX += swatchSize + 5.0f * m_scale; UiText(g, label, m_fonts->uiSmall, gd::RectF(cursorX, top, textWidth + 2.0f, height), m_theme->text, TextAlign::LEFT); cursorX += textWidth + 14.0f * m_scale; ++drawnCount; } if (!anyVisible) { UiText(g, L"(无可见信号)", m_fonts->uiSmall, gd::RectF(lane.rect.X + m_labelBandWidth + 6.0f * m_scale, top, lane.rect.Width, height), m_theme->textMuted, TextAlign::LEFT); } else if (drawnCount < visibleCount) { // 图例空间不足时提示剩余信号数量,避免误认为信号丢失。 const std::wstring moreText = L"+" + std::to_wstring(visibleCount - drawnCount); if (cursorX + 20.0f * m_scale <= maxX) { UiText(g, moreText, m_fonts->uiSmall, gd::RectF(cursorX, top, 24.0f * m_scale, height), m_theme->textMuted, TextAlign::LEFT); } } } void PlotView::PaintLaneReadout(gd::Graphics& g, int laneIndex, const LaneVisual& lane, float visibleTop, float visibleBottom) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return; } const double cursorTime = Tab().CursorTime(); if (cursorTime < Tab().ViewMin() || cursorTime > Tab().ViewMax()) { return; } struct ReadoutLine { std::wstring text; }; std::vector lines; const bool multiFile = LaneSpansMultipleFiles(laneIndex); for (const SignalRef& ref : m_tab->Lanes()[static_cast(laneIndex)].signals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr || resolved.file == nullptr || !resolved.style->visible) { continue; } const bool interpolate = (resolved.style->mode != DrawMode::SCATTER); double value = 0.0; if (!SampleFileAt(*resolved.file, *resolved.data, cursorTime, interpolate, value)) { continue; } ReadoutLine line; line.text = resolved.data->name; if (multiFile) { line.text += L" · " + resolved.file->fileName; } line.text += L" = " + FormatValue(value); lines.push_back(std::move(line)); } if (lines.empty()) { return; } const float lineHeight = 17.0f * m_scale; const float maxLines = std::max(1.0f, std::floor((lane.rect.Height - 12.0f * m_scale) / lineHeight)); const std::size_t shown = std::min(lines.size(), static_cast(maxLines)); float boxWidth = 0.0f; for (std::size_t index = 0; index < shown; ++index) { boxWidth = std::max(boxWidth, UiTextWidth(g, lines[index].text, m_fonts->monoSmall)); } boxWidth += 20.0f * m_scale; const float boxHeight = static_cast(shown) * lineHeight + 8.0f * m_scale; const gd::RectF box(lane.rect.X + lane.rect.Width - kLanePadding * m_scale - boxWidth, lane.rect.Y + 4.0f * m_scale, boxWidth, boxHeight); // 读数框整体必须落在可见区内,否则文本会越过绘图区边界。 if (!IsFullyVisible(box, visibleTop, visibleBottom)) { return; } UiFillRoundRect(g, box, 4.0f * m_scale, WithAlpha(m_theme->readoutBg, 0xEE)); UiDrawRoundRect(g, box, 4.0f * m_scale, m_theme->border, 1.0f); float textY = box.Y + 4.0f * m_scale; for (std::size_t index = 0; index < shown; ++index) { const gd::RectF lineRect(box.X + 10.0f * m_scale, textY, boxWidth - 14.0f * m_scale, lineHeight); UiText(g, lines[index].text, m_fonts->monoSmall, lineRect, m_theme->textStrong, TextAlign::LEFT); textY += lineHeight; } } bool PlotView::IsFullyVisible(const gd::RectF& rect, float visibleTop, float visibleBottom) { return (rect.Y >= visibleTop - 0.5f) && (rect.Y + rect.Height <= visibleBottom + 0.5f); } void PlotView::PaintTimeAxis(gd::Graphics& g, const std::vector& 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; } break; } } const float nameWidth = std::min(UiMeasureTextWidth(m_fonts->monoSmall, axisName) + 8.0f * m_scale, m_plotRect.Width * 0.4f); const gd::RectF nameRect(m_plotRect.X + 2.0f * m_scale, axisY + 3.0f * m_scale, nameWidth, std::max(0.0f, m_timeAxisRect.Height - 5.0f * m_scale)); UiText(g, UiEllipsize(g, axisName, m_fonts->monoSmall, nameWidth), m_fonts->monoSmall, nameRect, m_theme->textMuted, TextAlign::LEFT); const float labelWidth = 96.0f * m_scale; for (const double tick : timeTicks) { const float x = TimeToX(tick); if (x < m_plotRect.X - 1.0f || x > m_plotRect.X + m_plotRect.Width + 1.0f) { continue; } UiDrawLine(g, x, axisY, x, axisY + 4.0f * m_scale, m_theme->axisLine, 1.0f); // 刻度标签夹在绘图区横向范围内,并与左侧列名互不重叠。 float labelX = x - labelWidth * 0.5f; labelX = std::max(m_plotRect.X, std::min(labelX, m_plotRect.X + m_plotRect.Width - labelWidth)); const gd::RectF labelRect(labelX, axisY + 4.0f * m_scale, labelWidth, std::max(0.0f, m_timeAxisRect.Height - 6.0f * m_scale)); if (labelRect.X < nameRect.X + nameRect.Width) { continue; } UiText(g, FormatTick(tick, timeStep), m_fonts->monoSmall, labelRect, m_theme->textMuted, TextAlign::CENTER); } } void PlotView::PaintCursor(gd::Graphics& g, const std::vector& lanes) { if (!Tab().CursorEnabled() || m_tab == nullptr) { return; } const double cursorTime = Tab().CursorTime(); const float x = TimeToX(cursorTime); if (x < m_plotRect.X - 2.0f || x > m_plotRect.X + m_plotRect.Width + 2.0f) { return; } // 光标纵向贯穿全部网格,并延伸到时间轴底部,便于与刻度对齐读数。 const float lineTop = m_plotRect.Y; const float lineBottom = m_timeAxisRect.Y + m_timeAxisRect.Height; const float lineWidth = (m_cursorHover || m_dragMode == PlotDragMode::CURSOR_MOVE) ? 2.0f : 1.2f; UiDrawLine(g, x, lineTop, x, lineBottom, m_theme->cursor, lineWidth * m_scale); // 顶部三角把手,提示该线可以被拖拽。 { const float arm = 5.0f * m_scale; gd::PointF points[3]; points[0] = gd::PointF(x - arm, lineTop); points[1] = gd::PointF(x + arm, lineTop); points[2] = gd::PointF(x, lineTop + arm * 1.4f); gd::SolidBrush handle(ToGdiColor(m_theme->cursor)); g.FillPolygon(&handle, points, 3); } // 各网格上的取值标记(仅在网格可见范围内绘制)。 for (std::size_t laneIndex = 0; laneIndex < lanes.size(); ++laneIndex) { const LaneVisual& lane = lanes[laneIndex]; for (const SignalRef& ref : m_tab->Lanes()[laneIndex].signals) { const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr || resolved.file == nullptr || !resolved.style->visible) { continue; } double value = 0.0; if (!SampleFileAt(*resolved.file, *resolved.data, cursorTime, resolved.style->mode != DrawMode::SCATTER, value)) { continue; } const float y = ValueToY(value, lane); if (y < std::max(lane.rect.Y, m_plotRect.Y) - 2.0f || y > std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height) + 2.0f) { continue; } const float radius = 3.4f * m_scale; gd::SolidBrush ring(ToGdiColor(m_theme->cardBg)); gd::SolidBrush fill(ToGdiColor(StyleColor(*resolved.style))); g.FillEllipse(&ring, x - radius - 1.2f * m_scale, y - radius - 1.2f * m_scale, (radius + 1.2f * m_scale) * 2.0f, (radius + 1.2f * m_scale) * 2.0f); g.FillEllipse(&fill, x - radius, y - radius, radius * 2.0f, radius * 2.0f); } } // 具体时间显示在绘图区顶部(不遮挡时间轴刻度),且仅在鼠标覆盖光标时出现。 if (m_editTarget == PlotEditTarget::CURSOR_TIME) { PaintEditField(g); return; } if (!m_cursorHover) { return; } const gd::RectF labelRect = CursorLabelRect(); if (labelRect.Width <= 0.0f) { return; } UiFillRoundRect(g, labelRect, 3.0f * m_scale, m_theme->cursor); UiText(g, FormatValue(cursorTime), m_fonts->monoSmall, labelRect, m_theme->textOnAccent, TextAlign::CENTER); } void PlotView::PaintEditField(gd::Graphics& g) { const gd::RectF fieldRect = EditFieldRect(); if (fieldRect.Width <= 0.0f || fieldRect.Height <= 0.0f) { return; } // 编辑态:强调色外框 + 整段高亮文本,首次键入即整体替换。 UiFillRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->cardBg); UiDrawRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->accent, 1.6f); const std::wstring text = m_editText.empty() ? std::wstring(L" ") : m_editText; if (m_editFresh) { const float textWidth = UiTextWidth(g, text, m_fonts->monoSmall) + 8.0f * m_scale; const gd::RectF highlight(fieldRect.X + (fieldRect.Width - textWidth) * 0.5f, fieldRect.Y + 2.0f * m_scale, textWidth, fieldRect.Height - 4.0f * m_scale); UiFillRoundRect(g, highlight, 2.0f * m_scale, m_theme->accent); UiText(g, text, m_fonts->monoSmall, highlight, m_theme->textOnAccent, TextAlign::CENTER); } else { UiText(g, text + L"|", m_fonts->monoSmall, fieldRect, m_theme->textStrong, TextAlign::CENTER); } } 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 int target = (m_dragTargetLane >= 0) ? m_dragTargetLane : m_tab->LaneOfSignal(m_dragSignal); if (target >= 0 && target < static_cast(m_lanes.size())) { const LaneVisual& lane = m_lanes[static_cast(target)]; const ResolvedSignal resolved = ResolveSignal(m_dragSignal); if (resolved.data != nullptr && resolved.style != nullptr) { const int saveState = g.Save(); g.SetClip(gd::RectF(m_plotRect.X, std::max(lane.rect.Y, m_plotRect.Y), m_plotRect.Width, std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height) - std::max(lane.rect.Y, m_plotRect.Y)), gd::CombineModeIntersect); // 目标网格边框高亮。 gd::Pen frame(ToGdiColor(m_theme->accent), 2.0f); g.DrawRectangle(&frame, lane.rect.X, lane.rect.Y, lane.rect.Width, lane.rect.Height); std::vector points; std::vector breaks; BuildPolyline(resolved, lane, points, breaks); gd::Pen ghost(ToGdiColor(WithAlpha(m_theme->accent, 0xB0)), std::max(1.6f, resolved.style->lineWidth) * m_scale); ghost.SetDashStyle(gd::DashStyleDash); 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)); } g.Restore(saveState); // 目标网格名称提示。 const std::wstring hint = 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 = lane.rect.X + (lane.rect.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, lane.rect.Y + 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->name + 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