diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 8d81abe..633d6bc 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -941,6 +942,33 @@ namespace jdi */ bool ToggleLaneFreeMarker(int laneIndex); + /** + * @brief 设置某网格「自由标记」的**光标模式**(浮动面板最下方的切换按钮)。 + * + * 关闭(默认)时鼠标必须贴近信号曲线(稀疏判定下需靠近数据点)才显示取值预览, + * 鼠标下方没有曲线时按下即普通的平移/框选;开启时与最初实现一致 —— + * 网格内始终跟随鼠标画一条纵向光标。 + * + * @param [IN] laneIndex 网格下标。 + * @param [IN] enabled 是否启用光标模式。 + * @return 状态发生变化返回 true。 + */ + bool SetLaneFreeMarkerCursor(int laneIndex, bool enabled); + + /** + * @brief 查询某网格是否处于「自由标记」的光标模式。 + * @param [IN] laneIndex 网格下标。 + * @return 启用返回 true(该网格未开启自由标记时恒为 false)。 + */ + bool LaneFreeMarkerCursor(int laneIndex) const; + + /** + * @brief 切换某网格「自由标记」的光标模式。 + * @param [IN] laneIndex 网格下标。 + * @return 切换后的状态。 + */ + bool ToggleLaneFreeMarkerCursor(int laneIndex); + /** * @brief 直接把文本解析为网格 Y 轴上限/下限并应用(供菜单内联输入框提交使用)。 * @param [IN] laneIndex 网格下标。 @@ -1639,12 +1667,26 @@ namespace jdi */ std::vector rowNameRects; /** - * @brief 「自由标记」时间列的两条右边界(`t:` 列与 `Δt:` 列)。 + * @brief 「自由标记」面板的四列文本区(① 取值 ② 增量 ③ 时刻 ④ 间隔)。 * - * 两列各自右对齐 ⇒ 所有条目上下对齐;首项没有 `Δt` 时该列留白。 + * 每行都在这四列的**同一横坐标**上左对齐输出,因此同一信息在纵向上严格对齐 + *(用户 2026-10-20 明确要求:"将每个信息元素纵向对齐")。 + * 列宽固定(不按文本测量),绘制时用 `UiEllipsize` 截断,避免布局期查询采样。 */ - float markerTimeRight = 0.0f; - float markerDeltaRight = 0.0f; + struct MarkerColumn + { + float left = 0.0f; + float width = 0.0f; + }; + std::array markerColumns{}; + /** 四列的语义下标(`markerColumns` 的索引)。 */ + enum MarkerColumnIndex + { + MARKER_COL_VALUE = 0, + MARKER_COL_DELTA = 1, + MARKER_COL_TIME = 2, + MARKER_COL_DT = 3 + }; }; /** @@ -1660,10 +1702,16 @@ namespace jdi * * 它描述「鼠标此刻所在的、开启了自由标记的网格」里应当标注的那个数据点: * 该网格内所有可见信号中**离鼠标最近**的那一个。 + * + * 两种模式由 `Lane::freeMarkerCursor` 决定: + * * **默认(非光标模式)**:只有鼠标**贴近某条信号曲线**(或该信号命中「自动点线」 + * 稀疏判定、且鼠标靠近某个数据点)时才有结果(`valid == true`),也**不画**纵向光标; + * * **光标模式**:网格内始终 `valid == true`(画纵向光标),能取到数据点时再标注取值。 */ struct FreeMarkerProbe { - bool valid = false; /**< 鼠标是否落在某个开启了自由标记的网格内。 */ + bool valid = false; /**< 是否有结果(`ref` 有效,或光标模式下恒真)。 */ + bool cursorMode = false; /**< 该网格是否处于「光标模式」(决定是否画纵向光标)。 */ int laneIndex = -1; /**< 该网格下标。 */ double time = 0.0; /**< 标注时刻(显示时刻;吸附时 = 采样时刻)。 */ float x = 0.0f; /**< 该时刻的屏幕横坐标。 */ diff --git a/Components/PlotView/Src/PlotViewFeatures.cpp b/Components/PlotView/Src/PlotViewFeatures.cpp index 030dac7..19394a3 100644 --- a/Components/PlotView/Src/PlotViewFeatures.cpp +++ b/Components/PlotView/Src/PlotViewFeatures.cpp @@ -1655,6 +1655,44 @@ namespace jdi return enabled; } + bool PlotView::SetLaneFreeMarkerCursor(int laneIndex, bool enabled) + { + 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.freeMarker || lane.freeMarkerCursor == enabled) + { + return false; + } + lane.freeMarkerCursor = enabled; + // 光标模式的可见结果依赖鼠标位置,模式一变必须让已缓存的探针立刻作废, + // 否则关闭后仍会残留一条纵向光标(或开启后要等下一次移动才出现)。 + if (m_freeMarker.laneIndex == laneIndex) + { + m_freeMarker = FreeMarkerProbe(); + } + return true; + } + + bool PlotView::LaneFreeMarkerCursor(int laneIndex) const + { + if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) + { + return false; + } + const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + return lane.freeMarker && lane.freeMarkerCursor; + } + + bool PlotView::ToggleLaneFreeMarkerCursor(int laneIndex) + { + const bool enabled = !LaneFreeMarkerCursor(laneIndex); + SetLaneFreeMarkerCursor(laneIndex, enabled); + return enabled; + } + bool PlotView::MeanFilterValueAt(const TimeAxis& axis, const SignalData& signal, int points, int iterations, double time, double& value) const { diff --git a/Components/PlotView/Src/PlotViewInteraction.cpp b/Components/PlotView/Src/PlotViewInteraction.cpp index 04a0643..f8975cb 100644 --- a/Components/PlotView/Src/PlotViewInteraction.cpp +++ b/Components/PlotView/Src/PlotViewInteraction.cpp @@ -956,6 +956,12 @@ namespace jdi // 「自由标记」面板:行尾 ✕ 与信号名右侧 ✕ 都是可点击控件(手型)。 if (static_cast(panelKind) == EdgePanelKind::MARK) { + // 底部「启用光标模式 / 关闭光标模式」切换按钮。 + if (layout.button.Width > 0.0f && + UiPointInRect(layout.button, static_cast(x), static_cast(y))) + { + return LoadCursorW(nullptr, IDC_HAND); + } for (const EdgePanelLayout::MarkerRow& row : layout.markerRows) { if (row.close.Width > 0.0f && @@ -1418,6 +1424,12 @@ namespace jdi lane.weakPeakPanel.x = left; lane.weakPeakPanel.y = top; break; + case EdgePanelKind::MARK: + // ⚠️ 漏掉这一支会让「自由标记」面板**拖不动**(位置写进了 edgePanel, + // 下一帧又从 freeMarkerPanel 读到旧值 ⇒ 面板纹丝不动)。 + lane.freeMarkerPanel.x = left; + lane.freeMarkerPanel.y = top; + break; case EdgePanelKind::COUNT: default: lane.edgePanel.x = left; @@ -1436,9 +1448,22 @@ namespace jdi // ---- 「自由标记」:跟随光标解析 + 两处 ✕ 的悬停态 ---- // 跟随光标是**每帧随鼠标变化**的叠加层(不进任何缓存),因此只要鼠标位置变了就得重绘。 + // + // ⚠️ 鼠标压在浮动面板上时:**不画**网格内的自由光标、也**不**做任何自由标记交互 + //(用户 2026-10-20 明确要求:"当鼠标处于浮动面板上时网格不绘制自由光标, + // 用户操作浮动面板时的操作不要穿透到网格上")。 + bool freeMarkerOverPanel = false; { - const FreeMarkerProbe probe = ProbeFreeMarker(x, y); + int freePanelLane = -1; + int freePanelKind = 0; + freeMarkerOverPanel = TopPanelAtPoint(static_cast(x), static_cast(y), + freePanelLane, freePanelKind); + } + { + const FreeMarkerProbe probe = + freeMarkerOverPanel ? FreeMarkerProbe() : ProbeFreeMarker(x, y); const bool probeChanged = (probe.valid != m_freeMarker.valid) || + (probe.cursorMode != m_freeMarker.cursorMode) || (probe.laneIndex != m_freeMarker.laneIndex) || (probe.time != m_freeMarker.time) || (probe.snapped != m_freeMarker.snapped) || @@ -1454,14 +1479,17 @@ namespace jdi { int hoverLane = -1; std::size_t hoverItem = static_cast(-1); - for (const FreeMarkerHit& hit : m_freeMarkerHits) + if (!freeMarkerOverPanel) { - if (hit.close.Width > 0.0f && - UiPointInRect(hit.close, static_cast(x), static_cast(y))) + for (const FreeMarkerHit& hit : m_freeMarkerHits) { - hoverLane = hit.laneIndex; - hoverItem = hit.itemIndex; - break; + if (hit.close.Width > 0.0f && + UiPointInRect(hit.close, static_cast(x), static_cast(y))) + { + hoverLane = hit.laneIndex; + hoverItem = hit.itemIndex; + break; + } } } if (hoverLane != m_freeMarkerHoverLane || hoverItem != m_freeMarkerHoverItem) @@ -2131,6 +2159,13 @@ namespace jdi if (kind == EdgePanelKind::MARK && m_tab != nullptr) { Lane& tabLane = m_tab->Lanes()[static_cast(index)]; + // 底部切换按钮「启用光标模式 / 关闭光标模式」(默认关闭)。 + if (layout.button.Width > 0.0f && UiPointInRect(layout.button, fx, fy)) + { + ToggleLaneFreeMarkerCursor(index); + m_dragMode = PlotDragMode::NONE; + return true; + } for (const EdgePanelLayout::MarkerRow& row : layout.markerRows) { if (row.close.Width > 0.0f && UiPointInRect(row.close, fx, fy)) @@ -2192,11 +2227,16 @@ namespace jdi } // ---- 「自由标记」---- + // ⚠️ 鼠标压在浮动面板上时**一律不参与**自由标记的命中与固定 + //(面板自身的交互已在上面消费,这里再兜一层,确保不穿透到网格)。 + const bool freeOverPanel = TopPanelAtPoint(fx, fy, guardLane, guardKind); + // (1) 网格内固定点的 ✕:按下即移除该固定点(矩形由绘制时登记)。 for (const FreeMarkerHit& hit : m_freeMarkerHits) { - if (hit.close.Width > 0.0f && UiPointInRect(hit.close, fx, fy) && m_tab != nullptr && - hit.laneIndex >= 0 && hit.laneIndex < static_cast(m_tab->Lanes().size())) + if (!freeOverPanel && hit.close.Width > 0.0f && UiPointInRect(hit.close, fx, fy) && + m_tab != nullptr && hit.laneIndex >= 0 && + hit.laneIndex < static_cast(m_tab->Lanes().size())) { Lane& tabLane = m_tab->Lanes()[static_cast(hit.laneIndex)]; tabLane.RemoveFreeMarkerAt(hit.itemIndex); @@ -2209,6 +2249,9 @@ namespace jdi // (常规模式 = 平移;选区放大模式 = 框选)。 // ⚠️ 用户明确要求:「仅单击时」才算固定数据点 —— 因此这里**只登记意图**, // 真正固定发生在 `OnLButtonUp` 且整段操作没有发生过拖动。 + // ⚠️ 默认(非光标模式)下 `ProbeFreeMarker` 只在鼠标贴近曲线时有效 ⇒ + // 鼠标下方没有信号时按下就是普通的平移/框选(用户明确要求)。 + if (!freeOverPanel) { const int freeLane = LaneAtY(y); if (freeLane >= 0 && freeLane < static_cast(m_tab ? m_tab->Lanes().size() : 0) && @@ -2516,11 +2559,14 @@ namespace jdi continue; } const Lane::FreeMarkerItem& item = tabLane.freeMarkerItems[row.itemIndex]; - // 分隔点:时间列左边界(`markerTimeRight` 减去最宽文本的近似值)。 - // 右侧(Δt 列)→ 编辑间隔;左侧 → 编辑自身时刻。 - const float splitX = layout.markerTimeRight - 40.0f * m_scale; + // 分隔点:直接用布局里的列边界,不再"反推"列位置。 + // 右侧(③ 时刻列 或 ④ 间隔列)→ 编辑间隔;① 取值 / ② 增量列 → 编辑自身时刻。 + const float deltaColumnLeft = + layout.markerColumns[EdgePanelLayout::MARKER_COL_DT].left; + const float timeColumnLeft = + layout.markerColumns[EdgePanelLayout::MARKER_COL_TIME].left; m_editMarkerIndex = row.itemIndex; - if (static_cast(x) >= splitX) + if (static_cast(x) >= deltaColumnLeft) { // 间隔编辑:初始值 = 当前间隔(秒)。最左边的点没有前一项 ⇒ 不允许编辑。 std::vector order; @@ -2538,17 +2584,23 @@ namespace jdi break; } } - const gd::RectF anchor(layout.markerTimeRight, - row.rect.Y, - std::max(10.0f, layout.markerDeltaRight - layout.markerTimeRight), - row.rect.Height); + const gd::RectF anchor( + deltaColumnLeft, row.rect.Y, + std::max(10.0f, layout.markerColumns[EdgePanelLayout::MARKER_COL_DT].width), + row.rect.Height); BeginEdit(PlotEditTarget::FREE_MARKER_DELTA, panelLane, FormatValue(item.time - previousTime), anchor); m_editFieldRect = row.rect; return true; } + // 时刻编辑:输入框覆盖「③ 时刻」列(列起点由布局给出)。 + const gd::RectF timeAnchor( + timeColumnLeft, row.rect.Y, + std::max(10.0f, layout.markerColumns[EdgePanelLayout::MARKER_COL_TIME].width + + layout.markerColumns[EdgePanelLayout::MARKER_COL_DT].width), + row.rect.Height); BeginEdit(PlotEditTarget::FREE_MARKER_TIME, panelLane, FormatValue(item.time), - row.rect); + timeAnchor); m_editFieldRect = row.rect; return true; } diff --git a/Components/PlotView/Src/PlotViewInternal.hpp b/Components/PlotView/Src/PlotViewInternal.hpp index 96a0895..055d2b2 100644 --- a/Components/PlotView/Src/PlotViewInternal.hpp +++ b/Components/PlotView/Src/PlotViewInternal.hpp @@ -346,8 +346,29 @@ namespace jdi * 取固定值而**不做逐行文本测量**:`EdgePanelWidth` 会被 `EdgePanelLayoutFor` 调用, * 而后者在 `TopPanelAtPoint` / `SyncPanelOrder` 里被高频调用(每次鼠标移动数次); * 逐行取采样值算宽度会变成"鼠标一动就做几十次采样查询"。 + * + * 宽度 = 左右内边距 + 四列(取值/增量/时刻/间隔)+ 行 ✕ ⇒ 保证四列在纵向上对齐。 */ - inline const float kFreeMarkerPanelWidth = 340.0f; + inline const float kFreeMarkerPanelWidth = 415.0f; + + /** + * @brief 「自由标记」面板四列的逻辑宽度(**按此顺序**:取值 / 增量 / 时刻 / 间隔)。 + * + * 固定列宽(不按文本测量,见上),绘制时用 `UiEllipsize` 截断超长文本。 + * 四列左对齐且右边界由列宽推得 ⇒ 同一信息在所有条目上纵向上严格对齐。 + */ + inline const float kFreeMarkerColumnWidths[4] = {124.0f, 86.0f, 78.0f, 90.0f}; + + /** 「自由标记」面板行内相邻两列之间的间距。 */ + inline const float kFreeMarkerColumnGap = 2.0f; + + /** + * @brief 非光标模式下判定"鼠标在信号曲线上"的纵向容差(逻辑像素)。 + * + * 默认(不启用光标模式)时只有鼠标贴近曲线才显示取值预览;这个容差决定"贴近"的量级。 + * 取得太小会很难命中细线,太大则会在空白处误判成"有信号"从而吃掉平移操作。 + */ + inline const float kFreeMarkerHoverTolerance = 8.0f; /** 固定点取值胶囊与 ✕ 之间的间距。 */ inline const float kFreeMarkerCloseGap = 3.0f; @@ -504,6 +525,10 @@ namespace jdi inline const wchar_t* const kWeakPeakIterationsSliderLabel = L"迭代次数"; inline const wchar_t* const kWeakPeakTitle = L"弱峰调整"; /**< 弱峰调整面板标题。 */ inline const wchar_t* const kBuildSignalLabel = L"构建为新信号"; /**< 逐条目面板底部按钮文案(均值滤波 / 弱峰调整共用)。 */ + /** 「自由标记」面板底部切换按钮:光标模式**未启用**时的文案(点击后启用)。 */ + inline const wchar_t* const kFreeMarkerCursorOnLabel = L"启用光标模式"; + /** 「自由标记」面板底部切换按钮:光标模式**已启用**时的文案(点击后关闭)。 */ + inline const wchar_t* const kFreeMarkerCursorOffLabel = L"关闭光标模式"; /** * @brief 边沿统计累积状态上限。 * diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index bcbe15a..7ddddaf 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -5415,8 +5415,8 @@ namespace jdi height = titleHeight + pad + static_cast(rowCount) * rowHeight * rowsPerSignal + pad; } - // 「自由标记」面板:每个可见信号一行名称,下面跟着它的全部固定点; - // 没有任何固定点时在名称行下方给一行灰色提示。 + // 「自由标记」面板:逐信号一行名称(**只列出确实有固定点的信号**),下面跟着它的全部固定点; + // 没有任何固定点时给一行灰色提示,最后始终带一行底部切换按钮。 bool markerAny = false; for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems) { @@ -5430,12 +5430,9 @@ namespace jdi { const float nameRow = kFreeMarkerNameRowHeight * m_scale; const float itemRow = kFreeMarkerItemRowHeight * m_scale; + const float markerButtonHeight = kEdgePanelButtonHeight * m_scale; float content = 0.0f; std::vector order; - if (!markerAny) - { - content += nameRow; - } for (const SignalRef& ref : lane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); @@ -5443,11 +5440,22 @@ namespace jdi { continue; } - content += nameRow; + // ⚠️ 用户明确要求:某个信号没有任何固定点时,它的名称条目**不出现**在面板上。 SortedFreeMarkerIndices(lane, ref, order); + if (order.empty()) + { + continue; + } + content += nameRow; content += static_cast(order.size()) * itemRow; } - height = titleHeight + pad + content + pad; + if (!markerAny) + { + // 还没有任何固定点:第一行留作提示文案(绘制时画「在网格内单击以固定数据点」)。 + content += nameRow; + } + // 底部「启用光标模式 / 关闭光标模式」按钮:上下各留一个 pad(与左右内边距一致)。 + height = titleHeight + pad + content + pad + markerButtonHeight + pad; } // 首次使用(位置为负)时自动放到绘图区**右上角**,并按网格在**当前可见网格中的序数** @@ -5680,28 +5688,27 @@ namespace jdi if (freeMarker) { - // 「自由标记」面板:每个可见信号一行名称(右侧带「清除本信号全部固定点」✕), - // 下面逐行列出它的固定点(组内按时间升序 == 从左到右),行尾再带一个 ✕。 + // 「自由标记」面板:**只列出确实有固定点的信号**,每个信号一行名称 + //(右侧带「清除本信号全部固定点」✕),下面逐行列出它的固定点 + //(组内按时间升序 == 从左到右),行尾再带一个 ✕。 const float nameRow = kFreeMarkerNameRowHeight * m_scale; const float itemRow = kFreeMarkerItemRowHeight * m_scale; const float closeSize = kFreeMarkerCloseSize * m_scale; const float gap = 4.0f * m_scale; - // 时间两列右对齐:Δt 列贴面板右内边界(✕ 左侧),t 列在它左边。 - layout.markerDeltaRight = left + width - pad - closeSize - gap; - layout.markerTimeRight = layout.markerDeltaRight - 74.0f * m_scale; + const float markerButtonHeight = kEdgePanelButtonHeight * m_scale; + + // 四列(① 取值 ② 增量 ③ 时刻 ④ 间隔)从左往右**固定列宽**排布: + // 每一行的同类信息都从同一横坐标开始 ⇒ 纵向上严格对齐(用户明确要求)。 + float columnX = left + pad; + for (int column = 0; column < 4; ++column) + { + layout.markerColumns[column].left = columnX; + layout.markerColumns[column].width = kFreeMarkerColumnWidths[column] * m_scale; + columnX += layout.markerColumns[column].width + kFreeMarkerColumnGap * m_scale; + } float rowY = top + titleHeight + pad; std::vector order; - bool markerAny = false; - for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems) - { - markerAny = markerAny || item.ref.IsValid(); - } - if (!markerAny) - { - // 还没有任何固定点:第一行留作提示文案(绘制时画「在网格内单击以固定数据点」)。 - rowY += nameRow; - } for (const SignalRef& ref : lane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); @@ -5709,16 +5716,19 @@ namespace jdi { continue; } - layout.rowRefs.push_back(ref); + // 该信号没有任何固定点 ⇒ 名称条目与数据行都**不出现**(用户明确要求)。 SortedFreeMarkerIndices(lane, ref, order); - const bool hasMarkers = !order.empty(); + if (order.empty()) + { + continue; + } + layout.rowRefs.push_back(ref); layout.rowNameRects.push_back(gd::RectF(left + pad, rowY, std::max(0.0f, width - pad * 2.0f - closeSize - gap), nameRow)); layout.rowNameCloses.push_back( - hasMarkers ? gd::RectF(left + width - pad - closeSize, - rowY + (nameRow - closeSize) * 0.5f, closeSize, closeSize) - : gd::RectF()); + gd::RectF(left + width - pad - closeSize, rowY + (nameRow - closeSize) * 0.5f, + closeSize, closeSize)); rowY += nameRow; for (const std::size_t itemIndex : order) { @@ -5731,6 +5741,10 @@ namespace jdi rowY += itemRow; } } + + // 底部切换按钮:整行宽度、与面板下边界留一个 pad。 + layout.button = gd::RectF(left + pad, top + height - pad - markerButtonHeight, + std::max(0.0f, width - pad * 2.0f), markerButtonHeight); } return layout; } @@ -6213,7 +6227,7 @@ namespace jdi { mixRect(rect); } - // 「自由标记」:固定点行/名称 ✕ 的矩形、时间列边界与每个固定点的身份 + 时刻都要入键。 + // 「自由标记」:固定点行/名称 ✕ 的矩形、四列边界与每个固定点的身份 + 时刻都要入键。 for (const EdgePanelLayout::MarkerRow& row : layout.markerRows) { mixRect(row.rect); @@ -6224,12 +6238,21 @@ namespace jdi { mixRect(rect); } - mixFloat(layout.markerTimeRight); - mixFloat(layout.markerDeltaRight); + for (const gd::RectF& rect : layout.rowNameRects) + { + mixRect(rect); + } + for (const EdgePanelLayout::MarkerColumn& column : layout.markerColumns) + { + mixFloat(column.left); + mixFloat(column.width); + } const Lane& lane = Tab().Lanes()[static_cast(laneIndex)]; if (kind == EdgePanelKind::MARK) { + // 底部切换按钮的文案取决于光标模式 ⇒ 必须进键,否则开关后仍贴回旧像素。 + mix(lane.freeMarkerCursor ? 1ull : 0ull); // 固定点的**内容**(哪个信号、什么时刻)直接决定面板文字与行数,必须进键; // 否则新增/删除一个固定点后可能贴回旧像素。 for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems) @@ -6593,7 +6616,7 @@ namespace jdi } } - // ---- (2) 跟随鼠标的纵向光标(仅限本网格,上下各一个三角把手)---- + // ---- (2) 跟随鼠标的纵向光标(**仅「光标模式」**;默认不画,只给贴近曲线的取值预览)---- const FreeMarkerProbe& probe = m_freeMarker; if (probe.valid && probe.laneIndex >= 0 && probe.laneIndex < static_cast(lanes.size())) @@ -6605,7 +6628,8 @@ namespace jdi float x = probe.x; x = std::max(visual.rect.X + kFreeMarkerLineInset * m_scale, std::min(x, visual.rect.X + visual.rect.Width - kFreeMarkerLineInset * m_scale)); - if (clipBottom > clipTop) + // ⚠️ 用户明确要求:默认**不启用**网格私有光标 —— 只有「光标模式」才画这条线。 + if (probe.cursorMode && clipBottom > clipTop) { UiDrawLine(g, x, clipTop, x, clipBottom, lineColor, 1.4f * m_scale); // 上把手:尖端朝下(与常规光标的顶部把手一致);下把手:尖端朝上(用户明确要求)。 @@ -6719,11 +6743,16 @@ namespace jdi } const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; - probe.valid = true; + // 光标模式(默认关闭):决定"是否在网格内始终显示纵向光标"以及 + // "鼠标离曲线多远还算有信号"。关闭时只有贴近曲线(或稀疏判定下贴近数据点)才有结果。 + const bool cursorMode = lane.freeMarkerCursor; + probe.cursorMode = cursorMode; probe.laneIndex = laneIndex; probe.time = XToTime(static_cast(mouseX)); probe.x = static_cast(mouseX); + const double hoverTolerance = static_cast(kFreeMarkerHoverTolerance * m_scale); + // 在网格内所有可见信号中找**离鼠标最近**的那个数据点。 double bestDistance = std::numeric_limits::infinity(); double bestTime = probe.time; @@ -6753,6 +6782,12 @@ namespace jdi snapped = true; } } + // 非光标模式且该信号命中「自动点线」判据:**只有靠近数据点时**才给数值 + //(吸附失败 = 附近没有采样点 ⇒ 直接跳过,避免在两点之间也冒出预览)。 + if (!cursorMode && sparse && !snapped) + { + continue; + } const bool interpolate = (resolved.style->mode != DrawMode::SCATTER); double sampled = 0.0; if (!SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style, time, @@ -6768,6 +6803,11 @@ namespace jdi } const float y = ValueToY(value, visual); const double distance = std::fabs(static_cast(y) - static_cast(mouseY)); + // 非光标模式:鼠标必须**在信号上方**(纵向贴近曲线)才算命中该信号。 + if (!cursorMode && distance > hoverTolerance) + { + continue; + } if (distance >= bestDistance) { continue; @@ -6783,8 +6823,12 @@ namespace jdi if (!probe.ref.IsValid()) { - return probe; // 网格内没有任何可用数据点:只画纵向线 + // 网格内没有可标注的数据点:光标模式下仍要画纵向线(`valid` 为真), + // 非光标模式下则**什么都不显示**,鼠标按下即普通的平移/框选。 + probe.valid = cursorMode; + return probe; } + probe.valid = true; probe.time = bestTime; probe.x = TimeToX(bestTime); probe.value = bestValue; @@ -7389,28 +7433,37 @@ namespace jdi const std::wstring timeText = FreeMarkerTimeText(item.time); const std::wstring deltaText = FreeMarkerDeltaText(hasPrev, timeDelta); - const float timeRight = layout.markerTimeRight; - const float deltaRight = layout.markerDeltaRight; - const float valueWidth = - std::max(0.0f, timeRight - gap * 2.0f - (panel.X + pad)); - UiText(g, UiEllipsize(valueText, m_fonts->monoSmall, valueWidth), - m_fonts->monoSmall, - gd::RectF(panel.X + pad, markerRow.rect.Y, valueWidth, - markerRow.rect.Height), + // 四列(① 取值 ② 增量 ③ 时刻 ④ 间隔)各列**左对齐在固定横坐标**上, + // 于是同一信息在所有条目上纵向上严格对齐(用户明确要求); + // 列宽固定,超长文本用 `UiEllipsize` 截断(布局期不做文本测量)。 + const auto columnRect = [&](EdgePanelLayout::MarkerColumnIndex column) { + return gd::RectF(layout.markerColumns[column].left, markerRow.rect.Y, + layout.markerColumns[column].width, + markerRow.rect.Height); + }; + UiText(g, UiEllipsize(valueText, m_fonts->monoSmall, + layout.markerColumns[EdgePanelLayout::MARKER_COL_VALUE].width), + m_fonts->monoSmall, columnRect(EdgePanelLayout::MARKER_COL_VALUE), m_theme->text, TextAlign::LEFT); - - // 时间列各自右对齐 ⇒ 所有条目上下对齐(用户明确要求)。 - const float timeWidth = UiMeasureTextWidth(m_fonts->monoSmall, timeText); - UiText(g, timeText, m_fonts->monoSmall, - gd::RectF(timeRight - timeWidth, markerRow.rect.Y, timeWidth, - markerRow.rect.Height), + if (hasPrev) + { + UiText(g, + UiEllipsize(FormatSignedValue(delta), m_fonts->monoSmall, + layout.markerColumns[EdgePanelLayout::MARKER_COL_DELTA].width), + m_fonts->monoSmall, columnRect(EdgePanelLayout::MARKER_COL_DELTA), + m_theme->textMuted, TextAlign::LEFT); + } + UiText(g, + UiEllipsize(timeText, m_fonts->monoSmall, + layout.markerColumns[EdgePanelLayout::MARKER_COL_TIME].width), + m_fonts->monoSmall, columnRect(EdgePanelLayout::MARKER_COL_TIME), m_theme->textMuted, TextAlign::LEFT); if (!deltaText.empty()) { - const float deltaWidth = UiMeasureTextWidth(m_fonts->monoSmall, deltaText); - UiText(g, deltaText, m_fonts->monoSmall, - gd::RectF(deltaRight - deltaWidth, markerRow.rect.Y, deltaWidth, - markerRow.rect.Height), + UiText(g, + UiEllipsize(deltaText, m_fonts->monoSmall, + layout.markerColumns[EdgePanelLayout::MARKER_COL_DT].width), + m_fonts->monoSmall, columnRect(EdgePanelLayout::MARKER_COL_DT), m_theme->textMuted, TextAlign::LEFT); } @@ -7466,8 +7519,9 @@ namespace jdi rowY += rowHeight * 2.0f; } - // 逐条目面板底部按钮行:「构建为新信号」(悬停高亮)。 - if (hasButton) + // 逐条目面板底部按钮行:「构建为新信号」(悬停高亮); + // 「自由标记」面板的按钮是「启用光标模式 / 关闭光标模式」的切换按钮。 + if (hasButton || freeMarker) { const gd::RectF& buttonRect = layout.button; if (buttonRect.Width > 0.0f) @@ -7481,8 +7535,11 @@ namespace jdi UiFillRoundRect(g, inner, 4.0f * m_scale, hot ? WithAlpha(m_theme->accent, 0x3A) : m_theme->cardBg); UiDrawRoundRect(g, inner, 4.0f * m_scale, m_theme->accent, 1.2f); - UiText(g, kBuildSignalLabel, m_fonts->uiSmall, inner, m_theme->accent, - TextAlign::CENTER); + const std::wstring label = + freeMarker ? (tabLane.freeMarkerCursor ? std::wstring(kFreeMarkerCursorOffLabel) + : std::wstring(kFreeMarkerCursorOnLabel)) + : std::wstring(kBuildSignalLabel); + UiText(g, label, m_fonts->uiSmall, inner, m_theme->accent, TextAlign::CENTER); } } diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 100e0a2..81e7a9d 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -3900,6 +3900,24 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } 绘制时重建、`OnMouseMove`/`OnLButtonDown` 读取。 188. ⚠️ **`PaintFreeMarker` 必须画在 `PaintEdgeCursor`(浮动面板)之前**:面板在最上层, 自由标记的胶囊/✕ 不得盖住面板;接入点是 `paintOverlays()`(三个绘制分支共用)。 +189. ⚠️⚠️ **`EDGE_PANEL_MOVE` 的 `switch (m_edgePanelDragKind)` 必须为每个新面板加一个 case**: + 新增面板种类时若漏掉,拖动会把坐标写进 `edgePanel`,而布局又从 `xxxPanel` 读 ⇒ + 症状是"面板**完全拖不动**"(不是"拖动后回弹",容易误判成命中问题)。新增 `MARK` 时就踩了这个坑。 +190. ⚠️⚠️ **「自由标记」默认不显示网格私有光标**:`Lane::freeMarkerCursor`(默认 false)才决定 + 是否画那条纵向线。默认模式下 `ProbeFreeMarker` 仅在鼠标**纵向贴近某条曲线** + (`kFreeMarkerHoverTolerance`)时有效;命中「自动点线」稀疏判据的信号还要求 + `SnapTimeToSample` 成功(即靠近真实数据点)。鼠标下方没有曲线 ⇒ 探针无效 ⇒ + 按下就是普通平移/框选,**不会误固定数据点**(用户明确要求)。 +191. ⚠️⚠️ **鼠标压在浮动面板上时必须清空自由标记探针**(`OnMouseMove` 里先 `TopPanelAtPoint`, + 命中就把 `m_freeMarker` 置默认):否则光标模式下纵向光标会**穿过面板**, + 且固定点的 ✕ 命中矩形可能抢走面板上的点击("面板上的操作穿透到网格")。 + `OnLButtonDown` 的自由标记分支也要用同一守卫兜一层。 +192. ⚠️⚠️ **面板里的"列"必须在 `EdgePanelLayout` 里给定横坐标**(`markerColumns`), + 绘制侧只读不反推:这样"同一信息纵向对齐"是几何保证,而不是靠文本测量的巧合; + 双击分行(编辑时刻 / 编辑 Δt)也必须用这些列边界,不能再用 `xxxRight - 40*scale` 之类的近似值。 +193. ⚠️ **面板里"没有内容的条目"不要出现在布局里**:自由标记面板现在只给**确实有固定点**的 + 信号发名称行(`SortedFreeMarkerIndices` 为空就 `continue`),高度分支与矩形分支必须同步 + 跳过,否则面板会多出空白行。 ### 11.3 离屏验证方法学(本机偏好,强烈建议沿用) @@ -7200,5 +7218,81 @@ DG3 data=0 cycle=0 (空组,一个字符串通道) 胶囊绘制去重。剩余体积是新功能的真实代码量(~800 行新代码 / ~30 B 每行)—— 若必须回到 800 KiB 以内,需要用户决策(动态 CRT / UPX / 砍其它功能),本轮按"实现功能优先"未动。 +--- + +## 本轮(2026-10-20 第八轮,本机):「自由标记」体验改进(默认无光标 + 四列对齐 + 光标模式按钮) + +> 用户需求(逐条): +> ① **默认不启用**网格私有光标,只有鼠标处于信号上方时才显示数据点数值;命中「自动点线」稀疏判据的 +> 信号要"只有靠近数据点时"才显示;② 有数值预览时左键单击固定该点(固定点样式不变:半透明纵向细线); +> 鼠标下方没有信号曲线时就是普通拖拽平移(选区放大模式下照旧框选); +> ③ 浮动面板**最下方**加一个切换按钮「启用光标模式」↔「关闭光标模式」,启用后与最初实现一致 +> (纵向自由光标),但**必须修掉"光标模式下拖不动面板"**,且鼠标在面板上时网格不画光标、 +> 面板上的操作不穿透到网格;④ 面板只列**有固定点**的信号(信号名条目否则不出现); +> ⑤ 每个信息元素**纵向对齐**(当前值 / 增量 / 当前时间 / 相对时间各自一列)。 + +### A. 数据层 + +- `Lane` 新增 `bool freeMarkerCursor = false`(**光标模式**开关,默认关闭)。 + `freeMarker` 仍是功能总开关;`LaneCarry` 同步携带(增删信号导致网格重建时不丢状态)。 +- `PlotView` 新增 `SetLaneFreeMarkerCursor / LaneFreeMarkerCursor / ToggleLaneFreeMarkerCursor`; + 切换时若探针属于该网格就**立即作废**(否则关闭后仍残留纵向光标、开启后要等鼠标动一下)。 + +### B. 探针与绘制(默认模式 vs 光标模式) + +- `FreeMarkerProbe` 新增 `cursor.cursorMode`。`ProbeFreeMarker`: + * **默认(非光标)**:同一信号若命中 `MinorCellSparse` 判据却**吸附失败** ⇒ 直接跳过 + (两点之间不出预览);其余信号要求 `|y - mouseY| <= kFreeMarkerHoverTolerance`(8 逻辑像素); + 一个都不满足 ⇒ `valid == false`(无预览 ⇒ 单击/拖动回到普通平移/框选)。 + * **光标模式**:与原实现一致(网格内恒 `valid`,能取到点时才标注取值)。 +- `PaintFreeMarker`:纵向线 + 上下把手**只在光标模式**绘制;取值胶囊 / 数据点 / 吸附圆环两种模式都画。 + +### C. 浮动面板 + +- 底部新增切换按钮(`layout.button`,高度 `kEdgePanelButtonHeight` + 上下 `pad`), + 文案由 `lane.freeMarkerCursor` 决定(`kFreeMarkerCursorOnLabel` / `kFreeMarkerCursorOffLabel`), + 手型光标 + 悬停高亮;`PanelSlotKey` 里 mix `freeMarkerCursor`(否则开关后贴回旧像素)。 +- **只列有固定点的信号**:高度分支与矩形分支都用 `SortedFreeMarkerIndices(...).empty()` 跳过。 +- **四列对齐**:`EdgePanelLayout::markerColumns[4]`(取值 / 增量 / 时刻 / 间隔)给定固定左边界与列宽 + (`kFreeMarkerColumnWidths`,列间 `kFreeMarkerColumnGap`),面板宽度改为 + `kFreeMarkerPanelWidth = 415`(= 4 列 + 间隙 + 行 ✕ + 两侧 pad,正好不多不少)。 + 绘制一律左对齐 + `UiEllipsize`;**不再**用"左文 + 右对齐两列"的混排。 +- 双击分行改为按列边界判断(≥ Δt 列左边界 ⇒ 编辑间隔;否则编辑时刻),不再用 `xxxRight - 40*scale`。 + +### D. 修掉的 BUG(用户报告:"光标模式下拖不动浮动面板") + +根因**不是**命中判定,而是 `OnMouseMove` 的 `case PlotDragMode::EDGE_PANEL_MOVE` 里 +`switch (m_edgePanelDragKind)` **漏了 `MARK` 分支**:拖动时坐标被写进 `lane.edgePanel`, +而 `EdgePanelLayoutFor` 从 `lane.freeMarkerPanel` 读位置 ⇒ 面板纹丝不动 +(既没移动也没报错,看起来像"拖拽没生效")。补上 `case EdgePanelKind::MARK` ⇒ 写入 `freeMarkerPanel`。 + +### E. 面板与网格的隔离 + +- `OnMouseMove`:先 `TopPanelAtPoint`,命中则把探针置空(光标模式下不再画穿过面板的光标), + 固定点 ✕ 的悬停判定也一并跳过。 +- `OnLButtonDown`:自由标记的两个分支(固定点 ✕ / 登记固定意图)都加了 `freeOverPanel` 守卫, + 确保面板上的按下永远不会被网格抢走。 + +### F. 验证 + +- `.preflight/markcheck` 重写并扩到 **61/61**: + [A] 默认无光标(`LaneFreeMarkerCursor == false`)、贴近曲线才有效、远离曲线单击不固定、 + 拖动仍是平移、预览点落在悬停容差内; [B] 贴近曲线单击恰好固定 1 个点,再补 2 个共 3 个; + [C] 面板只给有固定点的信号发名称行、四列有宽度/不重叠/首列与信号名同左边界/全在行 ✕ 左侧、 + 行按时间升序、首项无增量; + [D] 底部按钮首次点击 → 光标模式开启(远离曲线也有效、`cursorMode == true`)、再点关闭、 + 按钮点击不会固定任何点; + [E] 光标模式下拖标题栏**确实移动面板**、拖动不固定点、内容不丢; + [F] 鼠标移到面板上 ⇒ 探针被清空(不画光标); + [G] 关掉光标模式后:选区放大里原地单击=固定且不缩放、拖动=缩放且不固定; + [H] 编辑 Δt 后该点移动并在越过后续点时变成最右项; + [I] 行尾 ✕ 只删 1 项、信号名 ✕ 清空该信号全部、清空后面板不再发任何信号行; + [J] 深度放大 `snapped == true` 且时刻精确等于采样时刻、视界外固定点不绘制。 +- 回归:`editcheck` 20/20、`round3check` 31/31、`round4check` 34/34、`round5check` 5/5、 + `convcheck` 18/18、`check` **136/137**(唯一失败项仍是那条已知不稳定项)、 + 真实 MF4 仍 **283** 条信号。 +- `make release` / `make debug` **零警告**;`Build/Release/Target.exe` **834,560 字节(815.0 KiB)**。 + + diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index 13ae67f..652fff5 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -919,15 +919,24 @@ namespace jdi LanePanelState weakPeakPanel; /** - * @brief 「实验性功能 → 自由标记」开关。 + * @brief 「实验性功能 → 自由标记」开关(功能总开关)。 * - * 开启后本网格内跟随鼠标显示一条**仅限本网格**的纵向光标(上下各一个三角把手, - * 不显示时间读数);光标穿过信号时只标注**离鼠标最近**的那个数据点。 - * 左键单击把该数据点固定下来(固定点带 ✕ 可单独移除),浮动面板按信号分组列出 - * 「值(±增量) = 枚举文本」+「t:… Δt:…」。 + * 开启后本网格参与自由标记:鼠标**贴近信号曲线**时标注离鼠标最近的数据点, + * 左键单击把该数据点固定下来(固定点带 ✕ 可单独移除,并在浮动面板按信号分组列出 + * 「值 / 增量 / 时刻 / 间隔」四列)。默认**不**显示网格私有纵向光标。 */ bool freeMarker = false; + /** + * @brief 「自由标记」的光标模式(浮动面板最下方的切换按钮)。 + * + * `false`(默认):鼠标在信号曲线上(或稀疏判定下靠近数据点)时才显示取值预览; + * 鼠标下方没有曲线时按下就是普通的平移/框选,不会误固定数据点。 + * `true`:与最初实现的语义相同 —— 网格内始终跟随鼠标画一条**仅限本网格**的纵向光标 + *(上下各一个三角把手、不显示时间读数),光标穿过信号时标注离鼠标最近的数据点。 + */ + bool freeMarkerCursor = false; + /** * @brief 已固定的自由标记点(**未排序**,按加入顺序)。 * diff --git a/Utilities/Src/DataModelInternal.hpp b/Utilities/Src/DataModelInternal.hpp index 2727d78..50655f7 100644 --- a/Utilities/Src/DataModelInternal.hpp +++ b/Utilities/Src/DataModelInternal.hpp @@ -89,6 +89,7 @@ namespace jdi std::vector weakPeakItems; LanePanelState weakPeakPanel; bool freeMarker = false; + bool freeMarkerCursor = false; std::vector freeMarkerItems; LanePanelState freeMarkerPanel; }; @@ -129,6 +130,7 @@ namespace jdi carry.weakPeakItems = lane.weakPeakItems; carry.weakPeakPanel = lane.weakPeakPanel; carry.freeMarker = lane.freeMarker; + carry.freeMarkerCursor = lane.freeMarkerCursor; carry.freeMarkerItems = lane.freeMarkerItems; carry.freeMarkerPanel = lane.freeMarkerPanel; for (const SignalRef& ref : lane.signals) @@ -174,6 +176,7 @@ namespace jdi lane.weakPeakPanel.x = carry.weakPeakPanel.x; lane.weakPeakPanel.y = carry.weakPeakPanel.y; lane.freeMarker = carry.freeMarker; + lane.freeMarkerCursor = carry.freeMarkerCursor; lane.freeMarkerItems = carry.freeMarkerItems; lane.freeMarkerPanel.open = carry.freeMarkerPanel.open; lane.freeMarkerPanel.x = carry.freeMarkerPanel.x;