Files
DeepSeek V4.1 Flash ea704b03ea fix(plotview): 边沿计数表格归位、统计面板悬停时持续更新、加载不再被统计阻塞、统计提速
1) 表格改造归位(上一轮误做在中心轴上)
- 「边沿计数」改为表格:表头(信号名/上升沿计数/下降沿计数)、每信号一行、
  行底色取信号配色、三列对齐、内容可滚动(Lane::edgeCountScroll)。
- 「中心轴边沿计数」恢复为可滚动分组列表(条目栏 + 复选框 + 展开后统计行 + 两行滑块),
  Lane::EnsureEdgeCenterItem 恢复默认展开。
- EdgePanelWidth / EdgePanelLayoutFor / PaintEdgeCursor 统一 listStyle / countTable 判定;
  EdgeCenterTableHeader 更名为 EdgeCountTableHeader。

2) 鼠标持续移动时统计面板不更新
- 根因:交互态(拖拽或 140ms 内移动)把统计数字整段从面板缓存键中跳过,
  只要鼠标不停移动就永远命中旧缓存。
- 修法:扫描推进/完成时自增 m_edgeScanRevision,Paint 开头转为 m_panelStatsVersion
  (非交互帧立即同步,交互帧按 kEdgeScanStatsRefreshFrames=4 节流),
  PanelSlotKey 对 COUNT/CENTER 仅混入该版本号(O(1),不再逐信号查询)。

3) 统计进行中无法加载文件
- 新增 PlotView::SetLoadingBusy:加载期间统计时间预算归零、跳过浮动面板绘制、
  不再自请求扫描帧;Application::LoadFileAsync 进出各设置一次。实测加载 22~31ms。

4) 统计提速
- 扫描预算由固定采样对改为 QPC 时间片(非交互 6ms / 交互 1.5ms,每块检查超时),
  采样对配额改为按未完成状态数公平分摊并把上限提到 8M(交互 2M)。
- 真实 MF4-Record.MF4(283 信号)全部统计状态在 1 帧内完成;悬停+移动帧中位 2~3ms。

验证:离屏探针 p20/p20c/p20d/p21/p22/p23/p25 全绿(新增 p25 12 项);
make release / make debug 零 warning;Release 808,960 B。共享规则已同步到 Documents/agent_mem.md。
2026-11-02 16:15:40 +08:00

