自由标记体验重做 + 全部实验性面板可折起

- 网格内固定点:取值胶囊按已放置矩形做纵向避让(不再互相遮挡);行尾 ✕ 改为与
  胶囊同高的正方形;移除固定点的纵向半透明竖线
- 新增「标记透明度」(0–100%),作用于本网格全部固定点的圆点/胶囊/描边/文字/✕;
  半透明时文字改走 GDI+(GDI DrawTextW 忽略 alpha),100% 时像素零变化;
  ≤2% 时不绘制且不登记 ✕ 命中矩形(看不见的按钮不可点)
- 面板按信号分组:信号色托底分组栏(展开三角 + 信号名 + N 标记点 + ✕,文字/三角/✕
  一律用自适应对比色),点击分组栏收起/展开;条目五列(值/枚举/增量/时刻/间隔),
  首项增列与 Δt 显示 N/A,整个面板没有枚举文本时枚举列整列缺省;行尾仍有 ✕
- 面板内容超高时右侧出现滚动条(分组栏与条目一起滚动),滚轮在内容区翻页;
  底部按钮与「标记透明度」滑块固定在底部、不参与滚动
- 底部按钮改为「光标选取模式」切换类按钮(外观与工具栏切换按钮一致,激活高亮),
  激活时其上方出现「标记透明度」滑动条(与信号样式区的透明度滑块同款)
- 所有实验性功能浮动面板支持展开/折起:标题栏 GX 徽标左侧新增「三角 + 横线」指示器,
  单击切换、双击标题栏切换;折起的面板只有标题栏
- 数据层:LanePanelState.collapsed、Lane.freeMarkerOpacity/freeMarkerScroll/
  freeMarkerCollapsedGroups,LaneCarry 与 SplitLane 一并继承
- 验证:新增离屏 .preflight/perf/mark2check 69/69(含两组 A/B);回归 datacheck 69/69、
  verify 49/49、panelanim 158/158、errcheck 19/19;make release/debug 零警告
This commit is contained in:
DeepSeek-V4.1-Flash committed 2026-10-21 02:59:05 +08:00
1 parent 067128f46c
commit 3400ac7c29
9 files changed
+1225 -202

No files matched your search

