Files
Just-DataInspector/Components/PlotView/Src/PlotViewPanels.cpp
T
DeepSeek V4.1 Flash 97b361dedd fix(ui): 采样统计显示一致化、GX 拖拽在布局边界下可用、信号「移动到…」与拖拽排序
一、采样间隔/频率显示不一致(用户报告 1ms vs 1000us、100us 0ns)
* FormatDurationCompound 改为「先量化到整数皮秒、再整数分解」,
  彻底消除同一数值因浮点噪声落到不同单位/留下零尾数的问题;
  秒级分支与亚秒分支共用 totalPs 方案,次级进位/单位升级集中在一处。
* FormatFrequency 的单位选择改为按「舍入后的显示值」判定(999.9999999Hz
  与 1000Hz 均为 1.000kHz),并做向下校正避免 0.001kHz。

二、选区截取面板
* 提示文本改为用户指定原文「启用双光标模式以进行选区截取」。
* EdgePanelWidth 计入该提示的实际文本宽度;绘制改用 UiTextClipped 不再省略。
* viewHeight 补回漏减的 bottomPad,修掉条目区与按钮之间的空白。

三、未展开的信号条目栏悬停不再变成手型光标。

四、开启「显示布局边界」后 GX 标签可正常拖拽排序
* 抽出 PaintLaneReorderOverlay(吸附槽位 + 被拖轮廓),贴图与整幅重绘共用。
* 整幅重绘在 LANE_REORDER 时按 LaneDragOffset 逐格平移重画(被拖网格最后画),
  修掉调试模式下不跟随鼠标、无吸附提示、只能盲拖的问题。

五、信号右键新增「移动到…」二级菜单(位于「仅显示该信号」之上)
* 条目为「分组 N(组名)」,当前组打勾;无标题行;底部「新建分组」。
* MenuItem::submenuMaxRows + PopupMenu 独立子菜单滚动(滚动条、滚轮、视口命中过滤),
  IsScrollable 计入子菜单可滚状态。
* 数据层 DataSet::MoveSignalToGroup/MoveSignal 让时间轴随信号搬运
  (独占则移动、共享则复制);TabConfig::RemapAfterSignalMove 改写引用而非先删后插。
* Application::MoveSignalCore 统一入口,保证派生计算暂停、引用修正、选中迁移与同步一致。

六、信号列表支持按住拖拽排序(组内重排 / 跨组移动)
* 4px 阈值进入拖拽;条目脱离原位跟随鼠标(阴影 + 强调色描边 + 插入指示线)。
* 其它信号行/分组行按插入位置做避让位移动画(RowRect 统一叠加插值位移,
  宿主 kScrollTimerId 驱动 AdvanceSignalDragAnimation)。
* 数据层 DataSet::MoveSignal 支持任意位置;TabConfig::RemapAfterSignalReorder 线性重映射。
* 拖拽中禁用组名悬浮;双击改名与 InvalidateRows 均正确终止拖拽态。

验证:删 .o 全量重编译零 error / 零 warning;产物 873,984 B(< 1MB 预警阈值)。
本轮未使用鼠标/键盘/屏幕,未运行任何 GUI 探针,仅编译验证 + 源码级核对。
2026-11-03 19:27:43 +08:00

