diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index d0ce6da..adf4905 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -678,10 +678,8 @@ namespace jdi // 工具栏可切换按钮的选中状态始终与当前标签页配置保持一致。 m_toolbar.SetToggleState(ToolbarAction::AUTO_FIT_Y, m_plot.AllLanesAutoFitToSelection()); - m_toolbar.SetToggleState(ToolbarAction::ZOOM_TIME, - m_plot.CurrentZoomMode() == PlotZoomMode::TIME); - m_toolbar.SetToggleState(ToolbarAction::ZOOM_VALUE, - m_plot.CurrentZoomMode() == PlotZoomMode::VALUE); + m_toolbar.SetToggleState(ToolbarAction::ZOOM_SELECT, + m_plot.CurrentZoomMode() == PlotZoomMode::SELECT); m_toolbar.SetToggleState(ToolbarAction::CURSOR, m_plot.CursorEnabled()); { @@ -1028,40 +1026,19 @@ namespace jdi case ToolbarAction::AUTO_FIT_Y: { // 与网格右键菜单中的「选区 Y 轴自适应(全局)」同一开关: - // 打开后每次时间轴选区放大都会自动把各网格 Y 轴匹配到新的可见区间。 + // 打开后立即按当前视图自适应一次,之后每次时间轴选区放大都会自动匹配。 const bool enable = !m_plot.AllLanesAutoFitToSelection(); - m_plot.SetAllLanesAutoFitToSelection(enable); + m_plot.SetAllLanesAutoFitToSelection(enable, true); SetStatusMessage(enable ? L"已开启选区 Y 轴自适应:时间轴选区放大后自动匹配各网格 Y 轴。" : L"已关闭选区 Y 轴自适应。", false); break; } - case ToolbarAction::ZOOM_TIME: - if (m_plot.ToggleZoomMode(PlotZoomMode::TIME)) - { - SetStatusMessage((m_plot.CurrentZoomMode() == PlotZoomMode::TIME) - ? L"时间轴选区:在绘图区横向拖拽选择区间即可放大。" - : L"已退出时间轴选区模式。", - false); - } - break; - case ToolbarAction::ZOOM_VALUE: - if (m_plot.ToggleZoomMode(PlotZoomMode::VALUE)) - { - SetStatusMessage((m_plot.CurrentZoomMode() == PlotZoomMode::VALUE) - ? L"Y 轴选区:在某个网格内纵向拖拽选择区间即可放大。" - : L"已退出 Y 轴选区模式。", - false); - } + case ToolbarAction::ZOOM_SELECT: + m_plot.ToggleZoomMode(PlotZoomMode::SELECT); break; case ToolbarAction::CURSOR: - if (m_plot.ToggleCursor()) - { - SetStatusMessage(m_plot.CursorEnabled() - ? L"时间光标已启用:拖动竖线移动,双击竖线可输入精确时间。" - : L"时间光标已关闭。", - false); - } + m_plot.CycleCursorMode(); break; case ToolbarAction::MERGE_LANES: if (!m_dataSet.Empty()) @@ -1513,12 +1490,13 @@ namespace jdi break; case PLOT_CMD_AUTO_FIT_ALL: { - // 全局切换:一次打开/关闭全部网格各自的开关。 + // 全局切换:一次打开/关闭全部网格各自的开关;打开时立即生效一次。 const bool enable = !m_plot.AllLanesAutoFitToSelection(); - m_plot.SetAllLanesAutoFitToSelection(enable); + m_plot.SetAllLanesAutoFitToSelection(enable, true); break; } case PLOT_CMD_LANE_FIT_TIME: + // 「按网格时间轴展开」:展开时间轴后同时把该网格的 Y 轴自适应一次。 m_plot.FitTimeToLane(laneIndex); break; case PLOT_CMD_FIT_ALL: @@ -2039,6 +2017,16 @@ namespace jdi const int x = GET_X_LPARAM(lParam); const int y = GET_Y_LPARAM(lParam); + // 「选区放大」正在框选时右键 = 放弃当前选区(显示提示), + // 与 Esc 不同:这里**不退出**选区放大模式,也不弹出网格菜单。 + // 左键仍按着,因此保留鼠标捕获与激活目标,松手时不会命中被放弃的选区。 + if (m_plot.IsRubberSelecting()) + { + m_plot.CancelDrag(true); + InvalidateRect(hwnd, nullptr, FALSE); + return 0; + } + // 编辑态下右键 = 取消选区并把插入符移到文本末尾(常规输入框行为), // 其它操作(弹菜单、拖拽)一并被吞掉。 if (m_tabBar.IsEditing() || m_panel.IsEditing() || m_plot.IsEditing() || m_colorPicker.IsEditing()) @@ -2097,8 +2085,12 @@ namespace jdi } case WM_RBUTTONUP: { - ReleaseCapture(); - m_activeTarget = Target::NONE; + // 左键仍按着时不释放捕获与激活目标(例如框选过程中右键放弃选区后再松开左键)。 + if ((wParam & MK_LBUTTON) == 0) + { + ReleaseCapture(); + m_activeTarget = Target::NONE; + } return 0; } case WM_MOUSEWHEEL: diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 79c1cb3..740aa1a 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -23,13 +23,22 @@ namespace jdi { NONE = 0, /**< 未拖拽。 */ PAN, /**< 常规模式:水平平移时间轴 + 纵向偏移当前网格幅值。 */ - RUBBER_TIME, /**< 时间轴缩放选区(竖向贯穿全高)。 */ - RUBBER_VALUE, /**< Y 轴缩放选区(限定在某个网格内)。 */ + RUBBER_SELECT, /**< 选区放大:按拖拽方向决定横向(时间轴)或纵向(幅值轴)选区。 */ SCROLL_LANES, /**< 拖动网格滚动条。 */ CURSOR_MOVE, /**< 拖拽时间光标。 */ MOVE_SIGNAL /**< 拖拽信号曲线到其它网格。 */ }; + /** + * @brief 选区放大拖拽的轴向(由首次明显位移的方向决定)。 + */ + enum class PlotRubberAxis : int + { + UNDECIDED = 0, /**< 尚未确定(拖拽刚刚开始)。 */ + TIME = 1, /**< 横向拖拽:放大时间轴。 */ + VALUE = 2 /**< 纵向拖拽:放大当前网格的幅值轴。 */ + }; + /** * @brief 绘图区内的内联数值输入目标。 */ @@ -255,13 +264,20 @@ namespace jdi /** * @brief 放弃当前正在进行的拖拽(缩放选区/平移/信号拖动),不改变视图范围。 + * @param [IN] showHint 是否同时显示“已放弃选区”提示。 * @return 确实取消了某个拖拽返回 true。 */ - bool CancelDrag(); + bool CancelDrag(bool showHint = false); /** - * @brief 退出全部缩放模式(时间轴选区 / Y 轴选区),使工具栏缩放按钮回到未选中态。 - * @return 之前处于某个缩放模式返回 true。 + * @brief 判断当前是否处于「选区放大」的框选过程中(供宿主处理右键放弃)。 + * @return 正在框选返回 true。 + */ + bool IsRubberSelecting() const { return m_dragMode == PlotDragMode::RUBBER_SELECT; } + + /** + * @brief 退出全部缩放模式(选区放大),使工具栏缩放按钮回到未选中态。 + * @return 之前处于缩放模式返回 true。 */ bool ExitZoomModes(); @@ -272,20 +288,26 @@ namespace jdi * 必须清空缓存,避免旧几何键与新的信号意外重合而取到过期顶点。 */ void InvalidatePolylineCache(); /** - * @brief 切换时间光标:启用时置于当前视图中心,再调用则关闭。 + * @brief 三态循环切换时间光标:无 → 单光标 → 双光标 → 无。 * @return 状态发生变化返回 true。 */ - bool ToggleCursor(); + bool CycleCursorMode(); /** - * @brief 判断时间光标是否已启用。 + * @brief 判断时间光标是否已启用(单/双光标均为启用)。 * @return 启用返回 true。 */ bool CursorEnabled() const; /** - * @brief 获取当前光标对应的时间。 - * @return 光标时间。 + * @brief 获取当前时间光标模式。 + * @return 光标模式。 + */ + PlotCursorMode CursorMode() const; + + /** + * @brief 获取光标 1 对应的时间。 + * @return 光标 1 时间。 */ double CursorTime() const; @@ -330,12 +352,24 @@ namespace jdi */ bool ToggleLaneAutoFitToSelection(int laneIndex); + /** + * @brief 让某网格的「选区 Y 轴自适应」开关立即生效一次(打开时调用)。 + * @param [IN] laneIndex 网格下标。 + */ + void ApplyAutoFitToSelectionNow(int laneIndex); + + /** + * @brief 对全部网格的「选区 Y 轴自适应」开关立即生效一次(全局打开时调用)。 + */ + void ApplyAllAutoFitToSelectionNow(); + /** * @brief 一次性设置全部网格的「选区 Y 轴自适应」开关(全局菜单项 / 工具栏按钮)。 * @param [IN] enabled 目标状态。 + * @param [IN] applyNow 打开时是否立即按当前视图执行一次 Y 轴自适应。 * @return 任一网格状态发生变化返回 true。 */ - bool SetAllLanesAutoFitToSelection(bool enabled); + bool SetAllLanesAutoFitToSelection(bool enabled, bool applyNow = true); /** * @brief 进入某网格 Y 轴上限/下限的内联输入状态。 @@ -601,23 +635,47 @@ namespace jdi void ShiftLaneRange(int laneIndex, double deltaValue); /** - * @brief 计算时间光标当前的横坐标。 + * @brief 计算指定时间光标当前的横坐标。 + * @param [IN] which 光标序号(1 或 2)。 * @return 横坐标。 */ - float CursorX() const; + float CursorX(int which) const; /** - * @brief 计算时间读数标签(或编辑框)的矩形。 - * @return 标签矩形,位于绘图区顶部以避免遮挡时间轴刻度。 + * @brief 计算指定光标时间读数标签的矩形。 + * @param [IN] which 光标序号(1 或 2)。 + * @return 标签矩形,位于绘图区底部(不遮挡网格内数据点标注)。 */ - gd::RectF CursorLabelRect() const; + gd::RectF CursorLabelRect(int which) const; /** - * @brief 判断给定横坐标是否落在时间光标附近(允许拖拽的容差范围)。 + * @brief 判断给定横坐标是否落在某条时间光标附近(允许拖拽的容差范围)。 * @param [IN] x 横坐标。 + * @param [OUT] which 命中的光标序号(1 或 2),未命中时为 0。 * @return 命中返回 true。 */ - bool IsOnCursorLine(int x) const; + bool IsOnCursorLine(int x, int& which) const; + + /** + * @brief 将两条光标的时间夹取到当前可见时间范围内。 + * + * 缩放/平移/自适应会改变可见区间,若不夹取,光标会落到视图之外并“消失”, + * 表现为“放大后无法再拖动光标”。 + */ + void ClampCursorToView(); + + /** + * @brief 获取指定光标对应的显示颜色。 + * @param [IN] which 光标序号(1 或 2)。 + * @return 颜色。 + */ + std::uint32_t CursorColor(int which) const; + + /** + * @brief 设置并显示一条临时提示(在绘图区底部以浮层方式绘制)。 + * @param [IN] text 提示文本;传空串则清除提示。 + */ + void SetHint(const std::wstring& text); /** * @brief 精确检测鼠标位置是否命中某条可见信号曲线。 @@ -703,6 +761,12 @@ namespace jdi */ void PaintSignalInteraction(gd::Graphics& g); + /** + * @brief 在绘图区底部绘制临时提示条(如“已放弃当前选区”)。 + * @param [IN,OUT] g 绘图上下文。 + */ + void PaintHint(gd::Graphics& g); + /** * @brief 绘制网格纵向滚动条。 * @param [IN,OUT] g 绘图上下文。 @@ -720,17 +784,6 @@ namespace jdi void PaintLaneLegend(gd::Graphics& g, int laneIndex, const LaneVisual& lane, float visibleTop, float visibleBottom); - /** - * @brief 在网格内绘制各信号的幅值读数。 - * @param [IN,OUT] g 绘图上下文。 - * @param [IN] laneIndex 网格下标。 - * @param [IN] lane 网格绘制信息。 - * @param [IN] visibleTop 网格在绘图区内的可见上边界。 - * @param [IN] visibleBottom 网格在绘图区内的可见下边界。 - */ - void PaintLaneReadout(gd::Graphics& g, int laneIndex, const LaneVisual& lane, float visibleTop, - float visibleBottom); - /** * @brief 判断给定矩形是否完整落在可见纵向区间内。 * @param [IN] rect 待测矩形。 @@ -747,15 +800,6 @@ namespace jdi */ void PaintEmptyState(gd::Graphics& g); - /** - * @brief 绘制全部网格的时间光标读数框。 - * - * 读数随时间光标实时变化,因此不能进入拖拽静态场景缓存,必须每帧单独绘制。 - * - * @param [IN,OUT] g 绘图上下文。 - */ - void PaintLaneReadouts(gd::Graphics& g); - const UiFonts* m_fonts = nullptr; /**< 字体资源。 */ const Theme* m_theme = nullptr; /**< 主题资源。 */ const DataSet* m_dataSet = nullptr; /**< 数据集合(只读访问)。 */ @@ -777,6 +821,7 @@ namespace jdi bool m_scrollBarVisible = false; /**< 是否需要显示滚动条。 */ PlotDragMode m_dragMode = PlotDragMode::NONE; /**< 当前拖拽模式。 */ + PlotRubberAxis m_rubberAxis = PlotRubberAxis::UNDECIDED; /**< 选区放大拖拽的轴向。 */ int m_dragLane = -1; /**< 拖拽所作用的网格下标。 */ bool m_dragMoved = false; /**< 拖拽过程中是否发生了位移。 */ int m_dragStartX = 0; /**< 拖拽起点横坐标。 */ @@ -790,7 +835,9 @@ namespace jdi int m_dragTargetLane = -1; /**< 拖拽信号时的目标网格下标。 */ int m_dragMouseX = 0; /**< 拖拽过程中鼠标的当前横坐标(虚影跟随)。 */ int m_dragMouseY = 0; /**< 拖拽过程中鼠标的当前纵坐标(虚影跟随)。 */ - bool m_cursorHover = false; /**< 鼠标是否悬停在时间光标上。 */ + bool m_cursorHover = false; /**< 鼠标是否悬停在某条时间光标上。 */ + int m_hoverCursor = 0; /**< 悬停/拖拽中的光标序号(1 或 2,0 表示无)。 */ + int m_editCursor = 1; /**< 内联输入所作用的 Time 光标序号。 */ PlotEditTarget m_editTarget = PlotEditTarget::NONE; /**< 当前内联输入目标。 */ TextEdit m_edit; /**< 输入框编辑模型(光标/选区/剪贴板)。 */ int m_editLane = -1; /**< 内联输入所作用的网格下标。 */ @@ -803,6 +850,7 @@ namespace jdi int m_hoverLane = -1; /**< 当前悬停网格下标。 */ int m_mouseX = 0; /**< 最近一次鼠标横坐标(用于悬停提示定位)。 */ int m_mouseY = 0; /**< 最近一次鼠标纵坐标(用于悬停提示定位)。 */ + std::wstring m_hintText; /**< 临时提示文本(空表示不显示)。 */ std::vector m_lanes; /**< 当前网格绘制信息集合。 */ /** diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 65bedaf..f96fbdd 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -45,6 +45,19 @@ namespace jdi const float kLegendItemGap = 14.0f; /**< 图例条目之间的间距。 */ const float kLegendMaxTextWidth = 200.0f; /**< 图例条目的文本宽度上限(超出则省略)。 */ + /** + * @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 输入值。 @@ -137,6 +150,11 @@ namespace jdi return Tab().CursorTime(); } + PlotCursorMode PlotView::CursorMode() const + { + return Tab().CursorMode(); + } + double PlotView::ViewMinTime() const { return Tab().ViewMin(); @@ -506,13 +524,15 @@ namespace jdi lane.autoRange = true; } } + ClampCursorToView(); BuildLaneVisuals(); } void PlotView::DisableCursor() { - Tab().CursorEnabled() = false; + Tab().CursorMode() = PlotCursorMode::NONE; m_cursorHover = false; + m_hoverCursor = 0; if (m_editTarget == PlotEditTarget::CURSOR_TIME) { EndEdit(false); @@ -523,7 +543,90 @@ namespace jdi } } - bool PlotView::CancelDrag() + 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; + SetHint(L"单光标:拖动竖线移动,双击竖线可输入精确时间"); + 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; + 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(); + m_cursorHover = true; + m_hoverCursor = 2; + SetHint(L"双光标:右侧光标显示当前值与 ±增量(右键光标可取消当前光标)"); + break; + } + case PlotCursorMode::DOUBLE: + default: + DisableCursor(); + SetHint(std::wstring()); + break; + } + return true; + } + + void PlotView::SetHint(const std::wstring& text) + { + m_hintText = text; + } + + void PlotView::ClampCursorToView() + { + 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; + Tab().CursorTime() = ClampDouble(Tab().CursorTime(), low + inset, high - inset); + Tab().CursorTimeAlt() = ClampDouble(Tab().CursorTimeAlt(), 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) { @@ -533,12 +636,18 @@ namespace jdi // 放弃当前拖拽:清空拖拽状态与尚未生效的缩放选区,不改动任何视图范围。 // 调用方仍持有鼠标捕获,松开左键时会走 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; } @@ -549,13 +658,13 @@ namespace jdi return false; } - // 退出全部缩放模式:工具栏的「时间轴缩放」「Y 轴缩放」按钮会在 - // Application::Render 中按 ZoomMode 同步为未选中态。 + // 退出缩放模式:工具栏的「选区放大」按钮会在 Application::Render 中同步为未选中态。 if (Tab().ZoomMode() == PlotZoomMode::NONE) { return false; } Tab().ZoomMode() = PlotZoomMode::NONE; + SetHint(std::wstring()); return true; } @@ -599,9 +708,17 @@ namespace jdi 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); + + ClampCursorToView(); BuildLaneVisuals(); return true; } @@ -696,21 +813,6 @@ namespace jdi return found; } - 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(); @@ -720,43 +822,64 @@ namespace jdi { 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() const + float PlotView::CursorX(int which) const { - return TimeToX(Tab().CursorTime()); + const double time = (which == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime(); + return TimeToX(time); } - gd::RectF PlotView::CursorLabelRect() const + gd::RectF PlotView::CursorLabelRect(int which) const { if (m_fonts == nullptr || m_fonts->monoSmall == nullptr) { return gd::RectF(); } - const std::wstring text = FormatValue(Tab().CursorTime()); + 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 = CursorX() - width * 0.5f; + // 时间读数改到绘图区**底部**:顶部留给网格图例与幅值标尺,避免互相遮挡。 + float left = CursorX(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 + 2.0f * m_scale; + 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) const + bool PlotView::IsOnCursorLine(int x, int& which) const { + which = 0; 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); + const float fx = static_cast(x); + + // 双光标重叠时优先命中光标 1,避免出现“抓不起来第二条”的情况。 + if (std::fabs(fx - CursorX(1)) <= tolerance) + { + which = 1; + return true; + } + if (Tab().CursorMode() == PlotCursorMode::DOUBLE && std::fabs(fx - CursorX(2)) <= tolerance) + { + which = 2; + return true; + } + return false; } bool PlotView::ApplyTimeSelection(float x0, float x1) @@ -789,6 +912,9 @@ namespace jdi Tab().ViewMin() = timeA; Tab().ViewMax() = timeB; ClampTimeView(); + // 视图范围变化后必须把光标夹回可见区间,否则光标会跑到视野外“消失”, + // 用户会看到“放大后光标无法再拖动”。 + ClampCursorToView(); // 「选区 Y 轴自适应」开关开启的网格:时间选区放大后自动把 Y 轴匹配到新的可见区间。 ApplySelectionAutoFit(); @@ -881,10 +1007,41 @@ namespace jdi } Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; lane.fitYToSelection = !lane.fitYToSelection; + if (lane.fitYToSelection) + { + // 打开时立即按当前视图自适应一次,否则当前量程不合适时看不到任何变化。 + AutoRangeLaneToView(laneIndex); + } return lane.fitYToSelection; } - bool PlotView::SetAllLanesAutoFitToSelection(bool enabled) + 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) { @@ -899,6 +1056,11 @@ namespace jdi changed = true; } } + if (enabled && applyNow) + { + // 全局打开时立刻让每个网格都自适应一次,保证界面立刻反映开关状态。 + ApplyAllAutoFitToSelectionNow(); + } return changed; } @@ -1337,12 +1499,14 @@ namespace jdi if (m_editTarget == PlotEditTarget::CURSOR_TIME) { - // 与显示态标签取相同宽度,避免进入编辑时框体跳动。 - const gd::RectF displayRect = CursorLabelRect(); + // 与显示态标签取相同宽度与位置(底部),避免进入编辑时框体跳动。 + const int which = (m_editCursor == 2) ? 2 : 1; + const gd::RectF displayRect = CursorLabelRect(which); width = std::max(width, displayRect.Width); - float left = CursorX() - width * 0.5f; + 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 + 2.0f * m_scale, width, height); + return gd::RectF(left, m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale, width, + height); } if (m_editTarget == PlotEditTarget::SIGNAL_NAME) @@ -1473,7 +1637,15 @@ namespace jdi 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); + 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; + } return true; } @@ -1656,10 +1828,13 @@ namespace jdi { case PlotDragMode::PAN: return LoadCursorW(nullptr, IDC_SIZEALL); - case PlotDragMode::RUBBER_TIME: + case PlotDragMode::RUBBER_SELECT: + // 选区放大:按当前轴向给出对应方向的光标提示。 + if (m_rubberAxis == PlotRubberAxis::VALUE) + { + return LoadCursorW(nullptr, IDC_SIZENS); + } 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: @@ -1676,9 +1851,12 @@ namespace jdi return LoadCursorW(nullptr, IDC_SIZENS); } - if (IsOnCursorLine(x) && UiPointInRect(m_area, 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))) @@ -1689,15 +1867,10 @@ namespace jdi return LoadCursorW(nullptr, IDC_SIZEALL); } - // 仅在缩放模式下才切换到对应的缩放光标,常规模式保持箭头(不出现十字指针)。 - const PlotZoomMode mode = Tab().ZoomMode(); - if (mode == PlotZoomMode::TIME) + // 选区放大模式下不固定方向,用四向光标表示“横向/纵向都可拉”。 + if (Tab().ZoomMode() == PlotZoomMode::SELECT) { - return LoadCursorW(nullptr, IDC_SIZEWE); - } - if (mode == PlotZoomMode::VALUE) - { - return LoadCursorW(nullptr, IDC_SIZENS); + return LoadCursorW(nullptr, IDC_SIZEALL); } } return nullptr; @@ -1733,6 +1906,7 @@ namespace jdi Tab().ViewMin() += delta; Tab().ViewMax() += delta; ClampTimeView(); + ClampCursorToView(); } const int laneIndex = m_dragLane; @@ -1756,26 +1930,41 @@ namespace jdi } return true; } - case PlotDragMode::RUBBER_TIME: + case PlotDragMode::RUBBER_SELECT: { - // 竖向选区贯穿整个绘图区高度,只取横向区间用于时间轴缩放。 - 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())) + const float dx = static_cast(x - m_dragStartX); + const float dy = static_cast(y - m_dragStartY); + + // 轴向由首次明显位移决定,之后固定不变(避免拖拽中途选区形态跳变)。 + if (m_rubberAxis == PlotRubberAxis::UNDECIDED) { - return false; + 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); } - 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; } @@ -1802,7 +1991,20 @@ namespace jdi { target = snapped; } - Tab().CursorTime() = target; + // 只允许在当前可见区间内移动,避免把光标拖到视野之外而“丢失”。 + const double low = Tab().ViewMin(); + const double high = Tab().ViewMax(); + const double inset = (high > low) ? (high - low) * 0.002 : 0.0; + target = ClampDouble(target, low + inset, high - inset); + + if (m_hoverCursor == 2 && Tab().CursorMode() == PlotCursorMode::DOUBLE) + { + Tab().CursorTimeAlt() = target; + } + else + { + Tab().CursorTime() = target; + } m_dragMoved = true; m_cursorHover = true; return true; @@ -1826,6 +2028,13 @@ namespace jdi bool changed = false; + // 网格内再次移动鼠标即认为用户已读到提示,把浮层收起(避免长期遮挡数据点取值)。 + if (!m_hintText.empty()) + { + m_hintText.clear(); + changed = true; + } + const int lane = LaneAtY(y); if (lane != m_hoverLane) { @@ -1834,12 +2043,18 @@ namespace jdi } // 时间光标不再跟随鼠标移动,仅在鼠标覆盖光标时才显示具体时间读数。 - const bool hover = Tab().CursorEnabled() && IsOnCursorLine(x); + 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; + } // 常规模式下精确检测鼠标是否贴近某条信号曲线,用于给出可拖拽提示。 // 图例优先于曲线:图例条目本身就是信号的可点选目标(与左侧信号列表一致)。 @@ -1876,6 +2091,9 @@ namespace jdi 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))) @@ -1895,11 +2113,15 @@ namespace jdi } // 靠近时间光标线时优先进入拖动,避免误触发平移或缩放选区。 - if (Tab().CursorEnabled() && IsOnCursorLine(x) && UiPointInRect(m_area, fx, fy)) { - m_dragMode = PlotDragMode::CURSOR_MOVE; - m_cursorHover = true; - return true; + int cursorWhich = 0; + if (IsOnCursorLine(x, cursorWhich) && UiPointInRect(m_area, fx, fy)) + { + m_dragMode = PlotDragMode::CURSOR_MOVE; + m_hoverCursor = cursorWhich; + m_cursorHover = true; + return true; + } } if (!UiPointInRect(m_plotRect, fx, fy)) @@ -1909,24 +2131,13 @@ namespace jdi } const PlotZoomMode mode = Tab().ZoomMode(); - if (mode == PlotZoomMode::TIME) + if (mode == PlotZoomMode::SELECT) { - 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); + // 选区放大:轴向由鼠标移动方向决定,因此按下时先记录网格并置为未定。 + m_dragLane = LaneAtY(y); + m_rubberAxis = PlotRubberAxis::UNDECIDED; + m_dragMode = PlotDragMode::RUBBER_SELECT; + SetHint(L"横向拖动放大时间轴,纵向拖动放大 Y 轴;右键放弃当前选区,Esc 退出"); return true; } @@ -1966,20 +2177,27 @@ namespace jdi switch (m_dragMode) { - case PlotDragMode::RUBBER_TIME: - if (m_dragMoved && - ApplyTimeSelection(m_rubberRect.X, m_rubberRect.X + m_rubberRect.Width)) + case PlotDragMode::RUBBER_SELECT: + { + // 按拖拽方向应用对应的缩放(横向 → 时间轴;纵向 → 当前网格幅值轴)。 + if (m_rubberAxis == PlotRubberAxis::TIME) { - changed = true; + 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)) + else if (m_rubberAxis == PlotRubberAxis::VALUE) { - changed = true; + 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; @@ -2002,6 +2220,7 @@ namespace jdi } m_dragMode = PlotDragMode::NONE; + m_rubberAxis = PlotRubberAxis::UNDECIDED; m_dragLane = -1; m_dragMoved = false; m_rubberRect = gd::RectF(); @@ -2049,9 +2268,15 @@ namespace jdi } // 命中时间光标:进入精确时间输入(交互与线宽输入框一致)。 - if (Tab().CursorEnabled() && IsOnCursorLine(x)) { - return BeginEdit(PlotEditTarget::CURSOR_TIME, -1, FormatValue(Tab().CursorTime())); + int cursorWhich = 0; + if (IsOnCursorLine(x, cursorWhich)) + { + m_editCursor = (cursorWhich == 2) ? 2 : 1; + const double time = + (m_editCursor == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime(); + return BeginEdit(PlotEditTarget::CURSOR_TIME, -1, FormatValue(time)); + } } // 命中图例条目:进入信号改名(与左侧列表双击改名的交互一致)。 @@ -2100,6 +2325,11 @@ namespace jdi m_cursorHover = false; changed = true; } + if (m_hoverCursor != 0 && m_dragMode != PlotDragMode::CURSOR_MOVE) + { + m_hoverCursor = 0; + changed = true; + } return changed; } @@ -2448,10 +2678,10 @@ namespace jdi if (SceneCacheMatches() && BlitSceneCache(g)) { PaintHoverHighlight(g); - PaintLaneReadouts(g); PaintRubberBand(g); PaintCursor(g, m_lanes); PaintSignalInteraction(g); + PaintHint(g); if (m_editTarget == PlotEditTarget::LANE_Y_MAX || m_editTarget == PlotEditTarget::LANE_Y_MIN) { @@ -2493,10 +2723,10 @@ namespace jdi CaptureSceneCache(g); PaintHoverHighlight(g); - PaintLaneReadouts(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) @@ -2835,15 +3065,20 @@ namespace jdi { return; } + if (m_dragMode != PlotDragMode::RUBBER_SELECT) + { + return; + } - const bool timeMode = (m_dragMode == PlotDragMode::RUBBER_TIME); - const bool valueMode = (m_dragMode == PlotDragMode::RUBBER_VALUE); + // 轴向未定时不绘制选区,避免刚按下就闪出一个奇怪的方框。 + const bool timeMode = (m_rubberAxis == PlotRubberAxis::TIME); + const bool valueMode = (m_rubberAxis == PlotRubberAxis::VALUE); if (!timeMode && !valueMode) { return; } - // 时间轴缩放用强调色,Y 轴缩放用警示色,便于区分两种模式。 + // 时间轴选区用强调色,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); @@ -2876,6 +3111,29 @@ namespace jdi 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->uiSmall == nullptr) + { + return; + } + + // 提示条贴在绘图区底部居中;时间光标的时间读数也在底部, + // 因此光标启用时把提示条上移一行,避免两者互相遮挡。 + const float reserved = (m_tab != nullptr && Tab().CursorEnabled()) ? 24.0f * m_scale : 0.0f; + const float width = UiTextWidth(g, m_hintText, m_fonts->uiSmall) + 20.0f * m_scale; + const float height = 22.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 - 2.0f * m_scale - reserved, + width, height); + + UiFillRoundRect(g, pill, 3.0f * m_scale, WithAlpha(m_theme->readoutBg, 0xF2)); + UiDrawRoundRect(g, pill, 3.0f * m_scale, m_theme->borderStrong, 1.0f); + UiText(g, m_hintText, m_fonts->uiSmall, pill, m_theme->textStrong, TextAlign::CENTER); + } + void PlotView::PaintLane(gd::Graphics& g, int laneIndex, const LaneVisual& lane, const std::vector& timeTicks) { @@ -3141,38 +3399,6 @@ namespace jdi g.Restore(saveState); } - void PlotView::PaintLaneReadouts(gd::Graphics& g) - { - // 读数框随时间光标实时变化,因此**不能**进入拖拽静态场景缓存, - // 必须每帧单独绘制(否则拖动光标时读数会停在旧值上)。 - if (m_tab == nullptr || !Tab().CursorEnabled()) - { - return; - } - - for (std::size_t index = 0; index < m_lanes.size(); ++index) - { - const LaneVisual& lane = m_lanes[index]; - if (lane.rect.Height < 24.0f * m_scale || lane.rect.Height < 40.0f * m_scale) - { - continue; - } - - 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; - } - - const int saveState = g.Save(); - g.SetClip(gd::RectF(m_area.X, clipTop, m_area.Width, clipBottom - clipTop)); - PaintLaneReadout(g, static_cast(index), lane, clipTop, clipBottom); - g.Restore(saveState); - } - } - SignalRef PlotView::LegendAtPoint(int x, int y) const { const float fx = static_cast(x); @@ -3350,92 +3576,6 @@ namespace jdi } } - 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.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->Title(); - if (multiFile) - { - line.text += L" · " + resolved.file->Title(); - } - 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); @@ -3511,86 +3651,183 @@ namespace jdi 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) + const bool doubleMode = (Tab().CursorMode() == PlotCursorMode::DOUBLE); + + // 双光标时统一以“时间较小者为左、较大者为右”,右侧光标负责显示 ±增量, + // 这样不论用户拖动哪一条,读数含义都稳定。 + double leftTime = Tab().CursorTime(); + double rightTime = Tab().CursorTimeAlt(); + int leftWhich = 1; + int rightWhich = 2; + if (doubleMode && rightTime < leftTime) { - return; + std::swap(leftTime, rightTime); + leftWhich = 2; + rightWhich = 1; } - // 光标纵向贯穿全部网格,并延伸到时间轴底部,便于与刻度对齐读数。 - 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 int cursorCount = doubleMode ? 2 : 1; - // 顶部三角把手,提示该线可以被拖拽。 + // ---- 光标竖线 + 顶部把手 ---- + for (int which = 1; which <= cursorCount; ++which) { + 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 active = (m_hoverCursor == which) || (m_dragMode == PlotDragMode::CURSOR_MOVE && 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, (active ? 2.0f : 1.4f) * m_scale); + + // 顶部三角把手,提示该线可以被拖拽。(光标 2 用朝下的把手与光标 1 区分) 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)); + if (which == 1) + { + points[0] = gd::PointF(x - arm, lineTop); + points[1] = gd::PointF(x + arm, lineTop); + points[2] = gd::PointF(x, lineTop + arm * 1.4f); + } + else + { + const float bottom = lineTop + arm * 1.4f + arm * 1.4f; + points[0] = gd::PointF(x - arm, bottom); + points[1] = gd::PointF(x + arm, bottom); + points[2] = gd::PointF(x, lineTop); + } + gd::SolidBrush handle(ToGdiColor(color)); g.FillPolygon(&handle, points, 3); } - // 各网格上的取值标记(仅在网格可见范围内绘制)。 - for (std::size_t laneIndex = 0; laneIndex < lanes.size(); ++laneIndex) + // ---- 直接在光标穿过的数据点上标注取值 ---- + // 每个网格里每条可见信号都标注一次,颜色与信号自身颜色对应; + // 双光标时右侧光标额外显示“当前值 , ±增量”。 + for (int pass = 0; pass < cursorCount; ++pass) { - const LaneVisual& lane = lanes[laneIndex]; - for (const SignalRef& ref : m_tab->Lanes()[laneIndex].signals) + const int which = doubleMode ? ((pass == 0) ? leftWhich : rightWhich) : 1; + const double time = (which == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime(); + const double otherTime = (which == 2) ? Tab().CursorTime() : Tab().CursorTimeAlt(); + const bool showDelta = doubleMode && (which == rightWhich); + const float x = TimeToX(time); + if (x < m_plotRect.X - 2.0f || x > m_plotRect.X + m_plotRect.Width + 2.0f) { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || resolved.style == nullptr || resolved.file == nullptr || - !resolved.visible) + continue; + } + + 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; } - double value = 0.0; - if (!SampleFileAt(*resolved.file, *resolved.data, cursorTime, - resolved.style->mode != DrawMode::SCATTER, value)) + for (const SignalRef& ref : m_tab->Lanes()[laneIndex].signals) { - continue; - } + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || resolved.style == nullptr || + resolved.file == nullptr || !resolved.visible) + { + 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 bool interpolate = (resolved.style->mode != DrawMode::SCATTER); + double value = 0.0; + if (!SampleFileAt(*resolved.file, *resolved.data, time, interpolate, value)) + { + continue; + } - const float radius = 3.4f * m_scale; - gd::SolidBrush ring(ToGdiColor(m_theme->cardBg)); - gd::SolidBrush fill(ToGdiColor(StyleColor(*resolved.style))); - g.FillEllipse(&ring, x - radius - 1.2f * m_scale, y - radius - 1.2f * m_scale, - (radius + 1.2f * m_scale) * 2.0f, (radius + 1.2f * m_scale) * 2.0f); - g.FillEllipse(&fill, x - radius, y - radius, radius * 2.0f, radius * 2.0f); + const 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)); + 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); + + // 取值标注:底色用信号色、文字用反色,保证在任何波形上都清晰可读, + // 并让“读数 ↔ 信号”一一对应。 + 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 = x + radius + 4.0f * m_scale; + if (labelLeft + textWidth > m_plotRect.X + m_plotRect.Width - 2.0f * m_scale) + { + labelLeft = x - 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); + UiText(g, text, m_fonts->monoSmall, labelRect, + dark ? 0xFFFDF6E3u : 0xFF073642u, TextAlign::CENTER); + } } } - // 具体时间显示在绘图区顶部(不遮挡时间轴刻度),且仅在鼠标覆盖光标时出现。 + // ---- 时间读数:改到绘图区底部 ---- if (m_editTarget == PlotEditTarget::CURSOR_TIME) { PaintEditField(g); return; } - if (!m_cursorHover) - { - return; - } - const gd::RectF labelRect = CursorLabelRect(); - if (labelRect.Width <= 0.0f) + // 时间读数固定在绘图区底部,单/双光标都始终显示, + // 这样不必把鼠标移到光线上就能读到时间。 + for (int which = 1; which <= cursorCount; ++which) { - return; + 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); + UiText(g, FormatValue(which == 2 ? Tab().CursorTimeAlt() : Tab().CursorTime()), + m_fonts->monoSmall, labelRect, + dark ? 0xFFFDF6E3u : 0xFF073642u, TextAlign::CENTER); } - - 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) diff --git a/Components/Toolbar/Inc/Toolbar.hpp b/Components/Toolbar/Inc/Toolbar.hpp index 836f3d1..7eb0d46 100644 --- a/Components/Toolbar/Inc/Toolbar.hpp +++ b/Components/Toolbar/Inc/Toolbar.hpp @@ -23,9 +23,8 @@ namespace jdi OPEN_FILE, /**< 打开 CSV 文件。 */ FIT_ALL, /**< 视图自适应到可见信号范围(同时退出缩放模式)。 */ AUTO_FIT_Y, /**< 切换「选区 Y 轴自适应」全局开关:时间轴选区放大后自动匹配各网格 Y 轴。 */ - ZOOM_TIME, /**< 切换「时间轴选区」模式(框选时间区间后放大)。 */ - ZOOM_VALUE, /**< 切换「Y 轴选区」模式(框选幅值区间后放大)。 */ - CURSOR, /**< 切换时间光标。 */ + ZOOM_SELECT, /**< 切换「选区放大」模式(横向拖拽放大时间轴,纵向拖拽放大该网格幅值)。 */ + CURSOR, /**< 三态循环切换时间光标:无 → 单光标 → 双光标。 */ MERGE_LANES, /**< 合并为单一网格。 */ SPLIT_LANES, /**< 按信号拆分为多网格。 */ CLEAR_ALL /**< 清空当前标签页的全部网格与信号勾选。 */ diff --git a/Components/Toolbar/Src/Toolbar.cpp b/Components/Toolbar/Src/Toolbar.cpp index 52f62b3..382041d 100644 --- a/Components/Toolbar/Src/Toolbar.cpp +++ b/Components/Toolbar/Src/Toolbar.cpp @@ -69,8 +69,7 @@ namespace jdi {ToolbarAction::OPEN_FILE, L"导入文件", true, false, false}, {ToolbarAction::FIT_ALL, L"适应视图", false, true, false}, {ToolbarAction::AUTO_FIT_Y, L"Y 轴自适应", false, true, true}, - {ToolbarAction::ZOOM_TIME, L"时间轴选区", false, true, true}, - {ToolbarAction::ZOOM_VALUE, L"Y 轴选区", false, true, true}, + {ToolbarAction::ZOOM_SELECT, L"选区放大", false, true, true}, {ToolbarAction::CURSOR, L"光标", false, true, true}, {ToolbarAction::MERGE_LANES, L"合并网格", false, true, false}, {ToolbarAction::SPLIT_LANES, L"拆分网格", false, true, false}, diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index 56ce8c1..820a6ca 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -27,8 +27,17 @@ namespace jdi enum class PlotZoomMode : int { NONE = 0, /**< 常规模式:拖拽平移时间轴,光标为箭头。 */ - TIME = 1, /**< 时间轴缩放:在绘图区拖拽竖向选区,放大时间范围。 */ - VALUE = 2 /**< Y 轴缩放:在某个网格内拖拽横向选区,放大该网格幅值范围。 */ + SELECT = 1 /**< 选区放大:横向拖拽放大时间轴,纵向拖拽放大当前网格的幅值范围。 */ + }; + + /** + * @brief 时间光标模式(由工具栏「光标」按钮三态循环切换)。 + */ + enum class PlotCursorMode : int + { + NONE = 0, /**< 不显示光标。 */ + SINGLE = 1, /**< 单光标。 */ + DOUBLE = 2 /**< 双光标(第二条用于与第一条对比取值)。 */ }; /** @@ -559,14 +568,20 @@ namespace jdi float& LaneScroll() { return m_laneScroll; } /// @brief 网格纵向滚动偏移量(只读)。 float LaneScroll() const { return m_laneScroll; } - /// @brief 时间光标是否启用(可读写)。 - bool& CursorEnabled() { return m_cursorEnabled; } - /// @brief 时间光标是否启用(只读)。 - bool CursorEnabled() const { return m_cursorEnabled; } - /// @brief 时间光标位置(可读写)。 + /// @brief 时间光标模式(可读写)。 + PlotCursorMode& CursorMode() { return m_cursorMode; } + /// @brief 时间光标模式(只读)。 + PlotCursorMode CursorMode() const { return m_cursorMode; } + /// @brief 是否启用了时间光标(单/双光标均为启用,只读)。 + bool CursorEnabled() const { return m_cursorMode != PlotCursorMode::NONE; } + /// @brief 光标 1(左侧)的时间位置(可读写)。 double& CursorTime() { return m_cursorTime; } - /// @brief 时间光标位置(只读)。 + /// @brief 光标 1(左侧)的时间位置(只读)。 double CursorTime() const { return m_cursorTime; } + /// @brief 光标 2 的时间位置(可读写,仅双光标模式使用)。 + double& CursorTimeAlt() { return m_cursorTimeAlt; } + /// @brief 光标 2 的时间位置(只读)。 + double CursorTimeAlt() const { return m_cursorTimeAlt; } /// @brief 绘图区拖拽缩放模式(可读写)。 PlotZoomMode& ZoomMode() { return m_zoomMode; } /// @brief 绘图区拖拽缩放模式(只读)。 @@ -582,8 +597,9 @@ namespace jdi double m_viewMax = 1.0; /**< 视图右边界时间。 */ bool m_viewInitialized = false; /**< 视图范围是否已初始化。 */ float m_laneScroll = 0.0f; /**< 网格纵向滚动偏移量。 */ - bool m_cursorEnabled = false; /**< 时间光标是否启用(由工具栏按钮切换)。 */ - double m_cursorTime = 0.0; /**< 时间光标位置。 */ + PlotCursorMode m_cursorMode = PlotCursorMode::NONE; /**< 时间光标模式(无/单/双)。 */ + double m_cursorTime = 0.0; /**< 光标 1(左侧)的时间位置。 */ + double m_cursorTimeAlt = 0.0; /**< 光标 2 的时间位置(仅双光标模式有效)。 */ PlotZoomMode m_zoomMode = PlotZoomMode::NONE; /**< 绘图区拖拽缩放模式。 */ }; diff --git a/Utilities/Inc/Theme.hpp b/Utilities/Inc/Theme.hpp index 2a2c494..6ffd790 100644 --- a/Utilities/Inc/Theme.hpp +++ b/Utilities/Inc/Theme.hpp @@ -102,7 +102,8 @@ namespace jdi std::uint32_t gridWeak = 0; /**< 次级网格线颜色。 */ std::uint32_t gridStrong = 0; /**< 主要网格线颜色。 */ std::uint32_t axisLine = 0; /**< 坐标轴线颜色。 */ - std::uint32_t cursor = 0; /**< 时间光标颜色。 */ + std::uint32_t cursor = 0; /**< 时间光标 1 的颜色。 */ + std::uint32_t cursorAlt = 0; /**< 时间光标 2 的颜色(与光标 1 区分且同一色系)。 */ std::uint32_t readoutBg = 0; /**< 数值读数底色。 */ std::uint32_t danger = 0; /**< 警示色。 */ std::uint32_t warning = 0; /**< 提示色。 */ diff --git a/Utilities/Src/Theme.cpp b/Utilities/Src/Theme.cpp index afc57d3..04a164f 100644 --- a/Utilities/Src/Theme.cpp +++ b/Utilities/Src/Theme.cpp @@ -62,6 +62,8 @@ namespace jdi theme.gridStrong = MakeArgb(0xC3B896u); theme.axisLine = MakeArgb(kBase1); theme.cursor = MakeArgb(kOrange); + // 第二光标用 Solarized 青色:与橙色同族饱和度、对比清晰又不刺眼。 + theme.cursorAlt = MakeArgb(kCyan); theme.readoutBg = MakeArgb(0xF6EFDCu); theme.danger = MakeArgb(kRed); theme.warning = MakeArgb(kYellow);