自由标记:面板表头/吸顶/行选中与悬停联动 + 原位显示 + 探测容差与阶跃吸附
- 面板:每个展开分组下方新增一行列表头(标记值/枚举文本/增量/时间点/时间间隔), 与数据行共用列边界;列宽同时容纳列标题;表头行计入内容总高与面板高度; 全部无枚举文本时仍整列缺省 - 吸顶:分组栏与表头各自按 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:
1 parent
8f735d51be
commit
79eebc281e
10 files changed
+618
-116
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
// ---- 内容滚动条(只在内容超高时出现;按钮与滑块不参与滚动)----
|
||||
|
||||
Reference in new issue
Block a user