1836 lines
87 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);
const bool resamplePanel = (kind == EdgePanelKind::RESAMPLE);
const bool clipPanel = (kind == EdgePanelKind::CLIP);
// 「均值滤波」「中心轴边沿计数」「弱峰调整」「重采样」「选区截取」= 可滚动的分组列表
//(信号色叠底条目栏 + 右侧复选框 + 展开后的参数行);
// **「边沿计数」= 表格**(表头:信号名 / 上升沿计数 / 下降沿计数)。
// ⚠️ 旧式「逐条目」排版(无滚动的名称+参数行)已彻底删除:上百个信号时它会算出
// 上万像素高的面板,超出绘图区后**面板像素缓存一次都存不进去** ⇒ 每帧整块重画。
const bool listStyle = (centerAxis || meanFilter || weakPeak || resamplePanel || clipPanel);
const bool countTable = (kind == EdgePanelKind::COUNT);
// 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” / “均值滤波” / “弱峰调整”
// + 光标区间尾缀)。
const std::wstring title =
meanFilter ? std::wstring(L"均值滤波")
: (weakPeak ? std::wstring(kWeakPeakTitle)
: (resamplePanel
? std::wstring(kResampleTitle)
: ((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 (resamplePanel)
{
// 「重采样」四行 = 「标签列 + 数值列」(两行统计 + 两行输入框)。
// 标签取四者中最宽的一个;数值列必须按**真实会显示的文本**量宽
// (用户 2026-11-03 要求:统计信息不能被省略成 `…`)。
const float resampleLabelColumn =
std::max(std::max(UiMeasureTextWidth(m_fonts->uiSmall, kResampleMaxIntervalLabel),
UiMeasureTextWidth(m_fonts->uiSmall, kResampleMinIntervalLabel)),
std::max(UiMeasureTextWidth(m_fonts->uiSmall, kResampleIntervalLabel),
UiMeasureTextWidth(m_fonts->uiSmall, kResampleFrequencyLabel)));
// ⚠️ 逐信号实测「最大 / 最小数据间隔」的真实文本宽度(含(频率)尾缀),
// 而不是按 `1h 1min 2s(1.23456 GHz)` 这类**典型样例**估算 ——
// 真实值(如 `16ms 182.614us(61.795 Hz)`)往往更长,估算不足就会被截断。
float resampleValueColumn = 0.0f;
for (const SignalRef& ref : lane.signals)
{
const Lane::ResampleItem* item = lane.FindResampleItem(ref);
if (item == nullptr)
{
continue;
}
resampleValueColumn = std::max(
resampleValueColumn,
UiMeasureTextWidth(m_fonts->monoSmall, FormatIntervalWithFrequency(item->maxInterval)));
resampleValueColumn = std::max(
resampleValueColumn,
UiMeasureTextWidth(m_fonts->monoSmall, FormatIntervalWithFrequency(item->minInterval)));
}
// 兜底下限:没有勾选任何信号时也保持一个可用的最小宽度。
resampleValueColumn = std::max(resampleValueColumn,
UiMeasureTextWidth(m_fonts->monoSmall, L"1h 1min 2s(1.23456 GHz)"));
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + resampleLabelColumn +
8.0f * m_scale + resampleValueColumn +
(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 || resamplePanel)
{
// 底部按钮行 = 「构建为新信号」(均值滤波 / 弱峰调整 / 重采样有),按按钮文字实测宽度确定下限。
const float buttonWidth =
UiMeasureTextWidth(m_fonts->uiSmall, kBuildSignalLabel) + 28.0f * m_scale;
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + buttonWidth);
}
if (clipPanel)
{
// 「选区截取」底部是**两个并排**按钮,宽度必须容得下两段文字 + 间隙 + 内外边距。
const float gap = 6.0f * m_scale;
const float twoButtons =
(UiMeasureTextWidth(m_fonts->uiSmall, kClipBuildLabel) + 28.0f * m_scale) + gap +
(UiMeasureTextWidth(m_fonts->uiSmall, kClipDestructiveLabel) + 28.0f * m_scale);
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + twoButtons);
// ⚠️ **未启用双光标**时面板只显示一行提示:宽度必须容得下它的**完整**文本
//(用户 2026-11-03 明确要求"能够完整显示这条提示内容",不能被省略成 `…`)。
if (Tab().CursorMode() != PlotCursorMode::DOUBLE)
{
const float hintWidth = UiMeasureTextWidth(m_fonts->uiSmall, kClipNeedDoubleCursorHint);
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + hintWidth +
(kFreeMarkerScrollWidth + 4.0f) * m_scale);
}
}
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);
const bool resamplePanel = (kind == EdgePanelKind::RESAMPLE);
const bool clipPanel = (kind == EdgePanelKind::CLIP);
// 「均值滤波」「中心轴边沿计数」「弱峰调整」「重采样」「选区截取」共用同一套
//**可滚动分组列表**排布(信号色叠底条目栏 + 展开后的参数行);
//「边沿计数」自 2026-11-02 起走**表格**分支。
//⚠️ 「选区截取」的条目**不可展开**(只需右侧复选框),因此只占一行。
const bool listStyle = (meanFilter || centerAxis || weakPeak || resamplePanel || clipPanel);
const bool countTable = (kind == EdgePanelKind::COUNT);
const bool hasButton = (meanFilter || weakPeak || resamplePanel || clipPanel);
const bool enabled = meanFilter ? lane.meanFilter
: (weakPeak ? lane.weakPeak
: (freeMarker ? lane.freeMarker
: (resamplePanel ? lane.resample
: (clipPanel ? lane.clip
: (centerAxis ? lane.edgeCenter
: lane.edgeCount)))));
const bool open = meanFilter ? lane.meanFilterPanel.open
: (weakPeak ? lane.weakPeakPanel.open
: (freeMarker ? lane.freeMarkerPanel.open
: (resamplePanel
? lane.resamplePanel.open
: (clipPanel
? lane.clipPanel.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
: (resamplePanel
? lane.resamplePanel.collapsed
: (clipPanel
? lane.clipPanel.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。
// ⚠️ 「选区截取」底部是**两个**按钮行(截取为新信号 / 原始信号截取),
// 因此 footer 要按两行算(用户 2026-11-03 要求)。
// ⚠️ **未启用双光标**时两个按钮都不显示 ⇒ footer 归零(否则按钮位置会留一大段空白)。
const bool clipReady = clipPanel && (Tab().CursorMode() == PlotCursorMode::DOUBLE);
const float footerHeight =
clipPanel ? (clipReady ? (buttonHeight * 2.0f + 6.0f * m_scale + pad) : 0.0f)
: (hasButton ? (buttonHeight + pad) : 0.0f);
const float bottomPad = pad;
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 = kWeakPeakSliderRowHeight * m_scale;
// ⚠️ 「选区截取」在**未启用双光标**时没有任何可操作内容(只有一行提示文字),
// 因此不按信号数累加行高、也不套用「至少 3 行」的下限
// (用户 2026-11-03 报告:未启用双光标时仍显示信号条目、且能触发滚动)。
const bool clipReady = clipPanel && (Tab().CursorMode() == PlotCursorMode::DOUBLE);
float content = 0.0f;
if (clipPanel && !clipReady)
{
// 提示行固定占一行高度(绘制时在内容区居中显示)。
content = kEdgePanelMeanRowHeight * m_scale;
}
else
{
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || !resolved.visible)
{
continue;
}
content += groupRow;
if (clipPanel)
{
continue; // 「选区截取」的条目**不可展开**,每条只占一行
}
bool expanded = false;
if (resamplePanel)
{
const Lane::ResampleItem* item = lane.FindResampleItem(ref);
expanded = (item != nullptr && !item->collapsed);
}
else 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)
{
if (resamplePanel)
{
// 四行:最大间隔 / 最小间隔 / 重采样间隔 / 重采样频率。
content += sliderRow * 4.0f;
}
else
{
content += sliderRow * 2.0f;
if (centerAxis)
{
content += groupRow; // 统计行(上升沿 / 下降沿)
}
}
}
}
}
meanContentHeight = content;
const float budget =
m_plotRect.Height - (titleHeight + pad + bottomPad + footerHeight);
// ⚠️ 高度必须**精确等于内容高度**(不超过可用预算),不要再套「至少 3 行」的下限:
// 那个下限会让只有 1 个信号的面板留出 2 行空白(用户 2026-11-03 报告
// “信号条目的滚动区域与下方按钮之间有一大段空白”)。
const float maxContent =
std::min(kFreeMarkerContentMaxHeight * m_scale, std::max(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
: (resamplePanel
? lane.resamplePanel.x
: (clipPanel
? lane.clipPanel.x
: (centerAxis
? lane.edgeCenterPanel.x
: lane.edgePanel.x)))));
const double storedY = meanFilter ? lane.meanFilterPanel.y
: (weakPeak ? lane.weakPeakPanel.y
: (freeMarker ? lane.freeMarkerPanel.y
: (resamplePanel
? lane.resamplePanel.y
: (clipPanel
? lane.clipPanel.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;
case EdgePanelKind::RESAMPLE:
return lane.resample && lane.resamplePanel.open;
case EdgePanelKind::CLIP:
return lane.clip && lane.clipPanel.open;
}
return false;
};
static const EdgePanelKind kOrder[7] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER,
EdgePanelKind::MEAN, EdgePanelKind::PEAK,
EdgePanelKind::MARK, EdgePanelKind::RESAMPLE,
EdgePanelKind::CLIP};
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 (clipPanel)
{
// 「选区截取」:底部**两个**并排的固定按钮(不参与滚动)。
// ⚠️ 未启用双光标模式时两者都是空矩形 ⇒ 不可点(面板只显示提示文字)。
const bool doubleCursor = (Tab().CursorMode() == PlotCursorMode::DOUBLE);
if (doubleCursor)
{
const float gap = 6.0f * m_scale;
const float halfWidth = std::max(0.0f, (width - pad * 2.0f - gap) * 0.5f);
const float buttonTop = top + height - pad - buttonRowHeight;
layout.clipBuildButton = gd::RectF(left + pad, buttonTop, halfWidth, buttonRowHeight);
layout.clipDestructiveButton =
gd::RectF(left + pad + halfWidth + gap, buttonTop, halfWidth, buttonRowHeight);
}
}
else 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 bool clipReadyHere =
clipPanel && (Tab().CursorMode() == PlotCursorMode::DOUBLE);
const float footerHeight =
clipPanel ? (clipReadyHere ? (buttonRowHeight * 2.0f + 6.0f * m_scale + pad) : 0.0f)
: (hasButton ? (buttonRowHeight + pad) : 0.0f);
// ⚠️⚠️ 内容区高度必须与高度算式
// `height = titleHeight + pad + view + bottomPad + footerHeight` **严格同源**:
// 即 `viewHeight = height - titleHeight - pad - bottomPad - footerHeight`。
// 少减 `bottomPad` 会让内容区比实际可用高度多出一个 pad ⇒
// 条目区下方与按钮之间留出一片空白(用户 2026-11-03 两次报告)。
const float viewHeight =
std::max(0.0f, height - titleHeight - pad - bottomPad - 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
: (resamplePanel ? lane.resampleScroll
: (clipPanel ? lane.clipScroll
: 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;
// 「重采样」四行的标签列(「最大数据间隔」比均值/弱峰的标签更宽):
// 与 `EdgePanelWidth` 用同一组实测宽度,保证字段不会压到标签上。
const float resampleLabelColumn =
std::max(std::max(UiMeasureTextWidth(m_fonts->uiSmall, kResampleMaxIntervalLabel),
UiMeasureTextWidth(m_fonts->uiSmall, kResampleMinIntervalLabel)),
std::max(UiMeasureTextWidth(m_fonts->uiSmall, kResampleIntervalLabel),
UiMeasureTextWidth(m_fonts->uiSmall, kResampleFrequencyLabel)));
const float rowLabelColumn = resamplePanel ? resampleLabelColumn : labelColumn;
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;
// ⚠️ 「选区截取」在**未启用双光标**时不生成任何信号行:面板只显示一句居中提示
// (用户 2026-11-03 明确要求:不应显示信号名条目、更不应能滚动)。
const bool clipRowsVisible = !clipPanel || clipReadyHere;
for (const SignalRef& ref : lane.signals)
{
if (!clipRowsVisible)
{
break;
}
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || !resolved.visible)
{
continue;
}
bool checked = false;
bool itemCollapsed = false;
if (clipPanel)
{
// 「选区截取」:只有勾选状态,条目**不可展开**(用户 2026-11-03 要求)。
checked = lane.ClipEnabled(ref);
}
else if (resamplePanel)
{
const Lane::ResampleItem* item = lane.FindResampleItem(ref);
checked = (item != nullptr);
itemCollapsed = (item != nullptr && item->collapsed);
}
else 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 && !clipPanel)
? 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 || clipPanel)
{
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());
if (resamplePanel)
{
layout.rowInfoMaxInterval.push_back(gd::RectF());
layout.rowInfoMinInterval.push_back(gd::RectF());
layout.rowIntervalField.push_back(gd::RectF());
layout.rowFrequencyField.push_back(gd::RectF());
}
continue;
}
if (resamplePanel)
{
// 「重采样」四行:① 最大间隔(只读)② 最小间隔(只读)
// ③ 重采样间隔(输入)④ 重采样频率(输入,与③联动)。
// 每行 = 左侧标签 + 右侧数值/输入框区域。
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.rowInfoMaxInterval.push_back(
gd::RectF(contentLeft, rowY, innerWidth, sliderRow));
rowY += sliderRow;
layout.rowInfoMinInterval.push_back(
gd::RectF(contentLeft, rowY, innerWidth, sliderRow));
rowY += sliderRow;
// 输入行的**数值矩形**靠右对齐,宽度取剩余可用宽度(绘制框与编辑框同源)。
const float fieldLeft = contentLeft + rowLabelColumn + 8.0f * m_scale;
const float fieldWidth =
std::max(24.0f * m_scale, innerWidth - rowLabelColumn - 8.0f * m_scale);
layout.rowIntervalField.push_back(
gd::RectF(fieldLeft, rowY, fieldWidth, sliderRow));
rowY += sliderRow;
layout.rowFrequencyField.push_back(
gd::RectF(fieldLeft, rowY, fieldWidth, sliderRow));
rowY += sliderRow;
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