From e610cea481585fc15b18566a6445db57985734f1 Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Wed, 28 Oct 2026 12:20:06 +0800 Subject: [PATCH] =?UTF-8?q?UI=20=E9=87=8D=E6=9E=84=20P2=20=E7=BB=AD?= =?UTF-8?q?=EF=BC=9A=E6=8A=8A=E5=B8=83=E5=B1=80=E4=B8=8E=E5=91=BD=E4=B8=AD?= =?UTF-8?q?=E5=87=A0=E4=BD=95=E4=BB=8E=E7=BB=98=E5=88=B6=E5=88=86=E7=89=87?= =?UTF-8?q?=E6=8B=86=E5=87=BA=EF=BC=88PlotViewPanels.cpp=EF=BC=8C=E9=80=90?= =?UTF-8?q?=E5=83=8F=E7=B4=A0=200=20=E5=B7=AE=E5=BC=82=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EdgePanelLayoutFor(651 行 / 19 个调用点)、EdgePanelWidth、LaneBadgeRect、 FreeMarkerValue/Delta/Contents/LabelRects、ProbeFreeMarker 都是「几何」而非「绘制」, 却塞在 9 千行的绘制文件里 —— 改布局要动整个绘制文件,回归面被放大。 本提交机械搬移这 5 个不连续块到新分片 PlotViewPanels.cpp(1,359 行),不改一行逻辑。 PlotViewPaint.cpp 6,645 -> 4,913 行;相对 UI_Refactor 起点 9,080 行共 -46%。 当前七个分片:Paint 4,913 / Interaction 3,430 / Features 3,266 / Raster 1,876 / Panels 1,359 / Core 889 / CacheKey 474。顶层注释改为「七个分片(按关注点)」。 验证: - make release TARGET_FILE_NAME=TargetCheck 零警告;Target.exe 877,568 B(< 900KB 阈值)。 - 逐像素 A/B:三场景哈希与迁移前完全一致 A 2166933039626F66、B C444ECFEB18FD73D、C 7984446D8E7A4418。 - Tools/uicheck 62 passed / 0 failed。 --- Components/PlotView/Src/PlotViewPaint.cpp | 1388 +------------------ Components/PlotView/Src/PlotViewPanels.cpp | 1409 ++++++++++++++++++++ Documents/UI_Refactor_Progress.md | 44 +- 3 files changed, 1452 insertions(+), 1389 deletions(-) create mode 100644 Components/PlotView/Src/PlotViewPanels.cpp diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 1356126..d2777a6 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -1,13 +1,15 @@ // PlotView 实现分片 —— 绘制(paint):只负责"把内容画出来"。 // // 共用的常量与工具函数在 PlotViewInternal.hpp(命名空间 `pvdetail`,全部 inline)。 -// 六个分片的职责划分(按**关注点**,不再按函数名前缀): +// 七个分片的职责划分(按**关注点**,不再按函数名前缀): // PlotView.cpp 视图核心:构造/析构、上下文绑定、布局、坐标映射、视界与平移、 // 网格结构与归属、选区/网格划分、拖动取消 // PlotViewPaint.cpp 绘制:全部 Paint*/Render* 与内联输入框绘制(**本文件**) // PlotViewRaster.cpp 光栅化像素缓存与每帧预算:光晕层、场景缓存、装饰层、每网格宽基底 // 图层、滚动补条、预热("怎么画才不卡") // PlotViewCacheKey.cpp 内容指纹与缓存键:SceneCacheKey / *Hash / 样式与派生快照 / 样式补丁帧 +// PlotViewPanels.cpp 几何:浮动面板布局(EdgePanelWidth/EdgePanelLayoutFor)、图例徽标、 +// 「自由标记」的取值/胶囊矩形/探测(绘制与命中同源的唯一来源) // PlotViewInteraction.cpp 交互与编辑:命中测试、鼠标与键盘处理、光标形状、内联输入框、 // 浮动面板层序与命中 // PlotViewFeatures.cpp 视图特性与派生数据:光标状态与读数、提示浮层、缩放模式、自适应、 @@ -19,182 +21,6 @@ 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 itemized = (centerAxis || meanFilter || weakPeak); - - // 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” / “均值滤波” / “弱峰调整” - // + 光标区间尾缀)。 - const std::wstring title = - meanFilter ? std::wstring(L"均值滤波") - : (weakPeak ? std::wstring(kWeakPeakTitle) - : ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") + EdgeRangeSuffix())); - float width = kEdgePanelPad * 2.0f * m_scale + kLaneBadgeWidth * m_scale + - 8.0f * m_scale + UiMeasureTextWidth(m_fonts->uiSmall, title) + - kEdgePanelTitleHeight * m_scale; - - // 数据行 = 名称与“上升沿 N / 下降沿 M”两行,取较宽者。 - // ⚠️ 名称宽度有上限(`kEdgePanelNameMaxWidth`):超长信号名不再把面板顶得很长, - // 绘制时超出部分用省略号(用户明确要求“超过某个长度就省略名称显示”)。 - // ⚠️ 统计信息按「上升沿 / 下降沿」**两列**排版(列起点固定)→ 多条信号时文字垂直对齐。 - float nameColumnWidth = 0.0f; - float statRisingWidth = 0.0f; - float statFallingWidth = 0.0f; - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - // ⚠️ 下面会解引用 `resolved.axis/style`(边沿统计),因此必须与其它绘制/命中路径 - // 用同一套空指针守卫;旧实现只判 `data`,一旦出现"有数据无样式/无时间轴"的引用 - // 就会在测量阶段空指针崩溃(`EdgePanelWidth` 被绘制、命中、`QueryCursor` 调用)。 - if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr || - !resolved.visible) - { - continue; - } - const float nameWidth = - std::min(UiMeasureTextWidth(m_fonts->uiSmall, resolved.data->name), - kEdgePanelNameMaxWidth * m_scale); - nameColumnWidth = std::max(nameColumnWidth, nameWidth); - if (itemized) - { - // 逐条目面板:第一排 = 名称 + 复选框;第三排(中心轴)= 统计结果。 - width = std::max(width, kEdgePanelPad * 2.0f * m_scale + nameWidth + - kEdgePanelMeanCheckSize * m_scale + 6.0f * m_scale); - } - std::size_t rising = 0; - std::size_t falling = 0; - bool complete = true; - if (meanFilter || weakPeak) - { - continue; - } - if (centerAxis) - { - const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); - if (item == nullptr) - { - continue; - } - EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, item->points, - item->iterations, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), - rising, falling, complete); - } - else - { - EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, lane.edgeValue, - EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); - } - // 宽度按实际要显示的文本测量(含未算完时的“…”),否则推进过程中数字变长会被截断。 - // 完全未算出时整行显示“统计中…”,因此两列都要预留它的宽度。 - const float pendingWidth = UiMeasureTextWidth(m_fonts->monoSmall, L"统计中…"); - statRisingWidth = std::max( - statRisingWidth, - std::max(UiMeasureTextWidth(m_fonts->monoSmall, EdgeRisingText(rising, complete)), - pendingWidth)); - statFallingWidth = - std::max(statFallingWidth, - UiMeasureTextWidth(m_fonts->monoSmall, EdgeFallingText(falling, complete))); - } - if (statRisingWidth > 0.0f || statFallingWidth > 0.0f) - { - // 两列**等宽**(取两列最大值中的较大者)→ 绘制时两列起点固定, - // 多条信号的“上升沿 / 下降沿”文字严格垂直对齐(用户明确要求)。 - const float maxColumn = std::max(statRisingWidth, statFallingWidth); - const float countsWidth = maxColumn * 2.0f + kEdgePanelStatColumnGap * m_scale; - width = std::max(width, kEdgePanelPad * 2.0f * m_scale + - std::max(nameColumnWidth, countsWidth)); - } - - if (centerAxis || meanFilter) - { - // 参数行 = 两个「说明文字 + 数值框」,必须保证文字不被数值框压住。 - // (MEAN / CENTER:「均值点数 X」+「迭代次数 N」。标签与数值框各占半行。) - const float labelXWidth = UiMeasureTextWidth(m_fonts->uiSmall, L"均值点数 X"); - const float labelN = UiMeasureTextWidth(m_fonts->uiSmall, L"迭代次数 N"); - const float fieldWidth = - std::max(kEdgePanelEditFieldWidth * m_scale * 0.75f, - UiMeasureTextWidth(m_fonts->monoSmall, L"1000000") + 18.0f * m_scale); - const float halfWidth = std::max(labelXWidth, labelN) + 6.0f * m_scale + fieldWidth; - const float rowWidth = halfWidth * 2.0f + 8.0f * m_scale; - width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth); - } - - if (weakPeak) - { - // 滑块行 = 「标签列 + 轨道(最小宽度)+ 数值列 + 复位按钮」(等级与迭代次数两行共用同一列宽)。 - const float rowWidth = WeakPeakLabelColumnWidth(m_fonts->uiSmall) + 6.0f * m_scale + - kWeakPeakTrackMinWidth * m_scale + 6.0f * m_scale + - WeakPeakValueColumnWidth(m_fonts->monoSmall) + - kWeakPeakResetGap * m_scale + kWeakPeakResetButtonSize * m_scale; - width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth); - } - - if (meanFilter || weakPeak) - { - // 底部按钮行 = 「构建为新信号」,按按钮文字实测宽度确定下限。 - const float buttonWidth = - UiMeasureTextWidth(m_fonts->uiSmall, kBuildSignalLabel) + 28.0f * m_scale; - width = std::max(width, kEdgePanelPad * 2.0f * m_scale + buttonWidth); - } - - if (kind == EdgePanelKind::MARK) - { - // 「自由标记」面板:宽度 = 六列(取「缺省列宽」「实测内容宽度」「列标题宽度」三者 - // 的大者)+ 行 ✕ + 左右内边距 + **滚动条预留位**(内容超高时右侧要放滚动条)。 - // ⚠️ 用户 2026-10-21 要求面板里的数据列**不得出现省略号**,因此不能再按固定列宽。 - // 逐行取采样值算宽度会拖慢交互(本函数被 `TopPanelAtPoint` / `SyncPanelOrder` - // 高频调用),所以走 `Lane::freeMarkerColumnWidths`:绘制该面板时顺便量好真实 - // 文本宽度存起来,这里只读。还没量过(全 0)时退回缺省列宽。 - const bool hasEnum = [this, &lane]() { - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data != nullptr && resolved.visible && - !resolved.data->enumText.Empty() && lane.HasFreeMarkers(ref)) - { - return true; - } - } - return false; - }(); - float columnsWidth = 0.0f; - for (int column = 0; column < 6; ++column) - { - const bool omitted = (column == EdgePanelLayout::MARKER_COL_ENUM) && !hasEnum; - if (omitted) - { - continue; - } - // 列宽还要容得下**表头的列标题**(如「时间间隔」),否则表头会被省略。 - const float labelWidth = - UiMeasureTextWidth(m_fonts->uiSmall, kFreeMarkerColumnLabels[column]) + - 2.0f * m_scale; - columnsWidth += std::max(std::max(kFreeMarkerColumnWidths[column] * m_scale, - lane.freeMarkerColumnWidths[static_cast(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)); - } const PlotView::PolylineCacheEntry* PlotView::AcquirePolyline(const SignalRef& ref, const ResolvedSignal& resolved, @@ -1258,29 +1084,6 @@ namespace jdi } - 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); - } void PlotView::PaintLaneReorder(gd::Graphics& g) { @@ -3036,656 +2839,6 @@ namespace jdi } } - 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 itemized = (centerAxis || meanFilter || weakPeak); - const bool hasButton = (meanFilter || weakPeak); - const bool enabled = meanFilter ? lane.meanFilter - : (weakPeak ? lane.weakPeak - : (freeMarker ? lane.freeMarker - : (centerAxis ? lane.edgeCenter - : lane.edgeCount))); - const bool open = meanFilter ? lane.meanFilterPanel.open - : (weakPeak ? lane.weakPeakPanel.open - : (freeMarker ? lane.freeMarkerPanel.open - : (centerAxis ? lane.edgeCenterPanel.open - : lane.edgePanel.open))); - if (!enabled || !open) - { - return layout; - } - // 面板的**折起**状态(只显示标题栏)。折起时仍然算宽度与位置,但内容区全部省略。 - const bool collapsed = meanFilter ? lane.meanFilterPanel.collapsed - : (weakPeak ? lane.weakPeakPanel.collapsed - : (freeMarker ? lane.freeMarkerPanel.collapsed - : (centerAxis - ? lane.edgeCenterPanel.collapsed - : lane.edgePanel.collapsed))); - layout.collapsed = collapsed; - - int rowCount = 0; - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data != nullptr && resolved.visible) - { - ++rowCount; - } - } - if (rowCount <= 0) - { - return layout; - } - layout.rows = rowCount; - - const float width = EdgePanelWidth(laneIndex, kind); - const float titleHeight = kEdgePanelTitleHeight * m_scale; - const float rowHeight = kEdgePanelRowHeight * m_scale; - const float pad = kEdgePanelPad * m_scale; - // 「中心轴边沿计数」「均值滤波」「弱峰调整」共用同一套「逐条目」排布, - // 因此不再有独立的底部编辑行;只有「边沿计数」保留两行文本式排版。 - const float buttonHeight = hasButton ? (kEdgePanelButtonHeight * m_scale) : 0.0f; - const float rowsPerSignal = meanFilter ? 1.0f : 2.0f; - - // 逐条目面板:每个条目 = 第一排(信号名 + 复选框) - // + 参数部分(**只在该条目被勾选时才占位**,因此面板高度随勾选数量动态变化): - // * MEAN / CENTER:一行「均值点数 X + 迭代次数 N」输入框(中心轴再多一行统计结果); - // * PEAK:两行滑块(调整幅度 % / 迭代次数 N),每个信号各有一套自己的参数。 - const bool itemHasParams = (centerAxis || meanFilter || weakPeak); - const float weakPeakSliderRow = kWeakPeakSliderRowHeight * m_scale; - float meanRowHeight = 0.0f; - float itemsHeight = 0.0f; - float height = 0.0f; - if (itemized) - { - meanRowHeight = kEdgePanelMeanRowHeight * m_scale; - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || !resolved.visible) - { - continue; - } - itemsHeight += meanRowHeight; - const bool checked = meanFilter ? lane.MeanFilterEnabled(ref) - : (weakPeak ? lane.WeakPeakEnabled(ref) - : lane.EdgeCenterEnabled(ref)); - if (checked && itemHasParams) - { - if (weakPeak) - { - itemsHeight += weakPeakSliderRow * 2.0f; - } - else - { - itemsHeight += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale; - if (centerAxis) - { - itemsHeight += kEdgePanelMeanRowHeight * m_scale; - } - } - } - } - // 底部按钮行:上方与条目之间、下方与面板下边界之间各留一个 `pad`, - // 与面板的左右内边距完全一致(原先按钮直接贴着下边框,看起来很不协调)。 - height = titleHeight + pad + itemsHeight + pad + buttonHeight + (hasButton ? pad : 0.0f); - } - else - { - height = titleHeight + pad + static_cast(rowCount) * rowHeight * rowsPerSignal + pad; - } - - // 「自由标记」面板:按信号分组(**分组栏** + 下属固定点条目), - // 只列出确实有固定点的信号;没有任何固定点时给一行灰色提示。 - // 底部固定区(自上而下):标记透明度滑块 → 「自由标记点选取」→ 两个显示开关。 - bool markerAny = false; - for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems) - { - if (item.ref.IsValid()) - { - markerAny = true; - break; - } - } - if (freeMarker) - { - const float groupRow = kFreeMarkerNameRowHeight * m_scale; - const float headerRow = kFreeMarkerHeaderRowHeight * m_scale; - const float itemRow = kFreeMarkerItemRowHeight * m_scale; - const float markerButtonHeight = kEdgePanelButtonHeight * m_scale; - // 「标记透明度」滑块行**始终显示**(用户 2026-10-21 要求:不再依附于「自由标记点选取」按钮)。 - const float sliderRow = kFreeMarkerSliderRowHeight * m_scale; - // 「显示增量 / 显示枚举文本」两个开关按钮独占一行,位于「自由标记点选取」下方 - //(用户 2026-10-23 要求)。 - const float switchRow = kEdgePanelButtonHeight * m_scale; - const float buttonGap = kFreeMarkerButtonGap * m_scale; - const float footer = sliderRow + markerButtonHeight + buttonGap + switchRow; - float content = 0.0f; - std::vector 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 - : (centerAxis ? lane.edgeCenterPanel.x - : lane.edgePanel.x))); - const double storedY = meanFilter ? lane.meanFilterPanel.y - : (weakPeak ? lane.weakPeakPanel.y - : (freeMarker ? lane.freeMarkerPanel.y - : (centerAxis ? lane.edgeCenterPanel.y - : lane.edgePanel.y))); - int openPanelOrdinal = 0; - { - // ⚠️ 错开量用「**当前真正显示出来的面板**中排在本面板之前的个数」: - // * 第一个打开的面板 ⇒ 0 ⇒ 正好贴在右上角(用户 2026-10-17 要求“应该在右上角”); - // * 后续面板依次下移 28 逻辑像素,避免与已打开的面板完全重叠; - // * 遍历顺序固定为(网格下标 → 面板种类)⇒ 每个面板的槽位稳定、与帧无关。 - // ⚠️ 不能用 `laneIndex`(上百个网格时会把面板推到右下角); - // ⚠️ 也不能只看 `*Panel.open`:该标志**默认就是 true**,必须同时看功能是否启用 - // (与 `EdgePanelLayoutFor` 的 `enabled && open` 判定一致)。 - const auto shown = [](const Lane& lane, EdgePanelKind panelKind) -> bool - { - switch (panelKind) - { - case EdgePanelKind::COUNT: - return lane.edgeCount && lane.edgePanel.open; - case EdgePanelKind::CENTER: - return lane.edgeCenter && lane.edgeCenterPanel.open; - case EdgePanelKind::MEAN: - return lane.meanFilter && lane.meanFilterPanel.open; - case EdgePanelKind::PEAK: - return lane.weakPeak && lane.weakPeakPanel.open; - case EdgePanelKind::MARK: - return lane.freeMarker && lane.freeMarkerPanel.open; - } - return false; - }; - static const EdgePanelKind kOrder[5] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER, - EdgePanelKind::MEAN, EdgePanelKind::PEAK, - EdgePanelKind::MARK}; - int openSeen = 0; - bool found = false; - for (std::size_t lane = 0; lane < m_lanes.size() && !found; ++lane) - { - const Lane& tabLane = Tab().Lanes()[lane]; - for (const EdgePanelKind panelKind : kOrder) - { - if (!shown(tabLane, panelKind)) - { - continue; - } - if (lane == static_cast(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 (itemized) - { - const float buttonRowHeight = kEdgePanelButtonHeight * m_scale; - if (hasButton) - { - // 底部按钮与面板下边界之间留出与左右、与内容相同的 `pad` 间距 - //(原先按钮行直接贴着下边框,视觉上与其它元素不协调); - // 面板高度已含这份 pad,因此这里只上移按钮行即可。 - layout.button = gd::RectF(left + pad, top + height - pad - buttonRowHeight, - std::max(0.0f, width - pad * 2.0f), buttonRowHeight); - } - - // 逐信号条目:第一排 = 名称 + 复选框(复选框靠右); - // 参数部分:MEAN / CENTER = 一行两个输入框(中心轴再多一行统计结果); - // PEAK = 两行滑块(每个信号各有自己的等级与迭代次数)。 - const float checkSize = kEdgePanelMeanCheckSize * m_scale; - const float fieldHeight = kEdgePanelMeanEditHeight * m_scale; - const float halfWidth = std::max(40.0f, (width - pad * 2.0f - 8.0f * m_scale) * 0.5f); - // 滑块行列宽(与 `EdgePanelWidth` 使用同一组算式,避免布局与宽度计算漂移)。 - const float weakPeakLabelWidth = WeakPeakLabelColumnWidth(m_fonts->uiSmall); - const float weakPeakValueWidth = WeakPeakValueColumnWidth(m_fonts->monoSmall); - const float weakPeakResetSize = kWeakPeakResetButtonSize * m_scale; - const float weakPeakResetGap = kWeakPeakResetGap * m_scale; - const float weakPeakTrackHeight = kWeakPeakTrackHeight * m_scale; - const float weakPeakTrackLeft = left + pad + weakPeakLabelWidth + 6.0f * m_scale; - const float weakPeakTrackWidth = - std::max(10.0f * m_scale, - (left + width - pad - weakPeakValueWidth - weakPeakResetGap - - weakPeakResetSize - 6.0f * m_scale) - - weakPeakTrackLeft); - float rowY = top + titleHeight + pad; - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || !resolved.visible) - { - continue; - } - - layout.rowRefs.push_back(ref); - layout.rowChecks.push_back(gd::RectF( - left + width - pad - checkSize, rowY + (meanRowHeight - checkSize) * 0.5f, checkSize, - checkSize)); - const bool checked = meanFilter ? lane.MeanFilterEnabled(ref) - : (weakPeak ? lane.WeakPeakEnabled(ref) - : lane.EdgeCenterEnabled(ref)); - if (weakPeak) - { - // 每个被勾选的信号各有两行滑块:调整幅度(-400% – +400%)与迭代次数(1 … 上限)。 - if (checked) - { - const float levelRowTop = rowY + meanRowHeight; - const float iterRowTop = levelRowTop + weakPeakSliderRow; - const gd::RectF levelTrack( - weakPeakTrackLeft, - levelRowTop + (weakPeakSliderRow - weakPeakTrackHeight) * 0.5f, - weakPeakTrackWidth, weakPeakTrackHeight); - const gd::RectF iterTrack( - weakPeakTrackLeft, - iterRowTop + (weakPeakSliderRow - weakPeakTrackHeight) * 0.5f, - weakPeakTrackWidth, weakPeakTrackHeight); - layout.rowLevelTracks.push_back(levelTrack); - layout.rowLevelValues.push_back(gd::RectF( - levelTrack.X + levelTrack.Width + 6.0f * m_scale, levelRowTop, - weakPeakValueWidth, weakPeakSliderRow)); - layout.rowIterTracks.push_back(iterTrack); - layout.rowIterValues.push_back(gd::RectF( - iterTrack.X + iterTrack.Width + 6.0f * m_scale, iterRowTop, - weakPeakValueWidth, weakPeakSliderRow)); - // 每行滑块右侧的**复位按钮**(与数值列同一右边界,保证两行上下对齐)。 - const float resetX = levelTrack.X + levelTrack.Width + 6.0f * m_scale + - weakPeakValueWidth + weakPeakResetGap; - layout.rowLevelResets.push_back( - gd::RectF(resetX, levelRowTop + (weakPeakSliderRow - weakPeakResetSize) * 0.5f, - weakPeakResetSize, weakPeakResetSize)); - layout.rowIterResets.push_back( - gd::RectF(resetX, iterRowTop + (weakPeakSliderRow - weakPeakResetSize) * 0.5f, - weakPeakResetSize, weakPeakResetSize)); - } - else - { - layout.rowLevelTracks.push_back(gd::RectF()); - layout.rowLevelValues.push_back(gd::RectF()); - layout.rowLevelResets.push_back(gd::RectF()); - layout.rowIterTracks.push_back(gd::RectF()); - layout.rowIterValues.push_back(gd::RectF()); - layout.rowIterResets.push_back(gd::RectF()); - } - layout.rowPoints.push_back(gd::RectF()); - layout.rowIterations.push_back(gd::RectF()); - layout.rowStats.push_back(gd::RectF()); - - rowY += meanRowHeight; - if (checked) - { - rowY += weakPeakSliderRow * 2.0f; - } - continue; - } - if (checked && itemHasParams) - { - const float editTop = rowY + meanRowHeight; - layout.rowPoints.push_back( - gd::RectF(left + pad, editTop, halfWidth, fieldHeight)); - layout.rowIterations.push_back( - gd::RectF(left + pad + halfWidth + 8.0f * m_scale, editTop, halfWidth, fieldHeight)); - if (centerAxis) - { - layout.rowStats.push_back( - gd::RectF(left + pad, editTop + fieldHeight, width - pad * 2.0f, - kEdgePanelMeanRowHeight * m_scale)); - } - else - { - layout.rowStats.push_back(gd::RectF()); - } - } - else - { - layout.rowPoints.push_back(gd::RectF()); - layout.rowIterations.push_back(gd::RectF()); - layout.rowStats.push_back(gd::RectF()); - } - - rowY += meanRowHeight; - if (checked && itemHasParams) - { - rowY += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale; - if (centerAxis) - { - rowY += kEdgePanelMeanRowHeight * m_scale; - } - } - } - } - - if (freeMarker) - { - // 「自由标记」面板:**只列出确实有固定点的信号**,每个信号一个**分组栏** - //(信号色托底 + 左侧展开/收起三角 + 信号名 + 右侧「N 标记点」与 ✕), - // 下面逐行列出它的固定点(组内按时间升序 == 从上到下),行尾再带一个 ✕。 - // 内容区超高时右侧出现滚动条;底部按钮与「标记透明度」滑块**固定**不滚动。 - const float groupRow = kFreeMarkerNameRowHeight * m_scale; - const float headerRow = kFreeMarkerHeaderRowHeight * m_scale; - const float itemRow = kFreeMarkerItemRowHeight * m_scale; - const float closeSize = kFreeMarkerCloseSize * m_scale; - const float markerButtonHeight = kEdgePanelButtonHeight * m_scale; - // 「标记透明度」独立于「自由标记点选取」:**总是**显示在按钮上方。 - const float sliderRow = kFreeMarkerSliderRowHeight * m_scale; - const float scrollW = kFreeMarkerScrollWidth * m_scale; - // 「显示增量 / 显示枚举文本」两个开关按钮:独占底部最后一行(左右各半个宽度)。 - const float switchRow = kEdgePanelButtonHeight * m_scale; - const float buttonGap = kFreeMarkerButtonGap * m_scale; - - // 底部固定区(自上而下):标记透明度滑块 → 「自由标记点选取」→ 两个显示开关。 - { - const float switchTop = top + height - pad - switchRow; - const float halfWidth = - std::max(0.0f, (width - pad * 2.0f - buttonGap) * 0.5f); - layout.showDelta = gd::RectF(left + pad, switchTop, halfWidth, switchRow); - layout.showEnum = - gd::RectF(left + pad + halfWidth + buttonGap, switchTop, halfWidth, switchRow); - // 「自由标记点选取」切换按钮(整行宽度)。 - layout.button = gd::RectF(left + pad, switchTop - buttonGap - markerButtonHeight, - std::max(0.0f, width - pad * 2.0f), markerButtonHeight); - } - { - // 「标记透明度」行:与信号样式区的「透明度」行同一套排版 - //(左侧标签 + 中间轨道 + 右侧百分比),位置固定在按钮上方。 - // ⚠️ 无论是否启用「自由标记点选取」都显示(用户 2026-10-21 要求)。 - const float rowTop = layout.button.Y - sliderRow; - const float valueWidth = - std::max(36.0f * m_scale, - UiMeasureTextWidth(m_fonts->monoSmall, L"100%") + 6.0f * m_scale); - const float labelWidth = - UiMeasureTextWidth(m_fonts->uiSmall, kFreeMarkerOpacityLabel) + 6.0f * m_scale; - const float trackLeft = left + pad + labelWidth; - const float trackRight = left + width - pad - valueWidth - 6.0f * m_scale; - const float trackHeight = kWeakPeakTrackHeight * m_scale; - layout.opacityTrack = - gd::RectF(trackLeft, rowTop + (sliderRow - trackHeight) * 0.5f, - std::max(20.0f * m_scale, trackRight - trackLeft), trackHeight); - layout.opacityValue = - gd::RectF(trackRight + 6.0f * m_scale, rowTop, valueWidth, sliderRow); - } - - // 内容区高度(面板高度算式里的 `view` 部分)与"内容总高"决定是否需要滚动条。 - const float viewHeight = std::max( - 0.0f, height - titleHeight - pad * 3.0f - markerButtonHeight - sliderRow - - buttonGap - switchRow); - float totalContent = 0.0f; - std::vector 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 - 12.0f * m_scale) * 0.5f, - 12.0f * m_scale, 12.0f * 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; - } void PlotView::PaintDataMarker(gd::Graphics& g, const DataMarker& marker, float radius, std::uint32_t fillColor, std::uint32_t ringColor) const @@ -4352,256 +3505,6 @@ namespace jdi return hash; } - 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; - } /** * @brief 用 **GDI+** 绘制一段可带 alpha 的文字(仅用于「标记透明度」低于 100% 的胶囊)。 @@ -4966,291 +3869,6 @@ namespace jdi } - 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; - bool bestSnapped = false; - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr || - !resolved.visible) - { - continue; - } - // 「自动点线」判据成立时吸附到最近采样点(与光标吸附同一规则)。 - // ⚠️ 用户明确要求:只要判据成立就吸附,**不**管当前是散点还是用户自己改过的样式。 - const bool sparse = MinorCellSparse(*resolved.axis, *resolved.data, resolved.style->offsetTime); - - // ---- ① 取“鼠标到这条曲线”的距离(复用绘制阶段的折线顶点)---- - 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)) - { - const double vertical = std::fabs( - static_cast(ValueToY(candidateValue, visual)) - - static_cast(fy)); - if (!candidateOk || vertical < distance) - { - distance = vertical; - nearestX = fx; - time = candidateTime; - sampled = candidateSample; - candidateOk = true; - } - } - } - } - } - if (!candidateOk) - { - continue; - } - // 固定点必须落在真实采样时刻上(两个候选都已吸附到采样点)。 - // 表示“已吸附”的圆环仍只在「自动点线」判据成立(`sparse`)时画: - // 密采样信号里吸附量远小于 1 像素,圆环会一直挂着,反而像是画错了。 - const bool snapped = sparse; - 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; - bestSnapped = snapped; - probe.ref = ref; - } - - if (!probe.ref.IsValid()) - { - // 网格内没有可标注的数据点:「自由标记点选取」模式下仍要画纵向线(`valid` 为真)。 - probe.valid = true; - return probe; - } - probe.valid = true; - probe.time = bestTime; - probe.x = TimeToX(bestTime); - probe.value = bestValue; - probe.enumValue = bestEnum; - probe.y = bestY; - probe.snapped = bestSnapped; - return probe; - } void PlotView::PaintEdgeCursor(gd::Graphics& g, const std::vector& lanes) { diff --git a/Components/PlotView/Src/PlotViewPanels.cpp b/Components/PlotView/Src/PlotViewPanels.cpp new file mode 100644 index 0000000..dec222f --- /dev/null +++ b/Components/PlotView/Src/PlotViewPanels.cpp @@ -0,0 +1,1409 @@ +// 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 itemized = (centerAxis || meanFilter || weakPeak); + + // 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” / “均值滤波” / “弱峰调整” + // + 光标区间尾缀)。 + const std::wstring title = + meanFilter ? std::wstring(L"均值滤波") + : (weakPeak ? std::wstring(kWeakPeakTitle) + : ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") + EdgeRangeSuffix())); + float width = kEdgePanelPad * 2.0f * m_scale + kLaneBadgeWidth * m_scale + + 8.0f * m_scale + UiMeasureTextWidth(m_fonts->uiSmall, title) + + kEdgePanelTitleHeight * m_scale; + + // 数据行 = 名称与“上升沿 N / 下降沿 M”两行,取较宽者。 + // ⚠️ 名称宽度有上限(`kEdgePanelNameMaxWidth`):超长信号名不再把面板顶得很长, + // 绘制时超出部分用省略号(用户明确要求“超过某个长度就省略名称显示”)。 + // ⚠️ 统计信息按「上升沿 / 下降沿」**两列**排版(列起点固定)→ 多条信号时文字垂直对齐。 + float nameColumnWidth = 0.0f; + float statRisingWidth = 0.0f; + float statFallingWidth = 0.0f; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + // ⚠️ 下面会解引用 `resolved.axis/style`(边沿统计),因此必须与其它绘制/命中路径 + // 用同一套空指针守卫;旧实现只判 `data`,一旦出现"有数据无样式/无时间轴"的引用 + // 就会在测量阶段空指针崩溃(`EdgePanelWidth` 被绘制、命中、`QueryCursor` 调用)。 + if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr || + !resolved.visible) + { + continue; + } + const float nameWidth = + std::min(UiMeasureTextWidth(m_fonts->uiSmall, resolved.data->name), + kEdgePanelNameMaxWidth * m_scale); + nameColumnWidth = std::max(nameColumnWidth, nameWidth); + if (itemized) + { + // 逐条目面板:第一排 = 名称 + 复选框;第三排(中心轴)= 统计结果。 + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + nameWidth + + kEdgePanelMeanCheckSize * m_scale + 6.0f * m_scale); + } + std::size_t rising = 0; + std::size_t falling = 0; + bool complete = true; + if (meanFilter || weakPeak) + { + continue; + } + if (centerAxis) + { + const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); + if (item == nullptr) + { + continue; + } + EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, item->points, + item->iterations, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), + rising, falling, complete); + } + else + { + EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, lane.edgeValue, + EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); + } + // 宽度按实际要显示的文本测量(含未算完时的“…”),否则推进过程中数字变长会被截断。 + // 完全未算出时整行显示“统计中…”,因此两列都要预留它的宽度。 + const float pendingWidth = UiMeasureTextWidth(m_fonts->monoSmall, L"统计中…"); + statRisingWidth = std::max( + statRisingWidth, + std::max(UiMeasureTextWidth(m_fonts->monoSmall, EdgeRisingText(rising, complete)), + pendingWidth)); + statFallingWidth = + std::max(statFallingWidth, + UiMeasureTextWidth(m_fonts->monoSmall, EdgeFallingText(falling, complete))); + } + if (statRisingWidth > 0.0f || statFallingWidth > 0.0f) + { + // 两列**等宽**(取两列最大值中的较大者)→ 绘制时两列起点固定, + // 多条信号的“上升沿 / 下降沿”文字严格垂直对齐(用户明确要求)。 + const float maxColumn = std::max(statRisingWidth, statFallingWidth); + const float countsWidth = maxColumn * 2.0f + kEdgePanelStatColumnGap * m_scale; + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + + std::max(nameColumnWidth, countsWidth)); + } + + if (centerAxis || meanFilter) + { + // 参数行 = 两个「说明文字 + 数值框」,必须保证文字不被数值框压住。 + // (MEAN / CENTER:「均值点数 X」+「迭代次数 N」。标签与数值框各占半行。) + const float labelXWidth = UiMeasureTextWidth(m_fonts->uiSmall, L"均值点数 X"); + const float labelN = UiMeasureTextWidth(m_fonts->uiSmall, L"迭代次数 N"); + const float fieldWidth = + std::max(kEdgePanelEditFieldWidth * m_scale * 0.75f, + UiMeasureTextWidth(m_fonts->monoSmall, L"1000000") + 18.0f * m_scale); + const float halfWidth = std::max(labelXWidth, labelN) + 6.0f * m_scale + fieldWidth; + const float rowWidth = halfWidth * 2.0f + 8.0f * m_scale; + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth); + } + + if (weakPeak) + { + // 滑块行 = 「标签列 + 轨道(最小宽度)+ 数值列 + 复位按钮」(等级与迭代次数两行共用同一列宽)。 + const float rowWidth = WeakPeakLabelColumnWidth(m_fonts->uiSmall) + 6.0f * m_scale + + kWeakPeakTrackMinWidth * m_scale + 6.0f * m_scale + + WeakPeakValueColumnWidth(m_fonts->monoSmall) + + kWeakPeakResetGap * m_scale + kWeakPeakResetButtonSize * m_scale; + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth); + } + + if (meanFilter || weakPeak) + { + // 底部按钮行 = 「构建为新信号」,按按钮文字实测宽度确定下限。 + const float buttonWidth = + UiMeasureTextWidth(m_fonts->uiSmall, kBuildSignalLabel) + 28.0f * m_scale; + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + buttonWidth); + } + + if (kind == EdgePanelKind::MARK) + { + // 「自由标记」面板:宽度 = 六列(取「缺省列宽」「实测内容宽度」「列标题宽度」三者 + // 的大者)+ 行 ✕ + 左右内边距 + **滚动条预留位**(内容超高时右侧要放滚动条)。 + // ⚠️ 用户 2026-10-21 要求面板里的数据列**不得出现省略号**,因此不能再按固定列宽。 + // 逐行取采样值算宽度会拖慢交互(本函数被 `TopPanelAtPoint` / `SyncPanelOrder` + // 高频调用),所以走 `Lane::freeMarkerColumnWidths`:绘制该面板时顺便量好真实 + // 文本宽度存起来,这里只读。还没量过(全 0)时退回缺省列宽。 + const bool hasEnum = [this, &lane]() { + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data != nullptr && resolved.visible && + !resolved.data->enumText.Empty() && lane.HasFreeMarkers(ref)) + { + return true; + } + } + return false; + }(); + float columnsWidth = 0.0f; + for (int column = 0; column < 6; ++column) + { + const bool omitted = (column == EdgePanelLayout::MARKER_COL_ENUM) && !hasEnum; + if (omitted) + { + continue; + } + // 列宽还要容得下**表头的列标题**(如「时间间隔」),否则表头会被省略。 + const float labelWidth = + UiMeasureTextWidth(m_fonts->uiSmall, kFreeMarkerColumnLabels[column]) + + 2.0f * m_scale; + columnsWidth += std::max(std::max(kFreeMarkerColumnWidths[column] * m_scale, + lane.freeMarkerColumnWidths[static_cast(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 itemized = (centerAxis || meanFilter || weakPeak); + const bool hasButton = (meanFilter || weakPeak); + const bool enabled = meanFilter ? lane.meanFilter + : (weakPeak ? lane.weakPeak + : (freeMarker ? lane.freeMarker + : (centerAxis ? lane.edgeCenter + : lane.edgeCount))); + const bool open = meanFilter ? lane.meanFilterPanel.open + : (weakPeak ? lane.weakPeakPanel.open + : (freeMarker ? lane.freeMarkerPanel.open + : (centerAxis ? lane.edgeCenterPanel.open + : lane.edgePanel.open))); + if (!enabled || !open) + { + return layout; + } + // 面板的**折起**状态(只显示标题栏)。折起时仍然算宽度与位置,但内容区全部省略。 + const bool collapsed = meanFilter ? lane.meanFilterPanel.collapsed + : (weakPeak ? lane.weakPeakPanel.collapsed + : (freeMarker ? lane.freeMarkerPanel.collapsed + : (centerAxis + ? lane.edgeCenterPanel.collapsed + : lane.edgePanel.collapsed))); + layout.collapsed = collapsed; + + int rowCount = 0; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data != nullptr && resolved.visible) + { + ++rowCount; + } + } + if (rowCount <= 0) + { + return layout; + } + layout.rows = rowCount; + + const float width = EdgePanelWidth(laneIndex, kind); + const float titleHeight = kEdgePanelTitleHeight * m_scale; + const float rowHeight = kEdgePanelRowHeight * m_scale; + const float pad = kEdgePanelPad * m_scale; + // 「中心轴边沿计数」「均值滤波」「弱峰调整」共用同一套「逐条目」排布, + // 因此不再有独立的底部编辑行;只有「边沿计数」保留两行文本式排版。 + const float buttonHeight = hasButton ? (kEdgePanelButtonHeight * m_scale) : 0.0f; + const float rowsPerSignal = meanFilter ? 1.0f : 2.0f; + + // 逐条目面板:每个条目 = 第一排(信号名 + 复选框) + // + 参数部分(**只在该条目被勾选时才占位**,因此面板高度随勾选数量动态变化): + // * MEAN / CENTER:一行「均值点数 X + 迭代次数 N」输入框(中心轴再多一行统计结果); + // * PEAK:两行滑块(调整幅度 % / 迭代次数 N),每个信号各有一套自己的参数。 + const bool itemHasParams = (centerAxis || meanFilter || weakPeak); + const float weakPeakSliderRow = kWeakPeakSliderRowHeight * m_scale; + float meanRowHeight = 0.0f; + float itemsHeight = 0.0f; + float height = 0.0f; + if (itemized) + { + meanRowHeight = kEdgePanelMeanRowHeight * m_scale; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || !resolved.visible) + { + continue; + } + itemsHeight += meanRowHeight; + const bool checked = meanFilter ? lane.MeanFilterEnabled(ref) + : (weakPeak ? lane.WeakPeakEnabled(ref) + : lane.EdgeCenterEnabled(ref)); + if (checked && itemHasParams) + { + if (weakPeak) + { + itemsHeight += weakPeakSliderRow * 2.0f; + } + else + { + itemsHeight += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale; + if (centerAxis) + { + itemsHeight += kEdgePanelMeanRowHeight * m_scale; + } + } + } + } + // 底部按钮行:上方与条目之间、下方与面板下边界之间各留一个 `pad`, + // 与面板的左右内边距完全一致(原先按钮直接贴着下边框,看起来很不协调)。 + height = titleHeight + pad + itemsHeight + pad + buttonHeight + (hasButton ? pad : 0.0f); + } + else + { + height = titleHeight + pad + static_cast(rowCount) * rowHeight * rowsPerSignal + pad; + } + + // 「自由标记」面板:按信号分组(**分组栏** + 下属固定点条目), + // 只列出确实有固定点的信号;没有任何固定点时给一行灰色提示。 + // 底部固定区(自上而下):标记透明度滑块 → 「自由标记点选取」→ 两个显示开关。 + bool markerAny = false; + for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems) + { + if (item.ref.IsValid()) + { + markerAny = true; + break; + } + } + if (freeMarker) + { + const float groupRow = kFreeMarkerNameRowHeight * m_scale; + const float headerRow = kFreeMarkerHeaderRowHeight * m_scale; + const float itemRow = kFreeMarkerItemRowHeight * m_scale; + const float markerButtonHeight = kEdgePanelButtonHeight * m_scale; + // 「标记透明度」滑块行**始终显示**(用户 2026-10-21 要求:不再依附于「自由标记点选取」按钮)。 + const float sliderRow = kFreeMarkerSliderRowHeight * m_scale; + // 「显示增量 / 显示枚举文本」两个开关按钮独占一行,位于「自由标记点选取」下方 + //(用户 2026-10-23 要求)。 + const float switchRow = kEdgePanelButtonHeight * m_scale; + const float buttonGap = kFreeMarkerButtonGap * m_scale; + const float footer = sliderRow + markerButtonHeight + buttonGap + switchRow; + float content = 0.0f; + std::vector 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 + : (centerAxis ? lane.edgeCenterPanel.x + : lane.edgePanel.x))); + const double storedY = meanFilter ? lane.meanFilterPanel.y + : (weakPeak ? lane.weakPeakPanel.y + : (freeMarker ? lane.freeMarkerPanel.y + : (centerAxis ? lane.edgeCenterPanel.y + : lane.edgePanel.y))); + int openPanelOrdinal = 0; + { + // ⚠️ 错开量用「**当前真正显示出来的面板**中排在本面板之前的个数」: + // * 第一个打开的面板 ⇒ 0 ⇒ 正好贴在右上角(用户 2026-10-17 要求“应该在右上角”); + // * 后续面板依次下移 28 逻辑像素,避免与已打开的面板完全重叠; + // * 遍历顺序固定为(网格下标 → 面板种类)⇒ 每个面板的槽位稳定、与帧无关。 + // ⚠️ 不能用 `laneIndex`(上百个网格时会把面板推到右下角); + // ⚠️ 也不能只看 `*Panel.open`:该标志**默认就是 true**,必须同时看功能是否启用 + // (与 `EdgePanelLayoutFor` 的 `enabled && open` 判定一致)。 + const auto shown = [](const Lane& lane, EdgePanelKind panelKind) -> bool + { + switch (panelKind) + { + case EdgePanelKind::COUNT: + return lane.edgeCount && lane.edgePanel.open; + case EdgePanelKind::CENTER: + return lane.edgeCenter && lane.edgeCenterPanel.open; + case EdgePanelKind::MEAN: + return lane.meanFilter && lane.meanFilterPanel.open; + case EdgePanelKind::PEAK: + return lane.weakPeak && lane.weakPeakPanel.open; + case EdgePanelKind::MARK: + return lane.freeMarker && lane.freeMarkerPanel.open; + } + return false; + }; + static const EdgePanelKind kOrder[5] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER, + EdgePanelKind::MEAN, EdgePanelKind::PEAK, + EdgePanelKind::MARK}; + int openSeen = 0; + bool found = false; + for (std::size_t lane = 0; lane < m_lanes.size() && !found; ++lane) + { + const Lane& tabLane = Tab().Lanes()[lane]; + for (const EdgePanelKind panelKind : kOrder) + { + if (!shown(tabLane, panelKind)) + { + continue; + } + if (lane == static_cast(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 (itemized) + { + const float buttonRowHeight = kEdgePanelButtonHeight * m_scale; + if (hasButton) + { + // 底部按钮与面板下边界之间留出与左右、与内容相同的 `pad` 间距 + //(原先按钮行直接贴着下边框,视觉上与其它元素不协调); + // 面板高度已含这份 pad,因此这里只上移按钮行即可。 + layout.button = gd::RectF(left + pad, top + height - pad - buttonRowHeight, + std::max(0.0f, width - pad * 2.0f), buttonRowHeight); + } + + // 逐信号条目:第一排 = 名称 + 复选框(复选框靠右); + // 参数部分:MEAN / CENTER = 一行两个输入框(中心轴再多一行统计结果); + // PEAK = 两行滑块(每个信号各有自己的等级与迭代次数)。 + const float checkSize = kEdgePanelMeanCheckSize * m_scale; + const float fieldHeight = kEdgePanelMeanEditHeight * m_scale; + const float halfWidth = std::max(40.0f, (width - pad * 2.0f - 8.0f * m_scale) * 0.5f); + // 滑块行列宽(与 `EdgePanelWidth` 使用同一组算式,避免布局与宽度计算漂移)。 + const float weakPeakLabelWidth = WeakPeakLabelColumnWidth(m_fonts->uiSmall); + const float weakPeakValueWidth = WeakPeakValueColumnWidth(m_fonts->monoSmall); + const float weakPeakResetSize = kWeakPeakResetButtonSize * m_scale; + const float weakPeakResetGap = kWeakPeakResetGap * m_scale; + const float weakPeakTrackHeight = kWeakPeakTrackHeight * m_scale; + const float weakPeakTrackLeft = left + pad + weakPeakLabelWidth + 6.0f * m_scale; + const float weakPeakTrackWidth = + std::max(10.0f * m_scale, + (left + width - pad - weakPeakValueWidth - weakPeakResetGap - + weakPeakResetSize - 6.0f * m_scale) - + weakPeakTrackLeft); + float rowY = top + titleHeight + pad; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || !resolved.visible) + { + continue; + } + + layout.rowRefs.push_back(ref); + layout.rowChecks.push_back(gd::RectF( + left + width - pad - checkSize, rowY + (meanRowHeight - checkSize) * 0.5f, checkSize, + checkSize)); + const bool checked = meanFilter ? lane.MeanFilterEnabled(ref) + : (weakPeak ? lane.WeakPeakEnabled(ref) + : lane.EdgeCenterEnabled(ref)); + if (weakPeak) + { + // 每个被勾选的信号各有两行滑块:调整幅度(-400% – +400%)与迭代次数(1 … 上限)。 + if (checked) + { + const float levelRowTop = rowY + meanRowHeight; + const float iterRowTop = levelRowTop + weakPeakSliderRow; + const gd::RectF levelTrack( + weakPeakTrackLeft, + levelRowTop + (weakPeakSliderRow - weakPeakTrackHeight) * 0.5f, + weakPeakTrackWidth, weakPeakTrackHeight); + const gd::RectF iterTrack( + weakPeakTrackLeft, + iterRowTop + (weakPeakSliderRow - weakPeakTrackHeight) * 0.5f, + weakPeakTrackWidth, weakPeakTrackHeight); + layout.rowLevelTracks.push_back(levelTrack); + layout.rowLevelValues.push_back(gd::RectF( + levelTrack.X + levelTrack.Width + 6.0f * m_scale, levelRowTop, + weakPeakValueWidth, weakPeakSliderRow)); + layout.rowIterTracks.push_back(iterTrack); + layout.rowIterValues.push_back(gd::RectF( + iterTrack.X + iterTrack.Width + 6.0f * m_scale, iterRowTop, + weakPeakValueWidth, weakPeakSliderRow)); + // 每行滑块右侧的**复位按钮**(与数值列同一右边界,保证两行上下对齐)。 + const float resetX = levelTrack.X + levelTrack.Width + 6.0f * m_scale + + weakPeakValueWidth + weakPeakResetGap; + layout.rowLevelResets.push_back( + gd::RectF(resetX, levelRowTop + (weakPeakSliderRow - weakPeakResetSize) * 0.5f, + weakPeakResetSize, weakPeakResetSize)); + layout.rowIterResets.push_back( + gd::RectF(resetX, iterRowTop + (weakPeakSliderRow - weakPeakResetSize) * 0.5f, + weakPeakResetSize, weakPeakResetSize)); + } + else + { + layout.rowLevelTracks.push_back(gd::RectF()); + layout.rowLevelValues.push_back(gd::RectF()); + layout.rowLevelResets.push_back(gd::RectF()); + layout.rowIterTracks.push_back(gd::RectF()); + layout.rowIterValues.push_back(gd::RectF()); + layout.rowIterResets.push_back(gd::RectF()); + } + layout.rowPoints.push_back(gd::RectF()); + layout.rowIterations.push_back(gd::RectF()); + layout.rowStats.push_back(gd::RectF()); + + rowY += meanRowHeight; + if (checked) + { + rowY += weakPeakSliderRow * 2.0f; + } + continue; + } + if (checked && itemHasParams) + { + const float editTop = rowY + meanRowHeight; + layout.rowPoints.push_back( + gd::RectF(left + pad, editTop, halfWidth, fieldHeight)); + layout.rowIterations.push_back( + gd::RectF(left + pad + halfWidth + 8.0f * m_scale, editTop, halfWidth, fieldHeight)); + if (centerAxis) + { + layout.rowStats.push_back( + gd::RectF(left + pad, editTop + fieldHeight, width - pad * 2.0f, + kEdgePanelMeanRowHeight * m_scale)); + } + else + { + layout.rowStats.push_back(gd::RectF()); + } + } + else + { + layout.rowPoints.push_back(gd::RectF()); + layout.rowIterations.push_back(gd::RectF()); + layout.rowStats.push_back(gd::RectF()); + } + + rowY += meanRowHeight; + if (checked && itemHasParams) + { + rowY += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale; + if (centerAxis) + { + rowY += kEdgePanelMeanRowHeight * m_scale; + } + } + } + } + + if (freeMarker) + { + // 「自由标记」面板:**只列出确实有固定点的信号**,每个信号一个**分组栏** + //(信号色托底 + 左侧展开/收起三角 + 信号名 + 右侧「N 标记点」与 ✕), + // 下面逐行列出它的固定点(组内按时间升序 == 从上到下),行尾再带一个 ✕。 + // 内容区超高时右侧出现滚动条;底部按钮与「标记透明度」滑块**固定**不滚动。 + const float groupRow = kFreeMarkerNameRowHeight * m_scale; + const float headerRow = kFreeMarkerHeaderRowHeight * m_scale; + const float itemRow = kFreeMarkerItemRowHeight * m_scale; + const float closeSize = kFreeMarkerCloseSize * m_scale; + const float markerButtonHeight = kEdgePanelButtonHeight * m_scale; + // 「标记透明度」独立于「自由标记点选取」:**总是**显示在按钮上方。 + const float sliderRow = kFreeMarkerSliderRowHeight * m_scale; + const float scrollW = kFreeMarkerScrollWidth * m_scale; + // 「显示增量 / 显示枚举文本」两个开关按钮:独占底部最后一行(左右各半个宽度)。 + const float switchRow = kEdgePanelButtonHeight * m_scale; + const float buttonGap = kFreeMarkerButtonGap * m_scale; + + // 底部固定区(自上而下):标记透明度滑块 → 「自由标记点选取」→ 两个显示开关。 + { + const float switchTop = top + height - pad - switchRow; + const float halfWidth = + std::max(0.0f, (width - pad * 2.0f - buttonGap) * 0.5f); + layout.showDelta = gd::RectF(left + pad, switchTop, halfWidth, switchRow); + layout.showEnum = + gd::RectF(left + pad + halfWidth + buttonGap, switchTop, halfWidth, switchRow); + // 「自由标记点选取」切换按钮(整行宽度)。 + layout.button = gd::RectF(left + pad, switchTop - buttonGap - markerButtonHeight, + std::max(0.0f, width - pad * 2.0f), markerButtonHeight); + } + { + // 「标记透明度」行:与信号样式区的「透明度」行同一套排版 + //(左侧标签 + 中间轨道 + 右侧百分比),位置固定在按钮上方。 + // ⚠️ 无论是否启用「自由标记点选取」都显示(用户 2026-10-21 要求)。 + const float rowTop = layout.button.Y - sliderRow; + const float valueWidth = + std::max(36.0f * m_scale, + UiMeasureTextWidth(m_fonts->monoSmall, L"100%") + 6.0f * m_scale); + const float labelWidth = + UiMeasureTextWidth(m_fonts->uiSmall, kFreeMarkerOpacityLabel) + 6.0f * m_scale; + const float trackLeft = left + pad + labelWidth; + const float trackRight = left + width - pad - valueWidth - 6.0f * m_scale; + const float trackHeight = kWeakPeakTrackHeight * m_scale; + layout.opacityTrack = + gd::RectF(trackLeft, rowTop + (sliderRow - trackHeight) * 0.5f, + std::max(20.0f * m_scale, trackRight - trackLeft), trackHeight); + layout.opacityValue = + gd::RectF(trackRight + 6.0f * m_scale, rowTop, valueWidth, sliderRow); + } + + // 内容区高度(面板高度算式里的 `view` 部分)与"内容总高"决定是否需要滚动条。 + const float viewHeight = std::max( + 0.0f, height - titleHeight - pad * 3.0f - markerButtonHeight - sliderRow - + buttonGap - switchRow); + float totalContent = 0.0f; + std::vector 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 - 12.0f * m_scale) * 0.5f, + 12.0f * m_scale, 12.0f * 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; + bool bestSnapped = false; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr || + !resolved.visible) + { + continue; + } + // 「自动点线」判据成立时吸附到最近采样点(与光标吸附同一规则)。 + // ⚠️ 用户明确要求:只要判据成立就吸附,**不**管当前是散点还是用户自己改过的样式。 + const bool sparse = MinorCellSparse(*resolved.axis, *resolved.data, resolved.style->offsetTime); + + // ---- ① 取“鼠标到这条曲线”的距离(复用绘制阶段的折线顶点)---- + 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)) + { + const double vertical = std::fabs( + static_cast(ValueToY(candidateValue, visual)) - + static_cast(fy)); + if (!candidateOk || vertical < distance) + { + distance = vertical; + nearestX = fx; + time = candidateTime; + sampled = candidateSample; + candidateOk = true; + } + } + } + } + } + if (!candidateOk) + { + continue; + } + // 固定点必须落在真实采样时刻上(两个候选都已吸附到采样点)。 + // 表示“已吸附”的圆环仍只在「自动点线」判据成立(`sparse`)时画: + // 密采样信号里吸附量远小于 1 像素,圆环会一直挂着,反而像是画错了。 + const bool snapped = sparse; + 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; + bestSnapped = snapped; + probe.ref = ref; + } + + if (!probe.ref.IsValid()) + { + // 网格内没有可标注的数据点:「自由标记点选取」模式下仍要画纵向线(`valid` 为真)。 + probe.valid = true; + return probe; + } + probe.valid = true; + probe.time = bestTime; + probe.x = TimeToX(bestTime); + probe.value = bestValue; + probe.enumValue = bestEnum; + probe.y = bestY; + probe.snapped = bestSnapped; + return probe; + } + +} // namespace jdi diff --git a/Documents/UI_Refactor_Progress.md b/Documents/UI_Refactor_Progress.md index 41e6b0d..817fc4e 100644 --- a/Documents/UI_Refactor_Progress.md +++ b/Documents/UI_Refactor_Progress.md @@ -107,9 +107,45 @@ Tools/uicheck/uicheck.exe # 62 passed, 0 failed --- +### 3. `P2 续`:把"布局与命中几何"从绘制分片里拆出(`PlotViewPanels.cpp`) + +**为什么**:`EdgePanelLayoutFor`(651 行,19 个调用点)、`EdgePanelWidth`、`LaneBadgeRect`、 +`FreeMarkerValue/Delta/Contents/LabelRects`、`ProbeFreeMarker` 都是**几何**,却被塞在绘制分片里 —— +改一次布局要动 9 千行的绘制文件,回归面被放大。 + +**做法**:同样机械搬移(5 个不连续块),`PlotViewPaint.cpp` 从 6 645 → **4 913** 行。 + +**最终七个分片**(按关注点) + +| 文件 | 行数 | 职责 | +| --- | --- | --- | +| `PlotViewPaint.cpp` | 4 913 | 绘制(`Paint*` / `Render*` / 输入框绘制) | +| `PlotViewInteraction.cpp` | 3 430 | 命中、鼠标/键盘、光标形状、面板层序 | +| `PlotViewFeatures.cpp` | 3 266 | 光标读数、提示、缩放、自适应、派生曲线、边沿统计 | +| `PlotViewRaster.cpp` | 1 876 | 光晕层 / 场景缓存 / 装饰层 / 宽基底图层与预算 | +| `PlotViewPanels.cpp` | 1 359 | 面板布局、图例徽标几何、自由标记取值与探测 | +| `PlotView.cpp` | 889 | 视图核心(构造、布局、坐标映射、网格结构) | +| `PlotViewCacheKey.cpp` | 474 | 内容指纹与缓存键 | + +**累计效果**:`PlotViewPaint.cpp` **9 080 → 4 913(−46%)**,最大文件从 9.1k 降到 4.9k。 + +**验证** + +1. `make release TARGET_FILE_NAME=TargetCheck` 零警告;`Target.exe` **877 568 B**(< 900 KB)。 +2. **逐像素 A/B**:同三场景哈希与迁移前**完全一致** + (`A 2166933039626F66 / B C444ECFEB18FD73D / C 7984446D8E7A4418`)。 +3. `Tools/uicheck` 62/62。 +4. 顶层注释更新为"七个分片(按关注点)",并标明 `Paint/PaintLane` 只允许定义在 `PlotViewPaint.cpp`。 + +--- + ## 下一步(接手者请从这里开始) -1. **P2 续**:把"布局与命中几何"也搬出去(`EdgePanelLayoutFor` 651 行 + `ProbeFreeMarker` 286 行 + - `FreeMarkerLabelRects` 118 行 → 新 `PlotViewPanels.cpp`),判据同上(逐像素 0 差异)。 -2. **P3:Toolbar 优先**(最小、最容易验证的组件),迁移后跑 `uicheck` + 新旧像素对比。 -3. 每个阶段单独提交,提交信息写清"改了什么 / 为什么 / 怎么验证"。 +1. **P3:Toolbar 优先**(最小、最容易验证的组件): + 继承 `UiWidget` → `OnMeasure` 由按钮文本实测得出 → `OnPaint` 用 `ctx.clip` 画按钮 → + `OnEvent` 返回 `{consumed, redraw, dirty}`;迁移后用 `uicheck` + 新旧像素对比。 +2. **P4:浮动面板**:`EdgePanelLayoutFor` 的 651 行 → 真控件(Stack + Abs + Measure-driven), + 并消掉面板里 10 多个平行数组(`rowRefs/rowChecks/rowPoints/rowOrder/rowLevelTracks/...`)。 +3. **P5:共享 DIB + `SurfaceView`**(`CreateCompatibleBitmap` → top-down 32bpp `CreateDIBSection`, + 带 `version` 作废机制),随后把 56 处 `InvalidateRect` 收敛成脏区并集。 +4. 每个阶段单独提交,提交信息写清"改了什么 / 为什么 / 怎么验证"。