// 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(m_tab->Lanes().size())) { return kEdgePanelWidth * m_scale; } const Lane& lane = m_tab->Lanes()[static_cast(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(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(m_lanes.size())) { return gd::RectF(); } const LaneVisual& lane = m_lanes[static_cast(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(m_tab->Lanes().size()) || laneIndex >= static_cast(m_lanes.size())) { return layout; } const Lane& lane = m_tab->Lanes()[static_cast(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(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 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(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(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(openPanelOrdinal) * 28.0f * m_scale, maxStagger); float left = (storedX >= 0.0) ? static_cast(storedX) : (m_plotRect.X + m_plotRect.Width - width - 16.0f * m_scale); float top = (storedY >= 0.0) ? static_cast(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 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(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(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(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 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::FreeMarkerContents(int laneIndex) const { static const std::vector kNoEntries; if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return kNoEntries; } const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; if (static_cast(laneIndex) >= m_freeMarkerContent.size()) { m_freeMarkerContent.resize(static_cast(laneIndex) + 1); } FreeMarkerContentCache& cache = m_freeMarkerContent[static_cast(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(bits)); }; const auto mixDouble = [&mix](double value) { std::uint64_t bits = 0; std::memcpy(&bits, &value, sizeof(bits)); mix(bits); }; mix(static_cast(lane.freeMarkerItems.size())); // ⚠️ 两个显示开关只影响胶囊文本,但同样属于“与视界无关的输入”,必须进键。 mix(lane.freeMarkerShowDelta ? 1ull : 0ull); mix(lane.freeMarkerShowEnum ? 1ull : 0ull); for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems) { mix(static_cast(static_cast(item.ref.groupIndex))); mix(static_cast(static_cast(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(resolved.data)); mix(static_cast(resolved.data->values.size())); mix(resolved.visible ? 1ull : 2ull); mix(static_cast(resolved.style->color)); mixFloat(resolved.style->opacity); mixFloat(resolved.style->gain); mixFloat(resolved.style->offsetY); mixDouble(resolved.style->offsetTime); mix(static_cast(static_cast(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(resolved.data->enumText.values.size())); mix(static_cast(resolved.data->enumText.texts.size())); mix(static_cast(resolved.data->enumText.rangeMin.size())); mix(static_cast( reinterpret_cast(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(m_tab->Lanes().size()) || laneIndex >= static_cast(m_lanes.size())) { return false; } const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; if (itemIndex >= lane.freeMarkerItems.size()) { return false; } const Lane::FreeMarkerItem& item = lane.freeMarkerItems[itemIndex]; // 取值与文本来自**与视界无关**的内容缓存(大量固定点 + 拖光标/平移时不重复采样)。 const std::vector& 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(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(mouseX), static_cast(mouseY))) { return probe; } const int laneIndex = LaneAtY(mouseY); if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size()) || laneIndex >= static_cast(m_tab->Lanes().size())) { return probe; } const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; if (!lane.freeMarker) { return probe; } // ⚠️⚠️ **未开启「自由标记点选取」时鼠标就是普通鼠标**(用户 2026-10-23 明确要求): // 不预览数据点、不画纵向光标、按下/拖动完全走常规语义(平移 / 框选 / 拖信号), // 也不会固定任何数据点。所有自由标记的探测都从这一句的“早退”开始。 if (!lane.freeMarkerCursor) { return probe; } const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; // 「自由标记点选取」已开启:网格内始终跟随鼠标画纵向光标(见 `PaintFreeMarker`)。 const bool cursorMode = true; probe.cursorMode = cursorMode; probe.laneIndex = laneIndex; probe.time = XToTime(static_cast(mouseX)); probe.x = static_cast(mouseX); // 命中容差 = **与"常规状态下触发信号曲线高亮"完全一致**(`kSignalHitTolerance`), // 且判定的是「鼠标到**画出来的折线**的垂直距离」,而不是"同一横坐标处的采样值之差"。 // ⚠️ 旧实现只看纵向差值 ⇒ 阶跃的**竖直跳变段**永远判不中:那一列上下两个采样值 // 都不等于鼠标所在的高度(用户 2026-10-22 报告“在跳变纵线上完全点不中、没有吸附提示”)。 const float fx = static_cast(mouseX); const float fy = static_cast(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(count), target); std::size_t index = static_cast(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(nearest) >= count) { return false; } out = axis.time[static_cast(nearest)] + style.offsetTime; return true; }; // 在网格内所有可见信号中找**离鼠标最近**的那个数据点。 double bestDistance = std::numeric_limits::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::infinity(); const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, visual); if (cached != nullptr && cached->points.size() >= 2) { const std::vector& points = cached->points; const std::vector& 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::lower_bound(points.begin(), points.end(), fx - tolerance, byX) - points.begin()); windowLast = static_cast( 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(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(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(TimeToX(candidateTime)); const double dx = pointX - static_cast(fx); const double vertical = std::fabs( static_cast(ValueToY(candidateValue, visual)) - static_cast(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(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