Files
Just-DataInspector/Components/PlotView/Src/PlotViewPanels.cpp
T
DeepSeek-V4.1-Flash 944c074ab3 288 信号卡顿根治 + 中心轴面板重做 + 标签页默认状态
一、卡顿真因(实测 29.8ms/帧 → 6.6ms/帧)
1. 中心轴面板高度没有上限:288 个信号时面板高 9996px(绘图区只有 1141px)
   ⇒ PanelRectInsideLayer 恒为假 ⇒ 面板像素缓存一次都存不进去 ⇒ 每帧全量重画。
2. PanelGlobalKey 混入鼠标驱动状态(m_hoverSignal / m_hoverLane / m_hoverCursor /
   CursorTime / EdgeRangeMin/Max)⇒ 鼠标一动键就变 ⇒ 所有面板每帧重画;
   PanelSlotKey 里还会为每个信号调一次 EdgeCenterCountsFor(单帧 288 次查询)。
3. 缓存命中路径的投影(与面板同大的半透明抗锯齿圆角矩形)每帧重画,GDI+ 实测 > 20ms。

修法:
* 新增 PlotView::InputIsInteractive()(拖拽中 / 鼠标 140ms 内移动过)。交互期间冻结
  全部鼠标驱动项与统计数字、跳过投影、统计预算降到 8 万(静止时 40 万)。
  m_meanRowHoverLane/Ref 例外 —— 它直接改面板像素且只在进出某行时变化。
* kEdgeScanPairsPerFrame 由 1,200,000 降为 400,000。
* 中心轴面板重做为可滚动列表 ⇒ 面板高度被绘图区夹住、缓存可用、只画可见行。

二、「中心轴边沿计数」面板重做(与「均值滤波」同风格)
条目栏为信号色叠底的可折叠栏 + 右端复选框;栏下同一行显示「上升沿 / 下降沿」;
再下面两行滑块(均值点数 / 迭代次数,标签不带 X/N),数值可点击输入上限、
右侧复位按钮;整个面板可滚动;未勾选时条目栏不可展开。
新增 Lane::edgeCenterScroll + SetLaneEdgeCenterScroll;交互复用 MEAN_* 拖拽模式 +
m_meanDragCenter / m_editMeanCenter 标志分派;光标形状与均值滤波一致。

三、标签页
* 唯一标签页点 ✕ 不再弹警告:直接 erase 后 NewTab(true),名字必然为「视图 1」。
* 新建标签页(含手动新建)默认:组全部折起、无选中信号(清空全局 Selected)、
  样式区无目标。

四、验证
.preflight/bugcheck.exe 59/59(A1 空闲 0.7ms、A2 平移 2.5ms、A3 双光标 2.4ms、
A4 自由标记 5.2ms、A5 中心轴拖动 6.6ms;B3 真实 WM_TIMER+WM_PAINT 路径;
B5 快速连点与动画反向;C 中心轴面板交互;D 标签页默认状态)
panelcheck 71/71、markercheck 13/13、ringcheck 6/6
make release / make debug 零 warning
2026-11-02 03:33:28 +08:00

