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

- 面板:每个展开分组下方新增一行列表头(标记值/枚举文本/增量/时间点/时间间隔),
  与数据行共用列边界;列宽同时容纳列标题;表头行计入内容总高与面板高度;
  全部无枚举文本时仍整列缺省
- 吸顶:分组栏与表头各自按 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

+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);
// ---- 内容滚动条(只在内容超高时出现;按钮与滑块不参与滚动)----