1622 lines
75 KiB
C++
Raw Permalink 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);
// 「均值滤波」「中心轴边沿计数」「弱峰调整」= 可滚动的分组列表
//(信号色叠底条目栏 + 右侧复选框 + 展开后的参数行);
// **「边沿计数」= 表格**(表头:信号名 / 上升沿计数 / 下降沿计数)。
// ⚠️ 旧式「逐条目」排版(无滚动的名称+参数行)已彻底删除:上百个信号时它会算出
// 上万像素高的面板,超出绘图区后**面板像素缓存一次都存不进去** ⇒ 每帧整块重画。
const bool listStyle = (centerAxis || meanFilter || weakPeak);
const bool countTable = (kind == EdgePanelKind::COUNT);
// 标题行 = “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 (listStyle)
{
// 条目 = **信号色叠底条目栏**(三角 + 名称 + 复选框)与参数行
//(标签 + 轨道 + 数值 + 复位按钮),右侧还要给滚动条留位。
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);
}
if (!countTable && !centerAxis)
{
continue; // 只有「边沿计数」表格 / 「中心轴」统计行需要量计数列宽
}
std::size_t rising = 0;
std::size_t falling = 0;
bool complete = true;
if (countTable)
{
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, EdgeCountCellText(rising, complete)),
pendingWidth));
statFallingWidth =
std::max(statFallingWidth,
UiMeasureTextWidth(m_fonts->monoSmall, EdgeCountCellText(falling, complete)));
}
else
{
// 「中心轴边沿计数」的统计行 = 「上升沿 N / 下降沿 M」**两列**(只量已勾选的条目)。
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);
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 (countTable)
{
// 「边沿计数」= **表格**(信号名 / 上升沿计数 / 下降沿计数):
// 宽度 = 名称列 + 两列计数(列宽取「表头文字」「实际数字」的较大者)+ 滚动条留位。
const float headerRising =
UiMeasureTextWidth(m_fonts->uiSmall, EdgeCountTableHeader(1));
const float headerFalling =
UiMeasureTextWidth(m_fonts->uiSmall, EdgeCountTableHeader(2));
const float risingColumn = std::max(headerRising, statRisingWidth) + 10.0f * m_scale;
const float fallingColumn = std::max(headerFalling, statFallingWidth) + 10.0f * m_scale;
const float gap = kEdgePanelStatColumnGap * m_scale;
width = std::max(width, kEdgePanelPad * 2.0f * m_scale +
nameColumnWidth + gap + risingColumn + gap + fallingColumn +
(kFreeMarkerScrollWidth + 4.0f) * m_scale);
}
else if (centerAxis && (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 + countsWidth);
}
if (listStyle)
{
// 两行滑块 = 「标签列 + 轨道(最小宽度)+ 数值列 + 复位按钮」(两行共用同一列宽),
// 右侧再给滚动条留位(上百个信号时内容必然超高)。
const float labelColumn =
std::max(UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterPointsSliderLabel),
UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterIterationsSliderLabel));
const float weakPeakLabelColumn =
std::max(UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakLevelLabel),
UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakIterationsSliderLabel));
const float valueColumn =
std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"),
UiMeasureTextWidth(m_fonts->monoSmall, L"-400%"));
const float sliderRowWidth = std::max(labelColumn, weakPeakLabelColumn) + 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 (countTable)
{
// 「边沿计数」表格:右侧要给内容滚动条留位(上百个信号时内容必然超高)。
width = std::max(width, kEdgePanelPad * 2.0f * m_scale +
(kFreeMarkerScrollWidth + 4.0f) * m_scale);
}
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-11-02 起走**表格**分支。
const bool listStyle = (meanFilter || centerAxis || weakPeak);
const bool countTable = (kind == EdgePanelKind::COUNT);
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 pad = kEdgePanelPad * m_scale;
// 「中心轴边沿计数」「均值滤波」「弱峰调整」共用同一套**可滚动列表**排布,
// 因此不再有独立的底部编辑行;「边沿计数」走独立的**表格**分支。
const float buttonHeight = hasButton ? (kEdgePanelButtonHeight * m_scale) : 0.0f;
// 面板底部(内容区之下)占的纵向空间:有按钮时 = pad + 按钮 + pad,无按钮时只有 pad。
const float footerHeight = hasButton ? (buttonHeight + pad) : 0.0f;
const float bottomPad = pad;
const float weakPeakSliderRow = kWeakPeakSliderRowHeight * m_scale;
float meanContentHeight = 0.0f;
float height = 0.0f;
float tableHeaderHeight = 0.0f;
if (countTable)
{
// 「边沿计数」= **表格**:表头(信号名 / 上升沿计数 / 下降沿计数)固定不滚动,
// 下面每条可见信号占一行(行底色 = 信号配色),内容超高时滚动。
const float rowH = kEdgePanelMeanRowHeight * m_scale;
const float content = static_cast<float>(rowCount) * rowH;
meanContentHeight = content;
tableHeaderHeight = rowH;
const float budget =
m_plotRect.Height - (titleHeight + pad + tableHeaderHeight + bottomPad);
const float maxContent =
std::max(3.0f * rowH, std::min(kFreeMarkerContentMaxHeight * m_scale,
std::max(3.0f * rowH, budget)));
const float view = std::min(content, maxContent);
height = titleHeight + pad + tableHeaderHeight + view + bottomPad;
}
else 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;
bool expanded = false;
if (weakPeak)
{
const Lane::WeakPeakItem* item = lane.FindWeakPeakItem(ref);
expanded = (item != nullptr && !item->collapsed);
}
else if (centerAxis)
{
const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref);
expanded = (item != nullptr && !item->collapsed);
}
else
{
const Lane::MeanFilterItem* item = lane.FindMeanFilterItem(ref);
expanded = (item != nullptr && !item->collapsed);
}
if (expanded)
{
content += sliderRow * 2.0f;
if (centerAxis)
{
content += groupRow; // 统计行(上升沿 / 下降沿)
}
}
}
meanContentHeight = content;
const float budget =
m_plotRect.Height - (titleHeight + pad + bottomPad + footerHeight);
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 + bottomPad + footerHeight;
}
else if (freeMarker)
{
// 「自由标记」的高度在下面的 `if (freeMarker)` 分支里计算(含底部固定区)。
height = titleHeight;
}
else
{
// 不应到达:所有面板种类都已覆盖。保留一个保守的高度以免布局全零。
height = titleHeight + pad + bottomPad;
}
// 「自由标记」面板:按信号分组(**分组栏** + 下属固定点条目),
// 只列出确实有固定点的信号;没有任何固定点时给一行灰色提示。
// 底部固定区(自上而下):标记透明度滑块 → 「自由标记点选取」→ 两个显示开关。
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 (countTable)
{
// 「边沿计数」= **表格**:表头固定、内容区可滚动。
// 列:① 信号名 ② 上升沿计数 ③ 下降沿计数(行底色 = 信号配色)。
const float rowH = kEdgePanelMeanRowHeight * m_scale;
const float scrollW = kFreeMarkerScrollWidth * m_scale;
const float viewHeight =
std::max(0.0f, height - titleHeight - pad - tableHeaderHeight - pad);
const bool scrollable = meanContentHeight > viewHeight + 0.5f;
const float maxScroll = std::max(0.0f, meanContentHeight - viewHeight);
const float scroll = std::max(0.0f, std::min(lane.edgeCountScroll, maxScroll));
const float innerWidth =
std::max(0.0f, width - pad * 2.0f - (scrollable ? (scrollW + 4.0f * m_scale) : 0.0f));
layout.tableHeader =
gd::RectF(left + pad, top + titleHeight + pad, innerWidth, tableHeaderHeight);
layout.content = gd::RectF(left + pad, layout.tableHeader.Y + tableHeaderHeight, 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);
}
// 三列:名称列吃掉剩余宽度,两列计数按内容宽度固定 —— 表头与数据行严格对齐。
const float gap = kEdgePanelStatColumnGap * m_scale;
const float contentLeft = layout.content.X;
// 计数列宽 = 「表头文字」「当前数字」的较大者(与 `EdgePanelWidth` 同源)。
float risingCol =
UiMeasureTextWidth(m_fonts->uiSmall, EdgeCountTableHeader(1)) + 10.0f * m_scale;
float fallingCol =
UiMeasureTextWidth(m_fonts->uiSmall, EdgeCountTableHeader(2)) + 10.0f * m_scale;
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.axis == nullptr ||
resolved.style == nullptr || !resolved.visible)
{
continue;
}
std::size_t rising = 0;
std::size_t falling = 0;
bool complete = true;
EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, lane.edgeValue,
EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling,
complete);
risingCol = std::max(
risingCol,
UiMeasureTextWidth(m_fonts->monoSmall, EdgeCountCellText(rising, complete)) +
10.0f * m_scale);
fallingCol = std::max(
fallingCol,
UiMeasureTextWidth(m_fonts->monoSmall, EdgeCountCellText(falling, complete)) +
10.0f * m_scale);
}
const float nameColumn =
std::max(20.0f * m_scale, innerWidth - gap - risingCol - gap - fallingCol);
layout.tableColumns[0].left = contentLeft;
layout.tableColumns[0].width = nameColumn;
layout.tableColumns[1].left = contentLeft + nameColumn + gap;
layout.tableColumns[1].width = risingCol;
layout.tableColumns[2].left = layout.tableColumns[1].left + risingCol + gap;
layout.tableColumns[2].width = fallingCol;
float rowY = layout.content.Y - scroll;
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || !resolved.visible)
{
continue;
}
layout.rowRefs.push_back(ref);
layout.rowHeaders.push_back(gd::RectF(contentLeft, rowY, innerWidth, rowH));
layout.rowNameRects.push_back(
gd::RectF(contentLeft + 4.0f * m_scale, rowY,
std::max(10.0f, nameColumn - 8.0f * m_scale), rowH));
layout.rowTriangles.push_back(gd::RectF());
layout.rowChecks.push_back(gd::RectF());
layout.rowPoints.push_back(gd::RectF());
layout.rowIterations.push_back(gd::RectF());
layout.rowStats.push_back(gd::RectF());
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());
rowY += rowH;
}
}
else 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 footerHeight = hasButton ? (buttonRowHeight + pad) : 0.0f;
const float viewHeight =
std::max(0.0f, height - titleHeight - pad - pad - footerHeight);
const bool scrollable = meanContentHeight > viewHeight + 0.5f;
const float maxScroll = std::max(0.0f, meanContentHeight - viewHeight);
const float storedScroll = meanFilter ? lane.meanFilterScroll
: (weakPeak ? lane.weakPeakScroll : 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(std::max(UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterPointsSliderLabel),
UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterIterationsSliderLabel)),
std::max(UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakLevelLabel),
UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakIterationsSliderLabel)));
const float valueColumn =
std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"),
UiMeasureTextWidth(m_fonts->monoSmall, L"-400%"));
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;
}
bool checked = false;
bool itemCollapsed = false;
if (weakPeak)
{
const Lane::WeakPeakItem* item = lane.FindWeakPeakItem(ref);
checked = (item != nullptr);
itemCollapsed = (item != nullptr && item->collapsed);
}
else if (centerAxis)
{
const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref);
checked = (item != nullptr);
itemCollapsed = (item != nullptr && item->collapsed);
}
else
{
const Lane::MeanFilterItem* item = lane.FindMeanFilterItem(ref);
checked = (item != nullptr);
itemCollapsed = (item != nullptr && item->collapsed);
}
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 || itemCollapsed)
{
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;
}
}
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