1616 lines
74 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// PlotView 实现分片 —— 几何:浮动面板布局、图例徽标几何、"自由标记"的取值/标签/探测。
//
// 本分片只包含 `PlotView` 的**成员函数定义**(从 `PlotViewPaint.cpp` 机械搬移,行为与搬移前逐字节一致)。
//
// 为什么要独立出来:
// * 这些函数**被高频询问**(`TopPanelAtPoint` / `SyncPanelOrder` / `QueryCursor` / 绘制各算一遍,
// `EdgePanelLayoutFor` 一度有 19 个调用点),属于"几何"而不是"绘制";
// * 与绘制混在一个文件里时,改动布局会连带触碰 9 千行的绘制文件,回归面被放大。
//
// ⚠️ 硬约束:
// 1. `EdgePanelLayoutFor` / `EdgePanelWidth` 会被鼠标移动的高频路径调用 ⇒ **不得**逐条查询采样值,
// 枚举列缺省只允许查 `SignalEnumText::Empty()`(O(1))。
// 2. 布局结果必须"绘制与命中同源":绘制侧不得自行反推列边界。
// 3. 面板几何键(`PanelSlotKey` 等)留在 `PlotViewPaint.cpp`(属于缓存键,不属于几何)。
#include "PlotViewInternal.hpp"
namespace jdi
{
float PlotView::EdgePanelWidth(int laneIndex, EdgePanelKind kind) const
{
if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 ||
laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return kEdgePanelWidth * m_scale;
}
const Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
const bool centerAxis = (kind == EdgePanelKind::CENTER);
const bool meanFilter = (kind == EdgePanelKind::MEAN);
const bool weakPeak = (kind == EdgePanelKind::PEAK);
// 「弱峰调整」仍是旧式逐条目面板(名称 + 复选框 + 两行滑块)。
// ⚠️ 「均值滤波」与「中心轴边沿计数」已改为**可滚动的分组列表**(信号色叠底条目栏),
// 它们的宽度由下面的 `groupWidth` / 滑块行分支确定。
const bool itemized = weakPeak;
// 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” / “均值滤波” / “弱峰调整”
// + 光标区间尾缀)。
const std::wstring title =
meanFilter ? std::wstring(L"均值滤波")
: (weakPeak ? std::wstring(kWeakPeakTitle)
: ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") + EdgeRangeSuffix()));
float width = kEdgePanelPad * 2.0f * m_scale + kLaneBadgeWidth * m_scale +
8.0f * m_scale + UiMeasureTextWidth(m_fonts->uiSmall, title) +
kEdgePanelTitleHeight * m_scale;
// 数据行 = 名称与“上升沿 N / 下降沿 M”两行,取较宽者。
// ⚠️ 名称宽度有上限(`kEdgePanelNameMaxWidth`):超长信号名不再把面板顶得很长,
// 绘制时超出部分用省略号(用户明确要求“超过某个长度就省略名称显示”)。
// ⚠️ 统计信息按「上升沿 / 下降沿」**两列**排版(列起点固定)→ 多条信号时文字垂直对齐。
float nameColumnWidth = 0.0f;
float statRisingWidth = 0.0f;
float statFallingWidth = 0.0f;
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
// ⚠️ 下面会解引用 `resolved.axis/style`(边沿统计),因此必须与其它绘制/命中路径
// 用同一套空指针守卫;旧实现只判 `data`,一旦出现"有数据无样式/无时间轴"的引用
// 就会在测量阶段空指针崩溃(`EdgePanelWidth` 被绘制、命中、`QueryCursor` 调用)。
if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr ||
!resolved.visible)
{
continue;
}
const float nameWidth =
std::min(UiMeasureTextWidth(m_fonts->uiSmall, resolved.data->name),
kEdgePanelNameMaxWidth * m_scale);
nameColumnWidth = std::max(nameColumnWidth, nameWidth);
if (itemized)
{
// 逐条目面板:第一排 = 名称 + 复选框;第三排(中心轴)= 统计结果。
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + nameWidth +
kEdgePanelMeanCheckSize * m_scale + 6.0f * m_scale);
}
if (meanFilter || centerAxis)
{
// 「均值滤波 / 中心轴边沿计数」的条目 = **信号色叠底条目栏**(三角 + 名称 + 复选框)
// 与参数行(标签 + 轨道 + 数值 + 复位按钮),右侧还要给滚动条留位。
const float groupWidth = kEdgePanelPad * 2.0f * m_scale +
kEdgePanelDisclosureWidth * m_scale + 6.0f * m_scale +
nameWidth + 6.0f * m_scale +
kEdgePanelMeanCheckSize * m_scale +
(kFreeMarkerScrollWidth + 4.0f) * m_scale;
width = std::max(width, groupWidth);
}
std::size_t rising = 0;
std::size_t falling = 0;
bool complete = true;
if (meanFilter || weakPeak)
{
continue;
}
if (centerAxis)
{
const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref);
if (item == nullptr)
{
continue;
}
EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, item->points,
item->iterations, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(),
rising, falling, complete);
}
else
{
EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, lane.edgeValue,
EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete);
}
// 宽度按实际要显示的文本测量(含未算完时的“…”),否则推进过程中数字变长会被截断。
// 完全未算出时整行显示“统计中…”,因此两列都要预留它的宽度。
const float pendingWidth = UiMeasureTextWidth(m_fonts->monoSmall, L"统计中…");
statRisingWidth = std::max(
statRisingWidth,
std::max(UiMeasureTextWidth(m_fonts->monoSmall, EdgeRisingText(rising, complete)),
pendingWidth));
statFallingWidth =
std::max(statFallingWidth,
UiMeasureTextWidth(m_fonts->monoSmall, EdgeFallingText(falling, complete)));
}
if (statRisingWidth > 0.0f || statFallingWidth > 0.0f)
{
// 两列**等宽**(取两列最大值中的较大者)→ 绘制时两列起点固定,
// 多条信号的“上升沿 / 下降沿”文字严格垂直对齐(用户明确要求)。
const float maxColumn = std::max(statRisingWidth, statFallingWidth);
const float countsWidth = maxColumn * 2.0f + kEdgePanelStatColumnGap * m_scale;
width = std::max(width, kEdgePanelPad * 2.0f * m_scale +
std::max(nameColumnWidth, countsWidth));
}
if (meanFilter || centerAxis)
{
// 两行滑块 = 「标签列 + 轨道(最小宽度)+ 数值列 + 复位按钮」(两行共用同一列宽),
// 右侧再给滚动条留位(上百个信号时内容必然超高)。
const float labelColumn =
std::max(UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterPointsSliderLabel),
UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterIterationsSliderLabel));
const float valueColumn =
std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"),
UiMeasureTextWidth(m_fonts->monoSmall, L"1000"));
const float sliderRowWidth = labelColumn + 6.0f * m_scale + kWeakPeakTrackMinWidth * m_scale +
6.0f * m_scale + valueColumn + kWeakPeakResetGap * m_scale +
kWeakPeakResetButtonSize * m_scale;
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + sliderRowWidth +
(kFreeMarkerScrollWidth + 4.0f) * m_scale);
}
if (weakPeak)
{
// 滑块行 = 「标签列 + 轨道(最小宽度)+ 数值列 + 复位按钮」(等级与迭代次数两行共用同一列宽)。
const float rowWidth = WeakPeakLabelColumnWidth(m_fonts->uiSmall) + 6.0f * m_scale +
kWeakPeakTrackMinWidth * m_scale + 6.0f * m_scale +
WeakPeakValueColumnWidth(m_fonts->monoSmall) +
kWeakPeakResetGap * m_scale + kWeakPeakResetButtonSize * m_scale;
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth);
}
if (meanFilter || weakPeak)
{
// 底部按钮行 = 「构建为新信号」,按按钮文字实测宽度确定下限。
const float buttonWidth =
UiMeasureTextWidth(m_fonts->uiSmall, kBuildSignalLabel) + 28.0f * m_scale;
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + buttonWidth);
}
if (kind == EdgePanelKind::MARK)
{
// 「自由标记」面板:宽度 = 六列(取「缺省列宽」「实测内容宽度」「列标题宽度」三者
// 的大者)+ 行 ✕ + 左右内边距 + **滚动条预留位**(内容超高时右侧要放滚动条)。
// ⚠️ 用户 2026-10-21 要求面板里的数据列**不得出现省略号**,因此不能再按固定列宽。
// 逐行取采样值算宽度会拖慢交互(本函数被 `TopPanelAtPoint` / `SyncPanelOrder`
// 高频调用),所以走 `Lane::freeMarkerColumnWidths`:绘制该面板时顺便量好真实
// 文本宽度存起来,这里只读。还没量过(全 0)时退回缺省列宽。
const bool hasEnum = [this, &lane]() {
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data != nullptr && resolved.visible &&
!resolved.data->enumText.Empty() && lane.HasFreeMarkers(ref))
{
return true;
}
}
return false;
}();
float columnsWidth = 0.0f;
for (int column = 0; column < 6; ++column)
{
const bool omitted = (column == EdgePanelLayout::MARKER_COL_ENUM) && !hasEnum;
if (omitted)
{
continue;
}
// 列宽还要容得下**表头的列标题**(如「时间间隔」),否则表头会被省略。
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;
}
// 行尾 ✕(按胶囊同高的正方形取宽)+ 滚动条预留 + 少量余量。
const float rowCloseWidth = kFreeMarkerItemRowHeight * m_scale + 2.0f * m_scale;
const float markerWidth = kEdgePanelPad * 2.0f * m_scale +
(kFreeMarkerScrollWidth + 4.0f) * m_scale + columnsWidth +
rowCloseWidth;
width = std::max(width, std::max(kFreeMarkerPanelWidth * m_scale, markerWidth));
}
// 夹在绘图区宽度之内(过窄的网格也不至于把面板挤到外面)。
return std::min(width, std::max(120.0f * m_scale, m_plotRect.Width - 8.0f * m_scale));
}
gd::RectF PlotView::LaneBadgeRect(int laneIndex) const
{
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
return gd::RectF();
}
const LaneVisual& lane = m_lanes[static_cast<std::size_t>(laneIndex)];
const gd::RectF legendBand(lane.rect.X + kLegendPad * m_scale, lane.rect.Y + kLegendPad * m_scale,
lane.rect.Width, kLegendHeight * m_scale);
// 网格被滚动出可视区时没有徽标可点。
const float visibleTop = std::max(lane.rect.Y, m_plotRect.Y);
const float visibleBottom =
std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height);
if (!IsFullyVisible(legendBand, visibleTop, visibleBottom))
{
return gd::RectF();
}
const float height = kLegendHeight * m_scale;
const float badgeHeight = kLaneBadgeHeight * m_scale;
return gd::RectF(legendBand.X, legendBand.Y + (height - badgeHeight) * 0.5f,
kLaneBadgeWidth * m_scale, badgeHeight);
}
PlotView::EdgePanelLayout PlotView::EdgePanelLayoutFor(int laneIndex, EdgePanelKind kind) const
{
EdgePanelLayout layout;
if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 ||
laneIndex >= static_cast<int>(m_tab->Lanes().size()) ||
laneIndex >= static_cast<int>(m_lanes.size()))
{
return layout;
}
const Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
const bool centerAxis = (kind == EdgePanelKind::CENTER);
const bool meanFilter = (kind == EdgePanelKind::MEAN);
const bool weakPeak = (kind == EdgePanelKind::PEAK);
const bool freeMarker = (kind == EdgePanelKind::MARK);
// 「均值滤波」与「弱峰调整」共用同一套「逐条目 + 底部按钮」排布。
// ⚠️ 「均值滤波」从 2026-10-30 起改为**可滚动的分组列表**(信号色叠底标题栏 +
// 展开后的两行滑块),与「自由标记」同一套浏览方式 ⇒ 不再走这里的 `itemized` 分支。
// ⚠️ 「中心轴边沿计数」从 2026-11-02 起同样改为**可滚动的分组列表**(用户明确要求
// “改造为与均值滤波相同的风格”)⇒ 也不走 `itemized`。它没有「构建为新信号」按钮,
// 但内容多一行「上升沿 / 下降沿」统计。
const bool listStyle = (meanFilter || centerAxis);
const bool itemized = weakPeak;
const bool hasButton = (meanFilter || weakPeak);
const bool enabled = meanFilter ? lane.meanFilter
: (weakPeak ? lane.weakPeak
: (freeMarker ? lane.freeMarker
: (centerAxis ? lane.edgeCenter
: lane.edgeCount)));
const bool open = meanFilter ? lane.meanFilterPanel.open
: (weakPeak ? lane.weakPeakPanel.open
: (freeMarker ? lane.freeMarkerPanel.open
: (centerAxis ? lane.edgeCenterPanel.open
: lane.edgePanel.open)));
if (!enabled || !open)
{
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)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data != nullptr && resolved.visible)
{
++rowCount;
}
}
if (rowCount <= 0)
{
return layout;
}
layout.rows = rowCount;
const float width = EdgePanelWidth(laneIndex, kind);
const float titleHeight = kEdgePanelTitleHeight * m_scale;
const float rowHeight = kEdgePanelRowHeight * m_scale;
const float pad = kEdgePanelPad * m_scale;
// 「中心轴边沿计数」「均值滤波」「弱峰调整」共用同一套「逐条目」排布,
// 因此不再有独立的底部编辑行;只有「边沿计数」保留两行文本式排版。
const float buttonHeight = hasButton ? (kEdgePanelButtonHeight * m_scale) : 0.0f;
const float rowsPerSignal = meanFilter ? 1.0f : 2.0f;
// 逐条目面板:每个条目 = 第一排(信号名 + 复选框)
// + 参数部分(**只在该条目被勾选时才占位**,因此面板高度随勾选数量动态变化):
// * MEAN / CENTER:一行「均值点数 X + 迭代次数 N」输入框(中心轴再多一行统计结果);
// * PEAK:两行滑块(调整幅度 % / 迭代次数 N),每个信号各有一套自己的参数。
const bool itemHasParams = (centerAxis || weakPeak);
const float weakPeakSliderRow = kWeakPeakSliderRowHeight * m_scale;
float meanRowHeight = 0.0f;
float itemsHeight = 0.0f;
float meanContentHeight = 0.0f;
float height = 0.0f;
if (listStyle)
{
// 「均值滤波 / 中心轴边沿计数」:内容区 = 逐信号的**信号色叠底条目栏**
//(内容总是显示,用于勾选/取消)+(勾选且未收起时)参数行。
// 「中心轴」的参数行 = 一行「上升沿 / 下降沿」统计 + 两行滑块;
// 「均值滤波」= 两行滑块。内容超高时出现滚动条;底部「构建为新信号」按钮固定不滚动。
const float groupRow = kEdgePanelMeanRowHeight * m_scale;
const float sliderRow = weakPeakSliderRow;
float content = 0.0f;
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || !resolved.visible)
{
continue;
}
content += groupRow;
const Lane::MeanFilterItem* item = meanFilter ? lane.FindMeanFilterItem(ref)
: lane.FindEdgeCenterItem(ref);
if (item != nullptr && !item->collapsed)
{
content += sliderRow * 2.0f;
if (centerAxis)
{
content += groupRow; // 统计行(上升沿 / 下降沿)
}
}
}
meanContentHeight = content;
const float budget = m_plotRect.Height - (titleHeight + pad * 3.0f + buttonHeight);
const float maxContent =
std::max(3.0f * groupRow,
std::min(kFreeMarkerContentMaxHeight * m_scale, std::max(3.0f * groupRow, budget)));
const float view = std::min(content, maxContent);
height = titleHeight + pad + view + pad + buttonHeight + pad;
}
else if (itemized)
{
meanRowHeight = kEdgePanelMeanRowHeight * m_scale;
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || !resolved.visible)
{
continue;
}
itemsHeight += meanRowHeight;
const bool checked = meanFilter ? lane.MeanFilterEnabled(ref)
: (weakPeak ? lane.WeakPeakEnabled(ref)
: lane.EdgeCenterEnabled(ref));
if (checked && itemHasParams)
{
if (weakPeak)
{
itemsHeight += weakPeakSliderRow * 2.0f;
}
else
{
itemsHeight += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale;
if (centerAxis)
{
itemsHeight += kEdgePanelMeanRowHeight * m_scale;
}
}
}
}
// 底部按钮行:上方与条目之间、下方与面板下边界之间各留一个 `pad`,
// 与面板的左右内边距完全一致(原先按钮直接贴着下边框,看起来很不协调)。
height = titleHeight + pad + itemsHeight + pad + buttonHeight + (hasButton ? pad : 0.0f);
}
else
{
height = titleHeight + pad + static_cast<float>(rowCount) * rowHeight * rowsPerSignal + pad;
}
// 「自由标记」面板:按信号分组(**分组栏** + 下属固定点条目),
// 只列出确实有固定点的信号;没有任何固定点时给一行灰色提示。
// 底部固定区(自上而下):标记透明度滑块 → 「自由标记点选取」→ 两个显示开关。
bool markerAny = false;
for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems)
{
if (item.ref.IsValid())
{
markerAny = true;
break;
}
}
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 要求:不再依附于「自由标记点选取」按钮)。
const float sliderRow = kFreeMarkerSliderRowHeight * m_scale;
// 「显示增量 / 显示枚举文本」两个开关按钮独占一行,位于「自由标记点选取」下方
//(用户 2026-10-23 要求)。
const float switchRow = kEdgePanelButtonHeight * m_scale;
const float buttonGap = kFreeMarkerButtonGap * m_scale;
const float footer = sliderRow + markerButtonHeight + buttonGap + switchRow;
float content = 0.0f;
std::vector<std::size_t> order;
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;
}
content += groupRow;
if (!lane.FreeMarkerGroupCollapsed(ref))
{
// 展开的分组:分组栏 + **表头行** + 条目。
content += headerRow + static_cast<float>(order.size()) * itemRow;
}
}
if (!markerAny)
{
// 还没有任何固定点:留一行提示文案(绘制时画「在网格内单击以固定数据点」)。
content += groupRow;
}
// 内容区高度上限:太长的列表不能把面板顶出绘图区(超出部分靠滚动条翻页)。
const float budget = m_plotRect.Height - (titleHeight + pad * 3.0f + footer);
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 + footer + pad;
}
// 折起的面板:只剩标题栏(内容区完全不占高度)。
if (collapsed)
{
height = titleHeight;
}
// 首次使用(位置为负)时自动放到绘图区**右上角**,并按网格在**当前可见网格中的序数**
// 依次错开,避免完全重叠。
// ⚠️ 旧实现用 `laneIndex` 错开 —— 上百个网格时 `laneIndex` 很大,
// `16 + laneIndex*28` 早就超过绘图区高度、被下面的夹取推到**右下角**
//(用户 2026-10-17 报告:“在当前范围内的网格开启实验性功能,面板首次出现在右下角”)。
// 改用**可见序数**:它的量级只有一屏的网格数(约 20),再夹到“面板上沿不超过绘图区
// 中间偏下”以内,于是面板始终停**右上区域**,同时保留“依次错开”的观感。
const double storedX = meanFilter ? lane.meanFilterPanel.x
: (weakPeak ? lane.weakPeakPanel.x
: (freeMarker ? lane.freeMarkerPanel.x
: (centerAxis ? lane.edgeCenterPanel.x
: lane.edgePanel.x)));
const double storedY = meanFilter ? lane.meanFilterPanel.y
: (weakPeak ? lane.weakPeakPanel.y
: (freeMarker ? lane.freeMarkerPanel.y
: (centerAxis ? lane.edgeCenterPanel.y
: lane.edgePanel.y)));
int openPanelOrdinal = 0;
{
// ⚠️ 错开量用「**当前真正显示出来的面板**中排在本面板之前的个数」:
// * 第一个打开的面板 ⇒ 0 ⇒ 正好贴在右上角(用户 2026-10-17 要求“应该在右上角”);
// * 后续面板依次下移 28 逻辑像素,避免与已打开的面板完全重叠;
// * 遍历顺序固定为(网格下标 → 面板种类)⇒ 每个面板的槽位稳定、与帧无关。
// ⚠️ 不能用 `laneIndex`(上百个网格时会把面板推到右下角);
// ⚠️ 也不能只看 `*Panel.open`:该标志**默认就是 true**,必须同时看功能是否启用
// (与 `EdgePanelLayoutFor` 的 `enabled && open` 判定一致)。
const auto shown = [](const Lane& lane, EdgePanelKind panelKind) -> bool
{
switch (panelKind)
{
case EdgePanelKind::COUNT:
return lane.edgeCount && lane.edgePanel.open;
case EdgePanelKind::CENTER:
return lane.edgeCenter && lane.edgeCenterPanel.open;
case EdgePanelKind::MEAN:
return lane.meanFilter && lane.meanFilterPanel.open;
case EdgePanelKind::PEAK:
return lane.weakPeak && lane.weakPeakPanel.open;
case EdgePanelKind::MARK:
return lane.freeMarker && lane.freeMarkerPanel.open;
}
return false;
};
static const EdgePanelKind kOrder[5] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER,
EdgePanelKind::MEAN, EdgePanelKind::PEAK,
EdgePanelKind::MARK};
int openSeen = 0;
bool found = false;
for (std::size_t lane = 0; lane < m_lanes.size() && !found; ++lane)
{
const Lane& tabLane = Tab().Lanes()[lane];
for (const EdgePanelKind panelKind : kOrder)
{
if (!shown(tabLane, panelKind))
{
continue;
}
if (lane == static_cast<std::size_t>(laneIndex) && panelKind == kind)
{
found = true;
break;
}
++openSeen;
}
}
openPanelOrdinal = found ? openSeen : 0;
}
// 错开量夹到“面板下沿不超过绘图区 45% 高度”以内 ⇒ 首现位置永远在右上区域。
const float maxStagger = std::max(0.0f, m_plotRect.Height * 0.45f - height - 16.0f * m_scale);
const float stagger =
std::min(static_cast<float>(openPanelOrdinal) * 28.0f * m_scale, maxStagger);
float left = (storedX >= 0.0)
? static_cast<float>(storedX)
: (m_plotRect.X + m_plotRect.Width - width - 16.0f * m_scale);
float top = (storedY >= 0.0) ? static_cast<float>(storedY)
: (m_plotRect.Y + 16.0f * m_scale + stagger);
// 面板始终限制在绘图区内(用户明确要求"可以在右侧的整个绘制区域拖动")。
left = std::max(m_plotRect.X + 2.0f * m_scale,
std::min(left, m_plotRect.X + m_plotRect.Width - width - 2.0f * m_scale));
top = std::max(m_plotRect.Y + 2.0f * m_scale,
std::min(top, m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale));
layout.panel = gd::RectF(left, top, width, height);
// 只有标题栏可以拖拽;✕ 位于标题栏右端,命中时优先按“关闭按钮”处理。
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 + kEdgePanelDisclosureWidth * m_scale,
top + (titleHeight - kLaneBadgeHeight * m_scale) * 0.5f, kLaneBadgeWidth * m_scale,
kLaneBadgeHeight * m_scale);
// 折起状态:到此为止(内容区、按钮、滚动条全部为空)。
if (collapsed)
{
return layout;
}
if (listStyle)
{
// 「均值滤波 / 中心轴边沿计数」:可滚动内容区
//(逐信号的信号色叠底条目栏 + 展开后的统计行与两行滑块)+(均值滤波)底部按钮。
const float buttonRowHeight = kEdgePanelButtonHeight * m_scale;
if (hasButton)
{
layout.button = gd::RectF(left + pad, top + height - pad - buttonRowHeight,
std::max(0.0f, width - pad * 2.0f), buttonRowHeight);
}
const float groupRow = kEdgePanelMeanRowHeight * m_scale;
const float sliderRow = kWeakPeakSliderRowHeight * m_scale;
const float scrollW = kFreeMarkerScrollWidth * m_scale;
const float viewHeight =
std::max(0.0f, height - titleHeight - pad * 3.0f - buttonRowHeight);
const bool scrollable = meanContentHeight > viewHeight + 0.5f;
const float maxScroll = std::max(0.0f, meanContentHeight - viewHeight);
const float storedScroll = meanFilter ? lane.meanFilterScroll : lane.edgeCenterScroll;
const float scroll = std::max(0.0f, std::min(storedScroll, 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 = meanContentHeight;
layout.scrollOffset = scroll;
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 / meanContentHeight));
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);
}
// 两行滑块的列宽(与 `EdgePanelWidth` 同一组算式 ⇒ 布局与宽度不会漂移)。
const float labelColumn =
std::max(UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterPointsSliderLabel),
UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterIterationsSliderLabel));
const float valueColumn =
std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"),
UiMeasureTextWidth(m_fonts->monoSmall, L"1000"));
const float resetSize = kWeakPeakResetButtonSize * m_scale;
const float resetGap = kWeakPeakResetGap * m_scale;
const float trackHeight = kWeakPeakTrackHeight * m_scale;
const float closeSize = kEdgePanelMeanCheckSize * m_scale;
const float contentLeft = layout.content.X;
const float trackLeft = contentLeft + labelColumn + 6.0f * m_scale;
const float resetLeft = contentLeft + innerWidth - resetSize;
const float trackWidth =
std::max(10.0f * m_scale,
(resetLeft - resetGap - valueColumn - 6.0f * m_scale) - trackLeft);
float rowY = layout.content.Y - scroll;
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || !resolved.visible)
{
continue;
}
const Lane::MeanFilterItem* item = meanFilter ? lane.FindMeanFilterItem(ref)
: lane.FindEdgeCenterItem(ref);
const bool checked = (item != nullptr);
layout.rowRefs.push_back(ref);
// 组头(信号色叠底):整行宽度,左侧三角(仅勾选时)+ 名称,右端复选框。
layout.rowHeaders.push_back(
gd::RectF(contentLeft, rowY, innerWidth, groupRow));
layout.rowNameRects.push_back(gd::RectF(contentLeft, rowY, innerWidth, groupRow));
layout.rowTriangles.push_back(
checked ? gd::RectF(contentLeft + 3.0f * m_scale,
rowY + (groupRow - kEdgePanelRowTriangleSize * m_scale) * 0.5f,
kEdgePanelRowTriangleSize * m_scale,
kEdgePanelRowTriangleSize * m_scale)
: gd::RectF());
layout.rowChecks.push_back(
gd::RectF(contentLeft + innerWidth - closeSize - 4.0f * m_scale,
rowY + (groupRow - closeSize) * 0.5f, closeSize, closeSize));
layout.rowPoints.push_back(gd::RectF());
layout.rowIterations.push_back(gd::RectF());
layout.rowStats.push_back(gd::RectF());
rowY += groupRow;
// 参数行:只有勾选且未收起时才有几何(未勾选的行两者都是空矩形)。
if (!checked || item->collapsed)
{
layout.rowLevelTracks.push_back(gd::RectF());
layout.rowLevelValues.push_back(gd::RectF());
layout.rowLevelResets.push_back(gd::RectF());
layout.rowIterTracks.push_back(gd::RectF());
layout.rowIterValues.push_back(gd::RectF());
layout.rowIterResets.push_back(gd::RectF());
continue;
}
// 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。
if (centerAxis && !layout.rowStats.empty())
{
layout.rowStats.back() = gd::RectF(contentLeft, rowY, innerWidth, groupRow);
rowY += groupRow;
}
const gd::RectF pointsTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f,
trackWidth, trackHeight);
layout.rowLevelTracks.push_back(pointsTrack);
layout.rowLevelValues.push_back(
gd::RectF(pointsTrack.X + pointsTrack.Width + 6.0f * m_scale, rowY, valueColumn,
sliderRow));
layout.rowLevelResets.push_back(
gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize));
rowY += sliderRow;
const gd::RectF iterTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f,
trackWidth, trackHeight);
layout.rowIterTracks.push_back(iterTrack);
layout.rowIterValues.push_back(
gd::RectF(iterTrack.X + iterTrack.Width + 6.0f * m_scale, rowY, valueColumn,
sliderRow));
layout.rowIterResets.push_back(
gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize));
rowY += sliderRow;
}
}
else if (itemized)
{
const float buttonRowHeight = kEdgePanelButtonHeight * m_scale;
if (hasButton)
{
// 底部按钮与面板下边界之间留出与左右、与内容相同的 `pad` 间距
//(原先按钮行直接贴着下边框,视觉上与其它元素不协调);
// 面板高度已含这份 pad,因此这里只上移按钮行即可。
layout.button = gd::RectF(left + pad, top + height - pad - buttonRowHeight,
std::max(0.0f, width - pad * 2.0f), buttonRowHeight);
}
// 逐信号条目:第一排 = 名称 + 复选框(复选框靠右);
// 参数部分:MEAN / CENTER = 一行两个输入框(中心轴再多一行统计结果);
// PEAK = 两行滑块(每个信号各有自己的等级与迭代次数)。
const float checkSize = kEdgePanelMeanCheckSize * m_scale;
const float fieldHeight = kEdgePanelMeanEditHeight * m_scale;
const float halfWidth = std::max(40.0f, (width - pad * 2.0f - 8.0f * m_scale) * 0.5f);
// 滑块行列宽(与 `EdgePanelWidth` 使用同一组算式,避免布局与宽度计算漂移)。
const float weakPeakLabelWidth = WeakPeakLabelColumnWidth(m_fonts->uiSmall);
const float weakPeakValueWidth = WeakPeakValueColumnWidth(m_fonts->monoSmall);
const float weakPeakResetSize = kWeakPeakResetButtonSize * m_scale;
const float weakPeakResetGap = kWeakPeakResetGap * m_scale;
const float weakPeakTrackHeight = kWeakPeakTrackHeight * m_scale;
const float weakPeakTrackLeft = left + pad + weakPeakLabelWidth + 6.0f * m_scale;
const float weakPeakTrackWidth =
std::max(10.0f * m_scale,
(left + width - pad - weakPeakValueWidth - weakPeakResetGap -
weakPeakResetSize - 6.0f * m_scale) -
weakPeakTrackLeft);
float rowY = top + titleHeight + pad;
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || !resolved.visible)
{
continue;
}
layout.rowRefs.push_back(ref);
layout.rowChecks.push_back(gd::RectF(
left + width - pad - checkSize, rowY + (meanRowHeight - checkSize) * 0.5f, checkSize,
checkSize));
const bool checked = meanFilter ? lane.MeanFilterEnabled(ref)
: (weakPeak ? lane.WeakPeakEnabled(ref)
: lane.EdgeCenterEnabled(ref));
if (weakPeak)
{
// 每个被勾选的信号各有两行滑块:调整幅度(-400% – +400%)与迭代次数(1 … 上限)。
if (checked)
{
const float levelRowTop = rowY + meanRowHeight;
const float iterRowTop = levelRowTop + weakPeakSliderRow;
const gd::RectF levelTrack(
weakPeakTrackLeft,
levelRowTop + (weakPeakSliderRow - weakPeakTrackHeight) * 0.5f,
weakPeakTrackWidth, weakPeakTrackHeight);
const gd::RectF iterTrack(
weakPeakTrackLeft,
iterRowTop + (weakPeakSliderRow - weakPeakTrackHeight) * 0.5f,
weakPeakTrackWidth, weakPeakTrackHeight);
layout.rowLevelTracks.push_back(levelTrack);
layout.rowLevelValues.push_back(gd::RectF(
levelTrack.X + levelTrack.Width + 6.0f * m_scale, levelRowTop,
weakPeakValueWidth, weakPeakSliderRow));
layout.rowIterTracks.push_back(iterTrack);
layout.rowIterValues.push_back(gd::RectF(
iterTrack.X + iterTrack.Width + 6.0f * m_scale, iterRowTop,
weakPeakValueWidth, weakPeakSliderRow));
// 每行滑块右侧的**复位按钮**(与数值列同一右边界,保证两行上下对齐)。
const float resetX = levelTrack.X + levelTrack.Width + 6.0f * m_scale +
weakPeakValueWidth + weakPeakResetGap;
layout.rowLevelResets.push_back(
gd::RectF(resetX, levelRowTop + (weakPeakSliderRow - weakPeakResetSize) * 0.5f,
weakPeakResetSize, weakPeakResetSize));
layout.rowIterResets.push_back(
gd::RectF(resetX, iterRowTop + (weakPeakSliderRow - weakPeakResetSize) * 0.5f,
weakPeakResetSize, weakPeakResetSize));
}
else
{
layout.rowLevelTracks.push_back(gd::RectF());
layout.rowLevelValues.push_back(gd::RectF());
layout.rowLevelResets.push_back(gd::RectF());
layout.rowIterTracks.push_back(gd::RectF());
layout.rowIterValues.push_back(gd::RectF());
layout.rowIterResets.push_back(gd::RectF());
}
layout.rowPoints.push_back(gd::RectF());
layout.rowIterations.push_back(gd::RectF());
layout.rowStats.push_back(gd::RectF());
rowY += meanRowHeight;
if (checked)
{
rowY += weakPeakSliderRow * 2.0f;
}
continue;
}
if (checked && itemHasParams)
{
const float editTop = rowY + meanRowHeight;
layout.rowPoints.push_back(
gd::RectF(left + pad, editTop, halfWidth, fieldHeight));
layout.rowIterations.push_back(
gd::RectF(left + pad + halfWidth + 8.0f * m_scale, editTop, halfWidth, fieldHeight));
if (centerAxis)
{
layout.rowStats.push_back(
gd::RectF(left + pad, editTop + fieldHeight, width - pad * 2.0f,
kEdgePanelMeanRowHeight * m_scale));
}
else
{
layout.rowStats.push_back(gd::RectF());
}
}
else
{
layout.rowPoints.push_back(gd::RectF());
layout.rowIterations.push_back(gd::RectF());
layout.rowStats.push_back(gd::RectF());
}
rowY += meanRowHeight;
if (checked && itemHasParams)
{
rowY += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale;
if (centerAxis)
{
rowY += kEdgePanelMeanRowHeight * m_scale;
}
}
}
}
if (freeMarker)
{
// 「自由标记」面板:**只列出确实有固定点的信号**,每个信号一个**分组栏**
//(信号色托底 + 左侧展开/收起三角 + 信号名 + 右侧「N 标记点」与 ✕),
// 下面逐行列出它的固定点(组内按时间升序 == 从上到下),行尾再带一个 ✕。
// 内容区超高时右侧出现滚动条;底部按钮与「标记透明度」滑块**固定**不滚动。
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;
// 「标记透明度」独立于「自由标记点选取」:**总是**显示在按钮上方。
const float sliderRow = kFreeMarkerSliderRowHeight * m_scale;
const float scrollW = kFreeMarkerScrollWidth * m_scale;
// 「显示增量 / 显示枚举文本」两个开关按钮:独占底部最后一行(左右各半个宽度)。
const float switchRow = kEdgePanelButtonHeight * m_scale;
const float buttonGap = kFreeMarkerButtonGap * m_scale;
// 底部固定区(自上而下):标记透明度滑块 → 「自由标记点选取」→ 两个显示开关。
{
const float switchTop = top + height - pad - switchRow;
const float halfWidth =
std::max(0.0f, (width - pad * 2.0f - buttonGap) * 0.5f);
layout.showDelta = gd::RectF(left + pad, switchTop, halfWidth, switchRow);
layout.showEnum =
gd::RectF(left + pad + halfWidth + buttonGap, switchTop, halfWidth, switchRow);
// 「自由标记点选取」切换按钮(整行宽度)。
layout.button = gd::RectF(left + pad, switchTop - buttonGap - markerButtonHeight,
std::max(0.0f, width - pad * 2.0f), markerButtonHeight);
}
{
// 「标记透明度」行:与信号样式区的「透明度」行同一套排版
//(左侧标签 + 中间轨道 + 右侧百分比),位置固定在按钮上方。
// ⚠️ 无论是否启用「自由标记点选取」都显示(用户 2026-10-21 要求)。
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);
}
// 内容区高度(面板高度算式里的 `view` 部分)与"内容总高"决定是否需要滚动条。
const float viewHeight = std::max(
0.0f, height - titleHeight - pad * 3.0f - markerButtonHeight - sliderRow -
buttonGap - switchRow);
float totalContent = 0.0f;
std::vector<std::size_t> order;
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;
}
totalContent += groupRow;
if (!lane.FreeMarkerGroupCollapsed(ref))
{
totalContent += headerRow + 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 < 6; ++column)
{
const bool omitted = (column == EdgePanelLayout::MARKER_COL_ENUM) && !hasEnum;
// ⚠️ 列宽取「缺省列宽」「实测内容宽度」与**表头列标题宽度**三者的大者:
// 面板里不再出现省略号(“时间间隔”四个字比缺省列宽 78 还宽)。
const float columnWidth =
omitted ? 0.0f
: 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;
const float contentTop = layout.content.Y;
float rowY = contentTop - 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);
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,
barTop + (groupRow - kEdgePanelRowTriangleSize * m_scale) * 0.5f,
kEdgePanelRowTriangleSize * m_scale,
kEdgePanelRowTriangleSize * m_scale);
group.close = gd::RectF(layout.content.X + innerWidth - closeSize - 2.0f * m_scale,
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)
{
// 只登记"在可视内容区里"的分组栏与条目(滚出视界的条目不得抢走
// 底部按钮 / 滑块的点击)。
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;
}
rowY += headerRow;
for (std::size_t orderPosition = 0; orderPosition < order.size(); ++orderPosition)
{
const std::size_t itemIndex = order[orderPosition];
EdgePanelLayout::MarkerRow markerRow;
markerRow.itemIndex = itemIndex;
// 「序号」列:组内按时间升序从 1 开始(每组 = 一个信号)。
markerRow.serial = orderPosition + 1;
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 > contentTop &&
markerRow.rect.Y < contentBottom && !coveredByPinned(markerRow.rect))
{
layout.markerRows.push_back(markerRow);
}
rowY += itemRow;
}
}
}
return layout;
}
bool PlotView::FreeMarkerValue(const Lane& lane, const Lane::FreeMarkerItem& item, double& value,
double& enumValue) const
{
if (m_dataSet == nullptr)
{
return false;
}
const ResolvedSignal resolved = ResolveSignal(item.ref);
if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr ||
!resolved.visible)
{
return false;
}
// 固定点存的是**显示时刻**,因此要用 `SampleDisplayedSignalAt`(内部处理时间偏移与
// 水平镜像)。散点模式下不插值,取最近采样点,与光标读数的规则一致。
const bool interpolate = (resolved.style->mode != DrawMode::SCATTER);
double sampled = 0.0;
if (!SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style, item.time,
interpolate, sampled) ||
!std::isfinite(sampled))
{
return false;
}
(void)lane;
enumValue = sampled; // 枚举按**原值**查
value = ApplyDrawTransform(*resolved.style, sampled); // 读数与画出来的波形一致
return std::isfinite(value);
}
bool PlotView::FreeMarkerDelta(const Lane& lane, std::size_t itemIndex, double value, bool& hasPrev,
double& delta, double& timeDelta) const
{
hasPrev = false;
delta = 0.0;
timeDelta = 0.0;
if (itemIndex >= lane.freeMarkerItems.size())
{
return false;
}
const Lane::FreeMarkerItem& item = lane.freeMarkerItems[itemIndex];
std::vector<std::size_t> order;
SortedFreeMarkerIndices(lane, item.ref, order);
// 找到自己在**排序后序列**里的位置;前一个即「左边最近的那个固定点」。
std::size_t position = order.size();
for (std::size_t index = 0; index < order.size(); ++index)
{
if (order[index] == itemIndex)
{
position = index;
break;
}
}
if (position == order.size() || position == 0)
{
return true; // 最左边(或找不到):没有增量
}
const Lane::FreeMarkerItem& previous = lane.freeMarkerItems[order[position - 1]];
double previousValue = 0.0;
double previousEnum = 0.0;
if (!FreeMarkerValue(lane, previous, previousValue, previousEnum))
{
return true; // 前一个点没有有效取值:不显示增量(避免编造一个数)
}
hasPrev = true;
delta = value - previousValue;
timeDelta = item.time - previous.time;
return true;
}
const std::vector<PlotView::FreeMarkerContentEntry>& PlotView::FreeMarkerContents(int laneIndex) const
{
static const std::vector<FreeMarkerContentEntry> kNoEntries;
if (m_tab == nullptr || laneIndex < 0 ||
laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return kNoEntries;
}
const Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
if (static_cast<std::size_t>(laneIndex) >= m_freeMarkerContent.size())
{
m_freeMarkerContent.resize(static_cast<std::size_t>(laneIndex) + 1);
}
FreeMarkerContentCache& cache = m_freeMarkerContent[static_cast<std::size_t>(laneIndex)];
// 缓存键:只混入**与视界无关**的输入(条目、信号样式、数据地址与长度)。
// ⚠️ 绝不能混入 `ViewMin/ViewMax`、鼠标位置或网格几何 —— 否则平移视界、
// 拖动时间光标时缓存永远命中不了,又回到"每帧对每个固定点采样一次"。
std::uint64_t key = 1469598103934665603ull;
const auto mix = [&key](std::uint64_t value) {
key ^= value;
key *= 1099511628211ull;
};
const auto mixFloat = [&mix](float value) {
std::uint32_t bits = 0;
std::memcpy(&bits, &value, sizeof(bits));
mix(static_cast<std::uint64_t>(bits));
};
const auto mixDouble = [&mix](double value) {
std::uint64_t bits = 0;
std::memcpy(&bits, &value, sizeof(bits));
mix(bits);
};
mix(static_cast<std::uint64_t>(lane.freeMarkerItems.size()));
// ⚠️ 两个显示开关只影响胶囊文本,但同样属于“与视界无关的输入”,必须进键。
mix(lane.freeMarkerShowDelta ? 1ull : 0ull);
mix(lane.freeMarkerShowEnum ? 1ull : 0ull);
for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems)
{
mix(static_cast<std::uint64_t>(static_cast<std::uint32_t>(item.ref.groupIndex)));
mix(static_cast<std::uint64_t>(static_cast<std::uint32_t>(item.ref.signalIndex)));
mixDouble(item.time);
const ResolvedSignal resolved = ResolveSignal(item.ref);
if (resolved.data == nullptr || resolved.style == nullptr)
{
mix(0ull);
continue;
}
mix(reinterpret_cast<std::uint64_t>(resolved.data));
mix(static_cast<std::uint64_t>(resolved.data->values.size()));
mix(resolved.visible ? 1ull : 2ull);
mix(static_cast<std::uint64_t>(resolved.style->color));
mixFloat(resolved.style->opacity);
mixFloat(resolved.style->gain);
mixFloat(resolved.style->offsetY);
mixDouble(resolved.style->offsetTime);
mix(static_cast<std::uint64_t>(static_cast<int>(resolved.style->mode)));
mix((resolved.style->absolute ? 1ull : 0ull) | (resolved.style->mirrorVertical ? 2ull : 0ull) |
(resolved.style->mirrorHorizontal ? 4ull : 0ull));
// ⚠️ 枚举表的内容决定「枚举文本」列与胶囊里的 `= <文本>`(以及缺省时的 `N/A`):
// 旧实现只混了信号指针与采样数量 ⇒ 重新加载/替换枚举表后仍贴回旧文本
//(离屏探针实测:加上枚举表后条目里仍是 `N/A`)。这里把表的规模与文本数组
// 的地址一并入键 —— 只比较 `size()` 不足以区分“换成另一张同样长度的表”。
mix(static_cast<std::uint64_t>(resolved.data->enumText.values.size()));
mix(static_cast<std::uint64_t>(resolved.data->enumText.texts.size()));
mix(static_cast<std::uint64_t>(resolved.data->enumText.rangeMin.size()));
mix(static_cast<std::uint64_t>(
reinterpret_cast<std::uintptr_t>(resolved.data->enumText.texts.data())));
}
if (cache.key != key)
{
cache.key = key;
cache.entries.clear();
cache.entries.reserve(lane.freeMarkerItems.size());
for (std::size_t itemIndex = 0; itemIndex < lane.freeMarkerItems.size(); ++itemIndex)
{
const Lane::FreeMarkerItem& item = lane.freeMarkerItems[itemIndex];
FreeMarkerContentEntry entry;
entry.itemIndex = itemIndex;
double value = 0.0;
double enumValue = 0.0;
const bool valueOk = FreeMarkerValue(lane, item, value, enumValue);
bool hasPrev = false;
double delta = 0.0;
double timeDelta = 0.0;
if (valueOk)
{
FreeMarkerDelta(lane, itemIndex, value, hasPrev, delta, timeDelta);
}
const ResolvedSignal resolved = ResolveSignal(item.ref);
const std::wstring* enumPtr =
(resolved.data != nullptr) ? resolved.data->enumText.Find(enumValue) : nullptr;
entry.valid = valueOk;
entry.value = value;
entry.color = (resolved.style != nullptr) ? StyleColor(*resolved.style) : 0u;
entry.capsuleText = FreeMarkerValueText(value, lane.freeMarkerShowDelta, hasPrev, delta,
lane.freeMarkerShowEnum, enumPtr);
entry.valueText = valueOk ? FormatValue(value) : std::wstring(kFreeMarkerNotAvailable);
// ⚠️ 枚举列一旦存在(面板里有任一固定点带枚举表),**没有**枚举文本的点必须显示
// `N/A` 而不是空着(用户 2026-10-23 要求);数值对不上任何枚举项也落在这一分支。
entry.enumText = (enumPtr != nullptr) ? *enumPtr : std::wstring(kFreeMarkerNotAvailable);
entry.deltaText = (valueOk && hasPrev) ? FormatSignedValue(delta)
: std::wstring(kFreeMarkerNotAvailable);
entry.timeText = FreeMarkerTimeText(item.time);
entry.deltaTimeText = FreeMarkerDeltaText(valueOk && hasPrev, timeDelta);
cache.entries.push_back(std::move(entry));
}
}
return cache.entries;
}
bool PlotView::FreeMarkerLabelRects(int laneIndex, std::size_t itemIndex, gd::RectF& label,
gd::RectF& close, std::wstring& text) const
{
label = gd::RectF();
close = gd::RectF();
text.clear();
if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 ||
laneIndex >= static_cast<int>(m_tab->Lanes().size()) ||
laneIndex >= static_cast<int>(m_lanes.size()))
{
return false;
}
const Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
if (itemIndex >= lane.freeMarkerItems.size())
{
return false;
}
const Lane::FreeMarkerItem& item = lane.freeMarkerItems[itemIndex];
// 取值与文本来自**与视界无关**的内容缓存(大量固定点 + 拖光标/平移时不重复采样)。
const std::vector<FreeMarkerContentEntry>& contents = FreeMarkerContents(laneIndex);
if (itemIndex >= contents.size() || !contents[itemIndex].valid)
{
return false;
}
const FreeMarkerContentEntry& entry = contents[itemIndex];
const double value = entry.value;
// 超出视界**直接不显示**(用户明确要求:不按常规数据点那样贴边定格 + 三角指示)。
const float rawX = TimeToX(item.time);
if (rawX < m_plotRect.X - 0.5f || rawX > m_plotRect.X + m_plotRect.Width + 0.5f)
{
return false;
}
const LaneVisual& visual = m_lanes[static_cast<std::size_t>(laneIndex)];
const float clipTop = std::max(visual.rect.Y, m_plotRect.Y);
const float clipBottom =
std::min(visual.rect.Y + visual.rect.Height, m_plotRect.Y + m_plotRect.Height);
const float rawY = ValueToY(value, visual);
if (!std::isfinite(rawY) || rawY < clipTop - 0.5f || rawY > clipBottom + 0.5f)
{
return false; // 纵向越界同样直接不显示
}
// 枚举文本按**采样原值**查找(与光标读数完全一致)。
text = entry.capsuleText;
// ✕ 按钮 = **与取值胶囊同高的正方形**(用户明确要求:长宽都等于胶囊的上下高度)。
// ⚠️ 宽度多加 2px 余量:`DrawTextW` 的 `DT_END_ELLIPSIS` 有时会因为亚像素/字形
// 伸部而把“刚好装满”的文本截成省略号(用户报告枚举文本被 … 缺省)。
const float textWidth = UiMeasureTextWidth(m_fonts->monoSmall, text) + 12.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;
if (left + totalWidth > m_plotRect.X + m_plotRect.Width - 2.0f * m_scale)
{
left = rawX - 6.0f * m_scale - totalWidth;
}
left = std::max(m_plotRect.X + 2.0f * m_scale,
std::min(left, m_plotRect.X + m_plotRect.Width - totalWidth - 2.0f * m_scale));
float top = rawY - textHeight * 0.5f;
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, closeSize, closeSize);
return true;
}
PlotView::FreeMarkerProbe PlotView::ProbeFreeMarker(int mouseX, int mouseY) const
{
FreeMarkerProbe probe;
if (m_tab == nullptr || m_dataSet == nullptr ||
!UiPointInRect(m_plotRect, static_cast<float>(mouseX), static_cast<float>(mouseY)))
{
return probe;
}
const int laneIndex = LaneAtY(mouseY);
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()) ||
laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return probe;
}
const Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
if (!lane.freeMarker)
{
return probe;
}
// ⚠️⚠️ **未开启「自由标记点选取」时鼠标就是普通鼠标**(用户 2026-10-23 明确要求):
// 不预览数据点、不画纵向光标、按下/拖动完全走常规语义(平移 / 框选 / 拖信号),
// 也不会固定任何数据点。所有自由标记的探测都从这一句的“早退”开始。
if (!lane.freeMarkerCursor)
{
return probe;
}
const LaneVisual& visual = m_lanes[static_cast<std::size_t>(laneIndex)];
// 「自由标记点选取」已开启:网格内始终跟随鼠标画纵向光标(见 `PaintFreeMarker`)。
const bool cursorMode = true;
probe.cursorMode = cursorMode;
probe.laneIndex = laneIndex;
probe.time = XToTime(static_cast<float>(mouseX));
probe.x = static_cast<float>(mouseX);
// 命中容差 = **与"常规状态下触发信号曲线高亮"完全一致**(`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();
double bestTime = probe.time;
double bestValue = 0.0;
double bestEnum = 0.0;
float bestY = 0.0f;
// 胜出信号的轴 / 数据 / 时间偏移:循环结束后**只为它**做一次「自动点线」稀疏判定
// (判定代价与信号数量成正比,而它只影响一个小圆环 ⇒ 绝不能每个信号都算)。
const TimeAxis* bestAxis = nullptr;
const SignalData* bestData = nullptr;
double bestOffsetTime = 0.0;
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr ||
!resolved.visible)
{
continue;
}
// 「自动点线」判据只用于决定要不要画“已吸附”圆环 ⇒ **不在循环里逐个信号算**:
// 它的代价是“每个小格两次二分”(视界 50 格 × 上百信号 = 数万次比较/帧),
// 而**只有最后胜出的那一个信号**需要它(用户 2026-10-30 报告的卡顿根因之一)。
// 见循环结束后的统一判定。
// ---- ① 取“鼠标到这条曲线”的距离(复用绘制阶段的折线顶点)----
float nearestX = fx;
double distance = std::numeric_limits<double>::infinity();
const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, visual);
if (cached != nullptr && cached->points.size() >= 2)
{
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)
{
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;
}
}
}
}
}
// ---- ② 决定标注时刻 ----
// ⚠️⚠️ 用户 2026-10-27 明确要求:**总是**预览「网格内离鼠标较近的那个信号」的数据点
//(同一时刻下 A 信号比 B 信号离鼠标更近 ⇒ 预览 A、单击固定的也是 A),
// **不要求鼠标贴近曲线**。
// 旧实现在两种距离都超出容差时直接 `continue`,于是「网格里只有一条信号、鼠标离曲线
// 较远」时既没有预览点,也无法用左键固定(用户报告的核心问题)。
//
// 因此这里准备**两个候选**,取更近的那一个:
// * 曲线候选 = 鼠标到**画出来的折线**的垂直距离(阶跃的竖直跳变段只有它能命中);
// * 采样候选 = 同一时刻(鼠标时刻吸附到最近采样)该采样点在屏幕上的**纵向距离**。
// 两者都不再设“必须命中”的门槛;门槛(`tolerance`)只用来决定是否画「已吸附」圆环。
const bool interpolate = (resolved.style->mode != DrawMode::SCATTER);
double time = 0.0;
double sampled = 0.0;
bool candidateOk = false;
// (a) 曲线候选
{
double candidateTime = XToTime(nearestX);
if (snapToNearestSample(*resolved.axis, *resolved.data, *resolved.style, candidateTime,
candidateTime))
{
double candidateSample = 0.0;
if (SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style,
candidateTime, interpolate, candidateSample) &&
std::isfinite(candidateSample))
{
time = candidateTime;
sampled = candidateSample;
candidateOk = true;
}
}
}
// (b) 采样候选:同一时刻的纵向距离(“哪个信号离鼠标更近”的主判据)
{
double candidateTime = probe.time;
if (snapToNearestSample(*resolved.axis, *resolved.data, *resolved.style, candidateTime,
candidateTime))
{
double candidateSample = 0.0;
if (SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style,
candidateTime, interpolate, candidateSample) &&
std::isfinite(candidateSample))
{
const double candidateValue =
ApplyDrawTransform(*resolved.style, candidateSample);
if (std::isfinite(candidateValue))
{
// ⚠️⚠️ 距离必须包含**横向**分量(用户 2026-10-30 报告):
// 只看纵向距离时,一个“单数据点信号”只要它的点在鼠标的**高度**附近,
// 就会在整幅宽度内一直胜出 —— 光标被它“吸住”,无法标注另一个常规信号。
// 改为鼠标到**该采样点屏幕位置**的欧氏距离后:稠密信号的横向分量≈ 0(仍等价于
// “纵向最近”);单点/稀疏信号只有鼠标真的靠近它时才会被选中。
const double pointX = static_cast<double>(TimeToX(candidateTime));
const double dx = pointX - static_cast<double>(fx);
const double vertical = std::fabs(
static_cast<double>(ValueToY(candidateValue, visual)) -
static_cast<double>(fy));
const double horizontal = std::fabs(dx);
const double sampleDistance = std::sqrt(vertical * vertical + horizontal * horizontal);
if (!candidateOk || sampleDistance < distance)
{
distance = sampleDistance;
nearestX = static_cast<float>(pointX);
time = candidateTime;
sampled = candidateSample;
candidateOk = true;
}
}
}
}
}
if (!candidateOk)
{
continue;
}
// 固定点必须落在真实采样时刻上(两个候选都已吸附到采样点)。
const double value = ApplyDrawTransform(*resolved.style, sampled);
if (!std::isfinite(value))
{
continue;
}
const float y = ValueToY(value, visual);
if (distance >= bestDistance)
{
continue;
}
bestDistance = distance;
bestTime = time;
bestValue = value;
bestEnum = sampled;
bestY = y;
bestAxis = resolved.axis;
bestData = resolved.data;
bestOffsetTime = resolved.style->offsetTime;
probe.ref = ref;
}
if (!probe.ref.IsValid())
{
// 网格内没有可标注的数据点:「自由标记点选取」模式下仍要画纵向线(`valid` 为真)。
probe.valid = true;
return probe;
}
probe.valid = true;
probe.time = bestTime;
// ⚠️ 纵向光标**画在鼠标所在的 x**(用户 2026-10-30 明确要求:纵向光标跟随鼠标,
// 被标注的数据点才是"离鼠标最近"的那个)。旧实现把竖线画在吸附后的采样时刻,
// 于是稀疏/单点信号会让整条竖线"跳"到数据点上,观感像光标被吸走了。
probe.x = fx;
probe.pointX = TimeToX(bestTime);
probe.value = bestValue;
probe.enumValue = bestEnum;
probe.y = bestY;
// 「已吸附」圆环只在「自动点线」稀疏判据成立时画(密采样信号里吸附量 < 1 像素,
// 圆环会一直挂着反而像画错了)。**只对胜出的这一个信号算**(见循环里的说明)。
if (bestAxis != nullptr && bestData != nullptr)
{
probe.snapped = MinorCellSparse(*bestAxis, *bestData, bestOffsetTime);
}
return probe;
}
} // namespace jdi