From 79eebc281ebd45ae762a2fc791062413a04519d3 Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Thu, 22 Oct 2026 21:04:03 +0800 Subject: [PATCH] =?UTF-8?q?=E8=87=AA=E7=94=B1=E6=A0=87=E8=AE=B0=EF=BC=9A?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E8=A1=A8=E5=A4=B4/=E5=90=B8=E9=A1=B6/?= =?UTF-8?q?=E8=A1=8C=E9=80=89=E4=B8=AD=E4=B8=8E=E6=82=AC=E5=81=9C=E8=81=94?= =?UTF-8?q?=E5=8A=A8=20+=20=E5=8E=9F=E4=BD=8D=E6=98=BE=E7=A4=BA=20+=20?= =?UTF-8?q?=E6=8E=A2=E6=B5=8B=E5=AE=B9=E5=B7=AE=E4=B8=8E=E9=98=B6=E8=B7=83?= =?UTF-8?q?=E5=90=B8=E9=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 面板:每个展开分组下方新增一行列表头(标记值/枚举文本/增量/时间点/时间间隔), 与数据行共用列边界;列宽同时容纳列标题;表头行计入内容总高与面板高度; 全部无枚举文本时仍整列缺省 - 吸顶:分组栏与表头各自按 max(自然, min(吸顶, 组下边界-自身高)) 钉住, 组末尾按"表头先走 -> 条目 -> 分组栏最后离场";绘制顺序改为条目->表头->吸顶分组栏, 被盖住的条目不绘制也不可命中 - 行交互:Lane::freeMarkerSelected(单击选中/再点取消)+ 独立的整行悬停状态 (整行悬停不会把该行 ✕ 变成 danger 色);悬停时网格内对应固定点整体高亮 (数据点圆环 + 胶囊强制不透明 + accent 描边,仅视界内) - 固定点恢复原位显示(删除纵向避让,允许遮挡,回到初版方案) - 探测判据重写:鼠标到画出来的折线的垂直距离 <= kSignalHitTolerance(与曲线高亮同源), 命中后吸附到最近采样时刻 => 修复阶跃竖直跳变线无法探测/固定的问题; 删除已无用的 kFreeMarkerHoverTolerance - 修复滚动时条目文字串到面板标题栏(GDI 文本不认 GDI+ 裁剪,改用 UiTextClipped) - 顺带清理过期注释(四列->五列、折起指示器已无横线、探测判据说明), RemoveFreeMarker 改为复用 RemoveFreeMarkerAt(选中下标校正只有一处) - 验证:新增离屏 .preflight/mark4check 38/38(三组 A/B 均如实失败); 回归 markcheck 61/61、fixcheck 37/37、round3check 31/31、round4check 34/34、 round5check 5/5、editcheck 20/20、convcheck 18/18;make release/debug 零警告 --- Components/PlotView/Inc/PlotView.hpp | 46 +- Components/PlotView/Src/PlotViewFeatures.cpp | 19 + .../PlotView/Src/PlotViewInteraction.cpp | 40 ++ Components/PlotView/Src/PlotViewInternal.hpp | 33 +- Components/PlotView/Src/PlotViewPaint.cpp | 420 +++++++++++++----- Documents/agent_mem.md | 115 +++++ Utilities/Inc/DataModel.hpp | 19 +- Utilities/Src/DataModel.cpp | 30 +- Utilities/Src/DataModelInternal.hpp | 3 + Utilities/Src/DataModelTab.cpp | 9 + 10 files changed, 618 insertions(+), 116 deletions(-) diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 256d221..8367e9a 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -991,6 +991,16 @@ namespace jdi */ bool ToggleLaneFreeMarkerGroup(int laneIndex, const SignalRef& ref); + /** + * @brief 选中「自由标记」面板里的某个条目(`-1` = 取消选中)。 + * + * 选中的行在面板里高亮;它与网格内的固定点一一对应。 + * @param [IN] laneIndex 网格下标。 + * @param [IN] itemIndex `Lane::freeMarkerItems` 下标(越界会被夹成“取消选中”)。 + * @return 选中状态发生变化返回 true。 + */ + bool SelectLaneFreeMarker(int laneIndex, int itemIndex); + /** * @brief 切换某个实验性功能浮动面板的**折起**状态(只显示标题栏)。 * @param [IN] laneIndex 网格下标。 @@ -1716,9 +1726,25 @@ namespace jdi { SignalRef ref; gd::RectF header; /**< 分组栏托底矩形。 */ + /** + * @brief 分组栏下方的一行**列表头**(取值 / 枚举文本 / 增量 / 时间点 / 时间间隔)。 + * + * 与条目列共用 `markerColumns` 的列边界 ⇒ 表头与数据严格对齐。 + * 分组被收起(或还没登记)时为空矩形。 + * ⚠️ 滚动时它可能与分组栏一起**悬浮**在内容区顶部(吸顶), + * 因此它的 Y 不一定是“分组栏 Y + 行高”。 + */ + gd::RectF tableHeader; gd::RectF triangle; /**< 展开/收起三角的命中矩形。 */ gd::RectF close; /**< 右侧✕的命中矩形。 */ bool collapsed = false; + /** + * @brief 该分组栏是否处于**吸顶**(滚动时被钉在内容区顶部)。 + * + * 吸顶时分组栏与表头会盖住正在向上滚的条目 ⇒ 绘制侧必须把它们画在**条目之后** + * (否则会被条目覆盖),交互侧也已把这些被盖住的条目从 `markerRows` 里剔除。 + */ + bool pinned = false; }; /** 「自由标记」:信号分组栏(顺序与面板显示顺序一致)。 */ std::vector markerGroups; @@ -1788,9 +1814,13 @@ namespace jdi * 该网格内所有可见信号中**离鼠标最近**的那一个。 * * 两种模式由 `Lane::freeMarkerCursor` 决定: - * * **默认(非光标模式)**:只有鼠标**贴近某条信号曲线**(或该信号命中「自动点线」 - * 稀疏判定、且鼠标靠近某个数据点)时才有结果(`valid == true`),也**不画**纵向光标; - * * **光标模式**:网格内始终 `valid == true`(画纵向光标),能取到数据点时再标注取值。 + * * **默认(非光标模式)**:只有鼠标**贴近某条信号曲线**时才有结果(`valid == true`), + * 也**不画**纵向光标。⚠️ 判据 = 鼠标到**画出来的折线**的垂直距离 ≤ + * `kSignalHitTolerance`(与「常规状态下触发信号曲线高亮」**完全同一范围**; + * 因此阶跃的竖直跳变段也能探测到)。判定通过后,标注时刻会被吸附到最近的**采样时刻** + * (固定点必须落在真实数据点上); + * * **光标模式**:网格内始终 `valid == true`(画纵向光标),能取到数据点时再标注取值 + * (此时只有命中「自动点线」稀疏判据的信号才吸附)。 */ struct FreeMarkerProbe { @@ -2665,6 +2695,16 @@ namespace jdi /** 最近一次解析出的「自由标记」跟随数据点(绘制与命中共用)。 */ FreeMarkerProbe m_freeMarker; + /** + * @brief 上一帧鼠标悬停在「自由标记」面板的哪个**条目行**上(-1 = 没有)。 + * + * 与 `m_freeMarkerPanelHoverLane/Item`(只表示 ✕ 的悬停)**分开**: + * 悬停在整行上时要把网格内对应的固定点整体高亮(取值胶囊 + 数据点), + * 但**不能**因此把该行的 ✕ 画成 danger 色(那是“准备删除”的语义)。 + */ + int m_freeMarkerRowHoverLane = -1; + std::size_t m_freeMarkerRowHoverItem = static_cast(-1); + /** * @brief 上一次绘制时登记的固定点 ✕ 命中矩形。 * diff --git a/Components/PlotView/Src/PlotViewFeatures.cpp b/Components/PlotView/Src/PlotViewFeatures.cpp index 0ae83fe..da4fcb5 100644 --- a/Components/PlotView/Src/PlotViewFeatures.cpp +++ b/Components/PlotView/Src/PlotViewFeatures.cpp @@ -1735,6 +1735,25 @@ namespace jdi return lane.ToggleFreeMarkerGroupCollapsed(ref); } + bool PlotView::SelectLaneFreeMarker(int laneIndex, int itemIndex) + { + if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) + { + return false; + } + Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + // 越界一律视为“取消选中”(面板重建、条目被删之后调用方不必先做下界检查)。 + const int clamped = (itemIndex >= 0 && static_cast(itemIndex) < lane.freeMarkerItems.size()) + ? itemIndex + : -1; + if (lane.freeMarkerSelected == clamped) + { + return false; + } + lane.freeMarkerSelected = clamped; + return true; + } + bool PlotView::SetLanePanelCollapsed(int laneIndex, EdgePanelKind kind, bool collapsed) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) diff --git a/Components/PlotView/Src/PlotViewInteraction.cpp b/Components/PlotView/Src/PlotViewInteraction.cpp index fae17af..5c79a8e 100644 --- a/Components/PlotView/Src/PlotViewInteraction.cpp +++ b/Components/PlotView/Src/PlotViewInteraction.cpp @@ -1584,6 +1584,8 @@ namespace jdi SignalRef hoverSignal; int triLane = -1; SignalRef triSignal; + int rowLane = -1; + std::size_t rowItem = static_cast(-1); int markLane = -1; int markKind = 0; if (m_tab != nullptr && @@ -1606,6 +1608,20 @@ namespace jdi break; } } + // 整行悬停(用于把网格内对应的固定点整体高亮)。 + if (hoverLane < 0 && insideContent) + { + for (const EdgePanelLayout::MarkerRow& row : layout.markerRows) + { + if (row.rect.Width > 0.0f && + UiPointInRect(row.rect, static_cast(x), static_cast(y))) + { + rowLane = markLane; + rowItem = row.itemIndex; + break; + } + } + } if (hoverLane < 0) { for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) @@ -1635,6 +1651,12 @@ namespace jdi m_freeMarkerPanelHoverSignal = hoverSignal; changed = true; } + if (rowLane != m_freeMarkerRowHoverLane || rowItem != m_freeMarkerRowHoverItem) + { + m_freeMarkerRowHoverLane = rowLane; + m_freeMarkerRowHoverItem = rowItem; + changed = true; + } if (triLane != m_freeMarkerTriangleHoverLane || !(triSignal == m_freeMarkerTriangleHoverSignal)) { @@ -2337,6 +2359,17 @@ namespace jdi m_freeMarkerPanelHoverSignal = SignalRef(); return true; } + // 行的**数据区域**(✕ 之外):单击选中该条目(再次单击取消选中)。 + // 选中行在面板里高亮;与网格内的固定点一一对应。 + if (insideContent && row.rect.Width > 0.0f && UiPointInRect(row.rect, fx, fy)) + { + SelectLaneFreeMarker( + index, (tabLane.freeMarkerSelected == static_cast(row.itemIndex)) + ? -1 + : static_cast(row.itemIndex)); + m_dragMode = PlotDragMode::NONE; + return true; + } } } @@ -2978,6 +3011,13 @@ namespace jdi m_freeMarkerPanelHoverSignal = SignalRef(); changed = true; } + if (m_freeMarkerRowHoverLane != -1 || + m_freeMarkerRowHoverItem != static_cast(-1)) + { + m_freeMarkerRowHoverLane = -1; + m_freeMarkerRowHoverItem = static_cast(-1); + changed = true; + } if (m_cursorHover) { m_cursorHover = false; diff --git a/Components/PlotView/Src/PlotViewInternal.hpp b/Components/PlotView/Src/PlotViewInternal.hpp index 5b22c6f..25f6e88 100644 --- a/Components/PlotView/Src/PlotViewInternal.hpp +++ b/Components/PlotView/Src/PlotViewInternal.hpp @@ -337,6 +337,23 @@ namespace jdi /** 「自由标记」面板里每个固定点行的高度(五列 + 行尾 ✕)。 */ inline const float kFreeMarkerItemRowHeight = 20.0f; + /** + * @brief 「自由标记」面板里每组分栏下方那一行**列表头**的高度(逻辑像素)。 + * + * 表头 = 取值 / (枚举文本)/ 增量 / 时间点 / 时间间隔 五个列标题, + * 与数据行共用同一组列边界 ⇒ 表头与数据严格对齐。 + * ⚠️ 滚动时分组栏与表头会**各自吸顶**(见 `EdgePanelLayoutFor` 的 `MarkerGroup`), + * 因此表头高度必须参与内容总高度与吸顶量计算。 + */ + inline const float kFreeMarkerHeaderRowHeight = 18.0f; + + /** + * @brief 「自由标记」面板表头的五个列标题(顺序与 `markerColumns` 一致: + * ① 取值 ② 枚举 ③ 增量 ④ 时刻 ⑤ 间隔)。 + */ + inline const wchar_t* const kFreeMarkerColumnLabels[5] = { + L"标记值", L"枚举文本", L"增量", L"时间点", L"时间间隔"}; + /** 「自由标记」面板与行尾 ✕ 按钮的边长(网格内取值胶囊后的 ✕ 另按胶囊高度取正方形)。 */ inline const float kFreeMarkerCloseSize = 13.0f; @@ -381,18 +398,16 @@ namespace jdi /** * @brief 所有实验性功能浮动面板标题栏左侧「展开/折起」指示器的宽度。 * - * 指示器 = 三角(朝右 = 已折起、朝下 = 已展开)+ 三角后方的一条横线; - * 点击它切换面板的折起状态。`GX` 编号徽标向右让出这块宽度。 + * 指示器 = 与信号列表组名**同款**的折叠三角(朝右 = 已折起、朝下 = 已展开), + * 单击它切换面板的折起状态。`GX` 编号徽标向右让出这块宽度。 + * ⚠️ 2026-10-21 起**不再**画三角后方那条横线(用户要求与组名标记完全一致)。 */ inline const float kEdgePanelDisclosureWidth = 20.0f; - /** - * @brief 非光标模式下判定"鼠标在信号曲线上"的纵向容差(逻辑像素)。 - * - * 默认(不启用光标模式)时只有鼠标贴近曲线才显示取值预览;这个容差决定"贴近"的量级。 - * 取得太小会很难命中细线,太大则会在空白处误判成"有信号"从而吃掉平移操作。 - */ - inline const float kFreeMarkerHoverTolerance = 8.0f; + // ⚠️ 这里曾有 `kFreeMarkerHoverTolerance = 8`(旧的非光标模式“纵向贴近”容差)。 + // 用户 2026-10-22 要求把自由标记的触发范围与「常规状态下触发信号曲线高亮」**统一**, + // 因此现在直接复用 `kSignalHitTolerance`(6 逻辑像素)并改判“到折线的垂直距离” + //(见 `PlotView::ProbeFreeMarker`)。旧的 8 像素常量已删除,不要再加回来。 /** 固定点取值胶囊与 ✕ 之间的间距。 */ inline const float kFreeMarkerCloseGap = 3.0f; diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 728eef9..a1d2497 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -170,8 +170,13 @@ namespace jdi { continue; } - columnsWidth += std::max(kFreeMarkerColumnWidths[column] * m_scale, - lane.freeMarkerColumnWidths[static_cast(column)]); + // 列宽还要容得下**表头的列标题**(如「时间间隔」),否则表头会被省略。 + const float labelWidth = + UiMeasureTextWidth(m_fonts->uiSmall, kFreeMarkerColumnLabels[column]) + + 2.0f * m_scale; + columnsWidth += std::max(std::max(kFreeMarkerColumnWidths[column] * m_scale, + lane.freeMarkerColumnWidths[static_cast(column)]), + labelWidth); columnsWidth += kFreeMarkerColumnGap * m_scale; } // 行尾 ✕(按胶囊同高的正方形取宽)+ 滚动条预留 + 少量余量。 @@ -5469,6 +5474,7 @@ namespace jdi if (freeMarker) { const float groupRow = kFreeMarkerNameRowHeight * m_scale; + const float headerRow = kFreeMarkerHeaderRowHeight * m_scale; const float itemRow = kFreeMarkerItemRowHeight * m_scale; const float markerButtonHeight = kEdgePanelButtonHeight * m_scale; // 「标记透明度」滑块行**始终显示**(用户 2026-10-21 要求:不再依附于光标模式按钮)。 @@ -5491,7 +5497,8 @@ namespace jdi content += groupRow; if (!lane.FreeMarkerGroupCollapsed(ref)) { - content += static_cast(order.size()) * itemRow; + // 展开的分组:分组栏 + **表头行** + 条目。 + content += headerRow + static_cast(order.size()) * itemRow; } } if (!markerAny) @@ -5762,6 +5769,7 @@ namespace jdi // 下面逐行列出它的固定点(组内按时间升序 == 从上到下),行尾再带一个 ✕。 // 内容区超高时右侧出现滚动条;底部按钮与「标记透明度」滑块**固定**不滚动。 const float groupRow = kFreeMarkerNameRowHeight * m_scale; + const float headerRow = kFreeMarkerHeaderRowHeight * m_scale; const float itemRow = kFreeMarkerItemRowHeight * m_scale; const float closeSize = kFreeMarkerCloseSize * m_scale; const float markerButtonHeight = kEdgePanelButtonHeight * m_scale; @@ -5812,7 +5820,7 @@ namespace jdi totalContent += groupRow; if (!lane.FreeMarkerGroupCollapsed(ref)) { - totalContent += static_cast(order.size()) * itemRow; + totalContent += headerRow + static_cast(order.size()) * itemRow; } } if (!markerAny) @@ -5860,18 +5868,22 @@ namespace jdi for (int column = 0; column < 5; ++column) { const bool omitted = (column == EdgePanelLayout::MARKER_COL_ENUM) && !hasEnum; - // ⚠️ 列宽取「缺省列宽」与**实测内容宽度**的较大者 ⇒ 面板里不再出现省略号。 + // ⚠️ 列宽取「缺省列宽」「实测内容宽度」与**表头列标题宽度**三者的大者: + // 面板里不再出现省略号(“时间间隔”四个字比缺省列宽 78 还宽)。 const float columnWidth = omitted ? 0.0f - : std::max(kFreeMarkerColumnWidths[column] * m_scale, - lane.freeMarkerColumnWidths[static_cast(column)]); + : std::max(std::max(kFreeMarkerColumnWidths[column] * m_scale, + lane.freeMarkerColumnWidths[static_cast(column)]), + UiMeasureTextWidth(m_fonts->uiSmall, kFreeMarkerColumnLabels[column]) + + 2.0f * 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; + const float contentTop = layout.content.Y; + float rowY = contentTop - scroll; for (const SignalRef& ref : lane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); @@ -5888,14 +5900,49 @@ namespace jdi EdgePanelLayout::MarkerGroup group; group.ref = ref; group.collapsed = lane.FreeMarkerGroupCollapsed(ref); - group.header = gd::RectF(layout.content.X, rowY, innerWidth, groupRow); + const float itemsHeight = + group.collapsed ? 0.0f : (headerRow + static_cast(order.size()) * itemRow); + const float groupHeight = groupRow + itemsHeight; + + // ---- 吸顶(sticky):分组栏与表头**各自**钉住,先后移出 ---- + // 规则(用户 2026-10-22): + // * 该分组还有条目没显示完全 ⇒ 分组栏钉在内容区顶部、表头钉在分组栏下方; + // * 到分组末尾时顺序是:**表头**先向上滚(被分组栏盖住)→ 最后一个条目滚到 + // 分组栏下 → **分组栏**最后移出内容区。 + // 实现(每个元素各自的“吸顶 + 推挤”夹取): + // y = max(自然位置, min(吸顶位置, 本组下边界 - 自身高度)) + // 夹取上限保证元素不会被推离自己的分组(这正是“推挤”效果)。 + float barTop = rowY; + float headerTop = rowY + groupRow; + if (!group.collapsed) + { + const float maxHeaderTop = rowY + groupHeight - headerRow; + headerTop = std::max(rowY + groupRow, std::min(contentTop + groupRow, maxHeaderTop)); + } + { + const float maxBarTop = rowY + groupHeight - groupRow; + barTop = std::max(rowY, std::min(contentTop, maxBarTop)); + } + group.pinned = (barTop > rowY + 0.5f); + group.header = gd::RectF(layout.content.X, barTop, innerWidth, groupRow); group.triangle = gd::RectF(layout.content.X + 3.0f * m_scale, - rowY + (groupRow - 12.0f * m_scale) * 0.5f, + barTop + (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) + barTop + (groupRow - closeSize) * 0.5f, closeSize, closeSize); + float pinnedBottom = barTop + groupRow; + if (!group.collapsed) + { + group.tableHeader = gd::RectF(layout.content.X, headerTop, innerWidth, headerRow); + pinnedBottom = std::max(pinnedBottom, headerTop + headerRow); + } + // 被(吸顶的)分组栏 / 表头盖住的那一片区域:其下的条目既不可绘制也不可命中。 + const float pinnedTop = std::min(barTop, group.tableHeader.Width > 0.0f ? headerTop : barTop); + const auto coveredByPinned = [pinnedTop, pinnedBottom](const gd::RectF& rect) + { + return rect.Y < pinnedBottom - 0.01f && rect.Y + rect.Height > pinnedTop + 0.01f; + }; + if (group.header.Y + group.header.Height > contentTop && group.header.Y < contentBottom) { // 只登记"在可视内容区里"的分组栏与条目(滚出视界的条目不得抢走 // 底部按钮 / 滑块的点击)。 @@ -5909,6 +5956,7 @@ namespace jdi { continue; } + rowY += headerRow; for (const std::size_t itemIndex : order) { EdgePanelLayout::MarkerRow markerRow; @@ -5917,8 +5965,9 @@ namespace jdi 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) + // 被吸顶的分组栏 / 表头完全盖住的条目既不绘制也不可命中。 + if (markerRow.rect.Y + markerRow.rect.Height > contentTop && + markerRow.rect.Y < contentBottom && !coveredByPinned(markerRow.rect)) { layout.markerRows.push_back(markerRow); } @@ -6420,7 +6469,7 @@ namespace jdi { mixRect(rect); } - // 「自由标记」:固定点行/名称 ✕ 的矩形、四列边界与每个固定点的身份 + 时刻都要入键。 + // 「自由标记」:固定点行/名称 ✕ 的矩形、五列边界、表头/吸顶状态与每个固定点的身份 + 时刻都要入键。 for (const EdgePanelLayout::MarkerRow& row : layout.markerRows) { mixRect(row.rect); @@ -6444,9 +6493,11 @@ namespace jdi for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) { mixRect(group.header); + mixRect(group.tableHeader); mixRect(group.triangle); mixRect(group.close); mix(group.collapsed ? 1ull : 0ull); + mix(group.pinned ? 1ull : 0ull); mix(static_cast(group.ref.groupIndex + 2)); mix(static_cast(group.ref.signalIndex + 2)); } @@ -6459,6 +6510,8 @@ namespace jdi mix(lane.freeMarkerCursor ? 1ull : 0ull); mixFloat(lane.freeMarkerOpacity); mixFloat(lane.freeMarkerScroll); + // 选中行会改变整行底色 ⇒ 必须进键(否则单击行后贴回旧像素)。 + mix(static_cast(lane.freeMarkerSelected + 2)); // 固定点的**内容**(哪个信号、什么时刻)直接决定面板文字与行数,必须进键; // 否则新增/删除一个固定点后可能贴回旧像素。 for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems) @@ -6925,8 +6978,8 @@ namespace jdi const std::size_t count = std::min(lanes.size(), tabLanes.size()); // ---- (1) 已固定的数据点(含各自的 ✕)---- - // ⚠️ 固定点**不再画纵向细线**(用户 2026-10-21 明确要求); - // 取值胶囊彼此重叠时做**纵向避让**(依次尝试下方 / 上方错开),保证都能读清。 + // ⚠️ 固定点**不画纵向细线**(2026-10-21 要求),且取值胶囊**就在原位显示**、 + // 不做重叠避让(用户 2026-10-22 明确要求恢复初版方案:允许遮挡)。 for (std::size_t laneIndex = 0; laneIndex < count; ++laneIndex) { const Lane& lane = tabLanes[laneIndex]; @@ -6937,7 +6990,6 @@ namespace jdi const float markerAlpha = std::max(0.0f, std::min(1.0f, lane.freeMarkerOpacity)); // 内容(取值 / 文本 / 信号色)来自与视界无关的缓存,不逐帧重新采样。 const std::vector& contents = FreeMarkerContents(static_cast(laneIndex)); - std::vector placed; for (std::size_t itemIndex = 0; itemIndex < lane.freeMarkerItems.size(); ++itemIndex) { gd::RectF label; @@ -6960,57 +7012,27 @@ namespace jdi std::min(visual.rect.Y + visual.rect.Height, m_plotRect.Y + m_plotRect.Height); const bool valueOk = entry.valid; - - // ---- 纵向避让:与本次已放置的胶囊重叠时,向下 / 向上交替错开 ---- - 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)); + // 鼠标悬停在面板里这一行时:网格内对应的固定点整体高亮(用户 2026-10-22 要求)。 + const bool rowHot = (m_freeMarkerRowHoverLane == static_cast(laneIndex) && + m_freeMarkerRowHoverItem == itemIndex); // 数据点:与光标的数据点同一画法(圆点),同样受「标记透明度」控制。 const float x = TimeToX(item.time); const float radius = 3.4f * m_scale; - const int dotAlpha = static_cast(std::lround(255.0f * markerAlpha)); + // 高亮(面板里悬停该行)时数据点必须完整可见 ⇒ 强制不透明。 + const float effectiveAlpha = rowHot ? 1.0f : markerAlpha; + const int dotAlpha = static_cast(std::lround(255.0f * effectiveAlpha)); gd::SolidBrush dot(ToGdiColor(WithAlpha(signalColor, dotAlpha))); const float dotY = valueOk ? ValueToY(entry.value, visual) : (clipTop + clipBottom) * 0.5f; + if (rowHot) + { + // 高亮提示:数据点外留一圈强调色环(与“吸附提示”的圆环同尺寸量级)。 + gd::Pen hotRing(ToGdiColor(WithAlpha(m_theme->accent, 0xE0)), 2.0f * m_scale); + g.DrawEllipse(&hotRing, x - radius - 3.0f * m_scale, + dotY - radius - 3.0f * m_scale, (radius + 3.0f * m_scale) * 2.0f, + (radius + 3.0f * m_scale) * 2.0f); + } g.FillEllipse(&dot, x - radius, dotY - radius, radius * 2.0f, radius * 2.0f); // ✕ 按钮的悬停态决定它的配色,因此先算再画胶囊。 @@ -7034,7 +7056,16 @@ namespace jdi capsuleAlpha = 1.0f; } } + // 面板里悬停该行 ⇒ 数值显示同样恢复全不透明并加一圈强调色描边。 + if (rowHot) + { + capsuleAlpha = 1.0f; + } PaintFreeMarkerCapsule(g, label, text, signalColor, close, hot, capsuleAlpha); + if (rowHot) + { + UiDrawRoundRect(g, label, 3.0f * m_scale, m_theme->accent, 2.0f * m_scale); + } FreeMarkerHit hit; hit.laneIndex = static_cast(laneIndex); @@ -7183,7 +7214,53 @@ namespace jdi probe.time = XToTime(static_cast(mouseX)); probe.x = static_cast(mouseX); - const double hoverTolerance = static_cast(kFreeMarkerHoverTolerance * m_scale); + // 命中容差 = **与"常规状态下触发信号曲线高亮"完全一致**(`kSignalHitTolerance`), + // 且判定的是「鼠标到**画出来的折线**的垂直距离」,而不是"同一横坐标处的采样值之差"。 + // ⚠️ 旧实现只看纵向差值 ⇒ 阶跃的**竖直跳变段**永远判不中:那一列上下两个采样值 + // 都不等于鼠标所在的高度(用户 2026-10-22 报告“在跳变纵线上完全点不中、没有吸附提示”)。 + const float fx = static_cast(mouseX); + const float fy = static_cast(mouseY); + const float tolerance = kSignalHitTolerance * m_scale; + + // 把“显示时刻”吸附到最近的**采样时刻**(返回显示时刻空间的值)。 + // 与 `SnapTimeToSample` 的区别:这里**不做稀疏判定**,任何信号都可以吸附, + // 因为固定点本来就应当落在真实数据点上(阶跃端点必须能选中)。 + const auto snapToNearestSample = [](const TimeAxis& axis, const SignalData& data, + const SignalStyle& style, double displayTime, + double& out) -> bool + { + const std::size_t count = std::min(axis.time.size(), data.values.size()); + if (count == 0) + { + return false; + } + const double target = displayTime - style.offsetTime; + if (axis.monotonic) + { + const auto lower = + std::lower_bound(axis.time.begin(), + axis.time.begin() + static_cast(count), target); + std::size_t index = static_cast(lower - axis.time.begin()); + if (index >= count) + { + index = count - 1; + } + if (index > 0 && + std::fabs(axis.time[index - 1] - target) <= std::fabs(axis.time[index] - target)) + { + --index; + } + out = axis.time[index] + style.offsetTime; + return true; + } + const int nearest = FindNearestSample(axis, target); + if (nearest < 0 || static_cast(nearest) >= count) + { + return false; + } + out = axis.time[static_cast(nearest)] + style.offsetTime; + return true; + }; // 在网格内所有可见信号中找**离鼠标最近**的那个数据点。 double bestDistance = std::numeric_limits::infinity(); @@ -7203,22 +7280,104 @@ namespace jdi // 「自动点线」判据成立时吸附到最近采样点(与光标吸附同一规则)。 // ⚠️ 用户明确要求:只要判据成立就吸附,**不**管当前是散点还是用户自己改过的样式。 const bool sparse = MinorCellSparse(*resolved.axis, *resolved.data, resolved.style->offsetTime); - double time = probe.time; - bool snapped = false; - if (sparse) + + // ---- ① 取“鼠标到这条曲线”的距离(复用绘制阶段的折线顶点)---- + float nearestX = fx; + double distance = std::numeric_limits::infinity(); + const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, visual); + if (cached != nullptr && cached->points.size() >= 2) { - double candidate = 0.0; - if (SnapTimeToSample(probe.time, candidate)) + const std::vector& points = cached->points; + const std::vector& breaks = cached->breaks; + // 横向先行裁剪(点到线段的距离必然 ≥ 横向距离):时间单调时二分定位。 + std::size_t windowFirst = 0; + std::size_t windowLast = points.size(); + if (resolved.axis->monotonic) { - time = candidate; - snapped = true; + const auto byX = [](const gd::PointF& point, float limit) { return point.X < limit; }; + windowFirst = static_cast( + std::lower_bound(points.begin(), points.end(), fx - tolerance, byX) - + points.begin()); + windowLast = static_cast( + std::upper_bound(points.begin(), points.end(), fx + tolerance, + [](float limit, const gd::PointF& point) + { return limit < point.X; }) - + points.begin()); + } + if (resolved.style->mode == DrawMode::SCATTER) + { + // 散点:直接比较采样点本身(不把连线当成可命中目标)。 + const std::size_t begin = (windowFirst > 0) ? (windowFirst - 1) : 0; + const std::size_t end = std::min(points.size(), windowLast + 1); + for (std::size_t index = begin; index < end; ++index) + { + const float dx = points[index].X - fx; + const float dy = points[index].Y - fy; + const double probeDistance = std::sqrt(static_cast(dx * dx + dy * dy)); + if (probeDistance < distance) + { + distance = probeDistance; + nearestX = points[index].X; + } + } + } + else + { + for (std::size_t k = 0; k < breaks.size(); ++k) + { + const std::size_t start = breaks[k]; + const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size(); + const std::size_t scanBegin = std::max(start + 1, windowFirst); + // `windowLast + 1`:包含“一端在窗口内、另一端在窗口外”的跨窗口线段 —— + // 阶跃的竖直段就是这种线段(上端与下端相隔整个跳变幅度)。 + const std::size_t scanEnd = std::min(stop, windowLast + 1); + for (std::size_t index = scanBegin; index < scanEnd; ++index) + { + const gd::PointF& a = points[index - 1]; + const gd::PointF& b = points[index]; + const float vx = b.X - a.X; + const float vy = b.Y - a.Y; + const float wx = fx - a.X; + const float wy = fy - a.Y; + const float lengthSquared = vx * vx + vy * vy; + float t = 0.0f; + if (lengthSquared > 1.0e-6f) + { + t = (wx * vx + wy * vy) / lengthSquared; + t = std::max(0.0f, std::min(1.0f, t)); + } + const float dx = wx - t * vx; + const float dy = wy - t * vy; + const double segmentDistance = + std::sqrt(static_cast(dx * dx + dy * dy)); + if (segmentDistance < distance) + { + distance = segmentDistance; + nearestX = a.X + t * vx; + } + } + } } } - // 非光标模式且该信号命中「自动点线」判据:**只有靠近数据点时**才给数值 - //(吸附失败 = 附近没有采样点 ⇒ 直接跳过,避免在两点之间也冒出预览)。 - if (!cursorMode && sparse && !snapped) + + // ---- ② 决定标注时刻:先取曲线上的最近点,再吸附到采样点 ---- + const bool hitCurve = (distance <= static_cast(tolerance)); + if (!hitCurve && !cursorMode) { - continue; + continue; // 非光标模式:鼠标不在曲线附近 ⇒ 该信号不参与 + } + double time = hitCurve ? XToTime(nearestX) : probe.time; + bool snapped = false; + if (!cursorMode || sparse) + { + // 非光标模式恒吸附(固定点必须落在真实采样点上,阶跃两端才点得中); + // 光标模式只在「自动点线」判据成立时吸附(保持原有手感)。 + double candidate = 0.0; + if (snapToNearestSample(*resolved.axis, *resolved.data, *resolved.style, time, candidate)) + { + time = candidate; + snapped = sparse; + } } const bool interpolate = (resolved.style->mode != DrawMode::SCATTER); double sampled = 0.0; @@ -7234,12 +7393,6 @@ namespace jdi continue; } 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; @@ -7890,25 +8043,31 @@ namespace jdi if (!markerAny) { - UiText(g, L"在网格内单击可固定数据点", m_fonts->uiSmall, - gd::RectF(layout.content.X, layout.content.Y, layout.content.Width, - groupRowHeight), - m_theme->textMuted, TextAlign::LEFT); + UiTextClipped(g, L"在网格内单击以固定数据点", m_fonts->uiSmall, + gd::RectF(layout.content.X, layout.content.Y, layout.content.Width, + groupRowHeight), + m_theme->textMuted, TextAlign::LEFT, layout.content); } // ---- 分组栏 ---- - for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) + // ⚠️ 抽取成 lambda:**吸顶**的分组栏必须在条目之后重画一次(否则正在向上滚的 + // 条目会盖住它)。见下方“条目 → 表头 → 吸顶分组栏”的绘制顺序。 + const auto paintGroupBar = [this, &g, &layout, &tabLane, index]( + const EdgePanelLayout::MarkerGroup& group) { const ResolvedSignal resolved = ResolveSignal(group.ref); if (resolved.data == nullptr) { - continue; + return; } const std::uint32_t signalColor = (resolved.style != nullptr) ? StyleColor(*resolved.style) : m_theme->border; // 托底 = 信号色;文字一律用**自适应对比色**(与信号面板的组名徽标同规则), // 因此无论信号是亮黄还是深蓝都能看清分组栏上的所有内容。 const UiInk ink = UiContrastInk(signalColor); + // 先铺一层**完全不透明**的同色,再叠 0xE8(观感不变): + // 吸顶时它要压住下面滚动的条目,半透明会让条目透出来。 + UiFillRoundRect(g, group.header, 3.0f * m_scale, signalColor); UiFillRoundRect(g, group.header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); // 左侧三角(与信号选择区域组名同款):朝下 = 展开、朝右 = 收起。 @@ -7929,23 +8088,22 @@ namespace jdi ++markerCount; } } - const std::wstring countText = - std::to_wstring(markerCount) + L" 标记点"; + 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); + UiTextClipped(g, countText, m_fonts->monoSmall, + gd::RectF(countRight - countWidth, group.header.Y, countWidth, + group.header.Height), + ink.text, TextAlign::RIGHT, layout.content); 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); + UiTextClipped(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, layout.content); const bool closeHot = (m_freeMarkerPanelHoverLane == static_cast(index) && m_freeMarkerPanelHoverSignal == group.ref && @@ -7965,6 +8123,13 @@ namespace jdi group.close.Y + crossPad, group.close.X + crossPad, group.close.Y + group.close.Height - crossPad, crossColor, 1.5f * m_scale); + }; + for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) + { + if (!group.pinned) + { + paintGroupBar(group); + } } // ---- 固定点条目(五列,列起点由布局给定 ⇒ 纵向严格对齐)---- @@ -8014,6 +8179,21 @@ namespace jdi } const FreeMarkerContentEntry& entry = contents[markerRow.itemIndex]; + // ---- 行的选中 / 悬停高亮(用户 2026-10-22 要求)---- + const bool selected = + (tabLane.freeMarkerSelected == static_cast(markerRow.itemIndex)); + const bool rowHot = (m_freeMarkerRowHoverLane == static_cast(index) && + m_freeMarkerRowHoverItem == markerRow.itemIndex); + if (selected || rowHot) + { + UiFillRect(g, + gd::RectF(markerRow.rect.X, markerRow.rect.Y + 1.0f * m_scale, + markerRow.rect.Width, + markerRow.rect.Height - 2.0f * m_scale), + selected ? WithAlpha(m_theme->accent, 0x3C) + : WithAlpha(m_theme->accent, 0x1E)); + } + const auto columnRect = [&](EdgePanelLayout::MarkerColumnIndex column) { return gd::RectF(layout.markerColumns[column].left, markerRow.rect.Y, layout.markerColumns[column].width, @@ -8021,14 +8201,17 @@ namespace jdi }; // ⚠️ 面板里的数据列**不得省略**(用户 2026-10-21 明确要求): // 列宽已按实测文本宽度撑开,因此这里直接整段输出,不再 `UiEllipsize`。 + // ⚠️ 必须用 `UiTextClipped`(GDI 裁剪):`UiText` 走 `DrawTextW`, + // **不认 GDI+ 裁剪** ⇒ 滚动到内容区上方的条目会把文字画到标题栏上 + //(用户 2026-10-22 报告的显示错误)。 const auto paintCell = [&](EdgePanelLayout::MarkerColumnIndex column, const std::wstring& text, std::uint32_t color) { if (layout.markerColumns[column].width <= 0.0f || text.empty()) { return; } - UiText(g, text, m_fonts->monoSmall, columnRect(column), color, - TextAlign::LEFT); + UiTextClipped(g, text, m_fonts->monoSmall, columnRect(column), color, + TextAlign::LEFT, layout.content); }; paintCell(EdgePanelLayout::MARKER_COL_VALUE, entry.valueText, m_theme->textStrong); @@ -8060,6 +8243,45 @@ namespace jdi crossColor, 1.2f * m_scale); } + // ---- 表头(每个展开分组一行,与条目共用列边界)---- + // ⚠️ 必须在条目**之后**绘制:表头底衬要不透明地盖住向上滚动的条目。 + for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) + { + if (group.tableHeader.Width <= 0.0f || group.tableHeader.Height <= 0.0f) + { + continue; + } + // 不透明底衬(与面板底色 + 少量边框色调混合,观感是“一条浅灰带”)。 + UiFillRect(g, group.tableHeader, + MixColor(m_theme->cardBg, m_theme->borderStrong, 0.22)); + UiDrawLine(g, group.tableHeader.X, group.tableHeader.Y + group.tableHeader.Height - 1.0f, + group.tableHeader.X + group.tableHeader.Width, + group.tableHeader.Y + group.tableHeader.Height - 1.0f, + WithAlpha(m_theme->borderStrong, 0x99), 1.0f); + for (int column = 0; column < 5; ++column) + { + const EdgePanelLayout::MarkerColumn& cell = + layout.markerColumns[column]; + if (cell.width <= 0.0f) + { + continue; + } + UiTextClipped(g, kFreeMarkerColumnLabels[column], m_fonts->uiSmall, + gd::RectF(cell.left, group.tableHeader.Y, cell.width, + group.tableHeader.Height), + m_theme->textMuted, TextAlign::LEFT, layout.content); + } + } + + // ---- 吸顶的分组栏(最后画:压住向上滚动的条目)---- + for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) + { + if (group.pinned) + { + paintGroupBar(group); + } + } + g.Restore(contentState); // ---- 内容滚动条(只在内容超高时出现;按钮与滑块不参与滚动)---- diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 240a84c..7d510ce 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -3017,6 +3017,8 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } | 2026-10-21 | DeepSeek-V4.1-Flash(**本机**) | **四处 BUG 修复 + 两处交互改进(详见 §12 末尾「2026-10-21 第三轮」与 §11.2 第 203–212 条)**。① **合并 → 拆分会把一个网格的实验性功能复制到每个拆分结果**(用户:第二个网格也自动启用了功能;「自由标记」还会在新网格里画出**别的信号的固定点**、而面板里查不到):`LaneCarry` 新增 `owner`(只有原网格**首个信号**继承功能状态),`SplitLane` 只让第一份继承;新增 `dmdetail::PruneLaneSignalItems` 在每次重建/拆分后按 `lane.signals` 过滤四类逐信号条目。② **阶跃信号的竖直沿比水平段粗**:`EmitEnvelopeBand` 的横向膨胀半径由 `ceil` 改 `floor`(1px 线宽不再膨胀 1 列)。③ **拖动浮动面板时下方另一个面板出现拖拽残影**:面板位置从 `PanelGlobalKey` 移除(曾让拖动任一面板都重画全部面板,而 MARK 面板的位置漏混 ⇒ 残影),并新增 `vacatedRects`(上一帧被移动/缩小/关闭面板腾出的矩形 → 与之相交的面板强制重画)。④ **大量固定点时拖全局光标/平移卡顿**:新增 `FreeMarkerContents` 内容缓存(取值 + 五列文本 + 信号色,键**不含视界**)。⑤ **面板外观/交互统一**:折起指示器改成与信号列表组名同款三角(去掉横线);GX 徽标与标题栏悬停**不改光标**、徽标气泡移除;标题栏**双击**滚动到该网格(单击不触发,折起只走三角)。⑥ **自由标记**:「标记透明度」滑块与光标模式解耦(始终显示);悬停胶囊恢复不透明(探针瞄着被遮住的数据点时除外);胶囊与面板五列**不再省略**(列宽按实测内容撑开)。验证:新增离屏 `.preflight/fixcheck` **37/37**(三处 A/B:恢复 `ceil` ⇒ 线宽断言失败;恢复 `owner`/`PruneLaneSignalItems` ⇒ 归属断言失败;禁用 `vacatedRects` ⇒ 残影 111px);回归 `round3check` 31/31(更新一条被用户推翻的旧断言)、`round4check` 34/34、`round5check` 5/5、`editcheck` 20/20、`convcheck` 18/18;`make release`/`make debug` 零警告;`TargetCheck.exe` **852,480 字节(832.5 KiB)**。 | +| 2026-10-22 | DeepSeek-V4.1-Flash(**本机**) | **「自由标记」面板表头 / 吸顶 / 行选中与悬停联动 + 固定点原位显示 + 探测容差与阶跃吸附(详见 §12 末尾「2026-10-22 第四轮」与 §11.2 第 213–220 条)**。① **列表头**:每个展开分组下方新增一行列标题(标记值 / 枚举文本 / 增量 / 时间点 / 时间间隔),与数据行共用列边界;列宽还需容纳列标题;内容总高与面板高度计入表头行;全部无枚举文本时整列缺省。② **吸顶**:分组栏与表头**各自**按 `y = max(自然, min(吸顶, 本组下边界 − 自身高度))` 钉住 ⇒ 组末尾按「表头先走 → 条目 → 分组栏最后离场」;绘制顺序改为「条目 → 表头 → 吸顶分组栏」,被盖住的条目不绘制也不可命中。③ **行交互**:`Lane::freeMarkerSelected`(单击选中/再点取消,`PanelSlotKey` 混入)+ 独立的整行悬停状态(整行悬停不会把 ✕ 变成 danger 色),悬停时网格内对应固定点整体高亮(数据点圆环 + 胶囊强制不透明 + accent 描边)。④ **固定点恢复原位显示**:删除 2026-10-21 的纵向避让,允许互相遮挡。⑤ **探测判据重写**:改为“鼠标到画出来的折线的垂直距离 ≤ `kSignalHitTolerance`”(与曲线高亮同源,复用 `AcquirePolyline` 顶点 + 点到线段距离),命中后吸附到最近采样时刻 ⇒ 修复 `SCATTER_TEST.csv` 阶跃竖直跳变线无法探测/固定的问题;删除 `kFreeMarkerHoverTolerance`。⑥ **滚动文字串到标题栏**:MARK 面板内全部文字改走 `UiTextClipped(..., content)`(GDI 文本不认 GDI+ 裁剪)。验证:新增离屏 `.preflight/mark4check` **38/38**(三组 A/B 均如实失败);回归 `markcheck` 61/61、`fixcheck` 37/37、`round3check` 31/31、`round4check` 34/34、`round5check` 5/5、`editcheck` 20/20、`convcheck` 18/18;`make release`/`make debug` 零警告;`TargetCheck.exe` **855,552 字节**。 | + ## 11. 跨设备协作须知(起源设备视角) > **本文件是唯一权威记忆**。所有设备 / Agent 共用它,**不要再新建 `agent_mem_origin.md` 等旁支记忆文件**。 @@ -3991,6 +3993,36 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } 面板宽度随之撑开;`DT_END_ELLIPSIS` 对“刚好装满”的文本仍可能截断,因此每列额外留 2px 余量。 网格内取值胶囊同理(宽度额外留 12px 余量)。 +213. ⚠️ **「自由标记」面板每个展开分组下必须有一行列表头** + (`MarkerGroup::tableHeader` + `kFreeMarkerHeaderRowHeight` + `kFreeMarkerColumnLabels`), + 与数据行共用 `markerColumns` 的列边界 ⇒ 表头与数据严格对齐;列宽还要 ≥ **列标题宽度**。 + 表头行高度必须计入内容总高(`totalContent`)与面板高度(`EdgePanelWidth`)。 +214. ⚠️ **枚举列缺省只由 `SignalEnumText::Empty()` 决定**(O(1),不逐条采样): + 所有带固定点的信号都没有枚举表时,枚举列宽记 0、列标题也不画。 +215. ⚠️⚠️ **吸顶是“每个元素各自”的**:`y = max(自然, min(吸顶, 本组下边界 − 自身高度))`。 + 分组栏吸到内容区顶、表头吸到分组栏下方 ⇒ 组末尾时**表头先被推走 → 条目 → 分组栏最后离场** + (用户 2026-10-22 明确要求的次序)。**不要**把它们当成一个整体块一起吸顶 + (那样两者会同时离场,不符合要求)。 + ⚠️ 只有一个分组且内容超高时分组栏会一直钉住,这是数学上正确的(组内容还没滚完)。 +216. ⚠️⚠️ **MARK 面板的绘制顺序必须是「条目 → 表头 → 吸顶分组栏」**:否则向上滚动的条目会盖住 + 吸顶元素。分组栏底衬必须**完全不透明**(先铺信号色、再叠 `WithAlpha(signalColor,0xE8)`), + 表头底衬同样不透明;被吸顶区域盖住的条目由 `coveredByPinned` 同时从绘制与命中里剔除。 +217. ⚠️⚠️ **行选中与「✕ 悬停」是两套状态**:`Lane::freeMarkerSelected`(选中行,`PanelSlotKey` + 必须混入)+ `m_freeMarkerRowHoverLane/Item`(整行悬停)。整行悬停时**不能**让该行的 ✕ + 变成 danger 色(那是“准备删除”的语义),但要把网格内对应的固定点整体高亮 + (数据点 accent 圆环 + 胶囊强制不透明 + 2px accent 描边)。删除固定点时必须校正选中下标。 +218. ⚠️ **固定点取值胶囊**原位显示**、不做重叠避让**(用户 2026-10-22 明确要求恢复初版方案: + 允许遮挡)。2026-10-21 的纵向避让(`placed/occupied`)已删除,不要再加回来。 +219. ⚠️⚠️ **非光标模式的探测判据 = 鼠标到「画出来的折线」的垂直距离 ≤ `kSignalHitTolerance`** + (与常规触发曲线高亮**完全同一范围**)。实现复用 `AcquirePolyline` 顶点 + 窗口二分 + + 点到线段距离(散点模式比顶点)。旧实现只看“同一横坐标的采样值之差” ⇒ 阶跃的**竖直段** + 永远判不中(两端采样值是 ±1,都不是鼠标高度),用户既看不到预览也无法在跳变线上固定点。 + ⚠️ 命中后时刻要**吸附到最近采样时刻**(非光标模式恒吸附;光标模式仍只在稀疏判据成立时吸附), + 这样固定点一定落在真实数据点上。`kFreeMarkerHoverTolerance(8)` 已删除。 +220. ⚠️⚠️ **面板内的文字一律用 `UiTextClipped(..., layout.content)`**:GDI `DrawTextW` + **不认 GDI+ 裁剪**,滚动时“部分可见”的条目会把文字画到标题栏上(用户 2026-10-22 报告)。 + 这条同时适用于分组栏名称/计数、表头列标题与空内容提示。 + ### 11.3 离屏验证方法学(本机偏好,强烈建议沿用) - **不要占用用户的鼠标 / 键盘 / 屏幕**:优先 `.preflight/uitest`(离屏 harness:`SW_HIDE` 窗口 + 直接注入消息 + `BitBlt` 从 `m_backDc` 截图)或 `.preflight/perf`(离屏计时 / 逐像素对比)。 @@ -7567,3 +7599,86 @@ DG3 data=0 cycle=0 (空组,一个字符串通道) **缓存矩形**"里,比较区域要取两个缓存矩形的交集(面板主体不透明会盖住大半残影);直接改 `lane.*Panel.x/y` 就等价于"拖动中的某一帧"(走鼠标注入会先 `BringPanelToFront` 并改全局键, 那一帧所有面板都重画,反而看不到残影)。 + +--- + +## 本轮(2026-10-22 第四轮,本机):「自由标记」面板表头 / 吸顶 / 行选中与悬停联动 + 原位显示 + 探测容差与阶跃吸附 + +> 用户需求(逐条): +> ① 面板在每个分组栏下方增加一行**列表头**(标记值 / 增量 / 枚举文本 / 时间点 / 时间间隔), +> 数据行按列对齐,一行 = 一个固定标记点;滚动时分组栏与表头**吸顶**,到组末尾按 +> 「表头 → 最后条目 → 分组栏」的次序依次滚出;所有固定点都没有枚举文本时**缺省枚举列**。 +> ② 条目行可**选中**(高亮);行**悬停**时对应的固定点在网格内(若在视界内)要连数值显示与 +> 数据点标记一起高亮。 +> ③ 固定点取值胶囊恢复**原位显示**(允许遮挡,回到初版方案)。 +> ④ 非光标模式的"鼠标接近标记"范围改成**与常规触发信号曲线高亮一致**。 +> ⑤ `TestData/SCATTER_TEST.csv` 的阶跃信号:默认视图下**竖直跳变线上点不中、也没有吸附提示**。 +> ⑥ 滚动时**文字串到面板标题栏**上。 + +### A. 面板:表头行(§11.2 第 213–214 条) + +- `EdgePanelLayout::MarkerGroup` 新增 `tableHeader`(一行列标题)与 `pinned`(是否吸顶)。 +- 新常量 `kFreeMarkerHeaderRowHeight = 18`、`kFreeMarkerColumnLabels[5]` + (**顺序与 `markerColumns` 一致**:标记值 / 枚举文本 / 增量 / 时间点 / 时间间隔)。 +- 内容总高与 `EdgePanelWidth` 都计入表头行;列宽 = max(缺省列宽, 实测内容宽度, **列标题宽度**) + ⇒ 表头与数据都不会被省略。枚举列仍按 `SignalEnumText::Empty()` 整列缺省。 +- 交互侧不登记表头(它只用于阅读),点击表头落到"面板主体"(消费、不穿透)。 + +### B. 吸顶:每个元素各自钉住(§11.2 第 215–216 条) + +- 公式:`y = max(自然位置, min(吸顶位置, 本组下边界 − 自身高度))`,分组栏与表头**各自**计算: + 分组栏吸到内容区顶、表头吸到分组栏下方 ⇒ 组末尾时表头先被推走(被分组栏盖住), + 最后一个条目随后滚到分组栏下,分组栏最后离场。探针实测:表头脱顶 502 设备像素 < 分组栏离场 550。 +- 绘制顺序固定为 **条目 → 表头 → 吸顶分组栏**:否则向上滚的条目会盖住吸顶元素; + 分组栏底衬改为**完全不透明**(先铺信号色再叠 0xE8,观感不变)。 +- 被吸顶区域盖住的条目**既不绘制也不登记命中**(`coveredByPinned`),否则会隔着分组栏点到条目。 + +### C. 行选中 / 行悬停(§11.2 第 217 条) + +- 数据层:`Lane::freeMarkerSelected`(-1 = 未选中),随 `LaneCarry` 与 `SplitLane` 携带; + `RemoveFreeMarker / RemoveFreeMarkerAt / RemoveFreeMarkersOf` 会校正下标(删到自己 = 取消, + 删在它前面 = 下标前移,整组清空 = 若选中项属于其它信号则重新定位)。 +- 交互:`PlotView::SelectLaneFreeMarker(lane, index)`;单击行的数据区域(✕ 之外)选中、 + 再点同一条取消。 +- 新状态 `m_freeMarkerRowHoverLane/Item`(**与“✕ 悬停”分开**:整行悬停时不能让该行的 ✕ + 变成 danger 色——那是“准备删除”的语义)。悬停该行时网格内对应固定点:数据点加 accent 圆环、 + 胶囊强制不透明并加 2px accent 描边(只在视界内显示)。 +- 面板里选中行用 `WithAlpha(accent,0x3C)` 整行底色、悬停行用 `0x1E`; + `PanelSlotKey` 混入 `freeMarkerSelected`(悬停靠“鼠标在面板内时混坐标”已有的机制)。 + +### D. 固定点恢复原位显示(§11.2 第 218 条) + +- 删除 2026-10-21 的纵向避让(`placed/occupied/totalRect`):胶囊直接画在数据点处,允许互相遮挡 + (用户明确要求回到初版方案)。 + +### E. 探测容差与阶跃竖直沿(§11.2 第 219 条) + +- `ProbeFreeMarker` 改成**按“鼠标到画出来的折线”的垂直距离**判定:复用 `AcquirePolyline` 顶点 + + 与 `HitTestSignal` 相同的窗口二分与点到线段距离(散点模式比较顶点), + 容差 = **`kSignalHitTolerance`(6 逻辑像素)** —— 与常规触发曲线高亮**完全同一范围**。 +- 命中后把时刻**吸附到最近采样时刻**(非光标模式恒吸附;光标模式仍只在稀疏判据成立时吸附), + 保证固定点落在真实数据点上。旧的 `kFreeMarkerHoverTolerance = 8` 已删除。 +- 根因:旧实现只看“同一横坐标的采样值之差”,而阶跃**竖直段**那一列上下两个采样值是 ±1, + 都不等于鼠标高度 ⇒ 竖直跳变线上永远探测不到、也固定不了。 + +### F. 滚动时文字串到标题栏(§11.2 第 220 条) + +- GDI `DrawTextW`(`UiText`)**不认 GDI+ 裁剪**:内容区上方“部分可见”的条目会把文字画进标题栏。 + MARK 面板里所有文字(条目单元格、表头列标题、分组栏名称与计数、空提示)改走 + `UiTextClipped(..., layout.content)`(同一套 `DrawTextW` 光栅化 + `SaveDC/IntersectClipRect`)。 + +### G. 验证(全离屏,未占用鼠标/键盘/屏幕) + +- 新增 `.preflight/mark4check` **38/38**: + [A] 表头存在/位置/高度、列宽容纳列标题、枚举列缺省与恢复;[B] 吸顶位置、被压条目不可命中、 + 表头先脱顶且分组栏后离场;[C] 选中/取消、悬停记录与网格高亮(胶囊区域像素变化)、 + 离开清除;[D] 两个近同时刻固定点的胶囊 Y 相同(无避让);[E] 阶跃竖直沿探测 + 单击固定到 + 真实采样时刻 + 3/15 像素内外容差;[F] 逐档滚动比较标题栏区域像素(必须为 0)。 +- **A/B(判别力已证)**:禁用吸顶 ⇒ 分组栏不再钉住;改回 `UiText` ⇒ 标题栏 196 像素差异; + 跳过竖直段 ⇒ 竖直沿探测/固定 2 项失败。 +- 回归:`markcheck` 61/61、`fixcheck` 37/37、`round3check` 31/31、`round4check` 34/34、 + `round5check` 5/5、`editcheck` 20/20、`convcheck` 18/18; + `make release` / `make debug` **零警告**;`TargetCheck.exe` **855,552 字节(835.5 KiB)**。 +- ⚠️ 探针坑:两条信号同网格时阶跃幅度更小(阈值取 40 逻辑像素); + 吸顶“离场”必须在**两组**内容上验证(单组且内容超高时分组栏数学上会一直钉住); + 滚动扫描步长按设备像素(`step*2`),再乘 `m_scale` 会扫不到组末尾。 diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index 451010c..798896b 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -930,16 +930,19 @@ namespace jdi /** * @brief 「实验性功能 → 自由标记」开关(功能总开关)。 * - * 开启后本网格参与自由标记:鼠标**贴近信号曲线**时标注离鼠标最近的数据点, - * 左键单击把该数据点固定下来(固定点带 ✕ 可单独移除,并在浮动面板按信号分组列出 - * 「值 / 增量 / 时刻 / 间隔」四列)。默认**不**显示网格私有纵向光标。 + * 开启后本网格参与自由标记:鼠标**贴近信号曲线**(判据 = 到**画出来的折线**的垂直距离 ≤ + * `kSignalHitTolerance`,与常规触发曲线高亮同源)时标注离鼠标最近的数据点, + * 左键单击把该数据点固定下来(固定点带 ✕ 可单独移除;浮动面板按信号分组, + * 每组一行**表头** + 五列数据:标记值 / 枚举文本 / 增量 / 时间点 / 时间间隔)。 + * 默认**不**显示网格私有纵向光标。 */ bool freeMarker = false; /** * @brief 「自由标记」的光标模式(浮动面板最下方的切换按钮)。 * - * `false`(默认):鼠标在信号曲线上(或稀疏判定下靠近数据点)时才显示取值预览; + * `false`(默认):鼠标在信号曲线上(判据同 `PlotView::ProbeFreeMarker`: + * 到画出来的折线的垂直距离 ≤ `kSignalHitTolerance`)时才显示取值预览; * 鼠标下方没有曲线时按下就是普通的平移/框选,不会误固定数据点。 * `true`:与最初实现的语义相同 —— 网格内始终跟随鼠标画一条**仅限本网格**的纵向光标 *(上下各一个三角把手、不显示时间读数),光标穿过信号时标注离鼠标最近的数据点。 @@ -1014,6 +1017,14 @@ namespace jdi */ std::vector freeMarkerCollapsedGroups; + /** + * @brief 「自由标记」面板里被**选中**的条目(`freeMarkerItems` 下标,-1 = 未选中)。 + * + * 单击面板里的条目的数据区域即选中(该行高亮);它与网格内的固定点一一对应, + * 因此增删固定点后需要重新校正(见 `RemoveFreeMarker*`)。 + */ + int freeMarkerSelected = -1; + /** * @brief 「自由标记」面板五列的**实测内容宽度**(设备像素,0 = 还没量过)。 * diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index 3b641be..2da1b5a 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -1457,7 +1457,7 @@ namespace jdi const int index = FindFreeMarker(ref, time); if (index >= 0) { - freeMarkerItems.erase(freeMarkerItems.begin() + index); + RemoveFreeMarkerAt(static_cast(index)); } } @@ -1474,14 +1474,42 @@ namespace jdi freeMarkerItems[current] = freeMarkerItems[current + 1]; } freeMarkerItems.pop_back(); + // 选中下标跟随:删的就是它 ⇒ 取消选中;删在它前面 ⇒ 下标前移。 + if (freeMarkerSelected == static_cast(index)) + { + freeMarkerSelected = -1; + } + else if (freeMarkerSelected > static_cast(index)) + { + --freeMarkerSelected; + } + if (freeMarkerSelected >= static_cast(freeMarkerItems.size())) + { + freeMarkerSelected = -1; + } } void Lane::RemoveFreeMarkersOf(const SignalRef& ref) { + // 选中项的引用先取下来:过滤会打乱下标,事后重新定位才不会错位到别的信号。 + SignalRef selectedRef; + double selectedTime = 0.0; + const bool hadSelection = (freeMarkerSelected >= 0 && + static_cast(freeMarkerSelected) < freeMarkerItems.size()); + if (hadSelection) + { + selectedRef = freeMarkerItems[static_cast(freeMarkerSelected)].ref; + selectedTime = freeMarkerItems[static_cast(freeMarkerSelected)].time; + } freeMarkerItems.erase( std::remove_if(freeMarkerItems.begin(), freeMarkerItems.end(), [&ref](const FreeMarkerItem& item) { return item.ref == ref; }), freeMarkerItems.end()); + freeMarkerSelected = -1; + if (hadSelection && !(selectedRef == ref)) + { + freeMarkerSelected = FindFreeMarker(selectedRef, selectedTime); + } } int FindNearestSample(const TimeAxis& axis, double time) diff --git a/Utilities/Src/DataModelInternal.hpp b/Utilities/Src/DataModelInternal.hpp index ddf522d..8cb1f9a 100644 --- a/Utilities/Src/DataModelInternal.hpp +++ b/Utilities/Src/DataModelInternal.hpp @@ -136,6 +136,7 @@ namespace jdi LanePanelState freeMarkerPanel; float freeMarkerOpacity = 1.0f; float freeMarkerScroll = 0.0f; + int freeMarkerSelected = -1; std::vector freeMarkerCollapsedGroups; }; @@ -180,6 +181,7 @@ namespace jdi carry.freeMarkerPanel = lane.freeMarkerPanel; carry.freeMarkerOpacity = lane.freeMarkerOpacity; carry.freeMarkerScroll = lane.freeMarkerScroll; + carry.freeMarkerSelected = lane.freeMarkerSelected; carry.freeMarkerCollapsedGroups = lane.freeMarkerCollapsedGroups; for (std::size_t signalIndex = 0; signalIndex < lane.signals.size(); ++signalIndex) { @@ -245,6 +247,7 @@ namespace jdi lane.freeMarkerPanel.y = carry.freeMarkerPanel.y; lane.freeMarkerOpacity = carry.freeMarkerOpacity; lane.freeMarkerScroll = carry.freeMarkerScroll; + lane.freeMarkerSelected = carry.freeMarkerSelected; lane.freeMarkerCollapsedGroups = carry.freeMarkerCollapsedGroups; }; diff --git a/Utilities/Src/DataModelTab.cpp b/Utilities/Src/DataModelTab.cpp index 2a5386b..844ef03 100644 --- a/Utilities/Src/DataModelTab.cpp +++ b/Utilities/Src/DataModelTab.cpp @@ -473,6 +473,15 @@ namespace jdi { if (item.ref == ref) { + // 选中下标按“第几个条目”跟随(过滤会改变下标)。 + if (source.freeMarkerSelected >= 0 && + static_cast(source.freeMarkerSelected) < + source.freeMarkerItems.size() && + &source.freeMarkerItems[static_cast(source.freeMarkerSelected)] == + &item) + { + lane.freeMarkerSelected = static_cast(lane.freeMarkerItems.size()); + } lane.freeMarkerItems.push_back(item); } }