+522 -144
View File
@@ -5342,6 +5342,14 @@ namespace jdi
{
return layout;
}
// 面板的**折起**状态(只显示标题栏)。折起时仍然算宽度与位置,但内容区全部省略。
const bool collapsed = meanFilter ? lane.meanFilterPanel.collapsed
: (weakPeak ? lane.weakPeakPanel.collapsed
: (freeMarker ? lane.freeMarkerPanel.collapsed
: (centerAxis
? lane.edgeCenterPanel.collapsed
: lane.edgePanel.collapsed)));
layout.collapsed = collapsed;
int rowCount = 0;
for (const SignalRef& ref : lane.signals)
@@ -5415,8 +5423,10 @@ namespace jdi
height = titleHeight + pad + static_cast<float>(rowCount) * rowHeight * rowsPerSignal + pad;
}
// 「自由标记」面板:逐信号一行名称(**只列出确实有固定点的信号**),下面跟着它的全部固定点;
// 没有任何固定点时给一行灰色提示,最后始终带一行底部切换按钮。
// 「自由标记」面板:按信号分组(**分组栏** + 下属固定点条目),
// 只列出确实有固定点的信号;没有任何固定点时给一行灰色提示。
// 内容区超高时出现滚动条;面板底部固定「标记透明度」滑块(仅光标模式激活时)
// 与「光标选取模式」切换按钮(二者**不参与**滚动)。
bool markerAny = false;
for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems)
{
@@ -5428,9 +5438,11 @@ namespace jdi
}
if (freeMarker)
{
const float nameRow = kFreeMarkerNameRowHeight * m_scale;
const float groupRow = kFreeMarkerNameRowHeight * m_scale;
const float itemRow = kFreeMarkerItemRowHeight * m_scale;
const float markerButtonHeight = kEdgePanelButtonHeight * m_scale;
const float sliderRow =
lane.freeMarkerCursor ? (kFreeMarkerSliderRowHeight * m_scale) : 0.0f;
float content = 0.0f;
std::vector<std::size_t> order;
for (const SignalRef& ref : lane.signals)
@@ -5440,22 +5452,37 @@ namespace jdi
{
continue;
}
// ⚠️ 用户明确要求:某个信号没有任何固定点时,它的名称条目**不出现**在面板上。
// ⚠️ 用户明确要求:某个信号没有任何固定点时,它的分组栏**不出现**在面板上。
SortedFreeMarkerIndices(lane, ref, order);
if (order.empty())
{
continue;
}
content += nameRow;
content += static_cast<float>(order.size()) * itemRow;
content += groupRow;
if (!lane.FreeMarkerGroupCollapsed(ref))
{
content += static_cast<float>(order.size()) * itemRow;
}
}
if (!markerAny)
{
// 还没有任何固定点:第一行留作提示文案(绘制时画「在网格内单击以固定数据点」)。
content += nameRow;
// 还没有任何固定点:留一行提示文案(绘制时画「在网格内单击以固定数据点」)。
content += groupRow;
}
// 底部「启用光标模式 / 关闭光标模式」按钮:上下各留一个 pad(与左右内边距一致)。
height = titleHeight + pad + content + pad + markerButtonHeight + pad;
// 内容区高度上限:太长的列表不能把面板顶出绘图区(超出部分靠滚动条翻页)。
const float budget =
m_plotRect.Height - (titleHeight + pad * 3.0f + markerButtonHeight + sliderRow);
const float maxContent =
std::max(2.0f * itemRow,
std::min(kFreeMarkerContentMaxHeight * m_scale, std::max(2.0f * itemRow, budget)));
const float view = std::min(content, maxContent);
height = titleHeight + pad + view + pad + sliderRow + markerButtonHeight + pad;
}
// 折起的面板:只剩标题栏(内容区完全不占高度)。
if (collapsed)
{
height = titleHeight;
}
// 首次使用(位置为负)时自动放到绘图区**右上角**,并按网格在**当前可见网格中的序数**
@@ -5546,10 +5573,22 @@ namespace jdi
layout.title = gd::RectF(left, top, width, titleHeight);
const float closeSize = titleHeight;
layout.close = gd::RectF(left + width - closeSize, top, closeSize, closeSize);
// 标题栏左侧的「展开 / 折起」三角指示器(位于 GX 徽标左边)。
// 折起后面板只剩标题栏,因此这个指示器是唯一的“重新展开”入口(双击标题栏亦可)。
layout.disclosure = gd::RectF(left + pad, top + (titleHeight - kEdgePanelDisclosureWidth * m_scale) * 0.5f,
kEdgePanelDisclosureWidth * m_scale,
kEdgePanelDisclosureWidth * m_scale);
// 标题栏左侧的「GX」编号徽标:绘制、悬停提示、手型光标与“点击滚动到该网格”
// 全部以这个矩形为准(唯一几何来源,避免“画的”和“点的”漂移)。
layout.badge = gd::RectF(left + pad, top + (titleHeight - kLaneBadgeHeight * m_scale) * 0.5f,
kLaneBadgeWidth * m_scale, kLaneBadgeHeight * m_scale);
layout.badge =
gd::RectF(left + pad + kEdgePanelDisclosureWidth * m_scale,
top + (titleHeight - kLaneBadgeHeight * m_scale) * 0.5f, kLaneBadgeWidth * m_scale,
kLaneBadgeHeight * m_scale);
// 折起状态:到此为止(内容区、按钮、滚动条全部为空)。
if (collapsed)
{
return layout;
}
if (itemized)
{
const float buttonRowHeight = kEdgePanelButtonHeight * m_scale;
@@ -5688,26 +5727,44 @@ namespace jdi
if (freeMarker)
{
// 「自由标记」面板:**只列出确实有固定点的信号**,每个信号一行名称
//(右侧带「清除本信号全部固定点」✕),下面逐行列出它的固定点
//(组内按时间升序 == 从左到右),行尾再带一个 ✕。
const float nameRow = kFreeMarkerNameRowHeight * m_scale;
// 「自由标记」面板:**只列出确实有固定点的信号**,每个信号一个**分组栏**
//(信号色托底 + 左侧展开/收起三角 + 信号名 + 右侧「N 标记点」与 ✕),
// 下面逐行列出它的固定点(组内按时间升序 == 从上到下),行尾再带一个 ✕。
// 内容区超高时右侧出现滚动条;底部按钮与「标记透明度」滑块**固定**不滚动。
const float groupRow = kFreeMarkerNameRowHeight * m_scale;
const float itemRow = kFreeMarkerItemRowHeight * m_scale;
const float closeSize = kFreeMarkerCloseSize * m_scale;
const float gap = 4.0f * m_scale;
const float markerButtonHeight = kEdgePanelButtonHeight * m_scale;
const float sliderRow = lane.freeMarkerCursor ? (kFreeMarkerSliderRowHeight * m_scale) : 0.0f;
const float scrollW = kFreeMarkerScrollWidth * m_scale;
// 四列(① 取值 ② 增量 ③ 时刻 ④ 间隔)从左往右**固定列宽**排布:
// 每一行的同类信息都从同一横坐标开始 ⇒ 纵向上严格对齐(用户明确要求)。
float columnX = left + pad;
for (int column = 0; column < 4; ++column)
// 底部固定区:切换按钮(整行宽度、与面板下边界留一个 pad)。
layout.button = gd::RectF(left + pad, top + height - pad - markerButtonHeight,
std::max(0.0f, width - pad * 2.0f), markerButtonHeight);
if (sliderRow > 0.0f)
{
layout.markerColumns[column].left = columnX;
layout.markerColumns[column].width = kFreeMarkerColumnWidths[column] * m_scale;
columnX += layout.markerColumns[column].width + kFreeMarkerColumnGap * m_scale;
// 「标记透明度」行:与信号样式区的「透明度」行同一套排版
//(左侧标签 + 中间轨道 + 右侧百分比),只是位置固定在按钮上方。
const float rowTop = layout.button.Y - sliderRow;
const float valueWidth =
std::max(36.0f * m_scale,
UiMeasureTextWidth(m_fonts->monoSmall, L"100%") + 6.0f * m_scale);
const float labelWidth =
UiMeasureTextWidth(m_fonts->uiSmall, kFreeMarkerOpacityLabel) + 6.0f * m_scale;
const float trackLeft = left + pad + labelWidth;
const float trackRight = left + width - pad - valueWidth - 6.0f * m_scale;
const float trackHeight = kWeakPeakTrackHeight * m_scale;
layout.opacityTrack =
gd::RectF(trackLeft, rowTop + (sliderRow - trackHeight) * 0.5f,
std::max(20.0f * m_scale, trackRight - trackLeft), trackHeight);
layout.opacityValue =
gd::RectF(trackRight + 6.0f * m_scale, rowTop, valueWidth, sliderRow);
}
float rowY = top + titleHeight + pad;
// 内容区高度(面板高度算式里的 `view` 部分)与"内容总高"决定是否需要滚动条。
const float viewHeight = std::max(
0.0f, height - titleHeight - pad * 3.0f - markerButtonHeight - sliderRow);
float totalContent = 0.0f;
std::vector<std::size_t> order;
for (const SignalRef& ref : lane.signals)
{
@@ -5716,35 +5773,124 @@ namespace jdi
{
continue;
}
// 该信号没有任何固定点 ⇒ 名称条目与数据行都**不出现**(用户明确要求)。
SortedFreeMarkerIndices(lane, ref, order);
if (order.empty())
{
continue;
}
layout.rowRefs.push_back(ref);
layout.rowNameRects.push_back(gd::RectF(left + pad, rowY,
std::max(0.0f, width - pad * 2.0f - closeSize - gap),
nameRow));
layout.rowNameCloses.push_back(
gd::RectF(left + width - pad - closeSize, rowY + (nameRow - closeSize) * 0.5f,
closeSize, closeSize));
rowY += nameRow;
totalContent += groupRow;
if (!lane.FreeMarkerGroupCollapsed(ref))
{
totalContent += static_cast<float>(order.size()) * itemRow;
}
}
if (!markerAny)
{
totalContent += groupRow;
}
const bool scrollable = totalContent > viewHeight + 0.5f;
const float maxScroll = std::max(0.0f, totalContent - viewHeight);
const float scroll = std::max(0.0f, std::min(lane.freeMarkerScroll, maxScroll));
const float innerWidth =
std::max(0.0f, width - pad * 2.0f - (scrollable ? (scrollW + 4.0f * m_scale) : 0.0f));
layout.content =
gd::RectF(left + pad, top + titleHeight + pad, innerWidth, viewHeight);
layout.markerContentHeight = totalContent;
if (scrollable)
{
layout.scrollTrack =
gd::RectF(left + pad + innerWidth + 4.0f * m_scale, layout.content.Y, scrollW,
viewHeight);
const float thumbHeight =
std::max(18.0f * m_scale, viewHeight * (viewHeight / totalContent));
const float thumbTop =
layout.content.Y +
(viewHeight - thumbHeight) * ((maxScroll > 0.0f) ? (scroll / maxScroll) : 0.0f);
layout.scrollThumb = gd::RectF(layout.scrollTrack.X, thumbTop, scrollW, thumbHeight);
}
// 五列(① 取值 ② 枚举 ③ 增量 ④ 时刻 ⑤ 间隔)从左往右**固定列宽**排布:
// 每一行的同类信息都从同一横坐标开始 ⇒ 纵向上严格对齐(用户明确要求)。
// ⚠️ 整个面板里没有任何枚举文本时,② 枚举列**整列缺省**(列宽记 0,后面的列左移)。
// 这里只查"该信号是否带枚举表"(O(1)),**不**逐条采样(布局函数被高频调用)。
bool hasEnum = false;
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data != nullptr && resolved.visible && !resolved.data->enumText.Empty() &&
lane.HasFreeMarkers(ref))
{
hasEnum = true;
break;
}
}
layout.markerHasEnumColumn = hasEnum;
float columnX = left + pad;
for (int column = 0; column < 5; ++column)
{
const bool omitted = (column == EdgePanelLayout::MARKER_COL_ENUM) && !hasEnum;
const float columnWidth =
omitted ? 0.0f : (kFreeMarkerColumnWidths[column] * m_scale);
layout.markerColumns[column].left = columnX;
layout.markerColumns[column].width = columnWidth;
columnX += columnWidth + (omitted ? 0.0f : kFreeMarkerColumnGap * m_scale);
}
const float contentBottom = layout.content.Y + layout.content.Height;
float rowY = layout.content.Y - scroll;
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || !resolved.visible)
{
continue;
}
// 该信号没有任何固定点 ⇒ 分组栏与数据行都**不出现**(用户明确要求)。
SortedFreeMarkerIndices(lane, ref, order);
if (order.empty())
{
continue;
}
EdgePanelLayout::MarkerGroup group;
group.ref = ref;
group.collapsed = lane.FreeMarkerGroupCollapsed(ref);
group.header = gd::RectF(layout.content.X, rowY, innerWidth, groupRow);
group.triangle = gd::RectF(layout.content.X + 3.0f * m_scale,
rowY + (groupRow - 12.0f * m_scale) * 0.5f,
12.0f * m_scale, 12.0f * m_scale);
group.close = gd::RectF(layout.content.X + innerWidth - closeSize - 2.0f * m_scale,
rowY + (groupRow - closeSize) * 0.5f, closeSize, closeSize);
if (group.header.Y + group.header.Height > layout.content.Y &&
group.header.Y < contentBottom)
{
// 只登记"在可视内容区里"的分组栏与条目(滚出视界的条目不得抢走
// 底部按钮 / 滑块的点击)。
layout.markerGroups.push_back(group);
layout.rowRefs.push_back(ref);
layout.rowNameRects.push_back(group.header);
layout.rowNameCloses.push_back(group.close);
}
rowY += groupRow;
if (group.collapsed)
{
continue;
}
for (const std::size_t itemIndex : order)
{
EdgePanelLayout::MarkerRow markerRow;
markerRow.itemIndex = itemIndex;
markerRow.rect = gd::RectF(left + pad, rowY, width - pad * 2.0f, itemRow);
markerRow.close = gd::RectF(left + width - pad - closeSize,
rowY + (itemRow - closeSize) * 0.5f, closeSize, closeSize);
layout.markerRows.push_back(markerRow);
markerRow.rect = gd::RectF(layout.content.X, rowY, innerWidth, itemRow);
markerRow.close = gd::RectF(layout.content.X + innerWidth - closeSize,
rowY + (itemRow - closeSize) * 0.5f, closeSize,
closeSize);
if (markerRow.rect.Y + markerRow.rect.Height > layout.content.Y &&
markerRow.rect.Y < contentBottom)
{
layout.markerRows.push_back(markerRow);
}
rowY += itemRow;
}
}
// 底部切换按钮:整行宽度、与面板下边界留一个 pad。
layout.button = gd::RectF(left + pad, top + height - pad - markerButtonHeight,
std::max(0.0f, width - pad * 2.0f), markerButtonHeight);
}
return layout;
}
@@ -6207,9 +6353,17 @@ namespace jdi
mixRect(layout.panel);
mixRect(layout.title);
mixRect(layout.close);
mixRect(layout.disclosure);
mixRect(layout.badge);
mixRect(layout.edit);
mixRect(layout.button);
mixRect(layout.content);
mixRect(layout.scrollTrack);
mixRect(layout.scrollThumb);
mixRect(layout.opacityTrack);
mixRect(layout.opacityValue);
mix(layout.collapsed ? 1ull : 0ull);
mix(layout.markerHasEnumColumn ? 1ull : 0ull);
mix(static_cast<std::uint64_t>(layout.rows));
for (const gd::RectF& rect : layout.rowChecks)
{
@@ -6247,12 +6401,25 @@ namespace jdi
mixFloat(column.left);
mixFloat(column.width);
}
// 「自由标记」的分组栏(托底矩形 / 三角 / ✕)与各自的收起状态都在面板像素里。
for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups)
{
mixRect(group.header);
mixRect(group.triangle);
mixRect(group.close);
mix(group.collapsed ? 1ull : 0ull);
mix(static_cast<std::uint64_t>(group.ref.groupIndex + 2));
mix(static_cast<std::uint64_t>(group.ref.signalIndex + 2));
}
const Lane& lane = Tab().Lanes()[static_cast<std::size_t>(laneIndex)];
if (kind == EdgePanelKind::MARK)
{
// 底部切换按钮的文案取决于光标模式 ⇒ 必须进键,否则开关后仍贴回旧像素。
// 底部切换按钮的激活态、透明度滑块与内容滚动偏移直接改变面板像素 ⇒ 必须进键,
// 否则开关 / 拖滑块 / 滚动之后仍会贴回旧像素。
mix(lane.freeMarkerCursor ? 1ull : 0ull);
mixFloat(lane.freeMarkerOpacity);
mixFloat(lane.freeMarkerScroll);
// 固定点的**内容**(哪个信号、什么时刻)直接决定面板文字与行数,必须进键;
// 否则新增/删除一个固定点后可能贴回旧像素。
for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems)
@@ -6499,10 +6666,11 @@ namespace jdi
: nullptr;
text = FreeMarkerValueText(value, hasPrev, delta, enumText);
const float closeSize = kFreeMarkerCloseSize * m_scale;
const float gap = kFreeMarkerCloseGap * m_scale;
// ✕ 按钮 = **与取值胶囊同高的正方形**(用户明确要求:长宽都等于胶囊的上下高度)。
const float textWidth = UiMeasureTextWidth(m_fonts->monoSmall, text) + 10.0f * m_scale;
const float textHeight = 17.0f * m_scale;
const float closeSize = textHeight;
const float gap = kFreeMarkerCloseGap * m_scale;
const float totalWidth = textWidth + gap + closeSize;
float left = rawX + 6.0f * m_scale;
@@ -6516,28 +6684,93 @@ namespace jdi
top = std::max(clipTop + 1.0f * m_scale, std::min(top, clipBottom - textHeight - 1.0f * m_scale));
label = gd::RectF(left, top, textWidth, textHeight);
close = gd::RectF(left + textWidth + gap, top + (textHeight - closeSize) * 0.5f, closeSize,
closeSize);
close = gd::RectF(left + textWidth + gap, top, closeSize, closeSize);
return true;
}
/**
* @brief 用 **GDI+** 绘制一段可带 alpha 的文字(仅用于「标记透明度」低于 100% 的胶囊)。
*
* ⚠️ 界面其余文字一律走 `UiText`(GDI `DrawTextW`)。GDI 文本**忽略 alpha**
*(`SetTextColor` 只吃 RGB)⇒ 直接把 alpha 乘进颜色不会让文字淡出。
* 因此只有在"固定点被调成半透明"这个特殊状态下才改用 GDI+ `DrawString`:
* 那样才能让文字与胶囊一起真正淡出;不透明度为 100% 时仍走 `UiText`,像素零变化。
*
* @param [IN,OUT] g 绘图上下文。
* @param [IN] text 文本。
* @param [IN] font 字体(与 `UiText` 同一 HFONT)。
* @param [IN] rect 文本矩形(居中)。
* @param [IN] argb 颜色(含目标 alpha)。
*/
void DrawFreeMarkerAlphaText(gd::Graphics& g, const std::wstring& text, HFONT font,
const gd::RectF& rect, std::uint32_t argb)
{
if (text.empty() || font == nullptr || rect.Width <= 0.0f || rect.Height <= 0.0f)
{
return;
}
LOGFONTW lf{};
if (GetObjectW(font, static_cast<int>(sizeof(lf)), &lf) != static_cast<int>(sizeof(lf)))
{
UiText(g, text, font, rect, argb, TextAlign::CENTER);
return;
}
HDC hdc = g.GetHDC();
if (hdc == nullptr)
{
return;
}
gd::Font textFont(hdc, &lf);
g.ReleaseHDC(hdc);
if (textFont.GetLastStatus() != gd::Ok)
{
UiText(g, text, font, rect, argb, TextAlign::CENTER);
return;
}
gd::SolidBrush brush(ToGdiColor(argb));
gd::StringFormat format;
format.SetAlignment(gd::StringAlignmentCenter);
format.SetLineAlignment(gd::StringAlignmentCenter);
format.SetTrimming(gd::StringTrimmingEllipsisCharacter);
format.SetFormatFlags(gd::StringFormatFlagsNoWrap);
g.DrawString(text.c_str(), static_cast<INT>(text.size()), &textFont, rect, &format, &brush);
}
void PlotView::PaintFreeMarkerCapsule(gd::Graphics& g, const gd::RectF& label,
const std::wstring& text, std::uint32_t color,
const gd::RectF& close, bool closeHot)
const gd::RectF& close, bool closeHot, float alphaScale)
{
const float scale = std::max(0.0f, std::min(1.0f, alphaScale));
const auto scaled = [scale](std::uint32_t argb, float base) -> std::uint32_t {
const int alpha = static_cast<int>(std::lround(base * scale));
return WithAlpha(argb, (alpha <= 0) ? 0 : (alpha >= 255 ? 255 : alpha));
};
const UiInk ink = UiContrastInk(color);
UiFillRoundRect(g, label, 3.0f * m_scale, WithAlpha(color, 0xF0));
DrawLabelOutline(g, label, 3.0f * m_scale, ink.outline);
UiText(g, text, m_fonts->monoSmall, label, ink.text, TextAlign::CENTER);
if (scale <= 0.02f)
{
return; // 完全透明:连描边与文字都不画(否则会留下"看不见的"像素)
}
UiFillRoundRect(g, label, 3.0f * m_scale, scaled(color, 0xF0));
DrawLabelOutline(g, label, 3.0f * m_scale, scaled(ink.outline, 255.0f));
if (scale >= 0.999f)
{
UiText(g, text, m_fonts->monoSmall, label, ink.text, TextAlign::CENTER);
}
else
{
// GDI 文本忽略 alpha ⇒ 半透明时必须改用 GDI+ 才能真的淡出(见上面说明)。
DrawFreeMarkerAlphaText(g, text, m_fonts->monoSmall, label, scaled(ink.text, 255.0f));
}
if (close.Width <= 0.0f)
{
return;
}
// ✕ 按钮:悬停时用 danger 底色 + 叉(与面板里的 ✕ 同一套语义)。
UiFillRoundRect(g, close, 3.0f * m_scale,
closeHot ? WithAlpha(m_theme->danger, 0x66) : WithAlpha(color, 0xC0));
const float crossPad = 3.5f * m_scale;
const std::uint32_t crossColor = closeHot ? m_theme->danger : ink.text;
closeHot ? WithAlpha(m_theme->danger, 0x66) : scaled(color, 0xC0));
const float crossPad = 4.0f * m_scale;
const std::uint32_t crossColor =
closeHot ? m_theme->danger : scaled(ink.text, 255.0f);
UiDrawLine(g, close.X + crossPad, close.Y + crossPad, close.X + close.Width - crossPad,
close.Y + close.Height - crossPad, crossColor, 1.4f * m_scale);
UiDrawLine(g, close.X + close.Width - crossPad, close.Y + crossPad, close.X + crossPad,
@@ -6560,6 +6793,8 @@ namespace jdi
const std::size_t count = std::min(lanes.size(), tabLanes.size());
// ---- (1) 已固定的数据点(含各自的 ✕)----
// ⚠️ 固定点**不再画纵向细线**(用户 2026-10-21 明确要求);
// 取值胶囊彼此重叠时做**纵向避让**(依次尝试下方 / 上方错开),保证都能读清。
for (std::size_t laneIndex = 0; laneIndex < count; ++laneIndex)
{
const Lane& lane = tabLanes[laneIndex];
@@ -6567,6 +6802,8 @@ namespace jdi
{
continue;
}
const float markerAlpha = std::max(0.0f, std::min(1.0f, lane.freeMarkerOpacity));
std::vector<gd::RectF> placed;
for (std::size_t itemIndex = 0; itemIndex < lane.freeMarkerItems.size(); ++itemIndex)
{
gd::RectF label;
@@ -6592,27 +6829,71 @@ namespace jdi
double enumValue = 0.0;
const bool valueOk = FreeMarkerValue(lane, item, value, enumValue);
// 纵向细线(仅限本网格,低透明度):让用户看得出固定点在哪一刻。
const float x = TimeToX(item.time);
UiDrawLine(g, x, clipTop, x, clipBottom, WithAlpha(signalColor, 0x70),
1.0f * m_scale);
// ---- 纵向避让:与本次已放置的胶囊重叠时,向下 / 向上交替错开 ----
const auto occupied = [&placed](const gd::RectF& rect) {
for (const gd::RectF& other : placed)
{
if (rect.X < other.X + other.Width && other.X < rect.X + rect.Width &&
rect.Y < other.Y + other.Height && other.Y < rect.Y + rect.Height)
{
return true;
}
}
return false;
};
const auto totalRect = [](const gd::RectF& labelRect, const gd::RectF& closeRect) {
const float right = std::max(labelRect.X + labelRect.Width,
closeRect.X + closeRect.Width);
return gd::RectF(labelRect.X, labelRect.Y, right - labelRect.X,
labelRect.Height);
};
const float step = label.Height + 2.0f * m_scale;
const float minTop = clipTop + 1.0f * m_scale;
const float maxTop = std::max(minTop, clipBottom - label.Height - 1.0f * m_scale);
const float baseTop = label.Y;
float chosenTop = baseTop;
for (int attempt = 0; attempt <= 8; ++attempt)
{
const float magnitude = static_cast<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));
// 数据点:与光标的数据点同一画法(圆点)。
// 数据点:与光标的数据点同一画法(圆点),同样受「标记透明度」控制。
const float x = TimeToX(item.time);
const float radius = 3.4f * m_scale;
gd::SolidBrush dot(ToGdiColor(signalColor));
const int dotAlpha = static_cast<int>(std::lround(255.0f * markerAlpha));
gd::SolidBrush dot(ToGdiColor(WithAlpha(signalColor, dotAlpha)));
const float dotY = valueOk ? ValueToY(value, visual) : (clipTop + clipBottom) * 0.5f;
g.FillEllipse(&dot, x - radius, dotY - radius, radius * 2.0f, radius * 2.0f);
// ✕ 按钮的悬停态决定它的配色,因此先算再画胶囊。
const bool hot = (m_freeMarkerHoverLane == static_cast<int>(laneIndex) &&
m_freeMarkerHoverItem == itemIndex);
PaintFreeMarkerCapsule(g, label, text, signalColor, close, hot);
PaintFreeMarkerCapsule(g, label, text, signalColor, close, hot, markerAlpha);
FreeMarkerHit hit;
hit.laneIndex = static_cast<int>(laneIndex);
hit.itemIndex = itemIndex;
hit.close = close;
m_freeMarkerHits.push_back(hit);
if (markerAlpha > 0.02f)
{
// 完全透明时不可见 ⇒ 也不该被点中(否则会“隔着看不见的按钮”移除固定点)。
m_freeMarkerHits.push_back(hit);
}
}
}
@@ -7079,6 +7360,33 @@ namespace jdi
UiFillRoundRect(g, gd::RectF(panel.X, panel.Y, panel.Width, titleHeight), 5.0f * m_scale,
WithAlpha(m_theme->accent, 0x2A));
// 标题栏左侧的「展开 / 折起」指示器:三角(朝下 = 已展开、朝右 = 已折起)
// + 三角后方的一条短横线(经典展开/折起标识),位于 GX 徽标左边。
// 单击它切换折起状态;折起后面板只剩标题栏,这里是唯一的“重新展开”按钮。
{
const gd::RectF& disclosureRect = layout.disclosure;
if (disclosureRect.Width > 0.0f)
{
const bool disclosureHot =
(m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) &&
UiPointInRect(disclosureRect, static_cast<float>(m_mouseX),
static_cast<float>(m_mouseY));
const std::uint32_t disclosureColor =
disclosureHot ? m_theme->accent : m_theme->text;
const float centerX = disclosureRect.X + 5.5f * m_scale;
const float centerY = disclosureRect.Y + disclosureRect.Height * 0.5f;
UiDrawTriangleIcon(
g,
gd::RectF(centerX - 4.5f * m_scale, centerY - 4.5f * m_scale,
9.0f * m_scale, 9.0f * m_scale),
disclosureColor,
layout.collapsed ? IconDirection::RIGHT : IconDirection::DOWN);
UiDrawLine(g, centerX + 6.0f * m_scale, centerY,
disclosureRect.X + disclosureRect.Width, centerY, disclosureColor,
1.4f * m_scale);
}
}
// 标题:与左侧信号面板一致的 “GX” 徽标。
// ⚠️ 几何必须取 `layout.badge`(与悬停提示 / 手型光标 / 点击联动同源);
// 悬停时给一圈强调色描边,作为“可点击”的视觉提示(用户 2026-10-19 要求)。
@@ -7125,6 +7433,9 @@ namespace jdi
closeRect.X + crossPad, closeRect.Y + closeRect.Height - crossPad, crossColor,
1.6f * m_scale);
// 折起的面板只保留标题栏:内容区(条目 / 分组栏 / 按钮 / 滚动条)全部省略。
if (!layout.collapsed)
{
// 「中心轴边沿计数」/「均值滤波」/「弱峰调整」面板都采用「逐条目」排布:
// 第一排 = 名称 + 复选框,第二排 = 两个参数输入框(仅勾选的条目,
// 弱峰调整没有参数输入框),中心轴再多第三排 = 对应的统计结果。
@@ -7345,68 +7656,101 @@ namespace jdi
}
}
// 「自由标记」面板:逐信号一行名称(带清除全部固定点的 ✕),
// 下面按时间升序列出该信号的固定点(`值(±增量) = 枚举` + 右对齐的 `t:… Δt:…` + 行尾 ✕)。
// 「自由标记」面板:按信号分组的**分组栏**(信号色托底 + 三角 + 名称 +
// 「N 标记点」+ ✕),下属固定点条目五列对齐(取值 / 枚举 / 增量 / 时刻 / 间隔),
// 行尾各带一个 ✕。内容区有滚动偏移时按内容区裁剪。
if (freeMarker)
{
const float closeSize = kFreeMarkerCloseSize * m_scale;
const float gap = 4.0f * m_scale;
const float groupRowHeight = kFreeMarkerNameRowHeight * m_scale;
bool markerAny = false;
for (const Lane::FreeMarkerItem& item : tabLane.freeMarkerItems)
{
markerAny = markerAny || item.ref.IsValid();
}
const int contentState = g.Save();
g.SetClip(layout.content, gd::CombineModeIntersect);
if (!markerAny)
{
UiText(g, L"在网格内单击可固定数据点",
m_fonts->uiSmall,
gd::RectF(panel.X + pad, panel.Y + titleHeight + pad,
panel.Width - pad * 2.0f, kFreeMarkerNameRowHeight * m_scale),
UiText(g, L"在网格内单击可固定数据点", m_fonts->uiSmall,
gd::RectF(layout.content.X, layout.content.Y, layout.content.Width,
groupRowHeight),
m_theme->textMuted, TextAlign::LEFT);
}
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex)
// ---- 分组栏 ----
for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups)
{
const SignalRef& ref = layout.rowRefs[rowIndex];
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.style == nullptr)
const ResolvedSignal resolved = ResolveSignal(group.ref);
if (resolved.data == nullptr)
{
continue;
}
const std::uint32_t signalColor = StyleColor(*resolved.style);
// 名称行矩形直接取自布局(与 ✕ 同一套几何,绘制侧不反推)。
const gd::RectF nameRect =
(rowIndex < layout.rowNameRects.size())
? layout.rowNameRects[rowIndex]
: gd::RectF(panel.X + pad, panel.Y + titleHeight + pad,
panel.Width - pad * 2.0f - (closeSize + gap),
kFreeMarkerNameRowHeight * m_scale);
UiText(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameRect.Width),
m_fonts->uiSmall, nameRect, signalColor, TextAlign::LEFT);
const std::uint32_t signalColor =
(resolved.style != nullptr) ? StyleColor(*resolved.style) : m_theme->border;
// 托底 = 信号色;文字一律用**自适应对比色**(与信号面板的组名徽标同规则),
// 因此无论信号是亮黄还是深蓝都能看清分组栏上的所有内容。
const UiInk ink = UiContrastInk(signalColor);
UiFillRoundRect(g, group.header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8));
// 信号名右侧的 ✕:清除该信号的**全部**固定点。
if (rowIndex < layout.rowNameCloses.size() &&
layout.rowNameCloses[rowIndex].Width > 0.0f)
// 左侧三角(与信号选择区域组名同款):朝下 = 展开、朝右 = 收起。
const bool triangleHot =
(m_freeMarkerTriangleHoverLane == static_cast<int>(index) &&
m_freeMarkerTriangleHoverSignal == group.ref);
UiDrawTriangleIcon(g, group.triangle,
triangleHot ? m_theme->accentSoft : ink.text,
group.collapsed ? IconDirection::RIGHT
: IconDirection::DOWN);
// 右侧:`N 标记点` + ✕(清除该信号全部固定点)。
std::size_t markerCount = 0;
for (const Lane::FreeMarkerItem& entry : tabLane.freeMarkerItems)
{
const gd::RectF& closeRect = layout.rowNameCloses[rowIndex];
const bool hot = (m_freeMarkerPanelHoverLane == static_cast<int>(index) &&
m_freeMarkerPanelHoverSignal == ref &&
m_freeMarkerPanelHoverItem == static_cast<std::size_t>(-1));
UiFillRoundRect(g, closeRect, 3.0f * m_scale,
hot ? WithAlpha(m_theme->danger, 0x66) : WithAlpha(signalColor, 0xC0));
const float crossPad = 3.5f * m_scale;
const std::uint32_t crossColor = hot ? m_theme->danger : m_theme->textOnAccent;
UiDrawLine(g, closeRect.X + crossPad, closeRect.Y + crossPad,
closeRect.X + closeRect.Width - crossPad,
closeRect.Y + closeRect.Height - crossPad, crossColor,
1.4f * m_scale);
UiDrawLine(g, closeRect.X + closeRect.Width - crossPad,
closeRect.Y + crossPad, closeRect.X + crossPad,
closeRect.Y + closeRect.Height - crossPad, crossColor,
1.4f * m_scale);
if (entry.ref == group.ref)
{
++markerCount;
}
}
const std::wstring countText =
std::to_wstring(markerCount) + L" 标记点";
const float countWidth =
UiMeasureTextWidth(m_fonts->monoSmall, countText) + 2.0f * m_scale;
const float countRight = group.close.X - 5.0f * m_scale;
UiText(g, countText, m_fonts->monoSmall,
gd::RectF(countRight - countWidth, group.header.Y, countWidth,
group.header.Height),
ink.text, TextAlign::RIGHT);
const float nameLeft = group.triangle.X + group.triangle.Width + 6.0f * m_scale;
const float nameWidth =
std::max(10.0f, countRight - countWidth - 6.0f * m_scale - nameLeft);
UiText(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth),
m_fonts->uiSmall,
gd::RectF(nameLeft, group.header.Y, nameWidth, group.header.Height),
ink.text, TextAlign::LEFT);
const bool closeHot = (m_freeMarkerPanelHoverLane == static_cast<int>(index) &&
m_freeMarkerPanelHoverSignal == group.ref &&
m_freeMarkerPanelHoverItem == static_cast<std::size_t>(-1));
if (closeHot)
{
UiFillRoundRect(g, group.close, 3.0f * m_scale,
WithAlpha(m_theme->danger, 0x77));
}
const float crossPad = 3.5f * m_scale;
const std::uint32_t crossColor = closeHot ? m_theme->danger : ink.text;
UiDrawLine(g, group.close.X + crossPad, group.close.Y + crossPad,
group.close.X + group.close.Width - crossPad,
group.close.Y + group.close.Height - crossPad, crossColor,
1.5f * m_scale);
UiDrawLine(g, group.close.X + group.close.Width - crossPad,
group.close.Y + crossPad, group.close.X + crossPad,
group.close.Y + group.close.Height - crossPad, crossColor,
1.5f * m_scale);
}
// 固定点行(已按信号分组 + 组内时间升序展开)。
// ---- 固定点条目(五列,列起点由布局给定 ⇒ 纵向严格对齐)----
for (const EdgePanelLayout::MarkerRow& markerRow : layout.markerRows)
{
const Lane::FreeMarkerItem& item = tabLane.freeMarkerItems[markerRow.itemIndex];
@@ -7426,48 +7770,43 @@ namespace jdi
FreeMarkerDelta(tabLane, markerRow.itemIndex, value, hasPrev, delta,
timeDelta);
}
const std::wstring* enumText =
resolved.data->enumText.Find(enumValue);
const std::wstring* enumText = resolved.data->enumText.Find(enumValue);
const std::wstring valueText =
FreeMarkerValueText(valueOk ? value : 0.0, hasPrev, delta, enumText);
valueOk ? FormatValue(value) : std::wstring(kFreeMarkerNotAvailable);
const std::wstring deltaText =
(valueOk && hasPrev) ? FormatSignedValue(delta)
: std::wstring(kFreeMarkerNotAvailable);
const std::wstring timeText = FreeMarkerTimeText(item.time);
const std::wstring deltaText = FreeMarkerDeltaText(hasPrev, timeDelta);
const std::wstring timeDeltaText =
FreeMarkerDeltaText(valueOk && hasPrev, timeDelta);
// 四列(① 取值 ② 增量 ③ 时刻 ④ 间隔)各列**左对齐在固定横坐标**上,
// 于是同一信息在所有条目上纵向上严格对齐(用户明确要求);
// 列宽固定,超长文本用 `UiEllipsize` 截断(布局期不做文本测量)。
const auto columnRect = [&](EdgePanelLayout::MarkerColumnIndex column) {
return gd::RectF(layout.markerColumns[column].left, markerRow.rect.Y,
layout.markerColumns[column].width,
markerRow.rect.Height);
};
UiText(g, UiEllipsize(valueText, m_fonts->monoSmall,
layout.markerColumns[EdgePanelLayout::MARKER_COL_VALUE].width),
m_fonts->monoSmall, columnRect(EdgePanelLayout::MARKER_COL_VALUE),
m_theme->text, TextAlign::LEFT);
if (hasPrev)
const auto paintCell = [&](EdgePanelLayout::MarkerColumnIndex column,
const std::wstring& text, std::uint32_t color) {
if (layout.markerColumns[column].width <= 0.0f || text.empty())
{
return;
}
const gd::RectF rect = columnRect(column);
UiText(g, UiEllipsize(text, m_fonts->monoSmall, rect.Width),
m_fonts->monoSmall, rect, color, TextAlign::LEFT);
};
paintCell(EdgePanelLayout::MARKER_COL_VALUE, valueText, m_theme->textStrong);
if (layout.markerHasEnumColumn)
{
UiText(g,
UiEllipsize(FormatSignedValue(delta), m_fonts->monoSmall,
layout.markerColumns[EdgePanelLayout::MARKER_COL_DELTA].width),
m_fonts->monoSmall, columnRect(EdgePanelLayout::MARKER_COL_DELTA),
m_theme->textMuted, TextAlign::LEFT);
}
UiText(g,
UiEllipsize(timeText, m_fonts->monoSmall,
layout.markerColumns[EdgePanelLayout::MARKER_COL_TIME].width),
m_fonts->monoSmall, columnRect(EdgePanelLayout::MARKER_COL_TIME),
m_theme->textMuted, TextAlign::LEFT);
if (!deltaText.empty())
{
UiText(g,
UiEllipsize(deltaText, m_fonts->monoSmall,
layout.markerColumns[EdgePanelLayout::MARKER_COL_DT].width),
m_fonts->monoSmall, columnRect(EdgePanelLayout::MARKER_COL_DT),
m_theme->textMuted, TextAlign::LEFT);
paintCell(EdgePanelLayout::MARKER_COL_ENUM,
(enumText != nullptr) ? *enumText : std::wstring(),
m_theme->text);
}
paintCell(EdgePanelLayout::MARKER_COL_DELTA, deltaText, m_theme->textMuted);
paintCell(EdgePanelLayout::MARKER_COL_TIME, timeText, m_theme->textMuted);
paintCell(EdgePanelLayout::MARKER_COL_DT, timeDeltaText, m_theme->textMuted);
// 行尾 ✕:移除该固定点。
// 行尾 ✕:移除该固定点(与之前一致)。
const gd::RectF& closeRect = markerRow.close;
const bool hot = (m_freeMarkerPanelHoverLane == static_cast<int>(index) &&
m_freeMarkerPanelHoverItem == markerRow.itemIndex);
@@ -7484,6 +7823,39 @@ namespace jdi
closeRect.X + crossPad, closeRect.Y + closeRect.Height - crossPad,
crossColor, 1.2f * m_scale);
}
g.Restore(contentState);
// ---- 内容滚动条(只在内容超高时出现;按钮与滑块不参与滚动)----
if (layout.scrollTrack.Width > 0.0f)
{
UiFillRoundRect(g, layout.scrollTrack, layout.scrollTrack.Width * 0.5f,
WithAlpha(m_theme->border, 0x88));
UiFillRoundRect(g, layout.scrollThumb, layout.scrollThumb.Width * 0.5f,
m_theme->borderStrong);
}
// ---- 「标记透明度」滑动条(仅「光标选取模式」激活时出现,固定在按钮上方)----
if (layout.opacityTrack.Width > 0.0f)
{
UiText(g, kFreeMarkerOpacityLabel, m_fonts->uiSmall,
gd::RectF(panel.X + pad, layout.opacityValue.Y,
layout.opacityTrack.X - panel.X - pad, layout.opacityValue.Height),
m_theme->textMuted, TextAlign::LEFT);
const bool sliderActive =
(m_dragMode == PlotDragMode::MARKER_OPACITY &&
m_markerOpacityDragLane == static_cast<int>(index)) ||
UiPointInRect(layout.opacityTrack, static_cast<float>(m_mouseX),
static_cast<float>(m_mouseY));
// 外观与信号样式区的「透明度」滑动条完全相同(同一 `UiDrawSlider` 口径)。
UiDrawSlider(g, layout.opacityTrack,
static_cast<double>(tabLane.freeMarkerOpacity), m_theme->accent,
sliderActive, m_scale);
const int percent =
static_cast<int>(tabLane.freeMarkerOpacity * 100.0f + 0.5f);
UiText(g, std::to_wstring(percent) + L"%", m_fonts->monoSmall,
layout.opacityValue, m_theme->textStrong, TextAlign::RIGHT);
}
}
float rowY = panel.Y + titleHeight + pad;
@@ -7520,7 +7892,8 @@ namespace jdi
}
// 逐条目面板底部按钮行:「构建为新信号」(悬停高亮);
// 「自由标记」面板的按钮是「启用光标模式 / 关闭光标模式」的切换按钮。
// 「自由标记」面板的按钮是「光标选取模式」的**切换类按钮**
//(交互外观与工具栏的切换按钮一致:激活时浅强调色底 + 强调描边 + 强文本色)。
if (hasButton || freeMarker)
{
const gd::RectF& buttonRect = layout.button;
@@ -7532,16 +7905,21 @@ namespace jdi
(m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) &&
UiPointInRect(inner, static_cast<float>(m_mouseX),
static_cast<float>(m_mouseY));
UiFillRoundRect(g, inner, 4.0f * m_scale,
hot ? WithAlpha(m_theme->accent, 0x3A) : m_theme->cardBg);
UiDrawRoundRect(g, inner, 4.0f * m_scale, m_theme->accent, 1.2f);
const std::wstring label =
freeMarker ? (tabLane.freeMarkerCursor ? std::wstring(kFreeMarkerCursorOffLabel)
: std::wstring(kFreeMarkerCursorOnLabel))
: std::wstring(kBuildSignalLabel);
UiText(g, label, m_fonts->uiSmall, inner, m_theme->accent, TextAlign::CENTER);
const bool checked = freeMarker && tabLane.freeMarkerCursor;
const std::uint32_t background =
checked ? (hot ? MixColor(m_theme->accentSoft, m_theme->accent, 0.16)
: m_theme->accentSoft)
: (hot ? m_theme->hover : m_theme->cardBg);
UiFillRoundRect(g, inner, 4.0f * m_scale, background);
UiDrawRoundRect(g, inner, 4.0f * m_scale,
checked ? m_theme->accent : m_theme->border, 1.2f);
const std::wstring label = freeMarker ? std::wstring(kFreeMarkerCursorModeLabel)
: std::wstring(kBuildSignalLabel);
UiText(g, label, m_fonts->uiSmall, inner,
checked ? m_theme->textStrong : m_theme->text, TextAlign::CENTER);
}
}
} // if (!layout.collapsed)
// 面板画完了:把它刚画进后备缓冲区的像素(连同缓存键)存进面板图层,
// 下一帧只要键不变就直接贴图。矩形外的 4 逻辑像素边距用于容纳投影。