自由标记:面板表头/吸顶/行选中与悬停联动 + 原位显示 + 探测容差与阶跃吸附

- 面板:每个展开分组下方新增一行列表头(标记值/枚举文本/增量/时间点/时间间隔),
  与数据行共用列边界;列宽同时容纳列标题;表头行计入内容总高与面板高度;
  全部无枚举文本时仍整列缺省
- 吸顶:分组栏与表头各自按 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 零警告
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-22 21:04:03 +08:00
1 parent 8f735d51be
commit 79eebc281e
10 files changed
+618 -116

No files matched your search

+43 -3
View File
@@ -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<MarkerGroup> 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<std::size_t>(-1);
/**
* @brief 上一次绘制时登记的固定点 ✕ 命中矩形。
*
@@ -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<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
// 越界一律视为“取消选中”(面板重建、条目被删之后调用方不必先做下界检查)。
const int clamped = (itemIndex >= 0 && static_cast<std::size_t>(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<int>(m_tab->Lanes().size()))
@@ -1584,6 +1584,8 @@ namespace jdi
SignalRef hoverSignal;
int triLane = -1;
SignalRef triSignal;
int rowLane = -1;
std::size_t rowItem = static_cast<std::size_t>(-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<float>(x), static_cast<float>(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<int>(row.itemIndex))
? -1
: static_cast<int>(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<std::size_t>(-1))
{
m_freeMarkerRowHoverLane = -1;
m_freeMarkerRowHoverItem = static_cast<std::size_t>(-1);
changed = true;
}
if (m_cursorHover)
{
m_cursorHover = false;
+24 -9
View File
@@ -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;
+321 -99
View File
@@ -170,8 +170,13 @@ namespace jdi
{
continue;
}
columnsWidth += std::max(kFreeMarkerColumnWidths[column] * m_scale,
lane.freeMarkerColumnWidths[static_cast<std::size_t>(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<std::size_t>(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<float>(order.size()) * itemRow;
// 展开的分组:分组栏 + **表头行** + 条目。
content += headerRow + static_cast<float>(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<float>(order.size()) * itemRow;
totalContent += headerRow + static_cast<float>(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<std::size_t>(column)]);
: std::max(std::max(kFreeMarkerColumnWidths[column] * m_scale,
lane.freeMarkerColumnWidths[static_cast<std::size_t>(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<float>(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<std::uint64_t>(group.ref.groupIndex + 2));
mix(static_cast<std::uint64_t>(group.ref.signalIndex + 2));
}
@@ -6459,6 +6510,8 @@ namespace jdi
mix(lane.freeMarkerCursor ? 1ull : 0ull);
mixFloat(lane.freeMarkerOpacity);
mixFloat(lane.freeMarkerScroll);
// 选中行会改变整行底色 ⇒ 必须进键(否则单击行后贴回旧像素)。
mix(static_cast<std::uint64_t>(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<FreeMarkerContentEntry>& contents = FreeMarkerContents(static_cast<int>(laneIndex));
std::vector<gd::RectF> 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<float>((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<int>(laneIndex) &&
m_freeMarkerRowHoverItem == itemIndex);
// 数据点:与光标的数据点同一画法(圆点),同样受「标记透明度」控制。
const float x = TimeToX(item.time);
const float radius = 3.4f * m_scale;
const int dotAlpha = static_cast<int>(std::lround(255.0f * markerAlpha));
// 高亮(面板里悬停该行)时数据点必须完整可见 ⇒ 强制不透明。
const float effectiveAlpha = rowHot ? 1.0f : markerAlpha;
const int dotAlpha = static_cast<int>(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<int>(laneIndex);
@@ -7183,7 +7214,53 @@ namespace jdi
probe.time = XToTime(static_cast<float>(mouseX));
probe.x = static_cast<float>(mouseX);
const double hoverTolerance = static_cast<double>(kFreeMarkerHoverTolerance * m_scale);
// 命中容差 = **与"常规状态下触发信号曲线高亮"完全一致**(`kSignalHitTolerance`),
// 且判定的是「鼠标到**画出来的折线**的垂直距离」,而不是"同一横坐标处的采样值之差"。
// ⚠️ 旧实现只看纵向差值 ⇒ 阶跃的**竖直跳变段**永远判不中:那一列上下两个采样值
// 都不等于鼠标所在的高度(用户 2026-10-22 报告“在跳变纵线上完全点不中、没有吸附提示”)。
const float fx = static_cast<float>(mouseX);
const float fy = static_cast<float>(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<std::ptrdiff_t>(count), target);
std::size_t index = static_cast<std::size_t>(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<std::size_t>(nearest) >= count)
{
return false;
}
out = axis.time[static_cast<std::size_t>(nearest)] + style.offsetTime;
return true;
};
// 在网格内所有可见信号中找**离鼠标最近**的那个数据点。
double bestDistance = std::numeric_limits<double>::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<double>::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<gd::PointF>& points = cached->points;
const std::vector<std::size_t>& 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::size_t>(
std::lower_bound(points.begin(), points.end(), fx - tolerance, byX) -
points.begin());
windowLast = static_cast<std::size_t>(
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<double>(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<double>(dx * dx + dy * dy));
if (segmentDistance < distance)
{
distance = segmentDistance;
nearestX = a.X + t * vx;
}
}
}
}
}
// 非光标模式且该信号命中「自动点线」判据:**只有靠近数据点时**才给数值
//(吸附失败 = 附近没有采样点 ⇒ 直接跳过,避免在两点之间也冒出预览)。
if (!cursorMode && sparse && !snapped)
// ---- ② 决定标注时刻:先取曲线上的最近点,再吸附到采样点 ----
const bool hitCurve = (distance <= static_cast<double>(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<double>(y) - static_cast<double>(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<int>(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<int>(markerRow.itemIndex));
const bool rowHot = (m_freeMarkerRowHoverLane == static_cast<int>(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);
// ---- 内容滚动条(只在内容超高时出现;按钮与滑块不参与滚动)----
+115
View File
@@ -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` 会扫不到组末尾。
+15 -4
View File
@@ -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<SignalRef> freeMarkerCollapsedGroups;
/**
* @brief 「自由标记」面板里被**选中**的条目(`freeMarkerItems` 下标,-1 = 未选中)。
*
* 单击面板里的条目的数据区域即选中(该行高亮);它与网格内的固定点一一对应,
* 因此增删固定点后需要重新校正(见 `RemoveFreeMarker*`)。
*/
int freeMarkerSelected = -1;
/**
* @brief 「自由标记」面板五列的**实测内容宽度**(设备像素,0 = 还没量过)。
*
+29 -1
View File
@@ -1457,7 +1457,7 @@ namespace jdi
const int index = FindFreeMarker(ref, time);
if (index >= 0)
{
freeMarkerItems.erase(freeMarkerItems.begin() + index);
RemoveFreeMarkerAt(static_cast<std::size_t>(index));
}
}
@@ -1474,14 +1474,42 @@ namespace jdi
freeMarkerItems[current] = freeMarkerItems[current + 1];
}
freeMarkerItems.pop_back();
// 选中下标跟随:删的就是它 ⇒ 取消选中;删在它前面 ⇒ 下标前移。
if (freeMarkerSelected == static_cast<int>(index))
{
freeMarkerSelected = -1;
}
else if (freeMarkerSelected > static_cast<int>(index))
{
--freeMarkerSelected;
}
if (freeMarkerSelected >= static_cast<int>(freeMarkerItems.size()))
{
freeMarkerSelected = -1;
}
}
void Lane::RemoveFreeMarkersOf(const SignalRef& ref)
{
// 选中项的引用先取下来:过滤会打乱下标,事后重新定位才不会错位到别的信号。
SignalRef selectedRef;
double selectedTime = 0.0;
const bool hadSelection = (freeMarkerSelected >= 0 &&
static_cast<std::size_t>(freeMarkerSelected) < freeMarkerItems.size());
if (hadSelection)
{
selectedRef = freeMarkerItems[static_cast<std::size_t>(freeMarkerSelected)].ref;
selectedTime = freeMarkerItems[static_cast<std::size_t>(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)
+3
View File
@@ -136,6 +136,7 @@ namespace jdi
LanePanelState freeMarkerPanel;
float freeMarkerOpacity = 1.0f;
float freeMarkerScroll = 0.0f;
int freeMarkerSelected = -1;
std::vector<SignalRef> 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;
};
+9
View File
@@ -473,6 +473,15 @@ namespace jdi
{
if (item.ref == ref)
{
// 选中下标按“第几个条目”跟随(过滤会改变下标)。
if (source.freeMarkerSelected >= 0 &&
static_cast<std::size_t>(source.freeMarkerSelected) <
source.freeMarkerItems.size() &&
&source.freeMarkerItems[static_cast<std::size_t>(source.freeMarkerSelected)] ==
&item)
{
lane.freeMarkerSelected = static_cast<int>(lane.freeMarkerItems.size());
}
lane.freeMarkerItems.push_back(item);
}
}