diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp index 9b6abbc..bdabe9f 100644 --- a/Components/Application/Inc/Application.hpp +++ b/Components/Application/Inc/Application.hpp @@ -340,6 +340,7 @@ namespace jdi float m_panelWidth = 366.0f; /**< 面板宽度(逻辑像素)。 */ bool m_draggingSplitter = false; /**< 是否正在拖动分隔条。 */ + bool m_middlePanning = false; /**< 是否正在用鼠标中键自由平移绘图区。 */ int m_splitterGrabOffset = 0; /**< 分隔条拖动的抓取偏移。 */ HDC m_backDc = nullptr; /**< 后备缓冲设备上下文。 */ diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index 98a45f0..646ea94 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -1441,9 +1441,15 @@ namespace jdi fitLaneTime.id = PLOT_CMD_LANE_FIT_TIME; items.push_back(fitLaneTime); + // 「拆分当前网格」:把该网格内的信号按个拆成独立网格,插到其下方依次排列。 + MenuItem splitLane; + splitLane.label = L"拆分当前网格"; + splitLane.id = PLOT_CMD_LANE_SPLIT; + splitLane.enabled = m_plot.LaneSignalCount(laneIndex) > 1; + items.push_back(splitLane); + // 实验性功能(二级菜单):横线上方,鼠标悬停即向右展开。 - MenuItem experimental; - experimental.kind = MenuItemKind::SUBMENU; + MenuItem experimental; experimental.kind = MenuItemKind::SUBMENU; experimental.label = L"实验性功能"; { MenuItem edgeCount; @@ -1567,6 +1573,10 @@ namespace jdi // 「实验性功能 → 边沿计数(中心轴)」:切换中心平均值曲线与统计面板。 m_plot.ToggleLaneEdgeCenterCount(laneIndex); break; + case PLOT_CMD_LANE_SPLIT: + // 「拆分当前网格」:信号按个拆成独立网格,新网格顺次插入其下方。 + m_plot.SplitLane(laneIndex); + break; case PLOT_CMD_FIT_ALL: m_plot.FitAll(); break; @@ -1883,7 +1893,7 @@ namespace jdi const int x = GET_X_LPARAM(lParam); const int y = GET_Y_LPARAM(lParam); - const bool buttonDown = (wParam & (MK_LBUTTON | MK_RBUTTON)) != 0; + const bool buttonDown = (wParam & (MK_LBUTTON | MK_RBUTTON | MK_MBUTTON)) != 0; if (HandleMouseMove(x, y, buttonDown)) { InvalidateRect(hwnd, nullptr, FALSE); @@ -2060,6 +2070,44 @@ namespace jdi InvalidateRect(hwnd, nullptr, TRUE); return 0; } + case WM_MBUTTONDOWN: + { + // 鼠标中键:在绘图区内按下即进入自由平移(任何缩放模式下都可用)。 + const int x = GET_X_LPARAM(lParam); + const int y = GET_Y_LPARAM(lParam); + if (m_plot.IsEditing()) + { + // 内联输入中:中键视为提交(与左键点外一致)。 + m_plot.OnEditKey(VK_RETURN); + ApplyPendingRename(); + } + if (m_plot.HitTest(x, y)) + { + SetCapture(hwnd); + m_middlePanning = true; + m_activeTarget = Target::PLOT; + if (m_plot.OnMButtonDown(x, y)) + { + ApplyPendingRename(); + InvalidateRect(hwnd, nullptr, FALSE); + } + UpdateDragCursor(x, y); + } + return 0; + } + case WM_MBUTTONUP: + { + if (m_middlePanning) + { + m_middlePanning = false; + m_plot.OnMButtonUp(); + ReleaseCapture(); + m_activeTarget = Target::NONE; + UpdateDragCursor(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + InvalidateRect(hwnd, nullptr, TRUE); + } + return 0; + } case WM_LBUTTONDBLCLK: { const int x = GET_X_LPARAM(lParam); diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index d11b447..f032cd8 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -68,7 +68,8 @@ namespace jdi PLOT_CMD_LANE_FIT_TIME = 5, /**< 时间轴自适应到本网格信号范围(按网格时间轴展开)。 */ PLOT_CMD_LANE_AUTO_VIEW = 6, /**< 切换本网格的「选区 Y 轴自适应」开关。 */ PLOT_CMD_LANE_EDGE_COUNT = 7, /**< 切换本网格的「实验性功能 → 边沿计数」开关。 */ - PLOT_CMD_LANE_EDGE_CENTER = 8 /**< 切换本网格的「实验性功能 → 边沿计数(中心轴)」开关。 */ + PLOT_CMD_LANE_EDGE_CENTER = 8, /**< 切换本网格的「实验性功能 → 边沿计数(中心轴)」开关。 */ + PLOT_CMD_LANE_SPLIT = 9 /**< 拆分当前网格:把网格内信号按个拆成独立网格并顺次下移编号。 */ }; /** @@ -149,6 +150,22 @@ namespace jdi */ bool OnLButtonUp(int x, int y); + /** + * @brief 处理鼠标中键按下:与左键在常规模式下一致,进入自由平移(水平平移时间轴 + + * 纵向偏移当前网格幅值)。**任何缩放模式下都可用**,用于在不改变当前模式的 + * 前提下自由查看区域。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 需要重绘返回 true。 + */ + bool OnMButtonDown(int x, int y); + + /** + * @brief 处理鼠标中键抬起:结束中键平移。 + * @return 需要重绘返回 true。 + */ + bool OnMButtonUp(); + /** * @brief 处理鼠标滚轮(仅用于平移与网格滚动,不再承担缩放)。 * @param [IN] x 客户区横坐标。 @@ -180,6 +197,20 @@ namespace jdi */ bool FitTimeToLane(int laneIndex); + /** + * @brief 「拆分当前网格」:把该网格内的信号按个拆分成独立网格,新网格依次排在其下方。 + * @param [IN] laneIndex 网格下标。 + * @return 拆分成功返回 true。 + */ + bool SplitLane(int laneIndex); + + /** + * @brief 查询某网格内的信号数量(用于菜单项可用状态判定)。 + * @param [IN] laneIndex 网格下标。 + * @return 信号数量,网格不存在时返回 0。 + */ + std::size_t LaneSignalCount(int laneIndex) const; + /** * @brief 依据当前显示时间段内的数据对该网格做 Y 轴自适应(“选区 Y 轴自适应”)。 * diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index be85697..95a205d 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -965,9 +965,36 @@ namespace jdi return true; } - bool PlotView::SampleSpacingPixels(const DataFile& file, double time, double& pixels) const + bool PlotView::SplitLane(int laneIndex) { - pixels = 0.0; + if (m_tab == nullptr) + { + return false; + } + // 拆分由 TabConfig 完成(含网格自身状态的继承与编号刷新)。 + const int first = m_tab->SplitLane(laneIndex); + if (first < 0) + { + return false; + } + // 网格数量变化 → 几何与滚动范围需要重建,静态场景缓存也要失效。 + BuildLaneVisuals(); + InvalidateSceneCache(); + return true; + } + + std::size_t PlotView::LaneSignalCount(int laneIndex) const + { + if (m_tab == nullptr || laneIndex < 0 || + laneIndex >= static_cast(m_tab->Lanes().size())) + { + return 0; + } + return m_tab->Lanes()[static_cast(laneIndex)].signals.size(); + } + + bool PlotView::SampleSpacingPixels(const DataFile& file, double time, double& pixels) const + { pixels = 0.0; const std::vector& samples = file.time; if (samples.size() < 2 || m_plotRect.Width <= 1.0f) @@ -2999,8 +3026,10 @@ namespace jdi bool changed = false; - // 鼠标压在实验性功能面板上时不再触发信号悬停提示(面板本来就盖住波形)。 + // 鼠标压在实验性功能面板上时不再触发信号悬停提示(面板本来就盖住波形), + // 但该面板所属的网格仍然要高亮(与鼠标直接停在网格上一致)。 bool overEdgePanel = false; + int panelLane = -1; for (int pass = 0; pass < 2 && !overEdgePanel; ++pass) { const bool centerAxis = (pass == 1); @@ -3011,12 +3040,14 @@ namespace jdi UiPointInRect(panel, static_cast(x), static_cast(y))) { overEdgePanel = true; + panelLane = index; break; } } } - const int lane = LaneAtY(y); + // 悬停网格:鼠标在面板上时高亮面板所属网格(用户明确要求)。 + const int lane = overEdgePanel ? panelLane : LaneAtY(y); if (lane != m_hoverLane) { m_hoverLane = lane; @@ -3037,15 +3068,16 @@ namespace jdi changed = true; } - // 常规模式下精确检测鼠标是否贴近某条信号曲线,用于给出可拖拽提示。 + // 精确检测鼠标是否贴近某条信号曲线,用于给出可拖拽提示。 // 图例优先于曲线:图例条目是可点选目标(与左侧信号列表一致), // 但悬停图例时**只做半高亮提示**,不显示曲线拖拽提示(也不触发光晕)。 + // **图例悬停在选区放大模式下同样生效**(该模式只改变拖拽语义)。 SignalRef hoverSignal; SignalRef hoverLegend; - if (Tab().ZoomMode() == PlotZoomMode::NONE && !m_cursorHover && !overEdgePanel) + if (!m_cursorHover && !overEdgePanel) { hoverLegend = LegendAtPoint(x, y); - if (!hoverLegend.IsValid()) + if (!hoverLegend.IsValid() && Tab().ZoomMode() == PlotZoomMode::NONE) { hoverSignal = HitTestSignal(x, y); } @@ -3235,6 +3267,19 @@ namespace jdi return false; } + // 点击图例条目等价于在左侧列表中选中该信号(样式区随之切换目标)。 + // **必须在选区放大判定之前**:选区放大模式只改变拖拽语义, + // 不应影响图例的悬停与点选(否则用户在选区放大模式下无法切换信号)。 + { + const SignalRef legendHit = LegendAtPoint(x, y); + if (legendHit.IsValid()) + { + SelectSignal(legendHit); + m_dragMode = PlotDragMode::NONE; + return true; + } + } + const PlotZoomMode mode = Tab().ZoomMode(); if (mode == PlotZoomMode::SELECT) { @@ -3246,18 +3291,6 @@ namespace jdi return true; } - // 常规模式下:点击图例条目等价于在左侧列表中选中该信号(样式区随之切换目标)。 - if (mode == PlotZoomMode::NONE) - { - const SignalRef legendHit = LegendAtPoint(x, y); - if (legendHit.IsValid()) - { - SelectSignal(legendHit); - m_dragMode = PlotDragMode::NONE; - return true; - } - } - // 常规模式下:先检测是否落在信号曲线上;只有命中提示时才允许拖拽该信号。 const SignalRef hit = HitTestSignal(x, y); if (hit.IsValid()) @@ -3276,9 +3309,47 @@ namespace jdi return true; } - bool PlotView::OnLButtonUp(int x, int y) + bool PlotView::OnMButtonDown(int x, int y) { - bool changed = (m_dragMode != PlotDragMode::NONE); + if (!HasDrawableContent()) + { + return false; + } + + // 中键:无论当前是否处于选区放大模式,都直接进入自由平移 + // (不改变选区放大模式本身,松手后仍可继续框选)。 + m_dragStartX = x; + m_dragStartY = y; + m_dragLastX = x; + m_dragLastY = y; + m_dragMoved = false; + m_rubberRect = gd::RectF(); + m_rubberAxis = PlotRubberAxis::UNDECIDED; + m_cursorGrabOffset = 0; + m_edgeDragLane = -1; + m_edgePanelDragLane = -1; + m_edgePanelDragCenter = false; + SetHint(std::wstring()); + m_dragLane = LaneAtY(y); + m_dragMode = PlotDragMode::PAN; + return true; + } + + bool PlotView::OnMButtonUp() + { + const bool changed = (m_dragMode == PlotDragMode::PAN); + if (changed) + { + m_dragMode = PlotDragMode::NONE; + m_dragLane = -1; + m_dragMoved = false; + ReleaseHint(); + } + return changed; + } + + bool PlotView::OnLButtonUp(int x, int y) + { bool changed = (m_dragMode != PlotDragMode::NONE); switch (m_dragMode) { @@ -5153,14 +5224,56 @@ namespace jdi const float centerY = ValueToY(centerValue, lane); if (centerY >= clipTop - 2.0f && centerY <= clipBottom + 2.0f) { - g.FillEllipse(&ring, pinX - radius - 1.2f * m_scale, - centerY - radius - 1.2f * m_scale, - (radius + 1.2f * m_scale) * 2.0f, - (radius + 1.2f * m_scale) * 2.0f); - g.FillEllipse(&fill, pinX - radius, centerY - radius, radius * 2.0f, - radius * 2.0f); + // 中心轴数据点与常规数据点一样:在可视区内是圆点, + // **光标在可视区之外(贴边显示)时同样改为三角**指示方向。 + const std::uint32_t centerColor = CursorColor(1); + gd::SolidBrush centerFill(ToGdiColor(centerColor)); + if (pinned) + { + const float arm = 5.0f * m_scale; + const bool offLeft = (rawX < pinX); + const float baseX = offLeft ? (pinX - arm) : (pinX + arm); + gd::PointF tri[3]; + tri[0] = gd::PointF(baseX, centerY - arm); + tri[1] = gd::PointF(baseX, centerY + arm); + tri[2] = gd::PointF(pinX, centerY); + g.FillPolygon(&ring, tri, 3); + gd::PointF inner[3]; + inner[0] = gd::PointF(baseX + (offLeft ? 1.4f : -1.4f) * m_scale, + centerY - arm * 0.72f); + inner[1] = gd::PointF(baseX + (offLeft ? 1.4f : -1.4f) * m_scale, + centerY + arm * 0.72f); + inner[2] = gd::PointF(pinX - (offLeft ? -1.4f : 1.4f) * m_scale, centerY); + g.FillPolygon(¢erFill, inner, 3); + } + else + { + g.FillEllipse(&ring, pinX - radius - 1.2f * m_scale, + centerY - radius - 1.2f * m_scale, + (radius + 1.2f * m_scale) * 2.0f, + (radius + 1.2f * m_scale) * 2.0f); + g.FillEllipse(¢erFill, pinX - radius, centerY - radius, + radius * 2.0f, radius * 2.0f); + } + + // 取值文本:数值在前,尾缀「(中心轴)」;双光标的右侧光标还要 + // 附上增量,形如「(+0.0008 中心轴)」(参考常规信号的增量显示)。 + std::wstring centerText = FormatValue(centerValue); + centerText += L"("; + if (showDelta) + { + double otherCenter = 0.0; + if (CenterValueAt(*resolved.file, *resolved.data, smooth, otherTime, + otherCenter)) + { + const double delta = centerValue - otherCenter; + centerText += (delta < 0.0) ? L"-" : L"+"; + centerText += FormatValue(std::fabs(delta)); + centerText += L" "; + } + } + centerText += L"中心轴)"; - const std::wstring centerText = L"中 " + FormatValue(centerValue); const float centerTextWidth = UiTextWidth(g, centerText, m_fonts->monoSmall) + 10.0f * m_scale; float centerLeft = pinX + radius + 4.0f * m_scale; @@ -5179,11 +5292,13 @@ namespace jdi std::min(centerTop, clipBottom - textHeight - 1.0f * m_scale)); gd::RectF centerRect(centerLeft, centerTop, centerTextWidth, textHeight); PlaceCursorLabel(centerRect, centerTop, textHeight, clipTop, clipBottom); + // 颜色样式与光标一致(同底色/同描边/同文字配色)。 + const bool centerDark = IsDarkColor(centerColor); UiFillRoundRect(g, centerRect, 3.0f * m_scale, - WithAlpha(signalColor, 0xB8)); - DrawLabelOutline(g, centerRect, 3.0f * m_scale, dark); + WithAlpha(centerColor, 0xE0)); + DrawLabelOutline(g, centerRect, 3.0f * m_scale, centerDark); UiText(g, centerText, m_fonts->monoSmall, centerRect, - dark ? 0xFFFDF6E3u : 0xFF073642u, TextAlign::CENTER); + centerDark ? 0xFFFDF6E3u : 0xFF073642u, TextAlign::CENTER); } } } @@ -5512,11 +5627,9 @@ namespace jdi return; } - // 中心平均值曲线与原信号同色,若直接用原色会被波形洩没; - // 根据背景明暗把信号色向黑/白方向拉远,保证叠加后清晰可辨。 - const std::uint32_t derived = - IsDarkColor(m_theme->plotBg) ? MixColor(StyleColor(*resolved.style), 0xFFFFFFFFu, 0.62f) - : MixColor(StyleColor(*resolved.style), 0xFF000000u, 0.58f); + // 中心平均值曲线使用与光标(「边沿计数」横向光标、中心轴取值胶囊)一致的颜色样式: + // 即光标的强调色。曲线与其取值标注、光标本身因此形成统一的视觉语言。 + const std::uint32_t derived = CursorColor(1); gd::Pen pen(ToGdiColor(derived), std::max(1.8f, resolved.style->lineWidth + 0.8f) * m_scale); pen.SetLineJoin(gd::LineJoinRound); pen.SetStartCap(gd::LineCapRound); diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index 8f4d420..b014e5f 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -567,6 +567,18 @@ namespace jdi */ int MoveSignalToLane(const DataSet& dataSet, const SignalRef& ref, int laneIndex); + /** + * @brief 「拆分当前网格」:把指定网格内的信号按个拆分成独立网格。 + * + * 新网格插入在原网格的**下方依次排列**(原位置 + 1 起),原本在其下方的网格 + * 顺次后移。由于网格编号(GX)完全由位置推导,拆分后所有引用该编号的地方 + * (左侧列表徽标、图例徽标、面板标题、右键菜单标题)会自动保持一致。 + * + * @param [IN] laneIndex 待拆分的网格下标。 + * @return 拆分后该网格内第一条信号所在的网格下标;无法拆分(不存在或只有 1 条信号)返回 -1。 + */ + int SplitLane(int laneIndex); + /** * @brief 查询某文件的分组是否处于折叠状态。 * @param [IN] fileIndex 文件下标。 diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index 746d620..63100f8 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -210,12 +210,50 @@ namespace jdi return fileVisible[static_cast(ref.signalIndex)]; }; + // 某信号当前所属的网格下标(-1 表示尚未分配网格)。 + const auto laneIndexOf = [&lanes](const SignalRef& ref) + { + for (std::size_t index = 0; index < lanes.size(); ++index) + { + const std::vector& refs = lanes[index].signals; + if (std::find(refs.begin(), refs.end(), ref) != refs.end()) + { + return static_cast(index); + } + } + return -1; + }; + if (mode == LaneMode::PER_SIGNAL) { // 每条可见信号独占一个网格:网格数量严格跟随可见信号数量。 - lanes.clear(); - lanes.reserve(visibleRefs.size()); + // **顺序按"原有网格位置"优先**:已经分配过网格的信号保持其相对次序, + // 新勾选(此前没有网格)的信号统一追加到最下方。 + // 若直接按 visibleRefs(文件/列序)重建,新勾选的信号会按其列序 + // 插到中间,位置看起来"不确定"。 + std::vector ordered; + ordered.reserve(visibleRefs.size()); for (const SignalRef& ref : visibleRefs) + { + if (laneIndexOf(ref) >= 0) + { + ordered.push_back(ref); + } + } + std::stable_sort(ordered.begin(), ordered.end(), + [&laneIndexOf](const SignalRef& a, const SignalRef& b) + { return laneIndexOf(a) < laneIndexOf(b); }); + for (const SignalRef& ref : visibleRefs) + { + if (laneIndexOf(ref) < 0) + { + ordered.push_back(ref); + } + } + + lanes.clear(); + lanes.reserve(ordered.size()); + for (const SignalRef& ref : ordered) { Lane lane; lane.autoRange = true; @@ -1169,9 +1207,64 @@ namespace jdi return LaneOfSignal(ref); } - bool TabConfig::IsCollapsed(int fileIndex) const + int TabConfig::SplitLane(int laneIndex) { - if (fileIndex < 0 || static_cast(fileIndex) >= m_collapsed.size()) + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + return -1; + } + const std::vector signals = m_lanes[static_cast(laneIndex)].signals; + if (signals.size() <= 1) + { + // 只有一条信号时无需拆分(本就独占一个网格)。 + return -1; + } + + // 手动排布 → 切换到自定义划分策略。 + m_laneMode = LaneMode::CUSTOM; + + // 用原网格的“网格自身状态”(Y 轴自适应/边沿计数等)作为每一份的初始状态, + // 这样拆分出来的网格保持原网格的显示设置。 + const Lane source = m_lanes[static_cast(laneIndex)]; + + std::vector pieces; + pieces.reserve(signals.size()); + for (const SignalRef& ref : signals) + { + Lane lane; + lane.autoRange = source.autoRange; + lane.yMin = source.yMin; + lane.yMax = source.yMax; + lane.fitYToSelection = source.fitYToSelection; + lane.edgeCount = source.edgeCount; + lane.edgeValue = source.edgeValue; + lane.edgeValueValid = source.edgeValueValid; + lane.edgePanelOpen = source.edgePanelOpen; + lane.edgePanelX = source.edgePanelX; + lane.edgePanelY = source.edgePanelY; + lane.edgeCenter = source.edgeCenter; + lane.edgeCenterSmooth = source.edgeCenterSmooth; + lane.edgeCenterPanelOpen = source.edgeCenterPanelOpen; + lane.edgeCenterPanelX = source.edgeCenterPanelX; + lane.edgeCenterPanelY = source.edgeCenterPanelY; + lane.signals.push_back(ref); + pieces.push_back(std::move(lane)); + } + + // 用 pieces 替换原网格(插入到原位置,因此其后方的网格整体顺次后移)。 + m_lanes.erase(m_lanes.begin() + laneIndex); + m_lanes.insert(m_lanes.begin() + laneIndex, pieces.begin(), pieces.end()); + + // 统一重命名,保证编号连续(GX 编号在各显示位置均由此位置推导)。 + for (std::size_t index = 0; index < m_lanes.size(); ++index) + { + m_lanes[index].name = L"网格 " + std::to_wstring(index + 1); + } + return laneIndex; + } + + bool TabConfig::IsCollapsed(int fileIndex) const + { if (fileIndex < 0 || static_cast(fileIndex) >= m_collapsed.size()) { return false; }