From 3400ac7c293fb1bba4e4f8a1091840ed010dd406 Mon Sep 17 00:00:00 2001 From: "DeepSeek-V4.1-Flash" <> Date: Wed, 21 Oct 2026 02:59:05 +0800 Subject: [PATCH] =?UTF-8?q?=E8=87=AA=E7=94=B1=E6=A0=87=E8=AE=B0=E4=BD=93?= =?UTF-8?q?=E9=AA=8C=E9=87=8D=E5=81=9A=20+=20=E5=85=A8=E9=83=A8=E5=AE=9E?= =?UTF-8?q?=E9=AA=8C=E6=80=A7=E9=9D=A2=E6=9D=BF=E5=8F=AF=E6=8A=98=E8=B5=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 网格内固定点:取值胶囊按已放置矩形做纵向避让(不再互相遮挡);行尾 ✕ 改为与 胶囊同高的正方形;移除固定点的纵向半透明竖线 - 新增「标记透明度」(0–100%),作用于本网格全部固定点的圆点/胶囊/描边/文字/✕; 半透明时文字改走 GDI+(GDI DrawTextW 忽略 alpha),100% 时像素零变化; ≤2% 时不绘制且不登记 ✕ 命中矩形(看不见的按钮不可点) - 面板按信号分组:信号色托底分组栏(展开三角 + 信号名 + N 标记点 + ✕,文字/三角/✕ 一律用自适应对比色),点击分组栏收起/展开;条目五列(值/枚举/增量/时刻/间隔), 首项增列与 Δt 显示 N/A,整个面板没有枚举文本时枚举列整列缺省;行尾仍有 ✕ - 面板内容超高时右侧出现滚动条(分组栏与条目一起滚动),滚轮在内容区翻页; 底部按钮与「标记透明度」滑块固定在底部、不参与滚动 - 底部按钮改为「光标选取模式」切换类按钮(外观与工具栏切换按钮一致,激活高亮), 激活时其上方出现「标记透明度」滑动条(与信号样式区的透明度滑块同款) - 所有实验性功能浮动面板支持展开/折起:标题栏 GX 徽标左侧新增「三角 + 横线」指示器, 单击切换、双击标题栏切换;折起的面板只有标题栏 - 数据层:LanePanelState.collapsed、Lane.freeMarkerOpacity/freeMarkerScroll/ freeMarkerCollapsedGroups,LaneCarry 与 SplitLane 一并继承 - 验证:新增离屏 .preflight/perf/mark2check 69/69(含两组 A/B);回归 datacheck 69/69、 verify 49/49、panelanim 158/158、errcheck 19/19;make release/debug 零警告 --- Components/PlotView/Inc/PlotView.hpp | 117 ++- Components/PlotView/Src/PlotViewFeatures.cpp | 109 +++ .../PlotView/Src/PlotViewInteraction.cpp | 250 ++++++- Components/PlotView/Src/PlotViewInternal.hpp | 67 +- Components/PlotView/Src/PlotViewPaint.cpp | 666 ++++++++++++++---- Documents/agent_mem.md | 133 ++++ Utilities/Inc/DataModel.hpp | 67 +- Utilities/Src/DataModelInternal.hpp | 14 + Utilities/Src/DataModelTab.cpp | 4 + 9 files changed, 1225 insertions(+), 202 deletions(-) diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 633d6bc..2d5d588 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -57,7 +57,9 @@ namespace jdi EDGE_PANEL_MOVE, /**< 拖动边沿计数浮动面板。 */ PEAK_LEVEL, /**< 拖动「弱峰调整」某信号的幅度滑块(-400% – +400%)。 */ PEAK_ITERATIONS, /**< 拖动「弱峰调整」某信号的迭代次数滑块(1 … 该信号自定义上限)。 */ - LANE_REORDER /**< 拖动网格图例中的 GX 编号徽标:上下调整网格次序。 */ + LANE_REORDER, /**< 拖动网格图例中的 GX 编号徽标:上下调整网格次序。 */ + MARKER_OPACITY, /**< 拖动「自由标记」面板的「标记透明度」滑块。 */ + MARKER_SCROLL /**< 拖动「自由标记」面板内容的滚动条滑块。 */ }; /** @@ -969,6 +971,46 @@ namespace jdi */ bool ToggleLaneFreeMarkerCursor(int laneIndex); + /** + * @brief 设置某网格「自由标记」固定点的**显示不透明度**(0–1)。 + * + * 只影响网格内固定数据点的显示内容(数据点 / 取值胶囊 / ✕), + * 面板自身的像素与交互不受影响。 + */ + bool SetLaneFreeMarkerOpacity(int laneIndex, float opacity); + + /** + * @brief 设置某网格「自由标记」面板内容的滚动偏移(客户区像素,0 = 顶部)。 + * @return 偏移真的发生变化返回 true。 + */ + bool SetLaneFreeMarkerScroll(int laneIndex, float scroll); + + /** + * @brief 切换某网格「自由标记」面板里某个信号分组的收起状态。 + * @return 切换后为「已收起」返回 true。 + */ + bool ToggleLaneFreeMarkerGroup(int laneIndex, const SignalRef& ref); + + /** + * @brief 切换某个实验性功能浮动面板的**折起**状态(只显示标题栏)。 + * @param [IN] laneIndex 网格下标。 + * @param [IN] kind 面板种类。 + * @param [IN] collapsed 是否折起。 + * @return 状态发生变化返回 true。 + */ + bool SetLanePanelCollapsed(int laneIndex, EdgePanelKind kind, bool collapsed); + + /** + * @brief 查询某个实验性功能浮动面板是否处于折起状态。 + */ + bool LanePanelCollapsed(int laneIndex, EdgePanelKind kind) const; + + /** + * @brief 切换某个实验性功能浮动面板的折起状态。 + * @return 切换后的状态(true = 已折起)。 + */ + bool ToggleLanePanelCollapsed(int laneIndex, EdgePanelKind kind); + /** * @brief 直接把文本解析为网格 Y 轴上限/下限并应用(供菜单内联输入框提交使用)。 * @param [IN] laneIndex 网格下标。 @@ -1595,6 +1637,15 @@ namespace jdi gd::RectF panel; /**< 面板整体矩形。 */ gd::RectF title; /**< 标题栏(**唯一可拖拽区域**)。 */ gd::RectF close; /**< ✕ 关闭按钮。 */ + /** + * @brief 标题栏左侧的「展开 / 折起」三角指示器(三角 + 后方一条横线)。 + * + * 单击它切换面板的折起状态;折起后面板只剩标题栏(内容区完全省略)。 + * 它位于「GX」编号徽标的**左边**。 + */ + gd::RectF disclosure; + /** 面板当前是否处于折起状态(只显示标题栏)。 */ + bool collapsed = false; /** * @brief 标题栏左侧的「GX」编号徽标。 * @@ -1654,6 +1705,35 @@ namespace jdi }; /** 「自由标记」:每个固定点一行(按信号分组、组内按时间升序展开后的顺序)。 */ std::vector markerRows; + /** + * @brief 「自由标记」的一个**信号分组栏**(以信号色为托底底色)。 + * + * 左侧 = 展开/收起三角 + 信号名;右侧 = 标记点个数(`N 标记点`)+ ✕ + *(清除该信号全部固定点)。单击分组栏(✕ 除外)切换收起状态。 + * 只有至少有一个固定点的信号才会有分组栏。 + */ + struct MarkerGroup + { + SignalRef ref; + gd::RectF header; /**< 分组栏托底矩形。 */ + gd::RectF triangle; /**< 展开/收起三角的命中矩形。 */ + gd::RectF close; /**< 右侧✕的命中矩形。 */ + bool collapsed = false; + }; + /** 「自由标记」:信号分组栏(顺序与面板显示顺序一致)。 */ + std::vector markerGroups; + /** + * @brief 「自由标记」面板的**可滚动内容区**(分组栏 + 固定点条目)。 + * + * 内容高度超过它时出现上下滚动条(不参与滚动的按钮与透明度滑块在它下方)。 + */ + gd::RectF content; + /** 「自由标记」面板的**内容总高度**(含被滚动出去的条目),用于换算滚动比例。 */ + float markerContentHeight = 0.0f; + gd::RectF scrollTrack; /**< 内容区右侧的滚动条轨道(不需要滚动时为空矩形)。 */ + gd::RectF scrollThumb; /**< 滚动条滑块。 */ + gd::RectF opacityTrack; /**< 「标记透明度」滑动条轨道(仅光标模式激活时非空)。 */ + gd::RectF opacityValue; /**< 「标记透明度」数值文本矩形。 */ /** * @brief 「自由标记」:每个信号名右侧的「清除该信号全部固定点」✕。 * @@ -1667,25 +1747,29 @@ namespace jdi */ std::vector rowNameRects; /** - * @brief 「自由标记」面板的四列文本区(① 取值 ② 增量 ③ 时刻 ④ 间隔)。 + * @brief 「自由标记」面板的五列文本区 + * (① 取值 ② 枚举文本 ③ 增量 ④ 时刻 ⑤ 间隔)。 * - * 每行都在这四列的**同一横坐标**上左对齐输出,因此同一信息在纵向上严格对齐 - *(用户 2026-10-20 明确要求:"将每个信息元素纵向对齐")。 + * 每行都在这五列的**同一横坐标**上左对齐输出,因此同一信息在纵向上严格对齐。 * 列宽固定(不按文本测量),绘制时用 `UiEllipsize` 截断,避免布局期查询采样。 + * 当整个面板里没有任何枚举文本时,② 枚举列的宽度为 0(整列缺省)。 */ struct MarkerColumn { float left = 0.0f; float width = 0.0f; }; - std::array markerColumns{}; - /** 四列的语义下标(`markerColumns` 的索引)。 */ + std::array markerColumns{}; + /** 面板里是否存在枚举文本(为 false 时 ② 列整列缺省)。 */ + bool markerHasEnumColumn = false; + /** 五列的语义下标(`markerColumns` 的索引)。 */ enum MarkerColumnIndex { MARKER_COL_VALUE = 0, - MARKER_COL_DELTA = 1, - MARKER_COL_TIME = 2, - MARKER_COL_DT = 3 + MARKER_COL_ENUM = 1, + MARKER_COL_DELTA = 2, + MARKER_COL_TIME = 3, + MARKER_COL_DT = 4 }; }; @@ -1776,9 +1860,11 @@ namespace jdi * @param [IN] color 信号色。 * @param [IN] close ✕ 按钮矩形(宽度为 0 表示不画)。 * @param [IN] closeHot ✕ 是否处于悬停态。 + * @param [IN] alphaScale 整体不透明度系数(1 = 不透明;固定点受「标记透明度」控制)。 */ void PaintFreeMarkerCapsule(gd::Graphics& g, const gd::RectF& label, const std::wstring& text, - std::uint32_t color, const gd::RectF& close, bool closeHot); + std::uint32_t color, const gd::RectF& close, bool closeHot, + float alphaScale = 1.0f); /** * @brief 计算某个固定点相对**同信号**前一个固定点的增量与其时间间隔。 @@ -2576,6 +2662,17 @@ namespace jdi /** 正在编辑的固定点(`m_editLane` 网格内的 `freeMarkerItems` 下标)。 */ std::size_t m_editMarkerIndex = static_cast(-1); + /** 「标记透明度」滑块拖拽的目标网格(-1 = 未拖拽)。 */ + int m_markerOpacityDragLane = -1; + /** 面板内容滚动条拖拽的目标网格(-1 = 未拖拽)。 */ + int m_markerScrollDragLane = -1; + /** 按下滑块时鼠标相对滚动条滑块上沿的偏移(避免滑块跳动)。 */ + float m_markerScrollGrabOffset = 0.0f; + + /** 「自由标记」面板:分组栏三角的悬停态(用于给出可点击提示)。 */ + int m_freeMarkerTriangleHoverLane = -1; + SignalRef m_freeMarkerTriangleHoverSignal; + int m_mouseX = 0; /**< 最近一次鼠标横坐标(用于悬停提示定位)。 */ int m_mouseY = 0; /**< 最近一次鼠标纵坐标(用于悬停提示定位)。 */ bool m_spanHover = false; /**< 鼠标是否悬停在双光标差值标签上。 */ diff --git a/Components/PlotView/Src/PlotViewFeatures.cpp b/Components/PlotView/Src/PlotViewFeatures.cpp index 19394a3..0ae83fe 100644 --- a/Components/PlotView/Src/PlotViewFeatures.cpp +++ b/Components/PlotView/Src/PlotViewFeatures.cpp @@ -1693,6 +1693,115 @@ namespace jdi return enabled; } + bool PlotView::SetLaneFreeMarkerOpacity(int laneIndex, float opacity) + { + if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) + { + return false; + } + Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + const float clamped = std::max(0.0f, std::min(1.0f, opacity)); + if (std::fabs(lane.freeMarkerOpacity - clamped) < 0.0005f) + { + return false; + } + lane.freeMarkerOpacity = clamped; + return true; + } + + bool PlotView::SetLaneFreeMarkerScroll(int laneIndex, float scroll) + { + if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) + { + return false; + } + Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + const float clamped = std::max(0.0f, scroll); + if (std::fabs(lane.freeMarkerScroll - clamped) < 0.5f) + { + return false; + } + lane.freeMarkerScroll = clamped; + return true; + } + + bool PlotView::ToggleLaneFreeMarkerGroup(int laneIndex, const SignalRef& ref) + { + if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) + { + return false; + } + Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + return lane.ToggleFreeMarkerGroupCollapsed(ref); + } + + bool PlotView::SetLanePanelCollapsed(int laneIndex, EdgePanelKind kind, bool collapsed) + { + if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) + { + return false; + } + Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + LanePanelState* state = nullptr; + switch (kind) + { + case EdgePanelKind::COUNT: + state = &lane.edgePanel; + break; + case EdgePanelKind::CENTER: + state = &lane.edgeCenterPanel; + break; + case EdgePanelKind::MEAN: + state = &lane.meanFilterPanel; + break; + case EdgePanelKind::PEAK: + state = &lane.weakPeakPanel; + break; + case EdgePanelKind::MARK: + state = &lane.freeMarkerPanel; + break; + default: + return false; + } + if (state->collapsed == collapsed) + { + return false; + } + state->collapsed = collapsed; + return true; + } + + bool PlotView::LanePanelCollapsed(int laneIndex, EdgePanelKind kind) 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)]; + switch (kind) + { + case EdgePanelKind::COUNT: + return lane.edgePanel.collapsed; + case EdgePanelKind::CENTER: + return lane.edgeCenterPanel.collapsed; + case EdgePanelKind::MEAN: + return lane.meanFilterPanel.collapsed; + case EdgePanelKind::PEAK: + return lane.weakPeakPanel.collapsed; + case EdgePanelKind::MARK: + return lane.freeMarkerPanel.collapsed; + default: + return false; + } + } + + bool PlotView::ToggleLanePanelCollapsed(int laneIndex, EdgePanelKind kind) + { + const bool collapsed = !LanePanelCollapsed(laneIndex, kind); + SetLanePanelCollapsed(laneIndex, kind, collapsed); + return collapsed; + } + 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 f8975cb..ad413b8 100644 --- a/Components/PlotView/Src/PlotViewInteraction.cpp +++ b/Components/PlotView/Src/PlotViewInteraction.cpp @@ -941,6 +941,12 @@ namespace jdi { return LoadCursorW(nullptr, IDC_HAND); } + // 「展开 / 折起」三角指示器:可点击控件 ⇒ 手型光标。 + if (layout.disclosure.Width > 0.0f && + UiPointInRect(layout.disclosure, static_cast(x), static_cast(y))) + { + return LoadCursorW(nullptr, IDC_HAND); + } if (UiPointInRect(layout.title, static_cast(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_SIZEALL); @@ -953,15 +959,27 @@ namespace jdi // 「构建为新信号」按钮:手型光标作为可点击提示。 return LoadCursorW(nullptr, IDC_HAND); } - // 「自由标记」面板:行尾 ✕ 与信号名右侧 ✕ 都是可点击控件(手型)。 + // 「自由标记」面板:行尾 ✕ / 分组栏 ✕ 与三角都是可点击控件(手型), + // 滚动条与「标记透明度」滑块则是拖动类控件(上下 / 左右箭头)。 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); } + if (layout.opacityTrack.Width > 0.0f && + WeakPeakSliderHit(layout.opacityTrack, m_scale, static_cast(x), + static_cast(y))) + { + return LoadCursorW(nullptr, IDC_SIZEWE); + } + if (layout.scrollTrack.Width > 0.0f && + UiPointInRect(layout.scrollTrack, static_cast(x), static_cast(y))) + { + return LoadCursorW(nullptr, IDC_SIZENS); + } for (const EdgePanelLayout::MarkerRow& row : layout.markerRows) { if (row.close.Width > 0.0f && @@ -970,13 +988,18 @@ namespace jdi return LoadCursorW(nullptr, IDC_HAND); } } - for (const gd::RectF& rect : layout.rowNameCloses) + for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) { - if (rect.Width > 0.0f && - UiPointInRect(rect, static_cast(x), static_cast(y))) + if (group.close.Width > 0.0f && + UiPointInRect(group.close, static_cast(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_HAND); } + if (UiPointInRect(group.header, static_cast(x), static_cast(y))) + { + // 分组栏本体(含三角)单击 = 展开 / 收起。 + return LoadCursorW(nullptr, IDC_HAND); + } } } // 「弱峰调整」的逐信号滑块:轨道 → 左右箭头;迭代次数右侧数值 → 手型(可点击改上限)。 @@ -1439,6 +1462,57 @@ namespace jdi m_dragMoved = true; return true; } + case PlotDragMode::MARKER_OPACITY: + { + // 「标记透明度」滑块:拖动时把滑块位置换算成 0–1 的不透明度。 + if (m_tab == nullptr || m_markerOpacityDragLane < 0 || + m_markerOpacityDragLane >= static_cast(m_tab->Lanes().size())) + { + break; + } + const EdgePanelLayout layout = + EdgePanelLayoutFor(m_markerOpacityDragLane, EdgePanelKind::MARK); + if (layout.opacityTrack.Width <= 0.0f) + { + break; + } + const double ratio = UiSliderRatioFromX(layout.opacityTrack, static_cast(x)); + if (SetLaneFreeMarkerOpacity(m_markerOpacityDragLane, static_cast(ratio))) + { + m_dragMoved = true; + return true; + } + return false; + } + case PlotDragMode::MARKER_SCROLL: + { + // 面板内容滚动条:按滑块在轨道里的位置换算滚动偏移。 + if (m_tab == nullptr || m_markerScrollDragLane < 0 || + m_markerScrollDragLane >= static_cast(m_tab->Lanes().size())) + { + break; + } + const EdgePanelLayout layout = + EdgePanelLayoutFor(m_markerScrollDragLane, EdgePanelKind::MARK); + const float travel = layout.scrollTrack.Height - layout.scrollThumb.Height; + const float maxScroll = + std::max(0.0f, layout.markerContentHeight - layout.content.Height); + if (layout.scrollTrack.Width <= 0.0f || travel <= 0.0f || maxScroll <= 0.0f) + { + break; + } + const float thumbTop = + std::max(layout.scrollTrack.Y, + std::min(static_cast(y) - m_markerScrollGrabOffset, + layout.scrollTrack.Y + travel)); + const float ratio = (thumbTop - layout.scrollTrack.Y) / travel; + if (SetLaneFreeMarkerScroll(m_markerScrollDragLane, ratio * maxScroll)) + { + m_dragMoved = true; + return true; + } + return false; + } case PlotDragMode::NONE: default: break; @@ -1500,11 +1574,13 @@ namespace jdi } } - // 面板内 ✕ 的悬停(信号名 ✕ 与固定点行 ✕)。 + // 面板内 ✕ 的悬停(分组栏 ✕ 与固定点行 ✕)与分组栏三角的悬停。 { int hoverLane = -1; std::size_t hoverItem = static_cast(-1); SignalRef hoverSignal; + int triLane = -1; + SignalRef triSignal; int markLane = -1; int markKind = 0; if (m_tab != nullptr && @@ -1512,10 +1588,14 @@ namespace jdi static_cast(markKind) == EdgePanelKind::MARK) { const EdgePanelLayout layout = EdgePanelLayoutFor(markLane, EdgePanelKind::MARK); + // 只认**内容区**内的命中:被滚动到内容区之外的条目(它们的矩形可能落在 + // 底部按钮 / 滑块区域)不得抢走那些控件的点击。 + const bool insideContent = + UiPointInRect(layout.content, static_cast(x), static_cast(y)); // 固定点行 ✕ 优先(更具体)。 for (const EdgePanelLayout::MarkerRow& row : layout.markerRows) { - if (row.close.Width > 0.0f && + if (insideContent && row.close.Width > 0.0f && UiPointInRect(row.close, static_cast(x), static_cast(y))) { hoverLane = markLane; @@ -1525,18 +1605,22 @@ namespace jdi } if (hoverLane < 0) { - for (std::size_t rowIndex = 0; rowIndex < layout.rowNameCloses.size(); ++rowIndex) + for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) { - const gd::RectF& rect = layout.rowNameCloses[rowIndex]; - if (rect.Width > 0.0f && - UiPointInRect(rect, static_cast(x), static_cast(y)) && - rowIndex < layout.rowRefs.size()) + if (insideContent && group.close.Width > 0.0f && + UiPointInRect(group.close, static_cast(x), static_cast(y))) { hoverLane = markLane; hoverItem = static_cast(-1); - hoverSignal = layout.rowRefs[rowIndex]; + hoverSignal = group.ref; break; } + if (insideContent && + UiPointInRect(group.triangle, static_cast(x), static_cast(y))) + { + triLane = markLane; + triSignal = group.ref; + } } } } @@ -1548,6 +1632,13 @@ namespace jdi m_freeMarkerPanelHoverSignal = hoverSignal; changed = true; } + if (triLane != m_freeMarkerTriangleHoverLane || + !(triSignal == m_freeMarkerTriangleHoverSignal)) + { + m_freeMarkerTriangleHoverLane = triLane; + m_freeMarkerTriangleHoverSignal = triSignal; + changed = true; + } } // 鼠标压在实验性功能面板上时不再触发信号悬停提示(面板本来就盖住波形), @@ -1911,6 +2002,15 @@ namespace jdi return true; } + // 标题栏左侧的「展开 / 折起」三角指示器:单击切换面板的折起状态。 + // 折起后面板只剩标题栏(再次点击三角、或双击标题栏可展开)。 + if (layout.disclosure.Width > 0.0f && UiPointInRect(layout.disclosure, fx, fy)) + { + m_dragMode = PlotDragMode::NONE; + ToggleLanePanelCollapsed(index, kind); + return true; + } + // 「中心轴边沿计数」/「均值滤波」/「弱峰调整」面板:逐信号条目 // (前两者是「名称 + 复选框 + 两个参数输入框」,弱峰调整是「名称 + 复选框 + 两行滑块」)。 if ((kind == EdgePanelKind::MEAN || kind == EdgePanelKind::CENTER || @@ -2154,37 +2254,80 @@ 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 (layout.opacityTrack.Width > 0.0f && + WeakPeakSliderHit(layout.opacityTrack, m_scale, fx, fy)) { - if (row.close.Width > 0.0f && UiPointInRect(row.close, fx, fy)) + SetLaneFreeMarkerOpacity(index, + static_cast(UiSliderRatioFromX( + layout.opacityTrack, fx))); + m_dragMode = PlotDragMode::MARKER_OPACITY; + m_markerOpacityDragLane = index; + return true; + } + // 内容滚动条:按在滑块上就带上抓取偏移,按在轨道上则直接跳转。 + if (layout.scrollTrack.Width > 0.0f && + UiPointInRect(layout.scrollTrack, fx, fy)) + { + const float travel = layout.scrollTrack.Height - layout.scrollThumb.Height; + const float maxScroll = + std::max(0.0f, layout.markerContentHeight - layout.content.Height); + if (travel > 0.0f && maxScroll > 0.0f) { - tabLane.RemoveFreeMarkerAt(row.itemIndex); + m_dragMode = PlotDragMode::MARKER_SCROLL; + m_markerScrollDragLane = index; + const bool onThumb = UiPointInRect(layout.scrollThumb, fx, fy); + m_markerScrollGrabOffset = + onThumb ? (fy - layout.scrollThumb.Y) + : (layout.scrollThumb.Height * 0.5f); + const float thumbTop = + std::max(layout.scrollTrack.Y, + std::min(fy - m_markerScrollGrabOffset, + layout.scrollTrack.Y + travel)); + SetLaneFreeMarkerScroll( + index, ((thumbTop - layout.scrollTrack.Y) / travel) * maxScroll); + return true; + } + } + // 分组栏:✕ → 清空该信号全部固定点;其它位置(含三角)→ 展开 / 收起。 + // ⚠️ 只认内容区内的命中:滚动出去的条目不得抢到底部按钮 / 滑块的点击。 + const bool insideContent = UiPointInRect(layout.content, fx, fy); + for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) + { + if (insideContent && group.close.Width > 0.0f && UiPointInRect(group.close, fx, fy)) + { + tabLane.RemoveFreeMarkersOf(group.ref); m_dragMode = PlotDragMode::NONE; m_freeMarkerPanelHoverLane = -1; m_freeMarkerPanelHoverItem = static_cast(-1); m_freeMarkerPanelHoverSignal = SignalRef(); return true; } - } - for (std::size_t rowIndex = 0; rowIndex < layout.rowNameCloses.size(); ++rowIndex) - { - const gd::RectF& rect = layout.rowNameCloses[rowIndex]; - if (rect.Width > 0.0f && UiPointInRect(rect, fx, fy) && - rowIndex < layout.rowRefs.size()) + if (insideContent && UiPointInRect(group.header, fx, fy)) { - tabLane.RemoveFreeMarkersOf(layout.rowRefs[rowIndex]); + ToggleLaneFreeMarkerGroup(index, group.ref); + m_dragMode = PlotDragMode::NONE; + return true; + } + } + for (const EdgePanelLayout::MarkerRow& row : layout.markerRows) + { + if (insideContent && row.close.Width > 0.0f && UiPointInRect(row.close, fx, fy)) + { + tabLane.RemoveFreeMarkerAt(row.itemIndex); m_dragMode = PlotDragMode::NONE; m_freeMarkerPanelHoverLane = -1; m_freeMarkerPanelHoverItem = static_cast(-1); @@ -2490,6 +2633,10 @@ namespace jdi // 弱峰滑块拖拽结束:清掉目标(避免留下上一轮的信号引用)。 m_peakDragLane = -1; m_peakDragRef = SignalRef(); + // 「标记透明度」滑块 / 面板内容滚动条拖拽结束。 + m_markerOpacityDragLane = -1; + m_markerScrollDragLane = -1; + m_markerScrollGrabOffset = 0.0f; // 松开左键:粘滞提示结束计时,开始"停留后淡出"。 ReleaseHint(); return changed; @@ -2504,6 +2651,37 @@ namespace jdi const double notches = static_cast(delta) / 120.0; + // 鼠标位于「自由标记」面板的可滚动内容区上:滚轮翻页该面板的内容(不滚动网格)。 + // ⚠️ 只认内容区:底部的切换按钮与「标记透明度」滑块不参与滚动, + // 因此鼠标压在它们上面时仍然滚动网格(与“它们不属于内容”的语义一致)。 + if (m_tab != nullptr) + { + int panelLane = -1; + int panelKind = 0; + if (TopPanelAtPoint(static_cast(m_mouseX), static_cast(m_mouseY), + panelLane, panelKind) && + static_cast(panelKind) == EdgePanelKind::MARK) + { + const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, EdgePanelKind::MARK); + if (layout.content.Width > 0.0f && + UiPointInRect(layout.content, static_cast(m_mouseX), + static_cast(m_mouseY))) + { + const float maxScroll = + std::max(0.0f, layout.markerContentHeight - layout.content.Height); + if (maxScroll > 0.0f) + { + const float next = static_cast(Tab().Lanes()[static_cast( + panelLane)] + .freeMarkerScroll - + notches * 40.0 * m_scale); + SetLaneFreeMarkerScroll(panelLane, std::max(0.0f, std::min(next, maxScroll))); + return true; + } + } + } + } + // 滚轮不再承担缩放:默认纵向滚动网格,按住 Shift 时水平平移时间轴。 if (shift) { @@ -2542,6 +2720,27 @@ namespace jdi int panelKind = 0; if (TopPanelAtPoint(static_cast(x), static_cast(y), panelLane, panelKind)) { + // 标题栏双击 = 展开 / 折起该面板(折起的面板只显示标题栏)。 + // ⚠️ 折起状态下标题栏是唯一可见区域,因此这里也是“双击重新展开”的入口。 + { + const EdgePanelLayout titleLayout = + EdgePanelLayoutFor(panelLane, static_cast(panelKind)); + if (titleLayout.title.Width > 0.0f && + UiPointInRect(titleLayout.title, static_cast(x), static_cast(y)) && + !(titleLayout.badge.Width > 0.0f && + UiPointInRect(titleLayout.badge, static_cast(x), + static_cast(y))) && + !(titleLayout.close.Width > 0.0f && + UiPointInRect(titleLayout.close, static_cast(x), + static_cast(y))) && + !(titleLayout.disclosure.Width > 0.0f && + UiPointInRect(titleLayout.disclosure, static_cast(x), + static_cast(y)))) + { + ToggleLanePanelCollapsed(panelLane, static_cast(panelKind)); + return true; + } + } // 「自由标记」面板:双击某一固定点行 → 编辑它的时间(左侧是自身时刻、 // 右侧是相对前一固定点的间隔,对应行内两列的位置)。 if (static_cast(panelKind) == EdgePanelKind::MARK && m_tab != nullptr && @@ -2553,6 +2752,7 @@ namespace jdi for (const EdgePanelLayout::MarkerRow& row : layout.markerRows) { if (row.rect.Width <= 0.0f || + !UiPointInRect(layout.content, static_cast(x), static_cast(y)) || !UiPointInRect(row.rect, static_cast(x), static_cast(y)) || row.itemIndex >= tabLane.freeMarkerItems.size()) { diff --git a/Components/PlotView/Src/PlotViewInternal.hpp b/Components/PlotView/Src/PlotViewInternal.hpp index 055d2b2..2b432ba 100644 --- a/Components/PlotView/Src/PlotViewInternal.hpp +++ b/Components/PlotView/Src/PlotViewInternal.hpp @@ -331,13 +331,13 @@ namespace jdi // ---------------------------------------------------------------- 自由标记 - /** 「自由标记」面板里信号名行的高度(比普通条目略高:右侧多一个 ✕)。 */ + /** 「自由标记」面板里信号**分组栏**的高度(左侧三角 + 信号名,右侧标记点数 + ✕)。 */ inline const float kFreeMarkerNameRowHeight = 22.0f; - /** 「自由标记」面板里每个固定点行的高度。 */ - inline const float kFreeMarkerItemRowHeight = 18.0f; + /** 「自由标记」面板里每个固定点行的高度(五列 + 行尾 ✕)。 */ + inline const float kFreeMarkerItemRowHeight = 20.0f; - /** 「自由标记」面板与网格内固定点 ✕ 按钮的边长。 */ + /** 「自由标记」面板与行尾 ✕ 按钮的边长(网格内取值胶囊后的 ✕ 另按胶囊高度取正方形)。 */ inline const float kFreeMarkerCloseSize = 13.0f; /** @@ -347,21 +347,45 @@ namespace jdi * 而后者在 `TopPanelAtPoint` / `SyncPanelOrder` 里被高频调用(每次鼠标移动数次); * 逐行取采样值算宽度会变成"鼠标一动就做几十次采样查询"。 * - * 宽度 = 左右内边距 + 四列(取值/增量/时刻/间隔)+ 行 ✕ ⇒ 保证四列在纵向上对齐。 + * 宽度 = 左右内边距 + 五列(取值/枚举/增量/时刻/间隔)+ 行 ✕ ⇒ 保证五列在纵向上对齐。 */ - inline const float kFreeMarkerPanelWidth = 415.0f; + inline const float kFreeMarkerPanelWidth = 424.0f; /** - * @brief 「自由标记」面板四列的逻辑宽度(**按此顺序**:取值 / 增量 / 时刻 / 间隔)。 + * @brief 「自由标记」面板五列的逻辑宽度 + * (**按此顺序**:取值 / 枚举文本 / 增量 / 时刻 / 间隔)。 * * 固定列宽(不按文本测量,见上),绘制时用 `UiEllipsize` 截断超长文本。 - * 四列左对齐且右边界由列宽推得 ⇒ 同一信息在所有条目上纵向上严格对齐。 + * 当整个面板里没有任何枚举文本时,枚举这一列会被**整列缺省**(列宽记 0, + * 后面的列左移),其它列的宽度不变。 */ - inline const float kFreeMarkerColumnWidths[4] = {124.0f, 86.0f, 78.0f, 90.0f}; + inline const float kFreeMarkerColumnWidths[5] = {74.0f, 80.0f, 60.0f, 72.0f, 78.0f}; /** 「自由标记」面板行内相邻两列之间的间距。 */ inline const float kFreeMarkerColumnGap = 2.0f; + /** 「自由标记」面板内容滚动条的宽度(逻辑像素)。 */ + inline const float kFreeMarkerScrollWidth = 7.0f; + + /** + * @brief 「自由标记」面板**内容区**的最大高度(逻辑像素)。 + * + * 超过它就出现上下滚动条(分组栏与固定点条目一起滚动);面板底部的按钮与 + * 「标记透明度」滑动条不参与滚动。 + */ + inline const float kFreeMarkerContentMaxHeight = 260.0f; + + /** 「自由标记」面板底部「标记透明度」滑动条行的高度(与信号样式区的行高同量级)。 */ + inline const float kFreeMarkerSliderRowHeight = 24.0f; + + /** + * @brief 所有实验性功能浮动面板标题栏左侧「展开/折起」指示器的宽度。 + * + * 指示器 = 三角(朝右 = 已折起、朝下 = 已展开)+ 三角后方的一条横线; + * 点击它切换面板的折起状态。`GX` 编号徽标向右让出这块宽度。 + */ + inline const float kEdgePanelDisclosureWidth = 20.0f; + /** * @brief 非光标模式下判定"鼠标在信号曲线上"的纵向容差(逻辑像素)。 * @@ -416,28 +440,29 @@ namespace jdi } /** - * @brief 「自由标记」的时间列文本:`t:<时刻>`。 + * @brief 「自由标记」的时间列文本:`<时刻>s`。 * - * 数值格式与光标时间读数**完全相同**(`FormatValue` + `s` 后缀, - * 即 `FormatCursorTime` 的口径);这里不调用 `FormatCursorTime` 是因为它在本头里 - * 定义在本段**之后**,直接展开可以避免前向声明。 + * 数值格式与光标时间读数**完全相同**(`FormatValue` + `s` 后缀)。 */ inline std::wstring FreeMarkerTimeText(double time) { - return L"t:" + FormatValue(time) + L"s"; + return FormatValue(time) + L"s"; } + /** 「自由标记」面板里缺省(不适用)的单元格文本。 */ + inline const wchar_t* const kFreeMarkerNotAvailable = L"N/A"; + /** - * @brief 「自由标记」的时间间隔列文本:`Δt:<间隔>`。 + * @brief 「自由标记」的时间间隔列文本:`Δt:+<间隔>`。 * * 平时用复合单位(自动匹配 ms / s / min,与双光标差值读数一致), - * 双击后按**秒**编辑。首个固定点没有前一项 ⇒ 返回空串(该列留白)。 + * 双击后按**秒**编辑。首个固定点没有前一项 ⇒ 显示 `N/A`(该列不省略,保持列对齐)。 */ inline std::wstring FreeMarkerDeltaText(bool hasPrev, double timeDelta) { if (!hasPrev) { - return std::wstring(); + return std::wstring(kFreeMarkerNotAvailable); } // 时间间隔恒为正(按时间排序后当前点总在后方);显示时显式带 `+`。 return L"Δt:+" + FormatDurationCompound(std::fabs(timeDelta)); @@ -525,10 +550,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"关闭光标模式"; + /** 「自由标记」面板底部切换按钮文案(切换按钮的激活态 = 光标模式开启)。 */ + inline const wchar_t* const kFreeMarkerCursorModeLabel = L"光标选取模式"; + /** 「自由标记」面板底部切换按钮激活时出现在其上方的滑动条标签。 */ + inline const wchar_t* const kFreeMarkerOpacityLabel = L"标记透明度"; /** * @brief 边沿统计累积状态上限。 * diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 7ddddaf..66f73b1 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -5342,6 +5342,14 @@ namespace jdi { return layout; } + // 面板的**折起**状态(只显示标题栏)。折起时仍然算宽度与位置,但内容区全部省略。 + const bool collapsed = meanFilter ? lane.meanFilterPanel.collapsed + : (weakPeak ? lane.weakPeakPanel.collapsed + : (freeMarker ? lane.freeMarkerPanel.collapsed + : (centerAxis + ? lane.edgeCenterPanel.collapsed + : lane.edgePanel.collapsed))); + layout.collapsed = collapsed; int rowCount = 0; for (const SignalRef& ref : lane.signals) @@ -5415,8 +5423,10 @@ namespace jdi height = titleHeight + pad + static_cast(rowCount) * rowHeight * rowsPerSignal + pad; } - // 「自由标记」面板:逐信号一行名称(**只列出确实有固定点的信号**),下面跟着它的全部固定点; - // 没有任何固定点时给一行灰色提示,最后始终带一行底部切换按钮。 + // 「自由标记」面板:按信号分组(**分组栏** + 下属固定点条目), + // 只列出确实有固定点的信号;没有任何固定点时给一行灰色提示。 + // 内容区超高时出现滚动条;面板底部固定「标记透明度」滑块(仅光标模式激活时) + // 与「光标选取模式」切换按钮(二者**不参与**滚动)。 bool markerAny = false; for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems) { @@ -5428,9 +5438,11 @@ namespace jdi } if (freeMarker) { - const float nameRow = kFreeMarkerNameRowHeight * m_scale; + const float groupRow = kFreeMarkerNameRowHeight * m_scale; const float itemRow = kFreeMarkerItemRowHeight * m_scale; const float markerButtonHeight = kEdgePanelButtonHeight * m_scale; + const float sliderRow = + lane.freeMarkerCursor ? (kFreeMarkerSliderRowHeight * m_scale) : 0.0f; float content = 0.0f; std::vector order; for (const SignalRef& ref : lane.signals) @@ -5440,22 +5452,37 @@ namespace jdi { continue; } - // ⚠️ 用户明确要求:某个信号没有任何固定点时,它的名称条目**不出现**在面板上。 + // ⚠️ 用户明确要求:某个信号没有任何固定点时,它的分组栏**不出现**在面板上。 SortedFreeMarkerIndices(lane, ref, order); if (order.empty()) { continue; } - content += nameRow; - content += static_cast(order.size()) * itemRow; + content += groupRow; + if (!lane.FreeMarkerGroupCollapsed(ref)) + { + content += static_cast(order.size()) * itemRow; + } } if (!markerAny) { - // 还没有任何固定点:第一行留作提示文案(绘制时画「在网格内单击以固定数据点」)。 - content += nameRow; + // 还没有任何固定点:留一行提示文案(绘制时画「在网格内单击以固定数据点」)。 + content += groupRow; } - // 底部「启用光标模式 / 关闭光标模式」按钮:上下各留一个 pad(与左右内边距一致)。 - height = titleHeight + pad + content + pad + markerButtonHeight + pad; + // 内容区高度上限:太长的列表不能把面板顶出绘图区(超出部分靠滚动条翻页)。 + const float budget = + m_plotRect.Height - (titleHeight + pad * 3.0f + markerButtonHeight + sliderRow); + const float maxContent = + std::max(2.0f * itemRow, + std::min(kFreeMarkerContentMaxHeight * m_scale, std::max(2.0f * itemRow, budget))); + const float view = std::min(content, maxContent); + height = titleHeight + pad + view + pad + sliderRow + markerButtonHeight + pad; + } + + // 折起的面板:只剩标题栏(内容区完全不占高度)。 + if (collapsed) + { + height = titleHeight; } // 首次使用(位置为负)时自动放到绘图区**右上角**,并按网格在**当前可见网格中的序数** @@ -5546,10 +5573,22 @@ namespace jdi layout.title = gd::RectF(left, top, width, titleHeight); const float closeSize = titleHeight; layout.close = gd::RectF(left + width - closeSize, top, closeSize, closeSize); + // 标题栏左侧的「展开 / 折起」三角指示器(位于 GX 徽标左边)。 + // 折起后面板只剩标题栏,因此这个指示器是唯一的“重新展开”入口(双击标题栏亦可)。 + layout.disclosure = gd::RectF(left + pad, top + (titleHeight - kEdgePanelDisclosureWidth * m_scale) * 0.5f, + kEdgePanelDisclosureWidth * m_scale, + kEdgePanelDisclosureWidth * m_scale); // 标题栏左侧的「GX」编号徽标:绘制、悬停提示、手型光标与“点击滚动到该网格” // 全部以这个矩形为准(唯一几何来源,避免“画的”和“点的”漂移)。 - layout.badge = gd::RectF(left + pad, top + (titleHeight - kLaneBadgeHeight * m_scale) * 0.5f, - kLaneBadgeWidth * m_scale, kLaneBadgeHeight * m_scale); + layout.badge = + gd::RectF(left + pad + kEdgePanelDisclosureWidth * m_scale, + top + (titleHeight - kLaneBadgeHeight * m_scale) * 0.5f, kLaneBadgeWidth * m_scale, + kLaneBadgeHeight * m_scale); + // 折起状态:到此为止(内容区、按钮、滚动条全部为空)。 + if (collapsed) + { + return layout; + } if (itemized) { const float buttonRowHeight = kEdgePanelButtonHeight * m_scale; @@ -5688,26 +5727,44 @@ namespace jdi if (freeMarker) { - // 「自由标记」面板:**只列出确实有固定点的信号**,每个信号一行名称 - //(右侧带「清除本信号全部固定点」✕),下面逐行列出它的固定点 - //(组内按时间升序 == 从左到右),行尾再带一个 ✕。 - const float nameRow = kFreeMarkerNameRowHeight * m_scale; + // 「自由标记」面板:**只列出确实有固定点的信号**,每个信号一个**分组栏** + //(信号色托底 + 左侧展开/收起三角 + 信号名 + 右侧「N 标记点」与 ✕), + // 下面逐行列出它的固定点(组内按时间升序 == 从上到下),行尾再带一个 ✕。 + // 内容区超高时右侧出现滚动条;底部按钮与「标记透明度」滑块**固定**不滚动。 + const float groupRow = kFreeMarkerNameRowHeight * m_scale; const float itemRow = kFreeMarkerItemRowHeight * m_scale; const float closeSize = kFreeMarkerCloseSize * m_scale; - const float gap = 4.0f * m_scale; const float markerButtonHeight = kEdgePanelButtonHeight * m_scale; + const float sliderRow = lane.freeMarkerCursor ? (kFreeMarkerSliderRowHeight * m_scale) : 0.0f; + const float scrollW = kFreeMarkerScrollWidth * m_scale; - // 四列(① 取值 ② 增量 ③ 时刻 ④ 间隔)从左往右**固定列宽**排布: - // 每一行的同类信息都从同一横坐标开始 ⇒ 纵向上严格对齐(用户明确要求)。 - float columnX = left + pad; - for (int column = 0; column < 4; ++column) + // 底部固定区:切换按钮(整行宽度、与面板下边界留一个 pad)。 + layout.button = gd::RectF(left + pad, top + height - pad - markerButtonHeight, + std::max(0.0f, width - pad * 2.0f), markerButtonHeight); + if (sliderRow > 0.0f) { - layout.markerColumns[column].left = columnX; - layout.markerColumns[column].width = kFreeMarkerColumnWidths[column] * m_scale; - columnX += layout.markerColumns[column].width + kFreeMarkerColumnGap * m_scale; + // 「标记透明度」行:与信号样式区的「透明度」行同一套排版 + //(左侧标签 + 中间轨道 + 右侧百分比),只是位置固定在按钮上方。 + const float rowTop = layout.button.Y - sliderRow; + const float valueWidth = + std::max(36.0f * m_scale, + UiMeasureTextWidth(m_fonts->monoSmall, L"100%") + 6.0f * m_scale); + const float labelWidth = + UiMeasureTextWidth(m_fonts->uiSmall, kFreeMarkerOpacityLabel) + 6.0f * m_scale; + const float trackLeft = left + pad + labelWidth; + const float trackRight = left + width - pad - valueWidth - 6.0f * m_scale; + const float trackHeight = kWeakPeakTrackHeight * m_scale; + layout.opacityTrack = + gd::RectF(trackLeft, rowTop + (sliderRow - trackHeight) * 0.5f, + std::max(20.0f * m_scale, trackRight - trackLeft), trackHeight); + layout.opacityValue = + gd::RectF(trackRight + 6.0f * m_scale, rowTop, valueWidth, sliderRow); } - float rowY = top + titleHeight + pad; + // 内容区高度(面板高度算式里的 `view` 部分)与"内容总高"决定是否需要滚动条。 + const float viewHeight = std::max( + 0.0f, height - titleHeight - pad * 3.0f - markerButtonHeight - sliderRow); + float totalContent = 0.0f; std::vector order; for (const SignalRef& ref : lane.signals) { @@ -5716,35 +5773,124 @@ namespace jdi { continue; } - // 该信号没有任何固定点 ⇒ 名称条目与数据行都**不出现**(用户明确要求)。 SortedFreeMarkerIndices(lane, ref, order); 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( - gd::RectF(left + width - pad - closeSize, rowY + (nameRow - closeSize) * 0.5f, - closeSize, closeSize)); - rowY += nameRow; + totalContent += groupRow; + if (!lane.FreeMarkerGroupCollapsed(ref)) + { + totalContent += static_cast(order.size()) * itemRow; + } + } + if (!markerAny) + { + totalContent += groupRow; + } + const bool scrollable = totalContent > viewHeight + 0.5f; + const float maxScroll = std::max(0.0f, totalContent - viewHeight); + const float scroll = std::max(0.0f, std::min(lane.freeMarkerScroll, maxScroll)); + const float innerWidth = + std::max(0.0f, width - pad * 2.0f - (scrollable ? (scrollW + 4.0f * m_scale) : 0.0f)); + layout.content = + gd::RectF(left + pad, top + titleHeight + pad, innerWidth, viewHeight); + layout.markerContentHeight = totalContent; + if (scrollable) + { + layout.scrollTrack = + gd::RectF(left + pad + innerWidth + 4.0f * m_scale, layout.content.Y, scrollW, + viewHeight); + const float thumbHeight = + std::max(18.0f * m_scale, viewHeight * (viewHeight / totalContent)); + const float thumbTop = + layout.content.Y + + (viewHeight - thumbHeight) * ((maxScroll > 0.0f) ? (scroll / maxScroll) : 0.0f); + layout.scrollThumb = gd::RectF(layout.scrollTrack.X, thumbTop, scrollW, thumbHeight); + } + + // 五列(① 取值 ② 枚举 ③ 增量 ④ 时刻 ⑤ 间隔)从左往右**固定列宽**排布: + // 每一行的同类信息都从同一横坐标开始 ⇒ 纵向上严格对齐(用户明确要求)。 + // ⚠️ 整个面板里没有任何枚举文本时,② 枚举列**整列缺省**(列宽记 0,后面的列左移)。 + // 这里只查"该信号是否带枚举表"(O(1)),**不**逐条采样(布局函数被高频调用)。 + bool hasEnum = false; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data != nullptr && resolved.visible && !resolved.data->enumText.Empty() && + lane.HasFreeMarkers(ref)) + { + hasEnum = true; + break; + } + } + layout.markerHasEnumColumn = hasEnum; + float columnX = left + pad; + for (int column = 0; column < 5; ++column) + { + const bool omitted = (column == EdgePanelLayout::MARKER_COL_ENUM) && !hasEnum; + const float columnWidth = + omitted ? 0.0f : (kFreeMarkerColumnWidths[column] * m_scale); + layout.markerColumns[column].left = columnX; + layout.markerColumns[column].width = columnWidth; + columnX += columnWidth + (omitted ? 0.0f : kFreeMarkerColumnGap * m_scale); + } + + const float contentBottom = layout.content.Y + layout.content.Height; + float rowY = layout.content.Y - scroll; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || !resolved.visible) + { + continue; + } + // 该信号没有任何固定点 ⇒ 分组栏与数据行都**不出现**(用户明确要求)。 + SortedFreeMarkerIndices(lane, ref, order); + if (order.empty()) + { + continue; + } + EdgePanelLayout::MarkerGroup group; + group.ref = ref; + group.collapsed = lane.FreeMarkerGroupCollapsed(ref); + group.header = gd::RectF(layout.content.X, rowY, innerWidth, groupRow); + group.triangle = gd::RectF(layout.content.X + 3.0f * m_scale, + rowY + (groupRow - 12.0f * m_scale) * 0.5f, + 12.0f * m_scale, 12.0f * m_scale); + group.close = gd::RectF(layout.content.X + innerWidth - closeSize - 2.0f * m_scale, + rowY + (groupRow - closeSize) * 0.5f, closeSize, closeSize); + if (group.header.Y + group.header.Height > layout.content.Y && + group.header.Y < contentBottom) + { + // 只登记"在可视内容区里"的分组栏与条目(滚出视界的条目不得抢走 + // 底部按钮 / 滑块的点击)。 + layout.markerGroups.push_back(group); + layout.rowRefs.push_back(ref); + layout.rowNameRects.push_back(group.header); + layout.rowNameCloses.push_back(group.close); + } + rowY += groupRow; + if (group.collapsed) + { + continue; + } for (const std::size_t itemIndex : order) { EdgePanelLayout::MarkerRow markerRow; markerRow.itemIndex = itemIndex; - markerRow.rect = gd::RectF(left + pad, rowY, width - pad * 2.0f, itemRow); - markerRow.close = gd::RectF(left + width - pad - closeSize, - rowY + (itemRow - closeSize) * 0.5f, closeSize, closeSize); - layout.markerRows.push_back(markerRow); + markerRow.rect = gd::RectF(layout.content.X, rowY, innerWidth, itemRow); + markerRow.close = gd::RectF(layout.content.X + innerWidth - closeSize, + rowY + (itemRow - closeSize) * 0.5f, closeSize, + closeSize); + if (markerRow.rect.Y + markerRow.rect.Height > layout.content.Y && + markerRow.rect.Y < contentBottom) + { + layout.markerRows.push_back(markerRow); + } rowY += itemRow; } } - - // 底部切换按钮:整行宽度、与面板下边界留一个 pad。 - layout.button = gd::RectF(left + pad, top + height - pad - markerButtonHeight, - std::max(0.0f, width - pad * 2.0f), markerButtonHeight); } return layout; } @@ -6207,9 +6353,17 @@ namespace jdi mixRect(layout.panel); mixRect(layout.title); mixRect(layout.close); + mixRect(layout.disclosure); mixRect(layout.badge); mixRect(layout.edit); mixRect(layout.button); + mixRect(layout.content); + mixRect(layout.scrollTrack); + mixRect(layout.scrollThumb); + mixRect(layout.opacityTrack); + mixRect(layout.opacityValue); + mix(layout.collapsed ? 1ull : 0ull); + mix(layout.markerHasEnumColumn ? 1ull : 0ull); mix(static_cast(layout.rows)); for (const gd::RectF& rect : layout.rowChecks) { @@ -6247,12 +6401,25 @@ namespace jdi mixFloat(column.left); mixFloat(column.width); } + // 「自由标记」的分组栏(托底矩形 / 三角 / ✕)与各自的收起状态都在面板像素里。 + for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) + { + mixRect(group.header); + mixRect(group.triangle); + mixRect(group.close); + mix(group.collapsed ? 1ull : 0ull); + mix(static_cast(group.ref.groupIndex + 2)); + mix(static_cast(group.ref.signalIndex + 2)); + } const Lane& lane = Tab().Lanes()[static_cast(laneIndex)]; if (kind == EdgePanelKind::MARK) { - // 底部切换按钮的文案取决于光标模式 ⇒ 必须进键,否则开关后仍贴回旧像素。 + // 底部切换按钮的激活态、透明度滑块与内容滚动偏移直接改变面板像素 ⇒ 必须进键, + // 否则开关 / 拖滑块 / 滚动之后仍会贴回旧像素。 mix(lane.freeMarkerCursor ? 1ull : 0ull); + mixFloat(lane.freeMarkerOpacity); + mixFloat(lane.freeMarkerScroll); // 固定点的**内容**(哪个信号、什么时刻)直接决定面板文字与行数,必须进键; // 否则新增/删除一个固定点后可能贴回旧像素。 for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems) @@ -6499,10 +6666,11 @@ namespace jdi : nullptr; text = FreeMarkerValueText(value, hasPrev, delta, enumText); - const float closeSize = kFreeMarkerCloseSize * m_scale; - const float gap = kFreeMarkerCloseGap * m_scale; + // ✕ 按钮 = **与取值胶囊同高的正方形**(用户明确要求:长宽都等于胶囊的上下高度)。 const float textWidth = UiMeasureTextWidth(m_fonts->monoSmall, text) + 10.0f * m_scale; const float textHeight = 17.0f * m_scale; + const float closeSize = textHeight; + const float gap = kFreeMarkerCloseGap * m_scale; const float totalWidth = textWidth + gap + closeSize; float left = rawX + 6.0f * m_scale; @@ -6516,28 +6684,93 @@ namespace jdi top = std::max(clipTop + 1.0f * m_scale, std::min(top, clipBottom - textHeight - 1.0f * m_scale)); label = gd::RectF(left, top, textWidth, textHeight); - close = gd::RectF(left + textWidth + gap, top + (textHeight - closeSize) * 0.5f, closeSize, - closeSize); + close = gd::RectF(left + textWidth + gap, top, closeSize, closeSize); return true; } + /** + * @brief 用 **GDI+** 绘制一段可带 alpha 的文字(仅用于「标记透明度」低于 100% 的胶囊)。 + * + * ⚠️ 界面其余文字一律走 `UiText`(GDI `DrawTextW`)。GDI 文本**忽略 alpha** + *(`SetTextColor` 只吃 RGB)⇒ 直接把 alpha 乘进颜色不会让文字淡出。 + * 因此只有在"固定点被调成半透明"这个特殊状态下才改用 GDI+ `DrawString`: + * 那样才能让文字与胶囊一起真正淡出;不透明度为 100% 时仍走 `UiText`,像素零变化。 + * + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] text 文本。 + * @param [IN] font 字体(与 `UiText` 同一 HFONT)。 + * @param [IN] rect 文本矩形(居中)。 + * @param [IN] argb 颜色(含目标 alpha)。 + */ + void DrawFreeMarkerAlphaText(gd::Graphics& g, const std::wstring& text, HFONT font, + const gd::RectF& rect, std::uint32_t argb) + { + if (text.empty() || font == nullptr || rect.Width <= 0.0f || rect.Height <= 0.0f) + { + return; + } + LOGFONTW lf{}; + if (GetObjectW(font, static_cast(sizeof(lf)), &lf) != static_cast(sizeof(lf))) + { + UiText(g, text, font, rect, argb, TextAlign::CENTER); + return; + } + HDC hdc = g.GetHDC(); + if (hdc == nullptr) + { + return; + } + gd::Font textFont(hdc, &lf); + g.ReleaseHDC(hdc); + if (textFont.GetLastStatus() != gd::Ok) + { + UiText(g, text, font, rect, argb, TextAlign::CENTER); + return; + } + gd::SolidBrush brush(ToGdiColor(argb)); + gd::StringFormat format; + format.SetAlignment(gd::StringAlignmentCenter); + format.SetLineAlignment(gd::StringAlignmentCenter); + format.SetTrimming(gd::StringTrimmingEllipsisCharacter); + format.SetFormatFlags(gd::StringFormatFlagsNoWrap); + g.DrawString(text.c_str(), static_cast(text.size()), &textFont, rect, &format, &brush); + } + void PlotView::PaintFreeMarkerCapsule(gd::Graphics& g, const gd::RectF& label, const std::wstring& text, std::uint32_t color, - const gd::RectF& close, bool closeHot) + const gd::RectF& close, bool closeHot, float alphaScale) { + const float scale = std::max(0.0f, std::min(1.0f, alphaScale)); + const auto scaled = [scale](std::uint32_t argb, float base) -> std::uint32_t { + const int alpha = static_cast(std::lround(base * scale)); + return WithAlpha(argb, (alpha <= 0) ? 0 : (alpha >= 255 ? 255 : alpha)); + }; const UiInk ink = UiContrastInk(color); - UiFillRoundRect(g, label, 3.0f * m_scale, WithAlpha(color, 0xF0)); - DrawLabelOutline(g, label, 3.0f * m_scale, ink.outline); - UiText(g, text, m_fonts->monoSmall, label, ink.text, TextAlign::CENTER); + if (scale <= 0.02f) + { + return; // 完全透明:连描边与文字都不画(否则会留下"看不见的"像素) + } + UiFillRoundRect(g, label, 3.0f * m_scale, scaled(color, 0xF0)); + DrawLabelOutline(g, label, 3.0f * m_scale, scaled(ink.outline, 255.0f)); + if (scale >= 0.999f) + { + UiText(g, text, m_fonts->monoSmall, label, ink.text, TextAlign::CENTER); + } + else + { + // GDI 文本忽略 alpha ⇒ 半透明时必须改用 GDI+ 才能真的淡出(见上面说明)。 + DrawFreeMarkerAlphaText(g, text, m_fonts->monoSmall, label, scaled(ink.text, 255.0f)); + } if (close.Width <= 0.0f) { return; } // ✕ 按钮:悬停时用 danger 底色 + 叉(与面板里的 ✕ 同一套语义)。 UiFillRoundRect(g, close, 3.0f * m_scale, - closeHot ? WithAlpha(m_theme->danger, 0x66) : WithAlpha(color, 0xC0)); - const float crossPad = 3.5f * m_scale; - const std::uint32_t crossColor = closeHot ? m_theme->danger : ink.text; + closeHot ? WithAlpha(m_theme->danger, 0x66) : scaled(color, 0xC0)); + const float crossPad = 4.0f * m_scale; + const std::uint32_t crossColor = + closeHot ? m_theme->danger : scaled(ink.text, 255.0f); UiDrawLine(g, close.X + crossPad, close.Y + crossPad, close.X + close.Width - crossPad, close.Y + close.Height - crossPad, crossColor, 1.4f * m_scale); UiDrawLine(g, close.X + close.Width - crossPad, close.Y + crossPad, close.X + crossPad, @@ -6560,6 +6793,8 @@ namespace jdi const std::size_t count = std::min(lanes.size(), tabLanes.size()); // ---- (1) 已固定的数据点(含各自的 ✕)---- + // ⚠️ 固定点**不再画纵向细线**(用户 2026-10-21 明确要求); + // 取值胶囊彼此重叠时做**纵向避让**(依次尝试下方 / 上方错开),保证都能读清。 for (std::size_t laneIndex = 0; laneIndex < count; ++laneIndex) { const Lane& lane = tabLanes[laneIndex]; @@ -6567,6 +6802,8 @@ namespace jdi { continue; } + const float markerAlpha = std::max(0.0f, std::min(1.0f, lane.freeMarkerOpacity)); + std::vector placed; for (std::size_t itemIndex = 0; itemIndex < lane.freeMarkerItems.size(); ++itemIndex) { gd::RectF label; @@ -6592,27 +6829,71 @@ namespace jdi double enumValue = 0.0; const bool valueOk = FreeMarkerValue(lane, item, value, enumValue); - // 纵向细线(仅限本网格,低透明度):让用户看得出固定点在哪一刻。 - const float x = TimeToX(item.time); - UiDrawLine(g, x, clipTop, x, clipBottom, WithAlpha(signalColor, 0x70), - 1.0f * m_scale); + // ---- 纵向避让:与本次已放置的胶囊重叠时,向下 / 向上交替错开 ---- + const auto occupied = [&placed](const gd::RectF& rect) { + for (const gd::RectF& other : placed) + { + if (rect.X < other.X + other.Width && other.X < rect.X + rect.Width && + rect.Y < other.Y + other.Height && other.Y < rect.Y + rect.Height) + { + return true; + } + } + return false; + }; + const auto totalRect = [](const gd::RectF& labelRect, const gd::RectF& closeRect) { + const float right = std::max(labelRect.X + labelRect.Width, + closeRect.X + closeRect.Width); + return gd::RectF(labelRect.X, labelRect.Y, right - labelRect.X, + labelRect.Height); + }; + const float step = label.Height + 2.0f * m_scale; + const float minTop = clipTop + 1.0f * m_scale; + const float maxTop = std::max(minTop, clipBottom - label.Height - 1.0f * m_scale); + const float baseTop = label.Y; + float chosenTop = baseTop; + for (int attempt = 0; attempt <= 8; ++attempt) + { + const float magnitude = static_cast((attempt + 1) / 2) * step; + const float offset = + (attempt == 0) ? 0.0f : ((attempt % 2 == 1) ? magnitude : -magnitude); + const float top = std::max(minTop, std::min(baseTop + offset, maxTop)); + gd::RectF candidateLabel = label; + gd::RectF candidateClose = close; + candidateLabel.Y = top; + candidateClose.Y = top; + if (!occupied(totalRect(candidateLabel, candidateClose))) + { + chosenTop = top; + break; + } + } + label.Y = chosenTop; + close.Y = chosenTop; + placed.push_back(totalRect(label, close)); - // 数据点:与光标的数据点同一画法(圆点)。 + // 数据点:与光标的数据点同一画法(圆点),同样受「标记透明度」控制。 + const float x = TimeToX(item.time); const float radius = 3.4f * m_scale; - gd::SolidBrush dot(ToGdiColor(signalColor)); + const int dotAlpha = static_cast(std::lround(255.0f * markerAlpha)); + gd::SolidBrush dot(ToGdiColor(WithAlpha(signalColor, dotAlpha))); const float dotY = valueOk ? ValueToY(value, visual) : (clipTop + clipBottom) * 0.5f; g.FillEllipse(&dot, x - radius, dotY - radius, radius * 2.0f, radius * 2.0f); // ✕ 按钮的悬停态决定它的配色,因此先算再画胶囊。 const bool hot = (m_freeMarkerHoverLane == static_cast(laneIndex) && m_freeMarkerHoverItem == itemIndex); - PaintFreeMarkerCapsule(g, label, text, signalColor, close, hot); + PaintFreeMarkerCapsule(g, label, text, signalColor, close, hot, markerAlpha); FreeMarkerHit hit; hit.laneIndex = static_cast(laneIndex); hit.itemIndex = itemIndex; hit.close = close; - m_freeMarkerHits.push_back(hit); + if (markerAlpha > 0.02f) + { + // 完全透明时不可见 ⇒ 也不该被点中(否则会“隔着看不见的按钮”移除固定点)。 + m_freeMarkerHits.push_back(hit); + } } } @@ -7079,6 +7360,33 @@ namespace jdi UiFillRoundRect(g, gd::RectF(panel.X, panel.Y, panel.Width, titleHeight), 5.0f * m_scale, WithAlpha(m_theme->accent, 0x2A)); + // 标题栏左侧的「展开 / 折起」指示器:三角(朝下 = 已展开、朝右 = 已折起) + // + 三角后方的一条短横线(经典展开/折起标识),位于 GX 徽标左边。 + // 单击它切换折起状态;折起后面板只剩标题栏,这里是唯一的“重新展开”按钮。 + { + const gd::RectF& disclosureRect = layout.disclosure; + if (disclosureRect.Width > 0.0f) + { + const bool disclosureHot = + (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && + UiPointInRect(disclosureRect, static_cast(m_mouseX), + static_cast(m_mouseY)); + const std::uint32_t disclosureColor = + disclosureHot ? m_theme->accent : m_theme->text; + const float centerX = disclosureRect.X + 5.5f * m_scale; + const float centerY = disclosureRect.Y + disclosureRect.Height * 0.5f; + UiDrawTriangleIcon( + g, + gd::RectF(centerX - 4.5f * m_scale, centerY - 4.5f * m_scale, + 9.0f * m_scale, 9.0f * m_scale), + disclosureColor, + layout.collapsed ? IconDirection::RIGHT : IconDirection::DOWN); + UiDrawLine(g, centerX + 6.0f * m_scale, centerY, + disclosureRect.X + disclosureRect.Width, centerY, disclosureColor, + 1.4f * m_scale); + } + } + // 标题:与左侧信号面板一致的 “GX” 徽标。 // ⚠️ 几何必须取 `layout.badge`(与悬停提示 / 手型光标 / 点击联动同源); // 悬停时给一圈强调色描边,作为“可点击”的视觉提示(用户 2026-10-19 要求)。 @@ -7125,6 +7433,9 @@ namespace jdi closeRect.X + crossPad, closeRect.Y + closeRect.Height - crossPad, crossColor, 1.6f * m_scale); + // 折起的面板只保留标题栏:内容区(条目 / 分组栏 / 按钮 / 滚动条)全部省略。 + if (!layout.collapsed) + { // 「中心轴边沿计数」/「均值滤波」/「弱峰调整」面板都采用「逐条目」排布: // 第一排 = 名称 + 复选框,第二排 = 两个参数输入框(仅勾选的条目, // 弱峰调整没有参数输入框),中心轴再多第三排 = 对应的统计结果。 @@ -7345,68 +7656,101 @@ namespace jdi } } - // 「自由标记」面板:逐信号一行名称(带清除全部固定点的 ✕), - // 下面按时间升序列出该信号的固定点(`值(±增量) = 枚举` + 右对齐的 `t:… Δt:…` + 行尾 ✕)。 + // 「自由标记」面板:按信号分组的**分组栏**(信号色托底 + 三角 + 名称 + + // 「N 标记点」+ ✕),下属固定点条目五列对齐(取值 / 枚举 / 增量 / 时刻 / 间隔), + // 行尾各带一个 ✕。内容区有滚动偏移时按内容区裁剪。 if (freeMarker) { - const float closeSize = kFreeMarkerCloseSize * m_scale; - const float gap = 4.0f * m_scale; + const float groupRowHeight = kFreeMarkerNameRowHeight * m_scale; bool markerAny = false; for (const Lane::FreeMarkerItem& item : tabLane.freeMarkerItems) { markerAny = markerAny || item.ref.IsValid(); } + + const int contentState = g.Save(); + g.SetClip(layout.content, gd::CombineModeIntersect); + if (!markerAny) { - UiText(g, L"在网格内单击可固定数据点", - m_fonts->uiSmall, - gd::RectF(panel.X + pad, panel.Y + titleHeight + pad, - panel.Width - pad * 2.0f, kFreeMarkerNameRowHeight * m_scale), + UiText(g, L"在网格内单击可固定数据点", m_fonts->uiSmall, + gd::RectF(layout.content.X, layout.content.Y, layout.content.Width, + groupRowHeight), m_theme->textMuted, TextAlign::LEFT); } - for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) + + // ---- 分组栏 ---- + for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) { - const SignalRef& ref = layout.rowRefs[rowIndex]; - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || resolved.style == nullptr) + const ResolvedSignal resolved = ResolveSignal(group.ref); + if (resolved.data == nullptr) { continue; } - const std::uint32_t signalColor = StyleColor(*resolved.style); - // 名称行矩形直接取自布局(与 ✕ 同一套几何,绘制侧不反推)。 - const gd::RectF nameRect = - (rowIndex < layout.rowNameRects.size()) - ? layout.rowNameRects[rowIndex] - : gd::RectF(panel.X + pad, panel.Y + titleHeight + pad, - panel.Width - pad * 2.0f - (closeSize + gap), - kFreeMarkerNameRowHeight * m_scale); - UiText(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameRect.Width), - m_fonts->uiSmall, nameRect, signalColor, TextAlign::LEFT); + const std::uint32_t signalColor = + (resolved.style != nullptr) ? StyleColor(*resolved.style) : m_theme->border; + // 托底 = 信号色;文字一律用**自适应对比色**(与信号面板的组名徽标同规则), + // 因此无论信号是亮黄还是深蓝都能看清分组栏上的所有内容。 + const UiInk ink = UiContrastInk(signalColor); + UiFillRoundRect(g, group.header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); - // 信号名右侧的 ✕:清除该信号的**全部**固定点。 - if (rowIndex < layout.rowNameCloses.size() && - layout.rowNameCloses[rowIndex].Width > 0.0f) + // 左侧三角(与信号选择区域组名同款):朝下 = 展开、朝右 = 收起。 + const bool triangleHot = + (m_freeMarkerTriangleHoverLane == static_cast(index) && + m_freeMarkerTriangleHoverSignal == group.ref); + UiDrawTriangleIcon(g, group.triangle, + triangleHot ? m_theme->accentSoft : ink.text, + group.collapsed ? IconDirection::RIGHT + : IconDirection::DOWN); + + // 右侧:`N 标记点` + ✕(清除该信号全部固定点)。 + std::size_t markerCount = 0; + for (const Lane::FreeMarkerItem& entry : tabLane.freeMarkerItems) { - const gd::RectF& closeRect = layout.rowNameCloses[rowIndex]; - const bool hot = (m_freeMarkerPanelHoverLane == static_cast(index) && - m_freeMarkerPanelHoverSignal == ref && - m_freeMarkerPanelHoverItem == static_cast(-1)); - UiFillRoundRect(g, closeRect, 3.0f * m_scale, - hot ? WithAlpha(m_theme->danger, 0x66) : WithAlpha(signalColor, 0xC0)); - const float crossPad = 3.5f * m_scale; - const std::uint32_t crossColor = hot ? m_theme->danger : m_theme->textOnAccent; - UiDrawLine(g, closeRect.X + crossPad, closeRect.Y + crossPad, - closeRect.X + closeRect.Width - crossPad, - closeRect.Y + closeRect.Height - crossPad, crossColor, - 1.4f * m_scale); - UiDrawLine(g, closeRect.X + closeRect.Width - crossPad, - closeRect.Y + crossPad, closeRect.X + crossPad, - closeRect.Y + closeRect.Height - crossPad, crossColor, - 1.4f * m_scale); + if (entry.ref == group.ref) + { + ++markerCount; + } } + const std::wstring countText = + std::to_wstring(markerCount) + L" 标记点"; + const float countWidth = + UiMeasureTextWidth(m_fonts->monoSmall, countText) + 2.0f * m_scale; + const float countRight = group.close.X - 5.0f * m_scale; + UiText(g, countText, m_fonts->monoSmall, + gd::RectF(countRight - countWidth, group.header.Y, countWidth, + group.header.Height), + ink.text, TextAlign::RIGHT); + + const float nameLeft = group.triangle.X + group.triangle.Width + 6.0f * m_scale; + const float nameWidth = + std::max(10.0f, countRight - countWidth - 6.0f * m_scale - nameLeft); + UiText(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth), + m_fonts->uiSmall, + gd::RectF(nameLeft, group.header.Y, nameWidth, group.header.Height), + ink.text, TextAlign::LEFT); + + const bool closeHot = (m_freeMarkerPanelHoverLane == static_cast(index) && + m_freeMarkerPanelHoverSignal == group.ref && + m_freeMarkerPanelHoverItem == static_cast(-1)); + if (closeHot) + { + UiFillRoundRect(g, group.close, 3.0f * m_scale, + WithAlpha(m_theme->danger, 0x77)); + } + const float crossPad = 3.5f * m_scale; + const std::uint32_t crossColor = closeHot ? m_theme->danger : ink.text; + UiDrawLine(g, group.close.X + crossPad, group.close.Y + crossPad, + group.close.X + group.close.Width - crossPad, + group.close.Y + group.close.Height - crossPad, crossColor, + 1.5f * m_scale); + UiDrawLine(g, group.close.X + group.close.Width - crossPad, + group.close.Y + crossPad, group.close.X + crossPad, + group.close.Y + group.close.Height - crossPad, crossColor, + 1.5f * m_scale); } - // 固定点行(已按信号分组 + 组内时间升序展开)。 + // ---- 固定点条目(五列,列起点由布局给定 ⇒ 纵向严格对齐)---- for (const EdgePanelLayout::MarkerRow& markerRow : layout.markerRows) { const Lane::FreeMarkerItem& item = tabLane.freeMarkerItems[markerRow.itemIndex]; @@ -7426,48 +7770,43 @@ namespace jdi FreeMarkerDelta(tabLane, markerRow.itemIndex, value, hasPrev, delta, timeDelta); } - const std::wstring* enumText = - resolved.data->enumText.Find(enumValue); + const std::wstring* enumText = resolved.data->enumText.Find(enumValue); const std::wstring valueText = - FreeMarkerValueText(valueOk ? value : 0.0, hasPrev, delta, enumText); + valueOk ? FormatValue(value) : std::wstring(kFreeMarkerNotAvailable); + const std::wstring deltaText = + (valueOk && hasPrev) ? FormatSignedValue(delta) + : std::wstring(kFreeMarkerNotAvailable); const std::wstring timeText = FreeMarkerTimeText(item.time); - const std::wstring deltaText = FreeMarkerDeltaText(hasPrev, timeDelta); + const std::wstring timeDeltaText = + FreeMarkerDeltaText(valueOk && hasPrev, timeDelta); - // 四列(① 取值 ② 增量 ③ 时刻 ④ 间隔)各列**左对齐在固定横坐标**上, - // 于是同一信息在所有条目上纵向上严格对齐(用户明确要求); - // 列宽固定,超长文本用 `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); - if (hasPrev) + const auto paintCell = [&](EdgePanelLayout::MarkerColumnIndex column, + const std::wstring& text, std::uint32_t color) { + if (layout.markerColumns[column].width <= 0.0f || text.empty()) + { + return; + } + const gd::RectF rect = columnRect(column); + UiText(g, UiEllipsize(text, m_fonts->monoSmall, rect.Width), + m_fonts->monoSmall, rect, color, TextAlign::LEFT); + }; + paintCell(EdgePanelLayout::MARKER_COL_VALUE, valueText, m_theme->textStrong); + if (layout.markerHasEnumColumn) { - 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()) - { - 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); + paintCell(EdgePanelLayout::MARKER_COL_ENUM, + (enumText != nullptr) ? *enumText : std::wstring(), + m_theme->text); } + paintCell(EdgePanelLayout::MARKER_COL_DELTA, deltaText, m_theme->textMuted); + paintCell(EdgePanelLayout::MARKER_COL_TIME, timeText, m_theme->textMuted); + paintCell(EdgePanelLayout::MARKER_COL_DT, timeDeltaText, m_theme->textMuted); - // 行尾 ✕:移除该固定点。 + // 行尾 ✕:移除该固定点(与之前一致)。 const gd::RectF& closeRect = markerRow.close; const bool hot = (m_freeMarkerPanelHoverLane == static_cast(index) && m_freeMarkerPanelHoverItem == markerRow.itemIndex); @@ -7484,6 +7823,39 @@ namespace jdi closeRect.X + crossPad, closeRect.Y + closeRect.Height - crossPad, crossColor, 1.2f * m_scale); } + + g.Restore(contentState); + + // ---- 内容滚动条(只在内容超高时出现;按钮与滑块不参与滚动)---- + if (layout.scrollTrack.Width > 0.0f) + { + UiFillRoundRect(g, layout.scrollTrack, layout.scrollTrack.Width * 0.5f, + WithAlpha(m_theme->border, 0x88)); + UiFillRoundRect(g, layout.scrollThumb, layout.scrollThumb.Width * 0.5f, + m_theme->borderStrong); + } + + // ---- 「标记透明度」滑动条(仅「光标选取模式」激活时出现,固定在按钮上方)---- + if (layout.opacityTrack.Width > 0.0f) + { + UiText(g, kFreeMarkerOpacityLabel, m_fonts->uiSmall, + gd::RectF(panel.X + pad, layout.opacityValue.Y, + layout.opacityTrack.X - panel.X - pad, layout.opacityValue.Height), + m_theme->textMuted, TextAlign::LEFT); + const bool sliderActive = + (m_dragMode == PlotDragMode::MARKER_OPACITY && + m_markerOpacityDragLane == static_cast(index)) || + UiPointInRect(layout.opacityTrack, static_cast(m_mouseX), + static_cast(m_mouseY)); + // 外观与信号样式区的「透明度」滑动条完全相同(同一 `UiDrawSlider` 口径)。 + UiDrawSlider(g, layout.opacityTrack, + static_cast(tabLane.freeMarkerOpacity), m_theme->accent, + sliderActive, m_scale); + const int percent = + static_cast(tabLane.freeMarkerOpacity * 100.0f + 0.5f); + UiText(g, std::to_wstring(percent) + L"%", m_fonts->monoSmall, + layout.opacityValue, m_theme->textStrong, TextAlign::RIGHT); + } } float rowY = panel.Y + titleHeight + pad; @@ -7520,7 +7892,8 @@ namespace jdi } // 逐条目面板底部按钮行:「构建为新信号」(悬停高亮); - // 「自由标记」面板的按钮是「启用光标模式 / 关闭光标模式」的切换按钮。 + // 「自由标记」面板的按钮是「光标选取模式」的**切换类按钮** + //(交互外观与工具栏的切换按钮一致:激活时浅强调色底 + 强调描边 + 强文本色)。 if (hasButton || freeMarker) { const gd::RectF& buttonRect = layout.button; @@ -7532,16 +7905,21 @@ namespace jdi (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && UiPointInRect(inner, static_cast(m_mouseX), static_cast(m_mouseY)); - 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); - 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); + const bool checked = freeMarker && tabLane.freeMarkerCursor; + const std::uint32_t background = + checked ? (hot ? MixColor(m_theme->accentSoft, m_theme->accent, 0.16) + : m_theme->accentSoft) + : (hot ? m_theme->hover : m_theme->cardBg); + UiFillRoundRect(g, inner, 4.0f * m_scale, background); + UiDrawRoundRect(g, inner, 4.0f * m_scale, + checked ? m_theme->accent : m_theme->border, 1.2f); + const std::wstring label = freeMarker ? std::wstring(kFreeMarkerCursorModeLabel) + : std::wstring(kBuildSignalLabel); + UiText(g, label, m_fonts->uiSmall, inner, + checked ? m_theme->textStrong : m_theme->text, TextAlign::CENTER); } } + } // if (!layout.collapsed) // 面板画完了:把它刚画进后备缓冲区的像素(连同缓存键)存进面板图层, // 下一帧只要键不变就直接贴图。矩形外的 4 逻辑像素边距用于容纳投影。 diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 81e7a9d..bc0f7cf 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -3011,6 +3011,10 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } | 2026-10-20 | DeepSeek-V4.1-Flash(**本机**) | **新增实验性功能「自由标记」(网格内跟随鼠标的纵向光标 + 单击固定数据点 + 面板)**(详见 §12 末尾「2026-10-20 第七轮」与 §11.2 第 181–188 条)。① **数据层**:`Lane` 新增 `freeMarker` 开关、`FreeMarkerItem{ref, time}` 列表(`time` 存**显示时刻**,与光标同一口径)与 `freeMarkerPanel`;新增 `AddFreeMarker/RemoveFreeMarker/RemoveFreeMarkerAt/RemoveFreeMarkersOf/FindFreeMarker/HasFreeMarkers`(同一信号同一时刻不重复加入,容差 1e-12 相对量);`LaneCarry` 一并携带(重建网格不丢)。② **网格内交互**:`ProbeFreeMarker` 在「开启该功能的网格」内挑**离鼠标最近**的那个数据点(`MinorCellSparse` 成立时经 `SnapTimeToSample` 吸附到采样时刻并画圆环提示);`PaintFreeMarker` 画**仅限本网格**的纵向光标(上把手尖端朝下、下把手尖端朝上,**不显示时间读数**)+ 就近取值胶囊;已固定点画细线 + 圆点 + `值(±增量) = 枚举` 胶囊 + 行尾 ✕(悬停高亮 + 提示气泡)。**超出视界直接不显示**(不像常规数据点那样贴边定格 + 三角)。③ **单击 vs 框选**:`OnLButtonDown` 只登记 `m_freeMarkerPendingFix`,`OnLButtonUp` 里**原地未拖动**才真正 `AddFreeMarker`;选区放大模式下「按下后拖动」照旧进入框选并缩放。④ **面板**:新 `EdgePanelKind::MARK`(`PLOT_CMD_LANE_FREE_MARKER = 12`),按「网格内信号顺序 → 该信号内时间升序」展开;每行 `值(±增量) = 枚举` + 两列**右对齐**的 `t:<时刻>s` / `Δt:+<复合单位>`;信号名右侧 ✕ 清除该信号全部固定点、行尾 ✕ 移除单个;双击行编辑「自身时刻」或「相对前一固定点的间隔」(秒),提交后 `SortedFreeMarkerIndices` 自动重排(越过后续点即交换条目位置)。⑤ **两处修正**:`EdgePanelWidth` 的 MARK 分支**不做采样查询**(它被 `TopPanelAtPoint`/`SyncPanelOrder` 高频调用,逐行取值会拖慢鼠标),改用固定宽度 `kFreeMarkerPanelWidth` + 绘制侧省略号;`PaintFreeMarker` 与跟随光标共用 `PaintFreeMarkerCapsule`(避免两份重复绘制)。验证:新增离屏 `.preflight/markcheck` **31/31**(探测/就近选择/单击固定/三处✕/增量与时间列对齐/选区放大共存/吸附/越界不显示/间隔编辑后重排);回归 `check` 136/137(唯一失败项为既有不稳定项,基线同样失败)、`editcheck` 20/20、`round3check` 31/31、`round4check` 34/34、`round5check` 5/5、`convcheck` 18/18,真实 MF4 仍 283/8/18/24;`make release`/`make debug` 零警告;`TargetCheck.exe` **833,536 字节(814.0 KiB)** —— 比上一轮 **+24 KB**,**首次超出 800 KiB 的旧红线约 14 KB**。 | +| 2026-10-21 | DeepSeek-V4.1-Flash(**本机**) | **快进同步远端 5 个提交(`181f95c → 067128f`,35 文件 +3664/−313;未改动任何源码)**:`b315201` MF4 换算 2/3/4/5/6 + `##CA` + 数值 VLSD;`30f853a` `##CA` 全字段/3 维命名 + `cc_type = 8` 区间枚举 + 包络带线宽双向加粗;`53b981a` 全项目大检查(数据层/UI 真 BUG、死代码清理、体积评估);`dd8f63a` 新增实验性「自由标记」;`067128f` 自由标记改进(默认无光标 + 面板四列对齐 + 光标模式切换按钮)。本机验证:`make release TARGET_FILE_NAME=TargetCheck` **零警告**、产物 **857,600 字节**(GCC 16.1 seh 比起源设备 GCC 15.2 UCRT 略大);`.preflight/perf/datacheck` **69/69**,`MF4-Record.MF4` 仍 3 条时间轴 / **283** 信号(与远端记录一致)。 | + +| 2026-10-21 | DeepSeek-V4.1-Flash(**本机**) | **「自由标记」体验重做 + 全部实验性面板可折起(详见 §12 末尾「2026-10-21 第二轮」与 §11.2 第 194–201 条)**。① **网格内固定点**:取值胶囊互相重叠时做**纵向避让**;✕ 改为**与胶囊同高的正方形**;**删除纵向半透明竖线**;新增「标记透明度」(0–100%,作用于本网格全部固定点:圆点 / 胶囊 / 文字 / ✕);⚠️ **GDI `DrawTextW` 忽略 alpha**,因此半透明时文字改走 **GDI+ `DrawString`**(不透明度 100% 时仍走 `UiText`,像素零变化),实测 0% 时胶囊区域与“无标记”帧逐像素相同(1433→**0**)。② **面板布局重做**:每个有固定点的信号一个**信号色托底分组栏**(三角 + 名称 + `N 标记点` + ✕,文字/三角/✕ 一律用 `UiContrastInk` 自适应对比色,点击分组栏收起该组);条目改成**五列**(取值 / 枚举 / 增量 / 时刻 / 间隔),首项增列与Δt列显示 `N/A`,**整个面板没有枚举文本时枚举列整列缺省**。③ **内容滚动**:内容超高时右侧出现滚动条(分组栏与条目一起滚动),滚轮在内容区翻页;底部按钮与透明度滑块**不参与滚动**。④ **面板折起**:`LanePanelState` 新增 `collapsed`,标题栏 GX 徽标左侧新增「三角 + 横线」指示器(`kEdgePanelDisclosureWidth`),单击折起/展开、双击标题栏切换;折起后只剩标题栏(五类面板全部支持)。验证:新增离屏 `.preflight/perf/mark2check.exe`(**69/69**,含 A/B:恢复竖线 ⇒ 1 项失败;禁用避让 ⇒ 2 项失败);回归 `datacheck` 69/69、`verify` 49/49、`panelanim` 158/158、`errcheck` 19/19;`make release`/`make debug` 零警告;`TargetCheck.exe` 866,816 字节。 | + ## 11. 跨设备协作须知(起源设备视角) > **本文件是唯一权威记忆**。所有设备 / Agent 共用它,**不要再新建 `agent_mem_origin.md` 等旁支记忆文件**。 @@ -3919,6 +3923,31 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } 信号发名称行(`SortedFreeMarkerIndices` 为空就 `continue`),高度分支与矩形分支必须同步 跳过,否则面板会多出空白行。 +194. ⚠️⚠️ **GDI 文本(`UiText` → `DrawTextW`)完全不认 alpha**:`SetTextColor` 只接受 RGB, + 把 alpha 乘进颜色**不会**让文字淡出(实测 0% 时文字仍满不透明,胶囊区域像素差 300+)。 + 需要"文字随不透明度淡出"时只能改用 **GDI+ `DrawString`**(见 `DrawFreeMarkerAlphaText`: + 由 HFONT 取 `LOGFONTW` 构造 `gd::Font(hdc, &lf)` —— ⚠️ **该构造函数没有 `Unit` 参数**)。 + ⚠️ 只在"确实需要 alpha"的分支改走 GDI+,100% 时继续用 `UiText`,否则整片文字的光栅化都会变。 +195. ⚠️⚠️ **看不见的元素不得可点**:网格内固定点在 `freeMarkerOpacity <= 2%` 时整段不绘制, + 因此也**不登记** ✕ 命中矩形(`m_freeMarkerHits`),否则用户会"隔着空气"删掉固定点。 +196. ⚠️ **面板内容区必须裁剪、且只登记"与内容区相交"的行**:否则滚出视界的条目的 ✕ / 行矩形 + 会落在底部按钮与透明度滑块的位置上抢走点击。交互侧另有 + `UiPointInRect(layout.content, …)` 兜底。 +197. ⚠️⚠️ **`LanePanelState::open` 与 `collapsed` 是两件事**:`open == false` = 功能没开(面板不存在); + `collapsed == true` = 功能开着但只显示标题栏。`EdgePanelLayoutFor` 折起时仍要算完 + `title / close / disclosure / badge` 再 return,否则三角指示器与 ✕ 会"消失"。 +198. ⚠️ **面板的折起状态必须一起继承**:`LaneCarry`(`RebuildLanes`)与 `SplitLane` 都要拷贝 + `*.collapsed`,否则勾选信号重建网格后面板会自己弹回展开态。 +199. ⚠️ **`markerColumns` 是 5 列**(值 / 枚举 / 增量 / 时刻 / 间隔)。枚举列缺省时**列宽记 0** + 并从排版里跳过(后面的列左移);判断"要不要这一列"只允许查 `SignalEnumText::Empty()` + (O(1)),**绝不能**逐条采样(布局函数在 `TopPanelAtPoint` / `SyncPanelOrder` 里被高频调用)。 +200. ⚠️ **面板内容宽度必须给滚动条让位**:`innerWidth = 宽 − 2×pad − (可滚动 ? scrollW + 4 : 0)`, + 且五列总宽 + 行 ✕ 必须 ≤ `innerWidth`(当前列宽 `{74,80,60,72,78}` + 4×2 间隙 + 13 ✕ = 391 ≤ 399)。 +201. ⚠️ **MARK 面板的滚动偏移在布局里 clamp、不回写状态**(回写会让面板像素缓存键反复抖动); + 滚轮与拖滚动条这两条写入路径必须自己按 `markerContentHeight − content.Height` 夹取。 +202. ⚠️ **固定点取值胶囊的重叠避让只在绘制侧做**(`PaintFreeMarker` 内按已放置矩形纵向错开), + 命中矩形(`m_freeMarkerHits`)由绘制时登记 ⇒ 自动跟随避让后的位置,**不要**在命中侧重算。 + ### 11.3 离屏验证方法学(本机偏好,强烈建议沿用) - **不要占用用户的鼠标 / 键盘 / 屏幕**:优先 `.preflight/uitest`(离屏 harness:`SW_HIDE` 窗口 + 直接注入消息 + `BitBlt` 从 `m_backDc` 截图)或 `.preflight/perf`(离屏计时 / 逐像素对比)。 @@ -7293,6 +7322,110 @@ DG3 data=0 cycle=0 (空组,一个字符串通道) 真实 MF4 仍 **283** 条信号。 - `make release` / `make debug` **零警告**;`Build/Release/Target.exe` **834,560 字节(815.0 KiB)**。 +--- + +## 本轮(2026-10-21 第二轮,本机):「自由标记」体验重做 + 全部实验性面板可折起 + +> 用户需求(逐条): +> ① 网格内固定点的**取值显示互相重叠遮挡** ⇒ 加避让;取值后面的 ✕ 改成**与取值同高的正方形**; +> 固定点**不再显示纵向半透明竖线**; +> ② 面板底部的按钮改为只显示「**光标选取模式**」,交互外观与工具栏的切换类按钮一致(激活高亮), +> 激活时在按钮**上方**出现「**标记透明度**」滑动条(外观与信号样式区的「透明度」同款), +> 它控制本网格内**所有固定点显示内容**的透明度; +> ③ 面板内容重排:与信号列表的「组 → 信号」类似 —— 每个有固定点的信号一个**信号色托底分组栏** +> (左:展开/收起三角 + 信号名;右:`N 标记点` + ✕;文字/三角/✕ 都用**自适应对比色**), +> 点击分组栏展开/收起;条目五列:**当前值 / 枚举文本 / 增量 / 标记点时刻 / 相对前一点的时间增量**, +> 列之间纵向对齐、互不重叠;整个面板都没有枚举文本时**缺省枚举列**;首项增列与 Δt 列显示 `N/A`; +> 每行末尾仍有 ✕; +> ④ 内容过多时面板要有**上下滚动条**(分组栏 + 条目一起滚动),底部按钮与滑动条**不参与滚动**; +> ⑤ **所有**实验性功能浮动面板都支持展开/折起:GX 徽标左边加**三角 + 横线**指示器, +> 单击折起/展开,收起的面板只有标题栏,双击标题栏也可展开。 + +### A. 数据层(`Utilities`) + +- `LanePanelState` 新增 `bool collapsed = false`(`open` 仍然表示“功能是否显示”,二者语义不同)。 +- `Lane` 新增:`float freeMarkerOpacity = 1.0f`(网格内固定点显示不透明度)、 + `float freeMarkerScroll = 0.0f`(面板内容滚动偏移)、 + `std::vector freeMarkerCollapsedGroups`(面板里被收起的信号分组;**不在列表里 = 展开**) + + `FreeMarkerGroupCollapsed()` / `ToggleFreeMarkerGroupCollapsed()`。 +- `LaneCarry`(`RebuildLanes`)与 `TabConfig::SplitLane` 这两处“网格重建/拆分时的状态继承”都带上新状态, + 并对五个面板都补了 `*.collapsed` 的拷贝。 + +### B. 网格内固定点 + +- **标签避让**:`PaintFreeMarker` 逐条记录已放置的胶囊外接矩形;新胶囊与它们相交时按 + `+1、-1、+2、-2 …` 步长(步长 = 胶囊高 + 2px)**纵向错开**,并夹在网格可视范围(`clipTop..clipBottom`)内。 +- **✕ 正方形**:`FreeMarkerLabelRects` 里 `closeSize = textHeight`(胶囊高度),宽高相等。 +- **删除纵向竖线**:固定点不再画那条 `WithAlpha(signalColor, 0x70)` 的竖线(跟随光标模式的光标线仍在)。 +- **标记透明度**:圆点、胶囊底色、描边、文字、✕ 都乘 `lane.freeMarkerOpacity`; + ⚠️ **GDI `DrawTextW` 忽略 alpha**(`SetTextColor` 只吃 RGB)⇒ 半透明时文字改由 + `DrawFreeMarkerAlphaText()`(GDI+ `DrawString` + alpha brush)绘制,100% 时仍走 `UiText` + (像素零变化)。不透明度 ≤ 2% 时**整个胶囊什么都不画**,且**不登记 ✕ 命中矩形** + (看不见的按钮不应可点)。 + +### C. 面板布局(`EdgePanelLayout` / `EdgePanelLayoutFor`) + +- 新增字段:`disclosure`(标题栏左侧三角指示器,宽 `kEdgePanelDisclosureWidth = 20`)、 + `collapsed`、`content`/`scrollTrack`/`scrollThumb`/`markerContentHeight`、 + `opacityTrack`/`opacityValue`、`markerGroups`(`MarkerGroup{ref, header, triangle, close, collapsed}`)、 + `markerHasEnumColumn`;`markerColumns` 由 4 列扩到 **5 列** + (`MARKER_COL_VALUE/ENUM/DELTA/TIME/DT`)。 +- `GX` 徽标右移一个 `disclosure` 宽度;**折起**时 `height = titleHeight` 并在算完 + `title/close/disclosure/badge` 后直接 return(内容区/按钮/滚动条都是空矩形)。 +- MARK:内容总高 = Σ(分组栏 + 未收起组的条目),`viewHeight = min(总高, 内容上限)`; + 内容上限 = `min(kFreeMarkerContentMaxHeight=260, 绘图区高 − 标题 − 3×pad − 按钮 − 滑块行)`。 + 超高 ⇒ `scrollTrack` 宽 `kFreeMarkerScrollWidth=7`、滑块高度按内容比例、位置按滚动比例; + 同时把内容宽度收窄 `scrollW + 4px`,保证五列 + 行 ✕ 不会与滚动条重叠 + (列宽因此调整为 `{74, 80, 60, 72, 78}`,面板宽 `424`)。 +- 只登记**与内容区相交**的分组栏与条目矩形 ⇒ 滚出视界的条目不会抢走底部按钮/滑块的点击 + (交互侧再用 `UiPointInRect(layout.content, …)` 兜一层)。 +- 枚举列:只查“这些信号是否带枚举表”(`SignalEnumText::Empty()`,O(1)), + **不逐条采样**(布局函数被 `TopPanelAtPoint`/`SyncPanelOrder` 高频调用)。 + +### D. 绘制 + +- 标题栏:先画「三角(朝下 = 展开 / 朝右 = 折起)+ 三角右侧一条短横线」,再画 GX 徽标与标题。 +- MARK 内容区:`g.SetClip(content, Intersect)` 后绘制;分组栏用 `WithAlpha(signalColor, 0xE8)` 托底, + 三角 / 名称 / `N 标记点` / ✕ 全部用 `UiContrastInk(signalColor)` 的自适应对比色; + 条目五列左对齐在**布局给定的列起点**上(超长 `UiEllipsize`), + `值 | (枚举) | ±增量 or N/A | <时刻>s | Δt:+… or N/A`。 +- 底部:按钮改为**切换类按钮**外观(激活 = `accentSoft` 底 + `accent` 描边 + `textStrong`),文案固定 + 「光标选取模式」;激活时其上方是「标记透明度」行(左侧标签 + 轨道 + 右侧百分比, + 与信号样式区的「透明度」行同款 `UiDrawSlider`)。 + +### E. 交互 + +- 新增拖拽模式 `MARKER_OPACITY`(拖滑块 → `SetLaneFreeMarkerOpacity`)与 + `MARKER_SCROLL`(拖滚动条滑块 → `SetLaneFreeMarkerScroll`,含抓取偏移与轨道点击跳转)。 +- `OnMouseWheel`:鼠标在 MARK **内容区**上时滚轮翻页面板(±40px/格),否则照旧滚网格; + 底部按钮/滑块区域**不**翻页。 +- `OnLButtonDown` 顺序:✕ → GX 徽标 → **disclosure** → 逐条目参数 → MARK 底部按钮 → 透明度滑块 → + 滚动条 → 分组栏(✕ 清空该信号 / 其它位置切换收起)→ 行尾 ✕ → 标题栏拖动 → 面板主体消费。 +- `OnDoubleClick`:标题栏(排除徽标/✕/三角)**双击切换折起**;MARK 条目仍按列边界编辑时刻/间隔。 +- 悬浮:分组栏三角悬停高亮(`m_freeMarkerTriangleHoverLane/Signal`),✕ 悬停沿用 + `m_freeMarkerPanelHover*`;光标形状:三角/分组栏/✕ = 手型,透明度滑块 = 左右箭头,滚动条 = 上下箭头。 + +### F. 验证 + +- 新增离屏 `.preflight/perf/mark2check.exe` **69/69**: + [A] 固定点:单点无竖线(与“无标记”帧逐像素对比,竖线列差异必须为 0)、✕ 正方形、 + 三点胶囊纵向避让(✕ 矩形互不相交且 Y 不同); + [B] 面板:一个分组栏(三角/✕ 在托底内)、三行条目、五列不重叠、枚举列缺省(宽 0); + [C] 分组收起后只留分组栏、行数归零、面板变矮,再次点击恢复; + [F] 五类面板折起后高度 == 标题栏高度且内容全空(含 COUNT 面板); + [D] 40 个固定点 ⇒ 内容超可视区、滚动条出现、滑块在轨道内;滚到最大时滑块贴底、 + 分组栏与条目一起移出、滚轮在内容区可翻页而在按钮区不翻页; + [E] 光标模式开关 ⇒ 滑块出现/消失、面板高度随之变化;点击轨道 25% ⇒ 不透明度 ≈25%; + 不透明度 0% 与“无标记”帧逐像素相同(1433 → **0** 像素差异)。 + [G] 额外导出 `mark2check_shot*.bmp`(人工目视检查用,不入库)。 +- **A/B**:临时恢复固定点竖线 ⇒ 「no translucent vertical line」如实失败; + 临时禁用避让(`attempt <= 0`)⇒ 「capsules do not overlap」如实失败。恢复后 69/69。 +- 回归:`datacheck` 69/69、`verify` 49/49、`panelanim` 158/158、`errcheck` 19/19; + `make release` / `make debug` **零警告**;`TargetCheck.exe` **866,816 字节**。 + ⚠️ `verify.exe` 在打印完结果后**退出时**仍会 0xC0000005(`git stash` 回到本轮改动前的基线 + 同样复现 ⇒ 探针自身的既有问题,与本轮无关)。 + + diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index 652fff5..3017e0e 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -720,7 +720,15 @@ namespace jdi */ struct LanePanelState { - bool open = true; /**< 面板是否展开(点击面板上的 ✕ 后置 false)。 */ + bool open = true; /**< 功能面板是否显示(功能关闭时置 false)。 */ + /** + * @brief 面板是否被**折起**(只显示标题栏)。 + * + * 与 `open` 的区别:`open == false` 表示该功能本身没有开启(面板完全不存在); + * `collapsed == true` 表示功能仍开启、但内容被收起,只留标题栏(点击标题栏左侧的 + * 三角指示器切换,双击标题栏也可展开)。 + */ + bool collapsed = false; double x = -1.0; /**< 面板左上角横坐标(客户区像素;<0 表示自动定位)。 */ double y = -1.0; /**< 面板左上角纵坐标(<0 表示自动定位)。 */ }; @@ -976,8 +984,63 @@ namespace jdi return false; } - /** 自由标记浮动面板的 UI 状态(展开 + 位置)。 */ + /** 自由标记浮动面板的 UI 状态(展开/折起 + 位置)。 */ LanePanelState freeMarkerPanel; + + /** + * @brief 网格内固定数据点(纵向细线 / 数据点 / 取值胶囊 / 行尾 ✕)的显示不透明度。 + * + * 0 = 完全透明(看不见),1 = 完全不透明(默认)。由浮动面板底部「光标选取模式」 + * 激活时出现的「标记透明度」滑动条驱动;只影响**本网格**的固定点显示内容, + * 不影响面板自身的像素与鼠标交互。 + */ + float freeMarkerOpacity = 1.0f; + + /** + * @brief 「自由标记」面板内容的滚动偏移(客户区像素,0 = 顶部)。 + * + * 面板内容(信号分组栏 + 固定点条目)超出可视高度时出现滚动条; + * 保持在这个范围之外的内容会被裁剪。面板底部的按钮与「标记透明度」 + * 滑动条**不参与**滚动(它们固定在面板底部)。 + */ + float freeMarkerScroll = 0.0f; + + /** + * @brief 「自由标记」面板里被**收起**的信号分组(点击分组栏切换)。 + * + * 只保存被收起的信号引用:不在列表里 = 展开(默认)。收起后该分组只显示 + * 分组栏(三角朝右),下属的固定点条目不显示。 + */ + std::vector freeMarkerCollapsedGroups; + + /** 某信号分组在「自由标记」面板里是否被收起。 */ + bool FreeMarkerGroupCollapsed(const SignalRef& ref) const + { + for (const SignalRef& entry : freeMarkerCollapsedGroups) + { + if (entry == ref) + { + return true; + } + } + return false; + } + + /** 切换某信号分组在「自由标记」面板里的收起状态,返回切换后的状态。 */ + bool ToggleFreeMarkerGroupCollapsed(const SignalRef& ref) + { + for (std::size_t index = 0; index < freeMarkerCollapsedGroups.size(); ++index) + { + if (freeMarkerCollapsedGroups[index] == ref) + { + freeMarkerCollapsedGroups.erase( + freeMarkerCollapsedGroups.begin() + static_cast(index)); + return false; + } + } + freeMarkerCollapsedGroups.push_back(ref); + return true; + } }; /** diff --git a/Utilities/Src/DataModelInternal.hpp b/Utilities/Src/DataModelInternal.hpp index 50655f7..5b2945d 100644 --- a/Utilities/Src/DataModelInternal.hpp +++ b/Utilities/Src/DataModelInternal.hpp @@ -92,6 +92,9 @@ namespace jdi bool freeMarkerCursor = false; std::vector freeMarkerItems; LanePanelState freeMarkerPanel; + float freeMarkerOpacity = 1.0f; + float freeMarkerScroll = 0.0f; + std::vector freeMarkerCollapsedGroups; }; // 先记下每个信号原来所在网格的网格自身状态: @@ -133,6 +136,9 @@ namespace jdi carry.freeMarkerCursor = lane.freeMarkerCursor; carry.freeMarkerItems = lane.freeMarkerItems; carry.freeMarkerPanel = lane.freeMarkerPanel; + carry.freeMarkerOpacity = lane.freeMarkerOpacity; + carry.freeMarkerScroll = lane.freeMarkerScroll; + carry.freeMarkerCollapsedGroups = lane.freeMarkerCollapsedGroups; for (const SignalRef& ref : lane.signals) { previousCarry.emplace_back(ref, carry); @@ -158,29 +164,37 @@ namespace jdi lane.edgeValue = carry.edgeValue; lane.edgeValueValid = carry.edgeValueValid; lane.edgePanel.open = carry.edgePanel.open; + lane.edgePanel.collapsed = carry.edgePanel.collapsed; lane.edgePanel.x = carry.edgePanel.x; lane.edgePanel.y = carry.edgePanel.y; lane.edgeCenter = carry.edgeCenter; lane.edgeCenterItems = carry.edgeCenterItems; lane.edgeCenterPanel.open = carry.edgeCenterPanel.open; + lane.edgeCenterPanel.collapsed = carry.edgeCenterPanel.collapsed; lane.edgeCenterPanel.x = carry.edgeCenterPanel.x; lane.edgeCenterPanel.y = carry.edgeCenterPanel.y; lane.meanFilter = carry.meanFilter; lane.meanFilterItems = carry.meanFilterItems; lane.meanFilterPanel.open = carry.meanFilterPanel.open; + lane.meanFilterPanel.collapsed = carry.meanFilterPanel.collapsed; lane.meanFilterPanel.x = carry.meanFilterPanel.x; lane.meanFilterPanel.y = carry.meanFilterPanel.y; lane.weakPeak = carry.weakPeak; lane.weakPeakItems = carry.weakPeakItems; lane.weakPeakPanel.open = carry.weakPeakPanel.open; + lane.weakPeakPanel.collapsed = carry.weakPeakPanel.collapsed; 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.collapsed = carry.freeMarkerPanel.collapsed; lane.freeMarkerPanel.x = carry.freeMarkerPanel.x; lane.freeMarkerPanel.y = carry.freeMarkerPanel.y; + lane.freeMarkerOpacity = carry.freeMarkerOpacity; + lane.freeMarkerScroll = carry.freeMarkerScroll; + lane.freeMarkerCollapsedGroups = carry.freeMarkerCollapsedGroups; }; // 收集全部可见信号引用。 diff --git a/Utilities/Src/DataModelTab.cpp b/Utilities/Src/DataModelTab.cpp index 44e77ab..0c374e1 100644 --- a/Utilities/Src/DataModelTab.cpp +++ b/Utilities/Src/DataModelTab.cpp @@ -430,18 +430,22 @@ namespace jdi lane.edgeValue = source.edgeValue; lane.edgeValueValid = source.edgeValueValid; lane.edgePanel.open = source.edgePanel.open; + lane.edgePanel.collapsed = source.edgePanel.collapsed; lane.edgePanel.x = source.edgePanel.x; lane.edgePanel.y = source.edgePanel.y; lane.edgeCenter = source.edgeCenter; lane.edgeCenterPanel.open = source.edgeCenterPanel.open; + lane.edgeCenterPanel.collapsed = source.edgeCenterPanel.collapsed; lane.edgeCenterPanel.x = source.edgeCenterPanel.x; lane.edgeCenterPanel.y = source.edgeCenterPanel.y; lane.weakPeak = source.weakPeak; lane.weakPeakPanel.open = source.weakPeakPanel.open; + lane.weakPeakPanel.collapsed = source.weakPeakPanel.collapsed; lane.weakPeakPanel.x = source.weakPeakPanel.x; lane.weakPeakPanel.y = source.weakPeakPanel.y; lane.meanFilter = source.meanFilter; lane.meanFilterPanel.open = source.meanFilterPanel.open; + lane.meanFilterPanel.collapsed = source.meanFilterPanel.collapsed; lane.meanFilterPanel.x = source.meanFilterPanel.x; lane.meanFilterPanel.y = source.meanFilterPanel.y; // 拆分后每个子网格只保留自己那个信号的均值滤波 / 中心轴 / 弱峰调整条目。