一、采样间隔/频率显示不一致(用户报告 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 探针,仅编译验证 + 源码级核对。
1836 lines
87 KiB
C++
1836 lines
87 KiB
C++
// 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
|